Files
sedona_editor/schema.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

281 lines
10 KiB
Python

"""Kit manifest loading, slot inheritance, and which slots are worth showing."""
import os
import xml.etree.ElementTree as ET
import app_state
_dependency_cache = {} # manifest path -> tuple of kit names it depends on
class MissingManifestError(Exception):
"""A kit the file needs has no manifest under the Sedona home folder.
Raised rather than handled here, so the caller decides what to abandon: opening a
file abandons the whole load, placing a palette component only abandons that
component. Carrying the kit names lets either one name them in its own wording.
"""
def __init__(self, kit_names, unresolved_type=None):
self.kit_names = sorted(set(kit_names))
self.unresolved_type = unresolved_type
super().__init__(", ".join(self.kit_names))
def describe_missing_manifests(error):
"""The body of the dialog both callers show, pointing at the folder to check."""
kit_list = "\n".join(f" • {name}" for name in error.kit_names)
home = app_state.SEDONA_HOME or "(not set)"
lines = ["No manifest was found for:", "", kit_list, "", f"Sedona home folder:\n {home}", ""]
if error.unresolved_type:
lines.append(
f"Type '{error.unresolved_type}' inherits from a kit that is not in the "
"file's schema block, so its slots cannot be resolved."
)
lines.append("")
lines.append(
"Check that the Sedona home folder is the right one and that it holds "
"manifests/<kit>/<kit>-<checksum>.xml for each kit listed above. "
"Preferences → Sedona home folder points it somewhere else."
)
return "\n".join(lines)
def read_kit_dependencies(kit_name, checksum=None):
"""The kits a manifest declares its kit was built against, by name only.
A manifest lists them as <depend on="sys 1+"/>; the version bound is dropped here,
since the schema block pins a build by checksum and has nowhere to record a range.
An unreadable or absent manifest yields no dependencies rather than an error: the
callers use this to widen a list, and a kit whose manifest is missing is already
reported by whatever needed its types.
"""
manifest_path = find_manifest_path(kit_name, checksum)
if not manifest_path:
return ()
if manifest_path in _dependency_cache:
return _dependency_cache[manifest_path]
dependencies = []
try:
root = ET.parse(manifest_path).getroot()
depends_element = root.find("depends")
if depends_element is not None:
for depend in depends_element.findall("depend"):
on = (depend.get("on") or "").strip()
if on:
dependencies.append(on.split()[0])
except Exception as error:
print(f"Cannot read dependencies of {manifest_path}: {error}")
_dependency_cache[manifest_path] = tuple(dependencies)
return _dependency_cache[manifest_path]
def read_summary_facet(slot_element):
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
facets = slot_element.find("facets")
if facets is None:
return True
for bool_facet in facets.findall("bool"):
if bool_facet.get("name") == "summary":
return (bool_facet.get("val", "true").strip().lower() != "false")
return True
def read_readonly_facet(slot_element):
"""Reads a slot's '@readonly' facet. Absent means writable."""
facets = slot_element.find("facets")
if facets is None:
return False
for bool_facet in facets.findall("bool"):
if bool_facet.get("name") == "readonly":
return (bool_facet.get("val", "false").strip().lower() == "true")
return False
def get_slots_with_inheritance(type_name, kit_schemas):
"""Recursively resolves the slots for a given type by traversing its base chain."""
if type_name not in kit_schemas:
kit_prefix = type_name.split("::")[0] if "::" in type_name else type_name
raise MissingManifestError([kit_prefix], unresolved_type=type_name)
type_element = kit_schemas[type_name]
base_type = type_element.get("base")
resolved_slots = []
if base_type:
resolved_slots = get_slots_with_inheritance(base_type, kit_schemas)
current_slots = []
for slot in type_element.findall("slot"):
current_slots.append({
"name": slot.get("name"),
"type": slot.get("type"),
"default": slot.get("default", "false"),
"flags": slot.get("flags", ""),
"readonly": read_readonly_facet(slot),
"summary": read_summary_facet(slot),
"declared_id": int(slot.get("id"))
})
current_slots.sort(key=lambda s: s["declared_id"])
resolved_slots.extend(current_slots)
return resolved_slots
def compile_schema_registry(manifest_paths):
"""Loads and maps types across multiple kit files, ensuring all dependencies exist."""
kit_schemas = {}
for path in manifest_paths:
try:
tree = ET.parse(path)
root = tree.getroot()
kit_name = root.get("name")
for type_tag in root.findall("type"):
type_name = f"{kit_name}::{type_tag.get('name')}"
kit_schemas[type_name] = type_tag
except Exception as e:
print(f"Error parsing manifest {path}: {e}")
app_state.final_components_registry.clear()
for type_name in kit_schemas.keys():
flat_slots = get_slots_with_inheritance(type_name, kit_schemas)
resolved_type_structure = []
for sequence_id, slot_data in enumerate(flat_slots):
resolved_type_structure.append({
"slot_id": sequence_id,
"name": slot_data["name"],
"type": slot_data["type"],
"default": slot_data["default"],
"flags": slot_data["flags"],
"readonly": slot_data["readonly"],
"summary": slot_data["summary"]
})
app_state.final_components_registry[type_name] = resolved_type_structure
def find_manifest_path(kit_name, checksum):
"""The manifest file describing a kit, or None when Sedona home holds none.
A declared checksum is honoured when its file exists; otherwise the last manifest by
filename is taken, the tolerance that lets a file with no checksums still open.
"""
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
if checksum:
target_file = os.path.join(manifest_dir, f"{kit_name}-{checksum}.xml")
if os.path.exists(target_file):
return target_file
if os.path.exists(manifest_dir):
files = [
f for f in os.listdir(manifest_dir)
if f.startswith(f"{kit_name}-") and f.endswith(".xml")
]
if files:
files.sort()
return os.path.join(manifest_dir, files[-1])
return None
def load_schema_kits_and_manifests():
"""Loads the manifests for the file's <schema> kits, plus any kit still only pending.
A component placed from the palette records its kit in ``pending_schema_kits`` rather
than writing to <schema> straight away — that reconciliation happens on save. Its
types still have to resolve now, or the box could not be painted, so pending kits are
compiled into the registry alongside the declared ones.
"""
app_state.active_schema_kits.clear()
if app_state.xml_root_element is None:
return
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None:
return
for kit in schema_elem.findall("kit"):
name = kit.get("name")
if name:
app_state.active_schema_kits[name] = kit.get("checksum")
kits_to_load = dict(app_state.active_schema_kits)
for name, checksum in app_state.pending_schema_kits.items():
kits_to_load.setdefault(name, checksum)
manifest_paths = []
missing_kits = []
for name, checksum in kits_to_load.items():
manifest_path = find_manifest_path(name, checksum)
if manifest_path:
manifest_paths.append(manifest_path)
else:
# Every missing kit is collected before reporting, so one dialog names them
# all rather than the user fixing them one reopen at a time.
missing_kits.append(name)
if missing_kits:
raise MissingManifestError(missing_kits)
compile_schema_registry(manifest_paths)
def get_all_slots_for_type(type_str):
"""Returns every slot for a type as (name, type_string, flags, readonly) tuples."""
if type_str not in app_state.final_components_registry:
return []
return [
(slot["name"], slot["type"], slot["flags"], slot["readonly"])
for slot in app_state.final_components_registry[type_str]
if slot["name"]
]
def get_slots_for_type(type_str, linked_slots=()):
"""
Returns the slots worth painting for a given type, prefixed with double-digit
index tags taken from the manifest so hidden rows leave gaps rather than renumber.
Visibility, in order of precedence:
- a linked slot is always shown, so its wire has a row to anchor to
- '@summary false' hides the slot, even when it is runtime
- runtime slots (no 'c' flag) are shown; they hold the live signal path
- config slots that nothing links to are hidden as clutter
- the 'o' (operator) flag hides a slot outright
"""
if type_str not in app_state.final_components_registry:
return []
slots_list = []
for slot_data in app_state.final_components_registry[type_str]:
slot_name = slot_data["name"]
if not slot_name:
continue
if "o" in slot_data["flags"]:
continue
is_linked = slot_name in linked_slots
if not is_linked:
if not slot_data.get("summary", True):
continue
if "c" in slot_data["flags"]:
continue
# Format to double digits (e.g., 00, 01, 02)
padded_id = f"{slot_data['slot_id']:02d}"
display_label = f"{padded_id}. {slot_name}"
# Keep structural name unchanged for XML lookup inside comp, but pass display label
slots_list.append((slot_name, display_label))
return slots_list