mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 17:34:12 +02:00
7df9052a47
The "one text per potential" switch (onetextperfolio) lives in a folio's conductor defaults, which no scripting call or qet_edit op could reach, so callers patched the saved .qet afterwards (#1178). qet.setConductorDefault(folio, property, value) sets onetextperfolio or any setConductorProperty name on a folio's defaults, or with folio -1 on the project's defaults that new folios copy. A change to onetextperfolio re-shows or hides the conductor texts at once, as the Folio properties dialog does. Not on the undo stack, like both dialogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3569 lines
181 KiB
Python
Executable File
3569 lines
181 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright 2006-2026 The QElectroTech Team
|
|
# This file is part of QElectroTech.
|
|
#
|
|
# QElectroTech is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# QElectroTech is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
qet-mcp — a Model Context Protocol server over QElectroTech projects.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
Verifying a QET change by screenshot is unreliable. Twice in one review
|
|
session a screenshot was read as showing a defect that the saved file
|
|
proved had not happened: once "dragging a multi-selection leaves the
|
|
symbols behind and detaches their labels" (the XML showed all four
|
|
elements moved and no label moved), and once "Apply does nothing" (Apply
|
|
was disabled because a required field was empty). Both times the pixels
|
|
misled and the model told the truth.
|
|
|
|
So the primary tools here read the *model*, not the screen, and the
|
|
primary tool is qet_diff: do the thing, then ask what actually changed.
|
|
|
|
DESIGN
|
|
|
|
Most tools parse the .qet XML directly and never launch QElectroTech.
|
|
That is deliberate: it is fast, deterministic, needs no display, and
|
|
cannot be confused by a dialog. Only qet_export shells out to the
|
|
binary, and it carries the launch traps with it (see _run_qet).
|
|
|
|
The project database would be a better query surface than XML, but it is
|
|
not reachable from outside the application: projectDataBase::newQuery()
|
|
and isReadOnlySelect() are C++-internal and the JavaScript scripting API
|
|
exposes no SQL binding. Until it does, structure lives here.
|
|
|
|
PROTOCOL
|
|
|
|
Line-delimited JSON-RPC 2.0 on stdin/stdout, per MCP's stdio transport.
|
|
Nothing but protocol goes to stdout; diagnostics go to stderr.
|
|
No third-party dependencies — the MCP SDK is not assumed to be present.
|
|
|
|
`--call <tool> [arguments]` runs one tool without an MCP client, for an
|
|
assistant that can execute Python but cannot launch a server (a web chat
|
|
with code execution). It goes through the same dispatcher, so the
|
|
workspace policy applies exactly as it does over stdio.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path, PurePath
|
|
|
|
SERVER_NAME = "qet-mcp"
|
|
SERVER_VERSION = "0.1.0"
|
|
DEFAULT_PROTOCOL = "2025-06-18"
|
|
|
|
EXPORT_FORMATS = {
|
|
"pdf": "--export-pdf",
|
|
"png": "--export-png",
|
|
"svg": "--export-svg",
|
|
"dxf": "--export-dxf",
|
|
"bom": "--export-bom",
|
|
"cables": "--export-cables",
|
|
"wires": "--export-wires",
|
|
"wiring": "--export-wiring",
|
|
"nets": "--export-nets",
|
|
"links": "--export-links",
|
|
"info": "--info",
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# model reading
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _root(path: str) -> ET.Element:
|
|
p = Path(path).expanduser()
|
|
if not p.is_file():
|
|
raise ValueError(f"no such file: {p}")
|
|
try:
|
|
return ET.parse(p).getroot()
|
|
except ET.ParseError as exc:
|
|
raise ValueError(f"{p.name} is not parseable XML: {exc}") from exc
|
|
|
|
|
|
def _element_info(el: ET.Element) -> dict:
|
|
"""The <elementInformations> bag, as a plain dict."""
|
|
out = {}
|
|
bag = el.find("elementInformations")
|
|
if bag is not None:
|
|
for info in bag.findall("elementInformation"):
|
|
name = info.get("name")
|
|
if name:
|
|
out[name] = (info.text or "").strip()
|
|
return out
|
|
|
|
|
|
def _folios(root: ET.Element):
|
|
"""Yield (index, diagram) for each folio, 1-based as the UI numbers them."""
|
|
for i, d in enumerate(root.iter("diagram"), start=1):
|
|
yield i, d
|
|
|
|
|
|
def _elements(root: ET.Element):
|
|
for i, d in _folios(root):
|
|
for el in d.iter("element"):
|
|
yield i, el
|
|
|
|
|
|
def _wires(diagram: ET.Element):
|
|
"""The folio's conductors: children of <conductors> only. A folio's wire
|
|
numbering rule is also saved as a <conductor> tag, under <autonum>, and
|
|
is not a wire."""
|
|
return diagram.findall("conductors/conductor")
|
|
|
|
|
|
def _conductors(root: ET.Element):
|
|
definitions = _definition_terminals(root)
|
|
for i, d in _folios(root):
|
|
index = _terminal_index(d, definitions)
|
|
for c in _wires(d):
|
|
yield i, c, index
|
|
|
|
|
|
def _uuid_key(value: str) -> str:
|
|
return (value or "").strip().strip("{}").lower()
|
|
|
|
|
|
# Orientation as a placed symbol's <terminal> record writes it (an int,
|
|
# Qet::Orientation) or as a definition does (n/e/s/w).
|
|
_ORIENTATIONS = {"n": 0, "e": 1, "s": 2, "w": 3, "0": 0, "1": 1, "2": 2, "3": 3}
|
|
|
|
# Where QElectroTech docks a wire, relative to the terminal's position in
|
|
# its definition (Terminal's constructor, Terminal::terminalSize = 4). A
|
|
# placed symbol's <terminal> record is written at that point.
|
|
_DOCK_OFFSET = {0: (0.0, 4.0), 1: (-4.0, 0.0), 2: (0.0, -4.0), 3: (4.0, 0.0)}
|
|
|
|
|
|
def _definition_terminals(root: ET.Element) -> dict:
|
|
"""Map each symbol stored in the project ("embed://" + its path in the
|
|
<collection>) to its terminals: {terminal uuid: (x, y, orientation)},
|
|
the position being the one in the definition."""
|
|
out = {}
|
|
|
|
def walk(node, path):
|
|
for child in node:
|
|
if child.tag == "category":
|
|
walk(child, path + [child.get("name", "")])
|
|
elif child.tag == "element":
|
|
terminals = {}
|
|
for t in child.findall("definition/description/terminal"):
|
|
try:
|
|
terminals[_uuid_key(t.get("uuid"))] = (
|
|
float(t.get("x")), float(t.get("y")),
|
|
_ORIENTATIONS.get((t.get("orientation") or "n")[:1], 0))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
terminals.pop("", None)
|
|
if terminals:
|
|
out["embed://" + "/".join(path + [child.get("name", "")])] = terminals
|
|
|
|
collection = root.find("collection")
|
|
if collection is not None:
|
|
walk(collection, [])
|
|
return out
|
|
|
|
|
|
def _element_row(folio: int, el: ET.Element) -> dict:
|
|
info = _element_info(el)
|
|
etype = el.get("type", "")
|
|
return {
|
|
"folio": folio,
|
|
"uuid": el.get("uuid", ""),
|
|
"type": etype,
|
|
"name": etype.rsplit("/", 1)[-1].removesuffix(".elmt"),
|
|
"x": el.get("x"),
|
|
"y": el.get("y"),
|
|
"label": info.get("label", ""),
|
|
"info": info,
|
|
}
|
|
|
|
|
|
def _terminal_index(diagram: ET.Element, definitions: dict | None = None) -> dict:
|
|
"""Map a folio's terminal ids to an identity that survives a save.
|
|
|
|
A conductor names its ends with terminal1/terminal2, which are plain
|
|
integers scoped to the folio -- and QElectroTech reassigns them on every
|
|
write, in whatever order it happens to serialise the elements. The same
|
|
untouched conductor comes back as terminal1="1" terminal2="16" before a
|
|
save and terminal1="34" terminal2="15" after one. Keying a conductor on
|
|
that pair, which this tool used to do, made every conductor in the file
|
|
read as removed-and-re-added whenever the "after" side had been through
|
|
QElectroTech -- which is exactly the case qet_edit produces, so the
|
|
conductor half of the diff was noise precisely when it was needed.
|
|
|
|
So resolve each id to (owning element uuid, terminal position and
|
|
orientation inside that element). Element uuids are persisted and
|
|
stable; the terminal's local geometry comes from the element definition
|
|
and does not move when the element moves. That pair is the same basis
|
|
QET's own Terminal::stableUuid() uses for terminals with no uuid of
|
|
their own, and it is stable for the same reasons.
|
|
|
|
Conductors in the corpus carry no element1/element2 attribute -- 0 of
|
|
47 in ArduinoLCD.qet, 0 of 67 in 741.qet -- so this mapping has to be
|
|
built from the elements rather than read off the conductor.
|
|
|
|
A conductor can also name its ends by terminal uuid (element1 +
|
|
terminal1), and QElectroTech writes that form as soon as the terminal
|
|
has a uuid -- which, since a project gives every terminal one on
|
|
opening, is the first save of any older file. So the same untouched
|
|
conductor is written in the numbered form before a save and the uuid
|
|
form after it. With @p definitions (from _definition_terminals()), a
|
|
uuid end is resolved too, keyed (element uuid, terminal uuid), to the
|
|
very same identity as the numbered end: the terminal's definition
|
|
position, moved to where the wire docks, is where the placed symbol's
|
|
<terminal> record is.
|
|
"""
|
|
index = {}
|
|
for el in diagram.iter("element"):
|
|
uuid = el.get("uuid", "")
|
|
if not uuid:
|
|
# Old enough to predate persisted element uuids. Leaving these
|
|
# ids unresolved is deliberate: keyed on terminal geometry
|
|
# alone, every element of the same type collapses together --
|
|
# in schema_indus.qet that merged nine distinct conductors onto
|
|
# one key, which is worse than the instability it was meant to
|
|
# fix. An unresolved end keeps them apart and stays visibly
|
|
# marked with a "#" so the caller can see the diff is on the
|
|
# unstable footing that file forces.
|
|
continue
|
|
records = []
|
|
for t in el.iter("terminal"):
|
|
tid = t.get("id")
|
|
if tid is None:
|
|
continue
|
|
key = (f"{uuid}@{t.get('x','?')},{t.get('y','?')}"
|
|
f",{t.get('orientation','?')}")
|
|
index[tid] = key
|
|
records.append((t, key))
|
|
for tuuid, (x, y, o) in (definitions or {}).get(el.get("type", ""), {}).items():
|
|
dx, dy = _DOCK_OFFSET[o]
|
|
for t, key in records:
|
|
try:
|
|
if (abs(float(t.get("x")) - (x + dx)) < 1e-6
|
|
and abs(float(t.get("y")) - (y + dy)) < 1e-6
|
|
and _ORIENTATIONS.get((t.get("orientation") or "")[:1]) == o):
|
|
index[(_uuid_key(uuid), tuuid)] = key
|
|
break
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return index
|
|
|
|
|
|
def _conductor_key(folio: int, c: ET.Element, index: dict) -> str:
|
|
"""Identify a conductor by its two ends, in whichever scheme it uses.
|
|
|
|
The project format has two, and a file can hold both at once -- the
|
|
same folio, after an edit, carries legacy conductors and new ones:
|
|
|
|
- legacy: terminal1/terminal2 are the folio-scoped integer ids, and
|
|
there is no element1/element2. Resolve them through index.
|
|
- current: terminal1/terminal2 are terminal uuids from the element
|
|
*definition*, with element1/element2 naming the placed instances.
|
|
The terminal uuid alone is not an identity -- two coils of the same
|
|
type have the same one on both ends, so a conductor between them
|
|
would key as a self-loop -- so it is the (instance, terminal) pair
|
|
that identifies an end.
|
|
"""
|
|
ends = []
|
|
for elem_attr, term_attr, name_attr in (("element1", "terminal1", "terminalname1"),
|
|
("element2", "terminal2", "terminalname2")):
|
|
tid = c.get(term_attr, "?")
|
|
owner = c.get(elem_attr)
|
|
if owner:
|
|
ends.append(index.get((_uuid_key(owner), _uuid_key(tid)))
|
|
or f"{owner}/{tid or c.get(name_attr, '?')}")
|
|
else:
|
|
# An id with no element behind it stays visible as itself
|
|
# rather than silently collapsing conductors onto one key.
|
|
ends.append(index.get(tid, f"#{tid}"))
|
|
# A conductor is undirected: whichever end QET happens to write first,
|
|
# it is the same connection.
|
|
return f"{folio}:" + "--".join(sorted(ends))
|
|
|
|
|
|
def _conductor_row(folio: int, c: ET.Element, index: dict | None = None) -> dict:
|
|
return {
|
|
"folio": folio,
|
|
"uuid": c.get("uuid", ""),
|
|
"key": _conductor_key(folio, c, index or {}),
|
|
"num": c.get("num", ""),
|
|
"formula": c.get("formula", ""),
|
|
"cable": c.get("cable", ""),
|
|
"bus": c.get("bus", ""),
|
|
"function": c.get("function", ""),
|
|
"color": c.get("conductor_color", ""),
|
|
"section": c.get("conductor_section", ""),
|
|
"type": c.get("type", ""),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# tools
|
|
# --------------------------------------------------------------------------
|
|
|
|
def tool_project_info(path: str) -> dict:
|
|
root = _root(path)
|
|
folios = []
|
|
for i, d in _folios(root):
|
|
folios.append({
|
|
"index": i,
|
|
"uuid": d.get("uuid", ""),
|
|
"title": d.get("title", ""),
|
|
"elements": sum(1 for _ in d.iter("element")),
|
|
"conductors": len(_wires(d)),
|
|
})
|
|
return {
|
|
"file": str(Path(path).expanduser()),
|
|
"title": root.get("title", ""),
|
|
"version": root.get("version", ""),
|
|
"folio_count": len(folios),
|
|
"element_count": sum(f["elements"] for f in folios),
|
|
"conductor_count": sum(f["conductors"] for f in folios),
|
|
"folios": folios,
|
|
}
|
|
|
|
|
|
def tool_elements(path: str, folio: int | None = None,
|
|
name_contains: str | None = None, limit: int = 200) -> dict:
|
|
rows = []
|
|
for i, el in _elements(_root(path)):
|
|
if folio is not None and i != folio:
|
|
continue
|
|
row = _element_row(i, el)
|
|
if name_contains and name_contains.lower() not in row["name"].lower():
|
|
continue
|
|
rows.append(row)
|
|
return {"count": len(rows), "truncated": len(rows) > limit,
|
|
"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:
|
|
rows = []
|
|
for i, c, ix in _conductors(_root(path)):
|
|
if folio is not None and i != folio:
|
|
continue
|
|
row = _conductor_row(i, c, ix)
|
|
if attribute is not None:
|
|
value = c.get(attribute, "")
|
|
if non_empty and not value.strip():
|
|
continue
|
|
row["value"] = value
|
|
rows.append(row)
|
|
return {"count": len(rows), "truncated": len(rows) > limit,
|
|
"conductors": rows[:limit]}
|
|
|
|
|
|
# No "version": that attribute is the file-format stamp QElectroTech rewrites
|
|
# on every save, so diffing it made every folio of any re-saved project look
|
|
# edited, and it is not something a script can set (see setFolioProperty).
|
|
_FOLIO_FIELDS = ("title", "author", "plant", "locmach", "indexrev",
|
|
"folio", "filename",
|
|
# the frame: attribute names as the file writes them
|
|
"cols", "colsize", "rows", "rowsize", "displaycols", "displayrows",
|
|
"titleblocktemplate")
|
|
|
|
|
|
def _plain_text(html: str) -> str:
|
|
"""The visible text of an independent text's HTML, which is what a
|
|
person means by "the text". The file stores a whole HTML document."""
|
|
import re
|
|
body = re.search(r"<body[^>]*>(.*)</body>", html or "", re.S)
|
|
inner = body.group(1) if body else (html or "")
|
|
inner = re.sub(r"<[^>]+>", "", inner)
|
|
for a, b in (("<", "<"), (">", ">"), ("&", "&"), (""", '"'), ("'", "'")):
|
|
inner = inner.replace(a, b)
|
|
return " ".join(inner.split())
|
|
|
|
|
|
def _angle(value: str) -> str:
|
|
"""A rotation in degrees, reduced to [0, 360) so equal angles compare equal.
|
|
|
|
QElectroTech writes the same angle in more than one way: rotating a symbol
|
|
and undoing it leaves its text fields at "-270" where they were "90", or
|
|
"-90" where they were "270". Compared as written, that read as a change.
|
|
Anything that is not a number is returned unchanged.
|
|
"""
|
|
try:
|
|
deg = float(value) % 360
|
|
except (TypeError, ValueError):
|
|
return value
|
|
return f"{deg:g}"
|
|
|
|
|
|
def _extras(root: ET.Element) -> dict:
|
|
"""Everything a folio holds besides elements and conductors.
|
|
|
|
Independent texts, shapes and images are records, not a keyed dict:
|
|
files written since they carry a uuid are compared by it (see
|
|
_diff_items), so an edited or moved text reads as that text, changed.
|
|
Older files have only position to go on, and there a change reads as
|
|
the old one removed and a new one added, with both shown.
|
|
"""
|
|
folios, folio_uuids, texts, shapes, images, tables = {}, {}, [], [], [], []
|
|
|
|
def record(el, label, value):
|
|
return {"uuid": el.get("uuid", ""), "key": tuple(label.values()),
|
|
"label": label, "value": value}
|
|
|
|
for n, d in _folios(root):
|
|
folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS}
|
|
folio_uuids[n] = d.get("uuid", "")
|
|
for tb in d.findall("tables/graphics_table"):
|
|
tables.append(record(
|
|
tb, {"folio": n, "name": tb.get("name", "")},
|
|
{"x": tb.get("x", ""), "y": tb.get("y", ""), "width": tb.get("width", ""),
|
|
"height": tb.get("height", ""), "rows_shown": tb.get("display_n_row", "")}))
|
|
# The folio's own items only: direct children of its <inputs>,
|
|
# <shapes> and <images>. Symbols in older files carry their own
|
|
# <inputs><input> texts, which iter() would count as free texts
|
|
# (122 extra in schema_indus.qet).
|
|
for t in d.findall("inputs/input"):
|
|
texts.append(record(
|
|
t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""),
|
|
"text": _plain_text(t.get("text", ""))},
|
|
{"rotation": _angle(t.get("rotation", "0")),
|
|
"font": t.get("font", ""), "color": t.get("color", "")}))
|
|
for sh in d.findall("shapes/shape"):
|
|
pen, brush = sh.find("pen"), sh.find("brush")
|
|
shapes.append(record(
|
|
sh, {"folio": n, "type": sh.get("type", ""),
|
|
"from": [sh.get("x1", ""), sh.get("y1", "")],
|
|
"to": [sh.get("x2", ""), sh.get("y2", "")]},
|
|
{"line_color": pen.get("color", "") if pen is not None else "",
|
|
"line_style": pen.get("style", "") if pen is not None else "",
|
|
"line_width": pen.get("widthF", "") if pen is not None else "",
|
|
"fill": (brush.get("color", "") if brush is not None and
|
|
brush.get("style", "") != "NoBrush" else "none"),
|
|
"rotation": _angle(sh.get("rotation", "0"))}))
|
|
for im in d.findall("images/image"):
|
|
images.append(record(
|
|
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_text_folios = {}, {}, {}
|
|
for n, d in _folios(root):
|
|
for el in d.iter("element"):
|
|
uuid = el.get("uuid", "")
|
|
seen = {}
|
|
for t in el.iter("dynamic_elmt_text"):
|
|
src = t.get("text_from", "")
|
|
what = (t.findtext("info_name") if src == "ElementInfo"
|
|
else t.findtext("composite_text") if src == "CompositeText"
|
|
else t.findtext("text")) or ""
|
|
base = (uuid, src, what)
|
|
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", "")),
|
|
"width": t.get("text_width", ""),
|
|
"shows": t.findtext("text") or ""}
|
|
|
|
strips = {}
|
|
for st in root.iter("terminal_strip"):
|
|
data = st.find("terminal_strip_data")
|
|
if data is None:
|
|
continue
|
|
info = {i.get("name"): (i.text or "") for i in data.iter("information")}
|
|
strips[data.get("uuid", "")] = {
|
|
"installation": info.get("installation", ""),
|
|
"location": info.get("location", ""),
|
|
"name": info.get("name", ""),
|
|
"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_text_folios": element_text_folios}
|
|
|
|
|
|
def _usable_ids(*sides) -> bool:
|
|
"""Whether uuids can identify items: present on every item, unique on each
|
|
side. Copying a symbol keeps its text fields' uuids, so a project can
|
|
hold the same field uuid twenty times; keying on it would merge them."""
|
|
return (any(sides) and all(all(s) for s in sides)
|
|
and all(len(set(s)) == len(s) for s in sides))
|
|
|
|
|
|
def _diff_keyed(a: dict, b: dict, label) -> dict:
|
|
"""added / removed / changed for two dicts keyed by identity."""
|
|
changed = []
|
|
for k in sorted(set(a) & set(b), key=str):
|
|
delta = {f: [a[k][f], b[k][f]] for f in a[k] if a[k][f] != b[k].get(f)}
|
|
if delta:
|
|
changed.append({"item": label(k), "changed": delta})
|
|
return {"before": len(a), "after": len(b),
|
|
"added": [label(k) for k in sorted(set(b) - set(a), key=str)][:50],
|
|
"removed": [label(k) for k in sorted(set(a) - set(b), key=str)][:50],
|
|
"changed": changed[:50]}
|
|
|
|
|
|
def _diff_items(a: list, b: list) -> dict:
|
|
"""_diff_keyed() over _extras() records of one kind.
|
|
|
|
Keyed on uuid only when every item on both sides has one. A file saved
|
|
before these items carried a uuid has none, and the first save by a
|
|
current QElectroTech gives them one, so a mixed pair falls back to
|
|
position for the whole kind rather than reading as everything removed
|
|
and re-added. On uuid, position is part of what is compared, so a move
|
|
is a change to that item.
|
|
"""
|
|
by_uuid = _usable_ids([r["uuid"] for r in a], [r["uuid"] for r in b])
|
|
|
|
def key(r):
|
|
return r["uuid"] if by_uuid else str(r["key"])
|
|
|
|
def keyed(rs):
|
|
return {key(r): ({**r["label"], **r["value"]} if by_uuid else r["value"])
|
|
for r in rs}
|
|
|
|
labels = {key(r): ({**r["label"], "uuid": r["uuid"]} if by_uuid else r["label"])
|
|
for r in a + b}
|
|
out = _diff_keyed(keyed(a), keyed(b), lambda k: labels[k])
|
|
out["keyed_by"] = "uuid" if by_uuid else "position"
|
|
return out
|
|
|
|
|
|
def _diff_folios(a: dict, b: dict) -> dict:
|
|
"""Folio fields, keyed by the folio's uuid when every folio has one.
|
|
|
|
By uuid, a folio moved to another position is reported once, under
|
|
"reordered", instead of as every folio after it changing its fields.
|
|
Without uuids (older files) folios are keyed by position, and a
|
|
removal or reorder in the middle shifts every later index -- the note
|
|
says so when the count changed.
|
|
"""
|
|
ua, ub = a["folio_uuids"], b["folio_uuids"]
|
|
by_uuid = _usable_ids(list(ua.values()), list(ub.values()))
|
|
changed, reordered, added, removed = [], [], [], []
|
|
if by_uuid:
|
|
pos_a = {u: n for n, u in ua.items()}
|
|
pos_b = {u: n for n, u in ub.items()}
|
|
for u in sorted(set(pos_a) & set(pos_b), key=lambda u: pos_b[u]):
|
|
fa, fb = a["folios"][pos_a[u]], b["folios"][pos_b[u]]
|
|
delta = {f: [fa[f], fb[f]] for f in _FOLIO_FIELDS if fa[f] != fb[f]}
|
|
if delta:
|
|
changed.append({"folio": pos_b[u], "uuid": u, "changed": delta})
|
|
if pos_a[u] != pos_b[u]:
|
|
reordered.append({"uuid": u, "title": fb["title"],
|
|
"from": pos_a[u], "to": pos_b[u]})
|
|
added = [{"folio": pos_b[u], "uuid": u, "title": b["folios"][pos_b[u]]["title"]}
|
|
for u in sorted(set(pos_b) - set(pos_a), key=lambda u: pos_b[u])]
|
|
removed = [{"folio": pos_a[u], "uuid": u, "title": a["folios"][pos_a[u]]["title"]}
|
|
for u in sorted(set(pos_a) - set(pos_b), key=lambda u: pos_a[u])]
|
|
else:
|
|
for n in sorted(set(a["folios"]) & set(b["folios"])):
|
|
delta = {f: [a["folios"][n][f], b["folios"][n][f]] for f in _FOLIO_FIELDS
|
|
if a["folios"][n][f] != b["folios"][n][f]}
|
|
if delta:
|
|
changed.append({"folio": n, "changed": delta})
|
|
out = {"before": len(a["folios"]), "after": len(b["folios"]),
|
|
"keyed_by": "uuid" if by_uuid else "position", "changed": changed[:50]}
|
|
if by_uuid:
|
|
out.update(added=added[:50], removed=removed[:50], reordered=reordered[:50])
|
|
elif len(a["folios"]) != len(b["folios"]) and changed:
|
|
out["note"] = ("the folio count changed, so changes listed here may be "
|
|
"later folios shifting position rather than edits")
|
|
return out
|
|
|
|
|
|
def _diff_element_texts(a: dict, b: dict) -> dict:
|
|
"""Element text fields, keyed by their own uuid when every field has one.
|
|
|
|
Otherwise by element, what the field is bound to, and the nth such field
|
|
-- which cannot tell a field that was removed from one that moved down
|
|
the list. A field's own text is also compared ("shows"), so relabelling
|
|
an element shows up here as well as in the element's information.
|
|
"""
|
|
# A field's uuid is unique only within its symbol (copies keep them), so
|
|
# a field is identified by its symbol's uuid and its own.
|
|
ka = {k: (k[0], u) if k[0] and u else "" for k, u in a["element_text_uuids"].items()}
|
|
kb = {k: (k[0], u) if k[0] and u else "" for k, u in b["element_text_uuids"].items()}
|
|
by_uuid = _usable_ids(list(ka.values()), list(kb.values()))
|
|
def label(k):
|
|
return {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]}
|
|
if not by_uuid:
|
|
out = _diff_keyed(a["element_texts"], b["element_texts"], label)
|
|
out["keyed_by"] = "position"
|
|
return out
|
|
labels = {u: {**label(k), "uuid": u[1]} for side in (ka, kb) for k, u in side.items()}
|
|
out = _diff_keyed({ka[k]: v for k, v in a["element_texts"].items()},
|
|
{kb[k]: v for k, v in b["element_texts"].items()},
|
|
lambda u: labels[u])
|
|
out["keyed_by"] = "uuid"
|
|
return out
|
|
|
|
|
|
def _diff_extras(before: ET.Element, after: ET.Element) -> dict:
|
|
a, b = _extras(before), _extras(after)
|
|
out = {}
|
|
ta, tb = before.get("title", ""), after.get("title", "")
|
|
out["project"] = {"changed": {"title": [ta, tb]} if ta != tb else {}}
|
|
out["folios"] = _diff_folios(a, b)
|
|
out["texts"] = _diff_items(a["texts"], b["texts"])
|
|
out["shapes"] = _diff_items(a["shapes"], b["shapes"])
|
|
out["images"] = _diff_items(a["images"], b["images"])
|
|
out["tables"] = _diff_items(a["tables"], b["tables"])
|
|
out["element_texts"] = _diff_element_texts(a, b)
|
|
out["terminal_strips"] = _diff_keyed(
|
|
a["strips"], b["strips"],
|
|
lambda k: (lambda v: f"{v['installation']} {v['location']} {v['name']}".strip())(
|
|
(b["strips"].get(k) or a["strips"].get(k))))
|
|
return out
|
|
|
|
|
|
def tool_diff(before: str, after: str) -> dict:
|
|
"""Structural diff of two .qet files.
|
|
|
|
This is the tool that answers "what did that edit actually change",
|
|
which is the question a screenshot answers badly.
|
|
"""
|
|
# Key on uuid where there is one. Files written before conductors and
|
|
# elements carried persisted uuids fall back to a positional key, which
|
|
# is why a move in such a file reads as remove+add rather than a move.
|
|
a_el, b_el = {}, {}
|
|
for i, e in _elements(_root(before)):
|
|
r = _element_row(i, e)
|
|
# Rotation is saved as "orientation", in quarter turns (0-3); it is
|
|
# the only thing a rotation changes, so without it a rotated symbol
|
|
# reads as untouched.
|
|
r["orientation"] = e.get("orientation", "0")
|
|
a_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
|
|
for i, e in _elements(_root(after)):
|
|
r = _element_row(i, e)
|
|
r["orientation"] = e.get("orientation", "0")
|
|
b_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
|
|
|
|
moved, rotated, relabelled, changed_info = [], [], [], []
|
|
for k, a in a_el.items():
|
|
b = b_el.get(k)
|
|
if b is None:
|
|
continue
|
|
if a["orientation"] != b["orientation"]:
|
|
rotated.append({"uuid": k, "name": a["name"], "folio": a["folio"],
|
|
"orientation": [a["orientation"], b["orientation"]]})
|
|
if (a["x"], a["y"]) != (b["x"], b["y"]):
|
|
moved.append({
|
|
"uuid": k, "name": a["name"], "folio": a["folio"],
|
|
"from": [a["x"], a["y"]], "to": [b["x"], b["y"]],
|
|
"delta": [_num(b["x"]) - _num(a["x"]),
|
|
_num(b["y"]) - _num(a["y"])],
|
|
})
|
|
if a["label"] != b["label"]:
|
|
relabelled.append({"uuid": k, "name": a["name"],
|
|
"from": a["label"], "to": b["label"]})
|
|
# An empty field and a missing one mean the same thing, and
|
|
# QElectroTech drops empty ones when it saves, so compare only the
|
|
# fields that hold a value.
|
|
a_info = {n: v for n, v in a["info"].items() if v}
|
|
b_info = {n: v for n, v in b["info"].items() if v}
|
|
if a_info != b_info:
|
|
changed_info.append({"uuid": k, "name": a["name"],
|
|
"from": a_info, "to": b_info})
|
|
|
|
a_rows = [_conductor_row(i, c, ix) for i, c, ix in _conductors(_root(before))]
|
|
b_rows = [_conductor_row(i, c, ix) for i, c, ix in _conductors(_root(after))]
|
|
# Keyed by the conductor's own uuid when every conductor on both sides
|
|
# has one, so a rewired conductor is that conductor, changed ("ends").
|
|
# QElectroTech keeps a uuid only on conductors that were loaded with one
|
|
# or created since, so an older file keys on its two ends instead.
|
|
co_by_uuid = _usable_ids([r["uuid"] for r in a_rows], [r["uuid"] for r in b_rows])
|
|
co_id = (lambda r: r["uuid"]) if co_by_uuid else (lambda r: r["key"])
|
|
a_co = {co_id(r): r for r in a_rows}
|
|
b_co = {co_id(r): r for r in b_rows}
|
|
# An end that could not be resolved to an element is keyed on the
|
|
# folio-scoped integer id, which QElectroTech reassigns on every write.
|
|
# Say so rather than presenting the result as if it were comparable:
|
|
# in such a file an untouched conductor can read as removed and re-added.
|
|
shaky = 0 if co_by_uuid else sum(1 for k in set(a_co) | set(b_co) if "#" in k)
|
|
unstable = {} if not shaky else {
|
|
"unstable_keys": shaky,
|
|
"warning": "some conductors sit on elements with no persisted uuid, so "
|
|
"they are keyed on folio-scoped terminal ids that "
|
|
"QElectroTech renumbers on save; added/removed entries "
|
|
"marked with # may be the same conductor, not a change",
|
|
}
|
|
conductor_changes = []
|
|
for k, a in a_co.items():
|
|
b = b_co.get(k)
|
|
if b is None:
|
|
continue
|
|
fields = {f: [a[f], b[f]] for f in
|
|
("num", "formula", "cable", "bus", "color", "section",
|
|
"function", "type")
|
|
if a[f] != b[f]}
|
|
if a["key"] != b["key"]:
|
|
fields["ends"] = [a["key"], b["key"]]
|
|
if fields:
|
|
conductor_changes.append({"key": b["key"], **({"uuid": k} if co_by_uuid else {}),
|
|
"changed": fields})
|
|
|
|
deltas = sorted({tuple(m["delta"]) for m in moved})
|
|
return {
|
|
"elements": {
|
|
"before": len(a_el), "after": len(b_el),
|
|
"added": sorted(set(b_el) - set(a_el))[:50],
|
|
"removed": sorted(set(a_el) - set(b_el))[:50],
|
|
"moved": moved[:100],
|
|
"moved_count": len(moved),
|
|
"distinct_move_deltas": [list(d) for d in deltas],
|
|
"relabelled": relabelled[:50],
|
|
"info_changed": changed_info[:50],
|
|
"rotated": rotated[:50],
|
|
},
|
|
"conductors": {
|
|
"before": len(a_co), "after": len(b_co),
|
|
"keyed_by": "uuid" if co_by_uuid else "ends",
|
|
"added": sorted(b_co[k]["key"] for k in set(b_co) - set(a_co))[:50],
|
|
"removed": sorted(a_co[k]["key"] for k in set(a_co) - set(b_co))[:50],
|
|
"changed": conductor_changes[:100],
|
|
"changed_count": len(conductor_changes),
|
|
**unstable,
|
|
},
|
|
**_diff_extras(_root(before), _root(after)),
|
|
}
|
|
|
|
|
|
def _num(v) -> float:
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def tool_scan(directory: str, tag: str = "conductor",
|
|
attribute: str = "cable", recursive: bool = True) -> dict:
|
|
"""Sweep every .qet in a directory, counting how many <tag> carry a
|
|
non-empty `attribute`.
|
|
|
|
This is the corpus question: "3190 conductors, 0 cable values across
|
|
25 projects" is exactly one call to this tool.
|
|
"""
|
|
d = Path(directory).expanduser()
|
|
if not d.is_dir():
|
|
raise ValueError(f"not a directory: {d}")
|
|
files = sorted(d.rglob("*.qet") if recursive else d.glob("*.qet"))
|
|
total = non_empty = 0
|
|
per_file, values, unreadable = [], {}, []
|
|
for f in files:
|
|
try:
|
|
root = ET.parse(f).getroot()
|
|
except ET.ParseError as exc:
|
|
unreadable.append({"file": f.name, "error": str(exc)})
|
|
continue
|
|
n = ne = 0
|
|
for node in root.iter(tag):
|
|
n += 1
|
|
v = (node.get(attribute) or "").strip()
|
|
if v:
|
|
ne += 1
|
|
values[v] = values.get(v, 0) + 1
|
|
total += n
|
|
non_empty += ne
|
|
per_file.append({"file": f.name, tag: n, "non_empty": ne})
|
|
return {
|
|
"directory": str(d), "files": len(files), "unreadable": unreadable,
|
|
"tag": tag, "attribute": attribute,
|
|
"total": total, "non_empty": non_empty,
|
|
"distinct_values": sorted(values.items(), key=lambda kv: -kv[1])[:25],
|
|
"per_file": per_file,
|
|
}
|
|
|
|
|
|
def _terminals_in_index_order(terminal_nodes) -> tuple:
|
|
"""Order an element's terminals the way QElectroTech indexes them.
|
|
|
|
Not file order. Element::parseTerminal() re-sorts the terminals every
|
|
time it adds one, top to bottom and then left to right, on each
|
|
terminal's local (y, x) -- Terminal::dockConductor() is mapToScene() of
|
|
its position, evaluated while the element still sits unrotated at the
|
|
origin. So the terminal a script reaches as index 0 is the topmost one,
|
|
whatever order the .elmt lists them in: bobine_ka_a_remanence.elmt
|
|
writes A2 (y=20) before A1 (y=-20), and add_conductor's index 0 is A1.
|
|
Getting this wrong wires the wrong end of a coil and nothing complains.
|
|
|
|
Returns (nodes in index order, ambiguous). Two terminals at the same
|
|
point tie, and the C++ sort is not stable, so which one is index 0 is
|
|
not defined; ambiguous says so instead of pretending.
|
|
"""
|
|
def key(t):
|
|
try:
|
|
return (float(t.get("y", 0)), float(t.get("x", 0)))
|
|
except ValueError:
|
|
return (0.0, 0.0)
|
|
ordered = sorted(terminal_nodes, key=key)
|
|
keys = [key(t) for t in ordered]
|
|
return ordered, len(keys) != len(set(keys))
|
|
|
|
|
|
def tool_element_info(path: str) -> dict:
|
|
"""Introspect a .elmt: names, terminals, and which info fields it carries."""
|
|
root = _root(path)
|
|
names = {n.get("lang"): (n.text or "") for n in root.iter("name")}
|
|
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", ""),
|
|
"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", ""),
|
|
"width": root.get("width"), "height": root.get("height"),
|
|
"names": names,
|
|
"terminal_count": len(terminals), "terminals": terminals,
|
|
"terminal_order": "index order: top to bottom then left to right, "
|
|
"not the file's order" + (
|
|
"; two terminals share a point, so their relative "
|
|
"index is undefined" if ambiguous else ""),
|
|
"info_fields": info_fields,
|
|
"parts": parts,
|
|
"part_list": part_list,
|
|
}
|
|
|
|
|
|
def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path:
|
|
"""The executable _run_qet() starts: a private copy, except on Windows.
|
|
|
|
The copy gives each run its own SingleApplication key, which is derived
|
|
from the executable's path. On Windows a program loads its DLLs from its
|
|
own folder, so a copy on its own dies before main() (0xC0000135, DLL not
|
|
found) and nothing could ever be exported or edited there. Run the
|
|
original instead: every flag this server passes is a CLI export flag or
|
|
--run, and QElectroTech handles both and returns before it constructs
|
|
SingleApplication (main.cpp), so there is no instance to be handed to.
|
|
The copy stays elsewhere for builds from before that early return.
|
|
"""
|
|
if windows:
|
|
return src
|
|
exe = sandbox / f"qet-mcp-{os.getpid()}"
|
|
shutil.copy2(src, exe)
|
|
return exe
|
|
|
|
|
|
def _launch_env(env: dict, home: Path, windows: bool) -> dict:
|
|
"""The environment _run_qet() starts QElectroTech in.
|
|
|
|
A private HOME and XDG directories, and QET_SETTINGS_DIR pointing into
|
|
them. HOME and XDG move QElectroTech's settings only on Linux: Qt keeps
|
|
them in the registry on Windows and in the user's preferences on
|
|
macOS, so without QET_SETTINGS_DIR every run there read the user's own
|
|
settings and never saw a collection path written for it
|
|
(qelectrotech-source-mirror#1178). A QElectroTech that knows the
|
|
variable keeps its settings in an INI file in that folder instead.
|
|
|
|
Except on Windows, also Qt's offscreen platform so no display is
|
|
needed. The Windows packages ship only the qwindows platform plugin:
|
|
asked for "offscreen", Qt finds no plugin and stops at a message box
|
|
nobody can close, so every call hung until its timeout. The export
|
|
flags and --run open no window there, so the default platform is what
|
|
they need.
|
|
"""
|
|
env = dict(env,
|
|
HOME=str(home),
|
|
XDG_CONFIG_HOME=str(home / ".config"),
|
|
XDG_DATA_HOME=str(home / ".local" / "share"),
|
|
QET_SETTINGS_DIR=str(home / ".config"))
|
|
if not windows:
|
|
env["QT_QPA_PLATFORM"] = "offscreen"
|
|
return env
|
|
|
|
|
|
def _collection_setting(collection: PurePath) -> str:
|
|
"""The settings file that points QElectroTech at @p collection.
|
|
|
|
Forward slashes: Qt reads a backslash in these files as an escape, so a
|
|
Windows path written as-is arrives mangled."""
|
|
return ("[elements-collections]\n"
|
|
f"common-collection-path={collection.as_posix()}\n")
|
|
|
|
|
|
def _run_qet(binary: str, args: list[str], timeout: int = 180,
|
|
elements_dir: str | None = None,
|
|
script: str | None = None, tail: int = 4000) -> dict:
|
|
"""Launch QElectroTech headlessly, carrying the known launch traps.
|
|
|
|
SingleApplication keys its socket on applicationFilePath(), so a second
|
|
launch of the same path forwards its request to an already-running
|
|
instance and returns THAT process's answer with no error. Copying the
|
|
binary to a unique path gives this run its own socket. A symlink will
|
|
not do: applicationFilePath() resolves it back.
|
|
|
|
The sandbox HOME that isolation buys also costs something, and it is
|
|
not obvious: with no settings file, QETApp::commonElementsDir() falls
|
|
back to the compiled-in QET_COMMON_COLLECTION_PATH, which on a machine
|
|
that has never run `make install` does not exist. Every "common://..."
|
|
path then fails to resolve and the only symptom is addElement()
|
|
reporting "does not resolve to an element" for a file that is plainly
|
|
there. elements_dir writes the one setting that fixes it. The file name
|
|
is not free-choice: QSettings derives it from the organisation and
|
|
application names main.cpp sets before this branch runs. It is written
|
|
twice: QElectroTech/QElectroTech.ini is what a QElectroTech that knows
|
|
QET_SETTINGS_DIR reads, on every system (see _launch_env()), and
|
|
QElectroTech/QElectroTech.conf is what an older one reads on Linux.
|
|
|
|
script, when given, is written into the sandbox and passed to --run.
|
|
It lives inside the temporary directory so it cannot collide with a
|
|
concurrent call, and it is returned to the caller on failure, because a
|
|
generated script nobody can see is not debuggable.
|
|
|
|
The environment is inherited, not rebuilt, so QET_ENABLE_SCRIPTING
|
|
reaches QElectroTech from wherever this server was started -- normally
|
|
the "env" block of the MCP client's own configuration. That is the
|
|
consent: whoever configured this server and pointed it at a
|
|
QElectroTech binary made the choice, and their interactive
|
|
QElectroTech keeps whatever its own setting says. This server does not
|
|
set the variable itself, because a switch a program turns on for
|
|
itself is not a switch.
|
|
"""
|
|
src = Path(binary).expanduser()
|
|
if not src.is_file() or not os.access(src, os.X_OK):
|
|
raise ValueError(f"not an executable: {src}")
|
|
with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp:
|
|
sandbox = Path(tmp)
|
|
exe = _launch_executable(src, sandbox, os.name == "nt")
|
|
home = sandbox / "home"
|
|
(home / ".config").mkdir(parents=True)
|
|
(home / ".local" / "share").mkdir(parents=True)
|
|
if elements_dir:
|
|
coll = Path(elements_dir).expanduser()
|
|
if not coll.is_dir():
|
|
raise ValueError(f"no such elements directory: {coll}")
|
|
cfg = home / ".config" / "QElectroTech"
|
|
cfg.mkdir(parents=True, exist_ok=True)
|
|
for name in ("QElectroTech.ini", "QElectroTech.conf"):
|
|
(cfg / name).write_text(_collection_setting(coll), encoding="utf-8")
|
|
if script is not None:
|
|
script_path = sandbox / "qet-mcp-edit.js"
|
|
script_path.write_text(script, encoding="utf-8")
|
|
args = ["--run", str(script_path), *args]
|
|
env = _launch_env(dict(os.environ), home, os.name == "nt")
|
|
try:
|
|
p = subprocess.run([str(exe), *args], env=env, timeout=timeout,
|
|
capture_output=True, text=True)
|
|
except subprocess.TimeoutExpired:
|
|
return {"ok": False, "timed_out": True, "timeout_s": timeout,
|
|
"hint": "a modal dialog during load will hang a headless "
|
|
"run; check the project's format version"}
|
|
result = {"ok": p.returncode == 0, "exit_code": p.returncode,
|
|
"stdout": p.stdout[-tail:], "stderr": p.stderr[-tail:]}
|
|
# QElectroTech refuses --run when scripting is switched off, which
|
|
# it is by default. Its own message is clear but French, and it
|
|
# names a settings dialog that nobody driving this server is
|
|
# looking at -- so say the thing that actually applies here. Keyed
|
|
# on QElectroTech naming the variable, with exit 3 as a fallback
|
|
# for a future build that words the refusal differently.
|
|
if script is not None and not result["ok"] and (
|
|
"QET_ENABLE_SCRIPTING" in p.stderr or p.returncode == 3):
|
|
result["hint"] = (
|
|
"this tool drives QElectroTech through a script, and this "
|
|
"QElectroTech has scripting switched off. Add "
|
|
"QET_ENABLE_SCRIPTING=1 to the environment this server is "
|
|
"started in -- in an MCP client that is the \"env\" block of "
|
|
"its entry in the client configuration. Only qet_query, "
|
|
"qet_continuity, qet_check, qet_project_new and qet_edit "
|
|
"need it; every other tool either reads the file directly "
|
|
"or uses a plain CLI flag.")
|
|
return result
|
|
|
|
|
|
def tool_export(binary: str, project: str, format: str, output: str,
|
|
timeout: int = 180) -> dict:
|
|
if format not in EXPORT_FORMATS:
|
|
raise ValueError(f"unknown format {format!r}; "
|
|
f"expected one of {', '.join(sorted(EXPORT_FORMATS))}")
|
|
proj = Path(project).expanduser()
|
|
if not proj.is_file():
|
|
raise ValueError(f"no such project: {proj}")
|
|
# The CLI matches its flags by exact string (cli_export.cpp:828) and takes
|
|
# the project and output as the two positional arguments after the flag
|
|
# (:862, :882). A "--export-bom=out.csv" form is NOT recognised: it fails
|
|
# the flag test, so the run is not treated as an export at all and the
|
|
# application starts its GUI instead, which then hangs on an offscreen
|
|
# platform. Order matters here.
|
|
flag = EXPORT_FORMATS[format]
|
|
result = _run_qet(binary, [flag, str(proj), output], timeout)
|
|
out = Path(output).expanduser()
|
|
result["output"] = str(out)
|
|
result["output_exists"] = out.exists()
|
|
if out.exists() and out.is_file():
|
|
result["output_bytes"] = out.stat().st_size
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# qet_element_build: author a .elmt definition
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Derived from the 6,918 shipped elements rather than from documentation:
|
|
# these are the attributes each part tag actually carries. Everything not
|
|
# listed here is refused, so a typo becomes an error instead of an
|
|
# attribute QElectroTech silently ignores.
|
|
PART_SCHEMA = {
|
|
"line": {"required": ("x1", "y1", "x2", "y2"),
|
|
"optional": ("end1", "end2", "length1", "length2")},
|
|
"rect": {"required": ("x", "y", "width", "height"), "optional": ("rx", "ry")},
|
|
"ellipse": {"required": ("x", "y", "width", "height"), "optional": ()},
|
|
"circle": {"required": ("x", "y", "diameter"), "optional": ()},
|
|
"arc": {"required": ("x", "y", "width", "height", "start", "angle"),
|
|
"optional": ()},
|
|
"polygon": {"required": ("points",), "optional": ("closed",)},
|
|
"text": {"required": ("x", "y", "text"), "optional": ("size", "rotation", "color")},
|
|
}
|
|
|
|
DEFAULT_STYLE = "line-style:normal;line-weight:normal;filling:none;color:black"
|
|
|
|
TERMINAL_ORIENTATIONS = ("n", "s", "e", "w")
|
|
|
|
# As used in the collection. "thumbnail" is included because it is the
|
|
# second most common value, not because this tool can build a good one.
|
|
LINK_TYPES = ("simple", "thumbnail", "master", "slave", "terminal",
|
|
"next_report", "previous_report")
|
|
|
|
|
|
def _f(value, where: str) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"{where}: expected a number, got {value!r}")
|
|
|
|
|
|
def _part_extent(kind: str, part: dict) -> list:
|
|
"""The x,y points a part reaches, for the bounding box."""
|
|
g = lambda k: _f(part[k], f"{kind}.{k}")
|
|
if kind == "line":
|
|
return [(g("x1"), g("y1")), (g("x2"), g("y2"))]
|
|
if kind in ("rect", "ellipse", "arc"):
|
|
x, y, w, h = g("x"), g("y"), g("width"), g("height")
|
|
return [(x, y), (x + w, y + h)]
|
|
if kind == "circle":
|
|
x, y, d = g("x"), g("y"), g("diameter")
|
|
return [(x, y), (x + d, y + d)]
|
|
if kind == "polygon":
|
|
return [(_f(px, "polygon point"), _f(py, "polygon point"))
|
|
for px, py in part["points"]]
|
|
if kind == "text":
|
|
return [(g("x"), g("y"))]
|
|
return []
|
|
|
|
|
|
def _element_geometry(parts: list, terminals: list) -> dict:
|
|
"""Bounding box, then a declared box that contains it.
|
|
|
|
The .elmt header carries width/height/hotspot_x/hotspot_y, and the
|
|
relationship to the drawing is a containment constraint rather than a
|
|
formula: the declared box runs from (-hotspot_x, -hotspot_y) to
|
|
(width - hotspot_x, height - hotspot_y) in the element's own
|
|
coordinates, and the drawing has to fit inside it. Checked against the
|
|
shipped collection, where authors chose their own margins -- one
|
|
element pads 2 units on the left and 3 on the right, another 8 and 2 --
|
|
so there is nothing to copy, only an invariant to satisfy.
|
|
|
|
Sizes are rounded out to multiples of 10, which is what every element
|
|
sampled from the collection uses and what keeps terminals on the grid.
|
|
"""
|
|
points = []
|
|
for part in parts:
|
|
points += _part_extent(part["type"], part)
|
|
for t in terminals:
|
|
points.append((_f(t["x"], "terminal.x"), _f(t["y"], "terminal.y")))
|
|
if not points:
|
|
raise ValueError("an element needs at least one part or terminal")
|
|
|
|
min_x = min(x for x, _ in points)
|
|
max_x = max(x for x, _ in points)
|
|
min_y = min(y for _, y in points)
|
|
max_y = max(y for _, y in points)
|
|
|
|
import math
|
|
pad = 5.0
|
|
hotspot_x = int(math.ceil((-min_x + pad) / 10.0) * 10)
|
|
hotspot_y = int(math.ceil((-min_y + pad) / 10.0) * 10)
|
|
width = int(math.ceil((max_x + hotspot_x + pad) / 10.0) * 10)
|
|
height = int(math.ceil((max_y + hotspot_y + pad) / 10.0) * 10)
|
|
|
|
# The invariant, asserted rather than trusted: an element whose drawing
|
|
# escapes its declared box is the classic way a hand-written .elmt
|
|
# renders clipped in the collection panel while looking fine in XML.
|
|
if not (-hotspot_x <= min_x and max_x <= width - hotspot_x
|
|
and -hotspot_y <= min_y and max_y <= height - hotspot_y):
|
|
raise ValueError(
|
|
f"internal error: declared box ({-hotspot_x}, {-hotspot_y}) to "
|
|
f"({width - hotspot_x}, {height - hotspot_y}) does not contain the "
|
|
f"drawing ({min_x}, {min_y}) to ({max_x}, {max_y})")
|
|
|
|
return {"width": width, "height": height,
|
|
"hotspot_x": hotspot_x, "hotspot_y": hotspot_y,
|
|
"bbox": [min_x, min_y, max_x, max_y]}
|
|
|
|
|
|
def _validate_part(index: int, part) -> str:
|
|
if not isinstance(part, dict):
|
|
raise ValueError(f"part {index} is not an object: {part!r}")
|
|
kind = part.get("type")
|
|
if kind not in PART_SCHEMA:
|
|
raise ValueError(f"part {index}: unknown type {kind!r}; expected one of "
|
|
f"{', '.join(sorted(PART_SCHEMA))}")
|
|
spec = PART_SCHEMA[kind]
|
|
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", "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:
|
|
raise ValueError(f"part {index} (polygon) needs at least two points")
|
|
for pt in pts:
|
|
if not (isinstance(pt, (list, tuple)) and len(pt) == 2):
|
|
raise ValueError(f"part {index} (polygon): each point is [x, y], got {pt!r}")
|
|
return kind
|
|
|
|
|
|
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))
|
|
node.set(f"y{n}", _fmt(py))
|
|
node.set("closed", "true" if part.get("closed", True) else "false")
|
|
elif kind == "text":
|
|
node.set("x", _fmt(part["x"]))
|
|
node.set("y", _fmt(part["y"]))
|
|
node.set("text", str(part["text"]))
|
|
node.set("rotation", _fmt(part.get("rotation", 0)))
|
|
node.set("font", f"Sans Serif,{int(part.get('size', 9))},-1,5,50,0,0,0,0,0")
|
|
node.set("color", str(part.get("color", "#000000")))
|
|
return node
|
|
else:
|
|
for key in PART_SCHEMA[kind]["required"] + PART_SCHEMA[kind]["optional"]:
|
|
if key in part:
|
|
node.set(key, _fmt(part[key]))
|
|
node.set("antialias", "true" if part.get("antialias", True) else "false")
|
|
node.set("style", part.get("style", DEFAULT_STYLE))
|
|
return node
|
|
|
|
|
|
def _fmt(v) -> str:
|
|
"""Numbers the way QElectroTech writes them: no trailing .0."""
|
|
if isinstance(v, bool):
|
|
return "true" if v else "false"
|
|
if isinstance(v, (int, float)):
|
|
f = float(v)
|
|
return str(int(f)) if f == int(f) else repr(f)
|
|
return str(v)
|
|
|
|
|
|
def tool_element_build(output: str, names: dict, parts: list,
|
|
terminals: list | None = None,
|
|
link_type: str = "simple",
|
|
informations: dict | None = None,
|
|
uuid: str | None = None) -> dict:
|
|
"""Write a .elmt element definition.
|
|
|
|
Unlike a project, an element definition is not rewritten by
|
|
QElectroTech on a round trip, so generating one here is safe in a way
|
|
that generating a .qet would not be: there is no toXml() that will
|
|
drop what this writer did not know to emit.
|
|
|
|
What it will not do is invent geometry. The caller supplies the parts;
|
|
this validates them against the schema the shipped collection actually
|
|
uses, computes the width/height/hotspot header so the declared box
|
|
contains the drawing, and refuses anything it cannot place.
|
|
"""
|
|
terminals = terminals or []
|
|
if not isinstance(names, dict) or not names:
|
|
raise ValueError('names must be a non-empty object, e.g. {"en": "Coil", "fr": "Bobine"}')
|
|
if link_type not in LINK_TYPES:
|
|
raise ValueError(f"unknown link_type {link_type!r}; expected one of "
|
|
f"{', '.join(LINK_TYPES)}")
|
|
if not isinstance(parts, list):
|
|
raise ValueError("parts must be a list")
|
|
for i, part in enumerate(parts):
|
|
_validate_part(i, part)
|
|
for i, t in enumerate(terminals):
|
|
if not isinstance(t, dict):
|
|
raise ValueError(f"terminal {i} is not an object: {t!r}")
|
|
for key in ("x", "y", "orientation"):
|
|
if key not in t:
|
|
raise ValueError(f"terminal {i} is missing {key!r}")
|
|
if t["orientation"] not in TERMINAL_ORIENTATIONS:
|
|
raise ValueError(f"terminal {i}: orientation is one of "
|
|
f"{', '.join(TERMINAL_ORIENTATIONS)}, got {t['orientation']!r}")
|
|
# A master with no terminal cannot be wired, and a slave with none
|
|
# cannot be placed on a rail -- both are silent failures at use time.
|
|
if link_type in ("master", "slave", "simple") and not terminals:
|
|
raise ValueError(f"a {link_type} element with no terminals cannot be connected; "
|
|
"add terminals, or use link_type 'thumbnail' for a drawing-only element")
|
|
|
|
geometry = _element_geometry(parts, terminals)
|
|
|
|
root = ET.Element("definition", {
|
|
"version": "0.100.0", "type": "element", "link_type": link_type,
|
|
"width": str(geometry["width"]), "height": str(geometry["height"]),
|
|
"hotspot_x": str(geometry["hotspot_x"]), "hotspot_y": str(geometry["hotspot_y"]),
|
|
})
|
|
ET.SubElement(root, "uuid", {"uuid": uuid or "{" + str(__import__("uuid").uuid4()) + "}"})
|
|
names_node = ET.SubElement(root, "names")
|
|
for lang in sorted(names):
|
|
ET.SubElement(names_node, "name", {"lang": lang}).text = str(names[lang])
|
|
if informations:
|
|
kind = ET.SubElement(root, "kindInformations")
|
|
for key in sorted(informations):
|
|
ET.SubElement(kind, "kindInformation", {"name": key}).text = str(informations[key])
|
|
ET.SubElement(root, "informations")
|
|
description = ET.SubElement(root, "description")
|
|
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"],
|
|
"type": t.get("type", "Generic"),
|
|
"uuid": "{" + str(__import__("uuid").uuid4()) + "}"}
|
|
if t.get("name"):
|
|
attrs["name"] = str(t["name"])
|
|
ET.SubElement(description, "terminal", attrs)
|
|
|
|
out = Path(output).expanduser()
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
ET.indent(root, space=" ")
|
|
out.write_bytes(ET.tostring(root, encoding="utf-8", xml_declaration=True))
|
|
|
|
# Read it back with the same reader every other tool here uses, rather
|
|
# than reporting what was intended.
|
|
check = tool_element_info(str(out))
|
|
return {"ok": True, "output": str(out), "bytes": out.stat().st_size,
|
|
**geometry,
|
|
# The order add_conductor will use, which is not the order the
|
|
# 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}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# qet_edit: drive the scripting API, then prove what it did
|
|
# --------------------------------------------------------------------------
|
|
|
|
# op name -> (qet method, argument spec). A spec entry is (json key, kind),
|
|
# where kind says how the value is turned into JavaScript and, for "folio"
|
|
# and "elmt", that it may be a "$name" reference to an earlier op's result.
|
|
OPS = {
|
|
"add_folio": (None, []),
|
|
"set_folio_title": ("setFolioTitle", [("folio", "folio"), ("title", "str")]),
|
|
"add_element": ("addElement", [("folio", "folio"), ("path", "str"),
|
|
("x", "num"), ("y", "num")]),
|
|
"set_position": ("setElementPosition", [("folio", "folio"), ("element", "elmt"),
|
|
("x", "num"), ("y", "num")]),
|
|
"move_element": ("moveElement", [("folio", "folio"), ("element", "elmt"),
|
|
("dx", "num"), ("dy", "num")]),
|
|
"rotate_element": ("rotateElement", [("folio", "folio"), ("element", "elmt"),
|
|
("angle", "num")]),
|
|
"set_label": ("setElementLabel", [("folio", "folio"), ("element", "elmt"),
|
|
("label", "str")]),
|
|
"set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"),
|
|
("key", "str"), ("value", "str")]),
|
|
"add_conductor": ("addConductor", [("folio", "folio"),
|
|
("from", "elmt"), ("from_terminal", "term"),
|
|
("to", "elmt"), ("to_terminal", "term")]),
|
|
"delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]),
|
|
"set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"),
|
|
("terminal", "term"), ("property", "str"),
|
|
("value", "str")]),
|
|
"move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"),
|
|
("terminal", "term"), ("segment", "num"),
|
|
("dx", "num"), ("dy", "num")]),
|
|
"link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"),
|
|
("to_folio", "folio"), ("to", "elmt")]),
|
|
"link_plc_io": ("linkElements", [("folio", "folio"), ("element", "elmt"),
|
|
("to_folio", "folio"), ("to", "elmt"),
|
|
("io_index", "folio")]),
|
|
"unlink_element": ("unlinkElement", [("folio", "folio"), ("element", "elmt")]),
|
|
"add_plc_io": ("addPlcIO", [("folio", "folio"), ("element", "elmt"),
|
|
("type", "str"), ("address", "str"),
|
|
("function", "str"), ("comment", "str")]),
|
|
"set_plc_io": ("setPlcIO", [("folio", "folio"), ("element", "elmt"),
|
|
("index", "folio"), ("property", "str"),
|
|
("value", "str")]),
|
|
"remove_plc_io": ("removePlcIO", [("folio", "folio"), ("element", "elmt"),
|
|
("index", "folio")]),
|
|
# 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", "term")]),
|
|
"remove_folio": ("removeFolio", [("folio", "folio")]),
|
|
"set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"),
|
|
("value", "str")]),
|
|
"add_terminal_strip": ("addTerminalStrip", [("installation", "str"), ("location", "str"),
|
|
("name", "str")]),
|
|
"remove_terminal_strip": ("removeTerminalStrip", [("strip", "folio")]),
|
|
"add_to_strip": ("addTerminalToStrip", [("strip", "folio"), ("folio", "folio"),
|
|
("element", "elmt")]),
|
|
"group_terminals": ("groupTerminals", [("strip", "folio"), ("indices", "indices")]),
|
|
"bridge_terminals": ("bridgeTerminals", [("strip", "folio"), ("indices", "indices")]),
|
|
"sort_terminal_strip": ("sortTerminalStrip", [("strip", "folio")]),
|
|
"add_autonum": ("addAutoNum", [("kind", "str"), ("name", "str"),
|
|
("parts", "list")]),
|
|
"remove_autonum": ("removeAutoNum", [("kind", "str"), ("name", "str")]),
|
|
"use_conductor_autonum": ("useConductorAutoNum", [("folio", "folio"), ("name", "str")]),
|
|
"use_element_autonum": ("useElementAutoNum", [("name", "str")]),
|
|
"number_element": ("numberElement", [("folio", "folio"), ("element", "elmt")]),
|
|
# The text fields drawn on a symbol. Indexed within the element's own
|
|
# list, which follows its definition and shifts on delete (and undo of a
|
|
# delete puts the field back at the end).
|
|
"add_element_text": ("addElementText", [("folio", "folio"), ("element", "elmt"),
|
|
("source", "str"), ("value", "str"),
|
|
("x", "num"), ("y", "num")]),
|
|
"set_element_text": ("setElementTextProperty", [("folio", "folio"), ("element", "elmt"),
|
|
("index", "element_text"), ("property", "str"),
|
|
("value", "str")]),
|
|
"delete_element_text": ("deleteElementText", [("folio", "folio"), ("element", "elmt"),
|
|
("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
|
|
# wire numbers, as they do on a paste in the application.
|
|
"insert_folio": ("insertFolio", [("position", "folio")]),
|
|
# Reads, not edits: the result is reported in the operations list, so a
|
|
# follow-up call can lay something out relative to it.
|
|
"element_geometry": ("elementGeometry", [("folio", "folio"), ("element", "elmt")]),
|
|
# Undo/redo act on QElectroTech's undo stack for this run, one command at
|
|
# a time. Consecutive edits to the same property or information key merge
|
|
# into one command, so one undo can revert several of them.
|
|
"undo": ("undo", []),
|
|
"redo": ("redo", []),
|
|
"search_and_replace": ("searchAndReplace", [("kind", "str"), ("field", "str"),
|
|
("pattern", "str"), ("replacement", "str"),
|
|
("regex", "bool"), ("case_sensitive", "bool")]),
|
|
"set_project_title": ("setProjectTitle", [("title", "str")]),
|
|
"set_folio_border": ("setFolioBorder", [("folio", "folio"), ("property", "str"),
|
|
("value", "str")]),
|
|
# A folio's conductor defaults, or with "folio": -1 the project's ones
|
|
# that each new folio copies.
|
|
"set_conductor_default": ("setConductorDefault", [("folio", "folio"), ("property", "str"),
|
|
("value", "str")]),
|
|
"embed_title_block_template": ("embedTitleBlockTemplate", [("name", "str")]),
|
|
"duplicate_elements": ("duplicateElements", [("folio", "folio"), ("elements", "elmts"),
|
|
("to_folio", "folio"), ("x", "num"), ("y", "num")]),
|
|
"add_text": ("addText", [("folio", "folio"), ("text", "str"),
|
|
("x", "num"), ("y", "num")]),
|
|
"set_text": ("setTextContent", [("folio", "folio"), ("index", "text"),
|
|
("text", "str")]),
|
|
"set_text_color": ("setTextColor", [("folio", "folio"), ("index", "text"),
|
|
("color", "str")]),
|
|
"rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "text"),
|
|
("angle", "num")]),
|
|
"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", "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", "image"),
|
|
("factor", "num")]),
|
|
"rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "image"),
|
|
("angle", "num")]),
|
|
"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", "shape"),
|
|
("points", "points")]),
|
|
"add_path": ("addPath", [("folio", "folio"), ("nodes", "nodes"),
|
|
("closed", "bool")]),
|
|
"set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "shape"),
|
|
("nodes", "nodes")]),
|
|
"set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "shape"),
|
|
("closed", "bool")]),
|
|
"add_table": ("addTable", [("folio", "folio"), ("kind", "str"),
|
|
("name", "str"), ("query", "str")]),
|
|
"set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"),
|
|
("x", "num"), ("y", "num")]),
|
|
"delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]),
|
|
}
|
|
|
|
SHAPES = ["line", "rectangle", "ellipse", "polygon"]
|
|
FOLIO_BORDER_PROPERTIES = ["columns", "column-width", "display-columns",
|
|
"rows", "row-height", "display-rows"]
|
|
ELEMENT_TEXT_SOURCES = ["text", "info", "composite"]
|
|
ELEMENT_TEXT_PROPERTIES = ["text", "source", "info", "composite", "frame", "size",
|
|
"x", "y", "rotation", "width"]
|
|
SHAPE_PROPERTIES = ["color", "fill", "width", "line-style", "rotation"]
|
|
AUTONUM_KINDS = ["conductor", "element", "folio"]
|
|
SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"]
|
|
FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach",
|
|
"indexrev", "folio", "template"]
|
|
|
|
# The ops that make a folio: their "$id" is an index the next insert_folio
|
|
# or remove_folio can shift, so the script also keeps the folio's uuid.
|
|
FOLIO_MAKING_OPS = ("add_folio", "insert_folio")
|
|
|
|
# The ops that address one conductor by element + terminal, and so also
|
|
# take "conductor": "{uuid}" (qet_conductors reports each one's uuid).
|
|
CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor")
|
|
|
|
# Accepted by set_conductor. The names are the project file's own, so what
|
|
# a script sets is what qet_conductors reports back.
|
|
CONDUCTOR_PROPERTIES = ["num", "formula", "function", "bus", "cable",
|
|
"tension_protocol", "conductor_color",
|
|
"conductor_section", "color", "text_color",
|
|
# the conductor's look, under the file's own names
|
|
"color2", "bicolor", "style", "dash-size",
|
|
"condsize", "numsize", "displaytext"]
|
|
|
|
# A folio's conductor defaults take the same names, plus the folio-wide
|
|
# "one text per potential" switch, which no single conductor carries.
|
|
CONDUCTOR_DEFAULT_PROPERTIES = ["onetextperfolio"] + CONDUCTOR_PROPERTIES
|
|
|
|
# Methods this tool needs that only exist in a build carrying the drawing
|
|
# verbs. Probed in the script rather than assumed, because the failure mode
|
|
# otherwise is a TypeError on line N of a generated file the caller never
|
|
# sees, reported as "the edit failed".
|
|
_REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} |
|
|
{"save", "folioCount", "conductorCount", "elementCount"})
|
|
|
|
_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."""
|
|
return json.dumps(value)
|
|
|
|
|
|
def _build_script(operations: list, output: str) -> str:
|
|
"""Turn the operation list into a script, or raise on a bad operation.
|
|
|
|
Every op is validated here, before QElectroTech is launched at all: a
|
|
typo in an op name should cost nothing, not a process start and a
|
|
JavaScript exception.
|
|
"""
|
|
refs: set[str] = set()
|
|
# "$name"s made by an op that creates a folio: held as the folio's uuid
|
|
# too, since a later insert_folio or remove_folio shifts its index.
|
|
folio_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"
|
|
element_js = None # the op's element, for lookups scoped to it
|
|
owner_js = None # the element a terminal argument belongs to
|
|
lines = [
|
|
"// generated by qet-mcp; do not edit",
|
|
"var R = {};", # $name -> value from an earlier op
|
|
"var F = {};", # $name -> uuid of a folio an op made
|
|
"var missing = [];",
|
|
"var need = @NEED@;",
|
|
"for (var i = 0; i < need.length; i++) {",
|
|
" if (typeof qet[need[i]] !== 'function') missing.push(need[i]);",
|
|
"}",
|
|
f"qet.log({_js(_MARKER)} + JSON.stringify("
|
|
"{kind: 'capabilities', missing: missing}));",
|
|
"var stop = false;",
|
|
# A conductor named by uuid is turned into one of its ends, as the
|
|
# conductor calls take it: an end whose terminal carries no other
|
|
# conductor, so the call cannot pick the wrong one. null, with the
|
|
# reason logged, if it is not on the folio or both ends are shared.
|
|
"function qetMcpConductorEnd(index, folio, uuid) {",
|
|
" var ends = qet.conductorEnds(folio, uuid);",
|
|
" var why = 'no conductor ' + uuid + ' on folio ' + folio;",
|
|
" if (ends && ends.length === 2) {",
|
|
" var lines = qet.conductors(folio);",
|
|
" for (var k = 0; k < 2; k++) {",
|
|
" if (ends[k] === '?') continue;",
|
|
" var n = 0;",
|
|
" for (var j = 0; j < lines.length; j++) {",
|
|
" var p = lines[j].split(' : ')[0].split(' -- ');",
|
|
" if (p[0] === ends[k] || p[1] === ends[k]) n++;",
|
|
" }",
|
|
" if (n === 1) {",
|
|
" var m = ends[k].split(' terminal ');",
|
|
" return {element: m[0], terminal: parseInt(m[1], 10)};",
|
|
" }",
|
|
" }",
|
|
" why = 'conductor ' + uuid + ' shares both of its terminals with other '",
|
|
" + 'conductors; the conductor calls address one by a terminal carrying '",
|
|
" + 'only it';",
|
|
" }",
|
|
f" qet.log({_js(_MARKER)} + JSON.stringify("
|
|
"{kind: 'op_note', index: index, note: why}));",
|
|
" return null;",
|
|
"}",
|
|
# A terminal named by uuid is turned into the index the calls take,
|
|
# on its own element; -1, with the reason logged, if that element
|
|
# has no terminal with it.
|
|
"function qetMcpTerminal(index, key, folio, element, uuid) {",
|
|
" var t = qet.terminalIndex(folio, element, uuid);",
|
|
" if (t < 0) qet.log(" + _js(_MARKER) + " + JSON.stringify({kind: 'op_note', "
|
|
"index: index, note: key + ': no terminal ' + uuid + ' on element ' + element + "
|
|
"' (or two of its terminals carry it)'}));",
|
|
" return t;",
|
|
"}",
|
|
"if (missing.length === 0) {",
|
|
]
|
|
|
|
def ref_or(value, kind: str, op_index: int, key: str) -> str:
|
|
if kind == "elmts":
|
|
if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value):
|
|
raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of "
|
|
f"elements (uuids or \"$id\" references), got {value!r}")
|
|
return "[" + ", ".join(ref_or(v, "elmt", op_index, key) for v in value) + "]"
|
|
if isinstance(value, str) and value.startswith("$"):
|
|
indexed = re.fullmatch(r"\$([A-Za-z0-9_]+)\[(\d+)\]", value)
|
|
if indexed:
|
|
# one item of a list result, e.g. the second copy from
|
|
# duplicate_elements
|
|
name, n = indexed.group(1), int(indexed.group(2))
|
|
if name not in refs:
|
|
raise ValueError(
|
|
f"operation {op_index} refers to {value!r}, which no earlier "
|
|
f"operation defined (set \"id\": {name!r} on the op that creates it)")
|
|
return f"R[{_js(name)}][{n}]"
|
|
name = value[1:]
|
|
if name not in refs:
|
|
raise ValueError(
|
|
f"operation {op_index} refers to {value!r}, which no earlier "
|
|
f"operation defined (set \"id\": {name!r} on the op that creates it)")
|
|
if kind == "folio" and name in folio_refs:
|
|
# the folio's index now, not when it was made; the stored
|
|
# index on a build that cannot report folio uuids
|
|
ref = _js(name)
|
|
return f"(F[{ref}] ? qet.folioIndex(F[{ref}]) : R[{ref}])"
|
|
return f"R[{_js(name)}]"
|
|
if kind == "num":
|
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
raise ValueError(f"operation {op_index}: {key!r} must be a number, "
|
|
f"got {value!r}")
|
|
return _js(value)
|
|
if kind == "list":
|
|
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
|
raise ValueError(f"operation {op_index}: {key!r} must be a list of strings, "
|
|
f"got {value!r}")
|
|
return _js(value)
|
|
if kind == "indices":
|
|
if (not isinstance(value, list) or not value
|
|
or not all(isinstance(x, int) and not isinstance(x, bool) for x in value)):
|
|
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 == "term":
|
|
# A terminal's uuid comes from its symbol's definition, so it
|
|
# names a terminal only on its element: the op's element, or for
|
|
# add_conductor the end's own. Turned into the index the call
|
|
# takes at run time; unlike the index, it is defined between two
|
|
# terminals at the same point.
|
|
if isinstance(value, str) and _UUID_RE.fullmatch(value):
|
|
uuid_methods.add("terminalIndex")
|
|
return (f"qetMcpTerminal({op_index}, {_js(key)}, {folio_js}, {owner_js}, "
|
|
f"{_js(value)})")
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise ValueError(f"operation {op_index}: {key!r} must be a terminal 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.
|
|
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":
|
|
# A uuid names the folio for good; it is turned into the index
|
|
# the call takes at run time, since adding or removing a folio
|
|
# shifts every index after it.
|
|
if isinstance(value, str) and _UUID_RE.fullmatch(value):
|
|
uuid_methods.add("folioIndex")
|
|
return f"qet.folioIndex({_js(value)})"
|
|
if value == "":
|
|
raise ValueError(f"operation {op_index}: {key!r} is empty -- a folio saved "
|
|
f"without a uuid has none in the file until the project is "
|
|
f"saved once; give its index instead")
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise ValueError(f"operation {op_index}: {key!r} must be a folio index, "
|
|
f"its uuid, or a \"$name\" reference, got {value!r}")
|
|
return _js(value)
|
|
if kind == "bool":
|
|
if not isinstance(value, bool):
|
|
raise ValueError(f"operation {op_index}: {key!r} must be true or false, "
|
|
f"got {value!r}")
|
|
return _js(value)
|
|
if kind in ("points", "nodes"):
|
|
def _num(v):
|
|
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
if not isinstance(value, list) or len(value) < 2:
|
|
raise ValueError(f"operation {op_index}: {key!r} must be a list of at "
|
|
f"least 2 {'points' if kind == 'points' else 'nodes'}, "
|
|
f"got {value!r}")
|
|
for item in value:
|
|
if not isinstance(item, dict) or not _num(item.get("x")) or not _num(item.get("y")):
|
|
raise ValueError(f"operation {op_index}: {key!r} entries must be "
|
|
f"{{\"x\": num, \"y\": num, ...}}, got {item!r}")
|
|
if kind == "nodes":
|
|
if "kind" in item and item["kind"] not in ("corner", "smooth", "symmetric"):
|
|
raise ValueError(f"operation {op_index}: {key!r} entry kind "
|
|
f"{item['kind']!r} must be corner, smooth or symmetric")
|
|
for hkey in ("inHandle", "outHandle"):
|
|
if hkey in item and (not isinstance(item[hkey], dict)
|
|
or not _num(item[hkey].get("x"))
|
|
or not _num(item[hkey].get("y"))):
|
|
raise ValueError(f"operation {op_index}: {key!r} entry "
|
|
f"{hkey!r} must be {{\"x\": num, \"y\": num}}")
|
|
return _js(value)
|
|
return _js("" if value is None else str(value))
|
|
|
|
for i, op in enumerate(operations):
|
|
if not isinstance(op, dict):
|
|
raise ValueError(f"operation {i} is not an object: {op!r}")
|
|
name = op.get("op")
|
|
if name not in OPS:
|
|
raise ValueError(f"operation {i}: unknown op {name!r}; "
|
|
f"expected one of {', '.join(sorted(OPS))}")
|
|
method, spec = OPS[name]
|
|
if name == "set_conductor" and op.get("property") not in CONDUCTOR_PROPERTIES:
|
|
raise ValueError(f"operation {i}: unknown conductor property "
|
|
f"{op.get('property')!r}; expected one of "
|
|
f"{', '.join(CONDUCTOR_PROPERTIES)}")
|
|
if name == "set_folio" and op.get("property") not in FOLIO_PROPERTIES:
|
|
raise ValueError(f"operation {i}: unknown folio property "
|
|
f"{op.get('property')!r}; expected one of "
|
|
f"{', '.join(FOLIO_PROPERTIES)}")
|
|
if name in ("add_autonum", "remove_autonum") and op.get("kind") not in AUTONUM_KINDS:
|
|
raise ValueError(f"operation {i}: unknown kind {op.get('kind')!r}; "
|
|
f"expected one of {', '.join(AUTONUM_KINDS)}")
|
|
if name == "search_and_replace":
|
|
if op.get("kind") not in SEARCH_REPLACE_KINDS:
|
|
raise ValueError(f"operation {i}: unknown kind {op.get('kind')!r}; "
|
|
f"expected one of {', '.join(SEARCH_REPLACE_KINDS)}")
|
|
if op.get("kind") == "conductor" and op.get("field") not in CONDUCTOR_PROPERTIES:
|
|
raise ValueError(f"operation {i}: unknown conductor field "
|
|
f"{op.get('field')!r}; expected one of "
|
|
f"{', '.join(CONDUCTOR_PROPERTIES)}")
|
|
if op.get("kind") == "element_info" and not op.get("field"):
|
|
raise ValueError(f"operation {i}: element_info needs a non-empty "
|
|
f"\"field\" (information key)")
|
|
if (name == "set_conductor_default"
|
|
and op.get("property") not in CONDUCTOR_DEFAULT_PROPERTIES):
|
|
raise ValueError(f"operation {i}: unknown conductor default "
|
|
f"{op.get('property')!r}; expected one of "
|
|
f"{', '.join(CONDUCTOR_DEFAULT_PROPERTIES)}")
|
|
if name == "set_folio_border" and op.get("property") not in FOLIO_BORDER_PROPERTIES:
|
|
raise ValueError(f"operation {i}: unknown folio border property "
|
|
f"{op.get('property')!r}; expected one of "
|
|
f"{', '.join(FOLIO_BORDER_PROPERTIES)}")
|
|
if name == "add_element_text" and op.get("source") not in ELEMENT_TEXT_SOURCES:
|
|
raise ValueError(f"operation {i}: unknown source {op.get('source')!r}; "
|
|
f"expected one of {', '.join(ELEMENT_TEXT_SOURCES)}")
|
|
if name == "set_element_text" and op.get("property") not in ELEMENT_TEXT_PROPERTIES:
|
|
raise ValueError(f"operation {i}: unknown element-text property "
|
|
f"{op.get('property')!r}; expected one of "
|
|
f"{', '.join(ELEMENT_TEXT_PROPERTIES)}")
|
|
if name == "set_shape" and op.get("property") not in SHAPE_PROPERTIES:
|
|
raise ValueError(f"operation {i}: unknown shape property "
|
|
f"{op.get('property')!r}; expected one of "
|
|
f"{', '.join(SHAPE_PROPERTIES)}")
|
|
if name == "add_shape" and op.get("shape") not in SHAPES:
|
|
raise ValueError(f"operation {i}: unknown shape {op.get('shape')!r}; "
|
|
f"expected one of {', '.join(SHAPES)}")
|
|
# The conductor ops take "conductor": "{uuid}" in place of element +
|
|
# terminal: a uuid names one conductor for good, where a terminal
|
|
# can carry several.
|
|
conductor_js = None
|
|
if name in CONDUCTOR_UUID_OPS and "conductor" in op:
|
|
if "element" in op or "terminal" in op:
|
|
raise ValueError(f"operation {i} ({name}): give either \"conductor\" "
|
|
f"(its uuid) or \"element\" + \"terminal\", not both")
|
|
if op["conductor"] == "":
|
|
raise ValueError(f"operation {i}: \"conductor\" is empty -- a conductor "
|
|
f"from a project saved before conductors carried a uuid "
|
|
f"has none in the file; name it by \"element\" + "
|
|
f"\"terminal\" instead")
|
|
if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]):
|
|
raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, "
|
|
f"got {op['conductor']!r}")
|
|
conductor_js = _js(op["conductor"])
|
|
uuid_methods.add("conductorEnds")
|
|
op = {**op, "element": "{00000000-0000-0000-0000-000000000000}", "terminal": 0}
|
|
args = []
|
|
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"
|
|
element_js = args[1] if len(args) > 1 else None
|
|
owner_js = args[-1] if args else None # a terminal's element precedes it
|
|
args.append(ref_or(op[key], kind, i, key))
|
|
if conductor_js is not None:
|
|
args[1], args[2] = f"e{i}.element", f"e{i}.terminal"
|
|
|
|
ident = op.get("id")
|
|
if ident is not None:
|
|
if not isinstance(ident, str) or not ident or ident.startswith("$"):
|
|
raise ValueError(f"operation {i}: \"id\" must be a non-empty name "
|
|
f"without a leading $, got {ident!r}")
|
|
if ident in refs:
|
|
raise ValueError(f"operation {i}: \"id\" {ident!r} is already used")
|
|
|
|
call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})"
|
|
lines.append(" if (!stop) {")
|
|
if conductor_js is not None:
|
|
lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});")
|
|
call = f"(e{i} ? {call} : false)"
|
|
lines.append(f" var v{i} = {call};")
|
|
if ident is not None:
|
|
lines.append(f" R[{_js(ident)}] = v{i};")
|
|
refs.add(ident)
|
|
if name in FOLIO_MAKING_OPS:
|
|
lines.append(f" F[{_js(ident)}] = (typeof qet.folioUuid === 'function' "
|
|
f"&& v{i} >= 0) ? qet.folioUuid(v{i}) : '';")
|
|
folio_refs.add(ident)
|
|
lines.append(
|
|
f" qet.log({_js(_MARKER)} + JSON.stringify("
|
|
f"{{kind: 'op', index: {i}, op: {_js(name)}, "
|
|
f"id: {_js(ident)}, result: v{i}}}));")
|
|
# An op that failed usually invalidates the ones after it -- a
|
|
# conductor to an element that was never placed is not a second,
|
|
# independent finding, it is noise on top of the first one. The
|
|
# three falsey returns are the three the API uses: false for a
|
|
# refused edit, "" for an addElement that placed nothing, -1 for an
|
|
# addFolio that added none.
|
|
# An empty list is a failure too (duplicateElements returns [] when it
|
|
# refuses). Duck-typed on .length, not Array.isArray: QJSEngine hands
|
|
# an empty QStringList back as an array-like wrapper for which
|
|
# Array.isArray is false, so that test never fired and the run went
|
|
# on past the failed operation.
|
|
lines.append(f" if (v{i} === false || v{i} === '' || v{i} === -1 || "
|
|
f"(typeof v{i} === 'object' && v{i} !== null && "
|
|
f"(v{i}.length === 0 || (v{i}.length === undefined && "
|
|
f"Object.keys(v{i}).length === 0)))) "
|
|
"stop = true;")
|
|
lines.append(" }")
|
|
lines.append(" // ---- end of operations ----")
|
|
|
|
# Save even after a failed op: a partial result that can be inspected
|
|
# beats no result at all, and the diff is what says how far it got.
|
|
lines.append(f" var saved = qet.save({_js(output)});")
|
|
lines.append(f" qet.log({_js(_MARKER)} + JSON.stringify("
|
|
"{kind: 'save', result: saved, stopped_early: stop}));")
|
|
lines.append("}")
|
|
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:
|
|
"""Read the marker lines the generated script emits.
|
|
|
|
They arrive on stderr, not stdout: QetScriptApi::log() is a
|
|
QTextStream(stderr). Read both anyway rather than depending on that --
|
|
the cost is nothing and the failure it prevents is silent (an edit that
|
|
worked, reported as having run no operations at all, which is what the
|
|
first version of this tool did)."""
|
|
caps, ops, saved, stopped, notes = None, [], None, False, {}
|
|
for line in text.splitlines():
|
|
idx = line.find(_MARKER)
|
|
if idx < 0:
|
|
continue
|
|
try:
|
|
rec = json.loads(line[idx + len(_MARKER):])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if rec.get("kind") == "capabilities":
|
|
caps = rec.get("missing") or []
|
|
elif rec.get("kind") == "op":
|
|
rec.pop("kind", None)
|
|
# Not "in (False, ...)": 0 == False in Python, and 0 is a valid
|
|
# index/folio result. The script side already uses strict ===.
|
|
r = rec.get("result")
|
|
rec["succeeded"] = not (r is None or r is False or r == "" or r == [] or r == {} or
|
|
(isinstance(r, int) and not isinstance(r, bool) and r == -1))
|
|
ops.append(rec)
|
|
elif rec.get("kind") == "op_note":
|
|
# An op can log more than one (add_conductor, one per end):
|
|
# keep them all rather than only the last.
|
|
idx = rec.get("index")
|
|
notes[idx] = (notes[idx] + "; " if idx in notes else "") + str(rec.get("note"))
|
|
elif rec.get("kind") == "save":
|
|
saved = bool(rec.get("result"))
|
|
stopped = bool(rec.get("stopped_early"))
|
|
for rec in ops:
|
|
if rec.get("index") in notes:
|
|
rec["note"] = notes[rec["index"]]
|
|
return {"missing_methods": caps, "operations": ops, "saved": saved,
|
|
"stopped_early": stopped}
|
|
|
|
|
|
def tool_query(binary: str, project: str, sql: str,
|
|
elements_dir: str | None = None, timeout: int = 180) -> dict:
|
|
"""Run a read-only SELECT against the project's SQLite database.
|
|
|
|
This is the surface the rest of this server has done without. Every
|
|
other structural tool here re-derives its answer from the XML, because
|
|
the database was unreachable from outside the application; it is
|
|
reachable now, through the same guarded path QElectroTech's own
|
|
"Requête SQL personnalisée" box uses, so a structural question can be
|
|
asked of the database that already knows it.
|
|
|
|
The three *_view names are the surface to depend on --
|
|
element_nomenclature_view, project_summary_view, wiring_list_view.
|
|
They exist to be queried. The underlying tables are how the cache is
|
|
arranged today and a column may move; qet_query with sql omitted
|
|
lists both.
|
|
"""
|
|
proj = Path(project).expanduser()
|
|
if not proj.is_file():
|
|
raise ValueError(f"no such project: {proj}")
|
|
|
|
if sql:
|
|
# Same first-word rule projectDataBase::isReadOnlySelect() applies,
|
|
# checked here too so an obvious write is refused without paying
|
|
# for a process launch. QET still enforces it; this is not the
|
|
# guard, only an early one.
|
|
head = sql.strip().lstrip("(").split(None, 1)[0].upper() if sql.strip() else ""
|
|
if head not in ("SELECT", "WITH"):
|
|
raise ValueError("only read-only queries are allowed: a statement must "
|
|
f"begin with SELECT or WITH, not {head or '(nothing)'}")
|
|
|
|
script = ("var out = %s ? qet.query(%s) : qet.tables();\n"
|
|
"qet.log(%s + JSON.stringify({kind: 'query', rows: out, "
|
|
"error: qet.queryError ? qet.queryError() : ''}));\n"
|
|
% (json.dumps(bool(sql)), json.dumps(sql or ""), json.dumps(_MARKER)))
|
|
|
|
result = _run_qet(binary, [str(proj)], timeout=timeout,
|
|
elements_dir=elements_dir, script=script, tail=400_000)
|
|
streams = result.get("stdout", "") + "\n" + result.get("stderr", "")
|
|
rows, error = None, ""
|
|
for line in streams.splitlines():
|
|
idx = line.find(_MARKER)
|
|
if idx < 0:
|
|
continue
|
|
try:
|
|
rec = json.loads(line[idx + len(_MARKER):])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if rec.get("kind") == "query":
|
|
rows, error = rec.get("rows"), rec.get("error") or ""
|
|
for key in ("stdout", "stderr"):
|
|
kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln]
|
|
result[key] = "\n".join(kept)[-4000:]
|
|
|
|
if rows is None:
|
|
result["ok"] = False
|
|
result.setdefault("hint", "the query returned nothing at all -- this build's "
|
|
"scripting API may predate qet.query()")
|
|
return result
|
|
if error:
|
|
result["ok"] = False
|
|
result["error"] = error
|
|
result["rows"] = rows
|
|
result["row_count"] = len(rows)
|
|
result["listing"] = not sql
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# qet_continuity: electrical continuity / ERC-style structural checks
|
|
# --------------------------------------------------------------------------
|
|
|
|
def tool_continuity(binary: str, project: str, folio: int | None = None,
|
|
elements_dir: str | None = None, timeout: int = 180) -> dict:
|
|
"""Run qet.checkContinuity() and get its findings back.
|
|
|
|
Three checks, against the live Terminal/Conductor object graph rather
|
|
than a heuristic read of the XML (that is qet_check's job, and the two
|
|
are complementary, not redundant -- qet_check looks at labels and
|
|
numbering conventions, this looks at the electrical graph itself):
|
|
unconnected_terminal (info -- routine, not necessarily a mistake),
|
|
potential_mismatch (error -- two conductors QElectroTech's own
|
|
setConductorProperty() would always keep identical, found disagreeing,
|
|
which only happens from hand-edited XML, a legacy file, or an external
|
|
tool), and report_link_mismatch (warning -- a next_report/
|
|
previous_report folio-jump pair whose conductors disagree on colour,
|
|
style, num, etc.; unlike potential_mismatch this one CAN happen through
|
|
ordinary use, since LinkElementCommand::isLinkable() never checks
|
|
conductor properties, only type and freedom -- see
|
|
qelectrotech/qelectrotech-source-mirror#974, which this check
|
|
reproduces exactly: one folio-link conductor drawn in two different
|
|
colours on either side of the link). See qet.checkContinuity()'s own
|
|
doc comment (qetscriptapi.cpp) for what this deliberately does not
|
|
check: pin electrical direction/power conflicts and No/Nc/Common
|
|
contact shorts, since QElectroTech's terminal data model does not
|
|
carry the information either would need.
|
|
"""
|
|
proj = Path(project).expanduser()
|
|
if not proj.is_file():
|
|
raise ValueError(f"no such project: {proj}")
|
|
if folio is not None:
|
|
# qet.checkContinuity() answers an index it has no folio for with an
|
|
# empty list, which reads exactly like a clean folio. The index counts
|
|
# from 0 while qet_elements numbers folios from 1, so the likely
|
|
# mistake -- passing the last folio's number -- would pass silently.
|
|
count = len(list(_folios(_root(str(proj)))))
|
|
if not 0 <= folio < count:
|
|
raise ValueError(
|
|
f"folio {folio} does not exist: the project has {count} folio(s), "
|
|
f"indexed 0 to {count - 1} here. qet_continuity counts folios from 0; "
|
|
"the folio qet_elements calls N is N - 1.")
|
|
|
|
folio_arg = -1 if folio is None else folio
|
|
script = ("var out = qet.checkContinuity(%s);\n"
|
|
"qet.log(%s + JSON.stringify({kind: 'continuity', findings: out}));\n"
|
|
% (json.dumps(folio_arg), json.dumps(_MARKER)))
|
|
|
|
result = _run_qet(binary, [str(proj)], timeout=timeout,
|
|
elements_dir=elements_dir, script=script, tail=400_000)
|
|
streams = result.get("stdout", "") + "\n" + result.get("stderr", "")
|
|
findings = None
|
|
for line in streams.splitlines():
|
|
idx = line.find(_MARKER)
|
|
if idx < 0:
|
|
continue
|
|
try:
|
|
rec = json.loads(line[idx + len(_MARKER):])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if rec.get("kind") == "continuity":
|
|
findings = rec.get("findings")
|
|
for key in ("stdout", "stderr"):
|
|
kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln]
|
|
result[key] = "\n".join(kept)[-4000:]
|
|
|
|
if findings is None:
|
|
result["ok"] = False
|
|
result.setdefault("hint", "no findings came back at all -- this build's "
|
|
"scripting API may predate qet.checkContinuity()")
|
|
return result
|
|
for f in findings:
|
|
# "folio" is the 0-based index qet.checkContinuity() uses; add the
|
|
# number qet_elements and the application show, so the two can be
|
|
# matched without arithmetic.
|
|
if isinstance(f, dict) and isinstance(f.get("folio"), int):
|
|
f["folio_number"] = f["folio"] + 1
|
|
result["findings"] = findings
|
|
result["finding_count"] = len(findings)
|
|
result["errors"] = sum(1 for f in findings if f.get("severity") == "error")
|
|
result["warnings"] = sum(1 for f in findings if f.get("severity") == "warning")
|
|
result["info"] = sum(1 for f in findings if f.get("severity") == "info")
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# qet_element_search: find a symbol in the collection
|
|
# --------------------------------------------------------------------------
|
|
|
|
_ELEMENT_INDEX: dict = {}
|
|
|
|
|
|
def _fold(text: str) -> str:
|
|
"""Case- and accent-insensitive form, so 'resistance' finds 'Résistance'."""
|
|
import unicodedata
|
|
return "".join(c for c in unicodedata.normalize("NFKD", text.lower())
|
|
if not unicodedata.combining(c))
|
|
|
|
|
|
def _collection_signature(root: Path):
|
|
"""Cheap change detector: file count and newest mtime, no parsing."""
|
|
count, newest = 0, 0.0
|
|
for f in root.rglob("*.elmt"):
|
|
count += 1
|
|
try:
|
|
newest = max(newest, f.stat().st_mtime)
|
|
except OSError:
|
|
pass
|
|
return count, newest
|
|
|
|
|
|
def _index_collection(root: Path) -> list:
|
|
"""Parse every .elmt under root once and keep what a search needs.
|
|
|
|
Cached for the life of the process and rebuilt when the file count or
|
|
the newest modification time changes -- which is what makes a symbol
|
|
written by qet_element_build findable straight away, without the caller
|
|
knowing there is an index at all.
|
|
"""
|
|
key = str(root.resolve())
|
|
sig = _collection_signature(root)
|
|
cached = _ELEMENT_INDEX.get(key)
|
|
if cached and cached["sig"] == sig:
|
|
return cached["items"]
|
|
|
|
items = []
|
|
for f in sorted(root.rglob("*.elmt")):
|
|
try:
|
|
d = ET.parse(f).getroot()
|
|
except (ET.ParseError, OSError):
|
|
continue
|
|
if d.tag != "definition":
|
|
continue
|
|
names = {n.get("lang", ""): (n.text or "").strip() for n in d.iter("name")}
|
|
kind = ""
|
|
for ki in d.iter("kindInformation"):
|
|
if ki.get("name") == "type":
|
|
kind = (ki.text or "").strip()
|
|
ordered, ambiguous = _terminals_in_index_order(list(d.iter("terminal")))
|
|
terminals = [t.get("name") or "" for t in ordered]
|
|
rel = f.relative_to(root).as_posix()
|
|
items.append({
|
|
"path": "common://" + rel,
|
|
"file": str(f),
|
|
"name": names.get("en") or names.get("fr") or next(iter(names.values()), ""),
|
|
"names": names,
|
|
"link_type": d.get("link_type", "simple"),
|
|
"kind": kind,
|
|
"terminals": len(terminals),
|
|
"terminal_names": terminals, # index order, not file order
|
|
"terminal_order_ambiguous": ambiguous,
|
|
"width": d.get("width"), "height": d.get("height"),
|
|
"haystack": _fold(" ".join([*names.values(), rel, kind])),
|
|
})
|
|
_ELEMENT_INDEX[key] = {"sig": sig, "items": items}
|
|
return items
|
|
|
|
|
|
def tool_element_search(directory: str, query: str = "", link_type: str | None = None,
|
|
min_terminals: int | None = None, max_terminals: int | None = None,
|
|
kind: str | None = None, limit: int = 25) -> dict:
|
|
"""Search an element collection by name, type and terminal count.
|
|
|
|
Matches every word of query against all the translated names, the
|
|
element's path and its kind, ignoring case and accents -- so a French
|
|
or German search finds the same symbol an English one does. Results
|
|
carry a common:// path that qet_edit's add_element takes directly, and
|
|
the terminal names in the order add_conductor indexes them -- which is
|
|
top to bottom then left to right, not the order the file lists them.
|
|
"""
|
|
root = Path(directory).expanduser()
|
|
if not root.is_dir():
|
|
raise ValueError(f"no such directory: {root}")
|
|
if link_type is not None and link_type not in LINK_TYPES:
|
|
raise ValueError(f"unknown link_type {link_type!r}; expected one of "
|
|
f"{', '.join(LINK_TYPES)}")
|
|
if limit < 1:
|
|
raise ValueError("limit must be >= 1")
|
|
|
|
words = _fold(query).split()
|
|
matches = []
|
|
for it in _index_collection(root):
|
|
if link_type and it["link_type"] != link_type:
|
|
continue
|
|
if kind and _fold(kind) not in _fold(it["kind"]):
|
|
continue
|
|
if min_terminals is not None and it["terminals"] < min_terminals:
|
|
continue
|
|
if max_terminals is not None and it["terminals"] > max_terminals:
|
|
continue
|
|
if not all(w in it["haystack"] for w in words):
|
|
continue
|
|
matches.append(it)
|
|
|
|
# Whole-name hits before substring hits, then shorter names first: a
|
|
# search for "coil" should offer "Coil" before "Remanence coil, latching".
|
|
def rank(it):
|
|
name = _fold(it["name"])
|
|
exact = 0 if (words and name == " ".join(words)) else 1
|
|
starts = 0 if (words and name.startswith(words[0])) else 1
|
|
return (exact, starts, len(it["name"]), it["path"])
|
|
matches.sort(key=rank)
|
|
|
|
shown = [{k: v for k, v in it.items() if k not in ("haystack", "names", "file")}
|
|
| {"languages": sorted(it["names"])} for it in matches[:limit]]
|
|
return {"query": query, "total_matches": len(matches), "returned": len(shown),
|
|
"indexed": len(_ELEMENT_INDEX[str(root.resolve())]["items"]),
|
|
"results": shown}
|
|
|
|
|
|
# Design-rule checks, each one a read-only query over the project database.
|
|
#
|
|
# Every check is a SELECT that returns the offending rows, so "no rows" is a
|
|
# pass and the same query is what a human would write by hand. The severity
|
|
# and the note say how much to trust a hit, because these are heuristics
|
|
# tuned against the 24 shipped examples, not standards:
|
|
#
|
|
# - Every text comparison is COALESCE'd. A value that was never set is NULL
|
|
# in the database when the element was placed in this session and an empty
|
|
# string when it was loaded from a file, and `col = ''` matches only the
|
|
# second -- which made the first version of these checks silently pass on
|
|
# exactly the freshly-edited projects qet_edit produces. The JavaScript
|
|
# side renders both as "", so the difference is invisible until a check
|
|
# fails to fire. Likewise exclude_from_bom is text, not a number.
|
|
# - An unnumbered conductor is '' in some files and '_' in others: '_' is the
|
|
# placeholder QElectroTech assigns when no numbering is configured, so
|
|
# testing for '' alone passed on industrial.qet's 36 placeholder conductors
|
|
# and on every project qet_edit builds without a numbering context.
|
|
# - Slaves and terminals are excluded from the duplicate-label check on
|
|
# purpose. A slave contact carries its master coil's label by design, and
|
|
# terminals repeat their numbers from one strip to the next; counting
|
|
# either would bury the real findings.
|
|
# - "simple" elements are checked for duplicates too but only as a warning:
|
|
# industrial.qet reuses V1..V6 across folios on purpose.
|
|
CHECKS = {
|
|
"duplicate_master_labels": {
|
|
"severity": "error",
|
|
"note": "Two master elements with the same label are ambiguous in every "
|
|
"report that keys on it (BOM, cross-references, wiring list).",
|
|
"sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view "
|
|
"WHERE COALESCE(label,'') <> '' AND element_type = 'master' "
|
|
"GROUP BY label HAVING n > 1 ORDER BY n DESC, label",
|
|
},
|
|
"duplicate_simple_labels": {
|
|
"severity": "warning",
|
|
"note": "Legitimate when a label is reused on purpose across folios "
|
|
"(industrial.qet does); worth a look otherwise.",
|
|
"sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view "
|
|
"WHERE COALESCE(label,'') <> '' AND element_type = 'simple' "
|
|
"GROUP BY label HAVING n > 1 ORDER BY n DESC, label",
|
|
},
|
|
"unlabelled_masters": {
|
|
"severity": "warning",
|
|
"note": "A master with no label cannot be told apart from its slaves' "
|
|
"cross-references.",
|
|
"sql": "SELECT folio, diagram_position, element_sub_type FROM "
|
|
"element_nomenclature_view WHERE element_type = 'master' AND COALESCE(label,'') = '' "
|
|
"ORDER BY folio, diagram_position",
|
|
},
|
|
"unnumbered_conductors": {
|
|
"severity": "info",
|
|
"note": "Conductors with no wire number -- empty, or QElectroTech's own "
|
|
"'_' placeholder, which is what a conductor gets when no "
|
|
"numbering is configured. If every conductor is unnumbered the "
|
|
"project simply does not use wire numbering; a few among many "
|
|
"numbered ones is the finding.",
|
|
"sql": "SELECT COUNT(*) AS unnumbered, (SELECT COUNT(*) FROM wiring_list_view) AS total "
|
|
"FROM wiring_list_view WHERE COALESCE(wire_number,'') IN ('', '_') "
|
|
"HAVING unnumbered > 0",
|
|
},
|
|
"empty_folios": {
|
|
"severity": "info",
|
|
"note": "Folios with no element on them. Often cover pages, sometimes "
|
|
"left behind by a deleted drawing.",
|
|
"sql": "SELECT p.pos AS position, p.title AS title FROM diagram d "
|
|
"JOIN project_summary_view p ON p.pos = d.pos "
|
|
"WHERE NOT EXISTS (SELECT 1 FROM element e WHERE e.diagram_uuid = d.uuid) "
|
|
"ORDER BY p.pos",
|
|
},
|
|
"masters_without_manufacturer_reference": {
|
|
"severity": "info",
|
|
"note": "Masters that will show a blank article number in the BOM.",
|
|
"sql": "SELECT label, folio, diagram_position FROM element_nomenclature_view "
|
|
"WHERE element_type = 'master' AND COALESCE(manufacturer_reference,'') = '' "
|
|
"AND COALESCE(exclude_from_bom,'') IN ('', '0', 'false') "
|
|
"ORDER BY folio, diagram_position",
|
|
},
|
|
}
|
|
|
|
|
|
def tool_check(binary: str, project: str, checks: list | None = None,
|
|
sample: int = 10, elements_dir: str | None = None,
|
|
timeout: int = 180) -> dict:
|
|
"""Run design-rule checks over a project in one QElectroTech launch.
|
|
|
|
Every check is a read-only SELECT over the project database, so this is
|
|
qet_query with the questions already written down. It exists because the
|
|
useful questions are always the same handful and re-deriving them per
|
|
conversation is where the mistakes creep in -- the first draft of the
|
|
duplicate-label check counted slave contacts, which share their coil's
|
|
label by design and flagged nearly every relay.
|
|
"""
|
|
proj = Path(project).expanduser()
|
|
if not proj.is_file():
|
|
raise ValueError(f"no such project: {proj}")
|
|
chosen = list(CHECKS) if not checks else list(checks)
|
|
for name in chosen:
|
|
if name not in CHECKS:
|
|
raise ValueError(f"unknown check {name!r}; expected one of "
|
|
f"{', '.join(sorted(CHECKS))}")
|
|
if sample < 0:
|
|
raise ValueError("sample must be >= 0")
|
|
|
|
queries = {name: CHECKS[name]["sql"] for name in chosen}
|
|
script = ("var Q = %s;\nfor (var k in Q) {\n"
|
|
" var rows = qet.query(Q[k]);\n"
|
|
" qet.log(%s + JSON.stringify({kind: 'check', name: k, rows: rows, "
|
|
"error: qet.queryError()}));\n}\n" % (json.dumps(queries), json.dumps(_MARKER)))
|
|
result = _run_qet(binary, [str(proj)], timeout=timeout,
|
|
elements_dir=elements_dir, script=script, tail=2_000_000)
|
|
streams = result.get("stdout", "") + "\n" + result.get("stderr", "")
|
|
|
|
got = {}
|
|
for line in streams.splitlines():
|
|
idx = line.find(_MARKER)
|
|
if idx < 0:
|
|
continue
|
|
try:
|
|
rec = json.loads(line[idx + len(_MARKER):])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if rec.get("kind") == "check":
|
|
got[rec["name"]] = rec
|
|
|
|
findings, errors, passed = [], [], []
|
|
for name in chosen:
|
|
rec = got.get(name)
|
|
if rec is None:
|
|
errors.append({"check": name, "error": "no result came back"})
|
|
continue
|
|
if rec.get("error"):
|
|
errors.append({"check": name, "error": rec["error"]})
|
|
continue
|
|
rows = rec.get("rows") or []
|
|
if not rows:
|
|
passed.append(name)
|
|
continue
|
|
findings.append({"check": name, "severity": CHECKS[name]["severity"],
|
|
"count": len(rows), "note": CHECKS[name]["note"],
|
|
"rows": rows[:sample]})
|
|
|
|
order = {"error": 0, "warning": 1, "info": 2}
|
|
findings.sort(key=lambda f: (order[f["severity"]], f["check"]))
|
|
answer = {"ok": not errors and not any(f["severity"] == "error" for f in findings),
|
|
"summary": {"errors": sum(f["severity"] == "error" for f in findings),
|
|
"warnings": sum(f["severity"] == "warning" for f in findings),
|
|
"info": sum(f["severity"] == "info" for f in findings),
|
|
"passed": len(passed), "check_failures": len(errors)},
|
|
"findings": findings, "passed": passed, "check_failures": errors}
|
|
# This answer is built fresh rather than layered onto the launch result,
|
|
# so a reason the launch failed at all has to be carried across
|
|
# explicitly. Without it every check reads "no result came back", which
|
|
# is true and tells nobody why.
|
|
if result.get("hint"):
|
|
answer["ok"] = False
|
|
answer["hint"] = result["hint"]
|
|
answer["exit_code"] = result.get("exit_code")
|
|
return answer
|
|
|
|
|
|
def tool_project_new(binary: str, output: str, title: str = "Untitled",
|
|
folios=1, author: str = "", overwrite: bool = False,
|
|
elements_dir: str | None = None, timeout: int = 180) -> dict:
|
|
"""Create a new, empty project so qet_edit has something to start from.
|
|
|
|
Every other edit tool needs an existing .qet, which made building a
|
|
schematic from nothing impossible. The obvious candidate,
|
|
examples/Projet_vierge.qet, is not blank: it is a 600 KB real project
|
|
with 23 elements and 15 conductors.
|
|
|
|
So this writes the smallest project QElectroTech will open -- one
|
|
element with a title, no folios -- and then has QElectroTech itself
|
|
add the folios and save. What is left on disk is QElectroTech's own
|
|
canonical output, not the hand-written skeleton, which is why this is
|
|
not the "write .qet XML directly" route that was rejected: the
|
|
skeleton never reaches the result, and the result is checked by
|
|
reading it back.
|
|
|
|
folios is a count, or a list of folio titles.
|
|
"""
|
|
out = Path(output).expanduser()
|
|
if out.exists() and not overwrite:
|
|
raise ValueError(f"{out} already exists; pass overwrite=true to replace it")
|
|
if isinstance(folios, bool) or not isinstance(folios, (int, list)):
|
|
raise ValueError("folios must be a count or a list of titles")
|
|
titles = ([""] * folios) if isinstance(folios, int) else [str(t) for t in folios]
|
|
if not 0 <= len(titles) <= 200:
|
|
raise ValueError("folios must be between 0 and 200")
|
|
if not isinstance(title, str) or not title.strip():
|
|
raise ValueError("title must be a non-empty string")
|
|
|
|
from xml.sax.saxutils import quoteattr
|
|
script = ["var t = %s;" % json.dumps(titles), "var made = [];",
|
|
"for (var i = 0; i < t.length; i++) {",
|
|
" var f = qet.addFolio(); made.push(f);",
|
|
" if (f >= 0 && t[i]) qet.setFolioTitle(f, t[i]);",
|
|
" if (f >= 0 && %s) qet.setFolioProperty(f, 'author', %s);" %
|
|
(json.dumps(bool(author)), json.dumps(author)),
|
|
"}",
|
|
"var saved = qet.save(%s);" % json.dumps(str(out)),
|
|
"qet.log(%s + JSON.stringify({kind: 'new', folios: made, saved: saved}));"
|
|
% json.dumps(_MARKER)]
|
|
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(prefix="qet-mcp-new-") as tmp:
|
|
skeleton = Path(tmp) / "skeleton.qet"
|
|
skeleton.write_text('<project version="0.100.0" title=%s>\n</project>\n'
|
|
% quoteattr(title), encoding="utf-8")
|
|
result = _run_qet(binary, [str(skeleton)], timeout=timeout,
|
|
elements_dir=elements_dir, script="\n".join(script), tail=200_000)
|
|
|
|
rec = None
|
|
for line in (result.get("stdout", "") + "\n" + result.get("stderr", "")).splitlines():
|
|
idx = line.find(_MARKER)
|
|
if idx >= 0:
|
|
try:
|
|
r = json.loads(line[idx + len(_MARKER):])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if r.get("kind") == "new":
|
|
rec = r
|
|
for key in ("stdout", "stderr"):
|
|
result[key] = "\n".join(l for l in result.get(key, "").splitlines()
|
|
if _MARKER not in l)[-2000:]
|
|
|
|
if rec is None or not rec.get("saved") or not out.is_file():
|
|
result["ok"] = False
|
|
#setdefault: _run_qet() may already have said something more
|
|
#specific than this guess -- notably that scripting is switched
|
|
#off, in which case "your build is too old" sends the reader
|
|
#looking for the wrong thing entirely.
|
|
result.setdefault("hint",
|
|
"QElectroTech did not write the project; this build's scripting "
|
|
"API may predate addFolio()/save()")
|
|
return result
|
|
if any(f < 0 for f in rec["folios"]):
|
|
result["ok"] = False
|
|
result["hint"] = "a folio could not be added"
|
|
return result
|
|
|
|
# Read back what is actually on disk rather than report what was asked for.
|
|
info = tool_project_info(str(out))
|
|
if info["title"] != title or info["folio_count"] != len(titles):
|
|
result["ok"] = False
|
|
result["hint"] = (f"the file on disk has title {info['title']!r} and "
|
|
f"{info['folio_count']} folio(s), not what was requested")
|
|
result["output"] = str(out)
|
|
result["project"] = info
|
|
return result
|
|
|
|
|
|
def _wrong_folio(project: str, op: dict) -> str:
|
|
"""Explain a failed op that named an element on the wrong folio.
|
|
|
|
qet_elements and qet_project_info number folios from 1, as the UI does;
|
|
qet_edit passes "folio" straight to the scripting API, which counts from
|
|
0. Passing the number qet_elements showed therefore addresses the next
|
|
folio, and the op fails with nothing but "false". When the element the
|
|
op names is in the project on some other folio, say which index to use.
|
|
"""
|
|
folio = op.get("folio")
|
|
spec = OPS.get(op.get("op"), (None, []))[1]
|
|
uuids = [op[key] for key, kind in spec
|
|
if kind == "elmt" and isinstance(op.get(key), str)
|
|
and not op[key].startswith("$")]
|
|
if not isinstance(folio, int) or not uuids:
|
|
return ""
|
|
try:
|
|
where = {el.get("uuid"): i for i, el in _elements(_root(project))}
|
|
except (OSError, ET.ParseError):
|
|
return ""
|
|
for uuid in uuids:
|
|
number = where.get(uuid)
|
|
if number is not None and number - 1 != folio:
|
|
return (f"Element {uuid} is on folio {number} as qet_elements numbers "
|
|
f"it, which is \"folio\": {number - 1} here: qet_edit counts "
|
|
"folios from 0.")
|
|
return ""
|
|
|
|
|
|
def tool_edit(binary: str, project: str, operations: list, output: str,
|
|
elements_dir: str | None = None, timeout: int = 180) -> dict:
|
|
"""Apply edits through the scripting API and report what actually changed.
|
|
|
|
The point is the last part. The scripting API returns a bool per call,
|
|
which says the call was accepted, not that the file came out the way
|
|
anyone intended -- so this runs qet_diff between the input project and
|
|
the saved result and puts that in the answer. A caller that trusts
|
|
"addConductor -> true" and stops there is back to trusting the
|
|
screenshot.
|
|
|
|
The project is never written in place: output is a separate file, and
|
|
the original is what the diff is taken against.
|
|
"""
|
|
proj = Path(project).expanduser()
|
|
if not proj.is_file():
|
|
raise ValueError(f"no such project: {proj}")
|
|
if not isinstance(operations, list) or not operations:
|
|
raise ValueError("operations must be a non-empty list")
|
|
out = Path(output).expanduser()
|
|
if out.resolve() == proj.resolve():
|
|
raise ValueError("output must differ from project; this tool does not "
|
|
"edit a project in place")
|
|
|
|
script = _build_script(operations, str(out))
|
|
# QET interrupts a script at 30 s (kScriptTimeoutMs in qetscripting.cpp),
|
|
# independently of this timeout. Leaving room above it means a script
|
|
# that hits the engine's limit comes back as a script error we can
|
|
# report, rather than as our own opaque process timeout.
|
|
result = _run_qet(binary, [str(proj)], timeout=timeout,
|
|
elements_dir=elements_dir, script=script, tail=200_000)
|
|
streams = result.get("stdout", "") + "\n" + result.get("stderr", "")
|
|
result.update(_parse_script_output(streams))
|
|
# The marker lines have been parsed into "operations"; leaving them in
|
|
# the reported streams as well just doubles the size of the answer.
|
|
for key in ("stdout", "stderr"):
|
|
kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln]
|
|
result[key] = "\n".join(kept)[-4000:]
|
|
result["output"] = str(out)
|
|
result["output_exists"] = out.exists()
|
|
|
|
if result.get("missing_methods") is None and not result.get("timed_out"):
|
|
# The script's first act is to report which methods exist. No report
|
|
# means the script never ran -- a binary with no --run support, one
|
|
# that exited early, or the wrong executable -- and exit code 0 from
|
|
# something that did nothing is not success.
|
|
result["ok"] = False
|
|
#setdefault, for the same reason as in tool_project_new(): a
|
|
#refusal to run scripts at all also produces no capability
|
|
#report, and "is it a build with --run support?" is then the
|
|
#wrong question.
|
|
result.setdefault("hint",
|
|
"the binary never ran the script (no capability report came "
|
|
"back), so nothing was changed. Is it a QElectroTech build with "
|
|
"--run support?")
|
|
result["script"] = script
|
|
return result
|
|
|
|
missing = result.get("missing_methods")
|
|
if missing:
|
|
result["ok"] = False
|
|
result["hint"] = (
|
|
"this build's scripting API lacks " + ", ".join(missing) +
|
|
" -- it predates the drawing verbs, so nothing was changed")
|
|
result["script"] = script
|
|
return result
|
|
|
|
for record in result.get("operations", []):
|
|
if not record["succeeded"]:
|
|
result["ok"] = False
|
|
hint = (f"operation {record['index']} ({record['op']}) returned "
|
|
f"{record['result']!r}; later operations were skipped. "
|
|
"qet.log lines in stderr/stdout say why.")
|
|
if 0 <= record["index"] < len(operations):
|
|
wrong = _wrong_folio(str(proj), operations[record["index"]])
|
|
if wrong:
|
|
hint += " " + wrong
|
|
result.setdefault("hint", hint)
|
|
break
|
|
|
|
if result.get("saved") is False:
|
|
result["ok"] = False
|
|
result.setdefault("hint", "the edits were made but save() failed")
|
|
|
|
if out.is_file():
|
|
result["output_bytes"] = out.stat().st_size
|
|
try:
|
|
result["diff"] = tool_diff(str(proj), str(out))
|
|
except ET.ParseError as exc: # a truncated or unwritten save
|
|
result["ok"] = False
|
|
result["diff_error"] = str(exc)
|
|
if not result.get("ok"):
|
|
result["script"] = script
|
|
return result
|
|
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "qet_project_info",
|
|
"description": "Summarise a .qet project: title, format version, folios "
|
|
"with their uuids, and element/conductor counts per folio. A "
|
|
"folio saved without a uuid shows it empty; QElectroTech gives "
|
|
"it one on load and writes it on the next save. Reads the file "
|
|
"directly; does not launch QElectroTech.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string", "description": "path to a .qet file"}},
|
|
"required": ["path"],
|
|
},
|
|
"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 "
|
|
"their elementInformations bag. Optionally filter by folio "
|
|
"or by element name substring.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"folio": {"type": "integer", "description": "1-based folio number"},
|
|
"name_contains": {"type": "string"},
|
|
"limit": {"type": "integer", "default": 200},
|
|
},
|
|
"required": ["path"],
|
|
},
|
|
"handler": lambda a: tool_elements(a["path"], a.get("folio"),
|
|
a.get("name_contains"),
|
|
a.get("limit", 200)),
|
|
},
|
|
{
|
|
"name": "qet_conductors",
|
|
"description": "List conductors with their documentation fields (num, "
|
|
"formula, cable, bus, function, colour, section). Set "
|
|
"attribute+non_empty to find only conductors that carry a "
|
|
"value for one attribute.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"folio": {"type": "integer",
|
|
"description": "folio number counted from 1, as qet_elements and the application show it"},
|
|
"attribute": {"type": "string",
|
|
"description": "an XML attribute of <conductor>, e.g. cable"},
|
|
"non_empty": {"type": "boolean", "default": False},
|
|
"limit": {"type": "integer", "default": 200},
|
|
},
|
|
"required": ["path"],
|
|
},
|
|
"handler": lambda a: tool_conductors(a["path"], a.get("folio"),
|
|
a.get("attribute"),
|
|
a.get("non_empty", False),
|
|
a.get("limit", 200)),
|
|
},
|
|
{
|
|
"name": "qet_diff",
|
|
"description": "Structurally diff two .qet files: which elements moved and "
|
|
"by what delta, which were rotated (orientation in quarter "
|
|
"turns, 0-3), which were added, removed or relabelled, and "
|
|
"which conductor fields changed; also folio fields, texts, shapes, "
|
|
"pictures, tables, symbol text fields and terminal strips. Items are "
|
|
"matched by their uuid when every one of a kind has one (each "
|
|
"section says so in \"keyed_by\"), otherwise by position or ends. "
|
|
"Use this to verify what an edit actually did, rather than reading "
|
|
"a screenshot.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"before": {"type": "string"},
|
|
"after": {"type": "string"},
|
|
},
|
|
"required": ["before", "after"],
|
|
},
|
|
"handler": lambda a: tool_diff(a["before"], a["after"]),
|
|
},
|
|
{
|
|
"name": "qet_scan",
|
|
"description": "Sweep every .qet in a directory and count how many nodes of "
|
|
"a given tag carry a non-empty attribute, with the distinct "
|
|
"values found. For corpus questions such as how many "
|
|
"conductors in the shipped examples have a cable value.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"directory": {"type": "string"},
|
|
"tag": {"type": "string", "default": "conductor"},
|
|
"attribute": {"type": "string", "default": "cable"},
|
|
"recursive": {"type": "boolean", "default": True},
|
|
},
|
|
"required": ["directory"],
|
|
},
|
|
"handler": lambda a: tool_scan(a["directory"], a.get("tag", "conductor"),
|
|
a.get("attribute", "cable"),
|
|
a.get("recursive", True)),
|
|
},
|
|
{
|
|
"name": "qet_element_info",
|
|
"description": "Introspect a .elmt element definition: translated names, "
|
|
"terminals, which dynamic-text info fields it carries, and a "
|
|
"count of its drawing parts.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string", "description": "path to a .elmt file"}},
|
|
"required": ["path"],
|
|
},
|
|
"handler": lambda a: tool_element_info(a["path"]),
|
|
},
|
|
{
|
|
"name": "qet_export",
|
|
"description": "Run a QElectroTech export headlessly (pdf, png, svg, dxf, bom, "
|
|
"cables, wires, wiring, nets, links, info). Launches the "
|
|
"binary in an isolated sandbox so it cannot be captured by, "
|
|
"or capture, a running QElectroTech.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"},
|
|
"project": {"type": "string"},
|
|
"format": {"type": "string", "enum": sorted(EXPORT_FORMATS)},
|
|
"output": {"type": "string"},
|
|
"overwrite": {"type": "boolean", "default": False,
|
|
"description": "replace \"output\" if it already exists; "
|
|
"without this an existing file is never clobbered"},
|
|
"timeout": {"type": "integer", "default": 180},
|
|
},
|
|
"required": ["project", "format", "output"],
|
|
},
|
|
"handler": lambda a: tool_export(a["binary"], a["project"], a["format"],
|
|
a["output"], a.get("timeout", 180)),
|
|
},
|
|
{
|
|
"name": "qet_edit",
|
|
"description": "Edit a project through QElectroTech's own scripting API "
|
|
"and report what actually changed. Places, moves, rotates, "
|
|
"labels and deletes elements, wires two terminals together, "
|
|
"and adds folios -- each through the same undo command the "
|
|
"GUI uses, so the result is undoable and reaches the project "
|
|
"database. Writes a new file, never the input, and returns a "
|
|
"qet_diff of the two. Needs a build whose scripting API "
|
|
"carries the drawing verbs; says so plainly if it does not.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"},
|
|
"project": {"type": "string", "description": "the .qet to start from; not modified"},
|
|
"output": {"type": "string", "description": "where to write the edited project"},
|
|
"overwrite": {"type": "boolean", "default": False,
|
|
"description": "replace \"output\" if it already exists; "
|
|
"without this an existing file is never clobbered"},
|
|
"operations": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"description":
|
|
"Operations applied in order. Each is an object with \"op\" "
|
|
"and that op's arguments. Ops: " + ", ".join(sorted(OPS)) + ". "
|
|
"Give an op an \"id\" to name what it produced, then refer to "
|
|
"it later as \"$id\" -- that is how an element placed by "
|
|
"add_element gets wired by add_conductor, and how a folio made "
|
|
"by add_folio is addressed. A \"folio\" given as a number is "
|
|
"an index counted from 0: the folio qet_elements and "
|
|
"qet_project_info call 1 is \"folio\": 0 here. A folio can "
|
|
"be given as its uuid instead (qet_project_info lists them), "
|
|
"which still names the same folio after an earlier op adds or "
|
|
"removes one; so does the \"$id\" of an add_folio or "
|
|
"insert_folio. A terminal (\"terminal\", \"from_terminal\", "
|
|
"\"to_terminal\") is given by its index -- qet_element_info "
|
|
"lists terminals in index order, top to bottom then left to "
|
|
"right -- or by its uuid, which qet_element_info also lists and "
|
|
"which, unlike the index, tells apart two terminals at the same "
|
|
"point; a terminal uuid names a terminal of the op's own "
|
|
"element (for add_conductor, of that end's element). "
|
|
"set_conductor addresses a conductor as the one on a given "
|
|
"terminal and applies the change to its whole electrical "
|
|
"potential, so name a terminal carrying exactly one conductor, "
|
|
"or give \"conductor\": its uuid from qet_conductors in place "
|
|
"of \"element\" + \"terminal\" (set_conductor, "
|
|
"move_conductor_segment and delete_conductor all accept it; it "
|
|
"needs one of the conductor's two terminals to carry only it, "
|
|
"and a conductor qet_conductors lists with an empty uuid has "
|
|
"none to give). A uuid names one wire, but set_conductor still "
|
|
"changes its whole potential, as it does by terminal. "
|
|
"set_conductor's \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) +
|
|
". move_conductor_segment reroutes the drawn path itself rather "
|
|
"than a property of the potential -- addressed the same way (a "
|
|
"terminal carrying exactly one conductor), plus a segment index "
|
|
"into that conductor's own path. A segment only moves "
|
|
"perpendicular to its own direction, the same as dragging its "
|
|
"handle in the GUI: dx moves a vertical segment, dy moves a "
|
|
"horizontal one, the other of the pair is silently ignored, and "
|
|
"the two segments touching a terminal are static (refused, no "
|
|
"handle exists on them either). There is no query op to list "
|
|
"segments or their indexes first -- a freshly auto-routed "
|
|
"conductor between two terminals is a static segment, one or two "
|
|
"movable ones, then a static segment, in that order from the "
|
|
"first terminal; call with a guessed index and read \"succeeded\" "
|
|
"to check it landed on a movable one. "
|
|
"link_elements takes a folio for each end, since a master "
|
|
"and its slave are usually on different ones. "
|
|
"delete_conductor removes only the conductor on the named "
|
|
"terminal (which must carry exactly one). remove_folio shifts "
|
|
"later folio indexes down. set_folio takes one of " +
|
|
", ".join(FOLIO_PROPERTIES) + ". "
|
|
"Auto-numbering: add_autonum defines a named context of kind "
|
|
"conductor, element or folio from parts written "
|
|
"\"type[:value[:increase]]\" (e.g. [\"string:W\", \"unit:1:1\"]); "
|
|
"use_conductor_autonum then makes new conductors on a folio "
|
|
"take their number from it, so define and select it BEFORE the "
|
|
"add_conductor ops it should number. For elements, "
|
|
"use_element_autonum selects the context and number_element applies "
|
|
"it to one element AFTER it is placed (add_element does not number "
|
|
"what it places); slaves and reports are refused, since they take "
|
|
"their label from their master. "
|
|
"Terminal strips: add_terminal_strip returns an index (name "
|
|
"it \"$id\"); add_to_strip puts a terminal-type element on "
|
|
"it, and refuses any other kind. group_terminals/bridge_terminals "
|
|
"take \"indices\" (at least two) into that strip's real-terminal "
|
|
"listing -- group merges onto whichever named position already has "
|
|
"the most terminals, not necessarily the first index given; bridge "
|
|
"refuses terminals that are not all at the same level. A group() call "
|
|
"can fully reorder the listing, not just shift indices after it -- "
|
|
"always re-list before addressing one by index again. "
|
|
"sort_terminal_strip reorders it canonically. "
|
|
"Images: add_image takes a file path (over 10 MB is refused) "
|
|
"and returns an index; the pixels are embedded in the saved "
|
|
"project. scale_image/rotate_image can change an image's sort "
|
|
"index, so rely on the \"$id\" only until the next scale or "
|
|
"rotate. "
|
|
"add_pdf_page renders one page of a PDF file to an image and "
|
|
"places it, through the same code path as the \"add image\" "
|
|
"toolbar action's own PDF support: \"page\" is 1-based, \"dpi\" "
|
|
"is the render resolution (the GUI dialog defaults to 150), and "
|
|
"the result is an ordinary image afterwards -- scale_image, "
|
|
"rotate_image and delete_image all apply to it same as any other. "
|
|
"Only reachable in a build with the QtPdf module (Qt >= 6.4); "
|
|
"some Qt6 distributions omit it, and the op is refused with a "
|
|
"clear reason rather than being absent, so check the op's own "
|
|
"\"succeeded\"/result rather than assuming a missing method. "
|
|
"insert_folio puts a new folio at a position (0 = first, the "
|
|
"folio count = last) and returns its index; element_geometry reads "
|
|
"an element's x, y, rotation and the box it occupies "
|
|
"(left/top/right/bottom) and reports it in the result -- use it to "
|
|
"lay things out relative to each other across calls; undo/redo step "
|
|
"QElectroTech's undo stack (consecutive edits to one property merge, "
|
|
"so one undo can revert several) and fail if there is nothing to "
|
|
"undo. search_and_replace finds and replaces a substring or (with "
|
|
"\"regex\": true) a regular expression within one text field, "
|
|
"across every folio, as a single undo step -- unlike doing the "
|
|
"same with a read op and set_conductor/set_info/set_text in a "
|
|
"loop, which would leave one undo entry per item touched. \"kind\" "
|
|
"is element_info (\"field\" is an information key such as "
|
|
"\"label\"), conductor (\"field\" is one of " +
|
|
", ".join(CONDUCTOR_PROPERTIES) + " -- replacing on one conductor "
|
|
"of a potential updates the whole potential, the same as "
|
|
"set_conductor always does) or text (independent texts; \"field\" "
|
|
"is ignored). This is NOT QElectroTech's own \"Search and replace\" "
|
|
"panel: that one is a batch overwrite-with-sentinel template built "
|
|
"for picking items from a tree interactively, a poor fit for a "
|
|
"script that can already say precisely which items it means. This "
|
|
"does what the name says instead -- an actual substring/regex "
|
|
"replace within each item's current value, touching only items "
|
|
"where it is found. Returns the number of items changed; never "
|
|
"matches an empty field. set_project_title renames the project. "
|
|
"set_folio_border sets one "
|
|
"of the folio frame's " + ", ".join(FOLIO_BORDER_PROPERTIES) +
|
|
" (counts 1-99, sizes 1-1000, display-* true/false). "
|
|
"set_conductor_default sets one of a folio's conductor defaults "
|
|
"(Folio properties > Conductors): onetextperfolio (true/false, one "
|
|
"wire number per potential on the folio) or any set_conductor "
|
|
"property, which new conductors on that folio start from; \"folio\": "
|
|
"-1 sets the project's defaults that each folio added later copies. "
|
|
"Not on the undo stack. "
|
|
"embed_title_block_template copies a template into the project from "
|
|
"the common/company/custom collection that has it (only reachable if "
|
|
"the binary's compiled-in template path resolves to something real -- "
|
|
"typically a make install'd QET; there is no per-run override for this "
|
|
"one the way elements_dir is for elements, since QElectroTech reads "
|
|
"--common-tbt-dir before --run's own argument handling ever sees it, "
|
|
"so this tool cannot pass it through). set_folio's \"template\" property "
|
|
"then embeds-if-needed and applies it in one call; a template literally "
|
|
"named \"default\" reads back as \"\" afterwards, since QElectroTech "
|
|
"treats the two as the same thing. "
|
|
"duplicate_elements copies elements, with the conductors between "
|
|
"them, to a position (the top-left of the copied group's bounding "
|
|
"box; (0,0) keeps the source coordinates) on the same or another "
|
|
"folio: \"elements\" is a list of uuids or \"$id\" references, "
|
|
"and the result lists the copies in that same order, so "
|
|
"\"$copies[0]\" is the copy of the first. Copies come without "
|
|
"labels or wire numbers, as on a paste in the application. "
|
|
"Symbol text fields (the label, terminal names, values drawn on a "
|
|
"symbol): add_element_text (source text|info|composite; value is "
|
|
"the string, an information key such as \"label\", or a formula; "
|
|
"x/y are in the element's own coordinates) returns an index within "
|
|
"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, 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 "
|
|
"many points as wanted instead, as [{\"x\":.., \"y\":..}, ...] "
|
|
"in scene coordinates (at least 2), plus \"closed\"; "
|
|
"set_shape_polygon replaces an existing one's points the same "
|
|
"way. add_path places a curved shape -- a polygon's points plus, "
|
|
"per node, an optional \"kind\" (corner, the default; smooth; or "
|
|
"symmetric) and optional \"inHandle\"/\"outHandle\" bezier "
|
|
"control points, the same model the pen tool and node-edit mode "
|
|
"build; set_shape_path_nodes replaces an existing path's nodes. "
|
|
"set_shape_closed opens or closes a polygon or path (a no-op on "
|
|
"any other shape). set_shape_polygon/set_shape_path_nodes refuse "
|
|
"a shape of the wrong kind -- a shape made by add_shape is never "
|
|
"a valid target for either, and vice versa. A shape's index can "
|
|
"shift on any edit that moves it, not only an add or delete: "
|
|
"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, 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 "
|
|
"query op) against element_nomenclature_view or "
|
|
"project_summary_view first to find one that returns real "
|
|
"columns; an empty query is refused, since each query widget "
|
|
"defaults to zero selected columns and produces a table with "
|
|
"no rows. Returns an index you can name as \"$id\". Every new "
|
|
"table lands at the same fixed (50, 50), so a folio getting "
|
|
"more than one must reposition all but the first with "
|
|
"set_table_position or they stack exactly on top of each "
|
|
"other. delete_table removes one; indexes shift afterwards. "
|
|
"PLC IO: a PLC master (elementData type Master, masterType "
|
|
"PLC) carries an IO table -- add_plc_io appends a row (type "
|
|
"is one of entree_digitale, sortie_digitale, "
|
|
"entree_analogique, sortie_analogique, entree_universelle, "
|
|
"sortie_universelle) and returns its index as \"$id\"; "
|
|
"set_plc_io changes one field (type, address, function or "
|
|
"comment) of an existing row; remove_plc_io deletes one and "
|
|
"shifts the indexes after it. None of the three are "
|
|
"undoable -- MasterPropertiesWidget's own PLC IO editor "
|
|
"isn't either, since it manages PLC linking through the "
|
|
"table rather than the ordinary link-tree undo path. "
|
|
"link_plc_io is link_elements plus an io_index: it links a "
|
|
"PLC slave onto one specific row of a PLC master's IO table "
|
|
"(io_index into that table, from add_plc_io's return or a "
|
|
"count of prior add_plc_io calls) rather than leaving which "
|
|
"row unspecified the way a plain link_elements call would. "
|
|
"If an op fails the rest are skipped, since they usually "
|
|
"depend on it.",
|
|
"items": {"type": "object"},
|
|
},
|
|
"elements_dir": {
|
|
"type": "string",
|
|
"description": "the common elements collection, e.g. a checkout's "
|
|
"elements/ directory. Required for \"common://\" "
|
|
"paths: the sandboxed run has no settings of its "
|
|
"own and would not find the collection otherwise. "
|
|
"An absolute .elmt path works without it.",
|
|
},
|
|
"timeout": {"type": "integer", "default": 180},
|
|
},
|
|
"required": ["project", "output", "operations"],
|
|
},
|
|
"handler": lambda a: tool_edit(a["binary"], a["project"], a["operations"],
|
|
a["output"], a.get("elements_dir"),
|
|
a.get("timeout", 180)),
|
|
},
|
|
{
|
|
"name": "qet_query",
|
|
"description": "Run a read-only SQL SELECT against the project's SQLite "
|
|
"database and get rows back. Prefer the views "
|
|
"(element_nomenclature_view, project_summary_view, "
|
|
"wiring_list_view) over the raw tables. Omit sql to list what "
|
|
"is queryable. Only SELECT and WITH are permitted -- "
|
|
"QElectroTech enforces this itself, the same way it does for "
|
|
"the custom-query box in its own interface.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"},
|
|
"project": {"type": "string", "description": "the .qet to query; never modified"},
|
|
"sql": {"type": "string",
|
|
"description": "a single SELECT or WITH...SELECT. "
|
|
"Omit to list the tables and views instead."},
|
|
"elements_dir": {"type": "string"},
|
|
"timeout": {"type": "integer", "default": 180},
|
|
},
|
|
"required": ["project"],
|
|
},
|
|
"handler": lambda a: tool_query(a["binary"], a["project"], a.get("sql", ""),
|
|
a.get("elements_dir"), a.get("timeout", 180)),
|
|
},
|
|
{
|
|
"name": "qet_continuity",
|
|
"description": "Electrical continuity / ERC-style checks against the live "
|
|
"Terminal/Conductor object graph, not a heuristic read of the "
|
|
"XML (that is qet_check; the two are complementary). "
|
|
"unconnected_terminal (info -- routine, not necessarily wrong) "
|
|
"and potential_mismatch (error -- two conductors on the same "
|
|
"electrical potential disagreeing on num/colour/section/"
|
|
"function/bus/cable, which QElectroTech's own edits never "
|
|
"produce, so it means hand-edited XML, a legacy file, or an "
|
|
"external tool); and report_link_mismatch (warning -- a "
|
|
"next_report/previous_report folio-jump pair whose conductors "
|
|
"disagree, which CAN happen through ordinary use since linking "
|
|
"two report elements never checks or syncs conductor "
|
|
"properties -- reproduces qelectrotech/qelectrotech-source-"
|
|
"mirror#974). Does NOT check pin electrical direction/power "
|
|
"conflicts or No/Nc/Common contact shorts -- QElectroTech's "
|
|
"terminal data model carries neither. One QElectroTech "
|
|
"launch; read-only.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"},
|
|
"project": {"type": "string", "description": "the .qet to check; never modified"},
|
|
"folio": {"type": "integer", "description":
|
|
"check one folio only; omit for the whole project. An index "
|
|
"counted from 0, like qet_edit: the folio qet_elements calls 1 "
|
|
"is 0 here. An index with no folio is refused, not reported "
|
|
"clean. Each finding carries both \"folio\" (this index) and "
|
|
"\"folio_number\" (counted from 1)."},
|
|
"elements_dir": {"type": "string"},
|
|
"timeout": {"type": "integer", "default": 180},
|
|
},
|
|
"required": ["project"],
|
|
},
|
|
"handler": lambda a: tool_continuity(a["binary"], a["project"], a.get("folio"),
|
|
a.get("elements_dir"), a.get("timeout", 180)),
|
|
},
|
|
{
|
|
"name": "qet_project_new",
|
|
"description": "Create a new, empty project to start a schematic from: a "
|
|
"title and any number of folios, written by QElectroTech "
|
|
"itself and read back to check. qet_edit needs an existing "
|
|
"project, and the shipped 'blank' example is not blank, so "
|
|
"this is the way to begin from nothing. Refuses to overwrite "
|
|
"unless told to.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"},
|
|
"output": {"type": "string", "description": "where to write the new .qet"},
|
|
"title": {"type": "string", "description": "the project title"},
|
|
"folios": {"description": "how many empty folios, or a list of folio titles",
|
|
"oneOf": [{"type": "integer", "minimum": 0, "maximum": 200},
|
|
{"type": "array", "items": {"type": "string"}}],
|
|
"default": 1},
|
|
"author": {"type": "string", "description": "set on every folio's title block"},
|
|
"overwrite": {"type": "boolean", "default": False},
|
|
"elements_dir": {"type": "string"},
|
|
"timeout": {"type": "integer", "default": 180},
|
|
},
|
|
"required": ["output", "title"],
|
|
},
|
|
"handler": lambda a: tool_project_new(
|
|
a["binary"], a["output"], a["title"], a.get("folios", 1), a.get("author", ""),
|
|
a.get("overwrite", False), a.get("elements_dir"), a.get("timeout", 180)),
|
|
},
|
|
{
|
|
"name": "qet_element_search",
|
|
"description": "Find a symbol in an element collection by name (any "
|
|
"language, ignoring case and accents), link type, kind or "
|
|
"terminal count. Results carry a common:// path that "
|
|
"qet_edit's add_element takes directly, and the terminal "
|
|
"names in add_conductor's index order (top-to-bottom, then "
|
|
"left-to-right; not file order). Indexes the "
|
|
"collection on first use and re-indexes when it changes, so "
|
|
"a symbol written by qet_element_build is found at once.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"directory": {"type": "string",
|
|
"description": "the collection root, e.g. a checkout's elements/ directory"},
|
|
"query": {"type": "string",
|
|
"description": "words to find; every word must match some name, the path or the kind"},
|
|
"link_type": {"type": "string", "enum": list(LINK_TYPES)},
|
|
"kind": {"type": "string", "description": "the element's type information, e.g. coil, protection"},
|
|
"min_terminals": {"type": "integer"},
|
|
"max_terminals": {"type": "integer"},
|
|
"limit": {"type": "integer", "default": 25},
|
|
},
|
|
"required": ["directory"],
|
|
},
|
|
"handler": lambda a: tool_element_search(
|
|
a["directory"], a.get("query", ""), a.get("link_type"),
|
|
a.get("min_terminals"), a.get("max_terminals"), a.get("kind"),
|
|
a.get("limit", 25)),
|
|
},
|
|
{
|
|
"name": "qet_check",
|
|
"description": "Run design-rule checks over a project and report findings by "
|
|
"severity: duplicate master labels (error), duplicate simple "
|
|
"labels and unlabelled masters (warning), unnumbered "
|
|
"conductors, empty folios and masters missing a manufacturer "
|
|
"reference (info). One QElectroTech launch; read-only. "
|
|
"These are heuristics tuned against QElectroTech's shipped "
|
|
"examples, not standards -- each finding carries a note "
|
|
"saying how far to trust it.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"},
|
|
"project": {"type": "string"},
|
|
"checks": {"type": "array", "items": {"type": "string", "enum": sorted(CHECKS)},
|
|
"description": "which checks to run; omit for all"},
|
|
"sample": {"type": "integer", "default": 10,
|
|
"description": "how many offending rows to return per check"},
|
|
"elements_dir": {"type": "string"},
|
|
"timeout": {"type": "integer", "default": 180},
|
|
},
|
|
"required": ["project"],
|
|
},
|
|
"handler": lambda a: tool_check(a["binary"], a["project"], a.get("checks"),
|
|
a.get("sample", 10), a.get("elements_dir"),
|
|
a.get("timeout", 180)),
|
|
},
|
|
{
|
|
"name": "qet_element_build",
|
|
"description": "Write a .elmt element definition: named in one or more "
|
|
"languages, drawn from lines, rectangles, ellipses, circles, "
|
|
"arcs, polygons and text, with terminals to wire it by. "
|
|
"Computes the width/height/hotspot header so the declared box "
|
|
"contains the drawing, validates every part against the schema "
|
|
"the shipped collection uses, and reads the result back. "
|
|
"Writes the file directly; does not launch QElectroTech.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"output": {"type": "string", "description": "path to write, ending .elmt"},
|
|
"overwrite": {"type": "boolean", "default": False,
|
|
"description": "replace \"output\" if it already exists; "
|
|
"without this an existing file is never clobbered"},
|
|
"names": {"type": "object",
|
|
"description": 'translated names by language code, e.g. '
|
|
'{"en": "Coil", "fr": "Bobine"}. French is '
|
|
"QElectroTech's source language; give it if you can."},
|
|
"parts": {
|
|
"type": "array",
|
|
"description":
|
|
'the drawing. Each part is {"type": ...} plus its own keys: '
|
|
'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, "
|
|
"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"},
|
|
},
|
|
"terminals": {
|
|
"type": "array",
|
|
"description": 'where conductors attach: {"x","y","orientation"} '
|
|
"with orientation n, s, e or w, plus an optional "
|
|
'name such as "A1". Their order here is the order '
|
|
"qet_edit indexes terminals by position, not by this order: "
|
|
"top to bottom, then left to right. qet_element_build "
|
|
"returns the resulting index order.",
|
|
"items": {"type": "object"},
|
|
},
|
|
"link_type": {"type": "string", "enum": list(LINK_TYPES),
|
|
"description": "simple for an ordinary symbol, master/slave "
|
|
"for a cross-referenced pair, thumbnail for "
|
|
"a drawing with no terminals"},
|
|
"informations": {"type": "object",
|
|
"description": "kindInformation entries, e.g. {\"type\": \"coil\"}"},
|
|
"uuid": {"type": "string", "description": "reuse an existing uuid; "
|
|
"omit to generate one"},
|
|
},
|
|
"required": ["output", "names", "parts"],
|
|
},
|
|
"handler": lambda a: tool_element_build(
|
|
a["output"], a["names"], a["parts"], a.get("terminals"),
|
|
a.get("link_type", "simple"), a.get("informations"), a.get("uuid")),
|
|
},
|
|
]
|
|
|
|
_BY_NAME = {t["name"]: t for t in TOOLS}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Filesystem policy
|
|
# --------------------------------------------------------------------------
|
|
#
|
|
# Every path in a tool call arrives from the model, so without a policy this
|
|
# server is a read/write primitive for anything the OS lets the process
|
|
# touch: read any .qet or .elmt, export a project's contents somewhere else,
|
|
# overwrite an unrelated file, embed an arbitrary local image or PDF. The
|
|
# sandboxed HOME each QElectroTech launch gets isolates *settings*, not the
|
|
# filesystem.
|
|
#
|
|
# So data paths are confined to a workspace, and the program the server
|
|
# launches is not the client's to choose:
|
|
#
|
|
# data chosen by the client per call -- the projects, directories,
|
|
# images and outputs below. Confined to the workspace.
|
|
# executable "binary". Resolved by the server itself (resolve_binary());
|
|
# a client may name it only when it is that same file or one
|
|
# whoever configured the server listed in QET_MCP_BINARIES.
|
|
# It once counted as configuration and went unchecked, but it
|
|
# is a per-call argument: a model steered by text in a
|
|
# project could run any program on the machine with it.
|
|
# collection "elements_dir". Normally outside the workspace (in /usr or
|
|
# a build tree), so allowed there, in the collection of the
|
|
# resolved install, or in a directory listed in
|
|
# QET_MCP_ELEMENTS.
|
|
#
|
|
# Enforced here, at the dispatcher, because this is the trust boundary --
|
|
# the point where model-supplied arguments enter. Calling the tool_* helpers
|
|
# directly from Python is not confined and is not meant to be: that is the
|
|
# server's own code calling itself.
|
|
_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",)},
|
|
"qet_element_info": {"read": ("path",)},
|
|
"qet_element_search": {"read": ("directory",)},
|
|
"qet_export": {"read": ("project",), "write": ("output",)},
|
|
"qet_edit": {"read": ("project",), "write": ("output",)},
|
|
"qet_query": {"read": ("project",)},
|
|
"qet_continuity": {"read": ("project",)},
|
|
"qet_check": {"read": ("project",)},
|
|
"qet_project_new": {"write": ("output",)},
|
|
"qet_element_build": {"write": ("output",)},
|
|
}
|
|
|
|
# Tools that launch QElectroTech, and so take "binary" and "elements_dir".
|
|
_LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity",
|
|
"qet_check", "qet_project_new"}
|
|
|
|
# qet_edit operations that name a file of their own.
|
|
_DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"}
|
|
|
|
|
|
def workspace_roots() -> list:
|
|
"""The directories tool calls may read and write.
|
|
|
|
QET_MCP_WORKSPACE, os.pathsep-separated, or the process's working
|
|
directory when unset -- a real confinement either way, and the working
|
|
directory is what an MCP host normally starts the server in. Set
|
|
QET_MCP_ALLOW_ANY_PATH=1 to turn confinement off entirely, which is
|
|
equivalent to granting the client local filesystem access with this
|
|
process's privileges; it exists so that is a deliberate, visible choice
|
|
rather than the default.
|
|
"""
|
|
if os.environ.get("QET_MCP_ALLOW_ANY_PATH") == "1":
|
|
return []
|
|
raw = os.environ.get("QET_MCP_WORKSPACE", "")
|
|
parts = [p for p in raw.split(os.pathsep) if p.strip()] or [os.getcwd()]
|
|
roots = []
|
|
for part in parts:
|
|
try:
|
|
roots.append(Path(part).expanduser().resolve())
|
|
except OSError:
|
|
continue
|
|
return roots
|
|
|
|
|
|
def _within_workspace(path: Path, roots: list) -> bool:
|
|
for root in roots:
|
|
try:
|
|
if path == root or path.is_relative_to(root):
|
|
return True
|
|
except ValueError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _env_paths(name: str) -> list:
|
|
"""An os.pathsep-separated list of paths from the environment, resolved."""
|
|
out = []
|
|
for part in os.environ.get(name, "").split(os.pathsep):
|
|
if part.strip():
|
|
try:
|
|
out.append(Path(part).expanduser().resolve())
|
|
except OSError:
|
|
continue
|
|
return out
|
|
|
|
|
|
def _installation() -> tuple | None:
|
|
"""(program directory, element collection) of the QElectroTech this
|
|
script was installed with, or None when it runs from anywhere else.
|
|
|
|
Two layouts, both put there by QElectroTech's own packaging:
|
|
<prefix>/share/qelectrotech/mcp/ -> <prefix>/bin, <prefix>/share/qelectrotech/elements
|
|
(make install: Linux, snap, flatpak, macOS)
|
|
<root>/mcp/ -> <root>/bin, <root>/elements
|
|
(the Windows installers and portable folder)
|
|
"""
|
|
here = Path(__file__).resolve().parent
|
|
if here.name != "mcp":
|
|
return None
|
|
if here.parent.name == "qelectrotech" and here.parent.parent.name == "share":
|
|
prefix = here.parent.parent.parent
|
|
return prefix / "bin", here.parent / "elements"
|
|
if (here.parent / "bin").is_dir():
|
|
return here.parent / "bin", here.parent / "elements"
|
|
return None
|
|
|
|
|
|
def resolve_binary() -> Path | None:
|
|
"""The QElectroTech this server launches, found without asking the client.
|
|
|
|
QET_BINARY first, then the install this script ships in, then
|
|
qelectrotech on PATH. None when there is none; the tools that launch
|
|
QElectroTech then say how to set it.
|
|
"""
|
|
env = os.environ.get("QET_BINARY", "").strip()
|
|
if env:
|
|
return Path(env).expanduser().resolve()
|
|
install = _installation()
|
|
if install is not None:
|
|
# The Windows build names it QElectroTech.exe; only a case-sensitive
|
|
# file system tells the spellings apart.
|
|
for name in ("qelectrotech", "qelectrotech.exe", "QElectroTech.exe"):
|
|
cand = install[0] / name
|
|
if cand.is_file():
|
|
return cand.resolve()
|
|
found = shutil.which("qelectrotech")
|
|
return Path(found).resolve() if found else None
|
|
|
|
|
|
def default_elements_dir() -> Path | None:
|
|
"""The element collection of the install this script ships in, if any."""
|
|
install = _installation()
|
|
if install is not None and install[1].is_dir():
|
|
return install[1].resolve()
|
|
return None
|
|
|
|
|
|
def _check_binary(arguments: dict) -> None:
|
|
"""Fill in "binary", or refuse one that is not the server's own choice."""
|
|
if os.environ.get("QET_MCP_ALLOW_ANY_BINARY") == "1" and arguments.get("binary"):
|
|
return
|
|
default = resolve_binary()
|
|
raw = arguments.get("binary")
|
|
if not raw:
|
|
if default is None:
|
|
raise ValueError(
|
|
"no QElectroTech found: set QET_BINARY to the qelectrotech "
|
|
"executable in the environment this server is started in")
|
|
arguments["binary"] = str(default)
|
|
return
|
|
if not isinstance(raw, str):
|
|
raise ValueError("'binary' must be a path")
|
|
given = Path(raw).expanduser().resolve()
|
|
allowed = ([default] if default else []) + _env_paths("QET_MCP_BINARIES")
|
|
if given not in allowed:
|
|
raise ValueError(
|
|
f"'binary' is not an allowed QElectroTech: {given}. Leave it out "
|
|
"to use " + (str(default) if default else "QET_BINARY")
|
|
+ "; whoever configured this server can list others in "
|
|
"QET_MCP_BINARIES, or set QET_MCP_ALLOW_ANY_BINARY=1 to "
|
|
"disable this check (which lets the client run any program).")
|
|
arguments["binary"] = str(given)
|
|
|
|
|
|
def _check_elements_dir(arguments: dict, roots: list) -> None:
|
|
"""Fill in "elements_dir" from the install, or confine a given one."""
|
|
raw = arguments.get("elements_dir")
|
|
if not raw:
|
|
default = default_elements_dir()
|
|
if default is not None:
|
|
arguments["elements_dir"] = str(default)
|
|
return
|
|
if not isinstance(raw, str):
|
|
raise ValueError("'elements_dir' must be a path")
|
|
given = Path(raw).expanduser().resolve()
|
|
extra = _env_paths("QET_MCP_ELEMENTS")
|
|
default = default_elements_dir()
|
|
if default is not None:
|
|
extra.append(default)
|
|
if roots and not _within_workspace(given, roots + extra):
|
|
raise ValueError(
|
|
f"'elements_dir' is outside the workspace: {given}. Leave it out "
|
|
"to use the installed collection, or list the directory in "
|
|
"QET_MCP_ELEMENTS.")
|
|
|
|
|
|
def _check_path(raw, arg: str, mode: str, roots: list) -> Path:
|
|
"""Resolve one path and refuse it if it leaves the workspace.
|
|
|
|
resolve() follows symlinks, so a link planted inside the workspace is
|
|
judged by where it actually points, not by where it sits. A path that
|
|
does not exist yet still resolves (its parents do), which is what makes
|
|
this usable for an output file.
|
|
"""
|
|
if not isinstance(raw, str) or not raw:
|
|
raise ValueError(f"{arg!r} must be a non-empty path")
|
|
resolved = Path(raw).expanduser().resolve()
|
|
if roots and not _within_workspace(resolved, roots):
|
|
raise ValueError(
|
|
f"{arg!r} is outside the workspace: {resolved}. Allowed: "
|
|
+ os.pathsep.join(str(r) for r in roots)
|
|
+ ". Set QET_MCP_WORKSPACE to widen it, or "
|
|
"QET_MCP_ALLOW_ANY_PATH=1 to disable this check "
|
|
"(which grants this client local filesystem access)."
|
|
)
|
|
return resolved
|
|
|
|
|
|
def enforce_path_policy(tool_name: str, arguments: dict) -> None:
|
|
"""Apply the workspace, executable and overwrite policy to one tool call.
|
|
|
|
For a tool that launches QElectroTech this also fills in "binary" and,
|
|
when the install has one, "elements_dir", so a client need not know them.
|
|
"""
|
|
spec = _DATA_PATHS.get(tool_name)
|
|
if spec is None:
|
|
return
|
|
roots = workspace_roots()
|
|
|
|
if tool_name in _LAUNCHES_QET:
|
|
_check_binary(arguments)
|
|
_check_elements_dir(arguments, roots)
|
|
|
|
for arg in spec.get("read", ()):
|
|
if arg in arguments:
|
|
_check_path(arguments[arg], arg, "read", roots)
|
|
|
|
for arg in spec.get("write", ()):
|
|
if arg not in arguments:
|
|
continue
|
|
out = _check_path(arguments[arg], arg, "write", roots)
|
|
# Writing over something that is already there is the one step this
|
|
# server cannot undo, so it is the one step it will not take on its
|
|
# own. qet_project_new already had this flag; the others now match it.
|
|
if out.exists() and not arguments.get("overwrite"):
|
|
raise ValueError(
|
|
f"{arg!r} already exists: {out}. Pass \"overwrite\": true to "
|
|
"replace it, or choose another name."
|
|
)
|
|
|
|
if tool_name == "qet_edit":
|
|
for i, op in enumerate(arguments.get("operations") or []):
|
|
if not isinstance(op, dict):
|
|
continue
|
|
key = _DATA_PATH_OPS.get(op.get("op"))
|
|
if key and key in op:
|
|
_check_path(op[key], f"operations[{i}].{key}", "read", roots)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# JSON-RPC / MCP plumbing
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _public(tool: dict) -> dict:
|
|
return {k: v for k, v in tool.items() if k != "handler"}
|
|
|
|
|
|
def handle(msg: dict) -> dict | None:
|
|
method = msg.get("method")
|
|
mid = msg.get("id")
|
|
|
|
if method == "initialize":
|
|
want = (msg.get("params") or {}).get("protocolVersion")
|
|
return _ok(mid, {
|
|
"protocolVersion": want or DEFAULT_PROTOCOL,
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
})
|
|
|
|
if method in ("notifications/initialized", "initialized"):
|
|
return None # notification: no reply
|
|
|
|
if method == "ping":
|
|
return _ok(mid, {})
|
|
|
|
if method == "tools/list":
|
|
return _ok(mid, {"tools": [_public(t) for t in TOOLS]})
|
|
|
|
if method == "tools/call":
|
|
params = msg.get("params") or {}
|
|
name = params.get("name")
|
|
tool = _BY_NAME.get(name)
|
|
if tool is None:
|
|
return _err(mid, -32602, f"unknown tool: {name}")
|
|
try:
|
|
arguments = params.get("arguments") or {}
|
|
enforce_path_policy(name, arguments)
|
|
result = tool["handler"](arguments)
|
|
text = json.dumps(result, indent=2, ensure_ascii=False)
|
|
return _ok(mid, {"content": [{"type": "text", "text": text}]})
|
|
except Exception as exc: # surfaced to the model, not the transport
|
|
return _ok(mid, {
|
|
"isError": True,
|
|
"content": [{"type": "text",
|
|
"text": f"{type(exc).__name__}: {exc}"}],
|
|
})
|
|
|
|
if mid is None:
|
|
return None
|
|
return _err(mid, -32601, f"method not found: {method}")
|
|
|
|
|
|
def _ok(mid, result):
|
|
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
|
|
|
|
def _err(mid, code, message):
|
|
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
|
|
|
|
|
def serve(stdin=sys.stdin, stdout=sys.stdout) -> None:
|
|
for line in stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
msg = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
print(json.dumps(_err(None, -32700, f"parse error: {exc}")),
|
|
file=stdout, flush=True)
|
|
continue
|
|
reply = handle(msg)
|
|
if reply is not None:
|
|
print(json.dumps(reply, ensure_ascii=False), file=stdout, flush=True)
|
|
|
|
|
|
def call_once(argv: list[str], stdin=sys.stdin, stdout=sys.stdout,
|
|
stderr=sys.stderr) -> int:
|
|
"""--call <tool> [arguments]: one tools/call, the result's text on stdout.
|
|
|
|
arguments is a JSON object, or "-" to read it from stdin (which spares
|
|
the caller from quoting JSON for a shell). Exit status: 0 the tool
|
|
succeeded, 1 the tool reported an error, 2 the call itself was malformed.
|
|
"""
|
|
if not argv or len(argv) > 2:
|
|
print("usage: qet_mcp.py --call <tool> ['<json arguments>' | -]",
|
|
file=stderr)
|
|
return 2
|
|
name, raw = argv[0], (argv[1] if len(argv) == 2 else "{}")
|
|
if raw == "-":
|
|
raw = stdin.read()
|
|
try:
|
|
arguments = json.loads(raw) if raw.strip() else {}
|
|
except json.JSONDecodeError as exc:
|
|
print(f"arguments are not valid JSON: {exc}", file=stderr)
|
|
return 2
|
|
if not isinstance(arguments, dict):
|
|
print("arguments must be a JSON object", file=stderr)
|
|
return 2
|
|
|
|
reply = handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": name, "arguments": arguments}})
|
|
if "error" in reply:
|
|
print(reply["error"]["message"], file=stderr)
|
|
return 2
|
|
result = reply["result"]
|
|
for part in result["content"]:
|
|
print(part["text"], file=stdout)
|
|
return 1 if result.get("isError") else 0
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) > 1 and sys.argv[1] in ("--list", "-l"):
|
|
for t in TOOLS:
|
|
print(f"{t['name']}\n {t['description']}\n")
|
|
return 0
|
|
if len(sys.argv) > 1 and sys.argv[1] == "--call":
|
|
return call_once(sys.argv[2:])
|
|
serve()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|