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,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")
@@ -0,0 +1,7 @@
1
+ """
2
+ Console formatting components for yanex CLI commands.
3
+ """
4
+
5
+ from .console import ExperimentTableFormatter
6
+
7
+ __all__ = ["ExperimentTableFormatter"]