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
@@ -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