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:
+1
-1
@@ -110,7 +110,7 @@ def save_preferences():
|
|||||||
|
|
||||||
def base_window_title():
|
def base_window_title():
|
||||||
"""The window title with no file context, which handlers extend with their own suffix."""
|
"""The window title with no file context, which handlers extend with their own suffix."""
|
||||||
return f"Sedona SAX Tree View Viewer & Program Editor [v{VERSION}]"
|
return f"Sedona SAX Offline Program Editor [v{VERSION}]"
|
||||||
|
|
||||||
|
|
||||||
def set_status(message, warn=False):
|
def set_status(message, warn=False):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import tkinter as tk
|
|||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
|
import manage_kits
|
||||||
import palette
|
import palette
|
||||||
import sax_file
|
import sax_file
|
||||||
import schema
|
import schema
|
||||||
@@ -768,6 +769,13 @@ menu_edit.add_separator()
|
|||||||
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
|
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
|
||||||
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
||||||
|
|
||||||
|
menu_kits = tk.Menu(menu_bar, tearoff=0)
|
||||||
|
menu_kits.add_command(
|
||||||
|
label="Manage Kits...",
|
||||||
|
command=lambda: manage_kits.open_dialog(on_applied=refresh_palette_kits)
|
||||||
|
)
|
||||||
|
menu_bar.add_cascade(label="Kits", menu=menu_kits)
|
||||||
|
|
||||||
menu_layout = tk.Menu(menu_bar, tearoff=0)
|
menu_layout = tk.Menu(menu_bar, tearoff=0)
|
||||||
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
|
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
|
||||||
menu_bar.add_cascade(label="Layout", menu=menu_layout)
|
menu_bar.add_cascade(label="Layout", menu=menu_layout)
|
||||||
|
|||||||
+480
@@ -0,0 +1,480 @@
|
|||||||
|
"""The Manage Kits dialog: what the app pins, what Sedona home offers, and repinning.
|
||||||
|
|
||||||
|
The table answers two questions per kit — which build the app is pinned to, and which
|
||||||
|
builds exist — and the Action column is where a different build is chosen. Nothing is
|
||||||
|
written to the file until one of the buttons is pressed, so the whole table can be set up
|
||||||
|
first and reviewed as a batch.
|
||||||
|
|
||||||
|
Rows come in three groups, in this order: kits the app needs — named by a component or
|
||||||
|
depended on by a kit that is — then kits the schema declares without needing, then
|
||||||
|
everything else installed in Sedona home.
|
||||||
|
|
||||||
|
The tick in the first column means "the schema declares this kit". A kit the app needs is
|
||||||
|
ticked and greyed, because removing it would stop the app running; one that is merely
|
||||||
|
declared can be unticked, which turns its Action into REMOVE. An installed kit that is not
|
||||||
|
declared starts unticked, and ticking it adds it at its newest build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
|
import app_state
|
||||||
|
import palette
|
||||||
|
import sax_file
|
||||||
|
|
||||||
|
NOT_DECLARED = "—" # em dash, for a column with nothing to report
|
||||||
|
NO_CHECKSUM = "(no checksum)"
|
||||||
|
REMOVE_ACTION = "REMOVE from app"
|
||||||
|
|
||||||
|
SECTION_TAG = "section"
|
||||||
|
|
||||||
|
CHECKBOX_SIZE = 13
|
||||||
|
|
||||||
|
# The tick, as filled 2x2 blocks: a short down-stroke into a long up-stroke.
|
||||||
|
TICK_BLOCKS = [
|
||||||
|
(2, 5), (3, 6), (4, 7), (5, 6), (6, 5), (7, 4), (8, 3), (9, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_checkbox_image(checked, enabled):
|
||||||
|
"""A checkbox drawn pixel by pixel, so a locked tick can be greyed out.
|
||||||
|
|
||||||
|
Tk has no disabled state for a cell, and a row tag would grey the whole row including
|
||||||
|
an Action the user can still change — so the box itself carries the distinction.
|
||||||
|
"""
|
||||||
|
image = tk.PhotoImage(width=CHECKBOX_SIZE, height=CHECKBOX_SIZE)
|
||||||
|
|
||||||
|
face = "#ffffff" if enabled else "#f0f0f0"
|
||||||
|
border = "#555555" if enabled else "#a8a8a8"
|
||||||
|
ink = "#1a1a1a" if enabled else "#909090"
|
||||||
|
|
||||||
|
image.put(face, to=(0, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||||
|
image.put(border, to=(0, 0, CHECKBOX_SIZE, 1))
|
||||||
|
image.put(border, to=(0, CHECKBOX_SIZE - 1, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||||
|
image.put(border, to=(0, 0, 1, CHECKBOX_SIZE))
|
||||||
|
image.put(border, to=(CHECKBOX_SIZE - 1, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||||
|
|
||||||
|
if checked:
|
||||||
|
for x, y in TICK_BLOCKS:
|
||||||
|
image.put(ink, to=(x, y, x + 2, y + 2))
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def build_label(version, checksum):
|
||||||
|
"""How a build is named in a sentence: its version, or its checksum when it has none."""
|
||||||
|
return version if version else f"checksum {checksum}"
|
||||||
|
|
||||||
|
|
||||||
|
def column_label(version, checksum):
|
||||||
|
"""How a build is named in the Installed and Latest columns, which are narrow."""
|
||||||
|
return version if version else checksum
|
||||||
|
|
||||||
|
|
||||||
|
def action_options(kit_name, current_checksum, is_declared, builds):
|
||||||
|
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
|
||||||
|
|
||||||
|
The no-op comes first so a row does nothing until the user picks something, and the
|
||||||
|
remaining entries read as upgrade or downgrade relative to the pinned build — which is
|
||||||
|
what makes the column scannable when a kit ships thirty builds.
|
||||||
|
"""
|
||||||
|
installed_version = None
|
||||||
|
for version, checksum in builds:
|
||||||
|
if checksum == current_checksum:
|
||||||
|
installed_version = version
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_declared:
|
||||||
|
options = [("Not in schema", None)]
|
||||||
|
elif current_checksum is None:
|
||||||
|
options = [("Keep with no checksum", None)]
|
||||||
|
elif installed_version is None:
|
||||||
|
# The file pins a build this machine does not hold, so there is nothing to name it
|
||||||
|
# by and no way to tell whether the other builds are newer or older than it.
|
||||||
|
options = [(f"Keep at checksum {current_checksum}", None)]
|
||||||
|
else:
|
||||||
|
options = [(f"Keep at {build_label(installed_version, current_checksum)}", None)]
|
||||||
|
|
||||||
|
installed_rank = palette.version_sort_key(installed_version) if installed_version else None
|
||||||
|
|
||||||
|
for version, checksum in builds:
|
||||||
|
if is_declared and checksum == current_checksum:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = build_label(version, checksum)
|
||||||
|
if not is_declared:
|
||||||
|
verb = "Add at"
|
||||||
|
elif installed_rank is None or not version:
|
||||||
|
verb = "Set to"
|
||||||
|
elif palette.version_sort_key(version) > installed_rank:
|
||||||
|
verb = "Upgrade to"
|
||||||
|
elif palette.version_sort_key(version) < installed_rank:
|
||||||
|
verb = "Downgrade to"
|
||||||
|
else:
|
||||||
|
verb = "Set to"
|
||||||
|
options.append((f"{verb} {label}", checksum))
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def collect_rows():
|
||||||
|
"""Every kit worth listing, as row dicts in the order they should appear.
|
||||||
|
|
||||||
|
A row carries what the table paints and what the buttons act on, so the dialog never
|
||||||
|
has to re-derive a kit's state from the DOM while it is open.
|
||||||
|
"""
|
||||||
|
declared = sax_file.schema_kit_checksums()
|
||||||
|
declared_names = [name for name, _ in declared]
|
||||||
|
required = sax_file.required_kit_names()
|
||||||
|
|
||||||
|
installed = palette.list_kits()
|
||||||
|
# A kit the schema declares but Sedona home does not hold still gets a row: it is the
|
||||||
|
# clearest place to see that the file points at something missing.
|
||||||
|
others = [name for name in installed if name not in declared_names]
|
||||||
|
|
||||||
|
groups = [
|
||||||
|
("Installed kits", [
|
||||||
|
(name, checksum) for name, checksum in declared if name in required
|
||||||
|
]),
|
||||||
|
("Installed but not used kits", [
|
||||||
|
(name, checksum) for name, checksum in declared if name not in required
|
||||||
|
]),
|
||||||
|
("Kits to be installed", [(name, None) for name in others]),
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for title, entries in groups:
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
rows.append({"section": title})
|
||||||
|
for name, checksum in entries:
|
||||||
|
builds = palette.kit_builds(name)
|
||||||
|
latest = builds[0] if builds else None
|
||||||
|
is_declared = name in declared_names
|
||||||
|
options = action_options(name, checksum, is_declared, builds)
|
||||||
|
|
||||||
|
if not is_declared:
|
||||||
|
installed_text = NOT_DECLARED
|
||||||
|
elif checksum is None:
|
||||||
|
installed_text = NO_CHECKSUM
|
||||||
|
else:
|
||||||
|
installed_text = column_label(
|
||||||
|
next((v for v, c in builds if c == checksum), ""), checksum
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"kit": name,
|
||||||
|
"declared": is_declared,
|
||||||
|
"required": name in required,
|
||||||
|
"checksum": checksum,
|
||||||
|
"builds": builds,
|
||||||
|
"latest": latest,
|
||||||
|
"installed_text": installed_text,
|
||||||
|
"latest_text": column_label(*latest) if latest else NOT_DECLARED,
|
||||||
|
"options": options,
|
||||||
|
"choice": 0,
|
||||||
|
# The tick reflects the file as it stands: declared kits start ticked.
|
||||||
|
"checked": is_declared,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def describe_changes(changes):
|
||||||
|
"""The applied-changes report, one kit per line, old build to new."""
|
||||||
|
lines = []
|
||||||
|
for kit_name, old_checksum, new_checksum in changes:
|
||||||
|
old_label = (
|
||||||
|
build_label(palette.version_for_checksum(kit_name, old_checksum), old_checksum)
|
||||||
|
if old_checksum else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_checksum is None:
|
||||||
|
was = f" (was {old_label})" if old_label else ""
|
||||||
|
lines.append(f" • {kit_name}: removed from the schema{was}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_label = build_label(
|
||||||
|
palette.version_for_checksum(kit_name, new_checksum), new_checksum
|
||||||
|
)
|
||||||
|
if old_checksum is None:
|
||||||
|
lines.append(f" • {kit_name}: added at {new_label}")
|
||||||
|
else:
|
||||||
|
lines.append(f" • {kit_name}: {old_label} → {new_label}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def open_dialog(on_applied=None):
|
||||||
|
"""Opens the kit table. ``on_applied`` is called after the schema really changed."""
|
||||||
|
window = app_state.window
|
||||||
|
|
||||||
|
if app_state.xml_root_element is None:
|
||||||
|
messagebox.showinfo("Manage Kits", "Open a SAX file first.")
|
||||||
|
return
|
||||||
|
if app_state.xml_root_element.find("schema") is None:
|
||||||
|
messagebox.showwarning("Manage Kits", "This file has no <schema> block to manage.")
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = tk.Toplevel(window)
|
||||||
|
dialog.title("Kits")
|
||||||
|
dialog.transient(window)
|
||||||
|
dialog.grab_set()
|
||||||
|
dialog.geometry(f"820x520+{window.winfo_x() + 60}+{window.winfo_y() + 40}")
|
||||||
|
dialog.minsize(640, 320)
|
||||||
|
|
||||||
|
frame = ttk.Frame(dialog, padding=10)
|
||||||
|
frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
COLUMNS = ("name", "installed", "latest", "action")
|
||||||
|
# 'tree headings' keeps the icon column, which is the only place a real checkbox image
|
||||||
|
# can live; the data columns follow it.
|
||||||
|
table = ttk.Treeview(frame, columns=COLUMNS, show="tree headings", selectmode="browse")
|
||||||
|
table.heading("#0", text="")
|
||||||
|
table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||||
|
for column, heading, width, anchor in [
|
||||||
|
("name", "Kit name", 200, "w"),
|
||||||
|
("installed", "Installed", 105, "w"),
|
||||||
|
("latest", "Latest", 105, "w"),
|
||||||
|
("action", "Action", 300, "w"),
|
||||||
|
]:
|
||||||
|
table.heading(column, text=heading, anchor=anchor)
|
||||||
|
table.column(column, width=width, anchor=anchor, stretch=(column == "action"))
|
||||||
|
|
||||||
|
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=table.yview)
|
||||||
|
table.configure(yscrollcommand=scrollbar.set)
|
||||||
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
table.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||||
|
|
||||||
|
# Held on the dialog: a PhotoImage is dropped as soon as nothing references it.
|
||||||
|
boxes = {
|
||||||
|
(True, True): make_checkbox_image(True, True),
|
||||||
|
(False, True): make_checkbox_image(False, True),
|
||||||
|
(True, False): make_checkbox_image(True, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
hint = ttk.Label(
|
||||||
|
frame,
|
||||||
|
text="A tick means the schema declares the kit. Grey ticks are needed by the app "
|
||||||
|
"and cannot be removed; untick another to remove it. Click the Action cell to "
|
||||||
|
"choose a build. Nothing reaches the file until you save it.",
|
||||||
|
foreground="#666666",
|
||||||
|
wraplength=780,
|
||||||
|
)
|
||||||
|
hint.pack(anchor="w", pady=(8, 0))
|
||||||
|
|
||||||
|
button_row = ttk.Frame(frame)
|
||||||
|
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||||
|
|
||||||
|
rows = [] # row dicts, table order
|
||||||
|
row_by_item = {} # Treeview item id -> row dict
|
||||||
|
editor = {"widget": None}
|
||||||
|
|
||||||
|
def close_editor(_event=None):
|
||||||
|
if editor["widget"] is not None:
|
||||||
|
editor["widget"].destroy()
|
||||||
|
editor["widget"] = None
|
||||||
|
|
||||||
|
def action_text(row):
|
||||||
|
"""What the Action cell says, which an untick overrides with REMOVE."""
|
||||||
|
if row["declared"] and not row["checked"]:
|
||||||
|
return REMOVE_ACTION
|
||||||
|
return row["options"][row["choice"]][0]
|
||||||
|
|
||||||
|
def row_values(row):
|
||||||
|
return (row["kit"], row["installed_text"], row["latest_text"], action_text(row))
|
||||||
|
|
||||||
|
def row_image(row):
|
||||||
|
return boxes[(row["checked"], not row["required"])]
|
||||||
|
|
||||||
|
def rebuild_table():
|
||||||
|
close_editor()
|
||||||
|
for item in table.get_children():
|
||||||
|
table.delete(item)
|
||||||
|
row_by_item.clear()
|
||||||
|
|
||||||
|
rows[:] = collect_rows()
|
||||||
|
for row in rows:
|
||||||
|
if "section" in row:
|
||||||
|
table.insert("", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,))
|
||||||
|
continue
|
||||||
|
item = table.insert("", "end", image=row_image(row), values=row_values(row))
|
||||||
|
row_by_item[item] = row
|
||||||
|
|
||||||
|
def refresh_row(item):
|
||||||
|
row = row_by_item[item]
|
||||||
|
table.item(item, image=row_image(row), values=row_values(row))
|
||||||
|
|
||||||
|
def set_choice(item, index):
|
||||||
|
row = row_by_item[item]
|
||||||
|
row["choice"] = index
|
||||||
|
|
||||||
|
# Naming a build is the user saying they want the kit at it, so the row ticks
|
||||||
|
# itself — and for a declared row that also cancels a pending removal.
|
||||||
|
if row["declared"] or row["options"][index][1] is not None:
|
||||||
|
row["checked"] = True
|
||||||
|
refresh_row(item)
|
||||||
|
|
||||||
|
def toggle_check(item):
|
||||||
|
row = row_by_item[item]
|
||||||
|
if row["required"]:
|
||||||
|
# Needed by the app: the tick is fixed, and the box is drawn greyed to say so.
|
||||||
|
return
|
||||||
|
|
||||||
|
row["checked"] = not row["checked"]
|
||||||
|
if not row["declared"]:
|
||||||
|
# Ticking a kit that is not in the schema means adding it, at its newest build
|
||||||
|
# unless the user picks another; unticking puts the row back to doing nothing.
|
||||||
|
row["choice"] = 1 if (row["checked"] and len(row["options"]) > 1) else 0
|
||||||
|
if row["checked"] and len(row["options"]) < 2:
|
||||||
|
row["checked"] = False
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Manage Kits",
|
||||||
|
f"Sedona home holds no manifest for '{row['kit']}', so there is no "
|
||||||
|
"build to add it at.",
|
||||||
|
parent=dialog
|
||||||
|
)
|
||||||
|
refresh_row(item)
|
||||||
|
|
||||||
|
def edit_action(item):
|
||||||
|
row = row_by_item[item]
|
||||||
|
cell = table.bbox(item, "action")
|
||||||
|
if not cell:
|
||||||
|
return
|
||||||
|
|
||||||
|
labels = [label for label, _ in row["options"]]
|
||||||
|
current = action_text(row)
|
||||||
|
variable = tk.StringVar(value=current if current in labels else labels[row["choice"]])
|
||||||
|
combo = ttk.Combobox(table, textvariable=variable, values=labels, state="readonly")
|
||||||
|
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
|
||||||
|
editor["widget"] = combo
|
||||||
|
|
||||||
|
def commit(_event=None):
|
||||||
|
if variable.get() in labels:
|
||||||
|
set_choice(item, labels.index(variable.get()))
|
||||||
|
close_editor()
|
||||||
|
|
||||||
|
combo.bind("<<ComboboxSelected>>", commit)
|
||||||
|
combo.bind("<Escape>", lambda event: close_editor())
|
||||||
|
combo.focus_set()
|
||||||
|
# A single click on the cell should show the builds, not just a collapsed box.
|
||||||
|
combo.after_idle(lambda: combo.event_generate("<Down>"))
|
||||||
|
|
||||||
|
def on_click(event):
|
||||||
|
close_editor()
|
||||||
|
if table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||||
|
return
|
||||||
|
item = table.identify_row(event.y)
|
||||||
|
if item not in row_by_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
column = table.identify_column(event.x)
|
||||||
|
if column == "#0":
|
||||||
|
toggle_check(item)
|
||||||
|
elif column == "#4":
|
||||||
|
edit_action(item)
|
||||||
|
|
||||||
|
table.bind("<Button-1>", on_click)
|
||||||
|
table.bind("<MouseWheel>", close_editor, add="+")
|
||||||
|
table.bind("<Configure>", close_editor, add="+")
|
||||||
|
|
||||||
|
def apply_changes(selections, removals, label, nothing_message, note=""):
|
||||||
|
if not selections and not removals:
|
||||||
|
messagebox.showinfo("Manage Kits", nothing_message, parent=dialog)
|
||||||
|
return
|
||||||
|
|
||||||
|
changes, error = sax_file.apply_kit_selections(selections, removals, label=label)
|
||||||
|
if error:
|
||||||
|
messagebox.showerror("Kits unchanged", error, parent=dialog)
|
||||||
|
rebuild_table()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Manage Kits", "The schema already reads that way.", parent=dialog
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
rebuild_table()
|
||||||
|
if on_applied is not None:
|
||||||
|
on_applied()
|
||||||
|
|
||||||
|
app_state.set_status(
|
||||||
|
f"schema kits changed ({len(changes)}) — save the file to keep it.",
|
||||||
|
warn=True
|
||||||
|
)
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Kits updated",
|
||||||
|
f"The schema block now reads:\n\n{describe_changes(changes)}\n\n"
|
||||||
|
+ (note + "\n\n" if note else "")
|
||||||
|
+ "Save the file to write this to disk. The device's scode must hold these "
|
||||||
|
"builds or the application will not run.",
|
||||||
|
parent=dialog
|
||||||
|
)
|
||||||
|
|
||||||
|
def upgrade_all():
|
||||||
|
"""Every kit the schema declares, moved to the newest build Sedona home holds."""
|
||||||
|
selections = []
|
||||||
|
for row in rows:
|
||||||
|
if "section" in row or not row["declared"] or not row["latest"]:
|
||||||
|
continue
|
||||||
|
latest_checksum = row["latest"][1]
|
||||||
|
if latest_checksum != row["checksum"]:
|
||||||
|
selections.append((row["kit"], latest_checksum))
|
||||||
|
|
||||||
|
if selections:
|
||||||
|
preview = "\n".join(
|
||||||
|
f" • {kit}: {build_label(palette.version_for_checksum(kit, checksum), checksum)}"
|
||||||
|
for kit, checksum in selections
|
||||||
|
)
|
||||||
|
proceed = messagebox.askokcancel(
|
||||||
|
"Upgrade all kits",
|
||||||
|
f"{len(selections)} kit{'' if len(selections) == 1 else 's'} in this app's "
|
||||||
|
f"schema will move to their latest installed build:\n\n{preview}\n\nContinue?",
|
||||||
|
icon=messagebox.WARNING,
|
||||||
|
parent=dialog
|
||||||
|
)
|
||||||
|
if not proceed:
|
||||||
|
return
|
||||||
|
|
||||||
|
apply_changes(
|
||||||
|
selections, [],
|
||||||
|
"upgrade all kits",
|
||||||
|
"Every kit in the schema is already at its latest installed build."
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_marked():
|
||||||
|
"""The ticks and the Action column, applied: repins, additions and removals."""
|
||||||
|
selections = []
|
||||||
|
removals = []
|
||||||
|
for row in row_by_item.values():
|
||||||
|
if row["declared"] and not row["checked"]:
|
||||||
|
removals.append(row["kit"])
|
||||||
|
continue
|
||||||
|
if not row["checked"]:
|
||||||
|
continue
|
||||||
|
checksum = row["options"][row["choice"]][1]
|
||||||
|
if checksum:
|
||||||
|
selections.append((row["kit"], checksum))
|
||||||
|
|
||||||
|
note = ""
|
||||||
|
if removals:
|
||||||
|
note = (
|
||||||
|
"Removed kits are gone from the schema only. Any build of them still "
|
||||||
|
"installed in Sedona home can be added back here."
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_changes(
|
||||||
|
selections, removals,
|
||||||
|
"update kits",
|
||||||
|
"Nothing is marked for a change. Pick a build in a kit's Action column, tick "
|
||||||
|
"a kit to add it, or untick one to remove it."
|
||||||
|
)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
button_row, text="Upgrade all kits to latest version", command=upgrade_all
|
||||||
|
).pack(side=tk.LEFT)
|
||||||
|
ttk.Button(button_row, text="Update kits", command=update_marked).pack(side=tk.LEFT, padx=6)
|
||||||
|
ttk.Button(button_row, text="Close", command=dialog.destroy).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
rebuild_table()
|
||||||
+40
-3
@@ -19,6 +19,7 @@ import app_state
|
|||||||
_versions_cache = {} # kit name -> {checksum: version string}
|
_versions_cache = {} # kit name -> {checksum: version string}
|
||||||
_manifest_cache = {} # kit name -> (version string, {type name: element})
|
_manifest_cache = {} # kit name -> (version string, {type name: element})
|
||||||
_component_cache = {} # 'kit::Type' -> bool
|
_component_cache = {} # 'kit::Type' -> bool
|
||||||
|
_builds_cache = {} # kit name -> [(version string, checksum)], newest first
|
||||||
|
|
||||||
|
|
||||||
def _manifests_dir():
|
def _manifests_dir():
|
||||||
@@ -40,7 +41,7 @@ def list_kits():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _version_sort_key(version_string):
|
def version_sort_key(version_string):
|
||||||
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
|
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
|
||||||
return numbers or [0]
|
return numbers or [0]
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ def resolve_manifest_path(kit_name):
|
|||||||
for entry in manifests:
|
for entry in manifests:
|
||||||
checksum = entry[len(kit_name) + 1:-4]
|
checksum = entry[len(kit_name) + 1:-4]
|
||||||
if checksum in versions:
|
if checksum in versions:
|
||||||
available.append((_version_sort_key(versions[checksum]), entry, versions[checksum]))
|
available.append((version_sort_key(versions[checksum]), entry, versions[checksum]))
|
||||||
|
|
||||||
if available:
|
if available:
|
||||||
available.sort()
|
available.sort()
|
||||||
@@ -106,6 +107,41 @@ def resolve_manifest_path(kit_name):
|
|||||||
return os.path.join(kit_dir, entry), versions.get(checksum, "")
|
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):
|
def _load_kit(kit_name):
|
||||||
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
|
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
|
||||||
if kit_name in _manifest_cache:
|
if kit_name in _manifest_cache:
|
||||||
@@ -201,7 +237,8 @@ def list_component_types(kit_name):
|
|||||||
|
|
||||||
|
|
||||||
def invalidate():
|
def invalidate():
|
||||||
"""Clears every cache, for when Sedona home changes in Preferences."""
|
"""Clears every cache, for when Sedona home changes or a kit is repinned."""
|
||||||
_versions_cache.clear()
|
_versions_cache.clear()
|
||||||
_manifest_cache.clear()
|
_manifest_cache.clear()
|
||||||
_component_cache.clear()
|
_component_cache.clear()
|
||||||
|
_builds_cache.clear()
|
||||||
|
|||||||
+20
@@ -3,9 +3,29 @@
|
|||||||
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
|
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_children(element):
|
||||||
|
"""Copies an element's children and leading text, so it can be put back wholesale.
|
||||||
|
|
||||||
|
Used for the <schema> block, which is short enough that copying it whole is easier to
|
||||||
|
trust than tracking which attribute moved and which entry was inserted where.
|
||||||
|
"""
|
||||||
|
return [copy.deepcopy(child) for child in element], element.text
|
||||||
|
|
||||||
|
|
||||||
|
def restore_children(element, snapshot):
|
||||||
|
"""Puts back what snapshot_children captured, whitespace and all."""
|
||||||
|
children, text = snapshot
|
||||||
|
for child in list(element):
|
||||||
|
element.remove(child)
|
||||||
|
element.text = text
|
||||||
|
for child in children:
|
||||||
|
element.append(copy.deepcopy(child))
|
||||||
|
|
||||||
|
|
||||||
def read_meta_property(meta_str):
|
def read_meta_property(meta_str):
|
||||||
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
||||||
if meta_str is None or not meta_str:
|
if meta_str is None or not meta_str:
|
||||||
|
|||||||
+228
-40
@@ -10,7 +10,13 @@ import palette
|
|||||||
import schema
|
import schema
|
||||||
import undo
|
import undo
|
||||||
import wiresheet
|
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):
|
def parse_xml_to_tree(treeview, parent_node, xml_element):
|
||||||
@@ -361,53 +367,62 @@ def used_kit_names():
|
|||||||
return 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():
|
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
|
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
|
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.
|
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
|
Entries already in the file are left as they are — a missing checksum there is a
|
||||||
deliberate tolerance on load, not something to backfill.
|
deliberate tolerance on load, not something to backfill.
|
||||||
|
|
||||||
Returns (added elements in the order written, whitespace to put back if the caller
|
Returns the <kit> elements that were added, in the order they were written.
|
||||||
rolls the insertion out).
|
|
||||||
"""
|
"""
|
||||||
if app_state.xml_root_element is None:
|
if app_state.xml_root_element is None:
|
||||||
return [], []
|
return []
|
||||||
|
|
||||||
schema_elem = app_state.xml_root_element.find("schema")
|
schema_elem = app_state.xml_root_element.find("schema")
|
||||||
if schema_elem is None:
|
if schema_elem is None:
|
||||||
return [], []
|
return []
|
||||||
|
|
||||||
declared = {kit.get("name") for kit in schema_elem.findall("kit")}
|
declared = {kit.get("name") for kit in schema_elem.findall("kit")}
|
||||||
|
|
||||||
added = []
|
added = []
|
||||||
tail_restores = []
|
for name in sorted(required_kit_names() - declared):
|
||||||
for name in sorted(used_kit_names() - declared):
|
|
||||||
kit_element = ET.Element("kit")
|
|
||||||
kit_element.set("name", name)
|
|
||||||
|
|
||||||
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
|
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
|
||||||
if checksum:
|
kit_element = insert_kit_element(schema_elem, name, 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)
|
|
||||||
|
|
||||||
app_state.active_schema_kits[name] = checksum
|
app_state.active_schema_kits[name] = checksum
|
||||||
added.append(kit_element)
|
added.append(kit_element)
|
||||||
|
|
||||||
return added, tail_restores
|
return added
|
||||||
|
|
||||||
|
|
||||||
def indent_kit_entry(schema_elem, kit_element):
|
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
|
the tail that used to close the block, and the entry before it takes the tail that
|
||||||
separates two kits.
|
separates two kits.
|
||||||
|
|
||||||
Handing a tail on means writing to a neighbour, so the pair needed to put that
|
Callers that may have to undo the insertion snapshot the whole block instead of
|
||||||
neighbour back is returned for a caller that may have to undo the insertion. It is
|
tracking these tails, so nothing is reported back.
|
||||||
(element, tail) for a block that already held kits, and None for one that did not.
|
|
||||||
"""
|
"""
|
||||||
kits = [child for child in schema_elem if child.tag == "kit" and child is not kit_element]
|
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 ""
|
existing_text = schema_elem.text or ""
|
||||||
kit_element.tail = existing_text if existing_text.strip() == "" and existing_text else "\n"
|
kit_element.tail = existing_text if existing_text.strip() == "" and existing_text else "\n"
|
||||||
schema_elem.text = "\n "
|
schema_elem.text = "\n "
|
||||||
return None
|
return
|
||||||
|
|
||||||
previous = kits[-1]
|
previous = kits[-1]
|
||||||
restore = (previous, previous.tail)
|
|
||||||
kit_element.tail = previous.tail
|
kit_element.tail = previous.tail
|
||||||
previous.tail = kits[-2].tail if len(kits) >= 2 else "\n "
|
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):
|
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"
|
kit_word = "kit is" if len(added_names) == 1 else "kits are"
|
||||||
lines = [
|
lines = [
|
||||||
f"The following {kit_word} used by a component but not declared in this app's "
|
f"The following {kit_word} needed by this app — by a component, or by a kit one "
|
||||||
"schema, and has been added to it:",
|
"of them depends on — but not declared in its schema, and has been added to it:",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
lines.extend(
|
lines.extend(
|
||||||
@@ -484,13 +535,155 @@ def describe_schema_kits(added_elements):
|
|||||||
return "\n".join(lines)
|
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():
|
def reconcile_schema_kits_before_save():
|
||||||
"""Brings <schema> in step with the components, warning before anything is written.
|
"""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
|
Returns False when the user backs out, in which case the added entries are taken back
|
||||||
out and the save is abandoned.
|
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:
|
if not added:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -501,14 +694,9 @@ def reconcile_schema_kits_before_save():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not proceed:
|
if not proceed:
|
||||||
schema_elem = app_state.xml_root_element.find("schema")
|
restore_children(schema_elem, before)
|
||||||
for kit_element in added:
|
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)
|
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
|
return False
|
||||||
|
|
||||||
# Declared in the file now, so nothing about them is pending any more.
|
# Declared in the file now, so nothing about them is pending any more.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import xml.etree.ElementTree as ET
|
|||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
|
|
||||||
|
_dependency_cache = {} # manifest path -> tuple of kit names it depends on
|
||||||
|
|
||||||
|
|
||||||
class MissingManifestError(Exception):
|
class MissingManifestError(Exception):
|
||||||
"""A kit the file needs has no manifest under the Sedona home folder.
|
"""A kit the file needs has no manifest under the Sedona home folder.
|
||||||
@@ -42,6 +44,38 @@ def describe_missing_manifests(error):
|
|||||||
return "\n".join(lines)
|
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):
|
def read_summary_facet(slot_element):
|
||||||
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
|
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
|
||||||
facets = slot_element.find("facets")
|
facets = slot_element.find("facets")
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import graph free of cycles (sax_file and wiresheet both push commands here).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
from sax_dom import update_meta_property
|
import palette
|
||||||
|
import schema
|
||||||
|
from sax_dom import restore_children, update_meta_property
|
||||||
|
|
||||||
_undo_stack = []
|
_undo_stack = []
|
||||||
_redo_stack = []
|
_redo_stack = []
|
||||||
@@ -79,6 +81,43 @@ def reset():
|
|||||||
_saved_depth = 0
|
_saved_depth = 0
|
||||||
|
|
||||||
|
|
||||||
|
def reload_schema_registry():
|
||||||
|
"""Recompiles the slot registry after the <schema> block changed.
|
||||||
|
|
||||||
|
A failure is swallowed: undo and redo only ever land on a state that resolved once
|
||||||
|
already, so a missing manifest here means Sedona home moved underneath the session,
|
||||||
|
which the next action reports in its own words.
|
||||||
|
"""
|
||||||
|
palette.invalidate()
|
||||||
|
try:
|
||||||
|
schema.load_schema_kits_and_manifests()
|
||||||
|
except schema.MissingManifestError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaBlockCommand:
|
||||||
|
"""A change to the file's <schema> block, held as a before/after snapshot.
|
||||||
|
|
||||||
|
Kit entries decide which manifest describes which type, so the slot registry is
|
||||||
|
recompiled in both directions — a repinned kit can change a component's slots.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, schema_element, before, after, label="update kits"):
|
||||||
|
# before/after: snapshots from sax_dom.snapshot_children
|
||||||
|
self.schema_element = schema_element
|
||||||
|
self.before = before
|
||||||
|
self.after = after
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
restore_children(self.schema_element, self.before)
|
||||||
|
reload_schema_registry()
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
restore_children(self.schema_element, self.after)
|
||||||
|
reload_schema_registry()
|
||||||
|
|
||||||
|
|
||||||
class MetaMoveCommand:
|
class MetaMoveCommand:
|
||||||
"""One or many components changing position, i.e. their packed meta x/y."""
|
"""One or many components changing position, i.e. their packed meta x/y."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user