From 95e5d25974d6c8b76aa1201dad0883d5b953d7c5 Mon Sep 17 00:00:00 2001 From: boulea7 Date: Sat, 3 Oct 2026 06:28:31 +0800 Subject: [PATCH] Fix German grammatical cases in naturaltime --- scripts/update-translations.sh | 13 ++ src/humanize/i18n.py | 51 +++++ .../locale/de_DE/LC_MESSAGES/humanize.po | 118 +++++++++- src/humanize/time.py | 118 +++++++--- tests/test_i18n.py | 208 ++++++++++++++++++ 5 files changed, 481 insertions(+), 27 deletions(-) diff --git a/scripts/update-translations.sh b/scripts/update-translations.sh index 7827900f..88e02965 100755 --- a/scripts/update-translations.sh +++ b/scripts/update-translations.sh @@ -10,6 +10,19 @@ xgettext \ -k'PS_:1c,2' \ -k'NS_:1,2' \ -k'_ngettext:1,2' \ + -k'G_:2' \ + -k'NG_:2,3' \ + -l python \ + src/humanize/*.py + +# Extract the contextual entries from the same calls without replacing ordinary ones. +xgettext \ + --from-code=UTF-8 \ + --join-existing \ + -o humanize.pot \ + --keyword \ + -k'G_:1c,2' \ + -k'NG_:1c,2,3' \ -l python \ src/humanize/*.py diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 0bf73de5..d4c4efc4 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -140,6 +140,57 @@ def _ngettext(message: str, plural: str, num: int) -> str: return get_translation().ngettext(message, plural, num) +def _pgettext_with_fallback( + msgctxt: str, message: str, use_context: bool = True +) -> str: + """Translate with context, falling back to the ordinary translation. + + Args: + msgctxt (str): Context of the translation. + message (str): Text to translate. + use_context (bool): Whether to look up the contextual translation first. + + Returns: + str: Translated text. + """ + translation = get_translation() + if not use_context: + return translation.gettext(message) + + contextual_message = f"{msgctxt}\x04{message}" + translated = translation.gettext(contextual_message) + if translated == contextual_message: + return translation.gettext(message) + return translated + + +def _npgettext_with_fallback( + msgctxt: str, message: str, plural: str, num: int, use_context: bool = True +) -> str: + """Translate a plural with context, falling back to the ordinary translation. + + Args: + msgctxt (str): Context of the translation. + message (str): Singular text to translate. + plural (str): Plural text to translate. + num (int): Number used to select the grammatical number. + use_context (bool): Whether to look up the contextual translation first. + + Returns: + str: Translated text. + """ + translation = get_translation() + if not use_context: + return translation.ngettext(message, plural, num) + + contextual_message = f"{msgctxt}\x04{message}" + contextual_plural = f"{msgctxt}\x04{plural}" + translated = translation.ngettext(contextual_message, contextual_plural, num) + if translated in (contextual_message, contextual_plural): + return translation.ngettext(message, plural, num) + return translated + + def _gettext_noop(message: str) -> str: """Mark a string as a translation string without translating it. diff --git a/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po b/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po index 026df970..04139b90 100644 --- a/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/de_DE/LC_MESSAGES/humanize.po @@ -336,7 +336,7 @@ msgstr[1] "%d Jahre" #: src/humanize/time.py:256 #, python-format msgid "%s from now" -msgstr "%s ab jetzt" +msgstr "in %s" #: src/humanize/time.py:256 #, python-format @@ -363,3 +363,119 @@ msgstr "gestern" #, python-format msgid "%s and %s" msgstr "%s und %s" + +# Relative time uses the dative after "vor" and "in". +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "a second" +msgstr "einer Sekunde" + +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "a minute" +msgstr "einer Minute" + +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "an hour" +msgstr "einer Stunde" + +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "a day" +msgstr "einem Tag" + +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "a month" +msgstr "einem Monat" + +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "a year" +msgstr "einem Jahr" + +#: src/humanize/time.py +msgctxt "naturaltime" +msgid "1 year, 1 month" +msgstr "einem Jahr und einem Monat" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d microsecond" +msgid_plural "%d microseconds" +msgstr[0] "%d Mikrosekunde" +msgstr[1] "%d Mikrosekunden" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d millisecond" +msgid_plural "%d milliseconds" +msgstr[0] "%d Millisekunde" +msgstr[1] "%d Millisekunden" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d second" +msgid_plural "%d seconds" +msgstr[0] "%d Sekunde" +msgstr[1] "%d Sekunden" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d minute" +msgid_plural "%d minutes" +msgstr[0] "%d Minute" +msgstr[1] "%d Minuten" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d hour" +msgid_plural "%d hours" +msgstr[0] "%d Stunde" +msgstr[1] "%d Stunden" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d day" +msgid_plural "%d days" +msgstr[0] "%d Tag" +msgstr[1] "%d Tagen" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d month" +msgid_plural "%d months" +msgstr[0] "%d Monat" +msgstr[1] "%d Monaten" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "%d year" +msgid_plural "%d years" +msgstr[0] "%d Jahr" +msgstr[1] "%d Jahren" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "1 year, %d day" +msgid_plural "1 year, %d days" +msgstr[0] "einem Jahr und %d Tag" +msgstr[1] "einem Jahr und %d Tagen" + +#: src/humanize/time.py +#, python-format +msgctxt "naturaltime" +msgid "1 year, %d month" +msgid_plural "1 year, %d months" +msgstr[0] "einem Jahr und %d Monat" +msgstr[1] "einem Jahr und %d Monaten" diff --git a/src/humanize/time.py b/src/humanize/time.py index 82a80424..6260e722 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -12,6 +12,8 @@ from .i18n import _gettext as _ from .i18n import _ngettext +from .i18n import _npgettext_with_fallback as NG_ +from .i18n import _pgettext_with_fallback as G_ from .number import intcomma TYPE_CHECKING = False @@ -137,6 +139,18 @@ def naturaldelta( ``` """ + result = _naturaldelta(value, months, minimum_unit) + return _("a moment") if result is None else result + + +def _naturaldelta( + value: dt.timedelta | float, + months: bool, + minimum_unit: str, + *, + natural_time: bool = False, +) -> str | None: + """Return a duration, or None for a moment.""" import datetime as dt tmp = Unit[minimum_unit.upper()] @@ -176,7 +190,13 @@ def naturaldelta( if delta.seconds == 0: if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000: return ( - _ngettext("%d microsecond", "%d microseconds", delta.microseconds) + NG_( + "naturaltime", + "%d microsecond", + "%d microseconds", + delta.microseconds, + natural_time, + ) % delta.microseconds ) @@ -185,79 +205,125 @@ def naturaldelta( ): milliseconds = delta.microseconds / 1000 return ( - _ngettext("%d millisecond", "%d milliseconds", int(milliseconds)) + NG_( + "naturaltime", + "%d millisecond", + "%d milliseconds", + int(milliseconds), + natural_time, + ) % milliseconds ) - return _("a moment") + return None if delta.seconds == 1: - return _("a second") + return G_("naturaltime", "a second", natural_time) if delta.seconds < 60: - return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds + return ( + NG_( + "naturaltime", + "%d second", + "%d seconds", + delta.seconds, + natural_time, + ) + % delta.seconds + ) if 60 <= delta.seconds < 3600: minutes = round(delta.seconds / 60) if minutes == 1: - return _("a minute") + return G_("naturaltime", "a minute", natural_time) if minutes == 60: - return _("an hour") + return G_("naturaltime", "an hour", natural_time) - return _ngettext("%d minute", "%d minutes", minutes) % minutes + return ( + NG_("naturaltime", "%d minute", "%d minutes", minutes, natural_time) + % minutes + ) if 3600 <= delta.seconds: hours = round(delta.seconds / 3600) if hours == 1: - return _("an hour") + return G_("naturaltime", "an hour", natural_time) if hours == 24: - return _("a day") + return G_("naturaltime", "a day", natural_time) - return _ngettext("%d hour", "%d hours", hours) % hours + return ( + NG_("naturaltime", "%d hour", "%d hours", hours, natural_time) % hours + ) elif years == 0: if days == 1: - return _("a day") + return G_("naturaltime", "a day", natural_time) if not use_months: - return _ngettext("%d day", "%d days", days) % days + return NG_("naturaltime", "%d day", "%d days", days, natural_time) % days if num_months == 0: - return _ngettext("%d day", "%d days", days) % days + return NG_("naturaltime", "%d day", "%d days", days, natural_time) % days if num_months == 1: - return _("a month") + return G_("naturaltime", "a month", natural_time) if num_months == 12: - return _("a year") + return G_("naturaltime", "a year", natural_time) - return _ngettext("%d month", "%d months", num_months) % num_months + return ( + NG_("naturaltime", "%d month", "%d months", num_months, natural_time) + % num_months + ) elif years == 1: if num_months == 0 and days == 0: - return _("a year") + return G_("naturaltime", "a year", natural_time) if num_months == 0: - return _ngettext("1 year, %d day", "1 year, %d days", days) % days + return ( + NG_( + "naturaltime", + "1 year, %d day", + "1 year, %d days", + days, + natural_time, + ) + % days + ) if use_months: if num_months == 1: - return _("1 year, 1 month") + return G_("naturaltime", "1 year, 1 month", natural_time) if num_months == 12: years += 1 - return _ngettext("%d year", "%d years", years) % years + return ( + NG_("naturaltime", "%d year", "%d years", years, natural_time) + % years + ) return ( - _ngettext("1 year, %d month", "1 year, %d months", num_months) + NG_( + "naturaltime", + "1 year, %d month", + "1 year, %d months", + num_months, + natural_time, + ) % num_months ) - return _ngettext("1 year, %d day", "1 year, %d days", days) % days + return ( + NG_("naturaltime", "1 year, %d day", "1 year, %d days", days, natural_time) + % days + ) years = round(delta.days / 365) - return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years) + return NG_("naturaltime", "%d year", "%d years", years, natural_time).replace( + "%d", "%s" + ) % intcomma(years) def naturaltime( @@ -303,9 +369,9 @@ def naturaltime( future = date > now ago = _("%s from now") if future else _("%s ago") - delta = naturaldelta(delta, months, minimum_unit) + delta = _naturaldelta(delta, months, minimum_unit, natural_time=True) - if delta == _("a moment"): + if delta is None: return _("now") return str(ago % delta) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 20db617b..2ce79254 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -20,6 +20,38 @@ NOW = dt.datetime.now(tz=dt.timezone.utc) +GERMAN_DELTA_CASES = [ + (dt.timedelta(seconds=1), "seconds", True, "einer Sekunde"), + (dt.timedelta(seconds=2), "seconds", True, "2 Sekunden"), + (dt.timedelta(minutes=1), "seconds", True, "einer Minute"), + (dt.timedelta(minutes=2), "seconds", True, "2 Minuten"), + (dt.timedelta(minutes=59, seconds=30), "seconds", True, "einer Stunde"), + (dt.timedelta(hours=1), "seconds", True, "einer Stunde"), + (dt.timedelta(hours=2), "seconds", True, "2 Stunden"), + (dt.timedelta(hours=23, minutes=59), "seconds", True, "einem Tag"), + (dt.timedelta(days=1), "seconds", True, "einem Tag"), + (dt.timedelta(days=2), "seconds", True, "2 Tagen"), + (dt.timedelta(days=65), "seconds", False, "65 Tagen"), + (dt.timedelta(days=31), "seconds", True, "einem Monat"), + (dt.timedelta(days=61), "seconds", True, "2 Monaten"), + (dt.timedelta(days=364), "seconds", True, "einem Jahr"), + (dt.timedelta(days=365), "seconds", True, "einem Jahr"), + (dt.timedelta(days=366), "seconds", True, "einem Jahr und 1 Tag"), + (dt.timedelta(days=369), "seconds", True, "einem Jahr und 4 Tagen"), + (dt.timedelta(days=400), "seconds", True, "einem Jahr und einem Monat"), + (dt.timedelta(days=426), "seconds", True, "einem Jahr und 2 Monaten"), + (dt.timedelta(days=400), "seconds", False, "einem Jahr und 35 Tagen"), + (dt.timedelta(days=729), "seconds", True, "2 Jahren"), + (dt.timedelta(days=730), "seconds", True, "2 Jahren"), + (dt.timedelta(days=365 * 1234), "seconds", True, "1.234 Jahren"), + (dt.timedelta(microseconds=1), "microseconds", True, "1 Mikrosekunde"), + (dt.timedelta(microseconds=4), "microseconds", True, "4 Mikrosekunden"), + (dt.timedelta(microseconds=4), "milliseconds", True, "0 Millisekunden"), + (dt.timedelta(milliseconds=1), "milliseconds", True, "1 Millisekunde"), + (dt.timedelta(milliseconds=4), "microseconds", True, "4 Millisekunden"), +] + + @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 @@ -61,6 +93,20 @@ def test_update_translations( for gender in ("male", "female") for value in range(10) ] + + [ + result + for value, minimum_unit, months, _ in GERMAN_DELTA_CASES + for result in ( + humanize.naturaldelta(value, months, minimum_unit), + humanize.naturaltime( + value, months=months, minimum_unit=minimum_unit, when=NOW + ), + humanize.naturaltime( + -value, months=months, minimum_unit=minimum_unit, when=NOW + ), + ) + ] + + [humanize.naturaldelta(0), humanize.naturaltime(0)] ) finally: humanize.deactivate() @@ -151,6 +197,168 @@ def test_naturaldelta() -> None: assert humanize.naturaldelta(seconds) == "1,234 years" +@pytest.mark.parametrize( + "future, expected", + [(False, "vor einer Stunde"), (True, "in einer Stunde")], +) +def test_naturaltime_german_grammatical_case(future: bool, expected: str) -> None: + try: + humanize.i18n.activate("de_DE") + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.naturaldelta(3600) == "eine Stunde" + assert humanize.naturaltime(3600, future=future, when=NOW) == expected + finally: + humanize.i18n.deactivate() + + +@pytest.mark.parametrize("value, minimum_unit, months, expected", GERMAN_DELTA_CASES) +@pytest.mark.parametrize("future", [False, True]) +@pytest.mark.parametrize("input_kind", ["timedelta", "datetime"]) +def test_naturaltime_german_units( + value: dt.timedelta, + minimum_unit: str, + months: bool, + expected: str, + future: bool, + input_kind: str, +) -> None: + delta = -value if future else value + test_input = NOW - delta if input_kind == "datetime" else delta + prefix = "in" if future else "vor" + try: + humanize.i18n.activate("de_DE") + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert ( + humanize.naturaltime( + test_input, + future=not future, + months=months, + minimum_unit=minimum_unit, + when=NOW, + ) + == f"{prefix} {expected}" + ) + finally: + humanize.i18n.deactivate() + + +@pytest.mark.parametrize("future", [False, True]) +@pytest.mark.parametrize("value", [0, dt.timedelta(microseconds=1), NOW]) +def test_naturaltime_german_now( + future: bool, value: int | dt.timedelta | dt.datetime +) -> None: + try: + humanize.i18n.activate("de_DE") + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.naturaltime(value, future=future, when=NOW) == "jetzt" + finally: + humanize.i18n.deactivate() + + +@pytest.mark.parametrize( + "value, expected", + [(float("nan"), "nan"), (float("inf"), "inf"), (float("-inf"), "-inf")], +) +def test_naturaltime_german_non_finite(value: float, expected: str) -> None: + try: + humanize.i18n.activate("de_DE") + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.naturaldelta(value) == expected + if expected == "nan": + assert humanize.naturaltime(value, when=NOW) == expected + else: + with pytest.raises(OverflowError): + humanize.naturaltime(value, when=NOW) + with pytest.raises(OverflowError): + humanize.naturaldelta(1e30) + with pytest.raises(ValueError, match="Minimum unit 'years' not supported"): + humanize.naturaltime(1, minimum_unit="years", when=NOW) + finally: + humanize.i18n.deactivate() + + +@pytest.mark.parametrize("contextual", [False, True]) +def test_naturaltime_context_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, contextual: bool +) -> None: + if shutil.which("msgfmt") is None: + pytest.skip("Catalog compilation requires msgfmt") + catalog = tmp_path / "humanize.po" + contents = (LOCALE_DIR / "ru_RU" / "LC_MESSAGES" / "humanize.po").read_text() + if contextual: + # Explicit contextual translations may equal the English source messages. + contents += """ +msgctxt "naturaltime" +msgid "an hour" +msgstr "an hour" + +#, python-format +msgctxt "naturaltime" +msgid "%d hour" +msgid_plural "%d hours" +msgstr[0] "%d hour" +msgstr[1] "%d hours" +msgstr[2] "%d hours" +""" + catalog.write_text(contents) + binary = catalog.with_suffix(".mo") + subprocess.run(["msgfmt", "--check", "-o", str(binary), str(catalog)], check=True) + with binary.open("rb") as stream: + translation = gettext.GNUTranslations(stream) + monkeypatch.setitem(humanize.i18n._TRANSLATIONS, "ru_RU", translation) + try: + humanize.activate("ru_RU") + for hours, ordinary, contextual_text in ( + (1, "час", "an hour"), + (2, "2 часа", "2 hours"), + (21, "21 час", "21 hour"), + ): + value = dt.timedelta(hours=hours) + relative = contextual_text if contextual else ordinary + assert humanize.naturaldelta(value) == ordinary + assert humanize.naturaltime(value, when=NOW) == f"{relative} назад" + assert humanize.naturaltime(-value, when=NOW) == f"через {relative}" + assert humanize.naturaltime(0, when=NOW) == "сейчас" + humanize.activate("de_DE") + assert humanize.naturaltime(3600, when=NOW) == "vor einer Stunde" + humanize.deactivate() + assert humanize.naturaltime(3600, when=NOW) == "an hour ago" + finally: + humanize.deactivate() + + +@pytest.mark.parametrize( + "value, expected", + [ + (dt.timedelta(seconds=1), "bir saniye önce"), + (dt.timedelta(seconds=-1), "şu andan itibaren bir saniye"), + (0, "şimdi"), + (dt.timedelta(milliseconds=4), "şimdi"), + (dt.timedelta(milliseconds=-4), "şimdi"), + ], +) +def test_naturaltime_moment_translation_collision( + value: int | dt.timedelta, expected: str +) -> None: + try: + humanize.activate("tr_TR") + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.naturaldelta(value) == "bir saniye" + assert humanize.naturaltime(value, when=NOW) == expected + finally: + humanize.deactivate() + + @pytest.mark.parametrize( "locale, number, expected_result", [