Files
sedona_editor/palette.py
T
ArdaandClaude Opus 5.5 e070bd0f1e Put with Files: choose kits, as Manage Kits does, and rebuild kits.scode
A kits step after the zip is chosen, laid out as the Manage Kits panel:
installed kits (what the app needs, greyed), installed but not used, kits
available on the computer, not supported; the Action cell offers Keep at /
Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest
version. Kits not in the backup can be added, with their dependencies.

The ticked kits make app.sab's schema (encode_sax takes the kit list, as
Ontrol's PutApp rewrites it). When they are not the device's kits, or
Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's
sedonac from the Niagara install, after checking the natives the kits call
against the device's platform manifest (PutApp.checkNativeCompatibility).

- kit_build.py: platform manifest lookup, native check, sedonac run
- palette.kit_versions: a checksum released more than once stands for its
  highest version (ontrolPlatform 1.0.1, not 1.0.0)
- palette.unsupported_kits takes the kit list to judge against
- sab_app: a type from a kit the app does not list is a clear error

Tested on a P-ION: a rebuild for its own kits is byte-identical to the one
it runs; adding and then removing the logic kit both come back running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:37:08 +03:00

433 lines
15 KiB
Python

"""Component palette: the kits installed in Sedona home, and the components inside each.
Deliberately sourced from Sedona home rather than the open file's <schema>, so the
palette shows everything installed and not just the 17 kits this app happens to use.
Version resolution matters here. A kit directory can hold many manifests — ontrolControl
ships 22 — and the manifest filename carries only a checksum, which sorts arbitrarily.
The real version number lives in the .kit filename under kits/, as
'<kit>-<checksum>-<version>.kit', so that is what is used to decide which manifest is
newest.
"""
import os
import re
import xml.etree.ElementTree as ET
import app_state
_versions_cache = {} # kit name -> {checksum: version string}
_manifest_cache = {} # kit name -> (version string, {type name: element})
_component_cache = {} # 'kit::Type' -> bool
_builds_cache = {} # kit name -> [(version string, checksum)], newest first
def _manifests_dir():
return os.path.join(app_state.SEDONA_HOME, "manifests")
def _kits_dir():
return os.path.join(app_state.SEDONA_HOME, "kits")
def list_kits():
"""Kit names installed in Sedona home, alphabetically."""
directory = _manifests_dir()
if not os.path.isdir(directory):
return []
return sorted(
name for name in os.listdir(directory)
if os.path.isdir(os.path.join(directory, name))
)
def version_sort_key(version_string):
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
return numbers or [0]
def _norm_checksum(checksum):
"""Strips leading zeros so differently-padded hex checksums compare equal."""
return checksum.lstrip("0") if checksum else checksum
def kit_versions(kit_name):
"""Maps checksum -> version string, read from the .kit filenames under kits/.
Keys are stored with leading zeros stripped because .kit files and manifest
filenames sometimes disagree on padding (e.g. 462d43e vs 0462d43e).
"""
if kit_name in _versions_cache:
return _versions_cache[kit_name]
versions = {}
kit_dir = os.path.join(_kits_dir(), kit_name)
if os.path.isdir(kit_dir):
pattern = re.compile(rf"^{re.escape(kit_name)}-([0-9a-fA-F]+)-(.+)\.kit$")
for entry in os.listdir(kit_dir):
match = pattern.match(entry)
if match:
# A checksum released more than once (ontrolPlatform 1.0.0 and 1.0.1)
# stands for its highest version, as the devices report it.
key, version = _norm_checksum(match.group(1)), match.group(2)
if (key not in versions or
version_sort_key(version) > version_sort_key(versions[key])):
versions[key] = version
_versions_cache[kit_name] = versions
return versions
def resolve_manifest_path(kit_name):
"""Picks the manifest to describe a kit, returning (path, version_label).
Preference order:
1. the checksum the open file's <schema> pins, when it declares one
2. the highest real version number, via the .kit filenames
3. the last manifest by filename, which is what the app has always fallen back to
"""
kit_dir = os.path.join(_manifests_dir(), kit_name)
if not os.path.isdir(kit_dir):
return None, ""
manifests = sorted(
entry for entry in os.listdir(kit_dir)
if entry.startswith(f"{kit_name}-") and entry.endswith(".xml")
)
if not manifests:
return None, ""
versions = kit_versions(kit_name)
pinned = app_state.active_schema_kits.get(kit_name)
if pinned:
candidate = f"{kit_name}-{pinned}.xml"
if candidate in manifests:
return os.path.join(kit_dir, candidate), versions.get(_norm_checksum(pinned), pinned)
available = []
for entry in manifests:
checksum = entry[len(kit_name) + 1:-4]
norm = _norm_checksum(checksum)
if norm in versions:
available.append((version_sort_key(versions[norm]), entry, versions[norm]))
if available:
available.sort()
_, entry, version_label = available[-1]
return os.path.join(kit_dir, entry), version_label
entry = manifests[-1]
checksum = entry[len(kit_name) + 1:-4]
return os.path.join(kit_dir, entry), versions.get(_norm_checksum(checksum), "")
def kit_builds(kit_name):
"""Every build of a kit this Sedona home can describe, newest version first.
One entry per manifest under manifests/<kit>, as (version string, checksum). A
manifest with no matching .kit file has no version to report, so its version is empty
and such builds sort last — there is nothing to order them by.
"""
if kit_name in _builds_cache:
return _builds_cache[kit_name]
kit_dir = os.path.join(_manifests_dir(), kit_name)
builds = []
if os.path.isdir(kit_dir):
versions = kit_versions(kit_name)
for entry in sorted(os.listdir(kit_dir)):
if not (entry.startswith(f"{kit_name}-") and entry.endswith(".xml")):
continue
checksum = entry[len(kit_name) + 1:-4]
builds.append((versions.get(_norm_checksum(checksum), ""), checksum))
builds.sort(
key=lambda build: (bool(build[0]), version_sort_key(build[0])),
reverse=True
)
_builds_cache[kit_name] = builds
return builds
def latest_build(kit_name):
"""The newest build of a kit, as (version string, checksum), or None if it has none."""
builds = kit_builds(kit_name)
return builds[0] if builds else None
def _load_kit(kit_name):
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
if kit_name in _manifest_cache:
return _manifest_cache[kit_name]
path, version_label = resolve_manifest_path(kit_name)
types = {}
if path:
try:
root = ET.parse(path).getroot()
for type_element in root.findall("type"):
types[type_element.get("name")] = type_element
except Exception as error:
print(f"Palette: cannot read manifest {path}: {error}")
_manifest_cache[kit_name] = (version_label, types)
return _manifest_cache[kit_name]
def kit_version_label(kit_name):
return _load_kit(kit_name)[0]
def resolve_checksum(kit_name):
"""Checksum of the manifest this palette resolved for a kit, or None."""
path, _ = resolve_manifest_path(kit_name)
if not path:
return None
filename = os.path.basename(path)
if not filename.startswith(f"{kit_name}-") or not filename.endswith(".xml"):
return None
return filename[len(kit_name) + 1:-4]
def version_for_checksum(kit_name, checksum):
"""The version a kit's checksum stands for, read from the .kit filenames.
Returns "" when Sedona home has no .kit for that checksum, which is the honest
answer for a file pinned to a build this machine does not hold.
"""
if not checksum:
return ""
return kit_versions(kit_name).get(_norm_checksum(checksum), "")
def _find_type(qualified_name):
if "::" not in qualified_name:
return None
kit_name, type_name = qualified_name.split("::", 1)
return _load_kit(kit_name)[1].get(type_name)
def _is_component(qualified_name, seen=None):
"""True when the base chain reaches sys::Component, so the type can live in an app."""
if qualified_name == "sys::Component":
return True
if qualified_name in _component_cache:
return _component_cache[qualified_name]
seen = seen or set()
if qualified_name in seen:
return False
seen.add(qualified_name)
type_element = _find_type(qualified_name)
if type_element is None:
result = False
else:
base = type_element.get("base")
result = bool(base) and _is_component(base, seen)
_component_cache[qualified_name] = result
return result
def _hidden_from_palette(type_element):
"""True when the type's own facets carry @palette=false.
Only the type-level <facets> counts; slots have <facets> of their own nested deeper.
"""
facets = type_element.find("facets")
if facets is None:
return False
for facet in facets.findall("bool"):
if facet.get("name") == "palette" and facet.get("val") == "false":
return True
return False
def list_component_types(kit_name):
"""Concrete component types in a kit, as (type name, base) pairs.
Primitives (no base), abstract types (the 'a' flag) and types marked @palette=false
are left out — none of them belong in the palette.
"""
_, types = _load_kit(kit_name)
entries = []
for type_name, type_element in types.items():
if "a" in (type_element.get("flags") or ""):
continue
if _hidden_from_palette(type_element):
continue
if not _is_component(f"{kit_name}::{type_name}"):
continue
entries.append((type_name, type_element.get("base") or ""))
return sorted(entries)
_unsupported_cache = {} # frozenset(sax kit names) -> set of unsupported kit names
def _platforms_db():
return os.path.join(app_state.SEDONA_HOME, "platforms", "db")
def _platform_native_kits(sax_names=None):
"""Return (matching, all_platforms) — two sets of kit names.
*matching* is the union of nativeKit names from platform builds that include
every PlatformService-providing kit the SAX declares. *all_platforms* is the
union across every build in the database regardless of match.
A kit that appears in *all_platforms* but not in *matching* has native code
compiled for a different device and cannot work with this SAX.
sax_names stands in for the open file's kits when given.
"""
sax_names = set(app_state.active_schema_kits.keys() if sax_names is None else sax_names)
db = _platforms_db()
if not os.path.isdir(db):
return None, None
sax_platform_kits = set()
for name in sax_names:
kit_dir = os.path.join(_manifests_dir(), name)
if not os.path.isdir(kit_dir):
continue
for entry in os.listdir(kit_dir):
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
continue
try:
tree = ET.parse(os.path.join(kit_dir, entry))
except ET.ParseError:
continue
for t in tree.getroot().findall(".//type"):
text = t.get("name", "") + " " + t.get("base", "")
if "PlatformService" in text:
sax_platform_kits.add(name)
break
if not sax_platform_kits:
return None, None
matching = set()
all_platforms = set()
found = False
for vendor in os.listdir(db):
vd = os.path.join(db, vendor)
if not os.path.isdir(vd):
continue
for device in os.listdir(vd):
dd = os.path.join(vd, device)
if not os.path.isdir(dd):
continue
for build in os.listdir(dd):
mf = os.path.join(dd, build, ".par", "platformManifest.xml")
if not os.path.isfile(mf):
continue
try:
tree = ET.parse(mf)
except ET.ParseError:
continue
nk_names = set()
for nk in tree.getroot().findall(".//nativeKit"):
nk_names.add(nk.get("depend", "").split()[0])
all_platforms.update(nk_names)
if sax_platform_kits <= nk_names:
matching.update(nk_names)
found = True
return (matching if found else None), all_platforms
def unsupported_kits(sax_names=None):
"""Kit names that cannot work with the open file's platform -- or with the platform
of the kits in `sax_names`, when given.
A kit is unsupported when:
- it has native code (hasNatives or listed as nativeKit on some platform)
but is not compiled into any build matching the SAX's device, or
- it provides a PlatformService type for a different device.
Kits that depend on an unsupported kit are also flagged.
"""
sax_names = frozenset(app_state.active_schema_kits.keys() if sax_names is None
else sax_names)
if sax_names in _unsupported_cache:
return _unsupported_cache[sax_names]
matching_natives, all_natives = _platform_native_kits(sax_names)
if matching_natives is None:
_unsupported_cache[sax_names] = set()
return set()
unsupported = set()
all_kits = list_kits()
for name in all_kits:
if name in sax_names:
continue
# Native on another platform but not on the matching one.
if name in all_natives and name not in matching_natives:
unsupported.add(name)
continue
kit_dir = os.path.join(_manifests_dir(), name)
if not os.path.isdir(kit_dir):
continue
for entry in os.listdir(kit_dir):
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
continue
try:
tree = ET.parse(os.path.join(kit_dir, entry))
except ET.ParseError:
continue
root = tree.getroot()
has_natives = root.get("hasNatives") == "true"
has_platform_type = any(
"PlatformService" in (t.get("name", "") + " " + t.get("base", ""))
for t in root.findall(".//type")
)
if (has_natives or has_platform_type) and name not in matching_natives:
unsupported.add(name)
break
# Transitive: kits that depend on an unsupported kit
changed = True
while changed:
changed = False
for name in all_kits:
if name in unsupported or name in sax_names:
continue
kit_dir = os.path.join(_manifests_dir(), name)
if not os.path.isdir(kit_dir):
continue
for entry in os.listdir(kit_dir):
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
continue
try:
tree = ET.parse(os.path.join(kit_dir, entry))
except ET.ParseError:
continue
for dep in tree.getroot().findall(".//depend"):
dep_name = dep.get("on", "").split()[0]
if dep_name in unsupported:
unsupported.add(name)
changed = True
break
_unsupported_cache[sax_names] = unsupported
return unsupported
def invalidate():
"""Clears every cache, for when Sedona home changes or a kit is repinned."""
_versions_cache.clear()
_manifest_cache.clear()
_component_cache.clear()
_builds_cache.clear()
_unsupported_cache.clear()