Skip to content

Interpolate the number into ordinal, intword and filesize translations - #403

Open
alexei wants to merge 1 commit into
python-humanize:mainfrom
alexei:interpolate-translation-placeholders
Open

alexei wants to merge 1 commit into
python-humanize:mainfrom
alexei:interpolate-translation-placeholders

Conversation

@alexei

@alexei alexei commented Sep 18, 2026 •

Copy link
Copy Markdown

This is a prerequisite for languages like Romanian, that position the number differently (or not at all), see #270

Note I used an LLM to produce a script that re-built the catalog with the placeholder prepended to the relevant strings:

"""One-off: rewrite .po entries whose msgids gained a %s placeholder.

Only touches the affected entries (no reflow, no msgmerge churn) and keeps each
translation's output identical to what the old concatenation produced:

    ordinal:     "th"       -> "%sth"         msgstr "X" -> "%sX"
    intword:     "million"  -> "%s million"   msgstr "X" -> "%s X"
    naturalsize: "kB"       -> "%s kB"        msgstr "X" -> "%s X"
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

ORDINAL_CTXT = re.compile(r"\d \((male|female)\)")
ORDINAL_IDS = {"th", "st", "nd", "rd"}
POWERS = set(
    "thousand million billion trillion quadrillion quintillion sextillion "
    "septillion octillion nonillion decillion googol".split()
)
UNITS = set(
    "kB MB GB TB PB EB ZB YB RB QB KiB MiB GiB TiB PiB EiB ZiB YiB RiB QiB".split()
)

FIELD = re.compile(r'^(msgctxt|msgid_plural|msgid|msgstr(?:\[\d+\])?) "(.*)"$')
CONT = re.compile(r'^"(.*)"$')


def parse(block: list[str]) -> dict[str, list[int]]:
    """Map each field name to the line indexes holding its string pieces."""
    fields: dict[str, list[int]] = {}
    current = None
    for i, line in enumerate(block):
        if m := FIELD.match(line):
            current = m.group(1)
            fields[current] = [i]
        elif CONT.match(line) and current:
            fields[current].append(i)
        else:
            current = None
    return fields


def value(block: list[str], idxs: list[int]) -> str:
    out = ""
    for i in idxs:
        m = FIELD.match(block[i]) or CONT.match(block[i])
        out += m.groups()[-1]
    return out


def prefix(block: list[str], idxs: list[int], pre: str) -> None:
    """Prepend `pre` to a (possibly multi-line) string, unless it is empty."""
    if not value(block, idxs):
        return  # untranslated: gettext falls back to the msgid, already templated
    assert "%" not in value(block, idxs), block
    for i in idxs:
        m = FIELD.match(block[i])
        if m and m.group(2) == "" and len(idxs) > 1:
            continue  # msgstr "" header line of a wrapped string
        if m:
            block[i] = f'{m.group(1)} "{pre}{m.group(2)}"'
        else:
            block[i] = f'"{pre}{CONT.match(block[i]).group(1)}"'
        return


def set_single(block: list[str], idxs: list[int], new: str) -> None:
    name = FIELD.match(block[idxs[0]]).group(1)
    block[idxs[0]] = f'{name} "{new}"'
    for i in reversed(idxs[1:]):
        del block[i]


def add_python_format(block: list[str]) -> None:
    for i, line in enumerate(block):
        if line.startswith("#,"):
            if "python-format" not in line:
                block[i] = line + ", python-format"
            return
    for i, line in enumerate(block):
        if line.startswith(("#|", "msgctxt", "msgid")):
            block.insert(i, "#, python-format")
            return


def migrate(block: list[str]) -> bool:
    if any(line.startswith("#~") for line in block):
        return False  # obsolete entries are left alone
    f = parse(block)
    if "msgid" not in f:
        return False
    msgid = value(block, f["msgid"])
    ctxt = value(block, f["msgctxt"]) if "msgctxt" in f else None
    msgstrs = [k for k in f if k.startswith("msgstr")]

    if ctxt and ORDINAL_CTXT.fullmatch(ctxt) and msgid in ORDINAL_IDS:
        pre = "%s"
    elif ctxt is None and "msgid_plural" in f and msgid in POWERS:
        pre = "%s "
    elif ctxt is None and "msgid_plural" not in f and msgid in UNITS:
        pre = "%s "
    else:
        return False

    # Rewrite msgstrs first: rewriting msgid may delete lines and shift indexes.
    for k in sorted(msgstrs, key=lambda k: f[k][0], reverse=True):
        prefix(block, f[k], pre)
    if "msgid_plural" in f:
        plural = value(block, f["msgid_plural"])
        set_single(block, f["msgid_plural"], pre + plural)
    set_single(block, f["msgid"], pre + msgid)
    add_python_format(block)
    return True


def main(paths: list[str]) -> None:
    for path in map(Path, paths):
        text = path.read_text(encoding="utf-8")
        blocks = [b.split("\n") for b in text.split("\n\n")]
        changed = sum(migrate(b) for b in blocks)
        path.write_text("\n\n".join("\n".join(b) for b in blocks), encoding="utf-8")
        print(f"{path.parts[-3]}: {changed} entries")


if __name__ == "__main__":
    main(sys.argv[1:])

Use as python migrate_po.py src/humanize/locale/*/LC_MESSAGES/humanize.po.

The same LLM pointed out that some of the existing translations are currently incorrect, and these changes would help some of them e.g. Chinese appends the number, see https://en.wikipedia.org/wiki/Chinese_numerals#Ordinal_numbers However my goal here was to just add the placeholder while preserving the current behaviour.

This changes a lot, but I think it should not impact anyone negatively unless they're using the humanize catalog directly.

@hugovk hugovk added the changelog: Added For new features label Sep 18, 2026
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.70%. Comparing base (392aef7) to head (06e11fd).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #403   +/-   ##
=======================================
  Coverage   99.69%   99.70%           
=======================================
  Files          12       12           
  Lines         996     1015   +19     
=======================================
+ Hits          993     1012   +19     
  Misses          3        3           
Flag Coverage Δ
macos-latest 97.53% <96.15%> (-0.06%) ⬇️
ubuntu-latest 97.53% <96.15%> (-0.06%) ⬇️
windows-latest 92.01% <46.15%> (-1.26%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: Added For new features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants