Files
sedona_editor/palette.py
T
ArdaandClaude Opus 5 01dadea610 Manage kits: reconcile the schema on save, and a dialog to repin builds
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>
2026-08-21 12:06:25 +03:00

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()