diff --git a/.env.example b/.env.example index 8afaffe..07d85c9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index 9c469ea..c6ce02b 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -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`) 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 | @@ -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-`. + +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. diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index aa881b0..a5732c5 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -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 @@ -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: @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f4270..c1f1dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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 diff --git a/README.md b/README.md index caca84a..fdbba4c 100644 --- a/README.md +++ b/README.md @@ -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:** @@ -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) | @@ -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 | @@ -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 diff --git a/docs/boost-endpoint-api.md b/docs/boost-endpoint-api.md index 0c61834..4385562 100644 --- a/docs/boost-endpoint-api.md +++ b/docs/boost-endpoint-api.md @@ -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. @@ -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 diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 86da946..a4c42fb 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -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 @@ -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 | diff --git a/pyproject.toml b/pyproject.toml index defe61e..3396b96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] diff --git a/src/boost_weblate/endpoint/errors.py b/src/boost_weblate/endpoint/errors.py index 8050c9d..d6ec0f8 100644 --- a/src/boost_weblate/endpoint/errors.py +++ b/src/boost_weblate/endpoint/errors.py @@ -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): diff --git a/src/boost_weblate/endpoint/tasks.py b/src/boost_weblate/endpoint/tasks.py index 8638d96..e9d66f6 100644 --- a/src/boost_weblate/endpoint/tasks.py +++ b/src/boost_weblate/endpoint/tasks.py @@ -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 @@ -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( *, @@ -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 diff --git a/src/boost_weblate/settings_override.py b/src/boost_weblate/settings_override.py index 2eedf43..f26666f 100644 --- a/src/boost_weblate/settings_override.py +++ b/src/boost_weblate/settings_override.py @@ -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], diff --git a/tests/endpoint/test_views.py b/tests/endpoint/test_views.py index 27fd5b8..e3f6b59 100644 --- a/tests/endpoint/test_views.py +++ b/tests/endpoint/test_views.py @@ -284,6 +284,52 @@ def process_all(self, submodules, *, user, request=None): # noqa: ANN001 assert result["ja"]["submodules"] == ["json"] +def test_boost_add_or_update_task_declares_celery_time_limits() -> None: + from boost_weblate.endpoint import tasks as tasks_mod + from boost_weblate.settings_override import ( + BOOST_TASK_SOFT_TIME_LIMIT, + BOOST_TASK_TIME_LIMIT, + ) + + task = tasks_mod.boost_add_or_update_task + assert task.soft_time_limit == BOOST_TASK_SOFT_TIME_LIMIT + assert task.time_limit == BOOST_TASK_TIME_LIMIT + + +def test_boost_add_or_update_task_soft_time_limit_raises_task_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from celery.exceptions import SoftTimeLimitExceeded + + from boost_weblate.endpoint import tasks as tasks_mod + from boost_weblate.settings_override import BOOST_TASK_SOFT_TIME_LIMIT + + user = MagicMock() + monkeypatch.setattr(tasks_mod.User.objects, "get", lambda pk: user) + + class TimeoutService: + def __init__(self, **_kw): # noqa: ANN003 + pass + + def process_all(self, _submodules, *, user, request=None): # noqa: ANN001 + raise SoftTimeLimitExceeded() + + monkeypatch.setattr(tasks_mod, "BoostComponentService", TimeoutService) + + with pytest.raises(BoostEndpointError) as exc_info: + tasks_mod.boost_add_or_update_task.run( + organization="o", + add_or_update={"en": ["x"]}, + version="v", + extensions=None, + user_id=1, + ) + + assert exc_info.value.code == BoostEndpointErrorCode.TASK_TIMEOUT + assert exc_info.value.metadata["soft_time_limit"] == BOOST_TASK_SOFT_TIME_LIMIT + assert "time_limit" in exc_info.value.metadata + + def test_boost_add_or_update_task_propagates_service_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_settings_override.py b/tests/test_settings_override.py index 4f7d997..8a19362 100644 --- a/tests/test_settings_override.py +++ b/tests/test_settings_override.py @@ -110,3 +110,36 @@ def test_allowed_clone_hosts_parses_env(monkeypatch: pytest.MonkeyPatch) -> None monkeypatch.setenv("BOOST_ALLOWED_CLONE_HOSTS", "") assert allowed_clone_hosts() == [] + + +def test_boost_task_timeout_settings_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BOOST_TASK_SOFT_TIME_LIMIT", raising=False) + monkeypatch.delenv("BOOST_TASK_TIME_LIMIT", raising=False) + + import boost_weblate.settings_override as so + + importlib.reload(so) + + assert so.BOOST_TASK_SOFT_TIME_LIMIT == 1800 + assert so.BOOST_TASK_TIME_LIMIT == 2100 + + +def test_boost_task_timeout_settings_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: + from boost_weblate.settings_override import boost_task_timeout_settings + + monkeypatch.setenv("BOOST_TASK_SOFT_TIME_LIMIT", "900") + monkeypatch.setenv("BOOST_TASK_TIME_LIMIT", "1200") + settings = boost_task_timeout_settings() + assert settings["soft_time_limit"] == 900 + assert settings["time_limit"] == 1200 + + +def test_boost_task_timeout_settings_rejects_invalid_limits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from boost_weblate.settings_override import boost_task_timeout_settings + + monkeypatch.setenv("BOOST_TASK_SOFT_TIME_LIMIT", "1200") + monkeypatch.setenv("BOOST_TASK_TIME_LIMIT", "900") + with pytest.raises(ValueError, match="BOOST_TASK_TIME_LIMIT"): + boost_task_timeout_settings()