taskferry 0.2.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.
- taskferry/__init__.py +211 -0
- taskferry/aio.py +486 -0
- taskferry/backends/__init__.py +38 -0
- taskferry/backends/inline.py +235 -0
- taskferry/backends/process.py +292 -0
- taskferry/backends/subprocess.py +390 -0
- taskferry/backends/thread.py +351 -0
- taskferry/capabilities.py +90 -0
- taskferry/cli.py +445 -0
- taskferry/config.py +360 -0
- taskferry/contract/__init__.py +56 -0
- taskferry/contract/base.py +179 -0
- taskferry/contract/inline.py +89 -0
- taskferry/contract/job.py +91 -0
- taskferry/contract/task.py +91 -0
- taskferry/core/__init__.py +130 -0
- taskferry/core/capabilities.py +89 -0
- taskferry/core/config.py +167 -0
- taskferry/core/correlation.py +120 -0
- taskferry/core/delivery.py +36 -0
- taskferry/core/errors.py +55 -0
- taskferry/core/ids.py +37 -0
- taskferry/core/observability.py +136 -0
- taskferry/core/otel.py +83 -0
- taskferry/core/provider.py +50 -0
- taskferry/core/py.typed +0 -0
- taskferry/core/registry.py +92 -0
- taskferry/core/serialization.py +79 -0
- taskferry/core/typing.py +16 -0
- taskferry/envelope.py +197 -0
- taskferry/errors.py +144 -0
- taskferry/execution.py +239 -0
- taskferry/functions.py +290 -0
- taskferry/handle.py +186 -0
- taskferry/hooks.py +238 -0
- taskferry/plugins.py +183 -0
- taskferry/ports.py +356 -0
- taskferry/py.typed +0 -0
- taskferry/retry.py +205 -0
- taskferry/router.py +160 -0
- taskferry/runtime.py +609 -0
- taskferry/specs.py +353 -0
- taskferry/tracking.py +129 -0
- taskferry-0.2.0.dist-info/METADATA +109 -0
- taskferry-0.2.0.dist-info/RECORD +48 -0
- taskferry-0.2.0.dist-info/WHEEL +4 -0
- taskferry-0.2.0.dist-info/entry_points.txt +2 -0
- taskferry-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Delivery-semantics vocabulary (sections 21-22, ADR-0010).
|
|
2
|
+
|
|
3
|
+
Taskferry **never** promises exactly-once. Distributed systems produce
|
|
4
|
+
duplicates; the whole family assumes at-least-once with possible duplicates and
|
|
5
|
+
promotes idempotency. These enums let adapters *declare* their real semantics so
|
|
6
|
+
callers can reason about them instead of guessing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeliveryGuarantee(StrEnum):
|
|
15
|
+
"""How many times a message may be delivered."""
|
|
16
|
+
|
|
17
|
+
AT_MOST_ONCE = "at_most_once"
|
|
18
|
+
"""Delivered zero or one time. Losses possible, duplicates impossible."""
|
|
19
|
+
|
|
20
|
+
AT_LEAST_ONCE = "at_least_once"
|
|
21
|
+
"""Delivered one or more times. Losses impossible, duplicates possible."""
|
|
22
|
+
|
|
23
|
+
# Note: EXACTLY_ONCE is intentionally absent. See ADR-0010.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Ordering(StrEnum):
|
|
27
|
+
"""Ordering guarantee across a stream/queue/topic."""
|
|
28
|
+
|
|
29
|
+
UNORDERED = "unordered"
|
|
30
|
+
PER_KEY = "per_key"
|
|
31
|
+
"""Ordered within a partition/group key (e.g. FIFO message group)."""
|
|
32
|
+
|
|
33
|
+
TOTAL = "total"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["DeliveryGuarantee", "Ordering"]
|
taskferry/core/errors.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The Taskferry error hierarchy root.
|
|
2
|
+
|
|
3
|
+
Adapter distributions define their own leaf errors that subclass
|
|
4
|
+
:class:`TaskferryError`, so a caller can catch the whole family or one adapter.
|
|
5
|
+
The execution-layer hierarchy built on this root lives in :mod:`taskferry.errors`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskferryError(Exception):
|
|
12
|
+
"""Base class for every error raised anywhere in the Taskferry family."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigurationError(TaskferryError):
|
|
16
|
+
"""Raised when configuration is missing, malformed, or contradictory."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProviderError(TaskferryError):
|
|
20
|
+
"""Wraps an error originating from an underlying provider/SDK.
|
|
21
|
+
|
|
22
|
+
Adapters should raise this (chaining the original with ``from``) so callers
|
|
23
|
+
can depend on a stable Taskferry type instead of provider-specific exceptions.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, provider: str | None = None) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.provider = provider
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsupportedCapabilityError(TaskferryError):
|
|
32
|
+
"""Raised when an operation requires a capability the provider lacks.
|
|
33
|
+
|
|
34
|
+
Taskferry never silently simulates a missing capability (ADR-0005). Adapters
|
|
35
|
+
fail loudly with this error, naming the capability and provider.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, capability: str, *, provider: str | None = None) -> None:
|
|
39
|
+
provider_suffix = f" (provider={provider!r})" if provider else ""
|
|
40
|
+
super().__init__(f"Unsupported capability: {capability!r}{provider_suffix}")
|
|
41
|
+
self.capability = capability
|
|
42
|
+
self.provider = provider
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SerializationError(TaskferryError):
|
|
46
|
+
"""Raised when a payload cannot be serialized to / from the wire format."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ConfigurationError",
|
|
51
|
+
"ProviderError",
|
|
52
|
+
"SerializationError",
|
|
53
|
+
"TaskferryError",
|
|
54
|
+
"UnsupportedCapabilityError",
|
|
55
|
+
]
|
taskferry/core/ids.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Portable Taskferry identifiers.
|
|
2
|
+
|
|
3
|
+
A Taskferry id is always Taskferry-owned and never *is* the provider id. Adapters
|
|
4
|
+
keep the provider id alongside it in :class:`~taskferry.core.provider.ProviderMetadata`
|
|
5
|
+
so a flow can be followed across systems (sections 20, 48).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import NewType
|
|
12
|
+
|
|
13
|
+
# A distinct type so a Taskferry id is never accidentally confused with a provider
|
|
14
|
+
# id or an arbitrary string in signatures.
|
|
15
|
+
TaskferryId = NewType("TaskferryId", str)
|
|
16
|
+
|
|
17
|
+
_ID_PREFIXES = frozenset({"tp", "task", "job", "evt", "sch", "run", "corr"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def new_id(prefix: str = "tp") -> TaskferryId:
|
|
21
|
+
"""Return a fresh, URL-safe, sortable-enough Taskferry id.
|
|
22
|
+
|
|
23
|
+
The prefix is advisory and only aids human debugging (e.g. ``job_9f2c...``).
|
|
24
|
+
Uses uuid4 for collision resistance without coordination.
|
|
25
|
+
"""
|
|
26
|
+
if not prefix or not prefix.isidentifier():
|
|
27
|
+
raise ValueError(f"id prefix must be a valid identifier, got {prefix!r}")
|
|
28
|
+
return TaskferryId(f"{prefix}_{uuid.uuid4().hex}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_taskferry_id(value: str) -> bool:
|
|
32
|
+
"""Best-effort check that ``value`` looks like a Taskferry id."""
|
|
33
|
+
prefix, _, rest = value.partition("_")
|
|
34
|
+
return bool(rest) and prefix.isidentifier()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["TaskferryId", "is_taskferry_id", "new_id"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Observability hooks — designed in from v1, mandatory on no one (ADR-0012).
|
|
2
|
+
|
|
3
|
+
Taskferry propagates trace context and correlation and emits spans at consistent
|
|
4
|
+
points, but it does **not** depend on the OpenTelemetry SDK. Out of the box a
|
|
5
|
+
:class:`NoopTracer` is used; installing the ``otel`` extra and calling
|
|
6
|
+
:func:`set_tracer` with an OTel-backed tracer lights everything up.
|
|
7
|
+
|
|
8
|
+
Span names and attribute keys are centralized here so every package
|
|
9
|
+
(``taskferry.task.enqueue``, ``taskferry.job.submit`` ...) stays coherent
|
|
10
|
+
(section 47).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterator, Mapping
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
# --- Semantic span names (section 47) -------------------------------------- #
|
|
21
|
+
SPAN_TASK_ENQUEUE = "taskferry.task.enqueue"
|
|
22
|
+
SPAN_TASK_EXECUTE = "taskferry.task.execute"
|
|
23
|
+
SPAN_JOB_SUBMIT = "taskferry.job.submit"
|
|
24
|
+
SPAN_JOB_POLL = "taskferry.job.poll"
|
|
25
|
+
SPAN_EVENT_PUBLISH = "taskferry.event.publish"
|
|
26
|
+
SPAN_EVENT_CONSUME = "taskferry.event.consume"
|
|
27
|
+
SPAN_SCHEDULE_CREATE = "taskferry.schedule.create"
|
|
28
|
+
|
|
29
|
+
# --- Semantic attribute keys (section 18) ---------------------------------- #
|
|
30
|
+
ATTR_PROVIDER = "taskferry.provider"
|
|
31
|
+
ATTR_TASK_ID = "taskferry.task_id"
|
|
32
|
+
ATTR_JOB_ID = "taskferry.job_id"
|
|
33
|
+
ATTR_EVENT_ID = "taskferry.event_id"
|
|
34
|
+
ATTR_SCHEDULE_ID = "taskferry.schedule_id"
|
|
35
|
+
ATTR_CORRELATION_ID = "taskferry.correlation_id"
|
|
36
|
+
ATTR_ATTEMPT = "taskferry.attempt"
|
|
37
|
+
ATTR_PROVIDER_ID = "taskferry.provider_id"
|
|
38
|
+
|
|
39
|
+
AttributeValue = str | int | float | bool
|
|
40
|
+
Attributes = Mapping[str, AttributeValue]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class Span(Protocol):
|
|
45
|
+
"""Minimal span surface adapters use inside a ``with`` block."""
|
|
46
|
+
|
|
47
|
+
def set_attribute(self, key: str, value: AttributeValue) -> None: ...
|
|
48
|
+
|
|
49
|
+
def record_exception(self, exc: BaseException) -> None: ...
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> Span: ...
|
|
52
|
+
|
|
53
|
+
def __exit__(
|
|
54
|
+
self,
|
|
55
|
+
exc_type: type[BaseException] | None,
|
|
56
|
+
exc: BaseException | None,
|
|
57
|
+
tb: TracebackType | None,
|
|
58
|
+
) -> None: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@runtime_checkable
|
|
62
|
+
class Tracer(Protocol):
|
|
63
|
+
"""A factory of spans. The OTel bridge and the no-op both satisfy this."""
|
|
64
|
+
|
|
65
|
+
def start_span(self, name: str, attributes: Attributes | None = None) -> Span: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _NoopSpan:
|
|
69
|
+
__slots__ = ()
|
|
70
|
+
|
|
71
|
+
def set_attribute(self, key: str, value: AttributeValue) -> None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def record_exception(self, exc: BaseException) -> None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> _NoopSpan:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc: object) -> None:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NoopTracer:
|
|
85
|
+
"""Default tracer: correct, cheap, does nothing."""
|
|
86
|
+
|
|
87
|
+
def start_span(self, name: str, attributes: Attributes | None = None) -> Span:
|
|
88
|
+
return _NoopSpan()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_tracer: Tracer = NoopTracer()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def set_tracer(tracer: Tracer) -> None:
|
|
95
|
+
"""Install the process-wide tracer (e.g. an OpenTelemetry-backed one)."""
|
|
96
|
+
global _tracer
|
|
97
|
+
_tracer = tracer
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_tracer() -> Tracer:
|
|
101
|
+
"""Return the process-wide tracer (a :class:`NoopTracer` until set)."""
|
|
102
|
+
return _tracer
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@contextmanager
|
|
106
|
+
def span(name: str, attributes: Attributes | None = None) -> Iterator[Span]:
|
|
107
|
+
"""Convenience wrapper: ``with span("taskferry.job.submit", {...}) as s:``."""
|
|
108
|
+
started = get_tracer().start_span(name, attributes)
|
|
109
|
+
with started as active:
|
|
110
|
+
yield active
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = [
|
|
114
|
+
"ATTR_ATTEMPT",
|
|
115
|
+
"ATTR_CORRELATION_ID",
|
|
116
|
+
"ATTR_EVENT_ID",
|
|
117
|
+
"ATTR_JOB_ID",
|
|
118
|
+
"ATTR_PROVIDER",
|
|
119
|
+
"ATTR_PROVIDER_ID",
|
|
120
|
+
"ATTR_SCHEDULE_ID",
|
|
121
|
+
"ATTR_TASK_ID",
|
|
122
|
+
"SPAN_EVENT_CONSUME",
|
|
123
|
+
"SPAN_EVENT_PUBLISH",
|
|
124
|
+
"SPAN_JOB_POLL",
|
|
125
|
+
"SPAN_JOB_SUBMIT",
|
|
126
|
+
"SPAN_SCHEDULE_CREATE",
|
|
127
|
+
"SPAN_TASK_ENQUEUE",
|
|
128
|
+
"SPAN_TASK_EXECUTE",
|
|
129
|
+
"Attributes",
|
|
130
|
+
"NoopTracer",
|
|
131
|
+
"Span",
|
|
132
|
+
"Tracer",
|
|
133
|
+
"get_tracer",
|
|
134
|
+
"set_tracer",
|
|
135
|
+
"span",
|
|
136
|
+
]
|
taskferry/core/otel.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""OpenTelemetry bridge (ADR-0012).
|
|
2
|
+
|
|
3
|
+
Installing the ``otel`` extra and calling :func:`configure_opentelemetry` swaps
|
|
4
|
+
the process-wide :class:`~taskferry.core.observability.NoopTracer` for one backed
|
|
5
|
+
by the OpenTelemetry API, so every ``taskferry.*`` span becomes a real OTel span.
|
|
6
|
+
|
|
7
|
+
OpenTelemetry is imported lazily here, so ``import taskferry.core`` (which imports
|
|
8
|
+
this module for the public re-exports) never requires the OTel packages — only
|
|
9
|
+
calling :func:`configure_opentelemetry` does.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .observability import Attributes, Span, Tracer, set_tracer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _OTelSpan:
|
|
21
|
+
"""Adapts an OpenTelemetry ``start_as_current_span`` context manager to :class:`Span`."""
|
|
22
|
+
|
|
23
|
+
__slots__ = ("_cm", "_span")
|
|
24
|
+
|
|
25
|
+
def __init__(self, cm: Any) -> None:
|
|
26
|
+
self._cm = cm
|
|
27
|
+
self._span: Any = None
|
|
28
|
+
|
|
29
|
+
def __enter__(self) -> _OTelSpan:
|
|
30
|
+
self._span = self._cm.__enter__()
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def set_attribute(self, key: str, value: object) -> None:
|
|
34
|
+
if self._span is not None:
|
|
35
|
+
self._span.set_attribute(key, value)
|
|
36
|
+
|
|
37
|
+
def record_exception(self, exc: BaseException) -> None:
|
|
38
|
+
if self._span is not None:
|
|
39
|
+
self._span.record_exception(exc)
|
|
40
|
+
|
|
41
|
+
def __exit__(
|
|
42
|
+
self,
|
|
43
|
+
exc_type: type[BaseException] | None,
|
|
44
|
+
exc: BaseException | None,
|
|
45
|
+
tb: TracebackType | None,
|
|
46
|
+
) -> None:
|
|
47
|
+
if exc is not None and self._span is not None:
|
|
48
|
+
self._span.record_exception(exc)
|
|
49
|
+
self._cm.__exit__(exc_type, exc, tb)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OTelTracer:
|
|
53
|
+
"""A :class:`Tracer` backed by an OpenTelemetry tracer."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, otel_tracer: Any) -> None:
|
|
56
|
+
self._tracer = otel_tracer
|
|
57
|
+
|
|
58
|
+
def start_span(self, name: str, attributes: Attributes | None = None) -> Span:
|
|
59
|
+
cm = self._tracer.start_as_current_span(
|
|
60
|
+
name, attributes=dict(attributes) if attributes else None
|
|
61
|
+
)
|
|
62
|
+
return _OTelSpan(cm)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def configure_opentelemetry(instrumenting_module_name: str = "taskferry") -> Tracer:
|
|
66
|
+
"""Install an OpenTelemetry-backed tracer process-wide and return it.
|
|
67
|
+
|
|
68
|
+
Requires the ``otel`` extra (``opentelemetry-api``). The application is still
|
|
69
|
+
responsible for configuring the OTel ``TracerProvider`` and exporters.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
from opentelemetry import trace
|
|
73
|
+
except ImportError as exc: # pragma: no cover - env-specific
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"OpenTelemetry is required for configure_opentelemetry; "
|
|
76
|
+
"install taskferry-otel (which pulls in opentelemetry-api)"
|
|
77
|
+
) from exc
|
|
78
|
+
tracer = OTelTracer(trace.get_tracer(instrumenting_module_name))
|
|
79
|
+
set_tracer(tracer)
|
|
80
|
+
return tracer
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["OTelTracer", "configure_opentelemetry"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Provider metadata — the escape hatch for provider-specific facts.
|
|
2
|
+
|
|
3
|
+
Portable contracts stay clean; provider-specific truth (the real provider id,
|
|
4
|
+
region, resource name, arbitrary labels) lives here, attached to handles/results.
|
|
5
|
+
This is how Taskferry avoids "contaminating the common models with hundreds of
|
|
6
|
+
cloud options" (section 17) while still exposing them when needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ProviderMetadata:
|
|
18
|
+
"""Immutable description of where/how a resource actually lives.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
provider: Short provider key, e.g. ``"local"``, ``"gcp"``, ``"aws"``,
|
|
22
|
+
``"azure"``, ``"kubernetes"``.
|
|
23
|
+
provider_id: The provider's own identifier (task name, execution name,
|
|
24
|
+
message id, schedule name). May be ``None`` before submission.
|
|
25
|
+
region: Provider region/location when meaningful.
|
|
26
|
+
resource: Fully-qualified provider resource name when meaningful.
|
|
27
|
+
labels: Arbitrary provider-specific key/value metadata.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
provider: str
|
|
31
|
+
provider_id: str | None = None
|
|
32
|
+
region: str | None = None
|
|
33
|
+
resource: str | None = None
|
|
34
|
+
labels: Mapping[str, str] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
# Freeze the mapping so a frozen dataclass is actually immutable.
|
|
38
|
+
object.__setattr__(self, "labels", MappingProxyType(dict(self.labels)))
|
|
39
|
+
|
|
40
|
+
def with_provider_id(self, provider_id: str) -> ProviderMetadata:
|
|
41
|
+
"""Return a copy carrying the resolved provider id."""
|
|
42
|
+
return replace(self, provider_id=provider_id)
|
|
43
|
+
|
|
44
|
+
def with_labels(self, **labels: str) -> ProviderMetadata:
|
|
45
|
+
"""Return a copy with additional labels merged in."""
|
|
46
|
+
merged = {**self.labels, **labels}
|
|
47
|
+
return replace(self, labels=merged)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["ProviderMetadata"]
|
taskferry/core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""A tiny lazy registry used by every domain (sections 35, 31).
|
|
2
|
+
|
|
3
|
+
``runners["default"]``, ``publishers["default"]``, ``schedulers["default"]`` are
|
|
4
|
+
all backed by this. Providers are built **lazily** on first access and cached, so
|
|
5
|
+
importing an adapter package never imports ``boto3``/``google.cloud`` for a
|
|
6
|
+
provider you don't touch.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
12
|
+
|
|
13
|
+
from .config import resolve_factory
|
|
14
|
+
from .errors import ConfigurationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LazyRegistry[T]:
|
|
18
|
+
"""Maps an alias to a zero-arg factory, instantiating on first access.
|
|
19
|
+
|
|
20
|
+
``kind`` is a human label used in error messages (e.g. ``"job runner"``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, kind: str) -> None:
|
|
24
|
+
self._kind = kind
|
|
25
|
+
self._factories: dict[str, Callable[[], T]] = {}
|
|
26
|
+
self._instances: dict[str, T] = {}
|
|
27
|
+
|
|
28
|
+
def register(self, alias: str, factory: Callable[[], T], *, replace: bool = False) -> None:
|
|
29
|
+
"""Register a zero-arg ``factory`` under ``alias``."""
|
|
30
|
+
if alias in self._factories and not replace:
|
|
31
|
+
raise ConfigurationError(f"{self._kind} alias {alias!r} is already registered")
|
|
32
|
+
self._factories[alias] = factory
|
|
33
|
+
self._instances.pop(alias, None)
|
|
34
|
+
|
|
35
|
+
def register_spec(self, alias: str, spec: str, /, **kwargs: object) -> None:
|
|
36
|
+
"""Register by import string, resolved lazily on first access.
|
|
37
|
+
|
|
38
|
+
``spec`` is ``"module.path:Factory"``; ``kwargs`` are passed to it.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def _factory() -> T:
|
|
42
|
+
target = resolve_factory(spec)
|
|
43
|
+
return target(**kwargs) # type: ignore[return-value]
|
|
44
|
+
|
|
45
|
+
self.register(alias, _factory, replace=True)
|
|
46
|
+
|
|
47
|
+
def configure(self, config: Mapping[str, Mapping[str, object]]) -> None:
|
|
48
|
+
"""Bulk-register from a settings-style mapping.
|
|
49
|
+
|
|
50
|
+
Each entry maps an alias to ``{"factory": "mod:Cls", ...kwargs}``. This is
|
|
51
|
+
the shape an adapter registry consumes.
|
|
52
|
+
"""
|
|
53
|
+
for alias, entry in config.items():
|
|
54
|
+
params = dict(entry)
|
|
55
|
+
factory_spec = params.pop("factory", None)
|
|
56
|
+
if not isinstance(factory_spec, str):
|
|
57
|
+
raise ConfigurationError(
|
|
58
|
+
f"{self._kind} {alias!r} config needs a 'factory' import string"
|
|
59
|
+
)
|
|
60
|
+
self.register_spec(alias, factory_spec, **params)
|
|
61
|
+
|
|
62
|
+
def __getitem__(self, alias: str) -> T:
|
|
63
|
+
if alias not in self._instances:
|
|
64
|
+
factory = self._factories.get(alias)
|
|
65
|
+
if factory is None:
|
|
66
|
+
known = ", ".join(sorted(self._factories)) or "<none>"
|
|
67
|
+
raise ConfigurationError(
|
|
68
|
+
f"no {self._kind} registered under {alias!r} (known: {known})"
|
|
69
|
+
)
|
|
70
|
+
self._instances[alias] = factory()
|
|
71
|
+
return self._instances[alias]
|
|
72
|
+
|
|
73
|
+
def __contains__(self, alias: object) -> bool:
|
|
74
|
+
return alias in self._factories
|
|
75
|
+
|
|
76
|
+
def __iter__(self) -> Iterator[str]:
|
|
77
|
+
return iter(self._factories)
|
|
78
|
+
|
|
79
|
+
def aliases(self) -> tuple[str, ...]:
|
|
80
|
+
return tuple(self._factories)
|
|
81
|
+
|
|
82
|
+
def clear(self) -> None:
|
|
83
|
+
"""Drop all registrations and cached instances (useful in tests)."""
|
|
84
|
+
self._factories.clear()
|
|
85
|
+
self._instances.clear()
|
|
86
|
+
|
|
87
|
+
def reset_instances(self) -> None:
|
|
88
|
+
"""Drop cached instances but keep registrations (re-build on next access)."""
|
|
89
|
+
self._instances.clear()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["LazyRegistry"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""JSON-only serialization for Taskferry payloads (section 23, ADR-0010).
|
|
2
|
+
|
|
3
|
+
Rules Taskferry enforces so payloads stay portable and safe:
|
|
4
|
+
|
|
5
|
+
* JSON is the default and only built-in transport format.
|
|
6
|
+
* **Never** pickle — it is unsafe for cloud/untrusted messages.
|
|
7
|
+
* Do not serialize ORM instances or arbitrary Python objects. Pass identifiers
|
|
8
|
+
(``process_resource(resource_id)``), not objects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from .errors import SerializationError
|
|
17
|
+
from .typing import JSONValue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Serializer(Protocol):
|
|
22
|
+
"""Minimal serializer contract adapters can depend on."""
|
|
23
|
+
|
|
24
|
+
content_type: str
|
|
25
|
+
|
|
26
|
+
def dumps(self, value: JSONValue) -> bytes: ...
|
|
27
|
+
|
|
28
|
+
def loads(self, data: bytes | str) -> JSONValue: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JsonSerializer:
|
|
32
|
+
"""Strict JSON serializer.
|
|
33
|
+
|
|
34
|
+
``dumps`` rejects non-JSON-serializable input eagerly with
|
|
35
|
+
:class:`SerializationError` instead of producing a partial/garbage payload.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
content_type = "application/json"
|
|
39
|
+
|
|
40
|
+
def __init__(self, *, sort_keys: bool = True) -> None:
|
|
41
|
+
self._sort_keys = sort_keys
|
|
42
|
+
|
|
43
|
+
def dumps(self, value: JSONValue) -> bytes:
|
|
44
|
+
try:
|
|
45
|
+
text = json.dumps(
|
|
46
|
+
value, sort_keys=self._sort_keys, separators=(",", ":"), allow_nan=False
|
|
47
|
+
)
|
|
48
|
+
except (TypeError, ValueError) as exc:
|
|
49
|
+
raise SerializationError(f"value is not JSON-serializable: {exc}") from exc
|
|
50
|
+
return text.encode("utf-8")
|
|
51
|
+
|
|
52
|
+
def loads(self, data: bytes | str) -> JSONValue:
|
|
53
|
+
try:
|
|
54
|
+
payload = data.decode("utf-8") if isinstance(data, bytes) else data
|
|
55
|
+
return json.loads(payload) # type: ignore[no-any-return]
|
|
56
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
57
|
+
raise SerializationError(f"payload is not valid JSON: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_json_serializable(value: object) -> JSONValue:
|
|
61
|
+
"""Return ``value`` unchanged if it round-trips through JSON, else raise.
|
|
62
|
+
|
|
63
|
+
A cheap boundary guard: call it where application data enters Taskferry so
|
|
64
|
+
failures surface at enqueue time, not on a remote worker.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
json.dumps(value, allow_nan=False)
|
|
68
|
+
except (TypeError, ValueError) as exc:
|
|
69
|
+
raise SerializationError(
|
|
70
|
+
f"value of type {type(value).__name__!r} is not JSON-serializable: {exc}"
|
|
71
|
+
) from exc
|
|
72
|
+
return value # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"JsonSerializer",
|
|
77
|
+
"Serializer",
|
|
78
|
+
"ensure_json_serializable",
|
|
79
|
+
]
|
taskferry/core/typing.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Typing helpers shared across the Taskferry family.
|
|
2
|
+
|
|
3
|
+
Kept deliberately tiny. Only genuinely cross-cutting aliases live here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# JSON is the only transport payload Taskferry promises to serialize (ADR-0010,
|
|
9
|
+
# section 23). These PEP 695 aliases document that contract at the type level;
|
|
10
|
+
# lazy evaluation lets the recursive definition reference itself without quotes.
|
|
11
|
+
type JSONScalar = str | int | float | bool | None
|
|
12
|
+
type JSONValue = JSONScalar | JSONArray | JSONObject
|
|
13
|
+
type JSONArray = list[JSONValue]
|
|
14
|
+
type JSONObject = dict[str, JSONValue]
|
|
15
|
+
|
|
16
|
+
__all__ = ["JSONArray", "JSONObject", "JSONScalar", "JSONValue"]
|