sagea-cursus 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sagea_cursus-0.1.0/PKG-INFO +114 -0
- sagea_cursus-0.1.0/README.md +99 -0
- sagea_cursus-0.1.0/pyproject.toml +36 -0
- sagea_cursus-0.1.0/sagea_cursus/__init__.py +251 -0
- sagea_cursus-0.1.0/sagea_cursus/_batching.py +88 -0
- sagea_cursus-0.1.0/sagea_cursus/_client.py +224 -0
- sagea_cursus-0.1.0/sagea_cursus/_media.py +58 -0
- sagea_cursus-0.1.0/sagea_cursus/_run.py +147 -0
- sagea_cursus-0.1.0/sagea_cursus.egg-info/PKG-INFO +114 -0
- sagea_cursus-0.1.0/sagea_cursus.egg-info/SOURCES.txt +14 -0
- sagea_cursus-0.1.0/sagea_cursus.egg-info/dependency_links.txt +1 -0
- sagea_cursus-0.1.0/sagea_cursus.egg-info/requires.txt +5 -0
- sagea_cursus-0.1.0/sagea_cursus.egg-info/top_level.txt +1 -0
- sagea_cursus-0.1.0/setup.cfg +4 -0
- sagea_cursus-0.1.0/tests/test_integration.py +53 -0
- sagea_cursus-0.1.0/tests/test_sdk.py +311 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sagea-cursus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cursus experiment-tracking SDK — barebones, self-hostable wandb-style init/log/finish.
|
|
5
|
+
Author: SAGEA Labs
|
|
6
|
+
License: SAGEA Noncommercial License 1.0 (see LICENSE in repo root)
|
|
7
|
+
Project-URL: Homepage, https://github.com/sagea-ai/cursus
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: requests>=2.31
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# sagea-cursus — Cursus Python SDK
|
|
17
|
+
|
|
18
|
+
Distribution name is `sagea-cursus` because `cursus` is taken on PyPI by an
|
|
19
|
+
unrelated SageMaker tool. The product is still branded "Cursus".
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install sagea-cursus
|
|
23
|
+
export CURSUS_API_KEY="cursus_..." # from <server>/<org>/settings/keys
|
|
24
|
+
export CURSUS_BASE_URL="https://cursus.example.com" # omit for localhost:3000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Full reference: [`docs/sdk.md`](../docs/sdk.md).
|
|
28
|
+
Runnable scripts: [`examples/`](./examples/) (need a live server + key).
|
|
29
|
+
|
|
30
|
+
## Basic training loop
|
|
31
|
+
|
|
32
|
+
Credentials come from the environment above, or explicitly per call
|
|
33
|
+
(handy for multi-server scripts — never commit real keys to git):
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import sagea_cursus as cursus
|
|
37
|
+
|
|
38
|
+
run = cursus.init(
|
|
39
|
+
project="demo",
|
|
40
|
+
config={"lr": 1e-4, "arch": "mlp"},
|
|
41
|
+
api_key="cursus_...", # or CURSUS_API_KEY
|
|
42
|
+
base_url="https://cursus.example.com", # or CURSUS_BASE_URL
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
for step in range(100):
|
|
46
|
+
loss = 1.0 / (step + 1)
|
|
47
|
+
cursus.log({"train/loss": loss}, step=step)
|
|
48
|
+
if step % 10 == 0:
|
|
49
|
+
cursus.log({"train/loss": loss, "eval/acc": step / 100}, step=step)
|
|
50
|
+
cursus.finish()
|
|
51
|
+
except Exception:
|
|
52
|
+
cursus.finish("crashed")
|
|
53
|
+
raise
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Images, artifacts, and mid-run config
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import sagea_cursus as cursus
|
|
60
|
+
|
|
61
|
+
run = cursus.init(project="vision", config={"backbone": "resnet50"})
|
|
62
|
+
for epoch in range(10):
|
|
63
|
+
train_one_epoch(...)
|
|
64
|
+
cursus.log({"train/loss": 0.4 / (epoch + 1)}, step=epoch)
|
|
65
|
+
cursus.log_image("val/samples", f"preds_epoch{epoch}.png", step=epoch)
|
|
66
|
+
if epoch == 5:
|
|
67
|
+
cursus.config.update({"lr": 1e-5}) # synced back, debounced
|
|
68
|
+
cursus.log_artifact("resnet50", "runs/best.pt", type="model", description="best val acc")
|
|
69
|
+
cursus.finish()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Group projects and named runs
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import sagea_cursus as cursus
|
|
76
|
+
|
|
77
|
+
# Log inside a group project (you must belong to the group).
|
|
78
|
+
run = cursus.init(project="detection", group="vision-team", name="baseline-a", tags=["v2-data"])
|
|
79
|
+
print("view at:", run.url)
|
|
80
|
+
cursus.log({"map50": 0.61}, step=1)
|
|
81
|
+
cursus.finish()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Sweep worker (grid/random)
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import sagea_cursus as cursus
|
|
88
|
+
|
|
89
|
+
sweep = cursus.create_sweep(
|
|
90
|
+
"demo",
|
|
91
|
+
{"lr": {"min": 1e-5, "max": 1e-1, "scale": "log"}, "batch": {"values": [16, 32]}},
|
|
92
|
+
name="lr-search",
|
|
93
|
+
)
|
|
94
|
+
while (trial := cursus.next_trial(sweep["id"])) is not None:
|
|
95
|
+
run = cursus.init(
|
|
96
|
+
project="demo",
|
|
97
|
+
config=trial["config"],
|
|
98
|
+
name=f"sweep-{trial['trial']}",
|
|
99
|
+
sweep_id=trial["sweep_id"],
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
train(trial["config"])
|
|
103
|
+
cursus.finish()
|
|
104
|
+
except Exception:
|
|
105
|
+
cursus.finish("crashed")
|
|
106
|
+
raise
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Reliability contract
|
|
110
|
+
|
|
111
|
+
`init()` may raise (missing key, version skew). Everything else degrades
|
|
112
|
+
to warnings: dead servers cost you points, never a crashed training job.
|
|
113
|
+
Always `finish()` in a `finally` — unflushed points and heartbeats die
|
|
114
|
+
with the process, and the run stays `RUNNING` until staleness flips it.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# sagea-cursus — Cursus Python SDK
|
|
2
|
+
|
|
3
|
+
Distribution name is `sagea-cursus` because `cursus` is taken on PyPI by an
|
|
4
|
+
unrelated SageMaker tool. The product is still branded "Cursus".
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install sagea-cursus
|
|
8
|
+
export CURSUS_API_KEY="cursus_..." # from <server>/<org>/settings/keys
|
|
9
|
+
export CURSUS_BASE_URL="https://cursus.example.com" # omit for localhost:3000
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Full reference: [`docs/sdk.md`](../docs/sdk.md).
|
|
13
|
+
Runnable scripts: [`examples/`](./examples/) (need a live server + key).
|
|
14
|
+
|
|
15
|
+
## Basic training loop
|
|
16
|
+
|
|
17
|
+
Credentials come from the environment above, or explicitly per call
|
|
18
|
+
(handy for multi-server scripts — never commit real keys to git):
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import sagea_cursus as cursus
|
|
22
|
+
|
|
23
|
+
run = cursus.init(
|
|
24
|
+
project="demo",
|
|
25
|
+
config={"lr": 1e-4, "arch": "mlp"},
|
|
26
|
+
api_key="cursus_...", # or CURSUS_API_KEY
|
|
27
|
+
base_url="https://cursus.example.com", # or CURSUS_BASE_URL
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
for step in range(100):
|
|
31
|
+
loss = 1.0 / (step + 1)
|
|
32
|
+
cursus.log({"train/loss": loss}, step=step)
|
|
33
|
+
if step % 10 == 0:
|
|
34
|
+
cursus.log({"train/loss": loss, "eval/acc": step / 100}, step=step)
|
|
35
|
+
cursus.finish()
|
|
36
|
+
except Exception:
|
|
37
|
+
cursus.finish("crashed")
|
|
38
|
+
raise
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Images, artifacts, and mid-run config
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import sagea_cursus as cursus
|
|
45
|
+
|
|
46
|
+
run = cursus.init(project="vision", config={"backbone": "resnet50"})
|
|
47
|
+
for epoch in range(10):
|
|
48
|
+
train_one_epoch(...)
|
|
49
|
+
cursus.log({"train/loss": 0.4 / (epoch + 1)}, step=epoch)
|
|
50
|
+
cursus.log_image("val/samples", f"preds_epoch{epoch}.png", step=epoch)
|
|
51
|
+
if epoch == 5:
|
|
52
|
+
cursus.config.update({"lr": 1e-5}) # synced back, debounced
|
|
53
|
+
cursus.log_artifact("resnet50", "runs/best.pt", type="model", description="best val acc")
|
|
54
|
+
cursus.finish()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Group projects and named runs
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import sagea_cursus as cursus
|
|
61
|
+
|
|
62
|
+
# Log inside a group project (you must belong to the group).
|
|
63
|
+
run = cursus.init(project="detection", group="vision-team", name="baseline-a", tags=["v2-data"])
|
|
64
|
+
print("view at:", run.url)
|
|
65
|
+
cursus.log({"map50": 0.61}, step=1)
|
|
66
|
+
cursus.finish()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Sweep worker (grid/random)
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import sagea_cursus as cursus
|
|
73
|
+
|
|
74
|
+
sweep = cursus.create_sweep(
|
|
75
|
+
"demo",
|
|
76
|
+
{"lr": {"min": 1e-5, "max": 1e-1, "scale": "log"}, "batch": {"values": [16, 32]}},
|
|
77
|
+
name="lr-search",
|
|
78
|
+
)
|
|
79
|
+
while (trial := cursus.next_trial(sweep["id"])) is not None:
|
|
80
|
+
run = cursus.init(
|
|
81
|
+
project="demo",
|
|
82
|
+
config=trial["config"],
|
|
83
|
+
name=f"sweep-{trial['trial']}",
|
|
84
|
+
sweep_id=trial["sweep_id"],
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
train(trial["config"])
|
|
88
|
+
cursus.finish()
|
|
89
|
+
except Exception:
|
|
90
|
+
cursus.finish("crashed")
|
|
91
|
+
raise
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Reliability contract
|
|
95
|
+
|
|
96
|
+
`init()` may raise (missing key, version skew). Everything else degrades
|
|
97
|
+
to warnings: dead servers cost you points, never a crashed training job.
|
|
98
|
+
Always `finish()` in a `finally` — unflushed points and heartbeats die
|
|
99
|
+
with the process, and the run stays `RUNNING` until staleness flips it.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sagea-cursus"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cursus experiment-tracking SDK — barebones, self-hostable wandb-style init/log/finish."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "SAGEA Noncommercial License 1.0 (see LICENSE in repo root)" }
|
|
12
|
+
classifiers = ["License :: Other/Proprietary License"]
|
|
13
|
+
authors = [{ name = "SAGEA Labs" }]
|
|
14
|
+
# Hard dependency: requests only. No optional-dependency creep — numpy/PIL
|
|
15
|
+
# are lazy optional imports, never install requirements.
|
|
16
|
+
dependencies = ["requests>=2.31"]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8", "ruff>=0.4"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/sagea-ai/cursus"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["sagea_cursus*"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
target-version = "py39"
|
|
30
|
+
line-length = 100
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
markers = [
|
|
35
|
+
"integration: talks to a real local server (opt-in, needs RUN_CURSUS_INTEGRATION=1)",
|
|
36
|
+
]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Public SDK surface: init, log, finish, log_artifact, log_image, config.
|
|
2
|
+
|
|
3
|
+
``__all__`` enforces the minimal contract — everything else is
|
|
4
|
+
underscore-prefixed and private.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from os import PathLike
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from ._client import CursusClient
|
|
17
|
+
from ._media import MAX_BYTES as _MAX_IMAGE_BYTES
|
|
18
|
+
from ._media import encode_image
|
|
19
|
+
from ._run import Run, _clear_current, _get_current, _set_current
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"config",
|
|
23
|
+
"create_sweep",
|
|
24
|
+
"finish",
|
|
25
|
+
"init",
|
|
26
|
+
"log",
|
|
27
|
+
"log_artifact",
|
|
28
|
+
"log_image",
|
|
29
|
+
"next_trial",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
config: dict[str, Any] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def init(
|
|
36
|
+
project: str,
|
|
37
|
+
config: dict[str, Any] | None = None,
|
|
38
|
+
name: str | None = None,
|
|
39
|
+
tags: list[str] | None = None,
|
|
40
|
+
group: str | None = None,
|
|
41
|
+
sweep_id: str | None = None,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
base_url: str | None = None,
|
|
44
|
+
) -> Run:
|
|
45
|
+
"""Create a run on the server and set the module-level singleton.
|
|
46
|
+
|
|
47
|
+
Reads the API key from ``api_key`` > ``CURSUS_API_KEY`` env >
|
|
48
|
+
``~/.cursus/config``. Fails loudly on version mismatch or missing key —
|
|
49
|
+
init is the one place raising is acceptable; log/finish never raise.
|
|
50
|
+
|
|
51
|
+
Pass ``group="slug"`` to log into a project inside that group (the
|
|
52
|
+
project is resolved or created there; you must belong to the group).
|
|
53
|
+
Omit it for org-wide projects visible to every member.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
import sagea_cursus as cursus
|
|
57
|
+
run = cursus.init(project="demo", config={"lr": 1e-4})
|
|
58
|
+
cursus.log({"train/loss": 0.4}, step=1)
|
|
59
|
+
cursus.finish()
|
|
60
|
+
"""
|
|
61
|
+
client = CursusClient(api_key=api_key, base_url=base_url)
|
|
62
|
+
if not client.api_key:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"Missing Cursus API key. Set CURSUS_API_KEY, pass api_key=..., "
|
|
65
|
+
"or write ~/.cursus/config."
|
|
66
|
+
)
|
|
67
|
+
client.check_version()
|
|
68
|
+
prev = _get_current()
|
|
69
|
+
if prev is not None:
|
|
70
|
+
warnings.warn("cursus: init() called twice; finishing previous run.")
|
|
71
|
+
try:
|
|
72
|
+
prev.finish()
|
|
73
|
+
except Exception: # noqa: BLE001,S110 — previous run must not break init
|
|
74
|
+
pass
|
|
75
|
+
payload = client.create_run(
|
|
76
|
+
project=project,
|
|
77
|
+
name=name,
|
|
78
|
+
config=config or {},
|
|
79
|
+
tags=tags or [],
|
|
80
|
+
group=group,
|
|
81
|
+
sweep_id=sweep_id,
|
|
82
|
+
)
|
|
83
|
+
run = Run(
|
|
84
|
+
client=client,
|
|
85
|
+
run_id=str(payload["run_id"]),
|
|
86
|
+
name=str(payload.get("name", "")),
|
|
87
|
+
url=str(payload.get("url", "")),
|
|
88
|
+
config=dict(config or {}),
|
|
89
|
+
)
|
|
90
|
+
_set_current(run)
|
|
91
|
+
globals()["config"] = run.config
|
|
92
|
+
return run
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def log(data: dict[str, float], step: int | None = None) -> None:
|
|
96
|
+
"""Log a dict of scalars. Warns + drops on failure; never raises."""
|
|
97
|
+
run = _get_current()
|
|
98
|
+
if run is None:
|
|
99
|
+
warnings.warn("cursus: log() called before init(); ignoring.")
|
|
100
|
+
return
|
|
101
|
+
run.log(data, step=step)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def finish(status: str = "finished") -> None:
|
|
105
|
+
"""Flush + mark the current run finished. Never raises."""
|
|
106
|
+
run = _get_current()
|
|
107
|
+
if run is None:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
run.finish(status=status)
|
|
111
|
+
finally:
|
|
112
|
+
_clear_current()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def log_artifact(
|
|
116
|
+
name: str,
|
|
117
|
+
paths: str | PathLike[str] | list[str | PathLike[str]],
|
|
118
|
+
type: str = "model",
|
|
119
|
+
description: str = "",
|
|
120
|
+
) -> dict[str, Any] | None:
|
|
121
|
+
"""Attach files to the current run as a new artifact version.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
cursus.log_artifact("yolov8m", "runs/train/weights/best.pt",
|
|
125
|
+
type="model", description="map50: 0.61")
|
|
126
|
+
|
|
127
|
+
Warns + skips on failure (missing files, network); never raises into
|
|
128
|
+
user code. Returns the created version payload, or None when skipped.
|
|
129
|
+
"""
|
|
130
|
+
run = _get_current()
|
|
131
|
+
if run is None:
|
|
132
|
+
warnings.warn("cursus: log_artifact() called before init(); ignoring.")
|
|
133
|
+
return None
|
|
134
|
+
if isinstance(paths, (str, PathLike)):
|
|
135
|
+
paths = [paths]
|
|
136
|
+
files: list[tuple[str, bytes]] = []
|
|
137
|
+
for p in paths:
|
|
138
|
+
try:
|
|
139
|
+
content = Path(p).read_bytes()
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
warnings.warn(f"cursus: log_artifact() skipping unreadable {p}: {exc}")
|
|
142
|
+
continue
|
|
143
|
+
files.append((Path(p).name, content))
|
|
144
|
+
if not files:
|
|
145
|
+
warnings.warn("cursus: log_artifact() found no readable files; skipping.")
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
# Same-package access to the run's client (the one funnel).
|
|
149
|
+
return run._client.create_artifact_version(
|
|
150
|
+
name=name,
|
|
151
|
+
files=files,
|
|
152
|
+
run_id=run.id,
|
|
153
|
+
artifact_type=type,
|
|
154
|
+
description=description,
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc: # noqa: BLE001 — never crash training
|
|
157
|
+
warnings.warn(f"cursus: log_artifact() failed (dropped): {exc}")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def log_image(
|
|
162
|
+
key: str,
|
|
163
|
+
image: str | PathLike[str] | bytes | Any,
|
|
164
|
+
step: int | None = None,
|
|
165
|
+
) -> dict[str, Any] | None:
|
|
166
|
+
"""Log one image at a step. Bytes go direct to object storage via a
|
|
167
|
+
presigned URL (no boto — plain requests PUT); the server only signs.
|
|
168
|
+
|
|
169
|
+
``image`` is a file path, raw PNG/JPEG/WEBP bytes, a PIL Image, or a
|
|
170
|
+
numpy array (last two need pillow installed). Never raises into user
|
|
171
|
+
code: bad inputs, oversize files (>5 MB), and network failures warn
|
|
172
|
+
and drop. Returns the completed media payload, or None when skipped.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
cursus.log_image("val/samples", "pred_epoch3.png", step=3)
|
|
176
|
+
"""
|
|
177
|
+
run = _get_current()
|
|
178
|
+
if run is None:
|
|
179
|
+
warnings.warn("cursus: log_image() called before init(); ignoring.")
|
|
180
|
+
return None
|
|
181
|
+
if step is None:
|
|
182
|
+
warnings.warn("cursus: log_image() without step; using 0.")
|
|
183
|
+
step = 0
|
|
184
|
+
try:
|
|
185
|
+
data, mime = encode_image(image)
|
|
186
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
187
|
+
warnings.warn(f"cursus: log_image() skipping bad input: {exc}")
|
|
188
|
+
return None
|
|
189
|
+
if len(data) > _MAX_IMAGE_BYTES:
|
|
190
|
+
warnings.warn(f"cursus: log_image() exceeds the 5 MB cap ({len(data)} bytes); skipping.")
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
ticket = run._client.request_upload_url(run.id, key, int(step), mime, len(data))
|
|
194
|
+
resp = requests.put(
|
|
195
|
+
ticket["url"],
|
|
196
|
+
data=data,
|
|
197
|
+
headers={"Content-Type": mime},
|
|
198
|
+
timeout=120.0,
|
|
199
|
+
)
|
|
200
|
+
resp.raise_for_status()
|
|
201
|
+
return run._client.complete_upload(run.id, ticket["mediaId"])
|
|
202
|
+
except Exception as exc: # noqa: BLE001 — never crash training
|
|
203
|
+
warnings.warn(f"cursus: log_image() failed (dropped): {exc}")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _sweep_client(api_key: str | None, base_url: str | None) -> CursusClient:
|
|
208
|
+
client = CursusClient(api_key=api_key, base_url=base_url)
|
|
209
|
+
if not client.api_key:
|
|
210
|
+
raise RuntimeError(
|
|
211
|
+
"Missing Cursus API key. Set CURSUS_API_KEY, pass api_key=..., "
|
|
212
|
+
"or write ~/.cursus/config."
|
|
213
|
+
)
|
|
214
|
+
client.check_version()
|
|
215
|
+
return client
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_sweep(
|
|
219
|
+
project: str,
|
|
220
|
+
space: dict[str, Any],
|
|
221
|
+
name: str | None = None,
|
|
222
|
+
method: str = "random",
|
|
223
|
+
api_key: str | None = None,
|
|
224
|
+
base_url: str | None = None,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""Create a sweep and return it (including ``id``).
|
|
227
|
+
|
|
228
|
+
``space`` maps names to ``{"values": [...]}`` (choice) or
|
|
229
|
+
``{"min": .., "max": .., "scale": "linear"|"log"}`` (random only).
|
|
230
|
+
May raise like ``init()`` — sweeps are control calls, not logging.
|
|
231
|
+
"""
|
|
232
|
+
client = _sweep_client(api_key, base_url)
|
|
233
|
+
return client.create_sweep(
|
|
234
|
+
project=project,
|
|
235
|
+
name=name or f"sweep-{project}",
|
|
236
|
+
method=method.upper(),
|
|
237
|
+
space=space,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def next_trial(
|
|
242
|
+
sweep_id: str,
|
|
243
|
+
api_key: str | None = None,
|
|
244
|
+
base_url: str | None = None,
|
|
245
|
+
) -> dict[str, Any] | None:
|
|
246
|
+
"""Claim the next trial config, or None when the sweep is exhausted
|
|
247
|
+
(grid done, or sweep finished/cancelled). May raise on transport
|
|
248
|
+
errors — retry or abort the worker loop as you see fit.
|
|
249
|
+
"""
|
|
250
|
+
client = _sweep_client(api_key, base_url)
|
|
251
|
+
return client.next_trial(sweep_id)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Background flush queue — batching logic, unit-testable with a fake client.
|
|
2
|
+
|
|
3
|
+
Flush every 5s or every 50 points (whichever first); the server
|
|
4
|
+
does one bulk insert per batch. Plain ``threading`` queue + timer — no
|
|
5
|
+
asyncio, so this works in every training script including ones without an
|
|
6
|
+
event loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import warnings
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
FLUSH_INTERVAL_S = 5.0
|
|
18
|
+
FLUSH_MAX_POINTS = 50
|
|
19
|
+
# Bounded retries on network failure: warn + drop, never raise into user code
|
|
20
|
+
# — the single most important SDK reliability property.
|
|
21
|
+
MAX_RETRIES = 3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _utcnow_iso() -> str:
|
|
25
|
+
return datetime.now(timezone.utc).isoformat()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Batcher:
|
|
29
|
+
"""Collects points and flushes them in the background via ``send``."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
send: Callable[[list[dict[str, Any]]], None],
|
|
34
|
+
flush_interval: float = FLUSH_INTERVAL_S,
|
|
35
|
+
max_points: int = FLUSH_MAX_POINTS,
|
|
36
|
+
max_retries: int = MAX_RETRIES,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._send = send
|
|
39
|
+
self._flush_interval = flush_interval
|
|
40
|
+
self._max_points = max_points
|
|
41
|
+
self._max_retries = max_retries
|
|
42
|
+
self._buf: list[dict[str, Any]] = []
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._stop = threading.Event()
|
|
45
|
+
self._thread = threading.Thread(target=self._loop, name="cursus-flush", daemon=True)
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
self._thread.start()
|
|
49
|
+
|
|
50
|
+
def enqueue(self, points: list[dict[str, Any]]) -> None:
|
|
51
|
+
should_flush = False
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._buf.extend(points)
|
|
54
|
+
should_flush = len(self._buf) >= self._max_points
|
|
55
|
+
if should_flush:
|
|
56
|
+
self.flush()
|
|
57
|
+
|
|
58
|
+
def flush(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
batch, self._buf = self._buf, []
|
|
61
|
+
if not batch:
|
|
62
|
+
return
|
|
63
|
+
last_exc: Exception | None = None
|
|
64
|
+
for _ in range(self._max_retries):
|
|
65
|
+
try:
|
|
66
|
+
self._send(batch)
|
|
67
|
+
return
|
|
68
|
+
except Exception as exc: # noqa: BLE001 — must never propagate
|
|
69
|
+
last_exc = exc
|
|
70
|
+
time.sleep(0.2)
|
|
71
|
+
warnings.warn(
|
|
72
|
+
f"cursus: dropped {len(batch)} metric point(s) after "
|
|
73
|
+
f"{self._max_retries} retries: {last_exc}",
|
|
74
|
+
stacklevel=3,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._stop.set()
|
|
79
|
+
self.flush()
|
|
80
|
+
if self._thread.is_alive():
|
|
81
|
+
self._thread.join(timeout=5)
|
|
82
|
+
|
|
83
|
+
def _loop(self) -> None:
|
|
84
|
+
while not self._stop.wait(self._flush_interval):
|
|
85
|
+
try:
|
|
86
|
+
self.flush()
|
|
87
|
+
except Exception: # noqa: BLE001,S112 — background thread never dies
|
|
88
|
+
continue
|