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/cli.py ADDED
@@ -0,0 +1,445 @@
1
+ """``taskferry`` — the command line, with no framework attached.
2
+
3
+ taskferry backends # what is configured, and where each route goes
4
+ taskferry capabilities NAME # what one backend can actually do
5
+ taskferry route --kind task --queue metadata
6
+ taskferry submit-job NAME --image ... -- python etl.py
7
+ taskferry status EXECUTION_ID --backend NAME
8
+ taskferry cancel EXECUTION_ID --backend NAME
9
+ taskferry result EXECUTION_ID --backend NAME
10
+ taskferry doctor # is this deployment actually wired up?
11
+
12
+ Configuration comes from ``TASKFERRY_*`` environment variables by default (see
13
+ :meth:`~taskferry.config.TaskferryConfig.from_env`) or from a JSON file passed with
14
+ ``--config``. Django management commands may wrap this, but Taskferry's CLI does
15
+ not need Django, a settings module, or an application at all — which is the point.
16
+
17
+ Built on :mod:`argparse` so the CLI adds no dependency to a package that promises
18
+ to have none.
19
+
20
+ Execution ids are process-local for the built-in backends, so ``status`` and
21
+ ``result`` need ``--backend`` when the execution was submitted somewhere else.
22
+ The commands say so rather than silently polling every configured engine.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ from collections.abc import Sequence
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from . import __version__
35
+ from .config import TaskferryConfig
36
+ from .errors import TaskferryError
37
+ from .execution import ExecutionKind
38
+ from .plugins import available_backends
39
+ from .runtime import Taskferry
40
+ from .specs import JobSpec, Resources, TaskSpec
41
+
42
+ EXIT_OK = 0
43
+ EXIT_ERROR = 1
44
+ EXIT_UNSUPPORTED = 2
45
+
46
+
47
+ def build_parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(
49
+ prog="taskferry",
50
+ description="Inspect and drive a Taskferry deployment.",
51
+ )
52
+ parser.add_argument("--version", action="version", version=f"taskferry {__version__}")
53
+ parser.add_argument(
54
+ "--config",
55
+ type=Path,
56
+ metavar="PATH",
57
+ help="JSON file with the Taskferry configuration (defaults to TASKFERRY_* env vars)",
58
+ )
59
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
60
+
61
+ sub = parser.add_subparsers(dest="command", required=True)
62
+
63
+ sub.add_parser("backends", help="list configured backends, routes and defaults")
64
+
65
+ caps = sub.add_parser("capabilities", help="show what a backend can do")
66
+ caps.add_argument("backend", nargs="?", help="backend name (omit for all)")
67
+
68
+ route = sub.add_parser("route", help="explain where a spec would be sent")
69
+ route.add_argument("--kind", choices=[k.value for k in ExecutionKind], default="task")
70
+ route.add_argument("--queue", default="default")
71
+ route.add_argument("--profile", default="default")
72
+ route.add_argument("--name", default="probe")
73
+
74
+ submit_task = sub.add_parser("submit-task", help="enqueue a task")
75
+ submit_task.add_argument("task", help="'package.module:function'")
76
+ submit_task.add_argument("--queue", default="default")
77
+ submit_task.add_argument("--backend", help="bypass routing")
78
+ submit_task.add_argument(
79
+ "--arg", action="append", default=[], metavar="JSON", help="positional argument (JSON)"
80
+ )
81
+ submit_task.add_argument(
82
+ "--kwarg", action="append", default=[], metavar="NAME=JSON", help="keyword argument"
83
+ )
84
+ submit_task.add_argument("--wait", type=float, metavar="SECONDS", help="wait for completion")
85
+
86
+ submit_job = sub.add_parser("submit-job", help="run a job")
87
+ submit_job.add_argument("job", help="job name")
88
+ submit_job.add_argument("--image")
89
+ submit_job.add_argument("--profile", default="default")
90
+ submit_job.add_argument("--backend", help="bypass routing")
91
+ submit_job.add_argument("--cpu")
92
+ submit_job.add_argument("--memory")
93
+ submit_job.add_argument("--gpu", type=int, default=0)
94
+ submit_job.add_argument(
95
+ "--env", action="append", default=[], metavar="NAME=VALUE", help="environment variable"
96
+ )
97
+ submit_job.add_argument("--wait", type=float, metavar="SECONDS", help="wait for completion")
98
+ # dest is "argv", not "command": argparse stores the subcommand name in
99
+ # `command`, and a positional of the same name would silently overwrite it.
100
+ # Everything after a literal `--` is split off before parsing (see main), so
101
+ # this only ever receives a command given without the separator.
102
+ submit_job.add_argument("argv", nargs="*", help="the command to run (prefer: -- cmd args)")
103
+
104
+ for name, help_text in (
105
+ ("status", "show an execution's current state"),
106
+ ("cancel", "cancel an execution"),
107
+ ("result", "show an execution's result"),
108
+ ):
109
+ cmd = sub.add_parser(name, help=help_text)
110
+ cmd.add_argument("execution_id")
111
+ cmd.add_argument("--backend", help="backend that owns the execution")
112
+ if name == "result":
113
+ cmd.add_argument("--timeout", type=float, help="seconds to wait for completion")
114
+
115
+ sub.add_parser("doctor", help="check that this deployment is wired up correctly")
116
+
117
+ return parser
118
+
119
+
120
+ def main(argv: Sequence[str] | None = None) -> int:
121
+ parser = build_parser()
122
+ # Split on the first literal `--` ourselves. argparse.REMAINDER would do
123
+ # this, but it starts consuming at the first token it does not recognise, so
124
+ # `submit-job j --env A=1 -- python x` hands `--env` to the job instead of to
125
+ # the parser. Splitting first makes the boundary mean exactly what it looks
126
+ # like it means.
127
+ head, tail = _split_on_separator(list(sys.argv[1:] if argv is None else argv))
128
+ args = parser.parse_args(head)
129
+ if tail:
130
+ args.argv = [*getattr(args, "argv", []), *tail]
131
+ try:
132
+ return _dispatch(args)
133
+ except TaskferryError as exc:
134
+ _fail(f"{type(exc).__name__}: {exc}", as_json=args.json)
135
+ return EXIT_ERROR
136
+
137
+
138
+ def _split_on_separator(argv: list[str]) -> tuple[list[str], list[str]]:
139
+ """Split ``[..., "--", ...]`` into the parser's arguments and the job's argv."""
140
+ if "--" not in argv:
141
+ return argv, []
142
+ index = argv.index("--")
143
+ return argv[:index], argv[index + 1 :]
144
+
145
+
146
+ def _dispatch(args: argparse.Namespace) -> int:
147
+ handlers = {
148
+ "backends": _cmd_backends,
149
+ "capabilities": _cmd_capabilities,
150
+ "route": _cmd_route,
151
+ "submit-task": _cmd_submit_task,
152
+ "submit-job": _cmd_submit_job,
153
+ "status": _cmd_status,
154
+ "cancel": _cmd_cancel,
155
+ "result": _cmd_result,
156
+ "doctor": _cmd_doctor,
157
+ }
158
+ return handlers[args.command](args)
159
+
160
+
161
+ # -- configuration --------------------------------------------------------- #
162
+ def _load_config(args: argparse.Namespace) -> TaskferryConfig:
163
+ if args.config is None:
164
+ return TaskferryConfig.from_env()
165
+ try:
166
+ payload = json.loads(args.config.read_text(encoding="utf-8"))
167
+ except OSError as exc:
168
+ raise TaskferryError(f"cannot read {args.config}: {exc}") from exc
169
+ except ValueError as exc:
170
+ raise TaskferryError(f"{args.config} is not valid JSON: {exc}") from exc
171
+ return TaskferryConfig.from_mapping(payload)
172
+
173
+
174
+ def _runtime(args: argparse.Namespace) -> Taskferry:
175
+ return Taskferry(config=_load_config(args))
176
+
177
+
178
+ # -- commands ---------------------------------------------------------------- #
179
+ def _cmd_backends(args: argparse.Namespace) -> int:
180
+ runtime = _runtime(args)
181
+ description = runtime.describe()
182
+ if args.json:
183
+ _emit(description)
184
+ return EXIT_OK
185
+ print("backends:")
186
+ for name, info in description["backends"].items():
187
+ options = f" options={info['options']}" if info["options"] else ""
188
+ print(f" {name:<20} {info['factory']}{options}")
189
+ print("\nroutes (first match wins):")
190
+ for route in description["routes"] or [" <none>"]:
191
+ print(f" {route}")
192
+ print("\ndefaults:")
193
+ for kind, backend in description["defaults"].items() or [("<none>", "")]:
194
+ print(f" {kind:<20} -> {backend}")
195
+ return EXIT_OK
196
+
197
+
198
+ def _cmd_capabilities(args: argparse.Namespace) -> int:
199
+ runtime = _runtime(args)
200
+ names = [args.backend] if args.backend else list(runtime.backend_names())
201
+ report: dict[str, Any] = {}
202
+ for name in names:
203
+ try:
204
+ report[name] = sorted(str(cap) for cap in runtime.capabilities(name))
205
+ except TaskferryError as exc:
206
+ report[name] = {"error": str(exc)}
207
+ if args.json:
208
+ _emit(report)
209
+ return EXIT_OK
210
+ for name, caps in report.items():
211
+ if isinstance(caps, dict):
212
+ print(f"{name}: unavailable — {caps['error']}")
213
+ else:
214
+ print(f"{name}: {', '.join(caps)}")
215
+ return EXIT_OK
216
+
217
+
218
+ def _cmd_route(args: argparse.Namespace) -> int:
219
+ runtime = _runtime(args)
220
+ kind = ExecutionKind(args.kind)
221
+ spec = _probe_spec(kind, queue=args.queue, profile=args.profile, name=args.name)
222
+ explanation = runtime.router.explain(spec)
223
+ try:
224
+ backend = runtime.router.resolve(spec)
225
+ except TaskferryError as exc:
226
+ if args.json:
227
+ _emit({"routable": False, "reason": str(exc)})
228
+ else:
229
+ print(f"unroutable: {exc}")
230
+ return EXIT_ERROR
231
+ if args.json:
232
+ _emit({"routable": True, "backend": backend, "matched": explanation})
233
+ else:
234
+ print(f"{kind.value} queue={args.queue} profile={args.profile} -> {backend}")
235
+ print(f" matched by {explanation}")
236
+ return EXIT_OK
237
+
238
+
239
+ def _cmd_submit_task(args: argparse.Namespace) -> int:
240
+ runtime = _runtime(args)
241
+ spec = TaskSpec(
242
+ task=args.task,
243
+ args=tuple(_parse_json(value, "--arg") for value in args.arg),
244
+ kwargs={
245
+ key: _parse_json(value, "--kwarg")
246
+ for key, value in (_split_pair(pair, "--kwarg") for pair in args.kwarg)
247
+ },
248
+ queue=args.queue,
249
+ )
250
+ handle = runtime.submit(spec, backend=args.backend)
251
+ return _report_submission(handle, args)
252
+
253
+
254
+ def _cmd_submit_job(args: argparse.Namespace) -> int:
255
+ runtime = _runtime(args)
256
+ command = list(args.argv)
257
+ spec = JobSpec(
258
+ job=args.job,
259
+ image=args.image,
260
+ command=command,
261
+ profile=args.profile,
262
+ env=dict(_split_pair(pair, "--env") for pair in args.env),
263
+ resources=Resources(cpu=args.cpu, memory=args.memory, gpu=args.gpu),
264
+ )
265
+ handle = runtime.submit(spec, backend=args.backend)
266
+ return _report_submission(handle, args)
267
+
268
+
269
+ def _report_submission(handle: Any, args: argparse.Namespace) -> int:
270
+ if args.wait is not None:
271
+ handle.wait(args.wait)
272
+ payload = _execution_payload(handle.execution)
273
+ if args.json:
274
+ _emit(payload)
275
+ else:
276
+ print(f"{payload['id']} {payload['state']} backend={payload['backend']}")
277
+ if payload.get("error"):
278
+ print(f" error: {payload['error']}")
279
+ return EXIT_OK if handle.execution.state.value != "failed" else EXIT_ERROR
280
+
281
+
282
+ def _cmd_status(args: argparse.Namespace) -> int:
283
+ runtime = _runtime(args)
284
+ handle = runtime.get(args.execution_id, backend=args.backend)
285
+ payload = _execution_payload(handle.refresh())
286
+ if args.json:
287
+ _emit(payload)
288
+ else:
289
+ print(f"{payload['id']} {payload['state']} backend={payload['backend']}")
290
+ return EXIT_OK
291
+
292
+
293
+ def _cmd_cancel(args: argparse.Namespace) -> int:
294
+ runtime = _runtime(args)
295
+ execution = runtime.cancel(args.execution_id, backend=args.backend)
296
+ if args.json:
297
+ _emit(_execution_payload(execution))
298
+ else:
299
+ print(f"{execution.id} {execution.state.value}")
300
+ return EXIT_OK
301
+
302
+
303
+ def _cmd_result(args: argparse.Namespace) -> int:
304
+ runtime = _runtime(args)
305
+ result = runtime.result(args.execution_id, timeout=args.timeout, backend=args.backend)
306
+ payload = {
307
+ "value": result.value,
308
+ "error": result.error,
309
+ "error_type": result.error_type,
310
+ "exit_code": result.exit_code,
311
+ "logs_uri": result.logs_uri,
312
+ }
313
+ if args.json:
314
+ _emit(payload)
315
+ else:
316
+ print(json.dumps(payload["value"], default=str))
317
+ return EXIT_OK
318
+
319
+
320
+ def _cmd_doctor(args: argparse.Namespace) -> int:
321
+ """Check the deployment end to end, and say which part is broken.
322
+
323
+ Every configured backend is actually built, because "the adapter is
324
+ installed" and "the adapter can be constructed with these options" are
325
+ different questions and only the second one matters at 3am.
326
+ """
327
+ findings: list[dict[str, str]] = []
328
+ try:
329
+ runtime = _runtime(args)
330
+ except TaskferryError as exc:
331
+ findings.append({"check": "configuration", "status": "fail", "detail": str(exc)})
332
+ return _report_doctor(findings, args)
333
+
334
+ findings.append(
335
+ {
336
+ "check": "configuration",
337
+ "status": "ok",
338
+ "detail": f"{len(runtime.config.backends)} backend(s), "
339
+ f"{len(runtime.config.routes)} route(s)",
340
+ }
341
+ )
342
+ findings.append(
343
+ {
344
+ "check": "plugins",
345
+ "status": "ok",
346
+ "detail": ", ".join(available_backends()),
347
+ }
348
+ )
349
+ for name in runtime.backend_names():
350
+ try:
351
+ capabilities = runtime.capabilities(name)
352
+ except Exception as exc:
353
+ findings.append(
354
+ {
355
+ "check": f"backend:{name}",
356
+ "status": "fail",
357
+ "detail": f"{type(exc).__name__}: {exc}",
358
+ }
359
+ )
360
+ continue
361
+ findings.append(
362
+ {
363
+ "check": f"backend:{name}",
364
+ "status": "ok",
365
+ "detail": ", ".join(sorted(str(c) for c in capabilities)),
366
+ }
367
+ )
368
+ for kind in ExecutionKind:
369
+ default = runtime.router.defaults.get(kind)
370
+ findings.append(
371
+ {
372
+ "check": f"default:{kind.value}",
373
+ "status": "ok" if default else "warn",
374
+ "detail": default or f"no default backend for {kind.value} specs",
375
+ }
376
+ )
377
+ return _report_doctor(findings, args)
378
+
379
+
380
+ def _report_doctor(findings: list[dict[str, str]], args: argparse.Namespace) -> int:
381
+ failed = any(f["status"] == "fail" for f in findings)
382
+ if args.json:
383
+ _emit({"ok": not failed, "checks": findings})
384
+ else:
385
+ symbols = {"ok": "PASS", "warn": "WARN", "fail": "FAIL"}
386
+ for finding in findings:
387
+ print(f"[{symbols[finding['status']]}] {finding['check']}: {finding['detail']}")
388
+ return EXIT_ERROR if failed else EXIT_OK
389
+
390
+
391
+ # -- helpers ------------------------------------------------------------------ #
392
+ def _probe_spec(kind: ExecutionKind, *, queue: str, profile: str, name: str) -> Any:
393
+ """A minimal spec used only to ask the router where it would go."""
394
+ if kind is ExecutionKind.JOB:
395
+ return JobSpec(job=name, profile=profile, queue=queue)
396
+ if kind is ExecutionKind.TASK:
397
+ return TaskSpec(task=f"probe:{name}", queue=queue, profile=profile)
398
+ from .specs import InlineSpec
399
+
400
+ return InlineSpec(func=lambda: None, name=name, queue=queue, profile=profile)
401
+
402
+
403
+ def _parse_json(raw: str, flag: str) -> Any:
404
+ try:
405
+ return json.loads(raw)
406
+ except ValueError:
407
+ # A bare word is far more common than a JSON string on a command line.
408
+ return raw
409
+
410
+
411
+ def _split_pair(raw: str, flag: str) -> tuple[str, str]:
412
+ key, sep, value = raw.partition("=")
413
+ if not sep:
414
+ raise TaskferryError(f"{flag} expects NAME=VALUE, got {raw!r}")
415
+ return key, value
416
+
417
+
418
+ def _execution_payload(execution: Any) -> dict[str, Any]:
419
+ return {
420
+ "id": str(execution.id),
421
+ "kind": execution.kind.value,
422
+ "backend": execution.backend,
423
+ "state": execution.state.value,
424
+ "name": execution.name,
425
+ "external_id": execution.external_id,
426
+ "error": execution.result.error if execution.result else None,
427
+ }
428
+
429
+
430
+ def _emit(payload: object) -> None:
431
+ print(json.dumps(payload, indent=2, default=str))
432
+
433
+
434
+ def _fail(message: str, *, as_json: bool) -> None:
435
+ if as_json:
436
+ print(json.dumps({"error": message}, indent=2), file=sys.stderr)
437
+ else:
438
+ print(f"error: {message}", file=sys.stderr)
439
+
440
+
441
+ if __name__ == "__main__": # pragma: no cover
442
+ raise SystemExit(main())
443
+
444
+
445
+ __all__ = ["build_parser", "main"]