Live code coverage for a running Flask application.
flask-coverage wraps coverage.py as a Flask extension and exposes a small debug blueprint at /debug/coverage. You can introspect what's been executed so far, take snapshots, view a per-file HTML report, and export the raw .coverage data — all from a running process, without restarting it.
It is designed for two scenarios:
- Browser tests. Run your full Flask app under Playwright/Selenium/Cypress, drive it however you like, then read the live coverage report to see which paths your end-to-end tests actually reach.
- Production / canary. Measure what code your live traffic exercises. Coverage measurement carries some overhead (typically <15% on Python 3.12+ with
sys.monitoring), but for low-to-mid QPS services that's a reasonable trade for ground-truth dead-code detection.
pip install flask-coverageRequires Python ≥ 3.12, Flask ≥ 2.3, coverage ≥ 7.4.
from flask import Flask
from flask_coverage import FlaskCoverage
app = Flask(__name__)
FlaskCoverage(app) # mounts /debug/coverageRun your app and visit http://127.0.0.1:5000/debug/coverage/.
A runnable demo with a step-by-step walkthrough lives in examples/.
Mounted under /debug/coverage by default (override with FlaskCoverage(app, url_prefix="…")).
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Dashboard: cache timestamp, snapshots panel, embedded text report |
GET |
/report |
Cached text report (same format as coverage report) |
GET |
/html/ |
Cached HTML report (coverage.py's native, per-file source views) |
GET |
/files |
Cached JSON list: {file, statements, missing, covered, percent} |
GET |
/export |
Cached merged .coverage data file |
POST |
/refresh |
Regenerate the cache. Browser refresh does not. |
POST |
/snapshot?label=… |
Take a labelled, timestamped copy into snapshot_dir |
GET |
/snapshots |
JSON list of saved snapshots |
GET |
/snapshots/<id> |
Download a saved snapshot's .coverage file |
POST |
/reset |
Erase all collected data |
POST endpoints content-negotiate: Accept: application/json returns JSON, anything else gets a 303 redirect to the dashboard with a flash message. Snapshots and refresh are designed to be operable from the dashboard without leaving it.
Coverage settings are read from [tool.coverage.*] in pyproject.toml automatically (via coverage.py's native config support), or from .coveragerc / setup.cfg / tox.ini if present.
[tool.coverage.run]
source = ["myapp"]
parallel = true # recommended for gunicorn/uwsgi (see Multi-worker)
[tool.coverage.report]
omit = ["*/migrations/*", "*/tests/*"]For accurate measurement of module-level code, coverage must start before your application modules are imported. Three options, in order of preference:
-
COVERAGE_PROCESS_STARTenv var (best for gunicorn/uwsgi):export COVERAGE_PROCESS_START=$(pwd)/pyproject.toml gunicorn myapp:app
-
flask-coverageCLI shim:flask-coverage --app myapp run --debug
-
Manual
start_early()as the very first line inwsgi.py:from flask_coverage import start_early start_early() # before any of your app modules from myapp import create_app app = create_app()
If a Coverage instance is already running (any of the above, or pytest-cov in tests), FlaskCoverage(app) adopts it instead of creating a duplicate tracer.
The /debug/coverage blueprint exposes filesystem paths for every measured source file — treat it as sensitive. Registration is fail-closed: it requires one of the following, or it raises RuntimeError:
-
app.debugisTrue, or -
FLASK_COVERAGE_PASSWORDenv var is set (HTTP Basic auth — useradmin, override withFLASK_COVERAGE_USERNAME), or -
a custom
auth=callback is passed toFlaskCoverage(...):FlaskCoverage(app, auth=lambda: current_user.is_authenticated and current_user.is_admin)
The basic-auth check uses hmac.compare_digest for constant-time comparison.
Set FLASK_COVERAGE_DISABLED to a truthy value (1, true, yes, on) before the process starts, and FlaskCoverage(app) becomes a no-op: no tracer, no blueprint, no auth check.
FLASK_COVERAGE_DISABLED=1 gunicorn myapp:appEach worker traces independently. Set parallel = true under [tool.coverage.run] so each worker writes .coverage.<host>.<pid>.<rand>. Two things happen automatically:
-
Auto-save thread. Each worker runs a daemon thread that calls
cov.save()everyautosave_intervalseconds (default 30, configurable viaFlaskCoverage(autosave_interval=…); set to 0 to disable). This bounds inter-worker staleness — when worker A serves/refresh, the on-disk data from workers B, C, D is at most 30s old. -
Non-destructive combine. Refreshing the cache merges the running worker's data with all sibling parallel files in a temp dir, runs
coverage combinethere, and uses the merged file. The original per-worker files on disk are never deleted, so workers keep accumulating data normally.
The dashboard surfaces both pieces of information: it shows the last-refresh timestamp and the autosave interval, so you can reason about freshness from the UI alone.
/report, /files, /html/, and /export all serve from a file-based cache (.flask-coverage-cache/ next to the data file, configurable via cache_dir=…). Refreshing the browser never regenerates the cache — only POST /refresh does. This makes the cost of hitting the dashboard predictable, and prevents accidental thundering herds in production. Cache regeneration is file-locked across workers.
On Python 3.12+, coverage.py uses sys.monitoring (PEP 669), which is significantly faster than the legacy sys.settrace path — typically under 15% overhead. Acceptable for staging and canary; profile before turning on for high-QPS production traffic.
git clone https://github.com/abilian/flask-coverage
cd flask-coverage
uv sync
uv run pytest # 45 tests
make check # lint + format + type checkTests are organised by the test pyramid:
tests/a_unit/— fast, isolated, mock-basedtests/b_integration/— Flask test-client + mocked Coveragetests/c_e2e/— realcoverage.Coveragerunning, including multi-worker simulation
CI runs ruff (lint + format), ty (type check), and pytest across Python 3.12 / 3.13 / 3.14.
MIT — see LICENSE.