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.
- {euler_train-2.2.0 → euler_train-2.4.0}/PKG-INFO +1 -1
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/__init__.py +25 -1
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/run.py +229 -7
- euler_train-2.4.0/euler_train/stream.py +572 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train.egg-info/PKG-INFO +1 -1
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train.egg-info/SOURCES.txt +1 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/pyproject.toml +1 -1
- {euler_train-2.2.0 → euler_train-2.4.0}/tests/test_runlog.py +258 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/README.md +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/_dataset_contract.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/architecture.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/environment.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/git_info.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/outputs.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/serialization.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train/slurm.py +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train.egg-info/dependency_links.txt +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train.egg-info/requires.txt +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/euler_train.egg-info/top_level.txt +0 -0
- {euler_train-2.2.0 → euler_train-2.4.0}/setup.cfg +0 -0
|
@@ -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__ = [
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
write_json(run_environment_path,
|
|
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
|
+
]
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "euler-train"
|
|
7
|
-
version = "2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|