diff --git a/README.md b/README.md index ceb10c8..d4ac0d5 100644 --- a/README.md +++ b/README.md @@ -131,5 +131,20 @@ To install tracksuite using pip (requires python, ecFlow (optional) and pip): --skip-attributes Don't synchronise attributes --skip-repeat Don't synchronise repeat +**To print the status of the suite (useful to create small html or md summary):** + + usage: tracksuite-print [-h] [--host HOST] [--port PORT] [-f FORMAT] node + + Print ecFlow node tree with states + + positional arguments: + node Ecflow node on server to print + + options: + -h, --help show this help message and exit + --host HOST Target host + --port PORT Ecflow port + -f FORMAT, --format FORMAT + Output format (md, html, raw) ## Overview ![](workflow.png) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bde5787..bfdfdef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,11 @@ exclude = ["tests"] tracksuite-revert = "tracksuite.revert:main" tracksuite-update-defs = "tracksuite.definition:main" tracksuite-replace = "tracksuite.replace:main" + tracksuite-print = "tracksuite.print:main" [tool.setuptools_scm] -write_to = "tracksuite/_version.py" -write_to_template = ''' +version_file = "tracksuite/_version.py" +version_file_template = ''' # Do not change! Do not track in version control! __version__ = "{version}" ''' diff --git a/tracksuite/ecflow_client.py b/tracksuite/ecflow_client.py index cde24a6..72db053 100644 --- a/tracksuite/ecflow_client.py +++ b/tracksuite/ecflow_client.py @@ -51,12 +51,20 @@ def get_defs(self): def get_suite(self, name: str) -> ecflow.Suite: """ - Get the suite definition from the server. + Get the suite object from the server for a given suite name. """ defs = self.get_defs() suite = defs.find_suite(name) return suite + def get_node(self, name: str) -> ecflow.Node: + """ + Get a node object from the server for a given path. + """ + defs = self.get_defs() + node = defs.find_abs_node(name) + return node + def set_state(self, node_path: str, state: str): """ Set the state of the node on the server. @@ -198,7 +206,14 @@ def sync_node_recursive( for new_child in new_node.nodes: for old_child in old_node.nodes: if new_child.name() == old_child.name(): - self.sync_node_recursive(new_child, old_child, attributes) + self.sync_node_recursive( + new_child, + old_child, + attributes, + skip_status, + skip_attributes, + skip_repeat, + ) break else: log.warning( diff --git a/tracksuite/print.py b/tracksuite/print.py new file mode 100644 index 0000000..0a25fc8 --- /dev/null +++ b/tracksuite/print.py @@ -0,0 +1,289 @@ +""" +tracksuite.print +================ + +Provides utilities for printing ecFlow node trees with state icons and formatting options. +Supports raw, Markdown, and HTML output formats. Displays job output logs for tasks if available. + +Classes: + SuiteDisplay: Extracts and formats ecFlow node trees and associated logs. + Formatter: Abstract base class for output formatters. + RawFormatter: Formatter for plain text output. + MarkdownFormatter: Formatter for Markdown output. + HTMLFormatter: Formatter for HTML output. + +Functions: + get_state_icon(node): Returns a unicode icon for the ecFlow node state. + get_formatter(format_type): Returns a formatter instance for the given format type. + get_parser(): Returns an argument parser for the CLI. + main(args=None): CLI entry point. + +Usage: + tracksuite-print [--host HOST] [--port PORT] [-f FORMAT] +""" + +import argparse +import os + +import ecflow + +from tracksuite.ecflow_client import EcflowClient + + +def get_state_icon(node): + """ + Return a unicode icon representing the state of the given ecFlow node. + + Args: + node (ecflow.Node): The ecFlow node. + + Returns: + str: Unicode icon for the node state. + + Raises: + ValueError: If the node state is unknown. + """ + if node.get_state() == ecflow.State.complete: + return "✅" + elif node.get_state() == ecflow.State.active: + return "▶️" + elif node.get_state() == ecflow.State.queued: + return "⏸️" + elif node.get_state() == ecflow.State.submitted: + return "🔄" + elif node.get_state() == ecflow.State.aborted: + return "❌" + else: + raise ValueError(f"Unknown state: {node.get_state()}") + + +class SuiteDisplay: + """ + Extracts and formats an ecFlow node tree and associated job output logs. + + Args: + format (str): Output format ('raw', 'md', or 'html'). + """ + + def __init__(self, format="raw"): + self.tree = "" + self.content = {} + self.formatter = get_formatter(format) + + def process_task(self, task): + """ + Process a task node, extracting its job output if available. + + Args: + task (ecflow.Task): The task node. + + Returns: + str: Path label for the task. + """ + var = task.find_gen_variable("ECF_JOBOUT") + jobout = getattr(var, "value")() + if jobout and os.path.exists(jobout): + with open(jobout, "r") as f: + jobout_content = f.read() + else: + jobout_content = "Could not find jobout file." + path = f"{task.get_abs_node_path()} + {get_state_icon(task)}" + self.content[path] = jobout_content + return path + + def extract_node_tree(self, node, prefix=""): + """ + Recursively extract the node tree structure and build formatted output. + + Args: + node (ecflow.Node): The root node. + prefix (str): Prefix for tree formatting. + """ + if self.tree == "": + self.tree = f"{node.name()}/\n" + + children = node.nodes + n_child = 0 + for child in children: + n_child += 1 + children = node.nodes + for i, child in enumerate(children): + connector = "└── " if i == n_child - 1 else "├── " + state_icon = get_state_icon(child) + if isinstance(child, ecflow.Task): + anchor = self.process_task(child) + label = self.formatter.label(anchor, f"{child.name()} {state_icon}") + else: + label = f"{child.name()} {state_icon}" + line = f"{prefix}{connector}{label}\n" + self.tree += line + try: + if child.nodes: + extension = " " if i == n_child - 1 else "│ " + self.extract_node_tree(child, prefix + extension) + except Exception: + # hit in case of Alias type, but maybe others too + pass + + def print(self): + """ + Print the formatted node tree and logs using the selected formatter. + """ + self.formatter.tree(self.tree) + self.formatter.logs(self.content) + + +def get_formatter(format_type): + """ + Return a formatter instance for the given format type. + + Args: + format_type (str): Output format ('raw', 'md', or 'html'). + + Returns: + Formatter: Formatter instance. + + Raises: + ValueError: If the format type is unknown. + """ + if format_type == "raw": + return RawFormatter() + elif format_type == "md": + return MarkdownFormatter() + elif format_type == "html": + return HTMLFormatter() + else: + raise ValueError(f"Unknown format type: {format_type}") + + +class Formatter: + """ + Abstract base class for output formatters. + """ + + def tree(self, tree): + """ + Print the formatted node tree. + + Args: + tree (str): The formatted tree string. + """ + raise NotImplementedError("Header method not implemented") + + def logs(self, logs): + """ + Print the logs for each node. + + Args: + logs (dict): Mapping from node path to log content. + """ + raise NotImplementedError("Logs method not implemented") + + def label(self, anchor, label): + """ + Format a label for a node. + + Args: + anchor (str): Anchor or reference for the node. + label (str): Display label. + + Returns: + str: Formatted label. + """ + raise NotImplementedError("Label method not implemented") + + +class RawFormatter(Formatter): + """ + Formatter for plain text output. + """ + + def tree(self, tree): + print(tree) + + def logs(self, logs): + print("Logs cannot be printed in raw format.") + + def label(self, anchor, label): + return label + + +class MarkdownFormatter(RawFormatter): + """ + Formatter for Markdown output. + """ + + def tree(self, tree): + print("```text") + print(tree) + print("```") + + def logs(self, logs): + for name, content in logs.items(): + print(f'
\n') + print(f"{name}\n\n") + print(f"{content}\n\n") + print("
\n") + + +class HTMLFormatter(Formatter): + """ + Formatter for HTML output. + """ + + def tree(self, tree): + print("
")
+        print(tree)
+        print("
\n") + + def logs(self, logs): + for name, content in logs.items(): + print(f'
\n') + print(f"{name}\n\n") + print("
\n")
+            print(f"{content}\n\n")
+            print("
\n") + print("
\n") + + def label(self, anchor, label): + return f'{label}' + + +def get_parser(): + """ + Return an argument parser for the CLI. + + Returns: + argparse.ArgumentParser: The argument parser. + """ + description = "Print ecFlow node tree with states" + parser = argparse.ArgumentParser(description=description) + parser.add_argument("node", help="Ecflow node on server to print") + parser.add_argument("--host", default=os.getenv("HOSTNAME"), help="Target host") + parser.add_argument("--port", default=3141, help="Ecflow port") + parser.add_argument( + "-f", "--format", default="raw", help="Output format (md, html, raw)" + ) + return parser + + +def main(args=None): + """ + CLI entry point for printing ecFlow node trees. + + Args: + args (list, optional): Command-line arguments. + """ + parser = get_parser() + args = parser.parse_args() + + client = EcflowClient(args.host, args.port) + node = client.get_node(args.node) + + processor = SuiteDisplay(args.format) + processor.extract_node_tree(node) + processor.print() + + +if __name__ == "__main__": + main()