diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index d7cb1642..5fa34af8 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -333,6 +333,9 @@ def report( ), ] = None, ): + """ + Generate a report about the Flows in the database. + """ jc = get_job_controller() timezone = datetime.now(tzlocal()).tzname() diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 6107a240..d225f158 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -1,15 +1,13 @@ -from typing import Annotated, Optional +from typing import Annotated import typer from rich.text import Text -from rich.tree import Tree from jobflow_remote.cli.jfr_typer import JFRTyper +from jobflow_remote.cli.types import tree_opt from jobflow_remote.cli.utils import ( cleanup_job_controller, complete_profiling, - find_subcommand, - get_command_tree, get_config_manager, initialize_config_manager, out_console, @@ -68,6 +66,7 @@ def main( hidden=True, ), ] = False, + print_tree: tree_opt = False, # If selected will print the tree of the CLI and exit ) -> None: """The controller CLI for jobflow-remote.""" from jobflow_remote import SETTINGS @@ -99,67 +98,3 @@ def main( except ConfigError: # no warning printed if not needed as this seems to be confusing for the user pass - - -@app.command() -def tree( - ctx: typer.Context, - start_path: Annotated[ - Optional[list[str]], - typer.Argument(help="Path to the starting command. e.g. 'jf tree job set'"), - ] = None, - show_options: Annotated[ - bool, - typer.Option( - "--options", - "-o", - help="Show command options in the tree", - ), - ] = False, - show_docs: Annotated[ - bool, - typer.Option( - "--docs", - "-D", - help="Show hidden commands", - ), - ] = False, - show_hidden: Annotated[ - bool, - typer.Option( - "--hidden", - "-h", - help="Show hidden commands", - ), - ] = False, - max_depth: Annotated[ - Optional[int], - typer.Option( - "--max-depth", - "-d", - help="Maximum depth of the tree to display", - ), - ] = None, -): - """ - Display a tree representation of the CLI command structure. - """ - # Get the top-level app - main_app = ctx.find_root().command - - if start_path: - start_command = find_subcommand(main_app, start_path) - if start_command is None: - typer.echo(f"Error: Command '{' '.join(start_path)}' not found", err=True) - raise typer.Exit(code=1) - tree_title = f"[bold red]{' '.join(start_path)}[/bold red]" - else: - start_command = main_app - tree_title = "[bold red]CLI App[/bold red]" - - tree = Tree(tree_title) - command_tree = get_command_tree( - start_command, tree, show_options, show_docs, show_hidden, max_depth - ) - - out_console.print(command_tree) diff --git a/src/jobflow_remote/cli/jfr_typer.py b/src/jobflow_remote/cli/jfr_typer.py index 1f59a068..63405574 100644 --- a/src/jobflow_remote/cli/jfr_typer.py +++ b/src/jobflow_remote/cli/jfr_typer.py @@ -3,6 +3,7 @@ import typer from typer.models import CommandFunctionType +from jobflow_remote.cli.types import tree_opt from jobflow_remote.cli.utils import cli_error_handler @@ -18,6 +19,9 @@ def __init__(self, *args, **kwargs) -> None: if "rich_markup_mode" not in kwargs: kwargs["rich_markup_mode"] = "rich" + if "callback" not in kwargs: + kwargs["callback"] = default_callback + # if "result_callback" not in kwargs: # kwargs["result_callback"] = test_cb @@ -44,3 +48,7 @@ def wrapper(fn): return typer_wrapper(fn) return wrapper + + +def default_callback(print_tree: tree_opt = False): + pass diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index a5970204..437a7d44 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -704,6 +704,9 @@ def report( ), ] = None, ): + """ + Generate a report about the Jobs in the database. + """ jc = get_job_controller() timezone = datetime.now(tzlocal()).tzname() @@ -718,7 +721,7 @@ def report( app_job_set = JFRTyper( - name="set", help="Commands for managing the jobs", no_args_is_help=True + name="set", help="Commands for setting properties for jobs", no_args_is_help=True ) app_job.add_typer(app_job_set) @@ -1162,6 +1165,9 @@ def files_get( ), ] = None, ) -> None: + """ + Retrieve files from the Job's execution folder. + """ db_id, job_id = get_job_db_ids(job_db_id, job_index) cm = get_config_manager() diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index 0152d759..a64c2e59 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -7,7 +7,12 @@ from jobflow_remote.cli.formatting import get_exec_config_table, get_worker_table from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper -from jobflow_remote.cli.types import force_opt, serialize_file_format_opt, verbosity_opt +from jobflow_remote.cli.types import ( + force_opt, + serialize_file_format_opt, + tree_opt, + verbosity_opt, +) from jobflow_remote.cli.utils import ( SerializeFileFormat, check_incompatible_opt, @@ -86,7 +91,10 @@ def list_projects( @app_project.callback(invoke_without_command=True) -def current_project(ctx: typer.Context) -> None: +def current_project( + ctx: typer.Context, + print_tree: tree_opt = False, # If selected will print the tree of the CLI and exit +) -> None: """Print the list of the project currently selected.""" # only run if no other subcommand is executed if ctx.invoked_subcommand is None: @@ -266,6 +274,9 @@ def remove( def list_exec_config( verbosity: verbosity_opt = 0, ) -> None: + """ + The list of defined Execution configurations + """ cm = get_config_manager() project = cm.get_project() table = get_exec_config_table(project.exec_config, verbosity) @@ -289,6 +300,9 @@ def list_exec_config( def list_worker( verbosity: verbosity_opt = 0, ) -> None: + """ + The list of defined workers + """ cm = get_config_manager() project = cm.get_project() table = get_worker_table(project.workers, verbosity) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index c96d9cab..6c14eee4 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -9,10 +9,21 @@ SerializeFileFormat, SortOption, str_to_dict, + tree_callback, ) from jobflow_remote.config.base import LogLevel from jobflow_remote.jobs.state import FlowState, JobState +tree_opt = Annotated[ + bool, + typer.Option( + "--tree", + help="Display a tree representation of the CLI command structure", + is_eager=True, + callback=tree_callback, + ), +] + job_ids_indexes_opt = Annotated[ Optional[list[str]], typer.Option( diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 85a3171b..3b21da91 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -17,6 +17,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from rich.prompt import Confirm from rich.text import Text +from rich.tree import Tree from typer.core import TyperCommand, TyperGroup from jobflow_remote import ConfigManager, JobController @@ -26,8 +27,6 @@ if TYPE_CHECKING: from cProfile import Profile - from rich.tree import Tree - from jobflow_remote.jobs.state import JobState logger = logging.getLogger(__name__) @@ -510,6 +509,7 @@ def get_command_tree( show_hidden: bool, max_depth: int | None, current_depth: int = 0, + max_doc_lines: int | None = None, ) -> Tree: """ Recursively build a tree representation of the command structure. @@ -530,6 +530,9 @@ def get_command_tree( Maximum depth of the tree to display. current_depth Current depth in the tree (used for recursion). + max_doc_lines + If show_docs is True, the maximum number of lines showed from the + documentation of each command/option. Returns ------- @@ -544,8 +547,17 @@ def get_command_tree( continue command_str = f"[bold green]{command_name}[/bold green]" - if show_docs and isinstance(command, TyperCommand) and command.help: + if ( + show_docs + and isinstance(command, (TyperCommand, TyperGroup)) + and command.help + ): command_str += f": {command.help}" + if max_doc_lines: + lines = command_str.splitlines() + command_str = "\n".join(lines[:max_doc_lines]) + if len(lines) > max_doc_lines: + command_str += " ..." branch = tree.add(command_str) if isinstance(command, TyperGroup): @@ -557,6 +569,7 @@ def get_command_tree( show_hidden, max_depth, current_depth + 1, + max_doc_lines, ) elif show_options: for param in command.params: @@ -568,37 +581,33 @@ def get_command_tree( param_str += " [red](required)[/red]" if show_docs and param.help: param_str += f": {param.help}" + if max_doc_lines: + lines = param_str.splitlines() + param_str = "\n".join(lines[:max_doc_lines]) + if len(lines) > max_doc_lines: + param_str += " ..." branch.add(param_str) return tree -def find_subcommand(app: TyperGroup, path: list[str]) -> TyperGroup | None: - """ - Find a subcommand in the command tree based on the given path. - - Parameters - ---------- - app - The root Typer app or command group. - path - List of command names forming the path to the desired subcommand. - - Returns - ------- - TyperGroup - The found subcommand, or None if not found. - """ - if not path: - return app - - current_command = app.commands.get(path[0]) - if current_command is None: - return None - - if len(path) == 1: - return current_command if isinstance(current_command, TyperGroup) else None - - if isinstance(current_command, TyperGroup): - return find_subcommand(current_command, path[1:]) +def tree_callback( + ctx: typer.Context, + value: bool, +): + if value: + main_app = ctx.command + tree_title = f"[bold red]{ctx.command.name}[/bold red]" + + tree = Tree(tree_title) + command_tree = get_command_tree( + main_app, + tree, + show_options=False, + show_docs=True, + show_hidden=False, + max_depth=None, + max_doc_lines=1, + ) - return None + out_console.print(command_tree) + raise typer.Exit(0) diff --git a/tests/db/cli/test_jf.py b/tests/db/cli/test_jf.py index 06c3a535..e2d3de51 100644 --- a/tests/db/cli/test_jf.py +++ b/tests/db/cli/test_jf.py @@ -2,25 +2,10 @@ def test_jobs_list() -> None: from jobflow_remote.testing.cli import run_check_cli outputs = ["job", "set", "resources", "admin"] - excluded = ["execution", "start_date"] # hidden, option - run_check_cli(["tree"], required_out=outputs, excluded_out=excluded) + excluded = ["─ execution", "start_date"] # hidden, option + run_check_cli(["--tree"], required_out=outputs, excluded_out=excluded) - # max depth - outputs = ["job", "set"] - excluded = ["execution", "start_date", "resources"] - run_check_cli(["tree", "-d", "2"], required_out=outputs, excluded_out=excluded) - - # hidden - outputs = ["job", "set", "resources", "execution"] - excluded = ["start_date"] - run_check_cli(["tree", "-h"], required_out=outputs, excluded_out=excluded) - - # options - outputs = ["job", "set", "resources", "start_date"] - excluded = ["execution"] - run_check_cli(["tree", "-o"], required_out=outputs, excluded_out=excluded) - - # start from - outputs = ["job set", "resources"] - excluded = ["execution", "start_date", "admin"] - run_check_cli(["tree", "job", "set"], required_out=outputs, excluded_out=excluded) + # test also on subcommands + outputs = ["job", "resources"] + excluded = ["─ execution", "start_date", "admin"] + run_check_cli(["job", "--tree"], required_out=outputs, excluded_out=excluded)