As Python codebases mature, adopting static type annotations becomes critical for maintaining code quality, reducing runtime errors, and improving develope
As Python codebases mature, adopting static type annotations becomes critical for maintaining code quality, reducing runtime errors, and improving developer tooling. However, transitioning an existing untyped or partially-typed codebase to strict typing is rarely done all at once. Engineering teams need a reliable way to measure, track, and enforce type annotation coverage over time.
While mypy is the industry standard static type checker, interpreting its output across thousands of lines of code to get high-level metrics can be difficult. standard mypy runs fail on type errors but don't easily quantify overall function, argument, or return-type annotation percentages. This script bridges that gap by combining AST (Abstract Syntax Tree) static analysis with mypy execution to calculate exact coverage metrics, generate visual HTML reports, and enforce coverage thresholds in CI/CD pipelines.
The script operates through a two-stage analysis architecture:
.py files and parses them into ASTs using Python's native ast module. An ast.NodeVisitor subclass inspects all synchronous and asynchronous function definitions, evaluating whether function parameters (excluding standard self and cls receivers) and return signatures include explicit type annotations.mypy.api.run() against the target directory. It captures stdout to count mypy-specific type errors and warnings per file, matching these back to the AST metrics.┌─────────────────┐ ┌──────────────────────────┐
│ Source Directory│───>│ AST Visitor │──┐
└─────────────────┘ │ (Functions/Args/Returns) │ │
└──────────────────────────┘ │
├──> Aggregator ──> HTML Report & Exit Code
┌─────────────────┐ ┌──────────────────────────┐ │
│ Mypy API │───>│ Error & Warning Counter │──┘
└─────────────────┘ └──────────────────────────┘
#!/usr/bin/env python3
"""
Python Type Coverage Analyzer
Analyzes Python source code for type annotation completeness and runs mypy
type verification to generate statistical metrics and HTML reports.
"""
import argparse
import ast
import html
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from mypy import api as mypy_api
# Configuration defaults via environment variables
DEFAULT_THRESHOLD = float(os.environ.get("TYPE_COVERAGE_THRESHOLD", "80.0"))
DEFAULT_REPORT_PATH = os.environ.get("TYPE_COVERAGE_REPORT_PATH", "type_report.html")
DEFAULT_EXCLUDE_DIRS = set(
os.environ.get("TYPE_COVERAGE_EXCLUDE_DIRS", ".venv,venv,build,dist,.git,__pycache__").split(",")
)
@dataclass
class FileCoverage:
"""Stores type coverage statistics for a single Python file."""
path: Path
total_functions: int = 0
annotated_functions: int = 0
total_args: int = 0
annotated_args: int = 0
returns_annotated: int = 0
mypy_errors: int = 0
parse_error: bool = False
@property
def coverage_percent(self) -> float:
"""Calculates combined function return and argument annotation percentage."""
total_items = self.total_functions + self.total_args
if total_items == 0:
return 100.0
annotated_items = self.returns_annotated + self.annotated_args
return (annotated_items / total_items) * 100.0
@dataclass
class SuiteCoverage:
"""Aggregates coverage metrics across all analyzed files."""
files: List[FileCoverage] = field(default_factory=list)
total_mypy_errors: int = 0
@property
def total_functions(self) -> int:
return sum(f.total_functions for f in self.files)
@property
def annotated_functions(self) -> int:
return sum(f.annotated_functions for f in self.files)
@property
def total_args(self) -> int:
return sum(f.total_args for f in self.files)
@property
def annotated_args(self) -> int:
return sum(f.annotated_args for f in self.files)
@property
def returns_annotated(self) -> int:
return sum(f.returns_annotated for f in self.files)
@property
def overall_coverage(self) -> float:
total_items = self.total_functions + self.total_args
if total_items == 0:
return 100.0
annotated_items = self.returns_annotated + self.annotated_args
return (annotated_items / total_items) * 100.0
class TypeVisitor(ast.NodeVisitor):
"""AST Visitor that calculates type annotation completeness on functions."""
def __init__(self) -> None:
self.total_functions = 0
self.annotated_functions = 0
self.total_args = 0
self.annotated_args = 0
self.returns_annotated = 0
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._analyze_function(node)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._analyze_function(node)
self.generic_visit(node)
def _analyze_function(self, node: ast.AST) -> None:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return
self.total_functions += 1
has_return_type = node.returns is not None
if has_return_type:
self.returns_annotated += 1
all_args = node.args.args + node.args.kwonlyargs
if node.args.vararg:
all_args.append(node.args.vararg)
if node.args.kwarg:
all_args.append(node.args.kwarg)
func_total_args = 0
func_annotated_args = 0
for arg in all_args:
# Skip implicit receiver arguments in methods
if arg.arg in ("self", "cls"):
continue
func_total_args += 1
if arg.annotation is not None:
func_annotated_args += 1
self.total_args += func_total_args
self.annotated_args += func_annotated_args
# A function is considered fully annotated if its return and all args are typed
is_fully_typed = has_return_type and (func_total_args == func_annotated_args)
if is_fully_typed:
self.annotated_functions += 1
def analyze_file(file_path: Path) -> FileCoverage:
"""Parses a single Python file using AST to extract type metrics."""
metrics = FileCoverage(path=file_path)
try:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(file_path))
visitor = TypeVisitor()
visitor.visit(tree)
metrics.total_functions = visitor.total_functions
metrics.annotated_functions = visitor.annotated_functions
metrics.total_args = visitor.total_args
metrics.annotated_args = visitor.annotated_args
metrics.returns_annotated = visitor.returns_annotated
except (SyntaxError, UnicodeDecodeError, PermissionError) as err:
print(f"[WARNING] Could not parse {file_path}: {err}", file=sys.stderr)
metrics.parse_error = True
return metrics
def run_mypy_analysis(target_dir: Path, strict: bool) -> Dict[str, int]:
"""Runs Mypy programmatically and parses error counts per file."""
error_counts: Dict[str, int] = {}
flags = [str(target_dir), "--show-column-numbers", "--no-error-summary"]
if strict:
flags.append("--strict")
stdout_data, stderr_data, exit_code = mypy_api.run(flags)
output = stdout_data or stderr_data
for line in output.splitlines():
# Expected mypy output format: path/to/file.py:line:col: error: message
parts = line.split(":")
if len(parts) >= 4 and "error" in parts[3].lower():
file_key = str(Path(parts[0].strip()).resolve())
error_counts[file_key] = error_counts.get(file_key, 0) + 1
return error_counts
def collect_python_files(target_dir: Path, exclude_dirs: set[str]) -> List[Path]:
"""Recursively collects Python files while skipping excluded directories."""
python_files: List[Path] = []
for root, dirs, files in os.walk(target_dir):
dirs[:] = [d for d in dirs if d not in exclude_dirs and not d.startswith(".")]
for file in files:
if file.endswith(".py"):
python_files.append(Path(root) / file)
return python_files
def generate_html_report(suite: SuiteCoverage, output_path: Path, threshold: float) -> None:
"""Generates an HTML report containing overall and per-file type coverage metrics."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
status_color = "#2ef072" if suite.overall_coverage >= threshold else "#ff5252"
table_rows = []
for f in sorted(suite.files, key=lambda x: x.coverage_percent):
rel_path = f.path.name
cov = f.coverage_percent
row_color = "#1e293b" if cov >= threshold else "#331219"
table_rows.append(f"""
<tr style="background-color: {row_color};">
<td title="{html.escape(str(f.path))}">{html.escape(rel_path)}</td>
<td>{f.total_functions}</td>
<td>{f.annotated_functions}</td>
<td>{f.returns_annotated}/{f.total_functions}</td>
<td>{f.annotated_args}/{f.total_args}</td>
<td>{f.mypy_errors}</td>
<td>
<div class="progress-bar">
<div class="progress-fill" style="width: {cov:.1f}%; background-color: {'#2ef072' if cov >= threshold else '#ff5252'};"></div>
</div>
<span>{cov:.1f}%</span>
</td>
</tr>
""")
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Type Coverage Report</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 2rem; }}
.container {{ max-width: 1200px; margin: 0 auto; }}
h1 {{ font-size: 2rem; margin-bottom: 0.5rem; }}
.meta {{ color: #94a3b8; margin-bottom: 2rem; font-size: 0.9rem; }}
.metrics-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }}
.card {{ background: #1e293b; padding: 1.5rem; border-radius: 8px; border: 1px solid #334155; }}
.card-title {{ color: #94a3b8; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }}
.card-value {{ font-size: 2rem; font-weight: bold; margin-top: 0.5rem; color: #f8fafc; }}
table {{ width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 8px; overflow: hidden; }}
th, td {{ padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #334155; }}
th {{ background: #0f172a; color: #94a3b8; font-size: 0.85rem; text-transform: uppercase; }}
.progress-bar {{ background: #334155; height: 8px; border-radius: 4px; overflow: hidden; display: inline-block; width: 100px; margin-right: 8px; }}
.progress-fill {{ height: 100%; }}
</style>
</head>
<body>
<div class="container">
<h1>Type Coverage Report</h1>
<div class="meta">Generated on {timestamp} | Target Threshold: {threshold:.1f}%</div>
<div class="metrics-grid">
<div class="card">
<div class="card-title">Overall Coverage</div>
<div class="card-value" style="color: {status_color};">{suite.overall_coverage:.1f}%</div>
</div>
<div class="card">
<div class="card-title">Total Functions</div>
<div class="card-value">{suite.total_functions}</div>
</div>
<div class="card">
<div class="card-title">Typed Arguments</div>
<div class="card-value">{suite.annotated_args} / {suite.total_args}</div>
</div>
<div class="card">
<div class="card-title">Mypy Errors</div>
<div class="card-value" style="color: {'#2ef072' if suite.total_mypy_errors == 0 else '#ff5252'};">{suite.total_mypy_errors}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>File</th>
<th>Functions</th>
<th>Fully Typed Funcs</th>
<th>Returns Typed</th>
<th>Args Typed</th>
<th>Mypy Errors</th>
<th>Coverage</th>
</tr>
</thead>
<tbody>
{"".join(table_rows)}
</tbody>
</table>
</div>
</body>
</html>
"""
output_path.write_text(html_content, encoding="utf-8")
print(f"[INFO] HTML report successfully written to: {output_path.resolve()}")
def main() -> None:
parser = argparse.ArgumentParser(description="Python Type Coverage Analyzer")
parser.add_argument("--src", type=str, default=".", help="Source directory to analyze")
parser.add_argument("--report", type=str, default=DEFAULT_REPORT_PATH, help="Path for HTML report output")
parser.add_argument("--strict", action="store_true", help="Enable strict mode checks in Mypy")
parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Minimum coverage percentage threshold")
args = parser.parse_args()
target_path = Path(args.src).resolve()
if not target_path.exists():
print(f"[ERROR] Source path '{target_path}' does not exist.", file=sys.stderr)
sys.exit(2)
print(f"[INFO] Collecting Python source files from: {target_path}")
py_files = collect_python_files(target_path, DEFAULT_EXCLUDE_DIRS)
if not py_files:
print("[WARNING] No Python files found to analyze.")
sys.exit(0)
print(f"[INFO] Analyzing AST for {len(py_files)} files...")
suite = SuiteCoverage()
for file_path in py_files:
metrics = analyze_file(file_path)
suite.files.append(metrics)
print("[INFO] Running Mypy type check verification...")
mypy_errors_map = run_mypy_analysis(target_path, strict=args.strict)
for metrics in suite.files:
resolved_str = str(metrics.path.resolve())
metrics.mypy_errors = mypy_errors_map.get(resolved_str, 0)
suite.total_mypy_