diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index f41d1d1..fc1a85e 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 4a07d52..6b3fd63 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,20 @@ 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 +185,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 +194,75 @@ 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) - % num_months - ) + return _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 +308,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 7699770..7764dcf 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -852,3 +852,60 @@ 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)