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,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"]
|
taskferry/core/config.py
ADDED
|
@@ -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
|
+
]
|