Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion scripts/update-translations.sh
Original file line number Diff line number Diff line change
@@ -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)"
Expand Down
13 changes: 13 additions & 0 deletions src/humanize/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
51 changes: 39 additions & 12 deletions src/humanize/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"),
)


Expand Down Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions tests/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down