Placing a palette component no longer edits <schema> straight away. The kit is noted as pending, its manifest is compiled so the box can be painted, and the status bar says the kit will be declared on save. The <schema> block is then reconciled when the file is written, in the one place that can report the whole kit list the file will carry, with each kit's version and checksum. What a kit's manifest depends on counts as used too, transitively, so placing a component can bring in more than one entry: hvac pulls in types, ontrolPion pulls in ontrolPlatform. New Kits > Manage Kits... dialog lists every kit in three groups -- needed by the app, declared but unused, and merely installed -- with the pinned build, the newest installed build, and an Action dropdown of every build a kit ships. The tick means the schema declares the kit: grey and fixed for kits the app needs, unticked to remove, ticked to add at the newest build. "Upgrade all kits to latest version" moves every declared kit; "Update kits" applies the ticks and the Action column, additions and removals included. Every schema change is applied as a batch, then verified: a missing manifest or a component type that stops resolving reverts the whole block and reports why, and a kit the app needs cannot be removed. Changes are one undo step, which recompiles the slot registry in both directions since a repin can change a component's slots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
7.8 KiB
Python
245 lines
7.8 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 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 <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(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 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(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(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)
|
|
|
|
|
|
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()
|