qet-mcp: qet_edit names a folio by its uuid

qet_edit takes a folio as its index, and the index shifts when an earlier
op in the same run adds, inserts or removes a folio: remove folio 0, then
edit "folio 2", and the edit lands on the folio that was 3. Every other
item qet_edit addresses (elements, texts, shapes, pictures, tables, symbol
text fields) can already be named by uuid; folios could not.

- "folio" and "to_folio" take the folio's uuid as well as its index,
  turned into the current index at run time by qet.folioIndex(). The
  lookup is required only when a uuid is used, so an index-only edit still
  runs on a build without it.
- qet_project_info lists each folio's uuid. A folio saved without one
  (132 of the 133 in the shipped examples) shows it empty until the
  project is saved once, and an empty "folio" says so.

Tests: the generated script for a uuid folio, on its own and inside a
table lookup and link_elements' to_folio; and a real run that removes
folio 0 and then retitles the old folio 2 by uuid, on a file saved without
folio uuids. The run fails against a build without qet.folioIndex().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-29 00:00:20 +13:00
parent b99f5551d2
commit c7e5c0c3b3
3 changed files with 75 additions and 8 deletions
+7 -1
View File
@@ -27,7 +27,7 @@ here read the model.
| Tool | What it answers |
|---|---|
| `qet_project_info` | title, format version, folios, element and conductor counts |
| `qet_project_info` | title, format version, folios with their uuids, element and conductor counts |
| `qet_elements` | placed elements: uuid, type, position, label, information bag |
| `qet_conductors` | conductors and their documentation fields; filter by attribute |
| `qet_items` | free texts, shapes, pictures, tables and symbol text fields, each with its uuid |
@@ -290,6 +290,12 @@ Python, plus the hang guard on `addConductor` and the database refresh in
`index` also takes that uuid, which does not shift the way an index
does. `qet_element_build` gives every part of a symbol a uuid as well,
returned in `part_uuids`; `qet_element_info` lists them in `part_list`.
- **A folio can be named by its uuid** wherever an op takes `folio` or
`to_folio`; `qet_project_info` lists each folio's. It still names the same
folio after an earlier op in the run adds, inserts or removes one, where
an index would shift. A folio saved without a uuid shows it empty:
QElectroTech gives it one on load and writes it on the next save, so it
appears after a first `qet_edit`. Needs `qet.folioIndex()` in the build.
- **`qet_export` isolates its launch.** SingleApplication keys its socket
on `applicationFilePath()`, so a second launch of the same binary path
forwards its request to an already-running instance and returns *that*
+21 -5
View File
@@ -250,6 +250,7 @@ def tool_project_info(path: str) -> dict:
for i, d in _folios(root):
folios.append({
"index": i,
"uuid": d.get("uuid", ""),
"title": d.get("title", ""),
"elements": sum(1 for _ in d.iter("element")),
"conductors": len(_wires(d)),
@@ -1480,9 +1481,19 @@ def _build_script(operations: list, output: str) -> str:
f"its uuid, or a \"$name\" reference, got {value!r}")
return _js(value)
if kind == "folio":
# A uuid names the folio for good; it is turned into the index
# the call takes at run time, since adding or removing a folio
# shifts every index after it.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
uuid_methods.add("folioIndex")
return f"qet.folioIndex({_js(value)})"
if value == "":
raise ValueError(f"operation {op_index}: {key!r} is empty -- a folio saved "
f"without a uuid has none in the file until the project is "
f"saved once; give its index instead")
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a folio index "
f"or a \"$name\" reference, got {value!r}")
raise ValueError(f"operation {op_index}: {key!r} must be a folio index, "
f"its uuid, or a \"$name\" reference, got {value!r}")
return _js(value)
if kind == "bool":
if not isinstance(value, bool):
@@ -2308,8 +2319,10 @@ def tool_edit(binary: str, project: str, operations: list, output: str,
TOOLS = [
{
"name": "qet_project_info",
"description": "Summarise a .qet project: title, format version, folios, "
"and element/conductor counts per folio. Reads the file "
"description": "Summarise a .qet project: title, format version, folios "
"with their uuids, and element/conductor counts per folio. A "
"folio saved without a uuid shows it empty; QElectroTech gives "
"it one on load and writes it on the next save. Reads the file "
"directly; does not launch QElectroTech.",
"inputSchema": {
"type": "object",
@@ -2487,7 +2500,10 @@ TOOLS = [
"add_element gets wired by add_conductor, and how a folio made "
"by add_folio is addressed. A \"folio\" given as a number is "
"an index counted from 0: the folio qet_elements and "
"qet_project_info call 1 is \"folio\": 0 here. Terminals are numbered by their "
"qet_project_info call 1 is \"folio\": 0 here. A folio can "
"be given as its uuid instead (qet_project_info lists them), "
"which still names the same folio after an earlier op adds or "
"removes one. Terminals are numbered by their "
"index in the element definition; qet_element_info lists them. "
"set_conductor addresses a conductor as the one on a given "
"terminal and applies the change to its whole electrical "
+47 -2
View File
@@ -194,6 +194,26 @@ class EditValidation(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "index or its uuid"):
self.build([op])
def test_folio_by_uuid(self):
"""A folio named by uuid is looked up at run time, wherever an op
takes a folio; the lookup is required only then."""
U = "{11111111-2222-4333-8444-555555555555}"
E = "{aaaaaaaa-0000-4000-8000-000000000001}"
s = self.build([{"op": "set_folio", "folio": U, "property": "author", "value": "a"}])
self.assertIn(f'qet.setFolioProperty(qet.folioIndex("{U}"), "author", "a")', s)
self.assertIn('"folioIndex"', s)
# the item lookups take the resolved folio too
s = self.build([{"op": "delete_table", "folio": U, "table": E}])
self.assertIn(f'qet.deleteTable(qet.folioIndex("{U}"), qet.tableIndex(qet.folioIndex("{U}"), "{E}"))', s)
s = self.build([{"op": "link_elements", "folio": 0, "element": E, "to_folio": U, "to": E}])
self.assertIn(f'qet.linkElements(0, "{E}", qet.folioIndex("{U}"), "{E}")', s)
self.assertNotIn('"folioIndex"', self.build(
[{"op": "set_folio", "folio": 0, "property": "author", "value": "a"}]))
with self.assertRaisesRegex(ValueError, "saved once"):
self.build([{"op": "set_folio", "folio": "", "property": "author", "value": "a"}])
with self.assertRaisesRegex(ValueError, "folio index, its uuid"):
self.build([{"op": "set_folio", "folio": "first", "property": "author", "value": "a"}])
def test_every_op_generates_a_script(self):
# one minimal valid instance of every op
f = {"op": "add_folio", "id": "f"}
@@ -783,8 +803,8 @@ class ReadToolContracts(unittest.TestCase):
self.assertEqual(m.tool_project_info(self.qet), {
"file": self.qet, "title": "T", "version": "0.100",
"folio_count": 2, "element_count": 4, "conductor_count": 3,
"folios": [{"index": 1, "title": "One", "elements": 3, "conductors": 2},
{"index": 2, "title": "Two", "elements": 1, "conductors": 1}]})
"folios": [{"index": 1, "uuid": "", "title": "One", "elements": 3, "conductors": 2},
{"index": 2, "uuid": "", "title": "Two", "elements": 1, "conductors": 1}]})
def test_elements_rows_exact(self):
r = m.tool_elements(self.qet)
@@ -3588,6 +3608,31 @@ class UuidIndexLookups(unittest.TestCase):
self.assertEqual([(t.get("uuid"), float(t.get("x")), float(t.get("y"))) for t in left],
[(twin.get("uuid"), 120.0, 340.0)])
def test_qet_edit_follows_a_folio_by_uuid_across_a_removal(self):
"""Remove folio 0, then retitle what was folio 2 by its uuid. By index
the second op would retitle the wrong folio. The file is saved
without folio uuids, so qet_project_info shows them only once a
first qet_edit has saved it."""
src = Path(EXAMPLES) / "tableau_domestique.qet"
self.assertEqual({f["uuid"] for f in m.tool_project_info(str(src))["folios"]}, {""})
with tempfile.TemporaryDirectory() as tmp:
once, out = Path(tmp) / "once.qet", Path(tmp) / "out.qet"
r = m.tool_edit(BINARY, str(src), [{"op": "set_folio", "folio": 0, "property": "author",
"value": "x"}], str(once), elements_dir=ELEMENTS or None)
self.assertTrue(r["ok"], r.get("hint"))
before = m.tool_project_info(str(once))["folios"]
self.assertTrue(all(m._UUID_RE.fullmatch(f["uuid"]) for f in before))
third = before[2]
r = m.tool_edit(BINARY, str(once), [
{"op": "remove_folio", "folio": before[0]["uuid"]},
{"op": "set_folio", "folio": third["uuid"], "property": "title", "value": "moved"}],
str(out), elements_dir=ELEMENTS or None)
self.assertTrue(r["ok"], r.get("hint"))
after = m.tool_project_info(str(out))["folios"]
self.assertEqual([f["uuid"] for f in after], [f["uuid"] for f in before[1:]])
self.assertEqual([f["title"] for f in after],
[f["title"] for f in before[1:2]] + ["moved"] + [f["title"] for f in before[3:]])
def test_qet_edit_edits_one_copys_field_by_uuid(self):
"""Two copies of a symbol share a field uuid; addressing it with the
symbol changes that copy's field only."""