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/router.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The Router — which backend runs this spec.
|
|
2
|
+
|
|
3
|
+
The whole point of Taskferry is that application code never names a provider. It
|
|
4
|
+
says *what* kind of work this is (`queue="metadata"`, `profile="gpu"`) and the
|
|
5
|
+
deployment decides *where* that runs:
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart TD
|
|
9
|
+
SPEC["ExecutionSpec"]
|
|
10
|
+
ROUTER["Router"]
|
|
11
|
+
|
|
12
|
+
SPEC --> ROUTER
|
|
13
|
+
|
|
14
|
+
ROUTER -->|"kind=task · queue=metadata"| PRO["procrastinate"]
|
|
15
|
+
ROUTER -->|"kind=task · queue=http"| CT["cloudtasks"]
|
|
16
|
+
ROUTER -->|"kind=job · profile=heavy"| CR["cloudrun"]
|
|
17
|
+
ROUTER -->|"kind=job · profile=gpu"| K8S["kubernetes-gpu"]
|
|
18
|
+
ROUTER -->|"kind=inline"| LOCAL["inline"]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Rules are ordered and explicit — **first match wins**, no scoring, no implicit
|
|
22
|
+
precedence to reason about. When nothing matches, the default backend for the
|
|
23
|
+
spec's kind is used; when there is no default either, routing raises
|
|
24
|
+
:class:`~taskferry.errors.RoutingError` naming what it tried, because silently
|
|
25
|
+
falling back to "whatever is around" is how work ends up on the wrong engine.
|
|
26
|
+
|
|
27
|
+
Routing is pure: same spec plus same rules, same answer. That makes it trivially
|
|
28
|
+
testable without a single backend instance.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import fnmatch
|
|
34
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
|
|
37
|
+
from .errors import RoutingError
|
|
38
|
+
from .execution import ExecutionKind
|
|
39
|
+
from .specs import ExecutionSpec
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Route:
|
|
44
|
+
"""One ordered routing rule: match on portable attributes, pick a backend.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
backend: Name of the backend to use when this rule matches.
|
|
48
|
+
kind: Restrict to one execution kind. ``None`` matches any kind.
|
|
49
|
+
queue: Match ``spec.queue``. Supports ``fnmatch`` globs (``"media-*"``).
|
|
50
|
+
profile: Match ``spec.profile``. Supports globs.
|
|
51
|
+
name: Match ``spec.name`` (task path or job name). Supports globs.
|
|
52
|
+
labels: Every entry must be present with the same value in
|
|
53
|
+
``spec.labels``. Extra labels on the spec are ignored.
|
|
54
|
+
|
|
55
|
+
Unset criteria simply do not constrain, so ``Route(backend="x")`` matches
|
|
56
|
+
everything and is a legitimate catch-all at the end of a rule list.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
backend: str
|
|
60
|
+
kind: ExecutionKind | None = None
|
|
61
|
+
queue: str | None = None
|
|
62
|
+
profile: str | None = None
|
|
63
|
+
name: str | None = None
|
|
64
|
+
labels: Mapping[str, str] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def matches(self, spec: ExecutionSpec) -> bool:
|
|
67
|
+
"""Whether this rule applies to ``spec``."""
|
|
68
|
+
if self.kind is not None and spec.kind is not self.kind:
|
|
69
|
+
return False
|
|
70
|
+
if self.queue is not None and not fnmatch.fnmatchcase(spec.queue, self.queue):
|
|
71
|
+
return False
|
|
72
|
+
if self.profile is not None and not fnmatch.fnmatchcase(spec.profile, self.profile):
|
|
73
|
+
return False
|
|
74
|
+
if self.name is not None and not fnmatch.fnmatchcase(spec.name, self.name):
|
|
75
|
+
return False
|
|
76
|
+
return all(spec.labels.get(key) == value for key, value in self.labels.items())
|
|
77
|
+
|
|
78
|
+
def describe(self) -> str:
|
|
79
|
+
"""Human-readable form used by ``taskferry backends`` and error messages."""
|
|
80
|
+
criteria = [
|
|
81
|
+
f"{field_name}={value}"
|
|
82
|
+
for field_name, value in (
|
|
83
|
+
("kind", self.kind.value if self.kind else None),
|
|
84
|
+
("queue", self.queue),
|
|
85
|
+
("profile", self.profile),
|
|
86
|
+
("name", self.name),
|
|
87
|
+
)
|
|
88
|
+
if value is not None
|
|
89
|
+
]
|
|
90
|
+
criteria += [f"label:{k}={v}" for k, v in sorted(self.labels.items())]
|
|
91
|
+
return f"{' '.join(criteria) or '*'} -> {self.backend}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Router:
|
|
95
|
+
"""Resolves a spec to a backend name. Immutable, pure, cheap to construct."""
|
|
96
|
+
|
|
97
|
+
__slots__ = ("_defaults", "_routes")
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
routes: Iterable[Route] = (),
|
|
102
|
+
*,
|
|
103
|
+
defaults: Mapping[ExecutionKind, str] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._routes: tuple[Route, ...] = tuple(routes)
|
|
106
|
+
self._defaults: dict[ExecutionKind, str] = dict(defaults or {})
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def routes(self) -> Sequence[Route]:
|
|
110
|
+
return self._routes
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def defaults(self) -> Mapping[ExecutionKind, str]:
|
|
114
|
+
"""Fallback backend per kind, used when no rule matches."""
|
|
115
|
+
return dict(self._defaults)
|
|
116
|
+
|
|
117
|
+
def __repr__(self) -> str:
|
|
118
|
+
return f"Router(routes={len(self._routes)}, defaults={self._defaults})"
|
|
119
|
+
|
|
120
|
+
def with_route(self, route: Route) -> Router:
|
|
121
|
+
"""Return a new router with ``route`` appended (lowest precedence)."""
|
|
122
|
+
return Router((*self._routes, route), defaults=self._defaults)
|
|
123
|
+
|
|
124
|
+
def with_default(self, kind: ExecutionKind, backend: str) -> Router:
|
|
125
|
+
"""Return a new router whose default for ``kind`` is ``backend``."""
|
|
126
|
+
return Router(self._routes, defaults={**self._defaults, kind: backend})
|
|
127
|
+
|
|
128
|
+
def resolve(self, spec: ExecutionSpec) -> str:
|
|
129
|
+
"""Return the backend name for ``spec``.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
RoutingError: when no rule matches and the kind has no default. The
|
|
133
|
+
message lists the rules that were considered, so a
|
|
134
|
+
misconfiguration is diagnosable from the traceback alone.
|
|
135
|
+
"""
|
|
136
|
+
for route in self._routes:
|
|
137
|
+
if route.matches(spec):
|
|
138
|
+
return route.backend
|
|
139
|
+
default = self._defaults.get(spec.kind)
|
|
140
|
+
if default is not None:
|
|
141
|
+
return default
|
|
142
|
+
tried = "; ".join(route.describe() for route in self._routes) or "<no routes>"
|
|
143
|
+
raise RoutingError(
|
|
144
|
+
f"no backend for {spec.kind.value} spec {spec.name!r} "
|
|
145
|
+
f"(queue={spec.queue!r}, profile={spec.profile!r}); "
|
|
146
|
+
f"no default for kind {spec.kind.value!r}; routes tried: {tried}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def explain(self, spec: ExecutionSpec) -> str:
|
|
150
|
+
"""Why ``spec`` routes where it does. For the CLI and for debugging."""
|
|
151
|
+
for index, route in enumerate(self._routes):
|
|
152
|
+
if route.matches(spec):
|
|
153
|
+
return f"route #{index} ({route.describe()})"
|
|
154
|
+
default = self._defaults.get(spec.kind)
|
|
155
|
+
if default is not None:
|
|
156
|
+
return f"default for kind {spec.kind.value} -> {default}"
|
|
157
|
+
return "unroutable"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = ["Route", "Router"]
|