qet-mcp: address texts, shapes and images by uuid; part uuids in symbols

- qet_edit: every op taking a text, shape or image "index" also takes its
  uuid, resolved at run time with textIndex()/shapeIndex()/imageIndex().
  Those are only required when a uuid is used.
- qet_element_build writes a uuid on every part (the caller's, or a new
  one) and returns them in part_uuids; qet_element_info lists part and
  terminal uuids.
- README and tests: drawing_item_view, uuid addressing, part uuids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-27 11:16:37 +13:00
parent 30e83d8c52
commit 76eea09192
3 changed files with 200 additions and 32 deletions
+7
View File
@@ -271,6 +271,13 @@ Python, plus the hang guard on `addConductor` and the database refresh in
QElectroTech applies to its own custom-query box, not one invented here.
An empty result and a failed query are told apart: `row_count` 0 with no
`error` means nothing matched, and a typo'd column name says so.
- **Texts, shapes and images are in the database too**, one row each in
`drawing_item_view` (`uuid`, `kind`, `folio`, position and size, and a
`description`: the shape type or the text). The uuid is the one saved in
the file, and every `qet_edit` op that takes a text, shape or image
`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`.
- **`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*
+79 -27
View File
@@ -626,14 +626,20 @@ def tool_element_info(path: str) -> dict:
ordered, ambiguous = _terminals_in_index_order(list(root.iter("terminal")))
terminals = [{"index": i, "x": t.get("x"), "y": t.get("y"),
"orientation": t.get("orientation"),
"name": t.get("name", ""), "type": t.get("type", "")}
"name": t.get("name", ""), "type": t.get("type", ""),
"uuid": t.get("uuid", "")}
for i, t in enumerate(ordered)]
info_fields = sorted({(i.text or "").strip()
for i in root.iter("info_name") if (i.text or "").strip()})
parts = {}
part_list = []
desc = root.find("description")
for child in (desc if desc is not None else []):
parts[child.tag] = parts.get(child.tag, 0) + 1
if child.tag != "terminal":
# uuid is empty for a part saved before parts carried one; the
# element editor gives it one on the next save.
part_list.append({"type": child.tag, "uuid": child.get("uuid", "")})
return {
"file": str(Path(path).expanduser()),
"type": root.get("type", ""), "link_type": root.get("link_type", ""),
@@ -646,6 +652,7 @@ def tool_element_info(path: str) -> dict:
"index is undefined" if ambiguous else ""),
"info_fields": info_fields,
"parts": parts,
"part_list": part_list,
}
@@ -883,11 +890,15 @@ def _validate_part(index: int, part) -> str:
for key in spec["required"]:
if key not in part:
raise ValueError(f"part {index} ({kind}) is missing {key!r}")
allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias"}
allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias", "uuid"}
for key in part:
if key not in allowed:
raise ValueError(f"part {index} ({kind}): unexpected {key!r}; "
f"allowed: {', '.join(sorted(allowed))}")
if "uuid" in part and not (isinstance(part["uuid"], str)
and _UUID_RE.fullmatch(part["uuid"])):
raise ValueError(f"part {index} ({kind}): uuid must look like "
f"{{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}}, got {part['uuid']!r}")
if kind == "polygon":
pts = part["points"]
if not isinstance(pts, list) or len(pts) < 2:
@@ -898,9 +909,19 @@ def _validate_part(index: int, part) -> str:
return kind
def _part_element(part: dict) -> ET.Element:
def _part_uuid(part: dict) -> str:
"""The caller's uuid for this part, braced as QElectroTech writes it, or
a new one. Every part carries one, as the element editor saves them."""
given = part.get("uuid")
if given:
return given if given.startswith("{") else "{" + given + "}"
return "{" + str(__import__("uuid").uuid4()) + "}"
def _part_element(part: dict, uuid: str) -> ET.Element:
kind = part["type"]
node = ET.Element(kind)
node.set("uuid", uuid)
if kind == "polygon":
for n, (px, py) in enumerate(part["points"], start=1):
node.set(f"x{n}", _fmt(px))
@@ -992,8 +1013,11 @@ def tool_element_build(output: str, names: dict, parts: list,
ET.SubElement(kind, "kindInformation", {"name": key}).text = str(informations[key])
ET.SubElement(root, "informations")
description = ET.SubElement(root, "description")
for part in parts:
description.append(_part_element(part))
part_uuids = [_part_uuid(part) for part in parts]
if len(set(part_uuids)) != len(part_uuids):
raise ValueError("two parts were given the same uuid")
for part, part_uuid in zip(parts, part_uuids):
description.append(_part_element(part, part_uuid))
for t in terminals:
attrs = {"x": _fmt(t["x"]), "y": _fmt(t["y"]),
"orientation": t["orientation"],
@@ -1017,6 +1041,8 @@ def tool_element_build(output: str, names: dict, parts: list,
# caller listed them in.
"terminal_index_order": [t["name"] or f"({t['x']},{t['y']})"
for t in check["terminals"]],
# In the order the parts were given.
"part_uuids": part_uuids,
"verified": check}
@@ -1066,8 +1092,9 @@ OPS = {
("value", "str")]),
"remove_plc_io": ("removePlcIO", [("folio", "folio"), ("element", "elmt"),
("index", "folio")]),
# Texts and shapes have no uuid; they are addressed by index into a
# position-sorted listing, and add_text/add_shape return that index so
# Texts, shapes and images are addressed by "index": their index in a
# position-sorted listing, which add_text/add_shape return, or their
# uuid, which does not shift when another one is added. So
# it can be named as "$id". Indexes shift when one is added or deleted.
"delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"),
("terminal", "num")]),
@@ -1123,38 +1150,38 @@ OPS = {
("to_folio", "folio"), ("x", "num"), ("y", "num")]),
"add_text": ("addText", [("folio", "folio"), ("text", "str"),
("x", "num"), ("y", "num")]),
"set_text": ("setTextContent", [("folio", "folio"), ("index", "folio"),
"set_text": ("setTextContent", [("folio", "folio"), ("index", "text"),
("text", "str")]),
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "folio"),
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "text"),
("color", "str")]),
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "folio"),
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "text"),
("angle", "num")]),
"delete_text": ("deleteText", [("folio", "folio"), ("index", "folio")]),
"delete_text": ("deleteText", [("folio", "folio"), ("index", "text")]),
"add_shape": ("addShape", [("folio", "folio"), ("shape", "str"),
("x1", "num"), ("y1", "num"),
("x2", "num"), ("y2", "num")]),
"set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "folio"),
"set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "shape"),
("property", "str"), ("value", "str")]),
"add_image": ("addImage", [("folio", "folio"), ("file", "str"),
("x", "num"), ("y", "num")]),
"add_pdf_page": ("addPdfPage", [("folio", "folio"), ("file", "str"),
("page", "num"), ("dpi", "num"),
("x", "num"), ("y", "num")]),
"scale_image": ("setImageScale", [("folio", "folio"), ("index", "folio"),
"scale_image": ("setImageScale", [("folio", "folio"), ("index", "image"),
("factor", "num")]),
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "folio"),
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "image"),
("angle", "num")]),
"delete_image": ("deleteImage", [("folio", "folio"), ("index", "folio")]),
"delete_shape": ("deleteShape", [("folio", "folio"), ("index", "folio")]),
"delete_image": ("deleteImage", [("folio", "folio"), ("index", "image")]),
"delete_shape": ("deleteShape", [("folio", "folio"), ("index", "shape")]),
"add_polygon": ("addPolygon", [("folio", "folio"), ("points", "points"),
("closed", "bool")]),
"set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "folio"),
"set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "shape"),
("points", "points")]),
"add_path": ("addPath", [("folio", "folio"), ("nodes", "nodes"),
("closed", "bool")]),
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "folio"),
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "shape"),
("nodes", "nodes")]),
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "folio"),
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "shape"),
("closed", "bool")]),
"add_table": ("addTable", [("folio", "folio"), ("kind", "str"),
("name", "str"), ("query", "str")]),
@@ -1193,6 +1220,9 @@ _REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} |
_MARKER = "QETEDIT "
_UUID_RE = re.compile(r"\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}?")
def _js(value) -> str:
"""A JSON literal is a JavaScript literal for every type used here."""
@@ -1207,11 +1237,15 @@ def _build_script(operations: list, output: str) -> str:
JavaScript exception.
"""
refs: set[str] = set()
# Resolvers a uuid reference needs; required only when one is used, so
# an index-only edit still runs on a build that predates them.
uuid_methods: set[str] = set()
folio_js = "0"
lines = [
"// generated by qet-mcp; do not edit",
"var R = {};", # $name -> value from an earlier op
"var missing = [];",
f"var need = {_js(_REQUIRED_METHODS)};",
"var need = @NEED@;",
"for (var i = 0; i < need.length; i++) {",
" if (typeof qet[need[i]] !== 'function') missing.push(need[i]);",
"}",
@@ -1260,6 +1294,18 @@ 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 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.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
method = {"text": "textIndex", "shape": "shapeIndex",
"image": "imageIndex"}[kind]
uuid_methods.add(method)
return f"qet.{method}({folio_js}, {_js(value)})"
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a {kind} index, "
f"its uuid, or a \"$name\" reference, got {value!r}")
return _js(value)
if kind == "folio":
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a folio index "
@@ -1346,6 +1392,7 @@ def _build_script(operations: list, output: str) -> str:
for key, kind in spec:
if key not in op:
raise ValueError(f"operation {i} ({name}) is missing {key!r}")
folio_js = args[0] if args else "0"
args.append(ref_or(op[key], kind, i, key))
ident = op.get("id")
@@ -1391,7 +1438,8 @@ def _build_script(operations: list, output: str) -> str:
lines.append(f" qet.log({_js(_MARKER)} + JSON.stringify("
"{kind: 'save', result: saved, stopped_early: stop}));")
lines.append("}")
return "\n".join(lines) + "\n"
need = _js(sorted(set(_REQUIRED_METHODS) | uuid_methods))
return "\n".join(line.replace("@NEED@", need) for line in lines) + "\n"
def _parse_script_output(text: str) -> dict:
@@ -2300,10 +2348,12 @@ TOOLS = [
"that element; set_element_text takes " +
", ".join(ELEMENT_TEXT_PROPERTIES) + ". A field bound with source "
"\"info\" follows set_label/set_info. Indexes shift on delete. "
"Texts and shapes have no uuid: add_text/add_shape return an "
"index you can name as \"$id\", and the other text/shape ops "
"take it as \"index\". Indexes shift when one is added or "
"deleted. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) +
"Texts, shapes and images: add_text/add_shape/add_image return an "
"index you can name as \"$id\", and the other text/shape/image "
"ops take it as \"index\". Indexes shift when one is added or "
"deleted; \"index\" also accepts the item's uuid (from the "
"project database's drawing_item_view, or a saved file), which "
"does not. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) +
" (fill accepts a colour or \"none\"). "
"add_shape's own \"polygon\" is always the degenerate two-point "
"form (it shares add_shape's p1/p2 shape); add_polygon takes as "
@@ -2323,7 +2373,7 @@ TOOLS = [
"shapes are listed by current on-folio position, so changing one "
"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. "
"edited in the same run, or address it by uuid. "
"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 "
@@ -2544,7 +2594,9 @@ TOOLS = [
'line x1,y1,x2,y2; rect/ellipse/arc x,y,width,height '
"(arc also start,angle); circle x,y,diameter; polygon "
'points:[[x,y],...] and closed; text x,y,text with optional '
"size, rotation, color. Any part may carry style and antialias. "
"size, rotation, color. Any part may carry style and antialias, "
"and a uuid to name it by; parts without one get a new uuid, "
"returned in part_uuids. "
"Coordinates are the element's own, with (0,0) at its origin.",
"items": {"type": "object"},
},
+114 -5
View File
@@ -328,6 +328,29 @@ class EditValidation(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "list of strings"):
self.build([{"op": "add_autonum", "kind": "conductor", "name": "a", "parts": "string:W"}])
def test_a_uuid_index_is_resolved_at_run_time(self):
u = "{11111111-1111-4111-8111-111111111111}"
script = self.build([{"op": "set_shape", "folio": 2, "index": u,
"property": "fill", "value": "none"},
{"op": "delete_text", "folio": 1, "index": u},
{"op": "delete_image", "folio": 0, "index": u}])
self.assertIn(f'qet.setShapeProperty(2, qet.shapeIndex(2, "{u}"), ', script)
self.assertIn(f'qet.deleteText(1, qet.textIndex(1, "{u}"))', script)
self.assertIn(f'qet.deleteImage(0, qet.imageIndex(0, "{u}"))', script)
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
self.assertTrue({"shapeIndex", "textIndex", "imageIndex"} <= set(need))
def test_an_index_only_edit_does_not_need_the_uuid_resolvers(self):
script = self.build([{"op": "delete_shape", "folio": 0, "index": 3}])
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need))
def test_a_string_index_that_is_not_a_uuid_is_refused(self):
for bad in ("3", "{nope}", "11111111-1111"):
with self.subTest(index=bad):
with self.assertRaises(ValueError):
self.build([{"op": "delete_shape", "folio": 0, "index": bad}])
def test_indexed_references_and_element_lists(self):
script = self.build([
{"op": "add_folio", "id": "f"},
@@ -536,6 +559,30 @@ class ElementBuild(unittest.TestCase):
{"x": 0, "y": -20, "orientation": "n", "name": "high"}])
self.assertEqual(r["terminal_index_order"], ["high", "low"])
def test_every_part_gets_its_own_uuid(self):
parts = [{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0},
{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5},
{"type": "text", "x": 0, "y": 0, "text": "K"}]
r = self.build(parts=parts)
desc = ET.parse(self.out).getroot().find("description")
written = [e.get("uuid") for e in desc if e.tag != "terminal"]
self.assertEqual(written, r["part_uuids"])
self.assertEqual(len(set(written)), 3)
self.assertEqual([p["uuid"] for p in r["verified"]["part_list"]], written)
def test_a_given_part_uuid_is_kept_and_braced(self):
r = self.build(parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0,
"uuid": "11111111-1111-4111-8111-111111111111"}])
self.assertEqual(r["part_uuids"], ["{11111111-1111-4111-8111-111111111111}"])
def test_bad_or_repeated_part_uuids_are_refused(self):
line = {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}
u = "{11111111-1111-4111-8111-111111111111}"
for parts in ([{**line, "uuid": "nope"}], [{**line, "uuid": u}, {**line, "uuid": u}]):
with self.subTest(parts=parts):
with self.assertRaises(ValueError):
self.build(parts=parts)
def test_output_reads_back(self):
r = self.build(names={"en": "Coil", "fr": "Bobine"})
self.assertEqual(r["verified"]["names"], {"en": "Coil", "fr": "Bobine"})
@@ -632,14 +679,17 @@ class Diff(unittest.TestCase):
def project(self, name, *, texts=(), shapes=(), images=(), author="", version="1",
strips=(), conductors=()):
inputs = "".join(f'<input x="{x}" y="{y}" rotation="0" font="f" '
# An optional last member of each tuple is the item's uuid.
uid = lambda u: f' uuid="{u[0]}"' if u else ""
inputs = "".join(f'<input x="{x}" y="{y}" rotation="0" font="f"{uid(u)} '
f'text="&lt;html&gt;&lt;body&gt;&lt;p&gt;{t}&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;"/>'
for x, y, t in texts)
shp = "".join(f'<shape type="Rectangle" x1="{a}" y1="{b}" x2="{c}" y2="{d}">'
for x, y, t, *u in texts)
shp = "".join(f'<shape type="Rectangle" x1="{a}" y1="{b}" x2="{c}" y2="{d}"{uid(u)}>'
f'<pen color="{pc}" style="SolidLine" widthF="1"/>'
f'<brush color="#fff" style="SolidPattern"/></shape>'
for a, b, c, d, pc in shapes)
img = "".join(f'<image x="{x}" y="{y}" size="{s}" rotation="0"/>' for x, y, s in images)
for a, b, c, d, pc, *u in shapes)
img = "".join(f'<image x="{x}" y="{y}" size="{s}" rotation="0"{uid(u)}/>'
for x, y, s, *u in images)
st = "".join(f'<terminal_strip><terminal_strip_data uuid="{u}"><informations>'
f'<information name="name">{n}</information></informations>'
f'</terminal_strip_data><layout/></terminal_strip>' for u, n in strips)
@@ -669,6 +719,39 @@ class Diff(unittest.TestCase):
self.assertEqual([t["text"] for t in d["removed"]], ["note"])
self.assertEqual([t["text"] for t in d["added"]], ["note EDITED"])
U1 = "{11111111-1111-4111-8111-111111111111}"
U2 = "{22222222-2222-4222-8222-222222222222}"
def test_edited_text_with_a_uuid_is_a_change_to_that_text(self):
a = self.project("a.qet", texts=[(1, 2, "note", self.U1)])
b = self.project("b.qet", texts=[(1, 2, "note EDITED", self.U1)])
d = m.tool_diff(a, b)["texts"]
self.assertEqual(d["keyed_by"], "uuid")
self.assertEqual((d["added"], d["removed"]), ([], []))
self.assertEqual(d["changed"][0]["changed"]["text"], ["note", "note EDITED"])
def test_moved_shape_with_a_uuid_is_a_change_to_that_shape(self):
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000", self.U1),
(9, 9, 12, 12, "#000000", self.U2)])
b = self.project("b.qet", shapes=[(100, 0, 105, 5, "#000000", self.U1),
(9, 9, 12, 12, "#000000", self.U2)])
d = m.tool_diff(a, b)["shapes"]
self.assertEqual((d["added"], d["removed"]), ([], []))
self.assertEqual(len(d["changed"]), 1)
self.assertEqual(d["changed"][0]["item"]["uuid"], self.U1)
self.assertEqual(d["changed"][0]["changed"]["from"], [["0", "0"], ["100", "0"]])
def test_a_file_without_uuids_against_its_resave_is_keyed_by_position(self):
"""The first save by a current QElectroTech adds uuids to a legacy
file: that must not read as every item removed and re-added."""
a = self.project("a.qet", texts=[(1, 2, "note")], images=[(3, 3, 1)])
b = self.project("b.qet", texts=[(1, 2, "note", self.U1)], images=[(3, 3, 1, self.U2)])
d = m.tool_diff(a, b)
for k in ("texts", "images"):
with self.subTest(section=k):
self.assertEqual(d[k]["keyed_by"], "position")
self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"])
def test_shape_restyle_is_a_change_to_that_item(self):
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000")])
b = self.project("b.qet", shapes=[(0, 0, 5, 5, "#ff0000")])
@@ -1975,6 +2058,32 @@ class Integration(unittest.TestCase):
self.assertEqual(len(d["shapes"]["added"]), 1)
self.assertEqual(len(d["images"]["added"]), 1)
def test_drawing_items_are_in_the_database_and_addressable_by_uuid(self):
base = self.sb.new()
r = self.ok(self.sb.edit(base, [
{"op": "add_text", "folio": 0, "text": "note", "x": 50, "y": 50},
{"op": "add_shape", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120}]))
root = ET.parse(r["output"]).getroot()
shape_uuid = next(root.iter("shape")).get("uuid")
text_uuid = next(root.iter("input")).get("uuid")
rows = m.tool_query(BINARY, r["output"],
"SELECT uuid, kind, folio, description FROM drawing_item_view "
"ORDER BY kind")["rows"]
self.assertEqual([(x["uuid"], x["kind"], x["folio"]) for x in rows],
[(shape_uuid, "shape", 1), (text_uuid, "text", 1)])
self.assertEqual(rows[1]["description"], "note")
# A second shape placed above the first shifts its index to 1; the
# uuid still names it.
r2 = self.ok(self.sb.edit(r["output"], [
{"op": "add_shape", "folio": 0, "shape": "line", "x1": 0, "y1": 0, "x2": 5, "y2": 0},
{"op": "set_shape", "folio": 0, "index": shape_uuid, "property": "fill", "value": "#00ff00"}],
out="out2.qet"))
d = r2["diff"]["shapes"]
self.assertEqual(d["keyed_by"], "uuid")
self.assertEqual([c["item"]["uuid"] for c in d["changed"]], [shape_uuid])
self.assertEqual(len(d["added"]), 1)
# ---- polygon and path shapes ----
def test_add_polygon_with_more_than_two_points(self):