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.
Files changed (74) hide show
  1. kdk/__init__.py +3 -0
  2. kdk/__main__.py +202 -0
  3. kdk/config.py +285 -0
  4. kdk/core.py +166 -0
  5. kdk/data/icon.ico +0 -0
  6. kdk/data/kodi/omega/colors.xml +153 -0
  7. kdk/data/kodi/omega/strings.po +24365 -0
  8. kdk/data/kodi/piers/colors.xml +153 -0
  9. kdk/data/kodi/piers/strings.po +25115 -0
  10. kdk/data/kodi_builtin_controls.xml +438 -0
  11. kdk/data/kodi_infolabel_functions.json +78 -0
  12. kdk/data/omega/controls.xml +569 -0
  13. kdk/data/omega/data.xml +1090 -0
  14. kdk/data/omega/windows.json +126 -0
  15. kdk/data/piers/controls.xml +569 -0
  16. kdk/data/piers/data.xml +1090 -0
  17. kdk/data/piers/windows.json +122 -0
  18. kdk/gui/__init__.py +0 -0
  19. kdk/gui/__main__.py +14 -0
  20. kdk/gui/app.py +463 -0
  21. kdk/libs/__init__.py +0 -0
  22. kdk/libs/addon/__init__.py +5 -0
  23. kdk/libs/addon/addon.py +304 -0
  24. kdk/libs/addon/translations.py +59 -0
  25. kdk/libs/infoprovider/__init__.py +3 -0
  26. kdk/libs/infoprovider/checker.py +717 -0
  27. kdk/libs/infoprovider/loader.py +135 -0
  28. kdk/libs/infoprovider/provider.py +21 -0
  29. kdk/libs/kodi_refs.py +88 -0
  30. kdk/libs/polib/__init__.py +1 -0
  31. kdk/libs/polib/polib.py +1893 -0
  32. kdk/libs/reporting/__init__.py +1 -0
  33. kdk/libs/reporting/text.py +111 -0
  34. kdk/libs/skin/__init__.py +16 -0
  35. kdk/libs/skin/include.py +120 -0
  36. kdk/libs/skin/index.py +1035 -0
  37. kdk/libs/skin/maps.py +36 -0
  38. kdk/libs/skin/resolution.py +375 -0
  39. kdk/libs/skin/resources.py +188 -0
  40. kdk/libs/skin/skin.py +783 -0
  41. kdk/libs/skin/textures.py +35 -0
  42. kdk/libs/utils/__init__.py +142 -0
  43. kdk/libs/utils/colors.py +36 -0
  44. kdk/libs/utils/debug.py +28 -0
  45. kdk/libs/utils/expressions.py +198 -0
  46. kdk/libs/utils/files.py +186 -0
  47. kdk/libs/utils/images.py +216 -0
  48. kdk/libs/utils/infobool.py +137 -0
  49. kdk/libs/utils/validation.py +354 -0
  50. kdk/libs/utils/xml.py +309 -0
  51. kdk/libs/validation/__init__.py +67 -0
  52. kdk/libs/validation/constants.py +141 -0
  53. kdk/libs/validation/expression.py +1012 -0
  54. kdk/libs/validation/filecheck.py +78 -0
  55. kdk/libs/validation/font.py +387 -0
  56. kdk/libs/validation/hierarchy.py +119 -0
  57. kdk/libs/validation/ids.py +330 -0
  58. kdk/libs/validation/image.py +118 -0
  59. kdk/libs/validation/include.py +381 -0
  60. kdk/libs/validation/interpreter.py +517 -0
  61. kdk/libs/validation/label.py +418 -0
  62. kdk/libs/validation/suppress.py +118 -0
  63. kdk/libs/validation/variable.py +233 -0
  64. kdk/output/__init__.py +33 -0
  65. kdk/output/browse.py +47 -0
  66. kdk/output/cache.py +60 -0
  67. kdk/output/progress.py +79 -0
  68. kdk/output/render.py +181 -0
  69. kdk-2.0.0.dist-info/METADATA +217 -0
  70. kdk-2.0.0.dist-info/RECORD +74 -0
  71. kdk-2.0.0.dist-info/WHEEL +5 -0
  72. kdk-2.0.0.dist-info/entry_points.txt +5 -0
  73. kdk-2.0.0.dist-info/licenses/LICENSE +338 -0
  74. kdk-2.0.0.dist-info/top_level.txt +1 -0
kdk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """KDK - Kodi skin validation tool."""
2
+
3
+ __version__ = "2.0.0"
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