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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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/<format>.py``.
2. Add ``formats/<format>.py`` with a ``<Format>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

Expand Down
2 changes: 1 addition & 1 deletion docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
23 changes: 22 additions & 1 deletion src/boost_weblate/formats/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
20 changes: 12 additions & 8 deletions src/boost_weblate/formats/quickbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,20 +17,25 @@
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:
from translate.storage.base import TranslationStore
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(
Expand Down
142 changes: 142 additions & 0 deletions src/boost_weblate/formats/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# SPDX-FileCopyrightText: 2026 William Jin <AuraMindNest@outlook.com>
#
# 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()
17 changes: 9 additions & 8 deletions src/boost_weblate/settings_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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``.
"""
Expand All @@ -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",
Expand Down
Loading
Loading