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 +897 -0
- extty/_logger.py +79 -0
- extty/_sink.py +37 -0
- extty/arg_parser.py +137 -0
- extty/artifact.py +555 -0
- extty/async_sink.py +157 -0
- extty/chart.py +65 -0
- extty/checkpoints.py +550 -0
- extty/compare.py +349 -0
- extty/confusion.py +49 -0
- extty/example.py +108 -0
- extty/image.py +67 -0
- extty/py.typed +0 -0
- extty/query.py +382 -0
- extty/run.py +551 -0
- extty/s3.py +1284 -0
- extty/storage.py +857 -0
- extty/system_monitor.py +126 -0
- extty-0.1.0.dist-info/METADATA +186 -0
- extty-0.1.0.dist-info/RECORD +23 -0
- extty-0.1.0.dist-info/WHEEL +5 -0
- extty-0.1.0.dist-info/licenses/LICENSE +21 -0
- extty-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|