DevLab / utilities / python-type-coverage-analyzer
UTILITIES PYTHON INTERMEDIATE

Python Type Coverage Analyzer

As Python codebases mature, adopting static type annotations becomes critical for maintaining code quality, reducing runtime errors, and improving develope

Python3.10+
OSLinux / macOS / Windows
Lines~180
Packages1 deps
Install Dependencies
$ pip install mypy
Usage
$ python3 type_coverage.py --src src/ --strict --report type_report.html
#utilities#python#intermediate#mypy

Problem Statement

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.


How It Works

The script operates through a two-stage analysis architecture:

  1. AST Structural Inspection: The script recursively scans the specified target directory for .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.
  2. Mypy Verification Engine: The script programmatically invokes 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.
  3. Metrics Aggregation & Reporting: Total functions, arguments, return annotations, and type errors are aggregated. The engine computes an overall coverage score and outputs both a CLI summary table and an HTML report complete with metric cards, progress bars, and file breakdown tables. If the coverage score falls below a configured threshold, the script exits with a non-zero exit code.
┌─────────────────┐    ┌──────────────────────────┐
│ Source Directory│───>│ AST Visitor              │──┐
└─────────────────┘    │ (Functions/Args/Returns) │  │
                       └──────────────────────────┘  │
                                                     ├──> Aggregator ──> HTML Report & Exit Code
┌─────────────────┐    ┌──────────────────────────┐  │
│ Mypy API        │───>│ Error & Warning Counter  │──┘
└─────────────────┘    └──────────────────────────┘

Full Script

#!/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_
// RELATED_SCRIPTS
← Back to DevLab valtersit.com