diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
| commit | f466977249d9ac10fe8b75180c30cb90dbcc22ca (patch) | |
| tree | f346500d39d077aaaeb350e0e99647a4d6e6367c /tools | |
| parent | 479d7ba2e843d5ea93979f2eed68f4d38d146c25 (diff) | |
build(lint): validate resource XML against the SDK schema and cross-check the settings form
tools/check_resources.py checks every resource file against the SDK's
resources.xsd and that settings, properties and strings agree. Adds the
pure-Python, MIT-licensed xmlschema to the dev image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/check_resources.py | 125 | ||||
| -rw-r--r-- | tools/tests/test_check_resources.py | 108 |
2 files changed, 233 insertions, 0 deletions
diff --git a/tools/check_resources.py b/tools/check_resources.py new file mode 100644 index 0000000..cdde8df --- /dev/null +++ b/tools/check_resources.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Validate the resource XML against the SDK's resources.xsd and cross-check settings, properties and strings. +"""Validate the app's resource XML and cross-check the settings form (M7). + +Checks: +1. Every XML file under resources/ and resources-*/ validates against the + Connect IQ SDK's bin/resources.xsd (the schema monkeyc ships). +2. settings.xml refers only to properties defined in properties.xml and to + strings defined in strings.xml (titles, prompts, descriptions, list + entries, error messages). +3. Every property appears in the form exactly once: a property the phone + can't show can't be cleared or read back, and one shown twice is a typo. +4. A list setting's default value is one of its entries, and read-only + (status) settings are string properties. + +The watch side of the contract (every key the code reads exists, the form +defaults match) is checked by the Monkey C test settingsDefaultsMatchProperties. + +Usage: check_resources.py ROOT [--xsd PATH] + (default PATH: $CIQ_SDK/bin/resources.xsd) +Exit: 0 all good, 1 problems found, 2 the schema can't be read. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import xml.etree.ElementTree as ET +from collections import Counter +from pathlib import Path + +import xmlschema + +EXIT_OK, EXIT_PROBLEMS, EXIT_NO_SCHEMA = 0, 1, 2 + +REFERENCE_RE = re.compile(r"@(Strings|Properties)\.(\w+)") + + +def resource_files(root: Path) -> list[Path]: + """Every resource XML file, in a stable order.""" + files: list[Path] = [] + for folder in sorted(root.glob("resources*")): + if folder.is_dir(): + files.extend(sorted(folder.rglob("*.xml"))) + return files + + +def validate(schema: xmlschema.XMLSchema, files: list[Path], root: Path) -> list[str]: + """Schema errors, one line per problem.""" + problems = [] + for path in files: + for error in schema.iter_errors(str(path)): + problems.append(f"{path.relative_to(root)}: {error.reason} (at {error.path})") + return problems + + +def ids(path: Path, tag: str) -> list[str]: + """The id attributes of the given elements in one file.""" + return [element.get("id", "") for element in ET.parse(path).getroot().iter(tag)] + + +def cross_check(settings: Path, properties: Path, strings: list[Path]) -> list[str]: + """Problems between settings.xml, properties.xml and the strings.""" + problems: list[str] = [] + prop_types = {p.get("id", ""): p.get("type", "") for p in ET.parse(properties).getroot().iter("property")} + prop_defaults = {p.get("id", ""): (p.text or "").strip() for p in ET.parse(properties).getroot().iter("property")} + string_ids = {sid for path in strings for sid in ids(path, "string")} + text = settings.read_text(encoding="utf-8") + for kind, name in REFERENCE_RE.findall(text): + if kind == "Strings" and name not in string_ids: + problems.append(f"settings.xml: @Strings.{name} is not defined") + if kind == "Properties" and name not in prop_types: + problems.append(f"settings.xml: @Properties.{name} is not defined in properties.xml") + used: Counter[str] = Counter() + for setting in ET.parse(settings).getroot().iter("setting"): + key = REFERENCE_RE.sub(r"\2", setting.get("propertyKey", "")) + used[key] += 1 + config = setting.find("settingConfig") + if config is None or key not in prop_types: + continue + if config.get("type") == "list": + values = [entry.get("value", "") for entry in config.iter("listEntry")] + if prop_defaults[key] not in values: + problems.append(f"{key}: default {prop_defaults[key]!r} is not one of the list values {values}") + if config.get("readonly") == "true" and prop_types[key] != "string": + problems.append(f"{key}: read-only settings must be string properties, not {prop_types[key]}") + for key in prop_types: + if used[key] == 0: + problems.append(f"properties.xml: {key} is not in the settings form") + elif used[key] > 1: + problems.append(f"settings.xml: {key} appears {used[key]} times") + return problems + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate resource XML and cross-check the settings form.") + parser.add_argument("root", type=Path, help="the project folder") + parser.add_argument("--xsd", type=Path, help="the SDK's resources.xsd") + args = parser.parse_args(argv) + root: Path = args.root + xsd: Path | None = args.xsd + if xsd is None and os.environ.get("CIQ_SDK"): + xsd = Path(os.environ["CIQ_SDK"]) / "bin" / "resources.xsd" + if xsd is None or not xsd.is_file(): + print(f"error: SDK schema not found ({xsd}); set CIQ_SDK or pass --xsd", file=sys.stderr) + return EXIT_NO_SCHEMA + schema = xmlschema.XMLSchema(str(xsd)) + files = resource_files(root) + problems = validate(schema, files, root) + problems += cross_check( + root / "resources" / "settings" / "settings.xml", + root / "resources" / "settings" / "properties.xml", + [path for path in files if path.parent.name == "strings"], + ) + for problem in problems: + print(problem) + print(f"check_resources: {len(files)} files, {len(problems)} problems") + return EXIT_PROBLEMS if problems else EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_check_resources.py b/tools/tests/test_check_resources.py new file mode 100644 index 0000000..3f0f946 --- /dev/null +++ b/tools/tests/test_check_resources.py @@ -0,0 +1,108 @@ +# Author: cirakg & claude ai +# Summary: Tests for tools/check_resources.py (schema validation and the settings cross-check). +"""Tests for tools/check_resources.py.""" + +from __future__ import annotations + +from pathlib import Path + +import check_resources as cr +import xmlschema + +TINY_XSD = """<?xml version="1.0"?> +<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:element name="strings"> + <xs:complexType><xs:sequence> + <xs:element name="string" maxOccurs="unbounded"> + <xs:complexType><xs:simpleContent><xs:extension base="xs:string"> + <xs:attribute name="id" type="xs:string" use="required"/> + </xs:extension></xs:simpleContent></xs:complexType> + </xs:element> + </xs:sequence></xs:complexType> + </xs:element> +</xs:schema> +""" + +PROPERTIES = """<properties> + <property id="kind" type="number">1</property> + <property id="status" type="string"></property> + <property id="count" type="number">0</property> + <property id="orphan" type="string"></property> +</properties> +""" + +SETTINGS = """<settings> + <group id="g" title="@Strings.Title"> + <setting propertyKey="@Properties.kind" title="@Strings.Kind"> + <settingConfig type="list"> + <listEntry value="0">@Strings.Zero</listEntry> + <listEntry value="2">@Strings.Missing</listEntry> + </settingConfig> + </setting> + <setting propertyKey="@Properties.status" title="@Strings.Title"> + <settingConfig type="alphaNumeric" readonly="true" /> + </setting> + <setting propertyKey="@Properties.count" title="@Strings.Title"> + <settingConfig type="numeric" readonly="true" /> + </setting> + <setting propertyKey="@Properties.count" title="@Strings.Title"> + <settingConfig type="numeric" /> + </setting> + <setting propertyKey="@Properties.ghost" title="@Strings.Title"> + <settingConfig type="boolean" /> + </setting> + </group> +</settings> +""" + +STRINGS = '<strings><string id="Title">T</string><string id="Kind">K</string><string id="Zero">0</string></strings>' + + +def write(folder: Path, name: str, text: str) -> Path: + path = folder / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def test_cross_check_finds_every_kind_of_mismatch(tmp_path: Path) -> None: + settings = write(tmp_path, "settings.xml", SETTINGS) + properties = write(tmp_path, "properties.xml", PROPERTIES) + strings = write(tmp_path, "strings.xml", STRINGS) + problems = cr.cross_check(settings, properties, [strings]) + assert problems == [ + "settings.xml: @Strings.Missing is not defined", + "settings.xml: @Properties.ghost is not defined in properties.xml", + "kind: default '1' is not one of the list values ['0', '2']", + "count: read-only settings must be string properties, not number", + "settings.xml: count appears 2 times", + "properties.xml: orphan is not in the settings form", + ] + + +def test_validate_reports_schema_errors(tmp_path: Path) -> None: + schema = xmlschema.XMLSchema(TINY_XSD) + good = write(tmp_path, "resources/strings/strings.xml", STRINGS) + bad = write(tmp_path, "resources/strings/bad.xml", "<strings><string>no id</string></strings>") + assert cr.resource_files(tmp_path) == [bad, good] + problems = cr.validate(schema, [bad, good], tmp_path) + assert len(problems) == 1 + assert problems[0].startswith("resources/strings/bad.xml: ") + assert "id" in problems[0] + + +def test_main_needs_the_schema(tmp_path: Path) -> None: + assert cr.main([str(tmp_path), "--xsd", str(tmp_path / "missing.xsd")]) == cr.EXIT_NO_SCHEMA + + +def test_cross_check_accepts_a_consistent_form(tmp_path: Path) -> None: + properties = write( + tmp_path, "properties.xml", '<properties><property id="kind" type="number">0</property></properties>' + ) + settings = write( + tmp_path, + "settings.xml", + '<settings><setting propertyKey="@Properties.kind" title="@Strings.Kind"><settingConfig type="list">' + '<listEntry value="0">@Strings.Zero</listEntry></settingConfig></setting></settings>', + ) + assert cr.cross_check(settings, properties, [write(tmp_path, "strings.xml", STRINGS)]) == [] |
