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,524 @@
1
+ """
2
+ Interactive comparison table using Textual DataTable.
3
+
4
+ This module provides a terminal-based interactive table for comparing experiments,
5
+ with sorting, navigation, and export functionality.
6
+ """
7
+
8
+ import csv
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Container, Horizontal
15
+ from textual.screen import ModalScreen
16
+ from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Static
17
+
18
+
19
+ class HelpScreen(ModalScreen):
20
+ """Modal screen showing keyboard shortcuts and help."""
21
+
22
+ def compose(self) -> ComposeResult:
23
+ """Compose the help screen layout."""
24
+ yield Container(
25
+ Label("yanex compare - Keyboard Shortcuts", classes="help-title"),
26
+ Static(
27
+ """
28
+ Navigation:
29
+ ↑/↓, j/k Navigate rows
30
+ ←/→, h/l Navigate columns
31
+ Home/End Jump to first/last row
32
+ PgUp/PgDn Navigate by page
33
+
34
+ Sorting:
35
+ s Sort ascending by current column
36
+ S Sort descending by current column
37
+ 1 Numerical sort ascending
38
+ 2 Numerical sort descending
39
+ r Reset to original order
40
+ R Reverse current sort order
41
+
42
+ Other Controls:
43
+ e Export current view to CSV
44
+ ? Show this help
45
+ q, Ctrl+C Quit
46
+
47
+ Column Types:
48
+ Parameters are prefixed with 'param:'
49
+ Metrics are prefixed with 'metric:'
50
+ Missing values are shown as '-'
51
+ """,
52
+ classes="help-content",
53
+ ),
54
+ Button("Close", variant="primary", id="close-help"),
55
+ classes="help-dialog",
56
+ )
57
+
58
+ def on_button_pressed(self, event: Button.Pressed) -> None:
59
+ """Handle button press events."""
60
+ if event.button.id == "close-help":
61
+ self.dismiss()
62
+
63
+
64
+ class ExportScreen(ModalScreen):
65
+ """Modal screen for CSV export."""
66
+
67
+ def __init__(self, default_path: str = "comparison.csv"):
68
+ super().__init__()
69
+ self.default_path = default_path
70
+
71
+ def compose(self) -> ComposeResult:
72
+ """Compose the export screen layout."""
73
+ yield Container(
74
+ Label("Export Comparison Data", classes="export-title"),
75
+ Label("Enter filename for CSV export:"),
76
+ Input(
77
+ value=self.default_path, placeholder="comparison.csv", id="export-path"
78
+ ),
79
+ Horizontal(
80
+ Button("Export", variant="primary", id="export-confirm"),
81
+ Button("Cancel", variant="default", id="export-cancel"),
82
+ classes="export-buttons",
83
+ ),
84
+ classes="export-dialog",
85
+ )
86
+
87
+ def on_button_pressed(self, event: Button.Pressed) -> None:
88
+ """Handle button press events."""
89
+ if event.button.id == "export-confirm":
90
+ path_input = self.query_one("#export-path", Input)
91
+ self.dismiss(path_input.value)
92
+ elif event.button.id == "export-cancel":
93
+ self.dismiss(None)
94
+
95
+
96
+ class ComparisonTableApp(App):
97
+ """Interactive comparison table application."""
98
+
99
+ CSS = """
100
+ .help-dialog {
101
+ width: 80;
102
+ height: 30;
103
+ background: $surface;
104
+ border: thick $primary;
105
+ margin: 2;
106
+ padding: 1;
107
+ }
108
+
109
+ .help-title {
110
+ text-align: center;
111
+ text-style: bold;
112
+ color: $primary;
113
+ margin-bottom: 1;
114
+ }
115
+
116
+ .help-content {
117
+ margin: 1;
118
+ padding: 1;
119
+ background: $surface-darken-1;
120
+ }
121
+
122
+ .export-dialog {
123
+ width: 60;
124
+ height: 12;
125
+ background: $surface;
126
+ border: thick $primary;
127
+ margin: 2;
128
+ padding: 1;
129
+ }
130
+
131
+ .export-title {
132
+ text-align: center;
133
+ text-style: bold;
134
+ color: $primary;
135
+ margin-bottom: 1;
136
+ }
137
+
138
+ .export-buttons {
139
+ margin-top: 1;
140
+ align: center middle;
141
+ }
142
+
143
+ DataTable {
144
+ height: 1fr;
145
+ }
146
+
147
+ #status-bar {
148
+ height: 1;
149
+ background: $surface-darken-1;
150
+ color: $text-muted;
151
+ }
152
+ """
153
+
154
+ BINDINGS = [
155
+ Binding("q", "quit", "Quit"),
156
+ Binding("question_mark", "help", "Help"),
157
+ Binding("s", "sort_asc", "Sort ↑"),
158
+ Binding("S", "sort_desc", "Sort ↓"),
159
+ Binding("1", "sort_numeric_asc", "Numeric ↑"),
160
+ Binding("2", "sort_numeric_desc", "Numeric ↓"),
161
+ Binding("r", "reset_sort", "Reset"),
162
+ Binding("R", "reverse_sort", "Reverse"),
163
+ Binding("e", "export", "Export"),
164
+ ]
165
+
166
+ def __init__(
167
+ self,
168
+ comparison_data: Dict[str, Any],
169
+ title: str = "yanex compare",
170
+ export_path: Optional[str] = None,
171
+ ):
172
+ """
173
+ Initialize the comparison table app.
174
+
175
+ Args:
176
+ comparison_data: Data from ExperimentComparisonData.get_comparison_data()
177
+ title: Application title
178
+ export_path: Default export path for CSV
179
+ """
180
+ super().__init__()
181
+ self.comparison_data = comparison_data
182
+ self.title_text = title
183
+ self.export_path = export_path or "comparison.csv"
184
+ self.original_rows = comparison_data.get("rows", [])
185
+ # Set default sort to "started" descending (latest experiments first)
186
+ self.current_sort_key = "started"
187
+ self.current_sort_reverse = True
188
+
189
+ def compose(self) -> ComposeResult:
190
+ """Compose the application layout."""
191
+ yield Header(show_clock=True)
192
+ yield DataTable(id="comparison-table")
193
+ yield Static("", id="status-bar")
194
+ yield Footer()
195
+
196
+ def on_mount(self) -> None:
197
+ """Initialize the table when app mounts."""
198
+ self.title = self.title_text
199
+
200
+ # Apply default sort (started descending) - this will also populate the table
201
+ self._sort_table("started", reverse=True)
202
+
203
+ def _setup_table_columns(self, table: DataTable) -> None:
204
+ """Set up table columns."""
205
+ rows = self.comparison_data.get("rows", [])
206
+ if not rows:
207
+ return
208
+
209
+ # Get column names from first row
210
+ first_row = rows[0]
211
+ column_keys = list(first_row.keys())
212
+
213
+ # Add columns to table
214
+ for key in column_keys:
215
+ header = self._get_column_header(key)
216
+ table.add_column(header, key=key)
217
+
218
+ def _populate_table_data(self, table: DataTable) -> None:
219
+ """Populate table with data."""
220
+ # Save cursor position before clearing
221
+ saved_cursor_row = table.cursor_row
222
+ saved_cursor_column = table.cursor_column
223
+
224
+ # Clear table completely (removes columns and rows)
225
+ table.clear(columns=True)
226
+ rows = self.comparison_data.get("rows", [])
227
+
228
+ if not rows:
229
+ return
230
+
231
+ # Get column order from data
232
+ column_keys = self._get_column_keys()
233
+
234
+ # Re-add columns with updated headers (including sort indicators)
235
+ for key in column_keys:
236
+ header = self._get_column_header(key)
237
+ table.add_column(header, key=key)
238
+
239
+ # Add rows
240
+ for row_data in rows:
241
+ row_values = [row_data.get(key, "-") for key in column_keys]
242
+ table.add_row(*row_values)
243
+
244
+ # Restore cursor position (with bounds checking)
245
+ if rows:
246
+ max_row = len(rows) - 1
247
+ max_column = len(column_keys) - 1
248
+
249
+ # Ensure cursor position is within bounds
250
+ new_cursor_row = min(saved_cursor_row, max_row)
251
+ new_cursor_column = min(saved_cursor_column, max_column)
252
+
253
+ # Restore cursor position
254
+ table.move_cursor(row=new_cursor_row, column=new_cursor_column)
255
+
256
+ def _get_column_keys(self) -> List[str]:
257
+ """Get column keys from data."""
258
+ rows = self.comparison_data.get("rows", [])
259
+ if not rows:
260
+ return []
261
+ return list(rows[0].keys())
262
+
263
+ def _get_column_header(self, key: str) -> str:
264
+ """Get formatted column header with optional sort indicator."""
265
+ # Base header without sort indicator
266
+ if key.startswith("param:"):
267
+ base_header = f"📊 {key[6:]}" # Remove 'param:' prefix
268
+ elif key.startswith("metric:"):
269
+ base_header = f"📈 {key[7:]}" # Remove 'metric:' prefix
270
+ elif key == "duration":
271
+ base_header = "Duration"
272
+ elif key == "tags":
273
+ base_header = "Tags"
274
+ elif key == "id":
275
+ base_header = "ID"
276
+ elif key == "name":
277
+ base_header = "Name"
278
+ else:
279
+ base_header = key.title()
280
+
281
+ # Add sort indicator if this column is currently sorted
282
+ if self.current_sort_key == key:
283
+ sort_indicator = " ↓" if self.current_sort_reverse else " ↑"
284
+ return base_header + sort_indicator
285
+
286
+ return base_header
287
+
288
+ def _update_status_bar(self) -> None:
289
+ """Update the status bar with current information."""
290
+ total_experiments = self.comparison_data.get("total_experiments", 0)
291
+ param_count = len(self.comparison_data.get("param_columns", []))
292
+ metric_count = len(self.comparison_data.get("metric_columns", []))
293
+
294
+ status_text = (
295
+ f"Experiments: {total_experiments} | "
296
+ f"Parameters: {param_count} | "
297
+ f"Metrics: {metric_count}"
298
+ )
299
+
300
+ if self.current_sort_key:
301
+ sort_direction = "↓" if self.current_sort_reverse else "↑"
302
+ status_text += f" | Sorted by: {self.current_sort_key} {sort_direction}"
303
+
304
+ status_bar = self.query_one("#status-bar", Static)
305
+ status_bar.update(status_text)
306
+
307
+ def action_help(self) -> None:
308
+ """Show help screen."""
309
+ self.push_screen(HelpScreen())
310
+
311
+ def action_sort_asc(self) -> None:
312
+ """Sort by current column ascending."""
313
+ table = self.query_one(DataTable)
314
+ column_keys = self._get_column_keys()
315
+ if table.cursor_column < len(column_keys):
316
+ column_key = column_keys[table.cursor_column]
317
+ self._sort_table(column_key, reverse=False)
318
+
319
+ def action_sort_desc(self) -> None:
320
+ """Sort by current column descending."""
321
+ table = self.query_one(DataTable)
322
+ column_keys = self._get_column_keys()
323
+ if table.cursor_column < len(column_keys):
324
+ column_key = column_keys[table.cursor_column]
325
+ self._sort_table(column_key, reverse=True)
326
+
327
+ def action_sort_numeric_asc(self) -> None:
328
+ """Sort by current column numerically ascending."""
329
+ table = self.query_one(DataTable)
330
+ column_keys = self._get_column_keys()
331
+ if table.cursor_column < len(column_keys):
332
+ column_key = column_keys[table.cursor_column]
333
+ self._sort_table(column_key, reverse=False, numeric=True)
334
+
335
+ def action_sort_numeric_desc(self) -> None:
336
+ """Sort by current column numerically descending."""
337
+ table = self.query_one(DataTable)
338
+ column_keys = self._get_column_keys()
339
+ if table.cursor_column < len(column_keys):
340
+ column_key = column_keys[table.cursor_column]
341
+ self._sort_table(column_key, reverse=True, numeric=True)
342
+
343
+ def action_reset_sort(self) -> None:
344
+ """Reset to default sort order (started descending)."""
345
+ # Reset to default sort (started descending)
346
+ self._sort_table("started", reverse=True)
347
+
348
+ def action_reverse_sort(self) -> None:
349
+ """Reverse current sort order."""
350
+ if self.current_sort_key:
351
+ # Reverse the current sort
352
+ self._sort_table(
353
+ self.current_sort_key, reverse=not self.current_sort_reverse
354
+ )
355
+ else:
356
+ # If no current sort, just reverse the rows and update state
357
+ self.comparison_data["rows"].reverse()
358
+ self.current_sort_reverse = not self.current_sort_reverse
359
+ table = self.query_one(DataTable)
360
+ self._populate_table_data(table)
361
+ self._update_status_bar()
362
+
363
+ def action_export(self) -> None:
364
+ """Export data to CSV."""
365
+ self.push_screen(ExportScreen(self.export_path), self._handle_export)
366
+
367
+ def _handle_export(self, export_path: Optional[str]) -> None:
368
+ """Handle export screen result."""
369
+ if export_path:
370
+ try:
371
+ self._export_to_csv(export_path)
372
+ self.notify(f"Exported to {export_path}", severity="information")
373
+ except Exception as e:
374
+ self.notify(f"Export failed: {e}", severity="error")
375
+
376
+ def _export_to_csv(self, file_path: str) -> None:
377
+ """Export current table data to CSV."""
378
+ rows = self.comparison_data.get("rows", [])
379
+ if not rows:
380
+ raise ValueError("No data to export")
381
+
382
+ # Get column order from data
383
+ column_keys = self._get_column_keys()
384
+ # For headers, we'll use the display headers we set up
385
+ column_headers = []
386
+ for key in column_keys:
387
+ if key.startswith("param:"):
388
+ column_headers.append(f"📊 {key[6:]}")
389
+ elif key.startswith("metric:"):
390
+ column_headers.append(f"📈 {key[7:]}")
391
+ elif key == "duration":
392
+ column_headers.append("Duration")
393
+ elif key == "tags":
394
+ column_headers.append("Tags")
395
+ elif key == "id":
396
+ column_headers.append("ID")
397
+ elif key == "name":
398
+ column_headers.append("Name")
399
+ else:
400
+ column_headers.append(key.title())
401
+
402
+ # Write CSV
403
+ path = Path(file_path)
404
+ with path.open("w", newline="", encoding="utf-8") as csvfile:
405
+ writer = csv.writer(csvfile)
406
+
407
+ # Write header
408
+ writer.writerow(column_headers)
409
+
410
+ # Write data rows
411
+ for row_data in rows:
412
+ row_values = [row_data.get(key, "-") for key in column_keys]
413
+ writer.writerow(row_values)
414
+
415
+ def _sort_table(
416
+ self, column_key: str, reverse: bool = False, numeric: bool = False
417
+ ) -> None:
418
+ """Sort table by specified column."""
419
+ rows = self.comparison_data.get("rows", [])
420
+ if not rows:
421
+ return
422
+
423
+ def sort_key(row_data: Dict[str, Any]) -> Any:
424
+ """Get sort key for a row."""
425
+ value = row_data.get(column_key, "-")
426
+
427
+ # Handle missing values
428
+ if value == "-":
429
+ return (
430
+ ""
431
+ if not numeric
432
+ else float("-inf")
433
+ if not reverse
434
+ else float("inf")
435
+ )
436
+
437
+ # Try numeric conversion if requested
438
+ if numeric:
439
+ try:
440
+ return float(value)
441
+ except (ValueError, TypeError):
442
+ return float("-inf") if not reverse else float("inf")
443
+
444
+ # Use string comparison
445
+ return str(value).lower()
446
+
447
+ # Sort the rows
448
+ sorted_rows = sorted(rows, key=sort_key, reverse=reverse)
449
+ self.comparison_data["rows"] = sorted_rows
450
+
451
+ # Update status tracking BEFORE populating table (so headers show indicators)
452
+ self.current_sort_key = column_key
453
+ self.current_sort_reverse = reverse
454
+
455
+ # Update table display
456
+ table = self.query_one(DataTable)
457
+ self._populate_table_data(table)
458
+ self._update_status_bar()
459
+
460
+
461
+ def run_comparison_table(
462
+ comparison_data: Dict[str, Any],
463
+ title: str = "yanex compare",
464
+ export_path: Optional[str] = None,
465
+ ) -> None:
466
+ """
467
+ Run the interactive comparison table.
468
+
469
+ Args:
470
+ comparison_data: Data from ExperimentComparisonData.get_comparison_data()
471
+ title: Application title
472
+ export_path: Default export path for CSV
473
+ """
474
+ app = ComparisonTableApp(comparison_data, title, export_path)
475
+ app.run()
476
+
477
+
478
+ if __name__ == "__main__":
479
+ # Example usage with mock data
480
+ mock_data = {
481
+ "rows": [
482
+ {
483
+ "run": "exp001",
484
+ "operation": "train.py",
485
+ "started": "2025-01-01 10:00:00",
486
+ "time": "01:30:45",
487
+ "status": "completed",
488
+ "label": "experiment-1",
489
+ "param:learning_rate": "0.01",
490
+ "param:epochs": "10",
491
+ "metric:accuracy": "0.95",
492
+ "metric:loss": "0.05",
493
+ },
494
+ {
495
+ "run": "exp002",
496
+ "operation": "train.py",
497
+ "started": "2025-01-01 12:00:00",
498
+ "time": "00:45:30",
499
+ "status": "failed",
500
+ "label": "experiment-2",
501
+ "param:learning_rate": "0.02",
502
+ "param:epochs": "5",
503
+ "metric:accuracy": "0.87",
504
+ "metric:loss": "0.13",
505
+ },
506
+ ],
507
+ "param_columns": ["learning_rate", "epochs"],
508
+ "metric_columns": ["accuracy", "loss"],
509
+ "column_types": {
510
+ "run": "string",
511
+ "operation": "string",
512
+ "started": "datetime",
513
+ "time": "string",
514
+ "status": "string",
515
+ "label": "string",
516
+ "param:learning_rate": "numeric",
517
+ "param:epochs": "numeric",
518
+ "metric:accuracy": "numeric",
519
+ "metric:loss": "numeric",
520
+ },
521
+ "total_experiments": 2,
522
+ }
523
+
524
+ run_comparison_table(mock_data)
@@ -0,0 +1,3 @@
1
+ """
2
+ Utility functions and classes for yanex.
3
+ """
@@ -0,0 +1,70 @@
1
+ """
2
+ Custom exceptions for yanex.
3
+ """
4
+
5
+
6
+ class YanexError(Exception):
7
+ """Base exception for all yanex errors."""
8
+
9
+ pass
10
+
11
+
12
+ class ExperimentError(YanexError):
13
+ """Errors related to experiment management."""
14
+
15
+ pass
16
+
17
+
18
+ class GitError(YanexError):
19
+ """Errors related to git operations."""
20
+
21
+ pass
22
+
23
+
24
+ class ConfigError(YanexError):
25
+ """Errors related to configuration loading and validation."""
26
+
27
+ pass
28
+
29
+
30
+ class StorageError(YanexError):
31
+ """Errors related to file storage operations."""
32
+
33
+ pass
34
+
35
+
36
+ class ValidationError(YanexError):
37
+ """Errors related to input validation."""
38
+
39
+ pass
40
+
41
+
42
+ class ExperimentNotFoundError(ExperimentError):
43
+ """Raised when an experiment cannot be found by ID or name."""
44
+
45
+ def __init__(self, identifier: str) -> None:
46
+ super().__init__(f"Experiment not found: {identifier}")
47
+ self.identifier = identifier
48
+
49
+
50
+ class ExperimentAlreadyRunningError(ExperimentError):
51
+ """Raised when trying to start an experiment while another is running."""
52
+
53
+ def __init__(self, running_id: str) -> None:
54
+ super().__init__(f"Another experiment is already running: {running_id}")
55
+ self.running_id = running_id
56
+
57
+
58
+ class DirtyWorkingDirectoryError(GitError):
59
+ """Raised when git working directory is not clean."""
60
+
61
+ def __init__(self, changes: list[str]) -> None:
62
+ change_list = "\n".join(f" - {change}" for change in changes)
63
+ super().__init__(f"Working directory is not clean:\n{change_list}")
64
+ self.changes = changes
65
+
66
+
67
+ class ExperimentContextError(ExperimentError):
68
+ """Raised when experiment context is used incorrectly."""
69
+
70
+ pass