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,286 @@
|
|
1
|
+
"""
|
2
|
+
Core experiment filtering functionality.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import fnmatch
|
6
|
+
from datetime import datetime
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import Any, Dict, List, Optional
|
9
|
+
|
10
|
+
from ...core.constants import EXPERIMENT_STATUSES_SET
|
11
|
+
from ...core.manager import ExperimentManager
|
12
|
+
|
13
|
+
|
14
|
+
class ExperimentFilter:
|
15
|
+
"""
|
16
|
+
Reusable experiment filtering system for CLI commands.
|
17
|
+
|
18
|
+
Supports filtering by status, name patterns, tags, and time ranges.
|
19
|
+
Can be used by list, delete, archive, and other commands.
|
20
|
+
"""
|
21
|
+
|
22
|
+
def __init__(self, manager: Optional[ExperimentManager] = None):
|
23
|
+
"""
|
24
|
+
Initialize experiment filter.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
manager: ExperimentManager instance (creates default if None)
|
28
|
+
"""
|
29
|
+
self.manager = manager or ExperimentManager()
|
30
|
+
|
31
|
+
def filter_experiments(
|
32
|
+
self,
|
33
|
+
status: Optional[str] = None,
|
34
|
+
name_pattern: Optional[str] = None,
|
35
|
+
tags: Optional[List[str]] = None,
|
36
|
+
started_after: Optional[datetime] = None,
|
37
|
+
started_before: Optional[datetime] = None,
|
38
|
+
ended_after: Optional[datetime] = None,
|
39
|
+
ended_before: Optional[datetime] = None,
|
40
|
+
limit: Optional[int] = None,
|
41
|
+
include_all: bool = False,
|
42
|
+
include_archived: bool = False,
|
43
|
+
) -> List[Dict[str, Any]]:
|
44
|
+
"""
|
45
|
+
Filter experiments based on multiple criteria.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
status: Filter by experiment status (created/running/completed/failed/cancelled/staged)
|
49
|
+
name_pattern: Filter by name using glob patterns (e.g., "*tuning*")
|
50
|
+
tags: List of tags - experiments must have ALL specified tags
|
51
|
+
started_after: Filter experiments started after this time
|
52
|
+
started_before: Filter experiments started before this time
|
53
|
+
ended_after: Filter experiments ended after this time
|
54
|
+
ended_before: Filter experiments ended before this time
|
55
|
+
limit: Maximum number of results to return (for pagination)
|
56
|
+
include_all: If True, ignore default limit and return all matching experiments
|
57
|
+
include_archived: If True, include archived experiments in results
|
58
|
+
|
59
|
+
Returns:
|
60
|
+
List of experiment metadata dictionaries matching all criteria
|
61
|
+
|
62
|
+
Raises:
|
63
|
+
ValueError: If invalid status or other parameters provided
|
64
|
+
"""
|
65
|
+
# Validate status if provided
|
66
|
+
if status is not None:
|
67
|
+
if status not in EXPERIMENT_STATUSES_SET:
|
68
|
+
raise ValueError(
|
69
|
+
f"Invalid status '{status}'. Valid options: {', '.join(sorted(EXPERIMENT_STATUSES_SET))}"
|
70
|
+
)
|
71
|
+
|
72
|
+
# Get all experiments
|
73
|
+
all_experiments = self._load_all_experiments(include_archived)
|
74
|
+
|
75
|
+
# Apply filters
|
76
|
+
filtered = all_experiments
|
77
|
+
|
78
|
+
if status is not None:
|
79
|
+
filtered = [exp for exp in filtered if exp.get("status") == status]
|
80
|
+
|
81
|
+
if name_pattern is not None:
|
82
|
+
filtered = [
|
83
|
+
exp for exp in filtered if self._matches_name_pattern(exp, name_pattern)
|
84
|
+
]
|
85
|
+
|
86
|
+
if tags:
|
87
|
+
filtered = [exp for exp in filtered if self._has_all_tags(exp, tags)]
|
88
|
+
|
89
|
+
if started_after is not None:
|
90
|
+
filtered = [
|
91
|
+
exp for exp in filtered if self._started_after(exp, started_after)
|
92
|
+
]
|
93
|
+
|
94
|
+
if started_before is not None:
|
95
|
+
filtered = [
|
96
|
+
exp for exp in filtered if self._started_before(exp, started_before)
|
97
|
+
]
|
98
|
+
|
99
|
+
if ended_after is not None:
|
100
|
+
filtered = [exp for exp in filtered if self._ended_after(exp, ended_after)]
|
101
|
+
|
102
|
+
if ended_before is not None:
|
103
|
+
filtered = [
|
104
|
+
exp for exp in filtered if self._ended_before(exp, ended_before)
|
105
|
+
]
|
106
|
+
|
107
|
+
# Sort by creation time (newest first)
|
108
|
+
filtered.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
109
|
+
|
110
|
+
# Apply limit
|
111
|
+
if not include_all and limit is not None:
|
112
|
+
filtered = filtered[:limit]
|
113
|
+
elif not include_all and limit is None:
|
114
|
+
# Default limit of 10 if not explicitly requesting all
|
115
|
+
filtered = filtered[:10]
|
116
|
+
|
117
|
+
return filtered
|
118
|
+
|
119
|
+
def _load_all_experiments(
|
120
|
+
self, include_archived: bool = False
|
121
|
+
) -> List[Dict[str, Any]]:
|
122
|
+
"""
|
123
|
+
Load metadata for all experiments.
|
124
|
+
|
125
|
+
Args:
|
126
|
+
include_archived: Whether to include archived experiments
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
List of experiment metadata dictionaries
|
130
|
+
"""
|
131
|
+
experiments = []
|
132
|
+
experiments_dir = self.manager.storage.experiments_dir
|
133
|
+
|
134
|
+
if not experiments_dir.exists():
|
135
|
+
return experiments
|
136
|
+
|
137
|
+
# Helper function to load experiments from a directory
|
138
|
+
def load_from_directory(directory: Path, is_archived: bool = False):
|
139
|
+
for exp_dir in directory.iterdir():
|
140
|
+
if not exp_dir.is_dir():
|
141
|
+
continue
|
142
|
+
|
143
|
+
# Skip the archived directory when loading regular experiments
|
144
|
+
if not is_archived and exp_dir.name == "archived":
|
145
|
+
continue
|
146
|
+
|
147
|
+
experiment_id = exp_dir.name
|
148
|
+
|
149
|
+
# Validate experiment ID format (8 characters)
|
150
|
+
if len(experiment_id) != 8:
|
151
|
+
continue
|
152
|
+
|
153
|
+
try:
|
154
|
+
# Load metadata for this experiment
|
155
|
+
metadata = self.manager.storage.load_metadata(
|
156
|
+
experiment_id, include_archived=is_archived
|
157
|
+
)
|
158
|
+
|
159
|
+
# Add the experiment ID to metadata for convenience
|
160
|
+
metadata["id"] = experiment_id
|
161
|
+
|
162
|
+
# Add archived flag to distinguish archived experiments
|
163
|
+
metadata["archived"] = is_archived
|
164
|
+
|
165
|
+
experiments.append(metadata)
|
166
|
+
|
167
|
+
except Exception:
|
168
|
+
# Skip experiments with corrupted or missing metadata
|
169
|
+
continue
|
170
|
+
|
171
|
+
# Load regular experiments
|
172
|
+
load_from_directory(experiments_dir, is_archived=False)
|
173
|
+
|
174
|
+
# Load archived experiments if requested
|
175
|
+
if include_archived:
|
176
|
+
archived_dir = experiments_dir / "archived"
|
177
|
+
if archived_dir.exists():
|
178
|
+
load_from_directory(archived_dir, is_archived=True)
|
179
|
+
|
180
|
+
return experiments
|
181
|
+
|
182
|
+
def _matches_name_pattern(self, experiment: Dict[str, Any], pattern: str) -> bool:
|
183
|
+
"""Check if experiment name matches glob pattern."""
|
184
|
+
name = experiment.get("name", "")
|
185
|
+
if not name:
|
186
|
+
# Handle unnamed experiments
|
187
|
+
name = "[unnamed]"
|
188
|
+
return fnmatch.fnmatch(name.lower(), pattern.lower())
|
189
|
+
|
190
|
+
def _has_all_tags(
|
191
|
+
self, experiment: Dict[str, Any], required_tags: List[str]
|
192
|
+
) -> bool:
|
193
|
+
"""Check if experiment has all required tags."""
|
194
|
+
exp_tags = set(experiment.get("tags", []))
|
195
|
+
required_tags_set = set(required_tags)
|
196
|
+
return required_tags_set.issubset(exp_tags)
|
197
|
+
|
198
|
+
def _started_after(self, experiment: Dict[str, Any], after_time: datetime) -> bool:
|
199
|
+
"""Check if experiment started after the specified time."""
|
200
|
+
started_at = experiment.get("started_at")
|
201
|
+
if not started_at:
|
202
|
+
return False
|
203
|
+
|
204
|
+
try:
|
205
|
+
# Parse the ISO format timestamp with proper timezone handling
|
206
|
+
if started_at.endswith("Z"):
|
207
|
+
exp_start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
208
|
+
elif "+" in started_at:
|
209
|
+
exp_start = datetime.fromisoformat(started_at)
|
210
|
+
else:
|
211
|
+
# No timezone info, assume UTC
|
212
|
+
from datetime import timezone
|
213
|
+
|
214
|
+
exp_start = datetime.fromisoformat(started_at).replace(
|
215
|
+
tzinfo=timezone.utc
|
216
|
+
)
|
217
|
+
return exp_start >= after_time
|
218
|
+
except Exception:
|
219
|
+
return False
|
220
|
+
|
221
|
+
def _started_before(
|
222
|
+
self, experiment: Dict[str, Any], before_time: datetime
|
223
|
+
) -> bool:
|
224
|
+
"""Check if experiment started before the specified time."""
|
225
|
+
started_at = experiment.get("started_at")
|
226
|
+
if not started_at:
|
227
|
+
return False
|
228
|
+
|
229
|
+
try:
|
230
|
+
# Parse the ISO format timestamp with proper timezone handling
|
231
|
+
if started_at.endswith("Z"):
|
232
|
+
exp_start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
233
|
+
elif "+" in started_at:
|
234
|
+
exp_start = datetime.fromisoformat(started_at)
|
235
|
+
else:
|
236
|
+
# No timezone info, assume UTC
|
237
|
+
from datetime import timezone
|
238
|
+
|
239
|
+
exp_start = datetime.fromisoformat(started_at).replace(
|
240
|
+
tzinfo=timezone.utc
|
241
|
+
)
|
242
|
+
return exp_start < before_time
|
243
|
+
except Exception:
|
244
|
+
return False
|
245
|
+
|
246
|
+
def _ended_after(self, experiment: Dict[str, Any], after_time: datetime) -> bool:
|
247
|
+
"""Check if experiment ended after the specified time."""
|
248
|
+
ended_at = experiment.get("ended_at")
|
249
|
+
if not ended_at:
|
250
|
+
return False
|
251
|
+
|
252
|
+
try:
|
253
|
+
# Parse the ISO format timestamp with proper timezone handling
|
254
|
+
if ended_at.endswith("Z"):
|
255
|
+
exp_end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
256
|
+
elif "+" in ended_at:
|
257
|
+
exp_end = datetime.fromisoformat(ended_at)
|
258
|
+
else:
|
259
|
+
# No timezone info, assume UTC
|
260
|
+
from datetime import timezone
|
261
|
+
|
262
|
+
exp_end = datetime.fromisoformat(ended_at).replace(tzinfo=timezone.utc)
|
263
|
+
return exp_end >= after_time
|
264
|
+
except Exception:
|
265
|
+
return False
|
266
|
+
|
267
|
+
def _ended_before(self, experiment: Dict[str, Any], before_time: datetime) -> bool:
|
268
|
+
"""Check if experiment ended before the specified time."""
|
269
|
+
ended_at = experiment.get("ended_at")
|
270
|
+
if not ended_at:
|
271
|
+
return False
|
272
|
+
|
273
|
+
try:
|
274
|
+
# Parse the ISO format timestamp with proper timezone handling
|
275
|
+
if ended_at.endswith("Z"):
|
276
|
+
exp_end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
277
|
+
elif "+" in ended_at:
|
278
|
+
exp_end = datetime.fromisoformat(ended_at)
|
279
|
+
else:
|
280
|
+
# No timezone info, assume UTC
|
281
|
+
from datetime import timezone
|
282
|
+
|
283
|
+
exp_end = datetime.fromisoformat(ended_at).replace(tzinfo=timezone.utc)
|
284
|
+
return exp_end < before_time
|
285
|
+
except Exception:
|
286
|
+
return False
|
@@ -0,0 +1,178 @@
|
|
1
|
+
"""
|
2
|
+
Time parsing utilities for human-readable date specifications.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from datetime import date, datetime, time, timedelta, timezone
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
import dateparser
|
9
|
+
|
10
|
+
|
11
|
+
def parse_time_spec(time_spec: str) -> Optional[datetime]:
|
12
|
+
"""
|
13
|
+
Parse human-readable time specification into datetime object.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
time_spec: Human-readable time string (e.g., "today", "2 hours ago", "2023-01-01")
|
17
|
+
|
18
|
+
Returns:
|
19
|
+
Parsed datetime object with timezone info, or None if parsing failed
|
20
|
+
|
21
|
+
Examples:
|
22
|
+
>>> parse_time_spec("today")
|
23
|
+
datetime(2025, 6, 28, 0, 0, tzinfo=...)
|
24
|
+
|
25
|
+
>>> parse_time_spec("2 hours ago")
|
26
|
+
datetime(2025, 6, 28, 10, 0, tzinfo=...)
|
27
|
+
|
28
|
+
>>> parse_time_spec("2023-01-01")
|
29
|
+
datetime(2023, 1, 1, 0, 0, tzinfo=...)
|
30
|
+
"""
|
31
|
+
if not time_spec or not time_spec.strip():
|
32
|
+
return None
|
33
|
+
|
34
|
+
time_spec = time_spec.strip().lower()
|
35
|
+
|
36
|
+
try:
|
37
|
+
# Handle special cases for relative day terms to return beginning of day
|
38
|
+
if time_spec in ["today"]:
|
39
|
+
today = date.today()
|
40
|
+
return datetime.combine(today, time.min, tzinfo=timezone.utc)
|
41
|
+
elif time_spec in ["yesterday"]:
|
42
|
+
yesterday = date.today() - timedelta(days=1)
|
43
|
+
return datetime.combine(yesterday, time.min, tzinfo=timezone.utc)
|
44
|
+
elif time_spec in ["tomorrow"]:
|
45
|
+
tomorrow = date.today() + timedelta(days=1)
|
46
|
+
return datetime.combine(tomorrow, time.min, tzinfo=timezone.utc)
|
47
|
+
|
48
|
+
# Use dateparser to handle natural language and various formats
|
49
|
+
parsed_dt = dateparser.parse(
|
50
|
+
time_spec,
|
51
|
+
settings={
|
52
|
+
"TIMEZONE": "local", # Use local timezone
|
53
|
+
"RETURN_AS_TIMEZONE_AWARE": True, # Always return timezone-aware datetime
|
54
|
+
"PREFER_DATES_FROM": "past", # For ambiguous dates, prefer past
|
55
|
+
"STRICT_PARSING": False, # Allow flexible parsing
|
56
|
+
},
|
57
|
+
)
|
58
|
+
|
59
|
+
if parsed_dt is None:
|
60
|
+
return None
|
61
|
+
|
62
|
+
# Ensure we have timezone info
|
63
|
+
if parsed_dt.tzinfo is None:
|
64
|
+
# Add local timezone if not present
|
65
|
+
|
66
|
+
local_tz = timezone.utc.replace(tzinfo=timezone.utc).astimezone().tzinfo
|
67
|
+
parsed_dt = parsed_dt.replace(tzinfo=local_tz)
|
68
|
+
|
69
|
+
return parsed_dt
|
70
|
+
|
71
|
+
except Exception:
|
72
|
+
# Return None for any parsing errors
|
73
|
+
return None
|
74
|
+
|
75
|
+
|
76
|
+
def format_duration(start_time: datetime, end_time: Optional[datetime] = None) -> str:
|
77
|
+
"""
|
78
|
+
Format duration between two times in human-readable format.
|
79
|
+
|
80
|
+
Args:
|
81
|
+
start_time: Start datetime
|
82
|
+
end_time: End datetime (if None, use current time)
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
Human-readable duration string
|
86
|
+
|
87
|
+
Examples:
|
88
|
+
>>> format_duration(start, end)
|
89
|
+
"2m 34s"
|
90
|
+
|
91
|
+
>>> format_duration(start, None) # Still running
|
92
|
+
"5m 12s (ongoing)"
|
93
|
+
"""
|
94
|
+
if end_time is None:
|
95
|
+
end_time = datetime.now(timezone.utc)
|
96
|
+
is_ongoing = True
|
97
|
+
else:
|
98
|
+
is_ongoing = False
|
99
|
+
|
100
|
+
# Ensure both times have timezone info
|
101
|
+
if start_time.tzinfo is None:
|
102
|
+
start_time = start_time.replace(tzinfo=timezone.utc)
|
103
|
+
if end_time.tzinfo is None:
|
104
|
+
end_time = end_time.replace(tzinfo=timezone.utc)
|
105
|
+
|
106
|
+
# Calculate duration
|
107
|
+
duration = end_time - start_time
|
108
|
+
total_seconds = int(duration.total_seconds())
|
109
|
+
|
110
|
+
if total_seconds < 0:
|
111
|
+
return "0s"
|
112
|
+
|
113
|
+
# Format as human-readable
|
114
|
+
if total_seconds < 60:
|
115
|
+
result = f"{total_seconds}s"
|
116
|
+
elif total_seconds < 3600: # Less than 1 hour
|
117
|
+
minutes = total_seconds // 60
|
118
|
+
seconds = total_seconds % 60
|
119
|
+
result = f"{minutes}m {seconds}s"
|
120
|
+
elif total_seconds < 86400: # Less than 1 day
|
121
|
+
hours = total_seconds // 3600
|
122
|
+
minutes = (total_seconds % 3600) // 60
|
123
|
+
result = f"{hours}h {minutes}m"
|
124
|
+
else: # 1 day or more
|
125
|
+
days = total_seconds // 86400
|
126
|
+
hours = (total_seconds % 86400) // 3600
|
127
|
+
result = f"{days}d {hours}h"
|
128
|
+
|
129
|
+
if is_ongoing:
|
130
|
+
result += " (ongoing)"
|
131
|
+
|
132
|
+
return result
|
133
|
+
|
134
|
+
|
135
|
+
def format_relative_time(dt: datetime) -> str:
|
136
|
+
"""
|
137
|
+
Format datetime as relative time from now.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
dt: Datetime to format
|
141
|
+
|
142
|
+
Returns:
|
143
|
+
Human-readable relative time string
|
144
|
+
|
145
|
+
Examples:
|
146
|
+
>>> format_relative_time(datetime.now() - timedelta(hours=2))
|
147
|
+
"2 hours ago"
|
148
|
+
|
149
|
+
>>> format_relative_time(datetime.now() - timedelta(days=1))
|
150
|
+
"1 day ago"
|
151
|
+
"""
|
152
|
+
now = datetime.now(timezone.utc)
|
153
|
+
|
154
|
+
# Ensure dt has timezone info
|
155
|
+
if dt.tzinfo is None:
|
156
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
157
|
+
|
158
|
+
# Calculate difference
|
159
|
+
diff = now - dt
|
160
|
+
total_seconds = int(diff.total_seconds())
|
161
|
+
|
162
|
+
if total_seconds < 0:
|
163
|
+
return "in the future"
|
164
|
+
|
165
|
+
if total_seconds < 60:
|
166
|
+
return "just now"
|
167
|
+
elif total_seconds < 3600: # Less than 1 hour
|
168
|
+
minutes = total_seconds // 60
|
169
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
170
|
+
elif total_seconds < 86400: # Less than 1 day
|
171
|
+
hours = total_seconds // 3600
|
172
|
+
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
173
|
+
elif total_seconds < 604800: # Less than 1 week
|
174
|
+
days = total_seconds // 86400
|
175
|
+
return f"{days} day{'s' if days != 1 else ''} ago"
|
176
|
+
else:
|
177
|
+
# For longer periods, show the actual date
|
178
|
+
return dt.strftime("%Y-%m-%d")
|