mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-28 21:34:12 +02:00
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:
@@ -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
@@ -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",)},
|
||||
|
||||
@@ -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="<html><body>note</body></html>"/></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():
|
||||
|
||||
Reference in New Issue
Block a user