From 59db014e78f87ab69469854fef427659edba6b6d Mon Sep 17 00:00:00 2001 From: Walter <44253688+svew@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:56:32 -0800 Subject: [PATCH 1/6] Update trogon.py --- trogon/trogon.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/trogon/trogon.py b/trogon/trogon.py index e36eb51..5cd6fa1 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -61,6 +61,7 @@ def __init__( cli: click.BaseCommand, click_app_name: str, command_name: str, + click_context: click.Context, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -69,7 +70,7 @@ def __init__( self.command_data: UserCommandData = UserCommandData(CommandName("_default")) self.cli = cli self.is_grouped_cli = isinstance(cli, click.Group) - self.command_schemas = introspect_click_app(cli) + self.command_schemas = introspect_click_app(cli, click_context) self.click_app_name = click_app_name self.command_name = command_name @@ -213,6 +214,7 @@ def __init__( self.post_run_command: list[str] = [] self.is_grouped_cli = isinstance(cli, click.Group) self.execute_on_exit = False + self.click_context = click_context if app_name is None and click_context is not None: self.app_name = detect_run_string() else: @@ -220,7 +222,12 @@ def __init__( self.command_name = command_name def get_default_screen(self) -> CommandBuilder: - return CommandBuilder(self.cli, self.app_name, self.command_name) + return CommandBuilder( + self.cli, + self.app_name, + self.command_name, + self.click_context, + ) @on(Button.Pressed, "#home-exec-button") def on_button_pressed(self): @@ -282,20 +289,30 @@ def action_visit(self, url: str) -> None: open_url(url) -def tui(name: str | None = None, command: str = "tui", help: str = "Open Textual TUI."): +def tui( + name: str | None = None, + command: str = "tui", + run_if_no_command: bool = False, + help: str = "Open Textual TUI." +): + def decorator(app: click.Group | click.Command): @click.pass_context def wrapped_tui(ctx, *args, **kwargs): Trogon(app, app_name=name, command_name=command, click_context=ctx).run() if isinstance(app, click.Group): - app.command(name=command, help=help)(wrapped_tui) + group = app else: - new_group = click.Group() - new_group.add_command(app) - new_group.command(name=command, help=help)(wrapped_tui) - return new_group + group = click.Group() + group.add_command(app) - return app + if run_if_no_command: + group.invoke_without_command = True + group.no_args_is_help = False + group.callback = wrapped_tui + else: + group.command(name=command, help=help)(wrapped_tui) + return group return decorator From 2913cbd677357831c0750ea8800134d58c90520c Mon Sep 17 00:00:00 2001 From: Walter <44253688+svew@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:58:24 -0800 Subject: [PATCH 2/6] Update introspect.py --- trogon/introspect.py | 215 ++++++++++++++++++++++++++++--------------- 1 file changed, 139 insertions(+), 76 deletions(-) diff --git a/trogon/introspect.py b/trogon/introspect.py index d200fa4..d3bdcae 100644 --- a/trogon/introspect.py +++ b/trogon/introspect.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any, Callable, Sequence, NewType @@ -8,6 +9,9 @@ from click import BaseCommand, ParamType +CommandName = NewType("CommandName", str) + + def generate_unique_id(): return f"id_{str(uuid.uuid4())[:8]}" @@ -68,17 +72,42 @@ class ArgumentSchema: nargs: int = 1 -@dataclass -class CommandSchema: - name: CommandName - function: Callable[..., Any | None] - key: str = field(default_factory=generate_unique_id) - docstring: str | None = None - options: list[OptionSchema] = field(default_factory=list) - arguments: list[ArgumentSchema] = field(default_factory=list) - subcommands: dict["CommandName", "CommandSchema"] = field(default_factory=dict) - parent: "CommandSchema | None" = None - is_group: bool = False +class CommandSchema(ABC): + + def __init__(self, name: CommandName, parent: "CommandSchema | None" = None): + self.name = name + self.parent = parent + self.key = generate_unique_id() + + @property + @abstractmethod + def options(self) -> list[OptionSchema]: + pass + + @property + @abstractmethod + def arguments(self) -> list[ArgumentSchema]: + pass + + @property + @abstractmethod + def subcommands(self) -> dict["CommandName", "CommandSchema"]: + pass + + @property + @abstractmethod + def docstring(self) -> str | None: + pass + + @property + @abstractmethod + def function(self) -> Callable[..., Any | None]: + pass + + @property + @abstractmethod + def is_group(self) -> bool: + pass @property def path_from_root(self) -> list["CommandSchema"]: @@ -92,7 +121,101 @@ def path_from_root(self) -> list["CommandSchema"]: return list(reversed(path)) -def introspect_click_app(app: BaseCommand) -> dict[CommandName, CommandSchema]: +class ClickCommandSchema(CommandSchema): + + def __init__( + self, + cmd_obj: click.Command, + cmd_ctx: click.Context, + cmd_name: CommandName | None = None, + parent: CommandSchema | None = None, + ): + super().__init__(cmd_name or cmd_obj.name, parent) + self.cmd_obj = cmd_obj + self.cmd_ctx = cmd_ctx + self._options = None + self._arguments = None + self._subcommands = None + self._docstring = None + + @property + def options(self) -> list[OptionSchema]: + if self._options is None: + self._options = list[OptionSchema]() + for param in self.cmd_obj.get_params(self.cmd_ctx): + default = MultiValueParamData.process_cli_option(param.default) + if isinstance(param, (click.Option, click.core.Group)): + option_data = OptionSchema( + name=param.opts, + type=param.type, + is_flag=param.is_flag, + is_boolean_flag=param.is_bool_flag, + flag_value=param.flag_value, + counting=param.count, + opts=param.opts, + secondary_opts=param.secondary_opts, + required=param.required, + default=default, + help=param.help, + multiple=param.multiple, + nargs=param.nargs, + ) + if isinstance(param.type, click.Choice): + option_data.choices = param.type.choices + self._options.append(option_data) + return self._options + + @property + def arguments(self) -> list[ArgumentSchema]: + if self._arguments is None: + self._arguments = list[ArgumentSchema]() + for param in self.cmd_obj.get_params(self.cmd_ctx): + default = MultiValueParamData.process_cli_option(param.default) + if isinstance(param, click.Argument): + argument_data = ArgumentSchema( + name=param.name, + type=param.type, + required=param.required, + multiple=param.multiple, + default=default, + nargs=param.nargs, + ) + if isinstance(param.type, click.Choice): + argument_data.choices = param.type.choices + self._arguments.append(argument_data) + return self._arguments + + @property + def subcommands(self) -> dict["CommandName", "CommandSchema"]: + if self._subcommands is None: + self._subcommands = dict["CommandName", "CommandSchema"]() + if isinstance(self.cmd_obj, click.core.Group): + self.cmd_obj.to_info_dict(self.cmd_ctx) + for subcmd_name, subcmd_obj in self.cmd_obj.commands.items(): + self._subcommands[CommandName(subcmd_name)] = ClickCommandSchema( + cmd_obj=subcmd_obj, + cmd_ctx=self.cmd_ctx, + cmd_name=subcmd_name, + parent=self, + ) + return self._subcommands + + @property + def docstring(self) -> str | None: + if self._docstring is None: + self._docstring = self.cmd_obj.get_help(self.cmd_ctx) + return self._docstring + + @property + def function(self) -> Callable[..., Any | None]: + return self.cmd_obj.callback + + @property + def is_group(self) -> bool: + return isinstance(self.cmd_obj, click.Group) + + +def introspect_click_app(app: BaseCommand, click_context: click.Context) -> dict[CommandName, CommandSchema]: """ Introspect a Click application and build a data structure containing information about all commands, options, arguments, and subcommands, @@ -112,80 +235,20 @@ def introspect_click_app(app: BaseCommand) -> dict[CommandName, CommandSchema]: TypedDicts (OptionData and ArgumentData). """ - def process_command( - cmd_name: CommandName, cmd_obj: click.Command, parent=None - ) -> CommandSchema: - cmd_data = CommandSchema( - name=cmd_name, - docstring=cmd_obj.help, - function=cmd_obj.callback, - options=[], - arguments=[], - subcommands={}, - parent=parent, - is_group=isinstance(cmd_obj, click.Group), - ) - - for param in cmd_obj.params: - default = MultiValueParamData.process_cli_option(param.default) - if isinstance(param, (click.Option, click.core.Group)): - option_data = OptionSchema( - name=param.opts, - type=param.type, - is_flag=param.is_flag, - is_boolean_flag=param.is_bool_flag, - flag_value=param.flag_value, - counting=param.count, - opts=param.opts, - secondary_opts=param.secondary_opts, - required=param.required, - default=default, - help=param.help, - multiple=param.multiple, - nargs=param.nargs, - ) - if isinstance(param.type, click.Choice): - option_data.choices = param.type.choices - cmd_data.options.append(option_data) - elif isinstance(param, click.Argument): - argument_data = ArgumentSchema( - name=param.name, - type=param.type, - required=param.required, - multiple=param.multiple, - default=default, - nargs=param.nargs, - ) - if isinstance(param.type, click.Choice): - argument_data.choices = param.type.choices - cmd_data.arguments.append(argument_data) - - if isinstance(cmd_obj, click.core.Group): - for subcmd_name, subcmd_obj in cmd_obj.commands.items(): - cmd_data.subcommands[CommandName(subcmd_name)] = process_command( - CommandName(subcmd_name), subcmd_obj, parent=cmd_data - ) - - return cmd_data - data: dict[CommandName, CommandSchema] = {} # Special case for the root group if isinstance(app, click.Group): root_cmd_name = CommandName("root") - data[root_cmd_name] = process_command(root_cmd_name, app) + data[root_cmd_name] = ClickCommandSchema(app, click_context, cmd_name=root_cmd_name) app = data[root_cmd_name] if isinstance(app, click.Group): for cmd_name, cmd_obj in app.commands.items(): - data[CommandName(cmd_name)] = process_command( - CommandName(cmd_name), cmd_obj - ) + data[CommandName(cmd_name)] = ClickCommandSchema(cmd_obj, click_context, cmd_name=CommandName(cmd_name)) + elif isinstance(app, click.Command): cmd_name = CommandName(app.name) - data[cmd_name] = process_command(cmd_name, app) + data[cmd_name] = ClickCommandSchema(cmd_obj, click_context, cmd_name=cmd_name) return data - - -CommandName = NewType("CommandName", str) From 727444e0e1a958eec1fc710d85a2fac587829e27 Mon Sep 17 00:00:00 2001 From: Walter <44253688+svew@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:58:52 -0800 Subject: [PATCH 3/6] Create test_lazy_command.py --- tests/test_lazy_command.py | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/test_lazy_command.py diff --git a/tests/test_lazy_command.py b/tests/test_lazy_command.py new file mode 100644 index 0000000..3234781 --- /dev/null +++ b/tests/test_lazy_command.py @@ -0,0 +1,93 @@ + +import click +import typing as t +import inspect +import trogon + +class LazyCommand(click.Command): + """ + This class is a wrapper meant to only load a command's module (file) when + it's absolutely necessary. This is so we don't have to load potentially + dozens of script files every time we do a CLI command, and also so that if + a script happens to be broken, it will be compartmentalized and not affect + running other scripts. + """ + + def __init__(self, + name: str, + callback: t.Callable[[], click.Command], + short_help: str, + params = [], + *args, + **kwargs): + assert len(params) == 0,\ + "Additional params were given to a LazyCommand class. "\ + "These should be added to the base command to be called. " + assert len(kwargs) == 0 and len(args) == 0,\ + "Additional arguments were supplied to a LazyCommand class. "\ + "The only allowed arguments are: name, short_help. "\ + f"Found: {', '.join(kwargs.keys())}" + super().__init__(name) + self.short_help = short_help + self.callback = callback + self.cmd: click.Command | None = None + self.hidden = False + + def to_info_dict(self, ctx: click.Context): + return self._get_cmd().to_info_dict(ctx) + + def get_params(self, ctx: click.Context) -> t.List["click.Parameter"]: + return self._get_cmd().get_params(ctx) + + def get_usage(self, ctx: click.Context) -> str: + return self._get_cmd().get_usage(ctx) + + def get_help(self, ctx: click.Context) -> str: + return self._get_cmd().get_help(ctx) + + def parse_args(self, ctx: click.Context, args: t.List[str]) -> t.List[str]: + return self._get_cmd().parse_args(ctx, args) + + def invoke(self, ctx: click.Context): + return self._get_cmd().invoke(ctx) + + def get_short_help_str(self, limit: int = 45) -> str: + return inspect.cleandoc(self.short_help).strip() + + def _get_cmd(self): + if self.cmd is None: + self.cmd = self.callback() + return self.cmd + +@trogon.tui(run_if_no_command=True) +@click.group() +def cli(): + """ + Super cool and great CLI + """ + pass + +@click.command() +@click.option('-t', help="Turns on the trigger") +def cmd_1(t): + """ + cmd_1 finds all the problems you have, and prints them + """ + +@click.command() +@click.argument('path') +def cmd_2(path): + """ + cmd_2 fixes all the problems you have, and prints a report, given a PATH + """ + +cmd_1_lazy = LazyCommand("cmd_1", lambda: cmd_1, "Really great command") +cmd_2_lazy = LazyCommand("cmd_2", lambda: cmd_2, "Really amazing command") +cli.add_command(cmd_1_lazy) +cli.add_command(cmd_2_lazy) + +def test_lazy_commands(): + cli() + +if __name__ == "__main__": + test_lazy_commands() From 7d4643fa6028b20a7dae1480e965b63e96b6f56e Mon Sep 17 00:00:00 2001 From: Walter Svenddal Date: Thu, 12 Dec 2024 14:26:24 -0800 Subject: [PATCH 4/6] Fixed bad docstring --- trogon/introspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trogon/introspect.py b/trogon/introspect.py index d3bdcae..6a839e1 100644 --- a/trogon/introspect.py +++ b/trogon/introspect.py @@ -203,7 +203,7 @@ def subcommands(self) -> dict["CommandName", "CommandSchema"]: @property def docstring(self) -> str | None: if self._docstring is None: - self._docstring = self.cmd_obj.get_help(self.cmd_ctx) + self._docstring = self.cmd_obj.get_short_help_str() return self._docstring @property From 0042a8480711f2fd045b9bc4e64f741225315022 Mon Sep 17 00:00:00 2001 From: Walter Svenddal Date: Thu, 12 Dec 2024 14:26:39 -0800 Subject: [PATCH 5/6] Fixed run_if_no_command for subcommands --- trogon/trogon.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/trogon/trogon.py b/trogon/trogon.py index 5cd6fa1..66ad6a9 100644 --- a/trogon/trogon.py +++ b/trogon/trogon.py @@ -298,8 +298,9 @@ def tui( def decorator(app: click.Group | click.Command): @click.pass_context - def wrapped_tui(ctx, *args, **kwargs): - Trogon(app, app_name=name, command_name=command, click_context=ctx).run() + def wrapped_tui(ctx: click.Context, *args, **kwargs): + if ctx.invoked_subcommand is None: + Trogon(app, app_name=name, command_name=command, click_context=ctx).run() if isinstance(app, click.Group): group = app From 9de7ad72e7b86375c876159dca5090b654e72c07 Mon Sep 17 00:00:00 2001 From: Walter Svenddal Date: Thu, 12 Dec 2024 14:53:53 -0800 Subject: [PATCH 6/6] Ignored help params --- trogon/introspect.py | 129 ++++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 64 deletions(-) diff --git a/trogon/introspect.py b/trogon/introspect.py index 6a839e1..3e1f84c 100644 --- a/trogon/introspect.py +++ b/trogon/introspect.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import cached_property import uuid from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -138,79 +139,79 @@ def __init__( self._subcommands = None self._docstring = None - @property + @cached_property def options(self) -> list[OptionSchema]: - if self._options is None: - self._options = list[OptionSchema]() - for param in self.cmd_obj.get_params(self.cmd_ctx): - default = MultiValueParamData.process_cli_option(param.default) - if isinstance(param, (click.Option, click.core.Group)): - option_data = OptionSchema( - name=param.opts, - type=param.type, - is_flag=param.is_flag, - is_boolean_flag=param.is_bool_flag, - flag_value=param.flag_value, - counting=param.count, - opts=param.opts, - secondary_opts=param.secondary_opts, - required=param.required, - default=default, - help=param.help, - multiple=param.multiple, - nargs=param.nargs, - ) - if isinstance(param.type, click.Choice): - option_data.choices = param.type.choices - self._options.append(option_data) - return self._options - - @property + options = list[OptionSchema]() + help_option_names = set(self.cmd_obj.get_help_option_names(self.cmd_ctx)) + for param in self.cmd_obj.get_params(self.cmd_ctx): + if not isinstance(param, (click.Option, click.core.Group)): + continue + is_help_param = len(help_option_names & set(param.opts)) > 0 + if is_help_param: + continue + default = MultiValueParamData.process_cli_option(param.default) + option_data = OptionSchema( + name=param.opts, + type=param.type, + is_flag=param.is_flag, + is_boolean_flag=param.is_bool_flag, + flag_value=param.flag_value, + counting=param.count, + opts=param.opts, + secondary_opts=param.secondary_opts, + required=param.required, + default=default, + help=param.help, + multiple=param.multiple, + nargs=param.nargs, + ) + if isinstance(param.type, click.Choice): + option_data.choices = param.type.choices + options.append(option_data) + return options + + @cached_property def arguments(self) -> list[ArgumentSchema]: - if self._arguments is None: - self._arguments = list[ArgumentSchema]() - for param in self.cmd_obj.get_params(self.cmd_ctx): - default = MultiValueParamData.process_cli_option(param.default) - if isinstance(param, click.Argument): - argument_data = ArgumentSchema( - name=param.name, - type=param.type, - required=param.required, - multiple=param.multiple, - default=default, - nargs=param.nargs, - ) - if isinstance(param.type, click.Choice): - argument_data.choices = param.type.choices - self._arguments.append(argument_data) - return self._arguments + arguments = list[ArgumentSchema]() + for param in self.cmd_obj.get_params(self.cmd_ctx): + default = MultiValueParamData.process_cli_option(param.default) + if isinstance(param, click.Argument): + argument_data = ArgumentSchema( + name=param.name, + type=param.type, + required=param.required, + multiple=param.multiple, + default=default, + nargs=param.nargs, + ) + if isinstance(param.type, click.Choice): + argument_data.choices = param.type.choices + arguments.append(argument_data) + return arguments - @property + @cached_property def subcommands(self) -> dict["CommandName", "CommandSchema"]: - if self._subcommands is None: - self._subcommands = dict["CommandName", "CommandSchema"]() - if isinstance(self.cmd_obj, click.core.Group): - self.cmd_obj.to_info_dict(self.cmd_ctx) - for subcmd_name, subcmd_obj in self.cmd_obj.commands.items(): - self._subcommands[CommandName(subcmd_name)] = ClickCommandSchema( - cmd_obj=subcmd_obj, - cmd_ctx=self.cmd_ctx, - cmd_name=subcmd_name, - parent=self, - ) - return self._subcommands - - @property + subcommands = dict["CommandName", "CommandSchema"]() + if isinstance(self.cmd_obj, click.core.Group): + self.cmd_obj.to_info_dict(self.cmd_ctx) + for subcmd_name, subcmd_obj in self.cmd_obj.commands.items(): + subcommands[CommandName(subcmd_name)] = ClickCommandSchema( + cmd_obj=subcmd_obj, + cmd_ctx=self.cmd_ctx, + cmd_name=subcmd_name, + parent=self, + ) + return subcommands + + @cached_property def docstring(self) -> str | None: - if self._docstring is None: - self._docstring = self.cmd_obj.get_short_help_str() - return self._docstring + return self.cmd_obj.get_short_help_str() - @property + @cached_property def function(self) -> Callable[..., Any | None]: return self.cmd_obj.callback - @property + @cached_property def is_group(self) -> bool: return isinstance(self.cmd_obj, click.Group)