Walkthrough#
Build a small app end to end: package layout, configuration, a template, a form
and tests. The code below runs as written with muffin and muffin-jinja2
installed.
Layout#
todo/
__init__.py application object, imports the rest
settings.py configuration module
storage.py the "database"
views.py routes
manage.py CLI commands
templates/
index.html
tests/
conftest.py
test_app.py
__init__.py creates the application and pulls in the modules that register
routes and commands:
# todo/__init__.py
from muffin import Application
app = Application("todo.settings")
app.import_submodules("views", "manage")
import_submodules imports the named submodules of the calling package, in
order, so registration order is explicit instead of alphabetical. Called without
arguments it imports every submodule, which is fine while the project is small.
Configuration#
# todo/settings.py
NAME = "todo"
DEBUG = False
JINJA2_TEMPLATE_FOLDERS = ["todo/templates"]
Set NAME first: it prefixes environment variables. With the module above,
every option is overridable as TODO_*:
$ TODO_DEBUG=true uvicorn todo:app
Template folders are resolved against the process working directory, so they are written relative to the project root. Precedence rules are in Configuration.
Storage#
# todo/storage.py
ITEMS = []
async def save(title):
ITEMS.append(title)
async def load_items():
return list(ITEMS)
A real project would talk to a database here, through one of the plugins.
Routes and templates#
views.py registers routes on the application object imported from the package:
# todo/views.py
import muffin_jinja2
from . import app
from .storage import load_items, save
jinja2 = muffin_jinja2.Plugin(app)
@app.route("/", methods=["GET", "POST"])
async def index(request):
if request.method == "POST":
form = await request.form()
title = form.get("title", "")
if title:
app.run_after_response(save(title))
return 303, {"Location": "/"}, ""
items = await load_items()
return await jinja2.render("index.html", items=items)
The handler reads the form, schedules storage work that does not need to delay
the response, and renders a template. 303 with Location is a redirect the
browser replays as a GET, so refreshing the page does not resubmit the form.
Returning a tuple of (status, headers, content) is what makes the redirect
possible; a two-element tuple is read as (status, content).
<!-- todo/templates/index.html -->
<form method="post">
<input name="title" placeholder="What needs doing?">
<button>Add</button>
</form>
<ul>
{% for item in items %}<li>{{ item }}</li>{% endfor %}
</ul>
Commands#
# todo/manage.py
from . import app
from .storage import save
@app.manage
async def add(title):
"""Add an item without opening the browser.
:param title: Item text
"""
await save(title)
print(f"added: {title}")
$ muffin todo add "buy milk"
added: buy milk
The function name becomes the command name, :param lines become its help.
The command API is described in CLI integration.
Tests#
The local fixtures below override the ones from the pytest plugin, which keeps
application state between tests otherwise: the plugin’s app fixture is
session-scoped.
# tests/conftest.py
import pytest
from muffin import TestClient
@pytest.fixture
def app():
from todo import app as todo_app
return todo_app
@pytest.fixture
def client(app):
return TestClient(app)
# tests/test_app.py
async def test_index(client):
response = await client.get("/")
assert response.status_code == 200
assert "Add" in await response.text()
async def test_add(client):
response = await client.post("/", data={"title": "buy milk"}, follow_redirect=False)
assert response.status_code == 303
assert response.headers["location"] == "/"
Async tests need a runner; pytest-aio is what Muffin itself uses, and it
parametrizes the backend. Details in Testing.
Running it#
$ uvicorn todo:app --reload # development
$ muffin todo run --port 8000 # the same app through the CLI
$ pytest tests -xs # tests
Deployment notes (Docker, NGINX, workers) are in Deployment.