lossy 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.
lossy-0.1.0/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ __pycache__/
4
+ *.egg-info/
5
+ dist/
lossy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: lossy
3
+ Version: 0.1.0
4
+ Summary: Two lines to hatch a life - lightweight training-run logger for Lossy (wandb-shaped, zero deps)
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # lossy
10
+
11
+ Two lines to hatch a life. A tiny, zero-dependency training-run logger that
12
+ feeds the [Lossy](../) pet — a wandb-shaped API with none of the weight.
13
+
14
+ ```python
15
+ import lossy
16
+
17
+ run = lossy.init(project="my-project", name="run-1", config={"lr": 3e-4})
18
+ for step in range(1, 301):
19
+ loss = train_step()
20
+ lossy.log({"train/loss": loss}, step=step)
21
+ run.finish()
22
+ ```
23
+
24
+ ## Setup
25
+
26
+ Pair with the Lossy app (Settings → Lossy SDK) to get your token, then:
27
+
28
+ ```bash
29
+ export LOSSY_TOKEN=lossy_xxxxx # pairing token from the app
30
+ export LOSSY_API=https://your-deployment.vercel.app # optional; defaults to prod
31
+ ```
32
+
33
+ No token? Your script still runs exactly as before — the SDK goes inert with
34
+ a single warning. It never raises, never blocks, never slows a training loop:
35
+ logging is a lock-guarded append to a bounded in-memory queue (oldest points
36
+ drop past 10k), a background thread ships batches (≥50 points or every 5s)
37
+ with retries and backoff, and every network failure is a stderr warning, not
38
+ an exception.
39
+
40
+ ## Crash honesty
41
+
42
+ Uncaught exceptions are reported as `crashed` with an exception summary
43
+ (`sys.excepthook`); a clean `finish()` — or a clean exit without one
44
+ (`atexit`) — reports `finished`. Hard kills (OOM/SIGKILL) can't run hooks;
45
+ the server's liveness sweep covers those.
46
+
47
+ Ctrl-C currently reports `crashed` too (distinguishable downstream via
48
+ `exitInfo.type == "KeyboardInterrupt"`); a distinct "interrupted" state is a
49
+ recorded product decision for later.
50
+
51
+ ## Environment knobs (mostly for tests)
52
+
53
+ `LOSSY_TOKEN`, `LOSSY_API`, `LOSSY_FLUSH_INTERVAL`, `LOSSY_BATCH_SIZE`,
54
+ `LOSSY_QUEUE_MAX`, `LOSSY_MAX_RETRIES`, `LOSSY_BACKOFF_BASE`,
55
+ `LOSSY_HTTP_TIMEOUT`, `LOSSY_FINISH_TIMEOUT`.
56
+
57
+ ## Tests
58
+
59
+ ```bash
60
+ cd sdk && uv run --with pytest pytest
61
+ ```
62
+
63
+ Fully offline — an in-process stub server plays the S1 ingest API.
lossy-0.1.0/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # lossy
2
+
3
+ Two lines to hatch a life. A tiny, zero-dependency training-run logger that
4
+ feeds the [Lossy](../) pet — a wandb-shaped API with none of the weight.
5
+
6
+ ```python
7
+ import lossy
8
+
9
+ run = lossy.init(project="my-project", name="run-1", config={"lr": 3e-4})
10
+ for step in range(1, 301):
11
+ loss = train_step()
12
+ lossy.log({"train/loss": loss}, step=step)
13
+ run.finish()
14
+ ```
15
+
16
+ ## Setup
17
+
18
+ Pair with the Lossy app (Settings → Lossy SDK) to get your token, then:
19
+
20
+ ```bash
21
+ export LOSSY_TOKEN=lossy_xxxxx # pairing token from the app
22
+ export LOSSY_API=https://your-deployment.vercel.app # optional; defaults to prod
23
+ ```
24
+
25
+ No token? Your script still runs exactly as before — the SDK goes inert with
26
+ a single warning. It never raises, never blocks, never slows a training loop:
27
+ logging is a lock-guarded append to a bounded in-memory queue (oldest points
28
+ drop past 10k), a background thread ships batches (≥50 points or every 5s)
29
+ with retries and backoff, and every network failure is a stderr warning, not
30
+ an exception.
31
+
32
+ ## Crash honesty
33
+
34
+ Uncaught exceptions are reported as `crashed` with an exception summary
35
+ (`sys.excepthook`); a clean `finish()` — or a clean exit without one
36
+ (`atexit`) — reports `finished`. Hard kills (OOM/SIGKILL) can't run hooks;
37
+ the server's liveness sweep covers those.
38
+
39
+ Ctrl-C currently reports `crashed` too (distinguishable downstream via
40
+ `exitInfo.type == "KeyboardInterrupt"`); a distinct "interrupted" state is a
41
+ recorded product decision for later.
42
+
43
+ ## Environment knobs (mostly for tests)
44
+
45
+ `LOSSY_TOKEN`, `LOSSY_API`, `LOSSY_FLUSH_INTERVAL`, `LOSSY_BATCH_SIZE`,
46
+ `LOSSY_QUEUE_MAX`, `LOSSY_MAX_RETRIES`, `LOSSY_BACKOFF_BASE`,
47
+ `LOSSY_HTTP_TIMEOUT`, `LOSSY_FINISH_TIMEOUT`.
48
+
49
+ ## Tests
50
+
51
+ ```bash
52
+ cd sdk && uv run --with pytest pytest
53
+ ```
54
+
55
+ Fully offline — an in-process stub server plays the S1 ingest API.
@@ -0,0 +1,44 @@
1
+ """lossy — two lines to hatch a life.
2
+
3
+ wandb-shaped module-level ergonomics::
4
+
5
+ import lossy
6
+ run = lossy.init(project="my-proj", name="run-1", config={"lr": 3e-4})
7
+ lossy.log({"train/loss": loss}, step=step) # or run.log(...)
8
+ run.finish() # or lossy.finish()
9
+
10
+ Auth comes from ``LOSSY_TOKEN`` (pairing token from the app); the endpoint
11
+ from ``LOSSY_API``. Without a token the SDK is inert (one warning) and your
12
+ script runs untouched. See lossy.client for the full behavioral contract.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .client import Run, _warn_once_global
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = ["init", "log", "finish", "run", "Run"]
20
+
21
+ run: Run | None = None # the current module-level run, wandb-style
22
+
23
+
24
+ def init(project: str | None = None, name: str | None = None,
25
+ config: dict | None = None, **kwargs) -> Run:
26
+ """Start a run and make it the module-level current run."""
27
+ global run
28
+ run = Run(project=project, name=name, config=config, **kwargs)
29
+ return run
30
+
31
+
32
+ def log(metrics: dict, step: int | None = None) -> None:
33
+ """Log metrics to the current run. Safe no-op (with a warning) pre-init."""
34
+ if run is None:
35
+ _warn_once_global("log-before-init",
36
+ "log() called before init(); point ignored")
37
+ return
38
+ run.log(metrics, step=step)
39
+
40
+
41
+ def finish() -> None:
42
+ """Finish the current run. Idempotent; safe without init()."""
43
+ if run is not None:
44
+ run.finish()
@@ -0,0 +1,359 @@
1
+ """Lossy SDK core: the Run class and its background shipping worker.
2
+
3
+ Contract (independence plan, Task S2 — built against S1's endpoint spec):
4
+
5
+ * Transport: ``POST {LOSSY_API}/api/ingest`` with ``Authorization: Bearer
6
+ <LOSSY_TOKEN>``; actions ``start`` (returns server ``runId``), ``log``
7
+ (batch ``points: [{step, metrics, ts}]`` + client ``seq`` for idempotent
8
+ retries), ``finish`` (``state: finished|crashed`` + optional ``exitInfo``).
9
+ Bodies stay <= 64KB; oversized batches split.
10
+ * Never stall training: ``log()`` only appends to a bounded queue (oldest
11
+ dropped past QUEUE_MAX with ONE stderr warning); a daemon thread batches
12
+ (>= BATCH_SIZE points or every FLUSH_INTERVAL seconds) and ships with
13
+ exponential-backoff retries. ALL network failures become stderr warnings —
14
+ never exceptions, so a dead server can't kill a live training job.
15
+ * Crash honesty: sys.excepthook reports ``crashed`` with an exception
16
+ summary; atexit closes a still-open run as ``finished`` (a clean exit is
17
+ not a crash — SIGKILL/OOM detection is the server liveness sweep's job).
18
+ ``Run._finished`` guards against hooks double-firing.
19
+ * No token -> inert run with a single process-wide warning; scripts must run
20
+ fine unpaired.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import atexit
25
+ import json
26
+ import os
27
+ import sys
28
+ import threading
29
+ import time
30
+ import traceback
31
+ import urllib.error
32
+ import urllib.request
33
+ from collections import deque
34
+ from typing import Any
35
+
36
+
37
+ def _env_int(name: str, default: int) -> int:
38
+ try:
39
+ return int(os.environ.get(name, ""))
40
+ except ValueError:
41
+ return default
42
+
43
+
44
+ def _env_float(name: str, default: float) -> float:
45
+ try:
46
+ return float(os.environ.get(name, ""))
47
+ except ValueError:
48
+ return default
49
+
50
+
51
+ DEFAULT_API = "https://lossy-server.vercel.app" # override with LOSSY_API
52
+ INGEST_PATH = "/api/ingest"
53
+ MAX_BODY_BYTES = 64 * 1024 # S1 rejects bigger bodies
54
+
55
+ # Timing/size knobs. Env overrides exist so subprocess tests (and unusual
56
+ # deployments) can tune them; in-process tests monkeypatch the module attrs.
57
+ QUEUE_MAX = _env_int("LOSSY_QUEUE_MAX", 10_000)
58
+ BATCH_SIZE = _env_int("LOSSY_BATCH_SIZE", 50)
59
+ FLUSH_INTERVAL = _env_float("LOSSY_FLUSH_INTERVAL", 5.0)
60
+ MAX_RETRIES = _env_int("LOSSY_MAX_RETRIES", 3)
61
+ BACKOFF_BASE = _env_float("LOSSY_BACKOFF_BASE", 0.5)
62
+ HTTP_TIMEOUT = _env_float("LOSSY_HTTP_TIMEOUT", 5.0)
63
+ FINISH_TIMEOUT = _env_float("LOSSY_FINISH_TIMEOUT", 10.0)
64
+
65
+ _sleep = time.sleep # indirection so tests can observe backoff delays
66
+ _WARNED_GLOBAL: set[str] = set() # process-wide once-only warning keys
67
+
68
+
69
+ def _warn(msg: str) -> None:
70
+ print(f"lossy: {msg}", file=sys.stderr)
71
+
72
+
73
+ def _warn_once_global(key: str, msg: str) -> None:
74
+ if key not in _WARNED_GLOBAL:
75
+ _WARNED_GLOBAL.add(key)
76
+ _warn(msg)
77
+
78
+
79
+ # Recursion depth cap for nested config/metrics values. Deep enough for any
80
+ # sane config; guards against cycles (which would otherwise recurse forever).
81
+ _JSONABLE_MAX_DEPTH = 10
82
+
83
+
84
+ def _jsonable(value: Any, _depth: int = 0) -> Any:
85
+ """Make a value JSON-serializable, preserving structure.
86
+
87
+ Dicts/lists/tuples recurse so ``config={"optimizer": {"lr": 1e-3}}``
88
+ arrives as real nested JSON, not a Python-repr string. Non-serializable
89
+ leaves (and anything past the depth cap) fall back to ``str``.
90
+ """
91
+ if isinstance(value, (int, float, str, bool)) or value is None:
92
+ return value
93
+ if _depth >= _JSONABLE_MAX_DEPTH:
94
+ return str(value)
95
+ if isinstance(value, dict):
96
+ return {str(k): _jsonable(v, _depth + 1) for k, v in value.items()}
97
+ if isinstance(value, (list, tuple)):
98
+ return [_jsonable(v, _depth + 1) for v in value]
99
+ return str(value)
100
+
101
+
102
+ class Run:
103
+ """One training run. Create via ``lossy.init()``."""
104
+
105
+ def __init__(self, project: str | None = None, name: str | None = None,
106
+ config: dict | None = None, *,
107
+ _api: str | None = None, _token: str | None = None):
108
+ self.project = project or "uncategorized"
109
+ self.name = name or f"run-{int(time.time())}"
110
+ self.config = {str(k): _jsonable(v) for k, v in (config or {}).items()}
111
+ self._token = _token if _token is not None else os.environ.get("LOSSY_TOKEN", "")
112
+ self._api = (_api or os.environ.get("LOSSY_API") or DEFAULT_API).rstrip("/")
113
+ self._finished = False
114
+ self._enabled = bool(self._token)
115
+ if not self._enabled:
116
+ _warn_once_global(
117
+ "no-token",
118
+ "LOSSY_TOKEN is not set — running unpaired; metrics will not "
119
+ "be sent (your script is unaffected).")
120
+ return
121
+
122
+ # capture knobs at init so a run's behavior is stable for its lifetime
123
+ self._queue_max = QUEUE_MAX
124
+ self._batch_size = BATCH_SIZE
125
+ self._flush_interval = FLUSH_INTERVAL
126
+ self._max_retries = max(1, MAX_RETRIES)
127
+ self._backoff_base = BACKOFF_BASE
128
+ self._http_timeout = HTTP_TIMEOUT
129
+ self._finish_timeout = FINISH_TIMEOUT
130
+
131
+ self._lock = threading.Lock()
132
+ self._queue: deque[dict] = deque()
133
+ self._wake = threading.Event()
134
+ self._closing = False
135
+ self._finish_state: tuple[str, dict | None] = ("finished", None)
136
+ self._run_id: str | None = None
137
+ self._degraded = False # start failed: swallow everything quietly
138
+ self._seq = 0
139
+ self._auto_step = 0
140
+ self._dropped_warned = False
141
+ self._warned: set[str] = set()
142
+
143
+ self._worker = threading.Thread(
144
+ target=self._worker_loop, name="lossy-worker", daemon=True)
145
+ self._worker.start()
146
+ _install_hooks(self)
147
+
148
+ # ------------------------------------------------------------------ API
149
+
150
+ def log(self, metrics: dict, step: int | None = None) -> None:
151
+ """Queue a point. Never blocks, never raises."""
152
+ try:
153
+ if not self._enabled or self._finished:
154
+ return
155
+ if step is None:
156
+ self._auto_step += 1
157
+ step = self._auto_step
158
+ else:
159
+ self._auto_step = max(self._auto_step, int(step))
160
+ point = {
161
+ "step": int(step),
162
+ "metrics": {str(k): _jsonable(v) for k, v in metrics.items()},
163
+ "ts": time.time(),
164
+ }
165
+ with self._lock:
166
+ self._queue.append(point)
167
+ if len(self._queue) > self._queue_max:
168
+ self._queue.popleft()
169
+ if not self._dropped_warned:
170
+ self._dropped_warned = True
171
+ _warn(f"metric queue is full ({self._queue_max}); "
172
+ "dropping oldest points (server slow or down?)")
173
+ ready = len(self._queue) >= self._batch_size
174
+ if ready:
175
+ self._wake.set()
176
+ except Exception as e: # absolutely never let logging kill training
177
+ self._warn_once("log-error", f"log() error swallowed: {e!r}")
178
+
179
+ def finish(self) -> None:
180
+ """Flush and mark the run finished. Idempotent, never raises."""
181
+ self._finish("finished", None)
182
+
183
+ # ------------------------------------------------------------ internals
184
+
185
+ def _finish(self, state: str, exit_info: dict | None) -> None:
186
+ if not self._enabled:
187
+ self._finished = True
188
+ return
189
+ if self._finished:
190
+ return
191
+ self._finished = True # set FIRST: atexit/excepthook can't re-enter
192
+ try:
193
+ self._finish_state = (state, exit_info)
194
+ self._closing = True
195
+ self._wake.set()
196
+ # bounded join: a dead server must not wedge interpreter exit
197
+ self._worker.join(timeout=self._finish_timeout)
198
+ if self._worker.is_alive():
199
+ self._warn_once("finish-timeout",
200
+ "gave up waiting for the upload worker; "
201
+ "some points may be lost")
202
+ except Exception as e:
203
+ self._warn_once("finish-error", f"finish() error swallowed: {e!r}")
204
+
205
+ def _warn_once(self, key: str, msg: str) -> None:
206
+ if key not in self._warned:
207
+ self._warned.add(key)
208
+ _warn(msg)
209
+
210
+ def _drain(self) -> list[dict]:
211
+ with self._lock:
212
+ points = list(self._queue)
213
+ self._queue.clear()
214
+ return points
215
+
216
+ def _worker_loop(self) -> None:
217
+ try:
218
+ self._start_run()
219
+ while True:
220
+ self._wake.wait(timeout=self._flush_interval)
221
+ self._wake.clear()
222
+ self._ship(self._drain())
223
+ if self._closing:
224
+ self._ship(self._drain()) # points raced in after drain
225
+ break
226
+ state, exit_info = self._finish_state
227
+ self._send_finish(state, exit_info)
228
+ except Exception as e:
229
+ self._warn_once("worker-error", f"upload worker died: {e!r}; "
230
+ "further metrics will be dropped")
231
+
232
+ def _start_run(self) -> None:
233
+ payload = {"action": "start",
234
+ "run": {"name": self.name, "project": self.project,
235
+ "config": self.config}}
236
+ resp = self._post(payload)
237
+ run_id = (resp or {}).get("runId")
238
+ if not run_id:
239
+ self._degraded = True
240
+ self._warn_once("degraded",
241
+ "could not register the run with the Lossy "
242
+ "server; this run's metrics will be dropped "
243
+ "(training is unaffected)")
244
+ else:
245
+ self._run_id = run_id
246
+
247
+ def _ship(self, points: list[dict]) -> None:
248
+ if not points or self._degraded:
249
+ return
250
+ for chunk in self._split_to_size(points):
251
+ self._seq += 1
252
+ payload = {"action": "log", "runId": self._run_id,
253
+ "seq": self._seq, "points": chunk}
254
+ self._post(payload) # retries inside; give up -> warning
255
+
256
+ def _split_to_size(self, points: list[dict]) -> list[list[dict]]:
257
+ """Split so each serialized body stays under MAX_BODY_BYTES."""
258
+ envelope = 256 # generous headroom for action/runId/seq fields
259
+ budget = MAX_BODY_BYTES - envelope
260
+ if len(_dumps(points)) <= budget:
261
+ return [points]
262
+ if len(points) == 1:
263
+ self._warn_once("giant-point",
264
+ "a single log() point exceeds the 64KB ingest "
265
+ "limit; dropping it")
266
+ return []
267
+ mid = len(points) // 2
268
+ return (self._split_to_size(points[:mid])
269
+ + self._split_to_size(points[mid:]))
270
+
271
+ def _send_finish(self, state: str, exit_info: dict | None) -> None:
272
+ if self._degraded or not self._run_id:
273
+ return
274
+ payload: dict[str, Any] = {"action": "finish", "runId": self._run_id,
275
+ "state": state}
276
+ if exit_info:
277
+ payload["exitInfo"] = exit_info
278
+ self._post(payload)
279
+
280
+ def _post(self, payload: dict) -> dict | None:
281
+ """POST with exponential-backoff retries. Failure -> warning + None.
282
+
283
+ The same body (same seq) is resent on every retry, which is what
284
+ makes the server-side dedupe work.
285
+ """
286
+ body = _dumps(payload)
287
+ last_err: Exception | None = None
288
+ for attempt in range(self._max_retries):
289
+ if attempt:
290
+ _sleep(self._backoff_base * (2 ** (attempt - 1)))
291
+ try:
292
+ req = urllib.request.Request(
293
+ self._api + INGEST_PATH, data=body, method="POST",
294
+ headers={"Authorization": f"Bearer {self._token}",
295
+ "Content-Type": "application/json"})
296
+ with urllib.request.urlopen(req, timeout=self._http_timeout) as resp:
297
+ data = resp.read()
298
+ try:
299
+ return json.loads(data) if data else {}
300
+ except ValueError:
301
+ return {}
302
+ except urllib.error.HTTPError as e:
303
+ e.close() # HTTPError wraps a live response; don't leak it
304
+ last_err = e
305
+ if 400 <= e.code < 500 and e.code not in (408, 429):
306
+ break # our bug or bad token: retrying won't help
307
+ except Exception as e: # URLError, socket errors, timeouts
308
+ last_err = e
309
+ self._warn_once(
310
+ f"net:{payload.get('action')}",
311
+ f"could not send '{payload.get('action')}' to {self._api} "
312
+ f"({last_err}); this batch was dropped, but future batches "
313
+ "will still be attempted")
314
+ return None
315
+
316
+
317
+ def _dumps(obj: Any) -> bytes:
318
+ return json.dumps(obj, separators=(",", ":"), default=str).encode()
319
+
320
+
321
+ # ------------------------------------------------------- crash-honesty hooks
322
+
323
+ def _install_hooks(run: Run) -> None:
324
+ """atexit + sys.excepthook. ``Run._finished`` (set before any network
325
+ work in ``_finish``) guarantees at most one finish, whatever order the
326
+ interpreter fires these in."""
327
+
328
+ def _on_exit() -> None:
329
+ # excepthook already handled a crash, or the user finish()ed: no-op.
330
+ run._finish("finished", None)
331
+
332
+ atexit.register(_on_exit)
333
+
334
+ prev_hook = sys.excepthook
335
+
336
+ def _on_exception(exc_type, exc_value, exc_tb):
337
+ # Product decision (recorded, not changed here): Ctrl-C reports
338
+ # `crashed` like any other uncaught exception. Downstream CAN
339
+ # distinguish it via exitInfo.type == "KeyboardInterrupt"; a
340
+ # distinct "interrupted" state is deferred for later.
341
+ try:
342
+ run._finish("crashed", _exception_summary(exc_type, exc_value, exc_tb))
343
+ finally:
344
+ prev_hook(exc_type, exc_value, exc_tb) # user still sees the traceback
345
+
346
+ sys.excepthook = _on_exception
347
+
348
+
349
+ def _exception_summary(exc_type, exc_value, exc_tb) -> dict:
350
+ info = {"type": getattr(exc_type, "__name__", str(exc_type)),
351
+ "message": str(exc_value)[:500]}
352
+ try:
353
+ frames = traceback.extract_tb(exc_tb)
354
+ if frames:
355
+ last = frames[-1]
356
+ info["where"] = f"{os.path.basename(last.filename)}:{last.lineno} in {last.name}"
357
+ except Exception:
358
+ pass
359
+ return info
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "lossy"
3
+ version = "0.1.0"
4
+ description = "Two lines to hatch a life - lightweight training-run logger for Lossy (wandb-shaped, zero deps)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = []
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["lossy"]
16
+
17
+ [tool.pytest.ini_options]
18
+ testpaths = ["tests"]
@@ -0,0 +1,126 @@
1
+ """Offline test harness: an in-process stub of the Lossy ingest server.
2
+
3
+ The SDK is built against the Task S1 contract (POST /api/ingest with
4
+ actions start/log/finish); these tests never touch the real server.
5
+ """
6
+ import json
7
+ import socket
8
+ import threading
9
+ import time
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+
12
+ import pytest
13
+
14
+
15
+ class _Handler(BaseHTTPRequestHandler):
16
+ def do_POST(self): # noqa: N802 (http.server API)
17
+ length = int(self.headers.get("Content-Length", 0))
18
+ raw = self.rfile.read(length)
19
+ try:
20
+ body = json.loads(raw)
21
+ except ValueError:
22
+ body = None
23
+ record = {
24
+ "path": self.path,
25
+ "headers": {k: v for k, v in self.headers.items()},
26
+ "body": body,
27
+ "raw_len": len(raw),
28
+ }
29
+ stub = self.server.stub
30
+ with stub.lock:
31
+ stub.requests.append(record)
32
+ fail = stub.fail_remaining > 0
33
+ if fail:
34
+ stub.fail_remaining -= 1
35
+ if fail:
36
+ self._respond(500, {"error": "injected failure"})
37
+ return
38
+ action = (body or {}).get("action")
39
+ if action == "start":
40
+ self._respond(200, {"runId": stub.run_id})
41
+ else:
42
+ self._respond(200, {"ok": True})
43
+
44
+ def _respond(self, code, payload):
45
+ data = json.dumps(payload).encode()
46
+ self.send_response(code)
47
+ self.send_header("Content-Type", "application/json")
48
+ self.send_header("Content-Length", str(len(data)))
49
+ self.end_headers()
50
+ self.wfile.write(data)
51
+
52
+ def log_message(self, *args): # keep pytest output clean
53
+ pass
54
+
55
+
56
+ class StubServer:
57
+ def __init__(self):
58
+ self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
59
+ self._httpd.stub = self
60
+ self.lock = threading.Lock()
61
+ self.requests: list[dict] = []
62
+ self.fail_remaining = 0
63
+ self.run_id = "lrun-test-1"
64
+ self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
65
+ self._thread.start()
66
+
67
+ @property
68
+ def url(self):
69
+ host, port = self._httpd.server_address
70
+ return f"http://{host}:{port}"
71
+
72
+ def set_failures(self, n):
73
+ with self.lock:
74
+ self.fail_remaining = n
75
+
76
+ def snapshot(self):
77
+ with self.lock:
78
+ return list(self.requests)
79
+
80
+ def by_action(self, action):
81
+ return [r for r in self.snapshot()
82
+ if r["body"] and r["body"].get("action") == action]
83
+
84
+ def wait_for(self, predicate, timeout=5.0):
85
+ deadline = time.monotonic() + timeout
86
+ while time.monotonic() < deadline:
87
+ snap = self.snapshot()
88
+ if predicate(snap):
89
+ return snap
90
+ time.sleep(0.02)
91
+ raise AssertionError(f"stub condition not met within {timeout}s; "
92
+ f"saw {len(self.snapshot())} requests")
93
+
94
+ def close(self):
95
+ self._httpd.shutdown()
96
+ self._httpd.server_close()
97
+
98
+
99
+ @pytest.fixture
100
+ def stub():
101
+ s = StubServer()
102
+ yield s
103
+ s.close()
104
+
105
+
106
+ @pytest.fixture
107
+ def closed_port():
108
+ """A localhost port with nothing listening (connection refused fast)."""
109
+ s = socket.socket()
110
+ s.bind(("127.0.0.1", 0))
111
+ port = s.getsockname()[1]
112
+ s.close()
113
+ return port
114
+
115
+
116
+ @pytest.fixture
117
+ def fast(monkeypatch):
118
+ """Speed up SDK timing knobs so tests run in milliseconds."""
119
+ from lossy import client
120
+ monkeypatch.setattr(client, "FLUSH_INTERVAL", 0.05)
121
+ monkeypatch.setattr(client, "BACKOFF_BASE", 0.01)
122
+ monkeypatch.setattr(client, "MAX_RETRIES", 3)
123
+ monkeypatch.setattr(client, "HTTP_TIMEOUT", 2.0)
124
+ monkeypatch.setattr(client, "FINISH_TIMEOUT", 5.0)
125
+ monkeypatch.setenv("LOSSY_TOKEN", "lossy_test_token")
126
+ return client
@@ -0,0 +1,21 @@
1
+ """Ingest bodies must stay <=64KB (S1 rate/size limit); big batches split."""
2
+ import lossy
3
+
4
+ LIMIT = 64 * 1024
5
+
6
+
7
+ def test_bodies_stay_under_64kb_and_points_survive_the_split(stub, fast):
8
+ run = lossy.init(project="p", name="chunky", _api=stub.url)
9
+ blob = "x" * 600
10
+ for i in range(200): # ~120KB of points if sent as one body
11
+ run.log({"loss": 0.1, "note": blob}, step=i)
12
+ run.finish()
13
+
14
+ logs = stub.by_action("log")
15
+ assert len(logs) >= 2, "oversized batch should split into multiple bodies"
16
+ for r in stub.snapshot():
17
+ assert r["raw_len"] <= LIMIT
18
+ steps = [p["step"] for r in logs for p in r["body"]["points"]]
19
+ assert steps == list(range(200))
20
+ seqs = [r["body"]["seq"] for r in logs]
21
+ assert len(set(seqs)) == len(seqs), "each shipped body needs its own seq"
@@ -0,0 +1,95 @@
1
+ """Crash honesty: atexit + sys.excepthook in a REAL interpreter (subprocess).
2
+
3
+ In-process pytest can't exercise excepthook/atexit truthfully, so these
4
+ spawn `python -c` children pointed at the stub server.
5
+ """
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ SDK_DIR = str(Path(__file__).resolve().parents[1])
12
+
13
+
14
+ def _run_child(code, stub, extra_env=None):
15
+ env = os.environ.copy()
16
+ env.update({
17
+ "PYTHONPATH": SDK_DIR,
18
+ "LOSSY_TOKEN": "lossy_child_token",
19
+ "LOSSY_API": stub.url,
20
+ # fast timing so the child exits quickly
21
+ "LOSSY_FLUSH_INTERVAL": "0.05",
22
+ "LOSSY_BACKOFF_BASE": "0.01",
23
+ "LOSSY_FINISH_TIMEOUT": "5",
24
+ "LOSSY_HTTP_TIMEOUT": "2",
25
+ })
26
+ env.update(extra_env or {})
27
+ return subprocess.run([sys.executable, "-c", code], env=env,
28
+ capture_output=True, text=True, timeout=30)
29
+
30
+
31
+ def test_uncaught_exception_reports_crashed_with_summary(stub):
32
+ proc = _run_child(
33
+ "import lossy\n"
34
+ "lossy.init(project='p', name='doomed')\n"
35
+ "lossy.log({'loss': 1.0}, step=1)\n"
36
+ "raise RuntimeError('simulated crash for Lossy testing')\n",
37
+ stub)
38
+ assert proc.returncode != 0
39
+ assert "RuntimeError" in proc.stderr # traceback still reaches the user
40
+
41
+ finishes = stub.by_action("finish")
42
+ assert len(finishes) == 1, "hooks must not double-fire"
43
+ body = finishes[0]["body"]
44
+ assert body["state"] == "crashed"
45
+ assert body["exitInfo"]["type"] == "RuntimeError"
46
+ assert "simulated crash" in body["exitInfo"]["message"]
47
+
48
+
49
+ def test_clean_finish_reports_finished_exactly_once(stub):
50
+ proc = _run_child(
51
+ "import lossy\n"
52
+ "run = lossy.init(project='p', name='clean')\n"
53
+ "run.log({'loss': 0.5}, step=1)\n"
54
+ "run.finish()\n",
55
+ stub)
56
+ assert proc.returncode == 0
57
+ finishes = stub.by_action("finish")
58
+ assert len(finishes) == 1, "atexit must not re-finish after finish()"
59
+ assert finishes[0]["body"]["state"] == "finished"
60
+
61
+
62
+ def test_exit_without_finish_autofinishes_as_finished(stub):
63
+ # No exception, no finish() call: atexit closes the run honestly as
64
+ # finished (a clean exit is not a crash; SIGKILL/OOM honesty is the
65
+ # server liveness sweep's job, per plan S1).
66
+ proc = _run_child(
67
+ "import lossy\n"
68
+ "lossy.init(project='p', name='forgetful')\n"
69
+ "lossy.log({'loss': 0.5}, step=1)\n",
70
+ stub)
71
+ assert proc.returncode == 0
72
+ finishes = stub.by_action("finish")
73
+ assert len(finishes) == 1
74
+ assert finishes[0]["body"]["state"] == "finished"
75
+
76
+
77
+ def test_crash_with_server_down_still_exits_promptly(closed_port):
78
+ env = os.environ.copy()
79
+ env.update({
80
+ "PYTHONPATH": SDK_DIR,
81
+ "LOSSY_TOKEN": "t",
82
+ "LOSSY_API": f"http://127.0.0.1:{closed_port}",
83
+ "LOSSY_FLUSH_INTERVAL": "0.05",
84
+ "LOSSY_BACKOFF_BASE": "0.01",
85
+ "LOSSY_FINISH_TIMEOUT": "3",
86
+ "LOSSY_HTTP_TIMEOUT": "1",
87
+ })
88
+ proc = subprocess.run(
89
+ [sys.executable, "-c",
90
+ "import lossy\n"
91
+ "lossy.init(project='p', name='offline-crash')\n"
92
+ "raise ValueError('boom')\n"],
93
+ env=env, capture_output=True, text=True, timeout=20)
94
+ assert proc.returncode != 0
95
+ assert "ValueError" in proc.stderr
@@ -0,0 +1,111 @@
1
+ """start/log/finish lifecycle, auth header, batching triggers."""
2
+ import lossy
3
+
4
+
5
+ def test_start_log_finish_lifecycle(stub, fast):
6
+ run = lossy.init(project="proj", name="lifecycle", config={"lr": 3e-4},
7
+ _api=stub.url)
8
+ run.log({"train/loss": 1.5}, step=1)
9
+ run.log({"train/loss": 1.2}, step=2)
10
+ run.finish()
11
+
12
+ starts = stub.by_action("start")
13
+ assert len(starts) == 1
14
+ body = starts[0]["body"]
15
+ assert body["run"]["name"] == "lifecycle"
16
+ assert body["run"]["project"] == "proj"
17
+ assert body["run"]["config"]["lr"] == 3e-4
18
+
19
+ logs = stub.by_action("log")
20
+ assert logs, "log batch never arrived"
21
+ points = [p for r in logs for p in r["body"]["points"]]
22
+ assert [p["step"] for p in points] == [1, 2]
23
+ assert points[0]["metrics"]["train/loss"] == 1.5
24
+ assert all("ts" in p for p in points)
25
+ assert all(r["body"]["runId"] == stub.run_id for r in logs)
26
+
27
+ finishes = stub.by_action("finish")
28
+ assert len(finishes) == 1
29
+ assert finishes[0]["body"]["state"] == "finished"
30
+ assert finishes[0]["body"]["runId"] == stub.run_id
31
+
32
+
33
+ def test_nested_config_survives_as_real_json(stub, fast):
34
+ class Opt:
35
+ def __repr__(self):
36
+ return "<Opt adam>"
37
+
38
+ run = lossy.init(
39
+ project="p", name="nested", _api=stub.url,
40
+ config={"optimizer": {"lr": 1e-3, "betas": [0.9, 0.999]},
41
+ "obj": Opt()})
42
+ run.finish()
43
+ cfg = stub.by_action("start")[0]["body"]["run"]["config"]
44
+ # nested dicts/lists arrive as real JSON, not a Python-repr string
45
+ assert cfg["optimizer"] == {"lr": 1e-3, "betas": [0.9, 0.999]}
46
+ # a non-serializable leaf falls back to str
47
+ assert cfg["obj"] == "<Opt adam>"
48
+
49
+
50
+ def test_jsonable_depth_cap_guards_against_cycles():
51
+ import json
52
+ from lossy.client import _jsonable, _JSONABLE_MAX_DEPTH
53
+ d: dict = {}
54
+ d["self"] = d # would recurse forever without the cap
55
+ out = _jsonable(d)
56
+ json.dumps(out) # cycle broken -> serializable
57
+ depth = 0
58
+ while isinstance(out, dict):
59
+ out = out["self"]
60
+ depth += 1
61
+ assert isinstance(out, str)
62
+ assert depth == _JSONABLE_MAX_DEPTH
63
+
64
+
65
+ def test_bearer_token_header_on_every_request(stub, fast, monkeypatch):
66
+ monkeypatch.setenv("LOSSY_TOKEN", "lossy_sekrit")
67
+ run = lossy.init(project="p", name="auth", _api=stub.url)
68
+ run.log({"a": 1})
69
+ run.finish()
70
+ snap = stub.snapshot()
71
+ assert snap
72
+ for r in snap:
73
+ assert r["headers"].get("Authorization") == "Bearer lossy_sekrit"
74
+ assert r["path"] == "/api/ingest"
75
+
76
+
77
+ def test_batch_of_50_flushes_before_interval(stub, fast, monkeypatch):
78
+ from lossy import client
79
+ monkeypatch.setattr(client, "FLUSH_INTERVAL", 60.0) # only size can trigger
80
+ run = lossy.init(project="p", name="batch50", _api=stub.url)
81
+ for i in range(50):
82
+ run.log({"loss": i * 0.1}, step=i)
83
+ stub.wait_for(lambda snap: any(
84
+ r["body"] and r["body"].get("action") == "log" for r in snap), timeout=3.0)
85
+ run.finish()
86
+
87
+
88
+ def test_small_batch_flushes_on_interval(stub, fast):
89
+ run = lossy.init(project="p", name="timeflush", _api=stub.url)
90
+ run.log({"loss": 0.5}, step=1)
91
+ stub.wait_for(lambda snap: any(
92
+ r["body"] and r["body"].get("action") == "log" for r in snap), timeout=3.0)
93
+ run.finish()
94
+
95
+
96
+ def test_auto_step_increments_when_omitted(stub, fast):
97
+ run = lossy.init(project="p", name="autostep", _api=stub.url)
98
+ run.log({"a": 1})
99
+ run.log({"a": 2})
100
+ run.finish()
101
+ points = [p for r in stub.by_action("log") for p in r["body"]["points"]]
102
+ assert [p["step"] for p in points] == [1, 2]
103
+
104
+
105
+ def test_module_level_log_and_finish(stub, fast):
106
+ lossy.init(project="p", name="module-api", _api=stub.url)
107
+ lossy.log({"loss": 0.9}, step=7)
108
+ lossy.finish()
109
+ points = [p for r in stub.by_action("log") for p in r["body"]["points"]]
110
+ assert points and points[0]["step"] == 7
111
+ assert len(stub.by_action("finish")) == 1
@@ -0,0 +1,47 @@
1
+ """Offline-server resilience and the no-token no-op mode.
2
+
3
+ The product contract: importing/using lossy must NEVER break or stall a
4
+ training script — server down, token missing, whatever.
5
+ """
6
+ import time
7
+
8
+ import lossy
9
+
10
+
11
+ def test_training_completes_with_server_down(fast, closed_port, capsys):
12
+ api = f"http://127.0.0.1:{closed_port}"
13
+ t0 = time.monotonic()
14
+ run = lossy.init(project="p", name="offline", _api=api)
15
+ for i in range(200):
16
+ run.log({"loss": 1.0 / (i + 1)}, step=i)
17
+ run.finish()
18
+ assert time.monotonic() - t0 < 5.0, "offline run must not stall training"
19
+ err = capsys.readouterr().err
20
+ assert "lossy" in err # failures surface as warnings...
21
+ # ...and never as exceptions (reaching this line is the assertion)
22
+
23
+
24
+ def test_no_token_is_inert_with_single_warning(monkeypatch, capsys):
25
+ monkeypatch.delenv("LOSSY_TOKEN", raising=False)
26
+ from lossy import client
27
+ client._WARNED_GLOBAL.discard("no-token")
28
+ run = lossy.init(project="p", name="untethered")
29
+ for i in range(10):
30
+ run.log({"loss": i})
31
+ run.finish()
32
+ lossy.finish() # module-level double-finish is safe too
33
+ err = capsys.readouterr().err
34
+ assert err.count("LOSSY_TOKEN") == 1, err
35
+
36
+ # second init in the same process stays quiet
37
+ run2 = lossy.init(project="p", name="untethered2")
38
+ run2.log({"a": 1})
39
+ run2.finish()
40
+ assert "LOSSY_TOKEN" not in capsys.readouterr().err
41
+
42
+
43
+ def test_log_before_init_warns_not_raises(monkeypatch, capsys):
44
+ monkeypatch.setattr(lossy, "run", None)
45
+ lossy.log({"loss": 1.0})
46
+ lossy.finish()
47
+ assert "init" in capsys.readouterr().err
@@ -0,0 +1,35 @@
1
+ """Bounded queue: drop-oldest beyond the cap, exactly one stderr warning."""
2
+ import lossy
3
+
4
+
5
+ def test_drop_oldest_beyond_cap_with_single_warning(stub, fast, monkeypatch, capsys):
6
+ from lossy import client
7
+ monkeypatch.setattr(client, "QUEUE_MAX", 100)
8
+ monkeypatch.setattr(client, "FLUSH_INTERVAL", 60.0)
9
+ monkeypatch.setattr(client, "BATCH_SIZE", 10_000) # worker never drains early
10
+ run = lossy.init(project="p", name="dropper", _api=stub.url)
11
+ for i in range(1, 251):
12
+ run.log({"loss": i}, step=i)
13
+ run.finish()
14
+
15
+ err = capsys.readouterr().err
16
+ assert err.count("dropping oldest") == 1, err
17
+
18
+ points = [p for r in stub.by_action("log") for p in r["body"]["points"]]
19
+ steps = [p["step"] for p in points]
20
+ # oldest 150 dropped; the newest 100 survive in order
21
+ assert steps == list(range(151, 251))
22
+
23
+
24
+ def test_log_never_blocks_when_queue_full(stub, fast, monkeypatch):
25
+ import time
26
+ from lossy import client
27
+ monkeypatch.setattr(client, "QUEUE_MAX", 50)
28
+ monkeypatch.setattr(client, "FLUSH_INTERVAL", 60.0)
29
+ monkeypatch.setattr(client, "BATCH_SIZE", 10_000)
30
+ run = lossy.init(project="p", name="nonblock", _api=stub.url)
31
+ t0 = time.monotonic()
32
+ for i in range(5_000):
33
+ run.log({"loss": i}, step=i)
34
+ assert time.monotonic() - t0 < 2.0, "log() must never stall training"
35
+ run.finish()
@@ -0,0 +1,45 @@
1
+ """Retries with exponential backoff; sequence-number idempotency on retry."""
2
+ import lossy
3
+
4
+
5
+ def test_retry_after_500_reuses_same_sequence_number(stub, fast):
6
+ run = lossy.init(project="p", name="retry", _api=stub.url)
7
+ # let start land cleanly first
8
+ stub.wait_for(lambda snap: any(
9
+ r["body"] and r["body"].get("action") == "start" for r in snap))
10
+ stub.set_failures(2)
11
+ run.log({"loss": 0.3}, step=1)
12
+ stub.wait_for(lambda snap: len(
13
+ [r for r in snap if r["body"] and r["body"].get("action") == "log"]) >= 3,
14
+ timeout=5.0)
15
+ run.finish()
16
+
17
+ logs = stub.by_action("log")
18
+ assert len(logs) == 3 # two failed attempts + the success
19
+ seqs = [r["body"]["seq"] for r in logs]
20
+ assert seqs[0] == seqs[1] == seqs[2], "retries must resend the same seq"
21
+ assert logs[0]["body"]["points"] == logs[2]["body"]["points"]
22
+
23
+
24
+ def test_backoff_delays_grow_exponentially(stub, fast, monkeypatch):
25
+ from lossy import client
26
+ delays = []
27
+ monkeypatch.setattr(client, "_sleep", delays.append)
28
+ monkeypatch.setattr(client, "MAX_RETRIES", 3)
29
+ monkeypatch.setattr(client, "BACKOFF_BASE", 0.25)
30
+ stub.set_failures(3) # start exhausts all attempts
31
+ run = lossy.init(project="p", name="backoff", _api=stub.url)
32
+ stub.wait_for(lambda snap: len(snap) >= 3, timeout=5.0)
33
+ run.finish()
34
+ assert delays[:2] == [0.25, 0.5]
35
+
36
+
37
+ def test_distinct_batches_get_increasing_seq(stub, fast):
38
+ run = lossy.init(project="p", name="seq", _api=stub.url)
39
+ run.log({"a": 1}, step=1)
40
+ stub.wait_for(lambda snap: any(
41
+ r["body"] and r["body"].get("action") == "log" for r in snap))
42
+ run.log({"a": 2}, step=2)
43
+ run.finish()
44
+ seqs = [r["body"]["seq"] for r in stub.by_action("log")]
45
+ assert len(seqs) == 2 and seqs[0] < seqs[1]
lossy-0.1.0/uv.lock ADDED
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "lossy"
7
+ version = "0.1.0"
8
+ source = { editable = "." }