euler-train 2.2.0__tar.gz → 2.4.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.4.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,12 @@ 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`` / ``api_token`` (or legacy ``access_token``)
113
+ enables best-effort dual-write streaming to the Euler View
114
+ model-run ingest API.
92
115
  """
93
116
  return Run(
94
117
  dir=dir, config=config, meta=meta,
@@ -97,4 +120,5 @@ def init(
97
120
  run_id=run_id,
98
121
  datasets=datasets, run_name=run_name,
99
122
  evaluations=evaluations, mode=mode,
123
+ stream=stream,
100
124
  )
@@ -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,572 @@
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
+ api_token: str | None = None
57
+ access_token: str | None = None
58
+ stream_token: str | None = None
59
+ datasource_id: int | None = None
60
+ euler_train_dir: str | None = None
61
+ run_dir: str | None = None
62
+ timeout_sec: float = 10.0
63
+ batch_size: int = 20
64
+ flush_interval_sec: float = 2.0
65
+ max_pending_events: int = 1000
66
+ session_expires_in_sec: int | None = None
67
+
68
+ def __post_init__(self) -> None:
69
+ if not isinstance(self.base_url, str):
70
+ raise ValueError("stream.base_url must be a string")
71
+ self.base_url = self.base_url.strip().rstrip("/")
72
+ if not self.base_url:
73
+ raise ValueError("stream.base_url is required")
74
+
75
+ self.access_token = _normalize_optional_text(
76
+ self.access_token,
77
+ field_name="stream.access_token",
78
+ )
79
+ self.api_token = _normalize_optional_text(
80
+ self.api_token,
81
+ field_name="stream.api_token",
82
+ )
83
+ self.stream_token = _normalize_optional_text(
84
+ self.stream_token,
85
+ field_name="stream.stream_token",
86
+ )
87
+ self.euler_train_dir = _normalize_optional_text(
88
+ self.euler_train_dir,
89
+ field_name="stream.euler_train_dir",
90
+ )
91
+ self.run_dir = _normalize_optional_text(
92
+ self.run_dir,
93
+ field_name="stream.run_dir",
94
+ )
95
+
96
+ self.model_id = _require_positive_int(
97
+ self.model_id,
98
+ field_name="stream.model_id",
99
+ allow_none=True,
100
+ )
101
+ self.datasource_id = _require_positive_int(
102
+ self.datasource_id,
103
+ field_name="stream.datasource_id",
104
+ allow_none=True,
105
+ )
106
+ self.session_expires_in_sec = _require_positive_int(
107
+ self.session_expires_in_sec,
108
+ field_name="stream.session_expires_in_sec",
109
+ allow_none=True,
110
+ )
111
+ self.batch_size = _require_positive_int(
112
+ self.batch_size,
113
+ field_name="stream.batch_size",
114
+ )
115
+ self.max_pending_events = _require_positive_int(
116
+ self.max_pending_events,
117
+ field_name="stream.max_pending_events",
118
+ )
119
+ self.timeout_sec = _require_positive_number(
120
+ self.timeout_sec,
121
+ field_name="stream.timeout_sec",
122
+ )
123
+ self.flush_interval_sec = _require_non_negative_number(
124
+ self.flush_interval_sec,
125
+ field_name="stream.flush_interval_sec",
126
+ )
127
+
128
+ if self.stream_token is None:
129
+ if self.model_id is None:
130
+ raise ValueError(
131
+ "stream.model_id is required when stream.stream_token is not provided",
132
+ )
133
+ if self.api_token is None and self.access_token is None:
134
+ raise ValueError(
135
+ "stream.api_token or stream.access_token is required when stream.stream_token is not provided",
136
+ )
137
+
138
+
139
+ class OutputStream:
140
+ """Dispatch events to one or more best-effort consumers."""
141
+
142
+ def __init__(self, consumers: Sequence[OutputStreamConsumer]) -> None:
143
+ self._consumers = list(consumers)
144
+ self._closed = False
145
+
146
+ @property
147
+ def consumers(self) -> tuple[OutputStreamConsumer, ...]:
148
+ return tuple(self._consumers)
149
+
150
+ def bind(self, context: StreamContext) -> None:
151
+ self._dispatch("bind", context)
152
+
153
+ def emit(self, event: dict[str, Any]) -> None:
154
+ self._dispatch("emit", event)
155
+
156
+ def flush(self) -> None:
157
+ self._dispatch("flush")
158
+
159
+ def close(self) -> None:
160
+ if self._closed:
161
+ return
162
+ self._dispatch("flush")
163
+ self._dispatch("close")
164
+ self._closed = True
165
+
166
+ def _dispatch(self, method_name: str, *args: Any) -> None:
167
+ if self._closed and method_name != "close":
168
+ return
169
+
170
+ active: list[OutputStreamConsumer] = []
171
+ for consumer in self._consumers:
172
+ method = getattr(consumer, method_name)
173
+ payload = copy.deepcopy(args)
174
+ try:
175
+ method(*payload)
176
+ active.append(consumer)
177
+ except Exception as exc: # pragma: no cover - defensive safeguard
178
+ log.warning(
179
+ "Disabling stream consumer after %s failure: %s",
180
+ method_name,
181
+ exc,
182
+ )
183
+ self._consumers = active
184
+
185
+
186
+ class EulerViewStreamConsumer:
187
+ """Best-effort HTTP consumer for the model-run streaming API."""
188
+
189
+ def __init__(self, config: EulerViewStreamConfig) -> None:
190
+ self.config = config
191
+ self._context: StreamContext | None = None
192
+ self._pending: list[dict[str, Any]] = []
193
+ self._stream_token: str | None = config.stream_token
194
+ self._session_expires_at = float("inf") if config.stream_token else 0.0
195
+ self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
196
+ self._last_flush_at = 0.0
197
+ self._next_retry_at = 0.0
198
+ self._last_warning: tuple[str, float] | None = None
199
+ self._closed = False
200
+
201
+ def bind(self, context: StreamContext) -> None:
202
+ self._context = context
203
+
204
+ def emit(self, event: dict[str, Any]) -> None:
205
+ if self._closed:
206
+ return
207
+
208
+ try:
209
+ payload = _to_jsonable(event)
210
+ except Exception as exc:
211
+ self._warn(
212
+ "Dropping stream event that could not be serialized",
213
+ exc,
214
+ )
215
+ return
216
+
217
+ self._pending.append(payload)
218
+ self._trim_pending()
219
+
220
+ force = payload.get("type") in {
221
+ "init",
222
+ "checkpoint",
223
+ "output_snapshot",
224
+ "finish",
225
+ }
226
+ self._maybe_flush(force=force)
227
+
228
+ def flush(self) -> None:
229
+ self._flush_pending(force=True)
230
+
231
+ def close(self) -> None:
232
+ if self._closed:
233
+ return
234
+ self._flush_pending(force=True)
235
+ self._closed = True
236
+
237
+ def _maybe_flush(self, *, force: bool = False) -> None:
238
+ if not self._pending:
239
+ return
240
+
241
+ now = time.time()
242
+ should_flush = force
243
+ should_flush = should_flush or len(self._pending) >= self.config.batch_size
244
+ should_flush = should_flush or (
245
+ now - self._last_flush_at >= self.config.flush_interval_sec
246
+ )
247
+ if should_flush:
248
+ self._flush_pending(force=force)
249
+
250
+ def _flush_pending(self, *, force: bool = False) -> None:
251
+ if not self._pending:
252
+ return
253
+
254
+ now = time.time()
255
+ if not force and now < self._next_retry_at:
256
+ return
257
+
258
+ if not self._ensure_session(force=force):
259
+ return
260
+
261
+ while self._pending:
262
+ batch = self._pending[: self.config.batch_size]
263
+ try:
264
+ self._post_json(
265
+ self._ingest_url,
266
+ {"events": batch},
267
+ token=self._stream_token,
268
+ )
269
+ except Exception as exc:
270
+ self._schedule_retry()
271
+ self._warn("Model-run stream ingest failed", exc)
272
+ return
273
+
274
+ del self._pending[: len(batch)]
275
+ self._last_flush_at = time.time()
276
+ self._next_retry_at = 0.0
277
+
278
+ def _ensure_session(self, *, force: bool = False) -> bool:
279
+ if self._stream_token and time.time() + 5 < self._session_expires_at:
280
+ return True
281
+
282
+ if self.config.stream_token:
283
+ self._stream_token = self.config.stream_token
284
+ self._session_expires_at = float("inf")
285
+ return True
286
+
287
+ if self._context is None:
288
+ return False
289
+
290
+ now = time.time()
291
+ if not force and now < self._next_retry_at:
292
+ return False
293
+
294
+ session_url = (
295
+ f"{self.config.base_url}/api/models/{self.config.model_id}"
296
+ f"/runs/{quote(self._context.run_id, safe='')}/stream/session"
297
+ )
298
+ session_body: dict[str, Any] = {
299
+ "runDir": self.config.run_dir or str(self._context.run_dir),
300
+ "eulerTrainDir": self.config.euler_train_dir or str(self._context.runs_dir),
301
+ }
302
+ if self.config.datasource_id is not None:
303
+ session_body["datasourceId"] = self.config.datasource_id
304
+ if self.config.session_expires_in_sec is not None:
305
+ session_body["expiresInSec"] = self.config.session_expires_in_sec
306
+
307
+ try:
308
+ payload = self._post_json(
309
+ session_url,
310
+ session_body,
311
+ token=self.config.api_token or self.config.access_token,
312
+ )
313
+ except Exception as exc:
314
+ self._schedule_retry()
315
+ self._warn("Model-run stream session setup failed", exc)
316
+ return False
317
+
318
+ token = payload.get("token")
319
+ if not isinstance(token, str) or not token.strip():
320
+ self._schedule_retry()
321
+ self._warn("Model-run stream session response did not contain a token")
322
+ return False
323
+
324
+ self._stream_token = token.strip()
325
+ ingest_url = payload.get("ingestUrl")
326
+ if isinstance(ingest_url, str) and ingest_url.strip():
327
+ self._ingest_url = ingest_url.strip()
328
+ else:
329
+ self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
330
+
331
+ self._session_expires_at = _parse_timestamp(payload.get("expiresAt"))
332
+ self._next_retry_at = 0.0
333
+ return True
334
+
335
+ def _post_json(
336
+ self,
337
+ url: str,
338
+ payload: dict[str, Any],
339
+ *,
340
+ token: str | None,
341
+ ) -> dict[str, Any]:
342
+ headers = {
343
+ "Accept": "application/json",
344
+ "Content-Type": "application/json",
345
+ }
346
+ if token:
347
+ headers["Authorization"] = f"Bearer {token}"
348
+
349
+ request = Request(
350
+ url,
351
+ data=json.dumps(payload, default=_json_default).encode("utf-8"),
352
+ headers=headers,
353
+ method="POST",
354
+ )
355
+
356
+ try:
357
+ with urlopen(request, timeout=self.config.timeout_sec) as response:
358
+ raw = response.read()
359
+ except HTTPError as exc:
360
+ message = _extract_http_error_message(exc)
361
+ raise RuntimeError(message) from exc
362
+ except URLError as exc:
363
+ reason = exc.reason if exc.reason else str(exc)
364
+ raise RuntimeError(str(reason)) from exc
365
+ except OSError as exc:
366
+ raise RuntimeError(str(exc)) from exc
367
+
368
+ if not raw:
369
+ return {}
370
+
371
+ try:
372
+ parsed = json.loads(raw.decode("utf-8"))
373
+ except json.JSONDecodeError as exc:
374
+ raise RuntimeError("Invalid JSON response from stream API") from exc
375
+
376
+ if isinstance(parsed, dict):
377
+ return parsed
378
+ raise RuntimeError("Unexpected stream API response payload")
379
+
380
+ def _trim_pending(self) -> None:
381
+ overflow = len(self._pending) - self.config.max_pending_events
382
+ if overflow <= 0:
383
+ return
384
+ del self._pending[:overflow]
385
+ self._warn(
386
+ f"Dropped {overflow} pending stream event(s) after queue overflow",
387
+ )
388
+
389
+ def _schedule_retry(self) -> None:
390
+ backoff = max(self.config.flush_interval_sec, 2.0)
391
+ self._next_retry_at = time.time() + backoff
392
+
393
+ def _warn(self, message: str, exc: Exception | None = None) -> None:
394
+ detail = f"{message}: {exc}" if exc is not None else message
395
+ now = time.time()
396
+ if self._last_warning is not None:
397
+ previous_detail, previous_at = self._last_warning
398
+ if previous_detail == detail and now - previous_at < 30:
399
+ return
400
+
401
+ log.warning(detail)
402
+ self._last_warning = (detail, now)
403
+
404
+
405
+ def coerce_output_stream(spec: Any) -> OutputStream | None:
406
+ if spec is None:
407
+ return None
408
+ if isinstance(spec, OutputStream):
409
+ return spec
410
+ if isinstance(spec, EulerViewStreamConfig):
411
+ return OutputStream([EulerViewStreamConsumer(spec)])
412
+ if isinstance(spec, Mapping):
413
+ return OutputStream([EulerViewStreamConsumer(_config_from_mapping(spec))])
414
+ if _looks_like_consumer(spec):
415
+ return OutputStream([spec])
416
+ if isinstance(spec, Sequence) and not isinstance(spec, (str, bytes, bytearray)):
417
+ consumers: list[OutputStreamConsumer] = []
418
+ for item in spec:
419
+ stream = coerce_output_stream(item)
420
+ if stream is None:
421
+ continue
422
+ consumers.extend(stream.consumers)
423
+ return OutputStream(consumers)
424
+ raise TypeError(
425
+ "stream must be a mapping, EulerViewStreamConfig, consumer, or sequence of consumers",
426
+ )
427
+
428
+
429
+ def _looks_like_consumer(value: Any) -> bool:
430
+ return all(
431
+ callable(getattr(value, name, None))
432
+ for name in ("bind", "emit", "flush", "close")
433
+ )
434
+
435
+
436
+ def _config_from_mapping(raw: Mapping[str, Any]) -> EulerViewStreamConfig:
437
+ provider = _mapping_get(raw, "provider", "kind", "type")
438
+ if provider is not None:
439
+ normalized = str(provider).strip().lower().replace("-", "_")
440
+ if normalized not in {"euler_view", "eulerview"}:
441
+ raise ValueError(
442
+ f"Unsupported stream provider {provider!r}; expected 'euler_view'",
443
+ )
444
+
445
+ return EulerViewStreamConfig(
446
+ base_url=_mapping_get(raw, "base_url", "baseUrl"),
447
+ model_id=_mapping_get(raw, "model_id", "modelId"),
448
+ access_token=_mapping_get(raw, "access_token", "accessToken"),
449
+ api_token=_mapping_get(raw, "api_token", "apiToken"),
450
+ stream_token=_mapping_get(raw, "stream_token", "streamToken"),
451
+ datasource_id=_mapping_get(raw, "datasource_id", "datasourceId"),
452
+ euler_train_dir=_mapping_get(raw, "euler_train_dir", "eulerTrainDir"),
453
+ run_dir=_mapping_get(raw, "run_dir", "runDir"),
454
+ timeout_sec=_mapping_get(raw, "timeout_sec", "timeoutSec", default=10.0),
455
+ batch_size=_mapping_get(raw, "batch_size", "batchSize", default=20),
456
+ flush_interval_sec=_mapping_get(
457
+ raw,
458
+ "flush_interval_sec",
459
+ "flushIntervalSec",
460
+ default=2.0,
461
+ ),
462
+ max_pending_events=_mapping_get(
463
+ raw,
464
+ "max_pending_events",
465
+ "maxPendingEvents",
466
+ default=1000,
467
+ ),
468
+ session_expires_in_sec=_mapping_get(
469
+ raw,
470
+ "session_expires_in_sec",
471
+ "sessionExpiresInSec",
472
+ ),
473
+ )
474
+
475
+
476
+ def _mapping_get(
477
+ mapping: Mapping[str, Any],
478
+ *names: str,
479
+ default: Any = None,
480
+ ) -> Any:
481
+ for name in names:
482
+ if name in mapping:
483
+ return mapping[name]
484
+ return default
485
+
486
+
487
+ def _normalize_optional_text(
488
+ value: Any,
489
+ *,
490
+ field_name: str,
491
+ ) -> str | None:
492
+ if value is None:
493
+ return None
494
+ if not isinstance(value, str):
495
+ raise ValueError(f"{field_name} must be a string when provided")
496
+ normalized = value.strip()
497
+ return normalized or None
498
+
499
+
500
+ def _require_positive_int(
501
+ value: Any,
502
+ *,
503
+ field_name: str,
504
+ allow_none: bool = False,
505
+ ) -> int | None:
506
+ if value is None:
507
+ if allow_none:
508
+ return None
509
+ raise ValueError(f"{field_name} is required")
510
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
511
+ raise ValueError(f"{field_name} must be a positive integer")
512
+ return value
513
+
514
+
515
+ def _require_positive_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 positive number")
518
+ return float(value)
519
+
520
+
521
+ def _require_non_negative_number(value: Any, *, field_name: str) -> float:
522
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
523
+ raise ValueError(f"{field_name} must be a non-negative number")
524
+ return float(value)
525
+
526
+
527
+ def _parse_timestamp(value: Any) -> float:
528
+ if not isinstance(value, str) or not value.strip():
529
+ return time.time() + 300
530
+
531
+ try:
532
+ return datetime.fromisoformat(
533
+ value.replace("Z", "+00:00"),
534
+ ).timestamp()
535
+ except ValueError:
536
+ return time.time() + 300
537
+
538
+
539
+ def _extract_http_error_message(error: HTTPError) -> str:
540
+ try:
541
+ raw = error.read()
542
+ except OSError:
543
+ raw = b""
544
+
545
+ if raw:
546
+ try:
547
+ payload = json.loads(raw.decode("utf-8"))
548
+ except (UnicodeDecodeError, json.JSONDecodeError):
549
+ text = raw.decode("utf-8", errors="replace").strip()
550
+ if text:
551
+ return f"HTTP {error.code}: {text}"
552
+ else:
553
+ if isinstance(payload, dict):
554
+ body_error = payload.get("error")
555
+ if isinstance(body_error, str) and body_error.strip():
556
+ return f"HTTP {error.code}: {body_error.strip()}"
557
+
558
+ return f"HTTP {error.code}: {error.reason}"
559
+
560
+
561
+ def _to_jsonable(value: Any) -> Any:
562
+ return json.loads(json.dumps(value, default=_json_default))
563
+
564
+
565
+ __all__ = [
566
+ "EulerViewStreamConfig",
567
+ "EulerViewStreamConsumer",
568
+ "OutputStream",
569
+ "OutputStreamConsumer",
570
+ "StreamContext",
571
+ "coerce_output_stream",
572
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.2.0
3
+ Version: 2.4.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.4.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
+ "api_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