Scan sedona/platforms/db/ to find which native kits are compiled for the SAX's platform. Kits with native code for other platforms, kits providing a PlatformService for a different device, and their transitive dependents are placed in a "Not supported" section with a red-cross checkbox that cannot be toggled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
14 KiB
Python
408 lines
14 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:
|
|
versions[_norm_checksum(match.group(1))] = match.group(2)
|
|
|
|
_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 list_component_types(kit_name):
|
|
"""Concrete component types in a kit, as (type name, base) pairs.
|
|
|
|
Primitives (no base) and abstract types (the 'a' flag) are left out — neither can be
|
|
placed in an application.
|
|
"""
|
|
_, types = _load_kit(kit_name)
|
|
|
|
entries = []
|
|
for type_name, type_element in types.items():
|
|
if "a" in (type_element.get("flags") or ""):
|
|
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():
|
|
"""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 = set(app_state.active_schema_kits.keys())
|
|
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():
|
|
"""Kit names that cannot work with the open file's platform.
|
|
|
|
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 in _unsupported_cache:
|
|
return _unsupported_cache[sax_names]
|
|
|
|
matching_natives, all_natives = _platform_native_kits()
|
|
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()
|