From c7e5c0c3b3e77823958c2e70d25b5a52839c0bf3 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 00:00:20 +1300 Subject: [PATCH] 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 --- misc/qet-mcp/README.md | 8 +++++- misc/qet-mcp/qet_mcp.py | 26 +++++++++++++++---- misc/qet-mcp/test_qet_mcp.py | 49 ++++++++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index e5136fb88..f86f06860 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -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* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 7703c4c0e..dbf8bb79b 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -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 " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 69291ff0a..8238a2ce4 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -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."""