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,3 @@
1
+ """gd-tools: A modern development workflow CLI for GDScript projects."""
2
+
3
+ __version__ = "0.1.0"
gd_tools/__main__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Module entry point for gd-tools.
2
+
3
+ Allows running gd-tools via ``python -m gd_tools``.
4
+ """
5
+
6
+ import sys
7
+
8
+ from .cli import cli
9
+ from .errors import GdToolsError
10
+
11
+
12
+ def main() -> None:
13
+ """Run the gd-tools CLI.
14
+
15
+ Calls the Click CLI group and catches any :class:`GdToolsError`,
16
+ printing the error message to stderr and exiting with the
17
+ exception's ``exit_code``.
18
+
19
+ Raises:
20
+ SystemExit: When the CLI exits or a :class:`GdToolsError` is
21
+ caught.
22
+ """
23
+ try:
24
+ cli()
25
+ except GdToolsError as e:
26
+ print(str(e), file=sys.stderr)
27
+ sys.exit(e.exit_code)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
File without changes
@@ -0,0 +1,43 @@
1
+ extends Node
2
+
3
+ # Coverage tracker autoload for gd-tools.
4
+ # Records line hit counts during GUT test execution.
5
+ # Activated via the GD_TOOLS_COVERAGE_ACTIVE environment variable.
6
+
7
+ var _hits: Dictionary = {}
8
+
9
+ var _active: bool = false
10
+
11
+
12
+ func _ready() -> void:
13
+ # Active only when GD_TOOLS_COVERAGE_ACTIVE is "1" or "true".
14
+ var env_val: String = OS.get_environment("GD_TOOLS_COVERAGE_ACTIVE")
15
+ env_val = env_val.to_lower()
16
+ _active = env_val == "1" or env_val == "true"
17
+
18
+
19
+ func hit(file_id: int, line_id: int) -> void:
20
+ # Single bool check for minimal overhead when inactive.
21
+ if not _active:
22
+ return
23
+ if not _hits.has(file_id):
24
+ _hits[file_id] = {}
25
+ if not _hits[file_id].has(line_id):
26
+ _hits[file_id][line_id] = 0
27
+ _hits[file_id][line_id] += 1
28
+
29
+
30
+ func get_hits() -> Dictionary:
31
+ return _hits
32
+
33
+
34
+ func reset() -> void:
35
+ _hits.clear()
36
+
37
+
38
+ func set_active(active: bool) -> void:
39
+ _active = active
40
+
41
+
42
+ func is_active() -> bool:
43
+ return _active
@@ -0,0 +1,113 @@
1
+ extends GutHookScript
2
+
3
+ ## Post-run hook for GUT test runner.
4
+ ## Collects coverage data from the _GDTCoverage tracker and
5
+ ## writes it to a JSON file after tests have executed.
6
+
7
+ const TRACKER_NAME = "_GDTCoverage"
8
+
9
+
10
+ func run() -> void:
11
+ var tracker = _get_tracker()
12
+ if tracker == null:
13
+ return
14
+
15
+ if not tracker.is_active():
16
+ return
17
+
18
+ var hits: Dictionary = tracker.get_hits()
19
+ var data: Dictionary = _build_coverage_json(hits)
20
+
21
+ var output_path: String = OS.get_environment("GD_TOOLS_COVERAGE_OUTPUT")
22
+ if output_path.is_empty():
23
+ _log_error(
24
+ "Cannot write coverage output.",
25
+ "GD_TOOLS_COVERAGE_OUTPUT environment variable is not set.",
26
+ "Set GD_TOOLS_COVERAGE_OUTPUT to a writable file path."
27
+ )
28
+ return
29
+
30
+ if not _write_json(output_path, data):
31
+ return
32
+
33
+ _log_summary(hits, output_path)
34
+
35
+
36
+ func _get_tracker() -> Node:
37
+ var tree = Engine.get_main_loop() as SceneTree
38
+ if tree == null:
39
+ _log_error(
40
+ "Cannot access coverage tracker.",
41
+ "SceneTree is not available.",
42
+ "Ensure the hook runs in a Godot project context."
43
+ )
44
+ return null
45
+
46
+ var tracker = tree.root.get_node_or_null(TRACKER_NAME)
47
+ if tracker == null:
48
+ _log_error(
49
+ "Cannot access coverage tracker.",
50
+ "Autoload '" + TRACKER_NAME + "' not found.",
51
+ "Ensure the coverage addon is installed and registered as an autoload."
52
+ )
53
+ return null
54
+
55
+ return tracker
56
+
57
+
58
+ func _build_coverage_json(hits: Dictionary) -> Dictionary:
59
+ var files: Array = []
60
+ for file_id in hits:
61
+ var file_hits: Dictionary = hits[file_id]
62
+ var hits_dict: Dictionary = {}
63
+ for line_id in file_hits:
64
+ hits_dict[str(line_id)] = file_hits[line_id]
65
+ files.append({"file_id": int(file_id), "hits": hits_dict})
66
+ return {
67
+ "version": 1,
68
+ "generated_at": Time.get_datetime_string_from_system(true, false) + "Z",
69
+ "files": files
70
+ }
71
+
72
+
73
+ func _write_json(path: String, data: Dictionary) -> bool:
74
+ var dir_path: String = path.get_base_dir()
75
+ if not dir_path.is_empty() and not DirAccess.dir_exists_absolute(dir_path):
76
+ var err: int = DirAccess.make_dir_recursive_absolute(dir_path)
77
+ if err != OK:
78
+ _log_error(
79
+ "Cannot create output directory.",
80
+ "Failed to create directory: " + dir_path,
81
+ "Check permissions and path validity."
82
+ )
83
+ return false
84
+
85
+ var file = FileAccess.open(path, FileAccess.WRITE)
86
+ if file == null:
87
+ _log_error(
88
+ "Cannot write coverage output.",
89
+ "Cannot open file for writing: " + path,
90
+ "Check file permissions and path validity."
91
+ )
92
+ return false
93
+
94
+ file.store_string(JSON.stringify(data, " "))
95
+ file = null
96
+ return true
97
+
98
+
99
+ func _log_summary(hits: Dictionary, output_path: String) -> String:
100
+ var total_files: int = hits.size()
101
+ var total_lines: int = 0
102
+ for file_id in hits:
103
+ total_lines += hits[file_id].size()
104
+ var summary: String = (
105
+ "[gd-tools] Coverage summary: %d files, %d lines tracked, output: %s"
106
+ % [total_files, total_lines, output_path]
107
+ )
108
+ print(summary)
109
+ return summary
110
+
111
+
112
+ func _log_error(what: String, cause: String, fix: String) -> void:
113
+ push_error("[gd-tools] [Error] " + what + "\n\n" + " Cause: " + cause + "\n" + " Fix: " + fix)
@@ -0,0 +1,229 @@
1
+ extends GutHookScript
2
+
3
+ ## Pre-run hook for GUT test runner.
4
+ ## Instruments GDScript source files with coverage tracking calls
5
+ ## before tests are executed.
6
+
7
+ const TRACKER_NAME = "_GDTCoverage"
8
+
9
+ var _plan: Dictionary = {}
10
+
11
+
12
+ func run() -> void:
13
+ var plan_path: String = OS.get_environment("GD_TOOLS_COVERAGE_PLAN")
14
+ if plan_path.is_empty():
15
+ print("[gd-tools] [Warning] GD_TOOLS_COVERAGE_PLAN not set. Skipping instrumentation.")
16
+ return
17
+
18
+ _plan = _load_plan(plan_path)
19
+ if _plan.is_empty():
20
+ return
21
+
22
+ if not _validate_plan(_plan):
23
+ _plan = {}
24
+ return
25
+
26
+ var files: Array = _plan.get("files", [])
27
+ if files.is_empty():
28
+ print("[gd-tools] [Warning] Coverage plan has no files to instrument.")
29
+ return
30
+
31
+ var instrumented_count: int = _instrument_files()
32
+ if instrumented_count > 0:
33
+ _activate_tracker()
34
+
35
+
36
+ func _instrument_files() -> int:
37
+ var files: Array = _plan.get("files", [])
38
+ var count: int = 0
39
+ for file_entry in files:
40
+ if _instrument_file(file_entry):
41
+ count += 1
42
+ return count
43
+
44
+
45
+ func _instrument_file(file_entry: Dictionary) -> bool:
46
+ var path: String = file_entry.get("path", "")
47
+ var file_id: int = int(file_entry.get("file_id", -1))
48
+ var lines: Array = file_entry.get("lines", [])
49
+
50
+ if lines.is_empty():
51
+ return false
52
+
53
+ var script = load(path) as GDScript
54
+ if script == null:
55
+ _log_error(
56
+ "Failed to instrument script.",
57
+ "Cannot load script: " + path,
58
+ "Verify the path in the plan exists and compiles."
59
+ )
60
+ return false
61
+
62
+ var source: String = script.source_code
63
+ var instrumented: String = _inject_trackers(source, file_id, lines)
64
+ script.source_code = instrumented
65
+ var err: int = script.reload()
66
+ if err != OK:
67
+ _log_error(
68
+ "Failed to reload instrumented script.",
69
+ "reload() failed for: " + path,
70
+ "Check tracker injection logic for syntax errors."
71
+ )
72
+ return false
73
+
74
+ return true
75
+
76
+
77
+ func _activate_tracker() -> void:
78
+ var tree = Engine.get_main_loop() as SceneTree
79
+ if tree == null:
80
+ _log_error(
81
+ "Failed to activate coverage tracker.",
82
+ "Cannot access SceneTree.",
83
+ "Ensure the hook runs in a Godot project context."
84
+ )
85
+ return
86
+
87
+ var tracker = tree.root.get_node_or_null(TRACKER_NAME)
88
+ if tracker == null:
89
+ _log_error(
90
+ "Failed to activate coverage tracker.",
91
+ "Autoload '" + TRACKER_NAME + "' not found.",
92
+ "Ensure the coverage addon is installed and registered as an autoload."
93
+ )
94
+ return
95
+
96
+ tracker.set_active(true)
97
+
98
+
99
+ static func _extract_indent(line: String) -> String:
100
+ var indent: String = ""
101
+ for c in line:
102
+ if c == " " or c == "\t":
103
+ indent += c
104
+ else:
105
+ break
106
+ return indent
107
+
108
+
109
+ static func _inject_trackers(source: String, file_id: int, lines: Array) -> String:
110
+ var source_lines: PackedStringArray = source.split("\n")
111
+ var sorted_lines: Array = lines.duplicate(true)
112
+ sorted_lines.sort_custom(func(a, b): return int(a["line"]) > int(b["line"]))
113
+ for entry in sorted_lines:
114
+ var target_line_num: int = int(entry["line"])
115
+ var line_id: int = int(entry["id"])
116
+ var target_index: int = target_line_num - 1
117
+ if target_index < 0 or target_index >= source_lines.size():
118
+ continue
119
+ var target_line: String = source_lines[target_index]
120
+ var indent: String = _extract_indent(target_line)
121
+ var tracker_call: String = "%s%s.hit(%d, %d)" % [indent, TRACKER_NAME, file_id, line_id]
122
+ source_lines.insert(target_index, tracker_call)
123
+ return "\n".join(source_lines)
124
+
125
+
126
+ func _load_plan(path: String) -> Dictionary:
127
+ if not FileAccess.file_exists(path):
128
+ _log_error(
129
+ "Failed to load coverage plan.",
130
+ "File not found: " + path,
131
+ "Ensure GD_TOOLS_COVERAGE_PLAN points to a valid plan JSON file."
132
+ )
133
+ return {}
134
+
135
+ var f = FileAccess.open(path, FileAccess.READ)
136
+ if f == null:
137
+ _log_error(
138
+ "Failed to load coverage plan.",
139
+ "Cannot open file: " + path,
140
+ "Check file permissions and try again."
141
+ )
142
+ return {}
143
+
144
+ var content: String = f.get_as_text()
145
+ f = null
146
+
147
+ var parsed = JSON.parse_string(content)
148
+ if not (parsed is Dictionary):
149
+ _log_error(
150
+ "Failed to parse coverage plan JSON.",
151
+ "Invalid JSON in file: " + path,
152
+ "Validate the JSON syntax and try again."
153
+ )
154
+ return {}
155
+
156
+ return parsed
157
+
158
+
159
+ func _validate_plan(plan: Dictionary) -> bool:
160
+ if not plan.has("version"):
161
+ _log_error(
162
+ "Invalid coverage plan structure.",
163
+ "Missing 'version' key.",
164
+ "Ensure the plan JSON has a 'version' field."
165
+ )
166
+ return false
167
+
168
+ if not plan.has("files") or not (plan["files"] is Array):
169
+ _log_error(
170
+ "Invalid coverage plan structure.",
171
+ "Missing or invalid 'files' key.",
172
+ "Ensure the plan JSON has a 'files' array."
173
+ )
174
+ return false
175
+
176
+ for file_entry in plan["files"]:
177
+ if not _validate_file_entry(file_entry):
178
+ return false
179
+
180
+ return true
181
+
182
+
183
+ func _validate_file_entry(file_entry: Variant) -> bool:
184
+ if not (file_entry is Dictionary):
185
+ _log_error(
186
+ "Invalid coverage plan structure.",
187
+ "File entry is not an object.",
188
+ "Ensure each file in 'files' is a JSON object."
189
+ )
190
+ return false
191
+
192
+ if not file_entry.has("file_id"):
193
+ _log_error(
194
+ "Invalid coverage plan structure.",
195
+ "File entry missing 'file_id'.",
196
+ "Ensure each file has a 'file_id' field."
197
+ )
198
+ return false
199
+
200
+ if not file_entry.has("path"):
201
+ _log_error(
202
+ "Invalid coverage plan structure.",
203
+ "File entry missing 'path'.",
204
+ "Ensure each file has a 'path' field."
205
+ )
206
+ return false
207
+
208
+ if not file_entry.has("lines") or not (file_entry["lines"] is Array):
209
+ _log_error(
210
+ "Invalid coverage plan structure.",
211
+ "Missing or invalid 'lines' key.",
212
+ "Ensure each file has a 'lines' array."
213
+ )
214
+ return false
215
+
216
+ for line_entry in file_entry["lines"]:
217
+ if not (line_entry is Dictionary) or not line_entry.has("line") or not line_entry.has("id"):
218
+ _log_error(
219
+ "Invalid coverage plan structure.",
220
+ "Line entry missing 'line' or 'id' key.",
221
+ "Ensure each line entry has 'line' and 'id' fields."
222
+ )
223
+ return false
224
+
225
+ return true
226
+
227
+
228
+ func _log_error(what: String, cause: String, fix: String) -> void:
229
+ push_error("[gd-tools] [Error] " + what + "\n\n" + " Cause: " + cause + "\n" + " Fix: " + fix)