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/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"]