fastapi / typer

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

[QUESTION] How to use typer w/ classes and intance methods #309

Open jd-solanki opened 3 years ago

jd-solanki commented 3 years ago

First check

Description

How can I use Classes app in Typer and specific instance methods as commands?

The more easy question can be how can I implement the below code using class. Please note not all instance methods shall be a command.

from typing import Optional

import typer

app = typer.Typer()

@app.command()
def hello(name: Optional[str] = None):
    if name:
        typer.echo(f"Hello {name}")
    else:
        typer.echo("Hello World!")

@app.command()
def bye(name: Optional[str] = None):
    if name:
        typer.echo(f"Bye {name}")
    else:
        typer.echo("Goodbye!")

if __name__ == "__main__":
    app()

Additional context

I checked https://github.com/tiangolo/typer/issues/306#issuecomment-889374055 but it has a static method.

Regards.

thibaud-opal commented 3 years ago

Hey, not a Python nor Typer expert, but after searching for a few hours for my own needs, I came up with this type of solution https://gist.github.com/thibaud-opal/84eceafd67bf67361f5b194aafdd75bc

Not class methods per-se, but still allows you to declare commands that can access other instance methods. The drawback to this approach is that methods declared as commands are not accessible in the rest of the code, only as commands. But if yuu work in an object approach, that should not be an issue as your commands' role is only to parse input, delegate to services/classes and generate the output

EDIT: While redacting this, I also came to realize that it does not really make sense to declare instance methods as commands, as they are single entry-points into your app logic.

sathoune commented 3 years ago

The static method mentioned in your comment is there because otherwise the function would have self argument, that would be registered as required and you would need to pass something, when invoking commands. Here are more examples: https://github.com/captainCapitalism/typer-oo-example

I believe you could also play around with creating class that inherits from typer.Typer, but self argument will simply be registered as typer.Argument. Other thing than using staticmethod would be modifying arguments of the given command by popping self from argument list and modifying the signature, but that is IMO much more hacky than staticmethod. Turns out the way typer works is a curse here rather than a blessing.

And could you explain maybe why having staticmethods does not work for you?

jd-solanki commented 3 years ago

@captainCapitalism I didn't try using static methods. Also, I prefer using instance methods (which are usual also) rather than static methods.

Regards.

Ed1123 commented 2 years ago

I have this problem where I have a program that queries a db, make some calculations and returns different answers based on the command issue. There are 3 main calculations, all of them need to connect to the db. In order to avoid repeating code I was thinking on a way to pass the same connection object to the 3 functions without having to explicitly connect in each of them. A class that initiates with the connection code will be an alternative if there were an easy way to expose methods as commands. Any ideas?

skycaptain commented 9 months ago

Currently, I think there are two straightforward methods for accomplishing this. Note that there are more complex solutions involving custom Typer and wrapper functions to trick Typer into ignoring self.

The first method involves using static methods:

import typer

class MyKlass:
    app = typer.Typer()

    @app.command()
    @staticmethod
    def run():
        print("Running")

if __name__ == "__main__":
    MyKlass().app()

The second method registers commands in __init__:

import typer

class MyKlass:
    def __init__(self):
        self.app = typer.Typer()
        self.app.command()(self.run)

    def run(self):
        print("Running")

if __name__ == "__main__":
    MyKlass().app()

The latter has the benefit of keeping MyKlass .run usable as a standalone function.

You can execute both scripts as follows:

$ python3 test.py myklass run
Running
danbailo commented 9 months ago

Currently, I think there are two straightforward methods for accomplishing this. Note that there are more complex solutions involving custom Typer and wrapper functions to trick Typer into ignoring self. ...

tks @skycaptain, work's fine!

Follow an example for a pattern that can be implement to use in command class to "auto" get all commands methods. This way, if u have a lot of commands u don't need to add them manually(only if u have a specific value to inject :X)

I was need to implement it to inject values in my commands.

import inspect

import typer

class MyKlass:
    some_default_value: str

    def __init__(self, some_default_value: str):
        self.some_default_value = some_default_value
        self.app = typer.Typer()

        for method, _ in inspect.getmembers(self, predicate=inspect.ismethod):
            if not method.startswith('cmd'):
                continue

            cmd_name = method.strip('cmd_')
            self.app.command(
                name=cmd_name,
                help=self.some_default_value
            )(eval(f'self.{method}'))

    def cmd_run(self):
        print("Running")

    def cmd_sleep(self):
        print("sleep")

if __name__ == "__main__":
    MyKlass(some_default_value="it's a command").app()
Usage: python -m legalops_commons.utils.foo [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:
  run    it's a command
  sleep  it's a command
skycaptain commented 9 months ago

@danbailo That's a nice addition, but I would recommend two changes. First, avoid using eval. You could use the value returned by inspect.getmembers instead. Second, str.strip trims characters, not a substring. So, cmd_build would be trimmed down to buil instead of build. I think you were looking for str.removeprefix. You can also use Typer's get_commandname function to obtain the same default formatting as Typer (for example, replacing `with-`).

import typer
from typer.main import get_command_name

class MyKlass:
    def __init__(self):
        self.app = typer.Typer()

        for method, func in inspect.getmembers(self, predicate=inspect.ismethod):
            if not method.startswith("cmd_"):
                continue

            # Generate the command name from the method name
            # e.g. cmd_build -> build
            # e.g. cmd_pre_commit -> pre-commit
            command_name = get_command_name(method.removeprefix("cmd_"))

            self.app.command(name=command_name)(func)

    def cmd_run(self):
        print("Running")