hpc-runner 0.1.1__py3-none-any.whl → 0.2.1__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.
Files changed (43) hide show
  1. hpc_runner/_version.py +2 -2
  2. hpc_runner/cli/cancel.py +1 -1
  3. hpc_runner/cli/config.py +2 -2
  4. hpc_runner/cli/main.py +17 -13
  5. hpc_runner/cli/monitor.py +30 -0
  6. hpc_runner/cli/run.py +223 -67
  7. hpc_runner/cli/status.py +6 -5
  8. hpc_runner/core/__init__.py +30 -0
  9. hpc_runner/core/descriptors.py +87 -33
  10. hpc_runner/core/exceptions.py +9 -0
  11. hpc_runner/core/job.py +272 -93
  12. hpc_runner/core/job_info.py +104 -0
  13. hpc_runner/core/result.py +4 -0
  14. hpc_runner/schedulers/base.py +148 -30
  15. hpc_runner/schedulers/detection.py +22 -4
  16. hpc_runner/schedulers/local/scheduler.py +119 -2
  17. hpc_runner/schedulers/sge/args.py +161 -94
  18. hpc_runner/schedulers/sge/parser.py +106 -13
  19. hpc_runner/schedulers/sge/scheduler.py +727 -171
  20. hpc_runner/schedulers/sge/templates/batch.sh.j2 +82 -0
  21. hpc_runner/schedulers/sge/templates/interactive.sh.j2 +78 -0
  22. hpc_runner/tui/__init__.py +5 -0
  23. hpc_runner/tui/app.py +436 -0
  24. hpc_runner/tui/components/__init__.py +17 -0
  25. hpc_runner/tui/components/detail_panel.py +187 -0
  26. hpc_runner/tui/components/filter_bar.py +174 -0
  27. hpc_runner/tui/components/filter_popup.py +345 -0
  28. hpc_runner/tui/components/job_table.py +260 -0
  29. hpc_runner/tui/providers/__init__.py +5 -0
  30. hpc_runner/tui/providers/jobs.py +197 -0
  31. hpc_runner/tui/screens/__init__.py +7 -0
  32. hpc_runner/tui/screens/confirm.py +67 -0
  33. hpc_runner/tui/screens/job_details.py +210 -0
  34. hpc_runner/tui/screens/log_viewer.py +170 -0
  35. hpc_runner/tui/snapshot.py +153 -0
  36. hpc_runner/tui/styles/monitor.tcss +567 -0
  37. hpc_runner-0.2.1.dist-info/METADATA +285 -0
  38. hpc_runner-0.2.1.dist-info/RECORD +56 -0
  39. hpc_runner/schedulers/sge/templates/job.sh.j2 +0 -39
  40. hpc_runner-0.1.1.dist-info/METADATA +0 -46
  41. hpc_runner-0.1.1.dist-info/RECORD +0 -38
  42. {hpc_runner-0.1.1.dist-info → hpc_runner-0.2.1.dist-info}/WHEEL +0 -0
  43. {hpc_runner-0.1.1.dist-info → hpc_runner-0.2.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,210 @@
1
+ """Job details modal screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Vertical
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Static, TextArea
12
+
13
+ if TYPE_CHECKING:
14
+ from hpc_runner.core.job_info import JobInfo
15
+
16
+
17
+ class JobDetailsScreen(ModalScreen[None]):
18
+ """Modal screen for viewing full job details.
19
+
20
+ Displays comprehensive job information including all resource requests,
21
+ paths, dependencies, and other metadata.
22
+ """
23
+
24
+ BINDINGS = [
25
+ Binding("escape", "close", "Close"),
26
+ Binding("q", "close", "Close"),
27
+ Binding("g", "go_top", "Top", show=False),
28
+ Binding("G", "go_bottom", "Bottom", show=False),
29
+ Binding("s", "screenshot", "Screenshot", show=False),
30
+ ]
31
+
32
+ def __init__(
33
+ self,
34
+ job: JobInfo,
35
+ extra_details: dict[str, Any] | None = None,
36
+ **kwargs: Any,
37
+ ) -> None:
38
+ """Initialize the job details screen.
39
+
40
+ Args:
41
+ job: The JobInfo object to display.
42
+ extra_details: Additional details from qstat -j (resources, etc.)
43
+ """
44
+ super().__init__(**kwargs)
45
+ self._job = job
46
+ self._extra = extra_details or {}
47
+
48
+ def compose(self) -> ComposeResult:
49
+ """Create the modal content."""
50
+ with Vertical(id="job-details-dialog"):
51
+ yield TextArea(id="job-details-content", read_only=True)
52
+ yield Static("q/Esc close | g top | G bottom", id="job-details-hint")
53
+
54
+ def on_mount(self) -> None:
55
+ """Set up the dialog with job content."""
56
+ dialog = self.query_one("#job-details-dialog", Vertical)
57
+ # Keep title short to avoid truncation
58
+ dialog.border_title = f"Job: {self._job.job_id}"
59
+
60
+ # Build and display content
61
+ content = self._build_content()
62
+ text_area = self.query_one("#job-details-content", TextArea)
63
+ text_area.load_text(content)
64
+ text_area.focus()
65
+
66
+ def _build_content(self) -> str:
67
+ """Build the formatted job details content."""
68
+ job = self._job
69
+ lines: list[str] = []
70
+ resources = self._extra.get("resources", {})
71
+
72
+ # Section: Basic Info
73
+ lines.append("═══ Basic Information ═══")
74
+ lines.append("")
75
+ lines.append(f" Job ID: {job.job_id}")
76
+ lines.append(f" Name: {job.name}")
77
+ lines.append(f" User: {job.user}")
78
+ lines.append(f" Status: {job.status.name}")
79
+ lines.append(f" Queue: {job.queue or '—'}")
80
+ lines.append(f" Node: {job.node or '—'}")
81
+ lines.append("")
82
+
83
+ # Section: Command
84
+ job_args = self._extra.get("job_args", [])
85
+ script = self._extra.get("script_file")
86
+ command = self._extra.get("command") # For qrsh interactive jobs
87
+ if job_args or script or command:
88
+ lines.append("═══ Command ═══")
89
+ lines.append("")
90
+ if command:
91
+ # Interactive job command (from QRSH_COMMAND)
92
+ lines.append(f" Command: {command}")
93
+ elif script:
94
+ lines.append(f" Script: {script}")
95
+ if job_args:
96
+ lines.append(f" Arguments: {' '.join(job_args)}")
97
+ lines.append("")
98
+
99
+ # Section: Timing
100
+ lines.append("═══ Timing ═══")
101
+ lines.append("")
102
+ if job.submit_time:
103
+ lines.append(f" Submitted: {job.submit_time.strftime('%Y-%m-%d %H:%M:%S')}")
104
+ else:
105
+ lines.append(" Submitted: —")
106
+ if job.start_time:
107
+ lines.append(f" Started: {job.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
108
+ else:
109
+ lines.append(" Started: —")
110
+ if job.end_time:
111
+ lines.append(f" Ended: {job.end_time.strftime('%Y-%m-%d %H:%M:%S')}")
112
+ lines.append(f" Runtime: {job.runtime_display}")
113
+ lines.append("")
114
+
115
+ # Section: Resources
116
+ lines.append("═══ Resources ═══")
117
+ lines.append("")
118
+
119
+ # Slots from PE
120
+ pe_name = self._extra.get("pe_name")
121
+ pe_range = self._extra.get("pe_range")
122
+ if pe_name:
123
+ lines.append(f" PE: {pe_name} ({pe_range or job.cpu or '?'} slots)")
124
+ else:
125
+ lines.append(f" Slots/CPUs: {job.cpu or '—'}")
126
+
127
+ # Memory - check resources dict for common memory keys
128
+ memory = job.memory
129
+ if not memory:
130
+ for key in ("h_vmem", "mem_free", "virtual_free", "mem", "memory"):
131
+ if key in resources:
132
+ memory = resources[key]
133
+ break
134
+ if memory:
135
+ lines.append(f" Memory: {memory}")
136
+
137
+ # GPU
138
+ if job.gpu:
139
+ lines.append(f" GPUs: {job.gpu}")
140
+
141
+ # All requested resources
142
+ if resources:
143
+ lines.append("")
144
+ lines.append(" All Requested Resources:")
145
+ for name, value in sorted(resources.items()):
146
+ lines.append(f" {name}: {value}")
147
+ lines.append("")
148
+
149
+ # Section: Paths
150
+ lines.append("═══ Paths ═══")
151
+ lines.append("")
152
+ cwd = self._extra.get("cwd")
153
+ if cwd:
154
+ lines.append(f" Working Dir: {cwd}")
155
+ lines.append(f" Stdout: {job.stdout_path or '—'}")
156
+ lines.append(f" Stderr: {job.stderr_path or '—'}")
157
+ lines.append("")
158
+
159
+ # Section: Dependencies
160
+ deps = self._extra.get("dependencies", [])
161
+ if deps or job.dependencies:
162
+ lines.append("═══ Dependencies ═══")
163
+ lines.append("")
164
+ all_deps = deps or job.dependencies or []
165
+ if all_deps:
166
+ for dep in all_deps:
167
+ lines.append(f" • {dep}")
168
+ else:
169
+ lines.append(" None")
170
+ lines.append("")
171
+
172
+ # Section: Array Job Info
173
+ if job.array_task_id is not None:
174
+ lines.append("═══ Array Job ═══")
175
+ lines.append("")
176
+ lines.append(f" Task ID: {job.array_task_id}")
177
+ lines.append("")
178
+
179
+ # Section: Other
180
+ project = self._extra.get("project")
181
+ department = self._extra.get("department")
182
+ if project or department:
183
+ lines.append("═══ Other ═══")
184
+ lines.append("")
185
+ if project:
186
+ lines.append(f" Project: {project}")
187
+ if department:
188
+ lines.append(f" Department: {department}")
189
+ lines.append("")
190
+
191
+ return "\n".join(lines)
192
+
193
+ def action_close(self) -> None:
194
+ """Close the details viewer."""
195
+ self.dismiss(None)
196
+
197
+ def action_go_top(self) -> None:
198
+ """Scroll to top."""
199
+ text_area = self.query_one("#job-details-content", TextArea)
200
+ text_area.cursor_location = (0, 0)
201
+
202
+ def action_go_bottom(self) -> None:
203
+ """Scroll to bottom."""
204
+ text_area = self.query_one("#job-details-content", TextArea)
205
+ text_area.cursor_location = (len(text_area.document.lines) - 1, 0)
206
+
207
+ def action_screenshot(self) -> None:
208
+ """Save a screenshot."""
209
+ path = self.app.save_screenshot(path="./")
210
+ self.app.notify(f"Screenshot saved: {path}", timeout=3)
@@ -0,0 +1,170 @@
1
+ """Log viewer modal screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Vertical
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Static, TextArea
13
+
14
+
15
+ # Maximum lines to read from large files
16
+ MAX_LINES = 5000
17
+
18
+
19
+ class LogViewerScreen(ModalScreen[None]):
20
+ """Modal screen for viewing job log files.
21
+
22
+ Displays file content in a scrollable text area.
23
+ Styles are defined in monitor.tcss.
24
+ """
25
+
26
+ BINDINGS = [
27
+ Binding("escape", "close", "Close"),
28
+ Binding("q", "close", "Close"),
29
+ Binding("g", "go_top", "Top", show=False),
30
+ Binding("G", "go_bottom", "Bottom", show=False),
31
+ Binding("s", "screenshot", "Screenshot", show=False),
32
+ ]
33
+
34
+ def __init__(
35
+ self,
36
+ file_path: Path | str,
37
+ title: str = "Log Viewer",
38
+ **kwargs: Any,
39
+ ) -> None:
40
+ super().__init__(**kwargs)
41
+ self._file_path = Path(file_path) if isinstance(file_path, str) else file_path
42
+ self._title = title
43
+ self._content: str = ""
44
+ self._error: str | None = None
45
+
46
+ def compose(self) -> ComposeResult:
47
+ """Create the modal content."""
48
+ with Vertical(id="log-viewer-dialog"):
49
+ yield Static(str(self._file_path), id="log-viewer-path")
50
+ yield TextArea(id="log-viewer-content", read_only=True)
51
+ yield Static("q/Esc close | g top | G bottom", id="log-viewer-hint")
52
+
53
+ def on_mount(self) -> None:
54
+ """Set up the dialog and load file content."""
55
+ dialog = self.query_one("#log-viewer-dialog", Vertical)
56
+ dialog.border_title = self._title
57
+
58
+ # Load file content
59
+ self._load_file()
60
+
61
+ # Update text area with content
62
+ text_area = self.query_one("#log-viewer-content", TextArea)
63
+ if self._error:
64
+ text_area.load_text(self._error)
65
+ else:
66
+ text_area.load_text(self._content)
67
+ # Scroll to bottom by default (most recent output)
68
+ text_area.action_cursor_line_end()
69
+ self.call_after_refresh(self._scroll_to_bottom)
70
+
71
+ text_area.focus()
72
+
73
+ def _scroll_to_bottom(self) -> None:
74
+ """Scroll text area to bottom."""
75
+ text_area = self.query_one("#log-viewer-content", TextArea)
76
+ # Move cursor to end of document
77
+ text_area.cursor_location = (len(text_area.document.lines) - 1, 0)
78
+
79
+ def _load_file(self) -> None:
80
+ """Load content from the log file."""
81
+ if not self._file_path.exists():
82
+ self._error = f"File not found:\n{self._file_path}"
83
+ return
84
+
85
+ try:
86
+ # Check file size first
87
+ file_size = self._file_path.stat().st_size
88
+
89
+ if file_size == 0:
90
+ self._content = "(empty file)"
91
+ return
92
+
93
+ # For large files, read only the last N lines
94
+ if file_size > 1_000_000: # 1MB threshold
95
+ self._content = self._read_tail()
96
+ else:
97
+ self._content = self._file_path.read_text(encoding="utf-8", errors="replace")
98
+
99
+ # Truncate if too many lines
100
+ lines = self._content.splitlines()
101
+ if len(lines) > MAX_LINES:
102
+ self._content = (
103
+ f"[Showing last {MAX_LINES} of {len(lines)} lines]\n\n"
104
+ + "\n".join(lines[-MAX_LINES:])
105
+ )
106
+
107
+ except PermissionError:
108
+ self._error = f"Permission denied:\n{self._file_path}"
109
+ except Exception as e:
110
+ self._error = f"Error reading file:\n{e}"
111
+
112
+ def _read_tail(self) -> str:
113
+ """Read the last N lines of a large file efficiently."""
114
+ lines: list[str] = []
115
+ try:
116
+ with open(self._file_path, "rb") as f:
117
+ # Seek to end
118
+ f.seek(0, 2)
119
+ file_size = f.tell()
120
+
121
+ # Read chunks from end
122
+ chunk_size = 8192
123
+ position = file_size
124
+ remaining = b""
125
+
126
+ while position > 0 and len(lines) < MAX_LINES:
127
+ read_size = min(chunk_size, position)
128
+ position -= read_size
129
+ f.seek(position)
130
+ chunk = f.read(read_size) + remaining
131
+
132
+ # Split into lines
133
+ chunk_lines = chunk.split(b"\n")
134
+ remaining = chunk_lines[0]
135
+ lines = [
136
+ line.decode("utf-8", errors="replace")
137
+ for line in chunk_lines[1:]
138
+ ] + lines
139
+
140
+ # Add any remaining content
141
+ if remaining and len(lines) < MAX_LINES:
142
+ lines.insert(0, remaining.decode("utf-8", errors="replace"))
143
+
144
+ # Trim to MAX_LINES
145
+ if len(lines) > MAX_LINES:
146
+ lines = lines[-MAX_LINES:]
147
+
148
+ return f"[Large file - showing last {len(lines)} lines]\n\n" + "\n".join(lines)
149
+
150
+ except Exception as e:
151
+ return f"Error reading file: {e}"
152
+
153
+ def action_close(self) -> None:
154
+ """Close the log viewer."""
155
+ self.dismiss(None)
156
+
157
+ def action_go_top(self) -> None:
158
+ """Scroll to top of file."""
159
+ text_area = self.query_one("#log-viewer-content", TextArea)
160
+ text_area.cursor_location = (0, 0)
161
+
162
+ def action_go_bottom(self) -> None:
163
+ """Scroll to bottom of file."""
164
+ text_area = self.query_one("#log-viewer-content", TextArea)
165
+ text_area.cursor_location = (len(text_area.document.lines) - 1, 0)
166
+
167
+ def action_screenshot(self) -> None:
168
+ """Save a screenshot."""
169
+ path = self.app.save_screenshot(path="./")
170
+ self.app.notify(f"Screenshot saved: {path}", timeout=3)
@@ -0,0 +1,153 @@
1
+ """TUI Snapshot utility for visual review after edits.
2
+
3
+ Usage:
4
+ python -m hpc_runner.tui.snapshot
5
+
6
+ This captures a screenshot and reports key visual properties to catch
7
+ regressions like solid backgrounds when transparency is expected.
8
+ """
9
+
10
+ import asyncio
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from .app import HpcMonitorApp
15
+
16
+
17
+ def _is_transparent(color) -> bool:
18
+ """Check if a color is transparent."""
19
+ if color is None:
20
+ return True
21
+ if color.a == 0:
22
+ return True
23
+ if hasattr(color, "ansi") and color.ansi == -1:
24
+ return True
25
+ return False
26
+
27
+
28
+ def _color_hex(color) -> str:
29
+ """Convert color to hex string for display."""
30
+ if color is None:
31
+ return "None"
32
+ if hasattr(color, "ansi") and color.ansi == -1:
33
+ return "ANSI_DEFAULT (transparent)"
34
+ if color.a == 0:
35
+ return f"transparent (a=0)"
36
+ return f"#{color.r:02x}{color.g:02x}{color.b:02x}"
37
+
38
+
39
+ async def capture_and_review() -> bool:
40
+ """Capture snapshot and review visual properties.
41
+
42
+ Returns:
43
+ True if all checks pass, False otherwise.
44
+ """
45
+ from textual.containers import HorizontalGroup
46
+ from textual.widgets import Tab, Header, TabbedContent, Static
47
+
48
+ app = HpcMonitorApp()
49
+ all_passed = True
50
+
51
+ async with app.run_test(size=(80, 24)) as pilot:
52
+ await pilot.pause()
53
+
54
+ # Save screenshot
55
+ screenshot_path = Path("tui_snapshot.svg")
56
+ app.save_screenshot(str(screenshot_path))
57
+
58
+ print("=" * 60)
59
+ print("TUI SNAPSHOT REVIEW")
60
+ print("=" * 60)
61
+ print(f"\nScreenshot saved to: {screenshot_path.absolute()}")
62
+ print(f"Theme: {app.theme}")
63
+ print(f"ANSI mode: {app.ansi_color}")
64
+
65
+ # Check Screen background
66
+ print("\n--- Background Transparency ---")
67
+ screen_bg = app.screen.styles.background
68
+ screen_ok = _is_transparent(screen_bg)
69
+ status = "✓ PASS" if screen_ok else "✗ FAIL"
70
+ print(f" Screen background: {_color_hex(screen_bg)} {status}")
71
+ if not screen_ok:
72
+ all_passed = False
73
+ print(" ^ Should be transparent for terminal background to show")
74
+
75
+ # Check Header
76
+ header = app.query_one(Header)
77
+ header_bg = header.styles.background
78
+ header_ok = _is_transparent(header_bg)
79
+ status = "✓ PASS" if header_ok else "✗ FAIL"
80
+ print(f" Header background: {_color_hex(header_bg)} {status}")
81
+ if not header_ok:
82
+ all_passed = False
83
+
84
+ # Check custom footer (#footer HorizontalGroup)
85
+ footer = app.query_one("#footer", HorizontalGroup)
86
+ footer_bg = footer.styles.background
87
+ footer_ok = _is_transparent(footer_bg)
88
+ status = "✓ PASS" if footer_ok else "✗ FAIL"
89
+ print(f" Footer background: {_color_hex(footer_bg)} {status}")
90
+ if not footer_ok:
91
+ all_passed = False
92
+
93
+ # Check footer children (all should be transparent)
94
+ for child in footer.children:
95
+ child_bg = child.styles.background
96
+ child_ok = _is_transparent(child_bg)
97
+ status = "✓ PASS" if child_ok else "✗ FAIL"
98
+ child_classes = " ".join(child.classes) if child.classes else "(no class)"
99
+ print(f" Footer child ({child_classes}): {_color_hex(child_bg)} {status}")
100
+ if not child_ok:
101
+ all_passed = False
102
+
103
+ # Check TabbedContent
104
+ tabbed = app.query_one(TabbedContent)
105
+ tabbed_bg = tabbed.styles.background
106
+ tabbed_ok = _is_transparent(tabbed_bg)
107
+ status = "✓ PASS" if tabbed_ok else "✗ FAIL"
108
+ print(f" TabbedContent background: {_color_hex(tabbed_bg)} {status}")
109
+ if not tabbed_ok:
110
+ all_passed = False
111
+
112
+ # Check tabs
113
+ print("\n--- Tab Styling ---")
114
+ tabs = app.query(Tab)
115
+ for tab in tabs:
116
+ is_active = tab.has_class("-active")
117
+ bg = tab.styles.background
118
+
119
+ if is_active:
120
+ # Active tab should have primary color (#88C0D0)
121
+ active_ok = bg is not None and bg.r == 136 and bg.g == 192 and bg.b == 208
122
+ status = "✓ PASS" if active_ok else "✗ FAIL"
123
+ print(f" Active tab '{tab.label.plain}': {_color_hex(bg)} {status}")
124
+ if not active_ok:
125
+ all_passed = False
126
+ print(" ^ Should be #88c0d0 (muted teal)")
127
+ else:
128
+ # Inactive tab should be transparent
129
+ inactive_ok = _is_transparent(bg)
130
+ status = "✓ PASS" if inactive_ok else "✗ FAIL"
131
+ print(f" Inactive tab '{tab.label.plain}': {_color_hex(bg)} {status}")
132
+ if not inactive_ok:
133
+ all_passed = False
134
+
135
+ # Summary
136
+ print("\n" + "=" * 60)
137
+ if all_passed:
138
+ print("RESULT: ✓ All visual checks passed")
139
+ else:
140
+ print("RESULT: ✗ Some checks failed - review needed!")
141
+ print("=" * 60)
142
+
143
+ return all_passed
144
+
145
+
146
+ def main():
147
+ """Run snapshot review."""
148
+ passed = asyncio.run(capture_and_review())
149
+ sys.exit(0 if passed else 1)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()