labtasker-client 2.1.0__tar.gz → 2.3.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 (26) hide show
  1. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/.gitignore +4 -0
  2. labtasker_client-2.3.0/PKG-INFO +17 -0
  3. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/pyproject.toml +10 -6
  4. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/__init__.py +21 -2
  5. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/api.py +101 -3
  6. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/binding.py +7 -1
  7. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/cli.py +127 -8
  8. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/client.py +201 -13
  9. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/command_worker.py +212 -83
  10. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/config.py +16 -2
  11. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/execution.py +7 -6
  12. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/journal.py +3 -1
  13. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/models.py +73 -2
  14. labtasker_client-2.3.0/src/labtasker/observations.py +160 -0
  15. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/tee.py +20 -2
  16. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/validation.py +39 -0
  17. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/worker.py +132 -46
  18. labtasker_client-2.1.0/PKG-INFO +0 -13
  19. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/LICENSE +0 -0
  20. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/__main__.py +0 -0
  21. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/command_template.py +0 -0
  22. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/errors.py +0 -0
  23. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/local.py +0 -0
  24. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/paths.py +0 -0
  25. {labtasker_client-2.1.0 → labtasker_client-2.3.0}/src/labtasker/py.typed +0 -0
  26. {labtasker_client-2.1.0 → labtasker_client-2.3.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/
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: labtasker-client
3
+ Version: 2.3.0
4
+ Summary: A small task queue for parallel model inference and evaluation
5
+ Project-URL: Homepage, https://github.com/luocfprime/labtasker
6
+ Project-URL: Repository, https://github.com/luocfprime/labtasker.git
7
+ Author-email: lcf <luocfprime@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx[socks]<1,>=0.25.2
12
+ Requires-Dist: packaging>=21.3
13
+ Requires-Dist: pydantic<3,>=2.12; python_version >= '3.14'
14
+ Requires-Dist: pydantic<3,>=2.5.3; python_version < '3.13'
15
+ Requires-Dist: pydantic<3,>=2.9; python_version >= '3.13' and python_version < '3.14'
16
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
17
+ Requires-Dist: typer<1,>=0.19
@@ -1,19 +1,23 @@
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.3.0"
8
8
  description = "A small task queue for parallel model inference and evaluation"
9
- requires-python = ">=3.11"
9
+ requires-python = ">=3.10"
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",
16
- "typer>=0.16,<1",
14
+ "httpx[socks]>=0.25.2,<1",
15
+ "packaging>=21.3",
16
+ "pydantic>=2.5.3,<3; python_version < '3.13'",
17
+ "pydantic>=2.9,<3; python_version >= '3.13' and python_version < '3.14'",
18
+ "pydantic>=2.12,<3; python_version >= '3.14'",
19
+ "tomli>=2.0.1; python_version < '3.11'",
20
+ "typer>=0.19,<1",
17
21
  ]
18
22
 
19
23
  [project.urls]
@@ -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.3.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
+ )
@@ -7,7 +7,6 @@ from typing import Annotated, Any, TypeVar, cast
7
7
 
8
8
  import typer
9
9
  from pydantic import BaseModel
10
- from typer._click.core import Context as ClickContext
11
10
  from typer.core import TyperCommand
12
11
 
13
12
  from labtasker import __version__
@@ -17,7 +16,7 @@ from labtasker.command_worker import run_command_worker
17
16
  from labtasker.config import resolve_config
18
17
  from labtasker.errors import LabtaskerError
19
18
  from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
20
- from labtasker.validation import RequestValidationError, validate_json_object
19
+ from labtasker.validation import RequestValidationError, validate_grouping, validate_json_object
21
20
 
22
21
  T = TypeVar("T")
23
22
  app = typer.Typer(
@@ -45,6 +44,13 @@ config_app = typer.Typer(
45
44
  no_args_is_help=True,
46
45
  rich_markup_mode=None,
47
46
  )
47
+ worker_app = typer.Typer(
48
+ help="Inspect online Worker observations.",
49
+ add_completion=False,
50
+ no_args_is_help=True,
51
+ rich_markup_mode=None,
52
+ )
53
+ app.add_typer(worker_app, name="worker")
48
54
  app.add_typer(task_app, name="task")
49
55
  app.add_typer(queue_app, name="queue")
50
56
  app.add_typer(config_app, name="config")
@@ -75,10 +81,12 @@ def main(
75
81
  class _SeparatedCommand(TyperCommand):
76
82
  """Require the explicit boundary between Worker options and child argv."""
77
83
 
78
- def collect_usage_pieces(self, ctx: ClickContext) -> list[str]:
84
+ # Typer 0.26 vendored Click, so its internal Context type differs from the
85
+ # public typer.Context used by earlier supported releases.
86
+ def collect_usage_pieces(self, ctx: Any) -> list[str]:
79
87
  return [*super().collect_usage_pieces(ctx), "--", "COMMAND", "[ARG...]"]
80
88
 
81
- def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]:
89
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
82
90
  ctx.meta["labtasker_command_separator"] = "--" in args
83
91
  return super().parse_args(ctx, args)
84
92
 
@@ -98,6 +106,12 @@ def worker_loop(
98
106
  str | None,
99
107
  typer.Option(help="Queue to claim from; otherwise use Client configuration."),
100
108
  ] = None,
109
+ max_consecutive_failures: Annotated[
110
+ int,
111
+ typer.Option(
112
+ help="Stop after this many consecutive execution failures (positive integer)."
113
+ ),
114
+ ] = 5,
101
115
  idle_timeout: Annotated[
102
116
  float,
103
117
  typer.Option(help="Seconds without an eligible Task before normal exit."),
@@ -137,6 +151,7 @@ def worker_loop(
137
151
  route=route,
138
152
  queue=queue,
139
153
  idle_timeout=idle_timeout,
154
+ max_consecutive_failures=max_consecutive_failures,
140
155
  force_stop_timeout=force_stop_timeout,
141
156
  )
142
157
  except (TemplateSyntaxError, RequestValidationError) as error:
@@ -158,7 +173,7 @@ def worker_loop(
158
173
  def task_submit(
159
174
  args: Annotated[
160
175
  str,
161
- typer.Option(help="Task arguments as one strict JSON object."),
176
+ typer.Option(metavar="<str>", help="Task arguments as one strict JSON object."),
162
177
  ] = "{}",
163
178
  name: Annotated[
164
179
  str | None,
@@ -240,6 +255,10 @@ def task_list(
240
255
  str | None,
241
256
  typer.Option(help="Select an exact Task name; empty string is valid."),
242
257
  ] = None,
258
+ name_fuzzy: Annotated[
259
+ str | None,
260
+ typer.Option(help="Case-insensitive subsequence search; every word must match."),
261
+ ] = None,
243
262
  filter: Annotated[
244
263
  str | None,
245
264
  typer.Option(help="Additional Task query expression."),
@@ -267,7 +286,7 @@ def task_list(
267
286
  ) -> None:
268
287
  """List one page of Tasks and print items plus next_cursor as JSON.
269
288
 
270
- --status, --name, and --filter are combined with logical AND.
289
+ --status, --name, --name-fuzzy, and --filter are combined with logical AND.
271
290
  Reuse a returned cursor only with the same selectors and ordering.
272
291
 
273
292
  Example:
@@ -282,6 +301,7 @@ def task_list(
282
301
  lambda client: client.list_tasks(
283
302
  status=status,
284
303
  name=name,
304
+ name_fuzzy=name_fuzzy,
285
305
  filter=filter,
286
306
  order_by=order_by,
287
307
  descending=descending,
@@ -304,10 +324,33 @@ def task_count(
304
324
  str | None,
305
325
  typer.Option(help="Select an exact Task name; empty string is valid."),
306
326
  ] = None,
327
+ name_fuzzy: Annotated[
328
+ str | None,
329
+ typer.Option(help="Case-insensitive subsequence search; every word must match."),
330
+ ] = None,
307
331
  filter: Annotated[
308
332
  str | None,
309
333
  typer.Option(help="Additional Task query expression."),
310
334
  ] = None,
335
+ group_by: Annotated[
336
+ list[str] | None,
337
+ typer.Option(
338
+ "--group-by",
339
+ help=(
340
+ "Comma-separated grouping fields, with no spaces "
341
+ "(for example: routes,status). Supported fields: routes, status. Specify once."
342
+ ),
343
+ ),
344
+ ] = None,
345
+ limit: Annotated[
346
+ int | None,
347
+ typer.Option(
348
+ min=1, max=1000, help="Maximum groups in this page (default 100); requires --group-by."
349
+ ),
350
+ ] = None,
351
+ cursor: Annotated[
352
+ str | None, typer.Option(help="Next group page cursor; requires --group-by.")
353
+ ] = None,
311
354
  queue: Annotated[
312
355
  str | None,
313
356
  typer.Option(help="Task Queue; otherwise use Client configuration."),
@@ -321,17 +364,93 @@ def task_count(
321
364
  labtasker task count --status failed \\
322
365
  --filter 'last_error.type == "ValueError"'
323
366
  """
367
+ count_options = _invoke(lambda: _count_options(group_by, {"routes", "status"}, limit, cursor))
324
368
  count = _invoke(
325
369
  lambda: _with_client(
326
370
  lambda client: client.count_tasks(
327
371
  status=status,
328
372
  name=name,
373
+ name_fuzzy=name_fuzzy,
329
374
  filter=filter,
330
375
  queue=queue,
376
+ **count_options,
331
377
  )
332
378
  )
333
379
  )
334
- _write_json({"count": count})
380
+ _write_json({"count": count} if isinstance(count, int) else count)
381
+
382
+
383
+ @worker_app.command("list")
384
+ def worker_list(
385
+ filter: Annotated[str | None, typer.Option(help="Worker filter expression.")] = None,
386
+ limit: Annotated[
387
+ int, typer.Option(min=1, max=1000, help="Maximum Workers in this page.")
388
+ ] = 100,
389
+ cursor: Annotated[
390
+ str | None, typer.Option(help="Next cursor from the same Worker query.")
391
+ ] = None,
392
+ queue: Annotated[
393
+ str | None, typer.Option(help="Queue; otherwise use Client configuration.")
394
+ ] = None,
395
+ ) -> None:
396
+ """List one page of unexpired Worker observations as JSON, ordered by ID."""
397
+ _write_json(
398
+ _invoke(
399
+ lambda: _with_client(
400
+ lambda client: client.list_workers(
401
+ filter=filter, limit=limit, cursor=cursor, queue=queue
402
+ )
403
+ )
404
+ )
405
+ )
406
+
407
+
408
+ @worker_app.command("count")
409
+ def worker_count(
410
+ filter: Annotated[str | None, typer.Option(help="Worker filter expression.")] = None,
411
+ group_by: Annotated[
412
+ list[str] | None,
413
+ typer.Option(
414
+ "--group-by",
415
+ help=(
416
+ "Comma-separated grouping fields, with no spaces "
417
+ "(for example: route,status). Supported fields: route, status. Specify once."
418
+ ),
419
+ ),
420
+ ] = None,
421
+ limit: Annotated[
422
+ int | None,
423
+ typer.Option(
424
+ min=1, max=1000, help="Maximum groups in this page (default 100); requires --group-by."
425
+ ),
426
+ ] = None,
427
+ cursor: Annotated[
428
+ str | None, typer.Option(help="Next group page cursor; requires --group-by.")
429
+ ] = None,
430
+ queue: Annotated[
431
+ str | None, typer.Option(help="Queue; otherwise use Client configuration.")
432
+ ] = None,
433
+ ) -> None:
434
+ """Count unexpired Worker observations, optionally grouped, as JSON."""
435
+ options = _invoke(lambda: _count_options(group_by, {"route", "status"}, limit, cursor))
436
+ result = _invoke(
437
+ lambda: _with_client(
438
+ lambda client: client.count_workers(filter=filter, queue=queue, **options)
439
+ )
440
+ )
441
+ _write_json({"count": result} if isinstance(result, int) else result)
442
+
443
+
444
+ def _count_options(
445
+ group_by: list[str] | None, allowed: set[str], limit: int | None, cursor: str | None
446
+ ) -> dict[str, Any]:
447
+ if group_by is not None and len(group_by) != 1:
448
+ raise RequestValidationError("Specify --group-by only once, comma-separated with no spaces")
449
+ value = None if group_by is None else group_by[0].split(",")
450
+ fields = validate_grouping(value, allowed, limit, cursor)
451
+ if fields is None:
452
+ return {}
453
+ return {"group_by": fields, "limit": limit, "cursor": cursor}
335
454
 
336
455
 
337
456
  @task_app.command("update")
@@ -512,7 +631,7 @@ def _json_object(value: str, *, option: str) -> dict[str, Any]:
512
631
  object_pairs_hook=reject_duplicate_keys,
513
632
  )
514
633
  return validate_json_object(parsed, field=option)
515
- except (json.JSONDecodeError, ValueError, RequestValidationError) as error:
634
+ except (json.JSONDecodeError, ValueError, RequestValidationError, RecursionError) as error:
516
635
  raise typer.BadParameter(f"{option} must be one strict JSON object: {error}") from error
517
636
 
518
637