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.
Files changed (28) hide show
  1. hawkapi_celery-0.2.0/CHANGELOG.md +25 -0
  2. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/PKG-INFO +1 -1
  3. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/pyproject.toml +1 -1
  4. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/__init__.py +1 -1
  5. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_app.py +5 -0
  6. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_context.py +13 -3
  7. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_plugin.py +14 -3
  8. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_tasks.py +27 -19
  9. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_app.py +19 -0
  10. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_plugin.py +1 -1
  11. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_tasks.py +39 -0
  12. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/uv.lock +1 -1
  13. hawkapi_celery-0.1.0/CHANGELOG.md +0 -15
  14. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/.github/workflows/release.yml +0 -0
  16. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/.gitignore +0 -0
  17. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/LICENSE +0 -0
  18. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/README.md +0 -0
  19. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_beat.py +0 -0
  20. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_health.py +0 -0
  21. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/_testing.py +0 -0
  22. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/src/hawkapi_celery/py.typed +0 -0
  23. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/__init__.py +0 -0
  24. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/conftest.py +0 -0
  25. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_beat.py +0 -0
  26. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_context.py +0 -0
  27. {hawkapi_celery-0.1.0 → hawkapi_celery-0.2.0}/tests/test_health.py +0 -0
  28. {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.1.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.1.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.1.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
- task.request._hawkapi_token = _CONTEXT_VAR.set(dict(ctx))
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
- token = getattr(task.request, "_hawkapi_token", None)
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: dict[int, Celery] = {}
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
- _ACTIVE_APPS[id(app)] = celery_app
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
- found = _ACTIVE_APPS.get(id(app))
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
- register_kwargs = {k: v for k, v in register_kwargs.items() if v is not None}
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
- loop = asyncio.get_event_loop()
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
- loop = asyncio.new_event_loop()
72
- asyncio.set_event_loop(loop)
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 loop.run_until_complete(coro_fn(*args, **kwargs))
83
+ return new_loop.run_until_complete(coro_fn(*args, **kwargs))
75
84
  finally:
76
- loop.close()
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"
@@ -57,7 +57,7 @@ def test_get_celery_500_when_not_configured() -> None:
57
57
 
58
58
  saved = _p._LAST_APP[0]
59
59
  _p._LAST_APP[0] = None
60
- _p._ACTIVE_APPS.pop(id(app), None)
60
+ _p._ACTIVE_APPS.pop(app, None)
61
61
  try:
62
62
  client = TestClient(app)
63
63
  r = client.get("/info")
@@ -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
@@ -115,7 +115,7 @@ wheels = [
115
115
 
116
116
  [[package]]
117
117
  name = "hawkapi-celery"
118
- version = "0.1.0"
118
+ version = "0.2.0"
119
119
  source = { editable = "." }
120
120
  dependencies = [
121
121
  { name = "celery" },
@@ -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