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 +5 -0
- lossy-0.1.0/PKG-INFO +63 -0
- lossy-0.1.0/README.md +55 -0
- lossy-0.1.0/lossy/__init__.py +44 -0
- lossy-0.1.0/lossy/client.py +359 -0
- lossy-0.1.0/pyproject.toml +18 -0
- lossy-0.1.0/tests/conftest.py +126 -0
- lossy-0.1.0/tests/test_body_limit.py +21 -0
- lossy-0.1.0/tests/test_crash_hooks.py +95 -0
- lossy-0.1.0/tests/test_lifecycle.py +111 -0
- lossy-0.1.0/tests/test_offline.py +47 -0
- lossy-0.1.0/tests/test_queue.py +35 -0
- lossy-0.1.0/tests/test_retry.py +45 -0
- lossy-0.1.0/uv.lock +8 -0
lossy-0.1.0/.gitignore
ADDED
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]
|