Merge pull request #1483 from ispyisail/fix/qetz-1472-followups

Fix saving to a read-only folder, and the #1472 review follow-ups
This commit is contained in:
ispyisail
2026-10-15 13:31:03 +13:00
committed by GitHub
50 changed files with 30788 additions and 30852 deletions
@@ -69,6 +69,10 @@ set reg_file=qet_install_file_associations.reg
echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command]
echo @="\"%final_qet_exe%\" \"%%1\""
rem *.qetz (zipped project): the same file type as *.qet
echo [HKEY_CURRENT_USER\Software\Classes\.qetz]
echo @="qet_diagram_file"
rem association de fichier *.elmt
echo [HKEY_CURRENT_USER\Software\Classes\.elmt]
echo @="qet_element_file"
+922 -943
View File
File diff suppressed because it is too large Load Diff
+931 -937
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+947 -878
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+947 -878
View File
File diff suppressed because it is too large Load Diff
+929 -935
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+911 -880
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+931 -937
View File
File diff suppressed because it is too large Load Diff
+926 -947
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+921 -946
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+931 -937
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+947 -878
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+959 -890
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+929 -935
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+920 -949
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+922 -943
View File
File diff suppressed because it is too large Load Diff
+931 -937
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -34,10 +34,62 @@
<string>qet</string>
<string>qetz</string>
</array>
<key>LSItemContentTypes</key>
<array>
<string>org.qelectrotech.project</string>
<string>org.qelectrotech.project-zip</string>
</array>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.qelectrotech.project</string>
<key>UTTypeDescription</key>
<string>QElectroTech project</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeIconFile</key>
<string>qet</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>qet</string>
</array>
<key>public.mime-type</key>
<string>application/x-qet-project</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.qelectrotech.project-zip</string>
<key>UTTypeDescription</key>
<string>QElectroTech zipped project</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeIconFile</key>
<string>qet</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>qetz</string>
</array>
<key>public.mime-type</key>
<string>application/x-qet-project+zip</string>
</dict>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>qelectrotech</string>
<key>CFBundleIconFile</key>
+1 -1
View File
@@ -6,7 +6,7 @@ Exec=qelectrotech %F
Icon=qelectrotech
Terminal=false
Type=Application
MimeType=application/x-qet-project;application/x-qelectrotech-project+zip;application/x-qet-element;application/x-qet-titleblock;
MimeType=application/x-qet-project;application/x-qet-project+zip;application/x-qet-element;application/x-qet-titleblock;
Categories=Graphics;
Keywords=Graphics;Science;Electricity;Engineering;
Comment=Edit electrical diagrams.
+6
View File
@@ -37,6 +37,12 @@
<summary xml:lang="tr">Elektrik şeması düzenleyici</summary>
<summary xml:lang="uk">Редактор електричних схем</summary>
<summary xml:lang="zh">电气图编辑器</summary>
<provides>
<mediatype>application/x-qet-project</mediatype>
<mediatype>application/x-qet-project+zip</mediatype>
<mediatype>application/x-qet-element</mediatype>
<mediatype>application/x-qet-titleblock</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.100.1-dev" date="2026"/>
+3 -1
View File
@@ -34,11 +34,13 @@
<comment xml:lang="zh">QElectroTech 项目文件</comment>
<glob pattern="*.qet" />
</mime-type>
<mime-type type="application/x-qelectrotech-project+zip">
<mime-type type="application/x-qet-project+zip">
<alias type="application/x-qelectrotech-project+zip"/>
<sub-class-of type="application/zip"/>
<comment>QElectroTech zipped project file</comment>
<glob pattern="*.qetz" />
<magic priority="60">
<match type="string" offset="30" value="mimetypeapplication/x-qet-project+zip"/>
<match type="string" offset="30" value="mimetypeapplication/x-qelectrotech-project+zip"/>
</magic>
</mime-type>
+52 -9
View File
@@ -56,6 +56,7 @@ workspace policy applies exactly as it does over stdio.
from __future__ import annotations
import atexit
import datetime
import json
import re
@@ -110,7 +111,21 @@ def _root(path: str) -> ET.Element:
# folio. The tools that read a project's XML read the .qet QElectroTech
# saves from it, so they read what QElectroTech reads, whatever the zipped
# format holds; one copy per version of the file (path, size, time).
# Only the newest few are kept, and the server removes them when it exits.
_QETZ_COPIES: dict = {}
_QETZ_COPIES_MAX = 8
def _forget_qetz_copy(key) -> None:
out = _QETZ_COPIES.pop(key, None)
if out is not None:
shutil.rmtree(out.parent, ignore_errors=True)
@atexit.register
def _forget_qetz_copies() -> None:
for key in list(_QETZ_COPIES):
_forget_qetz_copy(key)
def _is_qetz(path) -> bool:
@@ -129,6 +144,7 @@ def _as_qet(path) -> Path:
key = (str(p.resolve()), st.st_size, st.st_mtime_ns)
cached = _QETZ_COPIES.get(key)
if cached is not None and cached.is_file():
_QETZ_COPIES[key] = _QETZ_COPIES.pop(key) # newest last
return cached
binary = resolve_binary()
if not binary:
@@ -138,12 +154,21 @@ def _as_qet(path) -> Path:
out = folder / (p.stem + ".qet")
# Saving as .qet builds every folio: allow a big project its time
result = _run_qet(str(binary), ["--resave", str(p), str(out)], timeout=900)
if not out.is_file() or out.stat().st_size == 0:
# A run that timed out or failed may have left part of a .qet behind
if not result.get("ok") or not out.is_file() or out.stat().st_size == 0:
shutil.rmtree(folder, ignore_errors=True)
detail = (result.get("stderr") or result.get("stdout") or "").strip()[-400:]
if result.get("timed_out"):
detail = f"timed out after {result.get('timeout_s')} s"
else:
detail = (result.get("stderr") or result.get("stdout") or "").strip()[-400:]
raise ValueError(f"QElectroTech could not read {p.name}"
+ (f": {detail}" if detail else ""))
# An older version of the same file is not read again
for old in [k for k in _QETZ_COPIES if k[0] == key[0]]:
_forget_qetz_copy(old)
_QETZ_COPIES[key] = out
while len(_QETZ_COPIES) > _QETZ_COPIES_MAX:
_forget_qetz_copy(next(iter(_QETZ_COPIES)))
return out
@@ -840,12 +865,17 @@ def _num(v) -> float:
def tool_scan(directory: str, tag: str = "conductor",
attribute: str = "cable", recursive: bool = True) -> dict:
attribute: str = "cable", recursive: bool = True,
max_qetz: int = 20) -> dict:
"""Sweep every .qet and .qetz 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.
Each .qetz is read by QElectroTech saving it as a .qet, which can take
minutes for a big project, so only the first `max_qetz` are read; the
others are listed as skipped.
"""
d = Path(directory).expanduser()
if not d.is_dir():
@@ -853,8 +883,14 @@ def tool_scan(directory: str, tag: str = "conductor",
files = sorted([f for pattern in ("*.qet", "*.qetz")
for f in (d.rglob(pattern) if recursive else d.glob(pattern))])
total = non_empty = 0
per_file, values, unreadable = [], {}, []
per_file, values, unreadable, skipped = [], {}, [], []
qetz_read = 0
for f in files:
if _is_qetz(f):
if qetz_read >= max_qetz:
skipped.append(f.name)
continue
qetz_read += 1
try:
root = ET.parse(_as_qet(f)).getroot()
except (ET.ParseError, ValueError) as exc:
@@ -872,6 +908,9 @@ def tool_scan(directory: str, tag: str = "conductor",
per_file.append({"file": f.name, tag: n, "non_empty": ne})
return {
"directory": str(d), "files": len(files), "unreadable": unreadable,
**({"skipped_qetz": skipped,
"note": f"only the first {max_qetz} .qetz were read (max_qetz)"}
if skipped else {}),
"tag": tag, "attribute": attribute,
"total": total, "non_empty": non_empty,
"distinct_values": sorted(values.items(), key=lambda kv: -kv[1])[:25],
@@ -4902,10 +4941,12 @@ TOOLS = [
},
{
"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.",
"description": "Sweep every .qet and .qetz 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. Each "
".qetz is read through QElectroTech, which can take minutes "
"for a big project: only the first max_qetz are read.",
"inputSchema": {
"type": "object",
"properties": {
@@ -4913,12 +4954,14 @@ TOOLS = [
"tag": {"type": "string", "default": "conductor"},
"attribute": {"type": "string", "default": "cable"},
"recursive": {"type": "boolean", "default": True},
"max_qetz": {"type": "integer", "default": 20},
},
"required": ["directory"],
},
"handler": lambda a: tool_scan(a["directory"], a.get("tag", "conductor"),
a.get("attribute", "cable"),
a.get("recursive", True)),
a.get("recursive", True),
a.get("max_qetz", 20)),
},
{
"name": "qet_element_info",
+63 -2
View File
@@ -6489,6 +6489,66 @@ class ZippedProjectWithoutBinary(unittest.TestCase):
m.tool_project_info(str(z))
self.assertIn("QET_BINARY", str(caught.exception))
QET = '<project><diagram><conductors><conductor cable="W1"/></conductors></diagram></project>'
def fake_resave(self, ok=True, timed_out=False):
"""A _run_qet that writes a small .qet where --resave was asked to"""
def run(binary, args, timeout=180, **kw):
Path(args[2]).write_text(self.QET)
if timed_out:
return {"ok": False, "timed_out": True, "timeout_s": timeout}
return {"ok": ok, "exit_code": 0 if ok else 1, "stderr": "", "stdout": ""}
return run
def qetz_files(self, tmp, n):
files = []
for i in range(n):
z = Path(tmp) / f"p{i}.qetz"
z.write_bytes(b"PK")
files.append(z)
return files
def test_a_failed_or_timed_out_conversion_is_not_kept(self):
"""A .qet left behind by a run that did not finish is not read"""
for kind in ({"ok": False}, {"timed_out": True}):
with self.subTest(**kind), tempfile.TemporaryDirectory() as tmp:
m._forget_qetz_copies()
z, = self.qetz_files(tmp, 1)
with mock.patch.object(m, "resolve_binary", return_value="qet"), \
mock.patch.object(m, "_run_qet", side_effect=self.fake_resave(**kind)):
with self.assertRaises(ValueError):
m._as_qet(z)
self.assertEqual(m._QETZ_COPIES, {})
def test_only_the_newest_conversions_are_kept_on_disk(self):
with tempfile.TemporaryDirectory() as tmp:
m._forget_qetz_copies()
files = self.qetz_files(tmp, m._QETZ_COPIES_MAX + 2)
with mock.patch.object(m, "resolve_binary", return_value="qet"), \
mock.patch.object(m, "_run_qet", side_effect=self.fake_resave()):
copies = [m._as_qet(z) for z in files]
self.assertEqual(len(m._QETZ_COPIES), m._QETZ_COPIES_MAX)
self.assertFalse(copies[0].parent.exists())
self.assertTrue(copies[-1].is_file())
# A new version of a file replaces its old copy
os.utime(files[-1], ns=(1, 1))
newer = m._as_qet(files[-1])
self.assertFalse(copies[-1].parent.exists())
m._forget_qetz_copies()
self.assertFalse(newer.parent.exists())
def test_scan_reads_at_most_max_qetz(self):
with tempfile.TemporaryDirectory() as tmp:
m._forget_qetz_copies()
self.qetz_files(tmp, 3)
with mock.patch.object(m, "resolve_binary", return_value="qet"), \
mock.patch.object(m, "_run_qet", side_effect=self.fake_resave()) as run:
scan = m.tool_scan(tmp, max_qetz=2)
m._forget_qetz_copies()
self.assertEqual(run.call_count, 2)
self.assertEqual((scan["total"], scan["non_empty"]), (2, 2))
self.assertEqual(scan["skipped_qetz"], ["p2.qetz"])
@needs_examples
class ZippedProjects(unittest.TestCase):
@@ -6561,8 +6621,9 @@ class ZippedProjects(unittest.TestCase):
self.assertTrue(r.get("ok"), r)
self.assertEqual(out.read_bytes()[:2], b"PK")
# The rotation is what the diff of the two .qetz finds
diff = json.dumps(m.tool_diff(str(self.qetz), str(out)))
self.assertIn(m._uuid_key(first["uuid"]) if hasattr(m, "_uuid_key") else first["uuid"], diff.lower())
rotated = m.tool_diff(str(self.qetz), str(out))["elements"]["rotated"]
self.assertEqual([r["uuid"] for r in rotated], [first["uuid"]])
self.assertNotEqual(*rotated[0]["orientation"])
if __name__ == "__main__":
+4
View File
@@ -69,6 +69,10 @@ set reg_file=qet_install_file_associations.reg
echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command]
echo @="\"%final_qet_exe%\" \"%%1\""
rem *.qetz (zipped project): the same file type as *.qet
echo [HKEY_CURRENT_USER\Software\Classes\.qetz]
echo @="qet_diagram_file"
rem association de fichier *.elmt
echo [HKEY_CURRENT_USER\Software\Classes\.elmt]
echo @="qet_element_file"
+7
View File
@@ -1034,6 +1034,7 @@ bool TerminalStrip::fromXml(QDomElement &xml_element)
for (auto &xml_real : QETXML::findInDomElement(xml_physical, RealTerminal::xmlTagName()))
{
const auto uuid_ = QUuid(xml_real.attribute(QStringLiteral("element_uuid")));
bool found = false;
for (auto terminal_elmt : std::as_const(free_terminals))
{
if (terminal_elmt->uuid() == uuid_)
@@ -1042,9 +1043,15 @@ bool TerminalStrip::fromXml(QDomElement &xml_element)
//Remove the actual terminal element from the vector, they dicrease the size
//of the vector and so each iteration have less terminal element to check
free_terminals.removeOne(terminal_elmt);
found = true;
break;
}
}
if (!found) {
qWarning() << "TerminalStrip::fromXml: strip" << name()
<< "lists terminal" << uuid_.toString()
<< "but no free terminal symbol has this uuid; it is left out";
}
}
if (!real_t_vector.isEmpty()) {
+1 -1
View File
@@ -46,7 +46,7 @@ The code is `qetcontainer.cpp` (the zip and its files), `qetcontainerdb.cpp`
| Entry | Content |
|---|---|
| `mimetype` | `application/x-qelectrotech-project+zip`, the first entry, stored uncompressed |
| `mimetype` | `application/x-qet-project+zip`, the first entry, stored uncompressed (files written before October 2026 say `application/x-qelectrotech-project+zip`; both are read) |
| `manifest.xml` | `<qetz format="4" min-reader="4" writer="QElectroTech …"/>` |
| `project.xml` | the project document, each part replaced by `<qetz-part file="…"/>` |
| `project.sqlite` | the engineering data (section 4) |
+4 -2
View File
@@ -25,7 +25,8 @@
#include <QRegularExpression>
#include <QUuid>
const char QetContainer::MimeType[] = "application/x-qelectrotech-project+zip";
const char QetContainer::MimeType[] = "application/x-qet-project+zip";
const char QetContainer::OldMimeType[] = "application/x-qelectrotech-project+zip";
namespace {
const QString PartTag = QStringLiteral("qetz-part");
@@ -426,7 +427,8 @@ bool QetContainer::join(const QList<QetZip::Entry> &entries, QDomDocument *proje
for (const QetZip::Entry &entry : entries)
files.insert(entry.name, entry.data);
if (files.value(QStringLiteral("mimetype")) != QByteArray(MimeType)) {
const QByteArray mimetype = files.value(QStringLiteral("mimetype"));
if (mimetype != QByteArray(MimeType) && mimetype != QByteArray(OldMimeType)) {
setError(error, QStringLiteral("not a QElectroTech project (.qetz)"));
return false;
}
+2
View File
@@ -53,6 +53,8 @@
namespace QetContainer
{
extern const char MimeType[];
/// The MIME type .qetz files had before it was renamed; still read
extern const char OldMimeType[];
/// Picture bytes by file name, see join()
using Pictures = QHash<QString, QByteArray>;
/// The format written; files of a later format are refused by join()
+1 -1
View File
@@ -1828,7 +1828,7 @@ bool QETDiagramEditor::openProject()
this,
tr("Open a file"),
open_dialog_dir.absolutePath(),
tr("QElectroTech project(*.qet *.qetz);;XML Files (*.xml);;All files (*)")
QETProject::openFileFilter() + ";;" + tr("XML Files (*.xml);;All files (*)")
);
if (filepath.isEmpty()) return(false);
+22 -3
View File
@@ -2654,6 +2654,15 @@ bool QETProject::isContainerPath(const QString &path)
return path.endsWith(QLatin1String(".qetz"), Qt::CaseInsensitive);
}
/**
@brief QETProject::openFileFilter
@return the file-dialog filter for a project to open: .qet and .qetz
*/
QString QETProject::openFileFilter()
{
return tr("QElectroTech project (*.qet *.qetz)");
}
/**
@brief QETProject::writeContainer
Write the project document @p project to @p path as a .qetz: the same
@@ -2721,10 +2730,20 @@ QETResult QETProject::write()
return(QString("unable to save project to file: no filepath was specified"));
//A project read from a .qet is saved as a .qetz beside it; the
//.qet stays as it was (stage D1)
//.qet stays as it was (stage D1). If the .qetz cannot be written
//(a read-only folder), the project stays the .qet it was.
const QString opened_qet = m_opened_from_qet ? m_file_path : QString();
if (m_opened_from_qet) {
setFilePath(besideContainerPath());
}
const auto failed = [this, &opened_qet](const QString &error) {
const QString message = tr("Cannot save %1: %2").arg(m_file_path, error);
if (!opened_qet.isEmpty()) {
setFilePath(opened_qet);
m_opened_from_qet = true;
}
return(QETResult(message));
};
// If the project was opened read-only, only refuse when the target
// really can't be written: an existing file that is not writable, or a
@@ -2746,7 +2765,7 @@ QETResult QETProject::write()
//A container is written from the whole document: every folio
//built (stage D4 writes it from the database)
if (!writeContainerFile(m_file_path, &error_message))
return(error_message);
return(failed(error_message));
}
else
{
@@ -2756,7 +2775,7 @@ QETResult QETProject::write()
QDomDocument xml_project(toXml(&kept_folios));
if (!QET::writeXmlText(spliceKeptFolios(xml_project.toString(4), kept_folios),
m_file_path, &error_message))
return(error_message);
return(failed(error_message));
}
// The project has just been written to a writable file (e.g. saved to
+1
View File
@@ -455,6 +455,7 @@ class QETProject : public QObject
ProjectState openFile(QFile *file);
public:
static bool isContainerPath(const QString &path);
static QString openFileFilter();
/// Saving writes a .qetz: one read from a .qet is saved as
/// a .qetz beside it, the .qet left as it is
/// (stage D1)
+1 -1
View File
@@ -1166,7 +1166,7 @@ void ProjectAutoNumConfigPage::importFromProject()
this,
tr("Import numbering from a project"),
m_project->currentDir(),
tr("Project QElectroTech (*.qet *.qetz)"));
QETProject::openFileFilter());
if (path.isEmpty()) {
return;
}
+2 -2
View File
@@ -17,7 +17,7 @@ class tst_qetzip : public QObject
static QList<QetZip::Entry> sample()
{
QList<QetZip::Entry> entries;
entries.append({QStringLiteral("mimetype"), QByteArray("application/x-qelectrotech-project+zip"), false});
entries.append({QStringLiteral("mimetype"), QByteArray("application/x-qet-project+zip"), false});
QByteArray xml("<project>");
for (int i = 0 ; i < 2000 ; ++i) xml += "<element name=\"K1\"/>";
xml += "</project>";
@@ -65,7 +65,7 @@ private slots:
QCOMPARE(out.at(i).compress, in.at(i).compress);
}
// mimetype first and stored: its bytes appear as is after the header
QCOMPARE(zip.indexOf("application/x-qelectrotech-project+zip"), 30 + int(qstrlen("mimetype")));
QCOMPARE(zip.indexOf("application/x-qet-project+zip"), 30 + int(qstrlen("mimetype")));
}
void sameBytesTwice()