labtasker-client 2.0.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.
labtasker/cli.py ADDED
@@ -0,0 +1,506 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from collections.abc import Callable
6
+ from typing import Annotated, Any, TypeVar, cast
7
+
8
+ import typer
9
+ from pydantic import BaseModel
10
+ from typer._click.core import Context as ClickContext
11
+ from typer.core import TyperCommand
12
+
13
+ from labtasker.client import Client
14
+ from labtasker.command_template import TemplateSyntaxError
15
+ from labtasker.command_worker import run_command_worker
16
+ from labtasker.config import resolve_config
17
+ from labtasker.errors import LabtaskerError
18
+ from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
19
+ from labtasker.validation import RequestValidationError, validate_json_object
20
+
21
+ T = TypeVar("T")
22
+ app = typer.Typer(
23
+ help="Submit, inspect, and execute Labtasker v2 Tasks.",
24
+ add_completion=False,
25
+ no_args_is_help=True,
26
+ pretty_exceptions_enable=False,
27
+ rich_markup_mode=None,
28
+ )
29
+ task_app = typer.Typer(
30
+ help="Submit, inspect, update, and control Tasks.",
31
+ add_completion=False,
32
+ no_args_is_help=True,
33
+ rich_markup_mode=None,
34
+ )
35
+ queue_app = typer.Typer(
36
+ help="Create, list, and delete Queue namespaces.",
37
+ add_completion=False,
38
+ no_args_is_help=True,
39
+ rich_markup_mode=None,
40
+ )
41
+ config_app = typer.Typer(
42
+ help="Inspect the resolved Client configuration.",
43
+ add_completion=False,
44
+ no_args_is_help=True,
45
+ rich_markup_mode=None,
46
+ )
47
+ app.add_typer(task_app, name="task")
48
+ app.add_typer(queue_app, name="queue")
49
+ app.add_typer(config_app, name="config")
50
+ logger = logging.getLogger("labtasker.cli")
51
+
52
+
53
+ class _SeparatedCommand(TyperCommand):
54
+ """Require the explicit boundary between Worker options and child argv."""
55
+
56
+ def collect_usage_pieces(self, ctx: ClickContext) -> list[str]:
57
+ return [*super().collect_usage_pieces(ctx), "--", "COMMAND", "[ARG...]"]
58
+
59
+ def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]:
60
+ ctx.meta["labtasker_command_separator"] = "--" in args
61
+ return super().parse_args(ctx, args)
62
+
63
+
64
+ @app.command(
65
+ "loop",
66
+ cls=_SeparatedCommand,
67
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
68
+ )
69
+ def worker_loop(
70
+ context: typer.Context,
71
+ route: Annotated[
72
+ str,
73
+ typer.Option(help="Exact route claimed by this Worker."),
74
+ ] = "default",
75
+ queue: Annotated[
76
+ str | None,
77
+ typer.Option(help="Queue to claim from; otherwise use Client configuration."),
78
+ ] = None,
79
+ idle_timeout: Annotated[
80
+ float,
81
+ typer.Option(help="Seconds without an eligible Task before normal exit."),
82
+ ] = 300.0,
83
+ force_stop_timeout: Annotated[
84
+ float | None,
85
+ typer.Option(
86
+ help=(
87
+ "Seconds to wait after run revocation before killing the child; "
88
+ "wait forever if omitted."
89
+ )
90
+ ),
91
+ ] = None,
92
+ ) -> None:
93
+ """Claim matching Tasks and execute one child command for each claim.
94
+
95
+ The explicit -- separator is required. Everything after it is one argv
96
+ template; Labtasker never invokes a shell or re-splits arguments. %{name}
97
+ reads a Task argument, and %{object.field} traverses nested JSON objects.
98
+
99
+ Example:
100
+
101
+ 
102
+ labtasker loop --route train -- \\
103
+ python train.py --seed '%{seed}' --lr '%{optimizer.lr}'
104
+ """
105
+ if not context.meta.get("labtasker_command_separator", False):
106
+ raise typer.BadParameter("COMMAND is required after --")
107
+ argv = list(context.args)
108
+ if argv and argv[0] == "--":
109
+ argv.pop(0)
110
+ if not argv:
111
+ raise typer.BadParameter("COMMAND is required after --")
112
+ try:
113
+ run_command_worker(
114
+ argv,
115
+ route=route,
116
+ queue=queue,
117
+ idle_timeout=idle_timeout,
118
+ force_stop_timeout=force_stop_timeout,
119
+ )
120
+ except (TemplateSyntaxError, RequestValidationError) as error:
121
+ raise typer.BadParameter(str(error)) from error
122
+ except NotImplementedError as error:
123
+ typer.echo(str(error), err=True)
124
+ raise typer.Exit(1) from error
125
+ except LabtaskerError as error:
126
+ typer.echo(f"{error.code}: {error.message}", err=True)
127
+ raise typer.Exit(1) from error
128
+ except KeyboardInterrupt:
129
+ raise
130
+ except Exception as error:
131
+ logger.error("Worker stopped: %s", error)
132
+ raise typer.Exit(1) from error
133
+
134
+
135
+ @task_app.command("submit")
136
+ def task_submit(
137
+ args: Annotated[
138
+ str,
139
+ typer.Option(help="Task arguments as one strict JSON object."),
140
+ ] = "{}",
141
+ name: Annotated[
142
+ str | None,
143
+ typer.Option(help="Optional human-readable Task name."),
144
+ ] = None,
145
+ metadata: Annotated[
146
+ str,
147
+ typer.Option(help="Searchable metadata as one strict JSON object."),
148
+ ] = "{}",
149
+ priority: Annotated[
150
+ int,
151
+ typer.Option(help="Claim higher priorities first."),
152
+ ] = 0,
153
+ max_attempts: Annotated[
154
+ int,
155
+ typer.Option(min=1, help="Maximum number of charged execution attempts."),
156
+ ] = 3,
157
+ routes: Annotated[
158
+ list[str] | None,
159
+ typer.Option("--route", help="Compatible exact route; repeat for multiple routes."),
160
+ ] = None,
161
+ task_id: Annotated[
162
+ str | None,
163
+ typer.Option("--id", help="Caller-chosen idempotent Task ID."),
164
+ ] = None,
165
+ queue: Annotated[
166
+ str | None,
167
+ typer.Option(help="Target Queue; otherwise use Client configuration."),
168
+ ] = None,
169
+ ) -> None:
170
+ """Submit one Task and print its complete representation as JSON.
171
+
172
+ JSON types are preserved exactly; the CLI never guesses types from text.
173
+ --route defaults to default when omitted.
174
+
175
+ Example:
176
+
177
+ 
178
+ labtasker task submit --name baseline \\
179
+ --args '{"seed":1,"enabled":true}' \\
180
+ --metadata '{"group":"paper"}' --route train
181
+ """
182
+ result = _invoke(
183
+ lambda: _with_client(
184
+ lambda client: client.submit_task(
185
+ _json_object(args, option="--args"),
186
+ name=name,
187
+ metadata=_json_object(metadata, option="--metadata"),
188
+ priority=priority,
189
+ max_attempts=max_attempts,
190
+ routes=routes,
191
+ task_id=task_id,
192
+ queue=queue,
193
+ )
194
+ )
195
+ )
196
+ _write_json(result)
197
+
198
+
199
+ @task_app.command("get")
200
+ def task_get(
201
+ task_id: Annotated[str, typer.Argument(help="Task ID to retrieve.")],
202
+ queue: Annotated[
203
+ str | None,
204
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
205
+ ] = None,
206
+ ) -> None:
207
+ """Get one Task by ID and print its complete representation as JSON."""
208
+ _write_json(_invoke(lambda: _with_client(lambda client: client.get_task(task_id, queue=queue))))
209
+
210
+
211
+ @task_app.command("list")
212
+ def task_list(
213
+ status: Annotated[
214
+ TaskStatus | None,
215
+ typer.Option(help="Select exactly one lifecycle status."),
216
+ ] = None,
217
+ name: Annotated[
218
+ str | None,
219
+ typer.Option(help="Select an exact Task name; empty string is valid."),
220
+ ] = None,
221
+ filter: Annotated[
222
+ str | None,
223
+ typer.Option(help="Additional Task query expression."),
224
+ ] = None,
225
+ order_by: Annotated[
226
+ TaskOrderField,
227
+ typer.Option(help="Stable field used to order this page."),
228
+ ] = "created_at",
229
+ descending: Annotated[
230
+ bool,
231
+ typer.Option("--descending/--ascending", help="Choose ordering direction."),
232
+ ] = True,
233
+ limit: Annotated[
234
+ int,
235
+ typer.Option(min=1, max=1000, help="Maximum Tasks in this page."),
236
+ ] = 100,
237
+ cursor: Annotated[
238
+ str | None,
239
+ typer.Option(help="Opaque next_cursor from the same query and ordering."),
240
+ ] = None,
241
+ queue: Annotated[
242
+ str | None,
243
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
244
+ ] = None,
245
+ ) -> None:
246
+ """List one page of Tasks and print items plus next_cursor as JSON.
247
+
248
+ --status, --name, and --filter are combined with logical AND.
249
+ Reuse a returned cursor only with the same selectors and ordering.
250
+
251
+ Example:
252
+
253
+ 
254
+ labtasker task list --status pending \\
255
+ --filter 'priority >= 10 and metadata.group == "paper"' \\
256
+ --order-by priority --descending --limit 100
257
+ """
258
+ result = _invoke(
259
+ lambda: _with_client(
260
+ lambda client: client.list_tasks(
261
+ status=status,
262
+ name=name,
263
+ filter=filter,
264
+ order_by=order_by,
265
+ descending=descending,
266
+ limit=limit,
267
+ cursor=cursor,
268
+ queue=queue,
269
+ )
270
+ )
271
+ )
272
+ _write_json(result)
273
+
274
+
275
+ @task_app.command("count")
276
+ def task_count(
277
+ status: Annotated[
278
+ TaskStatus | None,
279
+ typer.Option(help="Select exactly one lifecycle status."),
280
+ ] = None,
281
+ name: Annotated[
282
+ str | None,
283
+ typer.Option(help="Select an exact Task name; empty string is valid."),
284
+ ] = None,
285
+ filter: Annotated[
286
+ str | None,
287
+ typer.Option(help="Additional Task query expression."),
288
+ ] = None,
289
+ queue: Annotated[
290
+ str | None,
291
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
292
+ ] = None,
293
+ ) -> None:
294
+ """Count Tasks matching all supplied selectors and print JSON.
295
+
296
+ Example:
297
+
298
+ 
299
+ labtasker task count --status failed \\
300
+ --filter 'last_error.type == "ValueError"'
301
+ """
302
+ count = _invoke(
303
+ lambda: _with_client(
304
+ lambda client: client.count_tasks(
305
+ status=status,
306
+ name=name,
307
+ filter=filter,
308
+ queue=queue,
309
+ )
310
+ )
311
+ )
312
+ _write_json({"count": count})
313
+
314
+
315
+ @task_app.command("update")
316
+ def task_update(
317
+ task_id: Annotated[
318
+ str | None,
319
+ typer.Argument(help="One Task ID; mutually exclusive with --filter."),
320
+ ] = None,
321
+ filter: Annotated[
322
+ str | None,
323
+ typer.Option(help="Atomically select many Tasks; mutually exclusive with TASK_ID."),
324
+ ] = None,
325
+ changes: Annotated[
326
+ str,
327
+ typer.Option(help="Fields to replace as one strict JSON object."),
328
+ ] = "",
329
+ queue: Annotated[
330
+ str | None,
331
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
332
+ ] = None,
333
+ ) -> None:
334
+ """Update one Task by ID or all Tasks matching a query.
335
+
336
+ Provide exactly one of TASK_ID and --filter. --changes replaces
337
+ every supplied field in full; unspecified fields remain unchanged. Running
338
+ Tasks cannot be updated. A batch update is one atomic Server operation.
339
+
340
+ Examples:
341
+
342
+ 
343
+ labtasker task update t_ABCDEFGHIJKL \\
344
+ --changes '{"priority":20}'
345
+ labtasker task update --filter 'status == "pending"' \\
346
+ --changes '{"routes":["train-v2"]}'
347
+ """
348
+ if (task_id is None) == (filter is None):
349
+ raise typer.BadParameter("provide exactly one of TASK_ID or --filter")
350
+ if not changes:
351
+ raise typer.BadParameter("--changes is required")
352
+ normalized = cast(TaskUpdate, _json_object(changes, option="--changes"))
353
+ result: object
354
+ if task_id is not None:
355
+ result = _invoke(
356
+ lambda: _with_client(
357
+ lambda client: client.update_task(task_id, normalized, queue=queue)
358
+ )
359
+ )
360
+ else:
361
+ result = _invoke(
362
+ lambda: _with_client(
363
+ lambda client: client.update_tasks(
364
+ filter=filter or "",
365
+ changes=normalized,
366
+ queue=queue,
367
+ )
368
+ )
369
+ )
370
+ _write_json(result)
371
+
372
+
373
+ @task_app.command("cancel")
374
+ def task_cancel(
375
+ task_id: Annotated[str, typer.Argument(help="Task ID to cancel.")],
376
+ queue: Annotated[
377
+ str | None,
378
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
379
+ ] = None,
380
+ ) -> None:
381
+ """Cancel a pending or running Task and print its new state as JSON.
382
+
383
+ Cancelling a running Task revokes its current run immediately on the Server;
384
+ local shutdown follows the Worker's cooperative or force-stop policy.
385
+ """
386
+ _write_json(
387
+ _invoke(lambda: _with_client(lambda client: client.cancel_task(task_id, queue=queue)))
388
+ )
389
+
390
+
391
+ @task_app.command("requeue")
392
+ def task_requeue(
393
+ task_id: Annotated[str, typer.Argument(help="Non-running Task ID to requeue.")],
394
+ queue: Annotated[
395
+ str | None,
396
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
397
+ ] = None,
398
+ ) -> None:
399
+ """Return a non-running Task to pending and reset its attempt count."""
400
+ _write_json(
401
+ _invoke(lambda: _with_client(lambda client: client.requeue_task(task_id, queue=queue)))
402
+ )
403
+
404
+
405
+ @task_app.command("delete")
406
+ def task_delete(
407
+ task_id: Annotated[str, typer.Argument(help="Non-running Task ID to delete.")],
408
+ queue: Annotated[
409
+ str | None,
410
+ typer.Option(help="Task Queue; otherwise use Client configuration."),
411
+ ] = None,
412
+ ) -> None:
413
+ """Permanently delete one non-running Task.
414
+
415
+ Success is quiet. This operation cannot be undone.
416
+ """
417
+ _invoke(lambda: _with_client(lambda client: client.delete_task(task_id, queue=queue)))
418
+
419
+
420
+ @queue_app.command("create")
421
+ def queue_create(name: Annotated[str, typer.Argument(help="Queue name to create.")]) -> None:
422
+ """Create a Queue, or return the existing Queue with the same name."""
423
+ _write_json(_invoke(lambda: _with_client(lambda client: client.create_queue(name))))
424
+
425
+
426
+ @queue_app.command("list")
427
+ def queue_list() -> None:
428
+ """List all Queue namespaces as formatted JSON."""
429
+ _write_json(_invoke(lambda: _with_client(lambda client: client.list_queues())))
430
+
431
+
432
+ @queue_app.command("delete")
433
+ def queue_delete(
434
+ name: Annotated[str, typer.Argument(help="Queue name to delete.")],
435
+ cascade: Annotated[
436
+ bool,
437
+ typer.Option(help="Also permanently delete every non-running Task in the Queue."),
438
+ ] = False,
439
+ ) -> None:
440
+ """Permanently delete one Queue.
441
+
442
+ A non-empty Queue requires explicit --cascade. A Queue containing a
443
+ running Task cannot be deleted. Success is quiet.
444
+ """
445
+ _invoke(lambda: _with_client(lambda client: client.delete_queue(name, cascade=cascade)))
446
+
447
+
448
+ @config_app.command("show")
449
+ def config_show() -> None:
450
+ """Print the effective URL, Queue, and non-secret token presence as JSON.
451
+
452
+ Resolution precedence is explicit arguments, environment, project-local
453
+ .labtasker/config.toml, then built-in defaults. The token value is never
454
+ printed.
455
+ """
456
+ _write_json(_invoke(lambda: resolve_config().public_dict()))
457
+
458
+
459
+ def _with_client(operation: Callable[[Client], T]) -> T:
460
+ with Client() as client:
461
+ return operation(client)
462
+
463
+
464
+ def _invoke(operation: Callable[[], T]) -> T:
465
+ try:
466
+ return operation()
467
+ except LabtaskerError as error:
468
+ _write_json(error.as_envelope(), error=True)
469
+ raise typer.Exit(1) from error
470
+ except RequestValidationError as error:
471
+ raise typer.BadParameter(str(error)) from error
472
+
473
+
474
+ def _json_object(value: str, *, option: str) -> dict[str, Any]:
475
+ def reject_constant(constant: str) -> None:
476
+ raise ValueError(f"non-standard number {constant}")
477
+
478
+ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
479
+ result: dict[str, Any] = {}
480
+ for key, item in pairs:
481
+ if key in result:
482
+ raise ValueError(f"duplicate key {key!r}")
483
+ result[key] = item
484
+ return result
485
+
486
+ try:
487
+ parsed = json.loads(
488
+ value,
489
+ parse_constant=reject_constant,
490
+ object_pairs_hook=reject_duplicate_keys,
491
+ )
492
+ return validate_json_object(parsed, field=option)
493
+ except (json.JSONDecodeError, ValueError, RequestValidationError) as error:
494
+ raise typer.BadParameter(f"{option} must be one strict JSON object: {error}") from error
495
+
496
+
497
+ def _write_json(value: object, *, error: bool = False) -> None:
498
+ if isinstance(value, BaseModel):
499
+ value = value.model_dump(mode="json")
500
+ elif isinstance(value, list) and all(isinstance(item, BaseModel) for item in value):
501
+ value = [item.model_dump(mode="json") for item in value]
502
+ typer.echo(
503
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
504
+ err=error,
505
+ nl=False,
506
+ )