Skip to content

feat(i18n): Build and package Chinese translations #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .clineignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
venv

*.mo
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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
- name: Run 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,
},
)
23 changes: 23 additions & 0 deletions tests/zxcvbn_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,26 @@ 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='fr') # French 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
69 changes: 64 additions & 5 deletions zxcvbn/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,77 @@
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'). 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)
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