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.
Files changed (48) hide show
  1. taskferry/__init__.py +211 -0
  2. taskferry/aio.py +486 -0
  3. taskferry/backends/__init__.py +38 -0
  4. taskferry/backends/inline.py +235 -0
  5. taskferry/backends/process.py +292 -0
  6. taskferry/backends/subprocess.py +390 -0
  7. taskferry/backends/thread.py +351 -0
  8. taskferry/capabilities.py +90 -0
  9. taskferry/cli.py +445 -0
  10. taskferry/config.py +360 -0
  11. taskferry/contract/__init__.py +56 -0
  12. taskferry/contract/base.py +179 -0
  13. taskferry/contract/inline.py +89 -0
  14. taskferry/contract/job.py +91 -0
  15. taskferry/contract/task.py +91 -0
  16. taskferry/core/__init__.py +130 -0
  17. taskferry/core/capabilities.py +89 -0
  18. taskferry/core/config.py +167 -0
  19. taskferry/core/correlation.py +120 -0
  20. taskferry/core/delivery.py +36 -0
  21. taskferry/core/errors.py +55 -0
  22. taskferry/core/ids.py +37 -0
  23. taskferry/core/observability.py +136 -0
  24. taskferry/core/otel.py +83 -0
  25. taskferry/core/provider.py +50 -0
  26. taskferry/core/py.typed +0 -0
  27. taskferry/core/registry.py +92 -0
  28. taskferry/core/serialization.py +79 -0
  29. taskferry/core/typing.py +16 -0
  30. taskferry/envelope.py +197 -0
  31. taskferry/errors.py +144 -0
  32. taskferry/execution.py +239 -0
  33. taskferry/functions.py +290 -0
  34. taskferry/handle.py +186 -0
  35. taskferry/hooks.py +238 -0
  36. taskferry/plugins.py +183 -0
  37. taskferry/ports.py +356 -0
  38. taskferry/py.typed +0 -0
  39. taskferry/retry.py +205 -0
  40. taskferry/router.py +160 -0
  41. taskferry/runtime.py +609 -0
  42. taskferry/specs.py +353 -0
  43. taskferry/tracking.py +129 -0
  44. taskferry-0.2.0.dist-info/METADATA +109 -0
  45. taskferry-0.2.0.dist-info/RECORD +48 -0
  46. taskferry-0.2.0.dist-info/WHEEL +4 -0
  47. taskferry-0.2.0.dist-info/entry_points.txt +2 -0
  48. 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"]
@@ -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"]
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
+ ]
@@ -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"]