From a23fa42e2c42fc4a9dafdf8096c7ffc9d7ccc56e Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 1 Aug 2026 22:27:46 +0530 Subject: [PATCH 1/2] fix: pass grammatical context through naturaldelta for i18n case-aware translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit naturaltime() composes naturaldelta() output with format strings like '%s ago' / '%s from now'. Languages such as German require different grammatical cases depending on the surrounding preposition (e.g. nominative 'eine Stunde' vs dative 'einer Stunde' after 'vor'). The two-step composition (naturaldelta → wrap) made it impossible for translators to provide case-correct forms because naturaldelta had no knowledge of the context in which its result would be used. Changes: - Add _npgettext() to humanize.i18n (plural + context gettext). - Add an optional 'context' parameter to naturaldelta(). When set, all internal gettext/ngettext calls are replaced with their pgettext/npgettext counterparts, keyed by the supplied context. - naturaltime() now passes 'naturaltime-past' or 'naturaltime-future' as context so translators can provide case-aware forms via msgctxt entries in .po files. The change is fully backward-compatible: without a context (or without matching msgctxt translations), output is identical to before. Fixes #263 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/humanize/i18n.py | 20 +++++++++++++ src/humanize/time.py | 71 ++++++++++++++++++++++++++++---------------- tests/test_time.py | 61 +++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 26 deletions(-) diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index f41d1d16..fc1a85eb 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -140,6 +140,26 @@ def _ngettext(message: str, plural: str, num: int) -> str: return get_translation().ngettext(message, plural, num) +def _npgettext(msgctxt: str, message: str, plural: str, num: int) -> str: + """Plural version of _pgettext. + + It works with ``msgctxt`` .po modifiers and allows duplicate keys with + different translations depending on grammatical context (e.g. nominative + vs. dative case). + + Args: + msgctxt (str): Context of the translation. + message (str): Singular text to translate. + plural (str): Plural text to translate. + num (int): The number (e.g. item count) to determine translation for the + respective grammatical number. + + Returns: + str: Translated text. + """ + return get_translation().npgettext(msgctxt, message, plural, num) + + def _gettext_noop(message: str) -> str: """Mark a string as a translation string without translating it. diff --git a/src/humanize/time.py b/src/humanize/time.py index 4a07d528..4b8bc9b4 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -11,7 +11,7 @@ from functools import total_ordering from .i18n import _gettext as _ -from .i18n import _ngettext +from .i18n import _ngettext, _npgettext, _pgettext from .number import intcomma TYPE_CHECKING = False @@ -98,6 +98,7 @@ def naturaldelta( value: dt.timedelta | float, months: bool = True, minimum_unit: str = "seconds", + context: str | None = None, ) -> str: """Return a natural representation of a timedelta or number of seconds. @@ -110,6 +111,11 @@ def naturaldelta( months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. + context (str | None): Optional gettext ``msgctxt`` passed through to + ``pgettext`` / ``npgettext``. When set, translators can provide + grammatically different forms for the same source string (e.g. + dative case for German *"vor …"*). ``naturaltime`` passes + ``"naturaltime-past"`` or ``"naturaltime-future"`` automatically. Returns: str (str or `value`): A natural representation of the amount of time @@ -138,6 +144,18 @@ def naturaldelta( """ import datetime as dt + # When a context is provided, use pgettext/npgettext so that translators + # can supply case-aware forms (e.g. German dative for "ago"/"from now"). + if context is not None: + def _ng(singular: str, plural: str, num: int) -> str: + return _npgettext(context, singular, plural, num) + + def _g(message: str) -> str: + return _pgettext(context, message) + else: + _ng = _ngettext + _g = _ + tmp = Unit[minimum_unit.upper()] if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS): msg = f"Minimum unit '{minimum_unit}' not supported" @@ -165,7 +183,7 @@ def naturaldelta( if delta.seconds == 0: if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000: return ( - _ngettext("%d microsecond", "%d microseconds", delta.microseconds) + _ng("%d microsecond", "%d microseconds", delta.microseconds) % delta.microseconds ) @@ -174,78 +192,78 @@ def naturaldelta( ): milliseconds = delta.microseconds / 1000 return ( - _ngettext("%d millisecond", "%d milliseconds", int(milliseconds)) + _ng("%d millisecond", "%d milliseconds", int(milliseconds)) % milliseconds ) - return _("a moment") + return _g("a moment") if delta.seconds == 1: - return _("a second") + return _g("a second") if delta.seconds < 60: - return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds + return _ng("%d second", "%d seconds", delta.seconds) % delta.seconds if 60 <= delta.seconds < 3600: minutes = round(delta.seconds / 60) if minutes == 1: - return _("a minute") + return _g("a minute") if minutes == 60: - return _("an hour") + return _g("an hour") - return _ngettext("%d minute", "%d minutes", minutes) % minutes + return _ng("%d minute", "%d minutes", minutes) % minutes if 3600 <= delta.seconds: hours = round(delta.seconds / 3600) if hours == 1: - return _("an hour") + return _g("an hour") if hours == 24: - return _("a day") + return _g("a day") - return _ngettext("%d hour", "%d hours", hours) % hours + return _ng("%d hour", "%d hours", hours) % hours elif years == 0: if days == 1: - return _("a day") + return _g("a day") if not use_months: - return _ngettext("%d day", "%d days", days) % days + return _ng("%d day", "%d days", days) % days if num_months == 0: - return _ngettext("%d day", "%d days", days) % days + return _ng("%d day", "%d days", days) % days if num_months == 1: - return _("a month") + return _g("a month") if num_months == 12: - return _("a year") + return _g("a year") - return _ngettext("%d month", "%d months", num_months) % num_months + return _ng("%d month", "%d months", num_months) % num_months elif years == 1: if num_months == 0 and days == 0: - return _("a year") + return _g("a year") if num_months == 0: - return _ngettext("1 year, %d day", "1 year, %d days", days) % days + return _ng("1 year, %d day", "1 year, %d days", days) % days if use_months: if num_months == 1: - return _("1 year, 1 month") + return _g("1 year, 1 month") if num_months == 12: years += 1 - return _ngettext("%d year", "%d years", years) % years + return _ng("%d year", "%d years", years) % years return ( - _ngettext("1 year, %d month", "1 year, %d months", num_months) + _ng("1 year, %d month", "1 year, %d months", num_months) % num_months ) - return _ngettext("1 year, %d day", "1 year, %d days", days) % days + return _ng("1 year, %d day", "1 year, %d days", days) % days - return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years) + return _ng("%d year", "%d years", years).replace("%d", "%s") % intcomma(years) def naturaltime( @@ -291,7 +309,8 @@ def naturaltime( future = date > now ago = _("%s from now") if future else _("%s ago") - delta = naturaldelta(delta, months, minimum_unit) + ctx = "naturaltime-future" if future else "naturaltime-past" + delta = naturaldelta(delta, months, minimum_unit, context=ctx) if delta == _("a moment"): return _("now") diff --git a/tests/test_time.py b/tests/test_time.py index 76997704..04bb2faf 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -852,3 +852,64 @@ def test_time_unit() -> None: ) def test_rounding_by_fmt(fmt: str, value: float, expected: float) -> None: assert time._rounding_by_fmt(fmt, value) == pytest.approx(expected) + + +@pytest.mark.parametrize( + "seconds, expected", + [ + (1, "a second"), + (30, "30 seconds"), + (60, "a minute"), + (3600, "an hour"), + (3600 * 24, "a day"), + (3600 * 24 * 65, "2 months"), + (3600 * 24 * 365, "a year"), + ], +) +def test_naturaldelta_context_returns_same_english( + seconds: int, expected: str +) -> None: + """When no translation is active, the context parameter must not change output.""" + assert humanize.naturaldelta(seconds, context="naturaltime-past") == expected + assert humanize.naturaldelta(seconds, context="naturaltime-future") == expected + + +def test_naturaldelta_context_calls_npgettext() -> None: + """Verify that pgettext/npgettext are invoked when a context is supplied.""" + from unittest.mock import patch + + with patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np, \ + patch("humanize.time._pgettext", wraps=time._pgettext) as mock_p: + humanize.naturaldelta(30, context="naturaltime-past") + # 30 seconds uses npgettext + mock_np.assert_called_once_with( + "naturaltime-past", "%d second", "%d seconds", 30 + ) + + mock_np.reset_mock() + mock_p.reset_mock() + + humanize.naturaldelta(1, context="naturaltime-future") + # 1 second uses pgettext for "a second" + mock_p.assert_called_once_with("naturaltime-future", "a second") + + +@freeze_time(FROZEN_DATE) +def test_naturaltime_passes_context_to_naturaldelta() -> None: + """naturaltime must pass tense context so translations can vary by case.""" + from unittest.mock import patch + + past = NOW - dt.timedelta(seconds=30) + future = NOW + dt.timedelta(seconds=30) + + with patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np: + humanize.naturaltime(past) + mock_np.assert_called_with( + "naturaltime-past", "%d second", "%d seconds", 30 + ) + + mock_np.reset_mock() + humanize.naturaltime(future) + mock_np.assert_called_with( + "naturaltime-future", "%d second", "%d seconds", 30 + ) From 9a3fcda8dd17aa60c861db1e811c5cd875ac96ff Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:59:09 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/humanize/time.py | 7 +++---- tests/test_time.py | 18 +++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 4b8bc9b4..6b3fd63e 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -147,11 +147,13 @@ def naturaldelta( # When a context is provided, use pgettext/npgettext so that translators # can supply case-aware forms (e.g. German dative for "ago"/"from now"). if context is not None: + def _ng(singular: str, plural: str, num: int) -> str: return _npgettext(context, singular, plural, num) def _g(message: str) -> str: return _pgettext(context, message) + else: _ng = _ngettext _g = _ @@ -256,10 +258,7 @@ def _g(message: str) -> str: years += 1 return _ng("%d year", "%d years", years) % years - return ( - _ng("1 year, %d month", "1 year, %d months", num_months) - % num_months - ) + return _ng("1 year, %d month", "1 year, %d months", num_months) % num_months return _ng("1 year, %d day", "1 year, %d days", days) % days diff --git a/tests/test_time.py b/tests/test_time.py index 04bb2faf..7764dcfc 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -866,9 +866,7 @@ def test_rounding_by_fmt(fmt: str, value: float, expected: float) -> None: (3600 * 24 * 365, "a year"), ], ) -def test_naturaldelta_context_returns_same_english( - seconds: int, expected: str -) -> None: +def test_naturaldelta_context_returns_same_english(seconds: int, expected: str) -> None: """When no translation is active, the context parameter must not change output.""" assert humanize.naturaldelta(seconds, context="naturaltime-past") == expected assert humanize.naturaldelta(seconds, context="naturaltime-future") == expected @@ -878,8 +876,10 @@ def test_naturaldelta_context_calls_npgettext() -> None: """Verify that pgettext/npgettext are invoked when a context is supplied.""" from unittest.mock import patch - with patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np, \ - patch("humanize.time._pgettext", wraps=time._pgettext) as mock_p: + with ( + patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np, + patch("humanize.time._pgettext", wraps=time._pgettext) as mock_p, + ): humanize.naturaldelta(30, context="naturaltime-past") # 30 seconds uses npgettext mock_np.assert_called_once_with( @@ -904,12 +904,8 @@ def test_naturaltime_passes_context_to_naturaldelta() -> None: with patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np: humanize.naturaltime(past) - mock_np.assert_called_with( - "naturaltime-past", "%d second", "%d seconds", 30 - ) + mock_np.assert_called_with("naturaltime-past", "%d second", "%d seconds", 30) mock_np.reset_mock() humanize.naturaltime(future) - mock_np.assert_called_with( - "naturaltime-future", "%d second", "%d seconds", 30 - ) + mock_np.assert_called_with("naturaltime-future", "%d second", "%d seconds", 30)