Merge pull request #1101 from ispyisail/feat/qet-mcp-edit-table-text-uuid

Let qet_edit address tables and symbol text fields by uuid
This commit is contained in:
ispyisail
2026-09-28 16:14:41 +13:00
committed by GitHub
2 changed files with 110 additions and 4 deletions
+31 -4
View File
@@ -1159,10 +1159,10 @@ OPS = {
("source", "str"), ("value", "str"),
("x", "num"), ("y", "num")]),
"set_element_text": ("setElementTextProperty", [("folio", "folio"), ("element", "elmt"),
("index", "folio"), ("property", "str"),
("index", "element_text"), ("property", "str"),
("value", "str")]),
"delete_element_text": ("deleteElementText", [("folio", "folio"), ("element", "elmt"),
("index", "folio")]),
("index", "element_text")]),
# Returns the uuids of the copies IN THE ORDER the elements were named,
# so "$copies[0]" is the copy of the first one. Conductors between the
# copied elements are copied with them; copies arrive without labels or
@@ -1222,9 +1222,9 @@ OPS = {
("closed", "bool")]),
"add_table": ("addTable", [("folio", "folio"), ("kind", "str"),
("name", "str"), ("query", "str")]),
"set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "folio"),
"set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"),
("x", "num"), ("y", "num")]),
"delete_table": ("deleteTable", [("folio", "folio"), ("table", "folio")]),
"delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]),
}
SHAPES = ["line", "rectangle", "ellipse", "polygon"]
@@ -1278,6 +1278,7 @@ def _build_script(operations: list, output: str) -> str:
# an index-only edit still runs on a build that predates them.
uuid_methods: set[str] = set()
folio_js = "0"
element_js = None # the op's element, for lookups scoped to it
lines = [
"// generated by qet-mcp; do not edit",
"var R = {};", # $name -> value from an earlier op
@@ -1331,6 +1332,26 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of "
f"integer indices, got {value!r}")
return _js(value)
if kind == "table":
# As for texts below: a uuid is resolved to the current index at
# run time, since deleting an earlier table shifts every index.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
uuid_methods.add("tableIndex")
return f"qet.tableIndex({folio_js}, {_js(value)})"
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a table index "
f"or its uuid, got {value!r}")
return _js(value)
if kind == "element_text":
# A field's uuid is unique only within its element (copies keep
# them), so the lookup takes the op's element too.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
uuid_methods.add("elementTextIndex")
return f"qet.elementTextIndex({folio_js}, {element_js}, {_js(value)})"
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a text field index "
f"or its uuid, got {value!r}")
return _js(value)
if kind in ("text", "shape", "image"):
# A uuid names the item for good; it is turned into the index
# the call takes at run time, by the item's own folio.
@@ -1430,6 +1451,7 @@ def _build_script(operations: list, output: str) -> str:
if key not in op:
raise ValueError(f"operation {i} ({name}) is missing {key!r}")
folio_js = args[0] if args else "0"
element_js = args[1] if len(args) > 1 else None
args.append(ref_or(op[key], kind, i, key))
ident = op.get("id")
@@ -2465,6 +2487,11 @@ TOOLS = [
"shape's points can reorder it relative to the others -- re-list "
"before addressing one by index again if more than one is being "
"edited in the same run, or address it by uuid. "
"set_table_position/delete_table take a table's index or its uuid, "
"and set_element_text/delete_element_text a text field's index or "
"its uuid (the field's own, looked up within the op's element) "
"-- a uuid still names the right item after an "
"earlier one is deleted. "
"Tables: add_table places a BOM/nomenclature or summary table "
"(kind is \"nomenclature\" or \"summary\") built from a query "
"against a project database view -- run qet.query() (the "
+79
View File
@@ -167,6 +167,33 @@ class EditValidation(unittest.TestCase):
def build(self, ops):
return m._build_script(ops, "/tmp/out.qet")
def test_tables_and_text_fields_by_uuid(self):
"""A table or a symbol text field named by uuid is looked up at run
time; a field's lookup is scoped to the op's element."""
U = "{11111111-2222-4333-8444-555555555555}"
E = "{aaaaaaaa-0000-4000-8000-000000000001}"
s = self.build([{"op": "set_table_position", "folio": 2, "table": U, "x": 1, "y": 2}])
self.assertIn(f'qet.setTablePosition(2, qet.tableIndex(2, "{U}"), 1, 2)', s)
s = self.build([{"op": "delete_table", "folio": 0, "table": 3}])
self.assertIn("qet.deleteTable(0, 3)", s)
s = self.build([{"op": "set_element_text", "folio": 1, "element": E, "index": U,
"property": "x", "value": "5"}])
self.assertIn(f'qet.setElementTextProperty(1, "{E}", '
f'qet.elementTextIndex(1, "{E}", "{U}"), "x", "5")', s)
# the element may be one placed earlier in the same run
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "add_element", "id": "k", "folio": "$f", "path": "p", "x": 0, "y": 0},
{"op": "delete_element_text", "folio": "$f", "element": "$k", "index": U}])
self.assertIn(f'qet.deleteElementText(R["f"], R["k"], qet.elementTextIndex(R["f"], R["k"], "{U}"))', s)
# the lookups are required only when a uuid is used
self.assertIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": U}]))
self.assertNotIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": 0}]))
for op in ({"op": "delete_table", "folio": 0, "table": "second"},
{"op": "delete_element_text", "folio": 0, "element": E, "index": "label"}):
with self.subTest(op=op["op"]):
with self.assertRaisesRegex(ValueError, "index or its uuid"):
self.build([op])
def test_every_op_generates_a_script(self):
# one minimal valid instance of every op
f = {"op": "add_folio", "id": "f"}
@@ -3284,6 +3311,58 @@ class UuidIndexLookups(unittest.TestCase):
self.assertEqual((out["bogus"], out["not_uuid"]), (-1, -1))
self.assertEqual((out["second_after"], out["first_after"]), (0, -1))
def test_qet_edit_deletes_then_moves_tables_by_uuid(self):
"""Delete one table, then move the other, both by uuid. By index the
second op would name the wrong table: deleting table 0 shifts table 1."""
text = (Path(EXAMPLES) / "industrial.qet").read_text(encoding="utf-8")
root = ET.fromstring(text)
folio, table = next((i, d.find("tables/graphics_table")) for i, d in enumerate(root.iter("diagram"))
if d.find("tables/graphics_table") is not None)
twin = ET.fromstring(ET.tostring(table))
twin.set("uuid", "{11111111-2222-4333-8444-555555555555}")
twin.set("x", str(float(table.get("x")) + 900))
# Insert into the raw text: re-serialising the whole file with
# ElementTree rewrites the embedded SVG logo's namespace, which
# QElectroTech then saves without its declaration.
start = text.index(f'uuid="{table.get("uuid")}"')
end = text.index("</graphics_table>", start) + len("</graphics_table>")
text = text[:end] + ET.tostring(twin, encoding="unicode") + text[end:]
with tempfile.TemporaryDirectory() as tmp:
src, out = Path(tmp) / "two.qet", Path(tmp) / "out.qet"
src.write_text(text, encoding="utf-8")
r = m.tool_edit(BINARY, str(src), [
{"op": "delete_table", "folio": folio, "table": table.get("uuid")},
{"op": "set_table_position", "folio": folio, "table": twin.get("uuid"), "x": 120, "y": 340}],
str(out), elements_dir=ELEMENTS or None)
self.assertTrue(r["ok"], r.get("hint"))
left = list(ET.parse(out).getroot().iter("diagram"))[folio].findall("tables/graphics_table")
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_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."""
root = ET.parse(Path(EXAMPLES) / "2612_ats_singlephase.qet").getroot()
owners = {}
for i, d in enumerate(root.iter("diagram")):
for el in d.iter("element"):
for t in el.findall("dynamic_texts/dynamic_elmt_text"):
if t.get("uuid"):
owners.setdefault((i, t.get("uuid")), []).append(el.get("uuid"))
(folio, field), (a, b) = next((k, v[:2]) for k, v in owners.items() if len(v) >= 2)
with tempfile.TemporaryDirectory() as tmp:
src, out = Path(tmp) / "in.qet", Path(tmp) / "out.qet"
shutil.copy(Path(EXAMPLES) / "2612_ats_singlephase.qet", src)
r = m.tool_edit(BINARY, str(src), [
{"op": "set_element_text", "folio": folio, "element": b, "index": field,
"property": "x", "value": "77"}], str(out), elements_dir=ELEMENTS or None)
self.assertTrue(r["ok"], r.get("hint"))
x_of = lambda path, el_uuid: next(
t.get("x") for el in ET.parse(path).getroot().iter("element") if el.get("uuid") == el_uuid
for t in el.findall("dynamic_texts/dynamic_elmt_text") if t.get("uuid") == field)
self.assertEqual(float(x_of(out, b)), 77.0)
self.assertEqual(x_of(out, a), x_of(src, a))
def test_element_text_index_needs_the_element_as_well(self):
"""Copies of a symbol share their text fields' uuids (2612_ats_singlephase.qet):
the same field uuid resolves on each copy to that copy's own field."""