Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/check-weblate-internal-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
131 changes: 131 additions & 0 deletions src/boost_weblate/formats/formats_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# 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
69 changes: 12 additions & 57 deletions src/boost_weblate/settings_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions tests/formats/test_weblate_formats_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# 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)

Comment thread
whisper67265 marked this conversation as resolved.

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",
)
12 changes: 4 additions & 8 deletions tests/test_settings_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand Down
Loading
Loading