diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f9ec440..9ab1ba4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 4ff21aa..57e9c23 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist build zxcvbn*.egg-info .vscode +*.mo +.tox \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 42eb410..22b297a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,4 @@ include LICENSE.txt +recursive-include zxcvbn/locale *.mo +recursive-include zxcvbn/locale *.po +recursive-include zxcvbn/locale *.pot diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..db67447 --- /dev/null +++ b/babel.cfg @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f1467cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "Babel"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt index 5ac1407..98dd74f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3c6e79c..d2200e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,3 @@ -[bdist_wheel] -universal=1 +[compile_catalog] +directory = zxcvbn/locale +domain = messages \ No newline at end of file diff --git a/setup.py b/setup.py index 02e7dcf..5d45136 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,27 @@ -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() @@ -6,7 +29,11 @@ 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', @@ -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, + }, ) diff --git a/tests/zxcvbn_test.py b/tests/zxcvbn_test.py index 6752c1f..c985072 100644 --- a/tests/zxcvbn_test.py +++ b/tests/zxcvbn_test.py @@ -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" diff --git a/tox.ini b/tox.ini index f73fe62..e714456 100644 --- a/tox.ini +++ b/tox.ini @@ -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 diff --git a/zxcvbn/__init__.py b/zxcvbn/__init__.py index b0b8424..6b923db 100644 --- a/zxcvbn/__init__.py +++ b/zxcvbn/__init__.py @@ -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) diff --git a/zxcvbn/__main__.py b/zxcvbn/__main__.py index 0f4ee91..24dfc89 100644 --- a/zxcvbn/__main__.py +++ b/zxcvbn/__main__.py @@ -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): @@ -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') diff --git a/zxcvbn/feedback.py b/zxcvbn/feedback.py index 144d634..09757f9 100644 --- a/zxcvbn/feedback.py +++ b/zxcvbn/feedback.py @@ -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): diff --git a/zxcvbn/locale/messages.pot b/zxcvbn/locale/messages.pot new file mode 100644 index 0000000..03de104 --- /dev/null +++ b/zxcvbn/locale/messages.pot @@ -0,0 +1,127 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: liukan@126.com\n" +"POT-Creation-Date: 2025-06-30 11:42+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: zxcvbn/feedback.py:10 +msgid "Use a few words, avoid common phrases." +msgstr "" + +#: zxcvbn/feedback.py:11 +msgid "No need for symbols, digits, or uppercase letters." +msgstr "" + +#: zxcvbn/feedback.py:27 +msgid "Add another word or two. Uncommon words are better." +msgstr "" + +#: zxcvbn/feedback.py:46 +msgid "Straight rows of keys are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:48 +msgid "Short keyboard patterns are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:53 +msgid "Use a longer keyboard pattern with more turns." +msgstr "" + +#: zxcvbn/feedback.py:58 +msgid "Repeats like \"aaa\" are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:60 +msgid "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"." +msgstr "" + +#: zxcvbn/feedback.py:65 +msgid "Avoid repeated words and characters." +msgstr "" + +#: zxcvbn/feedback.py:70 +msgid "Sequences like \"abc\" or \"6543\" are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:72 +msgid "Avoid sequences." +msgstr "" + +#: zxcvbn/feedback.py:78 +msgid "Recent years are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:80 +msgid "Avoid recent years." +msgstr "" + +#: zxcvbn/feedback.py:81 +msgid "Avoid years that are associated with you." +msgstr "" + +#: zxcvbn/feedback.py:86 +msgid "Dates are often easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:88 +msgid "Avoid dates and years that are associated with you." +msgstr "" + +#: zxcvbn/feedback.py:99 +msgid "This is a top-10 common password." +msgstr "" + +#: zxcvbn/feedback.py:101 +msgid "This is a top-100 common password." +msgstr "" + +#: zxcvbn/feedback.py:103 +msgid "This is a very common password." +msgstr "" + +#: zxcvbn/feedback.py:105 +msgid "This is similar to a commonly used password." +msgstr "" + +#: zxcvbn/feedback.py:108 +msgid "A word by itself is easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:112 +msgid "Names and surnames by themselves are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:114 +msgid "Common names and surnames are easy to guess." +msgstr "" + +#: zxcvbn/feedback.py:121 +msgid "Capitalization doesn't help very much." +msgstr "" + +#: zxcvbn/feedback.py:123 +msgid "All-uppercase is almost as easy to guess as all-lowercase." +msgstr "" + +#: zxcvbn/feedback.py:127 +msgid "Reversed words aren't much harder to guess." +msgstr "" + +#: zxcvbn/feedback.py:129 +msgid "Predictable substitutions like '@' instead of 'a' don't help very much." +msgstr "" + diff --git a/zxcvbn/locale/zh_CN/LC_MESSAGES/messages.po b/zxcvbn/locale/zh_CN/LC_MESSAGES/messages.po new file mode 100644 index 0000000..5738f85 --- /dev/null +++ b/zxcvbn/locale/zh_CN/LC_MESSAGES/messages.po @@ -0,0 +1,127 @@ +# Chinese (Simplified, China) translations for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: liukan@126.com\n" +"POT-Creation-Date: 2025-06-30 11:42+0800\n" +"PO-Revision-Date: 2025-06-30 15:27+0800\n" +"Last-Translator: FULL NAME \n" +"Language: zh_Hans_CN\n" +"Language-Team: zh_Hans_CN \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: zxcvbn/feedback.py:10 +msgid "Use a few words, avoid common phrases." +msgstr "请使用多个词语,避免常见短语。" + +#: zxcvbn/feedback.py:11 +msgid "No need for symbols, digits, or uppercase letters." +msgstr "不需要符号、数字或大写字母。" + +#: zxcvbn/feedback.py:27 +msgid "Add another word or two. Uncommon words are better." +msgstr "建议再添加一两个词,不常见的词更好。" + +#: zxcvbn/feedback.py:46 +msgid "Straight rows of keys are easy to guess." +msgstr "键盘上的直线按键排列容易被猜中。" + +#: zxcvbn/feedback.py:48 +msgid "Short keyboard patterns are easy to guess." +msgstr "短的键盘模式容易被猜中。" + +#: zxcvbn/feedback.py:53 +msgid "Use a longer keyboard pattern with more turns." +msgstr "请使用更长且转折更多的键盘模式。" + +#: zxcvbn/feedback.py:58 +msgid "Repeats like \"aaa\" are easy to guess." +msgstr "像\"aaa\"这样的重复模式容易被猜中。" + +#: zxcvbn/feedback.py:60 +msgid "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"." +msgstr "像\"abcabcabc\"这样的重复模式只比\"abc\"难猜一点。" + +#: zxcvbn/feedback.py:65 +msgid "Avoid repeated words and characters." +msgstr "避免重复的单词和字符。" + +#: zxcvbn/feedback.py:70 +msgid "Sequences like \"abc\" or \"6543\" are easy to guess." +msgstr "像\"abc\"或\"6543\"这样的连续序列容易被猜中。" + +#: zxcvbn/feedback.py:72 +msgid "Avoid sequences." +msgstr "避免使用连续序列。" + +#: zxcvbn/feedback.py:78 +msgid "Recent years are easy to guess." +msgstr "近年来份容易被猜中。" + +#: zxcvbn/feedback.py:80 +msgid "Avoid recent years." +msgstr "避免使用近年份。" + +#: zxcvbn/feedback.py:81 +msgid "Avoid years that are associated with you." +msgstr "避免使用与自己相关的年份。" + +#: zxcvbn/feedback.py:86 +msgid "Dates are often easy to guess." +msgstr "日期常常容易被猜中。" + +#: zxcvbn/feedback.py:88 +msgid "Avoid dates and years that are associated with you." +msgstr "避免使用与自己相关的日期和年份。" + +#: zxcvbn/feedback.py:99 +msgid "This is a top-10 common password." +msgstr "这是排名前10的常用密码。" + +#: zxcvbn/feedback.py:101 +msgid "This is a top-100 common password." +msgstr "这是排名前100的常用密码。" + +#: zxcvbn/feedback.py:103 +msgid "This is a very common password." +msgstr "这是一个非常常见的密码。" + +#: zxcvbn/feedback.py:105 +msgid "This is similar to a commonly used password." +msgstr "这与常用密码相似。" + +#: zxcvbn/feedback.py:108 +msgid "A word by itself is easy to guess." +msgstr "单个词语容易被猜中。" + +#: zxcvbn/feedback.py:112 +msgid "Names and surnames by themselves are easy to guess." +msgstr "单独的姓名容易被猜中。" + +#: zxcvbn/feedback.py:114 +msgid "Common names and surnames are easy to guess." +msgstr "常见姓名容易被猜中。" + +#: zxcvbn/feedback.py:121 +msgid "Capitalization doesn't help very much." +msgstr "大小写帮助不大。" + +#: zxcvbn/feedback.py:123 +msgid "All-uppercase is almost as easy to guess as all-lowercase." +msgstr "全大写几乎和全小写一样容易被猜中。" + +#: zxcvbn/feedback.py:127 +msgid "Reversed words aren't much harder to guess." +msgstr "反转的单词也没有更难猜。" + +#: zxcvbn/feedback.py:129 +msgid "Predictable substitutions like '@' instead of 'a' don't help very much." +msgstr "像用'@'代替'a'这种可预测的替换帮助不大。"