Files
computer-playbook/utils/i18n/extract.py
T
kevinveenbirkenbachandClaude Opus 5 b547f7504b refactor(web-app-docs): read the Sphinx identity from services.yml
services.docs.sphinx is the single place the project, copyright and author are configured. conf.py takes them from the environment, which env.j2 fills inside the container, but two callers run outside it: the host side of make i18n-extract and the unit tests. Both had a copy of the values, which is the duplication this removes.

infinito_docs/identity.py renders services.docs.sphinx with the group variables it references, through the same Jinja environment utils/templating/jinja.py:62 uses: StrictUndefined, so an undefined variable raises instead of rendering empty, and select_autoescape for html and xml, which leaves a from_string template unescaped and satisfies ruff S701.

utils/i18n/extract.py loads that module from the tooling path it already computes for the subprocess PYTHONPATH, without touching sys.path. The two tests import it through the _TOOLING entry they already insert. conf.py is unchanged and still requires the environment: reading the configuration there would turn it back into a default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-16 00:20:29 +02:00

211 lines
6.1 KiB
Python

"""The translatable strings core ships, keyed by the ``msgctxt`` of their source."""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from utils.cache.yaml import load_yaml_any
from utils.i18n.catalog import LOCALE_DIR, read_catalog
from utils.roles.mapping import ROLE_FILE_META_MAIN
CATEGORIES_FILE = Path("meta") / "categories.yml"
MENU_FILE = Path("roles") / "web-app-dashboard" / "vars" / "menu_categories.yml"
LOGOUT_FILE = Path("roles") / "web-app-keycloak" / "files" / "logout_i18n.yml"
DOCS_TOOLING = Path("roles") / "web-app-docs" / "files" / "python"
CATEGORY_FIELDS = ("title", "description")
def _docs_sphinx_identity(root: Path, tooling: Path) -> dict[str, str]:
"""The Sphinx identity the documentation role configures.
Args:
root: repository root.
tooling: directory holding the ``infinito_docs`` package.
Returns:
The rendered ``services.docs.sphinx`` mapping, read through the
package that ships with the role so the values stay in services.yml.
"""
spec = importlib.util.spec_from_file_location(
"_infinito_docs_identity", tooling / "infinito_docs" / "identity.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.docs_sphinx_identity(root)
def role_context(role: str) -> str:
"""Return the ``msgctxt`` of the description of ``role``.
Args:
role: role directory name.
"""
return f"role:{role}:description"
def category_context(path: str, field: str) -> str:
"""Return the ``msgctxt`` of a field of the category at ``path``.
Args:
path: dotted category path, e.g. ``web.app``.
field: ``title`` or ``description``.
"""
return f"category:{path}:{field}"
def menu_context(key: str, field: str) -> str:
"""Return the ``msgctxt`` of a field of the dashboard menu category ``key``.
Args:
key: menu category name as written in ``menu_categories.yml``.
field: ``title`` or ``description``.
"""
return f"menu:{key}:{field}"
def logout_context(key: str) -> str:
"""Return the ``msgctxt`` of the logout panel string ``key``.
Args:
key: key in ``logout_i18n.yml``.
"""
return f"logout:{key}"
def _load(path: Path):
return load_yaml_any(path)
def _text(value) -> str:
return value.strip() if isinstance(value, str) else ""
def role_messages(root: Path) -> list[tuple[str, str]]:
messages = []
roles = root / "roles"
for main in sorted(roles.glob(f"*/{ROLE_FILE_META_MAIN}")):
galaxy_info = (_load(main) or {}).get("galaxy_info") or {}
description = _text(galaxy_info.get("description"))
if description:
role = main.relative_to(roles).parts[0]
messages.append((role_context(role), description))
return messages
def category_messages(root: Path) -> list[tuple[str, str]]:
messages = []
def walk(node: dict, path: list[str]) -> None:
for key, value in node.items():
if not isinstance(value, dict):
continue
dotted = ".".join([*path, key])
for field in CATEGORY_FIELDS:
text = _text(value.get(field))
if text:
messages.append((category_context(dotted, field), text))
walk(value, [*path, key])
walk(_load(root / CATEGORIES_FILE)["roles"], [])
return messages
def menu_messages(root: Path) -> list[tuple[str, str]]:
messages = []
for key, entry in _load(root / MENU_FILE)["portfolio_menu_categories"].items():
messages.append((menu_context(key, "title"), key))
description = _text(entry.get("description"))
if description:
messages.append((menu_context(key, "description"), description))
return messages
def logout_messages(root: Path) -> list[tuple[str, str]]:
return [
(logout_context(key), _text(text))
for key, text in _load(root / LOGOUT_FILE).items()
]
def core_messages(root: Path) -> list[tuple[str, str]]:
"""Return every message of the ``core`` domain.
Args:
root: repository root.
Returns:
``(msgctxt, msgid)`` pairs in source order.
"""
return [
*role_messages(root),
*category_messages(root),
*menu_messages(root),
*logout_messages(root),
]
def _copy_working_tree(root: Path, target: Path) -> None:
listing = subprocess.run(
[
"git",
"-C",
str(root),
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
check=True,
capture_output=True,
).stdout.decode("utf-8")
for relative in filter(None, listing.split("\0")):
source = root / relative
if Path(relative).parts[0] == str(LOCALE_DIR) or not source.is_file():
continue
destination = target / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
def docs_template(root: Path, jobs: int):
"""Return the template of the ``docs`` domain, built from a copy of the working tree.
Args:
root: repository root.
jobs: parallel Sphinx processes.
"""
with tempfile.TemporaryDirectory(prefix="infinito-i18n-docs-") as scratch:
src = Path(scratch) / "src"
pot = Path(scratch) / "docs.pot"
_copy_working_tree(root, src)
tooling = root / DOCS_TOOLING
env = {
**os.environ,
**_docs_sphinx_identity(root, tooling),
"PYTHONPATH": os.pathsep.join([str(tooling), str(root)]),
}
subprocess.run(
[
sys.executable,
"-P",
"-m",
"infinito_docs.i18n",
"--src",
str(src),
"--output",
str(pot),
"--jobs",
str(jobs),
],
check=True,
env=env,
)
return read_catalog(pot)