Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ GitHub Actions and CI/CD helpers for this repository (see [`.github/`](../.githu
| [`workflows/ci-package.yml`](workflows/ci-package.yml) | Build and package checks |
| [`workflows/ci-dependencies.yml`](workflows/ci-dependencies.yml) | Dependency and license audit |
| [`workflows/ci-weblate-pin.yml`](workflows/ci-weblate-pin.yml) | **Weblate version sync** — callable from CI; runs [`scripts/check-weblate-pin-sync.sh`](../scripts/check-weblate-pin-sync.sh) so `pyproject.toml` and `Dockerfile.weblate-plugin` pins match |
| [`workflows/weblate-pin-bump.yml`](workflows/weblate-pin-bump.yml) | Scheduled Weblate pin bump (PyPI + Docker + `uv.lock`) |
| [`workflows/weblate-pin-bump.yml`](workflows/weblate-pin-bump.yml) | Scheduled Weblate pin bump (PyPI + Docker + `uv.lock`); runs **upstream contract check** ([`scripts/check-weblate-internal-contract.sh`](../scripts/check-weblate-internal-contract.sh) `--latest`) before bump/PR |
Comment thread
whisper67265 marked this conversation as resolved.
| [`workflows/ci-plugin-smoke.yml`](workflows/ci-plugin-smoke.yml) | Plugin smoke (Docker stack) |
| [`workflows/ci-plugin-functional.yml`](workflows/ci-plugin-functional.yml) | Plugin functional tests |
| [`workflows/ci-plugin-auth.yml`](workflows/ci-plugin-auth.yml) | Plugin auth tests |
Expand Down Expand Up @@ -118,7 +118,7 @@ Weblate is **not** bumped by Dependabot. A single logical release is pinned in t

| Location | Example | Format |
|----------|---------|--------|
| [`pyproject.toml`](../pyproject.toml) | `Weblate[all]==2026.5` | PyPI calver |
| [`pyproject.toml`](../pyproject.toml) | `Weblate[postgres]==2026.5` | PyPI calver |
| [`docker/Dockerfile.weblate-plugin`](../docker/Dockerfile.weblate-plugin) | `FROM weblate/weblate:2026.5.0.0` | Docker fixed tag `YEAR.MONTH.PATCH.BUILD` |

Mapping lives in [`scripts/weblate-version-map.sh`](../scripts/weblate-version-map.sh). CI runs [`scripts/check-weblate-pin-sync.sh`](../scripts/check-weblate-pin-sync.sh) on every PR. [`weblate-pin-bump.yml`](workflows/weblate-pin-bump.yml) opens a PR weekly (Monday 09:00 UTC) when a newer PyPI release has a matching Docker fixed tag.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ jobs:
plugin_version = data["project"]["version"]
weblate_version = None
for dep in data["project"]["dependencies"]:
match = re.fullmatch(r"Weblate\[all\]==(.+)", dep)
match = re.fullmatch(r"Weblate(?:\[[^\]]+\])?==(.+)", dep)
if match:
weblate_version = match.group(1)
break
if not weblate_version:
raise SystemExit("Weblate[all]== pin not found in pyproject.toml")
raise SystemExit("Weblate pin not found in pyproject.toml")
print(f"plugin_version={plugin_version}")
print(f"weblate_version={weblate_version}")
print(f"tag=v{plugin_version}")
Expand Down
24 changes: 23 additions & 1 deletion .github/workflows/weblate-pin-bump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,29 @@ permissions:
pull-requests: write

jobs:
contract-latest:
name: Weblate upstream contract (latest PyPI)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# actions/checkout v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
with:
persist-credentials: false
# astral-sh/setup-uv v8.1.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
with:
version: 0.11.12
- name: Install apt dependencies (Weblate venv)
run: sudo ./.github/ci/apt-install
- name: Install plugin dependencies
run: uv sync --frozen --group dev
- name: Verify Weblate internal API contracts (latest PyPI)
run: bash scripts/check-weblate-internal-contract.sh --latest

bump-weblate-pin:
needs: [contract-latest]
name: Bump Weblate pin
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -127,7 +149,7 @@ jobs:
if ! git diff --cached --quiet; then
git commit \
-m "chore(deps): bump Weblate pin to ${target_pypi}" \
-m "PyPI Weblate[all]==${target_pypi}" \
-m "PyPI Weblate[postgres]==${target_pypi}" \
-m "Docker weblate/weblate:${target_docker}"
fi

Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.0] - 2026-06-11
### Changed

- **Dependencies** — Replaced `Weblate[all]` with `Weblate[postgres]` in `pyproject.toml` (postgres extra required to import `weblate.urls`); removed redundant direct `packaging` pin (still provided by Weblate). Docker deployments are unaffected (full base image unchanged); local/CI installs use a smaller dependency tree.


### Added

Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ classifiers = [
"Topic :: Software Development :: Localization"
]
dependencies = [
"packaging==26.2",
"Weblate[all]==2026.5"
"Weblate[postgres]==2026.5"
]
description = "Standalone Weblate plugin for Boost documentation translation."
keywords = [
Expand Down Expand Up @@ -137,12 +136,13 @@ level = "cautious"
unauthorized_licenses = []

[tool.pytest.ini_options]
addopts = ["-m", "not plugin and not benchmark and not fuzz"]
addopts = ["-m", "not plugin and not benchmark and not fuzz and not weblate_contract"]
markers = [
"benchmark: parser performance benchmarks (slow; excluded from default test runs)",
"plugin: requires live Weblate stack (Docker Compose) and optional WEBLATE_API_TOKEN",
"slow: long-running plugin integration test",
"fuzz: property-based / fuzz tests (excluded from default test runs; pytest -m fuzz)"
"fuzz: property-based / fuzz tests (excluded from default test runs; pytest -m fuzz)",
"weblate_contract: verifies undocumented Weblate internal APIs (pin-bump gate only)"
]
python_classes = ["Test*"]
python_files = ["test_*.py", "*_test.py"]
Expand Down
2 changes: 1 addition & 1 deletion scripts/bump-weblate-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
exit 0
fi

sed -i "s/Weblate\\[all\\]==[0-9][0-9.]*/Weblate[all]==${target_pypi}/" "$PYPI_FILE"
sed -i "s/Weblate\\(\\[[^]]*\\]\\)\\?==[0-9][0-9.]*/Weblate[postgres]==${target_pypi}/" "$PYPI_FILE"
sed -i "s|^FROM weblate/weblate:[0-9][0-9.]*|FROM weblate/weblate:${target_docker}|" "$DOCKER_FILE"

(
Expand Down
99 changes: 99 additions & 0 deletions scripts/check-weblate-internal-contract.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# SPDX-License-Identifier: BSL-1.0
#
# Verify plugin assumptions about undocumented Weblate internals
# (FormatsConf.FORMATS AST, WEBLATE_FORMATS, weblate.urls.real_patterns).

set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

LATEST=0

usage() {
cat <<'EOF'
Usage: check-weblate-internal-contract.sh [--latest]

(default) Run contract tests against the already-installed Weblate version.
--latest Install the newest modern calver Weblate from PyPI, then run tests.
EOF
}

while [[ $# -gt 0 ]]; do
case "$1" in
--latest)
LATEST=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done

resolve_latest_weblate_pypi() {
uv run --with packaging python3 - <<'PY'
import json
import re
import sys
import urllib.request
from packaging.version import Version

calver = re.compile(r"^\d{4}\.\d+(?:\.\d+)?$")

def is_modern_calver(name: str) -> bool:
if not calver.match(name):
return False
year = int(name.split(".", 1)[0])
return year >= 2020

with urllib.request.urlopen(
"https://pypi.org/pypi/Weblate/json", timeout=30
) as resp:
data = json.load(resp)

releases = [v for v in data["releases"] if is_modern_calver(v)]
if not releases:
print("ERROR: no modern calver Weblate releases found on PyPI", file=sys.stderr)
raise SystemExit(1)

releases.sort(key=Version, reverse=True)
print(releases[0])
PY
}

if [[ "$LATEST" -eq 1 ]]; then
latest_ver="$(resolve_latest_weblate_pypi)"
echo "Installing latest PyPI Weblate[postgres]==${latest_ver}"
uv pip install "Weblate[postgres]==${latest_ver}"
fi

weblate_version="$(uv run python3 -c 'import importlib.metadata; print(importlib.metadata.version("Weblate"))')"
echo "Weblate version under test: ${weblate_version}"
echo "Contracts checked:"
echo " - FormatsConf.FORMATS AST (weblate/formats/models.py)"
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
pytest_status=$?
set -e

if [[ "$pytest_status" -ne 0 ]]; then
echo "Weblate internal API contract check failed." >&2
echo "Review pytest output above for which contract broke:" >&2
echo " [FormatsConf.FORMATS AST] | [WEBLATE_FORMATS] | [weblate.urls.real_patterns]" >&2
exit "$pytest_status"
fi

echo "Weblate internal API contract check passed (Weblate ${weblate_version})."
4 changes: 2 additions & 2 deletions scripts/check-weblate-pin-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pypi_ver="$(parse_pypi_weblate_version "$PYPI_FILE")"
docker_tag="$(parse_docker_weblate_tag "$DOCKER_FILE")"

if [[ -z "$pypi_ver" ]]; then
echo "ERROR: could not parse Weblate[all]==… from ${PYPI_FILE}" >&2
echo "ERROR: could not parse Weblate[]==… from ${PYPI_FILE}" >&2
exit 1
fi

Expand All @@ -29,7 +29,7 @@ expected_docker="$(pypi_to_docker_fixed "$pypi_ver")"

if [[ "$docker_tag" != "$expected_docker" ]]; then
echo "ERROR: Weblate pin mismatch between PyPI and Docker base image." >&2
echo " pyproject.toml (PyPI): Weblate[all]==${pypi_ver}" >&2
echo " pyproject.toml (PyPI): Weblate[postgres]==${pypi_ver}" >&2
echo " Dockerfile tag: weblate/weblate:${docker_tag}" >&2
echo " expected Docker fixed tag: weblate/weblate:${expected_docker}" >&2
exit 1
Expand Down
4 changes: 2 additions & 2 deletions scripts/weblate-version-map.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ pypi_to_docker_fixed() {

parse_pypi_weblate_version() {
local file="${1:-pyproject.toml}"
grep -E '^[[:space:]]*"Weblate\[all\]==[0-9][0-9.]+"' "$file" \
grep -E '^[[:space:]]*"Weblate(\[[^]]+\])?==[0-9][0-9.]+"' "$file" \
| head -n1 \
| sed -E 's/.*Weblate\[all\]==([0-9][0-9.]+).*/\1/'
| sed -E 's/.*Weblate(\[[^]]+\])?==([0-9][0-9.]+).*/\2/'
}

parse_docker_weblate_tag() {
Expand Down
121 changes: 121 additions & 0 deletions tests/test_weblate_internal_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# SPDX-License-Identifier: BSL-1.0

"""Verify undocumented Weblate internal APIs the plugin depends on (pin-bump gate)."""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest
from django.urls import URLResolver

from boost_weblate.endpoint.weblate_urls_adapter import (
_assert_weblate_url_layout,
_boost_endpoint_route,
)
from boost_weblate.settings_override import (
_parse_formatsconf_formats_ast,
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:
formats = weblate_formats_with_plugin_formats()
except RuntimeError as exc:
msg = f"{_CONTRACT_PREFIX_WEBLATE_FORMATS} {exc}"
raise AssertionError(msg) from exc
if not isinstance(formats, tuple):
msg = (
f"{_CONTRACT_PREFIX_WEBLATE_FORMATS} "
f"expected tuple, got {type(formats).__name__}"
)
raise AssertionError(msg)
if not formats:
msg = (
f"{_CONTRACT_PREFIX_WEBLATE_FORMATS} "
"weblate_formats_with_plugin_formats() returned an empty tuple"
)
raise AssertionError(msg)


@pytest.mark.weblate_contract
def test_weblate_contract_real_patterns_accepts_resolver() -> None:
try:
import weblate.urls as wl_urls
except ModuleNotFoundError as exc:
msg = f"{_CONTRACT_PREFIX_REAL_PATTERNS} weblate.urls is not importable: {exc}"
raise AssertionError(msg) from exc

try:
_assert_weblate_url_layout(wl_urls)
except Exception as exc:
msg = f"{_CONTRACT_PREFIX_REAL_PATTERNS} {exc}"
raise AssertionError(msg) from exc

real_patterns = wl_urls.real_patterns
if not isinstance(real_patterns, list):
msg = (
f"{_CONTRACT_PREFIX_REAL_PATTERNS} "
f"real_patterns is not a list (got {type(real_patterns).__name__})"
)
raise AssertionError(msg)

route = _boost_endpoint_route()
if not isinstance(route, URLResolver):
msg = (
f"{_CONTRACT_PREFIX_REAL_PATTERNS} "
f"expected URLResolver from _boost_endpoint_route(), "
f"got {type(route).__name__}"
)
raise AssertionError(msg)

before_len = len(real_patterns)
real_patterns.append(route)
try:
if len(real_patterns) != before_len + 1:
msg = (
f"{_CONTRACT_PREFIX_REAL_PATTERNS} "
"real_patterns did not accept appended URLResolver"
)
raise AssertionError(msg)
if real_patterns[-1] is not route:
msg = (
f"{_CONTRACT_PREFIX_REAL_PATTERNS} "
"appended URLResolver was not retained at list tail"
)
raise AssertionError(msg)
finally:
real_patterns.pop()
Loading
Loading