tiangolo / typer

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

[FEATURE] REPL mode #185

Open mbande opened 3 years ago

mbande commented 3 years ago

Is your feature request related to a problem

I want to keep context (e.g. session) between different CLI invocations.

The solution you would like

by activaticating REPL mode, e.g. with a --repl flag, user gets in interactive shell that he can communicate multiple commands and share the same context between them.

mgielda commented 3 years ago

This would be similar to what plac's interactive mode (-i) does. While plac has some great and nifty features, in my experience the out-of-the-box ease of use for regular, batch CLI apps is nowhere near as good as Typer's. Still, having an interactive shell capability would be great, for example for scripts that fetch some data or e.g. ask for some input at startup. Also, it makes it so much easier to use Typer as a go-to solution for all sorts of CLI apps.

Click seems to have a package for this? click-shell (although did not investigate if/how it works)

Mattie commented 2 years ago

Definitely would love to see this. Was looking to see if the feature was supported and stumbled upon this request, so I assume it isn't. Would be quite handy when I want to interact with the app a lot.

tirkarthi commented 2 years ago

You can do this with click-repl package : https://github.com/click-contrib/click-repl

import click
import typer
from click_repl import repl

app = typer.Typer()
remote = typer.Typer()
app.add_typer(remote, name="remote")

@remote.command()
def add(origin: str):
    typer.echo(f"Setting origin : {origin}")

@remote.command()
def remove(origin: str):
    typer.echo(f"Removing origin : {origin}")

@app.command()
def myrepl(ctx: typer.Context):
    repl(ctx)

if __name__ == "__main__":
    app()
afparsons commented 1 year ago

After some brief experimentation, I've concluded that I prefer click-shell for my use case (whether it will be further developed and supported is a different question). click-shell immediately launches a subshell without the user needing to provide a repl command.

Here's how I believe we integrate it with typer:

from typer import Context, Typer
from click_shell import make_click_shell

app: Typer = Typer()

@app.command()
def foobar():
    print("foobar")

@app.callback(invoke_without_command=True)
def launch(ctx: Context):
    shell = make_click_shell(ctx, prompt="<shell_prompt>", intro="<shell_introduction>")
    shell.cmdloop()

if __name__ == "__main__":
    app()

Nota bene: I'm new to all three projects (typer, click, click-shell); I've only been using them within the last hour. Please forgive any mistakes!


Edit, five days later: here's a quick project I threw together using click-shell and typer. It won't work without a DALL·E 2 account, but you can still look at the code! https://github.com/afparsons/albaretto

FergusFettes commented 1 year ago

This demo is good but one thing that took me a while was figuring out how to take advantage of typers nice help printing:

from typer import Context, Typer
from click_shell import make_click_shell
from rich import print

app: Typer = Typer()

@app.command()
def foobar():
    print("foobar")

@app.callback(invoke_without_command=True)
def launch(ctx: Context):
    shell = make_click_shell(ctx, prompt="<shell_prompt>", intro="<shell_introduction>")
    shell.cmdloop()

@app.command(hidden=True)
def help(ctx: Context):
    print("\n Type 'command --help' for help on a specific command.")
    ctx.parent.get_help()

if __name__ == "__main__":
    app()

maybe there is a better way to do it. The default click-shell help is pretty sad by comparison.

FergusFettes commented 1 year ago

btw i also checked out click-repl, was hard to choose between them cause neither of them are very well documented and both have stregths:

FergusFettes commented 1 year ago

Actually let me do you one better even. For all future generations, I leave this here:

from typer import Context, Typer, Argument
from typing import Optional
from typing_extensions import Annotated

@app.command(hidden=True)
def help(ctx: Context, command: Annotated[Optional[str], Argument()] = None):
    print("\n Type 'command --help' or 'help <command>' for help on a specific command.")
    if command:
        command = ctx.parent.command.get_command(ctx, command)
        command.get_help(ctx)
    else:
        ctx.parent.get_help()

enjoy

FergusFettes commented 1 year ago

Okay let me do you one more, typer_shell.py:

from typing_extensions import Annotated
from typing import Optional, Callable

from click_shell import make_click_shell
from typer import Context, Typer, Argument

from rich import print

def make_typer_shell(
        app: Typer,
        prompt: str = ">> ",
        intro: str = "\n Welcome to typer-shell! Type help to see commands.\n",
        default: Optional[Callable] = None
) -> None:
    @app.command(hidden=True)
    def help(ctx: Context, command: Annotated[Optional[str], Argument()] = None):
        print("\n Type 'command --help' or 'help <command>' for help on a specific command.")
        if not command:
            ctx.parent.get_help()
            return
        ctx.parent.command.get_command(ctx, command).get_help(ctx)

    @app.command(hidden=True)
    def _default(args: Annotated[Optional[str], Argument()] = None):
        """Default command"""
        if default:
            default(args)
        else:
            print("Command not found. Type 'help' to see commands.")

    @app.callback(invoke_without_command=True)
    def launch(ctx: Context):
        if ctx.invoked_subcommand is None:
            shell = make_click_shell(ctx, prompt=prompt, intro=intro)
            shell.default = _default
            shell.cmdloop()

and test.py:

#!/usr/bin/env python

from rich import print
from typer import Typer

from typer_shell import make_typer_shell

app: Typer = Typer()
make_typer_shell(app, prompt="🔥: ")
subapp: Typer = Typer()

default = lambda x: print(f"Inner Default, args: {x}")

make_typer_shell(
    subapp,
    prompt="🌲: ",
    intro="\n Welcome to the inner shell! Type help to see commands.\n",
    default=default
)
app.add_typer(subapp, name="inner")

@app.command()
def foobar():
    "Foobar command"
    print("foobar")

@subapp.command(name="foobar")
def _foobar():
    "Foobar command"
    print("foobar")

if __name__ == "__main__":
    app()

and some footage: https://asciinema.org/a/C1fUrJ8yvBoBRgn0XftkcF4cm

Shall I make this a PR? Would you want this? @mgielda

FergusFettes commented 1 year ago

i just made this, pretty simple really but very nice results:

https://github.com/FergusFettes/typer-shell

melsabagh commented 4 months ago

A simple REPL with Typer can be rolled as follows:

import shlex

import typer

class SampleRepl:
    def __init__(self):
        self.x = 0

    def drop_to_shell(self):
        app = typer.Typer(no_args_is_help=True, add_completion=False)
        app.command(help="set x")(self.set)
        app.command(help="get x")(self.get)

        typer.echo("Type --help to show help.")
        typer.echo("Type 'exit' to quit.")
        while True:
            args = shlex.split(typer.prompt("> "))
            if not args:
                continue
            if args == ["exit"]:
                typer.echo("bye!")
                break
            app(args, standalone_mode=False)

    def set(self, value: int):
        self.x = value

    def get(self) -> int:
        typer.echo(f"x = {self.x}")
        return self.x

def main():
    SampleRepl().drop_to_shell()

if __name__ == "__main__":
    main()