You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
369 lines
14 KiB
369 lines
14 KiB
#!/usr/bin/env python3
|
|
# CodeDetect覆盖率报告工具
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, List, Any, Optional
|
|
from dataclasses import dataclass, asdict
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime
|
|
|
|
@dataclass
|
|
class CoverageData:
|
|
"""覆盖率数据"""
|
|
file_path: str
|
|
line_coverage: float
|
|
branch_coverage: float
|
|
total_lines: int
|
|
covered_lines: int
|
|
total_branches: int
|
|
covered_branches: int
|
|
functions: List[Dict[str, Any]]
|
|
|
|
@dataclass
|
|
class CoverageReport:
|
|
"""覆盖率报告"""
|
|
project_name: str
|
|
timestamp: str
|
|
overall_coverage: Dict[str, float]
|
|
file_coverage: List[CoverageData]
|
|
summary: Dict[str, Any]
|
|
|
|
class CoverageReporter:
|
|
"""覆盖率报告生成器"""
|
|
|
|
def __init__(self, output_dir: str = "coverage_reports"):
|
|
self.output_dir = Path(output_dir)
|
|
self.output_dir.mkdir(exist_ok=True)
|
|
|
|
def generate_report(self, project_name: str = "CodeDetect") -> CoverageReport:
|
|
"""生成覆盖率报告"""
|
|
print("🚀 开始生成覆盖率报告...")
|
|
|
|
# 运行覆盖率测试
|
|
self._run_coverage_tests()
|
|
|
|
# 解析覆盖率数据
|
|
coverage_data = self._parse_coverage_data()
|
|
|
|
# 生成报告
|
|
report = self._create_report(project_name, coverage_data)
|
|
|
|
# 生成多种格式的报告
|
|
self._generate_html_report(report)
|
|
self._generate_json_report(report)
|
|
self._generate_summary_report(report)
|
|
|
|
return report
|
|
|
|
def _run_coverage_tests(self):
|
|
"""运行覆盖率测试"""
|
|
print("🧪 运行覆盖率测试...")
|
|
|
|
try:
|
|
# 运行pytest覆盖率测试
|
|
result = subprocess.run([
|
|
"python", "-m", "pytest",
|
|
"tests/",
|
|
"--cov=src/",
|
|
"--cov-report=xml:coverage.xml",
|
|
"--cov-report=html:htmlcov/",
|
|
"--cov-report=term-missing",
|
|
"-v"
|
|
], capture_output=True, text=True, timeout=300)
|
|
|
|
if result.returncode != 0:
|
|
print(f"⚠️ 覆盖率测试失败: {result.stderr}")
|
|
# 创建一个最小的覆盖率文件
|
|
self._create_minimal_coverage_file()
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ 覆盖率测试错误: {e}")
|
|
self._create_minimal_coverage_file()
|
|
|
|
def _create_minimal_coverage_file(self):
|
|
"""创建最小的覆盖率文件"""
|
|
minimal_xml = """<?xml version="1.0" ?>
|
|
<coverage version="5.0" timestamp="1620000000" line-rate="0.0" branch-rate="0.0" line-covered="0" lines-valid="0" branches-covered="0" branches-valid="0">
|
|
<sources>
|
|
<source>.</source>
|
|
</sources>
|
|
<packages>
|
|
<package name="src" line-rate="0.0" branch-rate="0.0" complexity="0.0">
|
|
<classes>
|
|
<class name="minimal" filename="src/minimal.py" line-rate="0.0" branch-rate="0.0" complexity="0.0">
|
|
<methods/>
|
|
<lines/>
|
|
</class>
|
|
</classes>
|
|
</package>
|
|
</packages>
|
|
</coverage>"""
|
|
|
|
with open("coverage.xml", "w") as f:
|
|
f.write(minimal_xml)
|
|
|
|
def _parse_coverage_data(self) -> Dict[str, Any]:
|
|
"""解析覆盖率数据"""
|
|
coverage_data = {
|
|
"files": [],
|
|
"overall": {
|
|
"line_coverage": 0.0,
|
|
"branch_coverage": 0.0,
|
|
"total_lines": 0,
|
|
"covered_lines": 0,
|
|
"total_branches": 0,
|
|
"covered_branches": 0
|
|
}
|
|
}
|
|
|
|
try:
|
|
if Path("coverage.xml").exists():
|
|
tree = ET.parse("coverage.xml")
|
|
root = tree.getroot()
|
|
|
|
# 获取总体覆盖率
|
|
coverage_data["overall"]["line_coverage"] = float(root.get("line-rate", 0)) * 100
|
|
coverage_data["overall"]["branch_coverage"] = float(root.get("branch-rate", 0)) * 100
|
|
coverage_data["overall"]["total_lines"] = int(root.get("lines-valid", 0))
|
|
coverage_data["overall"]["covered_lines"] = int(root.get("lines-covered", 0))
|
|
coverage_data["overall"]["total_branches"] = int(root.get("branches-valid", 0))
|
|
coverage_data["overall"]["covered_branches"] = int(root.get("branches-covered", 0))
|
|
|
|
# 解析文件覆盖率
|
|
for package in root.findall(".//package"):
|
|
for cls in package.findall(".//class"):
|
|
file_data = {
|
|
"file_path": cls.get("filename", "unknown"),
|
|
"line_coverage": float(cls.get("line-rate", 0)) * 100,
|
|
"branch_coverage": float(cls.get("branch-rate", 0)) * 100,
|
|
"total_lines": int(cls.get("lines-valid", 0)),
|
|
"covered_lines": int(cls.get("lines-covered", 0)),
|
|
"total_branches": int(cls.get("branches-valid", 0)),
|
|
"covered_branches": int(cls.get("branches-covered", 0)),
|
|
"functions": []
|
|
}
|
|
|
|
coverage_data["files"].append(file_data)
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ 解析覆盖率数据失败: {e}")
|
|
|
|
return coverage_data
|
|
|
|
def _create_report(self, project_name: str, coverage_data: Dict[str, Any]) -> CoverageReport:
|
|
"""创建覆盖率报告"""
|
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
# 创建文件覆盖率数据
|
|
file_coverage = []
|
|
for file_data in coverage_data["files"]:
|
|
file_coverage.append(CoverageData(
|
|
file_path=file_data["file_path"],
|
|
line_coverage=file_data["line_coverage"],
|
|
branch_coverage=file_data["branch_coverage"],
|
|
total_lines=file_data["total_lines"],
|
|
covered_lines=file_data["covered_lines"],
|
|
total_branches=file_data["total_branches"],
|
|
covered_branches=file_data["covered_branches"],
|
|
functions=file_data["functions"]
|
|
))
|
|
|
|
# 创建报告
|
|
report = CoverageReport(
|
|
project_name=project_name,
|
|
timestamp=timestamp,
|
|
overall_coverage=coverage_data["overall"],
|
|
file_coverage=file_coverage,
|
|
summary=self._create_summary(coverage_data)
|
|
)
|
|
|
|
return report
|
|
|
|
def _create_summary(self, coverage_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""创建汇总信息"""
|
|
overall = coverage_data["overall"]
|
|
|
|
summary = {
|
|
"total_files": len(coverage_data["files"]),
|
|
"total_lines": overall["total_lines"],
|
|
"covered_lines": overall["covered_lines"],
|
|
"total_branches": overall["total_branches"],
|
|
"covered_branches": overall["covered_branches"],
|
|
"line_coverage_percent": overall["line_coverage"],
|
|
"branch_coverage_percent": overall["branch_coverage"],
|
|
"uncovered_lines": overall["total_lines"] - overall["covered_lines"],
|
|
"uncovered_branches": overall["total_branches"] - overall["covered_branches"]
|
|
}
|
|
|
|
# 计算统计信息
|
|
if summary["total_files"] > 0:
|
|
files_with_coverage = len([f for f in coverage_data["files"] if f["line_coverage"] > 0])
|
|
summary["files_with_coverage"] = files_with_coverage
|
|
summary["files_without_coverage"] = summary["total_files"] - files_with_coverage
|
|
else:
|
|
summary["files_with_coverage"] = 0
|
|
summary["files_without_coverage"] = 0
|
|
|
|
return summary
|
|
|
|
def _generate_html_report(self, report: CoverageReport):
|
|
"""生成HTML报告"""
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{report.project_name} 覆盖率报告</title>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; margin: 20px; }}
|
|
.header {{ background-color: #f0f0f0; padding: 20px; border-radius: 5px; }}
|
|
.summary {{ background-color: #e8f5e8; padding: 15px; margin: 20px 0; border-radius: 5px; }}
|
|
.file-list {{ margin: 20px 0; }}
|
|
.file-item {{ border: 1px solid #ddd; margin: 10px 0; padding: 10px; border-radius: 5px; }}
|
|
.coverage-high {{ background-color: #d4edda; }}
|
|
.coverage-medium {{ background-color: #fff3cd; }}
|
|
.coverage-low {{ background-color: #f8d7da; }}
|
|
.progress-bar {{ width: 100px; height: 20px; background-color: #e0e0e0; border-radius: 10px; display: inline-block; }}
|
|
.progress-fill {{ height: 100%; background-color: #28a745; border-radius: 10px; }}
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
|
th {{ background-color: #f2f2f2; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>{report.project_name} 覆盖率报告</h1>
|
|
<p>生成时间: {report.timestamp}</p>
|
|
</div>
|
|
|
|
<div class="summary">
|
|
<h2>总体统计</h2>
|
|
<table>
|
|
<tr><th>指标</th><th>数值</th><th>百分比</th></tr>
|
|
<tr><td>代码行覆盖率</td><td>{report.summary['covered_lines']}/{report.summary['total_lines']}</td><td>{report.summary['line_coverage_percent']:.1f}%</td></tr>
|
|
<tr><td>分支覆盖率</td><td>{report.summary['covered_branches']}/{report.summary['total_branches']}</td><td>{report.summary['branch_coverage_percent']:.1f}%</td></tr>
|
|
<tr><td>文件总数</td><td>{report.summary['total_files']}</td><td>-</td></tr>
|
|
<tr><td>有覆盖率的文件</td><td>{report.summary['files_with_coverage']}</td><td>-</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="file-list">
|
|
<h2>文件覆盖率详情</h2>
|
|
"""
|
|
|
|
for file_data in report.file_coverage:
|
|
coverage_class = "coverage-high" if file_data.line_coverage >= 80 else "coverage-medium" if file_data.line_coverage >= 60 else "coverage-low"
|
|
|
|
html_content += f"""
|
|
<div class="file-item {coverage_class}">
|
|
<h3>{file_data.file_path}</h3>
|
|
<p>
|
|
<strong>行覆盖率:</strong>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: {file_data.line_coverage}%;"></div>
|
|
</div>
|
|
{file_data.line_coverage:.1f}% ({file_data.covered_lines}/{file_data.total_lines})
|
|
</p>
|
|
<p><strong>分支覆盖率:</strong> {file_data.branch_coverage:.1f}% ({file_data.covered_branches}/{file_data.total_branches})</p>
|
|
</div>
|
|
"""
|
|
|
|
html_content += """
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# 保存HTML报告
|
|
html_path = self.output_dir / f"coverage_report_{report.timestamp.replace(' ', '_').replace(':', '-')}.html"
|
|
with open(html_path, 'w', encoding='utf-8') as f:
|
|
f.write(html_content)
|
|
|
|
print(f"📄 HTML报告已生成: {html_path}")
|
|
|
|
def _generate_json_report(self, report: CoverageReport):
|
|
"""生成JSON报告"""
|
|
json_path = self.output_dir / f"coverage_report_{report.timestamp.replace(' ', '_').replace(':', '-')}.json"
|
|
with open(json_path, 'w', encoding='utf-8') as f:
|
|
json.dump(asdict(report), f, indent=2, ensure_ascii=False)
|
|
|
|
print(f"📄 JSON报告已生成: {json_path}")
|
|
|
|
def _generate_summary_report(self, report: CoverageReport):
|
|
"""生成汇总报告"""
|
|
summary_content = f"""
|
|
# {report.project_name} 覆盖率报告
|
|
|
|
**生成时间:** {report.timestamp}
|
|
|
|
## 总体统计
|
|
|
|
- **代码行覆盖率:** {report.summary['line_coverage_percent']:.1f}% ({report.summary['covered_lines']}/{report.summary['total_lines']})
|
|
- **分支覆盖率:** {report.summary['branch_coverage_percent']:.1f}% ({report.summary['covered_branches']}/{report.summary['total_branches']})
|
|
- **文件总数:** {report.summary['total_files']}
|
|
- **有覆盖率的文件:** {report.summary['files_with_coverage']}
|
|
- **未覆盖的行数:** {report.summary['uncovered_lines']}
|
|
- **未覆盖的分支数:** {report.summary['uncovered_branches']}
|
|
|
|
## 文件覆盖率详情
|
|
|
|
"""
|
|
|
|
for file_data in report.file_coverage:
|
|
summary_content += f"""
|
|
### {file_data.file_path}
|
|
|
|
- **行覆盖率:** {file_data.line_coverage:.1f}% ({file_data.covered_lines}/{file_data.total_lines})
|
|
- **分支覆盖率:** {file_data.branch_coverage:.1f}% ({file_data.covered_branches}/{file_data.total_branches})
|
|
|
|
"""
|
|
|
|
# 保存汇总报告
|
|
summary_path = self.output_dir / f"coverage_summary_{report.timestamp.replace(' ', '_').replace(':', '-')}.md"
|
|
with open(summary_path, 'w', encoding='utf-8') as f:
|
|
f.write(summary_content)
|
|
|
|
print(f"📄 汇总报告已生成: {summary_path}")
|
|
|
|
def print_summary(self, report: CoverageReport):
|
|
"""打印汇总信息"""
|
|
print("\n" + "="*60)
|
|
print(f"📊 覆盖率报告 - {report.project_name}")
|
|
print("="*60)
|
|
print(f"时间: {report.timestamp}")
|
|
print("-"*60)
|
|
print(f"代码行覆盖率: {report.summary['line_coverage_percent']:.1f}% ({report.summary['covered_lines']}/{report.summary['total_lines']})")
|
|
print(f"分支覆盖率: {report.summary['branch_coverage_percent']:.1f}% ({report.summary['covered_branches']}/{report.summary['total_branches']})")
|
|
print(f"文件总数: {report.summary['total_files']}")
|
|
print(f"有覆盖率的文件: {report.summary['files_with_coverage']}")
|
|
print("-"*60)
|
|
|
|
# 显示低覆盖率文件
|
|
low_coverage_files = [f for f in report.file_coverage if f.line_coverage < 60]
|
|
if low_coverage_files:
|
|
print("⚠️ 低覆盖率文件 (< 60%):")
|
|
for file_data in low_coverage_files:
|
|
print(f" - {file_data.file_path}: {file_data.line_coverage:.1f}%")
|
|
|
|
def main():
|
|
"""主函数"""
|
|
parser = argparse.ArgumentParser(description='CodeDetect覆盖率报告生成器')
|
|
parser.add_argument('--project', default='CodeDetect', help='项目名称')
|
|
parser.add_argument('--output', default='coverage_reports', help='输出目录')
|
|
parser.add_argument('--print-summary', action='store_true', help='打印汇总信息')
|
|
|
|
args = parser.parse_args()
|
|
|
|
reporter = CoverageReporter(args.output)
|
|
report = reporter.generate_report(args.project)
|
|
|
|
if args.print_summary:
|
|
reporter.print_summary(report)
|
|
|
|
if __name__ == "__main__":
|
|
main() |