225 lines
9.0 KiB
Python
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
|