qq_lib.jobs

Presentation utilities for batch-system job listings and statistics.

This module provides JobsPresenter, which formats batch-system job data into compact CLI tables and Rich panels.

Unlike many other qq modules, this module operates purely on information obtained directly from the batch system and does not use qq info files.

 1# Released under MIT License.
 2# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab
 3
 4"""
 5Presentation utilities for batch-system job listings and statistics.
 6
 7This module provides `JobsPresenter`, which formats batch-system job data
 8into compact CLI tables and Rich panels.
 9
10Unlike many other qq modules, this module operates purely
11on information obtained directly from the batch system
12and does not use qq info files.
13"""
14
15from .presenter import JobsPresenter
16
17__all__ = ["JobsPresenter"]
class JobsPresenter:
 23class JobsPresenter:
 24    """
 25    Present information about a collection of jobs from the batch system and their statistics.
 26    """
 27
 28    # Mapping of human-readable color names to ANSI escape codes.
 29    _ANSI_COLORS = {
 30        # default
 31        "default": "",
 32        # standard colors
 33        "black": "\033[30m",
 34        "red": "\033[31m",
 35        "green": "\033[32m",
 36        "yellow": "\033[33m",
 37        "blue": "\033[34m",
 38        "magenta": "\033[35m",
 39        "cyan": "\033[36m",
 40        "white": "\033[37m",
 41        # bright colors
 42        "bright_black": "\033[90m",
 43        "bright_red": "\033[91m",
 44        "bright_green": "\033[92m",
 45        "bright_yellow": "\033[93m",
 46        "bright_blue": "\033[94m",
 47        "bright_magenta": "\033[95m",
 48        "bright_cyan": "\033[96m",
 49        "bright_white": "\033[97m",
 50        # other colors
 51        "grey90": "\033[38;5;254m",
 52        "grey70": "\033[38;5;249m",
 53        "grey50": "\033[38;5;244m",
 54        "grey30": "\033[38;5;239m",
 55        "grey10": "\033[38;5;233m",
 56        # bold:
 57        "bold": "\033[1m",
 58        # reset
 59        "reset": "\033[0m",
 60    }
 61
 62    # Table formatting configuration for `tabulate`.
 63    _COMPACT_TABLE = TableFormat(
 64        lineabove=Line("", "", "", ""),
 65        linebelowheader="",
 66        linebetweenrows="",
 67        linebelow=Line("", "", "", ""),
 68        headerrow=("", " ", ""),
 69        datarow=("", " ", ""),
 70        padding=0,
 71        with_header_hide=["lineabove", "linebelow"],
 72    )
 73
 74    def __init__(
 75        self,
 76        batch_system: AnyBatchClass,
 77        jobs: list[BatchJobInterface],
 78        extra: bool,
 79        all: bool,
 80        server: str | None,
 81    ):
 82        """
 83        Initialize the presenter with a list of jobs.
 84
 85        Args:
 86            jobs (list[BatchJobInterface]): List of job information objects
 87                to be presented.
 88            extra (bool): Should show additional info about jobs.
 89            all (bool): Show all jobs, not just queued and running.
 90            server (str | None): Batch server for which the jobs were collected.
 91                `None` = default server.
 92        """
 93        self._batch_system = batch_system
 94        self._jobs = jobs
 95        self._stats = JobsStatistics()
 96        self._extra = extra
 97        self._all = all
 98        self._server = server
 99
100    def create_jobs_info_panel(self, console: Console | None = None) -> Group:
101        """
102        Create a Rich panel displaying job information and statistics.
103
104        Args:
105            console (Console | None): Optional Rich Console instance.
106                If None, a new Console will be created.
107
108        Returns:
109            Group: Rich Group containing the jobs table and stats panel.
110        """
111        console = console or Console()
112
113        jobs_table = self._create_basic_jobs_table()
114        if self._extra:
115            jobs_table = self._insert_extra_info(jobs_table)
116
117        # convert ANSI codes to Rich Text
118        jobs_panel = Text.from_ansi(jobs_table)
119        stats_panel = self._stats.create_stats_panel()
120
121        content = Group(
122            jobs_panel,
123            Text(""),
124            stats_panel,
125        )
126
127        panel = Panel(
128            content,
129            title=Text(
130                "COLLECTED JOBS",
131                style=CFG.jobs_presenter.title_style,
132                justify="center",
133            ),
134            subtitle=Text(
135                f"{self._server}",
136                style=CFG.jobs_presenter.subtitle_style,
137                justify="center",
138            )
139            if self._server
140            else None,
141            border_style=CFG.jobs_presenter.border_style,
142            padding=(1, 1),
143            width=get_panel_width(
144                console, 1, CFG.jobs_presenter.min_width, CFG.jobs_presenter.max_width
145            ),
146            expand=False,
147        )
148
149        return Group(Text(""), panel, Text(""))
150
151    def dump_yaml(self) -> None:
152        """
153        Print the YAML representation of all jobs to stdout.
154        """
155        for job in self._jobs:
156            print(job.to_yaml())
157
158    def _create_basic_jobs_table(self) -> str:
159        """
160        Build a compact tabulated string representation of the job list.
161
162        Returns:
163            str: Tabulated job information with ANSI color codes applied.
164
165        Notes:
166            - Uses `tabulate` with `_COMPACT_TABLE` format because
167              Rich's Table is prohibitively slow for large number of items.
168            - Updates internal job statistics via `self._stats`.
169        """
170        headers = self._get_visible_headers()
171        rows = [self._create_job_row(job, headers) for job in self._jobs]
172
173        return tabulate(
174            rows,
175            headers=self._format_headers(headers),
176            tablefmt=JobsPresenter._COMPACT_TABLE,
177            stralign="center",
178            numalign="center",
179        )
180
181    def _get_visible_headers(self) -> list[str]:
182        """
183        Get list of headers to display based on the batch system configuration.
184
185        Return:
186            list[str]: A list of headers to show.
187        """
188        all_headers = [
189            "S",
190            "Job ID",
191            "User",
192            "Job Name",
193            "Queue",
194            "NCPUs",
195            "NGPUs",
196            "NNodes",
197            "Times",
198            "Node",
199            "%CPU",
200            "%Mem",
201            "Exit" if self._all or CFG.jobs_presenter.columns_to_show else None,
202        ]
203        headers_to_show = (
204            CFG.jobs_presenter.columns_to_show
205            or self._batch_system.jobs_presenter_columns_to_show()
206        )
207        return [h for h in all_headers if h and h in headers_to_show]
208
209    def _create_job_row(self, job: BatchJobInterface, headers: list[str]) -> list[str]:
210        """
211        Create a single row of job data.
212
213        Args:
214            job (BatchJobInterface): Job to show information for.
215            headers (list[str]): List of headers to include in the row
216
217        Returns:
218            list[str]: List of formatted cell values.
219        """
220        state = job.get_state()
221        start_time, end_time = self._get_job_times(job, state)
222
223        # update statistics
224        cpus = job.get_n_cpus() or 0
225        gpus = job.get_n_gpus() or 0
226        nodes = job.get_n_nodes() or 0
227        self._stats.add_job(state, cpus, gpus, nodes)
228
229        if self._server:
230            # show full job ID if we are working with a non-standard server
231            job_id = job.get_id()
232        else:
233            # otherwise, show only the numerical portion of the ID
234            job_id = JobsPresenter._shorten_job_id(job.get_id())
235
236        # build the row
237        row_data: dict[str, str] = {
238            "S": JobsPresenter._color(state.to_code(), state.color),
239            "Job ID": JobsPresenter._main_color(job_id),
240            "User": JobsPresenter._main_color(job.get_user() or ""),
241            "Job Name": JobsPresenter._main_color(
242                JobsPresenter._shorten_job_name(job.get_name() or "")
243            ),
244            "Queue": JobsPresenter._main_color(job.get_queue() or ""),
245            "NCPUs": JobsPresenter._main_color(str(cpus)),
246            "NGPUs": JobsPresenter._main_color(str(gpus)),
247            "NNodes": JobsPresenter._main_color(str(nodes)),
248            "Times": JobsPresenter._format_time(
249                state, start_time, end_time, job.get_walltime()
250            ),
251            "Node": JobsPresenter._format_nodes_or_comment(state, job),
252            "%CPU": JobsPresenter._format_util_cpu(job.get_util_cpu()),
253            "%Mem": JobsPresenter._format_util_mem(job.get_util_mem()),
254            "Exit": JobsPresenter._format_exit_code(job, state) if self._all else "",
255        }
256
257        return [row_data[header] for header in headers if header in row_data]
258
259    @staticmethod
260    def _get_job_times(
261        job: BatchJobInterface, state: BatchState
262    ) -> tuple[datetime | None, datetime | None]:
263        """
264        Get start and end times for a job based on its state.
265
266        Args:
267            job (BatchJobInterface): Job to get times for.
268            state (BatchState): The current job state.
269
270        Returns:
271            tuple[datetime | None, datetime | None]: Tuple of (start_time, end_time).
272        """
273        if state in {BatchState.QUEUED, BatchState.HELD, BatchState.WAITING}:
274            start_time = job.get_submission_time()
275        else:
276            start_time = job.get_start_time() or job.get_submission_time()
277
278        if state in {BatchState.FINISHED, BatchState.FAILED}:
279            end_time = job.get_completion_time() or job.get_modification_time()
280        else:
281            end_time = datetime.now()
282
283        return start_time, end_time
284
285    def _format_headers(self, headers: list[str]) -> list[str]:
286        """
287        Apply formatting to table headers.
288
289        Args:
290            headers (list[str]): List of headers to format.
291
292        Returns:
293            list[str]: List of formatted and colored headers.
294        """
295        return [
296            JobsPresenter._color(
297                header, color=CFG.jobs_presenter.headers_style, bold=True
298            )
299            for header in headers
300        ]
301
302    def _insert_extra_info(self, table: str) -> str:
303        """
304        Augment a formatted job table with additional information about each job.
305
306        Lines where job attributes are missing are skipped.
307
308        Args:
309            table (str): The formatted table string containing one line per job.
310
311        Returns:
312            str: A new table string including the extra job information lines.
313        """
314        split_table = table.splitlines()
315        table_with_extra_info = split_table[0] + "\n"
316
317        for line, job in zip(split_table[1:], self._jobs):
318            table_with_extra_info += line + "\n"
319
320            if input_machine := job.get_input_machine():
321                table_with_extra_info += JobsPresenter._color(
322                    f" >   Input machine:   {input_machine}\n",
323                    CFG.jobs_presenter.extra_info_style,
324                )
325
326            if input_dir := job.get_input_dir():
327                table_with_extra_info += JobsPresenter._color(
328                    f" >   Input directory: {str(input_dir)}\n",
329                    CFG.jobs_presenter.extra_info_style,
330                )
331
332            if comment := job.get_comment():
333                table_with_extra_info += JobsPresenter._color(
334                    f" >   Comment:         {comment}\n",
335                    CFG.jobs_presenter.extra_info_style,
336                )
337            table_with_extra_info += "\n"
338
339        return table_with_extra_info
340
341    @staticmethod
342    def _format_time(
343        state: BatchState,
344        start_time: datetime | None,
345        end_time: datetime | None,
346        walltime: timedelta | None,
347    ) -> str:
348        """
349        Format the job running time, queued time or completion time with color coding.
350
351        Args:
352            state (BatchState): Current job state.
353            start_time (datetime | None): Job submission or start time.
354            end_time (datetime | None): Job completion or current time.
355            walltime (timedelta | None): Scheduled walltime for the job.
356
357        Returns:
358            str: ANSI-colored string representing elapsed or finished time.
359        """
360        # return an empty string if any of the required times is missing
361        if start_time is None or end_time is None or walltime is None:
362            return ""
363
364        match state:
365            case BatchState.UNKNOWN | BatchState.SUSPENDED:
366                return ""
367            case BatchState.FAILED | BatchState.FINISHED:
368                return JobsPresenter._color(
369                    end_time.strftime(CFG.date_formats.standard), color=state.color
370                )
371            case (
372                BatchState.HELD
373                | BatchState.QUEUED
374                | BatchState.WAITING
375                | BatchState.MOVING
376            ):
377                return JobsPresenter._color(
378                    format_duration_wdhhmmss(end_time - start_time),
379                    color=state.color,
380                )
381            case BatchState.RUNNING | BatchState.EXITING:
382                run_time = end_time - start_time
383                return JobsPresenter._color(
384                    format_duration_wdhhmmss(run_time),
385                    color=CFG.jobs_presenter.strong_warning_style
386                    if run_time > walltime
387                    else state.color,
388                ) + JobsPresenter._main_color(
389                    f" / {format_duration_wdhhmmss(walltime)}"
390                )
391
392        return Text("")
393
394    @staticmethod
395    def _format_util_cpu(util: int | None) -> str:
396        """
397        Format CPU utilization with color coding.
398
399        Args:
400            util (int | None): CPU usage percentage.
401
402        Returns:
403            str: ANSI-colored string representation of CPU utilization,
404                 or empty string if `util` is None.
405        """
406        if util is None:
407            return ""
408
409        if util > 100:
410            color = CFG.jobs_presenter.strong_warning_style
411        elif util >= 80:
412            color = CFG.jobs_presenter.main_style
413        elif util >= 60:
414            color = CFG.jobs_presenter.mild_warning_style
415        else:
416            color = CFG.jobs_presenter.strong_warning_style
417
418        return JobsPresenter._color(str(util), color=color)
419
420    @staticmethod
421    def _format_util_mem(util: int | None) -> str:
422        """
423        Format memory utilization with color coding.
424
425        Args:
426            util (int | None): Memory usage percentage.
427
428        Returns:
429            str: ANSI-colored string representation of memory utilization,
430                 or empty string if `util` is None.
431        """
432        if util is None:
433            return ""
434
435        if util < 90:
436            color = CFG.jobs_presenter.main_style
437        elif util < 100:
438            color = CFG.jobs_presenter.mild_warning_style
439        else:
440            color = CFG.jobs_presenter.strong_warning_style
441
442        return JobsPresenter._color(str(util), color=color)
443
444    @staticmethod
445    def _format_exit_code(job: BatchJobInterface, state: BatchState) -> str:
446        """
447        Get formatted exit code if the job is completed and color it appropriately.
448
449        The color of the exit code is set based on the state of the job,
450        not on the value of the exit code.
451
452        If the job is not completed, returns an empty string.
453
454        Args:
455            job (BatchJobInterface): Job to get the exit code for.
456            state (BatchState): The current job state.
457
458        Returns:
459            str: ANSI-colored exit code. Empty string if the job is not completed
460            or the exit code is undefined.
461        """
462        if (exit_code := job.get_exit_code()) is None:
463            return ""
464
465        match state:
466            case BatchState.FINISHED:
467                return JobsPresenter._main_color(str(exit_code))
468            case BatchState.FAILED:
469                return JobsPresenter._color(
470                    str(exit_code), color=CFG.jobs_presenter.strong_warning_style
471                )
472            case _:
473                return ""
474
475    @staticmethod
476    def _format_nodes_or_comment(state: BatchState, job: BatchJobInterface) -> str:
477        """
478        Format node information or an estimated runtime comment.
479
480        Args:
481            state (BatchState): Current job state.
482            job (BatchJobInterface): Job information object.
483
484        Returns:
485            str: ANSI-colored string for working node(s) or estimated start,
486                 or an empty string if neither information is available.
487        """
488        if nodes := job.get_short_nodes():
489            return JobsPresenter._main_color(
490                JobsPresenter._shorten_nodes(" + ".join(nodes)),
491            )
492
493        if state in {BatchState.FINISHED, BatchState.FAILED}:
494            return ""
495
496        if estimated := job.get_estimated():
497            truncated_nodes = JobsPresenter._shorten_nodes(estimated[1])
498            return JobsPresenter._color(
499                f"{truncated_nodes} in {format_duration_wdhhmmss(estimated[0] - datetime.now()).rsplit(':', 1)[0]}",
500                color=state.color,
501            )
502
503        return ""
504
505    @staticmethod
506    def _shorten_job_id(job_id: str) -> str:
507        """
508        Shorten the job ID to its primary component (before the first dot).
509
510        Args:
511            job_id (str): Full job identifier.
512
513        Returns:
514            str: Shortened job ID.
515        """
516        return job_id.split(".", 1)[0]
517
518    @staticmethod
519    def _shorten_job_name(job_name: str) -> str:
520        """
521        Truncate a job name if it exceeds the maximum allowed display length.
522
523        Args:
524            job_name (str): The original job name string.
525
526        Returns:
527            str: The possibly shortened job name. If the original name length is
528                less than or equal to the configured limit, it is returned unchanged.
529        """
530        if len(job_name) > CFG.jobs_presenter.max_job_name_length:
531            return f"{job_name[: CFG.jobs_presenter.max_job_name_length]}…"
532
533        return job_name
534
535    @staticmethod
536    def _shorten_nodes(nodes: str) -> str:
537        """
538        Truncate a list of nodes if it exceeds the maximum allowed display length.
539
540        Args:
541            nodes (str): The original nodes string.
542
543        Returns:
544            str: The possibly shortened list of nodes. If the original string length
545                is less than or equal to the configured limit, it is returned unchanged.
546        """
547        if len(nodes) > CFG.jobs_presenter.max_nodes_length:
548            return f"{nodes[: CFG.jobs_presenter.max_nodes_length]}…"
549
550        return nodes
551
552    @staticmethod
553    def _color(string: str, color: str | None = None, bold: bool = False) -> str:
554        """
555        Apply ANSI color codes and optional bold styling to a string.
556
557        Args:
558            string (str): The string to colorize.
559            color (str | None): Optional color.
560            bold (bool): Whether to apply bold formatting.
561
562        Returns:
563            str: ANSI-colored and optionally bolded string.
564        """
565        return f"{JobsPresenter._ANSI_COLORS['bold'] if bold else ''}{JobsPresenter._ANSI_COLORS[color] if color else ''}{string}{JobsPresenter._ANSI_COLORS['reset'] if color or bold else ''}"
566
567    @staticmethod
568    def _main_color(string: str, bold: bool = False) -> str:
569        """
570        Apply the main presenter color with optional bold styling.
571
572        Args:
573            string (str): String to format.
574            bold (bool): Whether to apply bold formatting.
575
576        Returns:
577            str: ANSI-colored string in the main presenter color.
578        """
579        return JobsPresenter._color(string, CFG.jobs_presenter.main_style, bold)
580
581    @staticmethod
582    def _secondary_color(string: str, bold: bool = False) -> str:
583        """
584        Apply the secondary presenter color with optional bold styling.
585
586        Args:
587            string (str): String to format.
588            bold (bool): Whether to apply bold formatting.
589
590        Returns:
591            Text: ANSI-colored Rich Text object in secondary color.
592        """
593        return JobsPresenter._color(string, CFG.jobs_presenter.secondary_style, bold)

Present information about a collection of jobs from the batch system and their statistics.

JobsPresenter( batch_system: AnyBatchClass, jobs: list[qq_lib.batch.interface.BatchJobInterface], extra: bool, all: bool, server: str | None)
74    def __init__(
75        self,
76        batch_system: AnyBatchClass,
77        jobs: list[BatchJobInterface],
78        extra: bool,
79        all: bool,
80        server: str | None,
81    ):
82        """
83        Initialize the presenter with a list of jobs.
84
85        Args:
86            jobs (list[BatchJobInterface]): List of job information objects
87                to be presented.
88            extra (bool): Should show additional info about jobs.
89            all (bool): Show all jobs, not just queued and running.
90            server (str | None): Batch server for which the jobs were collected.
91                `None` = default server.
92        """
93        self._batch_system = batch_system
94        self._jobs = jobs
95        self._stats = JobsStatistics()
96        self._extra = extra
97        self._all = all
98        self._server = server

Initialize the presenter with a list of jobs.

Arguments:
  • jobs (list[BatchJobInterface]): List of job information objects to be presented.
  • extra (bool): Should show additional info about jobs.
  • all (bool): Show all jobs, not just queued and running.
  • server (str | None): Batch server for which the jobs were collected. None = default server.
def create_jobs_info_panel(self, console: rich.console.Console | None = None) -> rich.console.Group:
100    def create_jobs_info_panel(self, console: Console | None = None) -> Group:
101        """
102        Create a Rich panel displaying job information and statistics.
103
104        Args:
105            console (Console | None): Optional Rich Console instance.
106                If None, a new Console will be created.
107
108        Returns:
109            Group: Rich Group containing the jobs table and stats panel.
110        """
111        console = console or Console()
112
113        jobs_table = self._create_basic_jobs_table()
114        if self._extra:
115            jobs_table = self._insert_extra_info(jobs_table)
116
117        # convert ANSI codes to Rich Text
118        jobs_panel = Text.from_ansi(jobs_table)
119        stats_panel = self._stats.create_stats_panel()
120
121        content = Group(
122            jobs_panel,
123            Text(""),
124            stats_panel,
125        )
126
127        panel = Panel(
128            content,
129            title=Text(
130                "COLLECTED JOBS",
131                style=CFG.jobs_presenter.title_style,
132                justify="center",
133            ),
134            subtitle=Text(
135                f"{self._server}",
136                style=CFG.jobs_presenter.subtitle_style,
137                justify="center",
138            )
139            if self._server
140            else None,
141            border_style=CFG.jobs_presenter.border_style,
142            padding=(1, 1),
143            width=get_panel_width(
144                console, 1, CFG.jobs_presenter.min_width, CFG.jobs_presenter.max_width
145            ),
146            expand=False,
147        )
148
149        return Group(Text(""), panel, Text(""))

Create a Rich panel displaying job information and statistics.

Arguments:
  • console (Console | None): Optional Rich Console instance. If None, a new Console will be created.
Returns:

Group: Rich Group containing the jobs table and stats panel.

def dump_yaml(self) -> None:
151    def dump_yaml(self) -> None:
152        """
153        Print the YAML representation of all jobs to stdout.
154        """
155        for job in self._jobs:
156            print(job.to_yaml())

Print the YAML representation of all jobs to stdout.