Keep headless runs' settings in a folder of their own (QET_SETTINGS_DIR)

The MCP server gives each QElectroTech run a private HOME and writes the
element collection path for it (elements_dir) into
~/.config/QElectroTech/QElectroTech.conf there. Only Linux reads that
file: Qt keeps settings in the registry on Windows and in the system
preferences on macOS. So on those systems elements_dir did nothing, and
every run read the user's own settings (#1178).

When QET_SETTINGS_DIR names a folder, QElectroTech now keeps its
settings in <folder>/QElectroTech/QElectroTech.ini on every system. It is
set in main() before the first setting is read. Without the variable
nothing changes.

The server sets it for each run and writes the collection path to the
.ini as well as the .conf, so an older QElectroTech keeps working on
Linux. The path is written with forward slashes: Qt reads a backslash in
these files as an escape. The README notes that elements_dir needs this
on Windows and macOS, and that long arguments go on stdin with "-"
(Windows refuses a command line over 32,767 characters).

tst_settingsdir: a script places a symbol that only the folder's settings
can resolve, while the usual settings file points at an empty collection.
It fails on master and passes here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-01 08:00:55 +13:00
parent 2a6155e0c9
commit 65a1080cd3
6 changed files with 214 additions and 14 deletions
+8 -1
View File
@@ -161,6 +161,10 @@ python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}'
echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check -
```
Pass long arguments, such as a large `qet_edit` operation list, on stdin
with `-`: Windows refuses a command line over 32,767 characters
(`WinError 206`).
It prints the tool's JSON result and exits 0, or 1 if the tool reported an
error, or 2 if the call itself was malformed. The workspace rule applies as
it does in a server. The sandbox has no QElectroTech in it, so only the
@@ -472,7 +476,10 @@ Python, plus the hang guard on `addConductor` and the database refresh in
collection path, which on a machine that never ran `make install` does not
exist. The only symptom is `addElement` reporting that a file plainly
present "does not resolve to an element". An absolute `.elmt` path works
without it.
without it. On Windows and macOS, `elements_dir` needs a QElectroTech that
reads `QET_SETTINGS_DIR` (#1178): an older one keeps its settings in the
registry or the system preferences, never sees the path written for the
run, and uses the collection it was installed with.
- **`set_conductor` changes the whole potential, not one segment.** That is
what the application does — a wire number describes a potential — so name
a terminal carrying exactly one conductor and the change reaches every
+32 -13
View File
@@ -64,7 +64,7 @@ import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from pathlib import Path, PurePath
SERVER_NAME = "qet-mcp"
SERVER_VERSION = "0.1.0"
@@ -906,22 +906,40 @@ def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path:
def _launch_env(env: dict, home: Path, windows: bool) -> dict:
"""The environment _run_qet() starts QElectroTech in.
A private HOME and XDG directories, and, except on Windows, 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.
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"))
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:
@@ -941,8 +959,10 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180,
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, so it must be
QElectroTech/QElectroTech.conf and nothing else.
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
@@ -973,9 +993,8 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180,
raise ValueError(f"no such elements directory: {coll}")
cfg = home / ".config" / "QElectroTech"
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "QElectroTech.conf").write_text(
"[elements-collections]\n"
f"common-collection-path={coll}\n", encoding="utf-8")
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")
+36
View File
@@ -2632,6 +2632,13 @@ class LaunchExecutable(unittest.TestCase):
self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen")
self.assertEqual(env["HOME"], str(self.sandbox))
def test_settings_stay_in_the_sandbox_everywhere(self):
"""HOME and XDG move the settings only on Linux; QET_SETTINGS_DIR
moves them on Windows and macOS too (#1178)."""
for windows in (True, False):
env = m._launch_env({}, self.sandbox, windows)
self.assertEqual(env["QET_SETTINGS_DIR"], str(self.sandbox / ".config"))
def test_elsewhere_runs_a_private_copy(self):
exe = m._launch_executable(self.src, self.sandbox, False)
self.assertEqual(exe.parent, self.sandbox)
@@ -2639,6 +2646,35 @@ class LaunchExecutable(unittest.TestCase):
self.assertEqual(exe.read_text(), self.src.read_text())
class ElementsDirSetting(unittest.TestCase):
"""elements_dir reaches QElectroTech as a settings file in the sandbox
(#1178): the .ini that a QElectroTech knowing QET_SETTINGS_DIR reads on
every system, and the .conf an older one reads on Linux."""
def test_both_files_carry_the_collection(self):
seen = {}
def run(argv, **kwargs):
cfg = Path(kwargs["env"]["QET_SETTINGS_DIR"]) / "QElectroTech"
for f in cfg.iterdir():
seen[f.name] = f.read_text(encoding="utf-8")
return subprocess.CompletedProcess(argv, 0, "", "")
with tempfile.TemporaryDirectory() as coll, \
mock.patch.object(m.subprocess, "run", run):
m._run_qet("/bin/true", ["x.qet"], elements_dir=coll)
want = m._collection_setting(Path(coll))
self.assertEqual(seen, {"QElectroTech.ini": want, "QElectroTech.conf": want})
self.assertIn(f"common-collection-path={Path(coll).as_posix()}", want)
def test_a_windows_path_has_forward_slashes(self):
"""Qt reads a backslash in the file as an escape."""
from pathlib import PureWindowsPath
text = m._collection_setting(PureWindowsPath(r"C:\x\custom"))
self.assertIn("common-collection-path=C:/x/custom\n", text)
self.assertNotIn("\\", text)
class ScriptingDisabledHint(unittest.TestCase):
"""QElectroTech may refuse to run scripts at all, and says so in French.