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/lifecycle.py ADDED
@@ -0,0 +1,52 @@
1
+ """Lifecycle shape detection for DI providers."""
2
+
3
+ import inspect
4
+
5
+ import structlog
6
+
7
+ from taskq._di.types import Factory, ProviderLifecycle
8
+
9
+ logger = structlog.get_logger("taskq._di.lifecycle")
10
+
11
+
12
+ def detect_lifecycle(cls: type) -> ProviderLifecycle:
13
+ """Inspect a class and return its lifecycle shape.
14
+
15
+ Priority order:
16
+ 1. hasattr(cls, '__aenter__') and hasattr(cls, '__aexit__') → AsyncContextManager
17
+ 2. hasattr(cls, 'aclose') → AsyncCloseable
18
+ 3. hasattr(cls, 'close') → SyncCloseable
19
+ 4. otherwise → Plain
20
+
21
+ Pure — no instantiation, no warnings, no logs, no side effects.
22
+ The caller (register_class) owns all WARNING emissions.
23
+ """
24
+ if hasattr(cls, "__aenter__") and hasattr(cls, "__aexit__"):
25
+ return ProviderLifecycle.AsyncContextManager
26
+ if hasattr(cls, "aclose"):
27
+ return ProviderLifecycle.AsyncCloseable
28
+ if hasattr(cls, "close"):
29
+ return ProviderLifecycle.SyncCloseable
30
+ return ProviderLifecycle.Plain
31
+
32
+
33
+ def detect_factory_lifecycle(factory: Factory[object]) -> ProviderLifecycle:
34
+ """Inspect a factory callable and return its lifecycle shape.
35
+
36
+ Priority order:
37
+ 1. inspect.isasyncgenfunction(factory) → AsyncGenerator
38
+ 2. inspect.isgeneratorfunction(factory) → SyncGenerator
39
+ 3. otherwise → PlainFactory
40
+
41
+ Pure — no warnings, no logs, no side effects, no invocation.
42
+ The caller (register_factory) owns all WARNING emissions .
43
+
44
+ factory: Factory[object] — detection inspects code flags, not the
45
+ produced type T; the caller (register_factory[T]) already
46
+ enforces Factory[T].
47
+ """
48
+ if inspect.isasyncgenfunction(factory):
49
+ return ProviderLifecycle.AsyncGenerator
50
+ if inspect.isgeneratorfunction(factory):
51
+ return ProviderLifecycle.SyncGenerator
52
+ return ProviderLifecycle.PlainFactory
taskq/_di/registry.py ADDED
@@ -0,0 +1,342 @@
1
+ """Concrete ProviderRegistry — registration API and seal mechanics.
2
+
3
+ The public surface (register_value / register_factory / register_class,
4
+ has_provider, get[T], providers, validate) matches the
5
+ published block. The validate() method delegates the five-phase startup
6
+ validation algorithm to ``_di._validate.run_validation``.
7
+ """
8
+
9
+ import inspect
10
+ import warnings
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Annotated,
14
+ Any,
15
+ cast,
16
+ get_args,
17
+ get_origin,
18
+ get_type_hints,
19
+ )
20
+
21
+ import structlog
22
+
23
+ from taskq._di._utils import (
24
+ _origin_is_job_context, # pyright: ignore[reportPrivateUsage] — internal helper shared within _di package; the _di prefix itself signals package-level privacy
25
+ )
26
+ from taskq._di._validate import (
27
+ _emit_redundant_override_warnings, # pyright: ignore[reportPrivateUsage] — internal helper shared within _di package; the _di prefix itself signals package-level privacy
28
+ run_validation,
29
+ )
30
+ from taskq._di.lifecycle import detect_factory_lifecycle, detect_lifecycle
31
+ from taskq._di.scope import LifecycleDetectionWarning, Scope
32
+ from taskq._di.types import Factory, FactoryShape, ProviderEntry, ProviderLifecycle
33
+ from taskq.exceptions import DIError, MissingProvider
34
+
35
+ if TYPE_CHECKING:
36
+ from taskq.actor import ActorRef
37
+ from taskq.ratelimit.registry import RateLimitRegistry
38
+
39
+ logger = structlog.get_logger("taskq._di.registry")
40
+
41
+
42
+ class ProviderRegistry:
43
+ """Mutable provider registry with seal guard and edge capture.
44
+
45
+ Structurally satisfies the ``taskq._di.types.ProviderRegistry``
46
+ Protocol without explicit inheritance — the Protocol is
47
+ ``runtime_checkable`` for isinstance checks, but the concrete class
48
+ does not inherit from it to avoid forcing runtime_checkable
49
+ constraints on the implementation.
50
+ """
51
+
52
+ def __init__(self) -> None:
53
+ self._providers: dict[type, ProviderEntry[object]] = {}
54
+ self._dep_edges: list[tuple[type, type, Scope | None]] = []
55
+ self._validated: bool = False
56
+ self._sealed: bool = False
57
+ self._plan_cache: dict[tuple[str, Scope], list[type]] = {}
58
+ self._validating: bool = False
59
+
60
+ def register_value[T](self, type_: type[T], scope: Scope, value: T) -> None:
61
+ if self._sealed:
62
+ raise RuntimeError("registry is sealed after validate()")
63
+ if type_ in self._providers:
64
+ raise ValueError(f"{type_!r} is already registered")
65
+ entry = ProviderEntry(
66
+ type_=type_,
67
+ scope=scope,
68
+ kind="value",
69
+ impl=value,
70
+ factory_shape=FactoryShape.VALUE,
71
+ )
72
+ self._providers[type_] = entry
73
+ logger.debug(
74
+ "provider-registered",
75
+ type_name=type_.__qualname__,
76
+ scope=scope.name,
77
+ kind="value",
78
+ )
79
+
80
+ def register_factory[T](self, type_: type[T], scope: Scope, factory: Factory[T]) -> None:
81
+ if self._sealed:
82
+ raise RuntimeError("registry is sealed after validate()")
83
+ if type_ in self._providers:
84
+ raise ValueError(f"{type_!r} is already registered")
85
+ if not callable(factory):
86
+ raise TypeError(f"factory must be callable, got {type(factory).__qualname__}")
87
+ if inspect.isasyncgenfunction(factory):
88
+ shape = FactoryShape.ASYNC_GENERATOR
89
+ elif inspect.isgeneratorfunction(factory):
90
+ shape = FactoryShape.SYNC_GENERATOR
91
+ elif inspect.iscoroutinefunction(factory):
92
+ shape = FactoryShape.ASYNC_CALLABLE
93
+ else:
94
+ shape = FactoryShape.SYNC_CALLABLE
95
+
96
+ lifecycle = detect_factory_lifecycle(factory)
97
+
98
+ if lifecycle is ProviderLifecycle.SyncGenerator:
99
+ warnings.warn(
100
+ LifecycleDetectionWarning(
101
+ f"{getattr(factory, '__qualname__', repr(factory))} is a sync "
102
+ f"generator factory; its cleanup will run synchronously and "
103
+ f"cannot be awaited by the DI engine."
104
+ ),
105
+ stacklevel=2,
106
+ )
107
+ logger.warning(
108
+ "sync_generator_registered",
109
+ factory_name=getattr(factory, "__qualname__", repr(factory)),
110
+ scope=scope.name,
111
+ )
112
+
113
+ edges = _collect_dep_edges(factory, type_)
114
+ self._dep_edges.extend(edges)
115
+
116
+ entry = ProviderEntry(
117
+ type_=type_,
118
+ scope=scope,
119
+ kind="factory",
120
+ impl=factory,
121
+ factory_shape=shape,
122
+ lifecycle=lifecycle,
123
+ )
124
+ self._providers[type_] = entry
125
+ logger.debug(
126
+ "provider-registered",
127
+ type_name=type_.__qualname__,
128
+ scope=scope.name,
129
+ kind="factory",
130
+ factory_shape=shape.name,
131
+ )
132
+
133
+ def register_class[T](
134
+ self,
135
+ type_: type[T],
136
+ scope: Scope,
137
+ *,
138
+ lifecycle: ProviderLifecycle | None = None,
139
+ ) -> None:
140
+ if self._sealed:
141
+ raise RuntimeError("registry is sealed after validate()")
142
+ if type_ in self._providers:
143
+ raise ValueError(f"{type_!r} is already registered")
144
+
145
+ resolved_lifecycle = lifecycle if lifecycle is not None else detect_lifecycle(type_)
146
+
147
+ if lifecycle is None:
148
+ if resolved_lifecycle is ProviderLifecycle.SyncCloseable:
149
+ warnings.warn(
150
+ LifecycleDetectionWarning(
151
+ f"{type_.__qualname__} has close() but no aclose(); "
152
+ f"the DI engine will call close() via asyncio.to_thread "
153
+ f"at teardown. Prefer an async close (aclose) for "
154
+ f"async-native code."
155
+ ),
156
+ stacklevel=2,
157
+ )
158
+ logger.warning(
159
+ "sync_close_registered",
160
+ class_name=type_.__qualname__,
161
+ scope=scope.name,
162
+ )
163
+ elif (
164
+ resolved_lifecycle is ProviderLifecycle.Plain
165
+ and hasattr(type_, "__enter__")
166
+ and not hasattr(type_, "__aenter__")
167
+ ):
168
+ warnings.warn(
169
+ LifecycleDetectionWarning(
170
+ f"{type_.__qualname__} has __enter__/__exit__ but no "
171
+ f"__aenter__/__aexit__; sync context managers are not "
172
+ f"supported by the async DI engine. The class will be "
173
+ f"managed as Plain (no teardown)."
174
+ ),
175
+ stacklevel=2,
176
+ )
177
+ logger.warning(
178
+ "sync_context_manager_not_supported",
179
+ class_name=type_.__qualname__,
180
+ scope=scope.name,
181
+ )
182
+
183
+ edges = _collect_dep_edges(type_.__init__, type_)
184
+ self._dep_edges.extend(edges)
185
+
186
+ entry = ProviderEntry(
187
+ type_=type_,
188
+ scope=scope,
189
+ kind="class",
190
+ impl=type_,
191
+ factory_shape=FactoryShape.CLASS,
192
+ lifecycle=resolved_lifecycle,
193
+ )
194
+ self._providers[type_] = entry
195
+ logger.debug(
196
+ "provider-registered",
197
+ type_name=type_.__qualname__,
198
+ scope=scope.name,
199
+ kind="class",
200
+ )
201
+
202
+ def has_provider(self, type_: type) -> bool:
203
+ return type_ in self._providers
204
+
205
+ def get[T](self, type_: type[T]) -> ProviderEntry[T]:
206
+ entry = self._providers.get(type_)
207
+ if entry is None:
208
+ raise MissingProvider(
209
+ type_name=type_.__qualname__,
210
+ required_by="<unknown>",
211
+ )
212
+ return cast(
213
+ ProviderEntry[T], entry
214
+ ) # Why: internal map is dict[type, ProviderEntry[object]] (erasure boundary); get[T] recovers the type parameter
215
+
216
+ @property
217
+ def providers(self) -> dict[type, ProviderEntry[object]]:
218
+ """Shallow copy preventing external mutation.
219
+
220
+ Why: the internal map is dict[type, ProviderEntry[object]] per
221
+ the erasure boundary.
222
+ Returning a copy prevents callers from mutating registry state.
223
+ """
224
+ return dict(self._providers)
225
+
226
+ def validate(
227
+ self,
228
+ actors: list["ActorRef[Any, Any]"] | None = None,
229
+ rate_limit_registry: "RateLimitRegistry | None" = None,
230
+ ) -> None:
231
+ """Walk all providers and actors; raise on first error; seal on success.
232
+
233
+ Pure graph-walk over registration-time metadata. Never invokes a
234
+ factory, calls a resolver, or performs await — the entire algorithm
235
+ is synchronous introspection on ``_providers`` and ``_dep_edges``.
236
+
237
+ actors: explicit list of ActorRef instances whose DI parameter
238
+ annotations are walked for MissingProvider checks and plan-cache
239
+ population. Defaults to None (no actor walk — only
240
+ provider→provider edges are validated).
241
+
242
+ rate_limit_registry: when provided, each actor's ``rate_limits``
243
+ and ``reservations`` lists are checked against the registry's
244
+ dicts. Unknown names raise ``MissingProvider`` at startup.
245
+ When ``None`` (default), the name-check phase is
246
+ skipped entirely.
247
+
248
+ # Why: ActorRef[Any, Any] is the sanctioned erasure
249
+ # boundary for the heterogeneous actor registry — each ActorRef has
250
+ # different P and R type parameters, and validate() does not need
251
+ # per-actor narrowing. The same erasure is used at the existing API
252
+ # boundary in worker/run.py:283 and cli.py:98.
253
+ """
254
+ if self._validated:
255
+ return
256
+ if self._validating:
257
+ raise RuntimeError("validate() called recursively or concurrently")
258
+ self._validating = True
259
+ try:
260
+ self._plan_cache = run_validation(
261
+ self._providers,
262
+ self._dep_edges,
263
+ actors,
264
+ rate_limit_registry,
265
+ )
266
+ if actors is not None:
267
+ _emit_redundant_override_warnings(actors, self._providers)
268
+ self._validated = True
269
+ self._sealed = True
270
+ logger.info(
271
+ "registry-validated",
272
+ provider_count=len(self._providers),
273
+ actor_count=len(actors) if actors else 0,
274
+ )
275
+ finally:
276
+ self._validating = False
277
+
278
+
279
+ def _collect_dep_edges(
280
+ callable_: object,
281
+ owner_type: type,
282
+ ) -> list[tuple[type, type, Scope | None]]:
283
+ """Walk ``callable_``'s parameter annotations and capture dependency edges.
284
+
285
+ Returns ``(owner_type, dep_type, override_scope_or_None)`` tuples.
286
+ ``owner_type`` is the provider type being registered; ``dep_type`` is
287
+ the annotated dependency; ``override_scope_or_None`` is ``None`` for
288
+ late binding or the explicit ``Scope`` from ``Annotated``.
289
+ """
290
+ if not callable(callable_):
291
+ return []
292
+
293
+ try:
294
+ hints = get_type_hints(callable_, include_extras=True)
295
+ except NameError as err:
296
+ qualname = getattr(callable_, "__qualname__", repr(callable_))
297
+ raise DIError(f"unresolvable annotation in {qualname}: {err}") from err
298
+
299
+ sig = inspect.signature(callable_)
300
+ edges: list[tuple[type, type, Scope | None]] = []
301
+
302
+ for param_name, param in sig.parameters.items():
303
+ if param_name == "self":
304
+ continue
305
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
306
+ continue
307
+
308
+ annotation = hints.get(param_name)
309
+ if annotation is None:
310
+ continue
311
+
312
+ if _origin_is_job_context(annotation) or param_name == "payload":
313
+ continue
314
+
315
+ unwrapped_type: type | None = None
316
+ override_scope: Scope | None = None
317
+ origin = get_origin(annotation)
318
+
319
+ if origin is Annotated:
320
+ args = get_args(annotation)
321
+ if args and isinstance(args[0], type):
322
+ unwrapped_type = args[0]
323
+ scopes: list[Scope] = []
324
+ for meta in args[1:]:
325
+ if isinstance(meta, Scope):
326
+ scopes.append(meta)
327
+ if len(scopes) > 1:
328
+ raise DIError(
329
+ f"parameter '{param_name}' has multiple Scope markers: "
330
+ f"{', '.join(s.name for s in scopes)}"
331
+ )
332
+ if scopes:
333
+ override_scope = scopes[0]
334
+ elif isinstance(annotation, type):
335
+ unwrapped_type = annotation
336
+
337
+ if unwrapped_type is None:
338
+ continue
339
+
340
+ edges.append((owner_type, unwrapped_type, override_scope))
341
+
342
+ return edges
taskq/_di/scope.py ADDED
@@ -0,0 +1,12 @@
1
+ """Re-export shim for :mod:`taskq._scope`.
2
+
3
+ Preserves the ``from taskq._di.scope import Scope`` import path used by
4
+ internal modules. The canonical definitions live in :mod:`taskq._scope`
5
+ so that ``taskq.exceptions`` can import :class:`Scope` without triggering
6
+ ``taskq._di.__init__`` (which would create a circular import through
7
+ ``taskq._di.scopes`` → ``taskq.context``).
8
+ """
9
+
10
+ from taskq._scope import LifecycleDetectionWarning, Scope
11
+
12
+ __all__ = ["LifecycleDetectionWarning", "Scope"]