autocal layout first design

This commit is contained in:
ykcheungab
2026-09-20 21:15:39 +08:00
parent 07d7c3bad6
commit e276b5de0f
92 changed files with 18071 additions and 64 deletions
+185
View File
@@ -0,0 +1,185 @@
"""Binding policy for AutoCal.
AutoCal is **PySide6 only**, and this file is the 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/`` — which serves both this app and the PyQt5-based studio — must
import no Qt binding at all;
* 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 an internal
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 the app must never 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.
_SCOPED_ENUMS = {
"Qt": (
"AlignmentFlag",
"ItemDataRole",
"Orientation",
"ConnectionType",
"TextInteractionFlag",
"TransformationMode",
"ToolButtonStyle",
"FocusPolicy",
"ScrollBarPolicy",
),
"QTableWidget": ("SelectionMode", "EditTrigger"),
"QSizePolicy": ("Policy",),
"QHeaderView": ("ResizeMode",),
"QMessageBox": ("StandardButton", "Icon"),
"QDialog": ("DialogCode",),
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
}
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 scoped spelling gets
*explained*), 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_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_qt_binding():
"""Shared code serves both products, so it must not pick a side."""
offenders = []
for path in _python_files(SHARED_SRC):
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, (
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
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