"""AST-based locale-key discovery for sites the regex scanner misses.
The regex scanner under :class:`locales.manager.LocaleManager`
captures `tr("…")` and `t("…")` literal call sites. Two surfaces slip
past that contract:
* Programmatic errors that pass a translation key to an exception
constructor through a ``message_key=`` / ``translation_key=`` kwarg rather than a
:func:`tr` call (for example
``WizardValidationError("wizard.errors.select_unknown")``).
* f-string call sites whose JoinedStr starts with a literal
dot-notation prefix matching the translation-key shape (for example
``tr(f"cli.registry.metrics.{key}")``) — the regex sees the prefix
but cannot tell what follows. The scanner emits a
``<prefix>.*`` marker that the parity check treats as a namespace
declaration rather than a single key.
* Bounded locale-key registries exposed as module constants whose names
end in ``_LOCALE_KEY`` or ``_LOCALE_KEYS``. These constants centralize
key selection for application policies that return translation keys
to a later caller instead of calling :func:`tr` locally.
Both findings feed into
:meth:`locales.manager.LocaleManager.get_codebase_keys` so the
parity audit covers programmatic emissions and dynamic namespaces.
"""
from __future__ import annotations
import ast
import re
from collections.abc import Iterator
from pathlib import Path
from ..core.logging import get_logger
_log = get_logger(__name__)
_KEY_PATTERN_PREFIX_MIN_PARTS = 2
"""A discovered f-string key prefix must carry at least two dotted
segments before the dynamic tail (e.g. ``cli.registry.metrics``)."""
_KEY_LITERAL_RE = re.compile(r"^\w+(?:\.\w+)+$", re.UNICODE)
"""A literal that qualifies as a translation-key prefix: word chars and
dots only, at least two dotted segments, no whitespace, slashes,
operators, or punctuation."""
_DYNAMIC_TRANSLATION_ROOTS = frozenset(
{
"application",
"cli",
"errors",
"profile",
"sheets",
"topic",
"wizard",
},
)
"""Top-level roots that can legitimately identify dynamic i18n namespaces.
Documented dynamic-dispatch survivors
--------------------------------------
The following f-string patterns in :mod:`application.wizard._catalogue`
produce dynamic translation keys. They are bounded (not open-ended) because
the tail is always an enum member value or a flow-registered question ID —
the set of runtime keys is fully enumerable from the domain model. They are
intentional survivors of the static-key constraint and are covered here by
the ``"wizard"`` root entry:
* ``tr(f"wizard.setup.{suffix}.{qid}.prompt")`` — ``suffix`` is drawn from
the wizard flow's registered section ID (e.g. ``"taxpayer-type"``,
``"obligations"``, ``"residence"``); ``qid`` is a flow-registered question
ID. Every concrete key exists in all locale files.
* ``tr(f"wizard.setup.taxpayer-type.entity-type.choices.{member.value...}.label")``
and equivalent patterns for ``LegalEntityForm``, ``IrpfIncomeCategory``,
``IrpfEstimationRegime``, ``IrpfSpecialRegime``, ``FiscalResidency``,
``CCAA``, and ``SUPPORTED_OUTPUT_LANGUAGES`` — the tail segment is an enum
member value (snake_case with underscores replaced by hyphens). The full
key space is bounded by the enum definition.
These patterns are picked up by :func:`_extract_fstring_prefixes` and emitted
as ``wizard.setup.*`` namespace markers, which the parity check validates
against concrete locale entries. No additional static registration is needed.
"""
def _is_dotted_literal(value: str) -> bool:
"""Return True when ``value`` matches the dot-notation key shape."""
return bool(_KEY_LITERAL_RE.match(value))
def _is_dynamic_translation_prefix(prefix: str) -> bool:
"""Return True when a dynamic dotted prefix belongs to the i18n catalogue."""
root = prefix.split(".", 1)[0]
return root in _DYNAMIC_TRANSLATION_ROOTS
def _extract_error_constructor_keys(tree: ast.AST) -> set[str]:
"""Find translation keys declared anywhere in the module.
Collects positional translation keys passed to classes whose name
ends with ``Error``/``Exception``, ``message_key=``/
``translation_key=`` / ``translated_message=`` dotted-literal kwargs on any callee
(exception constructors, ``ErrorCode`` registry rows,
``WizardCheckFinding`` verifier findings), direct
``tr("dotted.key")``/``t("dotted.key")`` calls, ``build_entry``
portal-catalogue keys, and dotted-literal defaults for kw-only
``translated_message``/``message_key``/``translation_key`` parameters.
"""
findings: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
_collect_kwonly_default_keys(node, findings)
elif isinstance(node, ast.Call):
_collect_call_site_keys(node, findings)
return findings
def _extract_locale_constant_keys(tree: ast.AST) -> set[str]:
"""Find dotted locale keys declared in explicit locale-key constants."""
findings: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
if any(_declares_locale_key_constant(target) for target in node.targets):
_collect_dotted_literals(node.value, findings)
elif isinstance(node, ast.AnnAssign) and _declares_locale_key_constant(node.target):
_collect_dotted_literals(node.value, findings)
return findings
def _declares_locale_key_constant(target: ast.expr) -> bool:
"""Return True when ``target`` names an explicit locale-key registry."""
if not isinstance(target, ast.Name):
return False
return target.id.endswith(("_LOCALE_KEY", "_LOCALE_KEYS"))
def _collect_dotted_literals(node: ast.expr | None, findings: set[str]) -> None:
"""Collect dotted string literals nested under ``node``."""
if node is None:
return
value = _dotted_literal_value(node)
if value is not None:
findings.add(value)
return
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.expr):
_collect_dotted_literals(child, findings)
def _collect_kwonly_default_keys(node: ast.FunctionDef, findings: set[str]) -> None:
"""Pick up dotted-literal defaults for translation-key kwonly args."""
for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults, strict=False):
if default is None or arg.arg not in {"translated_message", "message_key", "translation_key"}:
continue
value = _dotted_literal_value(default)
if value is not None:
findings.add(value)
def _collect_call_site_keys(node: ast.Call, findings: set[str]) -> None:
"""Pick up translation keys from call sites across multiple call patterns.
Handles ``tr(...)`` / ``t(...)`` direct calls, ``*Error``/``*Exception``
constructor translation keys, ``build_entry(...)`` portal-catalogue
translation keys, and ``message_key=`` / ``translation_key=`` / ``translated_message=``
dotted-literal kwargs on any callee.
The translation-key kwargs (``message_key=`` / ``translation_key=`` / ``translated_message=``)
are collected callee-agnostically: any call that names one of those
kwargs with a dotted-literal value declares a live operator-facing
translation key. This covers the ``ErrorCode(message_key=...)``
registry rows and ``WizardCheckFinding(message_key=...)`` verifier
findings, neither of which carries an ``*Error`` callee name.
"""
name = _callee_name(node.func)
if name is None:
return
_collect_translation_key_kwargs(node, findings)
if name in {"tr", "t"}:
_add_first_dotted_arg(node, findings)
return
if name == "build_entry":
_collect_build_entry_keys(node, findings)
return
if name.endswith("Error") or name.endswith("Exception"):
_add_first_dotted_arg(node, findings)
def _collect_translation_key_kwargs(node: ast.Call, findings: set[str]) -> None:
"""Collect translation-key dotted-literal kwargs.
The kwarg name alone identifies a translation key, so this is
callee-agnostic: it covers exception constructors,
``super().__init__(...)`` delegations, ``ErrorCode(...)`` registry
declarations, and ``WizardCheckFinding(...)`` verifier findings
alike.
"""
for kw in node.keywords:
if kw.arg not in {"message_key", "translation_key", "translated_message"}:
continue
value = _dotted_literal_value(kw.value)
if value is not None:
findings.add(value)
def _collect_build_entry_keys(node: ast.Call, findings: set[str]) -> None:
"""Pick up portal-catalogue translation keys passed to ``build_entry``.
:mod:`domain.portals._entries` modules construct each portal entry
through :func:`domain.portals._entries._common.build_entry`, passing the
multilingual ``label`` and
``purpose`` keys (and an optional ``notes`` tuple of keys) as keyword
arguments rather than through a ``tr(...)`` call. The regex scanner
and the ``tr``/``t`` call-site path both miss them, so resolve those
keyword arguments explicitly here.
"""
for kw in node.keywords:
if kw.arg in {"label", "purpose"}:
value = _dotted_literal_value(kw.value)
if value is not None:
findings.add(value)
elif kw.arg == "notes" and isinstance(kw.value, ast.Tuple | ast.List):
for element in kw.value.elts:
element_value = _dotted_literal_value(element)
if element_value is not None:
findings.add(element_value)
def _callee_name(callee: ast.expr) -> str | None:
if isinstance(callee, ast.Name):
return callee.id
if isinstance(callee, ast.Attribute):
return callee.attr
return None
def _dotted_literal_value(node: ast.expr | None) -> str | None:
"""Return the dotted-literal key string ``node`` carries, else ``None``.
Returns the resolved ``str`` directly so callers obtain a typed
value without a separate ``node.value`` access — ``ast.Constant.value``
is a broad ``str | bytes | int | ...`` union the type system cannot
narrow through a predicate. The runtime check is unchanged: the node
must be a Constant, its value must be a string, and the string must
match the dotted-literal shape.
"""
if isinstance(node, ast.Constant) and isinstance(node.value, str) and _is_dotted_literal(node.value):
return node.value
return None
def _add_first_dotted_arg(node: ast.Call, findings: set[str]) -> None:
if not node.args:
return
value = _dotted_literal_value(node.args[0])
if value is not None:
findings.add(value)
_KEY_PREFIX_RE = re.compile(r"^\w+(?:\.\w+)*\.$", re.UNICODE)
"""An f-string literal head qualifies as a key prefix when it ends in a
dot and carries at least one word segment before it (e.g. ``topic.``,
``cli.registry.metrics.``)."""
def _extract_fstring_prefixes(tree: ast.AST) -> set[str]:
"""Walk f-string literals and emit ``<prefix>.*`` namespace markers.
Walks every f-string literal whose leading segment matches the
dotted-key shape and emits ``<prefix>.*`` namespace markers.
Covers both inline call sites (``tr(f"cli.registry.metrics.{x}")``)
and the assignment form (``key = f"wizard.errors.{reason}"``)
that the runtime then passes to a downstream call.
The head literal must end in a dot — that's the explicit
key-segment marker. ``f"topic.{slug}.title"`` qualifies because
the head ``topic.`` ends in a dot; ``f"plain text {value}"``
does not.
"""
findings: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.JoinedStr):
continue
if not node.values:
continue
head = node.values[0]
if not isinstance(head, ast.Constant) or not isinstance(head.value, str):
continue
if not _KEY_PREFIX_RE.match(head.value):
continue
prefix = head.value.rstrip(".")
if not _is_dynamic_translation_prefix(prefix):
continue
findings.add(f"{prefix}.*")
return findings
def _extract_concat_prefixes(tree: ast.AST) -> set[str]:
"""Walk string-concatenation call sites and emit literal-prefix ``.*`` markers.
Walks ``tr(<literal> + <expr>)`` and ``t(<literal> + <expr>)``
concatenations and emits the literal-prefix ``.*`` marker.
Matches the dynamic-key pattern ``tr("cli.registry.metrics." + key)``
where the literal carries the registered key prefix.
"""
findings: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _callee_name(node.func) not in {"tr", "t"}:
continue
for argument in node.args:
prefix = _concat_prefix_marker(argument)
if prefix is not None:
findings.add(prefix)
return findings
def _concat_prefix_marker(argument: ast.expr) -> str | None:
"""Return the ``<prefix>.*`` marker for a ``"<literal>" + <expr>`` arg, or None."""
if not isinstance(argument, ast.BinOp) or not isinstance(argument.op, ast.Add):
return None
left = argument.left
if not isinstance(left, ast.Constant) or not isinstance(left.value, str):
return None
literal = left.value.rstrip(".")
if not _is_dotted_literal(literal):
return None
if len(literal.split(".")) < _KEY_PATTERN_PREFIX_MIN_PARTS:
return None
if not _is_dynamic_translation_prefix(literal):
return None
return f"{literal}.*"
def _iter_parseable_python_modules(root: Path) -> Iterator[ast.Module]:
"""Yield parseable Python ASTs under ``root`` for locale discovery."""
for module in root.rglob("*.py"):
if module.name in {"test_parity.py", "manager.py", "_ast_scanner.py"}:
continue
if module.name.startswith("test_") or module.name.startswith("_test_") or "/tests/" in module.as_posix():
continue
try:
source = module.read_text(encoding="utf-8", errors="ignore")
except OSError as exc:
_log.debug("locale ast scan: skipping %s (%s)", module, exc)
continue
try:
yield ast.parse(source, filename=str(module))
except SyntaxError as exc:
_log.debug("locale ast scan: parse failure %s (%s)", module, exc)
[docs]
def scan_source_tree(root: Path) -> set[str]:
"""Walk ``root`` for `.py` files and emit concrete dotted locale keys.
Concrete keys are literal translation keys passed to error
constructors (positional first argument or ``message_key=`` kwarg).
Dynamic namespaces (f-string and concatenation patterns) are
returned by :func:`scan_namespace_markers` and routed through a
separate parity check that asserts at least one concrete locale
entry exists under each declared namespace prefix.
"""
findings: set[str] = set()
for tree in _iter_parseable_python_modules(root):
findings.update(_extract_error_constructor_keys(tree))
findings.update(_extract_locale_constant_keys(tree))
return findings
[docs]
def scan_namespace_markers(root: Path) -> set[str]:
"""Walk ``root`` for `.py` files and emit dynamic-namespace markers.
A namespace marker is a ``<prefix>.*`` string identifying a
family of keys whose tail is computed at runtime (f-string
interpolation or string concatenation). Each marker passes the
parity check when at least one concrete locale key starts with
its prefix.
"""
findings: set[str] = set()
for tree in _iter_parseable_python_modules(root):
findings.update(_extract_fstring_prefixes(tree))
findings.update(_extract_concat_prefixes(tree))
return findings
__all__ = ["scan_namespace_markers", "scan_source_tree"]