typer icon indicating copy to clipboard operation
typer copied to clipboard

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

Open jd-solanki opened this issue 3 years ago • 7 comments

First check

  • [x] I used the GitHub search to find a similar issue and didn't find it.
  • [x] I searched the Typer documentation, with the integrated search.
  • [x] I already searched in Google "How to X in Typer" and didn't find any information.
  • [x] I already searched in Google "How to X in Click" and didn't find any information.

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.

jd-solanki avatar Aug 06 '21 13:08 jd-solanki

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.

thibaud-opal avatar Aug 07 '21 16:08 thibaud-opal

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?

sathoune avatar Aug 08 '21 10:08 sathoune

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

Regards.

jd-solanki avatar Aug 09 '21 11:08 jd-solanki

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?

Ed1123 avatar Jun 24 '22 21:06 Ed1123

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

skycaptain avatar Feb 08 '24 12:02 skycaptain

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

danbailo avatar Feb 10 '24 17:02 danbailo

@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_command_name 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")

skycaptain avatar Feb 11 '24 18:02 skycaptain