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.
- {euler_train-2.6.0 → euler_train-2.8.0}/PKG-INFO +1 -1
- {euler_train-2.6.0 → euler_train-2.8.0}/README.md +150 -3
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/__init__.py +2 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/stream.py +267 -43
- euler_train-2.8.0/euler_train/stream_check_cli.py +104 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/PKG-INFO +1 -1
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/SOURCES.txt +2 -0
- euler_train-2.8.0/euler_train.egg-info/entry_points.txt +2 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/pyproject.toml +4 -1
- {euler_train-2.6.0 → euler_train-2.8.0}/tests/test_runlog.py +116 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/_dataset_contract.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/architecture.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/environment.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/git_info.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/outputs.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/run.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/serialization.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train/slurm.py +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/dependency_links.txt +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/requires.txt +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/euler_train.egg-info/top_level.txt +0 -0
- {euler_train-2.6.0 → euler_train-2.8.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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())
|
|
@@ -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
|
|
@@ -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.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
|
|
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
|