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
@@ -0,0 +1,38 @@
1
+ """Backends that ship with Taskferry and need nothing installed.
2
+
3
+ ```mermaid
4
+ flowchart TD
5
+ RT["Taskferry runtime"]
6
+
7
+ RT -->|"inline"| I["InlineExecutionBackend<br/>this thread, right now"]
8
+ RT -->|"task"| T["ThreadTaskBackend<br/>ThreadPoolExecutor"]
9
+ RT -->|"task"| P["ProcessTaskBackend<br/>ProcessPoolExecutor"]
10
+ RT -->|"job"| S["SubprocessJobBackend<br/>child processes"]
11
+ ```
12
+
13
+ These exist so that Taskferry is useful with zero infrastructure — in a test, a
14
+ notebook, a CLI, a script, a small application — and so that every port has at
15
+ least one honest reference implementation to test adapters against.
16
+
17
+ They are **not** a queue. There is no durability, no cross-process visibility and
18
+ no delivery guarantee beyond "this process tried". A thread pool that dies takes
19
+ its pending work with it. That is stated plainly rather than papered over, and it
20
+ is exactly why the Procrastinate and Cloud Run adapters exist.
21
+
22
+ Each module imports lazily from this package, so ``import taskferry`` does not
23
+ create a process pool or touch the filesystem.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from .inline import InlineExecutionBackend
29
+ from .process import ProcessTaskBackend
30
+ from .subprocess import SubprocessJobBackend
31
+ from .thread import ThreadTaskBackend
32
+
33
+ __all__ = [
34
+ "InlineExecutionBackend",
35
+ "ProcessTaskBackend",
36
+ "SubprocessJobBackend",
37
+ "ThreadTaskBackend",
38
+ ]
@@ -0,0 +1,235 @@
1
+ """Inline execution — run it here, run it now.
2
+
3
+ The simplest backend and the one that makes Taskferry usable before any
4
+ infrastructure exists. ``submit`` runs the callable synchronously and returns an
5
+ already-terminal execution:
6
+
7
+ ```mermaid
8
+ sequenceDiagram
9
+ participant App
10
+ participant TP as Taskferry
11
+ participant IB as InlineExecutionBackend
12
+ participant F as your function
13
+
14
+ App->>TP: inline.submit(add, 20, 22)
15
+ TP->>IB: submit(InlineSpec)
16
+ IB->>F: add(20, 22)
17
+ F-->>IB: 42
18
+ IB-->>TP: Execution(SUCCEEDED, result=42)
19
+ TP-->>App: ExecutionHandle (already done)
20
+ ```
21
+
22
+ What it honestly does **not** do
23
+ --------------------------------
24
+
25
+ * **No timeout.** Nothing can preempt a synchronous Python call in the calling
26
+ thread. ``TIMEOUT`` is not advertised, so a spec asking for one is rejected
27
+ rather than silently ignored.
28
+ * **No cancel.** By the time ``submit`` returns, the work is over.
29
+ * **No delay.** Sleeping the caller's thread to honour ``delay`` would be a
30
+ denial of service dressed up as a feature.
31
+
32
+ Retries *are* real here: this backend is the engine, so it owns the retry and
33
+ applies the :class:`~taskferry.retry.RetryPolicy` itself, backoff included.
34
+
35
+ Async callables
36
+ ---------------
37
+
38
+ An ``async def`` is awaited with :func:`asyncio.run`. If a loop is already
39
+ running in this thread, the coroutine is run on a private loop in a worker
40
+ thread — blocking the caller either way, because ``submit`` is synchronous by
41
+ contract. Inside async code, use ``await runtime.inline.submit_async(...)``
42
+ instead of blocking the loop.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import asyncio
48
+ import concurrent.futures
49
+ import contextlib
50
+ import time
51
+ from collections.abc import Coroutine
52
+ from datetime import UTC, datetime
53
+ from typing import Any
54
+
55
+ from ..capabilities import Capability, CapabilitySet
56
+ from ..core.correlation import use_correlation
57
+ from ..execution import (
58
+ Execution,
59
+ ExecutionId,
60
+ ExecutionKind,
61
+ ExecutionResult,
62
+ ExecutionState,
63
+ new_execution_id,
64
+ )
65
+ from ..functions import is_async_callable
66
+ from ..ports import BaseBackend
67
+ from ..retry import RetryPolicy
68
+ from ..specs import ExecutionSpec, InlineSpec
69
+
70
+ INLINE_CAPABILITIES = frozenset(
71
+ {
72
+ Capability.SUBMIT,
73
+ Capability.STATE,
74
+ Capability.RESULT,
75
+ Capability.RETRY,
76
+ Capability.ASYNC_CALLABLE,
77
+ }
78
+ )
79
+
80
+
81
+ class InlineExecutionBackend(BaseBackend):
82
+ """Runs :class:`~taskferry.specs.InlineSpec` callables in the calling thread.
83
+
84
+ Finished executions are kept in a bounded ring so ``get()``/``result()`` work
85
+ for a while after submission. The bound matters: a long-running process that
86
+ submits inline work in a loop must not accumulate results forever. Once an id
87
+ ages out, ``get()`` raises :class:`~taskferry.errors.ExecutionNotFound` — the
88
+ handle returned by ``submit`` still carries the terminal snapshot and the
89
+ result, so the common path never depends on the cache.
90
+ """
91
+
92
+ def __init__(self, *, history: int = 1024) -> None:
93
+ if history < 0:
94
+ raise ValueError("history must be >= 0")
95
+ self._history = history
96
+ self._executions: dict[str, Execution] = {}
97
+ self._results: dict[str, ExecutionResult] = {}
98
+ self._order: list[str] = []
99
+
100
+ @property
101
+ def name(self) -> str:
102
+ return "inline"
103
+
104
+ @property
105
+ def kind(self) -> ExecutionKind:
106
+ return ExecutionKind.INLINE
107
+
108
+ @property
109
+ def capabilities(self) -> CapabilitySet:
110
+ return CapabilitySet(INLINE_CAPABILITIES, provider=self.name)
111
+
112
+ # -- submission ---------------------------------------------------------- #
113
+ def _submit(self, spec: ExecutionSpec) -> Execution:
114
+ assert isinstance(spec, InlineSpec) # guaranteed by BaseBackend.validate
115
+ execution_id = new_execution_id(ExecutionKind.INLINE)
116
+ started = datetime.now(UTC)
117
+ base = Execution(
118
+ id=execution_id,
119
+ kind=ExecutionKind.INLINE,
120
+ backend=self.name,
121
+ state=ExecutionState.RUNNING,
122
+ name=spec.name,
123
+ created_at=started,
124
+ started_at=started,
125
+ correlation=spec.correlation,
126
+ )
127
+ self.hooks.before_execute(base)
128
+
129
+ value, error, attempts = self._call_with_retries(spec, base)
130
+ finished = datetime.now(UTC)
131
+
132
+ if error is None:
133
+ result = ExecutionResult(value=value)
134
+ state = ExecutionState.SUCCEEDED
135
+ else:
136
+ result = ExecutionResult.from_exception(error)
137
+ state = ExecutionState.FAILED
138
+
139
+ execution = base.evolve(
140
+ state=state,
141
+ finished_at=finished,
142
+ attempt=attempts,
143
+ result=result,
144
+ )
145
+ self.hooks.after_execute(execution, result)
146
+ if error is None:
147
+ self.hooks.on_success(execution, result)
148
+ else:
149
+ self.hooks.on_failure(execution, error)
150
+ self._remember(execution, result)
151
+ return execution
152
+
153
+ def _call_with_retries(
154
+ self, spec: InlineSpec, execution: Execution
155
+ ) -> tuple[Any, BaseException | None, int]:
156
+ """Run the callable, applying the retry policy. This backend owns retries."""
157
+ policy = spec.retry
158
+ attempt = 1
159
+ bind = (
160
+ use_correlation(spec.correlation)
161
+ if spec.correlation is not None
162
+ else contextlib.nullcontext()
163
+ )
164
+ while True:
165
+ try:
166
+ with bind:
167
+ return self._call(spec), None, attempt
168
+ except Exception as exc:
169
+ if not policy.should_retry(exc, attempt):
170
+ return None, exc, attempt
171
+ self.hooks.on_retry(execution, attempt + 1, exc)
172
+ _sleep(policy, attempt + 1)
173
+ attempt += 1
174
+
175
+ def _call(self, spec: InlineSpec) -> Any:
176
+ if not is_async_callable(spec.func):
177
+ return spec.func(*spec.args, **spec.kwargs)
178
+ coro: Coroutine[Any, Any, Any] = spec.func(*spec.args, **spec.kwargs)
179
+ return _run_coroutine(coro)
180
+
181
+ # -- observation ---------------------------------------------------------- #
182
+ def _get(self, execution_id: ExecutionId) -> Execution:
183
+ execution = self._executions.get(str(execution_id))
184
+ if execution is None:
185
+ from ..errors import ExecutionNotFound
186
+
187
+ raise ExecutionNotFound(
188
+ f"inline execution {execution_id!r} is not in this backend's "
189
+ f"{self._history}-entry history",
190
+ backend=self.name,
191
+ )
192
+ return execution
193
+
194
+ def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
195
+ self._get(execution_id) # raises ExecutionNotFound with a good message
196
+ return self._results[str(execution_id)]
197
+
198
+ def _remember(self, execution: Execution, result: ExecutionResult) -> None:
199
+ if self._history == 0:
200
+ return
201
+ key = str(execution.id)
202
+ self._executions[key] = execution
203
+ self._results[key] = result
204
+ self._order.append(key)
205
+ while len(self._order) > self._history:
206
+ evicted = self._order.pop(0)
207
+ self._executions.pop(evicted, None)
208
+ self._results.pop(evicted, None)
209
+
210
+ def close(self) -> None:
211
+ self._executions.clear()
212
+ self._results.clear()
213
+ self._order.clear()
214
+
215
+
216
+ def _sleep(policy: RetryPolicy, attempt: int) -> None:
217
+ delay = policy.delay_for(attempt)
218
+ if delay > 0:
219
+ time.sleep(delay)
220
+
221
+
222
+ def _run_coroutine(coro: Coroutine[Any, Any, Any]) -> Any:
223
+ """Await ``coro`` from synchronous code, whether or not a loop is running."""
224
+ try:
225
+ asyncio.get_running_loop()
226
+ except RuntimeError:
227
+ return asyncio.run(coro)
228
+ # A loop is already running in this thread; asyncio.run() would raise. Run
229
+ # the coroutine on its own loop in a worker thread instead. This blocks the
230
+ # caller, which is the documented contract of the synchronous submit().
231
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
232
+ return pool.submit(asyncio.run, coro).result()
233
+
234
+
235
+ __all__ = ["INLINE_CAPABILITIES", "InlineExecutionBackend"]
@@ -0,0 +1,292 @@
1
+ """A task backend backed by a process pool.
2
+
3
+ Same shape as :mod:`taskferry.backends.thread`, one significant difference: the
4
+ work runs in a *different interpreter*, so it is a genuine rehearsal for what a
5
+ real worker deployment does to your code.
6
+
7
+ ```mermaid
8
+ flowchart LR
9
+ P["parent process"]
10
+ C1["worker process 1"]
11
+ C2["worker process 2"]
12
+
13
+ P -->|"'pkg.mod:fn' + JSON args"| C1
14
+ P -->|"'pkg.mod:fn' + JSON args"| C2
15
+ C1 -->|"JSON result"| P
16
+ C2 -->|"JSON result"| P
17
+ ```
18
+
19
+ Nothing but the function *name* and JSON arguments crosses the boundary — the
20
+ same contract Procrastinate and Cloud Tasks impose. Code that works here works
21
+ on a worker; code that relied on a closure, a module-level global mutated at
22
+ import time, or a live ORM object fails here, at development time, where it is
23
+ cheap to find. That is the whole reason this backend exists alongside the thread
24
+ pool.
25
+
26
+ Because the child imports the function by name, ``__main__``-defined functions
27
+ and lambdas cannot be used —
28
+ :class:`~taskferry.functions.FunctionRef` rejects them at submit time with an
29
+ explanation rather than letting the pool fail cryptically.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import threading
35
+ from concurrent.futures import Future, ProcessPoolExecutor
36
+ from datetime import UTC, datetime
37
+ from typing import Any
38
+
39
+ from ..capabilities import Capability, CapabilitySet
40
+ from ..core.typing import JSONValue
41
+ from ..errors import ExecutionNotFound, SubmissionError, TaskferryTimeoutError
42
+ from ..execution import (
43
+ Execution,
44
+ ExecutionId,
45
+ ExecutionKind,
46
+ ExecutionResult,
47
+ ExecutionState,
48
+ new_execution_id,
49
+ )
50
+ from ..functions import FunctionRef, is_async_callable
51
+ from ..ports import BaseBackend
52
+ from ..retry import RetryPolicy
53
+ from ..specs import ExecutionSpec, TaskSpec
54
+
55
+ PROCESS_CAPABILITIES = frozenset(
56
+ {
57
+ Capability.SUBMIT,
58
+ Capability.STATE,
59
+ Capability.RESULT,
60
+ Capability.CANCEL,
61
+ Capability.RETRY,
62
+ Capability.ASYNC_CALLABLE,
63
+ }
64
+ )
65
+
66
+
67
+ def _execute_in_child(
68
+ task: str,
69
+ args: list[JSONValue],
70
+ kwargs: dict[str, JSONValue],
71
+ policy: RetryPolicy,
72
+ ) -> Any:
73
+ """Entry point that runs in the worker process.
74
+
75
+ Module-level and picklable on purpose: a ``ProcessPoolExecutor`` can only
76
+ dispatch to something importable, which is the same constraint every real
77
+ task engine imposes.
78
+
79
+ Retries happen here, in the child, so an attempt that dies takes only its own
80
+ process time and the parent is not blocked coordinating them.
81
+ """
82
+ import asyncio
83
+
84
+ func = FunctionRef.parse(task).resolve()
85
+ attempt = 1
86
+ while True:
87
+ try:
88
+ if is_async_callable(func):
89
+ return asyncio.run(func(*args, **kwargs))
90
+ return func(*args, **kwargs)
91
+ except Exception as exc:
92
+ if not policy.should_retry(exc, attempt):
93
+ raise
94
+ attempt += 1
95
+ delay = policy.delay_for(attempt)
96
+ if delay:
97
+ import time
98
+
99
+ time.sleep(delay)
100
+
101
+
102
+ class _Entry:
103
+ __slots__ = ("execution", "future", "result")
104
+
105
+ def __init__(self, execution: Execution) -> None:
106
+ self.execution = execution
107
+ self.future: Future[Any] | None = None
108
+ self.result: ExecutionResult | None = None
109
+
110
+
111
+ class ProcessTaskBackend(BaseBackend):
112
+ """Runs tasks on a :class:`~concurrent.futures.ProcessPoolExecutor`.
113
+
114
+ Args:
115
+ max_workers: Pool size. ``None`` uses the interpreter default.
116
+ history: How many finished executions to keep for ``get``/``result``.
117
+
118
+ Like the thread backend this is **not** durable: pending work dies with the
119
+ parent. It does not advertise ``DELAY`` (nothing here schedules) or
120
+ ``PRIORITY`` (the pool is FIFO).
121
+
122
+ The pool is created lazily on first submit, so constructing this backend —
123
+ which the runtime does eagerly when it is configured — never forks.
124
+ """
125
+
126
+ def __init__(self, *, max_workers: int | None = None, history: int = 1024) -> None:
127
+ self._max_workers = max_workers
128
+ self._history = history
129
+ self._pool: ProcessPoolExecutor | None = None
130
+ self._entries: dict[str, _Entry] = {}
131
+ self._order: list[str] = []
132
+ self._lock = threading.Lock()
133
+ self._closed = False
134
+
135
+ @property
136
+ def name(self) -> str:
137
+ return "process"
138
+
139
+ @property
140
+ def kind(self) -> ExecutionKind:
141
+ return ExecutionKind.TASK
142
+
143
+ @property
144
+ def capabilities(self) -> CapabilitySet:
145
+ return CapabilitySet(PROCESS_CAPABILITIES, provider=self.name)
146
+
147
+ def _ensure_pool(self) -> ProcessPoolExecutor:
148
+ with self._lock:
149
+ if self._closed:
150
+ raise SubmissionError("this ProcessTaskBackend is closed", backend=self.name)
151
+ if self._pool is None:
152
+ self._pool = ProcessPoolExecutor(max_workers=self._max_workers)
153
+ return self._pool
154
+
155
+ # -- submission ------------------------------------------------------------ #
156
+ def _submit(self, spec: ExecutionSpec) -> Execution:
157
+ assert isinstance(spec, TaskSpec)
158
+ pool = self._ensure_pool()
159
+ execution = Execution(
160
+ id=new_execution_id(ExecutionKind.TASK),
161
+ kind=ExecutionKind.TASK,
162
+ backend=self.name,
163
+ state=ExecutionState.QUEUED,
164
+ name=spec.name,
165
+ created_at=datetime.now(UTC),
166
+ correlation=spec.correlation,
167
+ metadata={"queue": spec.queue},
168
+ )
169
+ key = str(execution.id)
170
+ entry = _Entry(execution)
171
+ with self._lock:
172
+ self._entries[key] = entry
173
+ self._remember_locked(key)
174
+ try:
175
+ future = pool.submit(
176
+ _execute_in_child,
177
+ spec.task,
178
+ list(spec.args),
179
+ dict(spec.kwargs),
180
+ spec.retry,
181
+ )
182
+ except Exception as exc:
183
+ raise SubmissionError(
184
+ f"could not dispatch {spec.task!r} to the process pool: {exc}", backend=self.name
185
+ ) from exc
186
+ entry.future = future
187
+ future.add_done_callback(lambda fut: self._settle(key, fut))
188
+ return execution
189
+
190
+ def _settle(self, key: str, future: Future[Any]) -> None:
191
+ """Record the outcome once the child reports back."""
192
+ if future.cancelled():
193
+ self._set_state(key, ExecutionState.CANCELLED)
194
+ return
195
+ error = future.exception()
196
+ if error is None:
197
+ result = ExecutionResult(value=future.result())
198
+ state = ExecutionState.SUCCEEDED
199
+ else:
200
+ result = ExecutionResult.from_exception(error)
201
+ state = ExecutionState.FAILED
202
+ with self._lock:
203
+ entry = self._entries.get(key)
204
+ if entry is None:
205
+ return
206
+ entry.result = result
207
+ entry.execution = entry.execution.evolve(
208
+ state=state, finished_at=datetime.now(UTC), result=result
209
+ )
210
+ execution = entry.execution
211
+ self.hooks.after_execute(execution, result)
212
+ if error is None:
213
+ self.hooks.on_success(execution, result)
214
+ else:
215
+ self.hooks.on_failure(execution, error)
216
+
217
+ def _set_state(self, key: str, state: ExecutionState) -> None:
218
+ with self._lock:
219
+ entry = self._entries.get(key)
220
+ if entry is None:
221
+ return
222
+ entry.execution = entry.execution.evolve(state=state, finished_at=datetime.now(UTC))
223
+
224
+ # -- observation ------------------------------------------------------------ #
225
+ def _entry(self, execution_id: ExecutionId) -> _Entry:
226
+ with self._lock:
227
+ entry = self._entries.get(str(execution_id))
228
+ if entry is None:
229
+ raise ExecutionNotFound(
230
+ f"task {execution_id!r} is not in this backend's {self._history}-entry history",
231
+ backend=self.name,
232
+ )
233
+ return entry
234
+
235
+ def _get(self, execution_id: ExecutionId) -> Execution:
236
+ entry = self._entry(execution_id)
237
+ # RUNNING is not observable across the pool boundary: a future is either
238
+ # pending or done, so QUEUED stands until the child reports.
239
+ if entry.future is not None and entry.future.running():
240
+ with self._lock:
241
+ if entry.execution.state is ExecutionState.QUEUED:
242
+ entry.execution = entry.execution.evolve(state=ExecutionState.RUNNING)
243
+ return entry.execution
244
+
245
+ def _wait(self, execution_id: ExecutionId, *, timeout: float | None) -> Execution:
246
+ entry = self._entry(execution_id)
247
+ if entry.future is not None:
248
+ try:
249
+ entry.future.result(timeout=timeout)
250
+ except TimeoutError as exc:
251
+ raise TaskferryTimeoutError(
252
+ f"task {execution_id!r} did not finish within {timeout}s"
253
+ ) from exc
254
+ except Exception:
255
+ pass # recorded on the execution by _settle
256
+ return self._get(execution_id)
257
+
258
+ def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
259
+ self._wait(execution_id, timeout=timeout)
260
+ result = self._entry(execution_id).result
261
+ if result is None:
262
+ return ExecutionResult.cancelled()
263
+ return result
264
+
265
+ def _cancel(self, execution_id: ExecutionId) -> Execution:
266
+ entry = self._entry(execution_id)
267
+ if entry.execution.is_terminal:
268
+ return entry.execution
269
+ # Only a task that has not been picked up can be stopped; a running child
270
+ # is left alone rather than killed mid-write.
271
+ if entry.future is not None and entry.future.cancel():
272
+ self._set_state(str(execution_id), ExecutionState.CANCELLED)
273
+ return self._entry(execution_id).execution
274
+
275
+ def _remember_locked(self, key: str) -> None:
276
+ self._order.append(key)
277
+ while len(self._order) > self._history:
278
+ evicted = self._order.pop(0)
279
+ if evicted != key:
280
+ self._entries.pop(evicted, None)
281
+
282
+ def close(self) -> None:
283
+ with self._lock:
284
+ self._closed = True
285
+ pool, self._pool = self._pool, None
286
+ self._entries.clear()
287
+ self._order.clear()
288
+ if pool is not None:
289
+ pool.shutdown(wait=True, cancel_futures=True)
290
+
291
+
292
+ __all__ = ["PROCESS_CAPABILITIES", "ProcessTaskBackend"]