Skip to content

My Flask tests pass one at a time, and fail together

  • Frameworks · Core
  • 40 min read, about 1 h 10 min with practice
  • Concept
  • Asked often: medium
  • Covers Flask 3.1, SQLAlchemy 2.1, Python 3.11+
  • Reviewed 2026-11
Quick recapDecorators· 2 min

A decorator wraps a function in another. @login_required above def notes(): means notes = login_required(notes): calls go through the wrapper first. functools.wraps copies the name and docstring across, so the wrapped function still looks like itself.

The Flask Notes API keeps its settings in one module-level dict, CONFIG, and builds one app when the module is imported. Each test changes what it needs: one sets DATABASE_URL to an in-memory database, another sets PAGE_SIZE to 5.

Each test passes when run alone. Run the whole file and three fail: the page-size test sees 5 notes a page in a test that expects 20. Worse, a test once wrote to the real notes.sqlite3, because the setting it relied on had been changed back by another test. On the server, PAGE_SIZE=50 from the environment arrives as the text "50", and DEBUG=false turns debug on.

Write make_config(environ, overrides): the settings for one new app.

  • Input: the environment variables (a dict of text), and settings from the caller, like a test.
  • Output: a new dict of settings, with the right Python types.
  • The rules: start from the defaults, then the environment, then the overrides; later wins. DEBUG is on only for 1, true, yes or on. In production, refuse to start with the default secret key.
  • The hard part: every call must get its own settings. Nothing one app does may show up in the next.

Example: make_config({"PAGE_SIZE": "50"}, {})["PAGE_SIZE"] is the number 50.

Hints open one at a time:

Hint 1 of 3

A module runs once per process. Its CONFIG dict is one object that every test shares.

Hint 2 of 3

If each app made its own settings dict, a change to one couldn’t reach the others.

Hint 3 of 3

That is what an app factory is: a function that builds a new app, with new settings, each time you call it.

Flask is small on purpose. It gives you routing, a request and response, sessions, templates (Jinja2) and a test client. You choose the rest: SQLAlchemy for the database, Flask-Migrate for migrations, and so on. The runnable Notes API is in examples/flask/. Start it with ./run.sh examples flask, run.bat examples flask or docker compose up flask.

Routes map a URL and method to a view function. <int:pk> in the rule turns that part of the path into an argument:

@bp.route("/notes/<int:pk>/", methods=["GET", "PATCH", "DELETE"])
@login_required
def note_detail(pk: int):
...

Contexts make request, session, g and current_app work without passing them around. For each request, Flask pushes an app context (current_app, and g, a scratch space for this request) and a request context (request, session). They are popped when the request ends. Outside a request, like in a script, open one with with app.app_context():.

Blueprints group routes, like a Django app. bp = Blueprint("api", __name__) holds the API’s routes and hooks; the app registers it with url_prefix="/api".

The app factory builds a new app each time. create_app(settings) makes the Flask object, loads config, sets up the database and registers blueprints. Tests call it with their own settings; run.py calls it with real ones. This is the fix for the leak above.

SQLAlchemy and Flask meet in g. get_db() opens a session on first use and keeps it in g.db; a teardown_appcontext function closes it, even if the view raised. The third practice problem builds this.

Jinja2 templates escape by default. render_template("note.html", note=note) fills {{ note.title }} with < turned into &lt;. The Notes API returns JSON instead, with jsonify.

Test with the test client. app.test_client().post("/api/notes/", json={...}) runs the whole request in the same process, no server needed.

One request through a Flask app

POST /api/notes/ in Flask

Parts:

  • Browser (the request is here)
  • WSGI server
  • Flask app (from create_app)
  • before_request: CSRF
  • View: api.notes
  • g.db session

Arrows:

  • Browser sends requests to WSGI server
  • WSGI server sends requests to Flask app (from create_app)
  • Flask app (from create_app) sends requests to before_request: CSRF
  • before_request: CSRF sends requests to View: api.notes
  • View: api.notes sends requests to g.db session

Step 1 of 7. The browser sends POST /api/notes/ with JSON, the session cookie and an X-CSRFToken header.

Three versions of make_config. The tests build several apps in a row, and read settings from the environment.

Okay

One shared dict

The idea: Change the module’s CONFIG dict in place, and return it.

It works for one app in one process, which is how a tutorial starts.

CONFIG = {
"ENV": "development",
"DEBUG": False,
"SECRET_KEY": "dev-only-key",
"DATABASE_URL": "sqlite:///notes.sqlite3",
"PAGE_SIZE": 20,
}
def make_config(environ: dict[str, str], overrides: dict) -> dict:
for name in CONFIG: # one shared dict, changed in place
if name in environ:
CONFIG[name] = environ[name]
CONFIG.update(overrides)
return CONFIG
Leaks between apps
Yes
PAGE_SIZE=50
The text 50
Why
Every app gets the same dict, so they all see each other’s changes.

Where it breaks: the second test still sees the first test’s settings. And environment values stay text, so PAGE_SIZE * 2 is "5050".

Better

A fresh copy, converted by type

The idea: Copy the defaults for each call; convert environment text with the default’s type.

The waste in Okay: it shared one dict across every app.

Highlighted lines are new in this rung.

DEFAULTS = {
"ENV": "development",
"DEBUG": False,
"SECRET_KEY": "dev-only-key",
"DATABASE_URL": "sqlite:///notes.sqlite3",
"PAGE_SIZE": 20,
}
def make_config(environ: dict[str, str], overrides: dict) -> dict:
config = dict(DEFAULTS) # a fresh copy for every app
for name, default in DEFAULTS.items():
if name in environ:
config[name] = type(default)(environ[name]) # "50" -> 50, "false" -> True!
config.update(overrides)
return config
Leaks between apps
Nowas Yes
PAGE_SIZE=50
The number 50was The text 50
Why
Each call builds its own dict, so apps can’t see each other.
What changed from the previous rung
CONFIG = {
DEFAULTS = {
"ENV": "development",
"DEBUG": False,
"SECRET_KEY": "dev-only-key",
"DATABASE_URL": "sqlite:///notes.sqlite3",
"PAGE_SIZE": 20,
}
def make_config(environ: dict[str, str], overrides: dict) -> dict:
for name in CONFIG: # one shared dict, changed in place
config = dict(DEFAULTS) # a fresh copy for every app
for name, default in DEFAULTS.items():
if name in environ:
CONFIG[name] = environ[name]
CONFIG.update(overrides)
return CONFIG
config[name] = type(default)(environ[name]) # "50" -> 50, "false" -> True!
config.update(overrides)
return config

Where it breaks: bool("false") is True, because any non-empty text is. So DEBUG=false turns debug on, which shows code and settings to anyone in production. And nothing stops production from using the public default key.

Best

Parse each type, and refuse unsafe production settings

The idea: Read booleans from a set of yes-words, ints with int(), and raise if production still has the default secret key.

The waste in Better: it trusted Python’s idea of “true”.

Highlighted lines are new in this rung.

DEFAULTS = {
"ENV": "development",
"DEBUG": False,
"SECRET_KEY": "dev-only-key",
"DATABASE_URL": "sqlite:///notes.sqlite3",
"PAGE_SIZE": 20,
}
def parse(text: str, default: object) -> object:
if isinstance(default, bool):
return text.strip().lower() in {"1", "true", "yes", "on"}
if isinstance(default, int):
return int(text)
return text
def make_config(environ: dict[str, str], overrides: dict) -> dict:
config = dict(DEFAULTS)
for name, default in DEFAULTS.items():
if name in environ:
config[name] = parse(environ[name], default)
config.update(overrides) # tests and run.py have the last word
if config["ENV"] == "production" and config["SECRET_KEY"] == DEFAULTS["SECRET_KEY"]:
raise RuntimeError("Set SECRET_KEY in production: the default one is public.")
return config
Leaks between apps
No
PAGE_SIZE=50
The number 50
Why
Every value has the right type, and a dangerous setup stops at start, not after a leak.
What changed from the previous rung
DEFAULTS = {
"ENV": "development",
"DEBUG": False,
"SECRET_KEY": "dev-only-key",
"DATABASE_URL": "sqlite:///notes.sqlite3",
"PAGE_SIZE": 20,
}
def parse(text: str, default: object) -> object:
if isinstance(default, bool):
return text.strip().lower() in {"1", "true", "yes", "on"}
if isinstance(default, int):
return int(text)
return text
def make_config(environ: dict[str, str], overrides: dict) -> dict:
config = dict(DEFAULTS) # a fresh copy for every app
config = dict(DEFAULTS)
for name, default in DEFAULTS.items():
if name in environ:
config[name] = type(default)(environ[name]) # "50" -> 50, "false" -> True!
config.update(overrides)
config[name] = parse(environ[name], default)
config.update(overrides) # tests and run.py have the last word
if config["ENV"] == "production" and config["SECRET_KEY"] == DEFAULTS["SECRET_KEY"]:
raise RuntimeError("Set SECRET_KEY in production: the default one is public.")
return config

When Best isn’t best: Flask has helpers: app.config.from_prefixed_env() reads FLASK_* variables and parses their JSON types. For many settings, a library like pydantic-settings checks every type for you.

Write make_config(environ, overrides) from the task. Press Run tests to check your code.

  • Copy DEFAULTS for each call.
  • For each default the environment sets, parse the text: booleans from 1, true, yes or on; ints with int(); text as it is.
  • Apply the overrides last. Then raise RuntimeError if ENV is "production" and SECRET_KEY is still the default.

Settings for one app

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: No environment, no overrides → {'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite:///notes.sqlite3', 'PAGE_SIZE': 20}
  2. Not run yet: PAGE_SIZE=50 in the environment → {'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite:///notes.sqlite3', 'PAGE_SIZE': 50}
  3. Not run yet: DEBUG=false → {'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite:///notes.sqlite3', 'PAGE_SIZE': 20}
  4. Not run yet: A test’s override beats the environment → {'ENV': 'development', 'DEBUG': False, 'SECRET_KEY': 'dev-only-key', 'DATABASE_URL': 'sqlite://', 'PAGE_SIZE': 20}
  5. Not run yet: One app sets PAGE_SIZE=5, then a second app is made → 20
  6. Not run yet: Production with the default secret key → raises RuntimeError
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayLeaks between appsTypesGoodNot so goodUse it when
Okay: one shared dictYesText from the environmentShortestTests change each other; numbers are textA one-file script
Better: copy, convert by typeNoRight, except booleansEach app is separate"false" is true; no production checkNever for booleans
Best: parse, then checkNoRightSafe defaults; fails at startA few more linesEvery Flask app with an app factory
  • Two views with the same function name. Flask names each endpoint after its function. A decorator without functools.wraps names them all wrapper, and Flask refuses the second one. The first practice problem shows this.
  • Hard-coded URLs. Use url_for("api.note_detail", pk=7), so a changed rule updates every link. The second practice problem builds a small url_for.
  • Debug mode in production. The debugger runs any Python typed into the error page. Never set debug=True on a server.
  • One database session for the whole app. Sessions aren’t thread-safe, and errors leave them broken. Open one per request and close it in teardown.
  • flask run in production. It is a development server. Use Gunicorn: gunicorn "flask_notes:create_app()".

Questions to ask first

  • How big will the app grow, and who will add to it?
  • Which pieces do we need beyond routing: database, migrations, auth, forms?
  • Where do settings come from in each environment?

A 60-second walkthrough

“Flask gives me routing, request and session objects, Jinja2 and a test client; I add the rest. I’d use an app factory: create_app builds the app, loads config and registers blueprints, so each test gets its own app. Config goes defaults, then environment, then test overrides, parsed to the right types. And it refuses to start in production with a default secret. For the database I use SQLAlchemy with one session per request, opened lazily in g and closed in teardown_appcontext. Views are grouped in blueprints, protected by decorators that keep their names with functools.wraps. Tests use the test client against an in-memory database.”

Likely follow-ups

  • What is g? A per-request scratch space on the app context: good for the database session or the current user. It is empty at the start of each request.
  • App context vs request context? The app context holds current_app and g; the request context holds request and session. A request pushes both; a CLI command pushes only the app one.
  • Flask or Django? Flask when you want to pick each piece, or for a small API; Django when you want the admin, ORM, auth and forms ready. The decision guide covers this.
  • How do you make Flask async? Flask allows async def views, but still uses one worker per request. For many open connections, use an ASGI framework like FastAPI.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.

Write login_required(view). The wrapper takes (session, *args, **kwargs). If "user_id" isn’t in the session, it returns ({"error": "Log in first."}, 401). Otherwise it calls the view. Keep the view’s __name__, because Flask names endpoints after it.

Example: a guarded show called with an empty session gives ({"error": "Log in first."}, 401).

Hint 1 of 3

A decorator is a function that takes a function and returns a new one.

Hint 2 of 3

An API answers 401 with JSON. A redirect to a log-in page is for HTML pages.

Hint 3 of 3

@functools.wraps(view) on the wrapper copies __name__ and the docstring.

A login_required decorator

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Logged in, note 7 → [{'id': 7}, 200]
  2. Not run yet: Not logged in → [{'error': 'Log in first.'}, 401]
  3. Not run yet: The wrapped view keeps its name → 'note_detail'
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

A plain wrapper

The idea: Check the session, then call the view; no wraps.

For one guarded view, it works.

def login_required(view):
def wrapper(session, *args):
if "user_id" not in session:
return {"error": "Log in first."}, 401
return view(session, *args)
return wrapper
Endpoint name
wrapper
Not logged in
401 JSON
Why
It guards the view correctly, but hides who it wraps.

Where it breaks: guard a second view and Flask sees two endpoints called wrapper, and refuses to start. Keyword arguments are lost too.

Better

wraps, and a redirect

The idea: Use functools.wraps, and send the user to /login/ with 302.

The waste in Okay: it threw away the view’s name.

Highlighted lines are new in this rung.

from functools import wraps
def login_required(view):
@wraps(view)
def wrapper(session, *args):
if "user_id" not in session:
return "/login/", 302 # send the browser to the log-in page
return view(session, *args)
return wrapper
Endpoint name
The view’swas wrapper
Not logged in
302 to /login/was 401 JSON
Why
Each view keeps its name, so many can be guarded.
What changed from the previous rung
from functools import wraps
def login_required(view):
@wraps(view)
def wrapper(session, *args):
if "user_id" not in session:
return {"error": "Log in first."}, 401
return "/login/", 302 # send the browser to the log-in page
return view(session, *args)
return wrapper

Where it breaks: a redirect suits a web page. A JSON client follows it and gets an HTML log-in page, with a 200. It should get a 401 it can act on.

Best

wraps, 401 JSON, and keyword arguments

The idea: Keep the name with wraps, answer 401 with JSON, and pass *args and **kwargs through.

The waste in Better: it answered an API like a web page.

Highlighted lines are new in this rung.

from functools import wraps
def login_required(view):
@wraps(view) # keeps view.__name__, which Flask uses as the endpoint name
def wrapper(session, *args, **kwargs):
if "user_id" not in session:
return {"error": "Log in first."}, 401 # an API answers; it doesn't redirect
return view(session, *args, **kwargs)
return wrapper
Endpoint name
The view’s
Not logged in
401 JSONwas 302 to /login/
Why
It fits an API, and works with any view.
What changed from the previous rung
from functools import wraps
def login_required(view):
@wraps(view)
def wrapper(session, *args):
@wraps(view) # keeps view.__name__, which Flask uses as the endpoint name
def wrapper(session, *args, **kwargs):
if "user_id" not in session:
return "/login/", 302 # send the browser to the log-in page
return view(session, *args)
return {"error": "Log in first."}, 401 # an API answers; it doesn't redirect
return view(session, *args, **kwargs)
return wrapper

When Best isn’t best: Flask-Login gives you login_required, current_user and remember-me cookies. In real Flask, the view reads session itself; here it is passed in so the test can run without Flask.

Write build_url(rules, endpoint, **values), a small url_for. Fill each <name> in the rule from values, quoting it so / and spaces are safe. Raise ValueError if one is missing. Any values left over go in the query string.

Example: build_url(RULES, "note", note_id=7, edit=1) gives "/notes/7/?edit=1".

Hint 1 of 3

re.findall(r"<(\w+)>", rule) lists the names a rule needs.

Hint 2 of 3

urllib.parse.quote(text, safe="") turns "/" into %2F and a space into %20.

Hint 3 of 3

urllib.parse.urlencode(extra) builds the query string from a dict.

Build a URL from its endpoint

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: note, note_id=7 → '/notes/7/'
  2. Not run yet: note, with no note_id → raises ValueError
  3. Not run yet: notes, page=2 → '/notes/?page=2'
  4. Not run yet: tag, name="to do/now" → '/tags/to%20do%2Fnow/'
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Replace each value

The idea: For each value given, replace its placeholder in the rule.

Right when every value is there and is a plain number.

def build_url(rules: dict[str, str], endpoint: str, **values) -> str:
url = rules[endpoint]
for name, value in values.items():
url = url.replace(f"<{name}>", str(value))
return url
Missing value
Left as <note_id>
Extra values
Dropped
Why
It fills what it is given, and never checks what the rule needs.

Where it breaks: a forgotten note_id gives /notes/<note_id>/, a broken link nobody notices until a user clicks it.

Better

Fill what the rule needs

The idea: Read the names from the rule, and raise if one is missing.

The waste in Okay: it went by the values, not the rule.

Highlighted lines are new in this rung.

import re
def build_url(rules: dict[str, str], endpoint: str, **values) -> str:
url = rules[endpoint]
for name in re.findall(r"<(\w+)>", url):
if name not in values:
raise ValueError(f"{endpoint} needs {name}")
url = url.replace(f"<{name}>", str(values[name]))
return url
Missing value
ValueErrorwas Left as <note_id>
Extra values
Dropped
Why
A missing value fails at once, where the link is built.
What changed from the previous rung
import re
def build_url(rules: dict[str, str], endpoint: str, **values) -> str:
url = rules[endpoint]
for name, value in values.items():
url = url.replace(f"<{name}>", str(value))
for name in re.findall(r"<(\w+)>", url):
if name not in values:
raise ValueError(f"{endpoint} needs {name}")
url = url.replace(f"<{name}>", str(values[name]))
return url

Where it breaks: page=2 is silently dropped, and a tag named to do/now puts a raw / in the path, which points at another route.

Best

Quote path parts, put the rest in the query

The idea: Fill and quote each needed name; urlencode the values left over.

The waste in Better: it trusted values to be URL-safe, and lost the rest.

Highlighted lines are new in this rung.

import re
from urllib.parse import quote, urlencode
def build_url(rules: dict[str, str], endpoint: str, **values) -> str:
url = rules[endpoint]
used = set()
for name in re.findall(r"<(\w+)>", url):
if name not in values:
raise ValueError(f"{endpoint} needs {name}")
url = url.replace(f"<{name}>", quote(str(values[name]), safe=""))
used.add(name)
extra = {k: v for k, v in values.items() if k not in used}
return f"{url}?{urlencode(extra)}" if extra else url
Missing value
ValueError
Extra values
Query stringwas Dropped
Why
Every value ends up in the URL, safely, and in the right part.
What changed from the previous rung
import re
from urllib.parse import quote, urlencode
def build_url(rules: dict[str, str], endpoint: str, **values) -> str:
url = rules[endpoint]
used = set()
for name in re.findall(r"<(\w+)>", url):
if name not in values:
raise ValueError(f"{endpoint} needs {name}")
url = url.replace(f"<{name}>", str(values[name]))
return url
url = url.replace(f"<{name}>", quote(str(values[name]), safe=""))
used.add(name)
extra = {k: v for k, v in values.items() if k not in used}
return f"{url}?{urlencode(extra)}" if extra else url

When Best isn’t best: in Flask, call url_for. It also checks converters like <int:pk>, adds the blueprint prefix, and can build full URLs for emails with _external=True.

3. A database session per request (medium)

Section titled “3. A database session per request (medium)”

Write handle(view, connect). Call view(get_db) and return its result. get_db() must open a connection with connect() the first time it is called, and give back the same one after that. When the view ends, close the connection if one was opened, even if the view raised.

Example: a view that calls get_db() twice opens one connection and closes it once. A view that never calls it opens none.

Hint 1 of 3

Keep the connection in a variable outside get_db, like Flask keeps it in g.

Hint 2 of 3

A list or nonlocal lets the inner function change it.

Hint 3 of 3

try: return view(get_db) / finally: close if opened. finally runs even when the view raises.

A database session per request

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: A view that uses the database twice → ['ok', ['open', 'close']]
  2. Not run yet: A view that never uses it → ['ok', []]
  3. Not run yet: A view that raises after opening it → ['open', 'close', 'raised']
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Open, run, close

The idea: Connect, run the view, then close.

Fine while every view succeeds.

def handle(view, connect):
conn = connect()
result = view(lambda: conn)
conn.close() # skipped if the view raises
return result
View raises
Never closed
View doesn’t need it
Opened anyway
Why
The close line only runs if the view returns.

Where it breaks: each error leaks a connection. After enough errors, the pool is empty and every request waits.

Better

try and finally

The idea: Connect first, then close in finally.

The waste in Okay: errors skipped the clean-up.

Highlighted lines are new in this rung.

def handle(view, connect):
conn = connect() # opened even if the view never uses it
try:
return view(lambda: conn)
finally:
conn.close()
View raises
Closedwas Never closed
View doesn’t need it
Opened anyway
Why
finally runs whether the view returns or raises.
What changed from the previous rung
def handle(view, connect):
conn = connect()
result = view(lambda: conn)
conn.close() # skipped if the view raises
return result
conn = connect() # opened even if the view never uses it
try:
return view(lambda: conn)
finally:
conn.close()

Where it breaks: a health check or a static page opens a connection for nothing. Under load, that is many idle connections.

Best

Open on first use, close in finally

The idea: get_db opens once and reuses; finally closes only what was opened.

The waste in Better: it paid for a connection before knowing it was needed.

Highlighted lines are new in this rung.

def handle(view, connect):
opened = []
def get_db():
if not opened:
opened.append(connect()) # open on first use, then reuse
return opened[0]
try:
return view(get_db)
finally:
if opened:
opened[0].close() # like Flask's teardown_appcontext
View raises
Closed
View doesn’t need it
Never openedwas Opened anyway
Why
Exactly one connection per request that needs one, always returned.
What changed from the previous rung
def handle(view, connect):
conn = connect() # opened even if the view never uses it
opened = []
def get_db():
if not opened:
opened.append(connect()) # open on first use, then reuse
return opened[0]
try:
return view(lambda: conn)
return view(get_db)
finally:
conn.close()
if opened:
opened[0].close() # like Flask's teardown_appcontext

When Best isn’t best: in Flask this is get_db() with g plus @app.teardown_appcontext, as in examples/flask/flask_notes/db.py. Flask-SQLAlchemy sets it up for you.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Multiple choiceWhy use an app factory, create_app(), instead of a module-level app?
  2. Predict the outputThe environment has DEBUG=false. What is bool(os.environ["DEBUG"])?
  3. Spot the bugFlask refuses to start after a second view is guarded. Why?
    def login_required(view):
        def wrapper(*args, **kwargs):
            if "user_id" not in session:
                return {"error": "Log in first."}, 401
            return view(*args, **kwargs)
        return wrapper
  4. Multiple choiceWhere should a Flask app keep the database session for the current request?
  5. Fill in the answerThe Flask object that groups routes, like a Django app, and is registered with a url_prefix: ____

Add these to your review deck. Each card comes back a little later every time you get it right.

  • create_app(settings) builds a new app: config, database, blueprints. Tests make their own.
  • Config: defaults, then environment (parsed), then overrides. No default secret in production.
  • Contexts: current_app and g (app), request and session (request), one set per request.
  • Database: one session per request, opened lazily in g, closed in teardown_appcontext.
  • Decorators keep names with functools.wraps; links come from url_for; test with app.test_client().

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started