From 5f026ef4f21f029d832f73383291d77700faa6c9 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 26 Sep 2026 18:00:52 +0100 Subject: [PATCH 1/5] Compile catalogs to top-level `turtle_docstringdict_` modules, and add support for older versions. --- .gitignore | 3 +-- README.md | 14 ++++++++----- scripts/hook.py | 11 ++++++---- scripts/i18n.py | 37 ++++++++++++++++++++++++++------- turtle_translations/__init__.py | 11 ---------- 5 files changed, 46 insertions(+), 30 deletions(-) delete mode 100644 turtle_translations/__init__.py diff --git a/.gitignore b/.gitignore index 6e7ae53..b56693b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Compiled from po/*.po -/turtle_translations/*.py -!/turtle_translations/__init__.py +/turtle_docstringdict_*.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 984b44a..8397c4d 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,16 @@ pl 42/103 translated (40%), 3 fuzzy ### Compiling -Each PO file compiles to a `turtle_translations/.py` module containing a -`docsdict`, which is what `turtle` loads. The generated modules are built -automatically when the wheel is built, so you normally only need this to test -locally: +Each PO file compiles to a top-level `turtle_docstringdict_.py` module +containing a `docsdict`, which is what `turtle` imports. Untranslated and fuzzy +entries are left out so their help stays English. When imported, the module drops +entries for names that do not exist in the running version of `turtle`, so a +single module built from the newest Python works on older ones too. + +The modules are built automatically when the wheel is built, so you normally +only need this to test locally: ```console $ python scripts/i18n.py compile -Compiled: turtle_translations/pl.py +Compiled: turtle_docstringdict_pl.py ``` diff --git a/scripts/hook.py b/scripts/hook.py index 47963c8..31fdf25 100644 --- a/scripts/hook.py +++ b/scripts/hook.py @@ -1,6 +1,7 @@ """Hatchling build hook to compile the PO catalogs when the wheel is built.""" import sys +import tempfile from pathlib import Path from hatchling.builders.hooks.plugin.interface import BuildHookInterface @@ -11,7 +12,9 @@ class CustomBuildHook(BuildHookInterface): def initialize(self, version, build_data): - # The generated modules are gitignored, so hatch need to be told to ship them. - build_data["artifacts"] = [ - str(path.relative_to(self.root)) for path in i18n._compile_catalogs() - ] + self._build_dir = tempfile.TemporaryDirectory() + for path in i18n._compile_catalogs(self._build_dir.name): + build_data["force_include"][str(path)] = path.name + + def finalize(self, version, build_data, artifact_path): + self._build_dir.cleanup() diff --git a/scripts/i18n.py b/scripts/i18n.py index c8f470d..963f907 100755 --- a/scripts/i18n.py +++ b/scripts/i18n.py @@ -10,7 +10,6 @@ ROOT = Path(__file__).resolve().parent.parent PO_DIR = ROOT / "po" -PACKAGE_DIR = ROOT / "turtle_translations" POT = PO_DIR / "turtle.pot" PROJECT = "turtle-translations" BUGS_ADDRESS = "https://github.com/python/turtle-translations/issues" @@ -115,16 +114,38 @@ def _load_docsdict(path): } -def _compile_catalogs(): +MODULE_FOOTER = """ +# turtle imports this module after defining its classes, so drop entries for +# names this version of turtle does not have. +import turtle + +for _key in list(docsdict): + _obj = turtle + for _attr in _key.split("."): + _obj = getattr(_obj, _attr, None) + if _obj is None: + del docsdict[_key] +""" + + +def _render_module(source, docsdict): + lines = [f"# Generated from {source.name}. Do not edit.", + "", + "docsdict = {" + ] + for key, doc in sorted(docsdict.items()): + lines.append(f" {key!r}: {doc!r},") + lines.append("}") + return "\n".join(lines) + "\n" + MODULE_FOOTER + + +def _compile_catalogs(output_dir=ROOT): + """Write a `turtle_docstringdict_.py` module for each PO file.""" written = [] for path in po_files(): # turtle lowercases the language before importing it! - target = PACKAGE_DIR / f"{path.stem.lower()}.py" - lines = [f"# Generated from {path.name}.", "", "docsdict = {"] - for key, doc in sorted(_load_docsdict(path).items()): - lines.append(f" {key!r}: {doc!r},") - lines.append("}") - target.write_text("\n".join(lines) + "\n", encoding="utf-8") + target = Path(output_dir) / f"turtle_docstringdict_{path.stem.lower()}.py" + target.write_text(_render_module(path, _load_docsdict(path)), encoding="utf-8") written.append(target) return written diff --git a/turtle_translations/__init__.py b/turtle_translations/__init__.py deleted file mode 100644 index 7c02458..0000000 --- a/turtle_translations/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Docstring translations for the Python 'turtle' module.""" - -import pkgutil - - -def available(): - """Return a list of the available languages.""" - return sorted( - info.name for info in pkgutil.iter_modules(__path__) - if not info.name.startswith("_") - ) From 56c2a5b995384c3e699e7e99b34931888c4b5251 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 28 Sep 2026 09:23:00 +0100 Subject: [PATCH 2/5] Apply batched suggestions from code review Co-authored-by: Maciej Olko --- scripts/i18n.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/i18n.py b/scripts/i18n.py index 963f907..2359cae 100755 --- a/scripts/i18n.py +++ b/scripts/i18n.py @@ -116,7 +116,7 @@ def _load_docsdict(path): MODULE_FOOTER = """ # turtle imports this module after defining its classes, so drop entries for -# names this version of turtle does not have. +# names this version of turtle does not have to avoid stdout reports. import turtle for _key in list(docsdict): @@ -129,7 +129,7 @@ def _load_docsdict(path): def _render_module(source, docsdict): - lines = [f"# Generated from {source.name}. Do not edit.", + lines = [f"# Generated from {source.name}.", "", "docsdict = {" ] From 44e788e455e7b422df0eb27859ea12e886cf61a9 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 28 Sep 2026 09:34:13 +0100 Subject: [PATCH 3/5] Add an integration test --- .github/workflows/integration-test.yml | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/integration-test.yml diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..a31ac54 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,77 @@ +name: Integration test + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read + +env: + FORCE_COLOR: 1 + +jobs: + integration: + name: Translate, build & install on Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: [3.13, 3.14, 3.15] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + + - name: Install build dependencies + run: python -m pip install babel hatchling build + + - name: Create a new translation + run: | + python scripts/i18n.py extract + python scripts/i18n.py init eo + + - name: Translate one docstring + run: | + python - <<'PY' + import sys + sys.path.insert(0, "scripts") + import i18n + + path = i18n.PO_DIR / "eo.po" + catalog = i18n.read_catalog(path) + for message in catalog: + if "turtle.Turtle.forward" in message.auto_comments: + message.string = "Movu la testudon antaŭen." + break + i18n.write_catalog(catalog, path) + PY + python scripts/i18n.py stats + + - name: Build and install the wheel + run: | + python -m build --wheel + python -m pip install dist/*.whl + + - name: Check the translated docstring is used + working-directory: ${{ runner.temp }} + run: | + echo "language = eo" > turtle.cfg + cat > check.py <<'PY' + import contextlib + import io + + # turtle prints errors for names it cannot resolve. + with contextlib.redirect_stdout(io.StringIO()) as output: + import turtle + assert output.getvalue() == "" + + assert turtle.Turtle.forward.__doc__ == "Movu la testudon antaŭen." + assert turtle.forward.__doc__ == "Movu la testudon antaŭen." + assert "Move the turtle backward" in turtle.back.__doc__ + PY + python check.py From 2a8706ca518bd6699e5c4c269a90188c6769df34 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 28 Sep 2026 09:36:52 +0100 Subject: [PATCH 4/5] Avoid duplicate runs --- .github/workflows/integration-test.yml | 6 +++++- .github/workflows/lint.yml | 6 +++++- .github/workflows/publish.yml | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index a31ac54..604ab24 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -1,6 +1,10 @@ name: Integration test -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: [main] + pull_request: + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a9058e3..ab191b2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,10 @@ name: Lint -on: [push, pull_request, workflow_dispatch] +on: + push: + branches: [main] + pull_request: + workflow_dispatch: permissions: {} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 58f6bae..8b85569 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,6 +2,7 @@ name: Upload package on: push: + branches: [main] pull_request: release: types: From 0a66a265198ac2d286411087fdd9bb8b34fc3e78 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 28 Sep 2026 09:40:20 +0100 Subject: [PATCH 5/5] Check non-existent entry is dropped from dict --- .github/workflows/integration-test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 604ab24..dfffd28 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -52,6 +52,9 @@ jobs: if "turtle.Turtle.forward" in message.auto_comments: message.string = "Movu la testudon antaŭen." break + # A nonexistent name to check it is dropped at import. + catalog.add("Not a real docstring.", "Ne vera dokumentaĵo.", + auto_comments=["turtle.Turtle.nonexistent"]) i18n.write_catalog(catalog, path) PY python scripts/i18n.py stats @@ -77,5 +80,9 @@ jobs: assert turtle.Turtle.forward.__doc__ == "Movu la testudon antaŭen." assert turtle.forward.__doc__ == "Movu la testudon antaŭen." assert "Move the turtle backward" in turtle.back.__doc__ + + import turtle_docstringdict_eo + assert "Turtle.nonexistent" not in turtle_docstringdict_eo.docsdict + assert not hasattr(turtle.Turtle, "nonexistent") PY python check.py