euler-train 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -82,7 +82,7 @@ The run ID and directory are available as `run.run_id` and `run.dir`.
82
82
 
83
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`
84
84
 
85
- 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.
85
+ 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
86
 
87
87
  | Parameter | Type | Description |
88
88
  |---|---|---|
@@ -365,6 +365,24 @@ Auto-managed, not written to directly.
365
365
  "traceback": "Traceback (most recent call last):\n ..."
366
366
  }
367
367
  },
368
+ "updated_at": {
369
+ "meta.json": {
370
+ "time": 1706403300.0,
371
+ "iso": "2025-01-28T16:25:00"
372
+ },
373
+ "config.json": {
374
+ "time": 1706400000.0,
375
+ "iso": "2025-01-28T15:30:42"
376
+ },
377
+ "train.jsonl": {
378
+ "time": 1706402999.0,
379
+ "iso": "2025-01-28T16:19:59"
380
+ },
381
+ "outputs/epoch_12_step_4800": {
382
+ "time": 1706403010.0,
383
+ "iso": "2025-01-28T16:20:10"
384
+ }
385
+ },
368
386
  "error": "RuntimeError: CUDA OOM",
369
387
  "traceback": "Traceback (most recent call last):\n ..."
370
388
  }
@@ -375,6 +393,7 @@ Auto-managed, not written to directly.
375
393
  - `datasets` is only present when `datasets=...` is passed to `euler_train.init`.
376
394
  - `evaluations` is only present when evaluations are provided via `evaluations=...` on `init()` or added via `run.add_evaluation()`.
377
395
  - `modes` is only present when `mode=...` is passed to `euler_train.init`; each key stores the latest lifecycle snapshot for that mode.
396
+ - `updated_at` stores last-write timestamps for tracked artifacts. Keys are run-relative paths when the artifact lives inside the run directory (for example `train.jsonl` or `outputs/epoch_2_step_500`) and absolute paths for external artifacts such as custom checkpoint locations.
378
397
  - `error` is only present when `status` is `"crashed"` (context manager / excepthook) or `"interrupted"` (SIGTERM/SIGINT). `traceback` is only present when `status` is `"crashed"`. When `mode=...` is set, the same fields are mirrored under `modes[mode]`.
379
398
 
380
399
  A formal JSON Schema for `meta.json` is available at [`meta-schema.json`](meta-schema.json).
@@ -82,6 +82,7 @@ class Run:
82
82
  self._gpu_stats_every: int = gpu_stats_every
83
83
  self.run_name: str | None = run_name
84
84
  self.mode: str | None = _normalize_mode(mode)
85
+ self._pending_updated_at: dict[str, dict[str, Any]] = {}
85
86
 
86
87
  # ── config ────────────────────────────────────────────────
87
88
  if resuming:
@@ -92,12 +93,19 @@ class Run:
92
93
  else:
93
94
  self.config: dict = normalize_config(config)
94
95
 
95
- write_json(self.dir / "config.json", self.config)
96
+ config_path = self.dir / "config.json"
97
+ write_json(config_path, self.config)
98
+ self._record_artifact_update(config_path)
96
99
 
97
100
  # ── code ref & run environment (fresh runs only) ─────────
98
101
  if not resuming:
99
- write_json(self.dir / "code_ref.json", get_code_ref())
100
- write_json(self.dir / "run_environment.json", get_run_environment())
102
+ code_ref_path = self.dir / "code_ref.json"
103
+ write_json(code_ref_path, get_code_ref())
104
+ self._record_artifact_update(code_ref_path)
105
+
106
+ run_environment_path = self.dir / "run_environment.json"
107
+ write_json(run_environment_path, get_run_environment())
108
+ self._record_artifact_update(run_environment_path)
101
109
 
102
110
  # ── meta ──────────────────────────────────────────────────
103
111
  if resuming:
@@ -238,7 +246,10 @@ class Run:
238
246
  record.update(self._get_gpu_stats())
239
247
 
240
248
  filename = "train.jsonl" if mode == "train" else "val.jsonl"
241
- append_jsonl(self.dir / filename, record)
249
+ log_path = self.dir / filename
250
+ append_jsonl(log_path, record)
251
+ self._record_artifact_update(log_path)
252
+ self._flush_meta()
242
253
 
243
254
  # ── GPU stats ─────────────────────────────────────────────────
244
255
 
@@ -313,6 +324,9 @@ class Run:
313
324
  self._output_visualization,
314
325
  output_type,
315
326
  )
327
+ if base.exists():
328
+ self._record_artifact_update(base)
329
+ self._flush_meta()
316
330
  return base
317
331
 
318
332
  # ── checkpoints ───────────────────────────────────────────────
@@ -339,6 +353,7 @@ class Run:
339
353
  if self.checkpoint_dir is not None:
340
354
  self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
341
355
  self._meta["checkpoint_dir"] = str(self.checkpoint_dir)
356
+ self._record_artifact_update(self.checkpoint_dir)
342
357
  self._flush_meta()
343
358
  log.info("Checkpoint dir: %s", self.checkpoint_dir)
344
359
  return self.checkpoint_dir
@@ -357,6 +372,7 @@ class Run:
357
372
  )
358
373
  self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
359
374
  self._meta["checkpoint_dir"] = str(self.checkpoint_dir)
375
+ self._record_artifact_update(self.checkpoint_dir)
360
376
  self._flush_meta()
361
377
  log.info("Checkpoint dir: %s", self.checkpoint_dir)
362
378
  return self.checkpoint_dir
@@ -415,6 +431,7 @@ class Run:
415
431
  :meth:`save_checkpoint` call it automatically. When *is_best*
416
432
  is ``True``, any previous checkpoint marked as best is cleared.
417
433
  """
434
+ checkpoint_path = Path(path)
418
435
  checkpoints: list[dict[str, Any]] = self._meta.get("checkpoints", [])
419
436
 
420
437
  if is_best:
@@ -422,7 +439,7 @@ class Run:
422
439
  entry.pop("is_best", None)
423
440
 
424
441
  record: dict[str, Any] = {
425
- "path": str(path),
442
+ "path": str(checkpoint_path),
426
443
  "epoch": epoch,
427
444
  "step": step,
428
445
  }
@@ -431,6 +448,7 @@ class Run:
431
448
 
432
449
  checkpoints.append(record)
433
450
  self._meta["checkpoints"] = checkpoints
451
+ self._record_artifact_update(checkpoint_path)
434
452
  self._flush_meta()
435
453
 
436
454
  # ── architecture export ─────────────────────────────────────────
@@ -467,6 +485,7 @@ class Run:
467
485
  model, dummy_input, self.dir / "architecture.onnx"
468
486
  )
469
487
  self._meta["architecture"] = "architecture.onnx"
488
+ self._record_artifact_update(output_path)
470
489
  self._flush_meta()
471
490
  return output_path
472
491
 
@@ -652,7 +671,45 @@ class Run:
652
671
  self._meta["modes"] = modes
653
672
  return modes
654
673
 
674
+ def _updated_at_store(self) -> dict[str, Any]:
675
+ if not hasattr(self, "_meta"):
676
+ return self._pending_updated_at
677
+
678
+ updated_at = self._meta.get("updated_at")
679
+ if not isinstance(updated_at, dict):
680
+ updated_at = {}
681
+ self._meta["updated_at"] = updated_at
682
+
683
+ if self._pending_updated_at:
684
+ updated_at.update(self._pending_updated_at)
685
+ self._pending_updated_at.clear()
686
+
687
+ return updated_at
688
+
689
+ def _record_artifact_update(
690
+ self,
691
+ path: str | Path,
692
+ *,
693
+ when: float | None = None,
694
+ ) -> None:
695
+ artifact_path = Path(path)
696
+ ts = when if when is not None else _artifact_mtime(artifact_path)
697
+ if ts is None:
698
+ ts = time.time()
699
+ self._updated_at_store()[self._artifact_key(artifact_path)] = (
700
+ _updated_at_entry(ts)
701
+ )
702
+
703
+ def _artifact_key(self, path: Path) -> str:
704
+ if path.is_absolute():
705
+ try:
706
+ return path.relative_to(self.dir).as_posix()
707
+ except ValueError:
708
+ return str(path)
709
+ return path.as_posix()
710
+
655
711
  def _flush_meta(self) -> None:
712
+ self._record_artifact_update(self.dir / "meta.json", when=time.time())
656
713
  write_json(self.dir / "meta.json", self._meta)
657
714
 
658
715
  def __repr__(self) -> str:
@@ -732,6 +789,20 @@ def _isotime(ts: float) -> str:
732
789
  return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts))
733
790
 
734
791
 
792
+ def _updated_at_entry(ts: float) -> dict[str, Any]:
793
+ return {
794
+ "time": ts,
795
+ "iso": _isotime(ts),
796
+ }
797
+
798
+
799
+ def _artifact_mtime(path: Path) -> float | None:
800
+ try:
801
+ return path.stat().st_mtime
802
+ except OSError:
803
+ return None
804
+
805
+
735
806
  def _normalize_mode(mode: str | None) -> str | None:
736
807
  if mode is None:
737
808
  return None
@@ -1050,6 +1121,9 @@ def _read_ds_crawler_descriptor(path: str) -> dict[str, Any]:
1050
1121
  try:
1051
1122
  cfg = load_dataset_config({"path": path})
1052
1123
  except Exception:
1124
+ descriptor = _read_output_json_descriptor(path)
1125
+ if descriptor:
1126
+ return descriptor
1053
1127
  try:
1054
1128
  contract = get_dataset_contract(path)
1055
1129
  except Exception:
@@ -1082,6 +1156,69 @@ def _read_ds_crawler_descriptor(path: str) -> dict[str, Any]:
1082
1156
  return descriptor
1083
1157
 
1084
1158
 
1159
+ def _read_output_json_descriptor(path: str) -> dict[str, Any]:
1160
+ try:
1161
+ from ds_crawler import zip_utils
1162
+ except Exception:
1163
+ return {}
1164
+
1165
+ try:
1166
+ raw = zip_utils.read_metadata_json(Path(path), "output.json")
1167
+ except Exception:
1168
+ return {}
1169
+
1170
+ return _descriptor_from_output_json(raw, path)
1171
+
1172
+
1173
+ def _descriptor_from_output_json(raw: Any, path: str) -> dict[str, Any]:
1174
+ if isinstance(raw, Mapping):
1175
+ candidates: list[Any]
1176
+ modalities = raw.get("modalities")
1177
+ if isinstance(modalities, list):
1178
+ candidates = modalities
1179
+ else:
1180
+ candidates = [raw]
1181
+ elif isinstance(raw, list):
1182
+ candidates = raw
1183
+ else:
1184
+ return {}
1185
+
1186
+ selected: Mapping[str, Any] | None = None
1187
+ path_name = Path(path).name
1188
+ for candidate in candidates:
1189
+ if not isinstance(candidate, Mapping):
1190
+ continue
1191
+ if _as_non_empty_str(candidate.get("name")) == path_name:
1192
+ selected = candidate
1193
+ break
1194
+ if selected is None:
1195
+ selected = candidate
1196
+
1197
+ if selected is None:
1198
+ return {}
1199
+
1200
+ descriptor: dict[str, Any] = {}
1201
+ properties = selected.get("properties")
1202
+ merged_properties = dict(properties) if isinstance(properties, Mapping) else {}
1203
+
1204
+ euler_train_props = selected.get("euler_train")
1205
+ if isinstance(euler_train_props, Mapping):
1206
+ merged_properties["euler_train"] = dict(euler_train_props)
1207
+
1208
+ if merged_properties:
1209
+ descriptor["properties"] = merged_properties
1210
+
1211
+ modality_key = _as_non_empty_str(selected.get("type"))
1212
+ if modality_key is not None:
1213
+ descriptor["modality_key"] = modality_key
1214
+
1215
+ hierarchy_regex = _as_non_empty_str(selected.get("hierarchy_regex"))
1216
+ if hierarchy_regex is not None:
1217
+ descriptor["hierarchy_regex"] = hierarchy_regex
1218
+
1219
+ return descriptor
1220
+
1221
+
1085
1222
  def _build_modality_entry(
1086
1223
  name: str,
1087
1224
  path: str,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-train"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Lightweight file-based experiment logger for PyTorch"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = ["numpy", "euler-dataset-contract"]
@@ -27,6 +27,12 @@ def _read_jsonl(path: Path) -> list[dict]:
27
27
  return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
28
28
 
29
29
 
30
+ def _assert_updated_at_entry(entry: dict) -> None:
31
+ assert isinstance(entry["time"], (int, float))
32
+ assert isinstance(entry["iso"], str)
33
+ assert entry["iso"]
34
+
35
+
30
36
  class _DummyModality:
31
37
  def __init__(self, path: str) -> None:
32
38
  self.path = path
@@ -228,6 +234,57 @@ class TestInit:
228
234
  run.finish()
229
235
 
230
236
 
237
+ class TestUpdatedAt:
238
+ def test_init_tracks_bootstrap_artifacts(self, tmp_path):
239
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
240
+
241
+ meta = _read_json(run.dir / "meta.json")
242
+ updated_at = meta["updated_at"]
243
+
244
+ for key in (
245
+ "meta.json",
246
+ "config.json",
247
+ "code_ref.json",
248
+ "run_environment.json",
249
+ ):
250
+ assert key in updated_at
251
+ _assert_updated_at_entry(updated_at[key])
252
+ run.finish()
253
+
254
+ def test_log_tracks_train_and_val_jsonl(self, tmp_path):
255
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
256
+
257
+ run.log({"loss": 1.0}, step=0, epoch=0)
258
+ run.log({"metric": 0.9}, step=1, epoch=0, mode="val")
259
+
260
+ meta = _read_json(run.dir / "meta.json")
261
+ updated_at = meta["updated_at"]
262
+ _assert_updated_at_entry(updated_at["train.jsonl"])
263
+ _assert_updated_at_entry(updated_at["val.jsonl"])
264
+ _assert_updated_at_entry(updated_at["meta.json"])
265
+ run.finish()
266
+
267
+ def test_save_outputs_tracks_output_directory(self, tmp_path):
268
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
269
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
270
+
271
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred=arr))
272
+
273
+ meta = _read_json(run.dir / "meta.json")
274
+ _assert_updated_at_entry(meta["updated_at"]["outputs/epoch_0_step_0"])
275
+ run.finish()
276
+
277
+ def test_log_saved_checkpoint_tracks_checkpoint_path(self, tmp_path):
278
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
279
+ checkpoint_path = tmp_path / "external" / "epoch_1.pt"
280
+
281
+ run.log_saved_checkpoint(checkpoint_path, epoch=1, step=10)
282
+
283
+ meta = _read_json(run.dir / "meta.json")
284
+ _assert_updated_at_entry(meta["updated_at"][str(checkpoint_path)])
285
+ run.finish()
286
+
287
+
231
288
  class TestDatasetMetadata:
232
289
  def test_prefers_dataset_describe_for_runlog_contract(
233
290
  self, tmp_path, monkeypatch
File without changes