d06501d7d5
补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。
227 lines
9.0 KiB
Python
227 lines
9.0 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"
|
|
|
|
#: 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
|