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.

@alexei
alexei force-pushed the interpolate-translation-placeholders branch from 06e11fd to f77241e Compare September 18, 2026 14:49
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