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 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
@@ -0,0 +1,3 @@
1
+ """
2
+ Command-line interface for yanex.
3
+ """
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
+ )
@@ -0,0 +1,3 @@
1
+ """
2
+ CLI command implementations.
3
+ """