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.
- jobdock_sdk-0.2.1rc2/.gitignore +23 -0
- jobdock_sdk-0.2.1rc2/PKG-INFO +98 -0
- jobdock_sdk-0.2.1rc2/README.md +86 -0
- jobdock_sdk-0.2.1rc2/hatch_build.py +12 -0
- jobdock_sdk-0.2.1rc2/pyproject.toml +31 -0
- jobdock_sdk-0.2.1rc2/src/jobdock/__init__.py +14 -0
- jobdock_sdk-0.2.1rc2/src/jobdock/client.py +347 -0
- jobdock_sdk-0.2.1rc2/src/jobdock/observability.py +55 -0
- jobdock_sdk-0.2.1rc2/tests/test_client.py +136 -0
- jobdock_sdk-0.2.1rc2/tests/test_versioning.py +67 -0
- jobdock_sdk-0.2.1rc2/versioning.py +95 -0
|
@@ -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())
|