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.
@@ -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