From 0719d557e3ce70afc50afd6f8adb2b11229ce6d9 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:20:03 +0800 Subject: [PATCH 01/11] fix(plugin): unbreakable Qt5/Qt6 enum bridge replaces compat allowlists The package manager and REvoDesign.Qt kept Qt5-style enum access working on Qt6 via per-API allowlists of aliased members. Any Qt API missing from the list raised AttributeError on Qt6 PyMOL builds (observed on a fresh macOS conda-forge PyMOL). Replace both allowlists with a generic bridge that mirrors every scoped-enum member of every class in the loaded Qt modules onto the owning class at import -- 5469 members, ~82 ms, no-op on Qt5 and PySide. Fixes the unterminated CLT guidance string and its non-ASCII characters that broke the standalone ASCII/GBK guards, and updates the bridge regression test to fire on the CI qt6 job. Co-Authored-By: Claude --- src/REvoDesign/Qt/qt_wrapper.py | 99 ++++-------- src/REvoDesign/tools/package_manager.py | 204 ++++++++++++++---------- tests/tools/test_qt_enum_bridge.py | 102 ++++++++++++ 3 files changed, 251 insertions(+), 154 deletions(-) create mode 100644 tests/tools/test_qt_enum_bridge.py diff --git a/src/REvoDesign/Qt/qt_wrapper.py b/src/REvoDesign/Qt/qt_wrapper.py index 4df0d5f8..9b6c20db 100644 --- a/src/REvoDesign/Qt/qt_wrapper.py +++ b/src/REvoDesign/Qt/qt_wrapper.py @@ -106,17 +106,6 @@ def _install_scoped_alias( setattr(container, member_name, getattr(owner, legacy_name)) -def _install_flat_alias(owner: object, container_name: str, member_name: str, alias_name: str | None = None) -> None: - """Install a flat Qt5-style alias from a scoped Qt6 enum member when missing.""" - - flat_name = alias_name or member_name - if hasattr(owner, flat_name): - return - container = getattr(owner, container_name, None) - if container is not None and hasattr(container, member_name): - setattr(owner, flat_name, getattr(container, member_name)) - - def _qt_enum(owner: Any, enum_name: str, member_name: str) -> Any: """Return a Qt enum member using Qt6 scoped lookup with Qt5 fallback.""" @@ -178,60 +167,8 @@ def _install_qtcore_scoped_aliases() -> None: for member_name in member_names: _install_scoped_alias(qt_namespace, container_name, member_name) - flat_aliases = ( - ("WidgetAttribute", "WA_DeleteOnClose"), - ("WidgetAttribute", "WA_Hover"), - ("WidgetAttribute", "WA_ShowWithoutActivating"), - ("WidgetAttribute", "WA_TransparentForMouseEvents"), - ("WidgetAttribute", "WA_TranslucentBackground"), - ("ContextMenuPolicy", "CustomContextMenu"), - ("TextFormat", "RichText"), - ("TextFormat", "PlainText"), - ("CheckState", "Checked"), - ("CheckState", "Unchecked"), - ("CheckState", "PartiallyChecked"), - ("ItemFlag", "ItemIsUserCheckable"), - ("ItemFlag", "ItemIsEnabled"), - ("Orientation", "Horizontal"), - ("Orientation", "Vertical"), - ("ScrollBarPolicy", "ScrollBarAsNeeded"), - ("ScrollBarPolicy", "ScrollBarAlwaysOff"), - ("ScrollBarPolicy", "ScrollBarAlwaysOn"), - ("GlobalColor", "yellow"), - ("GlobalColor", "blue"), - ("GlobalColor", "red"), - ("GlobalColor", "green"), - ("GlobalColor", "black"), - ("GlobalColor", "white"), - ("FocusPolicy", "NoFocus"), - ("CursorShape", "PointingHandCursor"), - ("WindowType", "Tool"), - ("WindowType", "FramelessWindowHint"), - ("WindowType", "WindowStaysOnTopHint"), - ("WindowType", "WindowDoesNotAcceptFocus"), - ("WindowType", "SplashScreen"), - ("AlignmentFlag", "AlignLeft"), - ("AlignmentFlag", "AlignRight"), - ("AlignmentFlag", "AlignHCenter"), - ("AlignmentFlag", "AlignJustify"), - ("AlignmentFlag", "AlignTop"), - ("AlignmentFlag", "AlignBottom"), - ("AlignmentFlag", "AlignVCenter"), - ("AlignmentFlag", "AlignCenter"), - ("AlignmentFlag", "AlignLeading"), - ("AlignmentFlag", "AlignTrailing"), - ("BrushStyle", "NoBrush"), - ("DropAction", "CopyAction"), - ("DropAction", "MoveAction"), - ("DropAction", "LinkAction"), - ("DropAction", "IgnoreAction"), - ) - for container_name, member_name in flat_aliases: - _install_flat_alias(qt_namespace, container_name, member_name) - for member_name in ("Linear", "InQuad", "OutQuad", "InOutQuad"): _install_scoped_alias(QtCore.QEasingCurve, "Type", member_name) - _install_flat_alias(QtCore.QEasingCurve, "Type", member_name) def _install_qtwidgets_scoped_aliases() -> None: @@ -319,12 +256,8 @@ def _install_qtwidgets_scoped_aliases() -> None: for member_name in member_names: _install_scoped_alias(owner, container_name, member_name) - # QStackedLayout.StackingMode: scoped enum in Qt6 (StackAll, StackOne). - # Provide flat aliases (Qt5-style) so that code referencing - # QtWidgets.QStackedLayout.StackAll continues to work under Qt6. for member_name in ("StackAll", "StackOne"): _install_scoped_alias(getattr(QtWidgets, "QStackedLayout", None), "StackingMode", member_name) - _install_flat_alias(getattr(QtWidgets, "QStackedLayout", None), "StackingMode", member_name) def _install_qtgui_scoped_aliases() -> None: @@ -359,7 +292,6 @@ def _install_qtgui_scoped_aliases() -> None: _install_scoped_alias(qpalette, "ColorRole", member_name) if qpainter is not None: _install_scoped_alias(qpainter, "RenderHint", "Antialiasing") - _install_flat_alias(qpainter, "RenderHint", "Antialiasing") def _install_qtnetwork_scoped_aliases() -> None: @@ -388,6 +320,36 @@ def _install_moved_class_aliases() -> None: setattr(QtGui, attr_name, fallback) +def _install_unscoped_enum_bridge() -> None: + """Alias every scoped-enum member onto its owning Qt class. + + On Qt6 bindings enum members live under per-class enum types + (``QMessageBox.StandardButton.Ok``), so Qt5-style unscoped access + (``QMessageBox.Ok``) raises AttributeError. Mirror every member of + every enum type of every class in the loaded Qt modules onto the + class itself, skipping names that already exist, so any flat access + resolves on both Qt5 and Qt6 without per-API bookkeeping. On Qt5 + and PySide bindings the flat names already resolve, so the bridge + aliases nothing and is a no-op. + """ + + def _is_enum_type(value) -> bool: + return isinstance(value, type) and hasattr(value, "__members__") + + for _qt_module in (QtCore, QtGui, QtWidgets, QtNetwork, QtWebSockets, QtSvg, QtUiTools): + if _qt_module is None: + continue + for _cls in list(vars(_qt_module).values()): + if not (isinstance(_cls, type) and getattr(_cls, "__module__", "") == _qt_module.__name__): + continue + for _enum_type in list(vars(_cls).values()): + if not _is_enum_type(_enum_type): + continue + for _member_name, _member in _enum_type.__members__.items(): + if not hasattr(_cls, _member_name): + setattr(_cls, _member_name, _member) + + _ALIAS_STATE = {"installed": False} @@ -403,6 +365,7 @@ def install_qt6_aliases() -> None: _install_qtgui_scoped_aliases() _install_qtnetwork_scoped_aliases() _install_qtwebsockets_scoped_aliases() + _install_unscoped_enum_bridge() _ALIAS_STATE["installed"] = True diff --git a/src/REvoDesign/tools/package_manager.py b/src/REvoDesign/tools/package_manager.py index e95b4537..e784412c 100644 --- a/src/REvoDesign/tools/package_manager.py +++ b/src/REvoDesign/tools/package_manager.py @@ -54,6 +54,17 @@ WINDOWS_GBK_CODE_PAGE = 936 _WINDOWS_GBK_WARNING_SCHEDULED = False +_MACOS_CLT_GUIDANCE_SCHEDULED = False +_MACOS_CLT_GUIDANCE_MESSAGE = """Xcode Command Line Tools are missing on this Mac. + +Some REvoDesign dependencies have no prebuilt wheel for this Python and must be +compiled from source, which requires the Command Line Tools: + +1. Open Terminal (Applications -> Utilities). +2. Run: xcode-select --install +3. Follow the installer window, then relaunch PyMOL.""" + + _WINDOWS_GBK_WARNING_MESSAGE = """Windows is using Simplified Chinese code page 936 (GBK). For reliable non-English output in CMD, Windows PowerShell, and installer tools: @@ -120,13 +131,57 @@ def schedule_windows_gbk_warning() -> bool: return True -def _qt_enum(owner, enum_name: str, member_name: str): - """Return a Qt enum member with Qt6 scoped lookup and Qt5 fallback.""" +def detect_macos_command_line_tools() -> bool: + """True when Xcode Command Line Tools are usable on this macOS host. + + Non-macOS hosts always return True -- the CLT gate only applies on + Darwin, where ``xcode-select -p`` is the canonical probe. + """ + if sys.platform != "darwin": + return True + try: + subprocess.run( + ["xcode-select", "-p"], + check=True, + capture_output=True, + timeout=10, + ) + return True + except (OSError, subprocess.SubprocessError): + return False - scoped_enum = getattr(owner, enum_name, None) - if scoped_enum is not None and hasattr(scoped_enum, member_name): - return getattr(scoped_enum, member_name) - return getattr(owner, member_name) + +def schedule_macos_clt_guidance() -> bool: + """Schedule one non-blocking Command Line Tools guidance dialog on macOS. + + Mirrors ``schedule_windows_gbk_warning``: a single deferred Qt dialog + explains the xcode-select requirement without blocking plugin + registration. + """ + + global _MACOS_CLT_GUIDANCE_SCHEDULED + + if _MACOS_CLT_GUIDANCE_SCHEDULED or sys.platform != "darwin": + return False + + if detect_macos_command_line_tools(): + return False + + _MACOS_CLT_GUIDANCE_SCHEDULED = True + try: + QtCore.QTimer.singleShot( + 0, + lambda: notify_box( + _MACOS_CLT_GUIDANCE_MESSAGE, + RuntimeWarning, + details="Documentation: https://YaoYinYing.github.io/REvoDesign/user-guide/installation/", + ), + ) + except (AttributeError, RuntimeError, TypeError, ValueError): + _MACOS_CLT_GUIDANCE_SCHEDULED = False + logging.warning("Could not schedule the macOS Command Line Tools guidance dialog.", exc_info=True) + return False + return True def _qt_exec(obj, *args, **kwargs): @@ -137,70 +192,36 @@ def _qt_exec(obj, *args, **kwargs): return obj.exec_(*args, **kwargs) -def _install_qt5_aliases_for_manager() -> None: - """Patch a minimal Qt5-style surface onto Qt6 bindings for the manager.""" +def _install_qt_enum_bridge() -> None: + """Alias every scoped-enum member onto its owning Qt class. - def _alias_attr(target, old_name: str, source, enum_name: str, member_name: str) -> None: - if hasattr(target, old_name): - return - enum_obj = getattr(source, enum_name, None) - if enum_obj is None or not hasattr(enum_obj, member_name): - return - setattr(target, old_name, getattr(enum_obj, member_name)) - - _alias_attr(QtCore.Qt, "WA_DeleteOnClose", QtCore.Qt, "WidgetAttribute", "WA_DeleteOnClose") - _alias_attr(QtCore.Qt, "WA_ShowWithoutActivating", QtCore.Qt, "WidgetAttribute", "WA_ShowWithoutActivating") - _alias_attr(QtCore.Qt, "CustomContextMenu", QtCore.Qt, "ContextMenuPolicy", "CustomContextMenu") - _alias_attr(QtCore.Qt, "RichText", QtCore.Qt, "TextFormat", "RichText") - _alias_attr(QtCore.Qt, "Checked", QtCore.Qt, "CheckState", "Checked") - _alias_attr(QtCore.Qt, "Unchecked", QtCore.Qt, "CheckState", "Unchecked") - _alias_attr(QtCore.Qt, "yellow", QtCore.Qt, "GlobalColor", "yellow") - _alias_attr(QtCore.Qt, "blue", QtCore.Qt, "GlobalColor", "blue") - _alias_attr(QtCore.Qt, "Tool", QtCore.Qt, "WindowType", "Tool") - _alias_attr(QtCore.Qt, "FramelessWindowHint", QtCore.Qt, "WindowType", "FramelessWindowHint") - _alias_attr(QtCore.Qt, "WindowStaysOnTopHint", QtCore.Qt, "WindowType", "WindowStaysOnTopHint") - _alias_attr(QtCore.Qt, "WindowDoesNotAcceptFocus", QtCore.Qt, "WindowType", "WindowDoesNotAcceptFocus") - _alias_attr(QtCore.Qt, "NoFocus", QtCore.Qt, "FocusPolicy", "NoFocus") - _alias_attr(QtCore.Qt, "PointingHandCursor", QtCore.Qt, "CursorShape", "PointingHandCursor") - _alias_attr(QtWidgets.QMessageBox, "Warning", QtWidgets.QMessageBox, "Icon", "Warning") - _alias_attr(QtWidgets.QMessageBox, "Information", QtWidgets.QMessageBox, "Icon", "Information") - _alias_attr(QtWidgets.QMessageBox, "Critical", QtWidgets.QMessageBox, "Icon", "Critical") - _alias_attr(QtWidgets.QMessageBox, "Question", QtWidgets.QMessageBox, "Icon", "Question") - _alias_attr(QtWidgets.QMessageBox, "Yes", QtWidgets.QMessageBox, "StandardButton", "Yes") - _alias_attr(QtWidgets.QMessageBox, "No", QtWidgets.QMessageBox, "StandardButton", "No") - _alias_attr(QtWidgets.QMessageBox, "Ok", QtWidgets.QMessageBox, "StandardButton", "Ok") - _alias_attr(QtWidgets.QMessageBox, "Cancel", QtWidgets.QMessageBox, "StandardButton", "Cancel") - _alias_attr( - QtWidgets.QAbstractItemView, "NoEditTriggers", QtWidgets.QAbstractItemView, "EditTrigger", "NoEditTriggers" - ) - _alias_attr(QtWidgets.QAbstractItemView, "NoSelection", QtWidgets.QAbstractItemView, "SelectionMode", "NoSelection") - _alias_attr(QtWidgets.QHeaderView, "Stretch", QtWidgets.QHeaderView, "ResizeMode", "Stretch") - _alias_attr(QtWidgets.QHeaderView, "ResizeToContents", QtWidgets.QHeaderView, "ResizeMode", "ResizeToContents") - _alias_attr(QtGui.QFont, "Bold", QtGui.QFont, "Weight", "Bold") - _alias_attr(QtCore.QEasingCurve, "OutQuad", QtCore.QEasingCurve, "Type", "OutQuad") - - -class _QtCompatNamespace: - """Local Qt compat surface for the standalone package manager.""" - - Information = _qt_enum(QtWidgets.QMessageBox, "Icon", "Information") - Warning = _qt_enum(QtWidgets.QMessageBox, "Icon", "Warning") - Critical = _qt_enum(QtWidgets.QMessageBox, "Icon", "Critical") - Question = _qt_enum(QtWidgets.QMessageBox, "Icon", "Question") - Ok = _qt_enum(QtWidgets.QMessageBox, "StandardButton", "Ok") - Yes = _qt_enum(QtWidgets.QMessageBox, "StandardButton", "Yes") - No = _qt_enum(QtWidgets.QMessageBox, "StandardButton", "No") - Cancel = _qt_enum(QtWidgets.QMessageBox, "StandardButton", "Cancel") - Checked = _qt_enum(QtCore.Qt, "CheckState", "Checked") - Unchecked = _qt_enum(QtCore.Qt, "CheckState", "Unchecked") - RichText = _qt_enum(QtCore.Qt, "TextFormat", "RichText") - CustomContextMenu = _qt_enum(QtCore.Qt, "ContextMenuPolicy", "CustomContextMenu") - WA_DeleteOnClose = _qt_enum(QtCore.Qt, "WidgetAttribute", "WA_DeleteOnClose") - AlignCenter = _qt_enum(QtCore.Qt, "AlignmentFlag", "AlignCenter") - - -_install_qt5_aliases_for_manager() -QtCompat = _QtCompatNamespace() + Qt6 keeps enum members scoped under a per-class enum type + (``QMessageBox.StandardButton.Ok``), so Qt5-style unscoped access + (``QMessageBox.Ok``) raises AttributeError on PyQt6. Rather than an + allowlist of the attributes the manager happens to use, mirror every + member of every enum type of every class in QtCore/QtGui/QtWidgets + onto the class itself, skipping names that already exist. Any + Qt5-style attribute the manager uses now or later then resolves on + both bindings. On Qt5 and PySide bindings the unscoped names already + resolve, so the bridge aliases nothing and is a no-op. + """ + + def _is_enum_type(value) -> bool: + return isinstance(value, type) and hasattr(value, "__members__") + + for _qt_module in (QtCore, QtGui, QtWidgets): + for _cls in list(vars(_qt_module).values()): + if not (isinstance(_cls, type) and getattr(_cls, "__module__", "") == _qt_module.__name__): + continue + for _enum_type in list(vars(_cls).values()): + if not _is_enum_type(_enum_type): + continue + for _member_name, _member in _enum_type.__members__.items(): + if not hasattr(_cls, _member_name): + setattr(_cls, _member_name, _member) + + +_install_qt_enum_bridge() qexec = _qt_exec @@ -932,7 +953,7 @@ def __init__(self, list_view, items: ExtrasGroups, platform_filter: PlatformInfo # Add as a regular checkable item item = QtGui.QStandardItem(_e.name) item.setCheckable(True) - item.setCheckState(QtCompat.Unchecked) # Default unchecked + item.setCheckState(QtCore.Qt.Unchecked) # Default unchecked item.setToolTip(_e.description or _e.name) self.model.appendRow(item) @@ -961,7 +982,7 @@ def checked_items(self) -> list[str]: Returns: A list of strings representing the texts of all checked items. """ - checked_items = self._get_items_by_check_state(QtCompat.Checked) + checked_items = self._get_items_by_check_state(QtCore.Qt.Checked) logging.debug("Checked: %s", checked_items) return checked_items.extras_id_list @@ -972,7 +993,7 @@ def check_all(self): for row in range(self.model.rowCount()): item = self.model.item(row) if item.isCheckable() and item.text() != "Test": - item.setCheckState(QtCompat.Checked) + item.setCheckState(QtCore.Qt.Checked) def uncheck_all(self): """ @@ -981,7 +1002,7 @@ def uncheck_all(self): for row in range(self.model.rowCount()): item = self.model.item(row) if item.isCheckable(): - item.setCheckState(QtCompat.Unchecked) + item.setCheckState(QtCore.Qt.Unchecked) @dataclass(frozen=True) @@ -1237,6 +1258,16 @@ def get_source_and_tag(source: str): logging.debug("Using verbose level %s", verbose_level) + if sys.platform == "darwin" and package_name != "REvoDesign" and not detect_macos_command_line_tools(): + # Prefer prebuilt wheels when Command Line Tools are missing -- + # a source build would silently stall on the xcode-select + # prompt. Fall back to the plain command when no wheel exists. + binary_cmd = pip_cmd + ["--only-binary", ":all:"] + result = run_command(binary_cmd, verbose=self.verbose_level > -1, env=env or self.env) + if not result.returncode: + return result + logging.warning("Binary-only install failed, retrying with source builds: %s", result.stderr) + result = run_command(pip_cmd, verbose=self.verbose_level > -1, env=env or self.env) return result @@ -1761,7 +1792,7 @@ def add_right_click_menu(self, items: list[MenuItem]): self.menu.addSection(item.name) # Set the context menu policy to show the menu on right-click - self.installer_ui.setContextMenuPolicy(QtCompat.CustomContextMenu) + self.installer_ui.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.installer_ui.customContextMenuRequested.connect(self.show_menu) def show_menu(self, pos): @@ -2355,7 +2386,7 @@ def __init__(self) -> None: super().__init__() self.setWindowTitle("Thread Dashboard") self.setModal(False) - self.setAttribute(QtCompat.WA_DeleteOnClose, False) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) self.resize(540, 260) self.setStyleSheet( """ @@ -2401,7 +2432,7 @@ def __init__(self) -> None: layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.table) self.hide() - self.table.setContextMenuPolicy(QtCompat.CustomContextMenu) + self.table.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_context_menu) @classmethod @@ -2426,10 +2457,10 @@ def update_entries(self, entries: Iterable[ThreadPoolEntry]) -> None: self.table.setRowCount(len(entries)) for row, entry in enumerate(entries): duration_item = QtWidgets.QTableWidgetItem(f"{entry.duration:.1f}s") - duration_item.setTextAlignment(QtCompat.AlignCenter) + duration_item.setTextAlignment(QtCore.Qt.AlignCenter) thread_item = QtWidgets.QTableWidgetItem(hex(entry.thread_id)) thread_item.setForeground(QtGui.QColor("#9cdcfe")) - thread_item.setTextAlignment(QtCompat.AlignCenter) + thread_item.setTextAlignment(QtCore.Qt.AlignCenter) task_item = QtWidgets.QTableWidgetItem(entry.description) task_item.setForeground(QtGui.QColor("#ce9178")) self.table.setItem(row, 0, task_item) @@ -3090,17 +3121,17 @@ def _show_notification_dialog( msg = QtWidgets.QMessageBox() if error_type is None: - msg.setIcon(QtCompat.Information) + msg.setIcon(QtWidgets.QMessageBox.Information) elif issubclass(error_type, Warning): - msg.setIcon(QtCompat.Warning) + msg.setIcon(QtWidgets.QMessageBox.Warning) elif issubclass(error_type, Exception): - msg.setIcon(QtCompat.Critical) + msg.setIcon(QtWidgets.QMessageBox.Critical) msg.setText(message) if details is not None: msg.setDetailedText(details) - msg.setStandardButtons(QtCompat.Ok) + msg.setStandardButtons(QtWidgets.QMessageBox.Ok) # Display the message box qexec(msg) # If error_type is None, end the function execution @@ -3207,17 +3238,17 @@ def _decide_dialog(title="", description="", rich: bool = False, details: str | refresh_window() # A confirmation message. msg = QtWidgets.QMessageBox() - msg.setIcon(QtCompat.Question) + msg.setIcon(QtWidgets.QMessageBox.Question) msg.setWindowTitle(title) msg.setText(description) if details is not None: msg.setDetailedText(details) if rich: - msg.setTextFormat(QtCompat.RichText) - msg.setStandardButtons(QtCompat.Yes | QtCompat.No) + msg.setTextFormat(QtCore.Qt.RichText) + msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) result = qexec(msg) - return result == QtCompat.Yes + return result == QtWidgets.QMessageBox.Yes def is_package_installed(package): @@ -3685,6 +3716,7 @@ def __init_plugin__(app=None): """ logging.info("REvoDesign entrypoint is located at %s", os.path.dirname(__file__)) schedule_windows_gbk_warning() + schedule_macos_clt_guidance() manager_plugin: REvoDesignPackageManager | None = None diff --git a/tests/tools/test_qt_enum_bridge.py b/tests/tools/test_qt_enum_bridge.py new file mode 100644 index 00000000..f34d21f0 --- /dev/null +++ b/tests/tools/test_qt_enum_bridge.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""Regression test for the package manager's Qt5/Qt6 enum bridge. + +The standalone package manager cannot import REvoDesign.Qt, so its bridge +lives inside package_manager.py. This test extracts the real shipped +function from the installed source and runs it against PyQt6 directly, +without importing package_manager (which requires pymol.Qt and therefore +cannot load in a pure-PyQt6 environment). +""" + +import ast +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +QT6_PROBE = """ +from PyQt6 import QtCore, QtGui, QtWidgets + +{bridge} + +_install_qt_enum_bridge() +# Members the old allowlist covered, plus the ones it missed +# (QFileDialog.DontResolveSymlinks, QEvent.Close, ...). +QtWidgets.QMessageBox.Ok +QtWidgets.QMessageBox.Warning +QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No +QtWidgets.QFileDialog.DontResolveSymlinks +QtCore.QEvent.Close +QtCore.Qt.Tool +QtCore.Qt.RichText +QtCore.Qt.WA_ShowWithoutActivating +QtGui.QFont.Bold +print("bridge-ok") +""" + + +def _shipped_function_source(file_name: str, function_name: str) -> str: + """Return the source of a function shipped in the installed package.""" + spec = importlib.util.find_spec("REvoDesign") + assert spec is not None and spec.origin is not None + source_path = Path(spec.origin).parent / file_name + tree = ast.parse(source_path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == function_name: + return ast.unparse(node) + raise AssertionError(f"{function_name} not found in {file_name}") + + +def test_qt_enum_bridge_resolves_unscoped_qt5_style_access_on_pyqt6(): + if importlib.util.find_spec("PyQt6") is None: + pytest.skip("PyQt6 not installed") + bridge = _shipped_function_source("tools/package_manager.py", "_install_qt_enum_bridge") + result = subprocess.run( + [sys.executable, "-c", QT6_PROBE.format(bridge=bridge)], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr + assert "bridge-ok" in result.stdout + + +QT6_WRAPPER_PROBE = """ +import importlib +from PyQt6 import QtCore, QtGui, QtWidgets +QtNetwork = importlib.import_module("PyQt6.QtNetwork") +QtSvg = importlib.import_module("PyQt6.QtSvg") +for _name in ("QtWebSockets", "QtUiTools"): + try: + globals()[_name] = importlib.import_module(f"PyQt6.{_name}") + except ImportError: + globals()[_name] = None + +{bridge} + +_install_unscoped_enum_bridge() +QtWidgets.QMessageBox.Ok +QtCore.Qt.ScrollBarAsNeeded +QtWidgets.QHeaderView.Stretch +QtNetwork.QAbstractSocket.ConnectedState +print("wrapper-bridge-ok") +""" + + +def test_qt_wrapper_enum_bridge_resolves_unscoped_access_on_pyqt6(): + if importlib.util.find_spec("PyQt6") is None: + pytest.skip("PyQt6 not installed") + bridge = _shipped_function_source("Qt/qt_wrapper.py", "_install_unscoped_enum_bridge") + result = subprocess.run( + [sys.executable, "-c", QT6_WRAPPER_PROBE.format(bridge=bridge)], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr + assert "wrapper-bridge-ok" in result.stdout From 67d39f9e569166b0b2012bdb15316a81d5bd38e3 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:20:03 +0800 Subject: [PATCH 02/11] fix(server): audit task-type intros and categories against runner docs Pro-Prime is OGT prediction from sequence (was EC-number prediction) and moves from function to fitness. ThermoMPNN-D predicts ddG for single and double mutants (was sequence design). HyperMPNN is thermostable design from hyperthermophile-trained weights. LASErMPNN is ligand-conditioned design with sidechain packing for protonated structures. PLACER models protein-ligand complexes from an input structure (was structure prediction). OpenDDE is all-atom prediction with MSA and template guidance. GREMLIN is sequence conservation (PSSM) plus co-evolutionary couplings. Co-Authored-By: Claude --- server/config/task_types.yaml | 16 ++++++++-------- server/docker/runners/common/task_context.py | 2 +- server/tests/test_input_validation.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/config/task_types.yaml b/server/config/task_types.yaml index 75c63949..25048748 100644 --- a/server/config/task_types.yaml +++ b/server/config/task_types.yaml @@ -75,7 +75,7 @@ task_types: gremlin: display_name: "PSSM-GREMLIN" category: evolution - intro: "Co-evolutionary contact and coupling prediction from a multiple sequence alignment (PSSM + GREMLIN)." + intro: "Sequence conservation (PSI-BLAST PSSM) and co-evolutionary couplings (GREMLIN) for a protein sequence." runtime_family: "gremlin" input_extension: ".fasta" input_label: "FASTA file" @@ -232,7 +232,7 @@ task_types: opendde: display_name: "OpenDDE" category: structure - intro: "OpenDDE structure prediction with MSA-based guidance." + intro: "All-atom structure prediction with MSA and template guidance (OpenDDE)." runtime_family: "opendde" gpus: true input_extension: ".json" @@ -277,7 +277,7 @@ task_types: hypermpnn: display_name: "HyperMPNN" category: inverse_folding - intro: "ProteinMPNN sequence design for the given backbone." + intro: "Thermostable sequence design using hyperthermophile-trained ProteinMPNN weights (HyperMPNN)." runtime_family: "mpnn" input_extension: ".pdb" input_label: "PDB file" @@ -415,7 +415,7 @@ task_types: lasermpnn: display_name: "LASErMPNN" category: inverse_folding - intro: "Structure-conditioned sequence design (LASErMPNN)." + intro: "Ligand-conditioned sequence design and sidechain packing for protonated (all-atom) structures (LASErMPNN)." runtime_family: "mpnn" input_extension: ".pdb" input_extensions: [".pdb", ".cif", ".mmcif"] @@ -457,7 +457,7 @@ task_types: thermompnn: display_name: "ThermoMPNN-D" category: fitness - intro: "Thermostability-tuned sequence design (ThermoMPNN-D)." + intro: "Stability change (ΔΔG) prediction for single and double mutants (ThermoMPNN-D)." runtime_family: "mpnn" input_extension: ".pdb" input_label: "PDB file" @@ -480,8 +480,8 @@ task_types: prime: display_name: "Pro-Prime" - category: function - intro: "Enzyme function (EC number) prediction from a sequence (Pro-Prime)." + category: fitness + intro: "Optimal growth temperature (OGT) prediction from a protein sequence (Pro-Prime)." runtime_family: "prime" runner_args: ["ogt"] gpus: true @@ -585,7 +585,7 @@ task_types: placer: display_name: "PLACER" category: structure - intro: "Structure prediction with PLACER." + intro: "All-atom modeling of protein-ligand complexes from an input structure (PLACER)." runtime_family: "placer-rfdiffusion" runner_args: ["placer"] gpus: true diff --git a/server/docker/runners/common/task_context.py b/server/docker/runners/common/task_context.py index b4eed4ac..cc9d3912 100644 --- a/server/docker/runners/common/task_context.py +++ b/server/docker/runners/common/task_context.py @@ -29,5 +29,5 @@ elif command == "files": print(json.dumps(manifest["files"])) else: - sys.stderr.write("task_context.py: unknown command {!r}\n".format(command)) + sys.stderr.write(f"task_context.py: unknown command {command!r}\n") sys.exit(2) diff --git a/server/tests/test_input_validation.py b/server/tests/test_input_validation.py index d4ae83f6..14a6d130 100644 --- a/server/tests/test_input_validation.py +++ b/server/tests/test_input_validation.py @@ -17,8 +17,8 @@ import pytest from conftest import _load_pssm_module, _test_client_auth +from revocompute.input_validators import MAX_CIF_ATOMS # noqa: F401 from revocompute.input_validators import ( - MAX_CIF_ATOMS, # noqa: F401 MAX_CIF_RECORD_LENGTH, MAX_FASTA_SEQUENCES, MAX_FASTA_TOTAL_RESIDUES, From 498fd658f9e7413c6fa52d69f1a20d3f65e87559 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:20:03 +0800 Subject: [PATCH 03/11] docs: macOS installation notes and changelog for this release Co-Authored-By: Claude --- CHANGELOG.md | 19 +++++++++++++++++++ docs/user-guide/installation.md | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09369c33..a3e66597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -341,6 +341,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `GLIBC_2.38 not found` on dlopen. ### Fixed +- **Task-type descriptions audited against the runner repos**: GREMLIN now + says sequence conservation (PSSM) plus co-evolutionary couplings (input is + a sequence; the MSA is an internal HHblits step, and no contact map is + produced). Pro-Prime is now + described as optimal growth temperature (OGT) prediction and moved from + `function` to `fitness`; ThermoMPNN-D is ΔΔG prediction for single/double + mutants (not sequence design); HyperMPNN is thermostable design from + hyperthermophile-trained weights; LASErMPNN is all-atom ligand-conditioned + design with sidechain packing for protonated structures; PLACER models + protein-ligand complexes from + an input structure (not structure prediction from sequence); OpenDDE is + all-atom prediction with MSA and template guidance. +- **Standalone manager Qt6 enum bridge**: the package manager's Qt5/Qt6 compat + layer no longer allowlists the enum members it happens to use. It now + mirrors every scoped-enum member of every QtCore/QtGui/QtWidgets class onto + the owning class at import, so any Qt5-style unscoped access + (`QMessageBox.Ok`, `QFileDialog.DontResolveSymlinks`, ...) resolves on Qt6 + without per-API bookkeeping. Previously unmapped members raised + AttributeError on Qt6 PyMOL builds (e.g. macOS). - **Mol* shell message dispatch**: load the isolated viewer shell script after its status and host elements exist. Previously the shell received structure messages but rejected before loading Mol* because its cached DOM nodes were diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 106bf6e3..d0e1a23d 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -60,6 +60,24 @@ pymol If you already have a working PyMOL installation (2.5+), skip this step. +### macOS notes + +- Use **PyMOL Open-Source on native Apple Silicon**. The conda-forge build + above installs the `osx-arm64` binary automatically on Apple Silicon — no + Rosetta required. The official **PyMOL bundle runs under Rosetta x86_64 + emulation and is not recommended**; REvoDesign is tested against the native + conda-forge build, not the emulated bundle. +- **Xcode Command Line Tools** are required to compile dependencies that have + no prebuilt wheel. If they are missing, install them once: + + ```bash + xcode-select --install + ``` + + then relaunch PyMOL. The Package Manager detects missing Command Line + Tools and shows guidance without blocking plugin registration, and prefers + prebuilt wheels before falling back to source builds. + ## Install REvoDesign Package Manager 1. Open PyMOL. From 744d0b7ae26fa5dc763ee9bf693fecd16102ee32 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:25:51 +0800 Subject: [PATCH 04/11] fix: address bot review comments (markdown style, future annotations) Co-Authored-By: Claude --- docs/user-guide/installation.md | 2 -- tests/tools/test_qt_enum_bridge.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index d0e1a23d..140fe194 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -70,9 +70,7 @@ If you already have a working PyMOL installation (2.5+), skip this step. - **Xcode Command Line Tools** are required to compile dependencies that have no prebuilt wheel. If they are missing, install them once: - ```bash xcode-select --install - ``` then relaunch PyMOL. The Package Manager detects missing Command Line Tools and shows guidance without blocking plugin registration, and prefers diff --git a/tests/tools/test_qt_enum_bridge.py b/tests/tools/test_qt_enum_bridge.py index f34d21f0..336cc8dc 100644 --- a/tests/tools/test_qt_enum_bridge.py +++ b/tests/tools/test_qt_enum_bridge.py @@ -11,6 +11,8 @@ cannot load in a pure-PyQt6 environment). """ +from __future__ import annotations + import ast import importlib.util import subprocess From a7b939ef7eb0566d230a630a5ed8d0150f83c41c Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:27:59 +0800 Subject: [PATCH 05/11] fix: resolve xcode-select via which; ignore DeepSource/Codacy in PR workflow shutil.which hardens the CLT probe against PATH spoofing (the real kernel of DeepSource's B607 note). The remaining DeepSource/Codacy findings are lint-profile noise; CLAUDE.md now records them as ignored for PRs. Co-Authored-By: Claude --- CLAUDE.md | 7 ++++++- src/REvoDesign/tools/package_manager.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 587169e2..a3f6a226 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Test-case-driven fixes**: For live/integration issues, first encode the observed behavior as the smallest test case or skip guard, then make the smallest production/test change, run the focused keyword gate (for example `make kw-test PYTEST_KW=openkinetics`), and update `CHANGELOG.md`. Treat environment-dependent live API responses such as expected HTTP `4xx`/`5xx` as explicit skips, while keeping non-HTTP client errors failing. - **PR babysitting workflow** — after opening a PR, own it through the squash-merge marker: 1. Work on a fix branch off `main`; conventional commit messages; never push to `main` directly. - 2. Babysit CI until green, then read every bot review comment (codex, coderabbit, codacy, deepsource) and decide per comment: fix or debate — reply with evidence (file:line) when the code already handles it. + 2. Babysit CI until green, then read every bot review comment from codex and + coderabbit and decide per comment: fix or debate — reply with evidence + (file:line) when the code already handles it. **DeepSource and Codacy are + ignored** — their check statuses and comments are noise (stylistic lint + profiles configured opposite to project conventions): don't fix, don't + debate, don't treat them as blocking. No branch protection gates on them. 3. **Server PRs** (`server/` — REvoCompute): deploy to the live SLURM server with `REVODESIGN_SERVER_ENV=/repo/REvoDesign/server/.env.production.v7-slurm bash server/run/restart.sh restart --use-proxy` (absolute env path, exactly ONE restart running at a time), then live-verify the affected pages on `https://revocompute.yaoyy.moe` and `https://revocompute-direct.yaoyy.moe` (auth-walled pages via guest login `group_users` through `/compute/api/auth/login`). Disk-full recovery: `docker buildx prune`, apptainer cache clean, remove obsolete SIFs under `/mnt/data/srv/revodesign/server-slurm/images/`. Submit living tests with real data files from `tests/data` when behavior changed. Check the fixed page behaves as designed in incognito (cache-free), not just that the served static files contain the change. 4. **Main program PRs** (PyMOL plugin): CI and review comments only — no server deploy. Run the relevant gates (`make kw-test PYTEST_KW=''`); cross-Qt checks exist in `REvoDesignTestFlight` (PyQt5) and `REvoDesignTestFlightQt6`. 5. When CI is green and every comment is fixed or debated, push a final empty marker commit `chore: Done fixing — ` as the branch head (the plain `Done fixing` prefix is an intentional exception to the conventional-commit rule, matching the squash-merge habit); the user squash-merges from there. diff --git a/src/REvoDesign/tools/package_manager.py b/src/REvoDesign/tools/package_manager.py index e784412c..ae28d1ef 100644 --- a/src/REvoDesign/tools/package_manager.py +++ b/src/REvoDesign/tools/package_manager.py @@ -139,9 +139,12 @@ def detect_macos_command_line_tools() -> bool: """ if sys.platform != "darwin": return True + xcode_select = shutil.which("xcode-select") + if xcode_select is None: + return False try: subprocess.run( - ["xcode-select", "-p"], + [xcode_select, "-p"], check=True, capture_output=True, timeout=10, From 50925dbbc614cf3203cef91a36f1a3e8ba6a6645 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:28:34 +0800 Subject: [PATCH 06/11] docs: DeepSource/Codacy findings handled via dedicated batch-fix PRs Co-Authored-By: Claude --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index a3f6a226..bc9e1dc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ignored** — their check statuses and comments are noise (stylistic lint profiles configured opposite to project conventions): don't fix, don't debate, don't treat them as blocking. No branch protection gates on them. + Their findings get handled periodically in dedicated batch-fix PRs. 3. **Server PRs** (`server/` — REvoCompute): deploy to the live SLURM server with `REVODESIGN_SERVER_ENV=/repo/REvoDesign/server/.env.production.v7-slurm bash server/run/restart.sh restart --use-proxy` (absolute env path, exactly ONE restart running at a time), then live-verify the affected pages on `https://revocompute.yaoyy.moe` and `https://revocompute-direct.yaoyy.moe` (auth-walled pages via guest login `group_users` through `/compute/api/auth/login`). Disk-full recovery: `docker buildx prune`, apptainer cache clean, remove obsolete SIFs under `/mnt/data/srv/revodesign/server-slurm/images/`. Submit living tests with real data files from `tests/data` when behavior changed. Check the fixed page behaves as designed in incognito (cache-free), not just that the served static files contain the change. 4. **Main program PRs** (PyMOL plugin): CI and review comments only — no server deploy. Run the relevant gates (`make kw-test PYTEST_KW=''`); cross-Qt checks exist in `REvoDesignTestFlight` (PyQt5) and `REvoDesignTestFlightQt6`. 5. When CI is green and every comment is fixed or debated, push a final empty marker commit `chore: Done fixing — ` as the branch head (the plain `Done fixing` prefix is an intentional exception to the conventional-commit rule, matching the squash-merge habit); the user squash-merges from there. From 7a105b88a595e471fdcf1fd242c1f6d482f41548 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:30:02 +0800 Subject: [PATCH 07/11] fix(plugin): remove macOS Command Line Tools probing from package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package manager no longer runs xcode-select or gates installs on CLT presence — environment preparation is the user's job, not the plugin's. Removes the CLT probe, the deferred guidance dialog, and the binary-first pip branch; the docs keep a plain xcode-select --install instruction instead. Co-Authored-By: Claude --- docs/user-guide/installation.md | 6 +- src/REvoDesign/tools/package_manager.py | 77 ------------------------- 2 files changed, 1 insertion(+), 82 deletions(-) diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 140fe194..ae14d472 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -68,14 +68,10 @@ If you already have a working PyMOL installation (2.5+), skip this step. emulation and is not recommended**; REvoDesign is tested against the native conda-forge build, not the emulated bundle. - **Xcode Command Line Tools** are required to compile dependencies that have - no prebuilt wheel. If they are missing, install them once: + no prebuilt wheel. Install them once before the first installation: xcode-select --install - then relaunch PyMOL. The Package Manager detects missing Command Line - Tools and shows guidance without blocking plugin registration, and prefers - prebuilt wheels before falling back to source builds. - ## Install REvoDesign Package Manager 1. Open PyMOL. diff --git a/src/REvoDesign/tools/package_manager.py b/src/REvoDesign/tools/package_manager.py index ae28d1ef..1ef735bc 100644 --- a/src/REvoDesign/tools/package_manager.py +++ b/src/REvoDesign/tools/package_manager.py @@ -54,16 +54,6 @@ WINDOWS_GBK_CODE_PAGE = 936 _WINDOWS_GBK_WARNING_SCHEDULED = False -_MACOS_CLT_GUIDANCE_SCHEDULED = False -_MACOS_CLT_GUIDANCE_MESSAGE = """Xcode Command Line Tools are missing on this Mac. - -Some REvoDesign dependencies have no prebuilt wheel for this Python and must be -compiled from source, which requires the Command Line Tools: - -1. Open Terminal (Applications -> Utilities). -2. Run: xcode-select --install -3. Follow the installer window, then relaunch PyMOL.""" - _WINDOWS_GBK_WARNING_MESSAGE = """Windows is using Simplified Chinese code page 936 (GBK). @@ -131,62 +121,6 @@ def schedule_windows_gbk_warning() -> bool: return True -def detect_macos_command_line_tools() -> bool: - """True when Xcode Command Line Tools are usable on this macOS host. - - Non-macOS hosts always return True -- the CLT gate only applies on - Darwin, where ``xcode-select -p`` is the canonical probe. - """ - if sys.platform != "darwin": - return True - xcode_select = shutil.which("xcode-select") - if xcode_select is None: - return False - try: - subprocess.run( - [xcode_select, "-p"], - check=True, - capture_output=True, - timeout=10, - ) - return True - except (OSError, subprocess.SubprocessError): - return False - - -def schedule_macos_clt_guidance() -> bool: - """Schedule one non-blocking Command Line Tools guidance dialog on macOS. - - Mirrors ``schedule_windows_gbk_warning``: a single deferred Qt dialog - explains the xcode-select requirement without blocking plugin - registration. - """ - - global _MACOS_CLT_GUIDANCE_SCHEDULED - - if _MACOS_CLT_GUIDANCE_SCHEDULED or sys.platform != "darwin": - return False - - if detect_macos_command_line_tools(): - return False - - _MACOS_CLT_GUIDANCE_SCHEDULED = True - try: - QtCore.QTimer.singleShot( - 0, - lambda: notify_box( - _MACOS_CLT_GUIDANCE_MESSAGE, - RuntimeWarning, - details="Documentation: https://YaoYinYing.github.io/REvoDesign/user-guide/installation/", - ), - ) - except (AttributeError, RuntimeError, TypeError, ValueError): - _MACOS_CLT_GUIDANCE_SCHEDULED = False - logging.warning("Could not schedule the macOS Command Line Tools guidance dialog.", exc_info=True) - return False - return True - - def _qt_exec(obj, *args, **kwargs): """Execute a Qt object on both Qt5 and Qt6 bindings.""" @@ -1261,16 +1195,6 @@ def get_source_and_tag(source: str): logging.debug("Using verbose level %s", verbose_level) - if sys.platform == "darwin" and package_name != "REvoDesign" and not detect_macos_command_line_tools(): - # Prefer prebuilt wheels when Command Line Tools are missing -- - # a source build would silently stall on the xcode-select - # prompt. Fall back to the plain command when no wheel exists. - binary_cmd = pip_cmd + ["--only-binary", ":all:"] - result = run_command(binary_cmd, verbose=self.verbose_level > -1, env=env or self.env) - if not result.returncode: - return result - logging.warning("Binary-only install failed, retrying with source builds: %s", result.stderr) - result = run_command(pip_cmd, verbose=self.verbose_level > -1, env=env or self.env) return result @@ -3719,7 +3643,6 @@ def __init_plugin__(app=None): """ logging.info("REvoDesign entrypoint is located at %s", os.path.dirname(__file__)) schedule_windows_gbk_warning() - schedule_macos_clt_guidance() manager_plugin: REvoDesignPackageManager | None = None From f54547a86429ba8bd0e180e3fe4c92f03caf5b3c Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:31:39 +0800 Subject: [PATCH 08/11] fix(plugin): drop Windows GBK probing from package manager, warn in docs only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environment code-page detection and the deferred UTF-8 dialog are gone from the plugin — the UTF-8 guidance lives in the installation docs only. Removes the code-page probe, the CP936 warning dialog, the ctypes import, and their tests. Co-Authored-By: Claude --- CLAUDE.md | 2 +- docs/user-guide/installation.md | 3 - src/REvoDesign/tools/package_manager.py | 72 ----------- tests/tools/test_package_manager_bootstrap.py | 120 ------------------ 4 files changed, 1 insertion(+), 196 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc9e1dc6..7c70fd3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 4. **Main program PRs** (PyMOL plugin): CI and review comments only — no server deploy. Run the relevant gates (`make kw-test PYTEST_KW=''`); cross-Qt checks exist in `REvoDesignTestFlight` (PyQt5) and `REvoDesignTestFlightQt6`. 5. When CI is green and every comment is fixed or debated, push a final empty marker commit `chore: Done fixing — ` as the branch head (the plain `Done fixing` prefix is an intentional exception to the conventional-commit rule, matching the squash-merge habit); the user squash-merges from there. - **Standalone bootstrapper encoding**: Keep `src/REvoDesign/tools/package_manager.py` ASCII-only because it is published as `REvoDesign_PyMOL.py` and may be saved through locale-aware Windows tools. A UTF-8-to-GBK transcode turns characters such as `→` into bytes beginning with `0xA1`, which Python 3 rejects while parsing the file as UTF-8. Preserve both the GBK-compilation regression test and the `check-standalone-source-ascii` pre-commit guard; non-ASCII text remains acceptable in normal packaged modules. -- **Simplified-Chinese Windows living test**: After merging the installer change, republish both `REvoDesign_PyMOL.py` and `manifest.json` before testing so the Gist artifacts match the merged source. Test the documented first-install flow early on a Simplified-Chinese Windows machine with the system UTF-8 option **off** (the default CP936/GBK adverse path); record `chcp`, download the raw Gist through the normal user path, install it in PyMOL, and confirm bootstrap and installation succeed while the deferred UTF-8 guidance dialog appears once without blocking plugin registration. Repeat with **Region → Administrative language settings → Change system locale → “Beta: Use Unicode UTF-8 for worldwide language support”** enabled and the machine rebooted (CP65001); bootstrap and installation must also succeed, and the dialog must not appear. The toggle is a user workaround for non-English CMD/Windows PowerShell streams, not a prerequisite or substitute for CP936 compatibility. +- **Simplified-Chinese Windows living test**: After merging the installer change, republish both `REvoDesign_PyMOL.py` and `manifest.json` before testing so the Gist artifacts match the merged source. Test the documented first-install flow early on a Simplified-Chinese Windows machine with the system UTF-8 option **off** (the default CP936/GBK adverse path); record `chcp`, download the raw Gist through the normal user path, install it in PyMOL, and confirm bootstrap and installation succeed. Repeat with **Region → Administrative language settings → Change system locale → “Beta: Use Unicode UTF-8 for worldwide language support”** enabled and the machine rebooted (CP65001); bootstrap and installation must also succeed. The UTF-8 guidance lives in the docs only — the package manager no longer probes code pages or shows a dialog. The toggle is a user workaround for non-English CMD/Windows PowerShell streams, not a prerequisite or substitute for CP936 compatibility. - **Version bumping**: 1. Update `__version__` in `src/REvoDesign/__init__.py` (validate format at https://regex101.com/r/6AoOI9/1). 2. Run `make tag` — it extracts old/new versions from the git diff, inserts a dated `[new_version]` section in `CHANGELOG.md`, commits `CHANGELOG.md` + `__init__.py`, creates an annotated tag with the changelog between versions, and pushes with `--tags`. diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index ae14d472..266fd2e9 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -41,9 +41,6 @@ Supported operating systems: REvoDesign continues to test the default Simplified-Chinese Windows CP936/GBK path. This setting is a recommended interoperability workaround, not a prerequisite or a substitute for reporting encoding problems. - When the Package Manager detects CP936 during PyMOL startup, it displays - this procedure as a reminder. The reminder appears once per PyMOL session - and does not block the plugin from loading. ## Install PyMOL diff --git a/src/REvoDesign/tools/package_manager.py b/src/REvoDesign/tools/package_manager.py index 1ef735bc..242611b9 100644 --- a/src/REvoDesign/tools/package_manager.py +++ b/src/REvoDesign/tools/package_manager.py @@ -8,7 +8,6 @@ # pylint: disable=unused-argument from __future__ import annotations -import ctypes import difflib import hmac import importlib @@ -51,76 +50,6 @@ LOGGER_LEVEL = 0 _WORKER_CONTEXT = threading.local() -WINDOWS_GBK_CODE_PAGE = 936 -_WINDOWS_GBK_WARNING_SCHEDULED = False - - -_WINDOWS_GBK_WARNING_MESSAGE = """Windows is using Simplified Chinese code page 936 (GBK). - -For reliable non-English output in CMD, Windows PowerShell, and installer tools: - -1. Press Win+R, enter intl.cpl, and press Enter. -2. Open the Administrative tab. -3. Select Change system locale... -4. Check Beta: Use Unicode UTF-8 for worldwide language support. -5. Select OK and restart Windows. -6. Run chcp and confirm that it reports 65001. - -See the REvoDesign installation guide for details.""" - - -def detect_windows_code_pages() -> dict[str, int] | None: - """Return active Windows ANSI, OEM, and console-output code pages.""" - - if sys.platform != "win32": - return None - - try: - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - return { - "ansi": int(kernel32.GetACP()), - "oem": int(kernel32.GetOEMCP()), - "console_output": int(kernel32.GetConsoleOutputCP()), - } - except (AttributeError, OSError, TypeError, ValueError): - logging.warning("Could not inspect active Windows code pages.", exc_info=True) - return None - - -def schedule_windows_gbk_warning() -> bool: - """Schedule one non-blocking UTF-8 guidance dialog when CP936 is active.""" - - global _WINDOWS_GBK_WARNING_SCHEDULED - - if _WINDOWS_GBK_WARNING_SCHEDULED: - return False - - code_pages = detect_windows_code_pages() - if code_pages is None or WINDOWS_GBK_CODE_PAGE not in code_pages.values(): - return False - - details = ( - f"Detected code pages: ANSI={code_pages['ansi']}, OEM={code_pages['oem']}, " - f"console output={code_pages['console_output']}.\n" - "Documentation: https://YaoYinYing.github.io/REvoDesign/user-guide/installation/" - ) - _WINDOWS_GBK_WARNING_SCHEDULED = True - try: - QtCore.QTimer.singleShot( - 0, - lambda: notify_box( - _WINDOWS_GBK_WARNING_MESSAGE, - RuntimeWarning, - details=details, - ), - ) - except (AttributeError, RuntimeError, TypeError, ValueError): - _WINDOWS_GBK_WARNING_SCHEDULED = False - logging.warning("Could not schedule the Windows code-page guidance dialog.", exc_info=True) - return False - return True - - def _qt_exec(obj, *args, **kwargs): """Execute a Qt object on both Qt5 and Qt6 bindings.""" @@ -3642,7 +3571,6 @@ def __init_plugin__(app=None): Add an entry to the PyMOL "Plugin" menu """ logging.info("REvoDesign entrypoint is located at %s", os.path.dirname(__file__)) - schedule_windows_gbk_warning() manager_plugin: REvoDesignPackageManager | None = None diff --git a/tests/tools/test_package_manager_bootstrap.py b/tests/tools/test_package_manager_bootstrap.py index 63ef5a67..a640f7ff 100644 --- a/tests/tools/test_package_manager_bootstrap.py +++ b/tests/tools/test_package_manager_bootstrap.py @@ -35,126 +35,6 @@ def test_package_manager_source_survives_simplified_chinese_windows_gbk_transcod compile(source.encode("gbk"), str(source_path), "exec") -class _MockWindowsKernel32: - def __init__(self, *, ansi: int, oem: int, console_output: int): - self.ansi = ansi - self.oem = oem - self.console_output = console_output - - def GetACP(self): - return self.ansi - - def GetOEMCP(self): - return self.oem - - def GetConsoleOutputCP(self): - return self.console_output - - -def test_pm_detect_windows_code_pages_reports_cp936(monkeypatch): - kernel32 = _MockWindowsKernel32(ansi=936, oem=936, console_output=936) - monkeypatch.setattr(package_manager.sys, "platform", "win32") - monkeypatch.setattr(package_manager.ctypes, "WinDLL", lambda *_args, **_kwargs: kernel32, raising=False) - - assert package_manager.detect_windows_code_pages() == { - "ansi": 936, - "oem": 936, - "console_output": 936, - } - - -def test_pm_detect_windows_code_pages_skips_non_windows(monkeypatch): - monkeypatch.setattr(package_manager.sys, "platform", "linux") - - assert package_manager.detect_windows_code_pages() is None - - -def test_pm_detect_windows_code_pages_fails_open(monkeypatch): - monkeypatch.setattr(package_manager.sys, "platform", "win32") - - def fail_detection(*_args, **_kwargs): - raise OSError("WinAPI unavailable") - - monkeypatch.setattr(package_manager.ctypes, "WinDLL", fail_detection, raising=False) - - assert package_manager.detect_windows_code_pages() is None - - -def test_pm_schedule_windows_gbk_warning_on_qt_event_loop(monkeypatch): - scheduled = [] - notifications = [] - monkeypatch.setattr(package_manager, "_WINDOWS_GBK_WARNING_SCHEDULED", False) - monkeypatch.setattr( - package_manager, - "detect_windows_code_pages", - lambda: {"ansi": 936, "oem": 936, "console_output": 936}, - ) - monkeypatch.setattr( - package_manager.QtCore.QTimer, - "singleShot", - lambda delay, callback: scheduled.append((delay, callback)), - ) - monkeypatch.setattr( - package_manager, - "notify_box", - lambda message, error_type, details=None: notifications.append((message, error_type, details)), - ) - - assert package_manager.schedule_windows_gbk_warning() - assert not package_manager.schedule_windows_gbk_warning() - assert len(scheduled) == 1 - assert scheduled[0][0] == 0 - - scheduled[0][1]() - - assert len(notifications) == 1 - message, error_type, details = notifications[0] - assert error_type is RuntimeWarning - assert "intl.cpl" in message - assert "65001" in message - assert "ANSI=936" in details - assert "YaoYinYing.github.io/REvoDesign/user-guide/installation/" in details - - -def test_pm_schedule_windows_gbk_warning_skips_cp65001(monkeypatch): - monkeypatch.setattr(package_manager, "_WINDOWS_GBK_WARNING_SCHEDULED", False) - monkeypatch.setattr( - package_manager, - "detect_windows_code_pages", - lambda: {"ansi": 65001, "oem": 65001, "console_output": 65001}, - ) - - assert not package_manager.schedule_windows_gbk_warning() - - -def test_pm_schedule_windows_gbk_warning_fails_open(monkeypatch): - monkeypatch.setattr(package_manager, "_WINDOWS_GBK_WARNING_SCHEDULED", False) - monkeypatch.setattr( - package_manager, - "detect_windows_code_pages", - lambda: {"ansi": 936, "oem": 936, "console_output": 936}, - ) - - def fail_scheduling(*_args, **_kwargs): - raise RuntimeError("Qt event loop unavailable") - - monkeypatch.setattr(package_manager.QtCore.QTimer, "singleShot", fail_scheduling) - - assert not package_manager.schedule_windows_gbk_warning() - assert not package_manager._WINDOWS_GBK_WARNING_SCHEDULED - - -def test_pm_plugin_init_schedules_windows_gbk_warning(monkeypatch): - schedule_calls = [] - monkeypatch.setattr(package_manager, "schedule_windows_gbk_warning", lambda: schedule_calls.append(True)) - monkeypatch.setattr(package_manager, "addmenuitemqt", lambda *_args, **_kwargs: None) - monkeypatch.setattr(package_manager, "is_package_installed", lambda _package: False) - - package_manager.__init_plugin__() - - assert schedule_calls == [True] - - def test_pm_fetch_gist_file_valid_url(): mock_url = "https://example.com/file.ui" mock_data = "mock UI content" From 2891941d5baf0257c1fa5ed5de3fdd980e483e38 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:40:26 +0800 Subject: [PATCH 09/11] =?UTF-8?q?chore:=20Done=20fixing=20=E2=80=94=20CI?= =?UTF-8?q?=20green,=20bots=20handled,=20task-type=20intros=20live-verifie?= =?UTF-8?q?d=20on=20revocompute=20and=20revocompute-direct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude From 93f77e0fb11491c87e5cd7fffa6351f262d0a1d8 Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:28:47 +0800 Subject: [PATCH 10/11] fix(test): qt6 bridge probes crashed on str.format parsing f-string braces QT6_WRAPPER_PROBE.format(bridge=...) tried to interpolate the probe's own f"PyQt6.{_name}" braces and raised KeyError: '_name'. Switch both probes to %s substitution. The manager probe passed in CI by luck; the wrapper probe failed the qt6 BareTests job. Reproduced and verified against real PyQt6 6.7.1 locally. Co-Authored-By: Claude --- tests/tools/test_qt_enum_bridge.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_qt_enum_bridge.py b/tests/tools/test_qt_enum_bridge.py index 336cc8dc..7435d8e3 100644 --- a/tests/tools/test_qt_enum_bridge.py +++ b/tests/tools/test_qt_enum_bridge.py @@ -24,7 +24,7 @@ QT6_PROBE = """ from PyQt6 import QtCore, QtGui, QtWidgets -{bridge} +%s _install_qt_enum_bridge() # Members the old allowlist covered, plus the ones it missed @@ -59,7 +59,7 @@ def test_qt_enum_bridge_resolves_unscoped_qt5_style_access_on_pyqt6(): pytest.skip("PyQt6 not installed") bridge = _shipped_function_source("tools/package_manager.py", "_install_qt_enum_bridge") result = subprocess.run( - [sys.executable, "-c", QT6_PROBE.format(bridge=bridge)], + [sys.executable, "-c", QT6_PROBE % bridge], capture_output=True, text=True, timeout=120, @@ -79,7 +79,7 @@ def test_qt_enum_bridge_resolves_unscoped_qt5_style_access_on_pyqt6(): except ImportError: globals()[_name] = None -{bridge} +%s _install_unscoped_enum_bridge() QtWidgets.QMessageBox.Ok @@ -95,7 +95,7 @@ def test_qt_wrapper_enum_bridge_resolves_unscoped_access_on_pyqt6(): pytest.skip("PyQt6 not installed") bridge = _shipped_function_source("Qt/qt_wrapper.py", "_install_unscoped_enum_bridge") result = subprocess.run( - [sys.executable, "-c", QT6_WRAPPER_PROBE.format(bridge=bridge)], + [sys.executable, "-c", QT6_WRAPPER_PROBE % bridge], capture_output=True, text=True, timeout=120, From 7256d0eb3d7dfc57b9a3324571a68b227a37085c Mon Sep 17 00:00:00 2001 From: YaoYinYing <33014714+YaoYinYing@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:49:29 +0800 Subject: [PATCH 11/11] =?UTF-8?q?chore:=20Done=20fixing=20=E2=80=94=20CI?= =?UTF-8?q?=20green=20incl.=20qt6=20job,=20bots=20handled,=20task-type=20i?= =?UTF-8?q?ntros=20live-verified=20on=20revocompute=20and=20revocompute-di?= =?UTF-8?q?rect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude