kdk 2.0.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.
- kdk/__init__.py +3 -0
- kdk/__main__.py +202 -0
- kdk/config.py +285 -0
- kdk/core.py +166 -0
- kdk/data/icon.ico +0 -0
- kdk/data/kodi/omega/colors.xml +153 -0
- kdk/data/kodi/omega/strings.po +24365 -0
- kdk/data/kodi/piers/colors.xml +153 -0
- kdk/data/kodi/piers/strings.po +25115 -0
- kdk/data/kodi_builtin_controls.xml +438 -0
- kdk/data/kodi_infolabel_functions.json +78 -0
- kdk/data/omega/controls.xml +569 -0
- kdk/data/omega/data.xml +1090 -0
- kdk/data/omega/windows.json +126 -0
- kdk/data/piers/controls.xml +569 -0
- kdk/data/piers/data.xml +1090 -0
- kdk/data/piers/windows.json +122 -0
- kdk/gui/__init__.py +0 -0
- kdk/gui/__main__.py +14 -0
- kdk/gui/app.py +463 -0
- kdk/libs/__init__.py +0 -0
- kdk/libs/addon/__init__.py +5 -0
- kdk/libs/addon/addon.py +304 -0
- kdk/libs/addon/translations.py +59 -0
- kdk/libs/infoprovider/__init__.py +3 -0
- kdk/libs/infoprovider/checker.py +717 -0
- kdk/libs/infoprovider/loader.py +135 -0
- kdk/libs/infoprovider/provider.py +21 -0
- kdk/libs/kodi_refs.py +88 -0
- kdk/libs/polib/__init__.py +1 -0
- kdk/libs/polib/polib.py +1893 -0
- kdk/libs/reporting/__init__.py +1 -0
- kdk/libs/reporting/text.py +111 -0
- kdk/libs/skin/__init__.py +16 -0
- kdk/libs/skin/include.py +120 -0
- kdk/libs/skin/index.py +1035 -0
- kdk/libs/skin/maps.py +36 -0
- kdk/libs/skin/resolution.py +375 -0
- kdk/libs/skin/resources.py +188 -0
- kdk/libs/skin/skin.py +783 -0
- kdk/libs/skin/textures.py +35 -0
- kdk/libs/utils/__init__.py +142 -0
- kdk/libs/utils/colors.py +36 -0
- kdk/libs/utils/debug.py +28 -0
- kdk/libs/utils/expressions.py +198 -0
- kdk/libs/utils/files.py +186 -0
- kdk/libs/utils/images.py +216 -0
- kdk/libs/utils/infobool.py +137 -0
- kdk/libs/utils/validation.py +354 -0
- kdk/libs/utils/xml.py +309 -0
- kdk/libs/validation/__init__.py +67 -0
- kdk/libs/validation/constants.py +141 -0
- kdk/libs/validation/expression.py +1012 -0
- kdk/libs/validation/filecheck.py +78 -0
- kdk/libs/validation/font.py +387 -0
- kdk/libs/validation/hierarchy.py +119 -0
- kdk/libs/validation/ids.py +330 -0
- kdk/libs/validation/image.py +118 -0
- kdk/libs/validation/include.py +381 -0
- kdk/libs/validation/interpreter.py +517 -0
- kdk/libs/validation/label.py +418 -0
- kdk/libs/validation/suppress.py +118 -0
- kdk/libs/validation/variable.py +233 -0
- kdk/output/__init__.py +33 -0
- kdk/output/browse.py +47 -0
- kdk/output/cache.py +60 -0
- kdk/output/progress.py +79 -0
- kdk/output/render.py +181 -0
- kdk-2.0.0.dist-info/METADATA +217 -0
- kdk-2.0.0.dist-info/RECORD +74 -0
- kdk-2.0.0.dist-info/WHEEL +5 -0
- kdk-2.0.0.dist-info/entry_points.txt +5 -0
- kdk-2.0.0.dist-info/licenses/LICENSE +338 -0
- kdk-2.0.0.dist-info/top_level.txt +1 -0
kdk/__init__.py
ADDED
kdk/__main__.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""CLI entry point; run `kdk --help` for the available subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def real_issues(issues):
|
|
12
|
+
"""Drop the sentinel rows checks return when clean: no severity and no source line."""
|
|
13
|
+
return [i for i in issues if i.get("severity") or (i.get("line") or 0) > 0]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def visible_issues(raw, args):
|
|
17
|
+
"""Apply the include filter, sentinel filter, and any --severity/--category narrowing."""
|
|
18
|
+
from kdk.core import filter_include_warnings
|
|
19
|
+
|
|
20
|
+
issues = raw if args.show_include_warnings else filter_include_warnings(raw)
|
|
21
|
+
|
|
22
|
+
wanted = (args.category or "").lower()
|
|
23
|
+
result = {}
|
|
24
|
+
for category, rows in issues.items():
|
|
25
|
+
if wanted and wanted not in category.lower():
|
|
26
|
+
continue
|
|
27
|
+
rows = real_issues(rows)
|
|
28
|
+
if args.severity:
|
|
29
|
+
rows = [i for i in rows if i.get("severity") == args.severity]
|
|
30
|
+
if rows:
|
|
31
|
+
result[category] = rows
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def exit_code(issues, strict):
|
|
36
|
+
"""1 on errors, or on anything at all under --strict."""
|
|
37
|
+
from kdk.output import counts
|
|
38
|
+
|
|
39
|
+
errors, warnings = counts(issues)
|
|
40
|
+
if errors:
|
|
41
|
+
return 1
|
|
42
|
+
return 1 if strict and warnings else 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_validate(args):
|
|
46
|
+
from kdk.core import CHECK_SEQUENCE, save_report, validate_skin
|
|
47
|
+
from kdk.output import (
|
|
48
|
+
RunProgress, browse, console, render_github, render_issues, render_json,
|
|
49
|
+
render_summary, save_run,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
skin_path = os.path.abspath(args.path)
|
|
53
|
+
if not os.path.isdir(skin_path):
|
|
54
|
+
print(f"Error: {skin_path} is not a directory", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
overrides = {}
|
|
58
|
+
if args.language:
|
|
59
|
+
overrides["language"] = args.language
|
|
60
|
+
if args.kodi_path:
|
|
61
|
+
overrides["kodi_path"] = args.kodi_path
|
|
62
|
+
|
|
63
|
+
with RunProgress(skin_path, len(CHECK_SEQUENCE), enabled=not args.json and not args.quiet) as bar:
|
|
64
|
+
result = validate_skin(skin_path, config_overrides=overrides, progress_callback=bar.update)
|
|
65
|
+
|
|
66
|
+
if result["error"]:
|
|
67
|
+
if args.json:
|
|
68
|
+
json.dump({"error": result["error"]}, sys.stdout, indent=2)
|
|
69
|
+
print()
|
|
70
|
+
else:
|
|
71
|
+
print(f"Error: {result['error']}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
save_run(skin_path, result, result["issues"])
|
|
75
|
+
issues = visible_issues(result["issues"], args)
|
|
76
|
+
|
|
77
|
+
if args.json:
|
|
78
|
+
payload = render_json(result, issues)
|
|
79
|
+
text = json.dumps(payload, indent=2)
|
|
80
|
+
if args.output:
|
|
81
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
82
|
+
f.write(text + "\n")
|
|
83
|
+
print(f"Written: {args.output}", file=sys.stderr)
|
|
84
|
+
else:
|
|
85
|
+
print(text)
|
|
86
|
+
return exit_code(issues, args.strict)
|
|
87
|
+
|
|
88
|
+
if args.github:
|
|
89
|
+
render_github(issues)
|
|
90
|
+
|
|
91
|
+
render_summary(result, issues)
|
|
92
|
+
|
|
93
|
+
if args.report or args.output:
|
|
94
|
+
report_path = save_report(result, args.output)
|
|
95
|
+
console.print(f" [dim]Report saved: {report_path}[/]")
|
|
96
|
+
console.print()
|
|
97
|
+
|
|
98
|
+
if issues and not args.quiet:
|
|
99
|
+
interactive = console.is_terminal and not args.list and sys.stdin.isatty()
|
|
100
|
+
if interactive:
|
|
101
|
+
browse(issues, skin_path)
|
|
102
|
+
else:
|
|
103
|
+
render_issues(issues, skin_path)
|
|
104
|
+
|
|
105
|
+
return exit_code(issues, args.strict)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_issues(args):
|
|
109
|
+
from kdk.output import browse, console, load_run, render_github, render_issues, render_json, render_summary
|
|
110
|
+
|
|
111
|
+
skin_path = os.path.abspath(args.path)
|
|
112
|
+
run = load_run(skin_path)
|
|
113
|
+
if not run:
|
|
114
|
+
print(f"No cached run for {skin_path} - run 'kdk validate' first", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
issues = visible_issues(run["issues"], args)
|
|
118
|
+
|
|
119
|
+
if args.json:
|
|
120
|
+
print(json.dumps(render_json(run, issues), indent=2))
|
|
121
|
+
return exit_code(issues, args.strict)
|
|
122
|
+
|
|
123
|
+
if args.github:
|
|
124
|
+
render_github(issues)
|
|
125
|
+
|
|
126
|
+
console.print()
|
|
127
|
+
console.print(f" [bold]{run['skin_name']}[/] [dim]validated {run['timestamp']}[/]")
|
|
128
|
+
console.print()
|
|
129
|
+
|
|
130
|
+
if args.browse and console.is_terminal and sys.stdin.isatty():
|
|
131
|
+
browse(issues, skin_path)
|
|
132
|
+
else:
|
|
133
|
+
render_issues(issues, skin_path)
|
|
134
|
+
|
|
135
|
+
if args.summary:
|
|
136
|
+
render_summary(run, issues)
|
|
137
|
+
|
|
138
|
+
return exit_code(issues, args.strict)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def add_filters(parser):
|
|
142
|
+
"""Flags shared by `validate` and `issues` so both narrow results the same way."""
|
|
143
|
+
from kdk.libs.validation.constants import SEVERITY_ERROR, SEVERITY_WARNING
|
|
144
|
+
|
|
145
|
+
parser.add_argument("--severity", choices=[SEVERITY_ERROR, SEVERITY_WARNING],
|
|
146
|
+
help="Only show issues of this severity")
|
|
147
|
+
parser.add_argument("--category", help="Only show categories matching this text (e.g. fonts)")
|
|
148
|
+
parser.add_argument("--show-include-warnings", action="store_true",
|
|
149
|
+
help="Include warnings that come from include content (hidden by default)")
|
|
150
|
+
parser.add_argument("--strict", action="store_true", help="Exit non-zero on warnings too, not just errors")
|
|
151
|
+
parser.add_argument("--json", action="store_true", help="Output JSON instead of the terminal view")
|
|
152
|
+
parser.add_argument("--github", action="store_true",
|
|
153
|
+
help="Emit GitHub Actions annotations so CI shows each issue inline")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main():
|
|
157
|
+
parser = argparse.ArgumentParser(
|
|
158
|
+
prog="kdk",
|
|
159
|
+
description="KDK - Kodi skin validation tool (CLI). Use kdk-gui for the GUI.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
162
|
+
|
|
163
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
164
|
+
|
|
165
|
+
p_validate = subparsers.add_parser("validate", help="Validate a Kodi skin")
|
|
166
|
+
# SUPPRESS so omitting it here does not overwrite the same flag given
|
|
167
|
+
# before the subcommand.
|
|
168
|
+
p_validate.add_argument("--debug", action="store_true", default=argparse.SUPPRESS,
|
|
169
|
+
help="Enable debug logging")
|
|
170
|
+
p_validate.add_argument("path", nargs="?", default=".", help="Path to the skin addon directory")
|
|
171
|
+
p_validate.add_argument("--quiet", "-q", action="store_true", help="Summary only, no issue list")
|
|
172
|
+
p_validate.add_argument("--list", action="store_true", help="Print every issue instead of the picker")
|
|
173
|
+
p_validate.add_argument("--report", action="store_true", help="Also save the full text report")
|
|
174
|
+
p_validate.add_argument("--output", "-o", help="Write output to this path")
|
|
175
|
+
p_validate.add_argument("--language", help="Language code (e.g. resource.language.en_gb)")
|
|
176
|
+
p_validate.add_argument("--kodi-path", help="Path to Kodi installation")
|
|
177
|
+
add_filters(p_validate)
|
|
178
|
+
p_validate.set_defaults(func=cmd_validate)
|
|
179
|
+
|
|
180
|
+
p_issues = subparsers.add_parser("issues", help="Show issues from the last validate run")
|
|
181
|
+
p_issues.add_argument("--debug", action="store_true", default=argparse.SUPPRESS,
|
|
182
|
+
help="Enable debug logging")
|
|
183
|
+
p_issues.add_argument("path", nargs="?", default=".", help="Path to the skin addon directory")
|
|
184
|
+
p_issues.add_argument("--browse", action="store_true", help="Pick a category instead of printing everything")
|
|
185
|
+
p_issues.add_argument("--summary", action="store_true", help="Append the per-category summary")
|
|
186
|
+
add_filters(p_issues)
|
|
187
|
+
p_issues.set_defaults(func=cmd_issues)
|
|
188
|
+
|
|
189
|
+
args = parser.parse_args()
|
|
190
|
+
|
|
191
|
+
from kdk.output import quiet_engine_logging
|
|
192
|
+
quiet_engine_logging(args.debug)
|
|
193
|
+
|
|
194
|
+
if not args.command:
|
|
195
|
+
parser.print_help(sys.stderr)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
return args.func(args)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|
kdk/config.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Plain-JSON config. Precedence: CLI overrides > `.kdk.json` (skin dir) > `~/.config/kdk/config.json` > defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("kdk.config")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _user_config_path() -> Path:
|
|
15
|
+
"""OS-native location for the user config file."""
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
base = os.environ.get("APPDATA") or str(Path.home())
|
|
18
|
+
return Path(base) / "kdk" / "config.json"
|
|
19
|
+
if sys.platform == "darwin":
|
|
20
|
+
return Path.home() / "Library" / "Application Support" / "kdk" / "config.json"
|
|
21
|
+
return Path.home() / ".config" / "kdk" / "config.json"
|
|
22
|
+
|
|
23
|
+
DEFAULTS = {
|
|
24
|
+
"language": "resource.language.en_gb",
|
|
25
|
+
"language_folders": ["resource.language.en_gb"],
|
|
26
|
+
"kodi_path": "",
|
|
27
|
+
"debug_mode": False,
|
|
28
|
+
# Editor for "open at line" - one of: code, sublime, notepad++, vim, nano, kate, gedit
|
|
29
|
+
# or empty string for OS default (no line number support)
|
|
30
|
+
"editor": "",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# {file} and {line} are substituted at call time. Sublime is handled specially
|
|
34
|
+
# (see `_open_in_sublime`) because the path:line CLI suffix is unreliable.
|
|
35
|
+
EDITOR_PATTERNS = {
|
|
36
|
+
"code": ["code", "--goto", "{file}:{line}"],
|
|
37
|
+
"notepad++": ["notepad++", "-n{line}", "{file}"],
|
|
38
|
+
"vim": ["vim", "+{line}", "{file}"],
|
|
39
|
+
"nano": ["nano", "+{line}", "{file}"],
|
|
40
|
+
"kate": ["kate", "--line", "{line}", "{file}"],
|
|
41
|
+
"gedit": ["gedit", "+{line}", "{file}"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_config(skin_path: str | None = None, overrides: dict | None = None) -> dict:
|
|
46
|
+
"""Merge defaults, OS-native user config, `<skin_path>/.kdk.json`, and `overrides` (in that order)."""
|
|
47
|
+
config = dict(DEFAULTS)
|
|
48
|
+
|
|
49
|
+
user_config = _user_config_path()
|
|
50
|
+
if user_config.exists():
|
|
51
|
+
try:
|
|
52
|
+
with open(user_config) as f:
|
|
53
|
+
config.update(json.load(f))
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.warning("Failed to load user config %s: %s", user_config, e)
|
|
56
|
+
|
|
57
|
+
if skin_path:
|
|
58
|
+
project_config_path = Path(skin_path) / ".kdk.json"
|
|
59
|
+
if project_config_path.exists():
|
|
60
|
+
try:
|
|
61
|
+
with open(project_config_path) as f:
|
|
62
|
+
config.update(json.load(f))
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning("Failed to load project config %s: %s", project_config_path, e)
|
|
65
|
+
|
|
66
|
+
if overrides:
|
|
67
|
+
config.update({k: v for k, v in overrides.items() if v is not None})
|
|
68
|
+
|
|
69
|
+
return config
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Settings:
|
|
73
|
+
"""Dict-like settings wrapper matching the interface expected by libs/."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, data: dict):
|
|
76
|
+
self._data = data
|
|
77
|
+
|
|
78
|
+
def get(self, key: str, default=None):
|
|
79
|
+
return self._data.get(key, default)
|
|
80
|
+
|
|
81
|
+
def __contains__(self, key: str) -> bool:
|
|
82
|
+
return key in self._data
|
|
83
|
+
|
|
84
|
+
def __getitem__(self, key: str):
|
|
85
|
+
return self._data[key]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def detect_editors() -> list[str]:
|
|
89
|
+
"""Return list of editors found on PATH or common install locations."""
|
|
90
|
+
import shutil
|
|
91
|
+
import sys
|
|
92
|
+
|
|
93
|
+
candidates = [
|
|
94
|
+
("code", ["code", "code.cmd"]),
|
|
95
|
+
("sublime", ["subl", "subl.exe", "sublime_text", "sublime_text.exe"]),
|
|
96
|
+
("notepad++", ["notepad++", "notepad++.exe"]),
|
|
97
|
+
("kate", ["kate"]),
|
|
98
|
+
("gedit", ["gedit"]),
|
|
99
|
+
("vim", ["vim"]),
|
|
100
|
+
("nano", ["nano"]),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Bundled installs of these editors don't always add themselves to PATH on Windows.
|
|
104
|
+
win_paths = [
|
|
105
|
+
("code", [
|
|
106
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd"),
|
|
107
|
+
os.path.expandvars(r"%PROGRAMFILES%\Microsoft VS Code\bin\code.cmd"),
|
|
108
|
+
]),
|
|
109
|
+
("sublime", [
|
|
110
|
+
os.path.expandvars(r"%PROGRAMFILES%\Sublime Text\subl.exe"),
|
|
111
|
+
os.path.expandvars(r"%PROGRAMFILES%\Sublime Text 3\subl.exe"),
|
|
112
|
+
os.path.expandvars(r"%PROGRAMFILES(x86)%\Sublime Text\subl.exe"),
|
|
113
|
+
]),
|
|
114
|
+
("notepad++", [
|
|
115
|
+
os.path.expandvars(r"%PROGRAMFILES%\Notepad++\notepad++.exe"),
|
|
116
|
+
os.path.expandvars(r"%PROGRAMFILES(x86)%\Notepad++\notepad++.exe"),
|
|
117
|
+
]),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
found = []
|
|
121
|
+
for name, exes in candidates:
|
|
122
|
+
if any(shutil.which(exe) for exe in exes):
|
|
123
|
+
found.append(name)
|
|
124
|
+
continue
|
|
125
|
+
# On Windows, check common install locations
|
|
126
|
+
if sys.platform == "win32":
|
|
127
|
+
for wname, wpaths in win_paths:
|
|
128
|
+
if wname == name and any(os.path.isfile(p) for p in wpaths):
|
|
129
|
+
found.append(name)
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
EDITOR_DISPLAY_NAMES = {
|
|
136
|
+
"": "System Default",
|
|
137
|
+
"code": "VS Code",
|
|
138
|
+
"sublime": "Sublime Text",
|
|
139
|
+
"notepad++": "Notepad++",
|
|
140
|
+
"vim": "Vim",
|
|
141
|
+
"nano": "Nano",
|
|
142
|
+
"kate": "Kate",
|
|
143
|
+
"gedit": "Gedit",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def save_user_config(key: str, value) -> None:
|
|
148
|
+
"""Save a single key to the user config file."""
|
|
149
|
+
config_path = _user_config_path()
|
|
150
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
existing = {}
|
|
153
|
+
if config_path.exists():
|
|
154
|
+
try:
|
|
155
|
+
with open(config_path) as f:
|
|
156
|
+
existing = json.load(f)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
existing[key] = value
|
|
161
|
+
with open(config_path, "w") as f:
|
|
162
|
+
json.dump(existing, f, indent=2)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_editor_exe(exe_name: str, editor_key: str) -> str | None:
|
|
166
|
+
"""Resolve `exe_name` via PATH, falling back to known Windows install paths; `None` means use `exe_name` as-is."""
|
|
167
|
+
import shutil
|
|
168
|
+
import sys
|
|
169
|
+
|
|
170
|
+
if shutil.which(exe_name):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
if sys.platform != "win32":
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
win_exe_map = {
|
|
177
|
+
"code": [
|
|
178
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd"),
|
|
179
|
+
os.path.expandvars(r"%PROGRAMFILES%\Microsoft VS Code\bin\code.cmd"),
|
|
180
|
+
],
|
|
181
|
+
"sublime": [
|
|
182
|
+
os.path.expandvars(r"%PROGRAMFILES%\Sublime Text\subl.exe"),
|
|
183
|
+
os.path.expandvars(r"%PROGRAMFILES%\Sublime Text 3\subl.exe"),
|
|
184
|
+
],
|
|
185
|
+
"notepad++": [
|
|
186
|
+
os.path.expandvars(r"%PROGRAMFILES%\Notepad++\notepad++.exe"),
|
|
187
|
+
os.path.expandvars(r"%PROGRAMFILES(x86)%\Notepad++\notepad++.exe"),
|
|
188
|
+
],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for path in win_exe_map.get(editor_key, []):
|
|
192
|
+
if os.path.isfile(path):
|
|
193
|
+
return path
|
|
194
|
+
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_sublime_exe() -> str | None:
|
|
199
|
+
"""Find a Sublime executable.
|
|
200
|
+
|
|
201
|
+
Prefer `subl.exe` (the CLI wrapper) over `sublime_text.exe` because the
|
|
202
|
+
wrapper routes args through Sublime's existing-instance IPC. Calling
|
|
203
|
+
`sublime_text.exe` directly on Windows can spawn a new (hidden) instance
|
|
204
|
+
that receives the line jump while the user's visible Sublime stays put -
|
|
205
|
+
making the jump look broken even when it technically worked.
|
|
206
|
+
"""
|
|
207
|
+
import shutil
|
|
208
|
+
import sys
|
|
209
|
+
|
|
210
|
+
if sys.platform == "win32":
|
|
211
|
+
candidates = [
|
|
212
|
+
r"%PROGRAMFILES%\Sublime Text\subl.exe",
|
|
213
|
+
r"%PROGRAMFILES%\Sublime Text 3\subl.exe",
|
|
214
|
+
r"%PROGRAMFILES(x86)%\Sublime Text\subl.exe",
|
|
215
|
+
r"%PROGRAMFILES%\Sublime Text\sublime_text.exe",
|
|
216
|
+
r"%PROGRAMFILES%\Sublime Text 3\sublime_text.exe",
|
|
217
|
+
]
|
|
218
|
+
for c in candidates:
|
|
219
|
+
expanded = os.path.expandvars(c)
|
|
220
|
+
if os.path.isfile(expanded):
|
|
221
|
+
return expanded
|
|
222
|
+
return shutil.which("subl") or shutil.which("sublime_text")
|
|
223
|
+
return shutil.which("subl") or shutil.which("sublime_text")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _open_in_sublime(file_path: str, line: int) -> bool:
|
|
227
|
+
"""Open `file_path` at `line` in Sublime via `subl FILE:LINE:COL`.
|
|
228
|
+
|
|
229
|
+
The path is normalized to forward slashes - Sublime's path:line:col parser
|
|
230
|
+
is happier with forward-slash paths on Windows even though backslash paths
|
|
231
|
+
are documented to work too.
|
|
232
|
+
"""
|
|
233
|
+
import subprocess
|
|
234
|
+
|
|
235
|
+
exe = _resolve_sublime_exe()
|
|
236
|
+
if not exe:
|
|
237
|
+
logger.warning("Sublime not found")
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
abs_path = os.path.abspath(file_path).replace("\\", "/")
|
|
241
|
+
arg = f"{abs_path}:{int(line)}:0" if line and line > 0 else abs_path
|
|
242
|
+
try:
|
|
243
|
+
subprocess.Popen([exe, arg])
|
|
244
|
+
return True
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
logger.warning("Sublime executable not launchable: %s", exe)
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def open_in_editor(file_path: str, line: int = 1, editor: str = "") -> bool:
|
|
251
|
+
"""Open `file_path:line` in `editor`; falls back to OS default if `editor` is empty or missing."""
|
|
252
|
+
import subprocess
|
|
253
|
+
import sys
|
|
254
|
+
|
|
255
|
+
editor = editor.strip().lower()
|
|
256
|
+
|
|
257
|
+
if editor == "sublime":
|
|
258
|
+
return _open_in_sublime(file_path, line)
|
|
259
|
+
|
|
260
|
+
if editor and editor in EDITOR_PATTERNS:
|
|
261
|
+
cmd = [
|
|
262
|
+
part.replace("{file}", file_path).replace("{line}", str(line))
|
|
263
|
+
for part in EDITOR_PATTERNS[editor]
|
|
264
|
+
]
|
|
265
|
+
exe = _resolve_editor_exe(cmd[0], editor)
|
|
266
|
+
if exe:
|
|
267
|
+
cmd[0] = exe
|
|
268
|
+
try:
|
|
269
|
+
subprocess.Popen(cmd)
|
|
270
|
+
return True
|
|
271
|
+
except FileNotFoundError:
|
|
272
|
+
logger.warning("Editor %r not found, falling back to OS default", editor)
|
|
273
|
+
|
|
274
|
+
# OS default opener can't take a line number.
|
|
275
|
+
try:
|
|
276
|
+
if sys.platform == "win32":
|
|
277
|
+
os.startfile(file_path)
|
|
278
|
+
elif sys.platform == "darwin":
|
|
279
|
+
subprocess.Popen(["open", file_path])
|
|
280
|
+
else:
|
|
281
|
+
subprocess.Popen(["xdg-open", file_path])
|
|
282
|
+
return True
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.warning("Failed to open file: %s", e)
|
|
285
|
+
return False
|
kdk/core.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Core validation runner shared by CLI and GUI; no UI code here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from .config import load_config, Settings
|
|
12
|
+
from .libs.infoprovider import InfoProvider
|
|
13
|
+
from .libs.reporting.text import generate_text_report
|
|
14
|
+
from .libs.validation.constants import SEVERITY_ERROR
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("kdk.core")
|
|
17
|
+
|
|
18
|
+
CHECK_SEQUENCE = [
|
|
19
|
+
("Variables", "check_variables"),
|
|
20
|
+
("Includes", "check_includes"),
|
|
21
|
+
("Labels", "check_labels"),
|
|
22
|
+
("Fonts", "check_fonts"),
|
|
23
|
+
("IDs", "check_ids"),
|
|
24
|
+
("Images", "check_images"),
|
|
25
|
+
("XML Validation", "check_values"),
|
|
26
|
+
("File Integrity", "check_file_integrity"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def filter_include_warnings(all_issues: dict) -> dict:
|
|
31
|
+
"""Drop warnings sourced from `<include>` content (kept if severity is `error`); reduces false positives."""
|
|
32
|
+
filtered = {}
|
|
33
|
+
for category, issues in all_issues.items():
|
|
34
|
+
filtered[category] = [
|
|
35
|
+
i for i in issues
|
|
36
|
+
if not (i.get("include_name") and i.get("severity") != "error")
|
|
37
|
+
]
|
|
38
|
+
return filtered
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_skin(
|
|
42
|
+
skin_path: str,
|
|
43
|
+
*,
|
|
44
|
+
config_overrides: dict | None = None,
|
|
45
|
+
progress_callback: Callable[[int, int, str], None] | None = None,
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""Run every check against the skin at `skin_path`. Returns `{issues, skin_name, skin_path, timestamp, duration, error}`."""
|
|
48
|
+
skin_path = os.path.abspath(skin_path)
|
|
49
|
+
config = load_config(skin_path, config_overrides)
|
|
50
|
+
settings = Settings(config)
|
|
51
|
+
total_steps = len(CHECK_SEQUENCE)
|
|
52
|
+
|
|
53
|
+
def progress(step: int, msg: str):
|
|
54
|
+
if progress_callback:
|
|
55
|
+
progress_callback(step, total_steps, msg)
|
|
56
|
+
|
|
57
|
+
addon_xml = os.path.join(skin_path, "addon.xml")
|
|
58
|
+
if not os.path.isfile(addon_xml):
|
|
59
|
+
return {
|
|
60
|
+
"issues": {},
|
|
61
|
+
"skin_name": "",
|
|
62
|
+
"skin_path": skin_path,
|
|
63
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
64
|
+
"duration": 0.0,
|
|
65
|
+
"error": f"No addon.xml found at {skin_path}",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
progress(0, "Initializing...")
|
|
69
|
+
|
|
70
|
+
provider = InfoProvider()
|
|
71
|
+
provider.load_settings(settings)
|
|
72
|
+
provider.init_addon(skin_path)
|
|
73
|
+
|
|
74
|
+
if not provider.addon:
|
|
75
|
+
return {
|
|
76
|
+
"issues": {},
|
|
77
|
+
"skin_name": "",
|
|
78
|
+
"skin_path": skin_path,
|
|
79
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
80
|
+
"duration": 0.0,
|
|
81
|
+
"error": f"Failed to load addon at {skin_path}",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Defensive: a fresh `init_addon` already builds a new `Skin`, but if a
|
|
85
|
+
# caller ever swaps to reusing one we want stale resolved trees gone.
|
|
86
|
+
cache = getattr(provider.addon, "_resolved_windows_cache", None)
|
|
87
|
+
if isinstance(cache, dict):
|
|
88
|
+
cache.clear()
|
|
89
|
+
|
|
90
|
+
fatal = getattr(provider, "_fatal_xml_error", None)
|
|
91
|
+
if fatal:
|
|
92
|
+
return {
|
|
93
|
+
"issues": {},
|
|
94
|
+
"skin_name": "",
|
|
95
|
+
"skin_path": skin_path,
|
|
96
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
97
|
+
"duration": 0.0,
|
|
98
|
+
"error": f"XML parse error in {os.path.basename(fatal)} - fix this file before validating",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
skin_name = getattr(provider.addon, "name", "") or os.path.basename(skin_path)
|
|
102
|
+
|
|
103
|
+
start_time = time.time()
|
|
104
|
+
all_issues = {}
|
|
105
|
+
|
|
106
|
+
for step, (name, method_name) in enumerate(CHECK_SEQUENCE, 1):
|
|
107
|
+
progress(step, f"Checking {name.lower()}...")
|
|
108
|
+
|
|
109
|
+
def step_progress(message, _step=step, _name=name):
|
|
110
|
+
progress(_step, f"{_name}: {message}")
|
|
111
|
+
|
|
112
|
+
method = getattr(provider, method_name, None)
|
|
113
|
+
if method:
|
|
114
|
+
try:
|
|
115
|
+
all_issues[name] = method(progress_callback=step_progress) or []
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error("Check %s failed: %s", name, e, exc_info=True)
|
|
118
|
+
# Severity matters: without it the row is filtered out of every
|
|
119
|
+
# output mode and the run exits 0 despite the check never running.
|
|
120
|
+
all_issues[name] = [{
|
|
121
|
+
"message": f"Check failed: {e}",
|
|
122
|
+
"file": "", "line": 0, "severity": SEVERITY_ERROR,
|
|
123
|
+
}]
|
|
124
|
+
else:
|
|
125
|
+
logger.warning("Check method %s not found", method_name)
|
|
126
|
+
|
|
127
|
+
duration = time.time() - start_time
|
|
128
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"issues": all_issues,
|
|
132
|
+
"skin_name": skin_name,
|
|
133
|
+
"skin_path": skin_path,
|
|
134
|
+
"timestamp": timestamp,
|
|
135
|
+
"duration": duration,
|
|
136
|
+
"error": None,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_downloads_folder() -> str:
|
|
141
|
+
"""Return the OS default Downloads folder, or home as fallback."""
|
|
142
|
+
home = os.path.expanduser("~")
|
|
143
|
+
downloads = os.path.join(home, "Downloads")
|
|
144
|
+
if os.path.isdir(downloads):
|
|
145
|
+
return downloads
|
|
146
|
+
return home
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def save_report(result: dict, output_path: str | None = None) -> str:
|
|
150
|
+
"""Write `result` (from `validate_skin`) as a text report; defaults to a timestamped file in the Downloads folder."""
|
|
151
|
+
if output_path is None:
|
|
152
|
+
safe_name = "".join(
|
|
153
|
+
c if c.isalnum() or c in ("-", "_") else "_"
|
|
154
|
+
for c in result["skin_name"]
|
|
155
|
+
)
|
|
156
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
157
|
+
output_path = os.path.join(get_downloads_folder(), f"{safe_name}_{ts}_report.txt")
|
|
158
|
+
|
|
159
|
+
generate_text_report(
|
|
160
|
+
all_issues=result["issues"],
|
|
161
|
+
skin_name=result["skin_name"],
|
|
162
|
+
skin_path=result["skin_path"],
|
|
163
|
+
output_path=output_path,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return output_path
|
kdk/data/icon.ico
ADDED
|
Binary file
|