summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docker/requirements.in3
-rw-r--r--docker/requirements.txt8
-rwxr-xr-xdocker/scripts/lint.sh6
-rw-r--r--tools/check_resources.py125
-rw-r--r--tools/tests/test_check_resources.py108
5 files changed, 250 insertions, 0 deletions
diff --git a/docker/requirements.in b/docker/requirements.in
index 787d84f..d344f23 100644
--- a/docker/requirements.in
+++ b/docker/requirements.in
@@ -19,6 +19,9 @@ segno>=1.6
pdf417gen>=0.7
ppf-datamatrix>=0.2
aztec-code-generator>=0.11
+# Validates the resource XML (settings, properties, strings) against the
+# SDK's resources.xsd (M7). Pure Python, MIT licensed.
+xmlschema>=3
# Screenshot handling (crop, compare, scale before decoding).
pillow>=10
# Lint and type-check the Python tools.
diff --git a/docker/requirements.txt b/docker/requirements.txt
index fe24646..c3574c6 100644
--- a/docker/requirements.txt
+++ b/docker/requirements.txt
@@ -63,6 +63,10 @@ aztec-code-generator==0.12 \
--hash=sha256:b8b498b5b93378e9c99226a7fada8811e41be33aa20973398d5eeebd19629909 \
--hash=sha256:e27db502c081d0336406bb587ddfe837a8b0a5d44835522f1f21f470c964706b
# via -r requirements.in
+elementpath==5.1.4 \
+ --hash=sha256:3c912a9c41311ac5244c038867670d7c0b0fec0e444813608e5b846dbb3428ac \
+ --hash=sha256:5ec8d7bb44b16c98414a6a95e20cb150f3cbfc3cac65edc14773288c7a937376
+ # via xmlschema
exceptiongroup==1.3.1 \
--hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \
--hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598
@@ -561,6 +565,10 @@ typing-extensions==4.16.0 \
# via
# exceptiongroup
# mypy
+xmlschema==4.3.2 \
+ --hash=sha256:cf5c970a30f6ebcb3da35260e694704cc0b6794d8ace46125bb2a95e38ed9307 \
+ --hash=sha256:f3b33a21a2b2cdc404ddc3fb599d8bc1dee90b155d547370672e6e684746b8d6
+ # via -r requirements.in
zxing-cpp==3.1.1 \
--hash=sha256:0343458a0fdf3f99c9dcff74dd09be6bae5d0d87a2f99de7876014317b939996 \
--hash=sha256:037cbcaeb0cb12497fc15ced23f6b778fce8a6a1d1bbffddbffd004c6225744d \
diff --git a/docker/scripts/lint.sh b/docker/scripts/lint.sh
index 998b2b9..4cca4d8 100755
--- a/docker/scripts/lint.sh
+++ b/docker/scripts/lint.sh
@@ -7,6 +7,8 @@
# 1. tools/lint_project.py: project rules (headers, no logging, no network, ...)
# 2. ruff (lint + formatting) and mypy --strict on the Python tools
# 3. tools/gen_products.py --check: the manifest's products meet the targets
+# tools/check_resources.py: resource XML valid against the SDK's
+# resources.xsd; settings, properties and strings agree
# 4. Debug and release builds with strict type checking (-l 3) on each device;
# build-app.sh fails on any compiler warning.
set -euo pipefail
@@ -26,6 +28,10 @@ ruff format --check /work/tools /work/spike
log "product list"
python3 /work/tools/gen_products.py --check
+log "resource XML against the SDK schema, settings cross-check"
+ciq_require_sdk
+python3 /work/tools/check_resources.py /work
+
for device in "$@"; do
build-app.sh debug "${device}"
build-app.sh release "${device}"
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)]) == []