hawkapi-celery 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hawkapi_celery-0.2.0/CHANGELOG.md +25 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/PKG-INFO +1 -1
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/pyproject.toml +1 -1
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/__init__.py +1 -1
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_app.py +5 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_context.py +13 -3
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_plugin.py +14 -3
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_tasks.py +27 -19
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_app.py +19 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_plugin.py +1 -1
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_tasks.py +39 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/uv.lock +1 -1
- hawkapi_celery-0.1.0/CHANGELOG.md +0 -15
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/.github/workflows/ci.yml +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/.github/workflows/release.yml +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/.gitignore +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/LICENSE +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/README.md +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_beat.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_health.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_testing.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/py.typed +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/__init__.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/conftest.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_beat.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_context.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_health.py +0 -0
- {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_testing.py +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-05-16
|
|
4
|
+
|
|
5
|
+
Security & correctness:
|
|
6
|
+
|
|
7
|
+
- `_run_coroutine` no longer conflates the "no running loop" `RuntimeError` with the active-loop guard; it inspects `asyncio.get_running_loop()` explicitly and raises a clear error inside an event loop. Removed process-wide `asyncio.set_event_loop()` side effects.
|
|
8
|
+
- `create_celery` validates `task_serializer` is contained in `accept_content` and raises `ValueError` otherwise.
|
|
9
|
+
- `@task` no longer drops `max_retries=0` / `bind=False` / other falsy-but-meaningful overrides; only non-default kwargs are forwarded.
|
|
10
|
+
- Context propagation stores reset tokens in a module-level `dict[str, Token]` keyed by `task.request.id` instead of mutating `task.request._hawkapi_token`.
|
|
11
|
+
- `init_celery` / `resolve_celery` use `WeakKeyDictionary` keyed by the app object to avoid `id()` ABA collisions.
|
|
12
|
+
|
|
13
|
+
## 0.1.0 — 2026-05-16
|
|
14
|
+
|
|
15
|
+
Initial release.
|
|
16
|
+
|
|
17
|
+
- `init_celery(app, ...)` + `app.state.celery`.
|
|
18
|
+
- `@task(celery_app, ...)` decorator with `async def` support (runs coroutines on a private event loop) and auto-retry knobs.
|
|
19
|
+
- Beat schedule helpers — `Periodic`, `add_periodic`, `every`, `every_seconds`, re-exported `crontab`.
|
|
20
|
+
- Healthchecks — `check_broker`, `check_workers`, `healthcheck` → `HealthReport`.
|
|
21
|
+
- Request-context propagation between HTTP handlers and workers (`bind_context`, `current_context`, `attach_context_signals`).
|
|
22
|
+
- DI helpers — `Depends(get_celery)`, `get_task_result(task_id, request)`.
|
|
23
|
+
- Test fixtures — `eager_mode`, `record_tasks` / `TaskRecorder`.
|
|
24
|
+
- Retry helper — `compute_backoff` with decorrelated jitter.
|
|
25
|
+
- Extras: `[redis]`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hawkapi-celery
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Celery integration for HawkAPI — async tasks, beat scheduler, context propagation, healthchecks, eager-mode fixtures
|
|
5
5
|
Project-URL: Homepage, https://pypi.org/project/hawkapi-celery/
|
|
6
6
|
Project-URL: Repository, https://github.com/ashimov/hawkapi-celery
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hawkapi-celery"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Celery integration for HawkAPI — async tasks, beat scheduler, context propagation, healthchecks, eager-mode fixtures"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -21,7 +21,7 @@ from ._plugin import get_celery, get_task_result, init_celery, resolve_celery
|
|
|
21
21
|
from ._tasks import compute_backoff, task
|
|
22
22
|
from ._testing import CapturedTask, TaskRecorder, eager_mode, record_tasks
|
|
23
23
|
|
|
24
|
-
__version__ = "0.
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
25
|
|
|
26
26
|
__all__ = [
|
|
27
27
|
"CapturedTask",
|
|
@@ -34,6 +34,11 @@ class CeleryConfig:
|
|
|
34
34
|
def create_celery(name: str = "hawkapi", *, config: CeleryConfig | None = None) -> Celery:
|
|
35
35
|
"""Build a Celery app from :class:`CeleryConfig` with sensible defaults."""
|
|
36
36
|
config = config or CeleryConfig()
|
|
37
|
+
if config.task_serializer not in config.accept_content:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"task_serializer {config.task_serializer!r} not in "
|
|
40
|
+
f"accept_content {config.accept_content!r}"
|
|
41
|
+
)
|
|
37
42
|
app = Celery(name, broker=config.broker_url, backend=config.result_backend)
|
|
38
43
|
app.conf.update(
|
|
39
44
|
broker_url=config.broker_url,
|
|
@@ -32,6 +32,11 @@ _CONTEXT_VAR: contextvars.ContextVar[dict[str, Any] | None] = contextvars.Contex
|
|
|
32
32
|
)
|
|
33
33
|
_HEADER_KEY = "hawkapi_context"
|
|
34
34
|
|
|
35
|
+
# Keyed by task.request.id; tokens are popped in task_postrun. Living on a
|
|
36
|
+
# module-level dict (rather than mutating ``task.request``) keeps Celery's
|
|
37
|
+
# request stack immutable from our side.
|
|
38
|
+
_PENDING_TOKENS: dict[str, contextvars.Token[dict[str, Any] | None]] = {}
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
def current_context() -> dict[str, Any]:
|
|
37
42
|
"""Return the active context dict (empty if unset)."""
|
|
@@ -79,17 +84,22 @@ def attach_context_signals(celery_app: Celery) -> None:
|
|
|
79
84
|
return
|
|
80
85
|
try:
|
|
81
86
|
headers = task.request.headers or {}
|
|
87
|
+
task_id = task.request.id
|
|
82
88
|
except AttributeError:
|
|
83
89
|
return
|
|
84
90
|
ctx = headers.get(_HEADER_KEY) if isinstance(headers, dict) else None
|
|
85
|
-
if isinstance(ctx, dict):
|
|
86
|
-
|
|
91
|
+
if isinstance(ctx, dict) and task_id:
|
|
92
|
+
_PENDING_TOKENS[task_id] = _CONTEXT_VAR.set(dict(ctx))
|
|
87
93
|
|
|
88
94
|
@task_postrun.connect(weak=False)
|
|
89
95
|
def _postrun(task: Any = None, **_: Any) -> None:
|
|
90
96
|
if task is None:
|
|
91
97
|
return
|
|
92
|
-
|
|
98
|
+
try:
|
|
99
|
+
task_id = task.request.id
|
|
100
|
+
except AttributeError:
|
|
101
|
+
return
|
|
102
|
+
token = _PENDING_TOKENS.pop(task_id, None) if task_id else None
|
|
93
103
|
if token is not None:
|
|
94
104
|
import contextlib
|
|
95
105
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
6
|
+
import weakref
|
|
5
7
|
from typing import Any
|
|
6
8
|
|
|
7
9
|
from celery import Celery
|
|
@@ -16,10 +18,16 @@ class _StateNamespace:
|
|
|
16
18
|
celery: Any
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
_ACTIVE_APPS:
|
|
21
|
+
_ACTIVE_APPS: weakref.WeakKeyDictionary[Any, Celery] = weakref.WeakKeyDictionary()
|
|
20
22
|
_LAST_APP: list[Celery | None] = [None]
|
|
21
23
|
|
|
22
24
|
|
|
25
|
+
def _try_remember(app: Any, celery_app: Celery) -> None:
|
|
26
|
+
# Unhashable / non-weakref-able app — skip the cache silently.
|
|
27
|
+
with contextlib.suppress(TypeError):
|
|
28
|
+
_ACTIVE_APPS[app] = celery_app
|
|
29
|
+
|
|
30
|
+
|
|
23
31
|
def init_celery(
|
|
24
32
|
app: Any,
|
|
25
33
|
*,
|
|
@@ -42,7 +50,7 @@ def init_celery(
|
|
|
42
50
|
if getattr(app, "state", None) is None:
|
|
43
51
|
app.state = _StateNamespace()
|
|
44
52
|
app.state.celery = celery_app
|
|
45
|
-
|
|
53
|
+
_try_remember(app, celery_app)
|
|
46
54
|
_LAST_APP[0] = celery_app
|
|
47
55
|
return celery_app
|
|
48
56
|
|
|
@@ -50,7 +58,10 @@ def init_celery(
|
|
|
50
58
|
def resolve_celery(app: Any) -> Celery | None:
|
|
51
59
|
if app is None:
|
|
52
60
|
return _LAST_APP[0]
|
|
53
|
-
|
|
61
|
+
try:
|
|
62
|
+
found = _ACTIVE_APPS.get(app)
|
|
63
|
+
except TypeError:
|
|
64
|
+
found = None
|
|
54
65
|
if found is not None:
|
|
55
66
|
return found
|
|
56
67
|
state = getattr(app, "state", None)
|
|
@@ -37,16 +37,22 @@ def task(
|
|
|
37
37
|
def decorator(fn: Callable[P, R]) -> Any:
|
|
38
38
|
register_kwargs: dict[str, Any] = {
|
|
39
39
|
"name": name or f"{fn.__module__}.{fn.__qualname__}",
|
|
40
|
-
"queue": queue,
|
|
41
|
-
"bind": bind,
|
|
42
|
-
"autoretry_for": autoretry_for,
|
|
43
|
-
"retry_backoff": retry_backoff,
|
|
44
|
-
"retry_backoff_max": retry_backoff_max,
|
|
45
|
-
"retry_jitter": retry_jitter,
|
|
46
|
-
"max_retries": max_retries,
|
|
47
|
-
**task_kwargs,
|
|
48
40
|
}
|
|
49
|
-
|
|
41
|
+
if queue is not None:
|
|
42
|
+
register_kwargs["queue"] = queue
|
|
43
|
+
if bind:
|
|
44
|
+
register_kwargs["bind"] = True
|
|
45
|
+
if autoretry_for:
|
|
46
|
+
register_kwargs["autoretry_for"] = autoretry_for
|
|
47
|
+
if retry_backoff is not False:
|
|
48
|
+
register_kwargs["retry_backoff"] = retry_backoff
|
|
49
|
+
if retry_backoff_max != 600:
|
|
50
|
+
register_kwargs["retry_backoff_max"] = retry_backoff_max
|
|
51
|
+
if retry_jitter is not True:
|
|
52
|
+
register_kwargs["retry_jitter"] = retry_jitter
|
|
53
|
+
if max_retries != 3:
|
|
54
|
+
register_kwargs["max_retries"] = max_retries
|
|
55
|
+
register_kwargs.update(task_kwargs)
|
|
50
56
|
|
|
51
57
|
if inspect.iscoroutinefunction(fn):
|
|
52
58
|
|
|
@@ -61,20 +67,22 @@ def task(
|
|
|
61
67
|
|
|
62
68
|
|
|
63
69
|
def _run_coroutine(coro_fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
64
|
-
"""Run ``coro_fn(*args, **kwargs)`` on a private event loop.
|
|
70
|
+
"""Run ``coro_fn(*args, **kwargs)`` on a private event loop.
|
|
71
|
+
|
|
72
|
+
Raises ``RuntimeError`` if called from within an already-running event loop —
|
|
73
|
+
we never want to block the outer loop or attempt re-entry.
|
|
74
|
+
"""
|
|
65
75
|
try:
|
|
66
|
-
|
|
67
|
-
if loop.is_running():
|
|
68
|
-
# Nested call — bail rather than block the outer loop.
|
|
69
|
-
raise RuntimeError("cannot run coroutine task inside an active event loop")
|
|
76
|
+
running = asyncio.get_running_loop()
|
|
70
77
|
except RuntimeError:
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
running = None
|
|
79
|
+
if running is not None and running.is_running():
|
|
80
|
+
raise RuntimeError("cannot run coroutine task inside an active event loop")
|
|
81
|
+
new_loop = asyncio.new_event_loop()
|
|
73
82
|
try:
|
|
74
|
-
return
|
|
83
|
+
return new_loop.run_until_complete(coro_fn(*args, **kwargs))
|
|
75
84
|
finally:
|
|
76
|
-
|
|
77
|
-
asyncio.set_event_loop(None)
|
|
85
|
+
new_loop.close()
|
|
78
86
|
|
|
79
87
|
|
|
80
88
|
def compute_backoff(
|
|
@@ -33,3 +33,22 @@ def test_create_celery_applies_config() -> None:
|
|
|
33
33
|
def test_create_celery_extra_kwargs() -> None:
|
|
34
34
|
app = create_celery(config=CeleryConfig(extra_kwargs={"task_acks_late": True}))
|
|
35
35
|
assert app.conf.task_acks_late is True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_accept_content_validation() -> None:
|
|
39
|
+
"""task_serializer must be listed in accept_content; otherwise create_celery rejects it."""
|
|
40
|
+
import pytest
|
|
41
|
+
|
|
42
|
+
bad_serializer = "pickle" # deliberately not in accept_content
|
|
43
|
+
with pytest.raises(ValueError, match="not in accept_content"):
|
|
44
|
+
create_celery(config=CeleryConfig(task_serializer=bad_serializer, accept_content=["json"]))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_accept_content_validation_passes_when_consistent() -> None:
|
|
48
|
+
app = create_celery(
|
|
49
|
+
config=CeleryConfig(
|
|
50
|
+
task_serializer="json",
|
|
51
|
+
accept_content=["json", "yaml"],
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
assert app.conf.task_serializer == "json"
|
|
@@ -44,3 +44,42 @@ def test_compute_backoff_increases() -> None:
|
|
|
44
44
|
|
|
45
45
|
def test_compute_backoff_capped() -> None:
|
|
46
46
|
assert compute_backoff(20, base=1, cap=10, jitter=False) == 10
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_run_coroutine_raises_clear_error_in_async_context(celery_app: Celery) -> None:
|
|
50
|
+
"""When invoked from inside an active event loop, the wrapper must bail loudly."""
|
|
51
|
+
import asyncio
|
|
52
|
+
|
|
53
|
+
import pytest
|
|
54
|
+
|
|
55
|
+
@task(celery_app, name="t.inner_async")
|
|
56
|
+
async def inner() -> int:
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
async def outer() -> None:
|
|
60
|
+
with eager_mode(celery_app):
|
|
61
|
+
# Calling .delay().get() inside an active loop hits _run_coroutine.
|
|
62
|
+
inner.delay().get()
|
|
63
|
+
|
|
64
|
+
with pytest.raises(RuntimeError, match="cannot run coroutine task inside an active event loop"):
|
|
65
|
+
asyncio.run(outer())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_max_retries_zero_is_passed_through(celery_app: Celery) -> None:
|
|
69
|
+
@task(celery_app, name="t.no_retry", max_retries=0)
|
|
70
|
+
def fn() -> str:
|
|
71
|
+
return "ok"
|
|
72
|
+
|
|
73
|
+
registered = celery_app.tasks["t.no_retry"]
|
|
74
|
+
assert registered.max_retries == 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_bind_false_default_does_not_override(celery_app: Celery) -> None:
|
|
78
|
+
"""``bind=False`` is the default and should not be forwarded as a kwarg."""
|
|
79
|
+
|
|
80
|
+
@task(celery_app, name="t.unbound")
|
|
81
|
+
def fn() -> str:
|
|
82
|
+
return "ok"
|
|
83
|
+
|
|
84
|
+
# The task should be registered cleanly without raising.
|
|
85
|
+
assert "t.unbound" in celery_app.tasks
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0 — 2026-05-16
|
|
4
|
-
|
|
5
|
-
Initial release.
|
|
6
|
-
|
|
7
|
-
- `init_celery(app, ...)` + `app.state.celery`.
|
|
8
|
-
- `@task(celery_app, ...)` decorator with `async def` support (runs coroutines on a private event loop) and auto-retry knobs.
|
|
9
|
-
- Beat schedule helpers — `Periodic`, `add_periodic`, `every`, `every_seconds`, re-exported `crontab`.
|
|
10
|
-
- Healthchecks — `check_broker`, `check_workers`, `healthcheck` → `HealthReport`.
|
|
11
|
-
- Request-context propagation between HTTP handlers and workers (`bind_context`, `current_context`, `attach_context_signals`).
|
|
12
|
-
- DI helpers — `Depends(get_celery)`, `get_task_result(task_id, request)`.
|
|
13
|
-
- Test fixtures — `eager_mode`, `record_tasks` / `TaskRecorder`.
|
|
14
|
-
- Retry helper — `compute_backoff` with decorrelated jitter.
|
|
15
|
-
- Extras: `[redis]`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|