366 lines
13 KiB
Python
366 lines
13 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_all_facets(slot_element):
|
|
"""Every facet on a slot, as {name: value} with each one typed by its own tag.
|
|
|
|
The manifest writes facets as <str name="unit" val="celsius"/>, <int name="max"
|
|
val="1000"/> and so on, so the tag is the type. The Properties dialog needs the lot —
|
|
unit, precision, min, max, trueText/falseText, range — and there are few enough of
|
|
them per slot that carrying the whole dict costs less than a lookup per facet.
|
|
"""
|
|
facets = {}
|
|
facet_element = slot_element.find("facets")
|
|
if facet_element is None:
|
|
return facets
|
|
|
|
for child in facet_element:
|
|
name = child.get("name")
|
|
if not name:
|
|
continue
|
|
raw = child.get("val", "")
|
|
if child.tag == "bool":
|
|
facets[name] = (raw.strip().lower() == "true")
|
|
elif child.tag == "int":
|
|
try:
|
|
facets[name] = int(raw)
|
|
except ValueError:
|
|
facets[name] = raw
|
|
elif child.tag in ("float", "double"):
|
|
try:
|
|
facets[name] = float(raw)
|
|
except ValueError:
|
|
facets[name] = raw
|
|
else:
|
|
facets[name] = raw
|
|
return facets
|
|
|
|
|
|
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"), # absent means "the type's own zero"
|
|
"flags": slot.get("flags", ""),
|
|
"readonly": read_readonly_facet(slot),
|
|
"summary": read_summary_facet(slot),
|
|
"facets": read_all_facets(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"],
|
|
"facets": slot_data["facets"]
|
|
})
|
|
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 resolve_truncated_checksum(kit_name, prefix):
|
|
"""The checksum of the one local manifest whose leading bytes match ``prefix``.
|
|
|
|
Used when a device's schema reply cut a kit's checksum short. Returns the
|
|
checksum as a signed 32-bit int (as read off the wire), or None when no
|
|
manifest, or more than one, matches.
|
|
"""
|
|
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
|
|
if not os.path.isdir(manifest_dir):
|
|
return None
|
|
want = prefix.hex()
|
|
matches = set()
|
|
for f in os.listdir(manifest_dir):
|
|
if f.startswith(f"{kit_name}-") and f.endswith(".xml"):
|
|
hex_part = f[len(kit_name) + 1:-4].zfill(8).lower()
|
|
if hex_part.startswith(want):
|
|
matches.add(hex_part)
|
|
if len(matches) != 1:
|
|
return None
|
|
value = int(matches.pop(), 16)
|
|
return value - (1 << 32) if value & 0x80000000 else value
|
|
|
|
|
|
def find_manifest(kit_name, checksum):
|
|
"""Returns the parsed ET root of a kit manifest, or None."""
|
|
checksum_hex = f"{checksum & 0xFFFFFFFF:08x}" if isinstance(checksum, int) else checksum
|
|
path = find_manifest_path(kit_name, checksum_hex)
|
|
if path is None:
|
|
return None
|
|
try:
|
|
return ET.parse(path).getroot()
|
|
except Exception:
|
|
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_full_slots_for_type(type_str):
|
|
"""Every slot of a type, whole, in manifest order — nothing filtered out.
|
|
|
|
get_slots_for_type hides most of them, which is right for a wiresheet box and wrong
|
|
for the Properties dialog: a config slot is only editable somewhere, and this is
|
|
that somewhere. Copies are handed out so a caller cannot edit the registry in place.
|
|
"""
|
|
return [dict(slot) for slot in app_state.final_components_registry.get(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), unless turned off in Preferences
|
|
if app_state.SHOW_SLOT_NUMBERS:
|
|
display_label = f"{slot_data['slot_id']:02d}. {slot_name}"
|
|
else:
|
|
display_label = 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
|