Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 100 to 103
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a matrix for pyqt 5 / 6 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pierre-Sassoulas I think I can add a matrix for PyQt5/6 if you think that’s the right approach - though they share most of the same code paths now. I could also try adding a PySide6 matrix, which might be more valuable since it goes through a different branch than PyQt, but it would require installing different libraries in the ci.
Also, currently, we have PyQt6 in the requirements file. If we decide to go with a matrix, I’m not sure whether the right approach is to include both PyQt6 and PySide6 in the requirements or to install them dynamically during CI. Let me know what you prefer, and I’ll implement it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be open to create a proof of concept of astroid plugin ? I.e. a repo containing the pyqt brain that we add as an optional dependency to pylint or astroid later (*) and that we extensively test in it's own repo with as much dependency as required (pyqt from 1 to 6, anything is possible because the test won't slow down the main repo). If you are then the question on the other comment is moot because this is clearly the superior way to do thing, we're just limited by available maintainer time to do it.

(*) Not sure for this part the brain need astroid so if astroid has an optional dependency to it it's going to be a circular one ?

Expand Down
153 changes: 99 additions & 54 deletions astroid/brain/brain_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""

Expand All @@ -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
)
17 changes: 14 additions & 3 deletions tests/brain/test_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@

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

HAS_PYQT6 = find_spec("PyQt6")


@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.")
Comment on lines -19 to -20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘

class TestBrainQt:
AstroidManager.brain["extension_package_whitelist"] = {"PyQt6"} # noqa: RUF012

Expand Down Expand Up @@ -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)