fastapi / typer

Typer, build great CLIs. Easy to code. Based on Python type hints.
https://typer.tiangolo.com/
MIT License
15.75k stars 671 forks source link

Command aliases #132

Closed max-block closed 4 years ago

max-block commented 4 years ago

It would be nice to have command aliases, when all these commands do the same thing:

my-app delete
my-app uninstall
my-app d

There is a module for click which can do it: https://github.com/click-contrib/click-aliases

Code looks like this:

import click
from click_aliases import ClickAliasedGroup

@click.group(cls=ClickAliasedGroup)
def cli():
    pass

@cli.command(aliases=["i", "inst"])
def install():
    click.echo("install")

@cli.command(aliases=["d", "uninstall", "remove"])
def delete():
    click.echo("delete")

Also this module click-aliases add info about alises to help:

Usage: t3 [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  delete (d,remove,uninstall)
  install (i,inst)
tiangolo commented 4 years ago

Hey, as Typer doesn't modify the function, and only "registers" it, you could do this:

import typer

app = typer.Typer()

@app.command("delete")
@app.command("uninstall")
@app.command("d")
def delete(name: str):
    typer.echo(f"Deleting {name}")

if __name__ == "__main__":
    app()
ssbarnea commented 3 years ago

Manually adding aliases does not really scale well. Click has some code for implementing git-like behavior at https://click.palletsprojects.com/en/7.x/advanced/ but the tricky bit is that I have no idea on how to adapt this code to work for typer, especially as I do generate commands dynamically at https://github.com/pycontribs/mk/blob/main/src/mk/__main__.py#L87-L91

tddschn commented 2 years ago

Hey, as Typer doesn't modify the function, and only "registers" it, you could do this:

import typer

app = typer.Typer()

@app.command("delete")
@app.command("uninstall")
@app.command("d")
def delete(name: str):
    typer.echo(f"Deleting {name}")

if __name__ == "__main__":
    app()

@tiangolo This doesn't work as intended if the user invokes the app with --help:

...
Commands:
  a         Add a new to-do with a DESCRIPTION.
  add       Add a new to-do with a DESCRIPTION.

What I want is:

...
Commands:
  add, a         Add a new to-do with a DESCRIPTION.

How do I do this with typer? Thank you.

misha commented 2 years ago

@tddschn I solved this by hiding the subsequent option, works pretty well.

@app.command(no_args_is_help=True, help='Open a log stream to a service. (also "logs")')
@app.command('logs', hidden=True)
def log(
  service: Service = typer.Argument(..., help='The service whose logs to stream.'),
):
  docker_execute(['logs', '-f', service])
ssbarnea commented 1 year ago

I wonder if we could use https://click.palletsprojects.com/en/8.1.x/advanced/ in with typer as being able to just catch an unknown command and redirect it to the correct one is far more flexible and could also allow us to address typos by avoiding pre-generating all possible values in advance, which is quite ugly.

FergusFettes commented 1 year ago

I have also been looking for a solution to this, tried a few different methods, none of them worked nicely. The best one is just adding hidden commands:

@app.command()
@app.command(name="f", hidden=True)
def foobar(hello: str):
    "Foobar command"
    print("foobar with hello: ", hello)

but then we don't get a list of aliases, and you cant run something like alias f foobar

If someone more experienced than me would give a pointer for how a function that does alias f foobar that would be lovely.

FFengIll commented 1 year ago

I always use bellow for alias (exactly, command is a decorator, so it is a caller too). Furthermore, you can do bellow before app() as a statement.

#  alias, it also use the decorator's logic and work well
app.command(name="byebye", help="alias of goodbye")(goodbye)
FergusFettes commented 1 year ago

@FFengIll thats neat but its only available on build right?

what i want is something like

@app.command()
def alias(ctx: Context, name: str, command: str):
    aliases = ctx.obj.get('aliases', None)
    command = ctx.parent.command.get_command(ctx, command)
    if aliases and command:
        aliases[name] = command

    app.command(name=name)(command)     # <-- this doesn't work because i cant get the global app obj? i also tried adding it to the context and running ctx.obj.app.... but it didn't work either

@app.command()
def unalias(....

that i can use from within a session..

gar1t commented 1 year ago

You can support this with a tweak to the Group class:

class AliasGroup(typer.core.TyperGroup):

    _CMD_SPLIT_P = re.compile(r", ?")

    def get_command(self, ctx, cmd_name):
        cmd_name = self._group_cmd_name(cmd_name)
        return super().get_command(ctx, cmd_name)

    def _group_cmd_name(self, default_name):
        for cmd in self.commands.values():
            if cmd.name and default_name in self._CMD_SPLIT_P.split(cmd.name):
                return cmd.name
        return default_name

Usage:

app = typer.Typer(cls=AliasGroup)

@app.command("foo, f")
def foo():
    """Print a message and exit."""
    print("Works as command 'foo' or its alias 'f'")

@app.callback()
def main():
    pass

app()

See the full Gist.

One could imagine more explicit support of this with a mod to the upstream group class and a new aliases arg to the command decorator.

app = Typer()  # Uses TyperGroup, which is modified to look for an 'aliases' attr on commands

@app.command("foo", aliases=["f"])
def foo():
    pass
mrharpo commented 1 year ago

Thank you, @gar1t ! This works great!

+1 for command aliases=[]

It would be nice if it could include a custom parsing separator, and display separator override. E.g. I like to use | for command separation in my help text:

b | build     Build the dev environment
c | cmd       Run a command inside the dev environment
d | dev       Run the dev environment
m | manage    Run a manage.py function
s | shell     Enter into a python shell inside the dev environment
t | tui       Run an interactive TUI
mrharpo commented 1 year ago

@gar1t A slightly more robust solution that allows for multiple (or sloppy) delimiters:

class AliasGroup(TyperGroup):

    _CMD_SPLIT_P = r'[,| ?\/]' # add other delimiters inside the [ ]

    ...

    def _group_cmd_name(self, default_name):
        for cmd in self.commands.values():
            if cmd.name and default_name in re.split(self._CMD_SPLIT_P, cmd.name):
                return cmd.name
        return default_name
rnag commented 3 weeks ago

@mrharpo Not really a typo, but a feeling of improvement for the regex (or maybe I didn't really understand the \/ part) could be something like:

r" ?[,|] ?"

My full code is like:

class AliasGroup(TyperGroup):

    _CMD_SPLIT_P = re.compile(r" ?[,|] ?")

    def get_command(self, ctx, cmd_name):
        cmd_name = self._group_cmd_name(cmd_name)
        return super().get_command(ctx, cmd_name)

    def _group_cmd_name(self, default_name):
        for cmd in self.commands.values():
            name = cmd.name
            if name and default_name in self._CMD_SPLIT_P.split(name):
                return name
        return default_name

app = typer.Typer(cls=AliasGroup)

@app.command("a | action | xyz")
def do_something():
    """
    Some description here.
    """
   ...

I agree the help text is certainly improved 🎉 :

 Usage: cmd [OPTIONS] COMMAND [ARGS]...

╭─ Options ──────────────────────────────────────────────────────────────────────────╮
│ --install-completion          Install completion for the current shell.            │
│ --show-completion             Show completion for the current shell, to copy it or │
│                               customize the installation.                          │
│ --help                        Show this message and exit.                          │
╰────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────╮
│ a | action | xyz   Some description here.                                          │
╰────────────────────────────────────────────────────────────────────────────────────╯