gd-tools-cli 0.1.4__tar.gz → 0.2.0__tar.gz

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.
Files changed (38) hide show
  1. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/PKG-INFO +5 -4
  2. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/README.md +3 -3
  3. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/pyproject.toml +3 -2
  4. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/addons/gd-tools-coverage/pre_run_hook.gd +8 -2
  5. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/cli.py +23 -7
  6. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/orchestrator.py +2 -0
  7. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/plan_generator.py +59 -0
  8. gd_tools_cli-0.2.0/src/gd_tools/file_discovery.py +69 -0
  9. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/format_runner.py +11 -4
  10. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/lint_runner.py +12 -4
  11. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/test_runner.py +9 -2
  12. gd_tools_cli-0.2.0/src/gd_tools/update_check.py +117 -0
  13. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools_cli.egg-info/PKG-INFO +5 -4
  14. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools_cli.egg-info/SOURCES.txt +1 -0
  15. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools_cli.egg-info/requires.txt +1 -0
  16. gd_tools_cli-0.1.4/src/gd_tools/file_discovery.py +0 -41
  17. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/setup.cfg +0 -0
  18. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/__init__.py +0 -0
  19. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/__main__.py +0 -0
  20. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/addons/__init__.py +0 -0
  21. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/addons/gd-tools-coverage/coverage.gd +0 -0
  22. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/addons/gd-tools-coverage/post_run_hook.gd +0 -0
  23. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/config.py +0 -0
  24. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/__init__.py +0 -0
  25. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/cobertura_reporter.py +0 -0
  26. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/html_reporter.py +0 -0
  27. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/lcov_reporter.py +0 -0
  28. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/reporter.py +0 -0
  29. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/templates/file.html +0 -0
  30. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/templates/index.html +0 -0
  31. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/coverage/terminal_reporter.py +0 -0
  32. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/doctor.py +0 -0
  33. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/errors.py +0 -0
  34. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/godot.py +0 -0
  35. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools/init.py +0 -0
  36. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools_cli.egg-info/dependency_links.txt +0 -0
  37. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools_cli.egg-info/entry_points.txt +0 -0
  38. {gd_tools_cli-0.1.4 → gd_tools_cli-0.2.0}/src/gd_tools_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gd-tools-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: A modern development workflow CLI for GDScript projects in Godot 4.5+
5
5
  Author-email: mansyar <m.ansyarafi@gmail.com>
6
6
  License: MIT
@@ -31,6 +31,7 @@ Requires-Dist: tomli; python_version < "3.11"
31
31
  Requires-Dist: tomli_w
32
32
  Requires-Dist: pydantic>=2.0
33
33
  Requires-Dist: requests
34
+ Requires-Dist: packaging
34
35
  Provides-Extra: dev
35
36
  Requires-Dist: pytest; extra == "dev"
36
37
  Requires-Dist: pytest-cov; extra == "dev"
@@ -108,9 +109,9 @@ gd-tools test --coverage --min 80
108
109
  |---------|-------------|
109
110
  | `gd-tools init` | Bootstrap a Godot project -- install GUT, deploy coverage addon, generate configs. |
110
111
  | `gd-tools doctor` | Diagnose the development environment -- Godot, GUT, coverage addon, tooling. |
111
- | `gd-tools test` | Run GUT tests with optional coverage, thresholds, JUnit XML output. |
112
- | `gd-tools lint` | Lint GDScript files using gdlint with text or JSON output. |
113
- | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. |
112
+ | `gd-tools test` | Run GUT tests with optional coverage, thresholds, JUnit XML output. Accepts optional path arguments to override configured test directories. |
113
+ | `gd-tools lint` | Lint GDScript files using gdlint with text or JSON output. Accepts one or more file or directory paths. |
114
+ | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. Accepts one or more file or directory paths. |
114
115
  | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`. |
115
116
 
116
117
  See the [User Guide](./docs/USER_GUIDE.md) for full command reference,
@@ -68,9 +68,9 @@ gd-tools test --coverage --min 80
68
68
  |---------|-------------|
69
69
  | `gd-tools init` | Bootstrap a Godot project -- install GUT, deploy coverage addon, generate configs. |
70
70
  | `gd-tools doctor` | Diagnose the development environment -- Godot, GUT, coverage addon, tooling. |
71
- | `gd-tools test` | Run GUT tests with optional coverage, thresholds, JUnit XML output. |
72
- | `gd-tools lint` | Lint GDScript files using gdlint with text or JSON output. |
73
- | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. |
71
+ | `gd-tools test` | Run GUT tests with optional coverage, thresholds, JUnit XML output. Accepts optional path arguments to override configured test directories. |
72
+ | `gd-tools lint` | Lint GDScript files using gdlint with text or JSON output. Accepts one or more file or directory paths. |
73
+ | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. Accepts one or more file or directory paths. |
74
74
  | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`. |
75
75
 
76
76
  See the [User Guide](./docs/USER_GUIDE.md) for full command reference,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gd-tools-cli"
7
- version = "0.1.4"
7
+ version = "0.2.0"
8
8
  description = "A modern development workflow CLI for GDScript projects in Godot 4.5+"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -36,6 +36,7 @@ dependencies = [
36
36
  "tomli_w",
37
37
  "pydantic >= 2.0",
38
38
  "requests",
39
+ "packaging",
39
40
  ]
40
41
 
41
42
  [project.urls]
@@ -98,7 +99,7 @@ target-version = ["py310"]
98
99
 
99
100
  [tool.commitizen]
100
101
  name = "cz_conventional_commits"
101
- version = "0.1.4"
102
+ version = "0.2.0"
102
103
  version_files = ["pyproject.toml:version"]
103
104
  changelog_file = "CHANGELOG.md"
104
105
  tag_format = "v$version"
@@ -59,16 +59,22 @@ func _instrument_file(file_entry: Dictionary) -> bool:
59
59
  )
60
60
  return false
61
61
 
62
- var source: String = script.source_code
63
- var instrumented: String = _inject_trackers(source, file_id, lines)
62
+ var original_source: String = script.source_code
63
+ var instrumented: String = _inject_trackers(original_source, file_id, lines)
64
64
  script.source_code = instrumented
65
65
  var err: int = script.reload()
66
+ if err == ERR_ALREADY_IN_USE:
67
+ print("[gd-tools] [Warning] Skipping instrumented script with active instances: " + path)
68
+ script.source_code = original_source
69
+ return false
66
70
  if err != OK:
67
71
  _log_error(
68
72
  "Failed to reload instrumented script.",
69
73
  "reload() failed for: " + path,
70
74
  "Check tracker injection logic for syntax errors."
71
75
  )
76
+ script.source_code = original_source
77
+ script.reload()
72
78
  return false
73
79
 
74
80
  return true
@@ -27,6 +27,7 @@ from .format_runner import run_format
27
27
  from .init import run_init
28
28
  from .lint_runner import format_lint_json, format_lint_text, run_lint
29
29
  from .test_runner import run_tests
30
+ from .update_check import check_for_update
30
31
 
31
32
 
32
33
  def _configure_windows_utf8() -> None:
@@ -63,6 +64,10 @@ class GdToolsGroup(click.Group):
63
64
  def invoke(self, ctx) -> Any:
64
65
  """Invoke the group, catching NotImplementedError as exit code 2.
65
66
 
67
+ Performs an update check before dispatching to the subcommand.
68
+ If a newer version is available, a notification is printed to
69
+ stderr. The check fails silently and never blocks execution.
70
+
66
71
  Args:
67
72
  ctx: The Click context for this invocation.
68
73
 
@@ -73,6 +78,14 @@ class GdToolsGroup(click.Group):
73
78
  SystemExit: With code 2 if a command raises
74
79
  NotImplementedError.
75
80
  """
81
+ latest = check_for_update()
82
+ if latest is not None:
83
+ click.echo(
84
+ f"A new version of gd-tools is available: {latest} "
85
+ f"(you have {__version__}).\n"
86
+ f"Run `pip install --upgrade gd-tools-cli` to update.",
87
+ err=True,
88
+ )
76
89
  try:
77
90
  return super().invoke(ctx)
78
91
  except NotImplementedError:
@@ -123,6 +136,7 @@ def doctor():
123
136
 
124
137
 
125
138
  @cli.command()
139
+ @click.argument("paths", nargs=-1)
126
140
  @click.option("--coverage", is_flag=True, help="Generate coverage report.")
127
141
  @click.option("--min", type=int, help="Minimum coverage threshold.")
128
142
  @click.option("--suite", help="Specify which test suite to run.")
@@ -138,7 +152,7 @@ def doctor():
138
152
  type=int,
139
153
  help="Timeout in seconds for the test run.",
140
154
  )
141
- def test(coverage, min, suite, test, junit_xml, no_exit_code, timeout):
155
+ def test(paths, coverage, min, suite, test, junit_xml, no_exit_code, timeout):
142
156
  """Run GDScript tests using GUT."""
143
157
  try:
144
158
  config = load_config()
@@ -164,6 +178,7 @@ def test(coverage, min, suite, test, junit_xml, no_exit_code, timeout):
164
178
  no_exit_code=no_exit_code,
165
179
  min_percent=min,
166
180
  timeout=timeout,
181
+ paths=list(paths) if paths else None,
167
182
  )
168
183
  else:
169
184
  run_tests(
@@ -175,6 +190,7 @@ def test(coverage, min, suite, test, junit_xml, no_exit_code, timeout):
175
190
  junit_xml=junit_xml,
176
191
  no_exit_code=no_exit_code,
177
192
  timeout=timeout,
193
+ paths=list(paths) if paths else None,
178
194
  )
179
195
  except TestFailureError as e:
180
196
  click.echo(f"Error: {e}", err=True)
@@ -190,7 +206,7 @@ def test(coverage, min, suite, test, junit_xml, no_exit_code, timeout):
190
206
 
191
207
 
192
208
  @cli.command()
193
- @click.argument("path", required=False, default=".")
209
+ @click.argument("paths", nargs=-1)
194
210
  @click.option(
195
211
  "--report-format",
196
212
  type=click.Choice(["text", "json"]),
@@ -202,7 +218,7 @@ def test(coverage, min, suite, test, junit_xml, no_exit_code, timeout):
202
218
  is_flag=True,
203
219
  help="Attempt to fix lint issues (no-op for gdlint).",
204
220
  )
205
- def lint(path, report_format, fix):
221
+ def lint(paths, report_format, fix):
206
222
  """Lint GDScript files."""
207
223
  if fix:
208
224
  click.echo(
@@ -216,7 +232,7 @@ def lint(path, report_format, fix):
216
232
  ctx = click.get_current_context()
217
233
  ctx.exit(2)
218
234
 
219
- result = run_lint(config, path, report_format)
235
+ result = run_lint(config, list(paths), report_format)
220
236
 
221
237
  if report_format == "json":
222
238
  output = format_lint_json(result)
@@ -232,10 +248,10 @@ def lint(path, report_format, fix):
232
248
 
233
249
 
234
250
  @cli.command()
235
- @click.argument("path", required=False, default=".")
251
+ @click.argument("paths", nargs=-1)
236
252
  @click.option("--check", is_flag=True, help="Check only, don't modify files.")
237
253
  @click.option("--diff", is_flag=True, help="Show diff of changes.")
238
- def format(path, check, diff):
254
+ def format(paths, check, diff):
239
255
  """Format GDScript files."""
240
256
  if check and diff:
241
257
  click.echo("Error: --check and --diff are mutually exclusive", err=True)
@@ -249,7 +265,7 @@ def format(path, check, diff):
249
265
  ctx = click.get_current_context()
250
266
  ctx.exit(2)
251
267
 
252
- result = run_format(config, path, check=check, diff=diff)
268
+ result = run_format(config, list(paths), check=check, diff=diff)
253
269
 
254
270
  ctx = click.get_current_context()
255
271
  if result.files_checked == 0:
@@ -36,6 +36,7 @@ def run_coverage_test(
36
36
  no_exit_code: bool = False,
37
37
  min_percent: int | None = None,
38
38
  timeout: int | None = None,
39
+ paths: list[str] | None = None,
39
40
  ) -> TestResult:
40
41
  """Run tests with coverage instrumentation and generate reports.
41
42
 
@@ -94,6 +95,7 @@ def run_coverage_test(
94
95
  junit_xml=junit_xml,
95
96
  no_exit_code=no_exit_code,
96
97
  timeout=timeout,
98
+ paths=paths,
97
99
  )
98
100
  except TestFailureError as exc:
99
101
  test_error = exc
@@ -306,6 +306,53 @@ class CoverageVisitor(Visitor):
306
306
  # --- Plan Generation (FR-4, FR-6) ---
307
307
 
308
308
 
309
+ def resolve_autoload_paths(project_root: str) -> list[str]:
310
+ """Read ``project.godot`` and resolve autoload script paths.
311
+
312
+ Parses the ``[autoload]`` section of ``project.godot`` and returns
313
+ a list of script paths relative to ``project_root`` (using forward
314
+ slashes, matching Godot's ``res://`` convention).
315
+
316
+ Args:
317
+ project_root: Root directory of the Godot project.
318
+
319
+ Returns:
320
+ List of relative paths for autoload scripts. Returns an empty
321
+ list if ``project.godot`` doesn't exist or has no ``[autoload]``
322
+ section.
323
+ """
324
+ project_file = Path(project_root) / "project.godot"
325
+ if not project_file.exists():
326
+ return []
327
+
328
+ lines = project_file.read_text(encoding="utf-8").splitlines()
329
+
330
+ in_autoload = False
331
+ paths: list[str] = []
332
+ for line in lines:
333
+ stripped = line.strip()
334
+ if stripped.startswith("[") and stripped.endswith("]"):
335
+ in_autoload = stripped == "[autoload]"
336
+ continue
337
+ if not in_autoload:
338
+ continue
339
+ if not stripped or stripped.startswith(";") or stripped.startswith("#"):
340
+ continue
341
+ if "=" not in stripped:
342
+ continue
343
+ # Parse key="value"
344
+ _, _, value = stripped.partition("=")
345
+ value = value.strip().strip('"').strip("'")
346
+ if value.startswith("*"):
347
+ value = value[1:]
348
+ if value.startswith("res://"):
349
+ value = value[len("res://") :]
350
+ if value:
351
+ paths.append(value.replace("\\", "/"))
352
+
353
+ return paths
354
+
355
+
309
356
  def generate_plan(
310
357
  project_root: str,
311
358
  exclude_dirs: list[str] | None = None,
@@ -345,6 +392,18 @@ def generate_plan(
345
392
  if not any(td in PurePath(f).parts for td in test_dirs)
346
393
  ]
347
394
 
395
+ # Filter out autoload scripts — they have active instances and
396
+ # cannot be safely instrumented (FR-2)
397
+ autoload_paths = resolve_autoload_paths(project_root)
398
+ if autoload_paths:
399
+ autoload_set = {ap.replace("\\", "/") for ap in autoload_paths}
400
+ gd_files = [
401
+ f
402
+ for f in gd_files
403
+ if str(Path(f).relative_to(project_root)).replace("\\", "/")
404
+ not in autoload_set
405
+ ]
406
+
348
407
  file_plans: list[FilePlan] = []
349
408
  console = Console()
350
409
  for file_id, gd_file in enumerate(gd_files):
@@ -0,0 +1,69 @@
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
+ Exclude entries are interpreted with hybrid matching:
19
+
20
+ - **Bare names** (no path separator, e.g. ``addons``, ``.godot``)
21
+ match any directory *basename* at any depth (backward compatible
22
+ with :data:`DEFAULT_EXCLUDES`).
23
+ - **Path prefixes** (containing ``/`` or ``\\``, e.g.
24
+ ``scripts/autoload``) match as a path prefix relative to the
25
+ search root — any file whose relative path starts with the
26
+ prefix is excluded.
27
+
28
+ Args:
29
+ path: Root directory to search.
30
+ excludes: Exclude entries (bare names or path prefixes).
31
+ Defaults to :data:`DEFAULT_EXCLUDES` from ``config.py``.
32
+
33
+ Returns:
34
+ List of file paths (as strings) to ``.gd`` files.
35
+ """
36
+ if excludes is None:
37
+ excludes = DEFAULT_EXCLUDES
38
+
39
+ bare_excludes: set[str] = set()
40
+ path_excludes: list[str] = []
41
+ for entry in excludes:
42
+ if "/" in entry or "\\" in entry:
43
+ # Normalize separators to the OS-native form
44
+ path_excludes.append(
45
+ entry.replace("\\", os.sep).replace("/", os.sep)
46
+ )
47
+ else:
48
+ bare_excludes.add(entry)
49
+
50
+ gd_files: list[str] = []
51
+ for root, dirs, files in os.walk(path):
52
+ # Prune basename-excluded dirs in-place so os.walk doesn't descend
53
+ dirs[:] = [d for d in dirs if d not in bare_excludes]
54
+ for file in files:
55
+ if file.lower().endswith(".gd"):
56
+ file_path = os.path.join(root, file)
57
+ if path_excludes:
58
+ rel_path = os.path.relpath(file_path, path)
59
+ normalized = rel_path.replace("\\", os.sep).replace(
60
+ "/", os.sep
61
+ )
62
+ if any(
63
+ normalized == prefix
64
+ or normalized.startswith(prefix + os.sep)
65
+ for prefix in path_excludes
66
+ ):
67
+ continue
68
+ gd_files.append(file_path)
69
+ return gd_files
@@ -46,15 +46,16 @@ class FormatResult:
46
46
 
47
47
  def run_format(
48
48
  config: GdToolsConfig,
49
- path: str = ".",
49
+ paths: list[str] | None = None,
50
50
  check: bool = False,
51
51
  diff: bool = False,
52
52
  ) -> FormatResult:
53
- """Format ``.gd`` files in ``path`` using the gdtoolkit formatter.
53
+ """Format ``.gd`` files in ``paths`` using the gdtoolkit formatter.
54
54
 
55
55
  Args:
56
56
  config: Project configuration with format excludes.
57
- path: Root directory to search for .gd files.
57
+ paths: Root directories to search for .gd files. Defaults to
58
+ ``["."]``.
58
59
  check: If True, report files needing format without modifying.
59
60
  diff: If True, show unified diffs without modifying.
60
61
 
@@ -64,6 +65,9 @@ def run_format(
64
65
  Raises:
65
66
  FormatError: If both check and diff are True (mutually exclusive).
66
67
  """
68
+ if not paths:
69
+ paths = ["."]
70
+
67
71
  if check and diff:
68
72
  raise FormatError(
69
73
  "--check and --diff are mutually exclusive",
@@ -71,7 +75,10 @@ def run_format(
71
75
  )
72
76
 
73
77
  excludes = config.format.exclude
74
- gd_files = discover_gd_files(path, excludes)
78
+ gd_files: list[str] = []
79
+ for p in paths:
80
+ gd_files.extend(discover_gd_files(p, excludes))
81
+ gd_files = list(dict.fromkeys(gd_files))
75
82
 
76
83
  if not gd_files:
77
84
  return FormatResult()
@@ -57,9 +57,11 @@ class LintResult:
57
57
 
58
58
 
59
59
  def run_lint(
60
- config: GdToolsConfig, path: str = ".", report_format: str = "text"
60
+ config: GdToolsConfig,
61
+ paths: list[str] | None = None,
62
+ report_format: str = "text",
61
63
  ) -> LintResult:
62
- """Run gdlint on ``path``, respecting config excludes.
64
+ """Run gdlint on ``paths``, respecting config excludes.
63
65
 
64
66
  Discovers ``.gd`` files via :func:`discover_gd_files`, reads each
65
67
  file, and invokes ``gdtoolkit.linter.lint_code`` to check for
@@ -68,7 +70,7 @@ def run_lint(
68
70
 
69
71
  Args:
70
72
  config: Project configuration with lint excludes.
71
- path: Root directory to lint.
73
+ paths: Root directories to lint. Defaults to ``["."]``.
72
74
  report_format: Output format hint (unused here; formatting
73
75
  is handled by :func:`format_lint_text` /
74
76
  :func:`format_lint_json`).
@@ -76,8 +78,14 @@ def run_lint(
76
78
  Returns:
77
79
  :class:`LintResult` with file count and issue lists.
78
80
  """
81
+ if not paths:
82
+ paths = ["."]
83
+
79
84
  excludes = config.lint.exclude
80
- gd_files = discover_gd_files(path, excludes)
85
+ gd_files: list[str] = []
86
+ for p in paths:
87
+ gd_files.extend(discover_gd_files(p, excludes))
88
+ gd_files = list(dict.fromkeys(gd_files))
81
89
 
82
90
  errors: list[LintIssue] = []
83
91
  warnings: list[LintIssue] = []
@@ -84,6 +84,7 @@ def build_gut_args(
84
84
  test_name: str | None = None,
85
85
  junit_xml: str | None = None,
86
86
  coverage: bool = False,
87
+ paths: list[str] | None = None,
87
88
  ) -> list[str]:
88
89
  """Build GUT CLI arguments from test configuration.
89
90
 
@@ -101,6 +102,8 @@ def build_gut_args(
101
102
  ``<project_root>/.gd-tools/results.xml``.
102
103
  coverage: Whether coverage hooks should be included. When True,
103
104
  adds ``-gpre_run_script`` and ``-gpost_run_script`` args.
105
+ paths: Optional test directories that override
106
+ ``config.test_dirs`` for this invocation.
104
107
 
105
108
  Returns:
106
109
  List of CLI arguments for Godot/GUT.
@@ -113,8 +116,10 @@ def build_gut_args(
113
116
  ]
114
117
 
115
118
  # Test directories (comma-separated per GUT 9.x CLI spec).
116
- if config.test_dirs:
117
- dirs = ",".join(f"res://{d}/" for d in config.test_dirs)
119
+ # Use paths override if provided, otherwise fall back to config.
120
+ test_dirs = paths if paths else config.test_dirs
121
+ if test_dirs:
122
+ dirs = ",".join(f"res://{d}/" for d in test_dirs)
118
123
  args.append(f"-gdir={dirs}")
119
124
 
120
125
  # Prefix/suffix for test file discovery.
@@ -320,6 +325,7 @@ def run_tests(
320
325
  junit_xml: str | None = None,
321
326
  no_exit_code: bool = False,
322
327
  timeout: int | None = 300,
328
+ paths: list[str] | None = None,
323
329
  ) -> TestResult:
324
330
  """Run GUT tests via the Godot CLI.
325
331
 
@@ -393,6 +399,7 @@ def run_tests(
393
399
  test_name=test_name,
394
400
  junit_xml=junit_xml,
395
401
  coverage=coverage,
402
+ paths=paths,
396
403
  )
397
404
 
398
405
  # Set up environment variables for coverage.
@@ -0,0 +1,117 @@
1
+ """PyPI update notification module.
2
+
3
+ Checks PyPI for a newer version of ``gd-tools-cli`` and caches the
4
+ result locally (24h TTL) to avoid network calls on every invocation.
5
+ Fails silently on any error and can be disabled via the
6
+ ``GD_TOOLS_NO_UPDATE_CHECK`` environment variable.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import requests
16
+ from packaging.version import parse as parse_version
17
+
18
+ from gd_tools import __version__
19
+
20
+ PYPI_URL = "https://pypi.org/pypi/gd-tools-cli/json"
21
+ REQUEST_TIMEOUT = 3
22
+ CACHE_TTL_HOURS = 24
23
+ CACHE_DIR = Path.home() / ".gd-tools"
24
+ CACHE_FILENAME = "update-check.json"
25
+
26
+
27
+ def check_for_update() -> Optional[str]:
28
+ """Check PyPI for a newer version of gd-tools-cli.
29
+
30
+ Returns the latest version string if an update is available,
31
+ or ``None`` otherwise. The check is cached for 24 hours and
32
+ fails silently on any error.
33
+
34
+ Returns:
35
+ The latest version string from PyPI if an update is
36
+ available, or ``None``.
37
+ """
38
+ # FR4.1: Environment variable disables check entirely.
39
+ if os.environ.get("GD_TOOLS_NO_UPDATE_CHECK") == "1":
40
+ return None
41
+
42
+ # FR1.5: Skip dev install (editable install without metadata).
43
+ if __version__ == "0.0.0":
44
+ return None
45
+
46
+ # Check cache first; fall back to a fresh PyPI request.
47
+ latest_version = _read_cached_version()
48
+ if latest_version is None:
49
+ latest_version = _fetch_latest_version()
50
+ if latest_version is not None:
51
+ _write_cache(latest_version)
52
+
53
+ if latest_version is None:
54
+ return None
55
+
56
+ # FR1.4: Compare versions using packaging.version.parse().
57
+ try:
58
+ is_newer = parse_version(latest_version) > parse_version(__version__)
59
+ except (TypeError, ValueError):
60
+ return None
61
+ if is_newer:
62
+ return latest_version
63
+ return None
64
+
65
+
66
+ def _read_cached_version() -> Optional[str]:
67
+ """Read the cached latest version if the cache is fresh (< 24h).
68
+
69
+ Returns ``None`` on cache miss, stale cache, or corrupt cache
70
+ file (FR3.3 — corrupt cache is treated as a cache miss).
71
+ """
72
+ cache_file = CACHE_DIR / CACHE_FILENAME
73
+ if not cache_file.exists():
74
+ return None
75
+ try:
76
+ data = json.loads(cache_file.read_text(encoding="utf-8"))
77
+ last_check = datetime.fromisoformat(data["last_check"])
78
+ if last_check.tzinfo is None:
79
+ last_check = last_check.replace(tzinfo=timezone.utc)
80
+ age = datetime.now(timezone.utc) - last_check
81
+ if age < timedelta(hours=CACHE_TTL_HOURS):
82
+ return data["latest_version"]
83
+ return None
84
+ except (ValueError, KeyError, OSError, TypeError):
85
+ return None
86
+
87
+
88
+ def _fetch_latest_version() -> Optional[str]:
89
+ """Fetch the latest version from the PyPI JSON API.
90
+
91
+ Returns ``None`` on any network or parsing error (FR3.1, FR3.2).
92
+ """
93
+ try:
94
+ response = requests.get(PYPI_URL, timeout=REQUEST_TIMEOUT)
95
+ data = response.json()
96
+ return data["info"]["version"]
97
+ except (requests.RequestException, ValueError, KeyError):
98
+ return None
99
+
100
+
101
+ def _write_cache(version: str) -> None:
102
+ """Write the cache file with the current timestamp and version.
103
+
104
+ Creates the cache directory if it does not exist (FR2.5).
105
+ Silently ignores write errors — the main functionality is
106
+ unaffected if the cache cannot be persisted.
107
+ """
108
+ try:
109
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
110
+ cache_file = CACHE_DIR / CACHE_FILENAME
111
+ entry = {
112
+ "last_check": datetime.now(timezone.utc).isoformat(),
113
+ "latest_version": version,
114
+ }
115
+ cache_file.write_text(json.dumps(entry), encoding="utf-8")
116
+ except OSError:
117
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gd-tools-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: A modern development workflow CLI for GDScript projects in Godot 4.5+
5
5
  Author-email: mansyar <m.ansyarafi@gmail.com>
6
6
  License: MIT
@@ -31,6 +31,7 @@ Requires-Dist: tomli; python_version < "3.11"
31
31
  Requires-Dist: tomli_w
32
32
  Requires-Dist: pydantic>=2.0
33
33
  Requires-Dist: requests
34
+ Requires-Dist: packaging
34
35
  Provides-Extra: dev
35
36
  Requires-Dist: pytest; extra == "dev"
36
37
  Requires-Dist: pytest-cov; extra == "dev"
@@ -108,9 +109,9 @@ gd-tools test --coverage --min 80
108
109
  |---------|-------------|
109
110
  | `gd-tools init` | Bootstrap a Godot project -- install GUT, deploy coverage addon, generate configs. |
110
111
  | `gd-tools doctor` | Diagnose the development environment -- Godot, GUT, coverage addon, tooling. |
111
- | `gd-tools test` | Run GUT tests with optional coverage, thresholds, JUnit XML output. |
112
- | `gd-tools lint` | Lint GDScript files using gdlint with text or JSON output. |
113
- | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. |
112
+ | `gd-tools test` | Run GUT tests with optional coverage, thresholds, JUnit XML output. Accepts optional path arguments to override configured test directories. |
113
+ | `gd-tools lint` | Lint GDScript files using gdlint with text or JSON output. Accepts one or more file or directory paths. |
114
+ | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. Accepts one or more file or directory paths. |
114
115
  | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`. |
115
116
 
116
117
  See the [User Guide](./docs/USER_GUIDE.md) for full command reference,
@@ -12,6 +12,7 @@ src/gd_tools/godot.py
12
12
  src/gd_tools/init.py
13
13
  src/gd_tools/lint_runner.py
14
14
  src/gd_tools/test_runner.py
15
+ src/gd_tools/update_check.py
15
16
  src/gd_tools/addons/__init__.py
16
17
  src/gd_tools/addons/gd-tools-coverage/coverage.gd
17
18
  src/gd_tools/addons/gd-tools-coverage/post_run_hook.gd
@@ -7,6 +7,7 @@ pyyaml
7
7
  tomli_w
8
8
  pydantic>=2.0
9
9
  requests
10
+ packaging
10
11
 
11
12
  [:python_version < "3.11"]
12
13
  tomli
@@ -1,41 +0,0 @@
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
File without changes