"""Component palette: the kits installed in Sedona home, and the components inside each. Deliberately sourced from Sedona home rather than the open file's , 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', 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 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/, 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 counts; slots have 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(): """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()