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.
@@ -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