fastapi / typer

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

typer-web to run typer cli as a web server (like click-web did) #433

Open axiomquant opened 2 years ago

axiomquant commented 2 years ago

First Check

Commit to Help

Example Code

import typer
from typer_web import create_fastapi_web_app

app = typer.Typer()

@create_fastapi_web_app
@app.command
def main(name: str):
    print(f"Hello {name}")

if __name__ == "__main__":
    app()

Description

Is it possible to create a typer-web module, which supports both the typer cli mode and the fastapi server mode? Click-web project https://github.com/fredrik-corneliusson/click-web can be used to generate a web app for click cli in a very simple way. It'd be great if typer can provide such a tool that supports both cli mode and web-server mode transparently.

Wanted Solution

As the description.

Wanted Code

NA

Alternatives

No response

Operating System

Linux

Operating System Details

No response

Typer Version

0.6.1

Python Version

3.7

Additional Context

No response

rspring-oncai commented 1 year ago

This would be a great feature to have, or alternatively some official guidance on how to use typer and fastapi at the same time (they are siblings, but do they get along?)

zsiegel92 commented 11 months ago

This is what Hug does, which the FastAPI documentation mentions. Hug supports using the same function for both a Web API and a CLI (the following is from their docs):

"""An example of writing an API to scrape hacker news once, and then enabling usage everywhere"""
import hug
import requests

@hug.local()
@hug.cli()
@hug.get()
def top_post(section: hug.types.one_of(('news', 'newest', 'show'))='news'):
    """Returns the top post from the provided section"""
    content = requests.get('https://news.ycombinator.com/{0}'.format(section)).content
    text = content.decode('utf-8')
    return text.split('<tr class=\'athing\'>')[1].split("<a href")[1].split(">")[1].split("<")[0]

So, the question is really, can you do this with Typer and FastAPI? I think yes:

from typer import Typer
from fastapi import FastAPI

cli_app = Typer()
webapp = FastAPI()

@webapp.get("/hello/{name}")
@cli_app.command()
def hello(name: str):
    print(f"Hello {name}")

@webapp.get("/goodbye/{name}")
@cli_app.command()
def goodbye(name: str, formal: bool = False):
    if formal:
        print(f"Goodbye Ms. {name}. Have a good day.")
    else:
        print(f"Bye {name}!")

if __name__ == "__main__":
    cli_app()

There are two ways to use this file, which let's say is called hybrid.py:


 Usage: hybrid.py hello [OPTIONS] NAME                                                        

╭─ Arguments ────────────────────────────────────────────────────────────────────────────────╮
│ *    name      TEXT  [default: None] [required]                                            │
╰────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                │
╰────────────────────────────────────────────────────────────────────────────────────────────╯
zsiegel92 commented 11 months ago

Since the type annotations contain most of the business logic, not the decorators, even a pretty complicated Typer or FastAPI configuration should work with minimal repeated logic; a few exceptions of course, like if both the FastAPI endpoint and Typer command had to be deprecated, that would have be be specified in both decorators.

To ensure there is really a single source of truth for every shared behavior of these two interfaces, someone would need to write a single class that handles both.

The single class - HybridAPI, say - would need to follow the ASGI spec (Uvicorn docs), which means its __call__ method would have to have the signature __call__(self, scope, receive, send):.... This rules out the possibility of using the documented Typer API if __name__=="__main__": app().

Minimal working example of getting around that issue:

from typer import Typer
from fastapi import FastAPI

class HybridAPI(FastAPI):

    def __init__(self, *args, typer_args=[], typer_kwargs={}, **kwargs):
        self.cli_app = Typer(*typer_args, **typer_kwargs)
        super().__init__(*args, **kwargs)

    def get(self, *args, typer_args=[], typer_kwargs={}, **kwargs):
        fastAPIGet = super().get(*args, **kwargs)
        cliCommand = self.cli_app.command(*typer_args, **typer_kwargs)
        return lambda func: fastAPIGet(cliCommand(func))

app = HybridAPI()

@app.get("/hello/{name}")
def hello(name: str):
    print(f"Hello {name}")

@app.get("/goodbye/{name}")
def goodbye(name: str, formal: bool = False):
    if formal:
        print(f"Goodbye Ms. {name}. Have a good day.")
    else:
        print(f"Bye {name}!")

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

All Typer and FastAPI functionality works. Of course, this only implements get (omitting post, put, etc.), and the Typer.__call__ (or any other Typer method) must be called via the cli_app field. Sharing positional or keyword arguments for the two applications would have to be implemented in the Frankenstein decorator functions.

To be clear I think this is a bad idea, but it was interesting to think about! Using two decorators, with some repeated information between them, is a small price to pay for defining business logic once.

zsiegel92 commented 11 months ago

Typer- and FastAPI-specific type annotations should work just fine together:

import typer
import fastapi
...
@app.get('/test')
def test(
    boolparam: Annotated[bool, typer.Option(prompt="Are you sure?"), fastapi.Body()],
):
    ...
Declow commented 4 months ago

For all of the people that stumbles on this issue. Nothing new needs to be implemented! You can in fact use click-web.

Take your Typer() instance and do the following

commands.py

import typer

typer_app = typer.Typer()

main.py

from click_web import create_click_web_app
import commands
import typer

typer_app = typer.Typer()

cmd = typer.main.get_command(commands.typer_app)
cmd.name = "cli"
app = create_click_web_app(commands, cmd)

start.sh

export FLASK_ENV=development
export FLASK_APP=app.py
flask run