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