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
gd_tools/cli.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""CLI entry point for gd-tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import load_config
|
|
12
|
+
from .coverage.orchestrator import (
|
|
13
|
+
generate_coverage_report,
|
|
14
|
+
merge_coverage_files,
|
|
15
|
+
run_coverage_test,
|
|
16
|
+
show_coverage_summary,
|
|
17
|
+
)
|
|
18
|
+
from .doctor import format_doctor_table, run_doctor
|
|
19
|
+
from .errors import (
|
|
20
|
+
ConfigError,
|
|
21
|
+
CoverageThresholdError,
|
|
22
|
+
GdToolsError,
|
|
23
|
+
TestFailureError,
|
|
24
|
+
)
|
|
25
|
+
from .format_runner import run_format
|
|
26
|
+
from .init import run_init
|
|
27
|
+
from .lint_runner import format_lint_json, format_lint_text, run_lint
|
|
28
|
+
from .test_runner import run_tests
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GdToolsGroup(click.Group):
|
|
32
|
+
"""Custom Click group that converts NotImplementedError to exit code 2.
|
|
33
|
+
|
|
34
|
+
Stub commands raise ``NotImplementedError`` to indicate they are not
|
|
35
|
+
yet implemented. This class catches that exception and exits with
|
|
36
|
+
code 2 (configuration/usage error), consistent with the gd-tools
|
|
37
|
+
error convention.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def invoke(self, ctx) -> Any:
|
|
41
|
+
"""Invoke the group, catching NotImplementedError as exit code 2.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
ctx: The Click context for this invocation.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The result of the underlying command invocation.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
SystemExit: With code 2 if a command raises
|
|
51
|
+
NotImplementedError.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
return super().invoke(ctx)
|
|
55
|
+
except NotImplementedError:
|
|
56
|
+
click.echo(
|
|
57
|
+
"Error: This command is not yet implemented.",
|
|
58
|
+
err=True,
|
|
59
|
+
)
|
|
60
|
+
ctx.exit(2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.group(cls=GdToolsGroup)
|
|
64
|
+
@click.version_option(
|
|
65
|
+
version=__version__,
|
|
66
|
+
prog_name="gd-tools",
|
|
67
|
+
message="%(prog)s %(version)s",
|
|
68
|
+
)
|
|
69
|
+
def cli():
|
|
70
|
+
"""gd-tools: A modern development workflow CLI for GDScript."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cli.command()
|
|
74
|
+
@click.option(
|
|
75
|
+
"--non-interactive",
|
|
76
|
+
is_flag=True,
|
|
77
|
+
help="Run without interactive prompts.",
|
|
78
|
+
)
|
|
79
|
+
def init(non_interactive):
|
|
80
|
+
"""Initialize a new gd-tools configuration."""
|
|
81
|
+
try:
|
|
82
|
+
run_init(non_interactive=non_interactive)
|
|
83
|
+
except GdToolsError as e:
|
|
84
|
+
click.echo(f"Error: {e}", err=True)
|
|
85
|
+
ctx = click.get_current_context()
|
|
86
|
+
ctx.exit(e.exit_code)
|
|
87
|
+
|
|
88
|
+
ctx = click.get_current_context()
|
|
89
|
+
ctx.exit(0)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cli.command()
|
|
93
|
+
def doctor():
|
|
94
|
+
"""Check the environment for required tools."""
|
|
95
|
+
result = run_doctor()
|
|
96
|
+
console = Console()
|
|
97
|
+
console.print(format_doctor_table(result))
|
|
98
|
+
ctx = click.get_current_context()
|
|
99
|
+
ctx.exit(0 if result.all_passed else 1)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@cli.command()
|
|
103
|
+
@click.option("--coverage", is_flag=True, help="Generate coverage report.")
|
|
104
|
+
@click.option("--min", type=int, help="Minimum coverage threshold.")
|
|
105
|
+
@click.option("--suite", help="Specify which test suite to run.")
|
|
106
|
+
@click.option("--test", help="Specify which test to run.")
|
|
107
|
+
@click.option("--junit-xml", help="Path to write JUnit XML report.")
|
|
108
|
+
@click.option(
|
|
109
|
+
"--no-exit-code",
|
|
110
|
+
is_flag=True,
|
|
111
|
+
help="Don't exit with non-zero on test failure.",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--timeout",
|
|
115
|
+
type=int,
|
|
116
|
+
help="Timeout in seconds for the test run.",
|
|
117
|
+
)
|
|
118
|
+
def test(coverage, min, suite, test, junit_xml, no_exit_code, timeout):
|
|
119
|
+
"""Run GDScript tests using GUT."""
|
|
120
|
+
try:
|
|
121
|
+
config = load_config()
|
|
122
|
+
except ConfigError as e:
|
|
123
|
+
click.echo(f"Error: {e}", err=True)
|
|
124
|
+
ctx = click.get_current_context()
|
|
125
|
+
ctx.exit(2)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
if coverage:
|
|
129
|
+
run_coverage_test(
|
|
130
|
+
config,
|
|
131
|
+
suite=suite,
|
|
132
|
+
test_name=test,
|
|
133
|
+
junit_xml=junit_xml,
|
|
134
|
+
no_exit_code=no_exit_code,
|
|
135
|
+
min_percent=min,
|
|
136
|
+
timeout=timeout,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
run_tests(
|
|
140
|
+
config,
|
|
141
|
+
coverage=coverage,
|
|
142
|
+
min_percent=min,
|
|
143
|
+
suite=suite,
|
|
144
|
+
test_name=test,
|
|
145
|
+
junit_xml=junit_xml,
|
|
146
|
+
no_exit_code=no_exit_code,
|
|
147
|
+
)
|
|
148
|
+
except TestFailureError as e:
|
|
149
|
+
click.echo(f"Error: {e}", err=True)
|
|
150
|
+
ctx = click.get_current_context()
|
|
151
|
+
ctx.exit(1)
|
|
152
|
+
except GdToolsError as e:
|
|
153
|
+
click.echo(f"Error: {e}", err=True)
|
|
154
|
+
ctx = click.get_current_context()
|
|
155
|
+
ctx.exit(e.exit_code)
|
|
156
|
+
|
|
157
|
+
ctx = click.get_current_context()
|
|
158
|
+
ctx.exit(0)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@cli.command()
|
|
162
|
+
@click.argument("path", required=False, default=".")
|
|
163
|
+
@click.option(
|
|
164
|
+
"--report-format",
|
|
165
|
+
type=click.Choice(["text", "json"]),
|
|
166
|
+
default="text",
|
|
167
|
+
help="Output format for the lint report.",
|
|
168
|
+
)
|
|
169
|
+
@click.option(
|
|
170
|
+
"--fix",
|
|
171
|
+
is_flag=True,
|
|
172
|
+
help="Attempt to fix lint issues (no-op for gdlint).",
|
|
173
|
+
)
|
|
174
|
+
def lint(path, report_format, fix):
|
|
175
|
+
"""Lint GDScript files."""
|
|
176
|
+
if fix:
|
|
177
|
+
click.echo(
|
|
178
|
+
"Warning: gdlint is read-only; --fix has no effect.", err=True
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
config = load_config()
|
|
183
|
+
except ConfigError as e:
|
|
184
|
+
click.echo(f"Error: {e}", err=True)
|
|
185
|
+
ctx = click.get_current_context()
|
|
186
|
+
ctx.exit(2)
|
|
187
|
+
|
|
188
|
+
result = run_lint(config, path, report_format)
|
|
189
|
+
|
|
190
|
+
if report_format == "json":
|
|
191
|
+
output = format_lint_json(result)
|
|
192
|
+
else:
|
|
193
|
+
output = format_lint_text(result)
|
|
194
|
+
|
|
195
|
+
click.echo(output)
|
|
196
|
+
|
|
197
|
+
ctx = click.get_current_context()
|
|
198
|
+
if result.errors:
|
|
199
|
+
ctx.exit(1)
|
|
200
|
+
ctx.exit(0)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@cli.command()
|
|
204
|
+
@click.argument("path", required=False, default=".")
|
|
205
|
+
@click.option("--check", is_flag=True, help="Check only, don't modify files.")
|
|
206
|
+
@click.option("--diff", is_flag=True, help="Show diff of changes.")
|
|
207
|
+
def format(path, check, diff):
|
|
208
|
+
"""Format GDScript files."""
|
|
209
|
+
if check and diff:
|
|
210
|
+
click.echo("Error: --check and --diff are mutually exclusive", err=True)
|
|
211
|
+
ctx = click.get_current_context()
|
|
212
|
+
ctx.exit(2)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
config = load_config()
|
|
216
|
+
except ConfigError as e:
|
|
217
|
+
click.echo(f"Error: {e}", err=True)
|
|
218
|
+
ctx = click.get_current_context()
|
|
219
|
+
ctx.exit(2)
|
|
220
|
+
|
|
221
|
+
result = run_format(config, path, check=check, diff=diff)
|
|
222
|
+
|
|
223
|
+
ctx = click.get_current_context()
|
|
224
|
+
if result.files_checked == 0:
|
|
225
|
+
click.echo("No .gd files found.")
|
|
226
|
+
ctx.exit(0)
|
|
227
|
+
|
|
228
|
+
if check:
|
|
229
|
+
if result.files_needing_format > 0:
|
|
230
|
+
for file_path in result.files_needing_format_paths:
|
|
231
|
+
click.echo(f" {file_path}")
|
|
232
|
+
click.echo(
|
|
233
|
+
f"\n{result.files_needing_format} file(s) need "
|
|
234
|
+
f"formatting (out of {result.files_checked} checked)."
|
|
235
|
+
)
|
|
236
|
+
ctx.exit(1)
|
|
237
|
+
else:
|
|
238
|
+
click.echo(f"All {result.files_checked} file(s) are formatted.")
|
|
239
|
+
ctx.exit(0)
|
|
240
|
+
elif diff:
|
|
241
|
+
console = Console()
|
|
242
|
+
for diff_str in result.diffs:
|
|
243
|
+
syntax = Syntax(diff_str, "diff", theme="ansi_dark")
|
|
244
|
+
console.print(syntax)
|
|
245
|
+
ctx.exit(0)
|
|
246
|
+
else:
|
|
247
|
+
if result.files_formatted > 0:
|
|
248
|
+
click.echo(
|
|
249
|
+
f"Formatted {result.files_formatted} of "
|
|
250
|
+
f"{result.files_checked} file(s)."
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
click.echo(f"All {result.files_checked} file(s) already formatted.")
|
|
254
|
+
ctx.exit(0)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@cli.group()
|
|
258
|
+
def coverage():
|
|
259
|
+
"""Coverage reporting commands."""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@coverage.command()
|
|
263
|
+
@click.option("--format", help="Output format for the report.")
|
|
264
|
+
@click.option("--output-dir", help="Directory to write the report to.")
|
|
265
|
+
def report(format, output_dir):
|
|
266
|
+
"""Generate a coverage report."""
|
|
267
|
+
try:
|
|
268
|
+
config = load_config()
|
|
269
|
+
except ConfigError as e:
|
|
270
|
+
click.echo(f"Error: {e}", err=True)
|
|
271
|
+
ctx = click.get_current_context()
|
|
272
|
+
ctx.exit(2)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
result = generate_coverage_report(
|
|
276
|
+
config, report_format=format, output_dir=output_dir
|
|
277
|
+
)
|
|
278
|
+
click.echo(f"Report written to: {result.output_path}")
|
|
279
|
+
except GdToolsError as e:
|
|
280
|
+
click.echo(f"Error: {e}", err=True)
|
|
281
|
+
ctx = click.get_current_context()
|
|
282
|
+
ctx.exit(e.exit_code)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@coverage.command()
|
|
286
|
+
@click.argument("files", nargs=-1, required=True)
|
|
287
|
+
@click.option("--output", help="Path for the merged output file.")
|
|
288
|
+
def merge(files, output):
|
|
289
|
+
"""Merge multiple coverage files."""
|
|
290
|
+
try:
|
|
291
|
+
config = load_config()
|
|
292
|
+
except ConfigError as e:
|
|
293
|
+
click.echo(f"Error: {e}", err=True)
|
|
294
|
+
ctx = click.get_current_context()
|
|
295
|
+
ctx.exit(2)
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
merge_coverage_files(
|
|
299
|
+
[Path(f) for f in files],
|
|
300
|
+
Path(output) if output else None,
|
|
301
|
+
config=config,
|
|
302
|
+
)
|
|
303
|
+
except GdToolsError as e:
|
|
304
|
+
click.echo(f"Error: {e}", err=True)
|
|
305
|
+
ctx = click.get_current_context()
|
|
306
|
+
ctx.exit(e.exit_code)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@coverage.command()
|
|
310
|
+
@click.option("--min", type=int, help="Minimum coverage threshold.")
|
|
311
|
+
def show(min):
|
|
312
|
+
"""Show coverage summary."""
|
|
313
|
+
try:
|
|
314
|
+
config = load_config()
|
|
315
|
+
except ConfigError as e:
|
|
316
|
+
click.echo(f"Error: {e}", err=True)
|
|
317
|
+
ctx = click.get_current_context()
|
|
318
|
+
ctx.exit(2)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
show_coverage_summary(config, min_percent=min)
|
|
322
|
+
except CoverageThresholdError as e:
|
|
323
|
+
click.echo(f"Error: {e}", err=True)
|
|
324
|
+
ctx = click.get_current_context()
|
|
325
|
+
ctx.exit(1)
|
|
326
|
+
except GdToolsError as e:
|
|
327
|
+
click.echo(f"Error: {e}", err=True)
|
|
328
|
+
ctx = click.get_current_context()
|
|
329
|
+
ctx.exit(e.exit_code)
|
gd_tools/config.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Configuration system for gd-tools.
|
|
2
|
+
|
|
3
|
+
Provides Pydantic v2 models for typed loading, validation, and
|
|
4
|
+
default resolution of ``gd-tools.toml`` configuration files.
|
|
5
|
+
See TDD §3.2 for model definitions and PRD §6 for config format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import (
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
ValidationError,
|
|
18
|
+
field_validator,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from gd_tools.errors import ConfigError
|
|
22
|
+
|
|
23
|
+
if sys.version_info >= (3, 11):
|
|
24
|
+
import tomllib
|
|
25
|
+
else: # pragma: no cover
|
|
26
|
+
import tomli as tomllib
|
|
27
|
+
|
|
28
|
+
DEFAULT_EXCLUDES = ["addons", ".godot", ".gd-tools", ".git"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GodotConfig(BaseModel):
|
|
32
|
+
"""Configuration for the Godot binary.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
binary: Path to the Godot binary. If None, auto-detection
|
|
36
|
+
is used (Track 4).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
binary: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestConfig(BaseModel):
|
|
45
|
+
"""Configuration for test discovery and GUT.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
test_dirs: Directories containing test files.
|
|
49
|
+
prefix: Test file prefix (GUT convention).
|
|
50
|
+
suffix: Test file suffix.
|
|
51
|
+
gutconfig: Path to the GUT config file.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__test__ = False
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
test_dirs: list[str] = Field(default_factory=lambda: ["test", "tests"])
|
|
59
|
+
prefix: str = "test_"
|
|
60
|
+
suffix: str = ".gd"
|
|
61
|
+
gutconfig: str = ".gutconfig.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LintConfig(BaseModel):
|
|
65
|
+
"""Configuration for linting.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
exclude: List of directories excluded from linting.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(extra="forbid")
|
|
72
|
+
|
|
73
|
+
exclude: list[str] = Field(default_factory=lambda: DEFAULT_EXCLUDES.copy())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FormatConfig(BaseModel):
|
|
77
|
+
"""Configuration for formatting.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
exclude: List of directories excluded from formatting.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
exclude: list[str] = Field(default_factory=lambda: DEFAULT_EXCLUDES.copy())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CoverageConfig(BaseModel):
|
|
89
|
+
"""Configuration for code coverage.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
enabled: Whether coverage is enabled.
|
|
93
|
+
min_percent: Minimum coverage percentage threshold.
|
|
94
|
+
format: Report format (html, lcov, cobertura, text).
|
|
95
|
+
output_dir: Directory for coverage data and reports.
|
|
96
|
+
exclude: Directories excluded from coverage measurement.
|
|
97
|
+
test_dirs: Directories containing test files.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(extra="forbid")
|
|
101
|
+
|
|
102
|
+
enabled: bool = False
|
|
103
|
+
min_percent: int = 0
|
|
104
|
+
format: str = "html"
|
|
105
|
+
output_dir: str = ".gd-tools/coverage"
|
|
106
|
+
exclude: list[str] = Field(default_factory=lambda: DEFAULT_EXCLUDES.copy())
|
|
107
|
+
test_dirs: list[str] = Field(default_factory=lambda: ["test", "tests"])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GdToolsConfig(BaseModel):
|
|
111
|
+
"""Root configuration model for gd-tools.
|
|
112
|
+
|
|
113
|
+
Contains all configuration sections and a field validator
|
|
114
|
+
that enforces coverage format and min_percent constraints.
|
|
115
|
+
|
|
116
|
+
Attributes:
|
|
117
|
+
godot: Godot binary configuration.
|
|
118
|
+
test: Test discovery configuration.
|
|
119
|
+
lint: Linting configuration.
|
|
120
|
+
format: Formatting configuration.
|
|
121
|
+
coverage: Coverage configuration.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
model_config = ConfigDict(extra="forbid")
|
|
125
|
+
|
|
126
|
+
godot: GodotConfig = Field(default_factory=GodotConfig)
|
|
127
|
+
test: TestConfig = Field(default_factory=TestConfig)
|
|
128
|
+
lint: LintConfig = Field(default_factory=LintConfig)
|
|
129
|
+
format: FormatConfig = Field(default_factory=FormatConfig)
|
|
130
|
+
coverage: CoverageConfig = Field(default_factory=CoverageConfig)
|
|
131
|
+
|
|
132
|
+
@field_validator("coverage")
|
|
133
|
+
@classmethod
|
|
134
|
+
def validate_coverage(cls, v: CoverageConfig) -> CoverageConfig:
|
|
135
|
+
"""Validate coverage format and min_percent constraints.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
v: The coverage configuration to validate.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The validated coverage configuration.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: If format is not in {html, lcov,
|
|
145
|
+
cobertura, text} or min_percent is outside
|
|
146
|
+
[0, 100].
|
|
147
|
+
"""
|
|
148
|
+
if v.format not in (
|
|
149
|
+
"html",
|
|
150
|
+
"lcov",
|
|
151
|
+
"cobertura",
|
|
152
|
+
"text",
|
|
153
|
+
):
|
|
154
|
+
raise ValueError(f"Invalid coverage format: {v.format}")
|
|
155
|
+
if not 0 <= v.min_percent <= 100:
|
|
156
|
+
raise ValueError(f"min_percent must be 0-100, got {v.min_percent}")
|
|
157
|
+
return v
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def find_project_root(
|
|
161
|
+
start_path: Path | None = None,
|
|
162
|
+
) -> Path:
|
|
163
|
+
"""Walk up from start_path to find the nearest project.godot.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
start_path: Directory to start searching from.
|
|
167
|
+
Defaults to the current working directory.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The directory containing ``project.godot``.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ConfigError: If ``project.godot`` is not found in any
|
|
174
|
+
parent directory.
|
|
175
|
+
"""
|
|
176
|
+
if start_path is None:
|
|
177
|
+
start_path = Path.cwd()
|
|
178
|
+
current = start_path.resolve()
|
|
179
|
+
while True:
|
|
180
|
+
if (current / "project.godot").is_file():
|
|
181
|
+
return current
|
|
182
|
+
if current == current.parent:
|
|
183
|
+
raise ConfigError(
|
|
184
|
+
"project.godot not found in any parent "
|
|
185
|
+
f"directory starting from {start_path}"
|
|
186
|
+
)
|
|
187
|
+
current = current.parent
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_config(
|
|
191
|
+
project_root: Path | None = None,
|
|
192
|
+
) -> GdToolsConfig:
|
|
193
|
+
"""Load gd-tools.toml configuration from the project root.
|
|
194
|
+
|
|
195
|
+
If ``project_root`` is None, discovers it via
|
|
196
|
+
:func:`find_project_root`.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
project_root: Path to the project root directory.
|
|
200
|
+
If None, the project root is discovered
|
|
201
|
+
automatically.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
A typed ``GdToolsConfig`` object. If
|
|
205
|
+
``gd-tools.toml`` is missing, returns an object
|
|
206
|
+
with all default values.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ConfigError: If the TOML file is invalid or contains
|
|
210
|
+
values that fail Pydantic validation.
|
|
211
|
+
"""
|
|
212
|
+
if project_root is None:
|
|
213
|
+
project_root = find_project_root()
|
|
214
|
+
|
|
215
|
+
config_file = project_root / "gd-tools.toml"
|
|
216
|
+
if not config_file.is_file():
|
|
217
|
+
return GdToolsConfig()
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
with open(config_file, "rb") as f:
|
|
221
|
+
data = tomllib.load(f)
|
|
222
|
+
except tomllib.TOMLDecodeError as exc:
|
|
223
|
+
raise ConfigError(f"Invalid TOML in {config_file}: {exc}") from exc
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
return GdToolsConfig(**data)
|
|
227
|
+
except ValidationError as exc:
|
|
228
|
+
raise ConfigError(
|
|
229
|
+
f"Invalid configuration in {config_file}: " f"{exc}"
|
|
230
|
+
) from exc
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def save_config(
|
|
234
|
+
config: GdToolsConfig,
|
|
235
|
+
project_root: Path,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Write a GdToolsConfig to gd-tools.toml in the project root.
|
|
238
|
+
|
|
239
|
+
Produces valid TOML that round-trips through
|
|
240
|
+
:func:`load_config`.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
config: The configuration to serialize.
|
|
244
|
+
project_root: Path to the project root directory.
|
|
245
|
+
"""
|
|
246
|
+
config_file = project_root / "gd-tools.toml"
|
|
247
|
+
data = config.model_dump(exclude_none=True)
|
|
248
|
+
with open(config_file, "wb") as f:
|
|
249
|
+
tomli_w.dump(data, f)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def generate_gdlintrc(
|
|
253
|
+
config: GdToolsConfig,
|
|
254
|
+
project_root: Path,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Generate a gdlintrc file from the lint exclude list.
|
|
257
|
+
|
|
258
|
+
Writes the excludes as a YAML set to ``gdlintrc`` in
|
|
259
|
+
the project root, using the ``!!set`` tag format that
|
|
260
|
+
gdtoolkit expects. Overwrites the file if it already
|
|
261
|
+
exists.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
config: The configuration to read excludes from.
|
|
265
|
+
project_root: Path to the project root directory.
|
|
266
|
+
"""
|
|
267
|
+
rc_file = project_root / "gdlintrc"
|
|
268
|
+
data = {"excluded_directories": set(config.lint.exclude)}
|
|
269
|
+
content = yaml.dump(data, default_flow_style=False, sort_keys=True)
|
|
270
|
+
rc_file.write_text(content, encoding="utf-8")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def generate_gdformatrc(
|
|
274
|
+
config: GdToolsConfig,
|
|
275
|
+
project_root: Path,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Generate a gdformatrc file from the format exclude list.
|
|
278
|
+
|
|
279
|
+
Writes one exclude path per line to ``gdformatrc`` in
|
|
280
|
+
the project root. Overwrites the file if it already
|
|
281
|
+
exists.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
config: The configuration to read excludes from.
|
|
285
|
+
project_root: Path to the project root directory.
|
|
286
|
+
"""
|
|
287
|
+
rc_file = project_root / "gdformatrc"
|
|
288
|
+
content = "\n".join(config.format.exclude) + "\n"
|
|
289
|
+
rc_file.write_text(content, encoding="utf-8")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Coverage subsystem package.
|
|
2
|
+
|
|
3
|
+
Re-exports the orchestrator functions for convenient access:
|
|
4
|
+
|
|
5
|
+
- :func:`run_coverage_test`: Full coverage flow (plan -> run -> report).
|
|
6
|
+
- :func:`generate_coverage_report`: Regenerate reports from existing data.
|
|
7
|
+
- :func:`merge_coverage_files`: Merge multiple coverage data files.
|
|
8
|
+
- :func:`show_coverage_summary`: Print terminal summary table.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from gd_tools.coverage.orchestrator import (
|
|
12
|
+
generate_coverage_report,
|
|
13
|
+
merge_coverage_files,
|
|
14
|
+
run_coverage_test,
|
|
15
|
+
show_coverage_summary,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"run_coverage_test",
|
|
20
|
+
"generate_coverage_report",
|
|
21
|
+
"merge_coverage_files",
|
|
22
|
+
"show_coverage_summary",
|
|
23
|
+
]
|