diff --git a/README.md b/README.md index efa939e6..81adef19 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ FileNotFoundError: [Errno 2] No translation file found for domain: 'humanize' How to add new phrases to existing locale files: ```sh -xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -k'NS_:1,2' -k'_ngettext:1,2' -l python src/humanize/*.py # extract new phrases +xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -k'PS_:1c,2' -k'NS_:1,2' -k'_ngettext:1,2' -l python src/humanize/*.py # extract new phrases msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files ``` diff --git a/scripts/update-translations.sh b/scripts/update-translations.sh index bb4ec807..eade289b 100755 --- a/scripts/update-translations.sh +++ b/scripts/update-translations.sh @@ -1,7 +1,7 @@ set -e # extract new phrases -xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -k'NS_:1,2' -k'_ngettext:1,2' -l python src/humanize/*.py +xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -k'PS_:1c,2' -k'NS_:1,2' -k'_ngettext:1,2' -l python src/humanize/*.py for d in src/humanize/locale/*/; do locale="$(basename $d)" diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index f41d1d16..9125faa5 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -179,6 +179,19 @@ def num_name(n): return singular, plural +def _pgettext_noop(msgctxt: str, message: str) -> tuple[str, str]: + """Mark a contextual translation without translating it. + + Args: + msgctxt (str): Context of the translation. + message (str): Text to translate in the future. + + Returns: + tuple: Original context and text, unchanged. + """ + return msgctxt, message + + def thousands_separator() -> str: """Return the thousands separator for a locale, default to comma. diff --git a/src/humanize/number.py b/src/humanize/number.py index 727435f2..d502af9a 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -7,9 +7,11 @@ import bisect from .i18n import _gettext as _ +from .i18n import _gettext_noop as N_ from .i18n import _ngettext, decimal_separator, thousands_separator from .i18n import _ngettext_noop as NS_ from .i18n import _pgettext as P_ +from .i18n import _pgettext_noop as PS_ TYPE_CHECKING = False if TYPE_CHECKING: @@ -35,18 +37,43 @@ } _SUPERSCRIPT_TRANS = str.maketrans(_SUPERSCRIPT_MAP) -_ORDINAL_SUFFIXES = ("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th") +_ORDINAL_SUFFIXES = { + "male": ( + PS_("0 (male)", "th"), + PS_("1 (male)", "st"), + PS_("2 (male)", "nd"), + PS_("3 (male)", "rd"), + PS_("4 (male)", "th"), + PS_("5 (male)", "th"), + PS_("6 (male)", "th"), + PS_("7 (male)", "th"), + PS_("8 (male)", "th"), + PS_("9 (male)", "th"), + ), + "female": ( + PS_("0 (female)", "th"), + PS_("1 (female)", "st"), + PS_("2 (female)", "nd"), + PS_("3 (female)", "rd"), + PS_("4 (female)", "th"), + PS_("5 (female)", "th"), + PS_("6 (female)", "th"), + PS_("7 (female)", "th"), + PS_("8 (female)", "th"), + PS_("9 (female)", "th"), + ), +} _APNUMBER_WORDS = ( - "zero", - "one", - "two", - "three", - "four", - "five", - "six", - "seven", - "eight", - "nine", + N_("zero"), + N_("one"), + N_("two"), + N_("three"), + N_("four"), + N_("five"), + N_("six"), + N_("seven"), + N_("eight"), + N_("nine"), ) @@ -110,7 +137,7 @@ def ordinal(value: NumberOrString, gender: str = "male") -> str: return str(value) gender = "male" if gender == "male" else "female" digit = 0 if value % 100 in (11, 12, 13) else value % 10 - return f"{value}{P_(f'{digit} ({gender})', _ORDINAL_SUFFIXES[digit])}" + return f"{value}{P_(*_ORDINAL_SUFFIXES[gender][digit])}" def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 6ea61a29..41932217 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -3,13 +3,67 @@ from __future__ import annotations import datetime as dt +import gettext import importlib +import shutil +import subprocess +from pathlib import Path import pytest from freezegun import freeze_time import humanize + +@pytest.mark.parametrize("locale, one", [("de_DE", "eins"), ("fr_FR", "un")]) +def test_update_translations( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, locale: str, one: str +) -> None: + for command in ("bash", "xgettext", "msgmerge", "msgfmt"): + if shutil.which(command) is None: + pytest.skip(f"Translation updates require {command}") + + root = Path(__file__).resolve().parents[1] + source = root / "src" / "humanize" + destination = tmp_path / "src" / "humanize" + messages = destination / "locale" / locale / "LC_MESSAGES" + messages.mkdir(parents=True) + for path in source.glob("*.py"): + shutil.copy2(path, destination) + catalog = messages / "humanize.po" + shutil.copy2(source / "locale" / locale / "LC_MESSAGES" / "humanize.po", catalog) + binary = catalog.with_suffix(".mo") + subprocess.run(["msgfmt", "--check", "-o", str(binary), str(catalog)], check=True) + + results = [] + try: + for updated in (False, True): + if updated: + subprocess.run( + ["bash", str(root / "scripts" / "update-translations.sh")], + cwd=tmp_path, + check=True, + ) + with binary.open("rb") as stream: + translation = gettext.GNUTranslations(stream) + # Replace the cached catalog so the second pass uses the updated one. + monkeypatch.setitem(humanize.i18n._TRANSLATIONS, locale, translation) + humanize.activate(locale) + results.append( + [humanize.apnumber(value) for value in range(10)] + + [ + humanize.ordinal(value, gender=gender) + for gender in ("male", "female") + for value in range(10) + ] + ) + finally: + humanize.deactivate() + + assert results[0][1] == one + assert results[1] == results[0] + + with freeze_time("2020-02-02"): NOW = dt.datetime.now(tz=dt.timezone.utc)