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