How to enforce annotation coverage on a Python library with Pyrefly
Annotation coverage tracks what fraction of a library’s public API carries type annotations. Pyrefly ships a coverage subcommand that reports this percentage per symbol and module, then exits non-zero in CI when the number falls below a threshold.
This is distinct from type checking: pyrefly check verifies that annotations are consistent; pyrefly coverage measures whether annotations exist. Ship type annotations with a py.typed marker first, then use this guide to track completeness.
Prerequisites
- uv installed
- A library project with a
src/layout and apyproject.toml
Add Pyrefly as a development dependencyA package needed during development (testing, linting, formatting) that is not shipped to users of your project. Installed with the --dev flag. :
uv add --dev pyreflyCheck current annotation coverage
pyrefly coverage report emits a JSON document with per-symbol and per-module counts. It always exits 0, making it safe for dashboards and log capture:
uv run pyrefly coverage reportThe summary block gives the project-wide percentage:
{
"summary": {
"n_typable": 4,
"n_typed": 2,
"n_any": 0,
"n_untyped": 2,
"coverage": 50.0,
"strict_coverage": 50.0
}
}n_typable counts function parameters, return types, and class attributes. Local variables are excluded. To extract a single metric for a CI log, pipe the output to jq:
uv run pyrefly coverage report | jq .summary.coverageFind what’s unannotated
pyrefly coverage check prints unannotated symbols with their source locations:
$ uv run pyrefly coverage check
WARN `mylib.farewell` is untyped [coverage-missing]
--> src/mylib/__init__.py:6:1
|
6 | / def farewell(name):
7 | | return f"Goodbye, {name}!"
| |______________________________-
|
ERROR type coverage 50.00% (2 of 4 typable) is below the 100.00% threshold
The default threshold is 100%, so the command exits 1 whenever any annotatable symbol lacks an annotation. Each coverage-missing warning identifies the exact function or method to annotate next.
Focus on the public API
--public-only restricts coverage to symbols reachable from the library’s __all__ and re-export chains. Internal modules and private helpers don’t affect the score:
uv run pyrefly coverage check --public-onlyFor a library with private implementation details that users never import, --public-only gives the number that actually matters to consumers. Start with this flag rather than measuring the whole project.
Set a minimum threshold
--fail-under N exits non-zero when coverage falls below N percent and exits 0 when coverage meets or exceeds it:
$ uv run pyrefly coverage check --public-only --fail-under 100
WARN `mylib.farewell` is untyped [coverage-missing]
--> src/mylib/__init__.py:6:1
|
6 | / def farewell(name):
7 | | return f"Goodbye, {name}!"
| |______________________________-
|
ERROR type coverage 50.00% (2 of 4 typable) is below the 100.00% threshold
When coverage meets the threshold, the command shows a summary and exits 0:
$ uv run pyrefly coverage check --public-only --fail-under 50
WARN `mylib.farewell` is untyped [coverage-missing]
--> src/mylib/__init__.py:6:1
|
6 | / def farewell(name):
7 | | return f"Goodbye, {name}!"
| |______________________________-
|
INFO type coverage 50.00% (2 of 4 typable)
Note that coverage-missing warnings still appear even when the threshold passes. The exit code governs CI; the warnings identify what to annotate next.
Add to CI
Add the check as a step in a GitHub Actions workflow:
- name: Check annotation coverage
run: uv run pyrefly coverage check --public-only --fail-under 80To set the initial threshold without failing on the first run, capture the current percentage with coverage report and set --fail-under to that value. Raise the threshold as annotations accumulate over time.
Use pyrefly infer to auto-generate annotations for the symbols the coverage-missing warnings surface.