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
taskferry/envelope.py ADDED
@@ -0,0 +1,197 @@
1
+ """The portable task envelope — one wire format, every transport.
2
+
3
+ ```mermaid
4
+ flowchart LR
5
+ SPEC["TaskSpec"]
6
+ ENV["envelope<br/>{taskferry, task, args, kwargs, correlation, retry}"]
7
+
8
+ ENV --> PG["Procrastinate job argument"]
9
+ ENV --> CT["Cloud Tasks HTTP body"]
10
+ ENV --> SQS["SQS message body"]
11
+ ENV --> SB["Service Bus message body"]
12
+ ENV --> DQ["Dramatiq actor argument"]
13
+
14
+ SPEC --> ENV
15
+ ```
16
+
17
+ Five adapters were about to grow the same twenty lines — build a dict, read the
18
+ correlation back, check the version, encode the retry policy. So it lives here
19
+ once, and every transport that can carry a JSON object gets a consistent,
20
+ inspectable payload.
21
+
22
+ What it is not
23
+ --------------
24
+
25
+ This is **not** a queue protocol. It carries no routing, no priority, no
26
+ scheduling and no delivery metadata, because every engine has its own and
27
+ better mechanisms for those — a `queueing_lock`, a `schedule_time`, a
28
+ `MessageGroupId`. The envelope carries only what the *worker* needs in order to
29
+ run the right function with the right arguments. Anything the engine can express
30
+ natively is translated by the adapter, not smuggled through here.
31
+
32
+ Two rules
33
+ ---------
34
+
35
+ **JSON only, never pickle.** A payload sits in a PostgreSQL table or an SQS queue
36
+ where an operator can read it, survives a Python upgrade, and cannot become
37
+ arbitrary code execution on the way back in. See
38
+ [ADR-0009](../../../docs/adr/0009-serialization.md).
39
+
40
+ **Version it, and reject what you do not know.** A worker running old code must
41
+ not silently drop a field a newer producer added. :func:`read_envelope` refuses
42
+ an unfamiliar version rather than guessing.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from typing import Any, TypedDict
48
+
49
+ from .core.correlation import Correlation
50
+ from .core.typing import JSONValue
51
+ from .errors import SerializationError
52
+ from .retry import NO_RETRY, Backoff, RetryPolicy
53
+ from .specs import TaskSpec
54
+
55
+ ENVELOPE_VERSION = "2"
56
+ """Bumped when the shape changes. Workers reject versions they do not know."""
57
+
58
+
59
+ class Envelope(TypedDict, total=False):
60
+ """The on-the-wire shape of a task."""
61
+
62
+ taskferry: str
63
+ task: str
64
+ args: list[JSONValue]
65
+ kwargs: dict[str, JSONValue]
66
+ name: str
67
+ queue: str
68
+ correlation: dict[str, str] | None
69
+ labels: dict[str, str]
70
+ retry: dict[str, JSONValue] | None
71
+
72
+
73
+ def build_envelope(spec: TaskSpec) -> Envelope:
74
+ """Turn a :class:`~taskferry.specs.TaskSpec` into the wire envelope.
75
+
76
+ Arguments were validated as JSON-shaped when the spec was constructed, so
77
+ nothing can fail here that would not already have failed at the call site.
78
+ """
79
+ return {
80
+ "taskferry": ENVELOPE_VERSION,
81
+ "task": spec.task,
82
+ "args": list(spec.args),
83
+ "kwargs": dict(spec.kwargs),
84
+ "name": spec.name,
85
+ "queue": spec.queue,
86
+ "correlation": spec.correlation.to_headers() if spec.correlation else None,
87
+ "labels": dict(spec.labels),
88
+ "retry": encode_retry(spec.retry),
89
+ }
90
+
91
+
92
+ def read_envelope(payload: Any) -> Envelope:
93
+ """Validate an incoming payload and return it as an :class:`Envelope`.
94
+
95
+ Raises:
96
+ SerializationError: when the payload is not an envelope of a version this
97
+ code understands, or carries no task. Failing loudly is deliberate: a
98
+ malformed body means a misconfigured endpoint or a version skew, and
99
+ quietly doing nothing would hide both.
100
+ """
101
+ if not isinstance(payload, dict):
102
+ raise SerializationError(
103
+ f"a taskferry envelope must be a JSON object, got {type(payload).__name__}"
104
+ )
105
+ version = payload.get("taskferry")
106
+ if version != ENVELOPE_VERSION:
107
+ raise SerializationError(
108
+ f"unsupported taskferry envelope version {version!r} "
109
+ f"(this worker speaks {ENVELOPE_VERSION!r}); upgrade the worker or the producer"
110
+ )
111
+ if not payload.get("task"):
112
+ raise SerializationError("taskferry envelope has no 'task'")
113
+ return payload # type: ignore[return-value]
114
+
115
+
116
+ def read_correlation(envelope: Envelope) -> Correlation:
117
+ """Rebuild the correlation, starting a fresh flow when the envelope has none."""
118
+ headers = envelope.get("correlation")
119
+ return Correlation.from_headers(headers) if headers else Correlation.start()
120
+
121
+
122
+ def encode_retry(policy: RetryPolicy) -> dict[str, JSONValue] | None:
123
+ """Serialize the portable retry intent, or ``None`` when nothing is asked for.
124
+
125
+ Only what a worker needs to compute the *next* delay travels. The policy
126
+ object itself is not shipped, because a queue payload must not depend on the
127
+ producer and the consumer running the same Taskferry version.
128
+ """
129
+ if not policy.enabled:
130
+ return None
131
+ return {
132
+ "max_attempts": policy.max_attempts,
133
+ "backoff": policy.backoff.value,
134
+ "initial_delay": policy.initial_delay,
135
+ "max_delay": policy.max_delay,
136
+ "retry_on": list(policy.retry_on),
137
+ "no_retry_on": list(policy.no_retry_on),
138
+ }
139
+
140
+
141
+ def decode_retry(envelope: Envelope) -> RetryPolicy:
142
+ """Rebuild the policy on the worker side. Unknown fields are ignored."""
143
+ raw = envelope.get("retry")
144
+ if not raw:
145
+ return NO_RETRY
146
+ return RetryPolicy(
147
+ max_attempts=int(raw.get("max_attempts", 1) or 1), # type: ignore[arg-type]
148
+ backoff=Backoff(str(raw.get("backoff", Backoff.EXPONENTIAL.value))),
149
+ initial_delay=float(raw.get("initial_delay", 1.0) or 0.0), # type: ignore[arg-type]
150
+ max_delay=(
151
+ None if raw.get("max_delay") is None else float(raw["max_delay"]) # type: ignore[arg-type]
152
+ ),
153
+ retry_on=tuple(str(name) for name in (raw.get("retry_on") or [])), # type: ignore[union-attr]
154
+ no_retry_on=tuple(str(name) for name in (raw.get("no_retry_on") or [])), # type: ignore[union-attr]
155
+ )
156
+
157
+
158
+ def execute_envelope(payload: Any, *, registry: Any = None) -> Any:
159
+ """Validate, resolve and run one envelope. The whole worker side, in one call.
160
+
161
+ Args:
162
+ payload: The decoded JSON body.
163
+ registry: A :class:`~taskferry.functions.FunctionRegistry`. **Give it an
164
+ allowlist** when envelopes arrive from a queue anything untrusted can
165
+ write to — resolving a name means importing a module. See
166
+ ``docs/security.md``.
167
+
168
+ Returns:
169
+ Whatever the task returned. Most engines discard it.
170
+ """
171
+ from .core.correlation import use_correlation
172
+ from .functions import FunctionRegistry, is_async_callable
173
+
174
+ envelope = read_envelope(payload)
175
+ resolver = registry if registry is not None else FunctionRegistry()
176
+ func = resolver.resolve(str(envelope["task"]))
177
+ args = list(envelope.get("args") or [])
178
+ kwargs = dict(envelope.get("kwargs") or {})
179
+
180
+ with use_correlation(read_correlation(envelope)):
181
+ if is_async_callable(func):
182
+ import asyncio
183
+
184
+ return asyncio.run(func(*args, **kwargs))
185
+ return func(*args, **kwargs)
186
+
187
+
188
+ __all__ = [
189
+ "ENVELOPE_VERSION",
190
+ "Envelope",
191
+ "build_envelope",
192
+ "decode_retry",
193
+ "encode_retry",
194
+ "execute_envelope",
195
+ "read_correlation",
196
+ "read_envelope",
197
+ ]
taskferry/errors.py ADDED
@@ -0,0 +1,144 @@
1
+ """The portable Taskferry error hierarchy.
2
+
3
+ Every error a caller can reasonably catch is a :class:`TaskferryError`. Adapters
4
+ translate provider exceptions into these types and **always** chain the original
5
+ as ``__cause__``::
6
+
7
+ raise SubmissionError("defer failed", backend="procrastinate") from exc
8
+
9
+ Provider exceptions (``google.api_core.GoogleAPIError``, ``psycopg.Error``,
10
+ ``kubernetes.client.ApiException`` ...) are never part of Taskferry's public API,
11
+ so application code never has to import a provider SDK to handle a failure.
12
+
13
+ ```mermaid
14
+ flowchart TD
15
+ E["TaskferryError"]
16
+ E --> C["ConfigurationError"]
17
+ E --> B["BackendError"]
18
+ B --> S["SubmissionError"]
19
+ B --> X["ExecutionError"]
20
+ B --> NF["ExecutionNotFound"]
21
+ E --> U["UnsupportedCapability"]
22
+ E --> SER["SerializationError"]
23
+ E --> T["TimeoutError"]
24
+ E --> R["RoutingError"]
25
+ E --> F["FunctionResolutionError"]
26
+ E --> CAN["ExecutionCancelled"]
27
+ ```
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from taskferry.core.errors import ConfigurationError, SerializationError, TaskferryError
33
+ from taskferry.core.errors import ProviderError as _ProviderError
34
+ from taskferry.core.errors import UnsupportedCapabilityError as _UnsupportedCapabilityError
35
+
36
+
37
+ class BackendError(_ProviderError):
38
+ """A backend failed to carry out an operation.
39
+
40
+ Subclasses ``ProviderError`` so code written against 0.1 keeps working. The
41
+ offending backend is named in :attr:`backend`.
42
+ """
43
+
44
+ def __init__(self, message: str, *, backend: str | None = None) -> None:
45
+ super().__init__(message, provider=backend)
46
+
47
+ @property
48
+ def backend(self) -> str | None:
49
+ """Name of the backend that raised, when known."""
50
+ return self.provider
51
+
52
+
53
+ class SubmissionError(BackendError):
54
+ """A spec could not be handed to the backend.
55
+
56
+ Raised at submit time — nothing was enqueued, so retrying the submission is
57
+ safe unless the backend documents otherwise.
58
+ """
59
+
60
+
61
+ class ExecutionError(BackendError):
62
+ """An execution ran and failed.
63
+
64
+ :attr:`cause_repr` carries the remote exception's textual form when the
65
+ backend can supply it (the real exception object rarely survives a process or
66
+ network boundary).
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ message: str,
72
+ *,
73
+ backend: str | None = None,
74
+ cause_repr: str | None = None,
75
+ ) -> None:
76
+ super().__init__(message, backend=backend)
77
+ self.cause_repr = cause_repr
78
+
79
+
80
+ class ExecutionNotFound(BackendError):
81
+ """No execution with the given id is known to the backend.
82
+
83
+ Distinct from :class:`ExecutionError`: the execution may never have existed,
84
+ or the backend may have expired its record.
85
+ """
86
+
87
+
88
+ class ExecutionCancelled(BackendError):
89
+ """The execution was cancelled before it could produce a result."""
90
+
91
+
92
+ UnsupportedCapability = _UnsupportedCapabilityError
93
+ """The operation needs a capability the backend does not advertise.
94
+
95
+ Taskferry raises this rather than emulating the capability, because an emulated
96
+ cancel or an emulated timeout is a correctness bug waiting for production.
97
+
98
+ This is an **alias**, not a subclass, of
99
+ :class:`taskferry.core.errors.UnsupportedCapabilityError`. It has to be: a
100
+ subclass would mean ``CapabilitySet.require()`` raises the parent while every
101
+ caller is told to catch the child, so the documented ``except
102
+ UnsupportedCapability`` would silently miss the most common source of the error.
103
+ One concept, one class, two names."""
104
+
105
+
106
+ class RoutingError(ConfigurationError):
107
+ """No backend could be selected for a spec, or the selected one is unknown."""
108
+
109
+
110
+ class FunctionResolutionError(TaskferryError):
111
+ """A ``package.module:function`` reference could not be resolved.
112
+
113
+ Also raised when a reference is rejected by the import allowlist — the
114
+ message says which, so a deployment problem is never mistaken for a typo.
115
+ """
116
+
117
+
118
+ class TaskferryTimeoutError(TaskferryError, TimeoutError):
119
+ """A ``wait``/``result`` call exceeded its timeout.
120
+
121
+ Subclasses the built-in :class:`TimeoutError` so ``except TimeoutError``
122
+ keeps working for callers that do not import Taskferry's hierarchy.
123
+ """
124
+
125
+
126
+ # Public alias. ``taskferry.TimeoutError`` shadows the builtin only inside an
127
+ # explicit ``from taskferry import TimeoutError``, which is opt-in and explicit.
128
+ TimeoutError = TaskferryTimeoutError
129
+
130
+ __all__ = [
131
+ "BackendError",
132
+ "ConfigurationError",
133
+ "ExecutionCancelled",
134
+ "ExecutionError",
135
+ "ExecutionNotFound",
136
+ "FunctionResolutionError",
137
+ "RoutingError",
138
+ "SerializationError",
139
+ "SubmissionError",
140
+ "TaskferryError",
141
+ "TaskferryTimeoutError",
142
+ "TimeoutError",
143
+ "UnsupportedCapability",
144
+ ]
taskferry/execution.py ADDED
@@ -0,0 +1,239 @@
1
+ """Execution — the portable identity of one unit of work in flight.
2
+
3
+ Every backend, of every kind, describes what it did in these terms. An
4
+ :class:`Execution` is an immutable *snapshot*: what the backend knew at the
5
+ moment it was asked. An :class:`~taskferry.handle.ExecutionHandle` (see
6
+ ``taskferry.handle``) is the live, refreshable reference built on top of it.
7
+
8
+ State machine
9
+ -------------
10
+
11
+ ```mermaid
12
+ stateDiagram-v2
13
+ [*] --> PENDING
14
+ PENDING --> QUEUED
15
+ PENDING --> RUNNING
16
+ QUEUED --> RUNNING
17
+ QUEUED --> CANCELLED
18
+ RUNNING --> SUCCEEDED
19
+ RUNNING --> FAILED
20
+ RUNNING --> CANCELLED
21
+ RUNNING --> TIMED_OUT
22
+ FAILED --> QUEUED: retry (engine-owned)
23
+ SUCCEEDED --> [*]
24
+ CANCELLED --> [*]
25
+ TIMED_OUT --> [*]
26
+ ```
27
+
28
+ Not every backend can observe every transition. An inline execution goes straight
29
+ from ``PENDING`` to a terminal state; a fire-and-forget push queue may only ever
30
+ report ``QUEUED`` and then ``UNKNOWN``. Adapters map what the provider actually
31
+ reports and use :data:`ExecutionState.UNKNOWN` rather than guessing.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections.abc import Mapping
37
+ from dataclasses import dataclass, field, replace
38
+ from datetime import datetime
39
+ from enum import StrEnum
40
+ from types import MappingProxyType
41
+ from typing import Any
42
+
43
+ from taskferry.core.correlation import Correlation
44
+ from taskferry.core.ids import TaskferryId, new_id
45
+ from taskferry.core.provider import ProviderMetadata
46
+
47
+ ExecutionId = TaskferryId
48
+ """A Taskferry-owned execution id. Never the provider's id — that lives in
49
+ :attr:`Execution.external_id`, so one execution stays followable across systems."""
50
+
51
+
52
+ class ExecutionKind(StrEnum):
53
+ """Which execution primitive produced this execution."""
54
+
55
+ INLINE = "inline"
56
+ TASK = "task"
57
+ JOB = "job"
58
+
59
+
60
+ class ExecutionState(StrEnum):
61
+ """Portable lifecycle state. See the diagram in the module docstring."""
62
+
63
+ PENDING = "pending"
64
+ """Accepted by Taskferry, not yet acknowledged by the engine."""
65
+
66
+ QUEUED = "queued"
67
+ """Accepted by the engine, waiting for a worker or a slot."""
68
+
69
+ RUNNING = "running"
70
+ CANCELLED = "cancelled"
71
+ SUCCEEDED = "succeeded"
72
+ FAILED = "failed"
73
+ TIMED_OUT = "timed_out"
74
+
75
+ UNKNOWN = "unknown"
76
+ """The backend cannot determine the state. Not an error and not terminal —
77
+ a later poll may resolve it."""
78
+
79
+ @property
80
+ def is_terminal(self) -> bool:
81
+ """Whether no further transition is expected (barring an engine retry)."""
82
+ return self in _TERMINAL
83
+
84
+ @property
85
+ def is_successful(self) -> bool:
86
+ return self is ExecutionState.SUCCEEDED
87
+
88
+
89
+ _TERMINAL = frozenset(
90
+ {
91
+ ExecutionState.SUCCEEDED,
92
+ ExecutionState.FAILED,
93
+ ExecutionState.CANCELLED,
94
+ ExecutionState.TIMED_OUT,
95
+ }
96
+ )
97
+
98
+ #: The transitions Taskferry considers legal. Adapters are not forced through
99
+ #: this table (providers report what they report), but ``Execution.transitions_to``
100
+ #: lets tests and diagnostics flag a mapping that cannot be right.
101
+ _ALLOWED: Mapping[ExecutionState, frozenset[ExecutionState]] = MappingProxyType(
102
+ {
103
+ ExecutionState.PENDING: frozenset(
104
+ {ExecutionState.QUEUED, ExecutionState.RUNNING, ExecutionState.UNKNOWN} | _TERMINAL
105
+ ),
106
+ # SUCCEEDED is reachable directly from QUEUED, and not as an edge case:
107
+ # a worker that finishes between the submit and the first poll means the
108
+ # observer never sees RUNNING at all. Every real engine does this
109
+ # constantly. A transition table that forbids it would flag correct
110
+ # adapters as broken.
111
+ ExecutionState.QUEUED: frozenset(
112
+ {ExecutionState.RUNNING, ExecutionState.UNKNOWN} | _TERMINAL
113
+ ),
114
+ ExecutionState.RUNNING: frozenset(
115
+ {ExecutionState.UNKNOWN} | _TERMINAL,
116
+ ),
117
+ # An engine-owned retry legitimately moves a failed execution back into
118
+ # the queue; see ADR-0011 on retry ownership.
119
+ ExecutionState.FAILED: frozenset({ExecutionState.QUEUED, ExecutionState.RUNNING}),
120
+ ExecutionState.TIMED_OUT: frozenset({ExecutionState.QUEUED}),
121
+ ExecutionState.SUCCEEDED: frozenset(),
122
+ ExecutionState.CANCELLED: frozenset(),
123
+ ExecutionState.UNKNOWN: frozenset(ExecutionState),
124
+ }
125
+ )
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class ExecutionResult:
130
+ """The outcome of a finished execution.
131
+
132
+ ``value`` is only meaningful when the backend advertises
133
+ :attr:`~taskferry.capabilities.Capability.RESULT`; otherwise it stays ``None``
134
+ and callers must not read anything into that.
135
+ """
136
+
137
+ value: Any = None
138
+ error: str | None = None
139
+ error_type: str | None = None
140
+ traceback: str | None = None
141
+ exit_code: int | None = None
142
+ logs_uri: str | None = None
143
+
144
+ @classmethod
145
+ def from_exception(cls, exc: BaseException) -> ExecutionResult:
146
+ """Build a failed result from a raised exception.
147
+
148
+ Captures the message, qualified type name and formatted traceback the
149
+ same way every in-process backend needs, so the construction lives here
150
+ once instead of being copied into each one.
151
+ """
152
+ import traceback as _traceback
153
+
154
+ return cls(
155
+ error=str(exc) or type(exc).__name__,
156
+ error_type=type(exc).__qualname__,
157
+ traceback="".join(_traceback.format_exception(type(exc), exc, exc.__traceback__)),
158
+ )
159
+
160
+ @classmethod
161
+ def cancelled(cls) -> ExecutionResult:
162
+ """The placeholder result for an execution cancelled before it produced one."""
163
+ return cls(error="execution was cancelled", error_type="ExecutionCancelled")
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class Execution:
168
+ """An immutable snapshot of one unit of work.
169
+
170
+ Attributes:
171
+ id: Taskferry-owned id, stable for the life of the execution.
172
+ kind: Which primitive produced it.
173
+ backend: Name of the backend that owns it (``"local"``, ``"procrastinate"``).
174
+ state: Portable lifecycle state at snapshot time.
175
+ name: Human-readable name — the task path or the job name.
176
+ created_at: When Taskferry accepted the submission.
177
+ started_at: When the engine began running it, if reported.
178
+ finished_at: When it reached a terminal state, if reported.
179
+ external_id: The engine's own id (Procrastinate job id, Cloud Run
180
+ execution name, Cloud Tasks task name).
181
+ attempt: 1-based attempt counter, when the engine reports one.
182
+ result: Outcome, populated once terminal and if the backend can supply it.
183
+ correlation: Correlation propagated from the spec.
184
+ provider_metadata: Region/resource/labels for the underlying resource.
185
+ metadata: Free-form backend annotations. JSON-shaped by convention.
186
+ """
187
+
188
+ id: ExecutionId
189
+ kind: ExecutionKind
190
+ backend: str
191
+ state: ExecutionState
192
+ name: str = ""
193
+ created_at: datetime | None = None
194
+ started_at: datetime | None = None
195
+ finished_at: datetime | None = None
196
+ external_id: str | None = None
197
+ attempt: int = 1
198
+ result: ExecutionResult | None = None
199
+ correlation: Correlation | None = None
200
+ provider_metadata: ProviderMetadata | None = None
201
+ metadata: Mapping[str, str] = field(default_factory=dict)
202
+
203
+ def __post_init__(self) -> None:
204
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
205
+
206
+ @property
207
+ def is_terminal(self) -> bool:
208
+ return self.state.is_terminal
209
+
210
+ @property
211
+ def is_successful(self) -> bool:
212
+ return self.state.is_successful
213
+
214
+ def transitions_to(self, state: ExecutionState) -> bool:
215
+ """Whether moving from this snapshot's state to ``state`` is legal.
216
+
217
+ Used by the contract suite to catch adapters that map provider states
218
+ onto impossible transitions (``SUCCEEDED`` → ``RUNNING``, say).
219
+ """
220
+ return state is self.state or state in _ALLOWED[self.state]
221
+
222
+ def evolve(self, **changes: Any) -> Execution:
223
+ """Return a copy with ``changes`` applied. The original is never mutated."""
224
+ return replace(self, **changes)
225
+
226
+
227
+ def new_execution_id(kind: ExecutionKind = ExecutionKind.TASK) -> ExecutionId:
228
+ """Mint a fresh execution id prefixed with the kind (``task_``, ``job_``)."""
229
+ return new_id(kind.value)
230
+
231
+
232
+ __all__ = [
233
+ "Execution",
234
+ "ExecutionId",
235
+ "ExecutionKind",
236
+ "ExecutionResult",
237
+ "ExecutionState",
238
+ "new_execution_id",
239
+ ]