labtasker-client 2.3.0__tar.gz → 2.4.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.3.0 → labtasker_client-2.4.0}/PKG-INFO +1 -1
  2. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/pyproject.toml +1 -1
  3. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/__init__.py +3 -1
  4. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/cli.py +24 -0
  5. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/client.py +18 -0
  6. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/execution.py +40 -0
  7. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/models.py +35 -3
  8. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/worker.py +45 -0
  9. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/.gitignore +0 -0
  10. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/LICENSE +0 -0
  11. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/__main__.py +0 -0
  12. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/api.py +0 -0
  13. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/binding.py +0 -0
  14. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/command_template.py +0 -0
  15. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/command_worker.py +0 -0
  16. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/config.py +0 -0
  17. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/errors.py +0 -0
  18. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/journal.py +0 -0
  19. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/local.py +0 -0
  20. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/observations.py +0 -0
  21. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/paths.py +0 -0
  22. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/py.typed +0 -0
  23. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/tee.py +0 -0
  24. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/types.py +0 -0
  25. {labtasker_client-2.3.0 → labtasker_client-2.4.0}/src/labtasker/validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labtasker-client
3
- Version: 2.3.0
3
+ Version: 2.4.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "labtasker-client"
7
- version = "2.3.0"
7
+ version = "2.4.0"
8
8
  description = "A small task queue for parallel model inference and evaluation"
9
9
  requires-python = ">=3.10"
10
10
  license = "Apache-2.0"
@@ -30,6 +30,7 @@ from labtasker.errors import (
30
30
  from labtasker.execution import (
31
31
  cancellation_requested,
32
32
  finish,
33
+ report_progress,
33
34
  set_force_stop_timeout,
34
35
  task_info,
35
36
  )
@@ -48,7 +49,7 @@ from labtasker.models import (
48
49
  from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
49
50
  from labtasker.worker import loop
50
51
 
51
- __version__ = "2.3.0"
52
+ __version__ = "2.4.0"
52
53
 
53
54
  __all__ = [
54
55
  "APIError",
@@ -87,6 +88,7 @@ __all__ = [
87
88
  "list_tasks",
88
89
  "list_workers",
89
90
  "loop",
91
+ "report_progress",
90
92
  "requeue_task",
91
93
  "set_force_stop_timeout",
92
94
  "submit_task",
@@ -15,6 +15,7 @@ from labtasker.command_template import TemplateSyntaxError
15
15
  from labtasker.command_worker import run_command_worker
16
16
  from labtasker.config import resolve_config
17
17
  from labtasker.errors import LabtaskerError
18
+ from labtasker.execution import report_progress as report_current_progress
18
19
  from labtasker.types import TaskOrderField, TaskStatus, TaskUpdate
19
20
  from labtasker.validation import RequestValidationError, validate_grouping, validate_json_object
20
21
 
@@ -169,6 +170,29 @@ def worker_loop(
169
170
  raise typer.Exit(1) from error
170
171
 
171
172
 
173
+ @app.command("progress")
174
+ def progress_report(
175
+ data: Annotated[
176
+ str,
177
+ typer.Option(help="Latest progress as one strict JSON object."),
178
+ ],
179
+ ) -> None:
180
+ """Replace the current Task run's progress snapshot.
181
+
182
+ This command is available inside a command launched by ``labtasker loop``.
183
+ It prints whether the best-effort report was accepted; transport failures
184
+ and confirmed revocation return ``reported: false`` without failing the
185
+ command workload.
186
+ """
187
+ try:
188
+ progress = _json_object(data, option="--data")
189
+ reported = _invoke(lambda: report_current_progress(progress))
190
+ except RuntimeError as error:
191
+ typer.echo(str(error), err=True)
192
+ raise typer.Exit(1) from error
193
+ _write_json({"reported": reported})
194
+
195
+
172
196
  @task_app.command("submit")
173
197
  def task_submit(
174
198
  args: Annotated[
@@ -589,6 +589,24 @@ class Client:
589
589
  parser=lambda response: _parse_none(response, {204}),
590
590
  )
591
591
 
592
+ def _report_progress(
593
+ self,
594
+ *,
595
+ task_id: str,
596
+ run_id: str,
597
+ progress: dict[str, JSONValue],
598
+ queue: str | None = None,
599
+ ) -> None:
600
+ normalized = validate_json_object(progress, field="progress")
601
+ self._run_action(
602
+ "progress",
603
+ task_id=task_id,
604
+ run_id=run_id,
605
+ queue=queue,
606
+ body={"progress": normalized},
607
+ parser=lambda response: _parse_none(response, {204}),
608
+ )
609
+
592
610
  def _fail(
593
611
  self,
594
612
  *,
@@ -17,6 +17,7 @@ from labtasker.types import JSONValue
17
17
  from labtasker.validation import RequestValidationError, validate_json_object
18
18
 
19
19
  CompletionReporter = Callable[[dict[str, JSONValue]], bool]
20
+ ProgressReporter = Callable[[dict[str, JSONValue]], bool]
20
21
  ContextKind = Literal["python", "command"]
21
22
 
22
23
 
@@ -128,12 +129,14 @@ class ExecutionContext:
128
129
  journal: LocalRunJournal,
129
130
  reporter: CompletionReporter,
130
131
  control: RunControl | None,
132
+ progress_reporter: ProgressReporter | None = None,
131
133
  ) -> None:
132
134
  self.info = info
133
135
  self.kind = kind
134
136
  self.journal = journal
135
137
  self.reporter = reporter
136
138
  self.control = control
139
+ self.progress_reporter = progress_reporter
137
140
  self._lock = threading.Lock()
138
141
  self._finish_started = False
139
142
  self._finished = False
@@ -161,6 +164,17 @@ class ExecutionContext:
161
164
  self.control.complete()
162
165
  _best_effort_journal(self.journal.acknowledged)
163
166
 
167
+ def report_progress(self, progress: dict[str, JSONValue]) -> bool:
168
+ with self._lock:
169
+ if self._finished:
170
+ raise RuntimeError("The current run has already completed.")
171
+ if self.control is not None and not self.control.active:
172
+ return False
173
+ reporter = self.progress_reporter
174
+ if reporter is None:
175
+ raise RuntimeError("Progress reporting is unavailable for this execution.")
176
+ return reporter(progress)
177
+
164
178
 
165
179
  _CONTEXT_LOCK = threading.RLock()
166
180
  _ACTIVE_CONTEXT: ExecutionContext | None = None
@@ -212,6 +226,20 @@ def finish(
212
226
  context.finish(normalized)
213
227
 
214
228
 
229
+ def report_progress(
230
+ progress: dict[str, JSONValue],
231
+ *,
232
+ skip_if_no_labtasker: bool = False,
233
+ ) -> bool:
234
+ context = _get_context()
235
+ if context is None:
236
+ if skip_if_no_labtasker:
237
+ return False
238
+ raise RuntimeError("No active Labtasker Task execution is available.")
239
+ normalized = validate_json_object(progress, field="progress")
240
+ return context.report_progress(normalized)
241
+
242
+
215
243
  def cancellation_requested() -> bool:
216
244
  context = _require_python_context()
217
245
  if context.finished or context.control is None:
@@ -340,12 +368,24 @@ def _load_environment_context() -> ExecutionContext | None:
340
368
  result=result,
341
369
  )
342
370
 
371
+ def progress_reporter(progress: dict[str, JSONValue]) -> bool:
372
+ from labtasker.worker import report_progress_once
373
+
374
+ return report_progress_once(
375
+ client,
376
+ queue=values["queue"] or "",
377
+ task_id=values["task_id"] or "",
378
+ run_id=run_id,
379
+ progress=progress,
380
+ )
381
+
343
382
  _ENV_CONTEXT = ExecutionContext(
344
383
  info=info,
345
384
  kind="command",
346
385
  journal=journal,
347
386
  reporter=report,
348
387
  control=None,
388
+ progress_reporter=progress_reporter,
349
389
  )
350
390
  return _ENV_CONTEXT
351
391
 
@@ -68,6 +68,9 @@ class Task(ResponseModel):
68
68
  max_attempts: int
69
69
  routes: list[str]
70
70
  result: dict[str, JSONValue]
71
+ progress: dict[str, JSONValue] | None = None
72
+ progress_updated_at: datetime | None = None
73
+ progress_attempt: int | None = None
71
74
  last_error: LastError | None
72
75
  last_route: str | None
73
76
  created_at: datetime
@@ -90,9 +93,15 @@ class Task(ResponseModel):
90
93
  def validate_name(cls, value: str | None) -> str | None:
91
94
  return validate_task_name(value)
92
95
 
93
- @field_validator("args", "metadata", "result")
96
+ @field_validator("args", "metadata", "result", "progress")
94
97
  @classmethod
95
- def validate_objects(cls, value: dict[str, JSONValue], info: object) -> dict[str, JSONValue]:
98
+ def validate_objects(
99
+ cls,
100
+ value: dict[str, JSONValue] | None,
101
+ info: object,
102
+ ) -> dict[str, JSONValue] | None:
103
+ if value is None:
104
+ return None
96
105
  return validate_json_object(value, field=getattr(info, "field_name", "task"))
97
106
 
98
107
  @field_validator("priority", "attempt")
@@ -100,6 +109,23 @@ class Task(ResponseModel):
100
109
  def validate_numbers(cls, value: int, info: object) -> int:
101
110
  return validate_int64(value, field=getattr(info, "field_name", "task"))
102
111
 
112
+ @field_validator("progress_attempt")
113
+ @classmethod
114
+ def validate_progress_attempt(cls, value: int | None) -> int | None:
115
+ if value is None:
116
+ return None
117
+ value = validate_int64(value, field="progress_attempt")
118
+ if value < 0:
119
+ raise ValueError("progress_attempt must be non-negative")
120
+ return value
121
+
122
+ @model_validator(mode="after")
123
+ def validate_progress_fields(self) -> Task:
124
+ fields = (self.progress, self.progress_updated_at, self.progress_attempt)
125
+ if any(value is None for value in fields) and any(value is not None for value in fields):
126
+ raise ValueError("progress fields must be all null or all present")
127
+ return self
128
+
103
129
  @field_validator("max_attempts")
104
130
  @classmethod
105
131
  def validate_max_attempts(cls, value: int) -> int:
@@ -118,7 +144,13 @@ class Task(ResponseModel):
118
144
  def validate_last_route(cls, value: str | None) -> str | None:
119
145
  return None if value is None else validate_identifier(value, field="last_route")
120
146
 
121
- @field_validator("created_at", "updated_at", "started_at", "finished_at")
147
+ @field_validator(
148
+ "created_at",
149
+ "updated_at",
150
+ "started_at",
151
+ "finished_at",
152
+ "progress_updated_at",
153
+ )
122
154
  @classmethod
123
155
  def validate_times(cls, value: datetime | None) -> datetime | None:
124
156
  return None if value is None else _utc_datetime(value)
@@ -19,6 +19,7 @@ from labtasker.errors import (
19
19
  APIError,
20
20
  ConfigError,
21
21
  FatalWorkerError,
22
+ LabtaskerError,
22
23
  TransientError,
23
24
  TransportError,
24
25
  )
@@ -260,6 +261,16 @@ def _run_python_claim(
260
261
  control.revoke("stale_run")
261
262
  return accepted
262
263
 
264
+ def progress_reporter(progress: dict[str, JSONValue]) -> bool:
265
+ return report_progress_once(
266
+ client,
267
+ queue=queue,
268
+ task_id=claim.task.id,
269
+ run_id=claim.run_id,
270
+ progress=progress,
271
+ control=control,
272
+ )
273
+
263
274
  info = TaskInfo(
264
275
  **claim.task.model_dump(),
265
276
  run_id=claim.run_id,
@@ -271,6 +282,7 @@ def _run_python_claim(
271
282
  journal=journal,
272
283
  reporter=report_complete,
273
284
  control=control,
285
+ progress_reporter=progress_reporter,
274
286
  )
275
287
  heartbeat = Heartbeat(
276
288
  client,
@@ -358,6 +370,39 @@ def report_complete_until_resolved(
358
370
  )
359
371
 
360
372
 
373
+ def report_progress_once(
374
+ client: Client,
375
+ *,
376
+ queue: str,
377
+ task_id: str,
378
+ run_id: str,
379
+ progress: dict[str, JSONValue],
380
+ control: RunControl | None = None,
381
+ ) -> bool:
382
+ try:
383
+ client._report_progress(
384
+ task_id=task_id,
385
+ run_id=run_id,
386
+ progress=progress,
387
+ queue=queue,
388
+ )
389
+ except APIError as error:
390
+ if error.code == "run_finalized" and error.details.get("action") == "complete":
391
+ if control is not None:
392
+ control.complete()
393
+ elif error.code in {"run_finalized", "stale_run"} and control is not None:
394
+ control.revoke(str(error.details.get("action", error.code)))
395
+ logger.warning("Progress report rejected; continuing Task: %s", error.message)
396
+ return False
397
+ except TransportError as error:
398
+ logger.warning("Progress report transport error; continuing Task: %s", error.message)
399
+ return False
400
+ except LabtaskerError as error:
401
+ logger.warning("Progress report failed; continuing Task: %s", error.message)
402
+ return False
403
+ return True
404
+
405
+
361
406
  def _report_complete(
362
407
  client: Client,
363
408
  journal: LocalRunJournal,