Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,12 @@ 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
msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files
scripts/update-translations.sh
```

This extracts new phrases from the source code into `humanize.pot`, merges them into
each locale's `.po` file and compiles the binary `.mo` catalogs.

How to add a new locale:

```sh
Expand Down
12 changes: 11 additions & 1 deletion scripts/update-translations.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
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
56 changes: 54 additions & 2 deletions tests/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,72 @@
from __future__ import annotations

import datetime as dt
import gettext
import importlib
import pathlib
import shutil
import subprocess
from pathlib import Path

import pytest
from freezegun import freeze_time

import humanize

LOCALE_DIR = pathlib.Path(humanize.i18n.__file__).parent / "locale"
LOCALE_DIR = Path(humanize.i18n.__file__).parent / "locale"

with freeze_time("2020-02-02"):
NOW = dt.datetime.now(tz=dt.timezone.utc)


@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]


@freeze_time("2020-02-02")
def test_i18n() -> None:
three_seconds = NOW - dt.timedelta(seconds=3)
Expand Down