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/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
+ ]