Skip to content
3 changes: 3 additions & 0 deletions .clineignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
venv

*.mo
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ jobs:
python -m pip install --upgrade pip
pip install -U setuptools
pip install -r requirements.txt
pybabel compile -d zxcvbn/locale
pip install .
pip install tox
pybabel compile -d zxcvbn/locale
- name: Run mypy
run: |
pip install -U mypy
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ dist
build
zxcvbn*.egg-info
.vscode
*.mo
.tox
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
include LICENSE.txt
recursive-include zxcvbn/locale *.mo
recursive-include zxcvbn/locale *.po
recursive-include zxcvbn/locale *.pot
12 changes: 12 additions & 0 deletions babel.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[python: zxcvbn/**.py]
# Babel will find all strings in *.py files

[extractors]
python = babel.messages.extract:extract_python

# Gen messages.pot and .po
# pybabel extract -F babel.cfg -o zxcvbn/locale/messages.pot .
# cd zxcvbn
# pybabel init -i locale/messages.pot -d locale -l zh_CN
# translation files
# cd .. && pybabel compile -d zxcvbn/locale
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools", "Babel"]
build-backend = "setuptools.build_meta"
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pytest==3.5.0; python_version < "3.6"

# For Python 3.6+, install a more modern Pytest:
pytest==7.4.2; python_version >= "3.6"
babel>=2.17.0
5 changes: 3 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[bdist_wheel]
universal=1
[compile_catalog]
directory = zxcvbn/locale
domain = messages
38 changes: 35 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
from setuptools import setup
from setuptools import setup, find_packages
from setuptools.command.build import build as _build
from setuptools.command.sdist import sdist as _sdist
from babel.messages.frontend import compile_catalog
import subprocess
import os

class build(_build):
def run(self):
self.run_command('compile_catalog')
super().run()

class sdist(_sdist):
def run(self):
# Compile babel messages before creating source distribution
self.run_command('compile_catalog')
super().run()

class CompileCatalog(compile_catalog):
def run(self):
# Ensure the locale directory exists
if not os.path.exists('zxcvbn/locale'):
return
super().run()

with open('README.rst') as file:
long_description = file.read()

setup(
name='zxcvbn',
version='4.5.0',
packages=['zxcvbn'],
packages=find_packages(),
include_package_data=True,
package_data={
'zxcvbn': ['locale/*/LC_MESSAGES/*.mo'],
},
url='https://github.com/dwolfhub/zxcvbn-python',
download_url='https://github.com/dwolfhub/zxcvbn-python/tarball/v4.5.0',
license='MIT',
Expand All @@ -33,5 +60,10 @@
'Programming Language :: Python :: 3.12',
'Topic :: Security',
'Topic :: Software Development :: Libraries :: Python Modules',
]
],
cmdclass={
'build': build,
'sdist': sdist,
'compile_catalog': CompileCatalog,
},
)
115 changes: 115 additions & 0 deletions tests/zxcvbn_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,118 @@ def test_empty_password():
zxcvbn(password, user_inputs=[input_])
except IndexError as ie:
assert False, "Empty password raised IndexError"

def test_chinese_language_support():
# test Chinese translation
password = "musculature"
result = zxcvbn(password, lang='zh')

assert result["feedback"]["warning"] == \
"单个词语容易被猜中。", \
"Returns Chinese translation for single-word passwords"

# test fallback to English if translation not found
result = zxcvbn(password, lang='zu') # Zulu not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

# test English if translation not found
result = zxcvbn(password) # French not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

def test_italian_language_support():
# test Italian translation
password = "musculature"
result = zxcvbn(password, lang='it')

assert result["feedback"]["warning"] == \
"Una singola parola è facile da indovinare.", \
"Returns Italian translation for single-word passwords"

# test fallback to English if translation not found
result = zxcvbn(password, lang='zu') # Zulu not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

# test English if translation not found
result = zxcvbn(password) # French not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

def test_german_language_support():
# test German translation
password = "musculature"
result = zxcvbn(password, lang='de')

assert result["feedback"]["warning"] == \
"Ein einzelnes Wort ist leicht zu erraten.", \
"Returns German translation for single-word passwords"

# test fallback to English if translation not found
result = zxcvbn(password, lang='zu') # Zulu not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

# test English if translation not found
result = zxcvbn(password) # French not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

def test_spanish_language_support():
# test Spanish translation
password = "musculature"
result = zxcvbn(password, lang='es')

assert result["feedback"]["warning"] == \
"Una sola palabra es fácil de adivinar.", \
"Returns Spanish translation for single-word passwords"

# test fallback to English if translation not found
result = zxcvbn(password, lang='zu') # Zulu not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

# test English if translation not found
result = zxcvbn(password) # French not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

def test_french_language_support():
# test French translation
password = "musculature"
result = zxcvbn(password, lang='fr')

assert result["feedback"]["warning"] == \
"Un mot seul est facile à deviner.", \
"Returns English translation for single-word passwords"

# test fallback to English if translation not found
result = zxcvbn(password, lang='zu') # Zulu not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"

# test English if translation not found
result = zxcvbn(password) # French not installed

assert result["feedback"]["warning"] == \
"A word by itself is easy to guess.", \
"Falls back to English for unsupported languages"
8 changes: 8 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ isolated_build = True
[testenv]
deps =
pytest
babel
commands =
pytest
python tests/test_compatibility.py tests/password_expected_value.json

[testenv:.pkg]
deps =
babel
allowlist_externals = pybabel
commands_pre =
pybabel compile -d zxcvbn/locale
89 changes: 84 additions & 5 deletions zxcvbn/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,97 @@
import os
from datetime import datetime

import gettext
from . import matching, scoring, time_estimates, feedback


def zxcvbn(password, user_inputs=None, max_length=72):
# Global variable to track the last language code for which translation was set up
_LAST_LANG_CODE_SETUP = None

def setup_translation(lang_code='en'):
"""Setup translation function _() for the given language code.

Args:
lang_code (str): Language code (e.g. 'en', 'zh_CN', 'it_IT', 'de_DE'). Defaults to 'en'.
"""
global _ # Make _ available globally
global _LAST_LANG_CODE_SETUP

# Only set up translation if the language code has changed
if lang_code == _LAST_LANG_CODE_SETUP:
return

LOCALE_DIR = os.path.join(os.path.dirname(__file__), 'locale')
DOMAIN = 'messages'
languages_to_try = []

# 1. Core logic for implementing locale aliasing
if lang_code.lower().startswith('zh'):
# For any Chinese variants, build a fallback chain
languages_to_try = [lang_code, 'zh_CN', 'zh']
# Remove duplicates while preserving order
languages_to_try = sorted(set(languages_to_try), key=languages_to_try.index)
elif lang_code.lower().startswith('it'):
# For any Italian variants, build a fallback chain
languages_to_try = [lang_code, 'it_IT', 'it']
# Remove duplicates while preserving order
languages_to_try = sorted(set(languages_to_try), key=languages_to_try.index)
elif lang_code.lower().startswith('de'):
# For any German variants, build a fallback chain
languages_to_try = [lang_code, 'de_DE', 'de']
# Remove duplicates while preserving order
languages_to_try = sorted(set(languages_to_try), key=languages_to_try.index)
elif lang_code.lower().startswith('es'):
# For any Spanish variants, build a fallback chain
languages_to_try = [lang_code, 'es_ES', 'es']
# Remove duplicates while preserving order
languages_to_try = sorted(set(languages_to_try), key=languages_to_try.index)
elif lang_code.lower().startswith('fr'):
# For any French variants, build a fallback chain
languages_to_try = [lang_code, 'fr_FR', 'fr']
# Remove duplicates while preserving order
languages_to_try = sorted(set(languages_to_try), key=languages_to_try.index)
else:
# For other languages, use directly
languages_to_try = [lang_code]

print(f"Attempting to load translations for '{lang_code}'. Search path: {languages_to_try}")

try:
# 2. Pass our constructed language list to gettext
translation = gettext.translation(
DOMAIN,
localedir=LOCALE_DIR,
languages=languages_to_try,
fallback=True # fallback=True ensures no exception if all languages not found
)

# 3. Install translation function _() globally
translation.install()
print(f"Successfully loaded translation: {translation.info().get('language')}")

except FileNotFoundError:
# If even fallback language is not found, use default gettext (no translation)
print("No suitable translation found. Falling back to original strings.")
_ = gettext.gettext

from .feedback import get_feedback as _get_feedback
from . import feedback
feedback._ = _

# Update the last configured language code
_LAST_LANG_CODE_SETUP = lang_code

def zxcvbn(password, user_inputs=None, max_length=72, lang='en'):
# Throw error if password exceeds max length
if len(password) > max_length:
raise ValueError(f"Password exceeds max length of {max_length} characters.")

try:
setup_translation(lang)
# Python 2/3 compatibility for string types
import sys
if sys.version_info[0] == 2:
# Python 2 string types
basestring = (str, unicode)
except NameError:
else:
# Python 3 string types
basestring = (str, bytes)

Expand Down
8 changes: 7 additions & 1 deletion zxcvbn/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
type=int,
help='Override password max length (default: 72)'
)
parser.add_argument(
'--lang',
default='en',
type=str,
help='Override language for feedback messages (default: en)'
)

class JSONEncoder(json.JSONEncoder):
def default(self, o):
Expand All @@ -42,7 +48,7 @@ def cli():
else:
password = getpass.getpass()

res = zxcvbn(password, user_inputs=args.user_input, max_length=args.max_length)
res = zxcvbn(password, user_inputs=args.user_input, max_length=args.max_length, lang=args.lang)
json.dump(res, sys.stdout, indent=2, cls=JSONEncoder)
sys.stdout.write('\n')

Expand Down
23 changes: 22 additions & 1 deletion zxcvbn/feedback.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
from zxcvbn.scoring import START_UPPER, ALL_UPPER
from gettext import gettext as _
from gettext import gettext as gettext_
from typing import Callable, Optional

# Store reference to translation function for fallback handling
def _(s: str) -> str:
"""Translation function wrapper that falls back to identity if no translation available.

Args:
s: The string to translate

Returns:
Translated string or original string if translation not available
"""
try:
# Try to use gettext translation
trans = globals().get('_', gettext_)
return trans(s)
except:
return s

# Store reference to translation function for fallback handling
_ = lambda s: s if not hasattr(_.__globals__, '_') else _.__globals__['_'](s)


def get_feedback(score, sequence):
Expand Down
Loading