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,41 @@
1
+ """Shared file discovery module for gd-tools.
2
+
3
+ Discovers ``.gd`` files under a given path, applying exclude
4
+ patterns from the configuration. Used by both the lint runner
5
+ and the format runner.
6
+ """
7
+
8
+ import os
9
+
10
+ from gd_tools.config import DEFAULT_EXCLUDES
11
+
12
+
13
+ def discover_gd_files(
14
+ path: str, excludes: list[str] | None = None
15
+ ) -> list[str]:
16
+ """Discover ``.gd`` files in ``path``, skipping excluded directories.
17
+
18
+ Recursively walks ``path`` and collects all files with a ``.gd``
19
+ extension (case-insensitive). Directories whose names appear in
20
+ ``excludes`` are pruned from the walk so their contents are never
21
+ visited.
22
+
23
+ Args:
24
+ path: Root directory to search.
25
+ excludes: Directory names to skip. Defaults to
26
+ :data:`DEFAULT_EXCLUDES` from ``config.py``.
27
+
28
+ Returns:
29
+ List of file paths (as strings) to ``.gd`` files.
30
+ """
31
+ if excludes is None:
32
+ excludes = DEFAULT_EXCLUDES
33
+
34
+ gd_files: list[str] = []
35
+ for root, dirs, files in os.walk(path):
36
+ # Prune excluded dirs in-place so os.walk doesn't descend into them
37
+ dirs[:] = [d for d in dirs if d not in excludes]
38
+ for file in files:
39
+ if file.lower().endswith(".gd"):
40
+ gd_files.append(os.path.join(root, file))
41
+ return gd_files
@@ -0,0 +1,118 @@
1
+ """Format runner module for gd-tools.
2
+
3
+ Wraps ``gdformat`` (via the gdtoolkit Python API) with config-driven
4
+ excludes and clean, formatted output. Discovers ``.gd`` files,
5
+ invokes the formatter programmatically, and returns structured results.
6
+ """
7
+
8
+ import difflib
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+
12
+ from gdtoolkit.formatter import format_code
13
+ from lark.exceptions import LarkError
14
+
15
+ from gd_tools.config import GdToolsConfig
16
+ from gd_tools.errors import FormatError
17
+ from gd_tools.file_discovery import discover_gd_files
18
+
19
+
20
+ @dataclass
21
+ class FormatResult:
22
+ """Result of a format run.
23
+
24
+ Attributes:
25
+ files_checked: Total number of .gd files examined.
26
+ files_formatted: Number of files that were reformatted
27
+ (written with changes). Only non-zero in default mode.
28
+ files_needing_format: Number of files whose formatted
29
+ version differs from the original. Non-zero in --check
30
+ and --diff modes.
31
+ files_needing_format_paths: List of file paths that need
32
+ formatting. Only populated in --check mode.
33
+ diffs: List of unified diff strings for files that differ.
34
+ Only populated in --diff mode.
35
+ """
36
+
37
+ files_checked: int = 0
38
+ files_formatted: int = 0
39
+ files_needing_format: int = 0
40
+ files_needing_format_paths: list[str] = field(default_factory=list)
41
+ diffs: list[str] = field(default_factory=list)
42
+
43
+
44
+ def run_format(
45
+ config: GdToolsConfig,
46
+ path: str = ".",
47
+ check: bool = False,
48
+ diff: bool = False,
49
+ ) -> FormatResult:
50
+ """Format ``.gd`` files in ``path`` using the gdtoolkit formatter.
51
+
52
+ Args:
53
+ config: Project configuration with format excludes.
54
+ path: Root directory to search for .gd files.
55
+ check: If True, report files needing format without modifying.
56
+ diff: If True, show unified diffs without modifying.
57
+
58
+ Returns:
59
+ FormatResult with counts and diffs.
60
+
61
+ Raises:
62
+ FormatError: If both check and diff are True (mutually exclusive).
63
+ """
64
+ if check and diff:
65
+ raise FormatError(
66
+ "--check and --diff are mutually exclusive",
67
+ exit_code=2,
68
+ )
69
+
70
+ excludes = config.format.exclude
71
+ gd_files = discover_gd_files(path, excludes)
72
+
73
+ if not gd_files:
74
+ return FormatResult()
75
+
76
+ files_checked = len(gd_files)
77
+ files_formatted = 0
78
+ files_needing_format = 0
79
+ files_needing_format_paths: list[str] = []
80
+ diffs_list: list[str] = []
81
+
82
+ for file_path in gd_files:
83
+ try:
84
+ with open(file_path, "r", encoding="utf-8") as f:
85
+ original_code = f.read()
86
+
87
+ formatted_code = format_code(original_code, max_line_length=100)
88
+
89
+ if formatted_code != original_code:
90
+ if check:
91
+ files_needing_format += 1
92
+ files_needing_format_paths.append(file_path)
93
+ elif diff:
94
+ diff_str = "".join(
95
+ difflib.unified_diff(
96
+ original_code.splitlines(keepends=True),
97
+ formatted_code.splitlines(keepends=True),
98
+ fromfile=file_path,
99
+ tofile=file_path,
100
+ )
101
+ )
102
+ diffs_list.append(diff_str)
103
+ else:
104
+ with open(file_path, "w", encoding="utf-8") as f:
105
+ f.write(formatted_code)
106
+ files_formatted += 1
107
+ except LarkError as e:
108
+ # Syntax error: report file path and description, then skip
109
+ print(f"Warning: Skipping {file_path}: {e}", file=sys.stderr)
110
+ continue
111
+
112
+ return FormatResult(
113
+ files_checked=files_checked,
114
+ files_formatted=files_formatted,
115
+ files_needing_format=files_needing_format,
116
+ files_needing_format_paths=files_needing_format_paths,
117
+ diffs=diffs_list,
118
+ )
gd_tools/godot.py ADDED
@@ -0,0 +1,346 @@
1
+ """Godot binary detection and invocation module.
2
+
3
+ Resolves the Godot binary path via a 5-level priority chain, detects
4
+ and validates the Godot version, maps Godot versions to compatible
5
+ GUT versions, and provides a subprocess wrapper for invoking Godot.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from gd_tools.config import GodotConfig
17
+ from gd_tools.errors import ConfigError, GodotNotFoundError
18
+
19
+
20
+ @dataclass
21
+ class GodotInfo:
22
+ """Information about a detected Godot binary.
23
+
24
+ Attributes:
25
+ path: Resolved binary path.
26
+ version: Parsed version string (e.g., "4.5.1").
27
+ is_valid: True if version >= 4.5.0.
28
+ """
29
+
30
+ path: str
31
+ version: str
32
+ is_valid: bool
33
+
34
+
35
+ # --- Version Detection ---
36
+
37
+
38
+ def get_godot_version(binary: str) -> str:
39
+ """Get the Godot version string from the binary.
40
+
41
+ Runs ``godot --version`` and parses the output into a normalized
42
+ ``major.minor.patch`` string.
43
+
44
+ Args:
45
+ binary: Path to the Godot binary.
46
+
47
+ Returns:
48
+ Normalized version string (e.g., "4.5.1").
49
+
50
+ Raises:
51
+ GodotNotFoundError: If the binary fails to run or produces
52
+ unparseable output.
53
+ """
54
+ try:
55
+ result = subprocess.run(
56
+ [binary, "--version"],
57
+ capture_output=True,
58
+ text=True,
59
+ )
60
+ except (OSError, subprocess.SubprocessError) as exc:
61
+ raise GodotNotFoundError(
62
+ f"Failed to execute Godot binary at {binary}: {exc}"
63
+ ) from exc
64
+
65
+ if result.returncode != 0:
66
+ raise GodotNotFoundError(
67
+ f"Godot binary at {binary} exited with code "
68
+ f"{result.returncode}: {result.stderr.strip()}"
69
+ )
70
+
71
+ output = result.stdout.strip()
72
+ match = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", output)
73
+ if not match:
74
+ raise GodotNotFoundError(
75
+ f"Could not parse Godot version from output: {output!r}"
76
+ )
77
+
78
+ major, minor = match.group(1), match.group(2)
79
+ patch = match.group(3) or "0"
80
+ return f"{major}.{minor}.{patch}"
81
+
82
+
83
+ def check_version_compatible(version: str) -> bool:
84
+ """Check if a Godot version is compatible (>= 4.5.0).
85
+
86
+ Args:
87
+ version: Normalized version string (e.g., "4.5.1").
88
+
89
+ Returns:
90
+ True if version >= 4.5.0, False otherwise.
91
+ """
92
+ match = re.match(r"(\d+)\.(\d+)\.(\d+)", version)
93
+ if not match:
94
+ return False
95
+ major, minor, patch = (
96
+ int(match.group(1)),
97
+ int(match.group(2)),
98
+ int(match.group(3)),
99
+ )
100
+ return (major, minor, patch) >= (4, 5, 0)
101
+
102
+
103
+ GUT_VERSION_MAP = {
104
+ "4.5": "9.5.0",
105
+ "4.6": "9.6.0",
106
+ "4.7": "9.7.0",
107
+ }
108
+
109
+
110
+ def get_gut_version_for_godot(godot_version: str) -> str:
111
+ """Map a Godot version to its compatible GUT version.
112
+
113
+ Uses the ``major.minor`` prefix to look up the GUT version.
114
+
115
+ Args:
116
+ godot_version: Normalized Godot version (e.g., "4.5.1").
117
+
118
+ Returns:
119
+ Compatible GUT version string (e.g., "9.5.0").
120
+
121
+ Raises:
122
+ ConfigError: If the Godot version is not in the GUT_VERSION_MAP.
123
+ """
124
+ parts = godot_version.split(".")
125
+ if len(parts) < 2:
126
+ raise ConfigError(
127
+ f"Invalid Godot version '{godot_version}': "
128
+ f"expected format 'major.minor.patch'"
129
+ )
130
+ key = f"{parts[0]}.{parts[1]}"
131
+ if key not in GUT_VERSION_MAP:
132
+ raise ConfigError(
133
+ f"No GUT version mapping for Godot {key}. "
134
+ f"Supported versions: {', '.join(sorted(GUT_VERSION_MAP))}"
135
+ )
136
+ return GUT_VERSION_MAP[key]
137
+
138
+
139
+ # --- Binary Resolution Chain ---
140
+
141
+
142
+ def _is_executable(path: str) -> bool:
143
+ """Check if a path exists and is executable.
144
+
145
+ Args:
146
+ path: File path to check.
147
+
148
+ Returns:
149
+ True if the file exists and is executable.
150
+ """
151
+ return Path(path).is_file() and os.access(path, os.X_OK)
152
+
153
+
154
+ def _check_config(config: GodotConfig) -> str | None:
155
+ """Check the user-specified binary path from config.
156
+
157
+ Args:
158
+ config: The Godot configuration.
159
+
160
+ Returns:
161
+ Binary path if config has a valid binary, None otherwise.
162
+ """
163
+ if config.binary is None:
164
+ return None
165
+ if _is_executable(config.binary):
166
+ return config.binary
167
+ return None
168
+
169
+
170
+ def _check_env_vars() -> str | None:
171
+ """Check environment variables for the Godot binary.
172
+
173
+ Checks ``GODOT_BIN``, ``GODOT4_BIN``, and ``GODOT_PATH`` in order.
174
+
175
+ Returns:
176
+ Binary path if found in an env var, None otherwise.
177
+ """
178
+ for var_name in ("GODOT_BIN", "GODOT4_BIN", "GODOT_PATH"):
179
+ path = os.environ.get(var_name)
180
+ if path and _is_executable(path):
181
+ return path
182
+ return None
183
+
184
+
185
+ def _check_path() -> str | None:
186
+ """Check PATH for the Godot binary via ``shutil.which``.
187
+
188
+ Returns:
189
+ Binary path if found on PATH, None otherwise.
190
+ """
191
+ for name in ("godot", "godot4"):
192
+ found = shutil.which(name)
193
+ if found:
194
+ return found
195
+ return None
196
+
197
+
198
+ def _check_common_locations() -> str | None:
199
+ """Check common install locations for the Godot binary.
200
+
201
+ Checks platform-specific standard install paths.
202
+
203
+ Returns:
204
+ Binary path if found at a common location, None otherwise.
205
+ """
206
+ if sys.platform == "win32":
207
+ localappdata = os.environ.get("LOCALAPPDATA")
208
+ candidates = [
209
+ r"C:\Program Files\Godot\godot.exe",
210
+ (
211
+ os.path.join(localappdata, "Godot", "godot.exe")
212
+ if localappdata
213
+ else None
214
+ ),
215
+ ]
216
+ elif sys.platform == "darwin":
217
+ candidates = [
218
+ "/Applications/Godot.app/Contents/MacOS/Godot",
219
+ "/opt/homebrew/bin/godot",
220
+ ]
221
+ else:
222
+ candidates = [
223
+ os.path.expanduser("~/.local/bin/godot"),
224
+ "/usr/bin/godot",
225
+ "/usr/local/bin/godot",
226
+ ]
227
+
228
+ for candidate in candidates:
229
+ if candidate and _is_executable(candidate):
230
+ return candidate
231
+ return None
232
+
233
+
234
+ def _build_not_found_message() -> str:
235
+ """Build a comprehensive 'Godot not found' error message.
236
+
237
+ Returns:
238
+ Error message with resolution methods and install instructions.
239
+ """
240
+ lines = ["Godot binary not found."]
241
+ lines.append("")
242
+ lines.append("Tried the following resolution methods:")
243
+ lines.append(" 1. Config: [godot].binary in gd-tools.toml")
244
+ lines.append(" 2. Environment: GODOT_BIN, GODOT4_BIN, GODOT_PATH")
245
+ lines.append(" 3. PATH: shutil.which('godot'), shutil.which('godot4')")
246
+ lines.append(" 4. Common install locations")
247
+ lines.append("")
248
+ if sys.platform == "win32":
249
+ lines.append("Install Godot on Windows:")
250
+ lines.append(
251
+ " Download from https://godotengine.org/download/windows/"
252
+ )
253
+ lines.append(" Or: winget install GodotEngine.GodotEngine")
254
+ elif sys.platform == "darwin":
255
+ lines.append("Install Godot on macOS:")
256
+ lines.append(" Download from https://godotengine.org/download/macos/")
257
+ lines.append(" Or: brew install --cask godot")
258
+ else:
259
+ lines.append("Install Godot on Linux:")
260
+ lines.append(" Download from https://godotengine.org/download/linux/")
261
+ lines.append(" Or: flatpak install org.godotengine.Godot")
262
+ lines.append("")
263
+ lines.append("To configure manually:")
264
+ lines.append(" - Run 'gd-tools init' to set up interactively")
265
+ lines.append(" - Set the GODOT_BIN environment variable")
266
+ lines.append(" - Add [godot].binary = '/path/to/godot' to gd-tools.toml")
267
+ return "\n".join(lines)
268
+
269
+
270
+ def find_godot(config: GodotConfig) -> GodotInfo:
271
+ """Find the Godot binary using a 5-level priority chain.
272
+
273
+ Resolution order (first match wins):
274
+ 1. ``config.binary``
275
+ 2. Environment variables (``GODOT_BIN``, ``GODOT4_BIN``,
276
+ ``GODOT_PATH``)
277
+ 3. PATH lookup (``shutil.which``)
278
+ 4. Common install locations
279
+ 5. Raise ``GodotNotFoundError``
280
+
281
+ Args:
282
+ config: The Godot configuration.
283
+
284
+ Returns:
285
+ GodotInfo with resolved path, version, and validity.
286
+
287
+ Raises:
288
+ GodotNotFoundError: If no Godot binary is found.
289
+ """
290
+ binary = (
291
+ _check_config(config)
292
+ or _check_env_vars()
293
+ or _check_path()
294
+ or _check_common_locations()
295
+ )
296
+
297
+ if binary is None:
298
+ raise GodotNotFoundError(_build_not_found_message())
299
+
300
+ try:
301
+ version = get_godot_version(binary)
302
+ is_valid = check_version_compatible(version)
303
+ except GodotNotFoundError:
304
+ version = "unknown"
305
+ is_valid = False
306
+
307
+ return GodotInfo(path=binary, version=version, is_valid=is_valid)
308
+
309
+
310
+ # --- Godot Invocation Wrapper ---
311
+
312
+
313
+ def run_godot(
314
+ binary: str,
315
+ project_path: Path,
316
+ args: list[str],
317
+ env: dict[str, str] | None = None,
318
+ timeout: int | None = None,
319
+ ) -> subprocess.CompletedProcess:
320
+ """Invoke Godot with the given project path and arguments.
321
+
322
+ Sets ``--path`` to ``project_path`` and merges the provided ``env``
323
+ with the current ``os.environ`` (caller values take precedence).
324
+
325
+ Args:
326
+ binary: Path to the Godot binary.
327
+ project_path: Path to the Godot project directory.
328
+ args: Additional arguments to pass to Godot.
329
+ env: Environment variables to merge with os.environ.
330
+ timeout: Timeout in seconds for the subprocess.
331
+
332
+ Returns:
333
+ The completed subprocess result.
334
+
335
+ Raises:
336
+ subprocess.TimeoutExpired: If the timeout is exceeded.
337
+ """
338
+ cmd = [binary, "--path", str(project_path), *args]
339
+ merged_env = {**os.environ, **(env or {})}
340
+ return subprocess.run(
341
+ cmd,
342
+ capture_output=True,
343
+ text=True,
344
+ env=merged_env,
345
+ timeout=timeout,
346
+ )