diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4d31f7134..cabd570df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -98,7 +98,6 @@ jobs: allow-prereleases: true check-latest: true - name: Install Qt - if: ${{ matrix.python-version == '3.10' }} run: | sudo apt-get update sudo apt-get install build-essential libgl1-mesa-dev diff --git a/astroid/brain/brain_qt.py b/astroid/brain/brain_qt.py index 30581e0ea..60653b8ba 100644 --- a/astroid/brain/brain_qt.py +++ b/astroid/brain/brain_qt.py @@ -2,70 +2,107 @@ # For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE # Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt -"""Astroid hooks for the PyQT library.""" +"""Astroid hooks for the Qt Python bindings (PyQt/PySide).""" from astroid import nodes from astroid.brain.helpers import register_module_extender from astroid.builder import AstroidBuilder, parse from astroid.manager import AstroidManager +_PYQT_ROOTS = {"PyQt5", "PyQt6"} -def _looks_like_signal( - node: nodes.FunctionDef, signal_name: str = "pyqtSignal" -) -> bool: - """Detect a Signal node.""" - klasses = node.instance_attrs.get("__class__", []) - # On PySide2 or PySide6 (since Qt 5.15.2) the Signal class changed locations - if node.qname().partition(".")[0] in {"PySide2", "PySide6"}: - return any(cls.qname() == "Signal" for cls in klasses) # pragma: no cover - if klasses: - try: - return klasses[0].name == signal_name - except AttributeError: # pragma: no cover - # return False if the cls does not have a name attribute - pass - return False +_PYQT_SIGNAL_QNAMES = { + "PyQt5.QtCore.pyqtSignal", + "PyQt6.QtCore.pyqtSignal", +} +_PYSIDE_ROOTS = {"PySide", "PySide2", "PySide6"} -def transform_pyqt_signal(node: nodes.FunctionDef) -> None: - module = parse( - """ - _UNSET = object() - - class pyqtSignal(object): - def connect(self, slot, type=None, no_receiver_check=False): - pass - def disconnect(self, slot=_UNSET): - pass - def emit(self, *args): - pass +_PYSIDE_SIGNAL_QNAMES = { + "PySide.QtCore.Signal", + "PySide2.QtCore.Signal", + "PySide6.QtCore.Signal", +} + +_PYQT_SIGNAL_TEMPLATE = parse( """ - ) - signal_cls: nodes.ClassDef = module["pyqtSignal"] - node.instance_attrs["emit"] = [signal_cls["emit"]] - node.instance_attrs["disconnect"] = [signal_cls["disconnect"]] - node.instance_attrs["connect"] = [signal_cls["connect"]] +_UNSET = object() +class _PyQtSignalTemplate(object): + def connect(self, slot, type=None, no_receiver_check=False): + pass + def disconnect(self, slot=_UNSET): + pass + def emit(self, *args): + pass +""" +)["_PyQtSignalTemplate"] -def transform_pyside_signal(node: nodes.FunctionDef) -> None: - module = parse( - """ - class NotPySideSignal(object): - def connect(self, receiver, type=None): - pass - def disconnect(self, receiver): - pass - def emit(self, *args): - pass +_PYSIDE_SIGNAL_TEMPLATE = parse( """ - ) - signal_cls: nodes.ClassDef = module["NotPySideSignal"] - node.instance_attrs["connect"] = [signal_cls["connect"]] - node.instance_attrs["disconnect"] = [signal_cls["disconnect"]] - node.instance_attrs["emit"] = [signal_cls["emit"]] +class _PySideSignalTemplate(object): + def connect(self, receiver, type=None): + pass + def disconnect(self, receiver=None): + pass + def emit(self, *args): + pass +""" +)["_PySideSignalTemplate"] + + +def _attach_signal_instance_attrs(node: nodes.NodeNG, template: nodes.ClassDef) -> None: + node.instance_attrs["connect"] = [template["connect"]] + node.instance_attrs["disconnect"] = [template["disconnect"]] + node.instance_attrs["emit"] = [template["emit"]] + + +def _transform_signal_on_functiondef(node: nodes.FunctionDef) -> None: + root = node.qname().partition(".")[0] + if root in _PYQT_ROOTS: + template = _PYQT_SIGNAL_TEMPLATE + else: + template = _PYSIDE_SIGNAL_TEMPLATE # pragma: no cover + _attach_signal_instance_attrs(node, template) + + +def _transform_pyqt_signal_class(node: nodes.ClassDef) -> None: + _attach_signal_instance_attrs(node, _PYQT_SIGNAL_TEMPLATE) + +def _transform_pyside_signal_class(node: nodes.ClassDef) -> None: + _attach_signal_instance_attrs(node, _PYSIDE_SIGNAL_TEMPLATE) # pragma: no cover -def pyqt4_qtcore_transform(): + +def _is_pyside_signal_classdef(n: nodes.ClassDef) -> bool: + return n.qname() in _PYSIDE_SIGNAL_QNAMES + + +def _is_pyqt_signal_classdef(n: nodes.ClassDef) -> bool: + return n.qname() in _PYQT_SIGNAL_QNAMES + + +def _is_qt_signal_functiondef(n: nodes.FunctionDef) -> bool: + root = n.qname().partition(".")[0] + if root not in _PYQT_ROOTS | _PYSIDE_ROOTS: + return False + + klasses = n.instance_attrs.get("__class__", []) + for cls in klasses: + name = getattr(cls, "name", "") + if name == "pyqtSignal" and root in _PYQT_ROOTS: + return True + if name == "Signal" and root in _PYSIDE_ROOTS: + return True # pragma: no cover + qname = getattr(cls, "qname", None) + if callable(qname): + qualified = qname() + if qualified and qualified.rsplit(".", 1)[-1] == "Signal": + return True # pragma: no cover + return False + + +def _pyqt4_qtcore_transform(): return AstroidBuilder(AstroidManager()).string_build( """ @@ -78,12 +115,20 @@ def emit(self, signal): pass def register(manager: AstroidManager) -> None: - register_module_extender(manager, "PyQt4.QtCore", pyqt4_qtcore_transform) + # PyQt4 legacy shim + register_module_extender(manager, "PyQt4.QtCore", _pyqt4_qtcore_transform) + + # PyQt function style manager.register_transform( - nodes.FunctionDef, transform_pyqt_signal, _looks_like_signal + nodes.FunctionDef, _transform_signal_on_functiondef, _is_qt_signal_functiondef ) + + # PyQt class style + manager.register_transform( + nodes.ClassDef, _transform_pyqt_signal_class, _is_pyqt_signal_classdef + ) + + # PySide class style manager.register_transform( - nodes.ClassDef, - transform_pyside_signal, - lambda node: node.qname() in {"PySide.QtCore.Signal", "PySide2.QtCore.Signal"}, + nodes.ClassDef, _transform_pyside_signal_class, _is_pyside_signal_classdef ) diff --git a/tests/brain/test_qt.py b/tests/brain/test_qt.py index 6e66c630f..993c57514 100644 --- a/tests/brain/test_qt.py +++ b/tests/brain/test_qt.py @@ -8,7 +8,6 @@ from astroid import Uninferable, extract_node from astroid.bases import UnboundMethod -from astroid.const import PY312_PLUS from astroid.manager import AstroidManager from astroid.nodes import FunctionDef @@ -16,8 +15,6 @@ @pytest.mark.skipif(HAS_PYQT6 is None, reason="These tests require the PyQt6 library.") -# TODO: enable for Python 3.12 as soon as PyQt6 release is compatible -@pytest.mark.skipif(PY312_PLUS, reason="This test was segfaulting with Python 3.12.") class TestBrainQt: AstroidManager.brain["extension_package_whitelist"] = {"PyQt6"} # noqa: RUF012 @@ -73,3 +70,17 @@ def test_slot_disconnect_no_args() -> None: pytest.skip("PyQt6 C bindings may not be installed?") assert isinstance(attribute_node, FunctionDef) assert attribute_node.args.defaults + + @staticmethod + def test_pyqt_signal_instance_connect_available() -> None: + """Test pyqtSignal() instances expose connect.""" + src = """ + from PyQt6.QtCore import pyqtSignal + sig = pyqtSignal() + sig.connect #@ + """ + node = extract_node(src) + attribute_node = node.inferred()[0] + if attribute_node is Uninferable: + pytest.skip("PyQt6 C bindings may not be installed?") + assert isinstance(attribute_node, FunctionDef)