labtasker-client 2.0.1__tar.gz → 2.2.0__tar.gz

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 (25) hide show
  1. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/.gitignore +4 -0
  2. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/PKG-INFO +4 -3
  3. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/pyproject.toml +5 -4
  4. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/__init__.py +21 -2
  5. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/api.py +101 -3
  6. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/binding.py +7 -1
  7. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/cli.py +146 -7
  8. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/client.py +201 -13
  9. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/command_worker.py +212 -83
  10. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/config.py +10 -1
  11. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/execution.py +7 -6
  12. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/models.py +70 -1
  13. labtasker_client-2.2.0/src/labtasker/observations.py +160 -0
  14. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/tee.py +20 -2
  15. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/validation.py +39 -0
  16. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/worker.py +132 -46
  17. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/LICENSE +0 -0
  18. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/__main__.py +0 -0
  19. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/command_template.py +0 -0
  20. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/errors.py +0 -0
  21. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/journal.py +0 -0
  22. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/local.py +0 -0
  23. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/paths.py +0 -0
  24. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/py.typed +0 -0
  25. {labtasker_client-2.0.1 → labtasker_client-2.2.0}/src/labtasker/types.py +0 -0
@@ -1,6 +1,7 @@
1
1
  .venv/
2
2
  .pytest_cache/
3
3
  .coverage
4
+ coverage/
4
5
  coverage.xml
5
6
  htmlcov/
6
7
  .mypy_cache/
@@ -12,3 +13,6 @@ build/
12
13
  dist/
13
14
  site/
14
15
  .labtasker/
16
+ .DS_Store
17
+ .idea/
18
+ tests/skill/runs/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labtasker-client
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: A small task queue for parallel model inference and evaluation
5
5
  Project-URL: Homepage, https://github.com/luocfprime/labtasker
6
6
  Project-URL: Repository, https://github.com/luocfprime/labtasker.git
@@ -8,6 +8,7 @@ Author-email: lcf <luocfprime@gmail.com>
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
10
  Requires-Python: >=3.11
11
- Requires-Dist: httpx<1,>=0.28
12
- Requires-Dist: pydantic<3,>=2.10
11
+ Requires-Dist: httpx[socks]<1,>=0.28
12
+ Requires-Dist: packaging<27,>=24
13
+ Requires-Dist: pydantic<3,>=2.13.5
13
14
  Requires-Dist: typer<1,>=0.16
@@ -1,18 +1,19 @@
1
1
  [build-system]
2
- requires = ["hatchling>=1.27"]
2
+ requires = ["hatchling>=1.32.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "labtasker-client"
7
- version = "2.0.1"
7
+ version = "2.2.0"
8
8
  description = "A small task queue for parallel model inference and evaluation"
9
9
  requires-python = ">=3.11"
10
10
  license = "Apache-2.0"
11
11
  license-files = ["LICENSE"]
12
12
  authors = [{ name = "lcf", email = "luocfprime@gmail.com" }]
13
13
  dependencies = [
14
- "httpx>=0.28,<1",
15
- "pydantic>=2.10,<3",
14
+ "httpx[socks]>=0.28,<1",
15
+ "packaging>=24,<27",
16
+ "pydantic>=2.13.5,<3",
16
17
  "typer>=0.16,<1",
17
18
  ]
18
19
 
@@ -3,12 +3,14 @@
3
3
  from labtasker.api import (
4
4
  cancel_task,
5
5
  count_tasks,
6
+ count_workers,
6
7
  create_queue,
7
8
  delete_queue,
8
9
  delete_task,
9
10
  get_task,
10
11
  list_queues,
11
12
  list_tasks,
13
+ list_workers,
12
14
  requeue_task,
13
15
  submit_task,
14
16
  update_task,
@@ -31,18 +33,31 @@ from labtasker.execution import (
31
33
  set_force_stop_timeout,
32
34
  task_info,
33
35
  )
34
- from labtasker.models import BulkUpdateResult, LastError, Queue, Task, TaskInfo, TaskPage
36
+ from labtasker.models import (
37
+ BulkUpdateResult,
38
+ CountGroup,
39
+ GroupCountPage,
40
+ LastError,
41
+ Queue,
42
+ Task,
43
+ TaskInfo,
44
+ TaskPage,
45
+ WorkerObservation,
46
+ WorkerPage,
47
+ )
35
48
  from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
36
49
  from labtasker.worker import loop
37
50
 
38
- __version__ = "2.0.1"
51
+ __version__ = "2.2.0"
39
52
 
40
53
  __all__ = [
41
54
  "APIError",
42
55
  "BulkUpdateResult",
43
56
  "Client",
44
57
  "ConfigError",
58
+ "CountGroup",
45
59
  "FatalWorkerError",
60
+ "GroupCountPage",
46
61
  "JSONValue",
47
62
  "LabtaskerError",
48
63
  "LastError",
@@ -57,9 +72,12 @@ __all__ = [
57
72
  "TaskUpdate",
58
73
  "TransientError",
59
74
  "TransportError",
75
+ "WorkerObservation",
76
+ "WorkerPage",
60
77
  "cancel_task",
61
78
  "cancellation_requested",
62
79
  "count_tasks",
80
+ "count_workers",
63
81
  "create_queue",
64
82
  "delete_queue",
65
83
  "delete_task",
@@ -67,6 +85,7 @@ __all__ = [
67
85
  "get_task",
68
86
  "list_queues",
69
87
  "list_tasks",
88
+ "list_workers",
70
89
  "loop",
71
90
  "requeue_task",
72
91
  "set_force_stop_timeout",
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
3
4
  from threading import Lock
5
+ from typing import overload
4
6
 
5
7
  from labtasker.client import Client
6
- from labtasker.models import BulkUpdateResult, Queue, Task, TaskPage
8
+ from labtasker.models import BulkUpdateResult, GroupCountPage, Queue, Task, TaskPage, WorkerPage
7
9
  from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
8
10
 
9
11
  _default_client: Client | None = None
@@ -41,6 +43,7 @@ def list_tasks(
41
43
  *,
42
44
  status: TaskStatus | None = None,
43
45
  name: str | None = None,
46
+ name_fuzzy: str | None = None,
44
47
  filter: str | None = None,
45
48
  order_by: TaskOrderField = "created_at",
46
49
  descending: bool = True,
@@ -51,6 +54,7 @@ def list_tasks(
51
54
  return _client().list_tasks(
52
55
  status=status,
53
56
  name=name,
57
+ name_fuzzy=name_fuzzy,
54
58
  filter=filter,
55
59
  order_by=order_by,
56
60
  descending=descending,
@@ -60,14 +64,108 @@ def list_tasks(
60
64
  )
61
65
 
62
66
 
67
+ @overload
63
68
  def count_tasks(
64
69
  *,
65
70
  status: TaskStatus | None = None,
66
71
  name: str | None = None,
72
+ name_fuzzy: str | None = None,
67
73
  filter: str | None = None,
68
74
  queue: str | None = None,
69
- ) -> int:
70
- return _client().count_tasks(status=status, name=name, filter=filter, queue=queue)
75
+ group_by: None = None,
76
+ limit: int | None = None,
77
+ cursor: str | None = None,
78
+ ) -> int: ...
79
+
80
+
81
+ @overload
82
+ def count_tasks(
83
+ *,
84
+ status: TaskStatus | None = None,
85
+ name: str | None = None,
86
+ name_fuzzy: str | None = None,
87
+ filter: str | None = None,
88
+ queue: str | None = None,
89
+ group_by: Sequence[str],
90
+ limit: int | None = None,
91
+ cursor: str | None = None,
92
+ ) -> GroupCountPage: ...
93
+
94
+
95
+ def count_tasks(
96
+ *,
97
+ status: TaskStatus | None = None,
98
+ name: str | None = None,
99
+ name_fuzzy: str | None = None,
100
+ filter: str | None = None,
101
+ queue: str | None = None,
102
+ group_by: Sequence[str] | None = None,
103
+ limit: int | None = None,
104
+ cursor: str | None = None,
105
+ ) -> int | GroupCountPage:
106
+ if group_by is None:
107
+ if limit is not None or cursor is not None:
108
+ from labtasker.validation import validate_grouping
109
+
110
+ validate_grouping(None, {"routes", "status"}, limit, cursor)
111
+ return _client().count_tasks(
112
+ status=status, name=name, name_fuzzy=name_fuzzy, filter=filter, queue=queue
113
+ )
114
+ return _client().count_tasks(
115
+ status=status,
116
+ name=name,
117
+ name_fuzzy=name_fuzzy,
118
+ filter=filter,
119
+ queue=queue,
120
+ group_by=group_by,
121
+ limit=limit,
122
+ cursor=cursor,
123
+ )
124
+
125
+
126
+ def list_workers(
127
+ *,
128
+ filter: str | None = None,
129
+ limit: int = 100,
130
+ cursor: str | None = None,
131
+ queue: str | None = None,
132
+ ) -> WorkerPage:
133
+ return _client().list_workers(filter=filter, limit=limit, cursor=cursor, queue=queue)
134
+
135
+
136
+ @overload
137
+ def count_workers(
138
+ *,
139
+ filter: str | None = None,
140
+ group_by: None = None,
141
+ limit: int | None = None,
142
+ cursor: str | None = None,
143
+ queue: str | None = None,
144
+ ) -> int: ...
145
+
146
+
147
+ @overload
148
+ def count_workers(
149
+ *,
150
+ filter: str | None = None,
151
+ group_by: Sequence[str],
152
+ limit: int | None = None,
153
+ cursor: str | None = None,
154
+ queue: str | None = None,
155
+ ) -> GroupCountPage: ...
156
+
157
+
158
+ def count_workers(
159
+ *,
160
+ filter: str | None = None,
161
+ group_by: Sequence[str] | None = None,
162
+ limit: int | None = None,
163
+ cursor: str | None = None,
164
+ queue: str | None = None,
165
+ ) -> int | GroupCountPage:
166
+ return _client().count_workers(
167
+ filter=filter, group_by=group_by, limit=limit, cursor=cursor, queue=queue
168
+ )
71
169
 
72
170
 
73
171
  def update_task(
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import inspect
4
4
  from collections.abc import Callable
5
5
  from dataclasses import dataclass
6
+ from functools import partial
6
7
  from typing import Any, TypeVar, get_type_hints, overload
7
8
 
8
9
  from pydantic import TypeAdapter, ValidationError
@@ -185,4 +186,9 @@ def _validate_resolver(resolver: object, name: str) -> None:
185
186
 
186
187
 
187
188
  def _is_async_callable(value: object) -> bool:
188
- return inspect.iscoroutinefunction(value) or inspect.iscoroutinefunction(type(value).__call__)
189
+ while isinstance(value, partial):
190
+ value = value.func
191
+ return any(
192
+ inspect.iscoroutinefunction(candidate) or inspect.isasyncgenfunction(candidate)
193
+ for candidate in (value, type(value).__call__)
194
+ )
@@ -10,13 +10,14 @@ from pydantic import BaseModel
10
10
  from typer._click.core import Context as ClickContext
11
11
  from typer.core import TyperCommand
12
12
 
13
+ from labtasker import __version__
13
14
  from labtasker.client import Client
14
15
  from labtasker.command_template import TemplateSyntaxError
15
16
  from labtasker.command_worker import run_command_worker
16
17
  from labtasker.config import resolve_config
17
18
  from labtasker.errors import LabtaskerError
18
19
  from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
19
- from labtasker.validation import RequestValidationError, validate_json_object
20
+ from labtasker.validation import RequestValidationError, validate_grouping, validate_json_object
20
21
 
21
22
  T = TypeVar("T")
22
23
  app = typer.Typer(
@@ -44,12 +45,40 @@ config_app = typer.Typer(
44
45
  no_args_is_help=True,
45
46
  rich_markup_mode=None,
46
47
  )
48
+ worker_app = typer.Typer(
49
+ help="Inspect online Worker observations.",
50
+ add_completion=False,
51
+ no_args_is_help=True,
52
+ rich_markup_mode=None,
53
+ )
54
+ app.add_typer(worker_app, name="worker")
47
55
  app.add_typer(task_app, name="task")
48
56
  app.add_typer(queue_app, name="queue")
49
57
  app.add_typer(config_app, name="config")
50
58
  logger = logging.getLogger("labtasker.cli")
51
59
 
52
60
 
61
+ def _version_callback(value: bool) -> None:
62
+ if value:
63
+ typer.echo(f"labtasker-client {__version__}")
64
+ raise typer.Exit()
65
+
66
+
67
+ @app.callback()
68
+ def main(
69
+ version: Annotated[
70
+ bool,
71
+ typer.Option(
72
+ "--version",
73
+ callback=_version_callback,
74
+ is_eager=True,
75
+ help="Show the Client package version and exit.",
76
+ ),
77
+ ] = False,
78
+ ) -> None:
79
+ """Submit, inspect, and execute Labtasker v2 Tasks."""
80
+
81
+
53
82
  class _SeparatedCommand(TyperCommand):
54
83
  """Require the explicit boundary between Worker options and child argv."""
55
84
 
@@ -76,6 +105,12 @@ def worker_loop(
76
105
  str | None,
77
106
  typer.Option(help="Queue to claim from; otherwise use Client configuration."),
78
107
  ] = None,
108
+ max_consecutive_failures: Annotated[
109
+ int,
110
+ typer.Option(
111
+ help="Stop after this many consecutive execution failures (positive integer)."
112
+ ),
113
+ ] = 5,
79
114
  idle_timeout: Annotated[
80
115
  float,
81
116
  typer.Option(help="Seconds without an eligible Task before normal exit."),
@@ -115,6 +150,7 @@ def worker_loop(
115
150
  route=route,
116
151
  queue=queue,
117
152
  idle_timeout=idle_timeout,
153
+ max_consecutive_failures=max_consecutive_failures,
118
154
  force_stop_timeout=force_stop_timeout,
119
155
  )
120
156
  except (TemplateSyntaxError, RequestValidationError) as error:
@@ -218,6 +254,10 @@ def task_list(
218
254
  str | None,
219
255
  typer.Option(help="Select an exact Task name; empty string is valid."),
220
256
  ] = None,
257
+ name_fuzzy: Annotated[
258
+ str | None,
259
+ typer.Option(help="Case-insensitive subsequence search; every word must match."),
260
+ ] = None,
221
261
  filter: Annotated[
222
262
  str | None,
223
263
  typer.Option(help="Additional Task query expression."),
@@ -245,7 +285,7 @@ def task_list(
245
285
  ) -> None:
246
286
  """List one page of Tasks and print items plus next_cursor as JSON.
247
287
 
248
- --status, --name, and --filter are combined with logical AND.
288
+ --status, --name, --name-fuzzy, and --filter are combined with logical AND.
249
289
  Reuse a returned cursor only with the same selectors and ordering.
250
290
 
251
291
  Example:
@@ -260,6 +300,7 @@ def task_list(
260
300
  lambda client: client.list_tasks(
261
301
  status=status,
262
302
  name=name,
303
+ name_fuzzy=name_fuzzy,
263
304
  filter=filter,
264
305
  order_by=order_by,
265
306
  descending=descending,
@@ -282,10 +323,33 @@ def task_count(
282
323
  str | None,
283
324
  typer.Option(help="Select an exact Task name; empty string is valid."),
284
325
  ] = None,
326
+ name_fuzzy: Annotated[
327
+ str | None,
328
+ typer.Option(help="Case-insensitive subsequence search; every word must match."),
329
+ ] = None,
285
330
  filter: Annotated[
286
331
  str | None,
287
332
  typer.Option(help="Additional Task query expression."),
288
333
  ] = None,
334
+ group_by: Annotated[
335
+ list[str] | None,
336
+ typer.Option(
337
+ "--group-by",
338
+ help=(
339
+ "Comma-separated grouping fields, with no spaces "
340
+ "(for example: routes,status). Supported fields: routes, status. Specify once."
341
+ ),
342
+ ),
343
+ ] = None,
344
+ limit: Annotated[
345
+ int | None,
346
+ typer.Option(
347
+ min=1, max=1000, help="Maximum groups in this page (default 100); requires --group-by."
348
+ ),
349
+ ] = None,
350
+ cursor: Annotated[
351
+ str | None, typer.Option(help="Next group page cursor; requires --group-by.")
352
+ ] = None,
289
353
  queue: Annotated[
290
354
  str | None,
291
355
  typer.Option(help="Task Queue; otherwise use Client configuration."),
@@ -299,17 +363,93 @@ def task_count(
299
363
  labtasker task count --status failed \\
300
364
  --filter 'last_error.type == "ValueError"'
301
365
  """
366
+ count_options = _invoke(lambda: _count_options(group_by, {"routes", "status"}, limit, cursor))
302
367
  count = _invoke(
303
368
  lambda: _with_client(
304
369
  lambda client: client.count_tasks(
305
370
  status=status,
306
371
  name=name,
372
+ name_fuzzy=name_fuzzy,
307
373
  filter=filter,
308
374
  queue=queue,
375
+ **count_options,
376
+ )
377
+ )
378
+ )
379
+ _write_json({"count": count} if isinstance(count, int) else count)
380
+
381
+
382
+ @worker_app.command("list")
383
+ def worker_list(
384
+ filter: Annotated[str | None, typer.Option(help="Worker filter expression.")] = None,
385
+ limit: Annotated[
386
+ int, typer.Option(min=1, max=1000, help="Maximum Workers in this page.")
387
+ ] = 100,
388
+ cursor: Annotated[
389
+ str | None, typer.Option(help="Next cursor from the same Worker query.")
390
+ ] = None,
391
+ queue: Annotated[
392
+ str | None, typer.Option(help="Queue; otherwise use Client configuration.")
393
+ ] = None,
394
+ ) -> None:
395
+ """List one page of unexpired Worker observations as JSON, ordered by ID."""
396
+ _write_json(
397
+ _invoke(
398
+ lambda: _with_client(
399
+ lambda client: client.list_workers(
400
+ filter=filter, limit=limit, cursor=cursor, queue=queue
401
+ )
309
402
  )
310
403
  )
311
404
  )
312
- _write_json({"count": count})
405
+
406
+
407
+ @worker_app.command("count")
408
+ def worker_count(
409
+ filter: Annotated[str | None, typer.Option(help="Worker filter expression.")] = None,
410
+ group_by: Annotated[
411
+ list[str] | None,
412
+ typer.Option(
413
+ "--group-by",
414
+ help=(
415
+ "Comma-separated grouping fields, with no spaces "
416
+ "(for example: route,status). Supported fields: route, status. Specify once."
417
+ ),
418
+ ),
419
+ ] = None,
420
+ limit: Annotated[
421
+ int | None,
422
+ typer.Option(
423
+ min=1, max=1000, help="Maximum groups in this page (default 100); requires --group-by."
424
+ ),
425
+ ] = None,
426
+ cursor: Annotated[
427
+ str | None, typer.Option(help="Next group page cursor; requires --group-by.")
428
+ ] = None,
429
+ queue: Annotated[
430
+ str | None, typer.Option(help="Queue; otherwise use Client configuration.")
431
+ ] = None,
432
+ ) -> None:
433
+ """Count unexpired Worker observations, optionally grouped, as JSON."""
434
+ options = _invoke(lambda: _count_options(group_by, {"route", "status"}, limit, cursor))
435
+ result = _invoke(
436
+ lambda: _with_client(
437
+ lambda client: client.count_workers(filter=filter, queue=queue, **options)
438
+ )
439
+ )
440
+ _write_json({"count": result} if isinstance(result, int) else result)
441
+
442
+
443
+ def _count_options(
444
+ group_by: list[str] | None, allowed: set[str], limit: int | None, cursor: str | None
445
+ ) -> dict[str, Any]:
446
+ if group_by is not None and len(group_by) != 1:
447
+ raise RequestValidationError("Specify --group-by only once, comma-separated with no spaces")
448
+ value = None if group_by is None else group_by[0].split(",")
449
+ fields = validate_grouping(value, allowed, limit, cursor)
450
+ if fields is None:
451
+ return {}
452
+ return {"group_by": fields, "limit": limit, "cursor": cursor}
313
453
 
314
454
 
315
455
  @task_app.command("update")
@@ -465,7 +605,7 @@ def _invoke(operation: Callable[[], T]) -> T:
465
605
  try:
466
606
  return operation()
467
607
  except LabtaskerError as error:
468
- _write_json(error.as_envelope(), error=True)
608
+ _write_json(error.as_envelope())
469
609
  raise typer.Exit(1) from error
470
610
  except RequestValidationError as error:
471
611
  raise typer.BadParameter(str(error)) from error
@@ -490,17 +630,16 @@ def _json_object(value: str, *, option: str) -> dict[str, Any]:
490
630
  object_pairs_hook=reject_duplicate_keys,
491
631
  )
492
632
  return validate_json_object(parsed, field=option)
493
- except (json.JSONDecodeError, ValueError, RequestValidationError) as error:
633
+ except (json.JSONDecodeError, ValueError, RequestValidationError, RecursionError) as error:
494
634
  raise typer.BadParameter(f"{option} must be one strict JSON object: {error}") from error
495
635
 
496
636
 
497
- def _write_json(value: object, *, error: bool = False) -> None:
637
+ def _write_json(value: object) -> None:
498
638
  if isinstance(value, BaseModel):
499
639
  value = value.model_dump(mode="json")
500
640
  elif isinstance(value, list) and all(isinstance(item, BaseModel) for item in value):
501
641
  value = [item.model_dump(mode="json") for item in value]
502
642
  typer.echo(
503
643
  json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
504
- err=error,
505
644
  nl=False,
506
645
  )