hpc-runner 0.3.0__py3-none-any.whl → 0.3.2__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.
- hpc_runner/_version.py +2 -2
- hpc_runner/cli/config.py +2 -2
- hpc_runner/cli/main.py +8 -3
- hpc_runner/cli/run.py +24 -9
- hpc_runner/cli/status.py +0 -1
- hpc_runner/cli/submit.py +0 -2
- hpc_runner/core/config.py +8 -2
- hpc_runner/core/descriptors.py +9 -3
- hpc_runner/core/job.py +6 -5
- hpc_runner/core/job_array.py +2 -1
- hpc_runner/core/resources.py +2 -1
- hpc_runner/schedulers/__init__.py +2 -2
- hpc_runner/schedulers/base.py +31 -17
- hpc_runner/schedulers/local/scheduler.py +103 -190
- hpc_runner/schedulers/local/templates/job.sh.j2 +17 -4
- hpc_runner/schedulers/sge/args.py +14 -14
- hpc_runner/schedulers/sge/parser.py +4 -4
- hpc_runner/schedulers/sge/scheduler.py +76 -78
- hpc_runner/schedulers/sge/templates/batch.sh.j2 +0 -5
- hpc_runner/schedulers/sge/templates/interactive.sh.j2 +0 -5
- hpc_runner/tui/app.py +14 -25
- hpc_runner/tui/components/filter_bar.py +2 -4
- hpc_runner/tui/components/filter_popup.py +13 -8
- hpc_runner/tui/components/job_table.py +5 -9
- hpc_runner/tui/providers/jobs.py +3 -5
- hpc_runner/tui/screens/confirm.py +3 -1
- hpc_runner/tui/screens/log_viewer.py +1 -3
- hpc_runner/tui/snapshot.py +7 -5
- hpc_runner/workflow/pipeline.py +2 -1
- {hpc_runner-0.3.0.dist-info → hpc_runner-0.3.2.dist-info}/METADATA +7 -5
- hpc_runner-0.3.2.dist-info/RECORD +57 -0
- hpc_runner-0.3.0.dist-info/RECORD +0 -57
- {hpc_runner-0.3.0.dist-info → hpc_runner-0.3.2.dist-info}/WHEEL +0 -0
- {hpc_runner-0.3.0.dist-info → hpc_runner-0.3.2.dist-info}/entry_points.txt +0 -0
|
@@ -6,31 +6,12 @@ import os
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import tempfile
|
|
8
8
|
import uuid
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
9
10
|
from datetime import datetime
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import TYPE_CHECKING
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
12
13
|
|
|
13
14
|
from hpc_runner.core.config import get_config
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def get_script_dir() -> Path:
|
|
17
|
-
"""Get directory for temporary job scripts.
|
|
18
|
-
|
|
19
|
-
Uses HPC_SCRIPT_DIR environment variable if set, otherwise
|
|
20
|
-
defaults to ~/.cache/hpc-runner/scripts/.
|
|
21
|
-
|
|
22
|
-
Returns:
|
|
23
|
-
Path to script directory (created if needed).
|
|
24
|
-
"""
|
|
25
|
-
if env_dir := os.environ.get("HPC_SCRIPT_DIR"):
|
|
26
|
-
script_dir = Path(env_dir)
|
|
27
|
-
else:
|
|
28
|
-
script_dir = Path.home() / ".cache" / "hpc-runner" / "scripts"
|
|
29
|
-
|
|
30
|
-
script_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
-
return script_dir
|
|
32
|
-
|
|
33
|
-
|
|
34
15
|
from hpc_runner.core.job_info import JobInfo
|
|
35
16
|
from hpc_runner.core.result import ArrayJobResult, JobResult, JobStatus
|
|
36
17
|
from hpc_runner.schedulers.base import BaseScheduler
|
|
@@ -64,6 +45,24 @@ if TYPE_CHECKING:
|
|
|
64
45
|
from hpc_runner.core.job_array import JobArray
|
|
65
46
|
|
|
66
47
|
|
|
48
|
+
def get_script_dir() -> Path:
|
|
49
|
+
"""Get directory for temporary job scripts.
|
|
50
|
+
|
|
51
|
+
Uses HPC_SCRIPT_DIR environment variable if set, otherwise
|
|
52
|
+
defaults to ~/.cache/hpc-runner/scripts/.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Path to script directory (created if needed).
|
|
56
|
+
"""
|
|
57
|
+
if env_dir := os.environ.get("HPC_SCRIPT_DIR"):
|
|
58
|
+
script_dir = Path(env_dir)
|
|
59
|
+
else:
|
|
60
|
+
script_dir = Path.home() / ".cache" / "hpc-runner" / "scripts"
|
|
61
|
+
|
|
62
|
+
script_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
return script_dir
|
|
64
|
+
|
|
65
|
+
|
|
67
66
|
class SGEScheduler(BaseScheduler):
|
|
68
67
|
"""Sun Grid Engine scheduler implementation."""
|
|
69
68
|
|
|
@@ -80,7 +79,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
80
79
|
self.time_resource = sge_config.get("time_resource", "h_rt")
|
|
81
80
|
|
|
82
81
|
# Module handling config
|
|
83
|
-
self.purge_modules = sge_config.get("purge_modules",
|
|
82
|
+
self.purge_modules = sge_config.get("purge_modules", True)
|
|
84
83
|
self.silent_modules = sge_config.get("silent_modules", False)
|
|
85
84
|
self.module_init_script = sge_config.get("module_init_script", "")
|
|
86
85
|
|
|
@@ -119,7 +118,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
119
118
|
|
|
120
119
|
def generate_script(
|
|
121
120
|
self,
|
|
122
|
-
job:
|
|
121
|
+
job: Job,
|
|
123
122
|
array_range: str | None = None,
|
|
124
123
|
keep_script: bool = False,
|
|
125
124
|
script_path: str | None = None,
|
|
@@ -142,7 +141,19 @@ class SGEScheduler(BaseScheduler):
|
|
|
142
141
|
keep_script=keep_script,
|
|
143
142
|
)
|
|
144
143
|
|
|
145
|
-
def
|
|
144
|
+
def generate_interactive_script(
|
|
145
|
+
self, job: Job, script_path: str, keep_script: bool = False
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Generate wrapper script for interactive jobs."""
|
|
148
|
+
return render_template(
|
|
149
|
+
"sge/templates/interactive.sh.j2",
|
|
150
|
+
job=job,
|
|
151
|
+
scheduler=self,
|
|
152
|
+
script_path=script_path,
|
|
153
|
+
keep_script=keep_script,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _build_directives(self, job: Job, array_range: str | None = None) -> list[str]:
|
|
146
157
|
"""Build complete list of #$ directives for the job.
|
|
147
158
|
|
|
148
159
|
Uses the rendering protocol from BaseScheduler, then adds
|
|
@@ -182,7 +193,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
182
193
|
|
|
183
194
|
return directives
|
|
184
195
|
|
|
185
|
-
def _build_dependency_string(self, job:
|
|
196
|
+
def _build_dependency_string(self, job: Job) -> str | None:
|
|
186
197
|
"""Build SGE dependency string from job dependencies."""
|
|
187
198
|
# String-based dependency from CLI
|
|
188
199
|
if job.dependency:
|
|
@@ -200,7 +211,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
200
211
|
# Command Building
|
|
201
212
|
# =========================================================================
|
|
202
213
|
|
|
203
|
-
def build_submit_command(self, job:
|
|
214
|
+
def build_submit_command(self, job: Job) -> list[str]:
|
|
204
215
|
"""Build qsub command line."""
|
|
205
216
|
cmd = ["qsub"]
|
|
206
217
|
cmd.extend(self.render_args(job))
|
|
@@ -208,7 +219,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
208
219
|
cmd.extend(job.sge_args)
|
|
209
220
|
return cmd
|
|
210
221
|
|
|
211
|
-
def build_interactive_command(self, job:
|
|
222
|
+
def build_interactive_command(self, job: Job) -> list[str]:
|
|
212
223
|
"""Build qrsh command for interactive jobs.
|
|
213
224
|
|
|
214
225
|
Note: qrsh supports a subset of qsub options. Notably:
|
|
@@ -242,9 +253,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
242
253
|
# Job Submission
|
|
243
254
|
# =========================================================================
|
|
244
255
|
|
|
245
|
-
def submit(
|
|
246
|
-
self, job: "Job", interactive: bool = False, keep_script: bool = False
|
|
247
|
-
) -> JobResult:
|
|
256
|
+
def submit(self, job: Job, interactive: bool = False, keep_script: bool = False) -> JobResult:
|
|
248
257
|
"""Submit a job to SGE.
|
|
249
258
|
|
|
250
259
|
Args:
|
|
@@ -257,7 +266,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
257
266
|
return self._submit_interactive(job, keep_script=keep_script)
|
|
258
267
|
return self._submit_batch(job, keep_script=keep_script)
|
|
259
268
|
|
|
260
|
-
def _submit_batch(self, job:
|
|
269
|
+
def _submit_batch(self, job: Job, keep_script: bool = False) -> JobResult:
|
|
261
270
|
"""Submit via qsub."""
|
|
262
271
|
# Determine script path first (needed for self-deletion in template)
|
|
263
272
|
script_dir = get_script_dir()
|
|
@@ -265,17 +274,18 @@ class SGEScheduler(BaseScheduler):
|
|
|
265
274
|
script_path = script_dir / script_name
|
|
266
275
|
|
|
267
276
|
# Generate script with cleanup instruction
|
|
268
|
-
script = self.generate_script(
|
|
269
|
-
job, keep_script=keep_script, script_path=str(script_path)
|
|
270
|
-
)
|
|
277
|
+
script = self.generate_script(job, keep_script=keep_script, script_path=str(script_path))
|
|
271
278
|
|
|
272
279
|
script_path.write_text(script)
|
|
273
280
|
script_path.chmod(0o755)
|
|
274
281
|
|
|
275
282
|
if keep_script:
|
|
276
283
|
import sys
|
|
284
|
+
|
|
277
285
|
print(f"Script saved: {script_path}", file=sys.stderr)
|
|
278
286
|
|
|
287
|
+
workdir = Path(job.workdir).resolve() if job.workdir else None
|
|
288
|
+
|
|
279
289
|
try:
|
|
280
290
|
result = subprocess.run(
|
|
281
291
|
["qsub", str(script_path)],
|
|
@@ -283,6 +293,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
283
293
|
text=True,
|
|
284
294
|
errors="replace",
|
|
285
295
|
check=True,
|
|
296
|
+
cwd=workdir,
|
|
286
297
|
)
|
|
287
298
|
job_id = parse_qsub_output(result.stdout)
|
|
288
299
|
|
|
@@ -296,7 +307,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
296
307
|
if not keep_script:
|
|
297
308
|
script_path.unlink(missing_ok=True)
|
|
298
309
|
|
|
299
|
-
def _submit_interactive(self, job:
|
|
310
|
+
def _submit_interactive(self, job: Job, keep_script: bool = False) -> JobResult:
|
|
300
311
|
"""Submit via qrsh for interactive execution.
|
|
301
312
|
|
|
302
313
|
Creates a wrapper script with full environment setup (modules, venv, etc.)
|
|
@@ -312,9 +323,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
312
323
|
script_path = script_dir / script_name
|
|
313
324
|
|
|
314
325
|
# Generate wrapper script with the actual path (for self-deletion)
|
|
315
|
-
script = self.
|
|
316
|
-
job, str(script_path), keep_script=keep_script
|
|
317
|
-
)
|
|
326
|
+
script = self.generate_interactive_script(job, str(script_path), keep_script=keep_script)
|
|
318
327
|
|
|
319
328
|
# Write script to shared filesystem
|
|
320
329
|
script_path.write_text(script)
|
|
@@ -323,13 +332,16 @@ class SGEScheduler(BaseScheduler):
|
|
|
323
332
|
if keep_script:
|
|
324
333
|
# Print script path for debugging
|
|
325
334
|
import sys
|
|
335
|
+
|
|
326
336
|
print(f"Script saved: {script_path}", file=sys.stderr)
|
|
327
337
|
|
|
328
338
|
# Build qrsh command with script path
|
|
329
339
|
cmd = self._build_qrsh_command(job, str(script_path))
|
|
330
340
|
|
|
341
|
+
workdir = Path(job.workdir).resolve() if job.workdir else None
|
|
342
|
+
|
|
331
343
|
# Run and capture exit code
|
|
332
|
-
result = subprocess.run(cmd, check=False)
|
|
344
|
+
result = subprocess.run(cmd, check=False, cwd=workdir)
|
|
333
345
|
|
|
334
346
|
# Clean up if script still exists and we're not keeping it
|
|
335
347
|
if not keep_script:
|
|
@@ -342,19 +354,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
342
354
|
_exit_code=result.returncode,
|
|
343
355
|
)
|
|
344
356
|
|
|
345
|
-
def
|
|
346
|
-
self, job: "Job", script_path: str, keep_script: bool = False
|
|
347
|
-
) -> str:
|
|
348
|
-
"""Generate wrapper script for interactive jobs."""
|
|
349
|
-
return render_template(
|
|
350
|
-
"sge/templates/interactive.sh.j2",
|
|
351
|
-
job=job,
|
|
352
|
-
scheduler=self,
|
|
353
|
-
script_path=script_path,
|
|
354
|
-
keep_script=keep_script,
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
def _build_qrsh_command(self, job: "Job", script_path: str) -> list[str]:
|
|
357
|
+
def _build_qrsh_command(self, job: Job, script_path: str) -> list[str]:
|
|
358
358
|
"""Build qrsh command to run wrapper script."""
|
|
359
359
|
cmd = ["qrsh"]
|
|
360
360
|
|
|
@@ -376,22 +376,23 @@ class SGEScheduler(BaseScheduler):
|
|
|
376
376
|
|
|
377
377
|
return cmd
|
|
378
378
|
|
|
379
|
-
def submit_array(self, array:
|
|
379
|
+
def submit_array(self, array: JobArray) -> ArrayJobResult:
|
|
380
380
|
"""Submit array job."""
|
|
381
381
|
script = self.generate_script(array.job, array_range=array.range_str)
|
|
382
382
|
|
|
383
|
-
with tempfile.NamedTemporaryFile(
|
|
384
|
-
mode="w", suffix=".sh", delete=False, prefix="hpc_"
|
|
385
|
-
) as f:
|
|
383
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False, prefix="hpc_") as f:
|
|
386
384
|
f.write(script)
|
|
387
385
|
script_path = f.name
|
|
388
386
|
|
|
387
|
+
workdir = Path(array.job.workdir).resolve() if array.job.workdir else None
|
|
388
|
+
|
|
389
389
|
try:
|
|
390
390
|
result = subprocess.run(
|
|
391
391
|
["qsub", script_path],
|
|
392
392
|
capture_output=True,
|
|
393
393
|
text=True,
|
|
394
394
|
check=True,
|
|
395
|
+
cwd=workdir,
|
|
395
396
|
)
|
|
396
397
|
job_id = parse_qsub_output(result.stdout)
|
|
397
398
|
|
|
@@ -636,17 +637,19 @@ class SGEScheduler(BaseScheduler):
|
|
|
636
637
|
if basic_info:
|
|
637
638
|
# Merge detailed info with basic info
|
|
638
639
|
if job_data.get("stdout_path"):
|
|
639
|
-
basic_info.stdout_path = job_data["stdout_path"]
|
|
640
|
+
basic_info.stdout_path = cast(Path, job_data["stdout_path"])
|
|
640
641
|
if job_data.get("stderr_path"):
|
|
641
|
-
basic_info.stderr_path = job_data["stderr_path"]
|
|
642
|
+
basic_info.stderr_path = cast(Path, job_data["stderr_path"])
|
|
642
643
|
if job_data.get("node"):
|
|
643
|
-
basic_info.node = job_data["node"]
|
|
644
|
+
basic_info.node = cast(str, job_data["node"])
|
|
644
645
|
|
|
645
646
|
# Always use timing from detailed qstat -j output (more reliable)
|
|
646
647
|
if job_data.get("submit_time"):
|
|
647
|
-
|
|
648
|
+
ts = cast(float, job_data["submit_time"])
|
|
649
|
+
basic_info.submit_time = datetime.fromtimestamp(ts)
|
|
648
650
|
if job_data.get("start_time"):
|
|
649
|
-
|
|
651
|
+
ts = cast(float, job_data["start_time"])
|
|
652
|
+
basic_info.start_time = datetime.fromtimestamp(ts)
|
|
650
653
|
# Calculate runtime if running
|
|
651
654
|
if basic_info.status == JobStatus.RUNNING:
|
|
652
655
|
basic_info.runtime = datetime.now() - basic_info.start_time
|
|
@@ -656,19 +659,19 @@ class SGEScheduler(BaseScheduler):
|
|
|
656
659
|
# Build from scratch using qstat -j data
|
|
657
660
|
job_info = JobInfo(
|
|
658
661
|
job_id=job_id,
|
|
659
|
-
name=job_data.get("name", job_id),
|
|
660
|
-
user=job_data.get("user", "unknown"),
|
|
661
|
-
status=job_data.get("status", JobStatus.UNKNOWN),
|
|
662
|
-
queue=job_data.get("queue"),
|
|
663
|
-
stdout_path=job_data.get("stdout_path"),
|
|
664
|
-
stderr_path=job_data.get("stderr_path"),
|
|
665
|
-
node=job_data.get("node"),
|
|
662
|
+
name=cast(str, job_data.get("name", job_id)),
|
|
663
|
+
user=cast(str, job_data.get("user", "unknown")),
|
|
664
|
+
status=cast(JobStatus, job_data.get("status", JobStatus.UNKNOWN)),
|
|
665
|
+
queue=cast("str | None", job_data.get("queue")),
|
|
666
|
+
stdout_path=cast("Path | None", job_data.get("stdout_path")),
|
|
667
|
+
stderr_path=cast("Path | None", job_data.get("stderr_path")),
|
|
668
|
+
node=cast("str | None", job_data.get("node")),
|
|
666
669
|
)
|
|
667
670
|
# Add timing info
|
|
668
671
|
if job_data.get("submit_time"):
|
|
669
|
-
job_info.submit_time = datetime.fromtimestamp(job_data["submit_time"])
|
|
672
|
+
job_info.submit_time = datetime.fromtimestamp(cast(float, job_data["submit_time"]))
|
|
670
673
|
if job_data.get("start_time"):
|
|
671
|
-
job_info.start_time = datetime.fromtimestamp(job_data["start_time"])
|
|
674
|
+
job_info.start_time = datetime.fromtimestamp(cast(float, job_data["start_time"]))
|
|
672
675
|
if job_info.status == JobStatus.RUNNING:
|
|
673
676
|
job_info.runtime = datetime.now() - job_info.start_time
|
|
674
677
|
return job_info, extra_details
|
|
@@ -684,7 +687,6 @@ class SGEScheduler(BaseScheduler):
|
|
|
684
687
|
- Dependencies: list of job IDs
|
|
685
688
|
- Other: project, department
|
|
686
689
|
"""
|
|
687
|
-
import xml.etree.ElementTree as ET
|
|
688
690
|
|
|
689
691
|
data: dict[str, object] = {}
|
|
690
692
|
|
|
@@ -748,9 +750,7 @@ class SGEScheduler(BaseScheduler):
|
|
|
748
750
|
pass
|
|
749
751
|
|
|
750
752
|
# Start time (for running jobs) - in JB_ja_tasks/ulong_sublist/JAT_start_time
|
|
751
|
-
task_start_text = job_info.findtext(
|
|
752
|
-
".//JB_ja_tasks/ulong_sublist/JAT_start_time"
|
|
753
|
-
)
|
|
753
|
+
task_start_text = job_info.findtext(".//JB_ja_tasks/ulong_sublist/JAT_start_time")
|
|
754
754
|
if task_start_text:
|
|
755
755
|
try:
|
|
756
756
|
data["start_time"] = int(task_start_text)
|
|
@@ -850,9 +850,8 @@ class SGEScheduler(BaseScheduler):
|
|
|
850
850
|
|
|
851
851
|
return data
|
|
852
852
|
|
|
853
|
-
def _strip_xml_namespaces(self, root:
|
|
853
|
+
def _strip_xml_namespaces(self, root: ET.Element) -> None:
|
|
854
854
|
"""Strip namespaces so ElementTree can match tag names directly."""
|
|
855
|
-
import xml.etree.ElementTree as ET
|
|
856
855
|
|
|
857
856
|
for elem in root.iter():
|
|
858
857
|
if isinstance(elem.tag, str) and "}" in elem.tag:
|
|
@@ -863,9 +862,8 @@ class SGEScheduler(BaseScheduler):
|
|
|
863
862
|
cleaned = "".join(ch if 32 <= ord(ch) < 127 else " " for ch in value)
|
|
864
863
|
return " ".join(cleaned.split())
|
|
865
864
|
|
|
866
|
-
def _parse_xml_root(self, xml_output: str) ->
|
|
865
|
+
def _parse_xml_root(self, xml_output: str) -> ET.Element | None:
|
|
867
866
|
"""Parse XML output, tolerating leading/trailing non-XML noise."""
|
|
868
|
-
import xml.etree.ElementTree as ET
|
|
869
867
|
|
|
870
868
|
try:
|
|
871
869
|
return ET.fromstring(xml_output)
|
hpc_runner/tui/app.py
CHANGED
|
@@ -31,7 +31,6 @@ from hpc_runner.tui.components import (
|
|
|
31
31
|
from hpc_runner.tui.providers import JobProvider
|
|
32
32
|
from hpc_runner.tui.screens import ConfirmScreen, JobDetailsScreen, LogViewerScreen
|
|
33
33
|
|
|
34
|
-
|
|
35
34
|
# Custom theme inspired by Nord color palette for a muted, professional look.
|
|
36
35
|
# NOTE: We intentionally do NOT set 'background' or 'foreground' here.
|
|
37
36
|
# This allows the terminal's own colors to show through (transparency).
|
|
@@ -110,9 +109,7 @@ class HpcMonitorApp(App[None]):
|
|
|
110
109
|
yield JobTable(id="active-jobs")
|
|
111
110
|
yield DetailPanel(id="detail-panel")
|
|
112
111
|
with TabPane("Completed", id="completed-tab"):
|
|
113
|
-
yield Static(
|
|
114
|
-
"Completed jobs will appear here", id="completed-placeholder"
|
|
115
|
-
)
|
|
112
|
+
yield Static("Completed jobs will appear here", id="completed-placeholder")
|
|
116
113
|
# Custom footer for ANSI transparency (Textual's Footer doesn't respect it)
|
|
117
114
|
with HorizontalGroup(id="footer"):
|
|
118
115
|
yield Static(" q", classes="footer-key")
|
|
@@ -167,10 +164,10 @@ class HpcMonitorApp(App[None]):
|
|
|
167
164
|
"""Quit the application."""
|
|
168
165
|
self.exit()
|
|
169
166
|
|
|
170
|
-
def action_screenshot(self) -> None:
|
|
167
|
+
def action_screenshot(self, filename: str | None = None, path: str | None = None) -> None:
|
|
171
168
|
"""Save a screenshot to the current directory."""
|
|
172
|
-
|
|
173
|
-
self.notify(f"Screenshot saved: {
|
|
169
|
+
result = self.save_screenshot(path=path or "./", filename=filename)
|
|
170
|
+
self.notify(f"Screenshot saved: {result}", timeout=3)
|
|
174
171
|
|
|
175
172
|
def action_help(self) -> None:
|
|
176
173
|
"""Show help popup."""
|
|
@@ -206,7 +203,7 @@ class HpcMonitorApp(App[None]):
|
|
|
206
203
|
Uses run_worker to run as a background task without blocking UI.
|
|
207
204
|
The exclusive=True ensures only one refresh runs at a time.
|
|
208
205
|
"""
|
|
209
|
-
self.run_worker(self._fetch_and_update_jobs, exclusive=True)
|
|
206
|
+
self.run_worker(self._fetch_and_update_jobs, exclusive=True) # type: ignore[arg-type]
|
|
210
207
|
|
|
211
208
|
async def _fetch_and_update_jobs(self) -> None:
|
|
212
209
|
"""Async coroutine to fetch jobs and update the table."""
|
|
@@ -237,10 +234,7 @@ class HpcMonitorApp(App[None]):
|
|
|
237
234
|
|
|
238
235
|
# Filter by status
|
|
239
236
|
if self._status_filter:
|
|
240
|
-
filtered = [
|
|
241
|
-
j for j in filtered
|
|
242
|
-
if j.status.name.lower() == self._status_filter.lower()
|
|
243
|
-
]
|
|
237
|
+
filtered = [j for j in filtered if j.status.name.lower() == self._status_filter.lower()]
|
|
244
238
|
|
|
245
239
|
# Filter by queue
|
|
246
240
|
if self._queue_filter:
|
|
@@ -250,8 +244,7 @@ class HpcMonitorApp(App[None]):
|
|
|
250
244
|
if self._search_filter:
|
|
251
245
|
search = self._search_filter.lower()
|
|
252
246
|
filtered = [
|
|
253
|
-
j for j in filtered
|
|
254
|
-
if search in j.name.lower() or search in j.job_id.lower()
|
|
247
|
+
j for j in filtered if search in j.name.lower() or search in j.job_id.lower()
|
|
255
248
|
]
|
|
256
249
|
|
|
257
250
|
# Update table
|
|
@@ -301,9 +294,7 @@ class HpcMonitorApp(App[None]):
|
|
|
301
294
|
"""Focus the search input."""
|
|
302
295
|
self.query_one(FilterStatusLine).focus_search()
|
|
303
296
|
|
|
304
|
-
def on_filter_panel_filter_changed(
|
|
305
|
-
self, event: FilterPanel.FilterChanged
|
|
306
|
-
) -> None:
|
|
297
|
+
def on_filter_panel_filter_changed(self, event: FilterPanel.FilterChanged) -> None:
|
|
307
298
|
"""Handle filter panel changes (arrow key navigation)."""
|
|
308
299
|
if event.filter_type == "status":
|
|
309
300
|
self._status_filter = event.value
|
|
@@ -311,9 +302,7 @@ class HpcMonitorApp(App[None]):
|
|
|
311
302
|
self._queue_filter = event.value
|
|
312
303
|
self._apply_filters_and_display()
|
|
313
304
|
|
|
314
|
-
def on_filter_status_line_search_changed(
|
|
315
|
-
self, event: FilterStatusLine.SearchChanged
|
|
316
|
-
) -> None:
|
|
305
|
+
def on_filter_status_line_search_changed(self, event: FilterStatusLine.SearchChanged) -> None:
|
|
317
306
|
"""Handle inline search changes."""
|
|
318
307
|
self._search_filter = event.value
|
|
319
308
|
self._apply_filters_and_display()
|
|
@@ -325,6 +314,8 @@ class HpcMonitorApp(App[None]):
|
|
|
325
314
|
"""
|
|
326
315
|
# Start with basic info from the event
|
|
327
316
|
job_info = event.job_info
|
|
317
|
+
if job_info is None:
|
|
318
|
+
return
|
|
328
319
|
self._selected_job_extra = {}
|
|
329
320
|
self._last_detail_error: str | None = None
|
|
330
321
|
|
|
@@ -394,17 +385,14 @@ class HpcMonitorApp(App[None]):
|
|
|
394
385
|
"""Handle request to cancel a job."""
|
|
395
386
|
job = event.job
|
|
396
387
|
|
|
397
|
-
def handle_confirm(confirmed: bool) -> None:
|
|
388
|
+
def handle_confirm(confirmed: bool | None) -> None:
|
|
398
389
|
"""Handle confirmation result and refocus table."""
|
|
399
390
|
if confirmed:
|
|
400
391
|
self._do_cancel_job(job)
|
|
401
392
|
self.query_one("#active-jobs", JobTable).focus()
|
|
402
393
|
|
|
403
394
|
# Format job details for confirmation dialog
|
|
404
|
-
message =
|
|
405
|
-
f"[bold]Job ID:[/] {job.job_id}\n"
|
|
406
|
-
f"[bold]Name:[/] {job.name}"
|
|
407
|
-
)
|
|
395
|
+
message = f"[bold]Job ID:[/] {job.job_id}\n[bold]Name:[/] {job.name}"
|
|
408
396
|
self.push_screen(
|
|
409
397
|
ConfirmScreen(
|
|
410
398
|
message=message,
|
|
@@ -423,6 +411,7 @@ class HpcMonitorApp(App[None]):
|
|
|
423
411
|
try:
|
|
424
412
|
# Run cancel in thread pool to avoid blocking
|
|
425
413
|
import asyncio
|
|
414
|
+
|
|
426
415
|
loop = asyncio.get_event_loop()
|
|
427
416
|
await loop.run_in_executor(
|
|
428
417
|
None,
|
|
@@ -9,8 +9,6 @@ from textual.containers import Horizontal
|
|
|
9
9
|
from textual.message import Message
|
|
10
10
|
from textual.widgets import Input, Select, Static
|
|
11
11
|
|
|
12
|
-
from hpc_runner.core.result import JobStatus
|
|
13
|
-
|
|
14
12
|
if TYPE_CHECKING:
|
|
15
13
|
from textual.app import ComposeResult
|
|
16
14
|
|
|
@@ -112,9 +110,9 @@ class FilterBar(Horizontal):
|
|
|
112
110
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
113
111
|
"""Handle dropdown selection changes."""
|
|
114
112
|
if event.select.id == "status-filter":
|
|
115
|
-
self._current_status = event.value
|
|
113
|
+
self._current_status = str(event.value) if event.value != Select.BLANK else None
|
|
116
114
|
elif event.select.id == "queue-filter":
|
|
117
|
-
self._current_queue = event.value
|
|
115
|
+
self._current_queue = str(event.value) if event.value != Select.BLANK else None
|
|
118
116
|
|
|
119
117
|
self._emit_filter_changed()
|
|
120
118
|
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
5
8
|
from textual import events, on
|
|
9
|
+
from textual.app import ComposeResult
|
|
6
10
|
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.geometry import Region
|
|
7
12
|
from textual.message import Message
|
|
8
13
|
from textual.widgets import Input, OptionList, Static
|
|
9
14
|
from textual.widgets.option_list import Option
|
|
@@ -42,7 +47,7 @@ class HelpPopup(Static, can_focus=True):
|
|
|
42
47
|
}
|
|
43
48
|
"""
|
|
44
49
|
|
|
45
|
-
def __init__(self, **kwargs) -> None:
|
|
50
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
46
51
|
super().__init__(self.HELP_TEXT, **kwargs)
|
|
47
52
|
self.add_class("hidden")
|
|
48
53
|
|
|
@@ -116,8 +121,8 @@ class FilterPanelPopup(OptionList):
|
|
|
116
121
|
self,
|
|
117
122
|
options: list[tuple[str, str | None]],
|
|
118
123
|
current_index: int = 0,
|
|
119
|
-
on_select:
|
|
120
|
-
**kwargs,
|
|
124
|
+
on_select: Callable[[int], None] | None = None,
|
|
125
|
+
**kwargs: Any,
|
|
121
126
|
) -> None:
|
|
122
127
|
super().__init__(**kwargs)
|
|
123
128
|
self._panel_options = options
|
|
@@ -140,7 +145,7 @@ class FilterPanelPopup(OptionList):
|
|
|
140
145
|
self,
|
|
141
146
|
options: list[tuple[str, str | None]],
|
|
142
147
|
current_index: int,
|
|
143
|
-
on_select:
|
|
148
|
+
on_select: Callable[[int], None] | None = None,
|
|
144
149
|
) -> None:
|
|
145
150
|
"""Update options and current selection."""
|
|
146
151
|
self._panel_options = options
|
|
@@ -150,7 +155,7 @@ class FilterPanelPopup(OptionList):
|
|
|
150
155
|
if self.is_mounted:
|
|
151
156
|
self._refresh_options()
|
|
152
157
|
|
|
153
|
-
def show_popup(self, region) -> None:
|
|
158
|
+
def show_popup(self, region: Region) -> None:
|
|
154
159
|
"""Show popup positioned relative to parent widget."""
|
|
155
160
|
self.remove_class("hidden")
|
|
156
161
|
self.styles.offset = (region.x, region.y + region.height)
|
|
@@ -199,7 +204,7 @@ class FilterPanel(Static, can_focus=True):
|
|
|
199
204
|
filter_type: str,
|
|
200
205
|
options: list[tuple[str, str | None]],
|
|
201
206
|
title: str = "",
|
|
202
|
-
**kwargs,
|
|
207
|
+
**kwargs: Any,
|
|
203
208
|
) -> None:
|
|
204
209
|
"""Initialize filter panel.
|
|
205
210
|
|
|
@@ -296,12 +301,12 @@ class FilterStatusLine(Horizontal):
|
|
|
296
301
|
("Held", "held"),
|
|
297
302
|
]
|
|
298
303
|
|
|
299
|
-
def __init__(self, **kwargs) -> None:
|
|
304
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
300
305
|
super().__init__(id="filter-status", **kwargs)
|
|
301
306
|
self._search: str = ""
|
|
302
307
|
self._queues: list[str] = []
|
|
303
308
|
|
|
304
|
-
def compose(self):
|
|
309
|
+
def compose(self) -> ComposeResult:
|
|
305
310
|
"""Create the status line widgets."""
|
|
306
311
|
yield FilterPanel(
|
|
307
312
|
"status",
|
|
@@ -8,7 +8,7 @@ from hpc_runner.core.job_info import JobInfo
|
|
|
8
8
|
from hpc_runner.core.result import JobStatus
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class JobTable(DataTable):
|
|
11
|
+
class JobTable(DataTable[str]):
|
|
12
12
|
"""DataTable for displaying HPC jobs.
|
|
13
13
|
|
|
14
14
|
Displays job information in a tabular format with columns for
|
|
@@ -79,7 +79,7 @@ class JobTable(DataTable):
|
|
|
79
79
|
"""Return the width available for columns within the table."""
|
|
80
80
|
content_size = getattr(self, "content_size", None)
|
|
81
81
|
if content_size is not None:
|
|
82
|
-
return content_size.width
|
|
82
|
+
return int(content_size.width)
|
|
83
83
|
return self.size.width or self.app.console.size.width
|
|
84
84
|
|
|
85
85
|
def _calculate_name_width(self, table_width: int) -> int:
|
|
@@ -115,7 +115,7 @@ class JobTable(DataTable):
|
|
|
115
115
|
def _set_name_column_width(self, width: int) -> None:
|
|
116
116
|
"""Apply name column width and refresh rows for correct truncation."""
|
|
117
117
|
self._name_col_width = width
|
|
118
|
-
name_column = self.columns.get("name")
|
|
118
|
+
name_column = self.columns.get("name") # type: ignore[call-overload]
|
|
119
119
|
if name_column is not None:
|
|
120
120
|
name_column.width = width
|
|
121
121
|
if self._jobs:
|
|
@@ -132,9 +132,7 @@ class JobTable(DataTable):
|
|
|
132
132
|
ordered_jobs.append(job)
|
|
133
133
|
seen.add(job_id)
|
|
134
134
|
if len(ordered_jobs) != len(self._jobs):
|
|
135
|
-
ordered_jobs.extend(
|
|
136
|
-
job for job_id, job in self._jobs.items() if job_id not in seen
|
|
137
|
-
)
|
|
135
|
+
ordered_jobs.extend(job for job_id, job in self._jobs.items() if job_id not in seen)
|
|
138
136
|
self.update_jobs(ordered_jobs)
|
|
139
137
|
|
|
140
138
|
def _setup_columns(self) -> None:
|
|
@@ -223,9 +221,7 @@ class JobTable(DataTable):
|
|
|
223
221
|
}
|
|
224
222
|
return status_map.get(status, str(status.name))
|
|
225
223
|
|
|
226
|
-
def on_data_table_row_highlighted(
|
|
227
|
-
self, event: DataTable.RowHighlighted
|
|
228
|
-
) -> None:
|
|
224
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
229
225
|
"""Handle row highlight - emit JobSelected message."""
|
|
230
226
|
if event.row_key is not None:
|
|
231
227
|
job_id = str(event.row_key.value)
|
hpc_runner/tui/providers/jobs.py
CHANGED
|
@@ -37,7 +37,7 @@ class JobProvider:
|
|
|
37
37
|
current_user: The current username for filtering.
|
|
38
38
|
"""
|
|
39
39
|
|
|
40
|
-
def __init__(self, scheduler:
|
|
40
|
+
def __init__(self, scheduler: BaseScheduler) -> None:
|
|
41
41
|
"""Initialize the job provider.
|
|
42
42
|
|
|
43
43
|
Args:
|
|
@@ -78,9 +78,7 @@ class JobProvider:
|
|
|
78
78
|
)
|
|
79
79
|
return jobs
|
|
80
80
|
except NotImplementedError:
|
|
81
|
-
logger.warning(
|
|
82
|
-
f"Scheduler {self.scheduler.name} does not implement list_active_jobs"
|
|
83
|
-
)
|
|
81
|
+
logger.warning(f"Scheduler {self.scheduler.name} does not implement list_active_jobs")
|
|
84
82
|
return []
|
|
85
83
|
except Exception as e:
|
|
86
84
|
logger.error(f"Error fetching active jobs: {e}")
|
|
@@ -141,7 +139,7 @@ class JobProvider:
|
|
|
141
139
|
logger.error(f"Error fetching completed jobs: {e}")
|
|
142
140
|
return []
|
|
143
141
|
|
|
144
|
-
async def get_job_details(self, job_id: str) -> JobInfo | None:
|
|
142
|
+
async def get_job_details(self, job_id: str) -> tuple[JobInfo, dict[str, object]] | None:
|
|
145
143
|
"""Get detailed information for a single job.
|
|
146
144
|
|
|
147
145
|
Args:
|