hawkapi-taskiq 0.1.0__py3-none-any.whl
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_taskiq/__init__.py +35 -0
- hawkapi_taskiq/_broker.py +68 -0
- hawkapi_taskiq/_config.py +38 -0
- hawkapi_taskiq/_health.py +60 -0
- hawkapi_taskiq/_plugin.py +98 -0
- hawkapi_taskiq/_schedule.py +36 -0
- hawkapi_taskiq/_tasks.py +53 -0
- hawkapi_taskiq/_testing.py +24 -0
- hawkapi_taskiq/py.typed +1 -0
- hawkapi_taskiq-0.1.0.dist-info/METADATA +178 -0
- hawkapi_taskiq-0.1.0.dist-info/RECORD +13 -0
- hawkapi_taskiq-0.1.0.dist-info/WHEEL +4 -0
- hawkapi_taskiq-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""hawkapi-taskiq — TaskIQ integration for HawkAPI.
|
|
2
|
+
|
|
3
|
+
Modern async-native task queue. URL-scheme allowlist enforces JSON-only
|
|
4
|
+
serialization to prevent arbitrary-deserialization vulnerabilities. Broker
|
|
5
|
+
DI via ``init_taskiq(app, ...)`` + ``Depends(get_broker)``. ``Scheduled``
|
|
6
|
+
provides cron-syntax validation; wire it into TaskIQ's own scheduler
|
|
7
|
+
sources (see README).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._broker import create_broker
|
|
13
|
+
from ._config import ALLOWED_BROKER_SCHEMES, TaskIQConfig
|
|
14
|
+
from ._health import HealthReport, check_broker
|
|
15
|
+
from ._plugin import get_broker, init_taskiq, resolve_broker
|
|
16
|
+
from ._schedule import Scheduled
|
|
17
|
+
from ._tasks import task
|
|
18
|
+
from ._testing import in_memory_broker
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ALLOWED_BROKER_SCHEMES",
|
|
24
|
+
"HealthReport",
|
|
25
|
+
"Scheduled",
|
|
26
|
+
"TaskIQConfig",
|
|
27
|
+
"__version__",
|
|
28
|
+
"check_broker",
|
|
29
|
+
"create_broker",
|
|
30
|
+
"get_broker",
|
|
31
|
+
"in_memory_broker",
|
|
32
|
+
"init_taskiq",
|
|
33
|
+
"resolve_broker",
|
|
34
|
+
"task",
|
|
35
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Broker factory — URL-scheme dispatch with strict allowlist."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._config import ALLOWED_BROKER_SCHEMES, TaskIQConfig, _scheme
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_broker(config: TaskIQConfig | None = None) -> Any:
|
|
11
|
+
"""Build an :class:`AsyncBroker` from ``config``. Raises ``ValueError`` for
|
|
12
|
+
any URL scheme not in :data:`ALLOWED_BROKER_SCHEMES`."""
|
|
13
|
+
cfg = config or TaskIQConfig()
|
|
14
|
+
|
|
15
|
+
if cfg.serializer != "json":
|
|
16
|
+
raise ValueError(
|
|
17
|
+
f"hawkapi-taskiq only supports the JSON serializer in v0.1.0; got {cfg.serializer!r}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Defense in depth — block ``serializer`` from ``extra`` so a future broker
|
|
21
|
+
# release that accepts the kwarg cannot bypass the JSON-only guarantee above.
|
|
22
|
+
if "serializer" in cfg.extra:
|
|
23
|
+
raise ValueError("'serializer' in extra is not permitted; JSON is enforced")
|
|
24
|
+
|
|
25
|
+
broker_url = cfg.broker_url.strip()
|
|
26
|
+
scheme = _scheme(broker_url)
|
|
27
|
+
if scheme not in ALLOWED_BROKER_SCHEMES:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"broker_url scheme {scheme!r} is not in the allowlist "
|
|
30
|
+
f"{sorted(ALLOWED_BROKER_SCHEMES)!r}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if cfg.result_backend_url:
|
|
34
|
+
# v0.1.0 does not yet plumb result_backend_url through to the broker —
|
|
35
|
+
# raise early so a misconfigured result backend cannot be silently dropped.
|
|
36
|
+
raise NotImplementedError(
|
|
37
|
+
"result_backend_url is not wired in v0.1.0; configure the result backend "
|
|
38
|
+
"on the broker directly via TaskIQConfig.extra"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if scheme == "memory":
|
|
42
|
+
from taskiq import InMemoryBroker
|
|
43
|
+
|
|
44
|
+
return InMemoryBroker()
|
|
45
|
+
|
|
46
|
+
if scheme in ("redis", "rediss"):
|
|
47
|
+
try:
|
|
48
|
+
from taskiq_redis import ListQueueBroker # type: ignore[import-not-found]
|
|
49
|
+
except ImportError as exc: # pragma: no cover
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"taskiq-redis is required for redis brokers; pip install 'hawkapi-taskiq[redis]'"
|
|
52
|
+
) from exc
|
|
53
|
+
return ListQueueBroker(url=broker_url, **cfg.extra)
|
|
54
|
+
|
|
55
|
+
if scheme == "nats":
|
|
56
|
+
try:
|
|
57
|
+
from taskiq_nats import NatsBroker # type: ignore[import-not-found]
|
|
58
|
+
except ImportError as exc: # pragma: no cover
|
|
59
|
+
raise ImportError(
|
|
60
|
+
"taskiq-nats is required for nats brokers; pip install 'hawkapi-taskiq[nats]'"
|
|
61
|
+
) from exc
|
|
62
|
+
return NatsBroker(servers=[broker_url], **cfg.extra)
|
|
63
|
+
|
|
64
|
+
# Unreachable — the allowlist check above gates this.
|
|
65
|
+
raise ValueError(f"unsupported scheme {scheme!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["create_broker"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""TaskIQ configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class TaskIQConfig:
|
|
11
|
+
"""Builder for a TaskIQ broker via :func:`hawkapi_taskiq.create_broker`."""
|
|
12
|
+
|
|
13
|
+
broker_url: str = "memory://"
|
|
14
|
+
"""Broker connection string. Only ``memory://``, ``redis://``, ``rediss://``,
|
|
15
|
+
``nats://`` are accepted — anything else raises ``ValueError`` to prevent
|
|
16
|
+
accidentally enabling unsafe-deserialization brokers."""
|
|
17
|
+
|
|
18
|
+
result_backend_url: str = ""
|
|
19
|
+
"""Optional result-backend URL. Same scheme allowlist as ``broker_url``."""
|
|
20
|
+
|
|
21
|
+
timezone: str = "UTC"
|
|
22
|
+
task_default_queue: str = "default"
|
|
23
|
+
serializer: str = "json"
|
|
24
|
+
"""Always ``"json"`` for v0.1.0. Other serializers are rejected at runtime
|
|
25
|
+
to prevent arbitrary-deserialization vulns (CWE-502)."""
|
|
26
|
+
|
|
27
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
"""Forwarded as keyword args to the broker constructor."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ALLOWED_BROKER_SCHEMES = frozenset({"memory", "redis", "rediss", "nats"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _scheme(url: str) -> str:
|
|
35
|
+
return url.split("://", 1)[0].lower() if "://" in url else ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["ALLOWED_BROKER_SCHEMES", "TaskIQConfig", "_scheme"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Broker health probe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_CRED_RE = re.compile(r"(redis|rediss|nats)://[^@]*@")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _scrub(text: str) -> str:
|
|
14
|
+
"""Strip user:password from any URL substrings in ``text``."""
|
|
15
|
+
return _CRED_RE.sub(r"\1://***@", text)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class HealthReport:
|
|
20
|
+
broker_ok: bool
|
|
21
|
+
broker_type: str = ""
|
|
22
|
+
error: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def check_broker(broker: Any, *, timeout: float = 5.0) -> HealthReport:
|
|
26
|
+
"""Probe the broker.
|
|
27
|
+
|
|
28
|
+
- ``InMemoryBroker`` is always healthy when constructed; we still verify
|
|
29
|
+
that ``startup()`` ran by inspecting ``is_worker_process`` plus the
|
|
30
|
+
``_running`` flag taskiq sets internally.
|
|
31
|
+
- For redis / nats brokers we attempt a no-op kick using the broker's
|
|
32
|
+
private startup machinery within ``timeout`` seconds.
|
|
33
|
+
"""
|
|
34
|
+
btype = type(broker).__name__
|
|
35
|
+
|
|
36
|
+
async def _probe() -> None:
|
|
37
|
+
# InMemoryBroker has no remote dependency — touching .is_worker_process
|
|
38
|
+
# is enough. For network brokers we read .connection / .pool /
|
|
39
|
+
# ._is_started; if any expected attribute raises, surface as failure.
|
|
40
|
+
is_worker = getattr(broker, "is_worker_process", None)
|
|
41
|
+
if is_worker is None:
|
|
42
|
+
raise RuntimeError("broker does not expose is_worker_process")
|
|
43
|
+
# Trigger any lazy connection setup the broker has wired into kick():
|
|
44
|
+
# most broker types lazily open the network only on first send. We
|
|
45
|
+
# don't actually kick a task (that would require a registered name);
|
|
46
|
+
# instead we look at ``_is_started`` if present.
|
|
47
|
+
started = getattr(broker, "_is_started", True)
|
|
48
|
+
if started is False:
|
|
49
|
+
raise RuntimeError("broker not started")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
await asyncio.wait_for(_probe(), timeout=timeout)
|
|
53
|
+
return HealthReport(broker_ok=True, broker_type=btype)
|
|
54
|
+
except TimeoutError:
|
|
55
|
+
return HealthReport(broker_ok=False, broker_type=btype, error="timeout")
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
return HealthReport(broker_ok=False, broker_type=btype, error=_scrub(str(exc)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["HealthReport", "check_broker"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""init_taskiq + get_broker + WeakKeyDictionary registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from weakref import WeakKeyDictionary
|
|
7
|
+
|
|
8
|
+
from hawkapi import HTTPException, Request
|
|
9
|
+
|
|
10
|
+
from ._broker import create_broker
|
|
11
|
+
from ._config import TaskIQConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _StateNamespace:
|
|
15
|
+
taskiq: Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_ACTIVE: WeakKeyDictionary[Any, Any] = WeakKeyDictionary()
|
|
19
|
+
_LAST: list[Any | None] = [None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init_taskiq(
|
|
23
|
+
app: Any,
|
|
24
|
+
*,
|
|
25
|
+
broker: Any = None,
|
|
26
|
+
config: TaskIQConfig | None = None,
|
|
27
|
+
auto_startup: bool = True,
|
|
28
|
+
) -> Any:
|
|
29
|
+
"""Attach a TaskIQ broker to ``app.state.taskiq``.
|
|
30
|
+
|
|
31
|
+
Pass ``broker=`` with a pre-built broker, or ``config=`` to construct one
|
|
32
|
+
via :func:`create_broker`. ``auto_startup=True`` wires ``broker.startup()``
|
|
33
|
+
and ``broker.shutdown()`` into the app lifecycle.
|
|
34
|
+
"""
|
|
35
|
+
if broker is None:
|
|
36
|
+
broker = create_broker(config)
|
|
37
|
+
|
|
38
|
+
# Reject double-initialisation — registering lifecycle hooks twice would
|
|
39
|
+
# call startup()/shutdown() on the stale broker after this one replaces it.
|
|
40
|
+
if getattr(app, "state", None) is not None and getattr(app.state, "taskiq", None) is not None:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"init_taskiq has already been called on this app; create a new app or "
|
|
43
|
+
"drop the previous broker explicitly before re-initialising"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if getattr(app, "state", None) is None:
|
|
47
|
+
app.state = _StateNamespace()
|
|
48
|
+
app.state.taskiq = broker
|
|
49
|
+
try:
|
|
50
|
+
_ACTIVE[app] = broker
|
|
51
|
+
except TypeError:
|
|
52
|
+
pass
|
|
53
|
+
_LAST[0] = broker
|
|
54
|
+
|
|
55
|
+
if auto_startup and hasattr(app, "on_startup"):
|
|
56
|
+
|
|
57
|
+
async def _start() -> None:
|
|
58
|
+
startup = getattr(broker, "startup", None)
|
|
59
|
+
if startup is not None:
|
|
60
|
+
await startup()
|
|
61
|
+
|
|
62
|
+
app.on_startup(_start)
|
|
63
|
+
|
|
64
|
+
if auto_startup and hasattr(app, "on_shutdown"):
|
|
65
|
+
|
|
66
|
+
async def _stop() -> None:
|
|
67
|
+
shutdown = getattr(broker, "shutdown", None)
|
|
68
|
+
if shutdown is not None:
|
|
69
|
+
await shutdown()
|
|
70
|
+
|
|
71
|
+
app.on_shutdown(_stop)
|
|
72
|
+
|
|
73
|
+
return broker
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_broker(app: Any) -> Any | None:
|
|
77
|
+
if app is None:
|
|
78
|
+
return _LAST[0]
|
|
79
|
+
try:
|
|
80
|
+
found = _ACTIVE.get(app)
|
|
81
|
+
except TypeError:
|
|
82
|
+
found = None
|
|
83
|
+
if found is not None:
|
|
84
|
+
return found
|
|
85
|
+
state = getattr(app, "state", None)
|
|
86
|
+
if state is not None and hasattr(state, "taskiq"):
|
|
87
|
+
return state.taskiq
|
|
88
|
+
return _LAST[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_broker(request: Request) -> Any:
|
|
92
|
+
broker = resolve_broker(request.scope.get("app"))
|
|
93
|
+
if broker is None:
|
|
94
|
+
raise HTTPException(500, detail="TaskIQ not configured — call init_taskiq(app, ...) first")
|
|
95
|
+
return broker
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["get_broker", "init_taskiq", "resolve_broker"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Cron-syntax validator for schedule entries.
|
|
2
|
+
|
|
3
|
+
For v0.1.0 we ship only the value-type :class:`Scheduled` with validation —
|
|
4
|
+
it is up to the operator to wire it into TaskIQ's native scheduler sources.
|
|
5
|
+
See README for the recommended pattern.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Scheduled:
|
|
15
|
+
"""A schedule descriptor with validation at construction time."""
|
|
16
|
+
|
|
17
|
+
cron: str = ""
|
|
18
|
+
interval_seconds: float = 0.0
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if (self.cron and self.interval_seconds) or (not self.cron and not self.interval_seconds):
|
|
22
|
+
raise ValueError("Scheduled requires exactly one of `cron` or `interval_seconds`")
|
|
23
|
+
if self.cron:
|
|
24
|
+
# Validate cron syntax up-front. croniter is an optional dep — if not
|
|
25
|
+
# installed, we skip validation rather than fail registration.
|
|
26
|
+
try:
|
|
27
|
+
from croniter import croniter # type: ignore[import-not-found]
|
|
28
|
+
except ImportError:
|
|
29
|
+
return
|
|
30
|
+
if not croniter.is_valid(self.cron):
|
|
31
|
+
raise ValueError(f"invalid cron expression: {self.cron!r}")
|
|
32
|
+
elif self.interval_seconds <= 0:
|
|
33
|
+
raise ValueError("interval_seconds must be > 0")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["Scheduled"]
|
hawkapi_taskiq/_tasks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Task decorator + name-collision guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def task(
|
|
10
|
+
broker: Any,
|
|
11
|
+
*,
|
|
12
|
+
name: str | None = None,
|
|
13
|
+
queue: str | None = None,
|
|
14
|
+
retry_on_error: bool = False,
|
|
15
|
+
max_retries: int = 3,
|
|
16
|
+
**task_kwargs: Any,
|
|
17
|
+
) -> Callable[[Callable[..., Any]], Any]:
|
|
18
|
+
"""Register a function as a TaskIQ task.
|
|
19
|
+
|
|
20
|
+
- Detects ``async def`` automatically — runs as coroutine on the worker.
|
|
21
|
+
- Sync functions are wrapped via TaskIQ's normal handling.
|
|
22
|
+
- Collision detection: registering the same ``name`` twice raises
|
|
23
|
+
``ValueError`` (TaskIQ silently overrides; we disallow).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def decorator(fn: Callable[..., Any]) -> Any:
|
|
27
|
+
task_name = name or f"{fn.__module__}.{fn.__qualname__}"
|
|
28
|
+
existing = getattr(broker, "_hawkapi_task_names", None)
|
|
29
|
+
if existing is None:
|
|
30
|
+
existing = set()
|
|
31
|
+
broker._hawkapi_task_names = existing
|
|
32
|
+
if task_name in existing:
|
|
33
|
+
raise ValueError(f"task name {task_name!r} already registered")
|
|
34
|
+
existing.add(task_name)
|
|
35
|
+
|
|
36
|
+
# Pop reserved keys from ``task_kwargs`` BEFORE merging — otherwise a
|
|
37
|
+
# caller could pass ``task_name=`` in ``**task_kwargs`` and silently
|
|
38
|
+
# bypass the duplicate-name guard above.
|
|
39
|
+
task_kwargs.pop("task_name", None)
|
|
40
|
+
kw: dict[str, Any] = {"task_name": task_name}
|
|
41
|
+
if queue is not None:
|
|
42
|
+
kw["queue"] = queue
|
|
43
|
+
if retry_on_error:
|
|
44
|
+
kw["retry_on_error"] = True
|
|
45
|
+
kw["max_retries"] = max_retries
|
|
46
|
+
kw.update(task_kwargs)
|
|
47
|
+
|
|
48
|
+
return broker.task(**kw)(fn)
|
|
49
|
+
|
|
50
|
+
return decorator
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["task"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Test helpers — in-memory broker fixture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._broker import create_broker
|
|
10
|
+
from ._config import TaskIQConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@asynccontextmanager
|
|
14
|
+
async def in_memory_broker() -> AsyncGenerator[Any, None]:
|
|
15
|
+
"""Yield a started InMemoryBroker; shut it down on exit."""
|
|
16
|
+
broker = create_broker(TaskIQConfig(broker_url="memory://"))
|
|
17
|
+
await broker.startup()
|
|
18
|
+
try:
|
|
19
|
+
yield broker
|
|
20
|
+
finally:
|
|
21
|
+
await broker.shutdown()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["in_memory_broker"]
|
hawkapi_taskiq/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hawkapi-taskiq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TaskIQ integration for HawkAPI — modern async-native task queue, DI, scheduling, JSON-only formatter
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/hawkapi-taskiq/
|
|
6
|
+
Project-URL: Repository, https://github.com/ashimov/hawkapi-taskiq
|
|
7
|
+
Project-URL: Issues, https://github.com/ashimov/hawkapi-taskiq/issues
|
|
8
|
+
Author-email: HawkAPI Contributors <hawkapi@users.noreply.github.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 HawkAPI Contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: async,hawkapi,queue,scheduling,taskiq,tasks
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Framework :: AsyncIO
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
40
|
+
Classifier: Typing :: Typed
|
|
41
|
+
Requires-Python: >=3.12
|
|
42
|
+
Requires-Dist: hawkapi>=0.1.7
|
|
43
|
+
Requires-Dist: taskiq>=0.11
|
|
44
|
+
Provides-Extra: cron
|
|
45
|
+
Requires-Dist: croniter>=2.0; extra == 'cron'
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: croniter>=2.0; extra == 'dev'
|
|
48
|
+
Requires-Dist: pyright>=1.1; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
52
|
+
Provides-Extra: nats
|
|
53
|
+
Requires-Dist: taskiq-nats>=0.5; extra == 'nats'
|
|
54
|
+
Provides-Extra: redis
|
|
55
|
+
Requires-Dist: taskiq-redis>=1.0; extra == 'redis'
|
|
56
|
+
Description-Content-Type: text/markdown
|
|
57
|
+
|
|
58
|
+
# hawkapi-taskiq
|
|
59
|
+
|
|
60
|
+
[TaskIQ](https://taskiq-python.github.io/) integration for [HawkAPI](https://github.com/ashimov/HawkAPI). Modern async-native task queue — a lighter, async-first alternative to Celery.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install hawkapi-taskiq
|
|
66
|
+
pip install 'hawkapi-taskiq[redis]' # + taskiq-redis
|
|
67
|
+
pip install 'hawkapi-taskiq[nats]' # + taskiq-nats
|
|
68
|
+
pip install 'hawkapi-taskiq[cron]' # + croniter for schedule validation
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quickstart
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from hawkapi import Depends, HawkAPI
|
|
75
|
+
from hawkapi_taskiq import TaskIQConfig, get_broker, init_taskiq, task
|
|
76
|
+
|
|
77
|
+
app = HawkAPI()
|
|
78
|
+
broker = init_taskiq(app, config=TaskIQConfig(broker_url="redis://localhost:6379/0"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@task(broker, name="emails.send")
|
|
82
|
+
async def send_email(to: str, subject: str) -> None:
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.post("/notify")
|
|
87
|
+
async def notify(email: str, b = Depends(get_broker)):
|
|
88
|
+
await send_email.kiq(email, "Hello")
|
|
89
|
+
return {"ok": True}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Broker selection
|
|
93
|
+
|
|
94
|
+
Choose by URL scheme — all others are rejected:
|
|
95
|
+
|
|
96
|
+
| URL | Broker |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `memory://` | `InMemoryBroker` (tests, single-process) |
|
|
99
|
+
| `redis://host:6379/0` | `ListQueueBroker` (taskiq-redis) |
|
|
100
|
+
| `rediss://...` | same, with TLS |
|
|
101
|
+
| `nats://server:4222` | `NatsBroker` (taskiq-nats) |
|
|
102
|
+
|
|
103
|
+
Any other scheme raises `ValueError` at `create_broker()` — this is a security feature, not a limitation. The allowlist prevents accidentally enabling brokers that use unsafe deserialization formats.
|
|
104
|
+
|
|
105
|
+
## Scheduling
|
|
106
|
+
|
|
107
|
+
v0.1.0 ships a `Scheduled` value type with cron-syntax validation. Wire it to TaskIQ's native scheduler yourself — we deliberately avoid a "magic" registration helper that doesn't compose cleanly with the upstream `TaskiqScheduler`:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from hawkapi_taskiq import Scheduled
|
|
111
|
+
from taskiq import TaskiqScheduler
|
|
112
|
+
from taskiq.schedule_sources import LabelScheduleSource
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@task(broker, name="myapp.cleanup")
|
|
116
|
+
async def cleanup() -> None:
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# 1. Validate the schedule (cron syntax) up front.
|
|
121
|
+
schedule = Scheduled(cron="0 * * * *") # raises ValueError if malformed
|
|
122
|
+
|
|
123
|
+
# 2. Apply it as a label LabelScheduleSource reads.
|
|
124
|
+
cleanup.labels["schedule"] = [{"cron": schedule.cron, "args": [], "kwargs": {}}]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# 3. Run a scheduler process alongside the worker.
|
|
128
|
+
scheduler = TaskiqScheduler(broker=broker, sources=[LabelScheduleSource(broker)])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`Scheduled(cron="...")` validates via [`croniter`](https://github.com/kiorky/croniter) (install with `[cron]` extra). Both `cron` and `interval_seconds` set are rejected (exactly one is required).
|
|
132
|
+
|
|
133
|
+
## Health
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from hawkapi_taskiq import check_broker
|
|
137
|
+
|
|
138
|
+
report = await check_broker(broker)
|
|
139
|
+
# HealthReport(broker_ok=True, broker_type="ListQueueBroker", error="")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Testing
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from hawkapi_taskiq import in_memory_broker, task
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_my_task():
|
|
149
|
+
async with in_memory_broker() as broker:
|
|
150
|
+
@task(broker, name="t.work")
|
|
151
|
+
async def work(x: int) -> int:
|
|
152
|
+
return x * 2
|
|
153
|
+
|
|
154
|
+
await work.kiq(21)
|
|
155
|
+
# Execute pending tasks via TaskIQ's normal flow.
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Security
|
|
159
|
+
|
|
160
|
+
- **JSON-only serialization** — TaskIQ defaults are fine; we explicitly reject any other serializer via `TaskIQConfig.serializer` to prevent arbitrary-deserialization at consume time (CWE-502).
|
|
161
|
+
- **Broker URL scheme allowlist** — only `memory://`, `redis://`, `rediss://`, `nats://`.
|
|
162
|
+
- **Task name registry** — duplicate `@task(name=...)` raises at registration. TaskIQ silently overrides; we disallow.
|
|
163
|
+
- **Cron expression validation** at registration time — malformed expressions fail fast.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
git clone https://github.com/ashimov/hawkapi-taskiq.git
|
|
169
|
+
cd hawkapi-taskiq
|
|
170
|
+
uv sync --extra dev
|
|
171
|
+
uv run pytest -q
|
|
172
|
+
uv run ruff check . && uv run ruff format --check .
|
|
173
|
+
uv run pyright src/
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
hawkapi_taskiq/__init__.py,sha256=mQtwxBvA7sjc3BG8Cwlv-VjkkaRWlQ0JaFiOGgGMTkg,987
|
|
2
|
+
hawkapi_taskiq/_broker.py,sha256=zvf5fTppAU5ucXgCqaK07ob9a0vllfuBUGR3Amst-sU,2591
|
|
3
|
+
hawkapi_taskiq/_config.py,sha256=JXHs4BCw5nbPa-APP_a_RmhRuSYtDOEyRCCaXM0725U,1222
|
|
4
|
+
hawkapi_taskiq/_health.py,sha256=cu3oAHYz9GOnzR9sSDQr1jQesSI8EThMnNkkalSdSds,2192
|
|
5
|
+
hawkapi_taskiq/_plugin.py,sha256=z9HwEBaBsqnViXaf0SMgRNG3S0vLpV8KrTNEvwm43ao,2732
|
|
6
|
+
hawkapi_taskiq/_schedule.py,sha256=9ZUHqHTM_cdYU3_qMYCNdnSVT4UWdaLWmcQMfqbm1KM,1301
|
|
7
|
+
hawkapi_taskiq/_tasks.py,sha256=BG1Bb3wHl2LhDoi_CNrAQQTAXyV7MeteVYPADuxah_w,1711
|
|
8
|
+
hawkapi_taskiq/_testing.py,sha256=syGKcLPcdls70CpQoKpL3rb_NSep1djpot7fcV0201s,611
|
|
9
|
+
hawkapi_taskiq/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
10
|
+
hawkapi_taskiq-0.1.0.dist-info/METADATA,sha256=dm-1osTS5brz-ytuyv820RXlOr0ixfAoqtQDIJhzdWU,6501
|
|
11
|
+
hawkapi_taskiq-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
hawkapi_taskiq-0.1.0.dist-info/licenses/LICENSE,sha256=_RpjhvsfLqqeG_gv2cRatjIxCTGXTpXhKU9jqLZXYa4,1077
|
|
13
|
+
hawkapi_taskiq-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HawkAPI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|