gd-tools-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gd_tools/__init__.py +3 -0
- gd_tools/__main__.py +31 -0
- gd_tools/addons/__init__.py +0 -0
- gd_tools/addons/gd-tools-coverage/coverage.gd +43 -0
- gd_tools/addons/gd-tools-coverage/post_run_hook.gd +113 -0
- gd_tools/addons/gd-tools-coverage/pre_run_hook.gd +229 -0
- gd_tools/cli.py +329 -0
- gd_tools/config.py +289 -0
- gd_tools/coverage/__init__.py +23 -0
- gd_tools/coverage/cobertura_reporter.py +136 -0
- gd_tools/coverage/html_reporter.py +95 -0
- gd_tools/coverage/lcov_reporter.py +90 -0
- gd_tools/coverage/orchestrator.py +282 -0
- gd_tools/coverage/plan_generator.py +377 -0
- gd_tools/coverage/reporter.py +518 -0
- gd_tools/coverage/templates/file.html +72 -0
- gd_tools/coverage/templates/index.html +90 -0
- gd_tools/coverage/terminal_reporter.py +107 -0
- gd_tools/doctor.py +495 -0
- gd_tools/errors.py +79 -0
- gd_tools/file_discovery.py +41 -0
- gd_tools/format_runner.py +118 -0
- gd_tools/godot.py +346 -0
- gd_tools/init.py +619 -0
- gd_tools/lint_runner.py +223 -0
- gd_tools/test_runner.py +474 -0
- gd_tools_cli-0.1.0.dist-info/METADATA +191 -0
- gd_tools_cli-0.1.0.dist-info/RECORD +31 -0
- gd_tools_cli-0.1.0.dist-info/WHEEL +5 -0
- gd_tools_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gd_tools_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Cobertura XML coverage reporter.
|
|
2
|
+
|
|
3
|
+
Generates Cobertura XML files compatible with Jenkins and GitLab CI
|
|
4
|
+
coverage parsers using the standard library ``xml.etree.ElementTree``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
|
|
10
|
+
from gd_tools.coverage.plan_generator import CoveragePlan, FilePlan
|
|
11
|
+
from gd_tools.coverage.reporter import CoverageData, FileCoverage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_cobertura_report(
|
|
15
|
+
plan: CoveragePlan,
|
|
16
|
+
data: CoverageData,
|
|
17
|
+
output_path: Path,
|
|
18
|
+
) -> Path:
|
|
19
|
+
"""Generate a Cobertura XML file from plan and coverage data.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
plan: The instrumentation plan.
|
|
23
|
+
data: The runtime coverage data.
|
|
24
|
+
output_path: Path where the XML file is written.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The path to the written file.
|
|
28
|
+
"""
|
|
29
|
+
output_path = Path(output_path)
|
|
30
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
coverage_by_id = {fc.file_id: fc for fc in data.files}
|
|
33
|
+
|
|
34
|
+
total_lines = 0
|
|
35
|
+
hit_lines = 0
|
|
36
|
+
total_branches = 0
|
|
37
|
+
hit_branches = 0
|
|
38
|
+
|
|
39
|
+
root = ET.Element("coverage")
|
|
40
|
+
packages_elem = ET.SubElement(root, "packages")
|
|
41
|
+
package_elem = ET.SubElement(packages_elem, "package")
|
|
42
|
+
classes_elem = ET.SubElement(package_elem, "classes")
|
|
43
|
+
|
|
44
|
+
for file_plan in plan.files:
|
|
45
|
+
file_data = coverage_by_id.get(
|
|
46
|
+
file_plan.file_id,
|
|
47
|
+
FileCoverage(file_id=file_plan.file_id, hits={}),
|
|
48
|
+
)
|
|
49
|
+
cls_lines, cls_hit, cls_br, cls_hit_br = _build_class_element(
|
|
50
|
+
file_plan, file_data, classes_elem
|
|
51
|
+
)
|
|
52
|
+
total_lines += cls_lines
|
|
53
|
+
hit_lines += cls_hit
|
|
54
|
+
total_branches += cls_br
|
|
55
|
+
hit_branches += cls_hit_br
|
|
56
|
+
|
|
57
|
+
root.set("line-rate", _format_rate(total_lines, hit_lines))
|
|
58
|
+
root.set("branch-rate", _format_rate(total_branches, hit_branches))
|
|
59
|
+
root.set("lines-covered", str(hit_lines))
|
|
60
|
+
root.set("lines-valid", str(total_lines))
|
|
61
|
+
root.set("branches-covered", str(hit_branches))
|
|
62
|
+
root.set("branches-valid", str(total_branches))
|
|
63
|
+
|
|
64
|
+
tree = ET.ElementTree(root)
|
|
65
|
+
ET.indent(tree, space=" ")
|
|
66
|
+
tree.write(output_path, encoding="utf-8", xml_declaration=True)
|
|
67
|
+
return output_path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_class_element(
|
|
71
|
+
file_plan: FilePlan,
|
|
72
|
+
file_data: FileCoverage,
|
|
73
|
+
classes_elem: ET.Element,
|
|
74
|
+
) -> tuple[int, int, int, int]:
|
|
75
|
+
"""Build a ``<class>`` element for a single file.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
file_plan: The file's instrumentation plan.
|
|
79
|
+
file_data: The file's coverage data.
|
|
80
|
+
classes_elem: The parent ``<classes>`` element.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A tuple of (total_lines, hit_lines, total_branches, hit_branches).
|
|
84
|
+
"""
|
|
85
|
+
cls_elem = ET.SubElement(classes_elem, "class")
|
|
86
|
+
cls_elem.set("filename", file_plan.path)
|
|
87
|
+
|
|
88
|
+
lines_elem = ET.SubElement(cls_elem, "lines")
|
|
89
|
+
|
|
90
|
+
total_lines = 0
|
|
91
|
+
hit_lines = 0
|
|
92
|
+
total_branches = 0
|
|
93
|
+
hit_branches = 0
|
|
94
|
+
|
|
95
|
+
for line_plan in file_plan.lines:
|
|
96
|
+
hit_count = file_data.hits.get(str(line_plan.id), 0)
|
|
97
|
+
line_elem = ET.SubElement(lines_elem, "line")
|
|
98
|
+
line_elem.set("number", str(line_plan.line))
|
|
99
|
+
line_elem.set("hits", str(hit_count))
|
|
100
|
+
|
|
101
|
+
total_lines += 1
|
|
102
|
+
if hit_count > 0:
|
|
103
|
+
hit_lines += 1
|
|
104
|
+
|
|
105
|
+
if line_plan.type == "branch":
|
|
106
|
+
line_elem.set("branch", "true")
|
|
107
|
+
total_branches += 1
|
|
108
|
+
if hit_count > 0:
|
|
109
|
+
hit_branches += 1
|
|
110
|
+
coverage_pct = 100 if hit_count > 0 else 0
|
|
111
|
+
line_elem.set(
|
|
112
|
+
"condition-coverage",
|
|
113
|
+
f"{coverage_pct}% ({1 if hit_count > 0 else 0}/1)",
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
line_elem.set("branch", "false")
|
|
117
|
+
|
|
118
|
+
cls_elem.set("line-rate", _format_rate(total_lines, hit_lines))
|
|
119
|
+
cls_elem.set("branch-rate", _format_rate(total_branches, hit_branches))
|
|
120
|
+
|
|
121
|
+
return total_lines, hit_lines, total_branches, hit_branches
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _format_rate(total: int, hit: int) -> str:
|
|
125
|
+
"""Format a coverage rate as a decimal string.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
total: Total count of items.
|
|
129
|
+
hit: Count of covered items.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
A string like ``"0.625"`` or ``"0.0"``.
|
|
133
|
+
"""
|
|
134
|
+
if total == 0:
|
|
135
|
+
return "0.0"
|
|
136
|
+
return f"{hit / total:.4f}".rstrip("0").rstrip(".")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""HTML coverage reporter using Jinja2 templates.
|
|
2
|
+
|
|
3
|
+
Generates an ``index.html`` summary page and one per-file HTML page
|
|
4
|
+
with line-level coverage highlighting (green/red/yellow).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
from gd_tools.coverage.plan_generator import CoveragePlan
|
|
12
|
+
from gd_tools.coverage.reporter import (
|
|
13
|
+
CoverageData,
|
|
14
|
+
FileCoverage,
|
|
15
|
+
compute_file_summary,
|
|
16
|
+
compute_summary,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
20
|
+
_env = Environment(
|
|
21
|
+
loader=FileSystemLoader(str(_TEMPLATES_DIR)), autoescape=True
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_html_report(
|
|
26
|
+
plan: CoveragePlan,
|
|
27
|
+
data: CoverageData,
|
|
28
|
+
output_dir: Path,
|
|
29
|
+
) -> Path:
|
|
30
|
+
"""Generate an HTML coverage report with index and per-file pages.
|
|
31
|
+
|
|
32
|
+
Creates ``index.html`` with a summary table and one ``file_<id>.html``
|
|
33
|
+
per source file showing line-by-line coverage status with CSS
|
|
34
|
+
highlighting (covered=green, uncovered=red, partial=yellow).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
plan: The instrumentation plan defining tracked lines.
|
|
38
|
+
data: The runtime coverage data with hit counts.
|
|
39
|
+
output_dir: Directory where HTML files are written.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Path to the generated ``index.html``.
|
|
43
|
+
"""
|
|
44
|
+
output_dir = Path(output_dir)
|
|
45
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
summary = compute_summary(plan, data)
|
|
48
|
+
|
|
49
|
+
coverage_by_id = {fc.file_id: fc for fc in data.files}
|
|
50
|
+
file_summaries = []
|
|
51
|
+
for file_plan in plan.files:
|
|
52
|
+
file_data = coverage_by_id.get(
|
|
53
|
+
file_plan.file_id,
|
|
54
|
+
FileCoverage(file_id=file_plan.file_id, hits={}),
|
|
55
|
+
)
|
|
56
|
+
file_summaries.append(compute_file_summary(file_plan, file_data))
|
|
57
|
+
|
|
58
|
+
index_template = _env.get_template("index.html")
|
|
59
|
+
index_content = index_template.render(
|
|
60
|
+
summary=summary, file_summaries=file_summaries
|
|
61
|
+
)
|
|
62
|
+
index_path = output_dir / "index.html"
|
|
63
|
+
index_path.write_text(index_content, encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
file_template = _env.get_template("file.html")
|
|
66
|
+
for file_plan, fs in zip(plan.files, file_summaries):
|
|
67
|
+
file_data = coverage_by_id.get(
|
|
68
|
+
file_plan.file_id,
|
|
69
|
+
FileCoverage(file_id=file_plan.file_id, hits={}),
|
|
70
|
+
)
|
|
71
|
+
lines = []
|
|
72
|
+
for line_plan in file_plan.lines:
|
|
73
|
+
hit_count = file_data.hits.get(str(line_plan.id), 0)
|
|
74
|
+
if hit_count > 0 and line_plan.type == "branch":
|
|
75
|
+
css_class = "partial"
|
|
76
|
+
elif hit_count > 0:
|
|
77
|
+
css_class = "covered"
|
|
78
|
+
else:
|
|
79
|
+
css_class = "uncovered"
|
|
80
|
+
lines.append(
|
|
81
|
+
{
|
|
82
|
+
"number": line_plan.line,
|
|
83
|
+
"hits": hit_count,
|
|
84
|
+
# TODO: Source code display deferred — requires reading
|
|
85
|
+
# .gd files from disk. Spec FR-4.3 partially met: line
|
|
86
|
+
# numbers shown, source content not yet populated.
|
|
87
|
+
"source": "",
|
|
88
|
+
"css_class": css_class,
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
file_content = file_template.render(file_summary=fs, lines=lines)
|
|
92
|
+
file_path = output_dir / f"file_{file_plan.file_id}.html"
|
|
93
|
+
file_path.write_text(file_content, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
return index_path
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""LCOV format coverage reporter.
|
|
2
|
+
|
|
3
|
+
Generates LCOV ``.info`` files compatible with codecov.io, coveralls,
|
|
4
|
+
and ``genhtml``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from gd_tools.coverage.plan_generator import CoveragePlan, FilePlan
|
|
10
|
+
from gd_tools.coverage.reporter import CoverageData, FileCoverage
|
|
11
|
+
|
|
12
|
+
_TEST_NAME = "gd-tools"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_lcov_report(
|
|
16
|
+
plan: CoveragePlan,
|
|
17
|
+
data: CoverageData,
|
|
18
|
+
output_path: Path,
|
|
19
|
+
) -> Path:
|
|
20
|
+
"""Generate an LCOV ``.info`` file from plan and coverage data.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
plan: The instrumentation plan.
|
|
24
|
+
data: The runtime coverage data.
|
|
25
|
+
output_path: Path where the ``.info`` file is written.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The path to the written file.
|
|
29
|
+
"""
|
|
30
|
+
output_path = Path(output_path)
|
|
31
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
coverage_by_id = {fc.file_id: fc for fc in data.files}
|
|
34
|
+
|
|
35
|
+
lines: list[str] = [f"TN:{_TEST_NAME}"]
|
|
36
|
+
|
|
37
|
+
for file_plan in plan.files:
|
|
38
|
+
file_data = coverage_by_id.get(
|
|
39
|
+
file_plan.file_id,
|
|
40
|
+
FileCoverage(file_id=file_plan.file_id, hits={}),
|
|
41
|
+
)
|
|
42
|
+
lines.extend(_generate_file_records(file_plan, file_data))
|
|
43
|
+
|
|
44
|
+
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
45
|
+
return output_path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _generate_file_records(
|
|
49
|
+
file_plan: FilePlan,
|
|
50
|
+
file_data: FileCoverage,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
"""Generate LCOV records for a single file.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
file_plan: The file's instrumentation plan.
|
|
56
|
+
file_data: The file's coverage data.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A list of LCOV record strings for this file section.
|
|
60
|
+
"""
|
|
61
|
+
records: list[str] = [f"SF:{file_plan.path}"]
|
|
62
|
+
|
|
63
|
+
total_lines = 0
|
|
64
|
+
hit_lines = 0
|
|
65
|
+
total_branches = 0
|
|
66
|
+
hit_branches = 0
|
|
67
|
+
brda_records: list[str] = []
|
|
68
|
+
|
|
69
|
+
for line_plan in file_plan.lines:
|
|
70
|
+
hit_count = file_data.hits.get(str(line_plan.id), 0)
|
|
71
|
+
records.append(f"DA:{line_plan.line},{hit_count}")
|
|
72
|
+
|
|
73
|
+
total_lines += 1
|
|
74
|
+
if hit_count > 0:
|
|
75
|
+
hit_lines += 1
|
|
76
|
+
|
|
77
|
+
if line_plan.type == "branch":
|
|
78
|
+
total_branches += 1
|
|
79
|
+
if hit_count > 0:
|
|
80
|
+
hit_branches += 1
|
|
81
|
+
brda_records.append(f"BRDA:{line_plan.line},0,0,{hit_count}")
|
|
82
|
+
|
|
83
|
+
records.extend(brda_records)
|
|
84
|
+
records.append(f"BRF:{total_branches}")
|
|
85
|
+
records.append(f"BRH:{hit_branches}")
|
|
86
|
+
records.append(f"LF:{total_lines}")
|
|
87
|
+
records.append(f"LH:{hit_lines}")
|
|
88
|
+
records.append("end_of_record")
|
|
89
|
+
|
|
90
|
+
return records
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Coverage orchestrator module.
|
|
2
|
+
|
|
3
|
+
Coordinates the full coverage flow by wiring together the plan generator,
|
|
4
|
+
test runner, and reporter modules. This is the orchestration layer mandated
|
|
5
|
+
by NFR-1 — CLI commands delegate to these functions rather than embedding
|
|
6
|
+
business logic directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from gd_tools.config import GdToolsConfig, find_project_root
|
|
17
|
+
from gd_tools.coverage import plan_generator, reporter
|
|
18
|
+
from gd_tools.coverage.reporter import (
|
|
19
|
+
CoverageData,
|
|
20
|
+
CoverageSummary,
|
|
21
|
+
ReportResult,
|
|
22
|
+
)
|
|
23
|
+
from gd_tools.errors import CoverageThresholdError, TestFailureError
|
|
24
|
+
from gd_tools.test_runner import TestResult, run_tests
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_coverage_test(
|
|
28
|
+
config: GdToolsConfig,
|
|
29
|
+
suite: str | None = None,
|
|
30
|
+
test_name: str | None = None,
|
|
31
|
+
junit_xml: str | None = None,
|
|
32
|
+
no_exit_code: bool = False,
|
|
33
|
+
min_percent: int | None = None,
|
|
34
|
+
timeout: int | None = None,
|
|
35
|
+
) -> TestResult:
|
|
36
|
+
"""Run tests with coverage instrumentation and generate reports.
|
|
37
|
+
|
|
38
|
+
Orchestrates the full coverage flow:
|
|
39
|
+
|
|
40
|
+
1. Generate an instrumentation plan.
|
|
41
|
+
2. Write the plan to ``<output_dir>/plan.json``.
|
|
42
|
+
3. Run tests with coverage enabled.
|
|
43
|
+
4. Read the coverage data produced by the runtime hooks.
|
|
44
|
+
5. Generate reports in the configured format.
|
|
45
|
+
|
|
46
|
+
Error precedence (NFR-2): ``TestFailureError`` is re-raised before
|
|
47
|
+
``CoverageThresholdError`` when both occur.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config: Project configuration.
|
|
51
|
+
suite: Optional test suite filter.
|
|
52
|
+
test_name: Optional specific test to run.
|
|
53
|
+
junit_xml: Optional JUnit XML output path.
|
|
54
|
+
no_exit_code: If True, test failures do not raise
|
|
55
|
+
:class:`TestFailureError`.
|
|
56
|
+
min_percent: Minimum coverage percentage (0-100). If set and
|
|
57
|
+
coverage is below this, raises
|
|
58
|
+
:class:`CoverageThresholdError`.
|
|
59
|
+
timeout: Optional test timeout in seconds.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The :class:`TestResult` from running tests.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TestFailureError: If tests fail (unless ``no_exit_code`` is True).
|
|
66
|
+
CoverageThresholdError: If coverage is below ``min_percent``.
|
|
67
|
+
"""
|
|
68
|
+
project_root = find_project_root()
|
|
69
|
+
output_dir = project_root / config.coverage.output_dir
|
|
70
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
# Generate and write instrumentation plan.
|
|
73
|
+
plan = plan_generator.generate_plan(
|
|
74
|
+
str(project_root),
|
|
75
|
+
None,
|
|
76
|
+
config.coverage.exclude,
|
|
77
|
+
config.coverage.test_dirs,
|
|
78
|
+
)
|
|
79
|
+
plan_generator.write_plan_json(plan, str(output_dir / "plan.json"))
|
|
80
|
+
|
|
81
|
+
# Run tests with coverage enabled.
|
|
82
|
+
test_error: TestFailureError | None = None
|
|
83
|
+
result: TestResult | None = None
|
|
84
|
+
try:
|
|
85
|
+
result = run_tests(
|
|
86
|
+
config,
|
|
87
|
+
coverage=True,
|
|
88
|
+
min_percent=min_percent,
|
|
89
|
+
suite=suite,
|
|
90
|
+
test_name=test_name,
|
|
91
|
+
junit_xml=junit_xml,
|
|
92
|
+
no_exit_code=no_exit_code,
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
)
|
|
95
|
+
except TestFailureError as exc:
|
|
96
|
+
test_error = exc
|
|
97
|
+
|
|
98
|
+
# Read coverage data and generate reports (even if tests failed).
|
|
99
|
+
data = reporter.read_coverage_json(output_dir / "coverage.json")
|
|
100
|
+
plan = plan_generator.read_plan_json(str(output_dir / "plan.json"))
|
|
101
|
+
|
|
102
|
+
min_threshold = min_percent / 100 if min_percent is not None else None
|
|
103
|
+
try:
|
|
104
|
+
reporter.generate_report(
|
|
105
|
+
plan,
|
|
106
|
+
data,
|
|
107
|
+
output_dir,
|
|
108
|
+
config.coverage.format,
|
|
109
|
+
min_threshold=min_threshold,
|
|
110
|
+
)
|
|
111
|
+
except CoverageThresholdError:
|
|
112
|
+
if test_error is not None:
|
|
113
|
+
raise test_error
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
if test_error is not None:
|
|
117
|
+
raise test_error
|
|
118
|
+
|
|
119
|
+
assert result is not None # test_error is None implies run_tests succeeded
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_coverage_report(
|
|
124
|
+
config: GdToolsConfig,
|
|
125
|
+
report_format: str | None = None,
|
|
126
|
+
output_dir: str | None = None,
|
|
127
|
+
) -> ReportResult:
|
|
128
|
+
"""Regenerate reports from existing coverage data without re-running tests.
|
|
129
|
+
|
|
130
|
+
Reads existing ``plan.json`` and ``coverage.json`` from the output
|
|
131
|
+
directory and regenerates reports in the specified format.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
config: Project configuration.
|
|
135
|
+
report_format: Report format override (e.g., ``"html"``, ``"lcov"``,
|
|
136
|
+
``"cobertura"``, ``"text"``). If ``None``, uses
|
|
137
|
+
``config.coverage.format``.
|
|
138
|
+
output_dir: Output directory override. If ``None``, uses
|
|
139
|
+
``config.coverage.output_dir``.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The :class:`ReportResult` from report generation.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
CoveragePlanError: If ``plan.json`` or ``coverage.json`` is
|
|
146
|
+
missing or invalid.
|
|
147
|
+
"""
|
|
148
|
+
project_root = find_project_root()
|
|
149
|
+
|
|
150
|
+
# Resolve effective output_dir: flag > config > default.
|
|
151
|
+
effective_output_dir = (
|
|
152
|
+
output_dir if output_dir is not None else config.coverage.output_dir
|
|
153
|
+
)
|
|
154
|
+
output_path = project_root / effective_output_dir
|
|
155
|
+
|
|
156
|
+
# Resolve effective format: flag > config > default.
|
|
157
|
+
effective_format = (
|
|
158
|
+
report_format if report_format is not None else config.coverage.format
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Read existing plan and coverage data.
|
|
162
|
+
plan = plan_generator.read_plan_json(str(output_path / "plan.json"))
|
|
163
|
+
data = reporter.read_coverage_json(output_path / "coverage.json")
|
|
164
|
+
|
|
165
|
+
# Generate report.
|
|
166
|
+
return reporter.generate_report(plan, data, output_path, effective_format)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def merge_coverage_files(
|
|
170
|
+
files: list[Path],
|
|
171
|
+
output: Path | None = None,
|
|
172
|
+
config: GdToolsConfig | None = None,
|
|
173
|
+
) -> CoverageData:
|
|
174
|
+
"""Merge multiple coverage data files into one.
|
|
175
|
+
|
|
176
|
+
Delegates to :func:`reporter.merge_coverage_data` to sum hit
|
|
177
|
+
counts, then writes the merged result as JSON.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
files: List of paths to coverage data JSON files.
|
|
181
|
+
output: Path for the merged output file. If ``None``,
|
|
182
|
+
defaults to ``<output_dir>/coverage.json`` (resolved via
|
|
183
|
+
``find_project_root`` and ``config.coverage.output_dir``
|
|
184
|
+
when ``config`` is provided, or ``.gd-tools/coverage/
|
|
185
|
+
coverage.json`` relative to the current working directory
|
|
186
|
+
otherwise).
|
|
187
|
+
config: Optional project configuration for resolving the
|
|
188
|
+
default output path.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The merged :class:`CoverageData`.
|
|
192
|
+
"""
|
|
193
|
+
if output is None:
|
|
194
|
+
if config is not None:
|
|
195
|
+
project_root = find_project_root()
|
|
196
|
+
output = project_root / config.coverage.output_dir / "coverage.json"
|
|
197
|
+
else:
|
|
198
|
+
output = Path.cwd() / ".gd-tools" / "coverage" / "coverage.json"
|
|
199
|
+
|
|
200
|
+
merged = reporter.merge_coverage_data(files)
|
|
201
|
+
|
|
202
|
+
reporter.write_coverage_json(merged, output)
|
|
203
|
+
|
|
204
|
+
console = Console()
|
|
205
|
+
console.print(
|
|
206
|
+
f"Merged {len(files)} file(s) → {len(merged.files)} file(s) "
|
|
207
|
+
f"in output. Written to: {output}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return merged
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def show_coverage_summary(
|
|
214
|
+
config: GdToolsConfig,
|
|
215
|
+
min_percent: int | None = None,
|
|
216
|
+
) -> CoverageSummary:
|
|
217
|
+
"""Display a terminal summary table of coverage results.
|
|
218
|
+
|
|
219
|
+
Reads existing ``plan.json`` and ``coverage.json``, computes a
|
|
220
|
+
summary, prints a Rich table, and optionally enforces a minimum
|
|
221
|
+
threshold.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
config: Project configuration.
|
|
225
|
+
min_percent: Minimum coverage percentage (0-100). If set and
|
|
226
|
+
coverage is below this, raises
|
|
227
|
+
:class:`CoverageThresholdError`.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
The :class:`CoverageSummary`.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
CoverageThresholdError: If ``min_percent`` is set and line
|
|
234
|
+
coverage is below the threshold.
|
|
235
|
+
CoveragePlanError: If ``plan.json`` or ``coverage.json`` is
|
|
236
|
+
missing or invalid.
|
|
237
|
+
"""
|
|
238
|
+
project_root = find_project_root()
|
|
239
|
+
output_dir = project_root / config.coverage.output_dir
|
|
240
|
+
|
|
241
|
+
# Read plan and coverage data.
|
|
242
|
+
plan = plan_generator.read_plan_json(str(output_dir / "plan.json"))
|
|
243
|
+
data = reporter.read_coverage_json(output_dir / "coverage.json")
|
|
244
|
+
|
|
245
|
+
# Compute summary.
|
|
246
|
+
summary = reporter.compute_summary(plan, data)
|
|
247
|
+
|
|
248
|
+
# Print Rich terminal table.
|
|
249
|
+
console = Console()
|
|
250
|
+
table = Table(title="Coverage Summary")
|
|
251
|
+
table.add_column("Metric", style="cyan")
|
|
252
|
+
table.add_column("Found", justify="right")
|
|
253
|
+
table.add_column("Hit", justify="right")
|
|
254
|
+
table.add_column("Rate", justify="right", style="green")
|
|
255
|
+
|
|
256
|
+
table.add_row(
|
|
257
|
+
"Lines",
|
|
258
|
+
str(summary.total_lines),
|
|
259
|
+
str(summary.covered_lines),
|
|
260
|
+
f"{summary.line_rate * 100:.1f}%",
|
|
261
|
+
)
|
|
262
|
+
table.add_row(
|
|
263
|
+
"Branches",
|
|
264
|
+
str(summary.total_branches),
|
|
265
|
+
str(summary.covered_branches),
|
|
266
|
+
f"{summary.branch_rate * 100:.1f}%",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
console.print(table)
|
|
270
|
+
|
|
271
|
+
# Threshold check.
|
|
272
|
+
if min_percent is not None and summary.line_rate * 100 < min_percent:
|
|
273
|
+
raise CoverageThresholdError(
|
|
274
|
+
f"[Error] Line coverage {summary.line_rate * 100:.1f}% is "
|
|
275
|
+
f"below minimum threshold {min_percent}%\n"
|
|
276
|
+
f" Cause: Only {summary.covered_lines} of "
|
|
277
|
+
f"{summary.total_lines} lines were executed.\n"
|
|
278
|
+
f" Fix: Add tests to cover uncovered lines or lower the "
|
|
279
|
+
"--min threshold."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return summary
|