Skip to content

Commit a23fa42

Browse files
fix: pass grammatical context through naturaldelta for i18n case-aware translations
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>
1 parent ce4147b commit a23fa42

3 files changed

Lines changed: 126 additions & 26 deletions

File tree

src/humanize/i18n.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ def _ngettext(message: str, plural: str, num: int) -> str:
140140
return get_translation().ngettext(message, plural, num)
141141

142142

143+
def _npgettext(msgctxt: str, message: str, plural: str, num: int) -> str:
144+
"""Plural version of _pgettext.
145+
146+
It works with ``msgctxt`` .po modifiers and allows duplicate keys with
147+
different translations depending on grammatical context (e.g. nominative
148+
vs. dative case).
149+
150+
Args:
151+
msgctxt (str): Context of the translation.
152+
message (str): Singular text to translate.
153+
plural (str): Plural text to translate.
154+
num (int): The number (e.g. item count) to determine translation for the
155+
respective grammatical number.
156+
157+
Returns:
158+
str: Translated text.
159+
"""
160+
return get_translation().npgettext(msgctxt, message, plural, num)
161+
162+
143163
def _gettext_noop(message: str) -> str:
144164
"""Mark a string as a translation string without translating it.
145165

src/humanize/time.py

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from functools import total_ordering
1212

1313
from .i18n import _gettext as _
14-
from .i18n import _ngettext
14+
from .i18n import _ngettext, _npgettext, _pgettext
1515
from .number import intcomma
1616

1717
TYPE_CHECKING = False
@@ -98,6 +98,7 @@ def naturaldelta(
9898
value: dt.timedelta | float,
9999
months: bool = True,
100100
minimum_unit: str = "seconds",
101+
context: str | None = None,
101102
) -> str:
102103
"""Return a natural representation of a timedelta or number of seconds.
103104
@@ -110,6 +111,11 @@ def naturaldelta(
110111
months (bool): If `True`, then a number of months (based on 30.5 days) will be
111112
used for fuzziness between years.
112113
minimum_unit (str): The lowest unit that can be used.
114+
context (str | None): Optional gettext ``msgctxt`` passed through to
115+
``pgettext`` / ``npgettext``. When set, translators can provide
116+
grammatically different forms for the same source string (e.g.
117+
dative case for German *"vor …"*). ``naturaltime`` passes
118+
``"naturaltime-past"`` or ``"naturaltime-future"`` automatically.
113119
114120
Returns:
115121
str (str or `value`): A natural representation of the amount of time
@@ -138,6 +144,18 @@ def naturaldelta(
138144
"""
139145
import datetime as dt
140146

147+
# When a context is provided, use pgettext/npgettext so that translators
148+
# can supply case-aware forms (e.g. German dative for "ago"/"from now").
149+
if context is not None:
150+
def _ng(singular: str, plural: str, num: int) -> str:
151+
return _npgettext(context, singular, plural, num)
152+
153+
def _g(message: str) -> str:
154+
return _pgettext(context, message)
155+
else:
156+
_ng = _ngettext
157+
_g = _
158+
141159
tmp = Unit[minimum_unit.upper()]
142160
if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS):
143161
msg = f"Minimum unit '{minimum_unit}' not supported"
@@ -165,7 +183,7 @@ def naturaldelta(
165183
if delta.seconds == 0:
166184
if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000:
167185
return (
168-
_ngettext("%d microsecond", "%d microseconds", delta.microseconds)
186+
_ng("%d microsecond", "%d microseconds", delta.microseconds)
169187
% delta.microseconds
170188
)
171189

@@ -174,78 +192,78 @@ def naturaldelta(
174192
):
175193
milliseconds = delta.microseconds / 1000
176194
return (
177-
_ngettext("%d millisecond", "%d milliseconds", int(milliseconds))
195+
_ng("%d millisecond", "%d milliseconds", int(milliseconds))
178196
% milliseconds
179197
)
180-
return _("a moment")
198+
return _g("a moment")
181199

182200
if delta.seconds == 1:
183-
return _("a second")
201+
return _g("a second")
184202

185203
if delta.seconds < 60:
186-
return _ngettext("%d second", "%d seconds", delta.seconds) % delta.seconds
204+
return _ng("%d second", "%d seconds", delta.seconds) % delta.seconds
187205

188206
if 60 <= delta.seconds < 3600:
189207
minutes = round(delta.seconds / 60)
190208
if minutes == 1:
191-
return _("a minute")
209+
return _g("a minute")
192210

193211
if minutes == 60:
194-
return _("an hour")
212+
return _g("an hour")
195213

196-
return _ngettext("%d minute", "%d minutes", minutes) % minutes
214+
return _ng("%d minute", "%d minutes", minutes) % minutes
197215

198216
if 3600 <= delta.seconds:
199217
hours = round(delta.seconds / 3600)
200218
if hours == 1:
201-
return _("an hour")
219+
return _g("an hour")
202220

203221
if hours == 24:
204-
return _("a day")
222+
return _g("a day")
205223

206-
return _ngettext("%d hour", "%d hours", hours) % hours
224+
return _ng("%d hour", "%d hours", hours) % hours
207225

208226
elif years == 0:
209227
if days == 1:
210-
return _("a day")
228+
return _g("a day")
211229

212230
if not use_months:
213-
return _ngettext("%d day", "%d days", days) % days
231+
return _ng("%d day", "%d days", days) % days
214232

215233
if num_months == 0:
216-
return _ngettext("%d day", "%d days", days) % days
234+
return _ng("%d day", "%d days", days) % days
217235

218236
if num_months == 1:
219-
return _("a month")
237+
return _g("a month")
220238

221239
if num_months == 12:
222-
return _("a year")
240+
return _g("a year")
223241

224-
return _ngettext("%d month", "%d months", num_months) % num_months
242+
return _ng("%d month", "%d months", num_months) % num_months
225243

226244
elif years == 1:
227245
if num_months == 0 and days == 0:
228-
return _("a year")
246+
return _g("a year")
229247

230248
if num_months == 0:
231-
return _ngettext("1 year, %d day", "1 year, %d days", days) % days
249+
return _ng("1 year, %d day", "1 year, %d days", days) % days
232250

233251
if use_months:
234252
if num_months == 1:
235-
return _("1 year, 1 month")
253+
return _g("1 year, 1 month")
236254

237255
if num_months == 12:
238256
years += 1
239-
return _ngettext("%d year", "%d years", years) % years
257+
return _ng("%d year", "%d years", years) % years
240258

241259
return (
242-
_ngettext("1 year, %d month", "1 year, %d months", num_months)
260+
_ng("1 year, %d month", "1 year, %d months", num_months)
243261
% num_months
244262
)
245263

246-
return _ngettext("1 year, %d day", "1 year, %d days", days) % days
264+
return _ng("1 year, %d day", "1 year, %d days", days) % days
247265

248-
return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years)
266+
return _ng("%d year", "%d years", years).replace("%d", "%s") % intcomma(years)
249267

250268

251269
def naturaltime(
@@ -291,7 +309,8 @@ def naturaltime(
291309
future = date > now
292310

293311
ago = _("%s from now") if future else _("%s ago")
294-
delta = naturaldelta(delta, months, minimum_unit)
312+
ctx = "naturaltime-future" if future else "naturaltime-past"
313+
delta = naturaldelta(delta, months, minimum_unit, context=ctx)
295314

296315
if delta == _("a moment"):
297316
return _("now")

tests/test_time.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,3 +852,64 @@ def test_time_unit() -> None:
852852
)
853853
def test_rounding_by_fmt(fmt: str, value: float, expected: float) -> None:
854854
assert time._rounding_by_fmt(fmt, value) == pytest.approx(expected)
855+
856+
857+
@pytest.mark.parametrize(
858+
"seconds, expected",
859+
[
860+
(1, "a second"),
861+
(30, "30 seconds"),
862+
(60, "a minute"),
863+
(3600, "an hour"),
864+
(3600 * 24, "a day"),
865+
(3600 * 24 * 65, "2 months"),
866+
(3600 * 24 * 365, "a year"),
867+
],
868+
)
869+
def test_naturaldelta_context_returns_same_english(
870+
seconds: int, expected: str
871+
) -> None:
872+
"""When no translation is active, the context parameter must not change output."""
873+
assert humanize.naturaldelta(seconds, context="naturaltime-past") == expected
874+
assert humanize.naturaldelta(seconds, context="naturaltime-future") == expected
875+
876+
877+
def test_naturaldelta_context_calls_npgettext() -> None:
878+
"""Verify that pgettext/npgettext are invoked when a context is supplied."""
879+
from unittest.mock import patch
880+
881+
with patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np, \
882+
patch("humanize.time._pgettext", wraps=time._pgettext) as mock_p:
883+
humanize.naturaldelta(30, context="naturaltime-past")
884+
# 30 seconds uses npgettext
885+
mock_np.assert_called_once_with(
886+
"naturaltime-past", "%d second", "%d seconds", 30
887+
)
888+
889+
mock_np.reset_mock()
890+
mock_p.reset_mock()
891+
892+
humanize.naturaldelta(1, context="naturaltime-future")
893+
# 1 second uses pgettext for "a second"
894+
mock_p.assert_called_once_with("naturaltime-future", "a second")
895+
896+
897+
@freeze_time(FROZEN_DATE)
898+
def test_naturaltime_passes_context_to_naturaldelta() -> None:
899+
"""naturaltime must pass tense context so translations can vary by case."""
900+
from unittest.mock import patch
901+
902+
past = NOW - dt.timedelta(seconds=30)
903+
future = NOW + dt.timedelta(seconds=30)
904+
905+
with patch("humanize.time._npgettext", wraps=time._npgettext) as mock_np:
906+
humanize.naturaltime(past)
907+
mock_np.assert_called_with(
908+
"naturaltime-past", "%d second", "%d seconds", 30
909+
)
910+
911+
mock_np.reset_mock()
912+
humanize.naturaltime(future)
913+
mock_np.assert_called_with(
914+
"naturaltime-future", "%d second", "%d seconds", 30
915+
)

0 commit comments

Comments
 (0)