diff --git a/scripts/check-weblate-internal-contract.sh b/scripts/check-weblate-internal-contract.sh index 9612f6d..c08e2f7 100755 --- a/scripts/check-weblate-internal-contract.sh +++ b/scripts/check-weblate-internal-contract.sh @@ -56,7 +56,7 @@ echo " - WEBLATE_FORMATS (weblate_formats_with_plugin_formats)" echo " - weblate.urls.real_patterns (list accepts URLResolver append)" set +e -uv run --group dev pytest tests/test_weblate_internal_contract.py -v --tb=short -m weblate_contract +uv run --group dev pytest tests/test_weblate_internal_contract.py tests/formats/test_weblate_formats_source.py -v --tb=short -m weblate_contract pytest_status=$? set -e diff --git a/src/boost_weblate/formats/formats_source.py b/src/boost_weblate/formats/formats_source.py new file mode 100644 index 0000000..377f6df --- /dev/null +++ b/src/boost_weblate/formats/formats_source.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Upstream Weblate core format path resolution for ``WEBLATE_FORMATS``.""" + +from __future__ import annotations + +import ast +import importlib.metadata +from pathlib import Path +from typing import Protocol + +import weblate.formats + + +class FormatsSourceResolutionError(Exception): + """Raised when a :class:`FormatsSource` cannot resolve core format paths.""" + + def __init__(self, *, source: str, detail: str) -> None: + self.source = source + self.detail = detail + super().__init__(detail) + + +class FormatsSource(Protocol): + """Strategy for resolving upstream Weblate core ``WEBLATE_FORMATS`` paths.""" + + def core_format_paths(self) -> tuple[str, ...]: + """Return dotted import paths for stock Weblate format handlers.""" + raise NotImplementedError + + +def _weblate_version() -> str: + try: + return importlib.metadata.version("Weblate") + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +def _formats_models_path() -> Path: + return Path(weblate.formats.__file__).resolve().parent / "models.py" + + +def _parse_formatsconf_formats_ast(models_text: str) -> list[str]: + """AST-parse ``FormatsConf.FORMATS`` from upstream ``models.py`` source text.""" + tree = ast.parse(models_text) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == "FormatsConf": + return _formats_assignment_to_strings(node.body) + msg = "Class FormatsConf not found in weblate formats models source" + raise RuntimeError(msg) + + +def _formats_assignment_to_strings(class_body: list[ast.stmt]) -> list[str]: + for node in class_body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "FORMATS": + return _string_tuple_or_list(node.value) + msg = "FORMATS assignment not found on FormatsConf" + raise RuntimeError(msg) + + +def _string_tuple_or_list(node: ast.expr) -> list[str]: + if isinstance(node, (ast.Tuple, ast.List)): + out: list[str] = [] + for elt in node.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + out.append(elt.value) + else: + msg = f"Unexpected literal in FormatsConf.FORMATS: {ast.dump(elt)}" + raise RuntimeError(msg) + return out + msg = f"Unexpected FormatsConf.FORMATS value: {ast.dump(node)}" + raise RuntimeError(msg) + + +class AstFormatsConfSource: + """Resolve core paths by AST-parsing ``FormatsConf.FORMATS`` in ``models.py``. + + Avoids ``import weblate.formats.models`` during settings import, which can + raise ``AppRegistryNotReady``. + """ + + def core_format_paths(self) -> tuple[str, ...]: + models_py = _formats_models_path() + try: + src = models_py.read_text(encoding="utf-8") + except OSError as exc: + raise FormatsSourceResolutionError( + source=str(models_py), + detail=f"could not read weblate formats models source: {exc}", + ) from exc + try: + core = tuple(_parse_formatsconf_formats_ast(src)) + except RuntimeError as exc: + raise FormatsSourceResolutionError( + source=str(models_py), + detail=str(exc), + ) from exc + except (SyntaxError, ValueError) as exc: + raise FormatsSourceResolutionError( + source=str(models_py), + detail=f"could not parse FormatsConf.FORMATS from {models_py}", + ) from exc + if not core: + raise FormatsSourceResolutionError( + source=str(models_py), + detail=f"no format paths parsed from {models_py}", + ) + return core + + +def resolve_core_weblate_formats( + *, + source: FormatsSource | None = None, +) -> tuple[str, ...]: + """Return upstream core format paths via *source* (default: AST parse).""" + resolved = AstFormatsConfSource() if source is None else source + try: + return resolved.core_format_paths() + except FormatsSourceResolutionError as exc: + version = _weblate_version() + msg = ( + f"boost_weblate: could not resolve core WEBLATE_FORMATS " + f"(Weblate {version}): {exc.detail} " + f"(source: {exc.source})" + ) + raise RuntimeError(msg) from exc diff --git a/src/boost_weblate/settings_override.py b/src/boost_weblate/settings_override.py index 38adf3f..366f996 100644 --- a/src/boost_weblate/settings_override.py +++ b/src/boost_weblate/settings_override.py @@ -9,12 +9,16 @@ ``/app/data/settings-override.py`` (hyphen on disk) or keep it on ``PYTHONPATH`` and point your image at the same content. -``WEBLATE_FORMATS`` is built by **reading** ``weblate/formats/models.py`` and -AST-parsing ``FormatsConf.FORMATS``. That avoids ``import weblate.formats.models``, -which pulls in Django ORM classes during settings import and raises -``AppRegistryNotReady``. If upstream restructures ``FormatsConf`` (e.g. renames the -class or moves ``FORMATS`` off a simple tuple assignment), update the AST helpers -below. +``WEBLATE_FORMATS`` is built via +:func:`~boost_weblate.formats.formats_source.resolve_core_weblate_formats` +(default :class:`~boost_weblate.formats.formats_source.AstFormatsConfSource`), +which reads ``weblate/formats/models.py`` and AST-parses ``FormatsConf.FORMATS``. +That avoids ``import weblate.formats.models``, which pulls in Django ORM classes +during settings import and raises ``AppRegistryNotReady``. The pin-bump contract gate +(``tests/formats/test_weblate_formats_source.py``, +``tests/test_weblate_internal_contract.py``) ensures pinned Weblate versions parse; +if upstream restructures ``FormatsConf``, update the AST helpers in +``formats/formats_source.py``. When this file is ``exec``'d into Weblate's settings namespace (Docker), ``INSTALLED_APPS`` is taken from ``globals()`` and extended. Upstream @@ -27,70 +31,21 @@ class or moves ``FORMATS`` off a simple tuple assignment), update the AST helper from __future__ import annotations -import ast import os -from pathlib import Path from typing import Any -# Package ``__init__`` loads the format registry; does not import ``formats.models``. -import weblate.formats - from boost_weblate.formats import registry # noqa: F401 — register plugin formats +from boost_weblate.formats.formats_source import resolve_core_weblate_formats _ENDPOINT_APP_CONFIG = "boost_weblate.endpoint.apps.BoostEndpointConfig" -def _parse_formatsconf_formats_ast(models_text: str) -> list[str]: - tree = ast.parse(models_text) - for node in tree.body: - if isinstance(node, ast.ClassDef) and node.name == "FormatsConf": - return _formats_assignment_to_strings(node.body) - msg = "Class FormatsConf not found in weblate formats models source" - raise RuntimeError(msg) - - -def _formats_assignment_to_strings(class_body: list[ast.stmt]) -> list[str]: - for node in class_body: - if not isinstance(node, ast.Assign): - continue - for target in node.targets: - if isinstance(target, ast.Name) and target.id == "FORMATS": - return _string_tuple_or_list(node.value) - msg = "FORMATS assignment not found on FormatsConf" - raise RuntimeError(msg) - - -def _string_tuple_or_list(node: ast.expr) -> list[str]: - if isinstance(node, (ast.Tuple, ast.List)): - out: list[str] = [] - for elt in node.elts: - if isinstance(elt, ast.Constant) and isinstance(elt.value, str): - out.append(elt.value) - else: - msg = f"Unexpected literal in FormatsConf.FORMATS: {ast.dump(elt)}" - raise RuntimeError(msg) - return out - msg = f"Unexpected FormatsConf.FORMATS value: {ast.dump(node)}" - raise RuntimeError(msg) - - def weblate_formats_with_plugin_formats() -> tuple[str, ...]: """Upstream ``FormatsConf.FORMATS`` paths plus plugin formats from the registry. Avoids importing ``weblate.formats.models``. """ - models_py = Path(weblate.formats.__file__).resolve().parent / "models.py" - src = models_py.read_text(encoding="utf-8") - try: - core = tuple(_parse_formatsconf_formats_ast(src)) - except RuntimeError: - raise - except (SyntaxError, ValueError) as exc: - msg = f"boost_weblate: could not parse FormatsConf.FORMATS from {models_py}" - raise RuntimeError(msg) from exc - if not core: - msg = f"boost_weblate: no format paths parsed from {models_py}" - raise RuntimeError(msg) + core = resolve_core_weblate_formats() plugin_paths = registry.weblate_class_paths() extra = tuple(path for path in plugin_paths if path not in core) return core + extra diff --git a/tests/formats/test_weblate_formats_source.py b/tests/formats/test_weblate_formats_source.py new file mode 100644 index 0000000..df13a4a --- /dev/null +++ b/tests/formats/test_weblate_formats_source.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Tests for upstream Weblate core format path resolution.""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util +import re +from pathlib import Path + +import pytest + +from boost_weblate.formats.formats_source import ( + AstFormatsConfSource, + FormatsSourceResolutionError, + _parse_formatsconf_formats_ast, + resolve_core_weblate_formats, +) + +_CONTRACT_PREFIX_FORMATSCONF = "Weblate contract broken [FormatsConf.FORMATS AST]:" + + +def _load_weblate_formats_models_source() -> str: + spec = importlib.util.find_spec("weblate") + if spec is None or not spec.submodule_search_locations: + msg = f"{_CONTRACT_PREFIX_FORMATSCONF} Weblate is not installed" + raise AssertionError(msg) + path = Path(spec.submodule_search_locations[0]) / "formats" / "models.py" + return path.read_text(encoding="utf-8") + + +def test_ast_formats_source_matches_direct_parse() -> None: + upstream = _load_weblate_formats_models_source() + expected = tuple(_parse_formatsconf_formats_ast(upstream)) + got = AstFormatsConfSource().core_format_paths() + assert got == expected + assert got + + +@pytest.mark.weblate_contract +def test_weblate_contract_formatsconf_ast() -> None: + try: + parsed = _parse_formatsconf_formats_ast(_load_weblate_formats_models_source()) + except (RuntimeError, SyntaxError, ValueError) as exc: + msg = f"{_CONTRACT_PREFIX_FORMATSCONF} {exc}" + raise AssertionError(msg) from exc + if not parsed: + msg = ( + f"{_CONTRACT_PREFIX_FORMATSCONF} " + "FormatsConf.FORMATS parsed to an empty sequence" + ) + raise AssertionError(msg) + + +def test_resolve_core_weblate_formats_error_includes_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + detail = "FORMATS assignment not found on FormatsConf" + source_path = "/tmp/models.py" + + def _fail(self: AstFormatsConfSource) -> tuple[str, ...]: + raise FormatsSourceResolutionError(source=source_path, detail=detail) + + monkeypatch.setattr(AstFormatsConfSource, "core_format_paths", _fail) + + weblate_version = importlib.metadata.version("Weblate") + with pytest.raises(RuntimeError, match=re.escape(weblate_version)) as exc_info: + resolve_core_weblate_formats() + assert detail in str(exc_info.value) + assert source_path in str(exc_info.value) + + +class _FalsyFormatsSource: + """Strategy that is falsy via ``__bool__`` but still callable.""" + + def __bool__(self) -> bool: + return False + + def core_format_paths(self) -> tuple[str, ...]: + return ("custom.formats.FakeFormat",) + + +def test_resolve_core_weblate_formats_honors_explicit_falsy_source() -> None: + assert resolve_core_weblate_formats(source=_FalsyFormatsSource()) == ( + "custom.formats.FakeFormat", + ) diff --git a/tests/test_settings_override.py b/tests/test_settings_override.py index 98726f0..00403e5 100644 --- a/tests/test_settings_override.py +++ b/tests/test_settings_override.py @@ -48,10 +48,8 @@ def _load_weblate_formats_models_source() -> str: def test_settings_override_formats_match_ast_parse_of_upstream() -> None: - from boost_weblate.settings_override import ( - _parse_formatsconf_formats_ast, - weblate_formats_with_plugin_formats, - ) + from boost_weblate.formats.formats_source import _parse_formatsconf_formats_ast + from boost_weblate.settings_override import weblate_formats_with_plugin_formats stock = _parse_formatsconf_formats_ast(_load_weblate_formats_models_source()) got = weblate_formats_with_plugin_formats() @@ -79,10 +77,8 @@ def test_settings_override_source_has_exec_docker_hints() -> None: def test_weblate_formats_includes_upstream_and_plugin_formats() -> None: - from boost_weblate.settings_override import ( - _parse_formatsconf_formats_ast, - weblate_formats_with_plugin_formats, - ) + from boost_weblate.formats.formats_source import _parse_formatsconf_formats_ast + from boost_weblate.settings_override import weblate_formats_with_plugin_formats stock = _parse_formatsconf_formats_ast(_load_weblate_formats_models_source()) paths = list(weblate_formats_with_plugin_formats()) diff --git a/tests/test_weblate_internal_contract.py b/tests/test_weblate_internal_contract.py index db932d8..7e61350 100644 --- a/tests/test_weblate_internal_contract.py +++ b/tests/test_weblate_internal_contract.py @@ -6,9 +6,6 @@ from __future__ import annotations -import importlib.util -from pathlib import Path - import pytest from django.urls import URLResolver @@ -16,40 +13,12 @@ _assert_weblate_url_layout, _boost_endpoint_route, ) -from boost_weblate.settings_override import ( - _parse_formatsconf_formats_ast, - weblate_formats_with_plugin_formats, -) +from boost_weblate.settings_override import weblate_formats_with_plugin_formats -_CONTRACT_PREFIX_FORMATSCONF = "Weblate contract broken [FormatsConf.FORMATS AST]:" _CONTRACT_PREFIX_WEBLATE_FORMATS = "Weblate contract broken [WEBLATE_FORMATS]:" _CONTRACT_PREFIX_REAL_PATTERNS = "Weblate contract broken [weblate.urls.real_patterns]:" -def _load_weblate_formats_models_source() -> str: - spec = importlib.util.find_spec("weblate") - if spec is None or not spec.submodule_search_locations: - msg = f"{_CONTRACT_PREFIX_FORMATSCONF} Weblate is not installed" - raise AssertionError(msg) - path = Path(spec.submodule_search_locations[0]) / "formats" / "models.py" - return path.read_text(encoding="utf-8") - - -@pytest.mark.weblate_contract -def test_weblate_contract_formatsconf_ast() -> None: - try: - parsed = _parse_formatsconf_formats_ast(_load_weblate_formats_models_source()) - except RuntimeError as exc: - msg = f"{_CONTRACT_PREFIX_FORMATSCONF} {exc}" - raise AssertionError(msg) from exc - if not parsed: - msg = ( - f"{_CONTRACT_PREFIX_FORMATSCONF} " - "FormatsConf.FORMATS parsed to an empty sequence" - ) - raise AssertionError(msg) - - @pytest.mark.weblate_contract def test_weblate_contract_weblate_formats_non_empty() -> None: try: