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.
Files changed (172) hide show
  1. taskq/__init__.py +147 -0
  2. taskq/_di/__init__.py +45 -0
  3. taskq/_di/_utils.py +13 -0
  4. taskq/_di/_validate.py +456 -0
  5. taskq/_di/lifecycle.py +52 -0
  6. taskq/_di/registry.py +342 -0
  7. taskq/_di/scope.py +12 -0
  8. taskq/_di/scopes.py +467 -0
  9. taskq/_di/solver.py +159 -0
  10. taskq/_di/types.py +121 -0
  11. taskq/_dsn.py +18 -0
  12. taskq/_ids.py +128 -0
  13. taskq/_json.py +105 -0
  14. taskq/_scope.py +39 -0
  15. taskq/actor.py +752 -0
  16. taskq/backend/__init__.py +69 -0
  17. taskq/backend/_cursor.py +32 -0
  18. taskq/backend/_dispatch.py +114 -0
  19. taskq/backend/_dispatch_sql.py +301 -0
  20. taskq/backend/_enqueue.py +490 -0
  21. taskq/backend/_notify.py +49 -0
  22. taskq/backend/_protocol.py +854 -0
  23. taskq/backend/_reads.py +177 -0
  24. taskq/backend/_records.py +116 -0
  25. taskq/backend/_schedules.py +226 -0
  26. taskq/backend/_sql.py +103 -0
  27. taskq/backend/_sql_templates.py +543 -0
  28. taskq/backend/_sweeps.py +487 -0
  29. taskq/backend/_terminal.py +830 -0
  30. taskq/backend/clock.py +47 -0
  31. taskq/backend/postgres.py +656 -0
  32. taskq/backend/statemachine.py +50 -0
  33. taskq/batch.py +274 -0
  34. taskq/cli.py +670 -0
  35. taskq/client/__init__.py +25 -0
  36. taskq/client/_args.py +233 -0
  37. taskq/client/_enqueuer.py +347 -0
  38. taskq/client/_handle.py +321 -0
  39. taskq/client/_jobs.py +756 -0
  40. taskq/client/_taskq.py +647 -0
  41. taskq/client/_transport.py +112 -0
  42. taskq/constants.py +152 -0
  43. taskq/context.py +171 -0
  44. taskq/contrib/__init__.py +1 -0
  45. taskq/contrib/kubernetes/__init__.py +1 -0
  46. taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
  47. taskq/contrib/prometheus/__init__.py +10 -0
  48. taskq/contrib/prometheus/_metrics.py +63 -0
  49. taskq/contrib/prometheus/rules.yaml +113 -0
  50. taskq/cron.py +332 -0
  51. taskq/di.py +7 -0
  52. taskq/exceptions.py +398 -0
  53. taskq/migrate.py +248 -0
  54. taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
  55. taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
  56. taskq/migrations/__init__.py +13 -0
  57. taskq/obs/__init__.py +120 -0
  58. taskq/obs/_otel.py +583 -0
  59. taskq/obs/_structlog.py +241 -0
  60. taskq/obs/error_reporter.py +120 -0
  61. taskq/progress/__init__.py +5 -0
  62. taskq/progress/_buffer.py +96 -0
  63. taskq/progress/_events.py +34 -0
  64. taskq/progress/_flush.py +140 -0
  65. taskq/progress/_publish.py +200 -0
  66. taskq/py.typed +0 -0
  67. taskq/ratelimit/__init__.py +42 -0
  68. taskq/ratelimit/_decision_log.py +38 -0
  69. taskq/ratelimit/_provider.py +90 -0
  70. taskq/ratelimit/_redis_utils.py +79 -0
  71. taskq/ratelimit/_scripts.py +265 -0
  72. taskq/ratelimit/_sliding_window_pg.py +432 -0
  73. taskq/ratelimit/_sliding_window_redis.py +398 -0
  74. taskq/ratelimit/composition.py +93 -0
  75. taskq/ratelimit/decision.py +47 -0
  76. taskq/ratelimit/refs.py +87 -0
  77. taskq/ratelimit/registry.py +678 -0
  78. taskq/ratelimit/reservation.py +593 -0
  79. taskq/ratelimit/sliding_window.py +570 -0
  80. taskq/ratelimit/token_bucket.py +754 -0
  81. taskq/retry.py +582 -0
  82. taskq/scheduler.py +59 -0
  83. taskq/settings.py +797 -0
  84. taskq/testing/__init__.py +102 -0
  85. taskq/testing/_dispatch.py +158 -0
  86. taskq/testing/_enqueue.py +225 -0
  87. taskq/testing/_reads.py +101 -0
  88. taskq/testing/_runner.py +650 -0
  89. taskq/testing/_slots.py +108 -0
  90. taskq/testing/_sweeps.py +184 -0
  91. taskq/testing/_terminal.py +666 -0
  92. taskq/testing/actor.py +322 -0
  93. taskq/testing/assertions.py +311 -0
  94. taskq/testing/asyncpg_chaos.py +157 -0
  95. taskq/testing/clock.py +49 -0
  96. taskq/testing/fixtures.py +860 -0
  97. taskq/testing/in_memory.py +773 -0
  98. taskq/testing/job_context.py +63 -0
  99. taskq/testing/jobs.py +190 -0
  100. taskq/testing/otel.py +251 -0
  101. taskq/testing/pg.py +310 -0
  102. taskq/testing/settings.py +71 -0
  103. taskq/testing/spy.py +15 -0
  104. taskq/types.py +48 -0
  105. taskq/web/__init__.py +1 -0
  106. taskq/web/admin/__init__.py +28 -0
  107. taskq/web/admin/_constants.py +26 -0
  108. taskq/web/admin/_factory.py +403 -0
  109. taskq/web/admin/_history.py +250 -0
  110. taskq/web/admin/_jsonb.py +23 -0
  111. taskq/web/admin/_listen.py +107 -0
  112. taskq/web/admin/_static.py +25 -0
  113. taskq/web/admin/auth/__init__.py +42 -0
  114. taskq/web/admin/auth/_session.py +190 -0
  115. taskq/web/admin/auth/oidc.py +299 -0
  116. taskq/web/admin/auth/saml.py +213 -0
  117. taskq/web/admin/auth/token.py +35 -0
  118. taskq/web/admin/jobs.py +555 -0
  119. taskq/web/admin/ops.py +658 -0
  120. taskq/web/admin/queues.py +197 -0
  121. taskq/web/admin/sse.py +104 -0
  122. taskq/web/admin/workers.py +105 -0
  123. taskq/web/health.py +81 -0
  124. taskq/web/progress.py +445 -0
  125. taskq/web/static/admin.css +2 -0
  126. taskq/web/static/admin.js +218 -0
  127. taskq/web/static/alpine.min.js +5 -0
  128. taskq/web/static/htmx.min.js +1 -0
  129. taskq/web/static/realtime.js +246 -0
  130. taskq/web/static/sse.min.js +1 -0
  131. taskq/web/static/tailwind.css +3 -0
  132. taskq/web/templates/_base.html +75 -0
  133. taskq/web/templates/_partials/job_card.html +97 -0
  134. taskq/web/templates/_partials/job_table.html +148 -0
  135. taskq/web/templates/_partials/sse_console.html +4 -0
  136. taskq/web/templates/_partials/table.html +37 -0
  137. taskq/web/templates/history.html +98 -0
  138. taskq/web/templates/job_detail.html +367 -0
  139. taskq/web/templates/jobs.html +193 -0
  140. taskq/web/templates/leader.html +73 -0
  141. taskq/web/templates/queue_detail.html +59 -0
  142. taskq/web/templates/queues.html +85 -0
  143. taskq/web/templates/rate_limits.html +104 -0
  144. taskq/web/templates/reservations.html +93 -0
  145. taskq/web/templates/schedules.html +76 -0
  146. taskq/web/templates/workers.html +69 -0
  147. taskq/worker/__init__.py +69 -0
  148. taskq/worker/_bootstrap.py +509 -0
  149. taskq/worker/_consumer.py +896 -0
  150. taskq/worker/_handlers.py +709 -0
  151. taskq/worker/_leader_shared.py +390 -0
  152. taskq/worker/_leader_sweeps.py +441 -0
  153. taskq/worker/actor_config.py +19 -0
  154. taskq/worker/budget.py +93 -0
  155. taskq/worker/cancel.py +438 -0
  156. taskq/worker/cron_loop.py +300 -0
  157. taskq/worker/deps.py +296 -0
  158. taskq/worker/dev.py +160 -0
  159. taskq/worker/dispatch.py +290 -0
  160. taskq/worker/health.py +330 -0
  161. taskq/worker/heartbeat.py +270 -0
  162. taskq/worker/leader.py +384 -0
  163. taskq/worker/notify.py +331 -0
  164. taskq/worker/run.py +576 -0
  165. taskq/worker/shutdown.py +287 -0
  166. taskq/worker/startup.py +214 -0
  167. taskq/worker/workgroup.py +778 -0
  168. taskq_py-0.1.0.dist-info/METADATA +364 -0
  169. taskq_py-0.1.0.dist-info/RECORD +172 -0
  170. taskq_py-0.1.0.dist-info/WHEEL +4 -0
  171. taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
  172. 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
+ """