labtasker-client 2.1.0__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.1.0 → labtasker_client-2.2.0}/.gitignore +4 -0
  2. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/PKG-INFO +4 -3
  3. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/pyproject.toml +5 -4
  4. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/__init__.py +21 -2
  5. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/api.py +101 -3
  6. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/binding.py +7 -1
  7. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/cli.py +122 -4
  8. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/client.py +201 -13
  9. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/command_worker.py +212 -83
  10. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/config.py +10 -1
  11. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/execution.py +7 -6
  12. {labtasker_client-2.1.0 → 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.1.0 → labtasker_client-2.2.0}/src/labtasker/tee.py +20 -2
  15. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/validation.py +39 -0
  16. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/worker.py +132 -46
  17. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/LICENSE +0 -0
  18. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/__main__.py +0 -0
  19. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/command_template.py +0 -0
  20. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/errors.py +0 -0
  21. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/journal.py +0 -0
  22. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/local.py +0 -0
  23. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/paths.py +0 -0
  24. {labtasker_client-2.1.0 → labtasker_client-2.2.0}/src/labtasker/py.typed +0 -0
  25. {labtasker_client-2.1.0 → 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.1.0
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.1.0"
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.1.0"
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
+ )
@@ -17,7 +17,7 @@ from labtasker.command_worker import run_command_worker
17
17
  from labtasker.config import resolve_config
18
18
  from labtasker.errors import LabtaskerError
19
19
  from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
20
- from labtasker.validation import RequestValidationError, validate_json_object
20
+ from labtasker.validation import RequestValidationError, validate_grouping, validate_json_object
21
21
 
22
22
  T = TypeVar("T")
23
23
  app = typer.Typer(
@@ -45,6 +45,13 @@ config_app = typer.Typer(
45
45
  no_args_is_help=True,
46
46
  rich_markup_mode=None,
47
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")
48
55
  app.add_typer(task_app, name="task")
49
56
  app.add_typer(queue_app, name="queue")
50
57
  app.add_typer(config_app, name="config")
@@ -98,6 +105,12 @@ def worker_loop(
98
105
  str | None,
99
106
  typer.Option(help="Queue to claim from; otherwise use Client configuration."),
100
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,
101
114
  idle_timeout: Annotated[
102
115
  float,
103
116
  typer.Option(help="Seconds without an eligible Task before normal exit."),
@@ -137,6 +150,7 @@ def worker_loop(
137
150
  route=route,
138
151
  queue=queue,
139
152
  idle_timeout=idle_timeout,
153
+ max_consecutive_failures=max_consecutive_failures,
140
154
  force_stop_timeout=force_stop_timeout,
141
155
  )
142
156
  except (TemplateSyntaxError, RequestValidationError) as error:
@@ -240,6 +254,10 @@ def task_list(
240
254
  str | None,
241
255
  typer.Option(help="Select an exact Task name; empty string is valid."),
242
256
  ] = None,
257
+ name_fuzzy: Annotated[
258
+ str | None,
259
+ typer.Option(help="Case-insensitive subsequence search; every word must match."),
260
+ ] = None,
243
261
  filter: Annotated[
244
262
  str | None,
245
263
  typer.Option(help="Additional Task query expression."),
@@ -267,7 +285,7 @@ def task_list(
267
285
  ) -> None:
268
286
  """List one page of Tasks and print items plus next_cursor as JSON.
269
287
 
270
- --status, --name, and --filter are combined with logical AND.
288
+ --status, --name, --name-fuzzy, and --filter are combined with logical AND.
271
289
  Reuse a returned cursor only with the same selectors and ordering.
272
290
 
273
291
  Example:
@@ -282,6 +300,7 @@ def task_list(
282
300
  lambda client: client.list_tasks(
283
301
  status=status,
284
302
  name=name,
303
+ name_fuzzy=name_fuzzy,
285
304
  filter=filter,
286
305
  order_by=order_by,
287
306
  descending=descending,
@@ -304,10 +323,33 @@ def task_count(
304
323
  str | None,
305
324
  typer.Option(help="Select an exact Task name; empty string is valid."),
306
325
  ] = None,
326
+ name_fuzzy: Annotated[
327
+ str | None,
328
+ typer.Option(help="Case-insensitive subsequence search; every word must match."),
329
+ ] = None,
307
330
  filter: Annotated[
308
331
  str | None,
309
332
  typer.Option(help="Additional Task query expression."),
310
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,
311
353
  queue: Annotated[
312
354
  str | None,
313
355
  typer.Option(help="Task Queue; otherwise use Client configuration."),
@@ -321,17 +363,93 @@ def task_count(
321
363
  labtasker task count --status failed \\
322
364
  --filter 'last_error.type == "ValueError"'
323
365
  """
366
+ count_options = _invoke(lambda: _count_options(group_by, {"routes", "status"}, limit, cursor))
324
367
  count = _invoke(
325
368
  lambda: _with_client(
326
369
  lambda client: client.count_tasks(
327
370
  status=status,
328
371
  name=name,
372
+ name_fuzzy=name_fuzzy,
329
373
  filter=filter,
330
374
  queue=queue,
375
+ **count_options,
331
376
  )
332
377
  )
333
378
  )
334
- _write_json({"count": count})
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
+ )
402
+ )
403
+ )
404
+ )
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}
335
453
 
336
454
 
337
455
  @task_app.command("update")
@@ -512,7 +630,7 @@ def _json_object(value: str, *, option: str) -> dict[str, Any]:
512
630
  object_pairs_hook=reject_duplicate_keys,
513
631
  )
514
632
  return validate_json_object(parsed, field=option)
515
- except (json.JSONDecodeError, ValueError, RequestValidationError) as error:
633
+ except (json.JSONDecodeError, ValueError, RequestValidationError, RecursionError) as error:
516
634
  raise typer.BadParameter(f"{option} must be one strict JSON object: {error}") from error
517
635
 
518
636