"""Binding policy for the Orisys apps. Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that: * 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; * no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6 removed in favour of ``Qt.AlignmentFlag.AlignLeft``; * ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it must not pull in a second binding, and the studio's own guard (``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its side. (Until the studio moved to PySide6 this file forbade *any* binding in ``shared/``; that rule existed for a two-binding workspace, which no longer exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and under the old rule those imports were failing silently instead.) * the controller layer must stay importable from a headless process. The reason the binding matters at all is licensing, not taste: PyQt5's free option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool needs no licence purchase. See ``README.md``. """ from __future__ import annotations import ast from pathlib import Path APP_SRC = Path(__file__).resolve().parents[1] / "src" SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src" #: Bindings no module may import. FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"} #: The one binding allowed, and where it may be named. ALLOWED_BINDING = "PySide6" #: Scoped enum holders we care about, with the value names actually used. #: #: Hand-maintained, and therefore incomplete by construction: the PySide6 migration #: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list #: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check), #: which is the better rule; this map is kept because AutoCal cannot import the binding #: in a headless test run. _SCOPED_ENUMS = { "Qt": ( "AlignmentFlag", "ItemDataRole", "Orientation", "ConnectionType", "TextInteractionFlag", "TransformationMode", "ToolButtonStyle", "FocusPolicy", "ScrollBarPolicy", "CursorShape", "MouseButton", "Key", "PenStyle", "GlobalColor", "ItemFlag", "TextElideMode", "TextFlag", "AspectRatioMode", "WindowType", ), "QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"), "QSizePolicy": ("Policy",), "QHeaderView": ("ResizeMode",), "QMessageBox": ("StandardButton", "Icon"), "QDialog": ("DialogCode",), "QAbstractItemView": ("SelectionMode", "EditTrigger"), "QDialogButtonBox": ("StandardButton", "ButtonRole"), "QPainter": ("RenderHint", "CompositionMode"), "QImage": ("Format",), "QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"), "QFrame": ("Shape",), } def _python_files(root: Path) -> list[Path]: return sorted(path for path in root.rglob("*.py") if "__pycache__" not in path.parts) def _imported_packages(path: Path) -> set[str]: """Every top-level package name imported by ``path``.""" tree = ast.parse(path.read_text(encoding="utf-8")) found: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: found.add(alias.name.split(".")[0]) elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: found.add(node.module.split(".")[0]) return found def _unscoped_enum_references(path: Path) -> list[str]: """Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects. Parsed rather than pattern-matched for two reasons: a regex also hits prose in a comment or docstring (which is exactly where the *unscoped* spelling gets explained, so a rewrite would destroy the explanation — this file quotes it three times, which is why the migration scripts skip it), and ``QMessageBox.information(...)`` looks like an enumerator but is a static method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum class rather than a plain name, so it is never a candidate. """ tree = ast.parse(path.read_text(encoding="utf-8")) called = { id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, 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: continue # a method or function call, not an enumerator holder = node.value.id enum_names = _SCOPED_ENUMS.get(holder) if not enum_names or node.attr in enum_names: continue found.add(f"{holder}.{node.attr}") return sorted(found) def test_no_module_imports_a_forbidden_binding(): offenders = [] for path in _python_files(APP_SRC): bad = _imported_packages(path) & FORBIDDEN_BINDINGS if bad: offenders.append(f"{path.name}: {sorted(bad)}") assert not offenders, ( f"AutoCal is {ALLOWED_BINDING}-only; these modules import another " f"binding: {offenders}" ) def test_every_ui_module_imports_the_allowed_binding(): """A UI file that names no binding would be using one it does not declare.""" offenders = [] for path in _python_files(APP_SRC / "orisys_autocal" / "ui"): if path.name == "__init__.py": continue # a re-export barrel, not a widget module if ALLOWED_BINDING not in _imported_packages(path): offenders.append(path.name) assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}" def test_no_module_uses_an_unscoped_enum(): offenders = [] for path in _python_files(APP_SRC): references = _unscoped_enum_references(path) if references: offenders.append(f"{path.name}: {references}") assert not offenders, ( "unscoped Qt enumerators were removed in PySide6; use the scoped " f"spelling: {offenders}" ) def test_window_type_guard_accepts_scoped_values_but_rejects_legacy_values(tmp_path): path = tmp_path / "window_flags.py" path.write_text("flag = Qt.WindowType.Widget\nlegacy = Qt.Widget\n", encoding="utf-8") assert _unscoped_enum_references(path) == ["Qt.Widget"] def test_the_controller_interface_is_qt_free(): """The controller and its guards stay importable from a headless process. ``poller`` is deliberately absent from this check: it *is* the Qt worker bridge (``QThread``, signals), and it lives beside the service precisely so that the service, the transports, the models and the error map do not need a GUI to be exercised. """ qt_free = ("models.py", "errors.py", "units.py", "service.py", "transport.py") offenders = [] for path in _python_files(APP_SRC / "orisys_autocal" / "motion"): if path.name not in qt_free: continue qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"} if qt_imports: offenders.append(f"{path.name}: {sorted(qt_imports)}") assert not offenders, offenders def test_shared_imports_no_forbidden_binding(): """Shared code serves both products, so it must not pull in a second binding. PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to import in a PySide6-only environment or — worse — work in one app's environment and not the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and the PyQt5 import in ``qss.py`` was failing silently as a result. """ offenders = [] for path in _python_files(SHARED_SRC): qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS if qt_imports: offenders.append(f"{path.name}: {sorted(qt_imports)}") assert not offenders, ( "shared code may import PySide6 only; these files import another binding: " f"{offenders}" ) def test_zmotion_client_imports_no_qt_binding(): zmotion = SHARED_SRC / "orisys_app_shared" / "zmotion" offenders = [] for path in _python_files(zmotion): qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"} if qt_imports: offenders.append(f"{path.name}: {sorted(qt_imports)}") assert not offenders, offenders def test_the_app_declares_its_binding_dependency(): """The packaging must state PySide6, not leave it to chance.""" pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text( encoding="utf-8" ) assert ALLOWED_BINDING in pyproject for forbidden in FORBIDDEN_BINDINGS: assert f'"{forbidden}' not in pyproject, forbidden