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/config.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal, TypedDict
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ from labtasker.errors import ConfigError
11
+ from labtasker.local import LocalPaths, local_paths, require_local_capabilities
12
+ from labtasker.validation import RequestValidationError, invalid_config, validate_identifier
13
+
14
+ DEFAULT_QUEUE = "default"
15
+ CONFIG_FIELDS = {"url", "queue", "token"}
16
+
17
+
18
+ class EndpointRecord(TypedDict):
19
+ mode: Literal["http", "local"]
20
+ url: str | None
21
+ socket: str | None
22
+ directory: str | None
23
+ database: str | None
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ResolvedConfig:
28
+ url: str | None
29
+ queue: str
30
+ token: str | None
31
+ local: LocalPaths | None
32
+
33
+ def public_dict(self) -> dict[str, object]:
34
+ return {
35
+ "mode": "local" if self.local is not None else "http",
36
+ "directory": str(self.local.directory) if self.local is not None else None,
37
+ "database": str(self.local.database) if self.local is not None else None,
38
+ "socket": str(self.local.socket) if self.local is not None else None,
39
+ "url": self.url,
40
+ "queue": self.queue,
41
+ "token_configured": self.token is not None,
42
+ }
43
+
44
+ def endpoint_dict(self) -> EndpointRecord:
45
+ return {
46
+ "mode": "local" if self.local is not None else "http",
47
+ "url": self.url,
48
+ "socket": str(self.local.socket) if self.local is not None else None,
49
+ "directory": str(self.local.directory) if self.local is not None else None,
50
+ "database": str(self.local.database) if self.local is not None else None,
51
+ }
52
+
53
+
54
+ def resolve_config(
55
+ *,
56
+ url: str | None = None,
57
+ token: str | None = None,
58
+ queue: str | None = None,
59
+ cwd: Path | None = None,
60
+ environment: dict[str, str] | None = None,
61
+ ) -> ResolvedConfig:
62
+ working_directory = Path.cwd() if cwd is None else cwd
63
+ environment_values = os.environ if environment is None else environment
64
+ file_values = _read_config_file(working_directory)
65
+ constructor_values = {"url": url, "token": token, "queue": queue}
66
+ environment_fields = {
67
+ "url": environment_values.get("LABTASKER_URL"),
68
+ "token": environment_values.get("LABTASKER_TOKEN"),
69
+ "queue": environment_values.get("LABTASKER_QUEUE"),
70
+ }
71
+ defaults: dict[str, str | None] = {
72
+ "url": None,
73
+ "token": None,
74
+ "queue": DEFAULT_QUEUE,
75
+ }
76
+
77
+ effective: dict[str, str | None] = {}
78
+ sources: dict[str, str] = {}
79
+ config_path = str(working_directory / ".labtasker" / "config.toml")
80
+ for field in ("url", "token", "queue"):
81
+ candidates = [
82
+ (constructor_values[field], "constructor"),
83
+ (environment_fields[field], "environment"),
84
+ (file_values.get(field), config_path),
85
+ (defaults[field], "default"),
86
+ ]
87
+ selected = next(
88
+ ((candidate, source) for candidate, source in candidates if candidate is not None),
89
+ (None, "default"),
90
+ )
91
+ value, source = selected
92
+ effective[field] = value
93
+ sources[field] = source
94
+
95
+ effective_url = _validate_url(effective["url"], source=sources["url"])
96
+ effective_queue = _validate_queue(effective["queue"], source=sources["queue"])
97
+ effective_token = _validate_token(effective["token"], source=sources["token"])
98
+ local: LocalPaths | None = None
99
+ if effective_url is None:
100
+ if effective_token is not None:
101
+ raise invalid_config(
102
+ "A token requires an explicit URL; local mode does not use authentication.",
103
+ source=sources["token"],
104
+ field="token",
105
+ )
106
+ require_local_capabilities()
107
+ local = local_paths(working_directory)
108
+ return ResolvedConfig(
109
+ url=effective_url,
110
+ queue=effective_queue,
111
+ token=effective_token,
112
+ local=local,
113
+ )
114
+
115
+
116
+ def _read_config_file(cwd: Path) -> dict[str, str]:
117
+ config_path = cwd / ".labtasker" / "config.toml"
118
+ legacy_path = cwd / ".labtasker" / "client.toml"
119
+ if not config_path.exists():
120
+ if legacy_path.exists():
121
+ raise ConfigError(
122
+ "legacy_config_found",
123
+ "A v1 client.toml was found; create the v2 flat config.toml manually.",
124
+ {"source": str(legacy_path)},
125
+ )
126
+ return {}
127
+ try:
128
+ raw = config_path.read_bytes()
129
+ parsed = tomllib.loads(raw.decode("utf-8"))
130
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
131
+ raise invalid_config(
132
+ "The client configuration file could not be read or parsed.",
133
+ source=str(config_path),
134
+ ) from error
135
+ if set(parsed) - CONFIG_FIELDS:
136
+ raise invalid_config(
137
+ f"Unknown configuration keys: {sorted(set(parsed) - CONFIG_FIELDS)!r}.",
138
+ source=str(config_path),
139
+ )
140
+ values: dict[str, str] = {}
141
+ for field, value in parsed.items():
142
+ if not isinstance(value, str) or not value:
143
+ raise invalid_config(
144
+ f"Configuration field '{field}' must be a non-empty string.",
145
+ source=str(config_path),
146
+ field=field,
147
+ )
148
+ values[field] = value
149
+ return values
150
+
151
+
152
+ def _validate_url(value: str | None, *, source: str) -> str | None:
153
+ if value is None:
154
+ return None
155
+ if not isinstance(value, str) or not value:
156
+ raise invalid_config("URL must be a non-empty string.", source=source, field="url")
157
+ try:
158
+ parsed = urlsplit(value)
159
+ port = parsed.port
160
+ except ValueError as error:
161
+ raise invalid_config("URL is invalid.", source=source, field="url") from error
162
+ if (
163
+ parsed.scheme not in {"http", "https"}
164
+ or not parsed.netloc
165
+ or parsed.hostname is None
166
+ or parsed.username is not None
167
+ or parsed.password is not None
168
+ or parsed.query
169
+ or parsed.fragment
170
+ or (port is not None and not 1 <= port <= 65535)
171
+ ):
172
+ raise invalid_config(
173
+ "URL must be an absolute HTTP(S) base URL without userinfo, query or fragment.",
174
+ source=source,
175
+ field="url",
176
+ )
177
+ path = parsed.path.rstrip("/")
178
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
179
+
180
+
181
+ def _validate_queue(value: str | None, *, source: str) -> str:
182
+ try:
183
+ return validate_identifier(value, field="queue")
184
+ except RequestValidationError as error:
185
+ raise invalid_config(str(error), source=source, field="queue") from error
186
+
187
+
188
+ def _validate_token(value: str | None, *, source: str) -> str | None:
189
+ if value is None:
190
+ return None
191
+ if not isinstance(value, str) or not value:
192
+ raise invalid_config("Token must be a non-empty string.", source=source, field="token")
193
+ return value
labtasker/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class LabtaskerError(Exception):
7
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
8
+ super().__init__(message)
9
+ self.code = code
10
+ self.message = message
11
+ self.details = {} if details is None else details
12
+
13
+ def as_envelope(self) -> dict[str, object]:
14
+ return {
15
+ "error": {
16
+ "code": self.code,
17
+ "message": self.message,
18
+ "details": self.details,
19
+ }
20
+ }
21
+
22
+
23
+ class ConfigError(LabtaskerError):
24
+ def __init__(self, code: str, message: str, details: dict[str, Any]) -> None:
25
+ if code not in {"invalid_config", "legacy_config_found"}:
26
+ raise ValueError(f"Unsupported ConfigError code: {code}")
27
+ super().__init__(code, message, details)
28
+
29
+
30
+ class TransportError(LabtaskerError):
31
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
32
+ super().__init__("transport_error", message, details)
33
+
34
+
35
+ class APIError(LabtaskerError):
36
+ def __init__(
37
+ self,
38
+ status_code: int,
39
+ code: str,
40
+ message: str,
41
+ details: dict[str, Any],
42
+ ) -> None:
43
+ super().__init__(code, message, details)
44
+ self.status_code = status_code
45
+
46
+
47
+ class TransientError(Exception):
48
+ """Return the current Task to pending without charging this incident."""
49
+
50
+
51
+ class TaskError(Exception):
52
+ """Fail the current execution and continue the Worker loop."""
53
+
54
+
55
+ class FatalWorkerError(Exception):
56
+ """Fail the current execution and stop the Worker process."""
labtasker/execution.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ import time
6
+ import warnings
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from labtasker.client import Client
12
+ from labtasker.config import EndpointRecord
13
+ from labtasker.errors import ConfigError
14
+ from labtasker.journal import LocalRunJournal
15
+ from labtasker.models import Task, TaskInfo
16
+ from labtasker.types import JSONValue
17
+ from labtasker.validation import RequestValidationError, validate_json_object
18
+
19
+ CompletionReporter = Callable[[dict[str, JSONValue]], bool]
20
+ ContextKind = Literal["python", "command"]
21
+
22
+
23
+ class RunControl:
24
+ def __init__(
25
+ self,
26
+ *,
27
+ force_stop_timeout: float | None,
28
+ force_stop: Callable[[], None],
29
+ ) -> None:
30
+ self._condition = threading.Condition()
31
+ self._force_stop_timeout = force_stop_timeout
32
+ self._force_stop = force_stop
33
+ self._revoked_at: float | None = None
34
+ self._revoked_action: str | None = None
35
+ self._fatal_error: Exception | None = None
36
+ self._completed = False
37
+ self._executor_done = False
38
+ self._watchdog = threading.Thread(
39
+ target=self._watch_force_stop,
40
+ name="labtasker-force-stop",
41
+ daemon=True,
42
+ )
43
+ self._watchdog.start()
44
+
45
+ @property
46
+ def revoked(self) -> bool:
47
+ with self._condition:
48
+ return self._revoked_at is not None
49
+
50
+ @property
51
+ def revoked_action(self) -> str | None:
52
+ with self._condition:
53
+ return self._revoked_action
54
+
55
+ @property
56
+ def completed(self) -> bool:
57
+ with self._condition:
58
+ return self._completed
59
+
60
+ @property
61
+ def fatal_error(self) -> Exception | None:
62
+ with self._condition:
63
+ return self._fatal_error
64
+
65
+ @property
66
+ def active(self) -> bool:
67
+ with self._condition:
68
+ return self._revoked_at is None and not self._completed
69
+
70
+ def revoke(self, action: str) -> None:
71
+ with self._condition:
72
+ if self._completed or self._revoked_at is not None:
73
+ return
74
+ self._revoked_at = time.monotonic()
75
+ self._revoked_action = action
76
+ self._condition.notify_all()
77
+
78
+ def fail(self, error: Exception) -> None:
79
+ with self._condition:
80
+ if self._completed or self._revoked_at is not None:
81
+ return
82
+ self._fatal_error = error
83
+ self._revoked_at = time.monotonic()
84
+ self._revoked_action = error.__class__.__name__
85
+ self._condition.notify_all()
86
+
87
+ def complete(self) -> None:
88
+ with self._condition:
89
+ if self._revoked_at is None:
90
+ self._completed = True
91
+ self._condition.notify_all()
92
+
93
+ def executor_done(self) -> None:
94
+ with self._condition:
95
+ self._executor_done = True
96
+ self._condition.notify_all()
97
+
98
+ def set_force_stop_timeout(self, value: float | None) -> None:
99
+ with self._condition:
100
+ if self._completed:
101
+ raise RuntimeError("The current run has already completed.")
102
+ self._force_stop_timeout = value
103
+ self._condition.notify_all()
104
+
105
+ def _watch_force_stop(self) -> None:
106
+ while True:
107
+ with self._condition:
108
+ if self._executor_done or self._completed:
109
+ return
110
+ if self._revoked_at is None or self._force_stop_timeout is None:
111
+ self._condition.wait()
112
+ continue
113
+ deadline = self._revoked_at + self._force_stop_timeout
114
+ remaining = deadline - time.monotonic()
115
+ if remaining > 0:
116
+ self._condition.wait(remaining)
117
+ continue
118
+ self._force_stop()
119
+ return
120
+
121
+
122
+ class ExecutionContext:
123
+ def __init__(
124
+ self,
125
+ *,
126
+ info: TaskInfo,
127
+ kind: ContextKind,
128
+ journal: LocalRunJournal,
129
+ reporter: CompletionReporter,
130
+ control: RunControl | None,
131
+ ) -> None:
132
+ self.info = info
133
+ self.kind = kind
134
+ self.journal = journal
135
+ self.reporter = reporter
136
+ self.control = control
137
+ self._lock = threading.Lock()
138
+ self._finish_started = False
139
+ self._finished = False
140
+
141
+ @property
142
+ def finished(self) -> bool:
143
+ with self._lock:
144
+ return self._finished
145
+
146
+ def finish(self, result: dict[str, JSONValue]) -> None:
147
+ with self._lock:
148
+ if self._finish_started:
149
+ raise RuntimeError("finish() has already been called for this execution.")
150
+ self._finish_started = True
151
+ _best_effort_journal(lambda: self.journal.reporting("complete", result))
152
+ accepted = self.reporter(result)
153
+ if not accepted:
154
+ _best_effort_journal(self.journal.revoked)
155
+ raise RuntimeError("The current run was revoked before finish() could complete it.")
156
+ with self._lock:
157
+ self._finished = True
158
+ if self.control is not None:
159
+ self.control.complete()
160
+ _best_effort_journal(self.journal.acknowledged)
161
+
162
+
163
+ _CONTEXT_LOCK = threading.RLock()
164
+ _ACTIVE_CONTEXT: ExecutionContext | None = None
165
+ _ENV_CONTEXT: ExecutionContext | None = None
166
+ _FORK_HOOK_INSTALLED = False
167
+
168
+
169
+ def activate_context(context: ExecutionContext) -> None:
170
+ global _ACTIVE_CONTEXT, _FORK_HOOK_INSTALLED
171
+ with _CONTEXT_LOCK:
172
+ if _ACTIVE_CONTEXT is not None:
173
+ raise RuntimeError("A Labtasker execution context is already active.")
174
+ if not _FORK_HOOK_INSTALLED and hasattr(os, "register_at_fork"):
175
+ os.register_at_fork(after_in_child=_clear_after_fork)
176
+ _FORK_HOOK_INSTALLED = True
177
+ _ACTIVE_CONTEXT = context
178
+
179
+
180
+ def deactivate_context(context: ExecutionContext) -> None:
181
+ global _ACTIVE_CONTEXT
182
+ with _CONTEXT_LOCK:
183
+ if _ACTIVE_CONTEXT is context:
184
+ _ACTIVE_CONTEXT = None
185
+
186
+
187
+ def active_context_present() -> bool:
188
+ with _CONTEXT_LOCK:
189
+ return _ACTIVE_CONTEXT is not None
190
+
191
+
192
+ def task_info() -> TaskInfo:
193
+ context = _get_context()
194
+ if context is None:
195
+ raise RuntimeError("No active Labtasker Task execution is available.")
196
+ return context.info
197
+
198
+
199
+ def finish(
200
+ result: dict[str, JSONValue] | None = None,
201
+ *,
202
+ skip_if_no_labtasker: bool = False,
203
+ ) -> None:
204
+ context = _get_context()
205
+ if context is None:
206
+ if skip_if_no_labtasker:
207
+ return
208
+ raise RuntimeError("No active Labtasker Task execution is available.")
209
+ normalized = validate_json_object({} if result is None else result, field="result")
210
+ context.finish(normalized)
211
+
212
+
213
+ def cancellation_requested() -> bool:
214
+ context = _require_python_context()
215
+ if context.finished or context.control is None:
216
+ return False
217
+ return context.control.revoked
218
+
219
+
220
+ def set_force_stop_timeout(seconds: float | None) -> None:
221
+ context = _require_python_context()
222
+ if context.finished or context.control is None:
223
+ raise RuntimeError("The current run is no longer cancellable.")
224
+ context.control.set_force_stop_timeout(_validate_force_stop_timeout(seconds))
225
+
226
+
227
+ def _require_python_context() -> ExecutionContext:
228
+ context = _get_context()
229
+ if context is None or context.kind != "python":
230
+ raise RuntimeError("This function requires an active Python Worker execution.")
231
+ return context
232
+
233
+
234
+ def _get_context() -> ExecutionContext | None:
235
+ with _CONTEXT_LOCK:
236
+ if _ACTIVE_CONTEXT is not None:
237
+ return _ACTIVE_CONTEXT
238
+ return _load_environment_context()
239
+
240
+
241
+ def _load_environment_context() -> ExecutionContext | None:
242
+ global _ENV_CONTEXT
243
+ with _CONTEXT_LOCK:
244
+ if _ENV_CONTEXT is not None:
245
+ return _ENV_CONTEXT
246
+ names = {
247
+ "queue": "LABTASKER_QUEUE",
248
+ "task_id": "LABTASKER_TASK_ID",
249
+ "run_id": "LABTASKER_RUN_ID",
250
+ "route": "LABTASKER_ROUTE",
251
+ "run_dir": "LABTASKER_RUN_DIR",
252
+ }
253
+ values = {field: os.environ.get(name) for field, name in names.items()}
254
+ execution_fields = {"task_id", "run_id", "route", "run_dir"}
255
+ present = {field for field, value in values.items() if value is not None}
256
+ if not (present & execution_fields):
257
+ return None
258
+ if present != set(names):
259
+ raise ConfigError(
260
+ "invalid_config",
261
+ "Inherited Labtasker execution context is incomplete.",
262
+ {"missing": sorted(set(names) - present)},
263
+ )
264
+ url = os.environ.get("LABTASKER_URL")
265
+ socket = os.environ.get("LABTASKER_SOCKET")
266
+ local_directory = os.environ.get("LABTASKER_LOCAL_DIRECTORY")
267
+ token = os.environ.get("LABTASKER_TOKEN")
268
+ http_endpoint = url is not None and socket is None and local_directory is None
269
+ local_endpoint = (
270
+ url is None and socket is not None and local_directory is not None and token is None
271
+ )
272
+ if not (http_endpoint or local_endpoint):
273
+ raise ConfigError(
274
+ "invalid_config",
275
+ "Inherited Labtasker execution endpoint is incomplete or ambiguous.",
276
+ {"expected": ("LABTASKER_URL, or LABTASKER_SOCKET and LABTASKER_LOCAL_DIRECTORY")},
277
+ )
278
+ run_dir = Path(values["run_dir"] or "")
279
+ if not run_dir.is_absolute():
280
+ raise ConfigError(
281
+ "invalid_config",
282
+ "LABTASKER_RUN_DIR must be absolute.",
283
+ {"field": "LABTASKER_RUN_DIR"},
284
+ )
285
+ try:
286
+ task = Task.model_validate_json((run_dir / "task.json").read_bytes(), strict=True)
287
+ run_id = values["run_id"] or ""
288
+ journal = LocalRunJournal.open(run_dir)
289
+ expected_endpoint: EndpointRecord
290
+ if http_endpoint:
291
+ expected_endpoint = {
292
+ "mode": "http",
293
+ "url": url,
294
+ "socket": None,
295
+ "directory": None,
296
+ "database": None,
297
+ }
298
+ client = Client(url=url, token=token, queue=values["queue"])
299
+ else:
300
+ directory = Path(local_directory or "")
301
+ if not directory.is_absolute() or directory.resolve() != directory:
302
+ raise ValueError("LABTASKER_LOCAL_DIRECTORY must be a canonical absolute path")
303
+ client = Client._from_local_directory(
304
+ directory,
305
+ queue=values["queue"] or "",
306
+ )
307
+ local = client.configuration.local
308
+ assert local is not None
309
+ if str(local.socket) != socket:
310
+ raise ValueError("local execution socket does not match its directory")
311
+ expected_endpoint = client.configuration.endpoint_dict()
312
+ if (
313
+ task.id != values["task_id"]
314
+ or task.queue != values["queue"]
315
+ or journal.endpoint != expected_endpoint
316
+ or journal.queue != values["queue"]
317
+ or journal.task_id != values["task_id"]
318
+ or journal.run_id != run_id
319
+ or journal.route != values["route"]
320
+ ):
321
+ raise ValueError("execution environment does not match the local journal")
322
+ info = TaskInfo(**task.model_dump(), run_id=run_id, run_dir=journal.run_dir)
323
+ except Exception as error:
324
+ raise ConfigError(
325
+ "invalid_config",
326
+ "Inherited Labtasker execution context could not be loaded.",
327
+ {"source": str(run_dir)},
328
+ ) from error
329
+
330
+ def report(result: dict[str, JSONValue]) -> bool:
331
+ from labtasker.worker import report_complete_until_resolved
332
+
333
+ return report_complete_until_resolved(
334
+ client,
335
+ queue=values["queue"] or "",
336
+ task_id=values["task_id"] or "",
337
+ run_id=run_id,
338
+ result=result,
339
+ )
340
+
341
+ _ENV_CONTEXT = ExecutionContext(
342
+ info=info,
343
+ kind="command",
344
+ journal=journal,
345
+ reporter=report,
346
+ control=None,
347
+ )
348
+ return _ENV_CONTEXT
349
+
350
+
351
+ def _best_effort_journal(operation: Callable[[], None]) -> None:
352
+ try:
353
+ operation()
354
+ except Exception as error:
355
+ warnings.warn(
356
+ f"Labtasker could not update the local run journal: {error}",
357
+ RuntimeWarning,
358
+ stacklevel=3,
359
+ )
360
+
361
+
362
+ def _validate_force_stop_timeout(value: float | None) -> float | None:
363
+ if value is None:
364
+ return None
365
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
366
+ raise RequestValidationError(
367
+ "force_stop_timeout must be a finite non-negative number or None"
368
+ )
369
+ normalized = float(value)
370
+ if not 0 <= normalized < float("inf"):
371
+ raise RequestValidationError(
372
+ "force_stop_timeout must be a finite non-negative number or None"
373
+ )
374
+ return normalized
375
+
376
+
377
+ def _clear_after_fork() -> None:
378
+ global _ACTIVE_CONTEXT, _ENV_CONTEXT
379
+ _ACTIVE_CONTEXT = None
380
+ _ENV_CONTEXT = None