diff --git a/README.md b/README.md index 70296ed..9c7528b 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ flowchart TB - **`src/boost_weblate/settings_override.py`** — Docker `exec()` fragment: sets `WEBLATE_FORMATS` and appends `BoostEndpointConfig` to `INSTALLED_APPS`. Copied to `/app/data/settings-override.py` by the Dockerfile. See [WEBLATE_FORMATS configuration](#weblate_formats-configuration) and [WEBLATE_ADD_APPS](#weblate_add_apps). -- **`src/boost_weblate/formats/`** — Weblate-facing **format classes** (subclasses of Weblate's `BaseFormat` family, such as `weblate.formats.convert.ConvertFormat`). `QuickBookFormat` follows the same pattern as built-in convert formats (for example AsciiDoc): it turns a template file into a translation store and, on save, applies translations back using the template plus the store. +- **`src/boost_weblate/formats/`** — Weblate **format classes** and the plugin **format registry**. Each format is a single class (e.g. ``QuickBookFormat``) that subclasses both Weblate's ``ConvertFormat`` and the plugin :class:`~boost_weblate.formats.registry.RegisteredFormat` metadata contract, registered via :class:`~boost_weblate.formats.registry.FormatRegistry`. - **`src/boost_weblate/utils/`** — **Format-specific logic** with no Weblate import cycle: QuickBook parsing, segment extraction, translate-toolkit storage (`QuickBookFile` / `QuickBookUnit`), and reconstruction (`QuickBookTranslator`). New formats should add a sibling module (or package) here. @@ -107,7 +107,14 @@ COPY settings-override.py /app/data/settings-override.py That path is fixed; Weblate does not scan `DATA_DIR` for arbitrary override files. The override file is **not** the same as `WEBLATE_PY_PATH` / `python/customize` (importable customization on `sys.path`); for format registration, use this exec hook unless your image explicitly imports another settings module. See the comments in `settings_override.py` for the full distinction. -**Adding another format:** implement the class under `boost_weblate/formats/`, append its dotted class path in `weblate_formats_with_quickbook()` (or extend the tuple built there), redeploy, and restart Weblate. If upstream restructures `FormatsConf` in `models.py` (e.g. renames the class or moves `FORMATS` off a simple tuple assignment), update the AST helpers in `settings_override.py` accordingly. +**Adding another format:** use the plugin :class:`~boost_weblate.formats.registry.FormatRegistry` (see ``boost_weblate/formats/registry.py``): + +1. Add parse/serialize logic under ``boost_weblate/utils/.py``. +2. Add ``formats/.py`` with a ``Format`` class subclassing both Weblate's ``ConvertFormat`` and :class:`~boost_weblate.formats.registry.RegisteredFormat`, decorated with ``@registry.register``. +3. Add a ``registry.register_entry(...)`` call in ``boost_weblate/formats/__init__.py`` (metadata only — avoids importing ``ConvertFormat`` during settings bootstrap). +4. Add tests under ``tests/formats/`` and ``tests/utils/``, redeploy, and restart Weblate. + +If upstream restructures ``FormatsConf`` in ``models.py`` (e.g. renames the class or moves ``FORMATS`` off a simple tuple assignment), update the AST helpers in ``settings_override.py`` accordingly. ## WEBLATE_ADD_APPS diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 1d58874..5395867 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -77,7 +77,7 @@ Do not duplicate pass-through vars in `environment:`; configure them once in `.e Build-time wiring (no env vars): 1. **`settings_override.py`** is copied to `/app/data/settings-override.py` by the Dockerfile. Weblate's Docker entrypoint `exec()`s this file during settings load. -2. **`WEBLATE_FORMATS`** — the override reads upstream `FormatsConf.FORMATS` via AST parse of `models.py`, appends `boost_weblate.formats.quickbook.QuickBookFormat`, and writes the result back to `WEBLATE_FORMATS`. No env var needed. +2. **`WEBLATE_FORMATS`** — the override reads upstream `FormatsConf.FORMATS` via AST parse of `models.py`, merges paths from the plugin :class:`~boost_weblate.formats.registry.FormatRegistry` (``registry.weblate_class_paths()``), and writes the result back to `WEBLATE_FORMATS`. No env var needed. 3. **`INSTALLED_APPS`** — the override appends `boost_weblate.endpoint.apps.BoostEndpointConfig`. The app's `ready()` hook then registers `/boost-endpoint/` routes on `weblate.urls.real_patterns`. Runtime plugin env vars (set in `.env`, read by `settings_override.py` at boot): diff --git a/src/boost_weblate/formats/__init__.py b/src/boost_weblate/formats/__init__.py index 5f41a51..6e822eb 100644 --- a/src/boost_weblate/formats/__init__.py +++ b/src/boost_weblate/formats/__init__.py @@ -4,4 +4,25 @@ """Weblate translation format handlers for Boost (QuickBook and related).""" -__all__: list[str] = ["QuickBookFormat"] +from __future__ import annotations + +from typing import Any + +from boost_weblate.formats.registry import registry + +registry.register_entry( + format_id="quickbook", + file_patterns=("*.qbk",), + weblate_class="boost_weblate.formats.quickbook.QuickBookFormat", +) + +__all__: list[str] = ["QuickBookFormat", "registry"] + + +def __getattr__(name: str) -> Any: + if name == "QuickBookFormat": + from boost_weblate.formats.quickbook import QuickBookFormat as _quickbook_format + + return _quickbook_format + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/src/boost_weblate/formats/quickbook.py b/src/boost_weblate/formats/quickbook.py index 1f0eaa6..050713e 100644 --- a/src/boost_weblate/formats/quickbook.py +++ b/src/boost_weblate/formats/quickbook.py @@ -2,12 +2,11 @@ # # SPDX-License-Identifier: BSL-1.0 -"""QuickBook file format adapter for upstream Weblate. +"""QuickBook file format for upstream Weblate. -Thin :class:`~weblate.formats.convert.ConvertFormat` subclass that delegates -parsing to :class:`~boost_weblate.utils.quickbook.QuickBookFile` and -reconstruction to :class:`~boost_weblate.utils.quickbook.QuickBookTranslator`. -Same control flow as ``AsciiDocFormat`` in ``weblate.formats.convert``. +:class:`QuickBookFormat` subclasses both Weblate's ``ConvertFormat`` and the plugin +:class:`~boost_weblate.formats.registry.RegisteredFormat` registry contract. Parsing and +reconstruction delegate to :mod:`boost_weblate.utils.quickbook`. """ from __future__ import annotations @@ -18,6 +17,7 @@ from weblate.formats.convert import ConvertFormat from weblate.formats.helpers import NamedBytesIO +from boost_weblate.formats.registry import RegisteredFormat, registry from boost_weblate.utils.quickbook import QuickBookFile, QuickBookTranslator if TYPE_CHECKING: @@ -25,13 +25,17 @@ from weblate.formats.base import TranslationFormat -class QuickBookFormat(ConvertFormat): +@registry.register +class QuickBookFormat(ConvertFormat, RegisteredFormat): """QuickBook (.qbk) documentation file format.""" + format_id = "quickbook" + file_patterns = ("*.qbk",) + weblate_class = "boost_weblate.formats.quickbook.QuickBookFormat" + # Translators: File format name name = gettext_lazy("QuickBook file") - autoload = ("*.qbk",) - format_id = "quickbook" + autoload = file_patterns monolingual = True def convertfile( diff --git a/src/boost_weblate/formats/registry.py b/src/boost_weblate/formats/registry.py new file mode 100644 index 0000000..359ae56 --- /dev/null +++ b/src/boost_weblate/formats/registry.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: 2026 William Jin +# +# SPDX-License-Identifier: BSL-1.0 + +"""Declarative registry for plugin translation format handlers.""" + +from __future__ import annotations + +import fnmatch +from abc import ABC +from typing import ClassVar + + +class RegisteredFormat(ABC): + """Plugin format metadata registered in :class:`FormatRegistry`. + + Subclasses declare ``format_id``, ``file_patterns``, and ``weblate_class``. + Parsing and serialization live in the Weblate ``ConvertFormat`` adapter + (``convertfile`` / ``save_content``). + """ + + format_id: ClassVar[str] + file_patterns: ClassVar[tuple[str, ...]] + weblate_class: ClassVar[str] + + +class FormatRegistry: + """Collects :class:`RegisteredFormat` specs for discovery and dispatch.""" + + def __init__(self) -> None: + self._formats: dict[str, type[RegisteredFormat]] = {} + + def register(self, fmt: type[RegisteredFormat]) -> type[RegisteredFormat]: + """Register a format class; usable as a class decorator.""" + self._validate(fmt) + existing = self._formats.get(fmt.format_id) + if existing is fmt: + return fmt + if existing is not None and not self._is_metadata_entry(existing): + return existing + self._formats[fmt.format_id] = fmt + return fmt + + def register_entry( + self, + *, + format_id: str, + file_patterns: tuple[str, ...], + weblate_class: str, + ) -> None: + """Register format metadata without importing the Weblate adapter class.""" + if not format_id: + msg = "format_id is required" + raise ValueError(msg) + self._validate_file_patterns(file_patterns) + if not weblate_class: + msg = "weblate_class is required" + raise ValueError(msg) + existing = self._formats.get(format_id) + if existing is not None and not self._is_metadata_entry(existing): + return + entry = type( + f"_{format_id.title()}FormatEntry", + (RegisteredFormat,), + { + "format_id": format_id, + "file_patterns": file_patterns, + "weblate_class": weblate_class, + }, + ) + self._formats[format_id] = entry + + def registered(self) -> tuple[type[RegisteredFormat], ...]: + """Return registered format specs in registration order.""" + return tuple(self._formats.values()) + + def weblate_class_paths(self) -> tuple[str, ...]: + """Dotted import paths for Weblate ``WEBLATE_FORMATS`` registration.""" + return tuple(fmt.weblate_class for fmt in self._formats.values()) + + def get_by_id(self, format_id: str) -> type[RegisteredFormat] | None: + """Return the spec for *format_id*, or ``None`` if not registered.""" + return self._formats.get(format_id) + + def match_filename(self, filename: str) -> type[RegisteredFormat] | None: + """Return the first spec whose :attr:`~RegisteredFormat.file_patterns` matches. + + Matching is against the basename of *filename*. + """ + basename = filename.rsplit("/", maxsplit=1)[-1] + for fmt in self._formats.values(): + if any(fnmatch.fnmatch(basename, pattern) for pattern in fmt.file_patterns): + return fmt + return None + + def extension_map(self) -> dict[str, str]: + """Map extensions (e.g. ``\".qbk\"``) to :attr:`~RegisteredFormat.format_id`.""" + result: dict[str, str] = {} + for fmt in self._formats.values(): + for pattern in fmt.file_patterns: + if pattern.startswith("*.") and len(pattern) > 2: + ext = "." + pattern[2:].lower() + result[ext] = fmt.format_id + return result + + def clear(self) -> None: + """Remove all registrations (intended for tests).""" + self._formats.clear() + + @staticmethod + def _is_metadata_entry(fmt: type[RegisteredFormat]) -> bool: + return fmt.__name__.endswith("FormatEntry") + + @staticmethod + def _validate_file_patterns( + patterns: object, + *, + name: str = "file_patterns", + ) -> None: + if isinstance(patterns, str) or not isinstance(patterns, (list, tuple)): + kind = type(patterns).__name__ + msg = f"{name} must be a list or tuple of glob patterns, not {kind}" + raise ValueError(msg) + if not patterns: + msg = f"{name} must be non-empty" + raise ValueError(msg) + + @staticmethod + def _validate(fmt: type[RegisteredFormat]) -> None: + if not getattr(fmt, "format_id", ""): + msg = f"{fmt.__name__}: format_id is required" + raise ValueError(msg) + patterns = getattr(fmt, "file_patterns", ()) + FormatRegistry._validate_file_patterns( + patterns, name=f"{fmt.__name__}: file_patterns" + ) + if not getattr(fmt, "weblate_class", ""): + msg = f"{fmt.__name__}: weblate_class is required" + raise ValueError(msg) + + +registry = FormatRegistry() diff --git a/src/boost_weblate/settings_override.py b/src/boost_weblate/settings_override.py index 75c0359..c3011ad 100644 --- a/src/boost_weblate/settings_override.py +++ b/src/boost_weblate/settings_override.py @@ -32,10 +32,11 @@ class or moves ``FORMATS`` off a simple tuple assignment), update the AST helper from pathlib import Path from typing import Any -# Package ``__init__`` is empty; does not import ``formats.models``. +# Package ``__init__`` loads the format registry; does not import ``formats.models``. import weblate.formats -_QUICKBOOK_FORMAT = "boost_weblate.formats.quickbook.QuickBookFormat" +from boost_weblate.formats import registry # noqa: F401 — register plugin formats + _ENDPOINT_APP_CONFIG = "boost_weblate.endpoint.apps.BoostEndpointConfig" @@ -73,8 +74,8 @@ def _string_tuple_or_list(node: ast.expr) -> list[str]: raise RuntimeError(msg) -def weblate_formats_with_quickbook() -> tuple[str, ...]: - """Upstream ``FormatsConf.FORMATS`` paths plus QuickBook. +def weblate_formats_with_plugin_formats() -> tuple[str, ...]: + """Upstream ``FormatsConf.FORMATS`` paths plus plugin formats from the registry. Avoids importing ``weblate.formats.models``. """ @@ -90,12 +91,12 @@ def weblate_formats_with_quickbook() -> tuple[str, ...]: if not core: msg = f"boost_weblate: no format paths parsed from {models_py}" raise RuntimeError(msg) - if _QUICKBOOK_FORMAT in core: - return core - return core + (_QUICKBOOK_FORMAT,) + plugin_paths = registry.weblate_class_paths() + extra = tuple(path for path in plugin_paths if path not in core) + return core + extra -WEBLATE_FORMATS = weblate_formats_with_quickbook() +WEBLATE_FORMATS = weblate_formats_with_plugin_formats() _DEFAULT_BOOST_ENDPOINT_THROTTLE_RATES = { "info": "60/minute", diff --git a/tests/formats/test_registry.py b/tests/formats/test_registry.py new file mode 100644 index 0000000..d5842ef --- /dev/null +++ b/tests/formats/test_registry.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2026 William Jin +# +# SPDX-License-Identifier: BSL-1.0 + +"""Tests for :mod:`boost_weblate.formats.registry`.""" + +from __future__ import annotations + +from typing import ClassVar + +import pytest + +from boost_weblate.formats.quickbook import QuickBookFormat +from boost_weblate.formats.registry import FormatRegistry, RegisteredFormat, registry + +_QBK_WEBLATE_CLASS = "boost_weblate.formats.quickbook.QuickBookFormat" + + +@pytest.fixture +def isolated_registry() -> FormatRegistry: + """Fresh registry with QuickBook pre-registered (mirrors production bootstrap).""" + reg = FormatRegistry() + reg.register(QuickBookFormat) + return reg + + +class MockFormatSpec(RegisteredFormat): + """Minimal format spec for registry tests.""" + + format_id = "mockformat" + file_patterns = ("*.mock",) + weblate_class = "boost_weblate.formats.mock.MockFormat" + + +class DuplicateMockFormatSpec(RegisteredFormat): + """Second class claiming the same format_id (should not replace the first).""" + + format_id = "mockformat" + file_patterns = ("*.mock2",) + weblate_class = "boost_weblate.formats.mock.MockFormat2" + + +def test_registry_register_decorator() -> None: + reg = FormatRegistry() + + @reg.register + class DecoratedSpec(RegisteredFormat): + format_id = "decorated" + file_patterns = ("*.dec",) + weblate_class = "example.DecoratedFormat" + + assert reg.get_by_id("decorated") is DecoratedSpec + + +def test_registry_includes_quickbook(isolated_registry: FormatRegistry) -> None: + ids = {fmt.format_id for fmt in isolated_registry.registered()} + assert "quickbook" in ids + + +def test_weblate_class_paths(isolated_registry: FormatRegistry) -> None: + paths = isolated_registry.weblate_class_paths() + assert _QBK_WEBLATE_CLASS in paths + + +def test_match_filename_quickbook(isolated_registry: FormatRegistry) -> None: + matched = isolated_registry.match_filename("docs/chapter.qbk") + assert matched is QuickBookFormat + + +def test_extension_map_quickbook(isolated_registry: FormatRegistry) -> None: + assert isolated_registry.extension_map()[".qbk"] == "quickbook" + + +def test_register_mock_format(isolated_registry: FormatRegistry) -> None: + isolated_registry.register(MockFormatSpec) + assert isolated_registry.get_by_id("mockformat") is MockFormatSpec + assert isolated_registry.match_filename("file.mock") is MockFormatSpec + assert isolated_registry.extension_map()[".mock"] == "mockformat" + paths = isolated_registry.weblate_class_paths() + assert "boost_weblate.formats.mock.MockFormat" in paths + + +def test_duplicate_register_is_idempotent(isolated_registry: FormatRegistry) -> None: + isolated_registry.register(MockFormatSpec) + isolated_registry.register(MockFormatSpec) + assert isolated_registry.get_by_id("mockformat") is MockFormatSpec + assert isolated_registry.registered().count(MockFormatSpec) == 1 + + +def test_duplicate_format_id_keeps_first(isolated_registry: FormatRegistry) -> None: + isolated_registry.register(MockFormatSpec) + isolated_registry.register(DuplicateMockFormatSpec) + assert isolated_registry.get_by_id("mockformat") is MockFormatSpec + + +def test_get_by_id_missing(isolated_registry: FormatRegistry) -> None: + assert isolated_registry.get_by_id("nonexistent") is None + + +def test_register_validation_missing_format_id() -> None: + reg = FormatRegistry() + + class BadSpec(RegisteredFormat): + format_id: ClassVar[str] = "" + file_patterns = ("*.x",) + weblate_class = "example.Bad" + + with pytest.raises(ValueError, match="format_id"): + reg.register(BadSpec) + + +def test_register_entry_bootstrap() -> None: + reg = FormatRegistry() + reg.register_entry( + format_id="quickbook", + file_patterns=("*.qbk",), + weblate_class=_QBK_WEBLATE_CLASS, + ) + assert reg.weblate_class_paths() == (_QBK_WEBLATE_CLASS,) + assert reg.extension_map()[".qbk"] == "quickbook" + + +def test_register_entry_replaced_by_class() -> None: + reg = FormatRegistry() + reg.register_entry( + format_id="quickbook", + file_patterns=("*.qbk",), + weblate_class=_QBK_WEBLATE_CLASS, + ) + reg.register(QuickBookFormat) + assert reg.get_by_id("quickbook") is QuickBookFormat + + +def test_module_registry_has_quickbook_entry() -> None: + assert registry.get_by_id("quickbook") is not None + assert registry.extension_map()[".qbk"] == "quickbook" + assert _QBK_WEBLATE_CLASS in registry.weblate_class_paths() + + +def test_module_registry_has_quickbook_class() -> None: + from boost_weblate.formats.quickbook import QuickBookFormat as _qb + + assert registry.get_by_id("quickbook") is _qb diff --git a/tests/test_settings_override.py b/tests/test_settings_override.py index 0a52030..4f7d997 100644 --- a/tests/test_settings_override.py +++ b/tests/test_settings_override.py @@ -12,7 +12,11 @@ import pytest -_QBK = "boost_weblate.formats.quickbook.QuickBookFormat" +from boost_weblate.formats import registry + + +def _plugin_weblate_paths() -> tuple[str, ...]: + return registry.weblate_class_paths() def _load_weblate_formats_models_source() -> str: @@ -27,21 +31,22 @@ 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_quickbook, + weblate_formats_with_plugin_formats, ) stock = _parse_formatsconf_formats_ast(_load_weblate_formats_models_source()) - got = weblate_formats_with_quickbook() + got = weblate_formats_with_plugin_formats() + plugin_paths = _plugin_weblate_paths() assert got[: len(stock)] == tuple(stock) - assert got[len(stock)] == _QBK - assert len(got) == len(stock) + 1 + assert got[len(stock) :] == plugin_paths + assert len(got) == len(stock) + len(plugin_paths) def test_settings_override_module_defines_weblate_formats() -> None: import boost_weblate.settings_override as so assert isinstance(so.WEBLATE_FORMATS, tuple) - assert so.WEBLATE_FORMATS == so.weblate_formats_with_quickbook() + assert so.WEBLATE_FORMATS == so.weblate_formats_with_plugin_formats() def test_settings_override_source_has_exec_docker_hints() -> None: @@ -54,14 +59,20 @@ def test_settings_override_source_has_exec_docker_hints() -> None: assert "AppRegistryNotReady" in text or "formats.models" in text -def test_weblate_formats_includes_upstream_and_quickbook() -> None: - from boost_weblate.settings_override import weblate_formats_with_quickbook +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, + ) - paths = list(weblate_formats_with_quickbook()) - assert len(paths) >= 40 + stock = _parse_formatsconf_formats_ast(_load_weblate_formats_models_source()) + paths = list(weblate_formats_with_plugin_formats()) + plugin_paths = _plugin_weblate_paths() + assert len(paths) >= len(stock) assert "weblate.formats.ttkit.PoFormat" in paths assert "weblate.formats.ttkit.TBXFormat" in paths - assert paths.count(_QBK) == 1 + for plugin_path in plugin_paths: + assert paths.count(plugin_path) == 1 def test_merge_boost_endpoint_throttle_rates_preserves_upstream() -> None: