qet-mcp: add qet_items, listing drawn items with their uuids

qet_edit addresses free texts, shapes, pictures and tables by uuid, and
qet_diff reports them by uuid, but no tool listed them: the only way to
learn an item's uuid was to read the .qet. qet_items lists every free
text, shape, picture, table and symbol text field per folio (counted from
1, as qet_elements), with its uuid and main fields; filter by folio and
kind; default limit 500.

Also a test that every tool argument holding a data path is in the
workspace policy (_DATA_PATHS). Nothing checked that direction: a new tool
left out of the policy would have read or written anywhere with every test
passing, as removing qet_items' entry showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-28 13:15:21 +13:00
parent f5888e8b56
commit 279c98f10a
3 changed files with 134 additions and 3 deletions
+1
View File
@@ -30,6 +30,7 @@ here read the model.
| `qet_project_info` | title, format version, folios, 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 |
| `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips |
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
+55 -2
View File
@@ -272,6 +272,36 @@ def tool_elements(path: str, folio: int | None = None,
"elements": rows[:limit]}
ITEM_KINDS = ["text", "shape", "image", "table", "element_text"]
def tool_items(path: str, folio: int | None = None, kind: str | None = None,
limit: int = 500) -> dict:
"""Every drawn item that is not a symbol or a wire, with its uuid.
Free texts, shapes, pictures, tables and the text fields of symbols --
the items a qet_edit op or a qet_diff entry names by uuid. Folios are
numbered from 1, as in qet_elements. An item saved before these items
carried a uuid has "" here; QElectroTech gives it one on the next save.
"""
if kind is not None and kind not in ITEM_KINDS:
raise ValueError(f"kind must be one of {ITEM_KINDS}, not {kind!r}")
ex = _extras(_root(path))
rows = []
for name, records in (("text", ex["texts"]), ("shape", ex["shapes"]),
("image", ex["images"]), ("table", ex["tables"])):
for r in records:
rows.append({"kind": name, "uuid": r["uuid"], **r["label"], **r["value"]})
for k, v in ex["element_texts"].items():
rows.append({"kind": "element_text", "uuid": ex["element_text_uuids"][k],
"folio": ex["element_text_folios"][k], "element": k[0],
"source": k[1], "bound_to": k[2], "n": k[3], **v})
rows = [r for r in rows if (folio is None or r["folio"] == folio)
and (kind is None or r["kind"] == kind)]
rows.sort(key=lambda r: (r["folio"], ITEM_KINDS.index(r["kind"])))
return {"count": len(rows), "truncated": len(rows) > limit, "items": rows[:limit]}
def tool_conductors(path: str, folio: int | None = None,
attribute: str | None = None,
non_empty: bool = False, limit: int = 200) -> dict:
@@ -373,7 +403,7 @@ def _extras(root: ET.Element) -> dict:
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
{"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))}))
element_texts, element_text_uuids = {}, {}
element_texts, element_text_uuids, element_text_folios = {}, {}, {}
for n, d in _folios(root):
for el in d.iter("element"):
uuid = el.get("uuid", "")
@@ -387,6 +417,7 @@ def _extras(root: ET.Element) -> dict:
seen[base] = seen.get(base, 0) + 1
fs = (t.get("font", "").split(",") + ["", ""])[1]
element_text_uuids[base + (seen[base],)] = t.get("uuid", "")
element_text_folios[base + (seen[base],)] = n
element_texts[base + (seen[base],)] = {
"x": t.get("x", ""), "y": t.get("y", ""), "size": fs,
"frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")),
@@ -406,7 +437,8 @@ def _extras(root: ET.Element) -> dict:
"terminals": sum(1 for _ in st.iter("real_terminal"))}
return {"folios": folios, "folio_uuids": folio_uuids, "texts": texts, "shapes": shapes,
"images": images, "tables": tables, "strips": strips,
"element_texts": element_texts, "element_text_uuids": element_text_uuids}
"element_texts": element_texts, "element_text_uuids": element_text_uuids,
"element_text_folios": element_text_folios}
def _diff_keyed(a: dict, b: dict, label) -> dict:
@@ -2225,6 +2257,26 @@ TOOLS = [
},
"handler": lambda a: tool_project_info(a["path"]),
},
{
"name": "qet_items",
"description": "List the drawn items that are not symbols or wires -- free texts, "
"shapes, pictures, tables and the text fields of symbols -- with "
"each one's uuid, folio (counted from 1) and main fields. Use the "
"uuid to address an item in qet_edit or to find it in qet_diff. "
"Reads the file directly; does not launch QElectroTech.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"folio": {"type": "integer", "description": "folio number counted from 1"},
"kind": {"type": "string", "enum": ITEM_KINDS},
"limit": {"type": "integer", "default": 500},
},
"required": ["path"],
},
"handler": lambda a: tool_items(a["path"], a.get("folio"), a.get("kind"),
a.get("limit", 500)),
},
{
"name": "qet_elements",
"description": "List placed elements with uuid, type, position, label and "
@@ -2798,6 +2850,7 @@ _BY_NAME = {t["name"]: t for t in TOOLS}
_DATA_PATHS = {
"qet_project_info": {"read": ("path",)},
"qet_elements": {"read": ("path",)},
"qet_items": {"read": ("path",)},
"qet_conductors": {"read": ("path",)},
"qet_diff": {"read": ("before", "after")},
"qet_scan": {"read": ("directory",)},
+78 -1
View File
@@ -153,7 +153,7 @@ class ToolRegistry(unittest.TestCase):
"qet_project_info", "qet_elements", "qet_conductors", "qet_diff",
"qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query",
"qet_project_new", "qet_element_search", "qet_check", "qet_element_build",
"qet_continuity"})
"qet_continuity", "qet_items"})
class EditValidation(unittest.TestCase):
@@ -838,6 +838,69 @@ class ReadToolContracts(unittest.TestCase):
self.assertEqual(m._plain_text(None), "")
class ItemsTool(unittest.TestCase):
"""qet_items: every drawn item that is not a symbol or a wire, with its uuid."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.qet = str(Path(self.tmp.name) / "p.qet")
Path(self.qet).write_text(
'<project><diagram><elements>'
'<element uuid="{el}" type="x.elmt" x="0" y="0"><dynamic_texts>'
'<dynamic_elmt_text uuid="{et}" x="5" y="6" rotation="0" text_width="-1" frame="false"'
' font="Sans,9,-1" text_from="ElementInfo"><text>K1</text><info_name>label</info_name>'
'</dynamic_elmt_text></dynamic_texts></element></elements><conductors/>'
'<inputs><input uuid="{tx}" x="1" y="2" rotation="0" font="f" color="c"'
' text="&lt;html&gt;&lt;body&gt;note&lt;/body&gt;&lt;/html&gt;"/></inputs>'
'<shapes><shape uuid="{sh}" type="Line" x1="0" y1="0" x2="9" y2="9" rotation="0">'
'<pen color="c" style="s" widthF="1"/></shape></shapes>'
'</diagram><diagram><elements/><conductors/>'
'<images><image x="3" y="4" size="1" rotation="0"/></images>'
'<graphics_table uuid="{tb}" name="Parts" x="0" y="0" width="100" height="50"'
' display_n_row="10"/>'
'</diagram></project>')
def tearDown(self):
self.tmp.cleanup()
def test_every_kind_with_its_uuid(self):
r = m.tool_items(self.qet)
self.assertEqual((r["count"], r["truncated"]), (5, False))
self.assertEqual(r["items"], [
{"kind": "text", "uuid": "{tx}", "folio": 1, "x": "1", "y": "2", "text": "note",
"rotation": "0", "font": "f", "color": "c"},
{"kind": "shape", "uuid": "{sh}", "folio": 1, "type": "Line", "from": ["0", "0"],
"to": ["9", "9"], "line_color": "c", "line_style": "s", "line_width": "1",
"fill": "none", "rotation": "0"},
{"kind": "element_text", "uuid": "{et}", "folio": 1, "element": "{el}",
"source": "ElementInfo", "bound_to": "label", "n": 1, "x": "5", "y": "6",
"size": "9", "frame": "false", "rotation": "0", "width": "-1", "shows": "K1"},
# saved before pictures carried a uuid: "" until the next save
{"kind": "image", "uuid": "", "folio": 2, "x": "3", "y": "4", "scale": "1",
"rotation": "0"},
{"kind": "table", "uuid": "{tb}", "folio": 2, "name": "Parts", "x": "0", "y": "0",
"width": "100", "height": "50", "rows_shown": "10"}])
def test_filters_and_limit(self):
kinds = lambda r: [i["kind"] for i in r["items"]]
self.assertEqual(kinds(m.tool_items(self.qet, folio=2)), ["image", "table"])
self.assertEqual(kinds(m.tool_items(self.qet, kind="shape")), ["shape"])
self.assertEqual(kinds(m.tool_items(self.qet, folio=2, kind="shape")), [])
r = m.tool_items(self.qet, limit=2)
self.assertEqual((r["count"], r["truncated"], len(r["items"])), (5, True, 2))
r = m.tool_items(self.qet, limit=5)
self.assertEqual((r["count"], r["truncated"]), (5, False))
with self.assertRaisesRegex(ValueError, "kind must be one of"):
m.tool_items(self.qet, kind="wire")
def test_default_limit_is_500(self):
many = "".join(f'<input uuid="{{{i}}}" x="{i}" y="0" text="t"/>' for i in range(501))
big = Path(self.tmp.name) / "big.qet"
big.write_text(f'<project><diagram><inputs>{many}</inputs></diagram></project>')
r = m.tool_items(str(big))
self.assertEqual((r["count"], r["truncated"], len(r["items"])), (501, True, 500))
class DiffContracts(unittest.TestCase):
"""Exact qet_diff output, section by section, on hand-made pairs.
@@ -1572,6 +1635,20 @@ class PathPolicy(unittest.TestCase):
if "overwrite" in t["inputSchema"].get("properties", {})}
self.assertEqual(guarded, advertised)
def test_every_data_path_argument_is_guarded(self):
"""The other direction: a tool whose schema takes a data path must be
in the policy, or that path is read or written with no workspace
check at all -- and nothing fails. binary and elements_dir are
configuration, deliberately not confined (see the README)."""
pathish = {"path", "project", "before", "after", "output", "directory"}
for t in m.TOOLS:
with self.subTest(tool=t["name"]):
props = t["inputSchema"].get("properties", {})
spec = m._DATA_PATHS.get(t["name"], {})
guarded = set(spec.get("read", ())) | set(spec.get("write", ()))
self.assertEqual(pathish & set(props), guarded & pathish,
f"{t['name']}: data path arguments not in _DATA_PATHS")
def test_the_policy_names_only_real_tools_and_arguments(self):
by_name = {t["name"]: t for t in m.TOOLS}
for name, spec in m._DATA_PATHS.items():