diff --git a/README.md b/README.md index 75d86ac..7da15bb 100644 --- a/README.md +++ b/README.md @@ -154,18 +154,9 @@ When `WEBLATE_URL_PREFIX` is set (e.g. `/weblate`), all paths are prefixed accor Weblate's `urls.py` does **not** auto-discover URLconfs from arbitrary `INSTALLED_APPS` entries. It builds a single `real_patterns` list by hand and only extends it for known built-in apps (legal, SAML, git-export, etc.) via explicit `if "app" in settings.INSTALLED_APPS:` guards — there is no generic plugin scan. -This plugin handles registration in `BoostEndpointConfig.ready()` (`src/boost_weblate/endpoint/apps.py`), which runs once at Django startup and appends to `weblate.urls.real_patterns`: +This plugin handles registration in `BoostEndpointConfig.ready()` (`src/boost_weblate/endpoint/apps.py`), which delegates to `register_boost_endpoint_urls()` in `src/boost_weblate/endpoint/weblate_urls_adapter.py`. That adapter appends to `weblate.urls.real_patterns` after fail-fast layout checks (raises `WeblateUrlLayoutError` if `real_patterns` is missing or Weblate is below the supported version). -```python -wl_urls.real_patterns.append( - path( - "boost-endpoint/", - include(("boost_weblate.endpoint.urls", "boost_endpoint")), - ), -) -``` - -The operation is idempotent (guarded by a `_cppa_boost_weblate_urls_registered` attribute on the module). Routes sit under Weblate's `URL_PREFIX` handling because `real_patterns` is used before the prefix wrapper is applied. +Registration is idempotent via `functools.lru_cache` on the adapter function (not a sentinel on Weblate's module). Routes sit under Weblate's `URL_PREFIX` handling because `real_patterns` is used before the prefix wrapper is applied. ### Request / response for `POST /boost-endpoint/add-or-update/` diff --git a/docs/boost-endpoint-api.md b/docs/boost-endpoint-api.md index cb578c5..9b01d91 100644 --- a/docs/boost-endpoint-api.md +++ b/docs/boost-endpoint-api.md @@ -29,18 +29,9 @@ The Boost Endpoint is the HTTP API surface of this plugin. It provides three rou Adding the app to `INSTALLED_APPS` is required but not sufficient for routes to be active. Weblate's `urls.py` builds its route list by hand (`real_patterns`) and does not auto-discover URLconfs from arbitrary apps. -`BoostEndpointConfig.ready()` (`src/boost_weblate/endpoint/apps.py`) appends to `weblate.urls.real_patterns` at Django startup: - -```python -wl_urls.real_patterns.append( - path( - "boost-endpoint/", - include(("boost_weblate.endpoint.urls", "boost_endpoint")), - ), -) -``` +`BoostEndpointConfig.ready()` (`src/boost_weblate/endpoint/apps.py`) delegates to `register_boost_endpoint_urls()` in `src/boost_weblate/endpoint/weblate_urls_adapter.py`, which appends to `weblate.urls.real_patterns` at Django startup after verifying Weblate's URL layout (raises `WeblateUrlLayoutError` on incompatibility). -This is idempotent — a module-level flag (`_cppa_boost_weblate_urls_registered`) prevents double-registration. The routes inherit Weblate's `URL_PREFIX` handling because `real_patterns` is processed before the prefix wrapper is applied. +Registration is idempotent via `functools.lru_cache` on the adapter. The routes inherit Weblate's `URL_PREFIX` handling because `real_patterns` is processed before the prefix wrapper is applied. For `INSTALLED_APPS` registration, use `settings_override.py` (recommended) or the `WEBLATE_ADD_APPS` Docker environment variable — **not both**. See the main [README](../README.md#weblate_add_apps) for the full comparison. diff --git a/pyproject.toml b/pyproject.toml index 78edd3d..b21f492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ classifiers = [ "Topic :: Software Development :: Localization" ] dependencies = [ + "packaging==26.2", "Weblate[all]==2026.5" ] description = "Standalone Weblate plugin for Boost documentation translation." diff --git a/src/boost_weblate/endpoint/apps.py b/src/boost_weblate/endpoint/apps.py index 07717e1..920872c 100644 --- a/src/boost_weblate/endpoint/apps.py +++ b/src/boost_weblate/endpoint/apps.py @@ -4,58 +4,26 @@ from __future__ import annotations -import logging - from django.apps import AppConfig -from django.urls import include, path - -logger = logging.getLogger(__name__) -_PLUGIN_URLS_ATTR = "_cppa_boost_weblate_urls_registered" +from boost_weblate.endpoint.weblate_urls_adapter import register_boost_endpoint_urls def register_plugin_urls() -> None: - """Append this app's routes to Weblate's pattern list. + """Register Boost endpoint routes with Weblate. - This is the supported plugin path: at process - startup, append a single ``path("boost-endpoint/", ...)`` entry to - ``weblate.urls.real_patterns`` so routes stay under Weblate's ``URL_PREFIX`` - handling. + Delegates to + :func:`~boost_weblate.endpoint.weblate_urls_adapter.register_boost_endpoint_urls`, + which appends a single ``path("boost-endpoint/", ...)`` entry to + ``weblate.urls.real_patterns`` after fail-fast layout checks. Exposed HTTP paths (relative to ``/boost-endpoint/``): ``info/``, ``add-or-update/``, and ``plugin-ping/`` (see ``boost_weblate.endpoint.urls``). - Weblate builds ``urlpatterns`` from module-level ``real_patterns`` (see - ``weblate.urls``). Optional plugins append to ``real_patterns`` before - the ``URL_PREFIX`` wrapper is applied, so mutating that list keeps routes - consistent when a path prefix is configured. + Raises :class:`~boost_weblate.endpoint.weblate_urls_adapter.WeblateUrlLayoutError` + when Weblate's URL module layout is incompatible. """ - try: - import weblate.urls as wl_urls # noqa: PLC0415 - except ModuleNotFoundError as exc: - logger.debug( - "boost_weblate.endpoint: skipping URL registration (import error: %s)", - exc, - ) - return - - if getattr(wl_urls, _PLUGIN_URLS_ATTR, False): - return - - if not hasattr(wl_urls, "real_patterns"): - logger.warning( - "boost_weblate.endpoint: weblate.urls has no real_patterns; " - "URL registration skipped (unexpected Weblate layout)." - ) - return - - wl_urls.real_patterns.append( - path( - "boost-endpoint/", - include(("boost_weblate.endpoint.urls", "boost_endpoint")), - ), - ) - setattr(wl_urls, _PLUGIN_URLS_ATTR, True) + register_boost_endpoint_urls() class BoostEndpointConfig(AppConfig): diff --git a/src/boost_weblate/endpoint/weblate_urls_adapter.py b/src/boost_weblate/endpoint/weblate_urls_adapter.py new file mode 100644 index 0000000..709a5f1 --- /dev/null +++ b/src/boost_weblate/endpoint/weblate_urls_adapter.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Adapter for registering Boost endpoint routes on Weblate's URL pattern list.""" + +from __future__ import annotations + +import importlib.metadata +import logging +from functools import lru_cache +from types import ModuleType + +from django.urls import URLResolver, include, path +from packaging.version import InvalidVersion, Version + +logger = logging.getLogger(__name__) + +_MIN_WEBLATE_VERSION = Version("2026.5") +_BOOST_ENDPOINT_PREFIX = "boost-endpoint/" + + +class WeblateUrlLayoutError(RuntimeError): + """Raised when ``weblate.urls`` lacks the layout this plugin expects.""" + + +def _weblate_version() -> str: + try: + return importlib.metadata.version("Weblate") + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +def _assert_weblate_url_layout(wl_urls: ModuleType) -> None: + """Verify Weblate exposes the ``real_patterns`` list before mutation.""" + version = _weblate_version() + if not hasattr(wl_urls, "real_patterns"): + msg = ( + "weblate.urls.real_patterns is missing; " + f"Weblate {version} URL layout is incompatible with cppa-weblate-plugin" + ) + raise WeblateUrlLayoutError(msg) + if not isinstance(wl_urls.real_patterns, list): + msg = ( + "weblate.urls.real_patterns is not a list; " + f"Weblate {version} URL layout is incompatible with cppa-weblate-plugin" + ) + raise WeblateUrlLayoutError(msg) + if version == "unknown": + msg = ( + "Weblate package version could not be determined; " + "cppa-weblate-plugin cannot verify minimum version compatibility" + ) + raise WeblateUrlLayoutError(msg) + try: + parsed = Version(version) + except InvalidVersion as exc: + msg = ( + f"Weblate version string {version!r} could not be parsed; " + "cppa-weblate-plugin cannot verify minimum version compatibility" + ) + raise WeblateUrlLayoutError(msg) from exc + if parsed < _MIN_WEBLATE_VERSION: + msg = ( + f"Weblate {version} is below the minimum supported version " + f"{_MIN_WEBLATE_VERSION}; cppa-weblate-plugin requires Weblate " + f"{_MIN_WEBLATE_VERSION} or newer" + ) + raise WeblateUrlLayoutError(msg) + + +def _boost_endpoint_route() -> URLResolver: + return path( + _BOOST_ENDPOINT_PREFIX, + include(("boost_weblate.endpoint.urls", "boost_endpoint")), + ) + + +def _route_already_registered(real_patterns: list) -> bool: + return any( + str(getattr(entry, "pattern", "")) == _BOOST_ENDPOINT_PREFIX + for entry in real_patterns + ) + + +@lru_cache(maxsize=1) +def register_boost_endpoint_urls() -> None: + """Append Boost endpoint routes to ``weblate.urls.real_patterns`` once. + + Weblate builds ``urlpatterns`` from module-level ``real_patterns``. Appending + before the ``URL_PREFIX`` wrapper keeps routes under prefix configuration. + + Idempotent via ``lru_cache``; safe to call from ``AppConfig.ready()``. + + Note: if ``weblate.urls`` is not importable at call time, the no-op result + is still cached; retrying after the module becomes importable requires an + explicit ``register_boost_endpoint_urls.cache_clear()`` call. + """ + try: + import weblate.urls as wl_urls # noqa: PLC0415 + except ModuleNotFoundError as exc: + logger.debug( + "boost_weblate.endpoint: skipping URL registration (import error: %s)", + exc, + ) + return + + _assert_weblate_url_layout(wl_urls) + + if _route_already_registered(wl_urls.real_patterns): + return + + wl_urls.real_patterns.append(_boost_endpoint_route()) diff --git a/tests/endpoint/test_apps.py b/tests/endpoint/test_apps.py index d62a576..4f3a37d 100644 --- a/tests/endpoint/test_apps.py +++ b/tests/endpoint/test_apps.py @@ -5,12 +5,36 @@ from __future__ import annotations import builtins -import sys -import types import pytest -from boost_weblate.endpoint.apps import register_plugin_urls +from boost_weblate.endpoint import apps +from boost_weblate.endpoint.weblate_urls_adapter import register_boost_endpoint_urls + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + register_boost_endpoint_urls.cache_clear() + yield + register_boost_endpoint_urls.cache_clear() + + +def test_register_plugin_urls_delegates_to_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called = False + + def fake_register() -> None: + nonlocal called + called = True + + monkeypatch.setattr( + apps, + "register_boost_endpoint_urls", + fake_register, + ) + apps.register_plugin_urls() + assert called is True def test_register_plugin_urls_skips_when_weblate_urls_missing( @@ -24,25 +48,4 @@ def fake_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", fake_import) - # Should not raise; no fake module to inspect. - register_plugin_urls() - - -def test_register_plugin_urls_skips_without_real_patterns( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake = types.ModuleType("weblate.urls") - monkeypatch.setitem(sys.modules, "weblate.urls", fake) - register_plugin_urls() - assert not hasattr(fake, "real_patterns") - - -def test_register_plugin_urls_appends_once(monkeypatch: pytest.MonkeyPatch) -> None: - fake = types.ModuleType("weblate.urls") - fake.real_patterns = [] - monkeypatch.setitem(sys.modules, "weblate.urls", fake) - - register_plugin_urls() - register_plugin_urls() - - assert len(fake.real_patterns) == 1 + apps.register_plugin_urls() diff --git a/tests/endpoint/test_weblate_urls_adapter.py b/tests/endpoint/test_weblate_urls_adapter.py new file mode 100644 index 0000000..1a0d01d --- /dev/null +++ b/tests/endpoint/test_weblate_urls_adapter.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +from __future__ import annotations + +import builtins +import importlib.metadata +import sys +import types + +import pytest +from django.conf import settings +from django.urls import URLResolver + +from boost_weblate.endpoint.weblate_urls_adapter import ( + WeblateUrlLayoutError, + _assert_weblate_url_layout, + _boost_endpoint_route, + _route_already_registered, + _weblate_version, + register_boost_endpoint_urls, +) + + +@pytest.fixture(autouse=True) +def _clear_url_registration_cache() -> None: + register_boost_endpoint_urls.cache_clear() + yield + register_boost_endpoint_urls.cache_clear() + + +def test_weblate_url_layout_error_is_runtime_error() -> None: + assert issubclass(WeblateUrlLayoutError, RuntimeError) + + +def test_assert_layout_raises_when_real_patterns_missing() -> None: + fake = types.ModuleType("weblate.urls") + with pytest.raises(WeblateUrlLayoutError, match="real_patterns"): + _assert_weblate_url_layout(fake) + + +def test_assert_layout_raises_when_real_patterns_not_list() -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = () + with pytest.raises(WeblateUrlLayoutError, match="not a list"): + _assert_weblate_url_layout(fake) + + +def test_assert_layout_raises_when_weblate_version_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = [] + monkeypatch.setattr( + "boost_weblate.endpoint.weblate_urls_adapter._weblate_version", + lambda: "unknown", + ) + with pytest.raises(WeblateUrlLayoutError, match="version"): + _assert_weblate_url_layout(fake) + + +def test_assert_layout_raises_when_weblate_version_too_low( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = [] + monkeypatch.setattr( + "boost_weblate.endpoint.weblate_urls_adapter._weblate_version", + lambda: "2020.1", + ) + with pytest.raises(WeblateUrlLayoutError, match="below the minimum supported"): + _assert_weblate_url_layout(fake) + + +def test_assert_layout_error_includes_weblate_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + monkeypatch.setitem(sys.modules, "weblate.urls", fake) + weblate_version = importlib.metadata.version("Weblate") + with pytest.raises(WeblateUrlLayoutError) as exc_info: + register_boost_endpoint_urls() + message = str(exc_info.value) + assert "real_patterns" in message + assert weblate_version in message + + +def test_weblate_version_unknown_when_package_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_not_found(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", raise_not_found) + assert _weblate_version() == "unknown" + + +def test_boost_endpoint_route_shape() -> None: + route = _boost_endpoint_route() + assert isinstance(route, URLResolver) + assert str(route.pattern) == "boost-endpoint/" + + +def test_route_already_registered_detects_existing() -> None: + patterns: list = [_boost_endpoint_route()] + assert _route_already_registered(patterns) is True + assert _route_already_registered([]) is False + + +def test_register_skips_when_weblate_urls_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_import = builtins.__import__ + + def fake_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if name == "weblate.urls": + raise ModuleNotFoundError("weblate.urls") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + register_boost_endpoint_urls() + + +def test_register_appends_boost_endpoint_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = [] + monkeypatch.setitem(sys.modules, "weblate.urls", fake) + + register_boost_endpoint_urls() + + assert len(fake.real_patterns) == 1 + assert str(fake.real_patterns[0].pattern) == "boost-endpoint/" + + +def test_register_is_idempotent_via_lru_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = [] + monkeypatch.setitem(sys.modules, "weblate.urls", fake) + + register_boost_endpoint_urls() + register_boost_endpoint_urls() + + assert len(fake.real_patterns) == 1 + + +def test_register_skips_duplicate_after_cache_clear( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = [] + monkeypatch.setitem(sys.modules, "weblate.urls", fake) + + register_boost_endpoint_urls() + register_boost_endpoint_urls.cache_clear() + register_boost_endpoint_urls() + + assert len(fake.real_patterns) == 1 + + +@pytest.mark.skipif( + settings.ROOT_URLCONF != "weblate.urls", + reason="requires Weblate ROOT_URLCONF (weblate.urls)", +) +def test_plugin_ping_resolves_after_registration() -> None: + from django.urls import resolve + + register_boost_endpoint_urls() + match = resolve("/boost-endpoint/plugin-ping/") + assert match.url_name == "plugin-ping" + assert match.func.__name__ == "plugin_ping" diff --git a/uv.lock b/uv.lock index 5298a4f..e691db4 100644 --- a/uv.lock +++ b/uv.lock @@ -697,6 +697,7 @@ wheels = [ [[package]] dependencies = [ + {name = "packaging"}, {name = "weblate", extra = ["all"]} ] name = "cppa-weblate-plugin" @@ -730,6 +731,7 @@ tooling = [ provides-extras = ["dev"] requires-dist = [ {name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.14.1"}, + {name = "packaging", specifier = "==26.2"}, {name = "prek", marker = "extra == 'dev'", specifier = "==0.4.4"}, {name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.3"}, {name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.1.0"},