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,91 @@
1
+ """The contract every :class:`~taskferry.ports.JobBackend` must satisfy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import abstractmethod
6
+
7
+ import pytest
8
+
9
+ from ..capabilities import Capability
10
+ from ..errors import UnsupportedCapability
11
+ from ..execution import ExecutionKind
12
+ from ..ports import ExecutionBackend
13
+ from ..specs import JobSpec, Resources
14
+ from .base import ExecutionBackendContract
15
+
16
+
17
+ class JobBackendContract(ExecutionBackendContract):
18
+ """Subclass in an adapter's test module and implement the two hooks.
19
+
20
+ ::
21
+
22
+ class TestCloudRun(JobBackendContract):
23
+ def make_backend(self):
24
+ return CloudRunJobBackend(project="p", location="eu", jobs_client=FakeJobs())
25
+
26
+ def success_spec(self):
27
+ return JobSpec(job="build-cog", image="gdal:latest")
28
+ """
29
+
30
+ @abstractmethod
31
+ def make_backend(self) -> ExecutionBackend: ...
32
+
33
+ @abstractmethod
34
+ def success_spec(self) -> JobSpec: ...
35
+
36
+ def test_is_a_job_backend(self) -> None:
37
+ assert self.make_backend().kind is ExecutionKind.JOB
38
+
39
+ def test_gpu_capability_matches_gpu_requests(self) -> None:
40
+ """The assertion that matters most: never run a GPU job on the CPU quietly."""
41
+ backend = self.make_backend()
42
+ spec = self.success_spec().evolve(resources=Resources(gpu=1, gpu_type="nvidia-tesla-t4"))
43
+ if Capability.GPU in backend.capabilities:
44
+ assert backend.submit(spec).id
45
+ else:
46
+ with pytest.raises(UnsupportedCapability):
47
+ backend.submit(spec)
48
+
49
+ def test_cpu_and_memory_capabilities_match_resource_requests(self) -> None:
50
+ backend = self.make_backend()
51
+ caps = backend.capabilities
52
+ spec = self.success_spec().evolve(resources=Resources(cpu="1000m", memory="512Mi"))
53
+ if Capability.CPU in caps and Capability.MEMORY in caps:
54
+ assert backend.submit(spec).id
55
+ else:
56
+ with pytest.raises(UnsupportedCapability):
57
+ backend.submit(spec)
58
+
59
+ def test_parallelism_capability_matches_array_jobs(self) -> None:
60
+ backend = self.make_backend()
61
+ spec = self.success_spec().evolve(parallelism=4)
62
+ if Capability.PARALLELISM in backend.capabilities:
63
+ assert backend.submit(spec).id
64
+ else:
65
+ with pytest.raises(UnsupportedCapability):
66
+ backend.submit(spec)
67
+
68
+ def test_timeout_capability_matches_timeout_requests(self) -> None:
69
+ from ..retry import TimeoutPolicy
70
+
71
+ backend = self.make_backend()
72
+ spec = self.success_spec().evolve(timeout=TimeoutPolicy(seconds=60))
73
+ if Capability.TIMEOUT in backend.capabilities:
74
+ assert backend.submit(spec).id
75
+ else:
76
+ with pytest.raises(UnsupportedCapability):
77
+ backend.submit(spec)
78
+
79
+ def test_exit_code_is_reported_when_results_are_supported(self) -> None:
80
+ if not self.reaches_terminal_state:
81
+ pytest.skip("backend does not execute work in this test environment")
82
+ backend = self.make_backend()
83
+ if Capability.RESULT not in backend.capabilities:
84
+ pytest.skip("backend does not report results")
85
+ execution = backend.submit(self.success_spec())
86
+ final = self.wait_for_terminal(backend, execution)
87
+ assert final.result is not None
88
+ assert final.result.exit_code == 0, "a successful job exits zero"
89
+
90
+
91
+ __all__ = ["JobBackendContract"]
@@ -0,0 +1,91 @@
1
+ """The contract every :class:`~taskferry.ports.TaskBackend` must satisfy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import abstractmethod
6
+ from datetime import timedelta
7
+
8
+ import pytest
9
+
10
+ from ..capabilities import Capability
11
+ from ..errors import UnsupportedCapability
12
+ from ..execution import ExecutionKind
13
+ from ..ports import ExecutionBackend
14
+ from ..specs import TaskSpec
15
+ from .base import ExecutionBackendContract
16
+
17
+
18
+ class TaskBackendContract(ExecutionBackendContract):
19
+ """Subclass in an adapter's test module and implement the two hooks.
20
+
21
+ ::
22
+
23
+ class TestProcrastinate(TaskBackendContract):
24
+ def make_backend(self):
25
+ return ProcrastinateTaskBackend(app=FakeApp())
26
+
27
+ def success_spec(self):
28
+ return TaskSpec(task="tests.tasks:ok", args=(1, 2))
29
+ """
30
+
31
+ @abstractmethod
32
+ def make_backend(self) -> ExecutionBackend: ...
33
+
34
+ @abstractmethod
35
+ def success_spec(self) -> TaskSpec: ...
36
+
37
+ def test_is_a_task_backend(self) -> None:
38
+ assert self.make_backend().kind is ExecutionKind.TASK
39
+
40
+ def test_delay_capability_matches_deferred_specs(self) -> None:
41
+ """A backend must either honour ``delay`` or refuse it outright."""
42
+ backend = self.make_backend()
43
+ deferred = self.success_spec().evolve(delay=timedelta(seconds=30))
44
+ if Capability.DELAY in backend.capabilities:
45
+ execution = backend.submit(deferred)
46
+ assert execution.id
47
+ else:
48
+ with pytest.raises(UnsupportedCapability):
49
+ backend.submit(deferred)
50
+
51
+ def test_priority_capability_matches_prioritised_specs(self) -> None:
52
+ backend = self.make_backend()
53
+ prioritised = self.success_spec().evolve(priority=5)
54
+ if Capability.PRIORITY in backend.capabilities:
55
+ assert backend.submit(prioritised).id
56
+ else:
57
+ with pytest.raises(UnsupportedCapability):
58
+ backend.submit(prioritised)
59
+
60
+ def test_deduplication_capability_matches_idempotency_keys(self) -> None:
61
+ """``idempotency_key`` is a request, and a backend that cannot honour it says so."""
62
+ backend = self.make_backend()
63
+ keyed = self.success_spec().evolve(idempotency_key="contract-key-1")
64
+ if Capability.DEDUPLICATION in backend.capabilities:
65
+ assert backend.submit(keyed).id
66
+ else:
67
+ with pytest.raises(UnsupportedCapability):
68
+ backend.submit(keyed)
69
+
70
+ def test_backend_options_for_other_backends_are_ignored(self) -> None:
71
+ """One spec must travel unchanged across engines.
72
+
73
+ A spec carrying options for a *different* backend has to submit cleanly —
74
+ otherwise moving a queue from Procrastinate to Cloud Tasks would mean
75
+ editing every call site, which is the coupling Taskferry exists to remove.
76
+ """
77
+ from ..specs import BackendOptions
78
+
79
+ backend = self.make_backend()
80
+ spec = self.success_spec().evolve(
81
+ backend_options=BackendOptions({"some-other-engine": {"nonsense": True}})
82
+ )
83
+ assert backend.submit(spec).id
84
+
85
+ def test_arguments_survive_the_spec_unchanged(self) -> None:
86
+ spec = self.success_spec()
87
+ assert spec.args == tuple(spec.args)
88
+ assert dict(spec.kwargs) == dict(spec.kwargs)
89
+
90
+
91
+ __all__ = ["TaskBackendContract"]
@@ -0,0 +1,130 @@
1
+ """``taskferry.core`` — the tiny shared substrate for the Taskferry family.
2
+
3
+ This package deliberately stays small (ADR-0004). It contains only what is
4
+ *genuinely* transversal across Tasks, Jobs, Events and Schedules: identifiers,
5
+ correlation, provider metadata, the capability model, configuration primitives,
6
+ serialization, observability hooks, the lazy registry, delivery vocabulary and
7
+ the root error hierarchy.
8
+
9
+ Domain types (``JobSpec``, ``Event``, ``Schedule`` ...) live in their own
10
+ packages, not here — centralization is not a reason to promote a type.
11
+
12
+ End users normally do not depend on ``taskferry-core`` directly; they install
13
+ ``taskferry-django`` / ``taskferry-jobs`` / ``taskferry-events`` /
14
+ ``taskferry-scheduler``, which depend on it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .capabilities import Capability, CapabilitySet
20
+ from .config import (
21
+ ProviderOptions,
22
+ env_bool,
23
+ env_int,
24
+ env_str,
25
+ require,
26
+ resolve_factory,
27
+ )
28
+ from .correlation import (
29
+ Correlation,
30
+ current_correlation,
31
+ ensure_correlation,
32
+ use_correlation,
33
+ )
34
+ from .delivery import DeliveryGuarantee, Ordering
35
+ from .errors import (
36
+ ConfigurationError,
37
+ ProviderError,
38
+ SerializationError,
39
+ TaskferryError,
40
+ UnsupportedCapabilityError,
41
+ )
42
+ from .ids import TaskferryId, is_taskferry_id, new_id
43
+ from .observability import (
44
+ ATTR_ATTEMPT,
45
+ ATTR_CORRELATION_ID,
46
+ ATTR_EVENT_ID,
47
+ ATTR_JOB_ID,
48
+ ATTR_PROVIDER,
49
+ ATTR_PROVIDER_ID,
50
+ ATTR_SCHEDULE_ID,
51
+ ATTR_TASK_ID,
52
+ SPAN_EVENT_CONSUME,
53
+ SPAN_EVENT_PUBLISH,
54
+ SPAN_JOB_POLL,
55
+ SPAN_JOB_SUBMIT,
56
+ SPAN_SCHEDULE_CREATE,
57
+ SPAN_TASK_ENQUEUE,
58
+ SPAN_TASK_EXECUTE,
59
+ NoopTracer,
60
+ Span,
61
+ Tracer,
62
+ get_tracer,
63
+ set_tracer,
64
+ span,
65
+ )
66
+ from .otel import OTelTracer, configure_opentelemetry
67
+ from .provider import ProviderMetadata
68
+ from .registry import LazyRegistry
69
+ from .serialization import JsonSerializer, Serializer, ensure_json_serializable
70
+ from .typing import JSONArray, JSONObject, JSONScalar, JSONValue
71
+
72
+ __version__ = "0.1.0"
73
+
74
+ __all__ = [
75
+ "ATTR_ATTEMPT",
76
+ "ATTR_CORRELATION_ID",
77
+ "ATTR_EVENT_ID",
78
+ "ATTR_JOB_ID",
79
+ "ATTR_PROVIDER",
80
+ "ATTR_PROVIDER_ID",
81
+ "ATTR_SCHEDULE_ID",
82
+ "ATTR_TASK_ID",
83
+ "SPAN_EVENT_CONSUME",
84
+ "SPAN_EVENT_PUBLISH",
85
+ "SPAN_JOB_POLL",
86
+ "SPAN_JOB_SUBMIT",
87
+ "SPAN_SCHEDULE_CREATE",
88
+ "SPAN_TASK_ENQUEUE",
89
+ "SPAN_TASK_EXECUTE",
90
+ "Capability",
91
+ "CapabilitySet",
92
+ "ConfigurationError",
93
+ "Correlation",
94
+ "DeliveryGuarantee",
95
+ "JSONArray",
96
+ "JSONObject",
97
+ "JSONScalar",
98
+ "JSONValue",
99
+ "JsonSerializer",
100
+ "LazyRegistry",
101
+ "NoopTracer",
102
+ "OTelTracer",
103
+ "Ordering",
104
+ "ProviderError",
105
+ "ProviderMetadata",
106
+ "ProviderOptions",
107
+ "SerializationError",
108
+ "Serializer",
109
+ "Span",
110
+ "TaskferryError",
111
+ "TaskferryId",
112
+ "Tracer",
113
+ "UnsupportedCapabilityError",
114
+ "__version__",
115
+ "configure_opentelemetry",
116
+ "current_correlation",
117
+ "ensure_correlation",
118
+ "ensure_json_serializable",
119
+ "env_bool",
120
+ "env_int",
121
+ "env_str",
122
+ "get_tracer",
123
+ "is_taskferry_id",
124
+ "new_id",
125
+ "require",
126
+ "resolve_factory",
127
+ "set_tracer",
128
+ "span",
129
+ "use_correlation",
130
+ ]
@@ -0,0 +1,89 @@
1
+ """The capability model shared by every Taskferry domain (ADR-0005).
2
+
3
+ Providers do not support the same things. Taskferry refuses to lie about this.
4
+ Each domain defines its own capability enum (a :class:`Capability` subclass) and
5
+ every provider exposes an immutable :class:`CapabilitySet`. Callers can:
6
+
7
+ * **feature-detect** — ``if cap in provider.capabilities: ...``
8
+ * **assert** — ``provider.capabilities.require(cap)`` raises
9
+ :class:`~taskferry.core.errors.UnsupportedCapabilityError` when absent.
10
+
11
+ A capability that does not exist is never ignored and never faked.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable, Iterator
17
+ from enum import StrEnum
18
+
19
+ from .errors import UnsupportedCapabilityError
20
+
21
+
22
+ class Capability(StrEnum):
23
+ """Base class for all domain capability enums.
24
+
25
+ Subclassed per domain (``TaskCapability``, ``JobCapability``, ...). Using a
26
+ ``StrEnum`` keeps capabilities serializable and comparable to plain strings,
27
+ which matters for logging, wire formats, and cross-package checks.
28
+ """
29
+
30
+
31
+ class CapabilitySet:
32
+ """An immutable, hashable set of capabilities advertised by a provider."""
33
+
34
+ __slots__ = ("_caps", "_provider")
35
+
36
+ def __init__(self, capabilities: Iterable[Capability], *, provider: str | None = None) -> None:
37
+ self._caps: frozenset[Capability] = frozenset(capabilities)
38
+ self._provider = provider
39
+
40
+ @property
41
+ def provider(self) -> str | None:
42
+ return self._provider
43
+
44
+ def __contains__(self, capability: object) -> bool:
45
+ return capability in self._caps
46
+
47
+ def __iter__(self) -> Iterator[Capability]:
48
+ return iter(self._caps)
49
+
50
+ def __len__(self) -> int:
51
+ return len(self._caps)
52
+
53
+ def __eq__(self, other: object) -> bool:
54
+ if isinstance(other, CapabilitySet):
55
+ return self._caps == other._caps
56
+ if isinstance(other, (set, frozenset)):
57
+ return self._caps == frozenset(other)
58
+ return NotImplemented
59
+
60
+ def __hash__(self) -> int:
61
+ return hash(self._caps)
62
+
63
+ def __repr__(self) -> str:
64
+ names = ", ".join(sorted(c.value for c in self._caps))
65
+ provider = f" provider={self._provider!r}" if self._provider else ""
66
+ return f"CapabilitySet({{{names}}}{provider})"
67
+
68
+ def supports(self, capability: Capability) -> bool:
69
+ """Return whether ``capability`` is advertised."""
70
+ return capability in self._caps
71
+
72
+ def supports_all(self, capabilities: Iterable[Capability]) -> bool:
73
+ return frozenset(capabilities) <= self._caps
74
+
75
+ def require(self, capability: Capability) -> None:
76
+ """Raise :class:`UnsupportedCapabilityError` unless ``capability`` is present."""
77
+ if capability not in self._caps:
78
+ raise UnsupportedCapabilityError(str(capability), provider=self._provider)
79
+
80
+ def require_all(self, capabilities: Iterable[Capability]) -> None:
81
+ for capability in capabilities:
82
+ self.require(capability)
83
+
84
+ def missing(self, capabilities: Iterable[Capability]) -> frozenset[Capability]:
85
+ """Return the subset of ``capabilities`` that is *not* supported."""
86
+ return frozenset(capabilities) - self._caps
87
+
88
+
89
+ __all__ = ["Capability", "CapabilitySet"]
@@ -0,0 +1,167 @@
1
+ """Configuration primitives (sections 14-17, ADR-0007).
2
+
3
+ Principle: platform config / secrets / env flow into *application* settings,
4
+ which are then handed to Taskferry. Taskferry libraries must **not** reach into
5
+ ``os.environ`` arbitrarily from deep inside adapters.
6
+
7
+ These helpers are therefore:
8
+
9
+ * **Opt-in** — an app may configure Taskferry from Django settings, plain Python,
10
+ a dict, ``.env``, Vault, Secret Manager, etc. Env vars are one option, never
11
+ the only one.
12
+ * **Injectable** — env readers take the environment mapping as an argument
13
+ (defaulting to ``os.environ``) so they are testable and never hidden.
14
+ * **Convention-friendly** — they read standard names (``REDIS_URL``,
15
+ ``DATABASE_URL``, ``GOOGLE_CLOUD_PROJECT`` ...) rather than forcing a
16
+ ``TASKFERRY_`` prefix on variables the ecosystem already standardizes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from collections.abc import Callable, Mapping
23
+ from dataclasses import dataclass, field
24
+ from types import MappingProxyType
25
+
26
+ from .errors import ConfigurationError
27
+ from .typing import JSONObject
28
+
29
+ _MISSING = object()
30
+
31
+ # Truthy/falsey spellings accepted by env_bool, matching common 12-factor usage.
32
+ _TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
33
+ _FALSE = frozenset({"0", "false", "f", "no", "n", "off", ""})
34
+
35
+
36
+ def env_str(
37
+ key: str,
38
+ default: str | None = None,
39
+ *,
40
+ required: bool = False,
41
+ environ: Mapping[str, str] | None = None,
42
+ ) -> str | None:
43
+ """Read a string from the environment mapping.
44
+
45
+ Raises :class:`ConfigurationError` when ``required`` and absent, so missing
46
+ configuration fails fast at startup (section 15).
47
+ """
48
+ source = environ if environ is not None else os.environ
49
+ value = source.get(key)
50
+ if value is None or value == "":
51
+ if required:
52
+ raise ConfigurationError(f"required environment variable {key!r} is not set")
53
+ return default
54
+ return value
55
+
56
+
57
+ def env_int(
58
+ key: str,
59
+ default: int | None = None,
60
+ *,
61
+ required: bool = False,
62
+ environ: Mapping[str, str] | None = None,
63
+ ) -> int | None:
64
+ raw = env_str(key, None, required=required, environ=environ)
65
+ if raw is None:
66
+ return default
67
+ try:
68
+ return int(raw)
69
+ except ValueError as exc:
70
+ raise ConfigurationError(f"{key!r} must be an integer, got {raw!r}") from exc
71
+
72
+
73
+ def env_bool(
74
+ key: str,
75
+ default: bool = False,
76
+ *,
77
+ environ: Mapping[str, str] | None = None,
78
+ ) -> bool:
79
+ raw = env_str(key, None, environ=environ)
80
+ if raw is None:
81
+ return default
82
+ lowered = raw.strip().lower()
83
+ if lowered in _TRUE:
84
+ return True
85
+ if lowered in _FALSE:
86
+ return False
87
+ raise ConfigurationError(f"{key!r} must be a boolean-like value, got {raw!r}")
88
+
89
+
90
+ def require[T](value: T | None, name: str) -> T:
91
+ """Return ``value`` or raise a clear :class:`ConfigurationError` if ``None``."""
92
+ if value is None:
93
+ raise ConfigurationError(f"{name} is required but was not provided")
94
+ return value
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class ProviderOptions:
99
+ """Provider-specific escape hatch (section 17).
100
+
101
+ Portable config lives on the common models; anything genuinely
102
+ provider-specific goes here, namespaced per provider::
103
+
104
+ ProviderOptions({"gcp": {"http_target": {...}}, "aws": {"MessageGroupId": "x"}})
105
+
106
+ Adapters read only their own namespace and ignore the rest, so one
107
+ configuration object travels unchanged across providers.
108
+ """
109
+
110
+ by_provider: Mapping[str, JSONObject] = field(default_factory=dict)
111
+
112
+ def __post_init__(self) -> None:
113
+ frozen = {k: MappingProxyType(dict(v)) for k, v in self.by_provider.items()}
114
+ object.__setattr__(self, "by_provider", MappingProxyType(frozen))
115
+
116
+ def for_provider(self, provider: str) -> JSONObject:
117
+ """Return this provider's options (an empty mapping if none supplied)."""
118
+ return dict(self.by_provider.get(provider, {}))
119
+
120
+ def option(
121
+ self,
122
+ provider: str,
123
+ key: str,
124
+ default: object = _MISSING,
125
+ ) -> object:
126
+ """Read a single option, raising if absent and no default is given."""
127
+ opts = self.by_provider.get(provider, {})
128
+ if key in opts:
129
+ return opts[key]
130
+ if default is _MISSING:
131
+ raise ConfigurationError(
132
+ f"provider option {provider!r}.{key!r} is required but missing"
133
+ )
134
+ return default
135
+
136
+
137
+ def resolve_factory(spec: str) -> Callable[..., object]:
138
+ """Resolve a ``"module.path:attr"`` (or ``"module.path.attr"``) string to a callable.
139
+
140
+ Used by registries (ADR and section 35) to build providers lazily from
141
+ configuration without importing every adapter eagerly.
142
+ """
143
+ import importlib
144
+
145
+ module_path, sep, attr = spec.partition(":")
146
+ if not sep:
147
+ module_path, _, attr = spec.rpartition(".")
148
+ if not module_path or not attr:
149
+ raise ConfigurationError(f"invalid factory spec {spec!r}")
150
+ try:
151
+ module = importlib.import_module(module_path)
152
+ target = getattr(module, attr)
153
+ except (ImportError, AttributeError) as exc:
154
+ raise ConfigurationError(f"could not import factory {spec!r}: {exc}") from exc
155
+ if not callable(target):
156
+ raise ConfigurationError(f"factory {spec!r} is not callable")
157
+ return target # type: ignore[no-any-return]
158
+
159
+
160
+ __all__ = [
161
+ "ProviderOptions",
162
+ "env_bool",
163
+ "env_int",
164
+ "env_str",
165
+ "require",
166
+ "resolve_factory",
167
+ ]
@@ -0,0 +1,120 @@
1
+ """Correlation metadata for following a logical flow across boundaries.
2
+
3
+ Lets you determine that an HTTP request → Task → Event → Job all belong to the
4
+ same logical flow (section 19) **without** turning Taskferry into a workflow
5
+ engine. It is just metadata that adapters propagate.
6
+
7
+ The ``trace_context`` mapping carries W3C Trace Context (``traceparent`` /
8
+ ``tracestate``) so distributed tracing works even without the OpenTelemetry SDK
9
+ installed (ADR-0012). Taskferry only moves the strings around.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextvars
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass, field, replace
17
+ from types import MappingProxyType
18
+
19
+ from .ids import TaskferryId, new_id
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Correlation:
24
+ """Immutable correlation envelope propagated with every operation.
25
+
26
+ Attributes:
27
+ correlation_id: Stable id shared by every step of one logical flow.
28
+ causation_id: Id of the *immediate* cause (the step that triggered this
29
+ one). ``None`` at the root of a flow.
30
+ trace_context: W3C Trace Context carrier (``traceparent``/``tracestate``).
31
+ """
32
+
33
+ correlation_id: TaskferryId
34
+ causation_id: TaskferryId | None = None
35
+ trace_context: Mapping[str, str] = field(default_factory=dict)
36
+
37
+ def __post_init__(self) -> None:
38
+ object.__setattr__(self, "trace_context", MappingProxyType(dict(self.trace_context)))
39
+
40
+ @classmethod
41
+ def start(cls) -> Correlation:
42
+ """Begin a fresh flow with a new correlation id and no cause."""
43
+ return cls(correlation_id=new_id("corr"))
44
+
45
+ def caused(self, cause_id: TaskferryId) -> Correlation:
46
+ """Return a child correlation for a step caused by ``cause_id``.
47
+
48
+ The correlation id is preserved (same flow); the causation id advances.
49
+ """
50
+ return replace(self, causation_id=cause_id)
51
+
52
+ def to_headers(self) -> dict[str, str]:
53
+ """Serialize to portable transport headers/attributes."""
54
+ headers = {
55
+ "taskferry-correlation-id": self.correlation_id,
56
+ **dict(self.trace_context),
57
+ }
58
+ if self.causation_id is not None:
59
+ headers["taskferry-causation-id"] = self.causation_id
60
+ return headers
61
+
62
+ @classmethod
63
+ def from_headers(cls, headers: Mapping[str, str]) -> Correlation:
64
+ """Reconstruct correlation from transport headers/attributes.
65
+
66
+ Falls back to starting a fresh flow when no correlation id is present.
67
+ """
68
+ correlation_id = headers.get("taskferry-correlation-id")
69
+ if not correlation_id:
70
+ return cls.start()
71
+ trace_context = {k: v for k, v in headers.items() if k in ("traceparent", "tracestate")}
72
+ causation = headers.get("taskferry-causation-id")
73
+ return cls(
74
+ correlation_id=TaskferryId(correlation_id),
75
+ causation_id=TaskferryId(causation) if causation else None,
76
+ trace_context=trace_context,
77
+ )
78
+
79
+
80
+ _current: contextvars.ContextVar[Correlation | None] = contextvars.ContextVar(
81
+ "taskferry_correlation", default=None
82
+ )
83
+
84
+
85
+ def current_correlation() -> Correlation | None:
86
+ """Return the correlation bound to the current context, if any."""
87
+ return _current.get()
88
+
89
+
90
+ def ensure_correlation() -> Correlation:
91
+ """Return the current correlation, starting a fresh flow if none is bound."""
92
+ existing = _current.get()
93
+ return existing if existing is not None else Correlation.start()
94
+
95
+
96
+ class use_correlation:
97
+ """Context manager binding ``correlation`` to the current execution context.
98
+
99
+ Restores the previous value on exit, so it nests cleanly.
100
+ """
101
+
102
+ def __init__(self, correlation: Correlation) -> None:
103
+ self._correlation = correlation
104
+ self._token: contextvars.Token[Correlation | None] | None = None
105
+
106
+ def __enter__(self) -> Correlation:
107
+ self._token = _current.set(self._correlation)
108
+ return self._correlation
109
+
110
+ def __exit__(self, *exc: object) -> None:
111
+ assert self._token is not None
112
+ _current.reset(self._token)
113
+
114
+
115
+ __all__ = [
116
+ "Correlation",
117
+ "current_correlation",
118
+ "ensure_correlation",
119
+ "use_correlation",
120
+ ]