experimaestro 2.0.0b8__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.
- experimaestro/__init__.py +12 -5
- experimaestro/cli/__init__.py +239 -126
- experimaestro/cli/filter.py +48 -23
- experimaestro/cli/jobs.py +253 -71
- experimaestro/cli/refactor.py +1 -2
- experimaestro/commandline.py +7 -4
- experimaestro/connectors/__init__.py +9 -1
- experimaestro/connectors/local.py +43 -3
- experimaestro/core/arguments.py +18 -18
- experimaestro/core/identifier.py +11 -11
- experimaestro/core/objects/config.py +96 -39
- experimaestro/core/objects/config_walk.py +3 -3
- experimaestro/core/{subparameters.py → partial.py} +16 -16
- experimaestro/core/partial_lock.py +394 -0
- experimaestro/core/types.py +12 -15
- experimaestro/dynamic.py +290 -0
- experimaestro/experiments/__init__.py +6 -2
- experimaestro/experiments/cli.py +217 -50
- experimaestro/experiments/configuration.py +24 -0
- experimaestro/generators.py +5 -5
- experimaestro/ipc.py +118 -1
- experimaestro/launcherfinder/__init__.py +2 -2
- experimaestro/launcherfinder/registry.py +6 -7
- experimaestro/launcherfinder/specs.py +2 -9
- experimaestro/launchers/slurm/__init__.py +2 -2
- experimaestro/launchers/slurm/base.py +62 -0
- experimaestro/locking.py +957 -1
- experimaestro/notifications.py +89 -201
- experimaestro/progress.py +63 -366
- experimaestro/rpyc.py +0 -2
- experimaestro/run.py +29 -2
- experimaestro/scheduler/__init__.py +8 -1
- experimaestro/scheduler/base.py +629 -53
- experimaestro/scheduler/dependencies.py +20 -16
- experimaestro/scheduler/experiment.py +732 -167
- experimaestro/scheduler/interfaces.py +316 -101
- experimaestro/scheduler/jobs.py +58 -20
- experimaestro/scheduler/remote/adaptive_sync.py +265 -0
- experimaestro/scheduler/remote/client.py +171 -117
- experimaestro/scheduler/remote/protocol.py +8 -193
- experimaestro/scheduler/remote/server.py +95 -71
- experimaestro/scheduler/services.py +53 -28
- experimaestro/scheduler/state_provider.py +663 -2430
- experimaestro/scheduler/state_status.py +1247 -0
- experimaestro/scheduler/transient.py +31 -0
- experimaestro/scheduler/workspace.py +1 -1
- experimaestro/scheduler/workspace_state_provider.py +1273 -0
- experimaestro/scriptbuilder.py +4 -4
- experimaestro/settings.py +36 -0
- experimaestro/tests/conftest.py +33 -5
- experimaestro/tests/connectors/bin/executable.py +1 -1
- experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
- experimaestro/tests/launchers/bin/test.py +1 -0
- experimaestro/tests/launchers/test_slurm.py +9 -9
- experimaestro/tests/partial_reschedule.py +46 -0
- experimaestro/tests/restart.py +3 -3
- experimaestro/tests/restart_main.py +1 -0
- experimaestro/tests/scripts/notifyandwait.py +1 -0
- experimaestro/tests/task_partial.py +38 -0
- experimaestro/tests/task_tokens.py +2 -2
- experimaestro/tests/tasks/test_dynamic.py +6 -6
- experimaestro/tests/test_dependencies.py +3 -3
- experimaestro/tests/test_deprecated.py +15 -15
- experimaestro/tests/test_dynamic_locking.py +317 -0
- experimaestro/tests/test_environment.py +24 -14
- experimaestro/tests/test_experiment.py +171 -36
- experimaestro/tests/test_identifier.py +25 -25
- experimaestro/tests/test_identifier_stability.py +3 -5
- experimaestro/tests/test_multitoken.py +2 -4
- experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
- experimaestro/tests/test_partial_paths.py +81 -138
- experimaestro/tests/test_pre_experiment.py +219 -0
- experimaestro/tests/test_progress.py +2 -8
- experimaestro/tests/test_remote_state.py +560 -99
- experimaestro/tests/test_stray_jobs.py +261 -0
- experimaestro/tests/test_tasks.py +1 -2
- experimaestro/tests/test_token_locking.py +52 -67
- experimaestro/tests/test_tokens.py +5 -6
- experimaestro/tests/test_transient.py +225 -0
- experimaestro/tests/test_workspace_state_provider.py +768 -0
- experimaestro/tests/token_reschedule.py +1 -3
- experimaestro/tests/utils.py +2 -7
- experimaestro/tokens.py +227 -372
- experimaestro/tools/diff.py +1 -0
- experimaestro/tools/documentation.py +4 -5
- experimaestro/tools/jobs.py +1 -2
- experimaestro/tui/app.py +438 -1966
- experimaestro/tui/app.tcss +162 -0
- experimaestro/tui/dialogs.py +172 -0
- experimaestro/tui/log_viewer.py +253 -3
- experimaestro/tui/messages.py +137 -0
- experimaestro/tui/utils.py +54 -0
- experimaestro/tui/widgets/__init__.py +23 -0
- experimaestro/tui/widgets/experiments.py +468 -0
- experimaestro/tui/widgets/global_services.py +238 -0
- experimaestro/tui/widgets/jobs.py +972 -0
- experimaestro/tui/widgets/log.py +156 -0
- experimaestro/tui/widgets/orphans.py +363 -0
- experimaestro/tui/widgets/runs.py +185 -0
- experimaestro/tui/widgets/services.py +314 -0
- experimaestro/tui/widgets/stray_jobs.py +528 -0
- experimaestro/utils/__init__.py +1 -1
- experimaestro/utils/environment.py +105 -22
- experimaestro/utils/fswatcher.py +124 -0
- experimaestro/utils/jobs.py +1 -2
- experimaestro/utils/jupyter.py +1 -2
- experimaestro/utils/logging.py +72 -0
- experimaestro/version.py +2 -2
- experimaestro/webui/__init__.py +9 -0
- experimaestro/webui/app.py +117 -0
- experimaestro/{server → webui}/data/index.css +66 -11
- experimaestro/webui/data/index.css.map +1 -0
- experimaestro/{server → webui}/data/index.js +82763 -87217
- experimaestro/webui/data/index.js.map +1 -0
- experimaestro/webui/routes/__init__.py +5 -0
- experimaestro/webui/routes/auth.py +53 -0
- experimaestro/webui/routes/proxy.py +117 -0
- experimaestro/webui/server.py +200 -0
- experimaestro/webui/state_bridge.py +152 -0
- experimaestro/webui/websocket.py +413 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
- experimaestro-2.0.0b17.dist-info/RECORD +219 -0
- experimaestro/cli/progress.py +0 -269
- experimaestro/scheduler/state.py +0 -75
- experimaestro/scheduler/state_db.py +0 -437
- experimaestro/scheduler/state_sync.py +0 -891
- experimaestro/server/__init__.py +0 -467
- experimaestro/server/data/index.css.map +0 -1
- experimaestro/server/data/index.js.map +0 -1
- experimaestro/tests/test_cli_jobs.py +0 -615
- experimaestro/tests/test_file_progress.py +0 -425
- experimaestro/tests/test_file_progress_integration.py +0 -477
- experimaestro/tests/test_state_db.py +0 -434
- experimaestro-2.0.0b8.dist-info/RECORD +0 -187
- /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
- /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
- /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
- /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
- /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
- /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
- /experimaestro/{server → webui}/data/favicon.ico +0 -0
- /experimaestro/{server → webui}/data/index.html +0 -0
- /experimaestro/{server → webui}/data/login.html +0 -0
- /experimaestro/{server → webui}/data/manifest.json +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
- {experimaestro-2.0.0b8.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()
|