Files
orisys-app/apps/autocal/tests/test_binding_purity.py
T
ykcheungab 06e06126ca 重构 Autocal 采集页:单一队列与控制器驱动的主按钮
选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
2026-10-18 16:04:32 +08:00

240 lines
9.8 KiB
Python

"""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"
#: ``ui/`` modules that deliberately name no binding. ``reveal.py`` hands a path
#: to the OS file manager; ``site_export.py`` writes the queue's CSV point sheet.
#: Neither builds a widget, so neither has a binding to declare. The rule below
#: exists so a UI module cannot *use* a binding it does not name; a module that
#: uses none cannot break it, and importing PySide6 only to satisfy the rule would
#: be the false declaration the rule is meant to prevent.
_BINDING_FREE_UI = {"reveal.py", "site_export.py"}
#: 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",
# ``Qt.WidgetAttribute.WA_DeleteOnClose`` is the scoped spelling too; the
# class simply was not needed before the video review dialog.
"WidgetAttribute",
),
"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 path.name in _BINDING_FREE_UI:
continue
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