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!