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/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"]
|