euler-train 2.6.0__tar.gz → 2.8.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.
Files changed (22) hide show
  1. {euler_train-2.6.0 → euler_train-2.8.0}/PKG-INFO +1 -1
  2. {euler_train-2.6.0 → euler_train-2.8.0}/README.md +150 -3
  3. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/__init__.py +2 -0
  4. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/stream.py +267 -43
  5. euler_train-2.8.0/euler_train/stream_check_cli.py +104 -0
  6. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/PKG-INFO +1 -1
  7. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/SOURCES.txt +2 -0
  8. euler_train-2.8.0/euler_train.egg-info/entry_points.txt +2 -0
  9. {euler_train-2.6.0 → euler_train-2.8.0}/pyproject.toml +4 -1
  10. {euler_train-2.6.0 → euler_train-2.8.0}/tests/test_runlog.py +116 -0
  11. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/_dataset_contract.py +0 -0
  12. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/architecture.py +0 -0
  13. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/environment.py +0 -0
  14. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/git_info.py +0 -0
  15. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/outputs.py +0 -0
  16. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/run.py +0 -0
  17. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/serialization.py +0 -0
  18. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/slurm.py +0 -0
  19. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/dependency_links.txt +0 -0
  20. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/requires.txt +0 -0
  21. {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/top_level.txt +0 -0
  22. {euler_train-2.6.0 → euler_train-2.8.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.6.0
3
+ Version: 2.8.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -49,6 +49,94 @@ with euler_train.init(dir="runs/exp02", config=cfg) as run:
49
49
  ... # if an exception is raised, meta.json records status="crashed" + traceback
50
50
  ```
51
51
 
52
+ ## Euler View stream mode
53
+
54
+ `euler_train` can dual-write runs to Euler View while still keeping the normal file-based run directory as the primary source of truth. Streaming is best-effort: local files are always written first, and stream events are sent alongside them when a stream consumer is configured.
55
+
56
+ Pass the stream config via `stream=...` on `euler_train.init()`:
57
+
58
+ ```python
59
+ import os
60
+ import euler_train
61
+
62
+ run = euler_train.init(
63
+ dir="runs/experiment_01",
64
+ config=cfg,
65
+ stream={
66
+ "base_url": os.environ["EULER_VIEW_BASE_URL"],
67
+ "model_id": 42,
68
+ "api_token": os.environ["EULER_VIEW_API_TOKEN"],
69
+ "stream_attach_token": os.environ.get("EULER_VIEW_STREAM_ATTACH_TOKEN"),
70
+ },
71
+ )
72
+ ```
73
+
74
+ What each field is for:
75
+
76
+ - `base_url`: Euler View server base URL, for example `https://view.example.com`.
77
+ - `model_id`: Euler View model that should receive this run.
78
+ - `api_token` or `access_token`: credential used to open a stream session. This is required whenever the package needs to negotiate its own short-lived ingest token, even if `stream_attach_token` is also provided.
79
+ - `stream_attach_token`: optional opaque launch-attachment token. When present, the producer sends it during the session handshake and Euler View attaches the stream to that launch directly. This is the preferred path for interactive or otherwise non-SLURM launches. Internally Euler View currently maps this token to `model_launches.id`, but the package treats it as an opaque token.
80
+ - `stream_token`: optional pre-issued ingest token. If you pass this, the package skips the session handshake entirely and posts events directly to the ingest endpoint.
81
+ - `datasource_id`: optional Euler View datasource hint for the run record.
82
+ - `euler_train_dir` and `run_dir`: optional path overrides. These are auto-derived in normal usage.
83
+ - `session_expires_in_sec`: optional requested session lifetime for the short-lived ingest token.
84
+
85
+ How the handshake works:
86
+
87
+ 1. `euler_train` creates the local run directory and writes `meta.json`, `config.json`, and the other standard files as usual.
88
+ 2. When the first stream flush happens, the package requests a session from Euler View at `/api/models/{model_id}/runs/{run_id}/stream/session`.
89
+ 3. If `stream_attach_token` is configured, it is sent as `streamAttachToken` and used as the explicit launch attachment key.
90
+ 4. If no `stream_attach_token` is configured, the package falls back to the SLURM metadata already captured in `meta.json["slurm"]["job_id"]`.
91
+ 5. Euler View returns a short-lived ingest token plus the canonical `streamAttachToken`; the package caches that returned token and reuses it on later session refreshes.
92
+ 6. Subsequent events are sent to `/api/model-run-stream/ingest`.
93
+
94
+ `stream_attach_token` is only the attachment/correlation key. It does not authenticate the session request by itself. In the current implementation you still need `api_token` or `access_token` for the session handshake, unless you already have a pre-issued `stream_token`.
95
+
96
+ ### Dry-run connectivity check
97
+
98
+ If you need to verify from a cluster node whether Euler View is reachable and whether the stream handshake would be accepted before starting a real run, the package exposes the same dry-run check used by the stream protocol:
99
+
100
+ ```bash
101
+ euler-train.stream.check \
102
+ --api-url https://view.example.com \
103
+ --model-id 42 \
104
+ --api-key "$EULER_VIEW_API_TOKEN" \
105
+ --stream-attach-token "$EULER_VIEW_STREAM_ATTACH_TOKEN"
106
+ ```
107
+
108
+ This command calls:
109
+
110
+ - `POST /api/models/{model_id}/runs/{run_id}/stream/check`
111
+
112
+ It validates:
113
+
114
+ - network reachability to Euler View
115
+ - API token validity and scope
116
+ - model existence
117
+ - stream attachment resolution via `stream_attach_token`, when provided
118
+ - SLURM fallback matching via `--slurm-job-id`, when no attach token is provided
119
+
120
+ Useful flags:
121
+
122
+ - `--run-id`: override the dry-run run ID. If omitted, the CLI generates a temporary `stream-check-*` ID.
123
+ - `--stream-attach-token`: test the explicit launch attachment path.
124
+ - `--slurm-job-id`: test the fallback SLURM matching path.
125
+ - `--datasource-id`: include the datasource hint that a real stream session would send.
126
+ - `--euler-train-dir` and `--run-dir`: include path hints in the dry-run payload.
127
+ - `--json`: print the raw JSON response for automation.
128
+
129
+ The dry-run check does not start a run and does not mint an ingest token. It only verifies that the real session handshake would be accepted with the same request shape.
130
+
131
+ What has to be configured where:
132
+
133
+ - In the training script or job wrapper: pass `stream={...}` to `euler_train.init(...)`.
134
+ - In Euler View / the launcher: if the run should attach to a specific launch regardless of SLURM availability, provide a `stream_attach_token` to the runtime and forward it into the `stream` config.
135
+ - Under plain SLURM launches: no extra attachment config is required as long as Euler View can uniquely match the run by `slurm.job_id`.
136
+ - If neither `stream_attach_token` nor a unique SLURM match is available, run streaming still works, but launch-to-run linking in Euler View may remain unresolved.
137
+
138
+ For new integrations, prefer `stream_attach_token`. The older `launch_id` / `launchId` config keys are still accepted as compatibility aliases, but new code should treat the attachment value as an opaque stream token rather than a launch ID.
139
+
52
140
  ## Directory structure
53
141
 
54
142
  Each `euler_train.init(dir=...)` call creates a timestamped subdirectory under `{dir}/runs/`:
@@ -67,6 +155,7 @@ Each `euler_train.init(dir=...)` call creates a timestamped subdirectory under `
67
155
  │ └── epoch_{N}.pt
68
156
  └── outputs/
69
157
  └── epoch_{N}_step_{M}/
158
+ ├── manifest.json
70
159
  └── {output_type}/
71
160
  ├── pred/
72
161
  ├── gt/
@@ -80,7 +169,7 @@ The run ID and directory are available as `run.run_id` and `run.dir`.
80
169
 
81
170
  ## API reference
82
171
 
83
- ### `euler_train.init(dir, config=None, meta=None, output_formats=None, output_visualization=None, run_id=None, datasets=None, run_name=None, evaluations=None, mode=None) → Run`
172
+ ### `euler_train.init(dir, config=None, meta=None, output_formats=None, output_visualization=None, run_id=None, datasets=None, run_name=None, evaluations=None, mode=None, stream=None, metric_naming=None) → Run`
84
173
 
85
174
  Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`, and `run_environment.json`. On resume (`run_id` provided), only `meta.json` and `config.json` are updated. `meta.json` also maintains an `updated_at` map with last-write timestamps for tracked artifacts.
86
175
 
@@ -96,6 +185,8 @@ Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`
96
185
  | `run_name` | `str \| None` | Optional human-readable run label stored in `meta.json`. |
97
186
  | `evaluations` | `dict[str, dict] \| None` | Optional evaluation key → entry map. See [Evaluations](#evaluations). |
98
187
  | `mode` | `str \| None` | Optional process label such as `"train"`, `"val"`, or `"eval"`. When set, lifecycle and crash details are also written under `meta.json["modes"][mode]`. |
188
+ | `stream` | `dict \| EulerViewStreamConfig \| consumer \| sequence \| None` | Optional output stream config / consumer. A mapping with `base_url`, `model_id`, and `api_token` (or `access_token`) enables best-effort streaming to Euler View. Prefer `stream_attach_token` for explicit launch attachment; otherwise the package falls back to `meta.json["slurm"]["job_id"]` when available. |
189
+ | `metric_naming` | `dict[str, Any] \| None` | Optional structured metric naming declaration stored in `meta.json["metric_naming"]` and included in the stream init event. |
99
190
 
100
191
  ---
101
192
 
@@ -112,7 +203,7 @@ run.log({"rgb.psnr": 28.3, "depth.mae": 0.03}, step=100, epoch=1, mode="val")
112
203
 
113
204
  ---
114
205
 
115
- ### `run.save_outputs(*, epoch=None, step=None, **output_types)`
206
+ ### `run.save_outputs(*, epoch=None, step=None, metadata=None, **output_types)`
116
207
 
117
208
  Saves arrays/images to `outputs/epoch_{N}_step_{M}/{output_type}/{slot}/`.
118
209
 
@@ -125,10 +216,11 @@ Each output type is a dict with these slot keys:
125
216
  | `input` | Model input |
126
217
  | `aux` | Dict of named auxiliary outputs (each becomes a subdirectory) |
127
218
 
128
- Values can be:
219
+ Slot values can be:
129
220
  - A single numpy array, torch tensor, or PIL Image
130
221
  - A list of the above (saved as `0000.ext`, `0001.ext`, ...)
131
222
  - A 4D numpy/torch array (split along dim 0 as a batch)
223
+ - A dict mapping custom string IDs to items (saved as `{id}.ext`)
132
224
 
133
225
  ```python
134
226
  run.save_outputs(
@@ -142,6 +234,61 @@ run.save_outputs(
142
234
  )
143
235
  ```
144
236
 
237
+ #### Named sample IDs
238
+
239
+ When slot values are dicts with string keys, files are saved with the key as the basename instead of sequential indices. This is useful for matching outputs back to specific input samples:
240
+
241
+ ```python
242
+ run.save_outputs(
243
+ epoch=1, step=500,
244
+ rgb=dict(pred={"scene_042": img_a, "scene_117": img_b}),
245
+ )
246
+ # produces: rgb/pred/scene_042.png, rgb/pred/scene_117.png
247
+ ```
248
+
249
+ #### Source metadata
250
+
251
+ The optional `metadata` parameter attaches dataset provenance and other context to the output manifest. When provided, the `dataset` key is required and must be a non-empty string:
252
+
253
+ ```python
254
+ run.save_outputs(
255
+ epoch=1, step=500,
256
+ metadata={"dataset": "vkitti2", "split": "val", "scene": "0001"},
257
+ rgb=dict(pred={"frame_042": img_a}),
258
+ )
259
+ ```
260
+
261
+ #### Output manifest
262
+
263
+ Each `save_outputs` call writes a `manifest.json` alongside the saved files, recording what was written and how. The manifest is also streamed via the `output_snapshot` event when streaming is active.
264
+
265
+ ```json
266
+ {
267
+ "version": 1,
268
+ "epoch": 1,
269
+ "step": 500,
270
+ "metadata": {"dataset": "vkitti2", "split": "val"},
271
+ "output_types": {
272
+ "rgb": {
273
+ "pred": {
274
+ "id_mode": "named",
275
+ "files": [
276
+ {"sample_id": "scene_042", "filename": "scene_042.png", "format": "png"}
277
+ ]
278
+ },
279
+ "gt": {
280
+ "id_mode": "indexed",
281
+ "files": [
282
+ {"sample_id": 0, "filename": "0000.png", "format": "png"}
283
+ ]
284
+ }
285
+ }
286
+ },
287
+ "format_overrides": {},
288
+ "visualization_overrides": {}
289
+ }
290
+ ```
291
+
145
292
  Torch tensors in `(C,H,W)` or `(B,C,H,W)` layout are automatically transposed to channels-last before saving.
146
293
 
147
294
  Pass `None` for any slot or output type to skip it.
@@ -12,6 +12,7 @@ from .stream import (
12
12
  OutputStream,
13
13
  OutputStreamConsumer,
14
14
  StreamContext,
15
+ check_stream_handshake,
15
16
  )
16
17
 
17
18
  __all__ = [
@@ -20,6 +21,7 @@ __all__ = [
20
21
  "OutputStream",
21
22
  "OutputStreamConsumer",
22
23
  "StreamContext",
24
+ "check_stream_handshake",
23
25
  "init",
24
26
  "Run",
25
27
  "export_architecture",
@@ -56,6 +56,8 @@ class EulerViewStreamConfig:
56
56
  api_token: str | None = None
57
57
  access_token: str | None = None
58
58
  stream_token: str | None = None
59
+ stream_attach_token: str | None = None
60
+ launch_id: str | None = None
59
61
  datasource_id: int | None = None
60
62
  euler_train_dir: str | None = None
61
63
  run_dir: str | None = None
@@ -84,6 +86,24 @@ class EulerViewStreamConfig:
84
86
  self.stream_token,
85
87
  field_name="stream.stream_token",
86
88
  )
89
+ self.stream_attach_token = _normalize_optional_text(
90
+ self.stream_attach_token,
91
+ field_name="stream.stream_attach_token",
92
+ )
93
+ self.launch_id = _normalize_optional_text(
94
+ self.launch_id,
95
+ field_name="stream.launch_id",
96
+ )
97
+ if (
98
+ self.stream_attach_token is not None
99
+ and self.launch_id is not None
100
+ and self.stream_attach_token != self.launch_id
101
+ ):
102
+ raise ValueError(
103
+ "stream.stream_attach_token and stream.launch_id must match when both are provided",
104
+ )
105
+ if self.stream_attach_token is None:
106
+ self.stream_attach_token = self.launch_id
87
107
  self.euler_train_dir = _normalize_optional_text(
88
108
  self.euler_train_dir,
89
109
  field_name="stream.euler_train_dir",
@@ -191,6 +211,7 @@ class EulerViewStreamConsumer:
191
211
  self._context: StreamContext | None = None
192
212
  self._pending: list[dict[str, Any]] = []
193
213
  self._stream_token: str | None = config.stream_token
214
+ self._resolved_stream_attach_token: str | None = config.stream_attach_token
194
215
  self._session_expires_at = float("inf") if config.stream_token else 0.0
195
216
  self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
196
217
  self._last_flush_at = 0.0
@@ -295,14 +316,17 @@ class EulerViewStreamConsumer:
295
316
  f"{self.config.base_url}/api/models/{self.config.model_id}"
296
317
  f"/runs/{quote(self._context.run_id, safe='')}/stream/session"
297
318
  )
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
319
+ session_body = _build_stream_session_body(
320
+ run_dir=self.config.run_dir or str(self._context.run_dir),
321
+ euler_train_dir=self.config.euler_train_dir or str(self._context.runs_dir),
322
+ stream_attach_token=(
323
+ self.config.stream_attach_token
324
+ or self._resolved_stream_attach_token
325
+ ),
326
+ slurm_job_id=_extract_slurm_job_id(self._context.meta),
327
+ datasource_id=self.config.datasource_id,
328
+ session_expires_in_sec=self.config.session_expires_in_sec,
329
+ )
306
330
 
307
331
  try:
308
332
  payload = self._post_json(
@@ -328,6 +352,10 @@ class EulerViewStreamConsumer:
328
352
  else:
329
353
  self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
330
354
 
355
+ resolved_stream_attach_token = _extract_session_stream_attach_token(payload)
356
+ if resolved_stream_attach_token is not None:
357
+ self._resolved_stream_attach_token = resolved_stream_attach_token
358
+
331
359
  self._session_expires_at = _parse_timestamp(payload.get("expiresAt"))
332
360
  self._next_retry_at = 0.0
333
361
  return True
@@ -339,44 +367,13 @@ class EulerViewStreamConsumer:
339
367
  *,
340
368
  token: str | None,
341
369
  ) -> 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(
370
+ return _post_json_request(
350
371
  url,
351
- data=json.dumps(payload, default=_json_default).encode("utf-8"),
352
- headers=headers,
353
- method="POST",
372
+ payload,
373
+ token=token,
374
+ timeout_sec=self.config.timeout_sec,
354
375
  )
355
376
 
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
377
  def _trim_pending(self) -> None:
381
378
  overflow = len(self._pending) - self.config.max_pending_events
382
379
  if overflow <= 0:
@@ -426,6 +423,111 @@ def coerce_output_stream(spec: Any) -> OutputStream | None:
426
423
  )
427
424
 
428
425
 
426
+ def check_stream_handshake(
427
+ config: EulerViewStreamConfig | Mapping[str, Any],
428
+ *,
429
+ run_id: str | None = None,
430
+ stream_attach_token: str | None = None,
431
+ slurm_job_id: int | None = None,
432
+ datasource_id: int | None = None,
433
+ euler_train_dir: str | None = None,
434
+ run_dir: str | None = None,
435
+ ) -> dict[str, Any]:
436
+ if isinstance(config, Mapping):
437
+ resolved_config = _config_from_mapping(config)
438
+ elif isinstance(config, EulerViewStreamConfig):
439
+ resolved_config = config
440
+ else:
441
+ raise TypeError(
442
+ "config must be a mapping or EulerViewStreamConfig",
443
+ )
444
+
445
+ if resolved_config.stream_token is not None:
446
+ raise ValueError(
447
+ "stream.stream_token bypasses the session handshake; a dry-run handshake requires api_token or access_token instead",
448
+ )
449
+
450
+ if resolved_config.model_id is None:
451
+ raise ValueError("stream.model_id is required for handshake checks")
452
+
453
+ normalized_run_id = (
454
+ _normalize_optional_text(run_id, field_name="run_id")
455
+ if run_id is not None
456
+ else _default_stream_check_run_id()
457
+ )
458
+ if normalized_run_id is None:
459
+ normalized_run_id = _default_stream_check_run_id()
460
+ normalized_stream_attach_token = (
461
+ _normalize_optional_text(
462
+ stream_attach_token,
463
+ field_name="stream_attach_token",
464
+ )
465
+ if stream_attach_token is not None
466
+ else None
467
+ )
468
+ if (
469
+ normalized_stream_attach_token is not None
470
+ and resolved_config.stream_attach_token is not None
471
+ and normalized_stream_attach_token != resolved_config.stream_attach_token
472
+ ):
473
+ raise ValueError(
474
+ "stream_attach_token argument does not match stream.stream_attach_token",
475
+ )
476
+
477
+ resolved_datasource_id = (
478
+ _require_positive_int(
479
+ datasource_id,
480
+ field_name="datasource_id",
481
+ allow_none=True,
482
+ )
483
+ if datasource_id is not None
484
+ else resolved_config.datasource_id
485
+ )
486
+ resolved_slurm_job_id = (
487
+ _require_positive_int(
488
+ slurm_job_id,
489
+ field_name="slurm_job_id",
490
+ allow_none=True,
491
+ )
492
+ if slurm_job_id is not None
493
+ else None
494
+ )
495
+ resolved_euler_train_dir = (
496
+ _normalize_optional_text(
497
+ euler_train_dir,
498
+ field_name="euler_train_dir",
499
+ )
500
+ if euler_train_dir is not None
501
+ else resolved_config.euler_train_dir
502
+ )
503
+ resolved_run_dir = (
504
+ _normalize_optional_text(run_dir, field_name="run_dir")
505
+ if run_dir is not None
506
+ else resolved_config.run_dir
507
+ )
508
+
509
+ url = (
510
+ f"{resolved_config.base_url}/api/models/{resolved_config.model_id}"
511
+ f"/runs/{quote(normalized_run_id, safe='')}/stream/check"
512
+ )
513
+ payload = _build_stream_session_body(
514
+ run_dir=resolved_run_dir,
515
+ euler_train_dir=resolved_euler_train_dir,
516
+ stream_attach_token=(
517
+ normalized_stream_attach_token or resolved_config.stream_attach_token
518
+ ),
519
+ slurm_job_id=resolved_slurm_job_id,
520
+ datasource_id=resolved_datasource_id,
521
+ session_expires_in_sec=resolved_config.session_expires_in_sec,
522
+ )
523
+ return _post_json_request(
524
+ url,
525
+ payload,
526
+ token=resolved_config.api_token or resolved_config.access_token,
527
+ timeout_sec=resolved_config.timeout_sec,
528
+ )
529
+
530
+
429
531
  def _looks_like_consumer(value: Any) -> bool:
430
532
  return all(
431
533
  callable(getattr(value, name, None))
@@ -448,6 +550,12 @@ def _config_from_mapping(raw: Mapping[str, Any]) -> EulerViewStreamConfig:
448
550
  access_token=_mapping_get(raw, "access_token", "accessToken"),
449
551
  api_token=_mapping_get(raw, "api_token", "apiToken"),
450
552
  stream_token=_mapping_get(raw, "stream_token", "streamToken"),
553
+ stream_attach_token=_mapping_get(
554
+ raw,
555
+ "stream_attach_token",
556
+ "streamAttachToken",
557
+ ),
558
+ launch_id=_mapping_get(raw, "launch_id", "launchId"),
451
559
  datasource_id=_mapping_get(raw, "datasource_id", "datasourceId"),
452
560
  euler_train_dir=_mapping_get(raw, "euler_train_dir", "eulerTrainDir"),
453
561
  run_dir=_mapping_get(raw, "run_dir", "runDir"),
@@ -484,6 +592,75 @@ def _mapping_get(
484
592
  return default
485
593
 
486
594
 
595
+ def _extract_slurm_job_id(meta: Mapping[str, Any] | None) -> int | None:
596
+ if not isinstance(meta, Mapping):
597
+ return None
598
+ slurm = meta.get("slurm")
599
+ if not isinstance(slurm, Mapping):
600
+ return None
601
+ raw = slurm.get("job_id")
602
+ if isinstance(raw, bool):
603
+ return None
604
+ if isinstance(raw, int):
605
+ return raw if raw > 0 else None
606
+ if isinstance(raw, float):
607
+ return int(raw) if raw.is_integer() and raw > 0 else None
608
+ if isinstance(raw, str):
609
+ text = raw.strip()
610
+ if not text:
611
+ return None
612
+ try:
613
+ value = int(text, 10)
614
+ except ValueError:
615
+ return None
616
+ return value if value > 0 else None
617
+ return None
618
+
619
+
620
+ def _extract_session_stream_attach_token(payload: Mapping[str, Any]) -> str | None:
621
+ run = payload.get("run")
622
+ if not isinstance(run, Mapping):
623
+ return None
624
+ stream_attach_token = run.get("streamAttachToken")
625
+ if not isinstance(stream_attach_token, str):
626
+ legacy_launch_id = run.get("launchId")
627
+ if not isinstance(legacy_launch_id, str):
628
+ return None
629
+ stream_attach_token = legacy_launch_id
630
+ normalized = stream_attach_token.strip()
631
+ return normalized or None
632
+
633
+
634
+ def _build_stream_session_body(
635
+ *,
636
+ run_dir: str | None,
637
+ euler_train_dir: str | None,
638
+ stream_attach_token: str | None,
639
+ slurm_job_id: int | None,
640
+ datasource_id: int | None,
641
+ session_expires_in_sec: int | None,
642
+ ) -> dict[str, Any]:
643
+ body: dict[str, Any] = {}
644
+ if run_dir is not None:
645
+ body["runDir"] = run_dir
646
+ if euler_train_dir is not None:
647
+ body["eulerTrainDir"] = euler_train_dir
648
+ if stream_attach_token is not None:
649
+ body["streamAttachToken"] = stream_attach_token
650
+ if slurm_job_id is not None:
651
+ body["slurmJobId"] = slurm_job_id
652
+ if datasource_id is not None:
653
+ body["datasourceId"] = datasource_id
654
+ if session_expires_in_sec is not None:
655
+ body["expiresInSec"] = session_expires_in_sec
656
+ return body
657
+
658
+
659
+ def _default_stream_check_run_id() -> str:
660
+ timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
661
+ return f"stream-check-{timestamp}"
662
+
663
+
487
664
  def _normalize_optional_text(
488
665
  value: Any,
489
666
  *,
@@ -558,6 +735,52 @@ def _extract_http_error_message(error: HTTPError) -> str:
558
735
  return f"HTTP {error.code}: {error.reason}"
559
736
 
560
737
 
738
+ def _post_json_request(
739
+ url: str,
740
+ payload: dict[str, Any],
741
+ *,
742
+ token: str | None,
743
+ timeout_sec: float,
744
+ ) -> dict[str, Any]:
745
+ headers = {
746
+ "Accept": "application/json",
747
+ "Content-Type": "application/json",
748
+ }
749
+ if token:
750
+ headers["Authorization"] = f"Bearer {token}"
751
+
752
+ request = Request(
753
+ url,
754
+ data=json.dumps(payload, default=_json_default).encode("utf-8"),
755
+ headers=headers,
756
+ method="POST",
757
+ )
758
+
759
+ try:
760
+ with urlopen(request, timeout=timeout_sec) as response:
761
+ raw = response.read()
762
+ except HTTPError as exc:
763
+ message = _extract_http_error_message(exc)
764
+ raise RuntimeError(message) from exc
765
+ except URLError as exc:
766
+ reason = exc.reason if exc.reason else str(exc)
767
+ raise RuntimeError(str(reason)) from exc
768
+ except OSError as exc:
769
+ raise RuntimeError(str(exc)) from exc
770
+
771
+ if not raw:
772
+ return {}
773
+
774
+ try:
775
+ parsed = json.loads(raw.decode("utf-8"))
776
+ except json.JSONDecodeError as exc:
777
+ raise RuntimeError("Invalid JSON response from stream API") from exc
778
+
779
+ if isinstance(parsed, dict):
780
+ return parsed
781
+ raise RuntimeError("Unexpected stream API response payload")
782
+
783
+
561
784
  def _to_jsonable(value: Any) -> Any:
562
785
  return json.loads(json.dumps(value, default=_json_default))
563
786
 
@@ -568,5 +791,6 @@ __all__ = [
568
791
  "OutputStream",
569
792
  "OutputStreamConsumer",
570
793
  "StreamContext",
794
+ "check_stream_handshake",
571
795
  "coerce_output_stream",
572
796
  ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections.abc import Sequence
7
+
8
+ from .stream import check_stream_handshake
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="euler-train.stream.check",
14
+ description="Dry-run the Euler View stream handshake",
15
+ )
16
+ parser.add_argument("--api-url", required=True, help="Euler View base URL")
17
+ parser.add_argument("--model-id", required=True, type=int, help="Euler View model ID")
18
+ parser.add_argument(
19
+ "--api-key",
20
+ required=True,
21
+ help="Euler View API token used to authorize the handshake check",
22
+ )
23
+ parser.add_argument(
24
+ "--run-id",
25
+ help="Run ID to use for the dry-run handshake. Defaults to an ephemeral stream-check ID.",
26
+ )
27
+ parser.add_argument(
28
+ "--stream-attach-token",
29
+ help="Optional explicit launch attachment token to validate",
30
+ )
31
+ parser.add_argument(
32
+ "--slurm-job-id",
33
+ type=int,
34
+ help="Optional SLURM job ID to test the fallback matching path",
35
+ )
36
+ parser.add_argument(
37
+ "--datasource-id",
38
+ type=int,
39
+ help="Optional datasource hint to include in the handshake payload",
40
+ )
41
+ parser.add_argument(
42
+ "--euler-train-dir",
43
+ help="Optional euler_train runs directory path to include in the handshake payload",
44
+ )
45
+ parser.add_argument(
46
+ "--run-dir",
47
+ help="Optional run directory path to include in the handshake payload",
48
+ )
49
+ parser.add_argument(
50
+ "--timeout-sec",
51
+ type=float,
52
+ default=10.0,
53
+ help="HTTP timeout in seconds (default: 10)",
54
+ )
55
+ parser.add_argument(
56
+ "--json",
57
+ action="store_true",
58
+ help="Print the raw JSON response instead of a human-readable summary",
59
+ )
60
+ return parser
61
+
62
+
63
+ def main(argv: Sequence[str] | None = None) -> int:
64
+ parser = build_parser()
65
+ args = parser.parse_args(argv)
66
+
67
+ try:
68
+ result = check_stream_handshake(
69
+ {
70
+ "base_url": args.api_url,
71
+ "model_id": args.model_id,
72
+ "api_token": args.api_key,
73
+ "stream_attach_token": args.stream_attach_token,
74
+ "datasource_id": args.datasource_id,
75
+ "euler_train_dir": args.euler_train_dir,
76
+ "run_dir": args.run_dir,
77
+ "timeout_sec": args.timeout_sec,
78
+ },
79
+ run_id=args.run_id,
80
+ slurm_job_id=args.slurm_job_id,
81
+ )
82
+ except Exception as exc:
83
+ if args.json:
84
+ print(json.dumps({"success": False, "error": str(exc)}, indent=2))
85
+ else:
86
+ print(f"Handshake check failed: {exc}", file=sys.stderr)
87
+ return 1
88
+
89
+ if args.json:
90
+ print(json.dumps(result, indent=2, sort_keys=True))
91
+ return 0
92
+
93
+ run = result.get("run", {})
94
+ print("Handshake check succeeded")
95
+ print(f" model_id: {run.get('modelId')}")
96
+ print(f" run_id: {run.get('runId')}")
97
+ print(f" resolution: {result.get('resolution')}")
98
+ print(f" stream_attach_token: {run.get('streamAttachToken')}")
99
+ print(f" ingest_url: {result.get('ingestUrl')}")
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.6.0
3
+ Version: 2.8.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -10,9 +10,11 @@ euler_train/run.py
10
10
  euler_train/serialization.py
11
11
  euler_train/slurm.py
12
12
  euler_train/stream.py
13
+ euler_train/stream_check_cli.py
13
14
  euler_train.egg-info/PKG-INFO
14
15
  euler_train.egg-info/SOURCES.txt
15
16
  euler_train.egg-info/dependency_links.txt
17
+ euler_train.egg-info/entry_points.txt
16
18
  euler_train.egg-info/requires.txt
17
19
  euler_train.egg-info/top_level.txt
18
20
  tests/test_runlog.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ euler-train.stream.check = euler_train.stream_check_cli:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-train"
7
- version = "2.6.0"
7
+ version = "2.8.0"
8
8
  description = "Lightweight file-based experiment logger for PyTorch"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = ["numpy", "euler-dataset-contract"]
@@ -16,5 +16,8 @@ architecture = ["onnx", "onnxruntime", "onnxsim"]
16
16
  naming = ["euler-metric-naming"]
17
17
  dev = ["pytest", "Pillow", "torch", "nvidia-ml-py", "onnx", "onnxruntime", "onnxsim"]
18
18
 
19
+ [project.scripts]
20
+ "euler-train.stream.check" = "euler_train.stream_check_cli:main"
21
+
19
22
  [tool.pytest.ini_options]
20
23
  pythonpath = ["."]
@@ -16,6 +16,7 @@ from PIL import Image
16
16
 
17
17
  import euler_train
18
18
  import euler_train.stream as stream_mod
19
+ from euler_train.stream_check_cli import main as stream_check_cli_main
19
20
 
20
21
  # ═══════════════════════════════════════════════════════════════════════════
21
22
  # Helpers
@@ -2053,6 +2054,121 @@ class TestStreaming:
2053
2054
  meta = _read_json(run.dir / "meta.json")
2054
2055
  assert meta["status"] == "completed"
2055
2056
 
2057
+ def test_check_stream_handshake_uses_dry_run_route(
2058
+ self,
2059
+ monkeypatch,
2060
+ ):
2061
+ calls: list[dict] = []
2062
+
2063
+ def fake_urlopen(request, timeout=0):
2064
+ headers = {k.lower(): v for k, v in request.header_items()}
2065
+ body = json.loads(request.data.decode("utf-8"))
2066
+ calls.append(
2067
+ {
2068
+ "url": request.full_url,
2069
+ "method": request.get_method(),
2070
+ "headers": headers,
2071
+ "body": body,
2072
+ "timeout": timeout,
2073
+ },
2074
+ )
2075
+ if request.full_url.endswith("/stream/check"):
2076
+ return _FakeHttpResponse(
2077
+ {
2078
+ "success": True,
2079
+ "resolution": "stream_attach_token",
2080
+ "ingestUrl": "https://sync.example/api/model-run-stream/ingest",
2081
+ "run": {
2082
+ "modelId": 42,
2083
+ "runId": "dry-run-1",
2084
+ "streamAttachToken": "attach-123",
2085
+ "launchId": "launch-abc",
2086
+ "datasourceId": 7,
2087
+ "eulerTrainDir": "/outputs",
2088
+ "runDir": "/outputs/dry-run-1",
2089
+ },
2090
+ },
2091
+ )
2092
+ raise AssertionError(f"Unexpected URL {request.full_url}")
2093
+
2094
+ monkeypatch.setattr(stream_mod, "urlopen", fake_urlopen)
2095
+
2096
+ payload = stream_mod.check_stream_handshake(
2097
+ {
2098
+ "base_url": "https://sync.example",
2099
+ "model_id": 42,
2100
+ "api_token": "user-token",
2101
+ "stream_attach_token": "attach-123",
2102
+ "datasource_id": 7,
2103
+ },
2104
+ run_id="dry-run-1",
2105
+ )
2106
+
2107
+ assert payload["success"] is True
2108
+ assert calls == [
2109
+ {
2110
+ "url": "https://sync.example/api/models/42/runs/dry-run-1/stream/check",
2111
+ "method": "POST",
2112
+ "headers": {
2113
+ "accept": "application/json",
2114
+ "authorization": "Bearer user-token",
2115
+ "content-type": "application/json",
2116
+ },
2117
+ "body": {
2118
+ "streamAttachToken": "attach-123",
2119
+ "datasourceId": 7,
2120
+ },
2121
+ "timeout": 10.0,
2122
+ },
2123
+ ]
2124
+
2125
+ def test_stream_check_cli_prints_json_payload(
2126
+ self,
2127
+ monkeypatch,
2128
+ capsys,
2129
+ ):
2130
+ def fake_urlopen(request, timeout=0):
2131
+ if not request.full_url.endswith("/stream/check"):
2132
+ raise AssertionError(f"Unexpected URL {request.full_url}")
2133
+ return _FakeHttpResponse(
2134
+ {
2135
+ "success": True,
2136
+ "resolution": "unresolved",
2137
+ "ingestUrl": "https://sync.example/api/model-run-stream/ingest",
2138
+ "run": {
2139
+ "modelId": 42,
2140
+ "runId": "dry-run-2",
2141
+ "streamAttachToken": None,
2142
+ "launchId": None,
2143
+ "datasourceId": None,
2144
+ "eulerTrainDir": None,
2145
+ "runDir": None,
2146
+ },
2147
+ },
2148
+ )
2149
+
2150
+ monkeypatch.setattr(stream_mod, "urlopen", fake_urlopen)
2151
+
2152
+ exit_code = stream_check_cli_main(
2153
+ [
2154
+ "--api-url",
2155
+ "https://sync.example",
2156
+ "--model-id",
2157
+ "42",
2158
+ "--api-key",
2159
+ "user-token",
2160
+ "--run-id",
2161
+ "dry-run-2",
2162
+ "--json",
2163
+ ],
2164
+ )
2165
+
2166
+ assert exit_code == 0
2167
+ payload = json.loads(capsys.readouterr().out)
2168
+ assert payload["success"] is True
2169
+ assert payload["resolution"] == "unresolved"
2170
+ assert payload["run"]["runId"] == "dry-run-2"
2171
+
2056
2172
 
2057
2173
  # ═══════════════════════════════════════════════════════════════════════════
2058
2174
  # lifecycle (finish, context manager, crash)
File without changes