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/journal.py ADDED
@@ -0,0 +1,314 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import threading
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Literal, TypedDict, cast
10
+
11
+ from labtasker.config import EndpointRecord
12
+ from labtasker.models import ClaimResponse
13
+ from labtasker.types import JSONValue
14
+ from labtasker.validation import (
15
+ validate_identifier,
16
+ validate_int64,
17
+ validate_run_id,
18
+ validate_task_id,
19
+ )
20
+
21
+ JournalPhase = Literal["running", "reporting", "acknowledged", "revoked"]
22
+ TerminalAction = Literal["complete", "fail", "unclaim"]
23
+ LOCAL_GITIGNORE = "*\n!.gitignore\n"
24
+
25
+
26
+ class RunRecord(TypedDict):
27
+ schema_version: int
28
+ endpoint: EndpointRecord
29
+ queue: str
30
+ task_id: str
31
+ run_id: str
32
+ route: str
33
+ attempt: int
34
+ started_at: str
35
+ finished_at: str | None
36
+ phase: JournalPhase
37
+ terminal_action: TerminalAction | None
38
+ acknowledged_at: str | None
39
+
40
+
41
+ class LocalRunJournal:
42
+ def __init__(self, run_dir: Path, record: RunRecord) -> None:
43
+ self.run_dir = run_dir
44
+ self._record = record
45
+ self._lock = threading.Lock()
46
+
47
+ @classmethod
48
+ def create(
49
+ cls,
50
+ *,
51
+ claim: ClaimResponse,
52
+ endpoint: EndpointRecord,
53
+ queue: str,
54
+ route: str,
55
+ cwd: Path | None = None,
56
+ ) -> LocalRunJournal:
57
+ started_at = claim.task.started_at
58
+ if started_at is None:
59
+ raise ValueError("claimed Task is missing started_at")
60
+ root = (Path.cwd() if cwd is None else cwd).resolve()
61
+ task_group = f"{_task_slug(claim.task.name)}__{claim.task.id}"
62
+ run_name = (
63
+ f"{started_at.astimezone(UTC):%Y%m%dT%H%M%SZ}"
64
+ f"__attempt-{claim.task.attempt}__{claim.run_id}"
65
+ )
66
+ labtasker_dir = root / ".labtasker"
67
+ labtasker_dir.mkdir(parents=True, exist_ok=True)
68
+ _ensure_local_gitignore(labtasker_dir)
69
+ run_dir = labtasker_dir / "runs" / queue / task_group / run_name
70
+ run_dir.mkdir(parents=True, exist_ok=False)
71
+ record: RunRecord = {
72
+ "schema_version": 1,
73
+ "endpoint": endpoint,
74
+ "queue": queue,
75
+ "task_id": claim.task.id,
76
+ "run_id": claim.run_id,
77
+ "route": route,
78
+ "attempt": claim.task.attempt,
79
+ "started_at": _timestamp(started_at),
80
+ "finished_at": None,
81
+ "phase": "running",
82
+ "terminal_action": None,
83
+ "acknowledged_at": None,
84
+ }
85
+ journal = cls(run_dir.resolve(), record)
86
+ _atomic_json(journal.task_path, claim.task.model_dump(mode="json"))
87
+ _atomic_json(journal.run_path, record)
88
+ journal.log_path.touch(exist_ok=False)
89
+ return journal
90
+
91
+ @classmethod
92
+ def open(cls, run_dir: Path) -> LocalRunJournal:
93
+ resolved = run_dir.resolve()
94
+ parsed = json.loads((resolved / "run.json").read_text(encoding="utf-8"))
95
+ return cls(resolved, _validate_record(parsed))
96
+
97
+ @property
98
+ def endpoint(self) -> EndpointRecord:
99
+ return self._record["endpoint"]
100
+
101
+ @property
102
+ def queue(self) -> str:
103
+ return self._record["queue"]
104
+
105
+ @property
106
+ def task_id(self) -> str:
107
+ return self._record["task_id"]
108
+
109
+ @property
110
+ def run_id(self) -> str:
111
+ return self._record["run_id"]
112
+
113
+ @property
114
+ def route(self) -> str:
115
+ return self._record["route"]
116
+
117
+ @property
118
+ def task_path(self) -> Path:
119
+ return self.run_dir / "task.json"
120
+
121
+ @property
122
+ def run_path(self) -> Path:
123
+ return self.run_dir / "run.json"
124
+
125
+ @property
126
+ def result_path(self) -> Path:
127
+ return self.run_dir / "result.json"
128
+
129
+ @property
130
+ def error_path(self) -> Path:
131
+ return self.run_dir / "error.json"
132
+
133
+ @property
134
+ def log_path(self) -> Path:
135
+ return self.run_dir / "run.log"
136
+
137
+ @property
138
+ def phase(self) -> JournalPhase:
139
+ return self._record["phase"]
140
+
141
+ @property
142
+ def terminal_action(self) -> TerminalAction | None:
143
+ return self._record["terminal_action"]
144
+
145
+ def read_result(self) -> dict[str, JSONValue]:
146
+ parsed = json.loads(self.result_path.read_text(encoding="utf-8"))
147
+ if not isinstance(parsed, dict):
148
+ raise ValueError("result.json must be an object")
149
+ return cast(dict[str, JSONValue], parsed)
150
+
151
+ def reporting(
152
+ self,
153
+ action: TerminalAction,
154
+ payload: dict[str, JSONValue] | None = None,
155
+ ) -> None:
156
+ with self._lock:
157
+ if action == "complete":
158
+ if payload is None:
159
+ raise ValueError("complete journal entry requires a result")
160
+ _atomic_json_once(self.result_path, payload)
161
+ elif action == "fail":
162
+ if payload is None:
163
+ raise ValueError("fail journal entry requires an error")
164
+ _atomic_json_once(self.error_path, payload)
165
+ elif payload is not None:
166
+ raise ValueError("unclaim journal entry cannot have a payload")
167
+ self._record["phase"] = "reporting"
168
+ self._record["terminal_action"] = action
169
+ self._record["finished_at"] = None
170
+ self._record["acknowledged_at"] = None
171
+ _atomic_json(self.run_path, self._record)
172
+
173
+ def acknowledged(self) -> None:
174
+ now = _timestamp(datetime.now(UTC))
175
+ with self._lock:
176
+ self._record["phase"] = "acknowledged"
177
+ self._record["finished_at"] = now
178
+ self._record["acknowledged_at"] = now
179
+ _atomic_json(self.run_path, self._record)
180
+
181
+ def revoked(self) -> None:
182
+ with self._lock:
183
+ self._record["phase"] = "revoked"
184
+ self._record["finished_at"] = _timestamp(datetime.now(UTC))
185
+ self._record["acknowledged_at"] = None
186
+ _atomic_json(self.run_path, self._record)
187
+
188
+
189
+ def _ensure_local_gitignore(labtasker_dir: Path) -> None:
190
+ try:
191
+ with (labtasker_dir / ".gitignore").open("x", encoding="utf-8", newline="\n") as stream:
192
+ stream.write(LOCAL_GITIGNORE)
193
+ except FileExistsError:
194
+ pass
195
+
196
+
197
+ def _task_slug(name: str | None) -> str:
198
+ source = name if name else "unnamed"
199
+ characters: list[str] = []
200
+ for character in source:
201
+ if character.isalnum():
202
+ characters.append(character)
203
+ elif characters and characters[-1] != "-":
204
+ characters.append("-")
205
+ slug = "".join(characters).strip("-") or "unnamed"
206
+ encoded = slug.encode("utf-8")
207
+ if len(encoded) <= 80:
208
+ return slug
209
+ prefix = encoded[:80]
210
+ while True:
211
+ try:
212
+ slug = prefix.decode("utf-8").rstrip("-")
213
+ break
214
+ except UnicodeDecodeError:
215
+ prefix = prefix[:-1]
216
+ return slug or "unnamed"
217
+
218
+
219
+ def _timestamp(value: datetime) -> str:
220
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
221
+
222
+
223
+ def _atomic_json(path: Path, value: object) -> None:
224
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
225
+ temporary_path = Path(temporary_name)
226
+ try:
227
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
228
+ json.dump(value, stream, ensure_ascii=False, allow_nan=False, indent=2)
229
+ stream.write("\n")
230
+ stream.flush()
231
+ os.fsync(stream.fileno())
232
+ temporary_path.replace(path)
233
+ except BaseException:
234
+ temporary_path.unlink(missing_ok=True)
235
+ raise
236
+
237
+
238
+ def _atomic_json_once(path: Path, value: object) -> None:
239
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
240
+ temporary_path = Path(temporary_name)
241
+ try:
242
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
243
+ json.dump(value, stream, ensure_ascii=False, allow_nan=False, indent=2)
244
+ stream.write("\n")
245
+ stream.flush()
246
+ os.fsync(stream.fileno())
247
+ try:
248
+ os.link(temporary_path, path)
249
+ except FileExistsError:
250
+ existing = json.loads(path.read_text(encoding="utf-8"))
251
+ if existing != value:
252
+ raise ValueError(
253
+ f"{path.name} already contains a different terminal payload"
254
+ ) from None
255
+ finally:
256
+ temporary_path.unlink(missing_ok=True)
257
+
258
+
259
+ def _validate_record(value: object) -> RunRecord:
260
+ fields = {
261
+ "schema_version",
262
+ "endpoint",
263
+ "queue",
264
+ "task_id",
265
+ "run_id",
266
+ "route",
267
+ "attempt",
268
+ "started_at",
269
+ "finished_at",
270
+ "phase",
271
+ "terminal_action",
272
+ "acknowledged_at",
273
+ }
274
+ if not isinstance(value, dict) or set(value) != fields:
275
+ raise ValueError("run.json does not match journal schema version 1")
276
+ if value["schema_version"] != 1:
277
+ raise ValueError("run.json uses an unsupported journal schema")
278
+ _validate_endpoint(value["endpoint"])
279
+ validate_identifier(value["queue"], field="queue")
280
+ validate_task_id(value["task_id"])
281
+ validate_run_id(value["run_id"])
282
+ validate_identifier(value["route"], field="route")
283
+ validate_int64(value["attempt"], field="attempt", positive=True)
284
+ for field in ("started_at", "finished_at", "acknowledged_at"):
285
+ if value[field] is not None and not isinstance(value[field], str):
286
+ raise ValueError(f"run.json {field} must be a timestamp string or null")
287
+ if value["phase"] not in {"running", "reporting", "acknowledged", "revoked"}:
288
+ raise ValueError("run.json phase is invalid")
289
+ if value["terminal_action"] not in {None, "complete", "fail", "unclaim"}:
290
+ raise ValueError("run.json terminal_action is invalid")
291
+ return cast(RunRecord, value)
292
+
293
+
294
+ def _validate_endpoint(value: object) -> EndpointRecord:
295
+ fields = {"mode", "url", "socket", "directory", "database"}
296
+ if not isinstance(value, dict) or set(value) != fields:
297
+ raise ValueError("run.json endpoint is invalid")
298
+ mode = value["mode"]
299
+ if mode == "http":
300
+ if not isinstance(value["url"], str) or not value["url"]:
301
+ raise ValueError("run.json HTTP endpoint URL is invalid")
302
+ if any(value[field] is not None for field in ("socket", "directory", "database")):
303
+ raise ValueError("run.json HTTP endpoint contains local paths")
304
+ elif mode == "local":
305
+ if value["url"] is not None:
306
+ raise ValueError("run.json local endpoint contains a URL")
307
+ if any(
308
+ not isinstance(value[field], str) or not value[field]
309
+ for field in ("socket", "directory", "database")
310
+ ):
311
+ raise ValueError("run.json local endpoint paths are invalid")
312
+ else:
313
+ raise ValueError("run.json endpoint mode is invalid")
314
+ return cast(EndpointRecord, value)
labtasker/local.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ import json
6
+ import os
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+
16
+ from labtasker.errors import ConfigError, TransportError
17
+
18
+ COORDINATOR_TIMEOUT_SECONDS = 35.0
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class LocalPaths:
23
+ directory: Path
24
+ database: Path
25
+ log: Path
26
+ runtime_directory: Path
27
+ socket: Path
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class LocalEnsureResult:
32
+ started: bool
33
+ pid: int | None
34
+ server_version: str | None
35
+
36
+
37
+ def require_local_capabilities() -> None:
38
+ if os.name != "posix" or not hasattr(socket, "AF_UNIX"):
39
+ raise ConfigError(
40
+ "invalid_config",
41
+ "Local mode requires POSIX Unix-domain sockets; configure a URL.",
42
+ {"source": "default", "field": "url"},
43
+ )
44
+
45
+
46
+ def local_paths(directory: Path | None = None) -> LocalPaths:
47
+ canonical = (Path.cwd() if directory is None else directory).resolve()
48
+ digest = hashlib.sha256(os.fsencode(canonical)).hexdigest()
49
+ runtime_directory = Path("/tmp") / f"labtasker-{os.geteuid()}"
50
+ local_directory = canonical / ".labtasker"
51
+ return LocalPaths(
52
+ directory=canonical,
53
+ database=local_directory / "server.db",
54
+ log=local_directory / "server.log",
55
+ runtime_directory=runtime_directory,
56
+ socket=runtime_directory / f"{digest}.sock",
57
+ )
58
+
59
+
60
+ def ensure_local_server(paths: LocalPaths, *, emit: Callable[[str], None]) -> LocalEnsureResult:
61
+ require_local_capabilities()
62
+ if socket_health(paths):
63
+ return LocalEnsureResult(started=False, pid=None, server_version=None)
64
+ if importlib.util.find_spec("labtasker_server") is None:
65
+ raise ConfigError(
66
+ "invalid_config",
67
+ "Local mode requires labtasker-server; install labtasker or configure a URL.",
68
+ {"source": "default", "field": "url"},
69
+ )
70
+
71
+ emit(f"requesting local daemon ensure directory={paths.directory} socket={paths.socket}")
72
+ try:
73
+ result = subprocess.run(
74
+ [
75
+ sys.executable,
76
+ "-m",
77
+ "labtasker_server",
78
+ "_ensure-daemon",
79
+ "--directory",
80
+ str(paths.directory),
81
+ ],
82
+ cwd=paths.directory,
83
+ stdin=subprocess.DEVNULL,
84
+ stdout=subprocess.PIPE,
85
+ stderr=None,
86
+ text=True,
87
+ check=False,
88
+ timeout=COORDINATOR_TIMEOUT_SECONDS,
89
+ )
90
+ except subprocess.TimeoutExpired as error:
91
+ raise _local_transport_error(
92
+ paths,
93
+ state="starting",
94
+ message="The local Server coordinator did not finish within 35 seconds.",
95
+ ) from error
96
+ except OSError as error:
97
+ raise _local_transport_error(
98
+ paths,
99
+ state="stopped",
100
+ message="The local Server coordinator could not be started.",
101
+ ) from error
102
+
103
+ payload = _parse_coordinator_result(result.stdout)
104
+ if result.returncode != 0 or payload is None or payload.get("ok") is not True:
105
+ state = payload.get("state") if payload is not None else None
106
+ message = payload.get("message") if payload is not None else None
107
+ raise _local_transport_error(
108
+ paths,
109
+ state=state if isinstance(state, str) else "unhealthy",
110
+ message=(
111
+ message
112
+ if isinstance(message, str)
113
+ else "The local Server coordinator failed without a valid result."
114
+ ),
115
+ retry_after_seconds=_optional_number(payload, "retry_after_seconds"),
116
+ )
117
+ if not socket_health(paths):
118
+ raise _local_transport_error(
119
+ paths,
120
+ state="unhealthy",
121
+ message="The local Server coordinator returned before its socket was healthy.",
122
+ )
123
+
124
+ pid = payload.get("pid")
125
+ version = payload.get("version")
126
+ started = payload.get("started")
127
+ return LocalEnsureResult(
128
+ started=started is True,
129
+ pid=pid if isinstance(pid, int) and not isinstance(pid, bool) and pid > 0 else None,
130
+ server_version=version if isinstance(version, str) else None,
131
+ )
132
+
133
+
134
+ def socket_transport(paths: LocalPaths) -> httpx.HTTPTransport:
135
+ return httpx.HTTPTransport(uds=str(paths.socket))
136
+
137
+
138
+ def socket_health(paths: LocalPaths, *, timeout: float = 0.2) -> bool:
139
+ try:
140
+ with httpx.Client(
141
+ transport=socket_transport(paths),
142
+ base_url="http://labtasker",
143
+ timeout=timeout,
144
+ ) as client:
145
+ response = client.get("/health")
146
+ return response.status_code == 200 and response.json() == {
147
+ "status": "ok",
148
+ "api_version": "2",
149
+ "database": "ok",
150
+ }
151
+ except (httpx.HTTPError, ValueError):
152
+ return False
153
+
154
+
155
+ def _parse_coordinator_result(output: str) -> dict[str, object] | None:
156
+ try:
157
+ payload = json.loads(output)
158
+ except (json.JSONDecodeError, TypeError):
159
+ return None
160
+ if not isinstance(payload, dict):
161
+ return None
162
+ return {str(key): value for key, value in payload.items()}
163
+
164
+
165
+ def _optional_number(payload: dict[str, object] | None, field: str) -> float | None:
166
+ if payload is None:
167
+ return None
168
+ value = payload.get(field)
169
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
170
+ return float(value)
171
+ return None
172
+
173
+
174
+ def _local_transport_error(
175
+ paths: LocalPaths,
176
+ *,
177
+ state: str,
178
+ message: str,
179
+ retry_after_seconds: float | None = None,
180
+ ) -> TransportError:
181
+ details: dict[str, object] = {
182
+ "state": state,
183
+ "directory": str(paths.directory),
184
+ "database": str(paths.database),
185
+ "socket": str(paths.socket),
186
+ "log": str(paths.log),
187
+ }
188
+ if retry_after_seconds is not None:
189
+ details["retry_after_seconds"] = retry_after_seconds
190
+ return TransportError(message, details)