How to Code with Me - Wrapping a Flask App in a CLI
Previous posts in my “How to Code with Me” series have addressed
packaging python code and
setting up a command line interface (CLI) using click
. This post is about how to
do this when your Python code is running a web application made with Flask and
how to set it up to run through your CLI.
The name of the package I’ll be referring to in this tutorial is granola_explosion
(not a real package!) that follows
the src/
layout. If you’re not familiar with this, check my previous post on organizing a Python package.
Example Flask Application
Let’s assume that the Flask application is in a module called granola_explosion.wsgi
located
at src/granola_explosion/wsgi.py
. This tutorial isn’t about building a Flask application, so below I’ll give a minimum
working example. Your Flask application may be much larger, even spanning multiple files. The important thing is that
the flask.Flask
instance is living in this file.
# wsgi.py
from flask import Flask
app = Flask(__name__)
@app.route()
def home():
return "There's no place like home."
if __name__ == '__main__':
app.run()
Note that the app.run()
is enclosed in
if __name__ == '__main__'
. This means that the app only gets run if
the granola_explosion.wsgi
is run as a script. Later, we will be importing this module inside our CLI, and we don’t
want it to run until we tell it to (and with our very own options).
Run a Flask Web Application with Click
Let’s also assume your command line interface is in a module called granola_explosion.cli
located
at src/granola_explosion/cli.py
using a click.Group
to organize several subcommands. The following example shows how you can import the app
object and run it from
inside the command line.
# cli.py
import click
@click.group()
def main():
"""Run the Granola Explosion CLI."""
@click.command()
def web():
from .wsgi import app
app.run()
if __name__ == '__main__':
main()
Now, you can run your web application with python -m granola_explosion.cli web
!
You can actually call your module and flask.Flask
instance whatever you want, but these two are pretty standard and
recognized by external tools (more on that later), and will make it easier for other people to understand what your code
does.
Configure Your Application
Normally, you can pass options like host
and port
into the flask.Flask.run()
function. Below, we use click
options to pass these through.
# cli.py
import click
@click.group()
def main():
"""Run the Granola Explosion CLI."""
@click.command()
@click.option('--host', default='0.0.0.0')
@click.option('--port', default=5000, type=int)
def web(host: str, port: int):
from .wsgi import app
app.run(host=host, port=port)
if __name__ == '__main__':
main()
I’ve written these options so many times, that I made a package called
more_click
that holds them for easy importing like in the following:
# cli.py
import click
from more_click import host_option, port_option
@click.group()
def main():
"""Run the Granola Explosion CLI."""
@main.command()
@host_option
@port_option
def web(host: str, port: str):
from .wsgi import app
app.run(host=host, port=port)
if __name__ == '__main__':
main()
Using GUnicorn
The flask.Flask.run()
function is convenient, but it’s only meant to be a lightweight development server - even your
own server yells at you every time you start it!
The official documentation and many excellent tutorials point to using more powerful servers like Gunicorn, but they start throwing around the (scary) acronym WSGI and tend to have very dense documentation that looks like it’s written only for sysadmins.
If you’re like me, you’re a big fan of keeping as much code in Python as possible, rather than floating around in
various shell scripts and dockerfiles. You would also probably like to be able to run a Flask app with Gunicorn with the
ease of app.run()
.
It turns out that gunicorn
is actually written in Python, and this is possible if you’re willing to read through the
codebase and understand the complicated design patterns they use. Or, you could use the more_click.run_app
, which
takes care of all of it for you. The implementation of this function
lives here, for the adventurous among you. It’s
basically a drop-in replacement for app.run()
except it’s called as run_app(app)
. Then, you can use
the with_gunicorn
keyword argument to turn on using Gunicorn.
# cli.py
import click
from more_click import host_option, port_option, run_app
@click.group()
def main():
"""Run the Granola Explosion CLI."""
@main.command()
@host_option
@port_option
def web(host: str, port: str):
from .wsgi import app
run_app(app=app, with_gunicorn=True, host=host, port=port)
if __name__ == '__main__':
main()
Now, your app runs with Gunicorn! If you want to be able to quickly switch back and forth between Flask and Gunicorn as
a server, you can use the handy more_click.with_gunicorn_option
. Further, you can specify the number of workers for
your Gunicorn server based on the following complete example:
# cli.py
import click
from more_click import host_option, port_option, with_gunicorn_option, workers_option, run_app
@click.group()
def main():
"""Run the Granola Explosion CLI."""
@main.command()
@host_option
@port_option
@with_gunicorn_option
@workers_option
def web(host: str, port: str, with_gunicorn: bool, workers: int):
from .wsgi import app
run_app(app=app, with_gunicorn=with_gunicorn, host=host, port=port, workers=workers)
if __name__ == '__main__':
main()
Ultimate Lazy Mode
For ultimate lazy mode, I’ve written a wrapper around the complete example in more_click.make_web_command
. This uses a
standard wsgi
-style string to locate the app. While this is a little less explicit than normal Python code that relies
on the import machinery, it has the benefit that it can lazily import the module in which your Flask application lives.
This could help avoid importing big requirements, as well as allow your package to specify Flask requirements as
optional. You might want to do this if your package can be used to perform a service locally, but also contains a Flask
application that wraps it with a RESTful service as well that not all users might need.
# cli.py
import click
from more_click import make_web_command
@click.group()
def main():
"""My awesome CLI."""
make_web_command('my_package_name.wsgi:app', group=main)
if __name__ == '__main__':
main()
The make_web_command()
function actually returns the command itself, so you can save it and add it to the group
manually instead of passing the group
argument.
# cli.py
import click
from more_click import make_web_command
@click.group()
def main():
"""My awesome CLI."""
web = make_web_command('my_package_name.wsgi:app')
main.add_command(web)
if __name__ == '__main__':
main()
Since any click command can be run by itself directly, the following minimal CLI also works well for apps that don’t need the click Group.
# cli.py
from more_click import make_web_command
web = make_web_command('granola_explosion.wsgi:app')
if __name__ == '__main__':
web()
I intentionally did not cover the built-in Flask Script because it doesn’t fit in with my paradigm of “everything must be packaged.”
This is my first post of 2021! Happy new year!