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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,9 @@ BOOST_ALLOWED_CLONE_HOSTS=github.com
BOOST_TASK_LOCK_TIMEOUT=1800
BOOST_TASK_LOCK_ON_CONFLICT=skip
BOOST_TASK_LOCK_WAIT_TIMEOUT=300

# Celery time limits for add-or-update tasks (settings_override.py).
# BOOST_TASK_SOFT_TIME_LIMIT: soft time limit in seconds (default 1800).
# BOOST_TASK_TIME_LIMIT: hard time limit in seconds (default 2100).
BOOST_TASK_SOFT_TIME_LIMIT=1800
BOOST_TASK_TIME_LIMIT=2100
8 changes: 7 additions & 1 deletion .github/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ GitHub Actions and CI/CD helpers for this repository (see [`.github/`](../.githu
| [`workflows/promote-main.yml`](workflows/promote-main.yml) | **Promote to production** — manual `workflow_dispatch`; ff-only `develop` → `main` via `PROMOTE_PAT` so CI and `cd.yml` run on `main` |
| [`workflows/release.yml`](workflows/release.yml) | **Release** — manual `workflow_dispatch` only; tags `main` from `pyproject.toml` (`v<version>`) and creates a GitHub Release with Weblate compatibility metadata |
| [`workflows/ci-lint.yml`](workflows/ci-lint.yml) | Lint and format (prek) |
| [`workflows/ci-test.yml`](workflows/ci-test.yml) | Unit tests and coverage |
| [`workflows/ci-test.yml`](workflows/ci-test.yml) | Unit tests and coverage (Python **3.12**, **3.13**, **3.14** matrix; `fail-fast: false`) |
| [`workflows/ci-benchmark.yml`](workflows/ci-benchmark.yml) | QuickBook parser benchmarks (`pytest-benchmark`; JSON artifact; regression gate vs `.benchmarks/`) |
| [`workflows/ci-package.yml`](workflows/ci-package.yml) | Build and package checks |
| [`workflows/ci-dependencies.yml`](workflows/ci-dependencies.yml) | Dependency and license audit |
Expand All @@ -29,6 +29,12 @@ GitHub Actions and CI/CD helpers for this repository (see [`.github/`](../.githu

Callable workflows (`ci-*`, `ci-plugin-*`) are triggered only via `workflow_call` from `ci.yml`, not directly on push.

## Unit test Python matrix

[`ci-test.yml`](workflows/ci-test.yml) runs pytest and the 90% coverage gate on **`ubuntu-latest`** for each supported CPython release declared in [`pyproject.toml`](../pyproject.toml) classifiers: **3.12**, **3.13**, and **3.14**. `fail-fast: false` keeps other matrix legs running when one version fails. Coverage artifacts are uploaded per matrix leg as `coverage-py<version>-<pr-or-run-id>`.

Lint ([`ci-lint.yml`](workflows/ci-lint.yml)), package ([`ci-package.yml`](workflows/ci-package.yml)), dependencies ([`ci-dependencies.yml`](workflows/ci-dependencies.yml)), and QuickBook benchmarks ([`ci-benchmark.yml`](workflows/ci-benchmark.yml)) still run on a single Python version (currently **3.14**). Plugin Docker jobs ([`ci-plugin-*`](workflows/ci-plugin-smoke.yml)) use **3.12** inside the Weblate image build context.

## Plugin integration jobs

Three callable workflows exercise the live Weblate Docker stack ([`docker/docker-compose.ci.yml`](../docker/docker-compose.ci.yml)). Each job builds the image, runs `compose up -d --wait`, probes `/healthz/` and the Boost ping endpoint, creates an API token (with retry), then runs pytest with `pytest-timeout` and one rerun on failure.
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ permissions:

jobs:
test-and-coverage:
name: Test and coverage
name: Test and coverage (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13', '3.14']
steps:
# actions/checkout v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
Expand All @@ -23,7 +27,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.14'
python-version: ${{ matrix.python-version }}
# astral-sh/setup-uv v8.1.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
with:
Expand All @@ -49,7 +53,7 @@ jobs:
- name: Upload coverage artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: coverage-${{ github.event.pull_request.number || github.run_id }}
name: coverage-py${{ matrix.python-version }}-${{ github.event.pull_request.number || github.run_id }}
path: |
coverage.xml
htmlcov/
Expand Down
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.

- **CI** — Unit test workflow runs on a Python version matrix (**3.12**, **3.13**, **3.14**) matching declared `pyproject.toml` classifiers; coverage artifacts are uploaded per matrix leg.

### Added

- **Celery task timeouts** — `boost_add_or_update_task` declares configurable soft/hard time limits (`BOOST_TASK_SOFT_TIME_LIMIT`, `BOOST_TASK_TIME_LIMIT`; defaults 1800/2100 s). Exceeding the soft limit raises `BoostEndpointError` with code `task_timeout`.

- **QuickBook format** — `QuickBookFormat` convert pipeline for `.qbk` templates; parsing and reconstruction in `boost_weblate.utils.quickbook`; registration via `WEBLATE_FORMATS` in `settings_override.py`.
- **Boost endpoint HTTP API** — routes under `/boost-endpoint/`:
- `GET /boost-endpoint/plugin-ping/` (public health check)
Expand Down Expand Up @@ -55,7 +57,7 @@ The following are subject to this policy:

- **HTTP API** — request/response schema and auth requirements for `POST /boost-endpoint/add-or-update/`, `GET /boost-endpoint/info/`, and related Boost endpoint routes documented in `docs/boost-endpoint-api.md`.
- **Format registration** — dotted import paths registered in `WEBLATE_FORMATS` (e.g. `boost_weblate.formats.quickbook.QuickBookFormat`).
- **Settings hook** — documented environment variables read by `settings_override.py` (e.g. `BOOST_ENDPOINT_THROTTLE_INFO`, `BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE`).
- **Settings hook** — documented environment variables read by `settings_override.py` (e.g. `BOOST_ENDPOINT_THROTTLE_INFO`, `BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE`, `BOOST_TASK_SOFT_TIME_LIMIT`, `BOOST_TASK_TIME_LIMIT`).

### Non-guarantees

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ boost_add_or_update_task.delay(

- Registered on Weblate's own Celery app (`weblate.utils.celery.app`), so it runs in the same worker pool as all other Weblate tasks with no extra broker configuration.
- `user_id` is passed instead of the `User` object because Celery serializes task arguments to JSON; the task re-fetches the user from the database inside the worker.
- Exceptions propagate unhandled so Celery marks the task as `FAILURE` and monitoring/alerting can act on it.
- Celery `soft_time_limit` and `time_limit` bound long-running add-or-update work (defaults **1800** s / **2100** s via `BOOST_TASK_SOFT_TIME_LIMIT` / `BOOST_TASK_TIME_LIMIT` in `.env`; see [Environment & Configuration Reference](#environment--configuration-reference)). Exceeding the soft limit raises `BoostEndpointError` with code `task_timeout`.
- Fatal exceptions propagate so Celery marks the task as `FAILURE` and monitoring/alerting can act on it.
- `trail=False` suppresses Celery's default task-result trail to avoid unbounded result-backend growth.

**Verifying the worker is running:**
Expand Down Expand Up @@ -286,7 +287,7 @@ Triggered on push and PR to `main` and `develop`. Calls nine reusable sub-workfl
| Job | Workflow | What it checks |
|-----|----------|----------------|
| `lint` | [`.github/workflows/ci-lint.yml`](.github/workflows/ci-lint.yml) | prek (Ruff, YAML/TOML, REUSE, actionlint, pytest) |
| `test` | [`.github/workflows/ci-test.yml`](.github/workflows/ci-test.yml) | pytest + 90% coverage gate (`--cov-fail-under=90`) |
| `test` | [`.github/workflows/ci-test.yml`](.github/workflows/ci-test.yml) | pytest + 90% coverage gate on Python **3.12**, **3.13**, and **3.14** (`--cov-fail-under=90`) |
| `benchmark` | [`.github/workflows/ci-benchmark.yml`](.github/workflows/ci-benchmark.yml) | QuickBook parser benchmarks (`pytest-benchmark`); JSON artifact; optional regression gate vs `.benchmarks/` baseline |
| `package` | [`.github/workflows/ci-package.yml`](.github/workflows/ci-package.yml) | `uv build`, twine, pydistcheck, pyroma, check-wheel-contents, check-manifest |
| `dependencies` | [`.github/workflows/ci-dependencies.yml`](.github/workflows/ci-dependencies.yml) | pip-audit, liccheck, dependency review (on PRs) |
Expand Down Expand Up @@ -347,6 +348,8 @@ Each script builds `docker/docker-compose.ci.yml`, waits for health, runs its py
| All env vars | [`.env.example`](.env.example) | Annotated template — copy to `.env` on the deploy server |
| Deployment & promotion | [`docs/deployment-runbook.md`](docs/deployment-runbook.md) | Staging/production CD, `PROMOTE_PAT`, environments, health checks, rollback, release tagging |
| Boost endpoint throttles | [`src/boost_weblate/settings_override.py`](src/boost_weblate/settings_override.py) | `BOOST_ENDPOINT_THROTTLE_INFO`, `BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE`; merged into `REST_FRAMEWORK` |
| Celery task timeouts | [`.env.example`](.env.example), [`src/boost_weblate/settings_override.py`](src/boost_weblate/settings_override.py) | `BOOST_TASK_SOFT_TIME_LIMIT`, `BOOST_TASK_TIME_LIMIT` for `boost_add_or_update_task`; defaults 1800/2100 s |
| Celery task lock | [`.env.example`](.env.example), [`src/boost_weblate/settings_override.py`](src/boost_weblate/settings_override.py) | `BOOST_TASK_LOCK_TIMEOUT`, `BOOST_TASK_LOCK_ON_CONFLICT`, `BOOST_TASK_LOCK_WAIT_TIMEOUT` (dedupe concurrent add-or-update) |
| Weblate version pins | [`pyproject.toml`](pyproject.toml), [`docker/Dockerfile.weblate-plugin`](docker/Dockerfile.weblate-plugin) | PyPI and Docker pins kept in sync; CI [`ci-weblate-pin.yml`](.github/workflows/ci-weblate-pin.yml); scheduled bumps via [`weblate-pin-bump.yml`](.github/workflows/weblate-pin-bump.yml) |
| Weblate pin scripts | [`scripts/weblate-version-map.sh`](scripts/weblate-version-map.sh), [`scripts/check-weblate-pin-sync.sh`](scripts/check-weblate-pin-sync.sh) | Calver mapping; CI check via [`ci-weblate-pin.yml`](.github/workflows/ci-weblate-pin.yml) |
| QuickBook grammar | [`docs/quickbook-grammar.md`](docs/quickbook-grammar.md) | Parser-supported constructs, coverage matrix, limitations |
Expand All @@ -367,6 +370,8 @@ prek run --all-files --show-diff-on-failure

- **Tests:** add tests next to the code you touch (`tests/formats/`, `tests/utils/`, or `tests/endpoint/`). Keep `django.setup()`-friendly patterns; heavy DB or migration suites are intentionally avoided in the bundled Django test settings.

- **Python versions:** the package supports CPython **3.12+** (`requires-python` in `pyproject.toml`). CI unit tests run on **3.12**, **3.13**, and **3.14**; lint, package, dependency, and benchmark jobs use a single version (currently **3.14**).

- **Coverage:** the CI test job enforces 90% minimum on `boost_weblate`. Run locally:

```bash
Expand Down
5 changes: 4 additions & 1 deletion docs/boost-endpoint-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ The task uses Weblate's own Celery `app` instance (`weblate.utils.celery.app`) a

`user_id` (an integer primary key) is passed rather than the user object itself because Celery serializes task arguments to JSON. The task re-fetches the user with `User.objects.get(pk=user_id)` inside the worker.

Fatal task failures raise `BoostEndpointError` (a `WeblateError` subclass from `weblate.trans.exceptions`) with a stable `code` and `metadata`, causing Celery to mark the task `FAILURE`. Examples: `task_user_not_found` when the `user_id` no longer exists, or `task_internal_error` for unexpected exceptions (after `report_error()`). Per-submodule errors that are recoverable (e.g. clone failure, permission denial for a single submodule) are collected into the submodule `errors` list as structured objects and do not raise exceptions.
Fatal task failures raise `BoostEndpointError` (a `WeblateError` subclass from `weblate.trans.exceptions`) with a stable `code` and `metadata`, causing Celery to mark the task `FAILURE`. Examples: `task_user_not_found` when the `user_id` no longer exists, `task_timeout` when the task exceeds its Celery soft time limit, or `task_internal_error` for unexpected exceptions (after `report_error()`). Per-submodule errors that are recoverable (e.g. clone failure, permission denial for a single submodule) are collected into the submodule `errors` list as structured objects and do not raise exceptions.

The task declares Celery `soft_time_limit` and `time_limit` from `settings_override.py` (defaults **1800** s / **2100** s, overridable via `BOOST_TASK_SOFT_TIME_LIMIT` and `BOOST_TASK_TIME_LIMIT`). Defaults align with `BOOST_TASK_LOCK_TIMEOUT` so the Redis task lock does not expire while a long-running add-or-update is still active. When the soft limit is exceeded, Celery raises `SoftTimeLimitExceeded`; the task catches it and re-raises `BoostEndpointError` with code `task_timeout` and metadata `soft_time_limit` / `time_limit`.

`trail=False` is set on the task to suppress Celery's default task-result trail and avoid unbounded result-backend growth in long-running deployments.

Expand Down Expand Up @@ -416,6 +418,7 @@ HTTP `400` responses and submodule `errors` lists use the same object schema. Va
| `git_push_timeout` | Git commit/push subprocess timeout |
| `all_components_failed` | Every scanned component failed create/update |
| `task_user_not_found` | Celery task `user_id` not found |
| `task_timeout` | Celery soft time limit exceeded (`BOOST_TASK_SOFT_TIME_LIMIT`) |
| `task_internal_error` | Unexpected exception in the Celery task |

### Recoverable vs fatal
Expand Down
10 changes: 10 additions & 0 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ Runtime plugin env vars (set in `.env`, read by `settings_override.py` at boot):
| `BOOST_ENDPOINT_THROTTLE_INFO` | `60/minute` | Scoped rate for `GET /boost-endpoint/info/` |
| `BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE` | `10/hour` | Scoped rate for `POST /boost-endpoint/add-or-update/` |
| `BOOST_ALLOWED_CLONE_HOSTS` | `github.com` | Comma-separated hostnames permitted for git clone URLs (HTTPS only; SSRF mitigation) |
| `BOOST_TASK_SOFT_TIME_LIMIT` | `1800` | Celery soft time limit (seconds) for `boost_add_or_update_task`; exceeding it fails the task with `task_timeout` |
| `BOOST_TASK_TIME_LIMIT` | `2100` | Celery hard time limit (seconds); must be greater than `BOOST_TASK_SOFT_TIME_LIMIT` |
| `BOOST_TASK_LOCK_TIMEOUT` | `1800` | Redis lock TTL (seconds) for deduplicating concurrent add-or-update requests |
| `BOOST_TASK_LOCK_ON_CONFLICT` | `skip` | `skip` (reject duplicate immediately) or `wait` (block up to wait timeout) |
| `BOOST_TASK_LOCK_WAIT_TIMEOUT` | `300` | Max seconds to wait when `BOOST_TASK_LOCK_ON_CONFLICT=wait` |

### Weblate environment variables

Expand All @@ -112,6 +117,11 @@ Key variables (full reference in `.env.example`):
| `BOOST_ENDPOINT_THROTTLE_INFO` | `60/minute` | `.env` | Plugin rate limit (see above) |
| `BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE` | `10/hour` | `.env` | Plugin rate limit (see above) |
| `BOOST_ALLOWED_CLONE_HOSTS` | `github.com` | `.env` | Hostnames allowed for git clone URLs (see above) |
| `BOOST_TASK_SOFT_TIME_LIMIT` | `1800` | `.env` | Celery soft time limit for add-or-update tasks (see above) |
| `BOOST_TASK_TIME_LIMIT` | `2100` | `.env` | Celery hard time limit for add-or-update tasks (see above) |
| `BOOST_TASK_LOCK_TIMEOUT` | `1800` | `.env` | Redis task-lock TTL for add-or-update deduplication (see above) |
| `BOOST_TASK_LOCK_ON_CONFLICT` | `skip` | `.env` | Lock conflict policy: `skip` or `wait` (see above) |
| `BOOST_TASK_LOCK_WAIT_TIMEOUT` | `300` | `.env` | Max wait when lock conflict policy is `wait` (see above) |
| `WEBLATE_EMAIL_HOST` | `smtp.example.com` | `.env` | SMTP server; set user/password for production |
| `WEBLATE_GITHUB_USERNAME` | — | `.env` | GitHub account for VCS; required with token for add-or-update |
| `WEBLATE_GITHUB_TOKEN` | — | `.env` | GitHub PAT (`repo` scope); rotate via pre-deploy checklist |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Internationalization",
"Topic :: Software Development :: Localization"
]
Expand Down
1 change: 1 addition & 0 deletions src/boost_weblate/endpoint/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class BoostEndpointErrorCode(StrEnum):
TASK_USER_NOT_FOUND = "task_user_not_found"
TASK_INTERNAL_ERROR = "task_internal_error"
TASK_DUPLICATE = "task_duplicate"
TASK_TIMEOUT = "task_timeout"


class BoostEndpointError(WeblateError):
Expand Down
20 changes: 19 additions & 1 deletion src/boost_weblate/endpoint/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from typing import Any

from celery.exceptions import SoftTimeLimitExceeded
from weblate.auth.models import AuthenticatedHttpRequest, User
from weblate.utils.celery import app
from weblate.utils.errors import report_error
Expand All @@ -18,13 +19,21 @@
wrap_task_error,
)
from boost_weblate.endpoint.services import BoostComponentService
from boost_weblate.settings_override import (
BOOST_TASK_SOFT_TIME_LIMIT,
BOOST_TASK_TIME_LIMIT,
)
from boost_weblate.utils.task_lock import (
build_add_or_update_lock_key,
redis_task_lock,
)


@app.task(trail=False)
@app.task(
trail=False,
soft_time_limit=BOOST_TASK_SOFT_TIME_LIMIT,
time_limit=BOOST_TASK_TIME_LIMIT,
)
@redis_task_lock(key_builder=build_add_or_update_lock_key)
def boost_add_or_update_task(
*,
Expand Down Expand Up @@ -67,6 +76,15 @@ def boost_add_or_update_task(
return results
except BoostEndpointError:
raise
except SoftTimeLimitExceeded as exc:
raise BoostEndpointError(
"Boost add-or-update task exceeded soft time limit",
code=BoostEndpointErrorCode.TASK_TIMEOUT,
metadata={
"soft_time_limit": BOOST_TASK_SOFT_TIME_LIMIT,
"time_limit": BOOST_TASK_TIME_LIMIT,
},
) from exc
except Exception as exc:
report_error(cause="Boost add-or-update task")
raise wrap_task_error(exc) from exc
36 changes: 36 additions & 0 deletions src/boost_weblate/settings_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,42 @@ def boost_task_lock_settings() -> dict[str, Any]:
BOOST_TASK_LOCK_ON_CONFLICT = _task_lock_settings["on_conflict"]
BOOST_TASK_LOCK_WAIT_TIMEOUT = _task_lock_settings["wait_timeout"]

# Defaults align with BOOST_TASK_LOCK_TIMEOUT so the Redis lock does not expire
# while a long-running add-or-update task is still active.
_DEFAULT_BOOST_TASK_SOFT_TIME_LIMIT = 1800
_DEFAULT_BOOST_TASK_TIME_LIMIT = 2100


def boost_task_timeout_settings() -> dict[str, int]:
"""Celery soft/hard time limits for add-or-update tasks (env overrides optional)."""
soft_time_limit = int(
os.environ.get(
"BOOST_TASK_SOFT_TIME_LIMIT",
str(_DEFAULT_BOOST_TASK_SOFT_TIME_LIMIT),
)
)
time_limit = int(
os.environ.get(
"BOOST_TASK_TIME_LIMIT",
str(_DEFAULT_BOOST_TASK_TIME_LIMIT),
)
)
if time_limit <= soft_time_limit:
msg = (
"BOOST_TASK_TIME_LIMIT must be greater than BOOST_TASK_SOFT_TIME_LIMIT "
f"({time_limit} <= {soft_time_limit})"
)
raise ValueError(msg)
return {
"soft_time_limit": soft_time_limit,
"time_limit": time_limit,
}


_task_timeout_settings = boost_task_timeout_settings()
BOOST_TASK_SOFT_TIME_LIMIT = _task_timeout_settings["soft_time_limit"]
BOOST_TASK_TIME_LIMIT = _task_timeout_settings["time_limit"]


def merge_boost_endpoint_throttle_rates(
rest_framework: dict[str, Any],
Expand Down
Loading