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 ADDED
@@ -0,0 +1,619 @@
1
+ """Initialize a Godot project for use with gd-tools.
2
+
3
+ This module implements the ``gd-tools init`` command, which bootstraps
4
+ a Godot project by:
5
+ - Detecting the project root and Godot version
6
+ - Installing GUT (Godot Unit Test) if not present
7
+ - Deploying the coverage addon as placeholder stubs
8
+ - Creating configuration files (``.gutconfig.json``, ``gd-tools.toml``,
9
+ ``gdlintrc``, ``gdformatrc``)
10
+ - Creating the ``.gd-tools/`` data directory
11
+ - Printing a summary of actions taken
12
+
13
+ The command is idempotent: running it multiple times produces the
14
+ same end state without duplicating entries.
15
+ """
16
+
17
+ import configparser
18
+ import json
19
+ import shutil
20
+ import sys
21
+ import tempfile
22
+ import zipfile
23
+ from pathlib import Path
24
+
25
+ import click
26
+ import requests
27
+ import yaml
28
+ from rich.console import Console
29
+
30
+ from .config import (
31
+ GdToolsConfig,
32
+ find_project_root,
33
+ load_config,
34
+ save_config,
35
+ )
36
+ from .errors import GdToolsError
37
+ from .godot import find_godot, get_gut_version_for_godot
38
+
39
+ # --- Constants ---
40
+
41
+ GUTCONFIG_TEMPLATE: dict = {
42
+ "dirs": ["res://test/", "res://tests/"],
43
+ "include_subdirs": True,
44
+ "prefix": "test_",
45
+ "suffix": ".gd",
46
+ "should_exit": True,
47
+ "junit_xml_file": ".gd-tools/results.xml",
48
+ "pre_run_script": "res://addons/gd-tools-coverage/pre_run_hook.gd",
49
+ "post_run_script": "res://addons/gd-tools-coverage/post_run_hook.gd",
50
+ }
51
+
52
+ GUT_DOWNLOAD_URL = (
53
+ "https://github.com/bitwes/Gut/archive/refs/tags/v{version}.zip"
54
+ )
55
+
56
+ GUT_PLUGIN_PATH = "res://addons/gut/plugin.gd"
57
+
58
+ COVERAGE_ADDON_FILES = [
59
+ "coverage.gd",
60
+ "pre_run_hook.gd",
61
+ "post_run_hook.gd",
62
+ ]
63
+
64
+ COVERAGE_AUTOLOAD_PATH = "res://addons/gd-tools-coverage/coverage.gd"
65
+
66
+ console = Console()
67
+
68
+
69
+ # --- Phase 1: Project Detection and Godot Version Detection ---
70
+
71
+
72
+ def detect_godot_version(config: GdToolsConfig) -> str:
73
+ """Detect the Godot version using the config's Godot settings.
74
+
75
+ Calls :func:`find_godot` to resolve the binary and parse its
76
+ version. If the detected version is below 4.5.0, a warning is
77
+ printed but the version is still returned.
78
+
79
+ Args:
80
+ config: The gd-tools configuration containing Godot settings.
81
+
82
+ Returns:
83
+ The detected Godot version string (e.g., ``"4.5.1"``).
84
+
85
+ Raises:
86
+ GodotNotFoundError: If the Godot binary cannot be found.
87
+ """
88
+ info = find_godot(config.godot)
89
+ if not info.is_valid:
90
+ console.print(
91
+ "[yellow]Warning: Godot "
92
+ f"{info.version} is below the required "
93
+ "version 4.5.0. Some features may not "
94
+ "work.[/yellow]"
95
+ )
96
+ return info.version
97
+
98
+
99
+ # --- Phase 2: GUT Installation ---
100
+
101
+
102
+ def check_gut_installed(project_root: Path) -> bool:
103
+ """Check if GUT is installed in the project.
104
+
105
+ Args:
106
+ project_root: Path to the Godot project root.
107
+
108
+ Returns:
109
+ True if ``addons/gut/gut.gd`` exists, False otherwise.
110
+ """
111
+ return (project_root / "addons" / "gut" / "gut.gd").exists()
112
+
113
+
114
+ def get_installed_gut_version(project_root: Path) -> str | None:
115
+ """Get the installed GUT version from ``addons/gut/plugin.cfg``.
116
+
117
+ Args:
118
+ project_root: Path to the Godot project root.
119
+
120
+ Returns:
121
+ The GUT version string (e.g., ``"9.5.0"``), or ``None`` if
122
+ ``plugin.cfg`` does not exist or has no ``version`` key.
123
+ """
124
+ plugin_cfg = project_root / "addons" / "gut" / "plugin.cfg"
125
+ if not plugin_cfg.exists():
126
+ return None
127
+ parser = configparser.ConfigParser()
128
+ parser.read(plugin_cfg)
129
+ version = parser.get("plugin", "version", fallback=None)
130
+ if version is None:
131
+ return None
132
+ return version.strip('"')
133
+
134
+
135
+ def download_gut(version: str, dest: Path) -> Path:
136
+ """Download the GUT zip archive for the given version.
137
+
138
+ Args:
139
+ version: The GUT version string (e.g., ``"9.5.0"``).
140
+ dest: Destination path for the downloaded zip file.
141
+
142
+ Returns:
143
+ The path to the downloaded zip file.
144
+
145
+ Raises:
146
+ GdToolsError: If the download fails (network error, HTTP error,
147
+ etc.). The error message includes manual install instructions.
148
+ """
149
+ url = GUT_DOWNLOAD_URL.format(version=version)
150
+ try:
151
+ response = requests.get(url, timeout=30)
152
+ response.raise_for_status()
153
+ except requests.RequestException as exc:
154
+ raise GdToolsError(
155
+ f"[Error] Failed to download GUT v{version}\n"
156
+ f" Cause: {exc}\n"
157
+ f" Fix: Download GUT manually from:\n"
158
+ f" - Godot Asset Library: "
159
+ f"https://godotengine.org/asset-library/asset/116\n"
160
+ f" - GitHub: {url}\n"
161
+ f" Extract the 'addons/gut/' folder to your project's "
162
+ f"'addons/' directory."
163
+ ) from exc
164
+ dest.write_bytes(response.content)
165
+ return dest
166
+
167
+
168
+ def extract_gut(zip_path: Path, project_root: Path) -> None:
169
+ """Extract the GUT zip archive and copy addons/gut/ to the project.
170
+
171
+ The GitHub archive contains a top-level directory (e.g.,
172
+ ``Gut-9.5.0/``) with ``addons/gut/`` inside it. This function
173
+ extracts to a temporary directory, locates ``addons/gut/``, copies
174
+ it to ``project_root/addons/gut/``, and cleans up the temp dir.
175
+
176
+ Args:
177
+ zip_path: Path to the downloaded GUT zip file.
178
+ project_root: Path to the Godot project root.
179
+
180
+ Raises:
181
+ GdToolsError: If the archive does not contain an
182
+ ``addons/gut/`` directory.
183
+ """
184
+ tmpdir = tempfile.mkdtemp()
185
+ try:
186
+ tmp_path = Path(tmpdir)
187
+ with zipfile.ZipFile(zip_path) as zf:
188
+ zf.extractall(tmp_path)
189
+ gut_source: Path | None = None
190
+ for addons_dir in tmp_path.rglob("addons"):
191
+ gut_dir = addons_dir / "gut"
192
+ if gut_dir.is_dir():
193
+ gut_source = gut_dir
194
+ break
195
+ if gut_source is None:
196
+ raise GdToolsError(
197
+ "[Error] Failed to extract GUT\n"
198
+ " Cause: addons/gut/ directory not found in archive\n"
199
+ " Fix: Download GUT manually from:\n"
200
+ " - Godot Asset Library: "
201
+ "https://godotengine.org/asset-library/asset/116\n"
202
+ " - GitHub: https://github.com/bitwes/Gut\n"
203
+ " Extract the 'addons/gut/' folder to your project's "
204
+ "'addons/' directory."
205
+ )
206
+ dest = project_root / "addons" / "gut"
207
+ if dest.exists():
208
+ shutil.rmtree(dest)
209
+ shutil.copytree(gut_source, dest)
210
+ finally:
211
+ shutil.rmtree(tmpdir, ignore_errors=True)
212
+
213
+
214
+ def install_gut(
215
+ project_root: Path, godot_version: str, non_interactive: bool
216
+ ) -> bool:
217
+ """Install GUT if not already installed.
218
+
219
+ If GUT is already installed, checks the installed version against
220
+ the expected version for the detected Godot version and warns if
221
+ they differ. If GUT is not installed, prompts the user (interactive
222
+ mode) or auto-installs (non-interactive mode).
223
+
224
+ Args:
225
+ project_root: Path to the Godot project root.
226
+ godot_version: The detected Godot version (e.g., ``"4.5.1"``).
227
+ non_interactive: If True, skip prompts and assume yes.
228
+
229
+ Returns:
230
+ True if GUT is installed (or was already present),
231
+ False if the user declined installation.
232
+ """
233
+ if check_gut_installed(project_root):
234
+ installed_version = get_installed_gut_version(project_root)
235
+ expected_version = get_gut_version_for_godot(godot_version)
236
+ if installed_version != expected_version:
237
+ console.print(
238
+ "[yellow]Warning: GUT version "
239
+ f"{installed_version} does not match expected "
240
+ f"version {expected_version} for Godot "
241
+ f"{godot_version}.[/yellow]"
242
+ )
243
+ return True
244
+
245
+ if not non_interactive:
246
+ if not click.confirm("Install GUT?", default=True):
247
+ console.print(
248
+ "GUT not installed. To install manually:\n"
249
+ " 1. Download from: "
250
+ "https://godotengine.org/asset-library/asset/116\n"
251
+ " 2. Extract the 'addons/gut/' folder to your "
252
+ "project's 'addons/' directory."
253
+ )
254
+ return False
255
+
256
+ gut_version = get_gut_version_for_godot(godot_version)
257
+ tmpdir = tempfile.mkdtemp()
258
+ zip_dest = Path(tmpdir) / "gut.zip"
259
+ try:
260
+ download_gut(gut_version, zip_dest)
261
+ extract_gut(zip_dest, project_root)
262
+ finally:
263
+ shutil.rmtree(tmpdir, ignore_errors=True)
264
+ return True
265
+
266
+
267
+ def enable_gut_plugin(project_root: Path) -> None:
268
+ """Enable the GUT plugin in ``project.godot``.
269
+
270
+ Adds the ``[editor_plugins]`` section with the GUT plugin in the
271
+ ``enabled`` list if not already present. Idempotent: running
272
+ multiple times produces the same result.
273
+
274
+ Args:
275
+ project_root: Path to the Godot project root.
276
+ """
277
+ project_godot = project_root / "project.godot"
278
+ content = project_godot.read_text()
279
+
280
+ gut_entry = f'"{GUT_PLUGIN_PATH}"'
281
+
282
+ # Idempotent: if GUT is already in the file, do nothing
283
+ if gut_entry in content:
284
+ return
285
+
286
+ if "[editor_plugins]" in content:
287
+ # Section exists, add GUT to enabled list
288
+ lines = content.split("\n")
289
+ for i, line in enumerate(lines):
290
+ if line.strip() == "[editor_plugins]":
291
+ # Look for enabled= in subsequent lines
292
+ for j in range(i + 1, len(lines)):
293
+ next_stripped = lines[j].strip()
294
+ if next_stripped.startswith("["):
295
+ # Reached next section, insert enabled= before it
296
+ lines.insert(
297
+ j,
298
+ f"enabled=PackedStringArray({gut_entry})",
299
+ )
300
+ break
301
+ if next_stripped.startswith("enabled="):
302
+ # Add GUT to existing PackedStringArray
303
+ lines[j] = lines[j].replace(
304
+ "PackedStringArray(",
305
+ f"PackedStringArray({gut_entry}, ",
306
+ )
307
+ break
308
+ else:
309
+ # No enabled= and no next section, append at end
310
+ lines.append(f"enabled=PackedStringArray({gut_entry})")
311
+ break
312
+ project_godot.write_text("\n".join(lines))
313
+ else:
314
+ # No [editor_plugins] section, append it
315
+ if not content.endswith("\n"):
316
+ content += "\n"
317
+ content += (
318
+ f"\n[editor_plugins]\n\n"
319
+ f"enabled=PackedStringArray({gut_entry})\n"
320
+ )
321
+ project_godot.write_text(content)
322
+
323
+
324
+ # --- Phase 3: Coverage Addon Deployment ---
325
+
326
+
327
+ def install_coverage_addon(project_root: Path) -> None:
328
+ """Copy bundled coverage addon placeholder files to the project.
329
+
330
+ Copies the placeholder GDScript stubs from the package data to
331
+ ``project_root/addons/gd-tools-coverage/``. Always overwrites
332
+ existing files to ensure they are up-to-date.
333
+
334
+ Args:
335
+ project_root: Path to the Godot project root.
336
+ """
337
+ source_dir = Path(__file__).parent / "addons" / "gd-tools-coverage"
338
+ target_dir = project_root / "addons" / "gd-tools-coverage"
339
+ target_dir.mkdir(parents=True, exist_ok=True)
340
+ for gd_file in COVERAGE_ADDON_FILES:
341
+ shutil.copy2(source_dir / gd_file, target_dir / gd_file)
342
+
343
+
344
+ def register_coverage_autoload(project_root: Path) -> None:
345
+ """Register the coverage tracker autoload in ``project.godot``.
346
+
347
+ Adds ``_GDTCoverage`` to the ``[autoload]`` section, pointing at
348
+ ``res://addons/gd-tools-coverage/coverage.gd``. Idempotent: running
349
+ multiple times produces the same result. If ``_GDTCoverage`` is
350
+ already registered with a different path, the entry is replaced
351
+ to avoid duplicate autoload keys.
352
+
353
+ Args:
354
+ project_root: Path to the Godot project root.
355
+ """
356
+ project_godot = project_root / "project.godot"
357
+ content = project_godot.read_text()
358
+
359
+ autoload_entry = f'_GDTCoverage="*{COVERAGE_AUTOLOAD_PATH}"'
360
+
361
+ # Idempotent: if already registered with correct path, do nothing.
362
+ if autoload_entry in content:
363
+ return
364
+
365
+ lines = content.split("\n")
366
+
367
+ # If _GDTCoverage is already registered with a different path,
368
+ # replace the existing entry to avoid duplicate autoload keys.
369
+ for i, line in enumerate(lines):
370
+ if line.strip().startswith("_GDTCoverage="):
371
+ lines[i] = autoload_entry
372
+ project_godot.write_text("\n".join(lines))
373
+ return
374
+
375
+ if "[autoload]" in content:
376
+ # Section exists, append entry after the section header.
377
+ for i, line in enumerate(lines):
378
+ if line.strip() == "[autoload]":
379
+ lines.insert(i + 1, f"\n{autoload_entry}")
380
+ break
381
+ project_godot.write_text("\n".join(lines))
382
+ else:
383
+ # No [autoload] section, append it.
384
+ if not content.endswith("\n"):
385
+ content += "\n"
386
+ content += f"\n[autoload]\n\n{autoload_entry}\n"
387
+ project_godot.write_text(content)
388
+
389
+
390
+ # --- Phase 4: Configuration File Generation ---
391
+
392
+ # Keys in .gutconfig.json that are always overwritten from the template.
393
+ _GUTCONFIG_OVERWRITE_KEYS = (
394
+ "should_exit",
395
+ "junit_xml_file",
396
+ "pre_run_script",
397
+ "post_run_script",
398
+ )
399
+
400
+ # Keys in .gutconfig.json that are preserved from the user's existing file.
401
+ _GUTCONFIG_PRESERVE_KEYS = (
402
+ "dirs",
403
+ "prefix",
404
+ "suffix",
405
+ "include_subdirs",
406
+ )
407
+
408
+
409
+ def update_gutconfig(project_root: Path, config: GdToolsConfig) -> None:
410
+ """Create or merge ``.gutconfig.json`` in the project root.
411
+
412
+ If the file does not exist, writes ``GUTCONFIG_TEMPLATE`` as JSON.
413
+ If it exists, merges: preserves the user's ``dirs``, ``prefix``,
414
+ ``suffix``, and ``include_subdirs``; always overwrites
415
+ ``should_exit``, ``junit_xml_file``, ``pre_run_script``, and
416
+ ``post_run_script`` from the template.
417
+
418
+ Args:
419
+ project_root: Path to the Godot project root.
420
+ config: The gd-tools configuration (unused but kept for
421
+ signature consistency with other init functions).
422
+ """
423
+ gutconfig_path = project_root / ".gutconfig.json"
424
+
425
+ if not gutconfig_path.exists():
426
+ gutconfig_path.write_text(
427
+ json.dumps(GUTCONFIG_TEMPLATE, indent=2) + "\n"
428
+ )
429
+ return
430
+
431
+ existing = json.loads(gutconfig_path.read_text())
432
+ merged = GUTCONFIG_TEMPLATE.copy()
433
+ for key in _GUTCONFIG_PRESERVE_KEYS:
434
+ if key in existing:
435
+ merged[key] = existing[key]
436
+ gutconfig_path.write_text(json.dumps(merged, indent=2) + "\n")
437
+
438
+
439
+ def create_config_file(project_root: Path, config: GdToolsConfig) -> None:
440
+ """Create ``gd-tools.toml`` if it does not exist.
441
+
442
+ If the file already exists, it is preserved unchanged. If it
443
+ does not exist, the provided config is written via
444
+ :func:`save_config`.
445
+
446
+ Args:
447
+ project_root: Path to the Godot project root.
448
+ config: The configuration to write if the file is missing.
449
+ """
450
+ config_file = project_root / "gd-tools.toml"
451
+ if config_file.exists():
452
+ return
453
+ save_config(config, project_root)
454
+
455
+
456
+ def generate_lint_format_rcs(project_root: Path, config: GdToolsConfig) -> None:
457
+ """Generate ``gdlintrc`` and ``gdformatrc`` if missing, warn if differs.
458
+
459
+ For each file:
460
+ - If the file does not exist: generate it from the config's
461
+ exclude lists.
462
+ - If the file exists but differs from what init would produce:
463
+ print a warning. Do not overwrite.
464
+ - If the file exists and matches: do nothing.
465
+
466
+ Args:
467
+ project_root: Path to the Godot project root.
468
+ config: The configuration to read exclude lists from.
469
+ """
470
+ # gdlintrc — YAML set format (same as config.generate_gdlintrc)
471
+ expected_lint = yaml.dump(
472
+ {"excluded_directories": set(config.lint.exclude)},
473
+ default_flow_style=False,
474
+ sort_keys=True,
475
+ )
476
+ lint_file = project_root / "gdlintrc"
477
+ if not lint_file.exists():
478
+ lint_file.write_text(expected_lint, encoding="utf-8")
479
+ elif lint_file.read_text(encoding="utf-8") != expected_lint:
480
+ console.print(
481
+ "[yellow]Warning: gdlintrc differs from expected "
482
+ "content. Delete it and re-run 'gd-tools init' to "
483
+ "regenerate.[/yellow]"
484
+ )
485
+
486
+ # gdformatrc — one exclude per line (same as config.generate_gdformatrc)
487
+ expected_format = "\n".join(config.format.exclude) + "\n"
488
+ format_file = project_root / "gdformatrc"
489
+ if not format_file.exists():
490
+ format_file.write_text(expected_format, encoding="utf-8")
491
+ elif format_file.read_text(encoding="utf-8") != expected_format:
492
+ console.print(
493
+ "[yellow]Warning: gdformatrc differs from expected "
494
+ "content. Delete it and re-run 'gd-tools init' to "
495
+ "regenerate.[/yellow]"
496
+ )
497
+
498
+
499
+ # --- Phase 5: Data Directory, Summary, and Orchestration ---
500
+
501
+
502
+ def create_data_dir(project_root: Path) -> None:
503
+ """Create the ``.gd-tools/`` data directory and update ``.gitignore``.
504
+
505
+ Creates ``project_root/.gd-tools/`` if it does not exist (idempotent).
506
+ Appends ``.gd-tools/`` to ``project_root/.gitignore``, creating the
507
+ file if necessary. If ``.gd-tools/`` is already present in
508
+ ``.gitignore``, no duplicate is added.
509
+
510
+ Args:
511
+ project_root: Path to the Godot project root.
512
+ """
513
+ data_dir = project_root / ".gd-tools"
514
+ data_dir.mkdir(exist_ok=True)
515
+
516
+ gitignore = project_root / ".gitignore"
517
+ entry = ".gd-tools/"
518
+ if gitignore.exists():
519
+ lines = gitignore.read_text(encoding="utf-8").splitlines()
520
+ if entry not in lines:
521
+ with gitignore.open("a", encoding="utf-8") as f:
522
+ f.write(f"\n{entry}\n")
523
+ else:
524
+ gitignore.write_text(f"{entry}\n", encoding="utf-8")
525
+
526
+
527
+ def print_summary(project_root: Path, actions: list[str]) -> None:
528
+ """Print a Rich-formatted summary of init actions and next steps.
529
+
530
+ Lists all actions taken during initialization and prints guidance
531
+ on what the user should do next (e.g., run tests).
532
+
533
+ Args:
534
+ project_root: Path to the Godot project root.
535
+ actions: List of action descriptions to display.
536
+ """
537
+ console.print("\n[bold green]gd-tools init complete![/bold green]\n")
538
+ console.print("[bold]Actions taken:[/bold]")
539
+ for action in actions:
540
+ console.print(f" - {action}")
541
+ console.print("\n[bold]Next steps:[/bold]")
542
+ console.print(" - Run [cyan]gd-tools test[/cyan] to execute tests")
543
+ console.print(" - Run [cyan]gd-tools lint[/cyan] to check code style")
544
+ console.print(
545
+ " - Run [cyan]gd-tools format[/cyan] to format GDScript files"
546
+ )
547
+
548
+
549
+ def run_init(non_interactive: bool = False) -> None:
550
+ """Run the full init flow to bootstrap a Godot project.
551
+
552
+ Orchestrates all initialization steps:
553
+ 1. Detect project root
554
+ 2. Load or create config
555
+ 3. Detect Godot version
556
+ 4. Resolve GUT version
557
+ 5. Check if GUT is installed
558
+ 6. Install GUT if needed
559
+ 7. Enable GUT plugin in project.godot
560
+ 8. Deploy coverage addon
561
+ 9. Create/update .gutconfig.json
562
+ 10. Create gd-tools.toml if missing
563
+ 11. Generate gdlintrc and gdformatrc
564
+ 12. Create .gd-tools/ data directory
565
+ 13. Print summary
566
+
567
+ Args:
568
+ non_interactive: If True, skip all interactive prompts
569
+ and assume defaults.
570
+ """
571
+ project_root = find_project_root()
572
+ config = load_config(project_root)
573
+
574
+ actions: list[str] = []
575
+
576
+ godot_version = detect_godot_version(config)
577
+ gut_version = get_gut_version_for_godot(godot_version)
578
+
579
+ is_installed = check_gut_installed(project_root)
580
+ if is_installed:
581
+ installed = get_installed_gut_version(project_root)
582
+ if installed:
583
+ actions.append(f"GUT already installed (v{installed})")
584
+ else:
585
+ actions.append("GUT already installed (version unknown)")
586
+ else:
587
+ actions.append(f"Installing GUT v{gut_version}")
588
+
589
+ if not install_gut(
590
+ project_root, godot_version, non_interactive=non_interactive
591
+ ):
592
+ console.print(
593
+ "\n[yellow]Init aborted: GUT was not installed.[/yellow]\n"
594
+ "Install GUT manually, then re-run 'gd-tools init'."
595
+ )
596
+ sys.exit(0)
597
+
598
+ enable_gut_plugin(project_root)
599
+ actions.append("Enabled GUT plugin in project.godot")
600
+
601
+ install_coverage_addon(project_root)
602
+ actions.append("Deployed coverage addon")
603
+
604
+ register_coverage_autoload(project_root)
605
+ actions.append("Registered _GDTCoverage autoload")
606
+
607
+ update_gutconfig(project_root, config)
608
+ actions.append("Created/updated .gutconfig.json")
609
+
610
+ create_config_file(project_root, config)
611
+ actions.append("Ensured gd-tools.toml exists")
612
+
613
+ generate_lint_format_rcs(project_root, config)
614
+ actions.append("Generated gdlintrc and gdformatrc")
615
+
616
+ create_data_dir(project_root)
617
+ actions.append("Created .gd-tools/ directory")
618
+
619
+ print_summary(project_root, actions)