taskq-py 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.
- taskq/__init__.py +147 -0
- taskq/_di/__init__.py +45 -0
- taskq/_di/_utils.py +13 -0
- taskq/_di/_validate.py +456 -0
- taskq/_di/lifecycle.py +52 -0
- taskq/_di/registry.py +342 -0
- taskq/_di/scope.py +12 -0
- taskq/_di/scopes.py +467 -0
- taskq/_di/solver.py +159 -0
- taskq/_di/types.py +121 -0
- taskq/_dsn.py +18 -0
- taskq/_ids.py +128 -0
- taskq/_json.py +105 -0
- taskq/_scope.py +39 -0
- taskq/actor.py +752 -0
- taskq/backend/__init__.py +69 -0
- taskq/backend/_cursor.py +32 -0
- taskq/backend/_dispatch.py +114 -0
- taskq/backend/_dispatch_sql.py +301 -0
- taskq/backend/_enqueue.py +490 -0
- taskq/backend/_notify.py +49 -0
- taskq/backend/_protocol.py +854 -0
- taskq/backend/_reads.py +177 -0
- taskq/backend/_records.py +116 -0
- taskq/backend/_schedules.py +226 -0
- taskq/backend/_sql.py +103 -0
- taskq/backend/_sql_templates.py +543 -0
- taskq/backend/_sweeps.py +487 -0
- taskq/backend/_terminal.py +830 -0
- taskq/backend/clock.py +47 -0
- taskq/backend/postgres.py +656 -0
- taskq/backend/statemachine.py +50 -0
- taskq/batch.py +274 -0
- taskq/cli.py +670 -0
- taskq/client/__init__.py +25 -0
- taskq/client/_args.py +233 -0
- taskq/client/_enqueuer.py +347 -0
- taskq/client/_handle.py +321 -0
- taskq/client/_jobs.py +756 -0
- taskq/client/_taskq.py +647 -0
- taskq/client/_transport.py +112 -0
- taskq/constants.py +152 -0
- taskq/context.py +171 -0
- taskq/contrib/__init__.py +1 -0
- taskq/contrib/kubernetes/__init__.py +1 -0
- taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
- taskq/contrib/prometheus/__init__.py +10 -0
- taskq/contrib/prometheus/_metrics.py +63 -0
- taskq/contrib/prometheus/rules.yaml +113 -0
- taskq/cron.py +332 -0
- taskq/di.py +7 -0
- taskq/exceptions.py +398 -0
- taskq/migrate.py +248 -0
- taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
- taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
- taskq/migrations/__init__.py +13 -0
- taskq/obs/__init__.py +120 -0
- taskq/obs/_otel.py +583 -0
- taskq/obs/_structlog.py +241 -0
- taskq/obs/error_reporter.py +120 -0
- taskq/progress/__init__.py +5 -0
- taskq/progress/_buffer.py +96 -0
- taskq/progress/_events.py +34 -0
- taskq/progress/_flush.py +140 -0
- taskq/progress/_publish.py +200 -0
- taskq/py.typed +0 -0
- taskq/ratelimit/__init__.py +42 -0
- taskq/ratelimit/_decision_log.py +38 -0
- taskq/ratelimit/_provider.py +90 -0
- taskq/ratelimit/_redis_utils.py +79 -0
- taskq/ratelimit/_scripts.py +265 -0
- taskq/ratelimit/_sliding_window_pg.py +432 -0
- taskq/ratelimit/_sliding_window_redis.py +398 -0
- taskq/ratelimit/composition.py +93 -0
- taskq/ratelimit/decision.py +47 -0
- taskq/ratelimit/refs.py +87 -0
- taskq/ratelimit/registry.py +678 -0
- taskq/ratelimit/reservation.py +593 -0
- taskq/ratelimit/sliding_window.py +570 -0
- taskq/ratelimit/token_bucket.py +754 -0
- taskq/retry.py +582 -0
- taskq/scheduler.py +59 -0
- taskq/settings.py +797 -0
- taskq/testing/__init__.py +102 -0
- taskq/testing/_dispatch.py +158 -0
- taskq/testing/_enqueue.py +225 -0
- taskq/testing/_reads.py +101 -0
- taskq/testing/_runner.py +650 -0
- taskq/testing/_slots.py +108 -0
- taskq/testing/_sweeps.py +184 -0
- taskq/testing/_terminal.py +666 -0
- taskq/testing/actor.py +322 -0
- taskq/testing/assertions.py +311 -0
- taskq/testing/asyncpg_chaos.py +157 -0
- taskq/testing/clock.py +49 -0
- taskq/testing/fixtures.py +860 -0
- taskq/testing/in_memory.py +773 -0
- taskq/testing/job_context.py +63 -0
- taskq/testing/jobs.py +190 -0
- taskq/testing/otel.py +251 -0
- taskq/testing/pg.py +310 -0
- taskq/testing/settings.py +71 -0
- taskq/testing/spy.py +15 -0
- taskq/types.py +48 -0
- taskq/web/__init__.py +1 -0
- taskq/web/admin/__init__.py +28 -0
- taskq/web/admin/_constants.py +26 -0
- taskq/web/admin/_factory.py +403 -0
- taskq/web/admin/_history.py +250 -0
- taskq/web/admin/_jsonb.py +23 -0
- taskq/web/admin/_listen.py +107 -0
- taskq/web/admin/_static.py +25 -0
- taskq/web/admin/auth/__init__.py +42 -0
- taskq/web/admin/auth/_session.py +190 -0
- taskq/web/admin/auth/oidc.py +299 -0
- taskq/web/admin/auth/saml.py +213 -0
- taskq/web/admin/auth/token.py +35 -0
- taskq/web/admin/jobs.py +555 -0
- taskq/web/admin/ops.py +658 -0
- taskq/web/admin/queues.py +197 -0
- taskq/web/admin/sse.py +104 -0
- taskq/web/admin/workers.py +105 -0
- taskq/web/health.py +81 -0
- taskq/web/progress.py +445 -0
- taskq/web/static/admin.css +2 -0
- taskq/web/static/admin.js +218 -0
- taskq/web/static/alpine.min.js +5 -0
- taskq/web/static/htmx.min.js +1 -0
- taskq/web/static/realtime.js +246 -0
- taskq/web/static/sse.min.js +1 -0
- taskq/web/static/tailwind.css +3 -0
- taskq/web/templates/_base.html +75 -0
- taskq/web/templates/_partials/job_card.html +97 -0
- taskq/web/templates/_partials/job_table.html +148 -0
- taskq/web/templates/_partials/sse_console.html +4 -0
- taskq/web/templates/_partials/table.html +37 -0
- taskq/web/templates/history.html +98 -0
- taskq/web/templates/job_detail.html +367 -0
- taskq/web/templates/jobs.html +193 -0
- taskq/web/templates/leader.html +73 -0
- taskq/web/templates/queue_detail.html +59 -0
- taskq/web/templates/queues.html +85 -0
- taskq/web/templates/rate_limits.html +104 -0
- taskq/web/templates/reservations.html +93 -0
- taskq/web/templates/schedules.html +76 -0
- taskq/web/templates/workers.html +69 -0
- taskq/worker/__init__.py +69 -0
- taskq/worker/_bootstrap.py +509 -0
- taskq/worker/_consumer.py +896 -0
- taskq/worker/_handlers.py +709 -0
- taskq/worker/_leader_shared.py +390 -0
- taskq/worker/_leader_sweeps.py +441 -0
- taskq/worker/actor_config.py +19 -0
- taskq/worker/budget.py +93 -0
- taskq/worker/cancel.py +438 -0
- taskq/worker/cron_loop.py +300 -0
- taskq/worker/deps.py +296 -0
- taskq/worker/dev.py +160 -0
- taskq/worker/dispatch.py +290 -0
- taskq/worker/health.py +330 -0
- taskq/worker/heartbeat.py +270 -0
- taskq/worker/leader.py +384 -0
- taskq/worker/notify.py +331 -0
- taskq/worker/run.py +576 -0
- taskq/worker/shutdown.py +287 -0
- taskq/worker/startup.py +214 -0
- taskq/worker/workgroup.py +778 -0
- taskq_py-0.1.0.dist-info/METADATA +364 -0
- taskq_py-0.1.0.dist-info/RECORD +172 -0
- taskq_py-0.1.0.dist-info/WHEEL +4 -0
- taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
- taskq_py-0.1.0.dist-info/licenses/LICENSE +21 -0
taskq/_di/types.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""DI type vocabulary for the solver engine."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum, IntEnum
|
|
6
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from taskq._di.scope import Scope
|
|
9
|
+
|
|
10
|
+
type Factory[T] = (
|
|
11
|
+
Callable[..., T]
|
|
12
|
+
| Callable[..., Awaitable[T]]
|
|
13
|
+
| Callable[..., AsyncIterator[T]]
|
|
14
|
+
| Callable[..., Iterator[T]]
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FactoryShape(IntEnum):
|
|
19
|
+
"""Classification of factory shapes for resolution-time dispatch."""
|
|
20
|
+
|
|
21
|
+
SYNC_CALLABLE = 0
|
|
22
|
+
ASYNC_CALLABLE = 1
|
|
23
|
+
ASYNC_GENERATOR = 2
|
|
24
|
+
SYNC_GENERATOR = 3
|
|
25
|
+
VALUE = 4
|
|
26
|
+
CLASS = 5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProviderLifecycle(Enum):
|
|
30
|
+
"""Provider lifecycle shapes for resolution-time dispatch."""
|
|
31
|
+
|
|
32
|
+
Plain = "plain"
|
|
33
|
+
AsyncContextManager = "acm"
|
|
34
|
+
AsyncCloseable = "acloseable"
|
|
35
|
+
SyncCloseable = "scloseable"
|
|
36
|
+
AsyncGenerator = "asyncgen"
|
|
37
|
+
SyncGenerator = "syncgen"
|
|
38
|
+
PlainFactory = "plainfactory"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ProviderEntry[T]:
|
|
43
|
+
"""One registered provider for type T.
|
|
44
|
+
|
|
45
|
+
Field-to-shape mapping (canonical):
|
|
46
|
+
kind="value" → factory_shape == FactoryShape.VALUE
|
|
47
|
+
kind="factory" → factory_shape ∈ {SYNC_CALLABLE, ASYNC_CALLABLE,
|
|
48
|
+
ASYNC_GENERATOR, SYNC_GENERATOR}
|
|
49
|
+
kind="class" → factory_shape == FactoryShape.CLASS
|
|
50
|
+
``register_value/factory/class`` methods are the single point that
|
|
51
|
+
constructs ``ProviderEntry`` and MUST honour this table.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
type_: type[T]
|
|
55
|
+
scope: Scope
|
|
56
|
+
kind: Literal["value", "factory", "class"]
|
|
57
|
+
impl: object # Why: heterogeneous (Factory[T] | type[T] | T) — erasure documented
|
|
58
|
+
factory_shape: FactoryShape
|
|
59
|
+
lifecycle: ProviderLifecycle | None = field(default=None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@runtime_checkable
|
|
63
|
+
class ProviderRegistry(Protocol):
|
|
64
|
+
"""Read-only registry surface consumed by the solver engine.
|
|
65
|
+
|
|
66
|
+
The full implementation (register_value / register_factory /
|
|
67
|
+
register_class / validate / has_provider) is owned by the container
|
|
68
|
+
implementation. The engine only needs the lookup surface defined here.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def providers(self) -> dict[type, ProviderEntry[object]]: ...
|
|
73
|
+
|
|
74
|
+
# Why: runtime_checkable Protocol cannot express per-method type
|
|
75
|
+
# parameters (PEP 695 + runtime_checkable limitation under pyright).
|
|
76
|
+
# The signature is erased from the published shape
|
|
77
|
+
# ``get[T](type_: type[T]) -> ProviderEntry[T]`` to ``type[object] /
|
|
78
|
+
# ProviderEntry[object]``. This is the sanctioned DI erasure boundary.
|
|
79
|
+
def get(self, type_: type[object]) -> ProviderEntry[object]:
|
|
80
|
+
"""Return the registered ProviderEntry for type_.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
MissingProvider: if type_ has no registered provider.
|
|
84
|
+
|
|
85
|
+
The engine relies on this contract: when a parameter has no
|
|
86
|
+
registered provider and is not in passthrough_kwargs, the
|
|
87
|
+
MissingProvider raised by this lookup is allowed to propagate.
|
|
88
|
+
Any test double or alternative implementation MUST match this
|
|
89
|
+
raises-contract — returning None or a sentinel breaks the solver.
|
|
90
|
+
"""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@runtime_checkable
|
|
95
|
+
class ScopeContainer(Protocol):
|
|
96
|
+
"""One scope-lifetime container; owns its own AsyncExitStack.
|
|
97
|
+
|
|
98
|
+
The container is responsible for ALL factory invocation, caching,
|
|
99
|
+
and teardown registration. The solver engine NEVER calls a factory
|
|
100
|
+
directly and NEVER touches an AsyncExitStack — every resolution
|
|
101
|
+
goes through ``get_or_create``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
async def get_or_create(
|
|
105
|
+
self,
|
|
106
|
+
type_: type[object],
|
|
107
|
+
entry: ProviderEntry[object],
|
|
108
|
+
) -> object:
|
|
109
|
+
"""Return a resolved instance for type_, creating it if needed."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def last_cache_hit(self) -> bool:
|
|
114
|
+
"""Whether the most recent ``get_or_create`` served a cached value."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
async def aclose(self) -> None:
|
|
118
|
+
"""Close the container's internal AsyncExitStack with the
|
|
119
|
+
log-and-continue policy.
|
|
120
|
+
"""
|
|
121
|
+
...
|
taskq/_dsn.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Credential-safe DSN host extraction for logging.
|
|
2
|
+
|
|
3
|
+
Single-purpose tiny module — same pattern as :mod:`taskq._json`.
|
|
4
|
+
The leading underscore on the module name signals "internal to taskq."
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
__all__ = ["dsn_host"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dsn_host(dsn: object) -> str:
|
|
13
|
+
"""Extract host from a DSN for safe logging (no credentials)."""
|
|
14
|
+
try:
|
|
15
|
+
parsed = urlparse(str(dsn))
|
|
16
|
+
return parsed.hostname or "unknown"
|
|
17
|
+
except Exception:
|
|
18
|
+
return "unknown"
|
taskq/_ids.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""UUID and base62 generation helpers for TaskQ.
|
|
2
|
+
|
|
3
|
+
Uses UUIDv7 (time-ordered) so job and worker IDs are monotonically
|
|
4
|
+
increasing, which improves B-tree locality for PostgreSQL index inserts
|
|
5
|
+
and makes IDs naturally sortable by creation time.
|
|
6
|
+
|
|
7
|
+
Also provides :func:`new_base62` for short identifiers where a full UUID
|
|
8
|
+
is overkill. Three precision modes are available:
|
|
9
|
+
|
|
10
|
+
- ``"random"`` — pure random, no timestamp. Good for test bucket names
|
|
11
|
+
and other labels where sortability doesn't matter.
|
|
12
|
+
- ``"second"`` — timestamp with second precision + random suffix.
|
|
13
|
+
IDs generated in different seconds sort correctly; within the same
|
|
14
|
+
second, order is arbitrary but IDs are still unique.
|
|
15
|
+
- ``"millisecond"`` — timestamp with millisecond precision + random suffix.
|
|
16
|
+
Finer-grained sortability at the cost of more timestamp characters.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import time as _time
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from uuid import UUID
|
|
25
|
+
|
|
26
|
+
import uuid_utils
|
|
27
|
+
|
|
28
|
+
from taskq.backend._protocol import JobId
|
|
29
|
+
|
|
30
|
+
__all__ = ["new_base62", "new_job_id", "new_uuid"]
|
|
31
|
+
|
|
32
|
+
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
33
|
+
_BASE62_LEN = len(_BASE62)
|
|
34
|
+
|
|
35
|
+
_TS_CHARS_SECOND = 6
|
|
36
|
+
_TS_CHARS_MILLISECOND = 8
|
|
37
|
+
_MIN_LEN_SECOND = _TS_CHARS_SECOND + 1
|
|
38
|
+
_MIN_LEN_MILLISECOND = _TS_CHARS_MILLISECOND + 1
|
|
39
|
+
_MIN_LEN_RANDOM = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Precision(Enum):
|
|
43
|
+
RANDOM = "random"
|
|
44
|
+
SECOND = "second"
|
|
45
|
+
MILLISECOND = "millisecond"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _encode_int(value: int, width: int) -> str:
|
|
49
|
+
out: list[str] = []
|
|
50
|
+
for _ in range(width):
|
|
51
|
+
out.append(_BASE62[value % _BASE62_LEN])
|
|
52
|
+
value //= _BASE62_LEN
|
|
53
|
+
if value:
|
|
54
|
+
raise ValueError(f"value {value} does not fit in {width} base62 characters")
|
|
55
|
+
return "".join(reversed(out))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _random_base62(count: int) -> str:
|
|
59
|
+
n = _BASE62_LEN**count
|
|
60
|
+
rand = int.from_bytes(os.urandom((n.bit_length() + 7) // 8), "big") % n
|
|
61
|
+
return _encode_int(rand, count)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def new_base62(
|
|
65
|
+
length: int = 8,
|
|
66
|
+
*,
|
|
67
|
+
precision: str = "random",
|
|
68
|
+
_now: float | None = None,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Return a base62 identifier of *length* characters.
|
|
71
|
+
|
|
72
|
+
Three *precision* modes control the timestamp prefix:
|
|
73
|
+
|
|
74
|
+
``"random"``
|
|
75
|
+
Pure random — no timestamp. Minimum length 1.
|
|
76
|
+
Good for test bucket names and labels.
|
|
77
|
+
|
|
78
|
+
``"second"``
|
|
79
|
+
Leading chars encode the current Unix timestamp at second
|
|
80
|
+
precision; remaining chars are random. IDs from different
|
|
81
|
+
seconds sort correctly (same B-tree locality benefit as UUIDv7).
|
|
82
|
+
Minimum length 7 (6 timestamp + 1 random).
|
|
83
|
+
|
|
84
|
+
``"millisecond"``
|
|
85
|
+
Same as ``"second"`` but with millisecond timestamp precision.
|
|
86
|
+
Minimum length 9 (8 timestamp + 1 random).
|
|
87
|
+
|
|
88
|
+
:param length: total character count (must be >= minimum for *precision*).
|
|
89
|
+
:param precision: ``"random"``, ``"second"``, or ``"millisecond"``.
|
|
90
|
+
:param _now: override the current time as a Unix timestamp (seconds).
|
|
91
|
+
For testing only; production callers should omit this parameter.
|
|
92
|
+
:raises ValueError: if *length* is below the minimum for *precision*.
|
|
93
|
+
"""
|
|
94
|
+
prec = _Precision(precision)
|
|
95
|
+
|
|
96
|
+
if prec is _Precision.RANDOM:
|
|
97
|
+
if length < _MIN_LEN_RANDOM:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"length must be >= {_MIN_LEN_RANDOM} for random precision, got {length}"
|
|
100
|
+
)
|
|
101
|
+
return _random_base62(length)
|
|
102
|
+
|
|
103
|
+
now = _now if _now is not None else _time.time()
|
|
104
|
+
|
|
105
|
+
if prec is _Precision.SECOND:
|
|
106
|
+
ts_chars = _TS_CHARS_SECOND
|
|
107
|
+
ts_val = int(now)
|
|
108
|
+
min_len = _MIN_LEN_SECOND
|
|
109
|
+
else:
|
|
110
|
+
ts_chars = _TS_CHARS_MILLISECOND
|
|
111
|
+
ts_val = int(now * 1000)
|
|
112
|
+
min_len = _MIN_LEN_MILLISECOND
|
|
113
|
+
|
|
114
|
+
if length < min_len:
|
|
115
|
+
raise ValueError(f"length must be >= {min_len} for {precision} precision, got {length}")
|
|
116
|
+
|
|
117
|
+
rand_chars = length - ts_chars
|
|
118
|
+
return _encode_int(ts_val, ts_chars) + _random_base62(rand_chars)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def new_uuid() -> UUID:
|
|
122
|
+
"""Return a new UUIDv7 as a stdlib :class:`~uuid.UUID`."""
|
|
123
|
+
return UUID(bytes=uuid_utils.uuid7().bytes)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def new_job_id() -> JobId:
|
|
127
|
+
"""Return a new UUIDv7 wrapped as a :data:`JobId`."""
|
|
128
|
+
return JobId(new_uuid())
|
taskq/_json.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""orjson-backed JSON helpers.
|
|
2
|
+
|
|
3
|
+
The library never imports stdlib ``json`` directly. Use ``dumps`` / ``loads``
|
|
4
|
+
from this module so behaviour is consistent (UUID, datetime, numpy support
|
|
5
|
+
where compiled) and the serialization hot path stays fast.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
import orjson
|
|
15
|
+
|
|
16
|
+
__all__ = ["dumps", "dumps_str", "loads", "structlog_serializer"]
|
|
17
|
+
|
|
18
|
+
_UUID_RE = re.compile(
|
|
19
|
+
r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _revive_uuids(value: Any) -> Any:
|
|
25
|
+
"""Recursively convert UUID-like strings back to :class:`uuid.UUID`.
|
|
26
|
+
|
|
27
|
+
orjson serializes :class:`uuid.UUID` to the canonical 36-character hex
|
|
28
|
+
string but deserializes it back as a plain ``str``. This helper walks
|
|
29
|
+
dict values and list items (not dict keys) and converts any string that
|
|
30
|
+
looks like a UUID back to a :class:`uuid.UUID` instance so that the
|
|
31
|
+
round-trip through PostgreSQL jsonb columns is transparent.
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
if len(value) == 36 and _UUID_RE.match(value):
|
|
35
|
+
try:
|
|
36
|
+
return UUID(value)
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
return value
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
d = cast(dict[object, Any], value)
|
|
42
|
+
return {k: _revive_uuids(v) for k, v in d.items()}
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
lst = cast(list[Any], value)
|
|
45
|
+
return [_revive_uuids(v) for v in lst]
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _orjson_fallback(obj: Any) -> Any:
|
|
50
|
+
"""Convert types orjson can't serialize natively to a JSON-safe form.
|
|
51
|
+
|
|
52
|
+
Only reached when *obj* is not a type orjson handles (UUID, datetime,
|
|
53
|
+
str, int, float, bool, None, list, dict). Kept fast-path: the vast
|
|
54
|
+
majority of values never hit this function.
|
|
55
|
+
"""
|
|
56
|
+
cls: type[Any] = type(obj) # pyright: ignore[reportUnknownVariableType] # Why: obj is Any from orjson's default function; type() always returns a valid type object.
|
|
57
|
+
mod = cls.__module__
|
|
58
|
+
name = cls.__qualname__
|
|
59
|
+
|
|
60
|
+
# asyncpg protocol-level UUID — raw record access can leak these into
|
|
61
|
+
# structlog event dicts; convert to standard UUID string form.
|
|
62
|
+
if mod.startswith("asyncpg") and "UUID" in name:
|
|
63
|
+
return str(obj)
|
|
64
|
+
|
|
65
|
+
# bytes in a log event dict — decode with replacement.
|
|
66
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
67
|
+
return obj.decode("utf-8", errors="replace")
|
|
68
|
+
|
|
69
|
+
raise TypeError(f"Type is not JSON serializable: {mod}.{name}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dumps(value: Any, /) -> bytes:
|
|
73
|
+
"""Serialize to bytes. Uses orjson defaults (UTC datetimes, UUID, etc.)."""
|
|
74
|
+
return orjson.dumps(
|
|
75
|
+
value,
|
|
76
|
+
default=_orjson_fallback,
|
|
77
|
+
option=orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z | orjson.OPT_NON_STR_KEYS,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dumps_str(value: Any, /) -> str:
|
|
82
|
+
"""Serialize to ``str``. Use only when the consumer demands text (e.g.,
|
|
83
|
+
asyncpg jsonb codec). Prefer :func:`dumps` for everything else."""
|
|
84
|
+
return dumps(value).decode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def loads(data: bytes | bytearray | memoryview | str, /) -> Any:
|
|
88
|
+
"""Deserialize bytes or text to a Python value.
|
|
89
|
+
|
|
90
|
+
UUID strings produced by :func:`dumps` / :func:`dumps_str` are revived
|
|
91
|
+
back to :class:`uuid.UUID` instances so that PostgreSQL jsonb columns,
|
|
92
|
+
NOTIFY payloads, event details, and other JSON-serialised state round-trip
|
|
93
|
+
correctly.
|
|
94
|
+
"""
|
|
95
|
+
return _revive_uuids(orjson.loads(data))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def structlog_serializer(value: Any, /, **_kwargs: Any) -> str:
|
|
99
|
+
"""Serialize to ``str`` for structlog's ``JSONRenderer(serializer=...)``.
|
|
100
|
+
|
|
101
|
+
Accepts and ignores ``**_kwargs`` (e.g. ``default``) that structlog passes
|
|
102
|
+
internally — orjson handles all types we encounter natively and does not
|
|
103
|
+
use the ``default`` fallback that stdlib ``json`` requires.
|
|
104
|
+
"""
|
|
105
|
+
return dumps_str(value)
|
taskq/_scope.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""DI scope enum and lifecycle warning — zero-dependency leaf module.
|
|
2
|
+
|
|
3
|
+
Extracted from ``taskq._di.scope`` so that ``taskq.exceptions`` can import
|
|
4
|
+
:class:`Scope` without triggering ``taskq._di.__init__`` (which loads
|
|
5
|
+
``taskq._di.scopes`` → ``taskq.context`` → circular import).
|
|
6
|
+
|
|
7
|
+
This module MUST NOT import from any other ``taskq`` submodule.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
|
|
12
|
+
__all__ = ["LifecycleDetectionWarning", "Scope"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Scope(IntEnum):
|
|
16
|
+
"""DI scope lifetime. Higher value = narrower scope.
|
|
17
|
+
|
|
18
|
+
PROCESS: worker process startup -> exit (singletons).
|
|
19
|
+
THREAD: worker thread spawn -> thread close (placeholder for multi-thread worker).
|
|
20
|
+
LOOP: worker loop start -> loop close (pools, long-lived clients).
|
|
21
|
+
TRANSIENT: fresh per injection point - no cache.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
PROCESS = 0
|
|
25
|
+
THREAD = 1
|
|
26
|
+
LOOP = 2
|
|
27
|
+
TRANSIENT = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LifecycleDetectionWarning(UserWarning):
|
|
31
|
+
"""Emitted for DI usage hazards detected during DI operations.
|
|
32
|
+
|
|
33
|
+
Specific timing varies per emission site and is documented at
|
|
34
|
+
each emission. Fires for redundant call-site Scope overrides
|
|
35
|
+
, sync close detection on registered
|
|
36
|
+
classes, and other drift-prone API patterns. Subclass
|
|
37
|
+
of UserWarning so pytest captures it under default filters via
|
|
38
|
+
pytest.warns(LifecycleDetectionWarning).
|
|
39
|
+
"""
|