experimaestro 2.0.0b4__py3-none-any.whl → 2.0.0b17__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.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

Files changed (154) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +393 -134
  3. experimaestro/cli/filter.py +48 -23
  4. experimaestro/cli/jobs.py +253 -71
  5. experimaestro/cli/refactor.py +1 -2
  6. experimaestro/commandline.py +7 -4
  7. experimaestro/connectors/__init__.py +9 -1
  8. experimaestro/connectors/local.py +43 -3
  9. experimaestro/core/arguments.py +18 -18
  10. experimaestro/core/identifier.py +11 -11
  11. experimaestro/core/objects/config.py +96 -39
  12. experimaestro/core/objects/config_walk.py +3 -3
  13. experimaestro/core/{subparameters.py → partial.py} +16 -16
  14. experimaestro/core/partial_lock.py +394 -0
  15. experimaestro/core/types.py +12 -15
  16. experimaestro/dynamic.py +290 -0
  17. experimaestro/experiments/__init__.py +6 -2
  18. experimaestro/experiments/cli.py +223 -52
  19. experimaestro/experiments/configuration.py +24 -0
  20. experimaestro/generators.py +5 -5
  21. experimaestro/ipc.py +118 -1
  22. experimaestro/launcherfinder/__init__.py +2 -2
  23. experimaestro/launcherfinder/registry.py +6 -7
  24. experimaestro/launcherfinder/specs.py +2 -9
  25. experimaestro/launchers/slurm/__init__.py +2 -2
  26. experimaestro/launchers/slurm/base.py +62 -0
  27. experimaestro/locking.py +957 -1
  28. experimaestro/notifications.py +89 -201
  29. experimaestro/progress.py +63 -366
  30. experimaestro/rpyc.py +0 -2
  31. experimaestro/run.py +29 -2
  32. experimaestro/scheduler/__init__.py +8 -1
  33. experimaestro/scheduler/base.py +650 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +764 -169
  36. experimaestro/scheduler/interfaces.py +338 -96
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/__init__.py +31 -0
  39. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  40. experimaestro/scheduler/remote/client.py +928 -0
  41. experimaestro/scheduler/remote/protocol.py +282 -0
  42. experimaestro/scheduler/remote/server.py +447 -0
  43. experimaestro/scheduler/remote/sync.py +144 -0
  44. experimaestro/scheduler/services.py +186 -35
  45. experimaestro/scheduler/state_provider.py +811 -2157
  46. experimaestro/scheduler/state_status.py +1247 -0
  47. experimaestro/scheduler/transient.py +31 -0
  48. experimaestro/scheduler/workspace.py +1 -1
  49. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  50. experimaestro/scriptbuilder.py +4 -4
  51. experimaestro/settings.py +36 -0
  52. experimaestro/tests/conftest.py +33 -5
  53. experimaestro/tests/connectors/bin/executable.py +1 -1
  54. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  55. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  56. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  58. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  59. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  60. experimaestro/tests/launchers/bin/test.py +1 -0
  61. experimaestro/tests/launchers/test_slurm.py +9 -9
  62. experimaestro/tests/partial_reschedule.py +46 -0
  63. experimaestro/tests/restart.py +3 -3
  64. experimaestro/tests/restart_main.py +1 -0
  65. experimaestro/tests/scripts/notifyandwait.py +1 -0
  66. experimaestro/tests/task_partial.py +38 -0
  67. experimaestro/tests/task_tokens.py +2 -2
  68. experimaestro/tests/tasks/test_dynamic.py +6 -6
  69. experimaestro/tests/test_dependencies.py +3 -3
  70. experimaestro/tests/test_deprecated.py +15 -15
  71. experimaestro/tests/test_dynamic_locking.py +317 -0
  72. experimaestro/tests/test_environment.py +24 -14
  73. experimaestro/tests/test_experiment.py +171 -36
  74. experimaestro/tests/test_identifier.py +25 -25
  75. experimaestro/tests/test_identifier_stability.py +3 -5
  76. experimaestro/tests/test_multitoken.py +2 -4
  77. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  78. experimaestro/tests/test_partial_paths.py +81 -138
  79. experimaestro/tests/test_pre_experiment.py +219 -0
  80. experimaestro/tests/test_progress.py +2 -8
  81. experimaestro/tests/test_remote_state.py +1132 -0
  82. experimaestro/tests/test_stray_jobs.py +261 -0
  83. experimaestro/tests/test_tasks.py +1 -2
  84. experimaestro/tests/test_token_locking.py +52 -67
  85. experimaestro/tests/test_tokens.py +5 -6
  86. experimaestro/tests/test_transient.py +225 -0
  87. experimaestro/tests/test_workspace_state_provider.py +768 -0
  88. experimaestro/tests/token_reschedule.py +1 -3
  89. experimaestro/tests/utils.py +2 -7
  90. experimaestro/tokens.py +227 -372
  91. experimaestro/tools/diff.py +1 -0
  92. experimaestro/tools/documentation.py +4 -5
  93. experimaestro/tools/jobs.py +1 -2
  94. experimaestro/tui/app.py +459 -1895
  95. experimaestro/tui/app.tcss +162 -0
  96. experimaestro/tui/dialogs.py +172 -0
  97. experimaestro/tui/log_viewer.py +253 -3
  98. experimaestro/tui/messages.py +137 -0
  99. experimaestro/tui/utils.py +54 -0
  100. experimaestro/tui/widgets/__init__.py +23 -0
  101. experimaestro/tui/widgets/experiments.py +468 -0
  102. experimaestro/tui/widgets/global_services.py +238 -0
  103. experimaestro/tui/widgets/jobs.py +972 -0
  104. experimaestro/tui/widgets/log.py +156 -0
  105. experimaestro/tui/widgets/orphans.py +363 -0
  106. experimaestro/tui/widgets/runs.py +185 -0
  107. experimaestro/tui/widgets/services.py +314 -0
  108. experimaestro/tui/widgets/stray_jobs.py +528 -0
  109. experimaestro/utils/__init__.py +1 -1
  110. experimaestro/utils/environment.py +105 -22
  111. experimaestro/utils/fswatcher.py +124 -0
  112. experimaestro/utils/jobs.py +1 -2
  113. experimaestro/utils/jupyter.py +1 -2
  114. experimaestro/utils/logging.py +72 -0
  115. experimaestro/version.py +2 -2
  116. experimaestro/webui/__init__.py +9 -0
  117. experimaestro/webui/app.py +117 -0
  118. experimaestro/{server → webui}/data/index.css +66 -11
  119. experimaestro/webui/data/index.css.map +1 -0
  120. experimaestro/{server → webui}/data/index.js +82763 -87217
  121. experimaestro/webui/data/index.js.map +1 -0
  122. experimaestro/webui/routes/__init__.py +5 -0
  123. experimaestro/webui/routes/auth.py +53 -0
  124. experimaestro/webui/routes/proxy.py +117 -0
  125. experimaestro/webui/server.py +200 -0
  126. experimaestro/webui/state_bridge.py +152 -0
  127. experimaestro/webui/websocket.py +413 -0
  128. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +8 -9
  129. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  130. experimaestro/cli/progress.py +0 -269
  131. experimaestro/scheduler/state.py +0 -75
  132. experimaestro/scheduler/state_db.py +0 -388
  133. experimaestro/scheduler/state_sync.py +0 -834
  134. experimaestro/server/__init__.py +0 -467
  135. experimaestro/server/data/index.css.map +0 -1
  136. experimaestro/server/data/index.js.map +0 -1
  137. experimaestro/tests/test_cli_jobs.py +0 -615
  138. experimaestro/tests/test_file_progress.py +0 -425
  139. experimaestro/tests/test_file_progress_integration.py +0 -477
  140. experimaestro/tests/test_state_db.py +0 -434
  141. experimaestro-2.0.0b4.dist-info/RECORD +0 -181
  142. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  143. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  145. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  147. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  148. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  149. /experimaestro/{server → webui}/data/index.html +0 -0
  150. /experimaestro/{server → webui}/data/login.html +0 -0
  151. /experimaestro/{server → webui}/data/manifest.json +0 -0
  152. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  153. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  154. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,137 @@
1
+ """Message classes for TUI inter-widget communication"""
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+ from textual.message import Message
5
+
6
+ if TYPE_CHECKING:
7
+ from experimaestro.scheduler.interfaces import JobState
8
+
9
+
10
+ class ExperimentSelected(Message):
11
+ """Message sent when an experiment is selected"""
12
+
13
+ def __init__(self, experiment_id: str, run_id: Optional[str] = None) -> None:
14
+ super().__init__()
15
+ self.experiment_id = experiment_id
16
+ self.run_id = run_id
17
+
18
+
19
+ class ExperimentDeselected(Message):
20
+ """Message sent when an experiment is deselected"""
21
+
22
+ pass
23
+
24
+
25
+ class JobSelected(Message):
26
+ """Message sent when a job is selected"""
27
+
28
+ def __init__(self, job_id: str, experiment_id: str) -> None:
29
+ super().__init__()
30
+ self.job_id = job_id
31
+ self.experiment_id = experiment_id
32
+
33
+
34
+ class JobDeselected(Message):
35
+ """Message sent when returning from job detail view"""
36
+
37
+ pass
38
+
39
+
40
+ class ViewJobLogs(Message):
41
+ """Message sent when user wants to view job logs"""
42
+
43
+ def __init__(
44
+ self, job_path: str, task_id: str, job_state: Optional["JobState"] = None
45
+ ) -> None:
46
+ super().__init__()
47
+ self.job_path = job_path
48
+ self.task_id = task_id
49
+ self.job_state = job_state
50
+
51
+
52
+ class ViewJobLogsRequest(Message):
53
+ """Message sent when user requests to view logs from jobs table"""
54
+
55
+ def __init__(self, job_id: str, experiment_id: str) -> None:
56
+ super().__init__()
57
+ self.job_id = job_id
58
+ self.experiment_id = experiment_id
59
+
60
+
61
+ class DeleteJobRequest(Message):
62
+ """Message sent when user requests to delete a job"""
63
+
64
+ def __init__(self, job_id: str, experiment_id: str) -> None:
65
+ super().__init__()
66
+ self.job_id = job_id
67
+ self.experiment_id = experiment_id
68
+
69
+
70
+ class DeleteExperimentRequest(Message):
71
+ """Message sent when user requests to delete an experiment"""
72
+
73
+ def __init__(self, experiment_id: str) -> None:
74
+ super().__init__()
75
+ self.experiment_id = experiment_id
76
+
77
+
78
+ class KillJobRequest(Message):
79
+ """Message sent when user requests to kill a running job"""
80
+
81
+ def __init__(self, job_id: str, experiment_id: str) -> None:
82
+ super().__init__()
83
+ self.job_id = job_id
84
+ self.experiment_id = experiment_id
85
+
86
+
87
+ class KillExperimentRequest(Message):
88
+ """Message sent when user requests to kill all running jobs in an experiment"""
89
+
90
+ def __init__(self, experiment_id: str) -> None:
91
+ super().__init__()
92
+ self.experiment_id = experiment_id
93
+
94
+
95
+ class FilterChanged(Message):
96
+ """Message sent when search filter changes"""
97
+
98
+ def __init__(self, filter_fn) -> None:
99
+ super().__init__()
100
+ self.filter_fn = filter_fn
101
+
102
+
103
+ class SearchApplied(Message):
104
+ """Message sent when search filter is applied via Enter"""
105
+
106
+ pass
107
+
108
+
109
+ class SizeCalculated(Message):
110
+ """Message sent when a folder size has been calculated"""
111
+
112
+ def __init__(self, job_id: str, size: str, size_bytes: int) -> None:
113
+ super().__init__()
114
+ self.job_id = job_id
115
+ self.size = size
116
+ self.size_bytes = size_bytes
117
+
118
+
119
+ class ShowRunsRequest(Message):
120
+ """Message sent when user wants to see experiment runs"""
121
+
122
+ def __init__(self, experiment_id: str, current_run_id: Optional[str]) -> None:
123
+ super().__init__()
124
+ self.experiment_id = experiment_id
125
+ self.current_run_id = current_run_id
126
+
127
+
128
+ class RunSelected(Message):
129
+ """Message sent when a run is selected from the runs screen"""
130
+
131
+ def __init__(
132
+ self, experiment_id: str, run_id: str, is_current: bool = True
133
+ ) -> None:
134
+ super().__init__()
135
+ self.experiment_id = experiment_id
136
+ self.run_id = run_id
137
+ self.is_current = is_current
@@ -0,0 +1,54 @@
1
+ """Utility functions for the TUI"""
2
+
3
+
4
+ def format_duration(seconds: float) -> str:
5
+ """Format duration in seconds to human-readable string"""
6
+ if seconds < 0:
7
+ return "-"
8
+ seconds = int(seconds)
9
+ if seconds < 60:
10
+ return f"{seconds}s"
11
+ elif seconds < 3600:
12
+ return f"{seconds // 60}m {seconds % 60}s"
13
+ elif seconds < 86400:
14
+ return f"{seconds // 3600}h {(seconds % 3600) // 60}m"
15
+ else:
16
+ return f"{seconds // 86400}d {(seconds % 86400) // 3600}h"
17
+
18
+
19
+ def get_status_icon(status: str, failure_reason=None, transient=None):
20
+ """Get status icon for a job state.
21
+
22
+ Args:
23
+ status: Job state name (e.g., "done", "error", "running")
24
+ failure_reason: Optional JobFailureStatus enum for error states
25
+ transient: Optional TransientMode enum
26
+
27
+ Returns:
28
+ Status icon string
29
+ """
30
+ if status == "done":
31
+ return "✅"
32
+ elif status == "error":
33
+ # Show different icons for different failure types
34
+ if failure_reason is not None:
35
+ from experimaestro.scheduler.interfaces import JobFailureStatus
36
+
37
+ if failure_reason == JobFailureStatus.DEPENDENCY:
38
+ return "🔗" # Dependency failed
39
+ elif failure_reason == JobFailureStatus.TIMEOUT:
40
+ return "⏱" # Timeout
41
+ elif failure_reason == JobFailureStatus.MEMORY:
42
+ return "💾" # Memory issue
43
+ # FAILED or unknown - use default error icon
44
+ return "❌"
45
+ elif status == "running":
46
+ return "▶"
47
+ elif status == "waiting":
48
+ return "⌛" # Waiting for dependencies
49
+ elif status == "unscheduled" and transient is not None and transient.is_transient:
50
+ # Transient job that was skipped (not needed)
51
+ return "💤" # Sleeping - dormant, not activated
52
+ else:
53
+ # phantom, unscheduled or unknown
54
+ return "👻"
@@ -0,0 +1,23 @@
1
+ """TUI Widgets"""
2
+
3
+ from experimaestro.tui.widgets.log import CaptureLog
4
+ from experimaestro.tui.widgets.experiments import ExperimentsList
5
+ from experimaestro.tui.widgets.services import ServicesList
6
+ from experimaestro.tui.widgets.jobs import JobsTable, JobDetailView, SearchBar
7
+ from experimaestro.tui.widgets.orphans import OrphanJobsScreen
8
+ from experimaestro.tui.widgets.runs import RunsList
9
+ from experimaestro.tui.widgets.global_services import GlobalServiceSyncs
10
+ from experimaestro.tui.widgets.stray_jobs import OrphanJobsTab
11
+
12
+ __all__ = [
13
+ "CaptureLog",
14
+ "ExperimentsList",
15
+ "ServicesList",
16
+ "JobsTable",
17
+ "JobDetailView",
18
+ "SearchBar",
19
+ "OrphanJobsScreen",
20
+ "OrphanJobsTab",
21
+ "RunsList",
22
+ "GlobalServiceSyncs",
23
+ ]
@@ -0,0 +1,468 @@
1
+ """Experiments list widget for the TUI"""
2
+
3
+ from datetime import datetime
4
+ import time as time_module
5
+ from typing import Optional
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Horizontal
8
+ from textual.widgets import DataTable, Label
9
+ from textual.widget import Widget
10
+ from textual.reactive import reactive
11
+ from textual.binding import Binding
12
+
13
+ from experimaestro.scheduler.state_provider import StateProvider
14
+ from experimaestro.tui.utils import format_duration
15
+ from experimaestro.tui.messages import (
16
+ ExperimentSelected,
17
+ ExperimentDeselected,
18
+ DeleteExperimentRequest,
19
+ KillExperimentRequest,
20
+ ShowRunsRequest,
21
+ )
22
+
23
+
24
+ class ExperimentsList(Widget):
25
+ """Widget displaying list of experiments"""
26
+
27
+ BINDINGS = [
28
+ Binding("d", "show_runs", "Runs"),
29
+ Binding("ctrl+d", "delete_experiment", "Delete", show=False),
30
+ Binding("k", "kill_experiment", "Kill", show=False),
31
+ Binding("S", "sort_by_status", "Sort ⚑", show=False),
32
+ Binding("D", "sort_by_date", "Sort Date", show=False),
33
+ ]
34
+
35
+ current_experiment: reactive[Optional[str]] = reactive(None)
36
+ collapsed: reactive[bool] = reactive(False)
37
+
38
+ # Track current sort state
39
+ _sort_column: Optional[str] = None
40
+ _sort_reverse: bool = False
41
+
42
+ # Status sort order (for sorting by status)
43
+ STATUS_ORDER = {
44
+ "failed": 0, # Failed experiments first (need attention)
45
+ "running": 1, # Running experiments
46
+ "done": 2, # Completed experiments
47
+ "empty": 3, # Empty experiments last
48
+ }
49
+
50
+ # Column key to display name mapping
51
+ COLUMN_LABELS = {
52
+ "id": "ID",
53
+ "run": "Run",
54
+ "runs": "#",
55
+ "host": "Host",
56
+ "jobs": "Jobs",
57
+ "status": "Status",
58
+ "started": "Started",
59
+ "duration": "Duration",
60
+ }
61
+
62
+ # Columns that support sorting (column key -> sort column name)
63
+ SORTABLE_COLUMNS = {
64
+ "status": "status",
65
+ "started": "started",
66
+ }
67
+
68
+ def __init__(self, state_provider: StateProvider) -> None:
69
+ super().__init__()
70
+ self.state_provider = state_provider
71
+ self.experiments = []
72
+
73
+ def _get_selected_experiment_id(self) -> Optional[str]:
74
+ """Get the experiment ID from the currently selected row"""
75
+ table = self.query_one("#experiments-table", DataTable)
76
+ if table.cursor_row is None:
77
+ return None
78
+ row_key = list(table.rows.keys())[table.cursor_row]
79
+ if row_key:
80
+ return str(row_key.value)
81
+ return None
82
+
83
+ def action_delete_experiment(self) -> None:
84
+ """Request to delete the selected experiment"""
85
+ exp_id = self._get_selected_experiment_id()
86
+ if exp_id:
87
+ self.post_message(DeleteExperimentRequest(exp_id))
88
+
89
+ def action_kill_experiment(self) -> None:
90
+ """Request to kill all running jobs in the selected experiment"""
91
+ exp_id = self._get_selected_experiment_id()
92
+ if exp_id:
93
+ self.post_message(KillExperimentRequest(exp_id))
94
+
95
+ def action_show_runs(self) -> None:
96
+ """Show runs for the selected experiment"""
97
+ exp_id = self._get_selected_experiment_id()
98
+ if exp_id:
99
+ # Get current run_id for this experiment
100
+ exp_info = next(
101
+ (exp for exp in self.experiments if exp.experiment_id == exp_id),
102
+ None,
103
+ )
104
+ current_run_id = (
105
+ getattr(exp_info, "current_run_id", None) if exp_info else None
106
+ )
107
+ self.post_message(ShowRunsRequest(exp_id, current_run_id))
108
+
109
+ def action_sort_by_status(self) -> None:
110
+ """Sort experiments by status"""
111
+ if self._sort_column == "status":
112
+ self._sort_reverse = not self._sort_reverse
113
+ else:
114
+ self._sort_column = "status"
115
+ self._sort_reverse = False
116
+ self._update_column_headers()
117
+ self.refresh_experiments()
118
+ order = "desc" if self._sort_reverse else "asc"
119
+ self.notify(f"Sorted by status ({order})", severity="information")
120
+
121
+ def action_sort_by_date(self) -> None:
122
+ """Sort experiments by start date"""
123
+ if self._sort_column == "started":
124
+ self._sort_reverse = not self._sort_reverse
125
+ else:
126
+ self._sort_column = "started"
127
+ self._sort_reverse = True # Default to newest first for date
128
+ self._update_column_headers()
129
+ self.refresh_experiments()
130
+ order = "newest first" if self._sort_reverse else "oldest first"
131
+ self.notify(f"Sorted by date ({order})", severity="information")
132
+
133
+ def _get_status_category(self, exp) -> str:
134
+ """Get status category for an experiment (for sorting)"""
135
+ failed = exp.failed_jobs
136
+ total = exp.total_jobs
137
+ finished = exp.finished_jobs
138
+
139
+ if failed > 0:
140
+ return "failed"
141
+ elif finished == total and total > 0:
142
+ return "done"
143
+ elif finished < total:
144
+ return "running"
145
+ else:
146
+ return "empty"
147
+
148
+ def _get_status_sort_key(self, exp):
149
+ """Get sort key for an experiment based on status"""
150
+ status_category = self._get_status_category(exp)
151
+ status_order = self.STATUS_ORDER.get(status_category, 99)
152
+ # Secondary sort by failed count (more failures first)
153
+ failed_count = exp.failed_jobs if status_category == "failed" else 0
154
+ return (status_order, -failed_count)
155
+
156
+ def _update_column_headers(self) -> None:
157
+ """Update column headers with sort indicators"""
158
+ table = self.query_one("#experiments-table", DataTable)
159
+ for column in table.columns.values():
160
+ col_key = str(column.key.value) if column.key else None
161
+ if col_key and col_key in self.COLUMN_LABELS:
162
+ label = self.COLUMN_LABELS[col_key]
163
+ sort_col = self.SORTABLE_COLUMNS.get(col_key)
164
+ if sort_col and self._sort_column == sort_col:
165
+ # Add sort indicator
166
+ indicator = "▼" if self._sort_reverse else "▲"
167
+ new_label = f"{label} {indicator}"
168
+ else:
169
+ new_label = label
170
+ column.label = new_label
171
+
172
+ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
173
+ """Handle column header click for sorting"""
174
+ col_key = str(event.column_key.value) if event.column_key else None
175
+ if col_key and col_key in self.SORTABLE_COLUMNS:
176
+ sort_col = self.SORTABLE_COLUMNS[col_key]
177
+ if self._sort_column == sort_col:
178
+ self._sort_reverse = not self._sort_reverse
179
+ else:
180
+ self._sort_column = sort_col
181
+ # Default to reverse for date (newest first)
182
+ self._sort_reverse = sort_col == "started"
183
+ self._update_column_headers()
184
+ self.refresh_experiments()
185
+
186
+ def compose(self) -> ComposeResult:
187
+ # Collapsed header (hidden initially)
188
+ with Horizontal(id="collapsed-header", classes="hidden"):
189
+ yield Label("", id="collapsed-experiment-info")
190
+
191
+ # Full experiments table
192
+ with Container(id="experiments-table-container"):
193
+ yield Label("Experiments", classes="section-title")
194
+ yield DataTable(id="experiments-table", cursor_type="row")
195
+
196
+ def on_mount(self) -> None:
197
+ """Initialize the experiments table"""
198
+ # Start expanded
199
+ self.add_class("expanded")
200
+
201
+ table = self.query_one("#experiments-table", DataTable)
202
+ table.add_column("ID", key="id")
203
+ table.add_column("Run", key="run")
204
+ table.add_column("#", key="runs", width=3)
205
+ table.add_column("Host", key="host")
206
+ table.add_column("Jobs", key="jobs")
207
+ table.add_column("Status", key="status")
208
+ table.add_column("Started", key="started")
209
+ table.add_column("Duration", key="duration")
210
+ self.refresh_experiments()
211
+
212
+ # If there's only one experiment, automatically select it
213
+ if len(self.experiments) == 1:
214
+ exp = self.experiments[0]
215
+ exp_id = exp.experiment_id
216
+ run_id = getattr(exp, "current_run_id", None)
217
+ self.current_experiment = exp_id
218
+ self.collapse_to_experiment(exp_id)
219
+ self.post_message(ExperimentSelected(exp_id, run_id))
220
+
221
+ def refresh_experiments(self) -> None: # noqa: C901
222
+ """Refresh the experiments list from state provider"""
223
+ # Guard: ensure the table is mounted before querying
224
+ try:
225
+ table = self.query_one("#experiments-table", DataTable)
226
+ except Exception:
227
+ # Widget not yet fully composed, will be called again from on_mount
228
+ return
229
+
230
+ # Guard: ensure columns have been added (on_mount may not have run yet)
231
+ if len(table.columns) == 0:
232
+ return
233
+
234
+ self.log.info(
235
+ f"State provider: {type(self.state_provider).__name__}, is_live={self.state_provider.is_live}"
236
+ )
237
+
238
+ try:
239
+ self.experiments = self.state_provider.get_experiments()
240
+ self.log.debug(
241
+ f"Refreshing experiments: found {len(self.experiments)} experiments"
242
+ )
243
+ except Exception as e:
244
+ self.log.error(f"ERROR refreshing experiments: {e}")
245
+ import traceback
246
+
247
+ self.log.error(traceback.format_exc())
248
+ self.experiments = []
249
+ return
250
+
251
+ # Sort experiments based on selected column
252
+ experiments_sorted = list(self.experiments)
253
+ if self._sort_column == "status":
254
+ experiments_sorted.sort(
255
+ key=self._get_status_sort_key,
256
+ reverse=self._sort_reverse,
257
+ )
258
+ elif self._sort_column == "started":
259
+ # Sort by started time (experiments without start time go to end)
260
+ experiments_sorted.sort(
261
+ key=lambda e: e.started_at or 0,
262
+ reverse=self._sort_reverse,
263
+ )
264
+ # Default: no sorting, use order from state provider
265
+
266
+ # Get existing row keys
267
+ existing_keys = set(table.rows.keys())
268
+ current_exp_ids = set()
269
+
270
+ # Check if we need to rebuild (sort order may have changed)
271
+ current_order = [e.experiment_id for e in experiments_sorted]
272
+ existing_order = [str(k.value) for k in table.rows.keys()]
273
+ needs_rebuild = current_order != existing_order
274
+
275
+ # Build row data for all experiments
276
+ rows_data = {}
277
+ for exp in experiments_sorted:
278
+ exp_id = exp.experiment_id
279
+ current_exp_ids.add(exp_id)
280
+ total = exp.total_jobs
281
+ finished = exp.finished_jobs
282
+ failed = exp.failed_jobs
283
+
284
+ # Determine status
285
+ if failed > 0:
286
+ status = f"❌ {failed} failed"
287
+ elif finished == total and total > 0:
288
+ status = "✓ Done"
289
+ elif finished < total:
290
+ status = f"▶ {finished}/{total}"
291
+ else:
292
+ status = "Empty"
293
+
294
+ jobs_text = f"{finished}/{total}"
295
+
296
+ # Format started time
297
+ if exp.started_at:
298
+ started = datetime.fromtimestamp(exp.started_at).strftime(
299
+ "%Y-%m-%d %H:%M"
300
+ )
301
+ else:
302
+ started = "-"
303
+
304
+ # Calculate duration
305
+ duration = "-"
306
+ if exp.started_at:
307
+ if exp.ended_at:
308
+ elapsed = exp.ended_at - exp.started_at
309
+ else:
310
+ # Still running - show elapsed time
311
+ elapsed = time_module.time() - exp.started_at
312
+ # Format duration
313
+ duration = format_duration(elapsed)
314
+
315
+ # Get hostname (may be None for older experiments)
316
+ hostname = getattr(exp, "hostname", None) or "-"
317
+
318
+ # Get run_id
319
+ run_id = getattr(exp, "current_run_id", None) or "-"
320
+
321
+ # Get runs count for this experiment (only for offline monitoring)
322
+ runs_count = "-"
323
+ if not self.state_provider.is_live:
324
+ try:
325
+ runs = self.state_provider.get_experiment_runs(exp_id)
326
+ runs_count = str(len(runs))
327
+ except Exception as e:
328
+ self.log.error(f"Error getting runs for {exp_id}: {e}")
329
+ import traceback
330
+
331
+ self.log.error(traceback.format_exc())
332
+
333
+ rows_data[exp_id] = (
334
+ exp_id,
335
+ run_id,
336
+ runs_count,
337
+ hostname,
338
+ jobs_text,
339
+ status,
340
+ started,
341
+ duration,
342
+ )
343
+
344
+ if needs_rebuild:
345
+ # Full rebuild needed - save selection, clear, rebuild
346
+ selected_key = None
347
+ if table.cursor_row is not None and table.row_count > 0:
348
+ try:
349
+ row_keys = list(table.rows.keys())
350
+ if table.cursor_row < len(row_keys):
351
+ selected_key = str(row_keys[table.cursor_row].value)
352
+ except (IndexError, KeyError):
353
+ pass
354
+
355
+ table.clear()
356
+ new_cursor_row = None
357
+ for idx, exp in enumerate(experiments_sorted):
358
+ exp_id = exp.experiment_id
359
+ table.add_row(*rows_data[exp_id], key=exp_id)
360
+ if selected_key == exp_id:
361
+ new_cursor_row = idx
362
+
363
+ if new_cursor_row is not None and table.row_count > 0:
364
+ table.move_cursor(row=new_cursor_row)
365
+ else:
366
+ # Update cells in place (no reordering needed)
367
+ for exp_id, row_data in rows_data.items():
368
+ if exp_id in existing_keys:
369
+ (
370
+ _,
371
+ run_id,
372
+ runs_count,
373
+ hostname,
374
+ jobs_text,
375
+ status,
376
+ started,
377
+ duration,
378
+ ) = row_data
379
+ table.update_cell(exp_id, "id", exp_id, update_width=True)
380
+ table.update_cell(exp_id, "run", run_id, update_width=True)
381
+ table.update_cell(exp_id, "runs", runs_count, update_width=True)
382
+ table.update_cell(exp_id, "host", hostname, update_width=True)
383
+ table.update_cell(exp_id, "jobs", jobs_text, update_width=True)
384
+ table.update_cell(exp_id, "status", status, update_width=True)
385
+ table.update_cell(exp_id, "started", started, update_width=True)
386
+ table.update_cell(exp_id, "duration", duration, update_width=True)
387
+ else:
388
+ table.add_row(*row_data, key=exp_id)
389
+
390
+ # Remove rows for experiments that no longer exist
391
+ for old_exp_id in existing_keys - current_exp_ids:
392
+ table.remove_row(old_exp_id)
393
+
394
+ # Update collapsed header if viewing an experiment
395
+ if self.collapsed and self.current_experiment:
396
+ self._update_collapsed_header(self.current_experiment)
397
+
398
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
399
+ """Handle experiment selection"""
400
+ if event.row_key:
401
+ exp_id = str(event.row_key.value)
402
+ self.current_experiment = exp_id
403
+ self.collapse_to_experiment(self.current_experiment)
404
+ # Find run_id from experiments list
405
+ exp_info = next(
406
+ (exp for exp in self.experiments if exp.experiment_id == exp_id),
407
+ None,
408
+ )
409
+ run_id = getattr(exp_info, "current_run_id", None) if exp_info else None
410
+ self.post_message(ExperimentSelected(exp_id, run_id))
411
+
412
+ def _update_collapsed_header(self, experiment_id: str) -> None:
413
+ """Update the collapsed experiment header with current stats"""
414
+ exp_info = next(
415
+ (exp for exp in self.experiments if exp.experiment_id == experiment_id),
416
+ None,
417
+ )
418
+ if not exp_info:
419
+ return
420
+
421
+ total = exp_info.total_jobs
422
+ finished = exp_info.finished_jobs
423
+ failed = exp_info.failed_jobs
424
+ run_id = getattr(exp_info, "current_run_id", None)
425
+
426
+ if failed > 0:
427
+ status = f"❌ {failed} failed"
428
+ elif finished == total and total > 0:
429
+ status = "✓ Done"
430
+ elif finished < total:
431
+ status = f"▶ {finished}/{total}"
432
+ else:
433
+ status = "Empty"
434
+
435
+ collapsed_label = self.query_one("#collapsed-experiment-info", Label)
436
+ run_text = f" [{run_id}]" if run_id else ""
437
+ collapsed_label.update(
438
+ f"📊 {experiment_id}{run_text} - {status} (click to go back)"
439
+ )
440
+
441
+ def collapse_to_experiment(self, experiment_id: str) -> None:
442
+ """Collapse the experiments list to show only the selected experiment"""
443
+ self._update_collapsed_header(experiment_id)
444
+
445
+ # Hide table, show collapsed header
446
+ self.query_one("#experiments-table-container").add_class("hidden")
447
+ self.query_one("#collapsed-header").remove_class("hidden")
448
+ self.collapsed = True
449
+ self.remove_class("expanded")
450
+
451
+ def expand_experiments(self) -> None:
452
+ """Expand back to full experiments list"""
453
+ # Show table, hide collapsed header
454
+ self.query_one("#collapsed-header").add_class("hidden")
455
+ self.query_one("#experiments-table-container").remove_class("hidden")
456
+ self.collapsed = False
457
+ self.current_experiment = None
458
+ self.add_class("expanded")
459
+
460
+ # Focus the experiments table
461
+ table = self.query_one("#experiments-table", DataTable)
462
+ table.focus()
463
+
464
+ def on_click(self) -> None:
465
+ """Handle clicks on the widget"""
466
+ if self.collapsed:
467
+ self.expand_experiments()
468
+ self.post_message(ExperimentDeselected())