feat: make test-ui checks the UI files with blueprint-compiler
The interface is hand-written GtkBuilder XML and nothing in the build can see a mistake in it: a
property a class does not have, or a child a container does not accept, is a Gtk-ERROR when the
window is built -- and Gtk-ERROR aborts the process, so a typo in a .ui takes the application down
rather than failing to open a dialog. xml-stripblanks catches malformed XML and nothing else, and
the probes catch the rest only for the windows they open. Three of those aborts happened during
this refactor.
tools/check_ui.py round-trips each file through blueprint-compiler: decompile to blueprint, then
compile that, which type-checks every property and child type against the GIR. The XML stays the
source of truth and stays hand-written -- this is a check, not a compiler, and nothing here is
needed to build the application. Which is why the target is opt-in (no ALL) and the tool is looked
for at configure time rather than required: without it the target is absent, not silently passing.
$ make -C build test-ui
check_ui: 5 files in src/ui
ok help.ui ... source-form.ui
check_ui: 5 ok, 0 failed
Injected the exact mistake that cost a run earlier (an AdwEntryRow subtitle) and the check caught
it, with the caret and the "did you mean title?" hint, exiting non-zero. --lint prints the linter's
advice separately: it is advice, and the linter has opinions this project does not share.
This commit is contained in:
1 parent
8073e7b3fd
commit
7a982da6c4
4 files changed
+190
-17
No files matched your search
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MIT
|
||||
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
"""Checks the interface files: `make test-ui`.
|
||||
|
||||
The interface is hand-written GtkBuilder XML, and nothing in the build can see a mistake in it. A
|
||||
property a class does not have, or a child a container does not accept, is a `Gtk-ERROR` when the
|
||||
window is built — and `Gtk-ERROR` aborts the process, so a typo in a `.ui` takes the application
|
||||
down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches malformed XML and
|
||||
nothing else, and the probes catch the rest only for the windows they open.
|
||||
|
||||
blueprint-compiler can see it: it type-checks every property and child type against the GIR. It reads
|
||||
blueprint rather than XML — but it reads XML too, because `decompile` turns a `.ui` back into
|
||||
blueprint. So the check is a round trip, and the XML stays the source of truth:
|
||||
|
||||
$ make test-ui
|
||||
check_ui: 5 files in src/ui
|
||||
ok help.ui
|
||||
ok main-window.ui
|
||||
ok preferences.ui
|
||||
ok shortcuts.ui
|
||||
ok source-form.ui
|
||||
check_ui: 5 ok, 0 failed
|
||||
check_ui: 23 pieces of advice — not failures; --lint shows them
|
||||
|
||||
A file that fails prints what the compiler said, with the line, the caret and its suggestion:
|
||||
|
||||
FAIL preferences.ui
|
||||
error: Class Adw.EntryRow does not have a property called subtitle
|
||||
at preferences.blp line 24 column 9:
|
||||
24 | subtitle: "empty = unicast";
|
||||
| ^^^^^^^^
|
||||
hint: Did you mean `title`?
|
||||
|
||||
`lint` runs too and its findings are printed as advice rather than failures: a linter has opinions
|
||||
this project does not have to share (it wants "udpscope" marked translatable, which it is not), and a
|
||||
check that fails on advice is a check people stop running.
|
||||
|
||||
A missing `blueprint-compiler` is a *failure* and not a skip. This target exists to be run, and
|
||||
answering "fine" without having looked is the one answer it must not give.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TOOL = "blueprint-compiler"
|
||||
|
||||
|
||||
def check(tool: str, ui: pathlib.Path, scratch: pathlib.Path) -> tuple[bool, list[str]]:
|
||||
"""Round-trips one `.ui` through blueprint and returns (ok, advice)."""
|
||||
blp = scratch / (ui.stem + ".blp")
|
||||
|
||||
# `decompile` only transcribes: it is the *compile* of its output that checks the properties, so
|
||||
# both exit codes matter -- a file the decompiler cannot read at all is just as broken.
|
||||
round_trip = subprocess.run(
|
||||
[tool, "decompile", str(ui), "--output", str(blp)], capture_output=True, text=True
|
||||
)
|
||||
if round_trip.returncode != 0 or not blp.exists():
|
||||
return False, [line for line in round_trip.stderr.splitlines() if line]
|
||||
|
||||
compiled = subprocess.run(
|
||||
[tool, "compile", str(blp)], capture_output=True, text=True
|
||||
)
|
||||
if compiled.returncode != 0:
|
||||
return False, [line for line in compiled.stderr.splitlines() if line]
|
||||
|
||||
linted = subprocess.run([tool, "lint", str(blp)], capture_output=True, text=True)
|
||||
advice = [line for line in linted.stderr.splitlines() if line]
|
||||
return True, advice
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("directory", nargs="?", default="src/ui",
|
||||
help="where the .ui files are (default: src/ui)")
|
||||
parser.add_argument("--lint", action="store_true",
|
||||
help="print the linter's advice as well as the failures")
|
||||
args = parser.parse_args()
|
||||
|
||||
tool = shutil.which(TOOL)
|
||||
if tool is None:
|
||||
print(f"check_ui: {TOOL} is not installed, so the UI files were NOT checked", file=sys.stderr)
|
||||
print(" install it (Arch: pacman -S blueprint-compiler) and run this again",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
directory = pathlib.Path(args.directory)
|
||||
files = sorted(directory.glob("*.ui"))
|
||||
if not files:
|
||||
print(f"check_ui: no .ui files in {directory}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"check_ui: {len(files)} files in {directory}")
|
||||
|
||||
failed = 0
|
||||
advice_count = 0
|
||||
with tempfile.TemporaryDirectory(prefix="check-ui-") as scratch_name:
|
||||
scratch = pathlib.Path(scratch_name)
|
||||
for ui in files:
|
||||
ok, messages = check(tool, ui, scratch)
|
||||
print(f" {'ok ' if ok else 'FAIL'} {ui.name}")
|
||||
if ok:
|
||||
advice_count += len([m for m in messages if m.startswith("warning:")])
|
||||
else:
|
||||
failed += 1
|
||||
# A failure is always printed; advice only when it was asked for. Twenty lines of opinion
|
||||
# on every run is how a check's output stops being read.
|
||||
if not ok or args.lint:
|
||||
for line in messages:
|
||||
print(f" {line}")
|
||||
|
||||
print(f"check_ui: {len(files) - failed} ok, {failed} failed")
|
||||
if advice_count:
|
||||
print(f"check_ui: {advice_count} pieces of advice — not failures; --lint shows them")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user