Files
orisys-app/apps/studio/tests/test_binding_purity.py
T
2026-09-21 09:34:56 +08:00

225 lines
9.0 KiB
Python

"""Binding policy for the studio.
The studio is **PySide6 only** — Qt's LGPLv3 binding, which is why the app was migrated off
PyQt5 (GPLv3-or-commercial). AutoCal has the same guard in
``apps/autocal/tests/test_binding_purity.py``; this file is the studio's half of it:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and
nothing else, so such an import is a hard start-up failure;
* every UI and 3D module must name the binding it uses rather than inheriting one;
* the PyQt5 signal spellings (``pyqtSignal``, ``pyqtBoundSignal``) do not exist in PySide6;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``, ``QPainter.Antialiasing``);
* importing the application must not drag another binding into ``sys.modules`` — that is
what breaks ``pyqtgraph``, which binds to whichever toolkit it finds first.
Two lessons from the migration are baked in here. The unscoped-enum check asks PySide6
instead of using a hand-written list of enum holders: a curated list missed
``QPainter.RenderHint.Antialiasing``, ``QPainter.CompositionMode.CompositionMode_SourceIn`` and
``QDialogButtonBox.StandardButton.Ok|Cancel|Close``. And the ``sys.modules`` check runs in a **fresh
interpreter**, because in-process the answer depends on what some earlier test imported.
"""
from __future__ import annotations
import ast
import enum
import subprocess
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1] # apps/studio
SRC = APP / "src"
PROJECT_ROOT = APP.parents[1] # orisys-app
#: Everything that ships or drives the app.
SCAN_ROOTS = (SRC, APP / "tools")
#: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed.
ALLOWED_BINDING = "PySide6"
#: Directories that must name their binding explicitly.
#:
#: ``ui/`` only: every widget there imports PySide6. ``scene3d/`` is deliberately mixed —
#: ``coordinator.py``, ``camera.py``, ``adapters.py``, ``prefs.py`` and ``keys.py`` import no
#: Qt binding at all so they can be tested headless, and requiring one would forbid that
#: design. The modules there that *do* use Qt already name PySide6.
BINDING_DECLARING_DIRS = (SRC / "orisys_studio" / "ui",)
#: Names that only ever existed in PyQt. Quoted here on purpose — the migration scripts that
#: do the rewriting skip this file, because a text-based rule cannot tell a forbidden spelling
#: in code from one named in the policy that forbids it.
PYQT_ONLY_NAMES = ("pyqtSignal", "pyqtBoundSignal", "pyqtSlot", "pyqtProperty")
_SKIP_PARTS = ("__pycache__",)
def _python_files(root: Path) -> list[Path]:
return sorted(
path
for path in root.rglob("*.py")
if not any(part in path.parts for part in _SKIP_PARTS)
)
def _read(path: Path) -> str:
# One file in the workspace carries a UTF-8 BOM; `utf-8-sig` keeps `ast` happy either way.
return path.read_text(encoding="utf-8-sig", errors="replace")
def _imported_packages(path: Path) -> set[str]:
"""Every top-level package name imported by ``path``."""
found: set[str] = set()
for node in ast.walk(ast.parse(_read(path))):
if isinstance(node, ast.Import):
found.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
found.add(node.module.split(".")[0])
return found
def _qt_namespace() -> dict[str, object]:
"""The Qt classes the sources could name, resolved through the binding itself."""
from PySide6 import QtCore, QtGui, QtOpenGLWidgets, QtWidgets
namespace: dict[str, object] = {"Qt": QtCore.Qt}
return namespace, (QtCore, QtGui, QtWidgets, QtOpenGLWidgets)
def _unscoped_enums(path: Path, namespace: dict[str, object], modules: tuple) -> list[str]:
"""Attribute reads whose value resolves to an enum instance rather than an enum class.
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute of an enum class, so the outer
``Qt.AlignmentFlag`` resolves to a *class* and the inner one is not a plain name — neither
is a candidate. ``QMessageBox.information`` resolves to a method. ``Qt.AlignmentFlag.AlignLeft``
resolves to an enum instance, which is the spelling this test exists to reject.
"""
tree = ast.parse(_read(path))
called = {
id(node.func)
for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
}
inner = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
found: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
continue
if id(node) in called or id(node) in inner:
continue
holder, member = node.value.id, node.attr
target_holder = namespace.get(holder)
if target_holder is None:
for module in modules:
if hasattr(module, holder):
target_holder = namespace[holder] = getattr(module, holder)
break
else:
continue
value = getattr(target_holder, member, None)
if isinstance(value, (enum.Enum, enum.Flag)) and member not in namespace:
found.add(f"{holder}.{member}")
return sorted(found)
def test_no_studio_module_imports_a_forbidden_binding():
offenders = []
for root in SCAN_ROOTS:
for path in _python_files(root):
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
if bad:
offenders.append(f"{path.relative_to(APP)}: {sorted(bad)}")
assert not offenders, (
f"the studio is {ALLOWED_BINDING}-only; these modules import another binding: "
f"{offenders}"
)
def test_every_ui_module_imports_the_allowed_binding():
"""A widget module that names no binding would be using one it does not declare."""
offenders = []
for directory in BINDING_DECLARING_DIRS:
for path in _python_files(directory):
if path.name == "__init__.py":
continue # a re-export barrel, not a widget module
if ALLOWED_BINDING not in _imported_packages(path):
offenders.append(str(path.relative_to(APP)))
assert not offenders, f"these modules do not import {ALLOWED_BINDING}: {offenders}"
def test_no_pyqt_only_spelling_survives():
offenders = []
for root in SCAN_ROOTS:
for path in _python_files(root):
text = _read(path)
for name in PYQT_ONLY_NAMES:
if name in text:
offenders.append(f"{path.relative_to(APP)}: {name}")
assert not offenders, f"these PyQt-only names do not exist in PySide6: {offenders}"
def test_no_module_uses_an_unscoped_enum():
try:
import PySide6 # noqa: F401
except ImportError: # pragma: no cover - depends on the machine
import pytest
pytest.skip("PySide6 is not installed here, so the enum rule cannot be checked")
namespace, modules = _qt_namespace()
offenders = {}
for root in SCAN_ROOTS:
for path in _python_files(root):
found = _unscoped_enums(path, namespace, modules)
if found:
offenders[str(path.relative_to(APP))] = found
assert not offenders, (
"unscoped Qt enumerators are the spelling PySide6 is removing; use the scoped "
f"form: {offenders}"
)
def test_importing_the_application_pulls_in_no_other_binding():
"""Fresh interpreter: PySide6 only, whatever this test session imported before."""
import os
script = (
"import sys;"
"import orisys_studio.app_window;"
"import orisys_studio.scene3d.view;"
"import orisys_studio.ui.force_plots;"
"print(','.join(m for m in ('PyQt5', 'PyQt6', 'PySide2') if m in sys.modules))"
)
env = dict(os.environ)
entries = [str(PROJECT_ROOT / "shared" / "src"), str(SRC)]
if env.get("PYTHONPATH"):
entries.append(env["PYTHONPATH"])
env["PYTHONPATH"] = os.pathsep.join(entries)
# The app's own start-up contract (``main.py``) and a display-free platform: the point
# is which *binding* comes in, not whether a window can be shown.
env.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
env.setdefault("QT_QPA_PLATFORM", "offscreen")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=180,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
pulled = [name for name in result.stdout.strip().split(",") if name]
assert pulled == [], (
f"importing the app pulled in {pulled}; pyqtgraph binds to whichever toolkit it "
"finds first, so a second binding in the environment breaks the plots"
)
def test_the_app_declares_its_binding_dependency():
"""The packaging must state PySide6, not leave it to chance."""
pyproject = (APP / "pyproject.toml").read_text(encoding="utf-8")
assert ALLOWED_BINDING in pyproject
for forbidden in FORBIDDEN_BINDINGS:
assert f'"{forbidden}' not in pyproject, forbidden