"""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 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 kit_versions(kit_name): """Maps checksum -> version string, read from the .kit filenames under kits/.""" 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[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(pinned, pinned) available = [] for entry in manifests: checksum = entry[len(kit_name) + 1:-4] if checksum in versions: available.append((_version_sort_key(versions[checksum]), entry, versions[checksum])) 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(checksum, "") 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 _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) def invalidate(): """Clears every cache, for when Sedona home changes in Preferences.""" _versions_cache.clear() _manifest_cache.clear() _component_cache.clear()