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,156 @@
1
+ """Log capture widget for the TUI"""
2
+
3
+ from pathlib import Path
4
+ from textual import events
5
+ from textual.widgets import RichLog
6
+ from textual.binding import Binding
7
+ from rich.text import Text
8
+
9
+
10
+ class CaptureLog(RichLog):
11
+ """Custom RichLog widget that captures print statements with log highlighting
12
+
13
+ Features:
14
+ - Captures print statements with log level highlighting
15
+ - Toggle follow mode (auto-scroll) with Ctrl+F
16
+ - Save log to file with Ctrl+S
17
+ - Copy log to clipboard with Ctrl+Y
18
+ - Tracks unread lines when tab is not visible
19
+ """
20
+
21
+ BINDINGS = [
22
+ Binding("ctrl+f", "toggle_follow", "Follow"),
23
+ Binding("ctrl+s", "save_log", "Save"),
24
+ Binding("ctrl+y", "copy_log", "Copy"),
25
+ ]
26
+
27
+ def __init__(self, *args, **kwargs) -> None:
28
+ super().__init__(*args, **kwargs)
29
+ self._has_unread = False
30
+
31
+ @property
32
+ def has_unread(self) -> bool:
33
+ """Whether there are unread log lines"""
34
+ return self._has_unread
35
+
36
+ def mark_as_read(self) -> None:
37
+ """Mark all log lines as read"""
38
+ self._has_unread = False
39
+ self._update_tab_title()
40
+
41
+ def _update_tab_title(self) -> None:
42
+ """Update the Logs tab title to show unread indicator"""
43
+ try:
44
+ self.app.update_logs_tab_title()
45
+ except Exception:
46
+ pass
47
+
48
+ def on_mount(self) -> None:
49
+ """Enable print capturing when widget is mounted"""
50
+ self.begin_capture_print()
51
+
52
+ def on_unmount(self) -> None:
53
+ """Stop print capturing when widget is unmounted"""
54
+ self.end_capture_print()
55
+
56
+ def _format_log_line(self, text: str) -> Text:
57
+ """Format a log line with appropriate styling based on log level"""
58
+ result = Text()
59
+
60
+ # Check for common log level patterns
61
+ if text.startswith("ERROR:") or ":ERROR:" in text:
62
+ result.append(text, style="bold red")
63
+ elif text.startswith("WARNING:") or ":WARNING:" in text:
64
+ result.append(text, style="yellow")
65
+ elif text.startswith("INFO:") or ":INFO:" in text:
66
+ result.append(text, style="green")
67
+ elif text.startswith("DEBUG:") or ":DEBUG:" in text:
68
+ result.append(text, style="dim")
69
+ elif text.startswith("CRITICAL:") or ":CRITICAL:" in text:
70
+ result.append(text, style="bold white on red")
71
+ else:
72
+ result.append(text)
73
+
74
+ return result
75
+
76
+ def _is_logs_tab_active(self) -> bool:
77
+ """Check if the Logs tab is currently active"""
78
+ try:
79
+ from textual.widgets import TabbedContent
80
+
81
+ tabs = self.app.query_one("#main-tabs", TabbedContent)
82
+ return tabs.active == "logs-tab"
83
+ except Exception:
84
+ return False
85
+
86
+ def on_print(self, event: events.Print) -> None:
87
+ """Handle print events from captured stdout/stderr"""
88
+ if text := event.text.strip():
89
+ self.write(self._format_log_line(text))
90
+ # Mark as unread only if Logs tab is not active
91
+ if not self._has_unread and not self._is_logs_tab_active():
92
+ self._has_unread = True
93
+ self._update_tab_title()
94
+
95
+ def action_toggle_follow(self) -> None:
96
+ """Toggle auto-scroll (follow) mode"""
97
+ self.auto_scroll = not self.auto_scroll
98
+ status = "enabled" if self.auto_scroll else "disabled"
99
+ self.notify(f"Follow mode {status}")
100
+
101
+ def action_save_log(self) -> None:
102
+ """Save log content to a file"""
103
+ from datetime import datetime
104
+
105
+ # Generate default filename with timestamp
106
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
107
+ default_filename = f"experiment_log_{timestamp}.txt"
108
+
109
+ # Get log content from RichLog's lines (Strip objects have .text property)
110
+ try:
111
+ content = self._get_log_content()
112
+ except Exception as e:
113
+ self.notify(f"Failed to get log content: {e}", severity="error")
114
+ return
115
+
116
+ if not content:
117
+ self.notify("No log content to save", severity="warning")
118
+ return
119
+
120
+ # Try to save to current directory
121
+ try:
122
+ filepath = Path(default_filename)
123
+ filepath.write_text(content)
124
+ path_str = str(filepath.absolute())
125
+ # Copy path to clipboard
126
+ try:
127
+ import pyperclip
128
+
129
+ pyperclip.copy(path_str)
130
+ self.notify(f"Log saved and path copied: {path_str}")
131
+ except Exception:
132
+ # Clipboard may not be available (headless systems)
133
+ self.notify(f"Log saved to {path_str}", timeout=60)
134
+ except Exception as e:
135
+ self.notify(f"Failed to save log: {e}", severity="error")
136
+
137
+ def action_copy_log(self) -> None:
138
+ """Copy log content to clipboard"""
139
+ content = self._get_log_content()
140
+ if not content:
141
+ self.notify("No log content to copy", severity="warning")
142
+ return
143
+
144
+ try:
145
+ import pyperclip
146
+
147
+ pyperclip.copy(content)
148
+ line_count = len(self.lines)
149
+ self.notify(f"Copied {line_count} lines to clipboard")
150
+ except Exception as e:
151
+ self.notify(f"Failed to copy: {e}", severity="error")
152
+
153
+ def _get_log_content(self) -> str:
154
+ """Get the full log content as plain text from RichLog's lines"""
155
+ # self.lines is a list of Strip objects, each has a .text property
156
+ return "\n".join(line.text for line in self.lines)
@@ -0,0 +1,363 @@
1
+ """Orphan jobs screen for the TUI (legacy modal screen)"""
2
+
3
+ from typing import Optional
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Header, Footer, DataTable, Static
7
+ from textual.screen import Screen
8
+ from textual.binding import Binding
9
+
10
+ from experimaestro.scheduler.state_provider import StateProvider
11
+ from experimaestro.tui.utils import get_status_icon
12
+ from experimaestro.tui.dialogs import DeleteConfirmScreen
13
+ from experimaestro.tui.messages import SizeCalculated
14
+
15
+
16
+ class OrphanJobsScreen(Screen):
17
+ """Screen for viewing and managing orphan jobs (legacy modal screen)"""
18
+
19
+ BINDINGS = [
20
+ Binding("ctrl+d", "delete_selected", "Delete"),
21
+ Binding("ctrl+shift+d", "delete_all", "Delete All", key_display="^D"),
22
+ Binding("escape", "go_back", "Back"),
23
+ Binding("q", "go_back", "Quit"),
24
+ Binding("r", "refresh", "Refresh"),
25
+ Binding("f", "copy_path", "Copy Path", show=False),
26
+ Binding("T", "sort_by_task", "Sort Task", show=False),
27
+ Binding("Z", "sort_by_size", "Sort Size", show=False),
28
+ ]
29
+
30
+ _size_cache: dict = {} # Class-level cache (formatted strings)
31
+ _size_bytes_cache: dict = {} # Class-level cache (raw bytes for sorting)
32
+
33
+ def __init__(self, state_provider: StateProvider) -> None:
34
+ super().__init__()
35
+ self.state_provider = state_provider
36
+ self.orphan_jobs = []
37
+ self._pending_jobs = [] # Jobs waiting for size calculation
38
+ self._sort_column: Optional[str] = None
39
+ self._sort_reverse: bool = False
40
+
41
+ def compose(self) -> ComposeResult:
42
+ yield Header()
43
+ with Vertical(id="orphan-container"):
44
+ yield Static("Orphan Jobs", id="orphan-title")
45
+ yield Static("", id="orphan-stats")
46
+ yield DataTable(id="orphan-table", cursor_type="row")
47
+ yield Static("", id="orphan-job-info")
48
+ yield Footer()
49
+
50
+ def on_mount(self) -> None:
51
+ """Initialize the orphan jobs table"""
52
+ table = self.query_one("#orphan-table", DataTable)
53
+ table.add_column("", key="status", width=3)
54
+ table.add_column("Job ID", key="job_id", width=10)
55
+ table.add_column("Task", key="task")
56
+ table.add_column("Size", key="size", width=10)
57
+ self.refresh_orphans()
58
+
59
+ def action_sort_by_task(self) -> None:
60
+ """Sort by task name"""
61
+ if self._sort_column == "task":
62
+ self._sort_reverse = not self._sort_reverse
63
+ else:
64
+ self._sort_column = "task"
65
+ self._sort_reverse = False
66
+ self._rebuild_table()
67
+ order = "desc" if self._sort_reverse else "asc"
68
+ self.notify(f"Sorted by task ({order})", severity="information")
69
+
70
+ def action_sort_by_size(self) -> None:
71
+ """Sort by size"""
72
+ if self._sort_column == "size":
73
+ self._sort_reverse = not self._sort_reverse
74
+ else:
75
+ self._sort_column = "size"
76
+ self._sort_reverse = True # Default: largest first
77
+ self._rebuild_table()
78
+ order = "largest first" if self._sort_reverse else "smallest first"
79
+ self.notify(f"Sorted by size ({order})", severity="information")
80
+
81
+ def _get_sorted_jobs(self):
82
+ """Return jobs sorted by current sort column"""
83
+ jobs = self.orphan_jobs[:]
84
+ if self._sort_column == "task":
85
+ jobs.sort(key=lambda j: j.task_id or "", reverse=self._sort_reverse)
86
+ elif self._sort_column == "size":
87
+ # Sort by raw bytes, jobs not in cache go to end
88
+ jobs.sort(
89
+ key=lambda j: self._size_bytes_cache.get(j.identifier, -1),
90
+ reverse=self._sort_reverse,
91
+ )
92
+ return jobs
93
+
94
+ def _rebuild_table(self) -> None:
95
+ """Rebuild the table with current sort order"""
96
+ table = self.query_one("#orphan-table", DataTable)
97
+ table.clear()
98
+
99
+ for job in self._get_sorted_jobs():
100
+ failure_reason = getattr(job, "failure_reason", None)
101
+ transient = getattr(job, "transient", None)
102
+ status_icon = get_status_icon(
103
+ job.state.name if job.state else "unknown", failure_reason, transient
104
+ )
105
+ if job.identifier in self._size_cache:
106
+ size_text = self._size_cache[job.identifier]
107
+ else:
108
+ size_text = "waiting"
109
+ table.add_row(
110
+ status_icon,
111
+ job.identifier[:7],
112
+ job.task_id,
113
+ size_text,
114
+ key=job.identifier,
115
+ )
116
+
117
+ def refresh_orphans(self) -> None:
118
+ """Refresh the orphan jobs list"""
119
+ # Only include orphan jobs that have an existing folder
120
+ all_orphans = self.state_provider.get_orphan_jobs()
121
+ self.orphan_jobs = [j for j in all_orphans if j.path and j.path.exists()]
122
+
123
+ # Update stats
124
+ stats = self.query_one("#orphan-stats", Static)
125
+ stats.update(f"Found {len(self.orphan_jobs)} orphan jobs")
126
+
127
+ # Collect jobs needing size calculation
128
+ self._pending_jobs = [
129
+ j for j in self.orphan_jobs if j.identifier not in self._size_cache
130
+ ]
131
+
132
+ # Rebuild table
133
+ self._rebuild_table()
134
+
135
+ # Start calculating sizes
136
+ if self._pending_jobs:
137
+ self._calculate_next_size()
138
+
139
+ def _calculate_next_size(self) -> None:
140
+ """Calculate size for the next pending job using a worker"""
141
+ if not self._pending_jobs:
142
+ return
143
+
144
+ job = self._pending_jobs.pop(0)
145
+ # Update to "calc..."
146
+ self._update_size_cell(job.identifier, "calc...")
147
+ # Run calculation in worker thread
148
+ self.run_worker(
149
+ self._calc_size_worker(job.identifier, job.path),
150
+ thread=True,
151
+ )
152
+
153
+ async def _calc_size_worker(self, job_id: str, path):
154
+ """Worker to calculate folder size"""
155
+ size_bytes = await self._get_folder_size_async(path)
156
+ size_str = self._format_size(size_bytes)
157
+ self._size_cache[job_id] = size_str
158
+ self._size_bytes_cache[job_id] = size_bytes
159
+ self.post_message(SizeCalculated(job_id, size_str, size_bytes))
160
+
161
+ def on_size_calculated(self, message: SizeCalculated) -> None:
162
+ """Handle size calculation completion"""
163
+ self._size_bytes_cache[message.job_id] = message.size_bytes
164
+ self._update_size_cell(message.job_id, message.size)
165
+ # Calculate next one
166
+ self._calculate_next_size()
167
+
168
+ @staticmethod
169
+ async def _get_folder_size_async(path) -> int:
170
+ """Calculate total size of a folder using du command if available"""
171
+ import asyncio
172
+ import shutil
173
+ import sys
174
+
175
+ # Try using du command for better performance
176
+ if shutil.which("du"):
177
+ try:
178
+ if sys.platform == "darwin":
179
+ # macOS: du -sk gives size in KB
180
+ proc = await asyncio.create_subprocess_exec(
181
+ "du",
182
+ "-sk",
183
+ str(path),
184
+ stdout=asyncio.subprocess.PIPE,
185
+ stderr=asyncio.subprocess.DEVNULL,
186
+ )
187
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
188
+ if proc.returncode == 0 and stdout:
189
+ # Output format: "SIZE\tPATH"
190
+ size_kb = int(stdout.decode().split()[0])
191
+ return size_kb * 1024
192
+ else:
193
+ # Linux: du -sb gives size in bytes
194
+ proc = await asyncio.create_subprocess_exec(
195
+ "du",
196
+ "-sb",
197
+ str(path),
198
+ stdout=asyncio.subprocess.PIPE,
199
+ stderr=asyncio.subprocess.DEVNULL,
200
+ )
201
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
202
+ if proc.returncode == 0 and stdout:
203
+ # Output format: "SIZE\tPATH"
204
+ return int(stdout.decode().split()[0])
205
+ except (asyncio.TimeoutError, ValueError, IndexError, OSError):
206
+ pass # Fall back to Python implementation
207
+
208
+ # Fallback: Python implementation
209
+ return OrphanJobsScreen._get_folder_size_sync(path)
210
+
211
+ @staticmethod
212
+ def _get_folder_size_sync(path) -> int:
213
+ """Calculate total size of a folder using Python (fallback)"""
214
+ total = 0
215
+ try:
216
+ for entry in path.rglob("*"):
217
+ if entry.is_file():
218
+ total += entry.stat().st_size
219
+ except (OSError, PermissionError):
220
+ pass
221
+ return total
222
+
223
+ @staticmethod
224
+ def _format_size(size: int) -> str:
225
+ """Format size in human-readable format"""
226
+ for unit in ["B", "KB", "MB", "GB"]:
227
+ if size < 1024:
228
+ return f"{size:.1f}{unit}" if unit != "B" else f"{size}{unit}"
229
+ size /= 1024
230
+ return f"{size:.1f}TB"
231
+
232
+ def _update_size_cell(self, job_id: str, value: str = None) -> None:
233
+ """Update the size cell for a job"""
234
+ try:
235
+ table = self.query_one("#orphan-table", DataTable)
236
+ size_text = (
237
+ value if value is not None else self._size_cache.get(job_id, "-")
238
+ )
239
+ table.update_cell(job_id, "size", size_text)
240
+ except Exception:
241
+ pass # Table may have changed
242
+
243
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
244
+ """Show job details when a row is selected"""
245
+ self._update_job_info()
246
+
247
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
248
+ """Show job details when cursor moves"""
249
+ self._update_job_info()
250
+
251
+ def _update_job_info(self) -> None:
252
+ """Update the job info display"""
253
+ table = self.query_one("#orphan-table", DataTable)
254
+ info = self.query_one("#orphan-job-info", Static)
255
+
256
+ if table.cursor_row is None:
257
+ info.update("")
258
+ return
259
+
260
+ row_key = list(table.rows.keys())[table.cursor_row]
261
+ if row_key:
262
+ job_id = str(row_key.value)
263
+ job = next((j for j in self.orphan_jobs if j.identifier == job_id), None)
264
+ if job and job.path:
265
+ size = self._size_cache.get(job.identifier, "calculating...")
266
+ info.update(f"Path: {job.path} | Size: {size}")
267
+ else:
268
+ info.update("")
269
+
270
+ def action_copy_path(self) -> None:
271
+ """Copy the job folder path to clipboard"""
272
+ import pyperclip
273
+
274
+ table = self.query_one("#orphan-table", DataTable)
275
+ if table.cursor_row is None:
276
+ return
277
+
278
+ row_key = list(table.rows.keys())[table.cursor_row]
279
+ if row_key:
280
+ job_id = str(row_key.value)
281
+ job = next((j for j in self.orphan_jobs if j.identifier == job_id), None)
282
+ if job and job.path:
283
+ try:
284
+ pyperclip.copy(str(job.path))
285
+ self.notify("Path copied", severity="information")
286
+ except Exception as e:
287
+ self.notify(f"Failed to copy: {e}", severity="error")
288
+
289
+ def action_delete_selected(self) -> None:
290
+ """Delete the selected orphan job"""
291
+ table = self.query_one("#orphan-table", DataTable)
292
+ if table.cursor_row is None:
293
+ return
294
+
295
+ row_key = list(table.rows.keys())[table.cursor_row]
296
+ if row_key:
297
+ job_id = str(row_key.value)
298
+ job = next((j for j in self.orphan_jobs if j.identifier == job_id), None)
299
+ if job:
300
+ self._delete_job(job)
301
+
302
+ def _delete_job(self, job) -> None:
303
+ """Delete a single orphan job with confirmation"""
304
+
305
+ def handle_delete(confirmed: bool) -> None:
306
+ if confirmed:
307
+ success, msg = self.state_provider.delete_job_safely(job)
308
+ if success:
309
+ self.notify(msg, severity="information")
310
+ self.refresh_orphans()
311
+ else:
312
+ self.notify(msg, severity="error")
313
+
314
+ self.app.push_screen(
315
+ DeleteConfirmScreen("orphan job", job.identifier),
316
+ handle_delete,
317
+ )
318
+
319
+ def action_delete_all(self) -> None:
320
+ """Delete all orphan jobs"""
321
+ if not self.orphan_jobs:
322
+ self.notify("No orphan jobs to delete", severity="warning")
323
+ return
324
+
325
+ # Filter out running jobs
326
+ deletable_jobs = [j for j in self.orphan_jobs if not j.state.running()]
327
+
328
+ if not deletable_jobs:
329
+ self.notify("All orphan jobs are running", severity="warning")
330
+ return
331
+
332
+ def handle_delete_all(confirmed: bool) -> None:
333
+ if confirmed:
334
+ deleted = 0
335
+ for job in deletable_jobs:
336
+ success, _ = self.state_provider.delete_job_safely(
337
+ job, cascade_orphans=False
338
+ )
339
+ if success:
340
+ deleted += 1
341
+
342
+ # Clean up orphan partials once at the end
343
+ self.state_provider.cleanup_orphan_partials(perform=True)
344
+
345
+ self.notify(f"Deleted {deleted} orphan jobs", severity="information")
346
+ self.refresh_orphans()
347
+
348
+ self.app.push_screen(
349
+ DeleteConfirmScreen(
350
+ "all orphan jobs",
351
+ f"{len(deletable_jobs)} jobs",
352
+ "This action cannot be undone",
353
+ ),
354
+ handle_delete_all,
355
+ )
356
+
357
+ def action_refresh(self) -> None:
358
+ """Refresh the orphan jobs list"""
359
+ self.refresh_orphans()
360
+
361
+ def action_go_back(self) -> None:
362
+ """Go back to main screen"""
363
+ self.dismiss()