Created
May 10, 2025 05:33
-
-
Save alhoo/13d17be49645f32bbb73787d52c15c06 to your computer and use it in GitHub Desktop.
coverage.json analyzer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import json | |
import ast | |
import os | |
def get_functions_from_source(filepath): | |
""" | |
Parses a Python file and returns a list of functions with their line numbers. | |
Each item is a tuple: (function_name, start_line, end_line). | |
""" | |
functions = [] | |
try: | |
with open(filepath, 'r', encoding='utf-8') as f: | |
source_code = f.read() | |
tree = ast.parse(source_code, filename=filepath) | |
for node in ast.walk(tree): | |
if isinstance(node, ast.FunctionDef): | |
start_line = node.lineno | |
# Determine end_line more robustly | |
if hasattr(node, 'end_lineno') and node.end_lineno is not None: | |
end_line = node.end_lineno | |
else: | |
# Fallback: find max line number of direct children or the function def line itself | |
if node.body: | |
# Get the last line of the last statement in the body | |
# This can be complex for multi-line statements. | |
# A simpler heuristic: last line of any node within the function body. | |
max_child_line = start_line | |
for child_node in ast.walk(node): # Walk within the function node | |
if child_node is not node and hasattr(child_node, 'lineno'): # Exclude the func def itself | |
current_line = getattr(child_node, 'end_lineno', getattr(child_node, 'lineno')) | |
if current_line is not None: | |
max_child_line = max(max_child_line, current_line) | |
end_line = max_child_line | |
if not node.body or end_line < start_line : # if only pass or docstring | |
end_line = start_line # Simplistic fallback for really empty functions | |
else: # Empty function body | |
end_line = start_line | |
functions.append((node.name, start_line, end_line)) | |
except Exception as e: | |
print(f"Could not parse {filepath}: {e}") | |
return functions | |
def find_partially_covered_functions( | |
coverage_json_path="coverage.json", | |
project_root=".", | |
coverage_threshold_percent=20.0 # Default threshold: 20% | |
): | |
""" | |
Identifies functions where the percentage of executed lines within the function | |
is below the specified threshold. | |
""" | |
under_covered_functions_map = {} | |
try: | |
with open(coverage_json_path, 'r') as f: | |
coverage_data = json.load(f) | |
except FileNotFoundError: | |
print(f"Error: {coverage_json_path} not found. Run pytest with --cov-report=json first.") | |
return {} | |
except json.JSONDecodeError: | |
print(f"Error: Could not decode {coverage_json_path}. Is it a valid JSON file?") | |
return {} | |
if 'files' not in coverage_data: | |
print("Error: 'files' key not found in coverage.json. The format might be unexpected.") | |
return {} | |
for filepath_rel, file_data in coverage_data.get('files', {}).items(): | |
filepath_abs = os.path.abspath(os.path.join(project_root, filepath_rel)) | |
if not os.path.exists(filepath_abs): | |
print(f"Warning: Source file {filepath_abs} (from {filepath_rel}) not found. Skipping.") | |
continue | |
if not filepath_abs.endswith(".py") or "test" in filepath_abs.lower(): | |
continue | |
source_functions = get_functions_from_source(filepath_abs) | |
executed_lines_in_file = set(file_data.get('executed_lines', [])) | |
missing_lines_in_file = set(file_data.get('missing_lines', [])) # Lines that could be executed | |
file_under_covered_functions = [] | |
for func_name, start_line, end_line in source_functions: | |
func_executed_count = 0 | |
func_executable_count = 0 # Lines in function that are either executed or missing | |
# Iterate over the lines within the function's span (inclusive) | |
for line_num in range(start_line, end_line + 1): | |
if line_num in executed_lines_in_file: | |
func_executed_count += 1 | |
func_executable_count += 1 | |
elif line_num in missing_lines_in_file: | |
# This line is part of the function, could have been executed, but wasn't. | |
# It contributes to the total number of executable lines in the function. | |
func_executable_count += 1 | |
# Lines not in executed_lines nor missing_lines are comments, blank, | |
# or excluded by pragmas (which coverage.py already handles by not listing them | |
# as missing or executed if they are truly excluded). | |
if func_executable_count > 0: # Avoid division by zero for empty/non-executable functions | |
percentage_covered = (func_executed_count / func_executable_count) * 100 | |
if percentage_covered < coverage_threshold_percent: | |
file_under_covered_functions.append( | |
f"{func_name} (lines {start_line}-{end_line}, {percentage_covered:.1f}% covered, {func_executed_count}/{func_executable_count} lines)" | |
) | |
elif func_executable_count == 0 and start_line <= end_line : | |
# This function has no lines that coverage.py considers executable | |
# (e.g., only pass, docstrings, or all lines #pragma: no cover) | |
# You might decide to always list these, or consider them "N/A" or "100% covered" | |
# For this script, if threshold is > 0, it will appear if not 100% (0/0 is tricky) | |
# Let's assume if threshold is 0, it means "any coverage". | |
# If threshold is > 0, and it has 0 executable lines, it won't meet the threshold | |
# unless the threshold is 0. If threshold is 0, it means "any coverage is fine", | |
# so a func with 0 executable lines is fine. | |
# If the threshold is, say, 20%, and a function has 0 executable lines, | |
# 0% < 20% is true, so it would be listed. | |
# This feels right: if it has no executable code, it can't meet a positive threshold. | |
if coverage_threshold_percent > 0 : # only list if we expect some coverage | |
file_under_covered_functions.append( | |
f"{func_name} (lines {start_line}-{end_line}, 0.0% covered, 0/0 executable lines)" | |
) | |
if file_under_covered_functions: | |
under_covered_functions_map[filepath_rel] = file_under_covered_functions | |
return under_covered_functions_map | |
if __name__ == "__main__": | |
# --- Configuration --- | |
json_file = "coverage.json" | |
# Set this to the root of your source code if coverage.json paths are relative to it | |
# Or if the script is not in the same dir as coverage.json | |
source_project_root = "." | |
# Minimum percentage of lines within a function that must be covered | |
min_coverage_percent_for_function = 20.0 | |
# --- End Configuration --- | |
print(f"Looking for functions with less than {min_coverage_percent_for_function}% line coverage...\n") | |
uncovered = find_partially_covered_functions( | |
coverage_json_path=json_file, | |
project_root=source_project_root, | |
coverage_threshold_percent=min_coverage_percent_for_function | |
) | |
if not uncovered: | |
print(f"All identified functions meet or exceed the {min_coverage_percent_for_function}% coverage threshold (or no Python files found/parsed)!") | |
else: | |
print(f"Functions with less than {min_coverage_percent_for_function}% coverage:") | |
for filepath, funcs in uncovered.items(): | |
print(f" File: {filepath}") | |
for func_info in funcs: | |
print(f" - {func_info}") | |
print("\nNote:") | |
print("- 'Executable lines' are lines coverage.py identified as either executed or missing (could be run).") | |
print("- Function line ranges are determined by AST parsing.") | |
print("- Exclude test files or virtual envs from your --cov target for cleaner results.") | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment