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.
@@ -0,0 +1,177 @@
1
+ """
2
+ Archive experiments - move them to archived directory.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from ...core.constants import EXPERIMENT_STATUSES
10
+ from ..filters import ExperimentFilter, parse_time_spec
11
+ from .confirm import (
12
+ confirm_experiment_operation,
13
+ find_experiments_by_filters,
14
+ find_experiments_by_identifiers,
15
+ )
16
+
17
+
18
+ @click.command("archive")
19
+ @click.argument("experiment_identifiers", nargs=-1)
20
+ @click.option(
21
+ "--status",
22
+ type=click.Choice(EXPERIMENT_STATUSES),
23
+ help="Archive experiments with specific status",
24
+ )
25
+ @click.option(
26
+ "--name",
27
+ "name_pattern",
28
+ help="Archive experiments matching name pattern (glob syntax)",
29
+ )
30
+ @click.option(
31
+ "--tag", "tags", multiple=True, help="Archive experiments with ALL specified tags"
32
+ )
33
+ @click.option(
34
+ "--started-after",
35
+ help="Archive experiments started after date/time (e.g., '2025-01-01', 'yesterday', '1 week ago')",
36
+ )
37
+ @click.option("--started-before", help="Archive experiments started before date/time")
38
+ @click.option("--ended-after", help="Archive experiments ended after date/time")
39
+ @click.option("--ended-before", help="Archive experiments ended before date/time")
40
+ @click.option("--force", is_flag=True, help="Skip confirmation prompt")
41
+ @click.pass_context
42
+ def archive_experiments(
43
+ ctx,
44
+ experiment_identifiers: tuple,
45
+ status: Optional[str],
46
+ name_pattern: Optional[str],
47
+ tags: tuple,
48
+ started_after: Optional[str],
49
+ started_before: Optional[str],
50
+ ended_after: Optional[str],
51
+ ended_before: Optional[str],
52
+ force: bool,
53
+ ):
54
+ """
55
+ Archive experiments by moving them to archived directory.
56
+
57
+ EXPERIMENT_IDENTIFIERS can be experiment IDs or names.
58
+ If no identifiers provided, experiments are filtered by options.
59
+
60
+ Examples:
61
+ \\b
62
+ yanex archive exp1 exp2 # Archive specific experiments
63
+ yanex archive --status failed # Archive all failed experiments
64
+ yanex archive --status completed --ended-before "1 month ago"
65
+ yanex archive --name "*training*" # Archive experiments with "training" in name
66
+ yanex archive --tag experiment-v1 # Archive experiments with specific tag
67
+ """
68
+ try:
69
+ filter_obj = ExperimentFilter()
70
+
71
+ # Validate mutually exclusive targeting
72
+ has_identifiers = len(experiment_identifiers) > 0
73
+ has_filters = any(
74
+ [
75
+ status,
76
+ name_pattern,
77
+ tags,
78
+ started_after,
79
+ started_before,
80
+ ended_after,
81
+ ended_before,
82
+ ]
83
+ )
84
+
85
+ if has_identifiers and has_filters:
86
+ click.echo(
87
+ "Error: Cannot use both experiment identifiers and filter options. Choose one approach.",
88
+ err=True,
89
+ )
90
+ ctx.exit(1)
91
+
92
+ if not has_identifiers and not has_filters:
93
+ click.echo(
94
+ "Error: Must specify either experiment identifiers or filter options",
95
+ err=True,
96
+ )
97
+ ctx.exit(1)
98
+
99
+ # Parse time specifications
100
+ started_after_dt = parse_time_spec(started_after) if started_after else None
101
+ started_before_dt = parse_time_spec(started_before) if started_before else None
102
+ ended_after_dt = parse_time_spec(ended_after) if ended_after else None
103
+ ended_before_dt = parse_time_spec(ended_before) if ended_before else None
104
+
105
+ # Find experiments to archive
106
+ if experiment_identifiers:
107
+ # Archive specific experiments by ID/name
108
+ experiments = find_experiments_by_identifiers(
109
+ filter_obj,
110
+ list(experiment_identifiers),
111
+ include_archived=False, # Can't archive already archived experiments
112
+ )
113
+ else:
114
+ # Archive experiments by filter criteria
115
+
116
+ experiments = find_experiments_by_filters(
117
+ filter_obj,
118
+ status=status,
119
+ name_pattern=name_pattern,
120
+ tags=list(tags) if tags else None,
121
+ started_after=started_after_dt,
122
+ started_before=started_before_dt,
123
+ ended_after=ended_after_dt,
124
+ ended_before=ended_before_dt,
125
+ include_archived=False, # Can't archive already archived experiments
126
+ )
127
+
128
+ # Filter out already archived experiments (extra safety)
129
+ experiments = [exp for exp in experiments if not exp.get("archived", False)]
130
+
131
+ if not experiments:
132
+ click.echo("No experiments found to archive.")
133
+ return
134
+
135
+ # Show experiments and get confirmation
136
+ if not confirm_experiment_operation(
137
+ experiments, "archive", force, default_yes=True
138
+ ):
139
+ click.echo("Archive operation cancelled.")
140
+ return
141
+
142
+ # Archive experiments
143
+ click.echo(f"Archiving {len(experiments)} experiment(s)...")
144
+
145
+ success_count = 0
146
+ for exp in experiments:
147
+ try:
148
+ experiment_id = exp["id"]
149
+ filter_obj.manager.storage.archive_experiment(experiment_id)
150
+
151
+ # Show progress
152
+ exp_name = exp.get("name", "[unnamed]")
153
+ click.echo(f" ✓ Archived {experiment_id} ({exp_name})")
154
+ success_count += 1
155
+
156
+ except Exception as e:
157
+ exp_name = exp.get("name", "[unnamed]")
158
+ click.echo(
159
+ f" ✗ Failed to archive {experiment_id} ({exp_name}): {e}", err=True
160
+ )
161
+
162
+ # Summary
163
+ if success_count == len(experiments):
164
+ click.echo(f"Successfully archived {success_count} experiment(s).")
165
+ else:
166
+ failed_count = len(experiments) - success_count
167
+ click.echo(
168
+ f"Archived {success_count} experiment(s), {failed_count} failed.",
169
+ err=True,
170
+ )
171
+ ctx.exit(1)
172
+
173
+ except click.ClickException:
174
+ raise # Re-raise ClickException to show proper error message
175
+ except Exception as e:
176
+ click.echo(f"Error: {e}", err=True)
177
+ ctx.exit(1)
@@ -0,0 +1,320 @@
1
+ """
2
+ Compare experiments - interactive table with parameters and metrics.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from ...core.comparison import ExperimentComparisonData
10
+ from ...core.constants import EXPERIMENT_STATUSES
11
+ from ...ui.compare_table import run_comparison_table
12
+ from ..filters import ExperimentFilter, parse_time_spec
13
+ from .confirm import find_experiments_by_filters, find_experiments_by_identifiers
14
+
15
+
16
+ @click.command("compare")
17
+ @click.argument("experiment_identifiers", nargs=-1)
18
+ @click.option(
19
+ "--status",
20
+ type=click.Choice(EXPERIMENT_STATUSES),
21
+ help="Compare experiments with specific status",
22
+ )
23
+ @click.option(
24
+ "--name",
25
+ "name_pattern",
26
+ help="Compare experiments matching name pattern (glob syntax)",
27
+ )
28
+ @click.option(
29
+ "--tag", "tags", multiple=True, help="Compare experiments with ALL specified tags"
30
+ )
31
+ @click.option(
32
+ "--started-after",
33
+ help="Compare experiments started after date/time (e.g., '2025-01-01', 'yesterday', '1 week ago')",
34
+ )
35
+ @click.option("--started-before", help="Compare experiments started before date/time")
36
+ @click.option("--ended-after", help="Compare experiments ended after date/time")
37
+ @click.option("--ended-before", help="Compare experiments ended before date/time")
38
+ @click.option(
39
+ "--archived",
40
+ is_flag=True,
41
+ help="Include archived experiments (default: only regular experiments)",
42
+ )
43
+ @click.option(
44
+ "--params",
45
+ help="Show only specified parameters (comma-separated, e.g., 'learning_rate,epochs')",
46
+ )
47
+ @click.option(
48
+ "--metrics",
49
+ help="Show only specified metrics (comma-separated, e.g., 'accuracy,loss')",
50
+ )
51
+ @click.option(
52
+ "--only-different",
53
+ is_flag=True,
54
+ help="Show only columns where values differ between experiments",
55
+ )
56
+ @click.option(
57
+ "--export",
58
+ "export_path",
59
+ help="Export comparison data to CSV file instead of interactive view",
60
+ )
61
+ @click.option(
62
+ "--no-interactive",
63
+ is_flag=True,
64
+ help="Print static table instead of interactive view",
65
+ )
66
+ @click.option(
67
+ "--max-rows",
68
+ type=int,
69
+ help="Limit number of experiments displayed",
70
+ )
71
+ @click.pass_context
72
+ def compare_experiments(
73
+ ctx,
74
+ experiment_identifiers: tuple,
75
+ status: Optional[str],
76
+ name_pattern: Optional[str],
77
+ tags: tuple,
78
+ started_after: Optional[str],
79
+ started_before: Optional[str],
80
+ ended_after: Optional[str],
81
+ ended_before: Optional[str],
82
+ archived: bool,
83
+ params: Optional[str],
84
+ metrics: Optional[str],
85
+ only_different: bool,
86
+ export_path: Optional[str],
87
+ no_interactive: bool,
88
+ max_rows: Optional[int],
89
+ ):
90
+ """
91
+ Compare experiments in an interactive table showing parameters and metrics.
92
+
93
+ EXPERIMENT_IDENTIFIERS can be experiment IDs or names.
94
+ If no identifiers provided, experiments are filtered by options.
95
+
96
+ The interactive table supports:
97
+ - Navigation with arrow keys or hjkl
98
+ - Sorting by any column (s/S for asc/desc, 1/2 for numeric)
99
+ - Export to CSV (press 'e' in interactive mode)
100
+ - Help (press '?' for keyboard shortcuts)
101
+
102
+ Examples:
103
+ \\b
104
+ yanex compare # All experiments
105
+ yanex compare exp1 exp2 exp3 # Specific experiments
106
+ yanex compare --status completed # Completed experiments
107
+ yanex compare --tag training --only-different # Training experiments, show differences only
108
+ yanex compare --params learning_rate,epochs # Show only specified parameters
109
+ yanex compare --export results.csv # Export to CSV
110
+ yanex compare --no-interactive # Static table output
111
+ """
112
+ try:
113
+ filter_obj = ExperimentFilter()
114
+
115
+ # Validate mutually exclusive targeting
116
+ has_identifiers = len(experiment_identifiers) > 0
117
+ has_filters = any(
118
+ [
119
+ status,
120
+ name_pattern,
121
+ tags,
122
+ started_after,
123
+ started_before,
124
+ ended_after,
125
+ ended_before,
126
+ ]
127
+ )
128
+
129
+ if has_identifiers and has_filters:
130
+ click.echo(
131
+ "Error: Cannot use both experiment identifiers and filter options. Choose one approach.",
132
+ err=True,
133
+ )
134
+ ctx.exit(1)
135
+
136
+ if not has_identifiers and not has_filters:
137
+ # No filters specified - use all experiments
138
+ pass
139
+
140
+ # Parse time specifications
141
+ started_after_dt = parse_time_spec(started_after) if started_after else None
142
+ started_before_dt = parse_time_spec(started_before) if started_before else None
143
+ ended_after_dt = parse_time_spec(ended_after) if ended_after else None
144
+ ended_before_dt = parse_time_spec(ended_before) if ended_before else None
145
+
146
+ # Find experiments to compare
147
+ if experiment_identifiers:
148
+ # Compare specific experiments by ID/name
149
+ experiments = find_experiments_by_identifiers(
150
+ filter_obj, list(experiment_identifiers), include_archived=archived
151
+ )
152
+ else:
153
+ # Compare experiments by filter criteria
154
+ experiments = find_experiments_by_filters(
155
+ filter_obj,
156
+ status=status,
157
+ name_pattern=name_pattern,
158
+ tags=list(tags) if tags else None,
159
+ started_after=started_after_dt,
160
+ started_before=started_before_dt,
161
+ ended_after=ended_after_dt,
162
+ ended_before=ended_before_dt,
163
+ include_archived=archived,
164
+ )
165
+
166
+ if not experiments:
167
+ location = "archived" if archived else "regular"
168
+ click.echo(f"No {location} experiments found to compare.")
169
+ return
170
+
171
+ # Limit number of experiments if requested
172
+ if max_rows and len(experiments) > max_rows:
173
+ experiments = experiments[:max_rows]
174
+ click.echo(f"Limiting display to first {max_rows} experiments.")
175
+
176
+ # Extract experiment IDs from the experiment metadata dictionaries
177
+ experiment_ids = [exp["id"] for exp in experiments]
178
+
179
+ # Parse parameter and metric lists
180
+ param_list = None
181
+ if params:
182
+ param_list = [p.strip() for p in params.split(",") if p.strip()]
183
+
184
+ metric_list = None
185
+ if metrics:
186
+ metric_list = [m.strip() for m in metrics.split(",") if m.strip()]
187
+
188
+ # Get comparison data
189
+ comparison_data_extractor = ExperimentComparisonData()
190
+ comparison_data = comparison_data_extractor.get_comparison_data(
191
+ experiment_ids=experiment_ids,
192
+ params=param_list,
193
+ metrics=metric_list,
194
+ only_different=only_different,
195
+ include_archived=archived,
196
+ )
197
+
198
+ if not comparison_data.get("rows"):
199
+ click.echo("No comparison data available.")
200
+ return
201
+
202
+ # Handle export mode
203
+ if export_path:
204
+ _export_comparison_data(comparison_data, export_path)
205
+ click.echo(f"Comparison data exported to {export_path}")
206
+ return
207
+
208
+ # Handle static table mode
209
+ if no_interactive:
210
+ _print_static_table(comparison_data)
211
+ return
212
+
213
+ # Run interactive table
214
+ total_experiments = comparison_data.get("total_experiments", 0)
215
+ param_count = len(comparison_data.get("param_columns", []))
216
+ metric_count = len(comparison_data.get("metric_columns", []))
217
+
218
+ title = f"yanex compare - {total_experiments} experiments, {param_count} params, {metric_count} metrics"
219
+
220
+ # Set default export path
221
+ default_export = export_path or "yanex_comparison.csv"
222
+
223
+ run_comparison_table(comparison_data, title=title, export_path=default_export)
224
+
225
+ except click.ClickException:
226
+ raise # Re-raise ClickException to show proper error message
227
+ except Exception as e:
228
+ click.echo(f"Error: {e}", err=True)
229
+ ctx.exit(1)
230
+
231
+
232
+ def _export_comparison_data(comparison_data: dict, file_path: str) -> None:
233
+ """Export comparison data to CSV file."""
234
+ import csv
235
+ from pathlib import Path
236
+
237
+ rows = comparison_data.get("rows", [])
238
+ if not rows:
239
+ raise ValueError("No data to export")
240
+
241
+ # Get column order - use same order as UI
242
+ first_row = rows[0]
243
+ column_keys = list(first_row.keys())
244
+
245
+ # Generate headers
246
+ column_headers = []
247
+ for key in column_keys:
248
+ if key.startswith("param:"):
249
+ column_headers.append(f"param_{key[6:]}") # Remove emoji for CSV
250
+ elif key.startswith("metric:"):
251
+ column_headers.append(f"metric_{key[7:]}") # Remove emoji for CSV
252
+ else:
253
+ column_headers.append(key)
254
+
255
+ # Write CSV
256
+ path = Path(file_path)
257
+ with path.open("w", newline="", encoding="utf-8") as csvfile:
258
+ writer = csv.writer(csvfile)
259
+
260
+ # Write header
261
+ writer.writerow(column_headers)
262
+
263
+ # Write data rows
264
+ for row_data in rows:
265
+ row_values = [row_data.get(key, "-") for key in column_keys]
266
+ writer.writerow(row_values)
267
+
268
+
269
+ def _print_static_table(comparison_data: dict) -> None:
270
+ """Print comparison data as a static table."""
271
+ from rich.console import Console
272
+ from rich.table import Table
273
+
274
+ console = Console()
275
+ rows = comparison_data.get("rows", [])
276
+
277
+ if not rows:
278
+ console.print("No data to display")
279
+ return
280
+
281
+ # Create table
282
+ table = Table(show_header=True, header_style="bold magenta")
283
+
284
+ # Get column order
285
+ first_row = rows[0]
286
+ column_keys = list(first_row.keys())
287
+
288
+ # Add columns with formatted headers
289
+ for key in column_keys:
290
+ if key.startswith("param:"):
291
+ header = f"📊 {key[6:]}"
292
+ elif key.startswith("metric:"):
293
+ header = f"📈 {key[7:]}"
294
+ elif key == "duration":
295
+ header = "Duration"
296
+ elif key == "tags":
297
+ header = "Tags"
298
+ elif key == "id":
299
+ header = "ID"
300
+ elif key == "name":
301
+ header = "Name"
302
+ else:
303
+ header = key.title()
304
+ table.add_column(header)
305
+
306
+ # Add rows
307
+ for row_data in rows:
308
+ row_values = [str(row_data.get(key, "-")) for key in column_keys]
309
+ table.add_row(*row_values)
310
+
311
+ # Print table
312
+ console.print(table)
313
+
314
+ # Print summary
315
+ param_count = len(comparison_data.get("param_columns", []))
316
+ metric_count = len(comparison_data.get("metric_columns", []))
317
+
318
+ console.print(
319
+ f"\n[dim]Showing {len(rows)} experiments with {param_count} parameters and {metric_count} metrics[/dim]"
320
+ )
@@ -0,0 +1,198 @@
1
+ """
2
+ Confirmation utilities for bulk operations on experiments.
3
+ """
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ import click
8
+
9
+ from ..filters import ExperimentFilter
10
+ from ..formatters.console import ExperimentTableFormatter
11
+
12
+
13
+ def confirm_experiment_operation(
14
+ experiments: List[Dict[str, Any]],
15
+ operation: str,
16
+ force: bool = False,
17
+ operation_verb: str = None,
18
+ default_yes: bool = False,
19
+ ) -> bool:
20
+ """
21
+ Show experiments and confirm operation.
22
+
23
+ Args:
24
+ experiments: List of experiment dictionaries to show
25
+ operation: Operation name (e.g., "archive", "delete")
26
+ force: Skip confirmation if True
27
+ operation_verb: Past tense verb for confirmation (e.g., "archived", "deleted")
28
+ default_yes: If True, default to Yes instead of No
29
+
30
+ Returns:
31
+ True if user confirms, False otherwise
32
+ """
33
+ if not experiments:
34
+ click.echo(f"No experiments found to {operation}.")
35
+ return False
36
+
37
+ if operation_verb is None:
38
+ operation_verb = f"{operation}d" # Default: "archive" -> "archived"
39
+
40
+ # Display table of experiments
41
+ formatter = ExperimentTableFormatter()
42
+
43
+ click.echo(
44
+ f"The following {len(experiments)} experiment(s) will be {operation_verb}:"
45
+ )
46
+ click.echo()
47
+
48
+ formatter.print_experiments_table(experiments)
49
+ click.echo()
50
+
51
+ # Skip confirmation if force flag is set
52
+ if force:
53
+ return True
54
+
55
+ # Get confirmation from user
56
+ if len(experiments) == 1:
57
+ message = f"{operation.capitalize()} this experiment?"
58
+ else:
59
+ message = f"{operation.capitalize()} these {len(experiments)} experiments?"
60
+
61
+ return click.confirm(message, default=default_yes)
62
+
63
+
64
+ def find_experiments_by_identifiers(
65
+ filter_obj: ExperimentFilter,
66
+ identifiers: List[str],
67
+ include_archived: bool = False,
68
+ archived_only: bool = False,
69
+ ) -> List[Dict[str, Any]]:
70
+ """
71
+ Find experiments by list of identifiers (IDs or names).
72
+
73
+ Args:
74
+ filter_obj: ExperimentFilter instance
75
+ identifiers: List of experiment IDs or names
76
+ include_archived: Whether to search archived experiments
77
+ archived_only: If True, search ONLY archived experiments (for unarchive command)
78
+
79
+ Returns:
80
+ List of found experiments
81
+
82
+ Raises:
83
+ click.ClickException: If any identifier is not found or ambiguous
84
+ """
85
+ from .show import find_experiment # Import here to avoid circular import
86
+
87
+ all_experiments = []
88
+
89
+ for identifier in identifiers:
90
+ if archived_only:
91
+ # For unarchive: search all experiments but only consider archived ones
92
+ result = find_experiment(filter_obj, identifier, include_archived=True)
93
+
94
+ if result is None:
95
+ raise click.ClickException(
96
+ f"No archived experiment found with ID or name '{identifier}'"
97
+ )
98
+
99
+ if isinstance(result, list):
100
+ # Filter to only archived experiments
101
+ archived_results = [exp for exp in result if exp.get("archived", False)]
102
+
103
+ if not archived_results:
104
+ raise click.ClickException(
105
+ f"No archived experiment found with name '{identifier}'"
106
+ )
107
+ elif len(archived_results) == 1:
108
+ result = archived_results[0]
109
+ else:
110
+ # Multiple archived experiments with same name
111
+ click.echo(
112
+ f"Multiple archived experiments found with name '{identifier}':"
113
+ )
114
+ click.echo()
115
+
116
+ formatter = ExperimentTableFormatter()
117
+ formatter.print_experiments_table(archived_results)
118
+ click.echo()
119
+ click.echo(
120
+ "Please use specific experiment IDs instead of names for bulk operations."
121
+ )
122
+ raise click.ClickException(
123
+ f"Ambiguous archived experiment name: '{identifier}'"
124
+ )
125
+ else:
126
+ # Single result - check if it's archived
127
+ if not result.get("archived", False):
128
+ raise click.ClickException(
129
+ f"Experiment '{identifier}' is not archived"
130
+ )
131
+ else:
132
+ # Normal mode: search as specified
133
+ result = find_experiment(filter_obj, identifier, include_archived)
134
+
135
+ if result is None:
136
+ location = "archived" if include_archived else "regular"
137
+ raise click.ClickException(
138
+ f"No {location} experiment found with ID or name '{identifier}'"
139
+ )
140
+
141
+ if isinstance(result, list):
142
+ # Multiple experiments with same name
143
+ click.echo(f"Multiple experiments found with name '{identifier}':")
144
+ click.echo()
145
+
146
+ formatter = ExperimentTableFormatter()
147
+ formatter.print_experiments_table(result)
148
+ click.echo()
149
+ click.echo(
150
+ "Please use specific experiment IDs instead of names for bulk operations."
151
+ )
152
+ raise click.ClickException(f"Ambiguous experiment name: '{identifier}'")
153
+
154
+ # Single experiment found
155
+ all_experiments.append(result)
156
+
157
+ return all_experiments
158
+
159
+
160
+ def find_experiments_by_filters(
161
+ filter_obj: ExperimentFilter,
162
+ status: str = None,
163
+ name_pattern: str = None,
164
+ tags: List[str] = None,
165
+ started_after=None,
166
+ started_before=None,
167
+ ended_after=None,
168
+ ended_before=None,
169
+ include_archived: bool = False,
170
+ ) -> List[Dict[str, Any]]:
171
+ """
172
+ Find experiments using filter criteria.
173
+
174
+ Args:
175
+ filter_obj: ExperimentFilter instance
176
+ status: Filter by status
177
+ name_pattern: Filter by name pattern
178
+ tags: Filter by tags
179
+ started_after: Filter by start time
180
+ started_before: Filter by start time
181
+ ended_after: Filter by end time
182
+ ended_before: Filter by end time
183
+ include_archived: Whether to search archived experiments
184
+
185
+ Returns:
186
+ List of found experiments
187
+ """
188
+ return filter_obj.filter_experiments(
189
+ status=status,
190
+ name_pattern=name_pattern,
191
+ tags=tags,
192
+ started_after=started_after,
193
+ started_before=started_before,
194
+ ended_after=ended_after,
195
+ ended_before=ended_before,
196
+ include_all=True, # Get all matching experiments for bulk operations
197
+ include_archived=include_archived,
198
+ )