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,107 @@
1
+ """Terminal reporter using Rich for formatted coverage output."""
2
+
3
+ import io
4
+
5
+ from rich import box
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from gd_tools.coverage.plan_generator import CoveragePlan
10
+ from gd_tools.coverage.reporter import (
11
+ CoverageData,
12
+ FileCoverage,
13
+ compute_file_summary,
14
+ compute_summary,
15
+ )
16
+
17
+ _GREEN_THRESHOLD = 0.80
18
+ _YELLOW_THRESHOLD = 0.50
19
+
20
+
21
+ def _color_for_rate(rate: float) -> str:
22
+ """Return Rich color name for a coverage rate.
23
+
24
+ Args:
25
+ rate: Coverage rate (0.0-1.0).
26
+
27
+ Returns:
28
+ Rich color name: ``"green"``, ``"yellow"``, or ``"red"``.
29
+ """
30
+ if rate >= _GREEN_THRESHOLD:
31
+ return "green"
32
+ elif rate >= _YELLOW_THRESHOLD:
33
+ return "yellow"
34
+ else:
35
+ return "red"
36
+
37
+
38
+ def generate_terminal_report(
39
+ plan: CoveragePlan,
40
+ data: CoverageData,
41
+ ) -> str:
42
+ """Generate a Rich table coverage report as a string.
43
+
44
+ Produces a color-coded table with per-file line and branch
45
+ coverage metrics, followed by an overall summary. Table borders
46
+ use ASCII characters for terminal compatibility.
47
+
48
+ Args:
49
+ plan: The instrumentation plan.
50
+ data: The runtime coverage data.
51
+
52
+ Returns:
53
+ A formatted Rich table string with ANSI color codes.
54
+ """
55
+ buf = io.StringIO()
56
+ console = Console(file=buf, force_terminal=True, width=120)
57
+
58
+ table = Table(title="Coverage Report", box=box.ASCII)
59
+ table.add_column("File")
60
+ table.add_column("Lines Found", justify="right")
61
+ table.add_column("Lines Hit", justify="right")
62
+ table.add_column("Line %", justify="right")
63
+ table.add_column("Branches Found", justify="right")
64
+ table.add_column("Branches Hit", justify="right")
65
+ table.add_column("Branch %", justify="right")
66
+
67
+ summary = compute_summary(plan, data)
68
+ coverage_by_id = {fc.file_id: fc for fc in data.files}
69
+
70
+ for file_plan in plan.files:
71
+ file_data = coverage_by_id.get(
72
+ file_plan.file_id,
73
+ FileCoverage(file_id=file_plan.file_id, hits={}),
74
+ )
75
+ fs = compute_file_summary(file_plan, file_data)
76
+
77
+ line_color = _color_for_rate(fs.line_rate)
78
+ branch_color = _color_for_rate(fs.branch_rate)
79
+
80
+ table.add_row(
81
+ file_plan.path,
82
+ str(fs.total_lines),
83
+ str(fs.covered_lines),
84
+ f"[{line_color}]{fs.line_rate:.1%}[/{line_color}]",
85
+ str(fs.total_branches),
86
+ str(fs.covered_branches),
87
+ f"[{branch_color}]{fs.branch_rate:.1%}[/{branch_color}]",
88
+ )
89
+
90
+ console.print(table)
91
+
92
+ # Overall summary
93
+ overall_line_color = _color_for_rate(summary.line_rate)
94
+ overall_branch_color = _color_for_rate(summary.branch_rate)
95
+ console.print()
96
+ console.print(
97
+ f"[bold]Overall Line Coverage:[/bold] "
98
+ f"[{overall_line_color}]{summary.line_rate:.1%}"
99
+ f"[/{overall_line_color}]"
100
+ )
101
+ console.print(
102
+ f"[bold]Overall Branch Coverage:[/bold] "
103
+ f"[{overall_branch_color}]{summary.branch_rate:.1%}"
104
+ f"[/{overall_branch_color}]"
105
+ )
106
+
107
+ return buf.getvalue()
gd_tools/doctor.py ADDED
@@ -0,0 +1,495 @@
1
+ """Diagnostic command for checking gd-tools project health.
2
+
3
+ Implements ``gd-tools doctor``, which runs a series of environment
4
+ and configuration checks and reports pass/fail status with actionable
5
+ fix hints. See TDD \u00a73.6 and PRD \u00a78.
6
+ """
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else: # pragma: no cover
17
+ import tomli as tomllib
18
+
19
+ from rich.table import Table
20
+
21
+ from .config import GdToolsConfig, find_project_root, load_config
22
+ from .errors import ConfigError
23
+ from .godot import (
24
+ GodotNotFoundError,
25
+ check_version_compatible,
26
+ find_godot,
27
+ get_gut_version_for_godot,
28
+ )
29
+ from .init import COVERAGE_ADDON_FILES, get_installed_gut_version
30
+
31
+
32
+ @dataclass
33
+ class CheckResult:
34
+ """Result of a single diagnostic check.
35
+
36
+ Attributes:
37
+ name: Human-readable name of the check.
38
+ passed: Whether the check passed.
39
+ message: Description of what was found.
40
+ fix_hint: Actionable suggestion for failures.
41
+ severity: "critical" or "warning".
42
+ """
43
+
44
+ name: str
45
+ passed: bool
46
+ message: str
47
+ fix_hint: str = ""
48
+ severity: str = "critical"
49
+
50
+
51
+ @dataclass
52
+ class DoctorResult:
53
+ """Aggregate result of all diagnostic checks.
54
+
55
+ Attributes:
56
+ checks: List of individual check results.
57
+ all_passed: True if every check passed.
58
+ """
59
+
60
+ checks: list[CheckResult]
61
+ all_passed: bool
62
+
63
+
64
+ # --- Godot and External Tool Checks ---
65
+
66
+
67
+ def check_godot_binary(config: GdToolsConfig) -> CheckResult:
68
+ """Check that a Godot binary is found via the detection chain.
69
+
70
+ Args:
71
+ config: The gd-tools configuration containing Godot settings.
72
+
73
+ Returns:
74
+ CheckResult indicating whether the Godot binary was found.
75
+ """
76
+ try:
77
+ info = find_godot(config.godot)
78
+ except GodotNotFoundError:
79
+ return CheckResult(
80
+ name="Godot Binary",
81
+ passed=False,
82
+ message="Godot binary not found",
83
+ fix_hint=(
84
+ "Install Godot 4.5+ from "
85
+ "https://godotengine.org and set GODOT_BIN "
86
+ "or add to PATH."
87
+ ),
88
+ severity="critical",
89
+ )
90
+ return CheckResult(
91
+ name="Godot Binary",
92
+ passed=True,
93
+ message=f"Godot {info.version} at {info.path}",
94
+ )
95
+
96
+
97
+ def check_godot_version(config: GdToolsConfig) -> CheckResult:
98
+ """Check that the detected Godot version is >= 4.5.0.
99
+
100
+ Args:
101
+ config: The gd-tools configuration containing Godot settings.
102
+
103
+ Returns:
104
+ CheckResult indicating whether the Godot version is compatible.
105
+ """
106
+ try:
107
+ info = find_godot(config.godot)
108
+ except GodotNotFoundError:
109
+ return CheckResult(
110
+ name="Godot Version",
111
+ passed=False,
112
+ message="Godot binary not found - cannot check version",
113
+ fix_hint=(
114
+ "Install Godot 4.5+ from "
115
+ "https://godotengine.org and set GODOT_BIN "
116
+ "or add to PATH."
117
+ ),
118
+ severity="critical",
119
+ )
120
+ if check_version_compatible(info.version):
121
+ return CheckResult(
122
+ name="Godot Version",
123
+ passed=True,
124
+ message=f"Godot {info.version} is >= 4.5.0",
125
+ )
126
+ return CheckResult(
127
+ name="Godot Version",
128
+ passed=False,
129
+ message=f"Godot {info.version} is below required 4.5.0",
130
+ fix_hint=(
131
+ "Install Godot 4.5+ from "
132
+ "https://godotengine.org and set GODOT_BIN "
133
+ "or add to PATH."
134
+ ),
135
+ severity="critical",
136
+ )
137
+
138
+
139
+ def check_gdtoolkit() -> CheckResult:
140
+ """Check that gdlint and gdformat CLI tools are installed.
141
+
142
+ Returns:
143
+ CheckResult indicating whether both tools are available.
144
+ """
145
+ missing = []
146
+ for tool in ("gdlint", "gdformat"):
147
+ try:
148
+ subprocess.run(
149
+ [tool, "--version"],
150
+ capture_output=True,
151
+ timeout=10,
152
+ )
153
+ except FileNotFoundError:
154
+ missing.append(tool)
155
+
156
+ if missing:
157
+ return CheckResult(
158
+ name="GD Toolkit",
159
+ passed=False,
160
+ message=f"Missing tools: {', '.join(missing)}",
161
+ fix_hint="Install gdtoolkit: pip install gdtoolkit",
162
+ severity="critical",
163
+ )
164
+ return CheckResult(
165
+ name="GD Toolkit",
166
+ passed=True,
167
+ message="gdlint and gdformat are installed",
168
+ )
169
+
170
+
171
+ # --- GUT and Project Configuration Checks ---
172
+
173
+
174
+ def check_gut_installed(project_root: Path) -> CheckResult:
175
+ """Check that GUT is installed in the project.
176
+
177
+ Args:
178
+ project_root: Path to the Godot project root.
179
+
180
+ Returns:
181
+ CheckResult indicating whether GUT is installed.
182
+ """
183
+ gut_path = project_root / "addons" / "gut" / "gut.gd"
184
+ if gut_path.exists():
185
+ return CheckResult(
186
+ name="GUT Installed",
187
+ passed=True,
188
+ message="GUT is installed",
189
+ )
190
+ return CheckResult(
191
+ name="GUT Installed",
192
+ passed=False,
193
+ message="GUT is not installed",
194
+ fix_hint=(
195
+ "Run `gd-tools init` to install GUT, "
196
+ "or see https://github.com/bitwes/Gut."
197
+ ),
198
+ severity="critical",
199
+ )
200
+
201
+
202
+ def check_gut_version(project_root: Path, godot_version: str) -> CheckResult:
203
+ """Check that the installed GUT version matches the expected version.
204
+
205
+ Args:
206
+ project_root: Path to the Godot project root.
207
+ godot_version: The detected Godot version string.
208
+
209
+ Returns:
210
+ CheckResult indicating whether the GUT version is compatible.
211
+ """
212
+ installed = get_installed_gut_version(project_root)
213
+ if installed is None:
214
+ return CheckResult(
215
+ name="GUT Version",
216
+ passed=True,
217
+ message="GUT version unknown - cannot verify",
218
+ )
219
+ expected = get_gut_version_for_godot(godot_version)
220
+ if installed == expected:
221
+ return CheckResult(
222
+ name="GUT Version",
223
+ passed=True,
224
+ message=f"GUT version {installed} matches expected {expected}",
225
+ )
226
+ return CheckResult(
227
+ name="GUT Version",
228
+ passed=False,
229
+ message=(
230
+ f"GUT version {installed} does not match " f"expected {expected}"
231
+ ),
232
+ fix_hint=(
233
+ f"Install GUT version {expected} " f"for Godot {godot_version}"
234
+ ),
235
+ severity="warning",
236
+ )
237
+
238
+
239
+ def check_coverage_addon(project_root: Path) -> CheckResult:
240
+ """Check that the gd-tools-coverage addon files are present.
241
+
242
+ Args:
243
+ project_root: Path to the Godot project root.
244
+
245
+ Returns:
246
+ CheckResult indicating whether all coverage addon files exist.
247
+ """
248
+ cov_dir = project_root / "addons" / "gd-tools-coverage"
249
+ missing = [f for f in COVERAGE_ADDON_FILES if not (cov_dir / f).exists()]
250
+ if missing:
251
+ return CheckResult(
252
+ name="Coverage Addon",
253
+ passed=False,
254
+ message=f"Missing coverage files: {', '.join(missing)}",
255
+ fix_hint="Run `gd-tools init` to install the coverage addon.",
256
+ severity="warning",
257
+ )
258
+ return CheckResult(
259
+ name="Coverage Addon",
260
+ passed=True,
261
+ message="Coverage addon is installed",
262
+ )
263
+
264
+
265
+ def check_gutconfig(project_root: Path) -> CheckResult:
266
+ """Check that .gutconfig.json is valid JSON with hook script keys.
267
+
268
+ Args:
269
+ project_root: Path to the Godot project root.
270
+
271
+ Returns:
272
+ CheckResult indicating whether .gutconfig.json exists, is valid
273
+ JSON, and contains both ``pre_run_script`` and ``post_run_script``
274
+ keys.
275
+ """
276
+ gutconfig_path = project_root / ".gutconfig.json"
277
+ if not gutconfig_path.exists():
278
+ return CheckResult(
279
+ name="GUT Config",
280
+ passed=False,
281
+ message=".gutconfig.json not found",
282
+ fix_hint="Run `gd-tools init` to generate .gutconfig.json.",
283
+ severity="warning",
284
+ )
285
+ try:
286
+ content = json.loads(gutconfig_path.read_text())
287
+ except ValueError as exc:
288
+ return CheckResult(
289
+ name="GUT Config",
290
+ passed=False,
291
+ message=f".gutconfig.json is invalid JSON: {exc}",
292
+ fix_hint="Fix the JSON syntax in .gutconfig.json or run `gd-tools init`.",
293
+ severity="warning",
294
+ )
295
+ missing_keys = [
296
+ key
297
+ for key in ("pre_run_script", "post_run_script")
298
+ if key not in content
299
+ ]
300
+ if missing_keys:
301
+ return CheckResult(
302
+ name="GUT Config",
303
+ passed=False,
304
+ message=f"Missing keys: {', '.join(missing_keys)}",
305
+ fix_hint="Run `gd-tools init` to regenerate .gutconfig.json with hook scripts.",
306
+ severity="warning",
307
+ )
308
+ return CheckResult(
309
+ name="GUT Config",
310
+ passed=True,
311
+ message=".gutconfig.json is valid with hook scripts",
312
+ )
313
+
314
+
315
+ def check_gd_tools_toml(project_root: Path) -> CheckResult:
316
+ """Check that gd-tools.toml exists and is parseable TOML.
317
+
318
+ Args:
319
+ project_root: Path to the Godot project root.
320
+
321
+ Returns:
322
+ CheckResult indicating whether gd-tools.toml exists and
323
+ is valid TOML.
324
+ """
325
+ toml_path = project_root / "gd-tools.toml"
326
+ if not toml_path.exists():
327
+ return CheckResult(
328
+ name="gd-tools.toml",
329
+ passed=False,
330
+ message="gd-tools.toml not found",
331
+ fix_hint="Run `gd-tools init` to generate gd-tools.toml.",
332
+ severity="critical",
333
+ )
334
+ try:
335
+ with open(toml_path, "rb") as f:
336
+ tomllib.load(f)
337
+ except tomllib.TOMLDecodeError as exc:
338
+ return CheckResult(
339
+ name="gd-tools.toml",
340
+ passed=False,
341
+ message=f"gd-tools.toml is invalid TOML: {exc}",
342
+ fix_hint="Fix the TOML syntax in gd-tools.toml or run `gd-tools init`.",
343
+ severity="critical",
344
+ )
345
+ return CheckResult(
346
+ name="gd-tools.toml",
347
+ passed=True,
348
+ message="gd-tools.toml is valid",
349
+ )
350
+
351
+
352
+ def check_autoload(project_root: Path) -> CheckResult:
353
+ """Check that _GDTCoverage autoload is registered in project.godot.
354
+
355
+ Args:
356
+ project_root: Path to the Godot project root.
357
+
358
+ Returns:
359
+ CheckResult indicating whether the ``_GDTCoverage`` autoload
360
+ is registered in the ``[autoload]`` section of
361
+ ``project.godot``.
362
+ """
363
+ project_godot = project_root / "project.godot"
364
+ if not project_godot.exists():
365
+ return CheckResult(
366
+ name="Autoload",
367
+ passed=False,
368
+ message="project.godot not found",
369
+ fix_hint="Run `gd-tools init` to deploy coverage addon.",
370
+ severity="critical",
371
+ )
372
+ content = project_godot.read_text()
373
+ in_autoload = False
374
+ for line in content.splitlines():
375
+ stripped = line.strip()
376
+ if stripped.startswith("["):
377
+ in_autoload = stripped == "[autoload]"
378
+ continue
379
+ if in_autoload and stripped.startswith("_GDTCoverage="):
380
+ return CheckResult(
381
+ name="Autoload",
382
+ passed=True,
383
+ message="_GDTCoverage autoload is registered",
384
+ )
385
+ return CheckResult(
386
+ name="Autoload",
387
+ passed=False,
388
+ message="_GDTCoverage autoload is not registered",
389
+ fix_hint=(
390
+ "Run `gd-tools init` to deploy coverage addon "
391
+ "(autoload registration in Phase 3)."
392
+ ),
393
+ severity="critical",
394
+ )
395
+
396
+
397
+ # --- Orchestration ---
398
+
399
+
400
+ def run_doctor() -> DoctorResult:
401
+ """Run all diagnostic checks and return aggregated result.
402
+
403
+ Resolves project root, loads config, and runs all 9 checks in
404
+ order. Never raises — all exceptions are caught and converted
405
+ to failed CheckResults.
406
+
407
+ Returns:
408
+ DoctorResult with all check results.
409
+ """
410
+ checks = []
411
+
412
+ try:
413
+ project_root = find_project_root()
414
+ except ConfigError:
415
+ project_root = Path.cwd()
416
+
417
+ try:
418
+ config = load_config()
419
+ except ConfigError:
420
+ config = GdToolsConfig()
421
+
422
+ godot_version = "unknown"
423
+ try:
424
+ info = find_godot(config.godot)
425
+ godot_version = info.version
426
+ except GodotNotFoundError:
427
+ pass
428
+
429
+ check_specs = [
430
+ ("Godot Binary", lambda: check_godot_binary(config)),
431
+ ("Godot Version", lambda: check_godot_version(config)),
432
+ ("GUT Installed", lambda: check_gut_installed(project_root)),
433
+ (
434
+ "GUT Version",
435
+ lambda: check_gut_version(project_root, godot_version),
436
+ ),
437
+ ("Coverage Addon", lambda: check_coverage_addon(project_root)),
438
+ ("GUT Config", lambda: check_gutconfig(project_root)),
439
+ ("gd-tools.toml", lambda: check_gd_tools_toml(project_root)),
440
+ ("GD Toolkit", lambda: check_gdtoolkit()),
441
+ ("Autoload", lambda: check_autoload(project_root)),
442
+ ]
443
+
444
+ for name, fn in check_specs:
445
+ try:
446
+ checks.append(fn())
447
+ except Exception as exc:
448
+ checks.append(
449
+ CheckResult(
450
+ name=name,
451
+ passed=False,
452
+ message=f"Unexpected error: {exc}",
453
+ severity="critical",
454
+ )
455
+ )
456
+
457
+ all_passed = all(c.passed for c in checks)
458
+ return DoctorResult(checks=checks, all_passed=all_passed)
459
+
460
+
461
+ def format_doctor_table(result: DoctorResult) -> Table:
462
+ """Build a rich Table from doctor check results.
463
+
464
+ Creates a color-coded table with Check, Status, Message, and
465
+ Fix Hint columns. Passing checks show a green checkmark, critical
466
+ failures show a red X, and warning failures show a yellow warning
467
+ symbol. A summary line shows the pass count.
468
+
469
+ Args:
470
+ result: The DoctorResult to format.
471
+
472
+ Returns:
473
+ A rich.table.Table ready for console printing.
474
+ """
475
+ table = Table(title="gd-tools Doctor")
476
+ table.add_column("Check", style="cyan")
477
+ table.add_column("Status")
478
+ table.add_column("Message")
479
+ table.add_column("Fix Hint")
480
+
481
+ passed_count = 0
482
+ for check in result.checks:
483
+ if check.passed:
484
+ status = "[green]\u2713[/green]"
485
+ passed_count += 1
486
+ elif check.severity == "critical":
487
+ status = "[red]\u2717[/red]"
488
+ else:
489
+ status = "[yellow]\u26a0[/yellow]"
490
+ table.add_row(check.name, status, check.message, check.fix_hint)
491
+
492
+ total = len(result.checks)
493
+ table.caption = f"{passed_count}/{total} checks passed"
494
+
495
+ return table
gd_tools/errors.py ADDED
@@ -0,0 +1,79 @@
1
+ """Exception hierarchy for gd-tools.
2
+
3
+ All gd-tools exceptions inherit from :class:`GdToolsError`, which carries an
4
+ ``exit_code`` attribute. The CLI entry point uses this code when the error
5
+ propagates to the top level, so every failure mode maps to a deterministic
6
+ process exit code.
7
+
8
+ Exit-code convention:
9
+ - ``2`` — configuration / environment problems (missing Godot, missing
10
+ GUT, bad config). These are "fix your setup" errors.
11
+ - ``1`` — tool failures (tests failed, lint found issues, coverage below
12
+ threshold). These are "your code has a problem" errors.
13
+ """
14
+
15
+
16
+ class GdToolsError(Exception):
17
+ """Base exception for all gd-tools errors.
18
+
19
+ Attributes:
20
+ exit_code: The exit code to use when this error causes the
21
+ program to terminate. Defaults to 2 (config error).
22
+ """
23
+
24
+ exit_code: int = 2
25
+
26
+ def __init__(
27
+ self, message: str = "", *, exit_code: int | None = None
28
+ ) -> None:
29
+ """Initialize the error.
30
+
31
+ Args:
32
+ message: The error message.
33
+ exit_code: Override the class default exit code. If None,
34
+ the class-level ``exit_code`` is used. Defaults to None.
35
+ """
36
+ super().__init__(message)
37
+ if exit_code is not None:
38
+ self.exit_code = exit_code
39
+
40
+
41
+ class ConfigError(GdToolsError):
42
+ """Raised when there is a configuration error."""
43
+
44
+
45
+ class GodotNotFoundError(GdToolsError):
46
+ """Raised when the Godot binary cannot be found."""
47
+
48
+
49
+ class GUTNotInstalledError(GdToolsError):
50
+ """Raised when GUT is not installed in the project."""
51
+
52
+
53
+ class CoveragePlanError(GdToolsError):
54
+ """Raised when there is an error generating the coverage plan."""
55
+
56
+
57
+ class CoverageThresholdError(GdToolsError):
58
+ """Raised when coverage falls below the required threshold."""
59
+
60
+ exit_code: int = 1
61
+
62
+
63
+ class TestFailureError(GdToolsError):
64
+ """Raised when tests fail."""
65
+
66
+ __test__ = False
67
+ exit_code: int = 1
68
+
69
+
70
+ class LintError(GdToolsError):
71
+ """Raised when linting finds issues."""
72
+
73
+ exit_code: int = 1
74
+
75
+
76
+ class FormatError(GdToolsError):
77
+ """Raised when formatting issues are found."""
78
+
79
+ exit_code: int = 1