Skip to content

Commit e929018

Browse files
authored
Python: Add coverage threshold gate for PR checks (#3392) (#3510)
* Python: Add coverage threshold gate for PR checks (#3392) - Add python-check-coverage.py script to enforce coverage threshold on specific modules - Modify python-test-coverage.yml to run coverage check after tests - Initial enforced module: agent_framework_azure_ai at 85% threshold - Other modules are reported for visibility but don't block merges * Fail if module not found * Force unit test job to run * Comment 1 * Fix coverage check to use full package paths for submodule support * Update report format
1 parent a0cce87 commit e929018

File tree

4 files changed

+273
-2
lines changed

4 files changed

+273
-2
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Microsoft. All rights reserved.
3+
"""Check Python test coverage against threshold for enforced modules.
4+
5+
This script parses a Cobertura XML coverage report and enforces a minimum
6+
coverage threshold on specific modules. Non-enforced modules are reported
7+
for visibility but don't block the build.
8+
9+
Usage:
10+
python python-check-coverage.py <coverage-xml-path> <threshold>
11+
12+
Example:
13+
python python-check-coverage.py python-coverage.xml 85
14+
"""
15+
16+
import sys
17+
import xml.etree.ElementTree as ET
18+
from dataclasses import dataclass
19+
20+
# =============================================================================
21+
# ENFORCED MODULES CONFIGURATION
22+
# =============================================================================
23+
# Add or remove modules from this set to control which packages must meet
24+
# the coverage threshold. Only these modules will fail the build if below
25+
# threshold. Other modules are reported for visibility only.
26+
#
27+
# Module paths should match the package paths as they appear in the coverage
28+
# report (e.g., "packages.azure-ai.agent_framework_azure_ai" for packages/azure-ai).
29+
# Sub-modules can be included by specifying their full path.
30+
# =============================================================================
31+
ENFORCED_MODULES: set[str] = {
32+
"packages.azure-ai.agent_framework_azure_ai",
33+
# Add more modules here as coverage improves:
34+
# "packages.core.agent_framework",
35+
# "packages.core.agent_framework._workflows",
36+
# "packages.anthropic.agent_framework_anthropic",
37+
}
38+
39+
40+
@dataclass
41+
class PackageCoverage:
42+
"""Coverage data for a single package."""
43+
44+
name: str
45+
line_rate: float
46+
branch_rate: float
47+
lines_valid: int
48+
lines_covered: int
49+
branches_valid: int
50+
branches_covered: int
51+
52+
@property
53+
def line_coverage_percent(self) -> float:
54+
"""Return line coverage as a percentage."""
55+
return self.line_rate * 100
56+
57+
@property
58+
def branch_coverage_percent(self) -> float:
59+
"""Return branch coverage as a percentage."""
60+
return self.branch_rate * 100
61+
62+
63+
def parse_coverage_xml(xml_path: str) -> tuple[dict[str, PackageCoverage], float, float]:
64+
"""Parse Cobertura XML and extract per-package coverage data.
65+
66+
Args:
67+
xml_path: Path to the Cobertura XML coverage report.
68+
69+
Returns:
70+
A tuple of (packages_dict, overall_line_rate, overall_branch_rate).
71+
"""
72+
tree = ET.parse(xml_path)
73+
root = tree.getroot()
74+
75+
# Get overall coverage from root element
76+
overall_line_rate = float(root.get("line-rate", 0))
77+
overall_branch_rate = float(root.get("branch-rate", 0))
78+
79+
packages: dict[str, PackageCoverage] = {}
80+
81+
for package in root.findall(".//package"):
82+
package_path = package.get("name", "unknown")
83+
84+
line_rate = float(package.get("line-rate", 0))
85+
branch_rate = float(package.get("branch-rate", 0))
86+
87+
# Count lines and branches from classes within this package
88+
lines_valid = 0
89+
lines_covered = 0
90+
branches_valid = 0
91+
branches_covered = 0
92+
93+
for class_elem in package.findall(".//class"):
94+
for line in class_elem.findall(".//line"):
95+
lines_valid += 1
96+
if int(line.get("hits", 0)) > 0:
97+
lines_covered += 1
98+
# Branch coverage from line elements
99+
if line.get("branch") == "true":
100+
condition_coverage = line.get("condition-coverage", "")
101+
if condition_coverage:
102+
# Parse "X% (covered/total)" format
103+
try:
104+
coverage_parts = condition_coverage.split("(")[1].rstrip(")").split("/")
105+
branches_covered += int(coverage_parts[0])
106+
branches_valid += int(coverage_parts[1])
107+
except (IndexError, ValueError):
108+
# Ignore malformed condition-coverage strings; treat this line as having no branch data.
109+
pass
110+
111+
# Use full package path as the key (no aggregation)
112+
packages[package_path] = PackageCoverage(
113+
name=package_path,
114+
line_rate=line_rate if lines_valid == 0 else lines_covered / lines_valid,
115+
branch_rate=branch_rate if branches_valid == 0 else branches_covered / branches_valid,
116+
lines_valid=lines_valid,
117+
lines_covered=lines_covered,
118+
branches_valid=branches_valid,
119+
branches_covered=branches_covered,
120+
)
121+
122+
return packages, overall_line_rate, overall_branch_rate
123+
124+
125+
def format_coverage_value(coverage: float, threshold: float, is_enforced: bool) -> str:
126+
"""Format a coverage value with optional pass/fail indicator.
127+
128+
Args:
129+
coverage: Coverage percentage (0-100).
130+
threshold: Minimum required coverage percentage.
131+
is_enforced: Whether this module is enforced.
132+
133+
Returns:
134+
Formatted string like "85.5%" or "85.5% ✅" or "75.0% ❌".
135+
"""
136+
formatted = f"{coverage:.1f}%"
137+
if is_enforced:
138+
icon = "✅" if coverage >= threshold else "❌"
139+
formatted = f"{formatted} {icon}"
140+
return formatted
141+
142+
143+
def print_coverage_table(
144+
packages: dict[str, PackageCoverage],
145+
threshold: float,
146+
overall_line_rate: float,
147+
overall_branch_rate: float,
148+
) -> None:
149+
"""Print a formatted coverage summary table.
150+
151+
Args:
152+
packages: Dictionary of package name to coverage data.
153+
threshold: Minimum required coverage percentage.
154+
overall_line_rate: Overall line coverage rate (0-1).
155+
overall_branch_rate: Overall branch coverage rate (0-1).
156+
"""
157+
print("\n" + "=" * 80)
158+
print("PYTHON TEST COVERAGE REPORT")
159+
print("=" * 80)
160+
161+
# Overall coverage
162+
print(f"\nOverall Line Coverage: {overall_line_rate * 100:.1f}%")
163+
print(f"Overall Branch Coverage: {overall_branch_rate * 100:.1f}%")
164+
print(f"Threshold: {threshold}%")
165+
166+
# Package table
167+
print("\n" + "-" * 110)
168+
print(f"{'Package':<80} {'Lines':<15} {'Line Cov':<15}")
169+
print("-" * 110)
170+
171+
# Sort: enforced modules first, then alphabetically
172+
sorted_packages = sorted(
173+
packages.values(),
174+
key=lambda p: (p.name not in ENFORCED_MODULES, p.name),
175+
)
176+
177+
for pkg in sorted_packages:
178+
is_enforced = pkg.name in ENFORCED_MODULES
179+
enforced_marker = "[ENFORCED] " if is_enforced else ""
180+
line_cov = format_coverage_value(pkg.line_coverage_percent, threshold, is_enforced)
181+
lines_info = f"{pkg.lines_covered}/{pkg.lines_valid}"
182+
package_label = f"{enforced_marker}{pkg.name}"
183+
184+
print(f"{package_label:<80} {lines_info:<15} {line_cov:<15}")
185+
186+
print("-" * 110)
187+
188+
189+
def check_coverage(xml_path: str, threshold: float) -> bool:
190+
"""Check if all enforced modules meet the coverage threshold.
191+
192+
Args:
193+
xml_path: Path to the Cobertura XML coverage report.
194+
threshold: Minimum required coverage percentage.
195+
196+
Returns:
197+
True if all enforced modules pass, False otherwise.
198+
"""
199+
packages, overall_line_rate, overall_branch_rate = parse_coverage_xml(xml_path)
200+
201+
print_coverage_table(packages, threshold, overall_line_rate, overall_branch_rate)
202+
203+
# Check enforced modules
204+
failed_modules: list[str] = []
205+
missing_modules: list[str] = []
206+
207+
for module_name in ENFORCED_MODULES:
208+
if module_name not in packages:
209+
missing_modules.append(module_name)
210+
continue
211+
212+
pkg = packages[module_name]
213+
if pkg.line_coverage_percent < threshold:
214+
failed_modules.append(f"{module_name} ({pkg.line_coverage_percent:.1f}%)")
215+
216+
# Report results
217+
if missing_modules:
218+
print(f"\n❌ FAILED: Enforced modules not found in coverage report: {', '.join(missing_modules)}")
219+
return False
220+
221+
if failed_modules:
222+
print(f"\n❌ FAILED: The following enforced modules are below {threshold}% coverage threshold:")
223+
for module in failed_modules:
224+
print(f" - {module}")
225+
print("\nTo fix: Add more tests to improve coverage for the failing modules.")
226+
return False
227+
228+
if ENFORCED_MODULES:
229+
found_enforced = [m for m in ENFORCED_MODULES if m in packages]
230+
if found_enforced:
231+
print(f"\n✅ PASSED: All enforced modules meet the {threshold}% coverage threshold.")
232+
233+
return True
234+
235+
236+
def main() -> int:
237+
"""Main entry point.
238+
239+
Returns:
240+
Exit code: 0 for success, 1 for failure.
241+
"""
242+
if len(sys.argv) != 3:
243+
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
244+
print(f"Example: {sys.argv[0]} python-coverage.xml 85")
245+
return 1
246+
247+
xml_path = sys.argv[1]
248+
try:
249+
threshold = float(sys.argv[2])
250+
except ValueError:
251+
print(f"Error: Invalid threshold value: {sys.argv[2]}")
252+
return 1
253+
254+
try:
255+
success = check_coverage(xml_path, threshold)
256+
return 0 if success else 1
257+
except FileNotFoundError:
258+
print(f"Error: Coverage file not found: {xml_path}")
259+
return 1
260+
except ET.ParseError as e:
261+
print(f"Error: Failed to parse coverage XML: {e}")
262+
return 1
263+
264+
265+
if __name__ == "__main__":
266+
sys.exit(main())

.github/workflows/python-test-coverage.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ on:
99
env:
1010
# Configure a constant location for the uv cache
1111
UV_CACHE_DIR: /tmp/.uv-cache
12+
# Coverage threshold percentage for enforced modules
13+
COVERAGE_THRESHOLD: 85
1214

1315
jobs:
1416
python-tests-coverage:
@@ -37,6 +39,8 @@ jobs:
3739
UV_CACHE_DIR: /tmp/.uv-cache
3840
- name: Run all tests with coverage report
3941
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
42+
- name: Check coverage threshold
43+
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
4044
- name: Upload coverage report
4145
uses: actions/upload-artifact@v6
4246
with:

python/packages/core/tests/workflow/test_magentic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1245,7 +1245,7 @@ def agent_factory() -> AgentProtocol:
12451245
custom_final_prompt = "Custom final: {task}"
12461246

12471247
# Create a custom task ledger
1248-
from agent_framework._workflows._magentic import _MagenticTaskLedger
1248+
from agent_framework._workflows._magentic import _MagenticTaskLedger # type: ignore
12491249

12501250
custom_task_ledger = _MagenticTaskLedger(
12511251
facts=ChatMessage(role=Role.ASSISTANT, text="Custom facts"),

python/samples/getting_started/agents/azure_ai/azure_ai_with_thread.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
from random import randint
55
from typing import Annotated
66

7+
from agent_framework import tool
78
from agent_framework.azure import AzureAIProjectAgentProvider
89
from azure.identity.aio import AzureCliCredential
910
from pydantic import Field
10-
from agent_framework import tool
1111

1212
"""
1313
Azure AI Agent with Thread Management Example
@@ -16,6 +16,7 @@
1616
persistent conversation capabilities using service-managed threads as well as storing messages in-memory.
1717
"""
1818

19+
1920
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
2021
@tool(approval_mode="never_require")
2122
def get_weather(

0 commit comments

Comments
 (0)