"""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