cheat sheet
flask
Build lightweight web apps and REST APIs with Flask. Covers routes, request handling, JSON responses, blueprints, and the debug-server warning.
flask — Micro Web Framework
What it is
Flask is a lightweight WSGI micro-framework. It gives you routing, request/response handling, and Jinja2 templates with no ORM, no admin, and no batteries forced on you. It's the right choice for small APIs, microservices, and apps where you want full control of the stack.
Install
pip install flask
Output: (none — exits 0 on success)
Quick example
# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/hello/<name>")
def hello(name: str):
return jsonify({"message": f"Hello, {name}!"})
if __name__ == "__main__":
app.run(debug=True)
python app.py &
curl -s http://127.0.0.1:5000/hello/Alice
Output:
{"message":"Hello, Alice!"}
When / why to use it
- Small REST APIs or webhooks where you don't need Django's full stack.
- Prototypes you want to ship fast.
- When you want to cherry-pick your own ORM, auth, and serialization libraries.
Prefer FastAPI when you need async, auto-generated OpenAPI docs, or Pydantic integration. Prefer Django when you need admin, migrations, and batteries included.
Common pitfalls
Never run the dev server in production —
app.run(debug=True)is single-threaded and exposes an interactive debugger. Usegunicornorwaitressin production:gunicorn "app:app" -w 4.
debug=Trueenables the Werkzeug debugger console — anyone who can reach it can execute arbitrary Python. Never usedebug=Truewith a publicly reachable host.
Use
flask runinstead ofpython app.pyduring development. SetFLASK_DEBUG=1in your env to enable auto-reload without hardcodingdebug=True.
Richer example — full CRUD with blueprints
# app.py
from flask import Flask, jsonify, request, abort, Blueprint
users_bp = Blueprint("users", __name__, url_prefix="/users")
USERS: dict[int, dict] = {
1: {"name": "Alice"},
2: {"name": "Bob"},
}
@users_bp.get("/")
def list_users():
return jsonify(list(USERS.values()))
@users_bp.get("/<int:user_id>")
def get_user(user_id: int):
user = USERS.get(user_id)
if not user:
abort(404, description="User not found")
return jsonify(user)
@users_bp.post("/")
def create_user():
data = request.get_json(force=True, silent=True) or {}
if "name" not in data:
abort(400, description="name is required")
new_id = max(USERS, default=0) + 1
USERS[new_id] = {"name": data["name"]}
return jsonify({"id": new_id, **USERS[new_id]}), 201
@users_bp.delete("/<int:user_id>")
def delete_user(user_id: int):
if user_id not in USERS:
abort(404)
del USERS[user_id]
return "", 204
app = Flask(__name__)
app.register_blueprint(users_bp)
if __name__ == "__main__":
app.run(debug=True)
# Start server, then:
curl -s http://127.0.0.1:5000/users/
curl -s http://127.0.0.1:5000/users/99
curl -s -X POST http://127.0.0.1:5000/users/ \
-H "Content-Type: application/json" \
-d '{"name": "Charlie"}'
Output:
[{"name":"Alice"},{"name":"Bob"}]
{"code":404,"description":"User not found","name":"Not Found"}
{"id":3,"name":"Charlie"}
Error handlers
@app.errorhandler(404)
def not_found(error):
return jsonify({"error": str(error)}), 404
@app.errorhandler(400)
def bad_request(error):
return jsonify({"error": str(error)}), 400
App factory pattern
An app factory is a function that constructs the Flask instance instead of defining it at module scope. This is the recommended pattern for any non-trivial Flask app: it lets you build separate apps for testing (with different config) without re-importing the module, supports multiple environments cleanly, and side-steps circular imports between blueprints and the app object.
# myapp/__init__.py
from flask import Flask
def create_app(config_name: str = "production") -> Flask:
app = Flask(__name__, instance_relative_config=True)
# Layered config: defaults → environment file → instance overrides
app.config.from_object(f"myapp.config.{config_name.capitalize()}Config")
app.config.from_pyfile("config.py", silent=True) # instance/config.py if present
# Initialize extensions (db, login, migrate, …) — see below
from .extensions import db, login_manager, migrate
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
# Register blueprints
from .blueprints.auth import auth_bp
from .blueprints.users import users_bp
app.register_blueprint(auth_bp)
app.register_blueprint(users_bp)
# Register error handlers + CLI commands
from .errors import register_error_handlers
from .cli import register_commands
register_error_handlers(app)
register_commands(app)
return app
# myapp/config.py
import os
class BaseConfig:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_TRACK_MODIFICATIONS = False
JSON_SORT_KEYS = False
class DevelopmentConfig(BaseConfig):
DEBUG = True
SQLALCHEMY_DATABASE_URI = "sqlite:///dev.db"
class ProductionConfig(BaseConfig):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False
# Tell flask CLI how to find the factory
export FLASK_APP="myapp:create_app('development')"
flask run
Output:
* Serving Flask app 'myapp:create_app('development')'
* Debug mode: off
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
When
FLASK_APPpoints to a module containing acreate_appcallable, the CLI will call it automatically. You can also exportFLASK_APP=myappand have a top-levelcreate_appfactory — Flask discovers it by convention.
Blueprints — modular routing
Blueprints group related routes, templates, static files, and error handlers into a deferred-registration object. The blueprint's routes don't become part of any app until you call app.register_blueprint(...). This lets you re-use blueprints across apps and keeps related code (auth, admin, API v1, API v2) in self-contained packages.
# myapp/blueprints/auth/__init__.py
from flask import Blueprint
auth_bp = Blueprint(
"auth",
__name__,
url_prefix="/auth",
template_folder="templates",
static_folder="static",
)
from . import routes # noqa: E402,F401 — register routes onto auth_bp
# myapp/blueprints/auth/routes.py
from flask import render_template, redirect, url_for, request, flash
from . import auth_bp
@auth_bp.get("/login")
def login_form():
return render_template("auth/login.html")
@auth_bp.post("/login")
def login_submit():
# url_for("auth.login_form") works because the endpoint is namespaced
if not request.form.get("email"):
flash("Email is required", "error")
return redirect(url_for("auth.login_form"))
return redirect(url_for("users.profile"))
Blueprint endpoints are namespaced by the blueprint name: a
def login_form()inauth_bpbecomesauth.login_forminurl_for(...). Forgetting the prefix is a common cause ofBuildError.
Application and request context
Flask uses two stacks of context-locals: the application context (current_app, g) and the request context (request, session). Both are pushed automatically when a request arrives and popped when it returns; outside a request you must push them manually. current_app is the right way to refer to the Flask app from inside a blueprint or extension (never import the app object directly — that breaks the factory pattern). g is a per-request scratch namespace for things like the current database connection or authenticated user.
from flask import current_app, g, request, session
@auth_bp.before_request
def load_user():
user_id = session.get("user_id")
g.user = User.query.get(user_id) if user_id else None
@auth_bp.get("/whoami")
def whoami():
return {
"user": g.user.email if g.user else None,
"app_name": current_app.name,
"request_id": request.headers.get("X-Request-Id"),
}
# Working outside a request — e.g. in a script or a Celery task
def send_daily_digest():
app = create_app("production")
with app.app_context():
# current_app, g now usable; request/session still not
users = User.query.filter_by(active=True).all()
for u in users:
current_app.logger.info("Digest queued for %s", u.email)
gresets between requests but not between context pushes within the same request. If you push a nested app context manually, you sharegwith the outer push.
Configuration patterns
Flask reads config from app.config, which is a dict subclass with helpers. Load order matters: defaults → object-based config → .env / pyfile → environment overrides. Never commit a real SECRET_KEY — use environment variables or a secrets manager.
# Three common loading strategies — combine them in create_app()
app.config.from_object("myapp.config.ProductionConfig") # Python class
app.config.from_pyfile("/etc/myapp/instance.cfg") # arbitrary .py file
app.config.from_envvar("MYAPP_CONFIG", silent=True) # path from env var
app.config.from_prefixed_env(prefix="FLASK") # FLASK_FOO → config["FOO"]
# Direct overrides — handy in tests
app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI="sqlite:///:memory:")
# Reading config inside a view
from flask import current_app
@app.get("/version")
def version():
return {
"debug": current_app.config["DEBUG"],
"version": current_app.config.get("APP_VERSION", "0.0.0"),
}
Sessions and cookies
Flask's default session is a client-side signed cookie — small, fast, and stateless, but limited to ~4 KB and visible to the client (not encrypted, only tamper-proof). For larger session data or server-side invalidation, use Flask-Session to store sessions in Redis, Memcached, or SQLAlchemy.
from flask import session, request, make_response
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
app.config["SESSION_COOKIE_SECURE"] = True # HTTPS-only
app.config["SESSION_COOKIE_HTTPONLY"] = True # JS cannot read it
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" # CSRF mitigation
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=14)
@app.post("/login")
def login():
user = authenticate(request.form["email"], request.form["password"])
if not user:
abort(401)
session.clear()
session["user_id"] = user.id
session.permanent = True
return redirect(url_for("dashboard"))
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("home"))
# Setting a non-session cookie
@app.get("/track")
def track():
resp = make_response({"ok": True})
resp.set_cookie("visitor", "v1", max_age=86400, httponly=True, samesite="Lax")
return resp
The default
sessionis signed but not encrypted — never put passwords, payment data, or secrets in it. Anyone with the cookie can base64-decode it and read its contents.
Error handlers
Error handlers convert exceptions into HTTP responses. Register handlers per app or per blueprint; blueprint handlers only fire for routes that belong to that blueprint. Pair errorhandler(code) with abort(code) for predictable, JSON-friendly errors throughout the app.
# myapp/errors.py
from flask import jsonify
from werkzeug.exceptions import HTTPException
def register_error_handlers(app):
@app.errorhandler(HTTPException)
def handle_http(exc: HTTPException):
return jsonify({
"error": exc.name,
"code": exc.code,
"message": exc.description,
}), exc.code
@app.errorhandler(Exception)
def handle_unexpected(exc: Exception):
app.logger.exception("Unhandled error")
return jsonify({"error": "InternalServerError", "code": 500}), 500
# Specific status code overrides
@app.errorhandler(404)
def handle_404(_):
return jsonify({"error": "NotFound", "code": 404}), 404
Flask CLI — custom commands
flask.cli exposes a Click-based CLI you can extend with @app.cli.command(). Custom commands are the right place for management tasks: seeding the database, creating an admin user, running batch jobs. Inside a CLI command, the app context is already pushed, so current_app, g, and any extension (db.session, etc.) work straight away.
# myapp/cli.py
import click
from .extensions import db
from .models import User
def register_commands(app):
@app.cli.command("init-db")
def init_db():
"""Create all tables."""
db.create_all()
click.echo("Initialized the database.")
@app.cli.command("create-admin")
@click.argument("email")
@click.password_option()
def create_admin(email: str, password: str):
"""Create an admin user."""
u = User(email=email, is_admin=True)
u.set_password(password)
db.session.add(u)
db.session.commit()
click.echo(f"Created admin: {email}")
flask --app myapp init-db
flask --app myapp create-admin admin@example.com
flask routes # list every URL rule
flask shell # interactive shell with app context
Output:
Initialized the database.
Created admin: admin@example.com
Endpoint Methods Rule
----------------- ------- -----------------------
auth.login POST /auth/login
auth.logout GET /auth/logout
static GET /static/<path:filename>
Python 3.12.1 (main, Jan 8 2026, 12:00:00) on linux
App: myapp [development]
Instance: /home/alice/code/myapp/instance
>>>
Flask extensions
Flask's ecosystem is held together by extensions — small libraries that follow the init_app(app) factory pattern. The big three for any database-backed app are Flask-SQLAlchemy (ORM), Flask-Migrate (Alembic wrapper), and Flask-Login (session-based auth). Define extension singletons in a separate module so you can call init_app(app) from create_app() without circular imports.
# myapp/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = "auth.login_form"
# myapp/models.py
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from .extensions import db, login_manager
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
@login_manager.user_loader
def load_user(user_id: str):
return User.query.get(int(user_id))
# Using @login_required and current_user in a blueprint
from flask_login import login_user, logout_user, login_required, current_user
@auth_bp.post("/login")
def login_submit():
user = User.query.filter_by(email=request.form["email"]).first()
if not user or not user.check_password(request.form["password"]):
abort(401)
login_user(user, remember=True)
return redirect(url_for("users.profile"))
@users_bp.get("/me")
@login_required
def me():
return {"email": current_user.email, "is_admin": current_user.is_admin}
# Flask-Migrate (Alembic)
flask db init # one-time: create migrations/ folder
flask db migrate -m "add users table" # autogenerate migration
flask db upgrade # apply pending migrations
flask db downgrade -1 # rollback one
flask db history # list versions
Output:
Creating directory '/home/alice/code/myapp/migrations' ... done
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'users'
Generating /home/alice/code/myapp/migrations/versions/4f2a9c1b3e7d_add_users_table.py ... done
INFO [alembic.runtime.migration] Running upgrade -> 4f2a9c1b3e7d, add users table
INFO [alembic.runtime.migration] Running downgrade 4f2a9c1b3e7d -> , add users table
<base> -> 4f2a9c1b3e7d (head), add users table
CORS, CSRF, and security headers
CORS, CSRF, and security headers are three orthogonal concerns. CORS controls which origins can call your API from a browser; CSRF prevents a malicious site from forging an authenticated request on a logged-in user's behalf; security headers (CSP, HSTS, X-Frame-Options) harden the browser-side defense even when other layers fail.
# pip install flask-cors flask-wtf flask-talisman
from flask_cors import CORS
from flask_wtf.csrf import CSRFProtect
from flask_talisman import Talisman
def create_app(config_name="production"):
app = Flask(__name__)
app.config.from_object(f"myapp.config.{config_name.capitalize()}Config")
# CORS — restrict to known frontends
CORS(app, resources={
r"/api/*": {"origins": ["https://app.example.com"]},
}, supports_credentials=True)
# CSRF — protects all POST/PUT/PATCH/DELETE forms
csrf = CSRFProtect(app)
# Exempt token-authenticated API routes (CSRF is for cookies):
# csrf.exempt(api_bp)
# Security headers + forced HTTPS
Talisman(
app,
force_https=True,
strict_transport_security=True,
content_security_policy={
"default-src": "'self'",
"img-src": ["'self'", "data:", "https:"],
"script-src": ["'self'", "'unsafe-inline'"],
},
)
return app
<!-- In a Jinja template -->
<form method="POST" action="{{ url_for('auth.login_submit') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="email" type="email" required>
<input name="password" type="password" required>
<button>Sign in</button>
</form>
Cookie-based session auth is vulnerable to CSRF — always enable
Flask-WTFCSRF protection or setSESSION_COOKIE_SAMESITE="Strict". Token-based auth (Authorization: Bearer …) is not CSRF-vulnerable and can be exempted.
Testing with the test client
Flask ships with a built-in test client that simulates HTTP requests against your app without binding a port. Combined with the app-factory pattern, you can spin up a fresh app per test with TESTING=True, an in-memory SQLite database, and CSRF disabled.
# tests/conftest.py
import pytest
from myapp import create_app
from myapp.extensions import db
@pytest.fixture
def app():
app = create_app("testing")
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()
# tests/test_users.py
def test_create_user(client):
resp = client.post("/users/", json={"name": "Alice Dev"})
assert resp.status_code == 201
assert resp.get_json()["name"] == "Alice Dev"
def test_login_flow(client):
client.post("/auth/register", data={"email": "alice@example.com", "password": "pw"})
resp = client.post("/auth/login", data={"email": "alice@example.com", "password": "pw"})
assert resp.status_code == 302 # redirect to dashboard
with client.session_transaction() as sess:
assert sess.get("user_id") is not None
def test_cli_init_db(runner):
result = runner.invoke(args=["init-db"])
assert "Initialized" in result.output
Real-world recipes
The patterns below show end-to-end snippets — bigger than a quick example but smaller than a full project — for problems that come up on most Flask deployments.
File uploads with size and MIME validation
from werkzeug.utils import secure_filename
import imghdr
ALLOWED_MIME = {"image/png", "image/jpeg", "image/webp"}
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 # 5 MB
@app.post("/upload")
def upload():
f = request.files.get("file")
if not f or not f.filename:
abort(400, "No file")
# Sanitise filename + verify content type by sniffing bytes, not headers
name = secure_filename(f.filename)
head = f.stream.read(512)
f.stream.seek(0)
kind = "image/" + (imghdr.what(None, head) or "")
if kind not in ALLOWED_MIME:
abort(415, "Unsupported file type")
f.save(f"/var/uploads/{name}")
return {"name": name}, 201
Streaming a long-running response
Response(generator, mimetype=...) lets you push data to the client as it becomes available — useful for log tailing, NDJSON, or server-sent events. The view returns immediately; Flask iterates the generator and flushes each chunk to the network.
from flask import Response, stream_with_context
import time, json
@app.get("/stream/jobs")
def stream_jobs():
def event_stream():
for i in range(10):
yield json.dumps({"job": i, "status": "done"}) + "\n"
time.sleep(0.5)
return Response(stream_with_context(event_stream()), mimetype="application/x-ndjson")
Rate limiting with Flask-Limiter
# pip install flask-limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(get_remote_address, default_limits=["200 per hour"])
limiter.init_app(app)
@app.post("/api/login")
@limiter.limit("5 per minute")
def login():
...
Background work with a task queue
Flask's request cycle is short by design — anything longer than a few hundred milliseconds belongs in a background worker. The standard pair is Celery (with Redis or RabbitMQ as a broker) or RQ (simpler, Redis-only). Both run a separate worker process that pulls tasks off the queue; the Flask request hands off to the queue and returns immediately.
# tasks.py
from celery import Celery
celery = Celery(__name__, broker=os.environ["REDIS_URL"])
@celery.task
def send_welcome_email(user_id: int):
user = User.query.get(user_id)
email.send(user.email, subject="Welcome", body="…")
# In a view
from .tasks import send_welcome_email
@auth_bp.post("/register")
def register():
user = User(email=request.form["email"])
db.session.add(user)
db.session.commit()
send_welcome_email.delay(user.id) # enqueue, return immediately
return redirect(url_for("auth.login_form"))
Performance and observability
Flask is single-threaded per worker process; scale by running multiple worker processes (gunicorn -w N) behind a reverse proxy. Inside the request: profile slow endpoints with werkzeug.middleware.profiler.ProfilerMiddleware, measure timing with a small middleware, and ship structured logs to stdout so the platform (Cloudflare, Datadog, Loki) can ingest them.
# Timing middleware (works for both views and static files)
import time
import logging
logger = logging.getLogger(__name__)
@app.before_request
def _start_timer():
g._start = time.perf_counter()
@app.after_request
def _log_duration(response):
duration = (time.perf_counter() - g._start) * 1000
logger.info("%s %s -> %d %.1fms",
request.method, request.path, response.status_code, duration)
response.headers["X-Response-Time-Ms"] = f"{duration:.1f}"
return response
# One-shot profiler (development only)
from werkzeug.middleware.profiler import ProfilerMiddleware
if app.config["PROFILE"]:
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30], sort_by=("cumulative",))
Running in production
A production deployment of Flask is a WSGI server + reverse proxy + process supervisor stack: gunicorn (or uwsgi / waitress) handles Python execution, nginx (or Caddy / Cloudflare) terminates TLS, serves static files, and load-balances, and systemd (or a container orchestrator) keeps the process alive. Always run with multiple workers — Flask itself is single-threaded per worker process.
# gunicorn (multi-process, Unix)
pip install gunicorn
gunicorn "myapp:create_app('production')" \
--workers 4 \
--worker-class gthread --threads 2 \
--bind 0.0.0.0:8000 \
--access-logfile - --error-logfile - \
--timeout 30 --graceful-timeout 30
# waitress (cross-platform — works on Windows)
pip install waitress
waitress-serve --port=8000 --call myapp:create_app
Output:
[2026-05-25 09:14:02 +0000] [12345] [INFO] Starting gunicorn 21.2.0
[2026-05-25 09:14:02 +0000] [12345] [INFO] Listening at: http://0.0.0.0:8000 (12345)
[2026-05-25 09:14:02 +0000] [12345] [INFO] Using worker: gthread
[2026-05-25 09:14:02 +0000] [12346] [INFO] Booting worker with pid: 12346
INFO:waitress:Serving on http://0.0.0.0:8000
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name app.example.com;
location /static/ {
alias /var/www/myapp/static/;
expires 30d;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# /etc/systemd/system/myapp.service
[Unit]
Description=My Flask App
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/var/www/myapp/.venv/bin/gunicorn "myapp:create_app('production')" \
--workers 4 --bind 127.0.0.1:8000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
When running behind a reverse proxy, wrap the WSGI app in
werkzeug.middleware.proxy_fix.ProxyFixsorequest.remote_addrandrequest.schemereflect the client's IP and protocol, not the proxy's:app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1).
Quick reference
| Task | Code |
|---|---|
| App factory | def create_app(): app = Flask(__name__); ...; return app |
| Blueprint | bp = Blueprint("name", __name__, url_prefix="/x") |
| Register BP | app.register_blueprint(bp) |
| Route | @app.get("/path") / @app.post("/path") |
| Path param | @app.get("/users/<int:user_id>") |
| JSON body | data = request.get_json(force=True) |
| Query param | request.args.get("q", default="") |
| Form data | request.form["field"] |
| File upload | request.files["file"] |
| Cookie | request.cookies.get("name") / resp.set_cookie(...) |
| Session | session["user_id"] = 1 |
| App-level state | g.user = ... |
| Current app | from flask import current_app |
| Error handler | @app.errorhandler(404) def fn(e): ... |
| Custom CLI | @app.cli.command("name") def cmd(): ... |
| Before request | @app.before_request def fn(): ... |
| After request | @app.after_request def fn(resp): return resp |
| URL building | url_for("auth.login_form", next="/dash") |
| Abort | abort(404, description="...") |
| Redirect | return redirect(url_for("home")) |
| Render template | return render_template("page.html", x=1) |
| Test client | client = app.test_client() |
| CLI test | runner = app.test_cli_runner() |