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/hooks.py ADDED
@@ -0,0 +1,238 @@
1
+ """Observability hooks — composable, explicit, never global.
2
+
3
+ Taskferry deliberately has no global signal bus. A bus makes it impossible to
4
+ know which listeners will run, listeners registered by a library leak into every
5
+ consumer, and tests have to remember to tear down. Instead a :class:`Hook` is an
6
+ ordinary object you pass to the runtime::
7
+
8
+ runtime = Taskferry(config=config, hooks=[LoggingHook(), MetricsHook()])
9
+
10
+ ```mermaid
11
+ sequenceDiagram
12
+ participant App
13
+ participant TP as Taskferry
14
+ participant H as Hooks
15
+ participant B as Backend
16
+ participant E as Engine
17
+
18
+ App->>TP: submit(spec)
19
+ TP->>H: before_submit(spec, backend)
20
+ TP->>B: submit(spec)
21
+ B->>E: enqueue
22
+ E-->>B: execution
23
+ B-->>TP: Execution
24
+ TP->>H: after_submit(spec, execution)
25
+ TP-->>App: ExecutionHandle
26
+ ```
27
+
28
+ Every method has a no-op default, so a hook implements only what it cares about.
29
+ A hook that raises is *never* allowed to break the submission it observes — the
30
+ chain catches, reports through :func:`hook_error_handler`, and carries on. A
31
+ telemetry outage must not take the application down with it.
32
+
33
+ Ordering, precisely
34
+ -------------------
35
+
36
+ Hooks come in two families, and the guarantee is *per family*:
37
+
38
+ * **submit-side** — ``before_submit`` always precedes ``after_submit``;
39
+ * **execute-side** — ``before_execute`` precedes ``on_success``/``on_failure``,
40
+ with ``on_retry`` in between for each re-attempt.
41
+
42
+ Across the two families there is **no ordering guarantee** whenever execution is
43
+ concurrent. A thread-pool worker can start before ``after_submit`` returns; a
44
+ Procrastinate worker runs in a different process, where ordering is not even a
45
+ meaningful question. The one exception is inline execution, where the work
46
+ happens inside ``submit`` and every execute-side hook therefore fires before
47
+ ``after_submit``. A hook that needs to distinguish "submitted" from "finished"
48
+ should read :attr:`~taskferry.execution.Execution.state`, not infer it from
49
+ arrival order.
50
+
51
+ Trace context propagation is separate: it rides on
52
+ :class:`~taskferry.core.correlation.Correlation`, which adapters copy into engine
53
+ metadata. Hooks observe; correlation travels.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import logging
59
+ from collections.abc import Callable, Iterable, Sequence
60
+ from typing import Protocol, runtime_checkable
61
+
62
+ from .execution import Execution, ExecutionResult
63
+ from .specs import ExecutionSpec
64
+
65
+ logger = logging.getLogger("taskferry.hooks")
66
+
67
+ HookErrorHandler = Callable[[str, "Hook", BaseException], None]
68
+ """Called when a hook method raises: ``(method_name, hook, exception)``."""
69
+
70
+
71
+ def _log_hook_error(method: str, hook: Hook, exc: BaseException) -> None:
72
+ logger.warning("taskferry hook %r failed in %s(): %s", type(hook).__name__, method, exc)
73
+
74
+
75
+ hook_error_handler: HookErrorHandler = _log_hook_error
76
+ """Module-level policy for hook failures. Replace to re-raise in tests."""
77
+
78
+
79
+ @runtime_checkable
80
+ class Hook(Protocol):
81
+ """Observe the lifecycle of executions. Every method is optional."""
82
+
83
+ def before_submit(self, spec: ExecutionSpec, backend: str) -> None: ...
84
+
85
+ def after_submit(self, spec: ExecutionSpec, execution: Execution) -> None: ...
86
+
87
+ def on_submit_error(self, spec: ExecutionSpec, backend: str, exc: BaseException) -> None: ...
88
+
89
+ def before_execute(self, execution: Execution) -> None: ...
90
+
91
+ def after_execute(self, execution: Execution, result: ExecutionResult) -> None: ...
92
+
93
+ def on_success(self, execution: Execution, result: ExecutionResult) -> None: ...
94
+
95
+ def on_failure(self, execution: Execution, exc: BaseException) -> None: ...
96
+
97
+ def on_cancel(self, execution: Execution) -> None: ...
98
+
99
+ def on_retry(self, execution: Execution, attempt: int, exc: BaseException) -> None: ...
100
+
101
+
102
+ class BaseHook:
103
+ """No-op implementation of every :class:`Hook` method. Subclass and override."""
104
+
105
+ def before_submit(self, spec: ExecutionSpec, backend: str) -> None:
106
+ """Called before a spec is handed to ``backend``."""
107
+
108
+ def after_submit(self, spec: ExecutionSpec, execution: Execution) -> None:
109
+ """Called once the backend has accepted the spec."""
110
+
111
+ def on_submit_error(self, spec: ExecutionSpec, backend: str, exc: BaseException) -> None:
112
+ """Called when submission itself failed. Nothing was enqueued."""
113
+
114
+ def before_execute(self, execution: Execution) -> None:
115
+ """Called by in-process backends immediately before running the work."""
116
+
117
+ def after_execute(self, execution: Execution, result: ExecutionResult) -> None:
118
+ """Called by in-process backends once the work finished, success or not."""
119
+
120
+ def on_success(self, execution: Execution, result: ExecutionResult) -> None:
121
+ """Called when an execution reached ``SUCCEEDED``."""
122
+
123
+ def on_failure(self, execution: Execution, exc: BaseException) -> None:
124
+ """Called when an execution failed with ``exc``."""
125
+
126
+ def on_cancel(self, execution: Execution) -> None:
127
+ """Called when an execution was cancelled through Taskferry."""
128
+
129
+ def on_retry(self, execution: Execution, attempt: int, exc: BaseException) -> None:
130
+ """Called by in-process backends before re-attempting after ``exc``."""
131
+
132
+
133
+ class HookChain:
134
+ """Fans one lifecycle event out to several hooks, in registration order.
135
+
136
+ Immutable and safe to share across threads. A failing hook is reported and
137
+ skipped; the remaining hooks still run.
138
+ """
139
+
140
+ __slots__ = ("_hooks",)
141
+
142
+ def __init__(self, hooks: Iterable[Hook] = ()) -> None:
143
+ self._hooks: tuple[Hook, ...] = tuple(hooks)
144
+
145
+ @property
146
+ def hooks(self) -> Sequence[Hook]:
147
+ return self._hooks
148
+
149
+ def __len__(self) -> int:
150
+ return len(self._hooks)
151
+
152
+ def __bool__(self) -> bool:
153
+ return bool(self._hooks)
154
+
155
+ def __repr__(self) -> str:
156
+ names = ", ".join(type(h).__name__ for h in self._hooks)
157
+ return f"HookChain({names})"
158
+
159
+ def with_hook(self, hook: Hook) -> HookChain:
160
+ """Return a new chain with ``hook`` appended. The original is unchanged."""
161
+ return HookChain((*self._hooks, hook))
162
+
163
+ def _dispatch(self, method: str, *args: object) -> None:
164
+ for hook in self._hooks:
165
+ handler = getattr(hook, method, None)
166
+ if handler is None:
167
+ continue
168
+ try:
169
+ handler(*args)
170
+ except Exception as exc: # never let telemetry break the workload
171
+ hook_error_handler(method, hook, exc)
172
+
173
+ def before_submit(self, spec: ExecutionSpec, backend: str) -> None:
174
+ self._dispatch("before_submit", spec, backend)
175
+
176
+ def after_submit(self, spec: ExecutionSpec, execution: Execution) -> None:
177
+ self._dispatch("after_submit", spec, execution)
178
+
179
+ def on_submit_error(self, spec: ExecutionSpec, backend: str, exc: BaseException) -> None:
180
+ self._dispatch("on_submit_error", spec, backend, exc)
181
+
182
+ def before_execute(self, execution: Execution) -> None:
183
+ self._dispatch("before_execute", execution)
184
+
185
+ def after_execute(self, execution: Execution, result: ExecutionResult) -> None:
186
+ self._dispatch("after_execute", execution, result)
187
+
188
+ def on_success(self, execution: Execution, result: ExecutionResult) -> None:
189
+ self._dispatch("on_success", execution, result)
190
+
191
+ def on_failure(self, execution: Execution, exc: BaseException) -> None:
192
+ self._dispatch("on_failure", execution, exc)
193
+
194
+ def on_cancel(self, execution: Execution) -> None:
195
+ self._dispatch("on_cancel", execution)
196
+
197
+ def on_retry(self, execution: Execution, attempt: int, exc: BaseException) -> None:
198
+ self._dispatch("on_retry", execution, attempt, exc)
199
+
200
+
201
+ class LoggingHook(BaseHook):
202
+ """Logs every lifecycle event. Useful on its own and as a worked example."""
203
+
204
+ def __init__(self, logger_name: str = "taskferry", level: int = logging.INFO) -> None:
205
+ self._log = logging.getLogger(logger_name)
206
+ self._level = level
207
+
208
+ def after_submit(self, spec: ExecutionSpec, execution: Execution) -> None:
209
+ self._log.log(
210
+ self._level,
211
+ "submitted %s %s to %s (%s)",
212
+ execution.kind.value,
213
+ execution.name,
214
+ execution.backend,
215
+ execution.id,
216
+ )
217
+
218
+ def on_submit_error(self, spec: ExecutionSpec, backend: str, exc: BaseException) -> None:
219
+ self._log.warning("submission of %s to %s failed: %s", spec.name, backend, exc)
220
+
221
+ def on_failure(self, execution: Execution, exc: BaseException) -> None:
222
+ self._log.warning("%s %s failed: %s", execution.kind.value, execution.id, exc)
223
+
224
+ def on_retry(self, execution: Execution, attempt: int, exc: BaseException) -> None:
225
+ self._log.info("retrying %s (attempt %d) after %s", execution.id, attempt, exc)
226
+
227
+ def on_cancel(self, execution: Execution) -> None:
228
+ self._log.info("cancelled %s %s", execution.kind.value, execution.id)
229
+
230
+
231
+ __all__ = [
232
+ "BaseHook",
233
+ "Hook",
234
+ "HookChain",
235
+ "HookErrorHandler",
236
+ "LoggingHook",
237
+ "hook_error_handler",
238
+ ]
taskferry/plugins.py ADDED
@@ -0,0 +1,183 @@
1
+ """Backend discovery — entry points, not import-time magic.
2
+
3
+ An adapter distribution advertises its backends in its own ``pyproject.toml``::
4
+
5
+ [project.entry-points."taskferry.backends"]
6
+ procrastinate = "taskferry_procrastinate:make_backend"
7
+
8
+ which lets a deployment write ``{"factory": "procrastinate", ...}`` in config and
9
+ have it work the moment the package is installed — without `taskferry` importing,
10
+ knowing about, or depending on that package.
11
+
12
+ ```mermaid
13
+ flowchart BT
14
+ PRO["taskferry-procrastinate<br/>entry point: procrastinate"]
15
+ CR["taskferry-cloudrun<br/>entry point: cloudrun"]
16
+ CT["taskferry-cloudtasks<br/>entry point: cloudtasks"]
17
+ EP["taskferry.backends<br/>entry-point group"]
18
+ TP["taskferry<br/>load_backend()"]
19
+
20
+ PRO --> EP
21
+ CR --> EP
22
+ CT --> EP
23
+ EP --> TP
24
+ ```
25
+
26
+ Two things this deliberately does *not* do. It does not scan installed packages
27
+ looking for anything importable, and it does not import every registered plugin
28
+ at startup: an entry point is only loaded when a backend that names it is
29
+ actually built. A process that enqueues tasks but never runs jobs never imports
30
+ the Google SDK.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import importlib
36
+ from collections.abc import Callable, Mapping
37
+ from functools import cache
38
+ from typing import Any, cast
39
+
40
+ from .config import DEFAULT_BACKENDS
41
+ from .core.errors import ConfigurationError
42
+ from .ports import ExecutionBackend
43
+
44
+ ENTRY_POINT_GROUP = "taskferry.backends"
45
+
46
+ BackendFactory = Callable[..., ExecutionBackend]
47
+ """A callable taking the configured options as keyword arguments."""
48
+
49
+ _manual: dict[str, BackendFactory] = {}
50
+
51
+
52
+ def register_backend(name: str, factory: BackendFactory, *, replace: bool = False) -> None:
53
+ """Register a factory in-process, bypassing entry points.
54
+
55
+ For tests, notebooks, and applications that build backends in Python rather
56
+ than through installed distributions. Takes precedence over entry points, so
57
+ a test can shadow ``"procrastinate"`` with a fake without touching the
58
+ installed package.
59
+ """
60
+ if name in _manual and not replace:
61
+ raise ConfigurationError(f"backend factory {name!r} is already registered")
62
+ _manual[name] = factory
63
+
64
+
65
+ def unregister_backend(name: str) -> None:
66
+ """Remove a manually registered factory. Silent when it was not registered."""
67
+ _manual.pop(name, None)
68
+
69
+
70
+ def registered_backends() -> Mapping[str, BackendFactory]:
71
+ """The manually registered factories, for introspection and test teardown."""
72
+ return dict(_manual)
73
+
74
+
75
+ @cache
76
+ def _entry_points() -> Mapping[str, Any]:
77
+ from importlib.metadata import entry_points
78
+
79
+ return {ep.name: ep for ep in entry_points(group=ENTRY_POINT_GROUP)}
80
+
81
+
82
+ def available_backends() -> tuple[str, ...]:
83
+ """Every backend name this process could build, from all three sources."""
84
+ return tuple(sorted({*DEFAULT_BACKENDS, *_entry_points(), *_manual}))
85
+
86
+
87
+ def resolve_factory(spec: str) -> BackendFactory:
88
+ """Resolve a factory from a plugin name, a built-in alias, or an import string.
89
+
90
+ Resolution order, most specific first:
91
+
92
+ 1. a factory registered with :func:`register_backend`;
93
+ 2. an installed ``taskferry.backends`` entry point;
94
+ 3. a built-in alias (``"inline"``, ``"thread"``, ``"process"``, ``"subprocess"``);
95
+ 4. an explicit ``"module.path:callable"`` import string.
96
+
97
+ Raises:
98
+ ConfigurationError: when nothing matches, listing what is available so
99
+ the fix is usually visible in the error itself.
100
+ """
101
+ manual = _manual.get(spec)
102
+ if manual is not None:
103
+ return manual
104
+
105
+ entry_point = _entry_points().get(spec)
106
+ if entry_point is not None:
107
+ try:
108
+ loaded = entry_point.load()
109
+ except Exception as exc:
110
+ raise ConfigurationError(
111
+ f"the {spec!r} backend plugin failed to load: {exc}. "
112
+ f"Is its distribution installed correctly?"
113
+ ) from exc
114
+ return _as_factory(loaded, spec)
115
+
116
+ builtin = DEFAULT_BACKENDS.get(spec)
117
+ if builtin is not None:
118
+ return _import_factory(builtin)
119
+
120
+ if ":" in spec or "." in spec:
121
+ return _import_factory(spec)
122
+
123
+ known = ", ".join(available_backends())
124
+ raise ConfigurationError(
125
+ f"unknown backend {spec!r}; install the adapter that provides it, register it with "
126
+ f"taskferry.register_backend(), or use a 'module:callable' import string "
127
+ f"(available: {known})"
128
+ )
129
+
130
+
131
+ def _import_factory(spec: str) -> BackendFactory:
132
+ module_path, sep, attr = spec.partition(":")
133
+ if not sep:
134
+ module_path, _, attr = spec.rpartition(".")
135
+ if not module_path or not attr:
136
+ raise ConfigurationError(f"invalid factory {spec!r}; expected 'package.module:callable'")
137
+ try:
138
+ module = importlib.import_module(module_path)
139
+ except ImportError as exc:
140
+ raise ConfigurationError(
141
+ f"cannot import {module_path!r} for backend factory {spec!r}: {exc}. "
142
+ f"The adapter distribution providing it is probably not installed."
143
+ ) from exc
144
+ try:
145
+ target = getattr(module, attr)
146
+ except AttributeError as exc:
147
+ raise ConfigurationError(f"{module_path!r} has no attribute {attr!r}") from exc
148
+ return _as_factory(target, spec)
149
+
150
+
151
+ def _as_factory(target: object, spec: str) -> BackendFactory:
152
+ if not callable(target):
153
+ raise ConfigurationError(f"backend factory {spec!r} is not callable")
154
+ return cast(BackendFactory, target)
155
+
156
+
157
+ def build_backend(factory_spec: str, options: Mapping[str, Any]) -> ExecutionBackend:
158
+ """Resolve ``factory_spec`` and call it with ``options`` as keyword arguments.
159
+
160
+ A ``TypeError`` from the factory is translated into a
161
+ :class:`~taskferry.core.errors.ConfigurationError`, because "you passed an
162
+ option this backend does not accept" is a configuration problem and should
163
+ read like one.
164
+ """
165
+ factory = resolve_factory(factory_spec)
166
+ try:
167
+ return factory(**dict(options))
168
+ except TypeError as exc:
169
+ raise ConfigurationError(
170
+ f"backend {factory_spec!r} rejected its options {sorted(options)}: {exc}"
171
+ ) from exc
172
+
173
+
174
+ __all__ = [
175
+ "ENTRY_POINT_GROUP",
176
+ "BackendFactory",
177
+ "available_backends",
178
+ "build_backend",
179
+ "register_backend",
180
+ "registered_backends",
181
+ "resolve_factory",
182
+ "unregister_backend",
183
+ ]