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
taskferry/functions.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Naming Python callables so a worker in another process can find them.
|
|
2
|
+
|
|
3
|
+
A remote task is a *name*, not an object. Taskferry's portable form is::
|
|
4
|
+
|
|
5
|
+
package.module:function_name
|
|
6
|
+
|
|
7
|
+
Chosen because it is unambiguous (a colon separates module from attribute, so
|
|
8
|
+
``a.b.c`` never has to be guessed at), it is already the convention used by
|
|
9
|
+
entry points, uvicorn and gunicorn, and it survives any transport as a plain
|
|
10
|
+
string.
|
|
11
|
+
|
|
12
|
+
Taskferry never pickles a callable. Pickling ties the payload to one interpreter
|
|
13
|
+
version and one code layout, and unpickling attacker-controlled bytes is remote
|
|
14
|
+
code execution. A reference plus JSON arguments is portable and inspectable.
|
|
15
|
+
|
|
16
|
+
Security
|
|
17
|
+
--------
|
|
18
|
+
|
|
19
|
+
Resolving a reference means importing a module and calling an attribute of it.
|
|
20
|
+
If task names arrive from an untrusted queue, an unrestricted resolver is an RCE
|
|
21
|
+
primitive. :class:`FunctionRegistry` therefore supports two controls:
|
|
22
|
+
|
|
23
|
+
* **explicit registration** — resolve only what was registered;
|
|
24
|
+
* **an import allowlist** — permit dynamic import only from named packages.
|
|
25
|
+
|
|
26
|
+
The default (``allow_import=True``, empty allowlist) is convenient for
|
|
27
|
+
development. Production deployments should set an allowlist; see
|
|
28
|
+
``docs/security.md``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import importlib
|
|
34
|
+
import inspect
|
|
35
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any, cast
|
|
38
|
+
|
|
39
|
+
from .errors import FunctionResolutionError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class FunctionRef:
|
|
44
|
+
"""A portable reference to a module-level callable."""
|
|
45
|
+
|
|
46
|
+
module: str
|
|
47
|
+
qualname: str
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not self.module or not self.qualname:
|
|
51
|
+
raise ValueError("FunctionRef needs both a module and a qualname")
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return self.path
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def path(self) -> str:
|
|
58
|
+
"""The portable ``package.module:function`` string."""
|
|
59
|
+
return f"{self.module}:{self.qualname}"
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def parse(cls, path: str) -> FunctionRef:
|
|
63
|
+
"""Parse ``"package.module:function"``.
|
|
64
|
+
|
|
65
|
+
Legacy dotted form (``"package.module.function"``) is accepted so 0.1
|
|
66
|
+
configuration keeps working; the colon form is unambiguous and preferred.
|
|
67
|
+
"""
|
|
68
|
+
module, sep, qualname = path.partition(":")
|
|
69
|
+
if not sep:
|
|
70
|
+
module, _, qualname = path.rpartition(".")
|
|
71
|
+
if not module or not qualname:
|
|
72
|
+
raise FunctionResolutionError(
|
|
73
|
+
f"{path!r} is not a valid function reference; expected 'package.module:function'"
|
|
74
|
+
)
|
|
75
|
+
return cls(module=module, qualname=qualname)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_callable(cls, func: Callable[..., Any]) -> FunctionRef:
|
|
79
|
+
"""Derive a reference from a callable.
|
|
80
|
+
|
|
81
|
+
Rejects anything a worker in another process could not reconstruct:
|
|
82
|
+
lambdas, closures, locals, ``__main__``, and — less obviously — callables
|
|
83
|
+
that carry **state**. Failing here is far kinder than failing on a worker.
|
|
84
|
+
|
|
85
|
+
The stateful cases are the subtle ones, and they are the shape a library
|
|
86
|
+
embedding Taskferry usually reaches for. A configured object like::
|
|
87
|
+
|
|
88
|
+
processor = Processor(factor=3)
|
|
89
|
+
runtime.tasks.submit(processor.run, 14)
|
|
90
|
+
|
|
91
|
+
has a perfectly good importable name — ``mypkg:Processor.run`` — so a
|
|
92
|
+
naive check accepts it. But only the *name* travels: the worker imports
|
|
93
|
+
the plain function, ``factor`` is gone, and the call fails with a
|
|
94
|
+
confusing ``TypeError`` about a missing argument, on the worker, at 3am.
|
|
95
|
+
The same is true of a callable instance, whose name resolves to its class.
|
|
96
|
+
|
|
97
|
+
Both are refused here, pointing at the way through: one importable
|
|
98
|
+
dispatcher, with the identity as JSON data. That is what
|
|
99
|
+
``taskferry_django.execute:run_task`` does for Django's Task objects, and
|
|
100
|
+
it is the pattern any embedder with configured units of work needs.
|
|
101
|
+
"""
|
|
102
|
+
method_self = getattr(func, "__self__", None)
|
|
103
|
+
if method_self is not None and not isinstance(method_self, type):
|
|
104
|
+
# A bound method of an *instance*. A classmethod (``__self__`` is the
|
|
105
|
+
# class) re-binds correctly on import and is deliberately allowed.
|
|
106
|
+
owner = type(method_self).__name__
|
|
107
|
+
raise FunctionResolutionError(
|
|
108
|
+
f"{func!r} is a method bound to a {owner} instance. Only its name would "
|
|
109
|
+
f"reach the worker, so the instance's state would be lost and the call "
|
|
110
|
+
f"would fail there rather than here. Submit a module-level function, or "
|
|
111
|
+
f"route through one importable dispatcher that rebuilds the {owner} from "
|
|
112
|
+
f"JSON arguments."
|
|
113
|
+
)
|
|
114
|
+
if (
|
|
115
|
+
method_self is None # a classmethod already passed the check above
|
|
116
|
+
and not isinstance(func, type)
|
|
117
|
+
and not inspect.isfunction(func)
|
|
118
|
+
and not inspect.isbuiltin(func)
|
|
119
|
+
and callable(func)
|
|
120
|
+
):
|
|
121
|
+
# A callable *instance*: its ``__qualname__`` names the class, so the
|
|
122
|
+
# worker would import the class and construct a new object with the
|
|
123
|
+
# task's arguments — silently the wrong thing.
|
|
124
|
+
raise FunctionResolutionError(
|
|
125
|
+
f"{func!r} is a callable {type(func).__name__} instance, not a function. "
|
|
126
|
+
f"Its name resolves to the class, so a worker would construct a new object "
|
|
127
|
+
f"rather than use this one. Submit a module-level function, or route "
|
|
128
|
+
f"through one importable dispatcher."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
module = getattr(func, "__module__", None)
|
|
132
|
+
qualname = getattr(func, "__qualname__", None)
|
|
133
|
+
if not module or not qualname:
|
|
134
|
+
raise FunctionResolutionError(
|
|
135
|
+
f"{func!r} has no importable name; only module-level functions can be "
|
|
136
|
+
"referenced remotely"
|
|
137
|
+
)
|
|
138
|
+
if "<locals>" in qualname or qualname == "<lambda>":
|
|
139
|
+
raise FunctionResolutionError(
|
|
140
|
+
f"{qualname!r} is defined inside another scope and cannot be resolved by a "
|
|
141
|
+
"remote worker; move it to module level"
|
|
142
|
+
)
|
|
143
|
+
if module == "__main__":
|
|
144
|
+
raise FunctionResolutionError(
|
|
145
|
+
f"{qualname!r} is defined in __main__, which resolves to a different module "
|
|
146
|
+
"in a worker process; move it into an importable module"
|
|
147
|
+
)
|
|
148
|
+
return cls(module=module, qualname=qualname)
|
|
149
|
+
|
|
150
|
+
def resolve(self) -> Callable[..., Any]:
|
|
151
|
+
"""Import the module and return the callable. No allowlist is applied.
|
|
152
|
+
|
|
153
|
+
Prefer :meth:`FunctionRegistry.resolve`, which applies the deployment's
|
|
154
|
+
policy. This method exists for trusted, in-process use.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
module = importlib.import_module(self.module)
|
|
158
|
+
except ImportError as exc:
|
|
159
|
+
raise FunctionResolutionError(f"cannot import module {self.module!r}: {exc}") from exc
|
|
160
|
+
target: Any = module
|
|
161
|
+
for part in self.qualname.split("."):
|
|
162
|
+
try:
|
|
163
|
+
target = getattr(target, part)
|
|
164
|
+
except AttributeError as exc:
|
|
165
|
+
raise FunctionResolutionError(
|
|
166
|
+
f"{self.module!r} has no attribute {self.qualname!r}"
|
|
167
|
+
) from exc
|
|
168
|
+
if not callable(target):
|
|
169
|
+
raise FunctionResolutionError(f"{self.path!r} resolved to a non-callable")
|
|
170
|
+
return cast(Callable[..., Any], target)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class FunctionRegistry:
|
|
174
|
+
"""Maps task names to callables, with an import policy.
|
|
175
|
+
|
|
176
|
+
Resolution order:
|
|
177
|
+
|
|
178
|
+
1. an explicit registration under that exact name;
|
|
179
|
+
2. dynamic import, if :attr:`allow_import` and the module passes the
|
|
180
|
+
allowlist.
|
|
181
|
+
|
|
182
|
+
Instances are not thread-safe for concurrent *registration*; register at
|
|
183
|
+
import time (the normal pattern) and the read path is then safe to share
|
|
184
|
+
across threads.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
allow_import: bool = True,
|
|
191
|
+
allowed_modules: Sequence[str] = (),
|
|
192
|
+
) -> None:
|
|
193
|
+
self._functions: dict[str, Callable[..., Any]] = {}
|
|
194
|
+
self.allow_import = allow_import
|
|
195
|
+
self.allowed_modules: tuple[str, ...] = tuple(allowed_modules)
|
|
196
|
+
|
|
197
|
+
def register(
|
|
198
|
+
self,
|
|
199
|
+
func: Callable[..., Any],
|
|
200
|
+
*,
|
|
201
|
+
name: str | None = None,
|
|
202
|
+
replace: bool = False,
|
|
203
|
+
) -> Callable[..., Any]:
|
|
204
|
+
"""Register ``func``, returning it so this works as a decorator::
|
|
205
|
+
|
|
206
|
+
@registry.register
|
|
207
|
+
def send_email(to: str) -> None: ...
|
|
208
|
+
"""
|
|
209
|
+
key = name or FunctionRef.from_callable(func).path
|
|
210
|
+
if key in self._functions and not replace and self._functions[key] is not func:
|
|
211
|
+
raise FunctionResolutionError(f"{key!r} is already registered to a different callable")
|
|
212
|
+
self._functions[key] = func
|
|
213
|
+
return func
|
|
214
|
+
|
|
215
|
+
def unregister(self, name: str) -> None:
|
|
216
|
+
self._functions.pop(name, None)
|
|
217
|
+
|
|
218
|
+
def clear(self) -> None:
|
|
219
|
+
self._functions.clear()
|
|
220
|
+
|
|
221
|
+
def __contains__(self, name: object) -> bool:
|
|
222
|
+
return name in self._functions
|
|
223
|
+
|
|
224
|
+
def __iter__(self) -> Iterator[str]:
|
|
225
|
+
return iter(self._functions)
|
|
226
|
+
|
|
227
|
+
def __len__(self) -> int:
|
|
228
|
+
return len(self._functions)
|
|
229
|
+
|
|
230
|
+
def names(self) -> tuple[str, ...]:
|
|
231
|
+
return tuple(sorted(self._functions))
|
|
232
|
+
|
|
233
|
+
def resolve(self, name: str) -> Callable[..., Any]:
|
|
234
|
+
"""Return the callable for ``name``, applying the import policy."""
|
|
235
|
+
registered = self._functions.get(name)
|
|
236
|
+
if registered is not None:
|
|
237
|
+
return registered
|
|
238
|
+
if not self.allow_import:
|
|
239
|
+
known = ", ".join(self.names()) or "<none>"
|
|
240
|
+
raise FunctionResolutionError(
|
|
241
|
+
f"{name!r} is not registered and dynamic import is disabled (registered: {known})"
|
|
242
|
+
)
|
|
243
|
+
ref = FunctionRef.parse(name)
|
|
244
|
+
if not self._module_allowed(ref.module):
|
|
245
|
+
allowed = ", ".join(self.allowed_modules)
|
|
246
|
+
raise FunctionResolutionError(
|
|
247
|
+
f"module {ref.module!r} is not in the import allowlist ({allowed})"
|
|
248
|
+
)
|
|
249
|
+
return ref.resolve()
|
|
250
|
+
|
|
251
|
+
def reference(self, target: Callable[..., Any] | str | FunctionRef) -> FunctionRef:
|
|
252
|
+
"""Normalise anything nameable into a :class:`FunctionRef`.
|
|
253
|
+
|
|
254
|
+
Registering the callable at the same time is deliberate: the process that
|
|
255
|
+
submits a task usually also runs it in tests and in inline mode, and an
|
|
256
|
+
unregistered-but-submittable name is a foot-gun.
|
|
257
|
+
"""
|
|
258
|
+
if isinstance(target, FunctionRef):
|
|
259
|
+
return target
|
|
260
|
+
if isinstance(target, str):
|
|
261
|
+
return FunctionRef.parse(target)
|
|
262
|
+
ref = FunctionRef.from_callable(target)
|
|
263
|
+
self._functions.setdefault(ref.path, target)
|
|
264
|
+
return ref
|
|
265
|
+
|
|
266
|
+
def _module_allowed(self, module: str) -> bool:
|
|
267
|
+
if not self.allowed_modules:
|
|
268
|
+
return True
|
|
269
|
+
return any(
|
|
270
|
+
module == allowed or module.startswith(f"{allowed}.")
|
|
271
|
+
for allowed in self.allowed_modules
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def is_async_callable(func: Callable[..., Any]) -> bool:
|
|
276
|
+
"""Whether calling ``func`` returns a coroutine.
|
|
277
|
+
|
|
278
|
+
Unwraps ``functools.partial`` and looks through ``__call__`` so async
|
|
279
|
+
callable objects are detected, not just plain ``async def``.
|
|
280
|
+
"""
|
|
281
|
+
unwrapped = func
|
|
282
|
+
while hasattr(unwrapped, "func"): # functools.partial and friends
|
|
283
|
+
unwrapped = unwrapped.func
|
|
284
|
+
if inspect.iscoroutinefunction(unwrapped):
|
|
285
|
+
return True
|
|
286
|
+
call = getattr(unwrapped, "__call__", None) # noqa: B004 - intentional
|
|
287
|
+
return call is not None and inspect.iscoroutinefunction(call)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = ["FunctionRef", "FunctionRegistry", "is_async_callable"]
|
taskferry/handle.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""ExecutionHandle — the live reference you actually hold.
|
|
2
|
+
|
|
3
|
+
:class:`~taskferry.execution.Execution` is a *snapshot*: immutable, cheap, exactly
|
|
4
|
+
what the backend knew at one moment. An :class:`ExecutionHandle` is the *live*
|
|
5
|
+
reference: it remembers which backend owns the execution and can go ask again.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart LR
|
|
9
|
+
H["ExecutionHandle<br/>id · backend ref · cached snapshot"]
|
|
10
|
+
B["Backend"]
|
|
11
|
+
E["Execution snapshot"]
|
|
12
|
+
|
|
13
|
+
H -->|"refresh() / status()"| B
|
|
14
|
+
B --> E
|
|
15
|
+
E --> H
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Operations the backend does not advertise raise
|
|
19
|
+
:class:`~taskferry.errors.UnsupportedCapability` — a handle never fakes a cancel
|
|
20
|
+
and never invents a result.
|
|
21
|
+
|
|
22
|
+
Thread safety
|
|
23
|
+
-------------
|
|
24
|
+
|
|
25
|
+
A handle caches the most recent snapshot behind a lock, so concurrent
|
|
26
|
+
``refresh()`` calls from several threads are safe and always leave a consistent
|
|
27
|
+
snapshot. The handle is bound to a live backend object, so it does **not** cross
|
|
28
|
+
a process boundary; to follow an execution from elsewhere, carry
|
|
29
|
+
:attr:`ExecutionHandle.id` and call ``runtime.get(id)`` there.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import contextlib
|
|
35
|
+
import threading
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
|
|
38
|
+
from .capabilities import Capability, CapabilitySet
|
|
39
|
+
from .errors import ExecutionError, TaskferryTimeoutError, UnsupportedCapability
|
|
40
|
+
from .execution import Execution, ExecutionId, ExecutionKind, ExecutionResult, ExecutionState
|
|
41
|
+
from .ports import ExecutionBackend
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExecutionHandle:
|
|
45
|
+
"""A refreshable reference to one execution on one backend."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_backend", "_execution", "_lock")
|
|
48
|
+
|
|
49
|
+
def __init__(self, execution: Execution, backend: ExecutionBackend) -> None:
|
|
50
|
+
self._execution = execution
|
|
51
|
+
self._backend = backend
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
# -- identity ----------------------------------------------------------- #
|
|
55
|
+
@property
|
|
56
|
+
def id(self) -> ExecutionId:
|
|
57
|
+
return self._execution.id
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def kind(self) -> ExecutionKind:
|
|
61
|
+
return self._execution.kind
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
return self._execution.name
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def backend(self) -> str:
|
|
69
|
+
"""Name of the backend that owns this execution."""
|
|
70
|
+
return self._backend.name
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def capabilities(self) -> CapabilitySet:
|
|
74
|
+
"""What the owning backend can do — ask before calling ``cancel``."""
|
|
75
|
+
return self._backend.capabilities
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def external_id(self) -> str | None:
|
|
79
|
+
"""The engine's own id for this execution, when it reported one."""
|
|
80
|
+
return self._execution.external_id
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def created_at(self) -> datetime | None:
|
|
84
|
+
return self._execution.created_at
|
|
85
|
+
|
|
86
|
+
# -- current snapshot ---------------------------------------------------- #
|
|
87
|
+
@property
|
|
88
|
+
def execution(self) -> Execution:
|
|
89
|
+
"""The most recent snapshot. Does **not** contact the backend."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
return self._execution
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def state(self) -> ExecutionState:
|
|
95
|
+
"""State as of the last snapshot. Call :meth:`status` to re-read."""
|
|
96
|
+
return self.execution.state
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def done(self) -> bool:
|
|
100
|
+
"""Whether the last snapshot was terminal. Does not contact the backend."""
|
|
101
|
+
return self.execution.is_terminal
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
snapshot = self.execution
|
|
105
|
+
return (
|
|
106
|
+
f"<ExecutionHandle {snapshot.kind.value} {snapshot.id} "
|
|
107
|
+
f"backend={self.backend!r} state={snapshot.state.value!r}>"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# -- live operations ------------------------------------------------------ #
|
|
111
|
+
def refresh(self) -> Execution:
|
|
112
|
+
"""Re-read the execution from the backend and cache the new snapshot.
|
|
113
|
+
|
|
114
|
+
A terminal snapshot is never re-read: terminal states do not change, and
|
|
115
|
+
polling a finished execution wastes an engine round-trip.
|
|
116
|
+
"""
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._execution.is_terminal:
|
|
119
|
+
return self._execution
|
|
120
|
+
fresh = self._backend.get(self.id)
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._execution = fresh
|
|
123
|
+
return fresh
|
|
124
|
+
|
|
125
|
+
def status(self) -> ExecutionState:
|
|
126
|
+
"""Refresh and return the current state."""
|
|
127
|
+
return self.refresh().state
|
|
128
|
+
|
|
129
|
+
def wait(self, timeout: float | None = None) -> Execution:
|
|
130
|
+
"""Block until the execution is terminal, then return the final snapshot.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
TaskferryTimeoutError: if ``timeout`` elapses first.
|
|
134
|
+
UnsupportedCapability: if the backend cannot report state.
|
|
135
|
+
"""
|
|
136
|
+
with self._lock:
|
|
137
|
+
if self._execution.is_terminal:
|
|
138
|
+
return self._execution
|
|
139
|
+
final = self._backend.wait(self.id, timeout=timeout)
|
|
140
|
+
with self._lock:
|
|
141
|
+
self._execution = final
|
|
142
|
+
return final
|
|
143
|
+
|
|
144
|
+
def result(self, timeout: float | None = None) -> ExecutionResult:
|
|
145
|
+
"""Return the outcome, waiting up to ``timeout`` for it.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ExecutionError: if the execution failed.
|
|
149
|
+
TaskferryTimeoutError: if it did not finish in time.
|
|
150
|
+
UnsupportedCapability: if the backend cannot return results.
|
|
151
|
+
"""
|
|
152
|
+
outcome = self._backend.result(self.id, timeout=timeout)
|
|
153
|
+
if self._backend.capabilities.supports(Capability.STATE):
|
|
154
|
+
# Refreshing is a courtesy so the handle reflects the final state; a
|
|
155
|
+
# slow backend must not turn a successful result() into a failure.
|
|
156
|
+
with contextlib.suppress(TaskferryTimeoutError):
|
|
157
|
+
self.refresh()
|
|
158
|
+
if outcome.error is not None:
|
|
159
|
+
raise ExecutionError(
|
|
160
|
+
f"{self.kind.value} {self.id} failed: {outcome.error}",
|
|
161
|
+
backend=self.backend,
|
|
162
|
+
cause_repr=outcome.traceback or outcome.error,
|
|
163
|
+
)
|
|
164
|
+
return outcome
|
|
165
|
+
|
|
166
|
+
def value(self, timeout: float | None = None) -> object:
|
|
167
|
+
"""The value the execution produced — :meth:`result` without the wrapper."""
|
|
168
|
+
return self.result(timeout).value
|
|
169
|
+
|
|
170
|
+
def cancel(self) -> Execution:
|
|
171
|
+
"""Cancel the execution.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
UnsupportedCapability: if the backend cannot cancel. Taskferry does
|
|
175
|
+
not emulate cancellation; an emulated cancel that leaves work
|
|
176
|
+
running is worse than a clear refusal.
|
|
177
|
+
"""
|
|
178
|
+
if not self._backend.capabilities.supports(Capability.CANCEL):
|
|
179
|
+
raise UnsupportedCapability(Capability.CANCEL.value, provider=self.backend)
|
|
180
|
+
cancelled = self._backend.cancel(self.id)
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._execution = cancelled
|
|
183
|
+
return cancelled
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
__all__ = ["ExecutionHandle"]
|