jobdock-sdk 0.2.1rc2__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.
@@ -0,0 +1,23 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ bin/
5
+ dist/
6
+ coverage/
7
+ web/node_modules/
8
+ web/dist/
9
+ *.tsbuildinfo
10
+ sdk/python/.pytest_cache/
11
+ sdk/python/.ruff_cache/
12
+ sdk/python/*.egg-info/
13
+ sdk/python/build/
14
+ sdk/python/dist/
15
+ __pycache__/
16
+ *.py[cod]
17
+ *.db
18
+ *.db-shm
19
+ *.db-wal
20
+ .jobdock/
21
+ .cache/
22
+ .e2e/
23
+ .tmp/
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: jobdock-sdk
3
+ Version: 0.2.1rc2
4
+ Summary: Optional telemetry SDK for jobs running in JobDock
5
+ Author: JobDock contributors
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Requires-Python: >=3.10
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest>=8.0; extra == 'test'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # JobDock Python SDK
14
+
15
+ The SDK adds optional progress, scalar metrics, parameters, structured events, artifact registration, and cooperative cancellation to a JobDock job. It has no runtime dependencies outside the Python standard library.
16
+
17
+ ```python
18
+ from jobdock import current_job
19
+
20
+ job = current_job()
21
+ job.progress(0.5)
22
+ job.metric("loss", 0.42, step=10)
23
+
24
+ # Units and metadata describe the series and stay stable for the attempt.
25
+ job.metric("throughput", 128.4, step=10, unit="samples/s", metadata={"split": "train"})
26
+
27
+ # Write checkpoints atomically beneath JOBDOCK_OUTPUT_DIR, then request a
28
+ # durable, resumable synchronization. The result is True only after the server
29
+ # confirms the complete immutable generation.
30
+ save_checkpoint(job.output_dir / "epoch-10.pt")
31
+ checkpoint_confirmed = job.sync(label="epoch 10", step=10, metadata={"score": 0.91}, timeout=60)
32
+
33
+ if job.should_stop():
34
+ save_checkpoint()
35
+ ```
36
+
37
+ Typed batches preserve observation order and accept explicit timezone-aware timestamps:
38
+
39
+ ```python
40
+ from datetime import datetime, timezone
41
+ from jobdock import Metric, current_job
42
+
43
+ job = current_job()
44
+ job.metrics([
45
+ Metric("train/loss", 0.42, step=10, timestamp=datetime.now(timezone.utc), unit="ratio", metadata={"dataset": "cifar10"}),
46
+ Metric("train/accuracy", 0.91, step=10, unit="ratio", metadata={"dataset": "cifar10"}),
47
+ ])
48
+ ```
49
+
50
+ `unit` and `metadata` are series descriptors for one metric name and attempt. Omitted descriptor fields inherit the existing values; conflicting values are rejected as a whole batch. Use distinct names such as `train/loss` and `validation/loss` for semantically different series.
51
+
52
+ Milestones can describe weighted stages. JobDock calculates global progress while retaining the current segment and upcoming stages independently for each attempt:
53
+
54
+ ```python
55
+ from jobdock import Milestone
56
+
57
+ job.define_milestones([
58
+ Milestone("prepare", weight=0.1),
59
+ Milestone("train", weight=0.8),
60
+ Milestone("evaluate", weight=0.1),
61
+ ])
62
+ job.milestone("prepare")
63
+ job.progress(0.5, milestone="train", step=10)
64
+ ```
65
+
66
+ Confusion matrices remain structured data rather than rendered images. They support an explicit step and timestamp and are bounded to 128 classes and a 1 MiB encoded payload:
67
+
68
+ ```python
69
+ job.confusion_matrix(
70
+ "validation",
71
+ [[48, 2], [3, 47]],
72
+ ["negative", "positive"],
73
+ step=10,
74
+ )
75
+ ```
76
+
77
+ The SDK exports presentation-independent `CheckpointObservation`, `ProgressObservation`, `Milestone`, and `MatrixObservation` contracts. These types contain no chart or React concepts.
78
+
79
+ Outside JobDock, `current_job()` returns a no-op object. Use `current_job(required=True)` when missing execution context should be an error.
80
+
81
+ ## Versioning
82
+
83
+ `jobdock-sdk` uses the JobDock product release tag as its only release-version
84
+ source. A tag such as `v0.3.0` builds Python package version `0.3.0`; SemVer
85
+ prereleases are converted deterministically to PEP 440, for example
86
+ `v0.3.0-rc.1` becomes `0.3.0rc1`. The installed version is available as
87
+ `jobdock.__version__` and is also used in the SDK HTTP user agent.
88
+
89
+ An untagged source build has an explicit `0.0.0.dev0+g<commit>` version and is
90
+ never indistinguishable from a release. Release automation supplies
91
+ `JOBDOCK_RELEASE_TAG` and `JOBDOCK_PRODUCT_VERSION`; inconsistent values fail the
92
+ package build instead of publishing mismatched artifacts.
93
+
94
+ Checkpoint uploads are chunked, acknowledged, and resumed from the server's
95
+ durable offset after a network or agent restart. A partially uploaded generation
96
+ never replaces the last confirmed checkpoint. The latest confirmed generation
97
+ remains downloadable for a `LOST` job from
98
+ `GET /api/v1/jobs/{job_id}/checkpoints/latest.zip`.
@@ -0,0 +1,86 @@
1
+ # JobDock Python SDK
2
+
3
+ The SDK adds optional progress, scalar metrics, parameters, structured events, artifact registration, and cooperative cancellation to a JobDock job. It has no runtime dependencies outside the Python standard library.
4
+
5
+ ```python
6
+ from jobdock import current_job
7
+
8
+ job = current_job()
9
+ job.progress(0.5)
10
+ job.metric("loss", 0.42, step=10)
11
+
12
+ # Units and metadata describe the series and stay stable for the attempt.
13
+ job.metric("throughput", 128.4, step=10, unit="samples/s", metadata={"split": "train"})
14
+
15
+ # Write checkpoints atomically beneath JOBDOCK_OUTPUT_DIR, then request a
16
+ # durable, resumable synchronization. The result is True only after the server
17
+ # confirms the complete immutable generation.
18
+ save_checkpoint(job.output_dir / "epoch-10.pt")
19
+ checkpoint_confirmed = job.sync(label="epoch 10", step=10, metadata={"score": 0.91}, timeout=60)
20
+
21
+ if job.should_stop():
22
+ save_checkpoint()
23
+ ```
24
+
25
+ Typed batches preserve observation order and accept explicit timezone-aware timestamps:
26
+
27
+ ```python
28
+ from datetime import datetime, timezone
29
+ from jobdock import Metric, current_job
30
+
31
+ job = current_job()
32
+ job.metrics([
33
+ Metric("train/loss", 0.42, step=10, timestamp=datetime.now(timezone.utc), unit="ratio", metadata={"dataset": "cifar10"}),
34
+ Metric("train/accuracy", 0.91, step=10, unit="ratio", metadata={"dataset": "cifar10"}),
35
+ ])
36
+ ```
37
+
38
+ `unit` and `metadata` are series descriptors for one metric name and attempt. Omitted descriptor fields inherit the existing values; conflicting values are rejected as a whole batch. Use distinct names such as `train/loss` and `validation/loss` for semantically different series.
39
+
40
+ Milestones can describe weighted stages. JobDock calculates global progress while retaining the current segment and upcoming stages independently for each attempt:
41
+
42
+ ```python
43
+ from jobdock import Milestone
44
+
45
+ job.define_milestones([
46
+ Milestone("prepare", weight=0.1),
47
+ Milestone("train", weight=0.8),
48
+ Milestone("evaluate", weight=0.1),
49
+ ])
50
+ job.milestone("prepare")
51
+ job.progress(0.5, milestone="train", step=10)
52
+ ```
53
+
54
+ Confusion matrices remain structured data rather than rendered images. They support an explicit step and timestamp and are bounded to 128 classes and a 1 MiB encoded payload:
55
+
56
+ ```python
57
+ job.confusion_matrix(
58
+ "validation",
59
+ [[48, 2], [3, 47]],
60
+ ["negative", "positive"],
61
+ step=10,
62
+ )
63
+ ```
64
+
65
+ The SDK exports presentation-independent `CheckpointObservation`, `ProgressObservation`, `Milestone`, and `MatrixObservation` contracts. These types contain no chart or React concepts.
66
+
67
+ Outside JobDock, `current_job()` returns a no-op object. Use `current_job(required=True)` when missing execution context should be an error.
68
+
69
+ ## Versioning
70
+
71
+ `jobdock-sdk` uses the JobDock product release tag as its only release-version
72
+ source. A tag such as `v0.3.0` builds Python package version `0.3.0`; SemVer
73
+ prereleases are converted deterministically to PEP 440, for example
74
+ `v0.3.0-rc.1` becomes `0.3.0rc1`. The installed version is available as
75
+ `jobdock.__version__` and is also used in the SDK HTTP user agent.
76
+
77
+ An untagged source build has an explicit `0.0.0.dev0+g<commit>` version and is
78
+ never indistinguishable from a release. Release automation supplies
79
+ `JOBDOCK_RELEASE_TAG` and `JOBDOCK_PRODUCT_VERSION`; inconsistent values fail the
80
+ package build instead of publishing mismatched artifacts.
81
+
82
+ Checkpoint uploads are chunked, acknowledged, and resumed from the server's
83
+ durable offset after a network or agent restart. A partially uploaded generation
84
+ never replaces the last confirmed checkpoint. The latest confirmed generation
85
+ remains downloadable for a `LOST` job from
86
+ `GET /api/v1/jobs/{job_id}/checkpoints/latest.zip`.
@@ -0,0 +1,12 @@
1
+ """Hatch metadata hook for the product-derived SDK version."""
2
+
3
+ from pathlib import Path
4
+ from runpy import run_path
5
+
6
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
7
+
8
+
9
+ class CustomMetadataHook(MetadataHookInterface):
10
+ def update(self, metadata: dict) -> None:
11
+ versioning = run_path(str(Path(__file__).with_name("versioning.py")), run_name="jobdock_sdk_versioning")
12
+ metadata["version"] = versioning["resolve_build_version"]()
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "jobdock-sdk"
7
+ dynamic = ["version"]
8
+ description = "Optional telemetry SDK for jobs running in JobDock"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{name = "JobDock contributors"}]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ ]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/jobdock"]
19
+ core-metadata-version = "2.4"
20
+
21
+ [tool.hatch.build.targets.sdist]
22
+ core-metadata-version = "2.4"
23
+
24
+ [tool.hatch.metadata.hooks.custom]
25
+
26
+ [tool.pytest.ini_options]
27
+ pythonpath = ["src"]
28
+ testpaths = ["tests"]
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=8.0"]
@@ -0,0 +1,14 @@
1
+ """Public JobDock telemetry API."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("jobdock-sdk")
7
+ except PackageNotFoundError:
8
+ # Running directly from a source checkout is always a development build.
9
+ __version__ = "0.0.0.dev0"
10
+
11
+ from .client import Job, NoopJob, current_job
12
+ from .observability import CheckpointObservation, JSONValue, MatrixObservation, Metric, Milestone, ProgressObservation
13
+
14
+ __all__ = ["CheckpointObservation", "JSONValue", "Job", "MatrixObservation", "Metric", "Milestone", "NoopJob", "ProgressObservation", "__version__", "current_job"]
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ import queue
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Iterable, Mapping
17
+
18
+ from .observability import CheckpointObservation, JSONValue, MatrixObservation, Metric, Milestone, ProgressObservation
19
+ from . import __version__
20
+
21
+ logger = logging.getLogger("jobdock")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class _Message:
26
+ endpoint: str
27
+ payload: dict[str, Any]
28
+
29
+
30
+ class NoopJob:
31
+ """Job context used when code runs outside JobDock."""
32
+
33
+ id: str | None = None
34
+ output_dir: Path = Path.cwd()
35
+
36
+ def progress(self, value: float, *, milestone: str | None = None, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None: pass
37
+ def define_milestones(self, items: Iterable[Milestone]) -> None: pass
38
+ def milestone(self, name: str, *, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None: pass
39
+ def matrix(self, observation: MatrixObservation) -> None: pass
40
+ def confusion_matrix(self, name: str, values: Iterable[Iterable[float]], labels: Iterable[str], *, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None: pass
41
+ def metric(self, name: str, value: float, step: int | None = None, *, timestamp: datetime | None = None, unit: str | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None: pass
42
+ def metrics(self, items: Iterable[Metric]) -> None: pass
43
+ def param(self, name: str, value: str | int | float | bool) -> None: pass
44
+ def event(self, event_type: str, payload: dict[str, Any] | None = None) -> None: pass
45
+ def artifact(self, relative_path: str | os.PathLike[str]) -> Path: return Path(relative_path)
46
+ def sync(self, timeout: float = 30.0, *, label: str | None = None, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> bool: return False
47
+ def should_stop(self) -> bool: return False
48
+ def close(self, timeout: float = 0.0) -> None: pass
49
+
50
+
51
+ class Job:
52
+ """Non-blocking telemetry client for the current JobDock execution."""
53
+
54
+ def __init__(self, job_id: str, api_url: str, token: str, output_dir: Path, *, queue_size: int = 1024) -> None:
55
+ self.id = job_id
56
+ self.api_url = api_url.rstrip("/")
57
+ self.output_dir = output_dir.resolve()
58
+ self._token = token
59
+ self._queue: queue.Queue[_Message | None] = queue.Queue(maxsize=queue_size)
60
+ self._closed = threading.Event()
61
+ self._stop_cache = (0.0, False)
62
+ self._worker = threading.Thread(target=self._run, name="jobdock-telemetry", daemon=True)
63
+ self._worker.start()
64
+ atexit.register(self.close)
65
+
66
+ def progress(self, value: float, *, milestone: str | None = None, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None:
67
+ if not 0.0 <= value <= 1.0:
68
+ raise ValueError("progress must be between 0.0 and 1.0")
69
+ if milestone is not None and (not milestone.strip() or len(milestone.strip()) > 128):
70
+ raise ValueError("milestone name must contain 1-128 characters")
71
+ milestone = milestone.strip() if milestone is not None else None
72
+ observation = ProgressObservation(float(value), milestone, step, timestamp, metadata)
73
+ self._enqueue("progress", _observation_payload(observation, value=float(value), milestone=milestone))
74
+
75
+ def define_milestones(self, items: Iterable[Milestone]) -> None:
76
+ payload = []
77
+ seen: set[str] = set()
78
+ for item in items:
79
+ name = item.name.strip()
80
+ if not name or len(name) > 128 or name in seen or item.weight is not None and (item.weight <= 0 or not math.isfinite(item.weight)):
81
+ raise ValueError("milestone requires a name and an optional positive finite weight")
82
+ seen.add(name)
83
+ entry: dict[str, Any] = {"name": name}
84
+ if item.weight is not None: entry["weight"] = item.weight
85
+ metadata = _validated_metadata(item.metadata)
86
+ if metadata is not None: entry["metadata"] = metadata
87
+ payload.append(entry)
88
+ if not payload or len(payload) > 128: raise ValueError("milestones must contain 1-128 unique items")
89
+ self._enqueue("milestones", {"items": payload})
90
+
91
+ def milestone(self, name: str, *, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None:
92
+ if not name.strip() or len(name.strip()) > 128: raise ValueError("milestone name must contain 1-128 characters")
93
+ observation = ProgressObservation(0, name.strip(), step, timestamp, metadata)
94
+ self._enqueue("milestones/reached", _observation_payload(observation, milestone=name.strip()))
95
+
96
+ def matrix(self, observation: MatrixObservation) -> None:
97
+ values = [[float(value) for value in row] for row in observation.values]
98
+ labels = list(observation.labels)
99
+ size = len(values)
100
+ if not observation.name.strip() or len(observation.name.strip()) > 128 or size == 0 or size > 128 or len(labels) != size or any(len(row) != size for row in values) or any(not label or len(label) > 128 for label in labels):
101
+ raise ValueError("matrix requires a name, NxN values, and one label per dimension")
102
+ if any(not math.isfinite(value) for row in values for value in row): raise ValueError("matrix values must be finite")
103
+ payload = _observation_payload(observation, name=observation.name.strip(), values=values, labels=labels)
104
+ if len(json.dumps(payload, separators=(",", ":")).encode()) > 1 << 20: raise ValueError("matrix payload must not exceed 1 MiB")
105
+ self._enqueue("matrices", payload)
106
+
107
+ def confusion_matrix(self, name: str, values: Iterable[Iterable[float]], labels: Iterable[str], *, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None:
108
+ self.matrix(MatrixObservation(name, [list(row) for row in values], list(labels), step, timestamp, metadata))
109
+
110
+ def metric(self, name: str, value: float, step: int | None = None, *, timestamp: datetime | None = None, unit: str | None = None, metadata: Mapping[str, JSONValue] | None = None) -> None:
111
+ """Report one scalar metric while preserving the original call shape."""
112
+ self.metrics([Metric(name, value, step, timestamp, unit, metadata)])
113
+
114
+ def metrics(self, items: Iterable[Metric]) -> None:
115
+ """Report typed scalar metrics as one ordered, non-blocking batch."""
116
+ payload = [_metric_payload(item) for item in items]
117
+ if payload:
118
+ self._enqueue("metrics", {"items": payload})
119
+
120
+ def param(self, name: str, value: str | int | float | bool) -> None:
121
+ if not name or not isinstance(value, (str, int, float, bool)):
122
+ raise ValueError("parameter requires a name and scalar value")
123
+ self._enqueue("params", {"items": [{"name": name, "value": value}]})
124
+
125
+ def event(self, event_type: str, payload: dict[str, Any] | None = None) -> None:
126
+ if not event_type:
127
+ raise ValueError("event type is required")
128
+ self._enqueue("events", {"type": event_type, "payload": payload or {}})
129
+
130
+ def artifact(self, relative_path: str | os.PathLike[str]) -> Path:
131
+ raw_candidate = self.output_dir / relative_path
132
+ if raw_candidate.is_symlink():
133
+ raise ValueError("artifact cannot be a symbolic link")
134
+ candidate = raw_candidate.resolve()
135
+ try:
136
+ candidate.relative_to(self.output_dir)
137
+ except ValueError as exc:
138
+ raise ValueError("artifact must be inside JOBDOCK_OUTPUT_DIR") from exc
139
+ if not candidate.exists():
140
+ raise ValueError("artifact must exist")
141
+ self.event("artifact_registered", {"path": candidate.relative_to(self.output_dir).as_posix()})
142
+ return candidate
143
+
144
+ def should_stop(self) -> bool:
145
+ checked, cached = self._stop_cache
146
+ if time.monotonic() - checked < 2.0:
147
+ return cached
148
+ try:
149
+ response = self._request("GET", "stop", None, timeout=1.0)
150
+ cached = bool(response.get("should_stop", False))
151
+ self._stop_cache = (time.monotonic(), cached)
152
+ except Exception:
153
+ logger.debug("Unable to query cooperative cancellation", exc_info=True)
154
+ return cached
155
+
156
+ def sync(self, timeout: float = 30.0, *, label: str | None = None, step: int | None = None, timestamp: datetime | None = None, metadata: Mapping[str, JSONValue] | None = None) -> bool:
157
+ """Durably synchronize the current output directory as a checkpoint.
158
+
159
+ Files should be written with an atomic rename before calling this method.
160
+ The call is bounded and returns only after the server has confirmed the
161
+ complete immutable generation. A later failed sync cannot replace it.
162
+ """
163
+ if timeout <= 0:
164
+ raise ValueError("checkpoint sync timeout must be positive")
165
+ if label is not None and len(label.strip()) > 128:
166
+ raise ValueError("checkpoint label must contain at most 128 characters")
167
+ label = label.strip() if label is not None else None
168
+ deadline = time.monotonic() + timeout
169
+ try:
170
+ observation = CheckpointObservation(label, step, timestamp, metadata)
171
+ created = self._request("POST", "checkpoints", _observation_payload(observation, label=label), timeout=min(5.0, timeout))
172
+ sync_id = str(created["id"])
173
+ while time.monotonic() < deadline:
174
+ remaining = deadline - time.monotonic()
175
+ status = self._request("GET", f"checkpoints/{sync_id}", None, timeout=min(2.0, max(0.1, remaining)))
176
+ if status.get("status") == "CONFIRMED":
177
+ return True
178
+ time.sleep(min(0.5, max(0.0, deadline - time.monotonic())))
179
+ except Exception:
180
+ logger.warning("JobDock checkpoint sync was not confirmed", exc_info=True)
181
+ return False
182
+ logger.warning("JobDock checkpoint sync timed out after %.1f seconds", timeout)
183
+ return False
184
+
185
+ def close(self, timeout: float = 2.0) -> None:
186
+ if self._closed.is_set():
187
+ return
188
+ self._closed.set()
189
+ try:
190
+ self._queue.put_nowait(None)
191
+ except queue.Full:
192
+ pass
193
+ self._worker.join(timeout=max(0.0, timeout))
194
+
195
+ def _enqueue(self, endpoint: str, payload: dict[str, Any]) -> None:
196
+ if self._closed.is_set():
197
+ return
198
+ try:
199
+ self._queue.put_nowait(_Message(endpoint, payload))
200
+ except queue.Full:
201
+ logger.warning("JobDock telemetry queue is full; dropping %s update", endpoint)
202
+
203
+ def _run(self) -> None:
204
+ while not self._closed.is_set() or not self._queue.empty():
205
+ try:
206
+ message = self._queue.get(timeout=0.2)
207
+ except queue.Empty:
208
+ continue
209
+ if message is None:
210
+ continue
211
+ batch = [message]
212
+ while len(batch) < 64:
213
+ try:
214
+ next_message = self._queue.get_nowait()
215
+ except queue.Empty:
216
+ break
217
+ if next_message is None:
218
+ continue
219
+ if next_message.endpoint != message.endpoint:
220
+ self._queue.put_nowait(next_message)
221
+ break
222
+ batch.append(next_message)
223
+ payload = message.payload
224
+ if message.endpoint in {"metrics", "params"} and len(batch) > 1:
225
+ payload = {"items": [item for entry in batch for item in entry.payload["items"]]}
226
+ self._send_with_retry(message.endpoint, payload)
227
+
228
+ def _send_with_retry(self, endpoint: str, payload: dict[str, Any]) -> None:
229
+ for delay in (0.0, 0.25, 1.0, 2.0):
230
+ if delay:
231
+ time.sleep(delay)
232
+ try:
233
+ self._request("POST", endpoint, payload, timeout=2.0)
234
+ return
235
+ except Exception:
236
+ logger.debug("JobDock telemetry delivery failed", exc_info=True)
237
+ logger.warning("Dropping JobDock %s telemetry after bounded retries", endpoint)
238
+
239
+ def _request(self, method: str, endpoint: str, payload: dict[str, Any] | None, *, timeout: float) -> dict[str, Any]:
240
+ body = None if payload is None else json.dumps(payload, separators=(",", ":")).encode()
241
+ request = urllib.request.Request(
242
+ f"{self.api_url}/api/v1/job-context/{endpoint}",
243
+ data=body,
244
+ method=method,
245
+ headers={"Authorization": f"Bearer {self._token}", "Content-Type": "application/json", "User-Agent": f"jobdock-sdk/{__version__}"},
246
+ )
247
+ try:
248
+ with urllib.request.urlopen(request, timeout=timeout) as response:
249
+ data = response.read()
250
+ except urllib.error.HTTPError as exc:
251
+ raise RuntimeError(f"JobDock API returned HTTP {exc.code}") from exc
252
+ return json.loads(data) if data else {}
253
+
254
+
255
+ def current_job(*, required: bool = False) -> Job | NoopJob:
256
+ job_id = os.getenv("JOBDOCK_JOB_ID")
257
+ api_url = os.getenv("JOBDOCK_API_URL")
258
+ token_file = os.getenv("JOBDOCK_JOB_TOKEN_FILE")
259
+ output_dir = os.getenv("JOBDOCK_OUTPUT_DIR")
260
+ if not all((job_id, api_url, token_file, output_dir)):
261
+ if required:
262
+ raise RuntimeError("JobDock execution context is incomplete")
263
+ return NoopJob()
264
+ try:
265
+ token = Path(token_file).read_text(encoding="utf-8").strip()
266
+ except OSError as exc:
267
+ if required:
268
+ raise RuntimeError("Unable to read JOBDOCK_JOB_TOKEN_FILE") from exc
269
+ return NoopJob()
270
+ return Job(job_id, api_url, token, Path(output_dir))
271
+
272
+
273
+ def _metric_payload(metric: Metric) -> dict[str, Any]:
274
+ name = metric.name.strip()
275
+ if not name or len(name) > 128:
276
+ raise ValueError("metric name must contain between 1 and 128 characters")
277
+ value = float(metric.value)
278
+ if not math.isfinite(value):
279
+ raise ValueError("metric value must be finite")
280
+ if metric.step is not None and (isinstance(metric.step, bool) or not isinstance(metric.step, int)):
281
+ raise ValueError("metric step must be an integer")
282
+ unit = metric.unit.strip() if metric.unit is not None else None
283
+ if unit is not None and (not unit or len(unit) > 64):
284
+ raise ValueError("metric unit must contain between 1 and 64 characters")
285
+ timestamp = metric.timestamp or datetime.now(timezone.utc)
286
+ if timestamp.tzinfo is None or timestamp.utcoffset() is None:
287
+ raise ValueError("metric timestamp must be timezone-aware")
288
+ metadata = _validated_metadata(metric.metadata)
289
+ item: dict[str, Any] = {"name": name, "value": value, "timestamp": timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")}
290
+ if metric.step is not None:
291
+ item["step"] = metric.step
292
+ if unit is not None:
293
+ item["unit"] = unit
294
+ if metadata is not None:
295
+ item["metadata"] = metadata
296
+ return item
297
+
298
+
299
+ def _validated_metadata(metadata: Mapping[str, JSONValue] | None) -> dict[str, JSONValue] | None:
300
+ if metadata is None:
301
+ return None
302
+ normalized = dict(metadata)
303
+ keys = [0]
304
+ _validate_json_value(normalized, 1, keys)
305
+ encoded = json.dumps(normalized, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
306
+ if len(encoded) > 16 << 10:
307
+ raise ValueError("metric metadata must not exceed 16 KiB")
308
+ return normalized
309
+
310
+
311
+ def _validate_json_value(value: JSONValue, depth: int, keys: list[int]) -> None:
312
+ if depth > 4:
313
+ raise ValueError("metric metadata nesting must not exceed four levels")
314
+ if isinstance(value, dict):
315
+ for key, child in value.items():
316
+ keys[0] += 1
317
+ if keys[0] > 64:
318
+ raise ValueError("metric metadata must contain at most 64 keys")
319
+ if not isinstance(key, str) or not key or len(key) > 128:
320
+ raise ValueError("metric metadata keys must contain 1-128 characters")
321
+ _validate_json_value(child, depth + 1, keys)
322
+ elif isinstance(value, list):
323
+ if len(value) > 64:
324
+ raise ValueError("metric metadata arrays must contain at most 64 items")
325
+ for child in value:
326
+ _validate_json_value(child, depth + 1, keys)
327
+ elif isinstance(value, str):
328
+ if len(value) > 1024:
329
+ raise ValueError("metric metadata strings must contain at most 1024 characters")
330
+ elif isinstance(value, bool) or value is None:
331
+ return
332
+ elif isinstance(value, (int, float)):
333
+ if not math.isfinite(float(value)):
334
+ raise ValueError("metric metadata numbers must be finite")
335
+ else:
336
+ raise ValueError("metric metadata contains an unsupported JSON value")
337
+
338
+
339
+ def _observation_payload(observation: Any, **fields: Any) -> dict[str, Any]:
340
+ payload = {key: value for key, value in fields.items() if value is not None}
341
+ if observation.step is not None: payload["step"] = observation.step
342
+ timestamp = observation.timestamp or datetime.now(timezone.utc)
343
+ if timestamp.tzinfo is None or timestamp.utcoffset() is None: raise ValueError("observation timestamp must be timezone-aware")
344
+ payload["timestamp"] = timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
345
+ metadata = _validated_metadata(observation.metadata)
346
+ if metadata is not None: payload["metadata"] = metadata
347
+ return payload
@@ -0,0 +1,55 @@
1
+ """Presentation-agnostic observability contracts for JobDock jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Mapping, Sequence, TypeAlias
8
+
9
+ JSONScalar: TypeAlias = str | int | float | bool | None
10
+ JSONValue: TypeAlias = JSONScalar | list["JSONValue"] | dict[str, "JSONValue"]
11
+ Metadata: TypeAlias = Mapping[str, JSONValue]
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Metric:
16
+ name: str
17
+ value: float
18
+ step: int | None = None
19
+ timestamp: datetime | None = None
20
+ unit: str | None = None
21
+ metadata: Metadata | None = None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class CheckpointObservation:
26
+ label: str | None = None
27
+ step: int | None = None
28
+ timestamp: datetime | None = None
29
+ metadata: Metadata | None = None
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Milestone:
34
+ name: str
35
+ weight: float | None = None
36
+ metadata: Metadata | None = None
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ProgressObservation:
41
+ value: float
42
+ milestone: str | None = None
43
+ step: int | None = None
44
+ timestamp: datetime | None = None
45
+ metadata: Metadata | None = None
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class MatrixObservation:
50
+ name: str
51
+ values: Sequence[Sequence[float]]
52
+ labels: Sequence[str] = ()
53
+ step: int | None = None
54
+ timestamp: datetime | None = None
55
+ metadata: Metadata | None = None
@@ -0,0 +1,136 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from jobdock import CheckpointObservation, Job, MatrixObservation, Metric, Milestone, NoopJob, ProgressObservation, current_job
7
+
8
+
9
+ def test_current_job_is_noop_without_environment(monkeypatch):
10
+ for name in ("JOBDOCK_JOB_ID", "JOBDOCK_API_URL", "JOBDOCK_JOB_TOKEN_FILE", "JOBDOCK_OUTPUT_DIR"):
11
+ monkeypatch.delenv(name, raising=False)
12
+ assert isinstance(current_job(), NoopJob)
13
+ with pytest.raises(RuntimeError):
14
+ current_job(required=True)
15
+
16
+
17
+ def test_progress_validation(tmp_path: Path):
18
+ job = Job("id", "http://127.0.0.1:1", "token", tmp_path)
19
+ with pytest.raises(ValueError):
20
+ job.progress(1.1)
21
+ job.close()
22
+
23
+
24
+ def test_enriched_metrics_are_typed_ordered_and_backwards_compatible(tmp_path: Path, monkeypatch):
25
+ job = Job("id", "http://jobdock.test", "token", tmp_path)
26
+ queued = []
27
+ monkeypatch.setattr(job, "_enqueue", lambda endpoint, payload: queued.append((endpoint, payload)))
28
+ job.metric("legacy", 1.0, 3)
29
+ observed = datetime(2026, 8, 13, 10, 30, tzinfo=timezone(timedelta(hours=2)))
30
+ job.metrics([
31
+ Metric("loss", .4, step=4, timestamp=observed, unit="ratio", metadata={"split": "train"}),
32
+ Metric("accuracy", .9, step=4, unit="ratio"),
33
+ ])
34
+ assert [item["name"] for item in queued[1][1]["items"]] == ["loss", "accuracy"]
35
+ assert queued[0][1]["items"][0]["step"] == 3
36
+ assert queued[1][1]["items"][0] == {
37
+ "name": "loss", "value": .4, "step": 4, "timestamp": "2026-08-13T08:30:00Z",
38
+ "unit": "ratio", "metadata": {"split": "train"},
39
+ }
40
+ job.close()
41
+
42
+
43
+ def test_metric_validation_rejects_unsafe_observations(tmp_path: Path):
44
+ job = Job("id", "http://jobdock.test", "token", tmp_path)
45
+ with pytest.raises(ValueError, match="timezone-aware"):
46
+ job.metrics([Metric("loss", 1, timestamp=datetime(2026, 1, 1))])
47
+ with pytest.raises(ValueError, match="finite"):
48
+ job.metric("loss", float("nan"))
49
+ with pytest.raises(ValueError, match="four levels"):
50
+ job.metric("loss", 1, metadata={"a": {"b": {"c": {"d": "too deep"}}}})
51
+ with pytest.raises(ValueError, match="16 KiB|1024"):
52
+ job.metric("loss", 1, metadata={"value": "x" * 17000})
53
+ job.close()
54
+
55
+
56
+ def test_noop_accepts_enriched_contracts_without_consuming_iterables():
57
+ noop = NoopJob()
58
+ consumed = False
59
+ def observations():
60
+ nonlocal consumed
61
+ consumed = True
62
+ yield Metric("loss", 1)
63
+ noop.metric("loss", 1, timestamp=datetime.now(timezone.utc), unit="ratio", metadata={"split": "train"})
64
+ noop.metrics(observations())
65
+ assert consumed is False
66
+ assert CheckpointObservation(label="best").label == "best"
67
+ assert ProgressObservation(.5, milestone="train").milestone == "train"
68
+ assert Milestone("train", weight=1).weight == 1
69
+ assert MatrixObservation("confusion", [[1, 0], [0, 1]], ["a", "b"]).labels == ["a", "b"]
70
+
71
+
72
+ def test_artifact_cannot_escape_output(tmp_path: Path):
73
+ output = tmp_path / "output"
74
+ output.mkdir()
75
+ outside = tmp_path / "outside.txt"
76
+ outside.write_text("unsafe")
77
+ job = Job("id", "http://127.0.0.1:1", "token", output)
78
+ with pytest.raises(ValueError):
79
+ job.artifact("../outside.txt")
80
+ job.close()
81
+
82
+
83
+ def test_explicit_checkpoint_sync_waits_for_confirmation(tmp_path: Path, monkeypatch):
84
+ job = Job("id", "http://jobdock.test", "token", tmp_path)
85
+ responses = iter([{"id": "sync-1"}, {"status": "PENDING"}, {"status": "CONFIRMED"}])
86
+ calls = []
87
+
88
+ def request(method, endpoint, payload, *, timeout):
89
+ calls.append((method, endpoint, payload))
90
+ return next(responses)
91
+
92
+ monkeypatch.setattr(job, "_request", request)
93
+ monkeypatch.setattr("jobdock.client.time.sleep", lambda _: None)
94
+ assert job.sync(timeout=1.0) is True
95
+ assert calls[0][0:2] == ("POST", "checkpoints")
96
+ assert calls[0][2]["timestamp"].endswith("Z")
97
+ assert calls[1:] == [("GET", "checkpoints/sync-1", None), ("GET", "checkpoints/sync-1", None)]
98
+ job.close()
99
+
100
+
101
+ def test_progress_milestones_matrices_and_checkpoint_context(tmp_path: Path, monkeypatch):
102
+ job = Job("id", "http://jobdock.test", "token", tmp_path)
103
+ queued = []
104
+ monkeypatch.setattr(job, "_enqueue", lambda endpoint, payload: queued.append((endpoint, payload)))
105
+ observed = datetime(2026, 8, 13, 9, 15, tzinfo=timezone.utc)
106
+ job.define_milestones([Milestone("prepare", .2), Milestone("train", .8, {"owner": "ml"})])
107
+ job.milestone("prepare", step=1, timestamp=observed)
108
+ job.progress(.5, milestone="train", step=5, timestamp=observed, metadata={"epoch": 1})
109
+ job.confusion_matrix("validation", [[8, 2], [1, 9]], ["cat", "dog"], step=5, timestamp=observed)
110
+ assert queued[0] == ("milestones", {"items": [{"name": "prepare", "weight": .2}, {"name": "train", "weight": .8, "metadata": {"owner": "ml"}}]})
111
+ assert queued[1][0] == "milestones/reached" and queued[1][1]["milestone"] == "prepare"
112
+ assert queued[2][0] == "progress" and queued[2][1]["value"] == .5 and queued[2][1]["milestone"] == "train"
113
+ assert queued[3][0] == "matrices" and queued[3][1]["values"] == [[8.0, 2.0], [1.0, 9.0]]
114
+
115
+ responses = iter([{"id": "sync-rich"}, {"status": "CONFIRMED"}])
116
+ calls = []
117
+ def request(method, endpoint, payload, *, timeout):
118
+ calls.append((method, endpoint, payload))
119
+ return next(responses)
120
+ monkeypatch.setattr(job, "_request", request)
121
+ assert job.sync(label="best", step=5, timestamp=observed, metadata={"score": .9})
122
+ assert calls[0][2] == {"label": "best", "step": 5, "timestamp": "2026-08-13T09:15:00Z", "metadata": {"score": .9}}
123
+ job.close()
124
+
125
+
126
+ def test_rich_observation_validation_is_bounded(tmp_path: Path):
127
+ job = Job("id", "http://jobdock.test", "token", tmp_path)
128
+ with pytest.raises(ValueError, match="positive finite"):
129
+ job.define_milestones([Milestone("train", 0)])
130
+ with pytest.raises(ValueError, match="NxN"):
131
+ job.confusion_matrix("broken", [[1, 2]], ["cat"])
132
+ with pytest.raises(ValueError, match="finite"):
133
+ job.confusion_matrix("broken", [[float("inf")]], ["cat"])
134
+ with pytest.raises(ValueError, match="timezone-aware"):
135
+ job.progress(.5, timestamp=datetime(2026, 1, 1))
136
+ job.close()
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from versioning import VersionError, resolve_build_version, semver_tag_to_pep440
11
+
12
+ SDK_ROOT = Path(__file__).resolve().parents[1]
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ ("tag", "expected"),
17
+ [
18
+ ("v0.3.0", "0.3.0"),
19
+ ("v0.3.0-rc.1", "0.3.0rc1"),
20
+ ("v1.2.3-beta.2", "1.2.3b2"),
21
+ ("v1.2.3-preview.4", "1.2.3.dev4+preview"),
22
+ ("v1.2.3-7", "1.2.3.dev7"),
23
+ ],
24
+ )
25
+ def test_semver_tag_translates_deterministically_to_pep440(tag: str, expected: str) -> None:
26
+ assert semver_tag_to_pep440(tag) == expected
27
+
28
+
29
+ def test_release_inputs_must_describe_the_same_product_version(tmp_path: Path) -> None:
30
+ with pytest.raises(VersionError, match="does not match release tag"):
31
+ resolve_build_version(tmp_path, {"JOBDOCK_RELEASE_TAG": "v0.3.0", "JOBDOCK_PRODUCT_VERSION": "0.4.0"})
32
+ with pytest.raises(VersionError, match="does not match"):
33
+ resolve_build_version(tmp_path, {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.3.0", "JOBDOCK_RELEASE_TAG": "v0.4.0"})
34
+ with pytest.raises(VersionError, match="SDK version"):
35
+ resolve_build_version(tmp_path, {"JOBDOCK_RELEASE_TAG": "v0.3.0-rc.1", "JOBDOCK_SDK_VERSION": "0.3.0"})
36
+
37
+
38
+ def test_non_tag_build_has_explicit_development_version(tmp_path: Path) -> None:
39
+ assert resolve_build_version(tmp_path, {}).startswith("0.0.0.dev0")
40
+
41
+
42
+ def test_built_wheel_metadata_and_public_version_match_release_tag(tmp_path: Path) -> None:
43
+ wheel_dir = tmp_path / "wheel"
44
+ env = {
45
+ **os.environ,
46
+ "GITHUB_REF_TYPE": "tag",
47
+ "GITHUB_REF_NAME": "v0.3.0-rc.1",
48
+ "JOBDOCK_RELEASE_TAG": "v0.3.0-rc.1",
49
+ "JOBDOCK_PRODUCT_VERSION": "0.3.0-rc.1",
50
+ }
51
+ subprocess.run(
52
+ [sys.executable, "-m", "pip", "wheel", ".", "--no-deps", "--wheel-dir", str(wheel_dir)],
53
+ cwd=SDK_ROOT,
54
+ env=env,
55
+ check=True,
56
+ capture_output=True,
57
+ text=True,
58
+ )
59
+ wheel = next(wheel_dir.glob("jobdock_sdk-0.3.0rc1-*.whl"))
60
+ target = tmp_path / "installed"
61
+ subprocess.run([sys.executable, "-m", "pip", "install", "--no-deps", "--target", str(target), str(wheel)], check=True, capture_output=True, text=True)
62
+ output = subprocess.check_output(
63
+ [sys.executable, "-c", "import jobdock; print(jobdock.__version__)"],
64
+ env={**os.environ, "PYTHONPATH": str(target)},
65
+ text=True,
66
+ ).strip()
67
+ assert output == "0.3.0rc1"
@@ -0,0 +1,95 @@
1
+ """Derive the Python SDK version from the JobDock product release tag."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ SEMVER_TAG = re.compile(
11
+ r"^v(?P<major>0|[1-9][0-9]*)\.(?P<minor>0|[1-9][0-9]*)\.(?P<patch>0|[1-9][0-9]*)"
12
+ r"(?:-(?P<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
13
+ )
14
+ KNOWN_PRERELEASES = {"a": "a", "alpha": "a", "b": "b", "beta": "b", "rc": "rc"}
15
+
16
+
17
+ class VersionError(ValueError):
18
+ """Raised when release version inputs are invalid or inconsistent."""
19
+
20
+
21
+ def semver_tag_to_pep440(tag: str) -> str:
22
+ match = SEMVER_TAG.fullmatch(tag.strip())
23
+ if not match:
24
+ raise VersionError("release tag must be SemVer, for example v0.3.0 or v0.3.0-rc.1")
25
+ base = ".".join(match.group(name) for name in ("major", "minor", "patch"))
26
+ prerelease = match.group("prerelease")
27
+ if not prerelease:
28
+ return base
29
+ identifiers = prerelease.split(".")
30
+ for identifier in identifiers:
31
+ if identifier.isdigit() and len(identifier) > 1 and identifier.startswith("0"):
32
+ raise VersionError("numeric prerelease identifiers must not contain leading zeroes")
33
+ label = identifiers[0].lower()
34
+ number = int(identifiers[1]) if len(identifiers) > 1 and identifiers[1].isdigit() else 0
35
+ remainder = identifiers[2:] if len(identifiers) > 1 and identifiers[1].isdigit() else identifiers[1:]
36
+ if label in KNOWN_PRERELEASES:
37
+ version = f"{base}{KNOWN_PRERELEASES[label]}{number}"
38
+ return version + _local_suffix(remainder)
39
+ if label.isdigit():
40
+ return f"{base}.dev{int(label)}" + _local_suffix(identifiers[1:])
41
+ return f"{base}.dev{number}" + _local_suffix([label, *remainder])
42
+
43
+
44
+ def resolve_build_version(root: Path | None = None, environ: dict[str, str] | None = None) -> str:
45
+ env = os.environ if environ is None else environ
46
+ tag = env.get("JOBDOCK_RELEASE_TAG", "").strip()
47
+ github_ref_type = env.get("GITHUB_REF_TYPE", "").strip()
48
+ github_ref_name = env.get("GITHUB_REF_NAME", "").strip()
49
+ if github_ref_type == "tag":
50
+ if tag and tag != github_ref_name:
51
+ raise VersionError(f"JOBDOCK_RELEASE_TAG {tag!r} does not match GitHub tag {github_ref_name!r}")
52
+ tag = github_ref_name
53
+ elif github_ref_type and tag:
54
+ raise VersionError("a release version cannot be built from a non-tag GitHub ref")
55
+ if not tag:
56
+ tag = _exact_git_tag(root or Path(__file__).resolve().parents[2])
57
+ if tag:
58
+ python_version = semver_tag_to_pep440(tag)
59
+ product_version = env.get("JOBDOCK_PRODUCT_VERSION", "").strip()
60
+ if product_version and product_version != tag.removeprefix("v"):
61
+ raise VersionError(f"product version {product_version!r} does not match release tag {tag!r}")
62
+ expected = env.get("JOBDOCK_SDK_VERSION", "").strip()
63
+ if expected and expected != python_version:
64
+ raise VersionError(f"SDK version {expected!r} does not match {tag!r} ({python_version})")
65
+ return python_version
66
+ return _development_version(root or Path(__file__).resolve().parents[2])
67
+
68
+
69
+ def _exact_git_tag(root: Path) -> str:
70
+ result = subprocess.run(
71
+ ["git", "describe", "--tags", "--exact-match", "--match", "v[0-9]*"],
72
+ cwd=root,
73
+ capture_output=True,
74
+ text=True,
75
+ check=False,
76
+ )
77
+ return result.stdout.strip() if result.returncode == 0 else ""
78
+
79
+
80
+ def _development_version(root: Path) -> str:
81
+ result = subprocess.run(
82
+ ["git", "rev-parse", "--short=12", "HEAD"], cwd=root, capture_output=True, text=True, check=False
83
+ )
84
+ revision = re.sub(r"[^0-9a-f]", "", result.stdout.lower())
85
+ return f"0.0.0.dev0+g{revision}" if revision else "0.0.0.dev0"
86
+
87
+
88
+ def _local_suffix(identifiers: list[str]) -> str:
89
+ normalized = [re.sub(r"[^0-9a-z]+", ".", item.lower()).strip(".") for item in identifiers]
90
+ normalized = [item for item in normalized if item]
91
+ return "+" + ".".join(normalized) if normalized else ""
92
+
93
+
94
+ if __name__ == "__main__":
95
+ print(resolve_build_version())