extty 0.1.0__py3-none-any.whl

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.
extty/__init__.py ADDED
@@ -0,0 +1,897 @@
1
+ """extty: Terminal-native ML experiment tracker."""
2
+
3
+ import atexit
4
+ import copy
5
+ import dataclasses
6
+ import functools
7
+ import os
8
+ import warnings
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from pathlib import Path
11
+ from typing import Any, Callable, ParamSpec, TypeVar
12
+
13
+ from extty._logger import log as logger
14
+ from extty.arg_parser import run_experiments_parser
15
+ from extty.artifact import (
16
+ ArtifactMeta,
17
+ artifact_exists,
18
+ delete_artifact,
19
+ get_artifact,
20
+ list_artifacts,
21
+ load_artifact,
22
+ )
23
+ from extty.artifact import (
24
+ save_artifact as _save_artifact_raw,
25
+ )
26
+ from extty.chart import Chart
27
+ from extty.checkpoints import (
28
+ DELETABLE_LOCALLY,
29
+ CheckpointStatus,
30
+ LocalCopy,
31
+ checkpoint_status,
32
+ local_checkpoint_dir,
33
+ read_local_checkpoint,
34
+ read_local_copy,
35
+ )
36
+ from extty.compare import compare, config_diff, plot_metric, reduce_metric
37
+ from extty.confusion import ConfusionMatrix
38
+ from extty.example import BatchExample, Example
39
+ from extty.image import Image
40
+ from extty.query import RunData, get_run, get_runs
41
+ from extty.run import NoOpRun, Run
42
+ from extty.s3 import S3Config
43
+ from extty.storage import (
44
+ ChartRecord,
45
+ Checkpoint,
46
+ CheckpointFile,
47
+ ConfusionMatrixRecord,
48
+ ExampleRecord,
49
+ ImageRecord,
50
+ MetricPoint,
51
+ SystemMetricPoint,
52
+ get_runs_dir,
53
+ )
54
+
55
+ _logger = logger
56
+
57
+ __all__ = [
58
+ "init",
59
+ "log",
60
+ "save_checkpoint",
61
+ "load_checkpoint",
62
+ "load_checkpoint_from",
63
+ "delete_local_checkpoint",
64
+ "finish",
65
+ "Run",
66
+ "RunData",
67
+ "get_runs",
68
+ "get_run",
69
+ "push",
70
+ "list_local_runs",
71
+ "compare",
72
+ "config_diff",
73
+ "plot_metric",
74
+ "reduce_metric",
75
+ "Example",
76
+ "BatchExample",
77
+ "ConfusionMatrix",
78
+ "ConfusionMatrixRecord",
79
+ "Chart",
80
+ "ChartRecord",
81
+ "Image",
82
+ "ImageRecord",
83
+ "MetricPoint",
84
+ "SystemMetricPoint",
85
+ "ExampleRecord",
86
+ "Checkpoint",
87
+ "CheckpointFile",
88
+ "has_active_run",
89
+ "NoOpRun",
90
+ "ArtifactMeta",
91
+ "save_artifact",
92
+ "list_artifacts",
93
+ "load_artifact",
94
+ "get_artifact",
95
+ "artifact_exists",
96
+ "delete_artifact",
97
+ "logger",
98
+ "run_experiments_parser",
99
+ ]
100
+
101
+ # Version is managed by setuptools_scm
102
+ try:
103
+ __version__ = version("extty")
104
+ except PackageNotFoundError:
105
+ # Package is not installed, use a default version
106
+ __version__ = "0.0.0+unknown"
107
+
108
+ _active_run: Run | None = None
109
+ _atexit_registered: bool = False
110
+
111
+
112
+ def has_active_run() -> bool:
113
+ return _active_run is not None
114
+
115
+
116
+ def _atexit_finish_active_run() -> None:
117
+ """Best-effort flush of any still-active run when the interpreter exits.
118
+
119
+ Daemon writer threads in :class:`extty.async_sink.AsyncSink` are killed
120
+ without draining at interpreter shutdown, so this handler protects users
121
+ who forget to call :func:`extty.finish`.
122
+ """
123
+ global _active_run
124
+ run = _active_run
125
+ if run is None:
126
+ return
127
+ try:
128
+ run.finish()
129
+ except Exception:
130
+ _logger.exception("extty atexit handler failed while finishing run")
131
+ finally:
132
+ _active_run = None
133
+
134
+
135
+ def _is_main_process(rank: int | None) -> bool:
136
+ if rank is not None:
137
+ return rank == 0
138
+ env_rank = os.environ.get("RANK")
139
+ if env_rank is not None:
140
+ try:
141
+ return int(env_rank) == 0
142
+ except ValueError:
143
+ return True
144
+ return True
145
+
146
+
147
+ def init(
148
+ project: str,
149
+ *,
150
+ name: str | None = None,
151
+ config: dict[str, Any] | None = None,
152
+ system_metrics: bool = True,
153
+ rank: int | None = None,
154
+ ) -> Run:
155
+ """
156
+ Initialize a new experiment run.
157
+
158
+ On non-main processes in distributed training (detected via the RANK
159
+ environment variable or the explicit ``rank`` parameter), returns a
160
+ :class:`NoOpRun` that silently discards all operations.
161
+
162
+ Parameters
163
+ ----------
164
+ project : str
165
+ Name of the project/experiment group.
166
+ name : str, optional
167
+ Name for this specific run. Auto-generated if not provided.
168
+ config : dict, optional
169
+ Hyperparameters and configuration to log.
170
+ system_metrics : bool, default True
171
+ Whether to automatically collect system metrics (RAM, GPU).
172
+ rank : int, optional
173
+ Explicit process rank. When provided, overrides the RANK environment
174
+ variable. Only rank 0 creates a real run.
175
+
176
+ Returns
177
+ -------
178
+ Run
179
+ The initialized run object (or :class:`NoOpRun` on non-main processes).
180
+ """
181
+ global _active_run, _atexit_registered
182
+ if _active_run is not None:
183
+ _active_run.finish()
184
+
185
+ if _is_main_process(rank):
186
+ if not _atexit_registered:
187
+ atexit.register(_atexit_finish_active_run)
188
+ _atexit_registered = True
189
+ _active_run = Run(
190
+ project,
191
+ name=name,
192
+ config=config,
193
+ system_metrics=system_metrics,
194
+ )
195
+ _logger.info(
196
+ "initialized run %s/%s, writing to %s",
197
+ project,
198
+ _active_run.name,
199
+ _active_run.run_dir,
200
+ )
201
+ else:
202
+ _active_run = NoOpRun(project, name=name, config=config)
203
+
204
+ return _active_run
205
+
206
+
207
+ def log(metrics: dict[str, Any], *, step: int) -> None:
208
+ """
209
+ Log metrics or structured examples for the current step.
210
+
211
+ Parameters
212
+ ----------
213
+ metrics : dict[str, Any]
214
+ Dictionary of metric names to values. Values can be:
215
+ - float/int: logged as metric
216
+ - Example: single prompt with grouped responses
217
+ - BatchExample: batch of prompts with grouped responses
218
+ - ConfusionMatrix: N×N matrix with class labels
219
+ - Chart: a 2D chart of (x, y) points with axis names
220
+ - Image: a PIL image wrapped in extty.Image, stored as PNG
221
+ step : int
222
+ The current training step.
223
+ """
224
+ if _active_run is None:
225
+ raise RuntimeError("No active run. Call extty.init() first.")
226
+ _active_run.log(metrics, step=step)
227
+
228
+
229
+ def save_checkpoint(
230
+ step: int,
231
+ *,
232
+ path: str | None = None,
233
+ state_dict: Any = None,
234
+ optimizer_state_dict: Any = None,
235
+ keep_local: bool = False,
236
+ ) -> None:
237
+ """
238
+ Save a checkpoint for the active run.
239
+
240
+ Saved to the run directory, or uploaded to S3 when configured; see
241
+ :meth:`extty.Run.save_checkpoint`.
242
+
243
+ Parameters
244
+ ----------
245
+ step : int
246
+ The training step for this checkpoint.
247
+ path : str or None
248
+ Path to an existing checkpoint file to save.
249
+ state_dict : Any or None
250
+ Model state dict to serialize with torch.save.
251
+ optimizer_state_dict : Any or None
252
+ Optimizer state dict to include when using state_dict.
253
+ keep_local : bool, default False
254
+ Keep the local copy after a successful S3 upload.
255
+ """
256
+ if _active_run is None:
257
+ raise RuntimeError("No active run. Call extty.init() first.")
258
+ _active_run.save_checkpoint(
259
+ step,
260
+ path=path,
261
+ state_dict=state_dict,
262
+ optimizer_state_dict=optimizer_state_dict,
263
+ keep_local=keep_local,
264
+ )
265
+
266
+
267
+ def load_checkpoint(
268
+ step: int,
269
+ *,
270
+ load_optimizer: bool = True,
271
+ ) -> dict[str, Any]:
272
+ """
273
+ Load a checkpoint for the active run, downloading from S3 if needed.
274
+
275
+ Parameters
276
+ ----------
277
+ step : int
278
+ The training step to load.
279
+ load_optimizer : bool, default True
280
+ Whether to include the optimizer state in the result.
281
+
282
+ Returns
283
+ -------
284
+ dict[str, Any]
285
+ Contains ``"model_state_dict"`` and optionally
286
+ ``"optimizer_state_dict"``.
287
+ """
288
+ if _active_run is None:
289
+ raise RuntimeError("No active run. Call extty.init() first.")
290
+ return _active_run.load_checkpoint(step, load_optimizer=load_optimizer)
291
+
292
+
293
+ def load_checkpoint_from(
294
+ project: str,
295
+ run_name: str,
296
+ step: int,
297
+ *,
298
+ load_optimizer: bool = True,
299
+ s3_config: S3Config | None = None,
300
+ ) -> dict[str, Any]:
301
+ """
302
+ Load a checkpoint from any run, downloading from S3 if needed.
303
+
304
+ A complete copy in the local runs directory is used without contacting
305
+ S3.
306
+
307
+ Parameters
308
+ ----------
309
+ project : str
310
+ The project name.
311
+ run_name : str
312
+ The run name.
313
+ step : int
314
+ The training step to load.
315
+ load_optimizer : bool, default True
316
+ Whether to include the optimizer state in the result.
317
+ s3_config : S3Config, optional
318
+ S3 configuration. Loaded from environment if not provided.
319
+
320
+ Returns
321
+ -------
322
+ dict[str, Any]
323
+ Contains ``"model_state_dict"`` and optionally
324
+ ``"optimizer_state_dict"``.
325
+
326
+ Raises
327
+ ------
328
+ FileNotFoundError
329
+ If the checkpoint is not available locally and S3 is not configured
330
+ or does not have it.
331
+ """
332
+ from extty.s3 import S3Storage
333
+
334
+ local_dir = local_checkpoint_dir(project, run_name, step)
335
+ local = read_local_checkpoint(local_dir, load_optimizer=load_optimizer)
336
+ if local is not None:
337
+ return local
338
+ if s3_config is None:
339
+ s3_config = S3Config.load()
340
+ if s3_config is None:
341
+ raise FileNotFoundError(
342
+ f"Checkpoint {project}/{run_name} step {step} not found in {local_dir} "
343
+ "and S3 is not configured."
344
+ )
345
+ storage = S3Storage(s3_config, project, run_name)
346
+ return storage.load_checkpoint(step, load_optimizer=load_optimizer)
347
+
348
+
349
+ def delete_local_checkpoint(
350
+ project: str,
351
+ run_name: str,
352
+ step: int,
353
+ *,
354
+ force: bool = False,
355
+ s3_config: S3Config | None = None,
356
+ ) -> bool:
357
+ """
358
+ Delete a checkpoint's local files, leaving any S3 copy intact.
359
+
360
+ The local copy may be the only one (no S3 configured, its upload failed,
361
+ or S3 has since been given a different save of the step), so unless
362
+ ``force`` is set this deletes only when S3 holds the same save as the
363
+ local copy. This is the inverse of :func:`load_checkpoint_from`, using
364
+ the same addressing.
365
+
366
+ Parameters
367
+ ----------
368
+ project : str
369
+ The project name.
370
+ run_name : str
371
+ The run name.
372
+ step : int
373
+ The training step whose local files should be removed.
374
+ force : bool, default False
375
+ Delete even if S3 does not hold this save of the checkpoint.
376
+ s3_config : S3Config, optional
377
+ S3 configuration. Loaded from environment if not provided.
378
+
379
+ Returns
380
+ -------
381
+ bool
382
+ True if a local directory was found and removed, False if there was
383
+ nothing locally.
384
+
385
+ Raises
386
+ ------
387
+ RuntimeError
388
+ If ``force`` is not set and S3 cannot confirm it has this save of the
389
+ checkpoint (not configured, unreachable, missing it, or holding a
390
+ different save of the step).
391
+ """
392
+ import shutil
393
+
394
+ local_dir = local_checkpoint_dir(project, run_name, step)
395
+ local = read_local_copy(local_dir)
396
+ if local is None:
397
+ return False
398
+ if not force:
399
+ _require_remote_copy(project, run_name, step, local, local_dir, s3_config)
400
+ shutil.rmtree(local_dir)
401
+ return True
402
+
403
+
404
+ _KEEP_REASONS = {
405
+ CheckpointStatus.LOCAL_ONLY: "It is not in S3.",
406
+ CheckpointStatus.UNTRACKED: "It is not in S3.",
407
+ CheckpointStatus.DIVERGED: "S3 has a different save of this step.",
408
+ }
409
+
410
+
411
+ def _require_remote_copy(
412
+ project: str,
413
+ run_name: str,
414
+ step: int,
415
+ local: LocalCopy,
416
+ local_dir: Path,
417
+ s3_config: S3Config | None,
418
+ ) -> None:
419
+ from extty.s3 import _S3_ERRORS, S3Storage
420
+
421
+ refusal = (
422
+ f"Refusing to delete {local_dir}: it may be the only copy of checkpoint "
423
+ f"{project}/{run_name} step {step}. Pass force=True to delete it anyway."
424
+ )
425
+ if s3_config is None:
426
+ s3_config = S3Config.load()
427
+ if s3_config is None:
428
+ raise RuntimeError(f"{refusal} (S3 is not configured.)")
429
+ try:
430
+ remote = S3Storage(s3_config, project, run_name).find_checkpoint(step)
431
+ except (ImportError, *_S3_ERRORS) as e:
432
+ raise RuntimeError(f"{refusal} (Could not check S3: {e})") from e
433
+ status = checkpoint_status(local, remote)
434
+ if status not in DELETABLE_LOCALLY:
435
+ raise RuntimeError(f"{refusal} ({_KEEP_REASONS[status]})")
436
+
437
+
438
+ def finish() -> None:
439
+ """Finish the current run and flush all data."""
440
+ global _active_run
441
+ if _active_run is None:
442
+ raise RuntimeError("No active run to finish.")
443
+ _active_run.finish()
444
+ _active_run = None
445
+
446
+
447
+ def save_artifact(
448
+ name: str,
449
+ path: str | Path,
450
+ *,
451
+ description: str = "",
452
+ metadata: dict[str, Any] | None = None,
453
+ s3_config: S3Config | None = None,
454
+ ) -> ArtifactMeta:
455
+ """
456
+ Save an artifact to S3.
457
+
458
+ If an active run exists, the artifact is automatically associated
459
+ with it.
460
+
461
+ Parameters
462
+ ----------
463
+ name : str
464
+ Unique name for this artifact.
465
+ path : str or Path
466
+ Local file or directory to upload.
467
+ description : str
468
+ Human-readable description.
469
+ metadata : dict[str, Any], optional
470
+ User-defined metadata (arbitrary JSON-serializable dict).
471
+ s3_config : S3Config, optional
472
+ S3 configuration. Loaded from environment if not provided.
473
+
474
+ Returns
475
+ -------
476
+ ArtifactMeta
477
+ Metadata for the saved artifact.
478
+ """
479
+ if isinstance(_active_run, NoOpRun):
480
+ return _active_run.save_artifact(
481
+ name, path, description=description, metadata=metadata, s3_config=s3_config
482
+ )
483
+ run_project = _active_run.project if _active_run is not None else None
484
+ run_name = _active_run.name if _active_run is not None else None
485
+ return _save_artifact_raw(
486
+ name,
487
+ path,
488
+ description=description,
489
+ metadata=metadata,
490
+ s3_config=s3_config,
491
+ run_project=run_project,
492
+ run_name=run_name,
493
+ )
494
+
495
+
496
+ P = ParamSpec("P")
497
+ T = TypeVar("T")
498
+
499
+
500
+ def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
501
+ def _convert(val: Any) -> Any:
502
+ if dataclasses.is_dataclass(val) and not isinstance(val, type):
503
+ return dataclasses.asdict(val)
504
+ if isinstance(val, dict):
505
+ return {k: _convert(v) for k, v in val.items()}
506
+ if isinstance(val, list):
507
+ return [_convert(v) for v in val]
508
+ return val
509
+
510
+ return {k: _convert(v) for k, v in config.items()}
511
+
512
+
513
+ def experiment(
514
+ project: str,
515
+ name: str | None = None,
516
+ name_kwarg: str | None = None,
517
+ conf_kwargs: list[str] | None = None,
518
+ non_conf_kwargs: list[str] | None = None,
519
+ system_metrics: bool = True,
520
+ ) -> Callable[[Callable[P, T]], Callable[P, T]]:
521
+ if conf_kwargs is not None and non_conf_kwargs is not None:
522
+ raise ValueError(
523
+ "cannot pass values for both `conf_kwargs` and `non_conf_kwargs`"
524
+ )
525
+
526
+ def dec(fn: Callable[P, T]) -> Callable[P, T]:
527
+ @functools.wraps(fn)
528
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
529
+ if len(args) > 0:
530
+ warnings.warn(
531
+ f"non-keyword args passed to {fn} will not be logged to `extty`."
532
+ )
533
+ try:
534
+ kwargs_copy = copy.deepcopy(kwargs)
535
+ if conf_kwargs is not None:
536
+ kwargs_copy = {k: kwargs_copy[k] for k in conf_kwargs}
537
+ if non_conf_kwargs is not None:
538
+ kwargs_copy = {
539
+ k: v for k, v in kwargs_copy.items() if k not in non_conf_kwargs
540
+ }
541
+
542
+ kwargs_copy = _sanitize_config(kwargs_copy)
543
+
544
+ run_name = name
545
+
546
+ if run_name is None and name_kwarg is not None:
547
+ run_name = kwargs_copy.pop(name_kwarg)
548
+
549
+ init(
550
+ project=project,
551
+ name=run_name,
552
+ config=kwargs_copy,
553
+ system_metrics=system_metrics,
554
+ )
555
+ return fn(*args, **kwargs)
556
+ finally:
557
+ global _active_run
558
+ if _active_run is not None:
559
+ finish()
560
+
561
+ return wrapper
562
+
563
+ return dec
564
+
565
+
566
+ def list_local_runs(project: str | None = None) -> list[tuple[str, str]]:
567
+ """
568
+ List all local runs.
569
+
570
+ Parameters
571
+ ----------
572
+ project : str, optional
573
+ Filter to runs in this project only.
574
+
575
+ Returns
576
+ -------
577
+ list[tuple[str, str]]
578
+ List of (project, run_name) tuples, sorted by start time
579
+ (most recent first).
580
+ """
581
+ return [(r.project, r.name) for r in get_runs(project=project)]
582
+
583
+
584
+ def push(
585
+ target: str | None = None,
586
+ *,
587
+ s3_config: S3Config | None = None,
588
+ force: bool = False,
589
+ dry_run: bool = False,
590
+ ) -> list[str]:
591
+ """
592
+ Push local runs to S3.
593
+
594
+ Parameters
595
+ ----------
596
+ target : str, optional
597
+ Target to push. Format: "project/run-name" for single run,
598
+ "project/" for all runs in project, or None for all runs.
599
+ s3_config : S3Config, optional
600
+ S3 configuration. If not provided, reads from environment variables.
601
+ force : bool, default False
602
+ Overwrite remote data without merging.
603
+ dry_run : bool, default False
604
+ Show what would be pushed without actually pushing.
605
+
606
+ Returns
607
+ -------
608
+ list[str]
609
+ List of pushed run paths (project/run_name format).
610
+ """
611
+ if s3_config is None:
612
+ s3_config = S3Config.load()
613
+ if s3_config is None:
614
+ raise ValueError(
615
+ "S3 configuration required. Set EXTTY_S3_BUCKET environment variable "
616
+ "or provide s3_config parameter."
617
+ )
618
+
619
+ try:
620
+ import boto3
621
+ except ImportError:
622
+ raise ImportError(
623
+ "boto3 is required for S3 operations. Install with: pip install extty[s3]"
624
+ )
625
+
626
+ kwargs: dict = {}
627
+ if s3_config.region:
628
+ kwargs["region_name"] = s3_config.region
629
+ if s3_config.access_key_id and s3_config.secret_access_key:
630
+ kwargs["aws_access_key_id"] = s3_config.access_key_id
631
+ kwargs["aws_secret_access_key"] = s3_config.secret_access_key
632
+ if s3_config.endpoint_url:
633
+ kwargs["endpoint_url"] = s3_config.endpoint_url
634
+
635
+ client = boto3.client("s3", **kwargs)
636
+
637
+ project_filter = None
638
+ run_filter = None
639
+ if target:
640
+ if target.endswith("/"):
641
+ project_filter = target.rstrip("/")
642
+ elif "/" in target:
643
+ project_filter, run_filter = target.split("/", 1)
644
+ else:
645
+ project_filter = target
646
+
647
+ runs_to_push = []
648
+ for proj, run_name in list_local_runs(project_filter):
649
+ if run_filter is not None and run_name != run_filter:
650
+ continue
651
+ runs_to_push.append((proj, run_name))
652
+
653
+ if dry_run:
654
+ for proj, run_name in runs_to_push:
655
+ _logger.info("would push: %s/%s", proj, run_name)
656
+ return [f"{p}/{r}" for p, r in runs_to_push]
657
+
658
+ pushed = []
659
+ runs_dir = get_runs_dir()
660
+ for proj, run_name in runs_to_push:
661
+ local_run_dir = runs_dir / proj / run_name
662
+ s3_prefix = f"{s3_config.prefix}/runs/{proj}/{run_name}"
663
+
664
+ _push_run_to_s3(client, s3_config.bucket, s3_prefix, local_run_dir, force=force)
665
+ pushed.append(f"{proj}/{run_name}")
666
+ _logger.info("pushed: %s/%s", proj, run_name)
667
+
668
+ return pushed
669
+
670
+
671
+ def _push_run_to_s3(
672
+ client,
673
+ bucket: str,
674
+ s3_prefix: str,
675
+ local_run_dir,
676
+ force: bool = False,
677
+ ) -> None:
678
+ """Push a single run directory to S3."""
679
+ import json
680
+
681
+ meta_path = local_run_dir / "meta.json"
682
+ if meta_path.exists():
683
+ with open(meta_path) as f:
684
+ meta_content = f.read()
685
+ if force:
686
+ client.put_object(
687
+ Bucket=bucket,
688
+ Key=f"{s3_prefix}/meta.json",
689
+ Body=meta_content.encode("utf-8"),
690
+ ContentType="application/json",
691
+ )
692
+ else:
693
+ try:
694
+ response = client.get_object(
695
+ Bucket=bucket, Key=f"{s3_prefix}/meta.json"
696
+ )
697
+ remote_meta = json.loads(response["Body"].read().decode("utf-8"))
698
+ local_meta = json.loads(meta_content)
699
+ merged_meta = _merge_meta(local_meta, remote_meta)
700
+ client.put_object(
701
+ Bucket=bucket,
702
+ Key=f"{s3_prefix}/meta.json",
703
+ Body=json.dumps(merged_meta, indent=2).encode("utf-8"),
704
+ ContentType="application/json",
705
+ )
706
+ except client.exceptions.NoSuchKey:
707
+ client.put_object(
708
+ Bucket=bucket,
709
+ Key=f"{s3_prefix}/meta.json",
710
+ Body=meta_content.encode("utf-8"),
711
+ ContentType="application/json",
712
+ )
713
+
714
+ metrics_dir = local_run_dir / "metrics"
715
+ if metrics_dir.exists():
716
+ for csv_file in metrics_dir.rglob("*.csv"):
717
+ relative_path = csv_file.relative_to(metrics_dir)
718
+ s3_key = f"{s3_prefix}/metrics/{relative_path}"
719
+ _push_csv_file(client, bucket, s3_key, csv_file, force=force)
720
+
721
+ examples_dir = local_run_dir / "examples"
722
+ if examples_dir.exists():
723
+ for jsonl_file in examples_dir.rglob("*.jsonl"):
724
+ relative_path = jsonl_file.relative_to(examples_dir)
725
+ s3_key = f"{s3_prefix}/examples/{relative_path}"
726
+ _push_jsonl_file(client, bucket, s3_key, jsonl_file, force=force)
727
+
728
+ confusion_dir = local_run_dir / "confusion_matrices"
729
+ if confusion_dir.exists():
730
+ for jsonl_file in confusion_dir.rglob("*.jsonl"):
731
+ relative_path = jsonl_file.relative_to(confusion_dir)
732
+ s3_key = f"{s3_prefix}/confusion_matrices/{relative_path}"
733
+ _push_jsonl_file(client, bucket, s3_key, jsonl_file, force=force)
734
+
735
+ charts_dir = local_run_dir / "charts"
736
+ if charts_dir.exists():
737
+ for jsonl_file in charts_dir.rglob("*.jsonl"):
738
+ relative_path = jsonl_file.relative_to(charts_dir)
739
+ s3_key = f"{s3_prefix}/charts/{relative_path}"
740
+ _push_jsonl_file(client, bucket, s3_key, jsonl_file, force=force)
741
+
742
+ images_dir = local_run_dir / "images"
743
+ if images_dir.exists():
744
+ for jsonl_file in images_dir.rglob("*.jsonl"):
745
+ relative_path = jsonl_file.relative_to(images_dir)
746
+ s3_key = f"{s3_prefix}/images/{relative_path}"
747
+ _push_jsonl_file(client, bucket, s3_key, jsonl_file, force=force)
748
+ for png_file in images_dir.rglob("*.png"):
749
+ relative_path = png_file.relative_to(images_dir)
750
+ s3_key = f"{s3_prefix}/images/{relative_path}"
751
+ _push_binary_file(
752
+ client, bucket, s3_key, png_file, "image/png", force=force
753
+ )
754
+
755
+ system_csv = local_run_dir / "system.csv"
756
+ if system_csv.exists():
757
+ s3_key = f"{s3_prefix}/system.csv"
758
+ _push_csv_file(client, bucket, s3_key, system_csv, force=force)
759
+
760
+
761
+ def _merge_meta(local: dict, remote: dict) -> dict:
762
+ """Merge meta.json from local and remote."""
763
+ if local.get("status") == "completed":
764
+ return local
765
+ if remote.get("status") == "completed":
766
+ return remote
767
+ local_finished = local.get("finished_at")
768
+ remote_finished = remote.get("finished_at")
769
+ if local_finished and remote_finished:
770
+ return local if local_finished >= remote_finished else remote
771
+ if local_finished:
772
+ return local
773
+ if remote_finished:
774
+ return remote
775
+ return local
776
+
777
+
778
+ def _push_csv_file(client, bucket: str, s3_key: str, local_path, force: bool) -> None:
779
+ """Push a CSV file to S3, optionally merging with existing data."""
780
+ import csv
781
+ import io
782
+
783
+ with open(local_path) as f:
784
+ reader = csv.DictReader(f)
785
+ local_rows = list(reader)
786
+ if not local_rows:
787
+ return
788
+ fieldnames = reader.fieldnames or []
789
+
790
+ if force:
791
+ with open(local_path, "rb") as f:
792
+ client.put_object(
793
+ Bucket=bucket, Key=s3_key, Body=f.read(), ContentType="text/csv"
794
+ )
795
+ return
796
+
797
+ existing_keys: set[tuple] = set()
798
+ existing_rows: list[dict] = []
799
+ try:
800
+ response = client.get_object(Bucket=bucket, Key=s3_key)
801
+ content = response["Body"].read().decode("utf-8")
802
+ reader = csv.DictReader(io.StringIO(content))
803
+ for row in reader:
804
+ key = (row.get("step", ""), row.get("timestamp", ""))
805
+ existing_keys.add(key)
806
+ existing_rows.append(row)
807
+ except client.exceptions.NoSuchKey:
808
+ pass
809
+
810
+ for row in local_rows:
811
+ key = (row.get("step", ""), row.get("timestamp", ""))
812
+ if key not in existing_keys:
813
+ existing_keys.add(key)
814
+ existing_rows.append(row)
815
+
816
+ existing_rows.sort(
817
+ key=lambda r: (float(r.get("step", 0)), float(r.get("timestamp", 0)))
818
+ )
819
+
820
+ output = io.StringIO()
821
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
822
+ writer.writeheader()
823
+ writer.writerows(existing_rows)
824
+
825
+ client.put_object(
826
+ Bucket=bucket,
827
+ Key=s3_key,
828
+ Body=output.getvalue().encode("utf-8"),
829
+ ContentType="text/csv",
830
+ )
831
+
832
+
833
+ def _push_binary_file(
834
+ client, bucket: str, s3_key: str, local_path, content_type: str, force: bool
835
+ ) -> None:
836
+ """Push an immutable binary file to S3, skipping if the key already exists."""
837
+ if not force:
838
+ try:
839
+ client.head_object(Bucket=bucket, Key=s3_key)
840
+ return
841
+ except Exception:
842
+ pass
843
+ with open(local_path, "rb") as f:
844
+ client.put_object(
845
+ Bucket=bucket, Key=s3_key, Body=f.read(), ContentType=content_type
846
+ )
847
+
848
+
849
+ def _push_jsonl_file(client, bucket: str, s3_key: str, local_path, force: bool) -> None:
850
+ """Push a JSONL file to S3, optionally merging with existing data."""
851
+ import json
852
+
853
+ with open(local_path) as f:
854
+ local_records = [json.loads(line) for line in f if line.strip()]
855
+
856
+ if force:
857
+ with open(local_path, "rb") as f:
858
+ client.put_object(
859
+ Bucket=bucket,
860
+ Key=s3_key,
861
+ Body=f.read(),
862
+ ContentType="application/x-ndjson",
863
+ )
864
+ return
865
+
866
+ existing_keys: set[tuple] = set()
867
+ existing_records: list[dict] = []
868
+ try:
869
+ response = client.get_object(Bucket=bucket, Key=s3_key)
870
+ content = response["Body"].read().decode("utf-8")
871
+ for line in content.strip().split("\n"):
872
+ if line:
873
+ record = json.loads(line)
874
+ key = (record.get("step"), record.get("timestamp"))
875
+ existing_keys.add(key)
876
+ existing_records.append(record)
877
+ except client.exceptions.NoSuchKey:
878
+ pass
879
+
880
+ for record in local_records:
881
+ key = (record.get("step"), record.get("timestamp"))
882
+ if key not in existing_keys:
883
+ existing_keys.add(key)
884
+ existing_records.append(record)
885
+
886
+ existing_records.sort(key=lambda r: (r.get("step", 0), r.get("timestamp", 0)))
887
+
888
+ output = "\n".join(json.dumps(r) for r in existing_records)
889
+ if output:
890
+ output += "\n"
891
+
892
+ client.put_object(
893
+ Bucket=bucket,
894
+ Key=s3_key,
895
+ Body=output.encode("utf-8"),
896
+ ContentType="application/x-ndjson",
897
+ )