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