yanex 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.
- yanex/__init__.py +74 -0
- yanex/api.py +507 -0
- yanex/cli/__init__.py +3 -0
- yanex/cli/_utils.py +114 -0
- yanex/cli/commands/__init__.py +3 -0
- yanex/cli/commands/archive.py +177 -0
- yanex/cli/commands/compare.py +320 -0
- yanex/cli/commands/confirm.py +198 -0
- yanex/cli/commands/delete.py +203 -0
- yanex/cli/commands/list.py +243 -0
- yanex/cli/commands/run.py +625 -0
- yanex/cli/commands/show.py +560 -0
- yanex/cli/commands/unarchive.py +177 -0
- yanex/cli/commands/update.py +282 -0
- yanex/cli/filters/__init__.py +8 -0
- yanex/cli/filters/base.py +286 -0
- yanex/cli/filters/time_utils.py +178 -0
- yanex/cli/formatters/__init__.py +7 -0
- yanex/cli/formatters/console.py +325 -0
- yanex/cli/main.py +45 -0
- yanex/core/__init__.py +3 -0
- yanex/core/comparison.py +549 -0
- yanex/core/config.py +587 -0
- yanex/core/constants.py +16 -0
- yanex/core/environment.py +146 -0
- yanex/core/git_utils.py +153 -0
- yanex/core/manager.py +555 -0
- yanex/core/storage.py +682 -0
- yanex/ui/__init__.py +1 -0
- yanex/ui/compare_table.py +524 -0
- yanex/utils/__init__.py +3 -0
- yanex/utils/exceptions.py +70 -0
- yanex/utils/validation.py +165 -0
- yanex-0.1.0.dist-info/METADATA +251 -0
- yanex-0.1.0.dist-info/RECORD +39 -0
- yanex-0.1.0.dist-info/WHEEL +5 -0
- yanex-0.1.0.dist-info/entry_points.txt +2 -0
- yanex-0.1.0.dist-info/licenses/LICENSE +21 -0
- yanex-0.1.0.dist-info/top_level.txt +1 -0
yanex/__init__.py
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
"""
|
2
|
+
Yanex - Yet Another Experiment Tracker
|
3
|
+
|
4
|
+
A lightweight, Git-aware experiment tracking system for Python.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from .api import (
|
8
|
+
ExperimentContext,
|
9
|
+
_clear_current_experiment_id,
|
10
|
+
_ExperimentCancelledException,
|
11
|
+
_ExperimentCompletedException,
|
12
|
+
_ExperimentFailedException,
|
13
|
+
_get_current_experiment_id,
|
14
|
+
# Internal functions (for testing)
|
15
|
+
_set_current_experiment_id,
|
16
|
+
cancel,
|
17
|
+
# Manual experiment control
|
18
|
+
completed,
|
19
|
+
create_context,
|
20
|
+
# Experiment creation (advanced)
|
21
|
+
create_experiment,
|
22
|
+
fail,
|
23
|
+
# Experiment information
|
24
|
+
get_experiment_id,
|
25
|
+
get_metadata,
|
26
|
+
get_param,
|
27
|
+
# Parameter access
|
28
|
+
get_params,
|
29
|
+
get_status,
|
30
|
+
has_context,
|
31
|
+
# Context detection
|
32
|
+
is_standalone,
|
33
|
+
log_artifact,
|
34
|
+
log_matplotlib_figure,
|
35
|
+
# Result logging
|
36
|
+
log_results,
|
37
|
+
log_text,
|
38
|
+
)
|
39
|
+
|
40
|
+
__version__ = "0.1.0"
|
41
|
+
__author__ = "Thomas"
|
42
|
+
|
43
|
+
__all__ = [
|
44
|
+
# Parameter access
|
45
|
+
"get_params",
|
46
|
+
"get_param",
|
47
|
+
# Context detection
|
48
|
+
"is_standalone",
|
49
|
+
"has_context",
|
50
|
+
# Result logging
|
51
|
+
"log_results",
|
52
|
+
"log_artifact",
|
53
|
+
"log_text",
|
54
|
+
"log_matplotlib_figure",
|
55
|
+
# Experiment information
|
56
|
+
"get_experiment_id",
|
57
|
+
"get_status",
|
58
|
+
"get_metadata",
|
59
|
+
# Experiment creation (advanced)
|
60
|
+
"create_experiment",
|
61
|
+
"create_context",
|
62
|
+
"ExperimentContext",
|
63
|
+
# Manual experiment control
|
64
|
+
"completed",
|
65
|
+
"fail",
|
66
|
+
"cancel",
|
67
|
+
# Internal functions (for testing)
|
68
|
+
"_clear_current_experiment_id",
|
69
|
+
"_ExperimentCancelledException",
|
70
|
+
"_ExperimentCompletedException",
|
71
|
+
"_ExperimentFailedException",
|
72
|
+
"_get_current_experiment_id",
|
73
|
+
"_set_current_experiment_id",
|
74
|
+
]
|
yanex/api.py
ADDED
@@ -0,0 +1,507 @@
|
|
1
|
+
"""
|
2
|
+
Public API for yanex experiment tracking.
|
3
|
+
|
4
|
+
This module provides the main interface for experiment tracking using context managers
|
5
|
+
and thread-local storage for safe concurrent usage.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import os
|
9
|
+
import threading
|
10
|
+
from pathlib import Path
|
11
|
+
from typing import Any, Dict, List, Optional
|
12
|
+
|
13
|
+
from .core.manager import ExperimentManager
|
14
|
+
from .utils.exceptions import ExperimentContextError, ExperimentNotFoundError
|
15
|
+
|
16
|
+
# Thread-local storage for current experiment context
|
17
|
+
_local = threading.local()
|
18
|
+
|
19
|
+
|
20
|
+
def _get_current_experiment_id() -> Optional[str]:
|
21
|
+
"""Get current experiment ID from thread-local storage or environment.
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
Current experiment ID, or None if no active experiment context
|
25
|
+
"""
|
26
|
+
# First check thread-local storage (for direct API usage)
|
27
|
+
if hasattr(_local, "experiment_id"):
|
28
|
+
return _local.experiment_id
|
29
|
+
|
30
|
+
# Then check environment variables (for CLI subprocess execution)
|
31
|
+
return os.environ.get("YANEX_EXPERIMENT_ID")
|
32
|
+
|
33
|
+
|
34
|
+
def _set_current_experiment_id(experiment_id: str) -> None:
|
35
|
+
"""Set current experiment ID in thread-local storage.
|
36
|
+
|
37
|
+
Args:
|
38
|
+
experiment_id: Experiment ID to set as current
|
39
|
+
"""
|
40
|
+
_local.experiment_id = experiment_id
|
41
|
+
|
42
|
+
|
43
|
+
def _clear_current_experiment_id() -> None:
|
44
|
+
"""Clear current experiment ID from thread-local storage."""
|
45
|
+
if hasattr(_local, "experiment_id"):
|
46
|
+
delattr(_local, "experiment_id")
|
47
|
+
|
48
|
+
|
49
|
+
def _get_experiment_manager() -> ExperimentManager:
|
50
|
+
"""Get experiment manager instance.
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
ExperimentManager instance
|
54
|
+
"""
|
55
|
+
# Use default experiments directory unless overridden
|
56
|
+
return ExperimentManager()
|
57
|
+
|
58
|
+
|
59
|
+
def is_standalone() -> bool:
|
60
|
+
"""Check if running in standalone mode (no experiment context).
|
61
|
+
|
62
|
+
Returns:
|
63
|
+
True if no active experiment context exists
|
64
|
+
"""
|
65
|
+
return _get_current_experiment_id() is None
|
66
|
+
|
67
|
+
|
68
|
+
def has_context() -> bool:
|
69
|
+
"""Check if there is an active experiment context.
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
True if there is an active experiment context
|
73
|
+
"""
|
74
|
+
return _get_current_experiment_id() is not None
|
75
|
+
|
76
|
+
|
77
|
+
def get_params() -> Dict[str, Any]:
|
78
|
+
"""Get experiment parameters.
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
Dictionary of experiment parameters (empty dict in standalone mode)
|
82
|
+
"""
|
83
|
+
experiment_id = _get_current_experiment_id()
|
84
|
+
if experiment_id is None:
|
85
|
+
return {}
|
86
|
+
|
87
|
+
# If experiment ID comes from environment (CLI mode), read params from environment
|
88
|
+
if hasattr(_local, "experiment_id"):
|
89
|
+
# Direct API usage - read from storage
|
90
|
+
manager = _get_experiment_manager()
|
91
|
+
return manager.storage.load_config(experiment_id)
|
92
|
+
else:
|
93
|
+
# CLI subprocess mode - read from environment variables
|
94
|
+
params = {}
|
95
|
+
for key, value in os.environ.items():
|
96
|
+
if key.startswith("YANEX_PARAM_"):
|
97
|
+
param_key = key[12:] # Remove "YANEX_PARAM_" prefix
|
98
|
+
# Try to parse as JSON for complex types, fallback to string
|
99
|
+
try:
|
100
|
+
import json
|
101
|
+
|
102
|
+
params[param_key] = json.loads(value)
|
103
|
+
except (json.JSONDecodeError, ValueError):
|
104
|
+
params[param_key] = value
|
105
|
+
return params
|
106
|
+
|
107
|
+
|
108
|
+
def get_param(key: str, default: Any = None) -> Any:
|
109
|
+
"""Get a specific experiment parameter with support for dot notation.
|
110
|
+
|
111
|
+
Args:
|
112
|
+
key: Parameter key to retrieve. Supports dot notation (e.g., "model.learning_rate")
|
113
|
+
default: Default value if key not found
|
114
|
+
|
115
|
+
Returns:
|
116
|
+
Parameter value or default (default is returned in standalone mode)
|
117
|
+
"""
|
118
|
+
params = get_params()
|
119
|
+
|
120
|
+
# Handle dot notation for nested parameters
|
121
|
+
if "." in key:
|
122
|
+
keys = key.split(".")
|
123
|
+
current = params
|
124
|
+
|
125
|
+
for k in keys:
|
126
|
+
if isinstance(current, dict) and k in current:
|
127
|
+
current = current[k]
|
128
|
+
else:
|
129
|
+
print(f"Warning: Parameter '{key}' not found in config. Using default value: {default}")
|
130
|
+
return default
|
131
|
+
|
132
|
+
return current
|
133
|
+
else:
|
134
|
+
# Simple key access
|
135
|
+
if key not in params:
|
136
|
+
print(f"Warning: Parameter '{key}' not found in config. Using default value: {default}")
|
137
|
+
return params.get(key, default)
|
138
|
+
|
139
|
+
|
140
|
+
def get_status() -> Optional[str]:
|
141
|
+
"""Get current experiment status.
|
142
|
+
|
143
|
+
Returns:
|
144
|
+
Current experiment status, or None in standalone mode
|
145
|
+
"""
|
146
|
+
experiment_id = _get_current_experiment_id()
|
147
|
+
if experiment_id is None:
|
148
|
+
return None
|
149
|
+
|
150
|
+
manager = _get_experiment_manager()
|
151
|
+
return manager.get_experiment_status(experiment_id)
|
152
|
+
|
153
|
+
|
154
|
+
def get_experiment_id() -> Optional[str]:
|
155
|
+
"""Get current experiment ID.
|
156
|
+
|
157
|
+
Returns:
|
158
|
+
Current experiment ID, or None in standalone mode
|
159
|
+
"""
|
160
|
+
return _get_current_experiment_id()
|
161
|
+
|
162
|
+
|
163
|
+
def get_metadata() -> Dict[str, Any]:
|
164
|
+
"""Get complete experiment metadata.
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
Complete experiment metadata (empty dict in standalone mode)
|
168
|
+
"""
|
169
|
+
experiment_id = _get_current_experiment_id()
|
170
|
+
if experiment_id is None:
|
171
|
+
return {}
|
172
|
+
|
173
|
+
manager = _get_experiment_manager()
|
174
|
+
return manager.get_experiment_metadata(experiment_id)
|
175
|
+
|
176
|
+
|
177
|
+
def log_results(data: Dict[str, Any], step: Optional[int] = None) -> None:
|
178
|
+
"""Log experiment results for current step.
|
179
|
+
|
180
|
+
Args:
|
181
|
+
data: Results data to log
|
182
|
+
step: Optional step number (auto-incremented if None)
|
183
|
+
|
184
|
+
Note:
|
185
|
+
Does nothing in standalone mode (no active experiment context)
|
186
|
+
"""
|
187
|
+
experiment_id = _get_current_experiment_id()
|
188
|
+
if experiment_id is None:
|
189
|
+
return # No-op in standalone mode
|
190
|
+
|
191
|
+
manager = _get_experiment_manager()
|
192
|
+
|
193
|
+
# Warn if replacing existing step
|
194
|
+
if step is not None:
|
195
|
+
existing_results = manager.storage.load_results(experiment_id)
|
196
|
+
if any(r.get("step") == step for r in existing_results):
|
197
|
+
print(f"Warning: Replacing existing results for step {step}")
|
198
|
+
|
199
|
+
manager.storage.add_result_step(experiment_id, data, step)
|
200
|
+
|
201
|
+
|
202
|
+
def log_artifact(name: str, file_path: Path) -> None:
|
203
|
+
"""Log file artifact.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
name: Name for the artifact
|
207
|
+
file_path: Path to source file
|
208
|
+
|
209
|
+
Note:
|
210
|
+
Does nothing in standalone mode (no active experiment context)
|
211
|
+
"""
|
212
|
+
experiment_id = _get_current_experiment_id()
|
213
|
+
if experiment_id is None:
|
214
|
+
return # No-op in standalone mode
|
215
|
+
|
216
|
+
manager = _get_experiment_manager()
|
217
|
+
manager.storage.save_artifact(experiment_id, name, file_path)
|
218
|
+
|
219
|
+
|
220
|
+
def log_text(content: str, filename: str) -> None:
|
221
|
+
"""Save text content as an artifact.
|
222
|
+
|
223
|
+
Args:
|
224
|
+
content: Text content to save
|
225
|
+
filename: Name for the artifact file
|
226
|
+
|
227
|
+
Note:
|
228
|
+
Does nothing in standalone mode (no active experiment context)
|
229
|
+
"""
|
230
|
+
experiment_id = _get_current_experiment_id()
|
231
|
+
if experiment_id is None:
|
232
|
+
return # No-op in standalone mode
|
233
|
+
|
234
|
+
manager = _get_experiment_manager()
|
235
|
+
manager.storage.save_text_artifact(experiment_id, filename, content)
|
236
|
+
|
237
|
+
|
238
|
+
def log_matplotlib_figure(fig, filename: str, **kwargs) -> None:
|
239
|
+
"""Save matplotlib figure as artifact.
|
240
|
+
|
241
|
+
Args:
|
242
|
+
fig: Matplotlib figure object
|
243
|
+
filename: Name for the artifact file
|
244
|
+
**kwargs: Additional arguments passed to fig.savefig()
|
245
|
+
|
246
|
+
Raises:
|
247
|
+
ImportError: If matplotlib is not available
|
248
|
+
|
249
|
+
Note:
|
250
|
+
Does nothing in standalone mode (no active experiment context)
|
251
|
+
"""
|
252
|
+
experiment_id = _get_current_experiment_id()
|
253
|
+
if experiment_id is None:
|
254
|
+
return # No-op in standalone mode
|
255
|
+
|
256
|
+
try:
|
257
|
+
import os
|
258
|
+
import tempfile
|
259
|
+
|
260
|
+
import matplotlib.pyplot as plt # noqa: F401
|
261
|
+
except ImportError as err:
|
262
|
+
raise ImportError(
|
263
|
+
"matplotlib is required for log_matplotlib_figure. Install it with: pip install matplotlib"
|
264
|
+
) from err
|
265
|
+
|
266
|
+
manager = _get_experiment_manager()
|
267
|
+
|
268
|
+
# Save figure to temporary file
|
269
|
+
with tempfile.NamedTemporaryFile(suffix=f"_{filename}", delete=False) as temp_file:
|
270
|
+
temp_path = Path(temp_file.name)
|
271
|
+
|
272
|
+
try:
|
273
|
+
# Save figure with provided options
|
274
|
+
fig.savefig(temp_path, **kwargs)
|
275
|
+
|
276
|
+
# Copy to experiment artifacts
|
277
|
+
manager.storage.save_artifact(experiment_id, filename, temp_path)
|
278
|
+
finally:
|
279
|
+
# Clean up temporary file
|
280
|
+
if temp_path.exists():
|
281
|
+
os.unlink(temp_path)
|
282
|
+
|
283
|
+
|
284
|
+
def completed() -> None:
|
285
|
+
"""Manually mark experiment as completed and exit context.
|
286
|
+
|
287
|
+
Raises:
|
288
|
+
ExperimentContextError: If no active experiment context
|
289
|
+
"""
|
290
|
+
experiment_id = _get_current_experiment_id()
|
291
|
+
if experiment_id is None:
|
292
|
+
raise ExperimentContextError(
|
293
|
+
"No active experiment context. Cannot mark experiment as completed in standalone mode."
|
294
|
+
)
|
295
|
+
|
296
|
+
manager = _get_experiment_manager()
|
297
|
+
manager.complete_experiment(experiment_id)
|
298
|
+
|
299
|
+
# Raise special exception to exit context cleanly
|
300
|
+
raise _ExperimentCompletedException()
|
301
|
+
|
302
|
+
|
303
|
+
def fail(message: str) -> None:
|
304
|
+
"""Mark experiment as failed with message and exit context.
|
305
|
+
|
306
|
+
Args:
|
307
|
+
message: Error message describing the failure
|
308
|
+
|
309
|
+
Raises:
|
310
|
+
ExperimentContextError: If no active experiment context
|
311
|
+
"""
|
312
|
+
experiment_id = _get_current_experiment_id()
|
313
|
+
if experiment_id is None:
|
314
|
+
raise ExperimentContextError(
|
315
|
+
"No active experiment context. Cannot mark experiment as failed in standalone mode."
|
316
|
+
)
|
317
|
+
|
318
|
+
manager = _get_experiment_manager()
|
319
|
+
manager.fail_experiment(experiment_id, message)
|
320
|
+
|
321
|
+
# Raise special exception to exit context
|
322
|
+
raise _ExperimentFailedException(message)
|
323
|
+
|
324
|
+
|
325
|
+
def cancel(message: str) -> None:
|
326
|
+
"""Mark experiment as cancelled with message and exit context.
|
327
|
+
|
328
|
+
Args:
|
329
|
+
message: Cancellation reason
|
330
|
+
|
331
|
+
Raises:
|
332
|
+
ExperimentContextError: If no active experiment context
|
333
|
+
"""
|
334
|
+
experiment_id = _get_current_experiment_id()
|
335
|
+
if experiment_id is None:
|
336
|
+
raise ExperimentContextError(
|
337
|
+
"No active experiment context. Cannot mark experiment as cancelled in standalone mode."
|
338
|
+
)
|
339
|
+
|
340
|
+
manager = _get_experiment_manager()
|
341
|
+
manager.cancel_experiment(experiment_id, message)
|
342
|
+
|
343
|
+
# Raise special exception to exit context
|
344
|
+
raise _ExperimentCancelledException(message)
|
345
|
+
|
346
|
+
|
347
|
+
class _ExperimentCompletedException(Exception):
|
348
|
+
"""Internal exception for manual experiment completion."""
|
349
|
+
|
350
|
+
pass
|
351
|
+
|
352
|
+
|
353
|
+
class _ExperimentFailedException(Exception):
|
354
|
+
"""Internal exception for manual experiment failure."""
|
355
|
+
|
356
|
+
pass
|
357
|
+
|
358
|
+
|
359
|
+
class _ExperimentCancelledException(Exception):
|
360
|
+
"""Internal exception for manual experiment cancellation."""
|
361
|
+
|
362
|
+
pass
|
363
|
+
|
364
|
+
|
365
|
+
class ExperimentContext:
|
366
|
+
"""Context manager for experiment execution."""
|
367
|
+
|
368
|
+
def __init__(self, experiment_id: str):
|
369
|
+
"""Initialize experiment context.
|
370
|
+
|
371
|
+
Args:
|
372
|
+
experiment_id: Experiment ID to manage
|
373
|
+
"""
|
374
|
+
self.experiment_id = experiment_id
|
375
|
+
self.manager = _get_experiment_manager()
|
376
|
+
self._manual_exit = False
|
377
|
+
|
378
|
+
def __enter__(self):
|
379
|
+
"""Enter experiment context."""
|
380
|
+
# Set thread-local experiment ID
|
381
|
+
_set_current_experiment_id(self.experiment_id)
|
382
|
+
|
383
|
+
# Start experiment (update status to 'running')
|
384
|
+
self.manager.start_experiment(self.experiment_id)
|
385
|
+
|
386
|
+
# Return self for potential context variable access
|
387
|
+
return self
|
388
|
+
|
389
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
390
|
+
"""Exit experiment context."""
|
391
|
+
try:
|
392
|
+
if exc_type is None:
|
393
|
+
# Normal exit - mark as completed
|
394
|
+
if not self._manual_exit:
|
395
|
+
self.manager.complete_experiment(self.experiment_id)
|
396
|
+
# Print completion message like CLI mode
|
397
|
+
exp_dir = self.manager.storage.get_experiment_directory(self.experiment_id)
|
398
|
+
print(f"✓ Experiment completed successfully: {self.experiment_id}")
|
399
|
+
print(f" Directory: {exp_dir}")
|
400
|
+
elif exc_type in (
|
401
|
+
_ExperimentCompletedException,
|
402
|
+
_ExperimentFailedException,
|
403
|
+
_ExperimentCancelledException,
|
404
|
+
):
|
405
|
+
# Manual exit via completed()/fail()/cancel() - already handled
|
406
|
+
self._manual_exit = True
|
407
|
+
# Don't propagate these internal exceptions
|
408
|
+
return True
|
409
|
+
elif exc_type is KeyboardInterrupt:
|
410
|
+
# User interruption - mark as cancelled
|
411
|
+
self.manager.cancel_experiment(self.experiment_id, "Interrupted by user (Ctrl+C)")
|
412
|
+
# Print cancellation message like CLI mode
|
413
|
+
exp_dir = self.manager.storage.get_experiment_directory(self.experiment_id)
|
414
|
+
print(f"✗ Experiment cancelled: {self.experiment_id}")
|
415
|
+
print(f" Directory: {exp_dir}")
|
416
|
+
# Re-raise KeyboardInterrupt
|
417
|
+
return False
|
418
|
+
else:
|
419
|
+
# Unhandled exception - mark as failed
|
420
|
+
error_message = f"{exc_type.__name__}: {exc_val}"
|
421
|
+
self.manager.fail_experiment(self.experiment_id, error_message)
|
422
|
+
# Print failure message like CLI mode
|
423
|
+
exp_dir = self.manager.storage.get_experiment_directory(self.experiment_id)
|
424
|
+
print(f"✗ Experiment failed: {self.experiment_id}")
|
425
|
+
print(f" Directory: {exp_dir}")
|
426
|
+
# Propagate the original exception
|
427
|
+
return False
|
428
|
+
finally:
|
429
|
+
# Always clear thread-local experiment ID
|
430
|
+
_clear_current_experiment_id()
|
431
|
+
|
432
|
+
|
433
|
+
def create_experiment(
|
434
|
+
script_path: Path,
|
435
|
+
name: Optional[str] = None,
|
436
|
+
config: Optional[Dict[str, Any]] = None,
|
437
|
+
tags: Optional[List[str]] = None,
|
438
|
+
description: Optional[str] = None,
|
439
|
+
allow_dirty: bool = False,
|
440
|
+
) -> ExperimentContext:
|
441
|
+
"""Create a new experiment.
|
442
|
+
|
443
|
+
Args:
|
444
|
+
script_path: Path to the experiment script
|
445
|
+
name: Optional experiment name
|
446
|
+
config: Optional experiment configuration
|
447
|
+
tags: Optional list of tags
|
448
|
+
description: Optional experiment description
|
449
|
+
allow_dirty: Allow running with uncommitted changes (default: False)
|
450
|
+
|
451
|
+
Returns:
|
452
|
+
ExperimentContext for the new experiment
|
453
|
+
|
454
|
+
Raises:
|
455
|
+
ExperimentContextError: If experiment creation fails or if called in CLI context
|
456
|
+
"""
|
457
|
+
# Check for CLI context conflict
|
458
|
+
if _is_cli_context():
|
459
|
+
raise ExperimentContextError(
|
460
|
+
"Cannot use yanex.create_experiment() when script is run via 'yanex run'. "
|
461
|
+
"Either:\n"
|
462
|
+
" - Run directly: python script.py\n"
|
463
|
+
" - Or remove yanex.create_experiment() and use: yanex run script.py"
|
464
|
+
)
|
465
|
+
|
466
|
+
manager = _get_experiment_manager()
|
467
|
+
experiment_id = manager.create_experiment(
|
468
|
+
script_path=script_path,
|
469
|
+
name=name,
|
470
|
+
config=config or {},
|
471
|
+
tags=tags or [],
|
472
|
+
description=description,
|
473
|
+
allow_dirty=allow_dirty,
|
474
|
+
)
|
475
|
+
return ExperimentContext(experiment_id)
|
476
|
+
|
477
|
+
|
478
|
+
def create_context(experiment_id: str) -> ExperimentContext:
|
479
|
+
"""Create context for an existing experiment.
|
480
|
+
|
481
|
+
Args:
|
482
|
+
experiment_id: ID of the existing experiment
|
483
|
+
|
484
|
+
Returns:
|
485
|
+
ExperimentContext for the experiment
|
486
|
+
|
487
|
+
Raises:
|
488
|
+
ExperimentNotFoundError: If experiment doesn't exist
|
489
|
+
"""
|
490
|
+
manager = _get_experiment_manager()
|
491
|
+
|
492
|
+
# Verify experiment exists
|
493
|
+
try:
|
494
|
+
manager.get_experiment_metadata(experiment_id)
|
495
|
+
except Exception as e:
|
496
|
+
raise ExperimentNotFoundError(f"Experiment '{experiment_id}' not found") from e
|
497
|
+
|
498
|
+
return ExperimentContext(experiment_id)
|
499
|
+
|
500
|
+
|
501
|
+
def _is_cli_context() -> bool:
|
502
|
+
"""Check if currently running in a yanex CLI-managed experiment.
|
503
|
+
|
504
|
+
Returns:
|
505
|
+
True if running in CLI context, False otherwise
|
506
|
+
"""
|
507
|
+
return bool(os.environ.get("YANEX_CLI_ACTIVE"))
|
yanex/cli/__init__.py
ADDED
yanex/cli/_utils.py
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
"""
|
2
|
+
Utility functions for yanex CLI.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Any, Dict, List, Optional
|
7
|
+
|
8
|
+
import click
|
9
|
+
|
10
|
+
from ..core.config import has_sweep_parameters, resolve_config
|
11
|
+
|
12
|
+
|
13
|
+
def load_and_merge_config(
|
14
|
+
config_path: Optional[Path], param_overrides: List[str], verbose: bool = False
|
15
|
+
) -> Dict[str, Any]:
|
16
|
+
"""
|
17
|
+
Load and merge configuration from various sources.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
config_path: Optional explicit config file path
|
21
|
+
param_overrides: Parameter override strings from CLI
|
22
|
+
verbose: Whether to enable verbose output
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
Merged configuration dictionary
|
26
|
+
"""
|
27
|
+
try:
|
28
|
+
# Use existing resolve_config function from core.config
|
29
|
+
merged_config = resolve_config(
|
30
|
+
config_path=config_path,
|
31
|
+
param_overrides=param_overrides,
|
32
|
+
)
|
33
|
+
|
34
|
+
if verbose:
|
35
|
+
if config_path:
|
36
|
+
click.echo(f"Loaded config from: {config_path}")
|
37
|
+
else:
|
38
|
+
# Check if default config was loaded
|
39
|
+
default_config = Path.cwd() / "yanex.yaml"
|
40
|
+
if default_config.exists():
|
41
|
+
click.echo(f"Loaded default config: {default_config}")
|
42
|
+
else:
|
43
|
+
click.echo("No configuration file found, using defaults")
|
44
|
+
|
45
|
+
return merged_config
|
46
|
+
|
47
|
+
except Exception as e:
|
48
|
+
raise click.ClickException(f"Failed to load configuration: {e}") from e
|
49
|
+
|
50
|
+
|
51
|
+
def validate_experiment_config(
|
52
|
+
script: Path,
|
53
|
+
name: Optional[str],
|
54
|
+
tags: List[str],
|
55
|
+
description: Optional[str],
|
56
|
+
config: Dict[str, Any],
|
57
|
+
) -> None:
|
58
|
+
"""
|
59
|
+
Validate experiment configuration before execution.
|
60
|
+
|
61
|
+
Args:
|
62
|
+
script: Script path
|
63
|
+
name: Experiment name
|
64
|
+
tags: List of tags
|
65
|
+
description: Experiment description
|
66
|
+
config: Configuration dictionary
|
67
|
+
|
68
|
+
Raises:
|
69
|
+
click.ClickException: If validation fails
|
70
|
+
"""
|
71
|
+
# Validate script
|
72
|
+
if not script.exists():
|
73
|
+
raise click.ClickException(f"Script file does not exist: {script}")
|
74
|
+
|
75
|
+
if not script.suffix == ".py":
|
76
|
+
raise click.ClickException(f"Script must be a Python file: {script}")
|
77
|
+
|
78
|
+
# Validate name if provided
|
79
|
+
if name is not None:
|
80
|
+
if not name.strip():
|
81
|
+
raise click.ClickException("Experiment name cannot be empty")
|
82
|
+
|
83
|
+
# Basic name validation (more detailed validation will be in core.validation)
|
84
|
+
if len(name) > 100:
|
85
|
+
raise click.ClickException("Experiment name too long (max 100 characters)")
|
86
|
+
|
87
|
+
# Validate tags if provided
|
88
|
+
for tag in tags:
|
89
|
+
if not tag.strip():
|
90
|
+
raise click.ClickException("Tags cannot be empty")
|
91
|
+
if " " in tag:
|
92
|
+
raise click.ClickException(f"Tag '{tag}' cannot contain spaces")
|
93
|
+
|
94
|
+
# Validate description length
|
95
|
+
if description is not None and len(description) > 1000:
|
96
|
+
raise click.ClickException("Description too long (max 1000 characters)")
|
97
|
+
|
98
|
+
|
99
|
+
def validate_sweep_requirements(config: Dict[str, Any], stage_flag: bool) -> None:
|
100
|
+
"""
|
101
|
+
Validate that parameter sweeps are used with --stage flag.
|
102
|
+
|
103
|
+
Args:
|
104
|
+
config: Configuration dictionary to check
|
105
|
+
stage_flag: Whether --stage flag was provided
|
106
|
+
|
107
|
+
Raises:
|
108
|
+
click.ClickException: If sweep parameters used without --stage
|
109
|
+
"""
|
110
|
+
if has_sweep_parameters(config) and not stage_flag:
|
111
|
+
raise click.ClickException(
|
112
|
+
"Parameter sweeps require --stage flag to avoid accidental batch execution.\n"
|
113
|
+
'Use: yanex run script.py --param "lr=range(0.01, 0.1, 0.01)" --stage'
|
114
|
+
)
|