Textualize / trogon

Easily turn your Click CLI into a powerful terminal application
MIT License
2.5k stars 54 forks source link

[Feature Request] Support for lazy loading commands #97

Open svew opened 1 day ago

svew commented 1 day ago

I have set up a Click CLI for my team that lazily loads commands, which is going to be very useful for scaling up how many commands our team can add to the tool without slowing the whole thing down. However, it doesn't seem to work with Trogon, which I'd very much like to give us the option of using.

image

The way this works for us mechanically is somewhat custom, though recommended by the Click documentation. Essentially, instead of loading python modules containing commands thru imports, we load them dynamically when the command or its info is needed. This LazyCommand class is a dummy wrapper around the real command, and only knows the command's name and short_help.

It looks like from the code (looking at introspect.py), Trogon obtains info about the Click command/group objects from their fields, not from their getter functions. Lazy loading relies on these getter functions being called to know when it needs to actual load the module and supply correct information about the command.

I'm thinking instead of accessing the Click command's fields directly to instead call the to_info_dict function , which seems to return all necessary info, and is interceptible by lazy loading. Or at least call it once so that lazy loading can work

class Command(BaseCommand):
    ...
    def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]:
        info_dict = super().to_info_dict(ctx)
        info_dict.update(
            params=[param.to_info_dict() for param in self.get_params(ctx)],
            help=self.help,
            epilog=self.epilog,
            short_help=self.short_help,
            hidden=self.hidden,
            deprecated=self.deprecated,
        )
        return info_dict

I do have concerns though that the Trogon app might load every command and its info all at once anyway instead of doing so lazily, which would somewhat defeat the point...

svew commented 1 day ago

I wrote a test case for this scenario:


import click
import typing as t
import inspect
import trogon

class LazyCommand(click.Command):
    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, f"Additional arguments were supplied to a LazyCommand class. The only allowed arguments are: name, short_help. 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_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

@click.group()
def cli():
    """
    Super cool and great CLI
    """
    pass

@click.command()
def cmd_1():
    """
    cmd_1 finds all the problems you have, and prints them
    """

@click.command()
@click.option('-c', '--celebrate', help="Enable celebration after fixing all problems")
def cmd_2(celebrate):
    """
    cmd_2 fixes all the problems you have, and prints a report
    """

cli.add_command(LazyCommand("cmd_1", lambda: cmd_1, "Really great command"))
cli.add_command(LazyCommand("cmd_2", lambda: cmd_2, "Really amazing command"))

def test_lazy_commands():
    app = trogon.Trogon(cli, "my_cli")
    app.execute_on_exit = False
    app.run()

    # Expected behavior: Both cmd_1 and cmd_2's help texts and parameters are visible
    # Expected behavior: cmd_1 and cmd_2 commands are only loaded when selected in the menu