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 +3 -0
- gd_tools/__main__.py +31 -0
- gd_tools/addons/__init__.py +0 -0
- gd_tools/addons/gd-tools-coverage/coverage.gd +43 -0
- gd_tools/addons/gd-tools-coverage/post_run_hook.gd +113 -0
- gd_tools/addons/gd-tools-coverage/pre_run_hook.gd +229 -0
- gd_tools/cli.py +329 -0
- gd_tools/config.py +289 -0
- gd_tools/coverage/__init__.py +23 -0
- gd_tools/coverage/cobertura_reporter.py +136 -0
- gd_tools/coverage/html_reporter.py +95 -0
- gd_tools/coverage/lcov_reporter.py +90 -0
- gd_tools/coverage/orchestrator.py +282 -0
- gd_tools/coverage/plan_generator.py +377 -0
- gd_tools/coverage/reporter.py +518 -0
- gd_tools/coverage/templates/file.html +72 -0
- gd_tools/coverage/templates/index.html +90 -0
- gd_tools/coverage/terminal_reporter.py +107 -0
- gd_tools/doctor.py +495 -0
- gd_tools/errors.py +79 -0
- gd_tools/file_discovery.py +41 -0
- gd_tools/format_runner.py +118 -0
- gd_tools/godot.py +346 -0
- gd_tools/init.py +619 -0
- gd_tools/lint_runner.py +223 -0
- gd_tools/test_runner.py +474 -0
- gd_tools_cli-0.1.0.dist-info/METADATA +191 -0
- gd_tools_cli-0.1.0.dist-info/RECORD +31 -0
- gd_tools_cli-0.1.0.dist-info/WHEEL +5 -0
- gd_tools_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gd_tools_cli-0.1.0.dist-info/top_level.txt +1 -0
gd_tools/__init__.py
ADDED
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)
|