euler-train 2.2.0__tar.gz → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -6,8 +6,24 @@ from typing import Any
6
6
  from . import _dataset_contract # noqa: F401
7
7
  from .run import Run
8
8
  from .architecture import export_architecture
9
+ from .stream import (
10
+ EulerViewStreamConfig,
11
+ EulerViewStreamConsumer,
12
+ OutputStream,
13
+ OutputStreamConsumer,
14
+ StreamContext,
15
+ )
9
16
 
10
- __all__ = ["init", "Run", "export_architecture"]
17
+ __all__ = [
18
+ "EulerViewStreamConfig",
19
+ "EulerViewStreamConsumer",
20
+ "OutputStream",
21
+ "OutputStreamConsumer",
22
+ "StreamContext",
23
+ "init",
24
+ "Run",
25
+ "export_architecture",
26
+ ]
11
27
  __version__ = "0.1.0"
12
28
 
13
29
 
@@ -22,6 +38,7 @@ def init(
22
38
  run_name: str | None = None,
23
39
  evaluations: dict[str, dict] | None = None,
24
40
  mode: str | None = None,
41
+ stream: Any = None,
25
42
  ) -> Run:
26
43
  """Create a new run — or resume an existing one — and return the handle.
27
44
 
@@ -89,6 +106,11 @@ def init(
89
106
  ``"train"``, ``"val"``, or ``"eval"``). When provided,
90
107
  lifecycle fields and crash details are mirrored into
91
108
  ``meta.json`` under ``modes[mode]``.
109
+ stream:
110
+ Optional output-stream consumer or config. Passing a mapping
111
+ with ``base_url`` plus either ``stream_token`` or the pair
112
+ ``model_id`` / ``access_token`` enables best-effort dual-write
113
+ streaming to the Euler View model-run ingest API.
92
114
  """
93
115
  return Run(
94
116
  dir=dir, config=config, meta=meta,
@@ -97,4 +119,5 @@ def init(
97
119
  run_id=run_id,
98
120
  datasets=datasets, run_name=run_name,
99
121
  evaluations=evaluations, mode=mode,
122
+ stream=stream,
100
123
  )
@@ -2,15 +2,16 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import atexit
5
+ import copy
5
6
  import logging
6
7
  import os
7
8
  import re
8
9
  import secrets
9
- import warnings
10
10
  import signal
11
11
  import sys
12
12
  import time
13
13
  import traceback
14
+ import warnings
14
15
  from collections.abc import Mapping
15
16
  from pathlib import Path
16
17
  from typing import Any
@@ -20,6 +21,7 @@ from .outputs import save_output_tree
20
21
  from .slurm import get_slurm_info
21
22
  from .git_info import get_code_ref
22
23
  from .environment import get_run_environment
24
+ from .stream import OutputStream, StreamContext, coerce_output_stream
23
25
 
24
26
  log = logging.getLogger("euler_train")
25
27
 
@@ -45,6 +47,7 @@ class Run:
45
47
  run_name: str | None = None,
46
48
  evaluations: dict[str, dict[str, Any]] | None = None,
47
49
  mode: str | None = None,
50
+ stream: Any = None,
48
51
  ) -> None:
49
52
  # ── detect run-directory shorthand ────────────────────────
50
53
  # When `dir` points to an existing run directory (contains
@@ -83,6 +86,8 @@ class Run:
83
86
  self.run_name: str | None = run_name
84
87
  self.mode: str | None = _normalize_mode(mode)
85
88
  self._pending_updated_at: dict[str, dict[str, Any]] = {}
89
+ self._stream: OutputStream | None = coerce_output_stream(stream)
90
+ self._stream_event_counters: dict[str, int] = {}
86
91
 
87
92
  # ── config ────────────────────────────────────────────────
88
93
  if resuming:
@@ -97,14 +102,25 @@ class Run:
97
102
  write_json(config_path, self.config)
98
103
  self._record_artifact_update(config_path)
99
104
 
100
- # ── code ref & run environment (fresh runs only) ─────────
101
- if not resuming:
102
- code_ref_path = self.dir / "code_ref.json"
103
- write_json(code_ref_path, get_code_ref())
105
+ # ── code ref & run environment ───────────────────────────
106
+ code_ref_path = self.dir / "code_ref.json"
107
+ run_environment_path = self.dir / "run_environment.json"
108
+ if resuming:
109
+ self._code_ref_payload = (
110
+ read_json(code_ref_path) if code_ref_path.exists() else None
111
+ )
112
+ self._run_environment_payload = (
113
+ read_json(run_environment_path)
114
+ if run_environment_path.exists()
115
+ else None
116
+ )
117
+ else:
118
+ self._code_ref_payload = get_code_ref()
119
+ write_json(code_ref_path, self._code_ref_payload)
104
120
  self._record_artifact_update(code_ref_path)
105
121
 
106
- run_environment_path = self.dir / "run_environment.json"
107
- write_json(run_environment_path, get_run_environment())
122
+ self._run_environment_payload = get_run_environment()
123
+ write_json(run_environment_path, self._run_environment_payload)
108
124
  self._record_artifact_update(run_environment_path)
109
125
 
110
126
  # ── meta ──────────────────────────────────────────────────
@@ -161,6 +177,8 @@ class Run:
161
177
  )
162
178
  self._mark_mode_running()
163
179
  self._flush_meta()
180
+ self._bind_stream()
181
+ self._emit_stream_init()
164
182
  self._setup_hooks()
165
183
 
166
184
  verb = "Resumed" if resuming else "Started"
@@ -202,6 +220,19 @@ class Run:
202
220
  existing=self._meta.get("evaluations"),
203
221
  )
204
222
  self._flush_meta()
223
+ evaluations = self._meta.get("evaluations", {})
224
+ if isinstance(evaluations, dict) and key in evaluations:
225
+ self._emit_stream_event(
226
+ {
227
+ "type": "meta",
228
+ "eventId": self._make_stream_event_id("evaluation", key),
229
+ "patch": {
230
+ "evaluations": {
231
+ key: copy.deepcopy(evaluations[key]),
232
+ },
233
+ },
234
+ },
235
+ )
205
236
 
206
237
  def finish_evaluation(self, key: str, status: str = "completed") -> None:
207
238
  """Mark an existing evaluation as finished.
@@ -216,6 +247,23 @@ class Run:
216
247
  )
217
248
  evals[key]["status"] = status
218
249
  self._flush_meta()
250
+ self._emit_stream_event(
251
+ {
252
+ "type": "meta",
253
+ "eventId": self._make_stream_event_id(
254
+ "evaluation_status",
255
+ key,
256
+ status,
257
+ ),
258
+ "patch": {
259
+ "evaluations": {
260
+ key: {
261
+ "status": status,
262
+ },
263
+ },
264
+ },
265
+ },
266
+ )
219
267
 
220
268
  # ── logging ───────────────────────────────────────────────────
221
269
 
@@ -250,6 +298,15 @@ class Run:
250
298
  append_jsonl(log_path, record)
251
299
  self._record_artifact_update(log_path)
252
300
  self._flush_meta()
301
+ split = "train" if mode == "train" else "val"
302
+ self._emit_stream_event(
303
+ {
304
+ "type": "metric",
305
+ "eventId": self._make_stream_event_id(split, epoch, step),
306
+ "split": split,
307
+ "records": [copy.deepcopy(record)],
308
+ },
309
+ )
253
310
 
254
311
  # ── GPU stats ─────────────────────────────────────────────────
255
312
 
@@ -327,6 +384,19 @@ class Run:
327
384
  if base.exists():
328
385
  self._record_artifact_update(base)
329
386
  self._flush_meta()
387
+ snapshot = self._build_output_snapshot(epoch, step, output_types)
388
+ if snapshot is not None:
389
+ self._emit_stream_event(
390
+ {
391
+ "type": "output_snapshot",
392
+ "eventId": self._make_stream_event_id(
393
+ "outputs",
394
+ epoch,
395
+ step,
396
+ ),
397
+ "snapshot": snapshot,
398
+ },
399
+ )
330
400
  return base
331
401
 
332
402
  # ── checkpoints ───────────────────────────────────────────────
@@ -450,6 +520,17 @@ class Run:
450
520
  self._meta["checkpoints"] = checkpoints
451
521
  self._record_artifact_update(checkpoint_path)
452
522
  self._flush_meta()
523
+ self._emit_stream_event(
524
+ {
525
+ "type": "checkpoint",
526
+ "eventId": self._make_stream_event_id(
527
+ "checkpoint",
528
+ epoch,
529
+ step,
530
+ ),
531
+ "checkpoint": copy.deepcopy(record),
532
+ },
533
+ )
453
534
 
454
535
  # ── architecture export ─────────────────────────────────────────
455
536
 
@@ -508,6 +589,16 @@ class Run:
508
589
  self._clear_terminal_fields(self._meta, status=status)
509
590
  self._finish_mode(status=status, end=end, duration=duration)
510
591
  self._flush_meta()
592
+ self._emit_stream_event(
593
+ {
594
+ "type": "finish",
595
+ "eventId": self._make_stream_event_id("finish", status),
596
+ "status": status,
597
+ "patch": self._build_finish_patch(),
598
+ },
599
+ flush=True,
600
+ )
601
+ self._close_stream()
511
602
 
512
603
  def detach(self) -> None:
513
604
  """Disconnect from the run without changing its status.
@@ -527,6 +618,7 @@ class Run:
527
618
  self._finished = True
528
619
  self._teardown_hooks()
529
620
  self._flush_meta()
621
+ self._close_stream()
530
622
 
531
623
  # ── context manager ───────────────────────────────────────────
532
624
 
@@ -712,6 +804,136 @@ class Run:
712
804
  self._record_artifact_update(self.dir / "meta.json", when=time.time())
713
805
  write_json(self.dir / "meta.json", self._meta)
714
806
 
807
+ def _bind_stream(self) -> None:
808
+ if self._stream is None:
809
+ return
810
+ self._stream.bind(
811
+ StreamContext(
812
+ run_id=self.run_id,
813
+ project_dir=self.project_dir,
814
+ runs_dir=self.dir.parent,
815
+ run_dir=self.dir,
816
+ meta=copy.deepcopy(self._meta),
817
+ config=copy.deepcopy(self.config),
818
+ code_ref=copy.deepcopy(self._code_ref_payload),
819
+ run_environment=copy.deepcopy(self._run_environment_payload),
820
+ ),
821
+ )
822
+
823
+ def _emit_stream_init(self) -> None:
824
+ tags = self._extract_stream_tags()
825
+ self._emit_stream_event(
826
+ {
827
+ "type": "init",
828
+ "eventId": self._make_stream_event_id("init"),
829
+ "meta": copy.deepcopy(self._meta),
830
+ "config": copy.deepcopy(self.config),
831
+ "codeRef": copy.deepcopy(self._code_ref_payload),
832
+ "runEnvironment": copy.deepcopy(self._run_environment_payload),
833
+ "tags": tags,
834
+ },
835
+ flush=True,
836
+ )
837
+
838
+ def _emit_stream_event(
839
+ self,
840
+ event: dict[str, Any],
841
+ *,
842
+ flush: bool = False,
843
+ ) -> None:
844
+ if self._stream is None:
845
+ return
846
+ self._stream.emit(event)
847
+ if flush:
848
+ self._stream.flush()
849
+
850
+ def _close_stream(self) -> None:
851
+ if self._stream is None:
852
+ return
853
+ self._stream.close()
854
+
855
+ def _extract_stream_tags(self) -> list[str] | None:
856
+ raw_tags = self._meta.get("tags")
857
+ if not isinstance(raw_tags, list):
858
+ return None
859
+ tags = [
860
+ str(tag).strip()
861
+ for tag in raw_tags
862
+ if isinstance(tag, str) and str(tag).strip()
863
+ ]
864
+ return tags or None
865
+
866
+ def _build_output_snapshot(
867
+ self,
868
+ epoch: int | None,
869
+ step: int | None,
870
+ output_types: dict[str, Any],
871
+ ) -> dict[str, Any] | None:
872
+ if epoch is None or step is None:
873
+ return None
874
+
875
+ snapshot_types: dict[str, list[str]] = {}
876
+ for output_type, slots in output_types.items():
877
+ if slots is None or not isinstance(slots, Mapping):
878
+ continue
879
+
880
+ categories: list[str] = []
881
+ for slot_name, data in slots.items():
882
+ if data is None:
883
+ continue
884
+ if slot_name == "aux" and isinstance(data, Mapping):
885
+ for aux_name, aux_value in data.items():
886
+ if aux_value is None:
887
+ continue
888
+ categories.append(f"aux/{aux_name}")
889
+ else:
890
+ categories.append(str(slot_name))
891
+
892
+ if categories:
893
+ snapshot_types[str(output_type)] = sorted(set(categories))
894
+
895
+ if not snapshot_types:
896
+ return None
897
+
898
+ return {
899
+ "epoch": epoch,
900
+ "step": step,
901
+ "types": snapshot_types,
902
+ }
903
+
904
+ def _build_finish_patch(self) -> dict[str, Any]:
905
+ patch = {
906
+ "end_time": self._meta.get("end_time"),
907
+ "end_iso": self._meta.get("end_iso"),
908
+ "duration_sec": self._meta.get("duration_sec"),
909
+ }
910
+
911
+ if "error" in self._meta:
912
+ patch["error"] = self._meta["error"]
913
+ if "traceback" in self._meta:
914
+ patch["traceback"] = self._meta["traceback"]
915
+
916
+ if self.mode is not None:
917
+ modes = self._meta.get("modes")
918
+ if isinstance(modes, dict) and self.mode in modes:
919
+ patch["modes"] = {
920
+ self.mode: copy.deepcopy(modes[self.mode]),
921
+ }
922
+
923
+ return patch
924
+
925
+ def _make_stream_event_id(self, prefix: str, *parts: Any) -> str:
926
+ counter = self._stream_event_counters.get(prefix, 0)
927
+ self._stream_event_counters[prefix] = counter + 1
928
+ tokens = [prefix]
929
+ tokens.extend(
930
+ str(part)
931
+ for part in parts
932
+ if part is not None and str(part).strip()
933
+ )
934
+ tokens.append(str(counter))
935
+ return ":".join(tokens)
936
+
715
937
  def __repr__(self) -> str:
716
938
  return f"Run(id={self.run_id!r}, dir={str(self.dir)!r}, status={self._meta['status']!r})"
717
939
 
@@ -0,0 +1,566 @@
1
+ """Optional model-run stream consumers for Euler View."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any, Protocol
13
+ from urllib.error import HTTPError, URLError
14
+ from urllib.parse import quote
15
+ from urllib.request import Request, urlopen
16
+
17
+ from .serialization import _json_default
18
+
19
+ log = logging.getLogger("euler_train.stream")
20
+
21
+
22
+ class OutputStreamConsumer(Protocol):
23
+ """Minimal interface for best-effort event consumers."""
24
+
25
+ def bind(self, context: "StreamContext") -> None:
26
+ ...
27
+
28
+ def emit(self, event: dict[str, Any]) -> None:
29
+ ...
30
+
31
+ def flush(self) -> None:
32
+ ...
33
+
34
+ def close(self) -> None:
35
+ ...
36
+
37
+
38
+ @dataclass
39
+ class StreamContext:
40
+ run_id: str
41
+ project_dir: Path
42
+ runs_dir: Path
43
+ run_dir: Path
44
+ meta: dict[str, Any]
45
+ config: dict[str, Any]
46
+ code_ref: dict[str, Any] | None
47
+ run_environment: dict[str, Any] | None
48
+
49
+
50
+ @dataclass
51
+ class EulerViewStreamConfig:
52
+ """Connection settings for the built-in Euler View HTTP consumer."""
53
+
54
+ base_url: str
55
+ model_id: int | None = None
56
+ access_token: str | None = None
57
+ stream_token: str | None = None
58
+ datasource_id: int | None = None
59
+ euler_train_dir: str | None = None
60
+ run_dir: str | None = None
61
+ timeout_sec: float = 10.0
62
+ batch_size: int = 20
63
+ flush_interval_sec: float = 2.0
64
+ max_pending_events: int = 1000
65
+ session_expires_in_sec: int | None = None
66
+
67
+ def __post_init__(self) -> None:
68
+ if not isinstance(self.base_url, str):
69
+ raise ValueError("stream.base_url must be a string")
70
+ self.base_url = self.base_url.strip().rstrip("/")
71
+ if not self.base_url:
72
+ raise ValueError("stream.base_url is required")
73
+
74
+ self.access_token = _normalize_optional_text(
75
+ self.access_token,
76
+ field_name="stream.access_token",
77
+ )
78
+ self.stream_token = _normalize_optional_text(
79
+ self.stream_token,
80
+ field_name="stream.stream_token",
81
+ )
82
+ self.euler_train_dir = _normalize_optional_text(
83
+ self.euler_train_dir,
84
+ field_name="stream.euler_train_dir",
85
+ )
86
+ self.run_dir = _normalize_optional_text(
87
+ self.run_dir,
88
+ field_name="stream.run_dir",
89
+ )
90
+
91
+ self.model_id = _require_positive_int(
92
+ self.model_id,
93
+ field_name="stream.model_id",
94
+ allow_none=True,
95
+ )
96
+ self.datasource_id = _require_positive_int(
97
+ self.datasource_id,
98
+ field_name="stream.datasource_id",
99
+ allow_none=True,
100
+ )
101
+ self.session_expires_in_sec = _require_positive_int(
102
+ self.session_expires_in_sec,
103
+ field_name="stream.session_expires_in_sec",
104
+ allow_none=True,
105
+ )
106
+ self.batch_size = _require_positive_int(
107
+ self.batch_size,
108
+ field_name="stream.batch_size",
109
+ )
110
+ self.max_pending_events = _require_positive_int(
111
+ self.max_pending_events,
112
+ field_name="stream.max_pending_events",
113
+ )
114
+ self.timeout_sec = _require_positive_number(
115
+ self.timeout_sec,
116
+ field_name="stream.timeout_sec",
117
+ )
118
+ self.flush_interval_sec = _require_non_negative_number(
119
+ self.flush_interval_sec,
120
+ field_name="stream.flush_interval_sec",
121
+ )
122
+
123
+ if self.stream_token is None:
124
+ if self.model_id is None:
125
+ raise ValueError(
126
+ "stream.model_id is required when stream.stream_token is not provided",
127
+ )
128
+ if self.access_token is None:
129
+ raise ValueError(
130
+ "stream.access_token is required when stream.stream_token is not provided",
131
+ )
132
+
133
+
134
+ class OutputStream:
135
+ """Dispatch events to one or more best-effort consumers."""
136
+
137
+ def __init__(self, consumers: Sequence[OutputStreamConsumer]) -> None:
138
+ self._consumers = list(consumers)
139
+ self._closed = False
140
+
141
+ @property
142
+ def consumers(self) -> tuple[OutputStreamConsumer, ...]:
143
+ return tuple(self._consumers)
144
+
145
+ def bind(self, context: StreamContext) -> None:
146
+ self._dispatch("bind", context)
147
+
148
+ def emit(self, event: dict[str, Any]) -> None:
149
+ self._dispatch("emit", event)
150
+
151
+ def flush(self) -> None:
152
+ self._dispatch("flush")
153
+
154
+ def close(self) -> None:
155
+ if self._closed:
156
+ return
157
+ self._dispatch("flush")
158
+ self._dispatch("close")
159
+ self._closed = True
160
+
161
+ def _dispatch(self, method_name: str, *args: Any) -> None:
162
+ if self._closed and method_name != "close":
163
+ return
164
+
165
+ active: list[OutputStreamConsumer] = []
166
+ for consumer in self._consumers:
167
+ method = getattr(consumer, method_name)
168
+ payload = copy.deepcopy(args)
169
+ try:
170
+ method(*payload)
171
+ active.append(consumer)
172
+ except Exception as exc: # pragma: no cover - defensive safeguard
173
+ log.warning(
174
+ "Disabling stream consumer after %s failure: %s",
175
+ method_name,
176
+ exc,
177
+ )
178
+ self._consumers = active
179
+
180
+
181
+ class EulerViewStreamConsumer:
182
+ """Best-effort HTTP consumer for the model-run streaming API."""
183
+
184
+ def __init__(self, config: EulerViewStreamConfig) -> None:
185
+ self.config = config
186
+ self._context: StreamContext | None = None
187
+ self._pending: list[dict[str, Any]] = []
188
+ self._stream_token: str | None = config.stream_token
189
+ self._session_expires_at = float("inf") if config.stream_token else 0.0
190
+ self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
191
+ self._last_flush_at = 0.0
192
+ self._next_retry_at = 0.0
193
+ self._last_warning: tuple[str, float] | None = None
194
+ self._closed = False
195
+
196
+ def bind(self, context: StreamContext) -> None:
197
+ self._context = context
198
+
199
+ def emit(self, event: dict[str, Any]) -> None:
200
+ if self._closed:
201
+ return
202
+
203
+ try:
204
+ payload = _to_jsonable(event)
205
+ except Exception as exc:
206
+ self._warn(
207
+ "Dropping stream event that could not be serialized",
208
+ exc,
209
+ )
210
+ return
211
+
212
+ self._pending.append(payload)
213
+ self._trim_pending()
214
+
215
+ force = payload.get("type") in {
216
+ "init",
217
+ "checkpoint",
218
+ "output_snapshot",
219
+ "finish",
220
+ }
221
+ self._maybe_flush(force=force)
222
+
223
+ def flush(self) -> None:
224
+ self._flush_pending(force=True)
225
+
226
+ def close(self) -> None:
227
+ if self._closed:
228
+ return
229
+ self._flush_pending(force=True)
230
+ self._closed = True
231
+
232
+ def _maybe_flush(self, *, force: bool = False) -> None:
233
+ if not self._pending:
234
+ return
235
+
236
+ now = time.time()
237
+ should_flush = force
238
+ should_flush = should_flush or len(self._pending) >= self.config.batch_size
239
+ should_flush = should_flush or (
240
+ now - self._last_flush_at >= self.config.flush_interval_sec
241
+ )
242
+ if should_flush:
243
+ self._flush_pending(force=force)
244
+
245
+ def _flush_pending(self, *, force: bool = False) -> None:
246
+ if not self._pending:
247
+ return
248
+
249
+ now = time.time()
250
+ if not force and now < self._next_retry_at:
251
+ return
252
+
253
+ if not self._ensure_session(force=force):
254
+ return
255
+
256
+ while self._pending:
257
+ batch = self._pending[: self.config.batch_size]
258
+ try:
259
+ self._post_json(
260
+ self._ingest_url,
261
+ {"events": batch},
262
+ token=self._stream_token,
263
+ )
264
+ except Exception as exc:
265
+ self._schedule_retry()
266
+ self._warn("Model-run stream ingest failed", exc)
267
+ return
268
+
269
+ del self._pending[: len(batch)]
270
+ self._last_flush_at = time.time()
271
+ self._next_retry_at = 0.0
272
+
273
+ def _ensure_session(self, *, force: bool = False) -> bool:
274
+ if self._stream_token and time.time() + 5 < self._session_expires_at:
275
+ return True
276
+
277
+ if self.config.stream_token:
278
+ self._stream_token = self.config.stream_token
279
+ self._session_expires_at = float("inf")
280
+ return True
281
+
282
+ if self._context is None:
283
+ return False
284
+
285
+ now = time.time()
286
+ if not force and now < self._next_retry_at:
287
+ return False
288
+
289
+ session_url = (
290
+ f"{self.config.base_url}/api/models/{self.config.model_id}"
291
+ f"/runs/{quote(self._context.run_id, safe='')}/stream/session"
292
+ )
293
+ session_body: dict[str, Any] = {
294
+ "runDir": self.config.run_dir or str(self._context.run_dir),
295
+ "eulerTrainDir": self.config.euler_train_dir or str(self._context.runs_dir),
296
+ }
297
+ if self.config.datasource_id is not None:
298
+ session_body["datasourceId"] = self.config.datasource_id
299
+ if self.config.session_expires_in_sec is not None:
300
+ session_body["expiresInSec"] = self.config.session_expires_in_sec
301
+
302
+ try:
303
+ payload = self._post_json(
304
+ session_url,
305
+ session_body,
306
+ token=self.config.access_token,
307
+ )
308
+ except Exception as exc:
309
+ self._schedule_retry()
310
+ self._warn("Model-run stream session setup failed", exc)
311
+ return False
312
+
313
+ token = payload.get("token")
314
+ if not isinstance(token, str) or not token.strip():
315
+ self._schedule_retry()
316
+ self._warn("Model-run stream session response did not contain a token")
317
+ return False
318
+
319
+ self._stream_token = token.strip()
320
+ ingest_url = payload.get("ingestUrl")
321
+ if isinstance(ingest_url, str) and ingest_url.strip():
322
+ self._ingest_url = ingest_url.strip()
323
+ else:
324
+ self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
325
+
326
+ self._session_expires_at = _parse_timestamp(payload.get("expiresAt"))
327
+ self._next_retry_at = 0.0
328
+ return True
329
+
330
+ def _post_json(
331
+ self,
332
+ url: str,
333
+ payload: dict[str, Any],
334
+ *,
335
+ token: str | None,
336
+ ) -> dict[str, Any]:
337
+ headers = {
338
+ "Accept": "application/json",
339
+ "Content-Type": "application/json",
340
+ }
341
+ if token:
342
+ headers["Authorization"] = f"Bearer {token}"
343
+
344
+ request = Request(
345
+ url,
346
+ data=json.dumps(payload, default=_json_default).encode("utf-8"),
347
+ headers=headers,
348
+ method="POST",
349
+ )
350
+
351
+ try:
352
+ with urlopen(request, timeout=self.config.timeout_sec) as response:
353
+ raw = response.read()
354
+ except HTTPError as exc:
355
+ message = _extract_http_error_message(exc)
356
+ raise RuntimeError(message) from exc
357
+ except URLError as exc:
358
+ reason = exc.reason if exc.reason else str(exc)
359
+ raise RuntimeError(str(reason)) from exc
360
+ except OSError as exc:
361
+ raise RuntimeError(str(exc)) from exc
362
+
363
+ if not raw:
364
+ return {}
365
+
366
+ try:
367
+ parsed = json.loads(raw.decode("utf-8"))
368
+ except json.JSONDecodeError as exc:
369
+ raise RuntimeError("Invalid JSON response from stream API") from exc
370
+
371
+ if isinstance(parsed, dict):
372
+ return parsed
373
+ raise RuntimeError("Unexpected stream API response payload")
374
+
375
+ def _trim_pending(self) -> None:
376
+ overflow = len(self._pending) - self.config.max_pending_events
377
+ if overflow <= 0:
378
+ return
379
+ del self._pending[:overflow]
380
+ self._warn(
381
+ f"Dropped {overflow} pending stream event(s) after queue overflow",
382
+ )
383
+
384
+ def _schedule_retry(self) -> None:
385
+ backoff = max(self.config.flush_interval_sec, 2.0)
386
+ self._next_retry_at = time.time() + backoff
387
+
388
+ def _warn(self, message: str, exc: Exception | None = None) -> None:
389
+ detail = f"{message}: {exc}" if exc is not None else message
390
+ now = time.time()
391
+ if self._last_warning is not None:
392
+ previous_detail, previous_at = self._last_warning
393
+ if previous_detail == detail and now - previous_at < 30:
394
+ return
395
+
396
+ log.warning(detail)
397
+ self._last_warning = (detail, now)
398
+
399
+
400
+ def coerce_output_stream(spec: Any) -> OutputStream | None:
401
+ if spec is None:
402
+ return None
403
+ if isinstance(spec, OutputStream):
404
+ return spec
405
+ if isinstance(spec, EulerViewStreamConfig):
406
+ return OutputStream([EulerViewStreamConsumer(spec)])
407
+ if isinstance(spec, Mapping):
408
+ return OutputStream([EulerViewStreamConsumer(_config_from_mapping(spec))])
409
+ if _looks_like_consumer(spec):
410
+ return OutputStream([spec])
411
+ if isinstance(spec, Sequence) and not isinstance(spec, (str, bytes, bytearray)):
412
+ consumers: list[OutputStreamConsumer] = []
413
+ for item in spec:
414
+ stream = coerce_output_stream(item)
415
+ if stream is None:
416
+ continue
417
+ consumers.extend(stream.consumers)
418
+ return OutputStream(consumers)
419
+ raise TypeError(
420
+ "stream must be a mapping, EulerViewStreamConfig, consumer, or sequence of consumers",
421
+ )
422
+
423
+
424
+ def _looks_like_consumer(value: Any) -> bool:
425
+ return all(
426
+ callable(getattr(value, name, None))
427
+ for name in ("bind", "emit", "flush", "close")
428
+ )
429
+
430
+
431
+ def _config_from_mapping(raw: Mapping[str, Any]) -> EulerViewStreamConfig:
432
+ provider = _mapping_get(raw, "provider", "kind", "type")
433
+ if provider is not None:
434
+ normalized = str(provider).strip().lower().replace("-", "_")
435
+ if normalized not in {"euler_view", "eulerview"}:
436
+ raise ValueError(
437
+ f"Unsupported stream provider {provider!r}; expected 'euler_view'",
438
+ )
439
+
440
+ return EulerViewStreamConfig(
441
+ base_url=_mapping_get(raw, "base_url", "baseUrl"),
442
+ model_id=_mapping_get(raw, "model_id", "modelId"),
443
+ access_token=_mapping_get(raw, "access_token", "accessToken"),
444
+ stream_token=_mapping_get(raw, "stream_token", "streamToken"),
445
+ datasource_id=_mapping_get(raw, "datasource_id", "datasourceId"),
446
+ euler_train_dir=_mapping_get(raw, "euler_train_dir", "eulerTrainDir"),
447
+ run_dir=_mapping_get(raw, "run_dir", "runDir"),
448
+ timeout_sec=_mapping_get(raw, "timeout_sec", "timeoutSec", default=10.0),
449
+ batch_size=_mapping_get(raw, "batch_size", "batchSize", default=20),
450
+ flush_interval_sec=_mapping_get(
451
+ raw,
452
+ "flush_interval_sec",
453
+ "flushIntervalSec",
454
+ default=2.0,
455
+ ),
456
+ max_pending_events=_mapping_get(
457
+ raw,
458
+ "max_pending_events",
459
+ "maxPendingEvents",
460
+ default=1000,
461
+ ),
462
+ session_expires_in_sec=_mapping_get(
463
+ raw,
464
+ "session_expires_in_sec",
465
+ "sessionExpiresInSec",
466
+ ),
467
+ )
468
+
469
+
470
+ def _mapping_get(
471
+ mapping: Mapping[str, Any],
472
+ *names: str,
473
+ default: Any = None,
474
+ ) -> Any:
475
+ for name in names:
476
+ if name in mapping:
477
+ return mapping[name]
478
+ return default
479
+
480
+
481
+ def _normalize_optional_text(
482
+ value: Any,
483
+ *,
484
+ field_name: str,
485
+ ) -> str | None:
486
+ if value is None:
487
+ return None
488
+ if not isinstance(value, str):
489
+ raise ValueError(f"{field_name} must be a string when provided")
490
+ normalized = value.strip()
491
+ return normalized or None
492
+
493
+
494
+ def _require_positive_int(
495
+ value: Any,
496
+ *,
497
+ field_name: str,
498
+ allow_none: bool = False,
499
+ ) -> int | None:
500
+ if value is None:
501
+ if allow_none:
502
+ return None
503
+ raise ValueError(f"{field_name} is required")
504
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
505
+ raise ValueError(f"{field_name} must be a positive integer")
506
+ return value
507
+
508
+
509
+ def _require_positive_number(value: Any, *, field_name: str) -> float:
510
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
511
+ raise ValueError(f"{field_name} must be a positive number")
512
+ return float(value)
513
+
514
+
515
+ def _require_non_negative_number(value: Any, *, field_name: str) -> float:
516
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
517
+ raise ValueError(f"{field_name} must be a non-negative number")
518
+ return float(value)
519
+
520
+
521
+ def _parse_timestamp(value: Any) -> float:
522
+ if not isinstance(value, str) or not value.strip():
523
+ return time.time() + 300
524
+
525
+ try:
526
+ return datetime.fromisoformat(
527
+ value.replace("Z", "+00:00"),
528
+ ).timestamp()
529
+ except ValueError:
530
+ return time.time() + 300
531
+
532
+
533
+ def _extract_http_error_message(error: HTTPError) -> str:
534
+ try:
535
+ raw = error.read()
536
+ except OSError:
537
+ raw = b""
538
+
539
+ if raw:
540
+ try:
541
+ payload = json.loads(raw.decode("utf-8"))
542
+ except (UnicodeDecodeError, json.JSONDecodeError):
543
+ text = raw.decode("utf-8", errors="replace").strip()
544
+ if text:
545
+ return f"HTTP {error.code}: {text}"
546
+ else:
547
+ if isinstance(payload, dict):
548
+ body_error = payload.get("error")
549
+ if isinstance(body_error, str) and body_error.strip():
550
+ return f"HTTP {error.code}: {body_error.strip()}"
551
+
552
+ return f"HTTP {error.code}: {error.reason}"
553
+
554
+
555
+ def _to_jsonable(value: Any) -> Any:
556
+ return json.loads(json.dumps(value, default=_json_default))
557
+
558
+
559
+ __all__ = [
560
+ "EulerViewStreamConfig",
561
+ "EulerViewStreamConsumer",
562
+ "OutputStream",
563
+ "OutputStreamConsumer",
564
+ "StreamContext",
565
+ "coerce_output_stream",
566
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -9,6 +9,7 @@ euler_train/outputs.py
9
9
  euler_train/run.py
10
10
  euler_train/serialization.py
11
11
  euler_train/slurm.py
12
+ euler_train/stream.py
12
13
  euler_train.egg-info/PKG-INFO
13
14
  euler_train.egg-info/SOURCES.txt
14
15
  euler_train.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-train"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Lightweight file-based experiment logger for PyTorch"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = ["numpy", "euler-dataset-contract"]
@@ -7,6 +7,7 @@ import signal
7
7
  import sys
8
8
  from argparse import Namespace
9
9
  from pathlib import Path
10
+ from urllib.error import URLError
10
11
 
11
12
  import numpy as np
12
13
  import pytest
@@ -14,6 +15,7 @@ import torch
14
15
  from PIL import Image
15
16
 
16
17
  import euler_train
18
+ import euler_train.stream as stream_mod
17
19
 
18
20
  # ═══════════════════════════════════════════════════════════════════════════
19
21
  # Helpers
@@ -72,6 +74,40 @@ class _DatasetWithRunlogDescription:
72
74
  return self._description
73
75
 
74
76
 
77
+ class _RecordingStreamConsumer:
78
+ def __init__(self) -> None:
79
+ self.context = None
80
+ self.events: list[dict] = []
81
+ self.flush_count = 0
82
+ self.close_count = 0
83
+
84
+ def bind(self, context) -> None:
85
+ self.context = context
86
+
87
+ def emit(self, event: dict) -> None:
88
+ self.events.append(json.loads(json.dumps(event)))
89
+
90
+ def flush(self) -> None:
91
+ self.flush_count += 1
92
+
93
+ def close(self) -> None:
94
+ self.close_count += 1
95
+
96
+
97
+ class _FakeHttpResponse:
98
+ def __init__(self, payload: dict) -> None:
99
+ self._payload = payload
100
+
101
+ def read(self) -> bytes:
102
+ return json.dumps(self._payload).encode("utf-8")
103
+
104
+ def __enter__(self):
105
+ return self
106
+
107
+ def __exit__(self, exc_type, exc, tb) -> bool:
108
+ return False
109
+
110
+
75
111
  # ═══════════════════════════════════════════════════════════════════════════
76
112
  # init / config normalisation
77
113
  # ═══════════════════════════════════════════════════════════════════════════
@@ -1605,6 +1641,228 @@ class TestLogSavedCheckpoint:
1605
1641
  run.finish()
1606
1642
 
1607
1643
 
1644
+ # ═══════════════════════════════════════════════════════════════════════════
1645
+ # streaming
1646
+ # ═══════════════════════════════════════════════════════════════════════════
1647
+
1648
+ class TestStreaming:
1649
+ def test_run_emits_expected_stream_events(self, tmp_path):
1650
+ consumer = _RecordingStreamConsumer()
1651
+
1652
+ run = euler_train.init(
1653
+ dir=str(tmp_path / "r"),
1654
+ config={"lr": 1e-3},
1655
+ meta={"tags": ["baseline", "fast"]},
1656
+ stream=consumer,
1657
+ )
1658
+ run.add_evaluation(
1659
+ "eval_rgb",
1660
+ name="RGB Eval",
1661
+ status="running",
1662
+ metadata={"seed": 7},
1663
+ )
1664
+ run.finish_evaluation("eval_rgb")
1665
+ run.log({"loss": 0.5}, step=0, epoch=0, mode="train")
1666
+ run.log({"psnr": 28.1}, step=1, epoch=0, mode="val")
1667
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1668
+ aux = np.ones((4, 4), dtype=np.float32)
1669
+ run.save_outputs(
1670
+ epoch=0,
1671
+ step=1,
1672
+ rgb=dict(pred=arr, gt=arr),
1673
+ depth=dict(aux=dict(transmission=aux)),
1674
+ )
1675
+ run.log_saved_checkpoint("/tmp/model.pt", epoch=0, step=1, is_best=True)
1676
+ run.finish()
1677
+
1678
+ assert consumer.context.run_id == run.run_id
1679
+ assert consumer.context.run_dir == run.dir
1680
+ assert consumer.close_count == 1
1681
+ assert consumer.flush_count >= 2
1682
+
1683
+ event_types = [event["type"] for event in consumer.events]
1684
+ assert event_types == [
1685
+ "init",
1686
+ "meta",
1687
+ "meta",
1688
+ "metric",
1689
+ "metric",
1690
+ "output_snapshot",
1691
+ "checkpoint",
1692
+ "finish",
1693
+ ]
1694
+
1695
+ init_event = consumer.events[0]
1696
+ assert init_event["meta"]["run_id"] == run.run_id
1697
+ assert init_event["config"] == {"lr": 1e-3}
1698
+ assert init_event["tags"] == ["baseline", "fast"]
1699
+ assert isinstance(init_event["codeRef"], dict)
1700
+ assert isinstance(init_event["runEnvironment"], dict)
1701
+
1702
+ add_eval_event = consumer.events[1]
1703
+ assert add_eval_event["patch"] == {
1704
+ "evaluations": {
1705
+ "eval_rgb": {
1706
+ "name": "RGB Eval",
1707
+ "status": "running",
1708
+ "metadata": {"seed": 7},
1709
+ },
1710
+ },
1711
+ }
1712
+
1713
+ finish_eval_event = consumer.events[2]
1714
+ assert finish_eval_event["patch"] == {
1715
+ "evaluations": {
1716
+ "eval_rgb": {
1717
+ "status": "completed",
1718
+ },
1719
+ },
1720
+ }
1721
+
1722
+ train_event = consumer.events[3]
1723
+ assert train_event["split"] == "train"
1724
+ assert train_event["records"][0]["loss"] == 0.5
1725
+ assert "elapsed_sec" in train_event["records"][0]
1726
+
1727
+ val_event = consumer.events[4]
1728
+ assert val_event["split"] == "val"
1729
+ assert val_event["records"][0]["psnr"] == 28.1
1730
+ assert "elapsed_sec" not in val_event["records"][0]
1731
+
1732
+ snapshot_event = consumer.events[5]
1733
+ assert snapshot_event["snapshot"] == {
1734
+ "epoch": 0,
1735
+ "step": 1,
1736
+ "types": {
1737
+ "depth": ["aux/transmission"],
1738
+ "rgb": ["gt", "pred"],
1739
+ },
1740
+ }
1741
+
1742
+ checkpoint_event = consumer.events[6]
1743
+ assert checkpoint_event["checkpoint"] == {
1744
+ "path": "/tmp/model.pt",
1745
+ "epoch": 0,
1746
+ "step": 1,
1747
+ "is_best": True,
1748
+ }
1749
+
1750
+ finish_event = consumer.events[7]
1751
+ assert finish_event["status"] == "completed"
1752
+ assert finish_event["patch"]["end_time"] is not None
1753
+ assert finish_event["patch"]["duration_sec"] >= 0
1754
+
1755
+ def test_euler_view_http_consumer_uses_session_and_ingest_routes(
1756
+ self,
1757
+ tmp_path,
1758
+ monkeypatch,
1759
+ ):
1760
+ calls: list[dict] = []
1761
+
1762
+ def fake_urlopen(request, timeout=0):
1763
+ headers = {k.lower(): v for k, v in request.header_items()}
1764
+ body = json.loads(request.data.decode("utf-8"))
1765
+ calls.append(
1766
+ {
1767
+ "url": request.full_url,
1768
+ "method": request.get_method(),
1769
+ "headers": headers,
1770
+ "body": body,
1771
+ "timeout": timeout,
1772
+ },
1773
+ )
1774
+ if request.full_url.endswith("/stream/session"):
1775
+ return _FakeHttpResponse(
1776
+ {
1777
+ "token": "stream-token",
1778
+ "expiresAt": "2099-01-01T00:00:00+00:00",
1779
+ "ingestUrl": "https://sync.example/api/model-run-stream/ingest",
1780
+ },
1781
+ )
1782
+ if request.full_url.endswith("/api/model-run-stream/ingest"):
1783
+ return _FakeHttpResponse(
1784
+ {
1785
+ "success": True,
1786
+ "events": [],
1787
+ "latestCursor": len(calls),
1788
+ },
1789
+ )
1790
+ raise AssertionError(f"Unexpected URL {request.full_url}")
1791
+
1792
+ monkeypatch.setattr(stream_mod, "urlopen", fake_urlopen)
1793
+
1794
+ run = euler_train.init(
1795
+ dir=str(tmp_path / "r"),
1796
+ config={"lr": 1e-3},
1797
+ stream={
1798
+ "base_url": "https://sync.example",
1799
+ "model_id": 42,
1800
+ "access_token": "user-token",
1801
+ "datasource_id": 7,
1802
+ "batch_size": 1,
1803
+ },
1804
+ )
1805
+ run.log({"loss": 0.25}, step=3, epoch=1)
1806
+ run.finish()
1807
+
1808
+ assert calls[0]["url"] == (
1809
+ f"https://sync.example/api/models/42/runs/{run.run_id}/stream/session"
1810
+ )
1811
+ assert calls[0]["method"] == "POST"
1812
+ assert calls[0]["headers"]["authorization"] == "Bearer user-token"
1813
+ assert calls[0]["body"] == {
1814
+ "runDir": str(run.dir),
1815
+ "eulerTrainDir": str(run.dir.parent),
1816
+ "datasourceId": 7,
1817
+ }
1818
+
1819
+ ingest_calls = calls[1:]
1820
+ assert [call["body"]["events"][0]["type"] for call in ingest_calls] == [
1821
+ "init",
1822
+ "metric",
1823
+ "finish",
1824
+ ]
1825
+ for ingest_call in ingest_calls:
1826
+ assert ingest_call["url"] == "https://sync.example/api/model-run-stream/ingest"
1827
+ assert ingest_call["headers"]["authorization"] == "Bearer stream-token"
1828
+
1829
+ metric_call = ingest_calls[1]
1830
+ metric_event = metric_call["body"]["events"][0]
1831
+ assert metric_event["split"] == "train"
1832
+ assert metric_event["records"][0]["step"] == 3
1833
+ assert metric_event["records"][0]["epoch"] == 1
1834
+ assert metric_event["records"][0]["loss"] == 0.25
1835
+
1836
+ finish_event = ingest_calls[2]["body"]["events"][0]
1837
+ assert finish_event["status"] == "completed"
1838
+
1839
+ def test_stream_network_failures_do_not_break_training(
1840
+ self,
1841
+ tmp_path,
1842
+ monkeypatch,
1843
+ ):
1844
+ def fake_urlopen(request, timeout=0):
1845
+ raise URLError("offline")
1846
+
1847
+ monkeypatch.setattr(stream_mod, "urlopen", fake_urlopen)
1848
+
1849
+ run = euler_train.init(
1850
+ dir=str(tmp_path / "r"),
1851
+ config={},
1852
+ stream={
1853
+ "base_url": "https://sync.example",
1854
+ "model_id": 42,
1855
+ "access_token": "user-token",
1856
+ "batch_size": 1,
1857
+ },
1858
+ )
1859
+ run.log({"loss": 1.0}, step=0, epoch=0)
1860
+ run.finish()
1861
+
1862
+ meta = _read_json(run.dir / "meta.json")
1863
+ assert meta["status"] == "completed"
1864
+
1865
+
1608
1866
  # ═══════════════════════════════════════════════════════════════════════════
1609
1867
  # lifecycle (finish, context manager, crash)
1610
1868
  # ═══════════════════════════════════════════════════════════════════════════
File without changes
File without changes