hpc-runner 0.3.0__py3-none-any.whl → 0.3.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.
@@ -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", False)
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: "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 _build_directives(self, job: "Job", array_range: str | None = None) -> list[str]:
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: "Job") -> str | None:
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: "Job") -> list[str]:
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: "Job") -> list[str]:
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: "Job", keep_script: bool = False) -> JobResult:
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: "Job", keep_script: bool = False) -> JobResult:
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._generate_interactive_script(
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 _generate_interactive_script(
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: "JobArray") -> ArrayJobResult:
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
- basic_info.submit_time = datetime.fromtimestamp(job_data["submit_time"])
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
- basic_info.start_time = datetime.fromtimestamp(job_data["start_time"])
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: "ET.Element") -> None:
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) -> "ET.Element | None":
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)
@@ -80,11 +80,6 @@ if [ -n "$MAKEFLAGS" ]; then
80
80
  fi
81
81
  {% endif %}
82
82
 
83
- {% if job.workdir %}
84
- # Change to working directory
85
- cd {{ job.workdir }}
86
- {% endif %}
87
-
88
83
  # Execute command
89
84
  {{ job.command }}
90
85
  _exit_code=$?
@@ -76,11 +76,6 @@ if [ -n "$MAKEFLAGS" ]; then
76
76
  fi
77
77
  {% endif %}
78
78
 
79
- {% if job.workdir %}
80
- # Change to working directory
81
- cd {{ job.workdir }}
82
- {% endif %}
83
-
84
79
  # Execute command
85
80
  {{ job.command }}
86
81
  _exit_code=$?
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
- path = self.save_screenshot(path="./")
173
- self.notify(f"Screenshot saved: {path}", timeout=3)
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: callable = None,
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: callable = None,
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)
@@ -37,7 +37,7 @@ class JobProvider:
37
37
  current_user: The current username for filtering.
38
38
  """
39
39
 
40
- def __init__(self, scheduler: "BaseScheduler") -> None:
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: