qq_lib.nodes

Provides node presentation utilities.

This module organizes and formats information about compute nodes as reported by the batch system, preparing it for human-readable terminal output.

Internal grouping logic clusters nodes with similar naming patterns, extracts shared attributes, and aggregates resource and property data. These groups are then rendered by NodesPresenter, which produces a unified panel showing node availability, CPU/GPU capacities, scratch resources, and other relevant metrics.

 1# Released under MIT License.
 2# Copyright (c) 2025-2026 Ladislav Bartos and Robert Vacha Lab
 3
 4"""
 5Provides node presentation utilities.
 6
 7This module organizes and formats information about compute nodes as reported by
 8the batch system, preparing it for human-readable terminal output.
 9
10Internal grouping logic clusters nodes with similar naming patterns, extracts
11shared attributes, and aggregates resource and property data. These groups are
12then rendered by `NodesPresenter`, which produces a unified panel showing node
13availability, CPU/GPU capacities, scratch resources, and other relevant metrics.
14"""
15
16from .presenter import NodesPresenter
17
18__all__ = ["NodesPresenter"]
class NodesPresenter:
396class NodesPresenter:
397    """
398    Presenter class for displaying information about batch system nodes.
399    """
400
401    def __init__(
402        self, nodes: list[BatchNodeInterface], user: str, all: bool, server: str | None
403    ):
404        """
405        Initialize the presenter with a list of nodes.
406
407        Args:
408            nodes (list[BatchNodeInterface]): List of node information objects
409                to be presented.
410            user (str): Name of the user for which the nodes are displayed.
411            all (boolean): Display all nodes or only those that are available.
412            server (str | None): Batch server for which the nodes were collected.
413                `None` = default server.
414        """
415        self._nodes = nodes
416        self._user = user
417        self._display_all = all
418        self._server = server
419
420        self._node_groups = self._create_node_groups()
421
422    def dump_yaml(self) -> None:
423        """
424        Print the YAML representation of all nodes to stdout.
425        """
426        for node in self._nodes:
427            print(node.to_yaml())
428
429    def create_nodes_info_panel(self, console: Console | None = None) -> Group:
430        """
431        Build a complete Rich panel summarizing all node groups.
432
433        Args:
434            console (Console | None): Optional Rich console instance used to
435                determine available terminal width. If None, a new console
436                is created.
437
438        Returns:
439            Group: A Rich `Group` containing the formatted node information panel.
440        """
441        console = console or Console()
442
443        groups = self._node_groups
444        parts = [g.create_full_info_panel() for g in self._node_groups]
445        seps = [NodesPresenter._create_separator(g.name) for g in self._node_groups[1:]]
446
447        content: list[Group] = NodesPresenter._interleave(parts, seps)
448
449        if len(groups) > 1:
450            content.append(self._create_metadata_panel())
451
452        panel = Panel(
453            Group(*content),
454            title=Text(
455                f"NODE GROUP: {groups[0].name}",
456                style=CFG.nodes_presenter.title_style,
457                justify="center",
458            ),
459            subtitle=Text(
460                f"{self._server}",
461                style=CFG.jobs_presenter.subtitle_style,
462                justify="center",
463            )
464            if self._server is not None
465            else None,
466            border_style=CFG.nodes_presenter.border_style,
467            padding=(1, 1),
468            width=get_panel_width(
469                console, 1, CFG.nodes_presenter.min_width, CFG.nodes_presenter.max_width
470            ),
471            expand=False,
472        )
473
474        return Group(Text(""), panel, Text(""))
475
476    def _create_metadata_panel(self) -> Group:
477        """
478        Create a summary panel displaying overall statistics across all node groups.
479
480        Returns:
481            Group: A Rich `Group` containing the overall statistics summary.
482        """
483        total_stats = NodeGroupStats.sum_stats(*(g.stats for g in self._node_groups))
484
485        return Group(
486            "",
487            Rule(
488                title=Text(
489                    "OVERALL STATISTICS",
490                    style=CFG.nodes_presenter.title_style,
491                ),
492                style=CFG.nodes_presenter.rule_style,
493            ),
494            "",
495            NodesPresenter._format_metadata_table(
496                list(total_stats.properties), "All properties", total_stats
497            ),
498        )
499
500    def _create_node_groups(self) -> list[NodeGroup]:
501        """
502        Organize nodes into logical groups based on common name prefixes.
503
504        Nodes sharing the same alphabetic prefix are grouped together (e.g.,
505        `node1`, `node2`, `node3` form one group). Groups with fewer than three
506        nodes are merged into a generic "others" group.
507
508        Returns:
509            list[NodeGroup]: A list of node groups created from the input nodes.
510        """
511        raw_groups = defaultdict(list)
512        unassigned = []
513
514        # get nodes with same names
515        for node in self._nodes:
516            name = node.get_name()
517            match = re.match(r"[A-Za-z]+", name)
518            prefix = match.group(0) if match else ""
519            raw_groups[prefix].append(node)
520
521        # create node groups; each node group must have at least 3 members
522        groups: list[NodeGroup] = []
523        for prefix, nodes in raw_groups.items():
524            if len(nodes) >= 3:
525                groups.append(NodeGroup(prefix, nodes, self._user))
526            else:
527                unassigned.extend(nodes)
528
529        # create a node group for the unassigned nodes
530        if unassigned:
531            groups.append(
532                NodeGroup(
533                    CFG.nodes_presenter.others_group_name
534                    if len(groups) > 0
535                    else CFG.nodes_presenter.all_nodes_group_name,
536                    unassigned,
537                    self._user,
538                )
539            )
540
541        return groups
542
543    def _create_separator(title: str) -> Group:
544        """
545        Create a visual separator between node group panels.
546
547        Args:
548            title (str): The title to display within the rule separator.
549
550        Returns:
551            Group: A Rich `Group` containing a rule with the given title.
552        """
553        return Group(
554            "",
555            Rule(
556                title=Text(
557                    f"NODE GROUP: {title}", style=CFG.nodes_presenter.title_style
558                ),
559                style=CFG.nodes_presenter.rule_style,
560            ),
561            "",
562        )
563
564    def _interleave(sections: list[Group], seps: list[Group]) -> list[Group]:
565        """
566        Interleave content sections with separator elements.
567
568        Args:
569            sections (list[Group]): Panels or sections to display.
570            seps (list[Group]): Separators to insert between sections.
571
572        Returns:
573            list[Group]: Combined ordered list of sections and separators.
574        """
575        chunks = []
576        for part, sep in zip_longest(sections, seps, fillvalue=None):
577            if part is not None:
578                chunks.append(part)
579            if sep is not None:
580                chunks.append(sep)
581        return chunks
582
583    @staticmethod
584    def _format_processing_units(free: int, total: int, available: bool) -> Text:
585        """
586        Format numbers of free and total CPUs or GPUs as a styled Rich text element.
587
588        Args:
589            free (int): Number of free units (e.g., CPUs or GPUs).
590            total (int): Total number of units.
591            available (bool): Whether the node is available to the user.
592
593        Returns:
594            Text: A styled Rich text element showing free and total counts.
595        """
596        if not available:
597            style = CFG.nodes_presenter.unavailable_node_style
598        elif total == 0:
599            style = CFG.nodes_presenter.main_text_style
600        elif total == free:
601            style = CFG.nodes_presenter.free_node_style
602        elif free > 0:
603            style = CFG.nodes_presenter.part_free_node_style
604        else:
605            style = CFG.nodes_presenter.busy_node_style
606
607        return Text(f"{free} / {total}", style=style)
608
609    @staticmethod
610    def _format_size_property(free: Size, total: Size, style: str) -> Text:
611        """
612        Format a memory or storage property as a styled text string.
613
614        Args:
615            free (Size): Available memory or storage.
616            total (Size): Total memory or storage.
617            style (str): Rich style string to apply to the text.
618
619        Returns:
620            Text: A formatted text element showing free and total capacity.
621        """
622        return Text(f"{free} / {total}", style=style)
623
624    @staticmethod
625    def _format_node_properties(
626        props: list[str], shared_props: list[str], style: str
627    ) -> Text:
628        """
629        Format node-specific properties for display, excluding shared ones.
630
631        Args:
632            props (list[str]): All properties of the node.
633            shared_props (list[str]): Properties common to all nodes in the group.
634            style (str): Rich text style for formatting.
635
636        Returns:
637            Text: Comma-separated list of unique node properties.
638        """
639        return Text(", ".join(x for x in props if x not in shared_props), style=style)
640
641    @staticmethod
642    def _format_state_mark(
643        free_cpus: int,
644        total_cpus: int,
645        free_gpus: int,
646        total_gpus: int,
647        available: bool,
648    ) -> Text:
649        """
650        Generate a state mark symbol indicating node utilization and availability.
651
652        Args:
653            free_cpus (int): Number of free CPU cores.
654            total_cpus (int): Total number of CPU cores.
655            free_gpus (int): Number of free GPUs.
656            total_gpus (int): Total number of GPUs.
657            available (bool): Whether the node is accessible to the user.
658
659        Returns:
660            Text: A styled Rich text symbol representing node state.
661        """
662        if not available:
663            style = CFG.nodes_presenter.unavailable_node_style
664        elif free_cpus == total_cpus and free_gpus == total_gpus:
665            style = CFG.nodes_presenter.free_node_style
666        elif free_cpus != 0 or free_gpus != 0:
667            style = CFG.nodes_presenter.part_free_node_style
668        else:
669            style = CFG.nodes_presenter.busy_node_style
670
671        return Text(CFG.nodes_presenter.state_mark, style=style)
672
673    @staticmethod
674    def _format_properties_section(props: list[str], title: str) -> Text:
675        """
676        Create a formatted text section showing a list of properties.
677
678        Args:
679            props (list[str]): Properties to display.
680            title (str): Section label to prefix before the property list.
681
682        Returns:
683            Text: A Rich text object combining the title and formatted property list.
684        """
685        return Text(
686            f"{title}: ",
687            style=f"{CFG.nodes_presenter.main_text_style} bold",
688        ) + Text(
689            (", ".join(sorted(props))).ljust(CFG.nodes_presenter.max_props_panel_width),
690            style=f"{CFG.nodes_presenter.main_text_style} not bold",
691        )
692
693    @staticmethod
694    def _format_metadata_table(
695        props: list[str], title: str, stats: NodeGroupStats
696    ) -> Table:
697        """
698        Create a metadata table showing a list of (shared/all) properties
699        and an aggregated statistics summary (CPU, GPU, node counts).
700
701        Args:
702            props (list[str]): List of properties to display.
703            title (str): Title label for the property section.
704            stats (NodeGroupStats): Aggregated statistics for the corresponding node group.
705
706        Returns:
707            Table: A Rich grid table with property and statistics columns.
708        """
709        grid = Table.grid(expand=False, padding=(0, 5))
710        grid.add_column(max_width=CFG.nodes_presenter.max_props_panel_width)
711        grid.add_column()
712
713        grid.add_row(
714            NodesPresenter._format_properties_section(props, title),
715            stats.create_stats_table(),
716        )
717
718        return grid

Presenter class for displaying information about batch system nodes.

NodesPresenter( nodes: list[qq_lib.batch.interface.BatchNodeInterface], user: str, all: bool, server: str | None)
401    def __init__(
402        self, nodes: list[BatchNodeInterface], user: str, all: bool, server: str | None
403    ):
404        """
405        Initialize the presenter with a list of nodes.
406
407        Args:
408            nodes (list[BatchNodeInterface]): List of node information objects
409                to be presented.
410            user (str): Name of the user for which the nodes are displayed.
411            all (boolean): Display all nodes or only those that are available.
412            server (str | None): Batch server for which the nodes were collected.
413                `None` = default server.
414        """
415        self._nodes = nodes
416        self._user = user
417        self._display_all = all
418        self._server = server
419
420        self._node_groups = self._create_node_groups()

Initialize the presenter with a list of nodes.

Arguments:
  • nodes (list[BatchNodeInterface]): List of node information objects to be presented.
  • user (str): Name of the user for which the nodes are displayed.
  • all (boolean): Display all nodes or only those that are available.
  • server (str | None): Batch server for which the nodes were collected. None = default server.
def dump_yaml(self) -> None:
422    def dump_yaml(self) -> None:
423        """
424        Print the YAML representation of all nodes to stdout.
425        """
426        for node in self._nodes:
427            print(node.to_yaml())

Print the YAML representation of all nodes to stdout.

def create_nodes_info_panel(self, console: rich.console.Console | None = None) -> rich.console.Group:
429    def create_nodes_info_panel(self, console: Console | None = None) -> Group:
430        """
431        Build a complete Rich panel summarizing all node groups.
432
433        Args:
434            console (Console | None): Optional Rich console instance used to
435                determine available terminal width. If None, a new console
436                is created.
437
438        Returns:
439            Group: A Rich `Group` containing the formatted node information panel.
440        """
441        console = console or Console()
442
443        groups = self._node_groups
444        parts = [g.create_full_info_panel() for g in self._node_groups]
445        seps = [NodesPresenter._create_separator(g.name) for g in self._node_groups[1:]]
446
447        content: list[Group] = NodesPresenter._interleave(parts, seps)
448
449        if len(groups) > 1:
450            content.append(self._create_metadata_panel())
451
452        panel = Panel(
453            Group(*content),
454            title=Text(
455                f"NODE GROUP: {groups[0].name}",
456                style=CFG.nodes_presenter.title_style,
457                justify="center",
458            ),
459            subtitle=Text(
460                f"{self._server}",
461                style=CFG.jobs_presenter.subtitle_style,
462                justify="center",
463            )
464            if self._server is not None
465            else None,
466            border_style=CFG.nodes_presenter.border_style,
467            padding=(1, 1),
468            width=get_panel_width(
469                console, 1, CFG.nodes_presenter.min_width, CFG.nodes_presenter.max_width
470            ),
471            expand=False,
472        )
473
474        return Group(Text(""), panel, Text(""))

Build a complete Rich panel summarizing all node groups.

Arguments:
  • console (Console | None): Optional Rich console instance used to determine available terminal width. If None, a new console is created.
Returns:

Group: A Rich Group containing the formatted node information panel.