qet-mcp: tools to write, test and store scripts that QElectroTech shows as buttons

qet_script_api lists every qet.* call, asked of the QElectroTech that will
run it (qet.apiSignatures() where the build has it, names otherwise), with
the header format that makes a script a button.

qet_script_test runs a script's text on a copy of a project and returns the
qet_diff of what it changed, its log and its errors with their line.

qet_script_install stores a script, and optionally an SVG icon, in the
scripts folder QElectroTech reads; with test_project it tests first and
stores nothing if the test fails. qet_script_list, qet_script_read and
qet_script_remove complete the set.

The folder is the server's choice, never the call's: ids are checked as
file names, and storing or removing needs QET_ENABLE_SCRIPTING=1 like an
edit, because a stored script runs when the user clicks it. A header is
checked with the same rules as QElectroTech's own, so nothing is stored
that would get no button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-02 09:13:05 +13:00
parent b6495b51a6
commit 55eac848df
3 changed files with 711 additions and 12 deletions
+43 -5
View File
@@ -41,6 +41,10 @@ here read the model.
| `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count |
| `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios |
| `qet_query` | **ask the project database** — read-only SQL over the views and tables |
| `qet_script_api` | **what a script can call** — every `qet.*` call of this build, and the header that makes a script a button |
| `qet_script_test` | **try a script** on a copy of a project: what it would change, what it logged, its errors |
| `qet_script_install` | **make a button** — store a script (and an SVG icon) where QElectroTech shows it in Projet > Scripts and the Scripts toolbar |
| `qet_script_list`, `qet_script_read`, `qet_script_remove` | the stored scripts: list, read one to change it, delete one |
`qet_export` and `qet_edit` launch QElectroTech. Everything else parses the
file directly, which is faster, needs no display, and cannot be confused by
@@ -172,17 +176,17 @@ tools that read files work there: `qet_project_info`, `qet_elements`,
`qet_conductors`, `qet_items`, `qet_diff`, `qet_scan`,
`qet_element_info`, `qet_element_search` and `qet_element_build`.
## Five tools need scripting switched on
## Some tools need scripting switched on
A QElectroTech with JavaScript scripting switched off refuses `--run`, and
off is the default from
[#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/984)
onwards. Five tools here drive it that way and stop working until it is
turned on:
onwards. These tools drive it that way, or store a script that runs when
clicked, and stop working until it is turned on:
| | |
|---|---|
| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit` |
| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit`, `qet_script_api`, `qet_script_test`, `qet_script_install`, `qet_script_remove` |
| unaffected | everything else — they read the `.qet` directly, or, in `qet_export`'s case, use a plain CLI flag |
The variable goes in the environment this server is started in, which for an
@@ -193,7 +197,7 @@ configured this server and pointed it at a QElectroTech binary made that
choice, and their interactive QElectroTech keeps whatever its own setting
says.
Without it, those five come back `"ok": false` with a `hint` naming the
Without it, those come back `"ok": false` with a `hint` naming the
variable. Older builds, from before the setting existed, need nothing.
## What the server is allowed to touch
@@ -247,6 +251,40 @@ each tool. Importing `qet_mcp` and calling `tool_export()` from your own
Python is not confined and is not meant to be — that is your code calling a
library, and you already chose the paths.
## Script buttons
QElectroTech turns every `.js` file in its scripts folder that starts with a
`// ==QETScript==` header into a command with an icon: in Projet > Scripts,
on the Scripts toolbar, in command search and in the shortcut bar. A person
can write that file by hand; an assistant uses the tools above. Both end
with the same file, and an open QElectroTech picks it up without a restart.
```js
// ==QETScript==
// @name Add revision note
// @icon add-revision-note.svg
// @tooltip Puts a "Rev A" note on the folio on screen
// @shortcut Ctrl+Alt+R
// @context canvas
// ==/QETScript==
qet.addText(qet.currentFolio(), "Rev A", 40, 40);
```
The usual round: `qet_script_api` for the calls, `qet_script_test` on a
project until the diff is what was wanted, then `qet_script_install` with
`test_project` set, so a script that fails is not stored. The assistant
never presses the button: the user does, and one Ctrl+Z undoes the run.
| | |
|---|---|
| folder | QElectroTech's data folder + `/scripts`: `~/.local/share/QElectroTech/QElectroTech/scripts` on Linux, `%APPDATA%\QElectroTech\QElectroTech\scripts` on Windows, `~/Library/Application Support/QElectroTech/QElectroTech/scripts` on macOS |
| `QET_MCP_SCRIPTS_DIR` | another folder, for a QElectroTech started with `--data-dir` |
The folder is chosen by the server, never by a call, and a script's id
becomes its file name only if it is `a-z`, `0-9`, `-` and `_`. Storing or
removing a script needs `QET_ENABLE_SCRIPTING=1` like an edit does: a
stored script runs with the user's rights when they click it.
## Worked examples
**What did that edit change?**
+452 -4
View File
@@ -1023,8 +1023,10 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180,
"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 "
"qet_continuity, qet_check, qet_project_new, qet_edit, "
"qet_script_api, qet_script_test, qet_script_install and "
"qet_script_remove need it; every other tool either reads "
"the file directly "
"or uses a plain CLI flag.")
return result
@@ -2551,6 +2553,338 @@ def tool_edit(binary: str, project: str, operations: list, output: str,
return result
# --------------------------------------------------------------------------
# Stored scripts: the buttons in Projet > Scripts and on the Scripts toolbar
# --------------------------------------------------------------------------
#
# QElectroTech turns every .js file in one folder into a command with an
# icon, read from a // ==QETScript== header at the top of the file. The
# folder is the whole contract: a person writing a script by hand and an
# assistant using these tools both end with a file there, and QElectroTech
# notices it without a restart. So installing is writing a file, and these
# tools never talk to a running QElectroTech.
#
# The folder is not the client's to choose -- scripts_dir() finds it the
# way QElectroTech does -- and writing to it needs the same consent as
# editing a project: QET_ENABLE_SCRIPTING=1 in this server's environment.
# A stored script runs, with the user's rights, when they click its button.
_SCRIPT_CONTEXTS = ("canvas", "selection", "conductor")
_SCRIPT_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_SCRIPT_MAX_BYTES = 256 * 1024
_ICON_MAX_BYTES = 64 * 1024
SCRIPT_HEADER_HELP = (
"A stored script is one .js file; its first lines say how its button "
"looks:\n"
"// ==QETScript==\n"
"// @name Add revision note (required)\n"
"// @icon <id>.svg (optional: a file next to the "
"script, or builtin:<icon theme name>; a tile with the initials otherwise)\n"
"// @tooltip Puts a note on the folio on screen\n"
"// @shortcut Ctrl+Alt+R (optional default shortcut)\n"
"// @context canvas (canvas: always enabled; "
"selection: something selected; conductor: a conductor selected)\n"
"// @api 1\n"
"// ==/QETScript==\n"
"The script sees one global, qet. qet.currentFolio() is the folio on "
"screen; folio indexes count from 0. A click is one undo step, so do "
"not call qet.undo() in a stored script. Report with qet.log(); "
"qet.showMessage() opens a dialog the user has to close.")
def scripts_dir() -> Path:
"""The folder QElectroTech reads stored scripts from.
QETApp::dataDir() + "/scripts", where dataDir() is Qt's
AppDataLocation for organisation and application "QElectroTech".
QET_MCP_SCRIPTS_DIR overrides it, for a QElectroTech started with
--data-dir or a test.
"""
env = os.environ.get("QET_MCP_SCRIPTS_DIR", "").strip()
if env:
return Path(env).expanduser()
if os.name == "nt":
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
return base / "QElectroTech" / "QElectroTech" / "scripts"
def parse_script_header(text: str, script_id: str) -> dict:
"""The same rules as QElectroTech's ScriptHeader::parse().
Returns the header's fields, with "error" set when QElectroTech would
refuse it (and so show no button for it).
"""
h = {"id": script_id, "name": "", "icon": "", "tooltip": "", "shortcut": "",
"context": "canvas", "api": 1,
"action_id": "diagrameditor.script." + script_id}
m = re.search(r"//\s*==QETScript==\s*\n(.*?)//\s*==/QETScript==", text, re.S)
if not m:
h["error"] = "no // ==QETScript== header"
return h
for line in m.group(1).split("\n"):
lm = re.match(r"^\s*//\s*@(\w+)\s+(.*?)\s*$", line)
if not lm:
continue
key, value = lm.groups()
if key == "api":
try:
h["api"] = int(value)
except ValueError:
h["api"] = 0
elif key in ("name", "icon", "tooltip", "shortcut", "context"):
h[key] = value
else:
h["error"] = f"unknown header key @{key}"
return h
if not h["name"]:
h["error"] = "@name is required"
elif h["context"] not in _SCRIPT_CONTEXTS:
h["error"] = "@context must be one of: " + ", ".join(_SCRIPT_CONTEXTS)
elif h["api"] != 1:
h["error"] = f"@api {h['api']} is not supported by this version (1 is)"
return h
def _script_id(script_id) -> str:
if not isinstance(script_id, str) or not _SCRIPT_ID.match(script_id):
raise ValueError("'id' must be 1-64 characters of a-z, 0-9, '-' and '_', "
"starting with a letter or digit: it is the file name")
return script_id
def _require_script_consent() -> None:
if os.environ.get("QET_ENABLE_SCRIPTING") != "1":
raise ValueError(
"storing a script needs the same consent as editing a project: "
"QET_ENABLE_SCRIPTING=1 in the environment this server is started "
"in. A stored script runs with the user's rights when they click it.")
def _check_icon_svg(svg: str) -> None:
if not isinstance(svg, str) or len(svg.encode("utf-8")) > _ICON_MAX_BYTES:
raise ValueError(f"'icon_svg' must be SVG text under {_ICON_MAX_BYTES // 1024} KB")
try:
root = ET.fromstring(svg)
except ET.ParseError as exc:
raise ValueError(f"'icon_svg' is not well-formed XML: {exc}") from None
if root.tag.rsplit("}", 1)[-1] != "svg":
raise ValueError("'icon_svg' must have <svg> as its root element")
def _write_atomic(path: Path, data: str) -> None:
"""Write, then rename into place, so the watcher never reads half a file."""
tmp = path.with_name("." + path.name + ".tmp")
tmp.write_text(data, encoding="utf-8")
os.replace(tmp, path)
def tool_script_api(binary: str, timeout: int = 120) -> dict:
"""The calls a script can make, asked of the QElectroTech that will run it."""
script = ("var sigs = typeof qet.apiSignatures === 'function' ? qet.apiSignatures()"
" : null;\n"
"var names = []; for (var k in qet) if (typeof qet[k] === 'function') "
"names.push(k);\n"
"qet.log(%s + JSON.stringify({kind: 'api', signatures: sigs, names: names}));\n"
% json.dumps(_MARKER))
with tempfile.TemporaryDirectory(prefix="qet-mcp-api-") as tmp:
proj = Path(tmp) / "api.qet"
proj.write_text('<project version="0.100.0" title="api">\n</project>\n',
encoding="utf-8")
result = _run_qet(binary, [str(proj)], timeout=timeout, script=script,
tail=400_000)
rec = None
for line in (result.get("stdout", "") + "\n" + result.get("stderr", "")).splitlines():
idx = line.find(_MARKER)
if idx >= 0:
try:
rec = json.loads(line[idx + len(_MARKER):])
except json.JSONDecodeError:
pass
if rec is None:
result["ok"] = False
result["stdout"] = result.get("stdout", "")[-4000:]
result["stderr"] = result.get("stderr", "")[-4000:]
return result
out = {"ok": True, "header_format": SCRIPT_HEADER_HELP}
if rec.get("signatures"):
out["calls"] = rec["signatures"]
else:
out["calls"] = sorted(n for n in rec.get("names", [])
if not n.endswith("Changed") and n != "deleteLater")
out["note"] = ("this QElectroTech predates qet.apiSignatures(): names only, "
"see the JavaScript Scripting wiki page for parameters")
out["call_count"] = len(out["calls"])
return out
def tool_script_test(binary: str, project: str, source: str,
elements_dir: str | None = None, timeout: int = 180) -> dict:
"""Run a script on a copy of a project and say what it would change.
What clicking its button would do, without touching the project: the
script runs headless on a copy, the copy is saved, and qet_diff compares
it with the original. Headless there is no folio on screen, so
qet.currentFolio() is the first folio.
"""
proj = Path(project).expanduser()
if not proj.is_file():
raise ValueError(f"no such project: {proj}")
if not isinstance(source, str) or not source.strip():
raise ValueError("'source' must be the script's text")
header = parse_script_header(source, "test")
with tempfile.TemporaryDirectory(prefix="qet-mcp-script-") as tmp:
copy = Path(tmp) / proj.name
shutil.copy2(proj, copy)
# Older builds have no currentFolio(); the first folio stands in, as
# it does headless in builds that have it. On the script's own first
# line, so the line numbers in its errors are its own.
script = ("if (typeof qet.currentFolio !== 'function') "
"qet.currentFolio = function () { return qet.folioCount() ? 0 : -1; }; "
+ source + "\n"
"qet.log(%s + JSON.stringify({kind: 'save', result: qet.save(%s)}));\n"
% (json.dumps(_MARKER), json.dumps(str(copy))))
result = _run_qet(binary, [str(copy)], timeout=timeout,
elements_dir=elements_dir, script=script, tail=400_000)
streams = result.get("stdout", "") + "\n" + result.get("stderr", "")
saved = None
for line in streams.splitlines():
idx = line.find(_MARKER)
if idx >= 0:
try:
saved = json.loads(line[idx + len(_MARKER):]).get("result")
except json.JSONDecodeError:
pass
errors = [ln.strip() for ln in streams.splitlines() if "Script error:" in ln]
log = [ln for ln in streams.splitlines()
if ln.strip() and _MARKER not in ln and "Script error:" not in ln]
out = {"ok": bool(result.get("ok")) and saved is True and not errors,
"header": header, "errors": errors, "log": log[-60:]}
if result.get("hint"):
out["hint"] = result["hint"]
if result.get("timed_out"):
out["timed_out"] = True
if saved is True:
out["diff"] = tool_diff(str(proj), str(copy))
elif not errors:
out["errors"] = ["the script did not finish: it threw before the "
"copy could be saved, or never ran"]
if header.get("error"):
out["header_warning"] = (f"QElectroTech would show no button for this "
f"script: {header['error']}")
return out
def tool_script_install(script_id: str, source: str, icon_svg: str | None = None,
overwrite: bool = False, test_project: str | None = None,
binary: str | None = None, elements_dir: str | None = None,
timeout: int = 180) -> dict:
"""Store a script so QElectroTech shows it as a button."""
_require_script_consent()
sid = _script_id(script_id)
if not isinstance(source, str) or not source.strip():
raise ValueError("'source' must be the script's text")
if len(source.encode("utf-8")) > _SCRIPT_MAX_BYTES:
raise ValueError(f"'source' is over {_SCRIPT_MAX_BYTES // 1024} KB")
header = parse_script_header(source, sid)
if header.get("error"):
raise ValueError(f"QElectroTech would refuse this header: {header['error']}. "
+ SCRIPT_HEADER_HELP)
folder = scripts_dir()
icon = header["icon"]
if icon_svg is not None:
_check_icon_svg(icon_svg)
if icon != f"{sid}.svg":
raise ValueError(f"with 'icon_svg', the header must say '// @icon {sid}.svg'")
elif icon and not icon.startswith("builtin:") and not (folder / icon).is_file():
raise ValueError(f"the header names icon file {icon!r}, which is not in "
f"{folder}: pass its SVG as 'icon_svg', use builtin:<name>, "
"or leave @icon out for an initials tile")
target = folder / f"{sid}.js"
if target.exists() and not overwrite:
raise ValueError(f"a script with id {sid!r} is already stored: {target}. "
"Pass \"overwrite\": true to replace it.")
test = None
if test_project:
test = tool_script_test(binary, test_project, source, elements_dir, timeout)
if not test.get("ok"):
return {"ok": False, "installed": None,
"reason": "the test run failed, so nothing was stored",
"test": test}
folder.mkdir(parents=True, exist_ok=True)
if icon_svg is not None:
_write_atomic(folder / f"{sid}.svg", icon_svg)
_write_atomic(target, source)
out = {"ok": True, "installed": str(target), "header": header,
"where": "Projet > Scripts, the Scripts toolbar, command search "
"(Ctrl+Shift+M) and the shortcut bar's Customise list; an "
"open QElectroTech picks it up without a restart"}
if test is not None:
out["test"] = {"ok": True, "diff": test.get("diff")}
return out
def tool_script_list() -> dict:
folder = scripts_dir()
scripts, refused = [], []
if folder.is_dir():
for path in sorted(folder.glob("*.js")):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
refused.append({"file": path.name, "error": str(exc)})
continue
h = parse_script_header(text, path.stem)
if h.get("error"):
refused.append({"file": path.name, "error": h["error"]})
else:
scripts.append(h)
return {"folder": str(folder), "exists": folder.is_dir(),
"scripts": scripts, "refused": refused}
def tool_script_read(script_id: str) -> dict:
sid = _script_id(script_id)
folder = scripts_dir()
path = folder / f"{sid}.js"
if not path.is_file():
raise ValueError(f"no stored script {sid!r} in {folder}")
source = path.read_text(encoding="utf-8")
out = {"id": sid, "path": str(path), "source": source,
"header": parse_script_header(source, sid)}
icon = folder / f"{sid}.svg"
if icon.is_file():
out["icon_svg"] = icon.read_text(encoding="utf-8")
return out
def tool_script_remove(script_id: str) -> dict:
_require_script_consent()
sid = _script_id(script_id)
folder = scripts_dir()
path = folder / f"{sid}.js"
if not path.is_file():
raise ValueError(f"no stored script {sid!r} in {folder}")
icon = parse_script_header(path.read_text(encoding="utf-8"), sid).get("icon", "")
path.unlink()
removed = [str(path)]
# The icon goes too unless another script still names it.
if icon and not icon.startswith("builtin:") and "/" not in icon and "\\" not in icon:
others = {parse_script_header(p.read_text(encoding="utf-8"), p.stem).get("icon")
for p in folder.glob("*.js")}
if icon not in others and (folder / icon).is_file():
(folder / icon).unlink()
removed.append(str(folder / icon))
return {"removed": removed}
TOOLS = [
{
"name": "qet_project_info",
@@ -3151,6 +3485,108 @@ TOOLS = [
a["output"], a["names"], a["parts"], a.get("terminals"),
a.get("link_type", "simple"), a.get("informations"), a.get("uuid")),
},
{
"name": "qet_script_api",
"description": "List every call a QElectroTech script can make (the global "
"'qet'), asked of the QElectroTech that will run it, plus the "
"header format that turns a script into a button. Read this "
"before writing a script for qet_script_install. One launch; "
"needs QET_ENABLE_SCRIPTING=1.",
"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"},
"timeout": {"type": "integer", "default": 120},
},
},
"handler": lambda a: tool_script_api(a["binary"], a.get("timeout", 120)),
},
{
"name": "qet_script_test",
"description": "Run a script's text on a COPY of a project and return what it "
"would change (a qet_diff), what it logged, and any error with "
"its line. The project is never modified. Headless there is no "
"folio on screen: qet.currentFolio() is the first folio. Also "
"says if the header would get no button. Needs "
"QET_ENABLE_SCRIPTING=1.",
"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 try it on; never modified"},
"source": {"type": "string", "description": "the script's full text"},
"elements_dir": {"type": "string"},
"timeout": {"type": "integer", "default": 180},
},
"required": ["project", "source"],
},
"handler": lambda a: tool_script_test(a["binary"], a["project"], a["source"],
a.get("elements_dir"), a.get("timeout", 180)),
},
{
"name": "qet_script_install",
"description": "Store a script so QElectroTech shows it as a button with an "
"icon: Projet > Scripts, the Scripts toolbar, command search "
"and the shortcut bar. A running QElectroTech picks it up "
"without a restart. The text must start with a "
"// ==QETScript== header (see qet_script_api); the file is "
"<id>.js in the user's scripts folder, which this server "
"chooses. Give 'icon_svg' to store an icon as <id>.svg (the "
"header must then say '// @icon <id>.svg'). Give "
"'test_project' to run qet_script_test first and store "
"nothing if it fails -- recommended. Does not run the "
"script: the user clicks it. Needs QET_ENABLE_SCRIPTING=1.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "file name without .js: a-z, 0-9, '-', '_'"},
"source": {"type": "string", "description": "the script's full text, header first"},
"icon_svg": {"type": "string", "description": "optional SVG for the button, stored as <id>.svg"},
"overwrite": {"type": "boolean", "default": False},
"test_project": {"type": "string", "description": "optional .qet to test on first; never modified"},
"binary": {"type": "string", "description": "the qelectrotech executable for the test; leave it out to use the one this server is configured with"},
"elements_dir": {"type": "string"},
"timeout": {"type": "integer", "default": 180},
},
"required": ["id", "source"],
},
"handler": lambda a: tool_script_install(
a["id"], a["source"], a.get("icon_svg"), bool(a.get("overwrite")),
a.get("test_project"), a.get("binary"), a.get("elements_dir"),
a.get("timeout", 180)),
},
{
"name": "qet_script_list",
"description": "List the stored scripts QElectroTech shows as buttons: each "
"one's id, name, icon, tooltip, shortcut and context, and the "
"files it ignores with the reason. Reads files only.",
"inputSchema": {"type": "object", "properties": {}},
"handler": lambda a: tool_script_list(),
},
{
"name": "qet_script_read",
"description": "The text (and stored SVG icon, if any) of one stored script, "
"to change it and store it again with qet_script_install "
"overwrite=true.",
"inputSchema": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
"handler": lambda a: tool_script_read(a["id"]),
},
{
"name": "qet_script_remove",
"description": "Delete a stored script, and its icon if no other script uses "
"it; its button goes from a running QElectroTech. Needs "
"QET_ENABLE_SCRIPTING=1.",
"inputSchema": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
"handler": lambda a: tool_script_remove(a["id"]),
},
]
_BY_NAME = {t["name"]: t for t in TOOLS}
@@ -3203,11 +3639,23 @@ _DATA_PATHS = {
"qet_check": {"read": ("project",)},
"qet_project_new": {"write": ("output",)},
"qet_element_build": {"write": ("output",)},
# The scripts folder is chosen by scripts_dir(), never by the client,
# so only the project a script is tried on is a data path here.
"qet_script_api": {},
"qet_script_test": {"read": ("project",)},
"qet_script_install": {"read": ("test_project",)},
}
# 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_check", "qet_project_new", "qet_script_api", "qet_script_test"}
# Tools that launch QElectroTech only when given this argument.
_LAUNCHES_QET_WITH = {"qet_script_install": "test_project"}
# Tools whose "overwrite" guards a file the server names itself (the
# stored script, in scripts_dir()), not a client-chosen output path.
_OVERWRITE_OWN_FILE = {"qet_script_install"}
# qet_edit operations that name a file of their own.
_DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"}
@@ -3392,7 +3840,7 @@ def enforce_path_policy(tool_name: str, arguments: dict) -> None:
return
roots = workspace_roots()
if tool_name in _LAUNCHES_QET:
if tool_name in _LAUNCHES_QET or arguments.get(_LAUNCHES_QET_WITH.get(tool_name, "")):
_check_binary(arguments)
_check_elements_dir(arguments, roots)
+216 -3
View File
@@ -164,7 +164,9 @@ class ToolRegistry(unittest.TestCase):
"qet_project_info", "qet_elements", "qet_conductors", "qet_diff",
"qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query",
"qet_project_new", "qet_element_search", "qet_check", "qet_element_build",
"qet_continuity", "qet_items"})
"qet_continuity", "qet_items", "qet_script_api", "qet_script_test",
"qet_script_install", "qet_script_list", "qet_script_read",
"qet_script_remove"})
class EditValidation(unittest.TestCase):
@@ -2417,7 +2419,7 @@ class PathPolicy(unittest.TestCase):
guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")}
advertised = {t["name"] for t in m.TOOLS
if "overwrite" in t["inputSchema"].get("properties", {})}
self.assertEqual(guarded, advertised)
self.assertEqual(guarded, advertised - m._OVERWRITE_OWN_FILE)
def test_every_data_path_argument_is_guarded(self):
"""The other direction: a tool whose schema takes a data path must be
@@ -2527,7 +2529,7 @@ class BinaryPolicy(unittest.TestCase):
would run whatever it was given."""
takes = {t["name"] for t in m.TOOLS
if "binary" in t["inputSchema"].get("properties", {})}
self.assertEqual(takes, m._LAUNCHES_QET)
self.assertEqual(takes, m._LAUNCHES_QET | set(m._LAUNCHES_QET_WITH))
for name in takes:
self.assertNotIn("binary", m._BY_NAME[name]["inputSchema"].get("required", []),
f"{name} still requires the client to name a binary")
@@ -2773,6 +2775,217 @@ class ScriptingDisabledHint(unittest.TestCase):
self.assertIn(tool, hint)
NOTE_SCRIPT = (
"// ==QETScript==\n"
"// @name Add a note\n"
"// @tooltip Puts a note on the folio on screen\n"
"// @shortcut Ctrl+Alt+N\n"
"// ==/QETScript==\n"
"qet.addText(qet.currentFolio(), 'SCRIPT-NOTE', 40, 40);\n"
"qet.log('added');\n")
ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16"/></svg>'
class ScriptHeader(unittest.TestCase):
"""The same cases as QElectroTech's tst_scriptheader: a header this
accepts and QElectroTech refuses would install a script that never gets
a button."""
def h(self, lines):
return m.parse_script_header(
"// ==QETScript==\n" + lines + "// ==/QETScript==\nqet.log('x');\n", "a")
def test_full(self):
h = self.h("// @name Add revision note\n// @icon note.svg\n"
"// @tooltip Puts a note\n// @shortcut Ctrl+Alt+R\n"
"// @context selection\n// @api 1\n")
self.assertNotIn("error", h)
self.assertEqual((h["name"], h["icon"], h["shortcut"], h["context"]),
("Add revision note", "note.svg", "Ctrl+Alt+R", "selection"))
self.assertEqual(h["action_id"], "diagrameditor.script.a")
def test_refused(self):
cases = {
"qet.log('x');\n": "no // ==QETScript== header",
}
for text, error in cases.items():
self.assertEqual(m.parse_script_header(text, "a")["error"], error)
for lines, error in [
("// @icon x.svg\n", "@name is required"),
("// @name A\n// @shortcutt Ctrl+K\n", "unknown header key @shortcutt"),
("// @name A\n// @context wires\n",
"@context must be one of: canvas, selection, conductor"),
("// @name A\n// @api 2\n",
"@api 2 is not supported by this version (1 is)")]:
with self.subTest(lines=lines):
self.assertEqual(self.h(lines)["error"], error)
def test_only_the_block(self):
h = m.parse_script_header(
"// ==QETScript==\n// @name Real\n// ==/QETScript==\n"
"// @name Not this one\n// @bogus key\n", "a")
self.assertNotIn("error", h)
self.assertEqual(h["name"], "Real")
class ScriptStore(unittest.TestCase):
"""install / list / read / remove against a scripts folder of its own."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = Path(self.tmp.name) / "scripts"
self.env = mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": str(self.dir),
"QET_ENABLE_SCRIPTING": "1"})
self.env.start()
def tearDown(self):
self.env.stop()
self.tmp.cleanup()
def test_round_trip(self):
r = m.tool_script_install("add-note", NOTE_SCRIPT)
self.assertTrue(r["ok"])
self.assertEqual((self.dir / "add-note.js").read_text(), NOTE_SCRIPT)
listed = m.tool_script_list()
self.assertEqual([s["id"] for s in listed["scripts"]], ["add-note"])
self.assertEqual(listed["scripts"][0]["shortcut"], "Ctrl+Alt+N")
self.assertEqual(m.tool_script_read("add-note")["source"], NOTE_SCRIPT)
m.tool_script_remove("add-note")
self.assertFalse((self.dir / "add-note.js").exists())
self.assertEqual(m.tool_script_list()["scripts"], [])
def test_icon_stored_and_removed_with_it(self):
src = NOTE_SCRIPT.replace("// @tooltip", "// @icon iconic.svg\n// @tooltip")
m.tool_script_install("iconic", src, icon_svg=ICON)
self.assertEqual((self.dir / "iconic.svg").read_text(), ICON)
self.assertEqual(m.tool_script_read("iconic")["icon_svg"], ICON)
m.tool_script_remove("iconic")
self.assertEqual(list(self.dir.iterdir()), [])
def test_shared_icon_kept_while_used(self):
src = NOTE_SCRIPT.replace("// @tooltip", "// @icon one.svg\n// @tooltip")
m.tool_script_install("one", src, icon_svg=ICON)
m.tool_script_install("two", src) # names one.svg, which exists
m.tool_script_remove("one")
self.assertTrue((self.dir / "one.svg").exists())
m.tool_script_remove("two")
self.assertFalse((self.dir / "one.svg").exists())
def test_refusals(self):
for sid in ("../evil", "Upper", "", "a/b", "-x", "x" * 65, None):
with self.subTest(id=sid), self.assertRaisesRegex(ValueError, "'id' must be"):
m.tool_script_install(sid, NOTE_SCRIPT)
with self.assertRaisesRegex(ValueError, "would refuse this header: @name"):
m.tool_script_install("a", "// ==QETScript==\n// ==/QETScript==\n")
with self.assertRaisesRegex(ValueError, "must say '// @icon a.svg'"):
m.tool_script_install("a", NOTE_SCRIPT, icon_svg=ICON)
with self.assertRaisesRegex(ValueError, "not in"):
m.tool_script_install("a", NOTE_SCRIPT.replace(
"// @tooltip", "// @icon missing.svg\n// @tooltip"))
src = NOTE_SCRIPT.replace("// @tooltip", "// @icon a.svg\n// @tooltip")
for bad in ("<html/>", "<svg", "x" * (65 * 1024)):
with self.subTest(svg=bad[:10]), self.assertRaises(ValueError):
m.tool_script_install("a", src, icon_svg=bad)
self.assertFalse(self.dir.exists(), "a refused install wrote something")
def test_overwrite_needs_asking(self):
m.tool_script_install("a", NOTE_SCRIPT)
with self.assertRaisesRegex(ValueError, "already stored"):
m.tool_script_install("a", NOTE_SCRIPT.replace("Add a note", "Changed"))
m.tool_script_install("a", NOTE_SCRIPT.replace("Add a note", "Changed"),
overwrite=True)
self.assertEqual(m.tool_script_list()["scripts"][0]["name"], "Changed")
def test_needs_consent(self):
"""Installing stores code the user will run by clicking: the same
switch as editing a project, or nothing is written."""
with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}):
with self.assertRaisesRegex(ValueError, "QET_ENABLE_SCRIPTING=1"):
m.tool_script_install("a", NOTE_SCRIPT)
m.tool_script_list() # reading needs no consent
m.tool_script_install("a", NOTE_SCRIPT)
with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}):
with self.assertRaisesRegex(ValueError, "QET_ENABLE_SCRIPTING=1"):
m.tool_script_remove("a")
self.assertTrue((self.dir / "a.js").exists())
def test_list_reports_refused_files(self):
self.dir.mkdir(parents=True)
(self.dir / "bad.js").write_text("qet.log(1);\n")
r = m.tool_script_list()
self.assertEqual(r["refused"], [{"file": "bad.js",
"error": "no // ==QETScript== header"}])
def test_default_folder_is_qets_data_dir(self):
with mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": "",
"XDG_DATA_HOME": "/x/share"}), \
mock.patch.object(m.os, "name", "posix"), \
mock.patch.object(m.sys, "platform", "linux"):
self.assertEqual(m.scripts_dir(),
Path("/x/share/QElectroTech/QElectroTech/scripts"))
def test_install_does_not_need_a_binary_without_a_test(self):
"""Only a test run launches QElectroTech; storing a file must not be
refused because no QElectroTech is configured."""
with mock.patch.dict(os.environ, {"QET_BINARY": ""}), \
mock.patch.object(m, "resolve_binary", return_value=None):
args = {"id": "a", "source": NOTE_SCRIPT}
m.enforce_path_policy("qet_script_install", args)
with self.assertRaisesRegex(ValueError, "no QElectroTech found"):
m.enforce_path_policy("qet_script_install",
{"id": "a", "source": NOTE_SCRIPT,
"test_project": self.tmp.name + "/p.qet"})
@needs_examples
class ScriptToolsIntegration(unittest.TestCase):
"""Through a real QElectroTech with stored scripts (script buttons)."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = Path(self.tmp.name) / "scripts"
self.env = mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": str(self.dir),
"QET_ENABLE_SCRIPTING": "1"})
self.env.start()
self.project = Path(self.tmp.name) / "perceuse.qet"
shutil.copy2(Path(EXAMPLES) / "perceuse.qet", self.project)
def tearDown(self):
self.env.stop()
self.tmp.cleanup()
def test_api_lists_the_calls(self):
r = m.tool_script_api(BINARY)
self.assertTrue(r["ok"], r)
self.assertIn("addText", " ".join(r["calls"]))
self.assertGreater(r["call_count"], 100)
def test_test_runs_on_a_copy(self):
before = self.project.read_bytes()
r = m.tool_script_test(BINARY, str(self.project), NOTE_SCRIPT)
self.assertTrue(r["ok"], r)
self.assertEqual([t["text"] for t in r["diff"]["texts"]["added"]], ["SCRIPT-NOTE"])
self.assertIn("added", "\n".join(r["log"]))
self.assertEqual(self.project.read_bytes(), before)
def test_error_reported_with_its_line(self):
r = m.tool_script_test(BINARY, str(self.project),
NOTE_SCRIPT + "qet.noSuchCall();\n")
self.assertFalse(r["ok"])
self.assertTrue(r["errors"], r)
self.assertIn(":8:", r["errors"][0]) # line 8 of the script as given
def test_failed_test_stores_nothing(self):
r = m.tool_script_install("broken", NOTE_SCRIPT + "throw new Error('no');\n",
test_project=str(self.project), binary=BINARY)
self.assertFalse(r["ok"])
self.assertFalse(self.dir.exists() and any(self.dir.iterdir()))
r = m.tool_script_install("good", NOTE_SCRIPT,
test_project=str(self.project), binary=BINARY)
self.assertTrue(r["ok"], r)
self.assertTrue((self.dir / "good.js").is_file())
class PathPolicyOverStdio(unittest.TestCase):
"""Proves the policy is actually wired into the dispatcher.