diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 6d29fe6dc..dd555a59a 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -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 | diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 9cf5d64e2..a33630123 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -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",)}, diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index a549581d4..3d90e9d13 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -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( + '' + '' + 'K1label' + '' + '' + '' + '' + '' + '' + '' + '') + + 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'' for i in range(501)) + big = Path(self.tmp.name) / "big.qet" + big.write_text(f'{many}') + 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():