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,325 @@
1
+ """
2
+ Rich console formatting for experiment data.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from ..filters.time_utils import format_duration, format_relative_time
13
+
14
+
15
+ class ExperimentTableFormatter:
16
+ """
17
+ Rich console formatter for experiment tables.
18
+
19
+ Provides colored status indicators, formatted durations, and clean table layout.
20
+ """
21
+
22
+ # Status color mapping
23
+ STATUS_COLORS = {
24
+ "completed": "green",
25
+ "failed": "red",
26
+ "running": "yellow",
27
+ "created": "white",
28
+ "cancelled": "bright_red",
29
+ "staged": "cyan",
30
+ }
31
+
32
+ # Status symbols for better visual distinction
33
+ STATUS_SYMBOLS = {
34
+ "completed": "✓",
35
+ "failed": "✗",
36
+ "running": "⚡",
37
+ "created": "○",
38
+ "cancelled": "✖",
39
+ "staged": "⏲",
40
+ }
41
+
42
+ def __init__(self, console: Console = None):
43
+ """
44
+ Initialize formatter.
45
+
46
+ Args:
47
+ console: Rich console instance (creates default if None)
48
+ """
49
+ self.console = console or Console()
50
+
51
+ def format_experiments_table(self, experiments: List[Dict[str, Any]]) -> Table:
52
+ """
53
+ Format experiments as a rich table.
54
+
55
+ Args:
56
+ experiments: List of experiment metadata dictionaries
57
+
58
+ Returns:
59
+ Rich Table object ready for console output
60
+ """
61
+ # Create table with columns
62
+ table = Table(show_header=True, header_style="bold")
63
+
64
+ # Add columns
65
+ table.add_column("ID", style="dim", width=8)
66
+ table.add_column("Name", min_width=12, max_width=25)
67
+ table.add_column("Status", width=12)
68
+ table.add_column("Duration", width=10, justify="right")
69
+ table.add_column("Tags", min_width=8, max_width=20)
70
+ table.add_column("Started", width=15, justify="right")
71
+
72
+ # Add rows
73
+ for exp in experiments:
74
+ table.add_row(
75
+ self._format_id(exp.get("id", "")),
76
+ self._format_name(exp.get("name")),
77
+ self._format_status(exp.get("status", "unknown")),
78
+ self._format_duration(exp),
79
+ self._format_tags(exp.get("tags", [])),
80
+ self._format_started_time(exp.get("started_at")),
81
+ )
82
+
83
+ return table
84
+
85
+ def print_experiments_table(
86
+ self, experiments: List[Dict[str, Any]], title: str = None
87
+ ) -> None:
88
+ """
89
+ Print experiments table to console.
90
+
91
+ Args:
92
+ experiments: List of experiment metadata dictionaries
93
+ title: Optional table title
94
+ """
95
+ if not experiments:
96
+ self.console.print("No experiments found.", style="dim")
97
+ return
98
+
99
+ table = self.format_experiments_table(experiments)
100
+
101
+ if title:
102
+ table.title = title
103
+
104
+ self.console.print(table)
105
+
106
+ def print_summary(
107
+ self,
108
+ experiments: List[Dict[str, Any]],
109
+ total_count: int = None,
110
+ show_help: bool = True,
111
+ ) -> None:
112
+ """
113
+ Print summary information about the experiments.
114
+
115
+ Args:
116
+ experiments: Filtered experiment list
117
+ total_count: Total number of experiments before filtering (if different)
118
+ show_help: Whether to show helpful hints about viewing more experiments
119
+ """
120
+ count = len(experiments)
121
+
122
+ if total_count is not None and total_count != count:
123
+ summary = f"Showing {count} of {total_count} experiments"
124
+ # Add helpful hint if showing limited results
125
+ if show_help and count < total_count:
126
+ summary += " (use --all to show all experiments)"
127
+ else:
128
+ summary = f"Found {count} experiment{'s' if count != 1 else ''}"
129
+
130
+ self.console.print(f"\n{summary}", style="dim")
131
+
132
+ def _format_id(self, experiment_id: str) -> str:
133
+ """Format experiment ID."""
134
+ if not experiment_id:
135
+ return "unknown"
136
+ return experiment_id
137
+
138
+ def _format_name(self, name: str) -> Text:
139
+ """Format experiment name with fallback for unnamed experiments."""
140
+ if not name:
141
+ return Text("[unnamed]", style="dim italic")
142
+
143
+ # Truncate long names
144
+ if len(name) > 28:
145
+ name = name[:25] + "..."
146
+
147
+ return Text(name)
148
+
149
+ def _format_status(self, status: str) -> Text:
150
+ """Format status with color and symbol."""
151
+ color = self.STATUS_COLORS.get(status, "white")
152
+ symbol = self.STATUS_SYMBOLS.get(status, "?")
153
+
154
+ return Text(f"{symbol} {status}", style=color)
155
+
156
+ def _format_duration(self, experiment: Dict[str, Any]) -> Text:
157
+ """Format experiment duration."""
158
+ started_at = experiment.get("started_at")
159
+ status = experiment.get("status", "")
160
+
161
+ # Try different possible end time fields
162
+ ended_at = (
163
+ experiment.get("ended_at")
164
+ or experiment.get("completed_at")
165
+ or experiment.get("failed_at")
166
+ or experiment.get("cancelled_at")
167
+ )
168
+
169
+ if not started_at:
170
+ return Text("-", style="dim")
171
+
172
+ try:
173
+ if started_at.endswith("Z"):
174
+ start_time = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
175
+ elif "+" in started_at:
176
+ start_time = datetime.fromisoformat(started_at)
177
+ else:
178
+ # No timezone info, assume UTC
179
+ from datetime import timezone
180
+
181
+ start_time = datetime.fromisoformat(started_at).replace(
182
+ tzinfo=timezone.utc
183
+ )
184
+ end_time = None
185
+
186
+ if ended_at:
187
+ if ended_at.endswith("Z"):
188
+ end_time = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
189
+ elif "+" in ended_at or ended_at.endswith("Z"):
190
+ end_time = datetime.fromisoformat(ended_at)
191
+ else:
192
+ # No timezone info, assume UTC
193
+ from datetime import timezone
194
+
195
+ end_time = datetime.fromisoformat(ended_at).replace(
196
+ tzinfo=timezone.utc
197
+ )
198
+ elif status == "running":
199
+ # For running experiments, end_time stays None to show "(ongoing)"
200
+ pass
201
+ else:
202
+ # For non-running experiments without end time, use current time as fallback
203
+ from datetime import timezone
204
+
205
+ end_time = datetime.now(timezone.utc)
206
+
207
+ duration_str = format_duration(start_time, end_time)
208
+
209
+ # Color coding based on status
210
+ if status == "running":
211
+ return Text(duration_str, style="yellow")
212
+ elif status == "completed":
213
+ return Text(duration_str, style="green")
214
+ elif status in ("failed", "cancelled"):
215
+ return Text(duration_str, style="red")
216
+ else:
217
+ return Text(duration_str, style="dim")
218
+
219
+ except Exception:
220
+ return Text("unknown", style="dim")
221
+
222
+ def _format_tags(self, tags: List[str]) -> Text:
223
+ """Format tags list."""
224
+ if not tags:
225
+ return Text("-", style="dim")
226
+
227
+ # Limit displayed tags and truncate if necessary
228
+ display_tags = tags[:3] # Show max 3 tags
229
+
230
+ if len(tags) > 3:
231
+ tag_str = ", ".join(display_tags) + f" (+{len(tags) - 3})"
232
+ else:
233
+ tag_str = ", ".join(display_tags)
234
+
235
+ # Truncate if still too long
236
+ if len(tag_str) > 23:
237
+ tag_str = tag_str[:20] + "..."
238
+
239
+ return Text(tag_str, style="blue")
240
+
241
+ def _format_started_time(self, started_at: str) -> Text:
242
+ """Format started time as relative time."""
243
+ if not started_at:
244
+ return Text("-", style="dim")
245
+
246
+ try:
247
+ if started_at.endswith("Z"):
248
+ start_time = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
249
+ elif "+" in started_at:
250
+ start_time = datetime.fromisoformat(started_at)
251
+ else:
252
+ # No timezone info, assume UTC
253
+ from datetime import timezone
254
+
255
+ start_time = datetime.fromisoformat(started_at).replace(
256
+ tzinfo=timezone.utc
257
+ )
258
+ relative_str = format_relative_time(start_time)
259
+ return Text(relative_str, style="cyan")
260
+ except Exception:
261
+ return Text("unknown", style="dim")
262
+
263
+ def _format_time(self, time_str: str) -> str:
264
+ """Format timestamp for detailed display."""
265
+ try:
266
+ from datetime import datetime, timezone
267
+
268
+ if isinstance(time_str, str):
269
+ dt = datetime.fromisoformat(time_str)
270
+ if dt.tzinfo is None:
271
+ dt = dt.replace(tzinfo=timezone.utc)
272
+ else:
273
+ dt = time_str
274
+
275
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
276
+ except Exception:
277
+ return str(time_str)
278
+
279
+ def _calculate_duration(
280
+ self, start_time: str, end_time: Optional[str] = None
281
+ ) -> str:
282
+ """Calculate and format duration between two times."""
283
+ try:
284
+ from datetime import datetime, timezone
285
+
286
+ from yanex.cli.filters.time_utils import format_duration
287
+
288
+ if isinstance(start_time, str):
289
+ start_dt = datetime.fromisoformat(start_time)
290
+ if start_dt.tzinfo is None:
291
+ start_dt = start_dt.replace(tzinfo=timezone.utc)
292
+ else:
293
+ start_dt = start_time
294
+
295
+ if end_time:
296
+ if isinstance(end_time, str):
297
+ end_dt = datetime.fromisoformat(end_time)
298
+ if end_dt.tzinfo is None:
299
+ end_dt = end_dt.replace(tzinfo=timezone.utc)
300
+ else:
301
+ end_dt = end_time
302
+ else:
303
+ end_dt = None
304
+
305
+ return format_duration(start_dt, end_dt)
306
+ except Exception:
307
+ return "unknown"
308
+
309
+ def _format_file_size(self, size_bytes: int) -> str:
310
+ """Format file size in human-readable format."""
311
+ for unit in ["B", "KB", "MB", "GB"]:
312
+ if size_bytes < 1024:
313
+ return f"{size_bytes:.1f} {unit}"
314
+ size_bytes /= 1024
315
+ return f"{size_bytes:.1f} TB"
316
+
317
+ def _format_timestamp(self, timestamp: float) -> str:
318
+ """Format Unix timestamp for display."""
319
+ try:
320
+ from datetime import datetime
321
+
322
+ dt = datetime.fromtimestamp(timestamp)
323
+ return dt.strftime("%Y-%m-%d %H:%M")
324
+ except Exception:
325
+ return "unknown"
yanex/cli/main.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ Main CLI entry point for yanex.
3
+ """
4
+
5
+ import click
6
+
7
+ from .commands.archive import archive_experiments
8
+ from .commands.compare import compare_experiments
9
+ from .commands.delete import delete_experiments
10
+ from .commands.list import list_experiments
11
+ from .commands.run import run
12
+ from .commands.show import show_experiment
13
+ from .commands.unarchive import unarchive_experiments
14
+ from .commands.update import update_experiments
15
+
16
+
17
+ @click.group()
18
+ @click.version_option(version="0.1.0", prog_name="yanex")
19
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
20
+ @click.pass_context
21
+ def cli(ctx: click.Context, verbose: bool) -> None:
22
+ """
23
+ Yet Another Experiment Tracker - A lightweight experiment tracking harness.
24
+
25
+ Use yanex to track machine learning experiments with automatic versioning,
26
+ parameter management, and artifact storage.
27
+ """
28
+ # Ensure context object exists
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["verbose"] = verbose
31
+
32
+
33
+ # Register commands
34
+ cli.add_command(run)
35
+ cli.add_command(list_experiments, name="list")
36
+ cli.add_command(show_experiment, name="show")
37
+ cli.add_command(archive_experiments, name="archive")
38
+ cli.add_command(delete_experiments, name="delete")
39
+ cli.add_command(unarchive_experiments, name="unarchive")
40
+ cli.add_command(update_experiments, name="update")
41
+ cli.add_command(compare_experiments, name="compare")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ cli()
yanex/core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ Core functionality for experiment management, storage, and configuration.
3
+ """