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>
This commit is contained in:
2026-08-21 12:06:25 +03:00
co-authored by Claude Opus 5
parent b286b208da
commit 01dadea610
8 changed files with 851 additions and 45 deletions
+228 -40
View File
@@ -10,7 +10,13 @@ import palette
import schema
import undo
import wiresheet
from sax_dom import find_parent_element, get_component_path, update_meta_property
from sax_dom import (
find_parent_element,
get_component_path,
restore_children,
snapshot_children,
update_meta_property,
)
def parse_xml_to_tree(treeview, parent_node, xml_element):
@@ -361,53 +367,62 @@ def used_kit_names():
return names
def required_kit_names():
"""Every kit this app cannot run without: those its components name, plus their
dependencies, and the dependencies of those, all the way down.
A kit's manifest records the kits it was built against, and the device's scode needs
those just as much as the one a component names — so as far as the schema block is
concerned, being depended on by a used kit is the same as being used.
"""
unvisited = list(used_kit_names())
required = set(unvisited)
while unvisited:
kit_name = unvisited.pop()
checksum = (app_state.active_schema_kits.get(kit_name)
or app_state.pending_schema_kits.get(kit_name))
for dependency in schema.read_kit_dependencies(kit_name, checksum):
if dependency not in required:
required.add(dependency)
unvisited.append(dependency)
return required
def declare_used_kits():
"""Adds a <kit> entry for every used kit the <schema> block does not list.
"""Adds a <kit> entry for every kit the app needs that the <schema> block does not list.
The DOM is the authority on what is used, not the pending list, so a placement that
was later undone cannot leave a kit declared for nothing. Pending entries only supply
the checksum of the manifest the palette resolved when the component was placed.
A kit that a used kit depends on counts as needed too, so placing one component can
bring in more than one entry.
Entries already in the file are left as they are — a missing checksum there is a
deliberate tolerance on load, not something to backfill.
Returns (added elements in the order written, whitespace to put back if the caller
rolls the insertion out).
Returns the <kit> elements that were added, in the order they were written.
"""
if app_state.xml_root_element is None:
return [], []
return []
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None:
return [], []
return []
declared = {kit.get("name") for kit in schema_elem.findall("kit")}
added = []
tail_restores = []
for name in sorted(used_kit_names() - declared):
kit_element = ET.Element("kit")
kit_element.set("name", name)
for name in sorted(required_kit_names() - declared):
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
if checksum:
kit_element.set("checksum", checksum)
# Added after the last existing kit. The block is not alphabetical — sys comes
# first by convention — so appending is safer than guessing a sort position.
insert_at = len(list(schema_elem))
for index, existing in enumerate(list(schema_elem)):
if existing.tag == "kit":
insert_at = index + 1
schema_elem.insert(insert_at, kit_element)
restore = indent_kit_entry(schema_elem, kit_element)
if restore is not None:
tail_restores.append(restore)
kit_element = insert_kit_element(schema_elem, name, checksum)
app_state.active_schema_kits[name] = checksum
added.append(kit_element)
return added, tail_restores
return added
def indent_kit_entry(schema_elem, kit_element):
@@ -418,9 +433,8 @@ def indent_kit_entry(schema_elem, kit_element):
the tail that used to close the block, and the entry before it takes the tail that
separates two kits.
Handing a tail on means writing to a neighbour, so the pair needed to put that
neighbour back is returned for a caller that may have to undo the insertion. It is
(element, tail) for a block that already held kits, and None for one that did not.
Callers that may have to undo the insertion snapshot the whole block instead of
tracking these tails, so nothing is reported back.
"""
kits = [child for child in schema_elem if child.tag == "kit" and child is not kit_element]
@@ -430,13 +444,50 @@ def indent_kit_entry(schema_elem, kit_element):
existing_text = schema_elem.text or ""
kit_element.tail = existing_text if existing_text.strip() == "" and existing_text else "\n"
schema_elem.text = "\n "
return None
return
previous = kits[-1]
restore = (previous, previous.tail)
kit_element.tail = previous.tail
previous.tail = kits[-2].tail if len(kits) >= 2 else "\n "
return restore
def insert_kit_element(schema_elem, kit_name, checksum):
"""Builds a <kit> entry, places it after the last existing one, and returns it.
The block is not alphabetical — sys comes first by convention — so appending is safer
than guessing a sort position.
"""
kit_element = ET.Element("kit")
kit_element.set("name", kit_name)
if checksum:
kit_element.set("checksum", checksum)
insert_at = len(list(schema_elem))
for index, existing in enumerate(list(schema_elem)):
if existing.tag == "kit":
insert_at = index + 1
schema_elem.insert(insert_at, kit_element)
indent_kit_entry(schema_elem, kit_element)
return kit_element
def remove_kit_element(schema_elem, kit_element):
"""Takes a <kit> entry out of the block, leaving its line breaks tidy.
Whitespace lives in the tail of the element before it, so the entry that closes the
block is the one carrying the break that puts </schema> on its own line: when that is
the entry going away, its tail passes back to whatever now ends the block.
"""
kits = [child for child in schema_elem if child.tag == "kit"]
if kits and kits[-1] is kit_element:
if len(kits) >= 2:
kits[-2].tail = kit_element.tail
else:
schema_elem.text = kit_element.tail
schema_elem.remove(kit_element)
def format_kit_row(marker, kit_name, checksum):
@@ -456,8 +507,8 @@ def describe_schema_kits(added_elements):
kit_word = "kit is" if len(added_names) == 1 else "kits are"
lines = [
f"The following {kit_word} used by a component but not declared in this app's "
"schema, and has been added to it:",
f"The following {kit_word} needed by this app — by a component, or by a kit one "
"of them depends on — but not declared in its schema, and has been added to it:",
"",
]
lines.extend(
@@ -484,13 +535,155 @@ def describe_schema_kits(added_elements):
return "\n".join(lines)
def schema_kit_checksums():
"""The file's declared kits as an ordered list of (name, checksum), checksum may be None."""
if app_state.xml_root_element is None:
return []
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None:
return []
return [
(kit.get("name"), kit.get("checksum"))
for kit in schema_elem.findall("kit")
if kit.get("name")
]
def unresolved_component_types():
"""Types placed in the app that the current registry cannot describe, sorted.
Repinning a kit to another build can drop a type the app still uses, which would leave
those components on the sheet with no slots. Checked after every schema change so the
change can be refused rather than silently emptying a box.
"""
if app_state.xml_root_element is None:
return []
missing = set()
for comp in app_state.xml_root_element.iter("comp"):
type_str = comp.get("type")
if type_str and type_str not in app_state.final_components_registry:
missing.add(type_str)
return sorted(missing)
def apply_kit_selections(selections, removals=(), label="update kits"):
"""Repins, adds and drops <kit> entries in one go.
``selections`` is an iterable of (kit name, checksum) the block should end up
declaring; ``removals`` is kit names it should stop declaring. Entries that already
match are skipped, so the return value describes only what really moved.
The whole set is applied, then the registry is recompiled once and checked: if a
manifest is missing, or a type the app uses no longer resolves, the block goes back to
how it was and the reason is returned instead. Nothing is half-applied.
Returns (changes, error) where changes is a list of (kit name, old checksum, new
checksum) — a new checksum of None meaning the entry was dropped — and error is None
on success.
"""
if app_state.xml_root_element is None:
return [], "No file is open."
schema_elem = app_state.xml_root_element.find("schema")
if schema_elem is None:
return [], "This file has no <schema> block to record kits in."
before = snapshot_children(schema_elem)
# Whatever the file could not describe already is not this change's doing, and holding
# it against the change would lock the kit dialog on the very file that needs it.
before_unresolved = set(unresolved_component_types())
# A kit some component needs, directly or through another kit's dependencies, cannot
# be dropped whatever the caller asks: the app would not run without it.
protected = sorted(set(removals) & required_kit_names())
if protected:
return [], (
"No kit was changed.\n\nThese kits cannot be removed, because this app "
"needs them:\n\n"
+ "\n".join(f" • {name}" for name in protected)
)
changes = []
for kit_name in removals:
for kit in schema_elem.findall("kit"):
if kit.get("name") == kit_name:
changes.append((kit_name, kit.get("checksum"), None))
remove_kit_element(schema_elem, kit)
app_state.active_schema_kits.pop(kit_name, None)
break
for kit_name, checksum in selections:
if not kit_name or not checksum:
continue
existing = None
for kit in schema_elem.findall("kit"):
if kit.get("name") == kit_name:
existing = kit
break
if existing is None:
insert_kit_element(schema_elem, kit_name, checksum)
changes.append((kit_name, None, checksum))
elif existing.get("checksum") != checksum:
old_checksum = existing.get("checksum")
existing.set("checksum", checksum)
changes.append((kit_name, old_checksum, checksum))
if not changes:
return [], None
def abandon(reason):
restore_children(schema_elem, before)
undo.reload_schema_registry()
return [], reason
# The palette resolves a kit through the checksum the schema pins, so its cached
# manifests describe the builds that were pinned a moment ago.
palette.invalidate()
try:
schema.load_schema_kits_and_manifests()
except schema.MissingManifestError as missing:
return abandon(
"No kit was changed.\n\n" + schema.describe_missing_manifests(missing)
)
unresolved = sorted(set(unresolved_component_types()) - before_unresolved)
if unresolved:
type_list = "\n".join(f" • {name}" for name in unresolved)
return abandon(
"No kit was changed.\n\nIllegal components:\n\n"
f"{type_list}\n\nFor correcting:\n\n- Delete those components first\nor\n"
"- Add related kits."
)
for kit_name, _, checksum in changes:
app_state.pending_schema_kits.pop(kit_name, None)
undo.push(undo.SchemaBlockCommand(
schema_elem, before, snapshot_children(schema_elem), label=label
))
app_state.mark_dirty()
refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_current_sheet()
return changes, None
def reconcile_schema_kits_before_save():
"""Brings <schema> in step with the components, warning before anything is written.
Returns False when the user backs out, in which case the added entries are taken back
out and the save is abandoned.
"""
added, tail_restores = declare_used_kits()
schema_elem = app_state.xml_root_element.find("schema")
before = snapshot_children(schema_elem) if schema_elem is not None else None
added = declare_used_kits()
if not added:
return True
@@ -501,14 +694,9 @@ def reconcile_schema_kits_before_save():
)
if not proceed:
schema_elem = app_state.xml_root_element.find("schema")
restore_children(schema_elem, before)
for kit_element in added:
if schema_elem is not None and kit_element in list(schema_elem):
schema_elem.remove(kit_element)
app_state.active_schema_kits.pop(kit_element.get("name"), None)
# Oldest first, so the entry that ends the block gets the last word on its tail.
for element, tail in reversed(tail_restores):
element.tail = tail
return False
# Declared in the file now, so nothing about them is pending any more.