Agent Skills: PyQt/PySide Development

PyQt/PySide6 overview hub - installation, comparison, project structure. See sub-skills for detailed topics.

UncategorizedID: CodeAtCode/oss-ai-skills/pyqt

Install this agent skill to your local

pnpm dlx add-skill https://github.com/CodeAtCode/oss-ai-skills/tree/HEAD/frameworks/pyqt

Skill Files

Browse the full folder contents for pyqt.

Download Skill

Loading file tree…

frameworks/pyqt/SKILL.md

Skill Metadata

Name
pyqt
Description
"PyQt/PySide6 overview hub - installation, comparison, project structure. See sub-skills for detailed topics."

PyQt/PySide Development

PyQt and PySide are Python bindings for the Qt application framework for building cross-platform desktop applications.

Sub-Skills

For detailed information, see the specialized sub-skills:

| Skill | Description | Path | |-------|-------------|------| | pyqt-core | Signals, slots, timers, settings, file I/O | core/SKILL.md | | pyqt-widgets | All widgets and layouts | widgets/SKILL.md | | pyqt-threading | QThread, thread pools, concurrency | threading/SKILL.md | | pyqt-dialogs | Standard and custom dialogs | dialogs/SKILL.md | | pyqt-testing | pytest-qt testing patterns | testing/SKILL.md | | pyqt-styling | QSS styling and themes | styling/SKILL.md |

PyQt vs PySide Comparison

| Feature | PyQt5 | PyQt6 | PySide6 | |---------|-------|-------|---------| | License | GPL | GPL | LGPL | | Qt Version | Qt 5 | Qt 6 | Qt 6 | | Maintained | Security only | Active | Active | | Signal Syntax | pyqtSignal | pyqtSignal | Signal | | Slot Syntax | pyqtSlot | pyqtSlot | Slot | | Property Syntax | pyqtProperty | pyqtProperty | Property | | Commercial Use | Requires license | Requires license | Free | | QML Registration | qmlRegisterType() | qmlRegisterType() | @QmlElement |

When to Use Each

  • PySide6: Recommended for most projects (LGPL, official Qt Company support)
  • PyQt6: If you need GPL compatibility or existing PyQt codebase
  • PyQt5: Legacy projects only (security fixes only)

Installation

PySide6 (Recommended)

pip install PySide6

PyQt6

pip install PyQt6

PyQt5 (Legacy)

pip install PyQt5

Additional Dependencies

# System packages (Ubuntu/Debian)
sudo apt install libgl1-mesa-glx libglib2.0-0

# System packages (Fedora)
sudo dnf install mesa-libGL glib2

# System packages (Arch)
sudo pacman -S mesa glib2

Basic Application

#!/usr/bin/env python3
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QLabel
from PySide6.QtCore import Qt

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("My Application")
        self.setGeometry(100, 100, 800, 600)
        
        label = QLabel("Hello, Qt!")
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setCentralWidget(label)

def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

Recommended Project Structure

my_app/
├── src/
│   ├── __init__.py
│   ├── main.py
│   ├── main_window.py
│   ├── widgets/
│   │   ├── __init__.py
│   │   └── custom_widget.py
│   ├── models/
│   │   └── data_model.py
│   ├── resources/
│   │   ├── icons/
│   │   └── styles/
│   │       └── style.qss
│   └── utils/
│       └── helpers.py
├── tests/
│   └── test_main.py
├── requirements.txt
└── pyproject.toml

Quick Reference

Core Imports

# QtWidgets - UI Components
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QLabel, QPushButton, QLineEdit, QTextEdit,
    QComboBox, QSpinBox, QCheckBox, QRadioButton,
    QSlider, QProgressBar, QGroupBox,
    QTabWidget, QStackedWidget, QSplitter,
    QListWidget, QTreeWidget, QTableWidget,
    QScrollArea, QToolBar, QStatusBar,
    QMenuBar, QMenu
)

# QtCore - Core Non-GUI
from PySide6.QtCore import (
    Qt, QObject, QTimer, QThread,
    Signal, Slot, Property,
    QSize, QPoint, QRect,
    QSettings, QFile, QDir,
    QUrl, QMimeData,
    QDateTime, QDate, QTime
)

# QtGui - Graphics
from PySide6.QtGui import (
    QIcon, QPixmap, QImage,
    QPainter, QPen, QBrush, QColor,
    QFont, QCursor,
    QKeySequence, QShortcut
)

Signal/Slot Basics

from PySide6.QtCore import QObject, Signal, Slot

class MyObject(QObject):
    valueChanged = Signal(int)
    
    @Slot(int)
    def setValue(self, value):
        self._value = value
        self.valueChanged.emit(value)

# Connect
button.clicked.connect(self.onButtonClick)

# Emit
self.valueChanged.emit(42)

Layout Basics

from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout

# Vertical
layout = QVBoxLayout()
layout.addWidget(label)
layout.addWidget(button)

# Horizontal
h_layout = QHBoxLayout()
h_layout.addWidget(left)
h_layout.addWidget(right)

# Grid
grid = QGridLayout()
grid.addWidget(label, 0, 0)
grid.addWidget(input, 0, 1)

# Form
form = QFormLayout()
form.addRow("Name:", nameEdit)

References

  • Qt for Python Documentation: https://doc.qt.io/qtforpython-6/
  • PySide6 GitHub: https://github.com/pyside/pyside-setup
  • PyQt6 Documentation: https://www.riverbankcomputing.com/static/Docs/PyQt6/
  • pytest-qt: https://pytest-qt.readthedocs.io/

Signals and Slots

Signal Declaration

from PySide6.QtCore import QObject, Signal

class MyObject(QObject):
    # Define signals at class level
    valueChanged = Signal(int)
    nameChanged = Signal(str)
    dataReady = Signal(dict)
    errorOccurred = Signal(str)
    
    # Signal with multiple arguments
    positionChanged = Signal(int, int)

Slot Declaration

from PySide6.QtCore import Slot

class MyObject(QObject):
    @Slot()
    def doSomething(self):
        print("Action performed")
    
    @Slot(int)
    def setValue(self, value):
        self._value = value
    
    @Slot(str, int)
    def processData(self, name, count):
        pass
    
    @Slot(result=str)  # Return type annotation
    def getName(self) -> str:
        return self._name

Connecting Signals to Slots

# Connect signal to slot
button.clicked.connect(self.onButtonClick)
valueChanged.connect(self.updateValue)

# Connect with lambda
button.clicked.connect(lambda: print("Clicked!"))

# Connect with partial
from functools import partial
button.clicked.connect(partial(self.processItem, item_id))

# Disconnect
button.clicked.disconnect(self.onButtonClick)

# Emit signal
self.valueChanged.emit(42)
self.positionChanged.emit(x, y)

# Block signals temporarily
button.blockSignals(True)
button.setChecked(True)
button.blockSignals(False)

PyQt6 Syntax (Different)

from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot

class MyObject(QObject):
    valueChanged = pyqtSignal(int)
    
    @pyqtSlot(int)
    def setValue(self, value):
        pass

Layout Management

Box Layouts

from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QGroupBox

# Vertical layout
layout = QVBoxLayout()
layout.addWidget(label)
layout.addWidget(button)
layout.addStretch()  # Add stretchable space
layout.addWidget(bottom_label)

# Horizontal layout
h_layout = QHBoxLayout()
h_layout.addWidget(left_button)
h_layout.addStretch()
h_layout.addWidget(right_button)

# Nest layouts
main_layout = QVBoxLayout()
main_layout.addLayout(h_layout)

Grid Layout

from PySide6.QtWidgets import QGridLayout

layout = QGridLayout()
layout.addWidget(label1, 0, 0)  # row 0, col 0
layout.addWidget(lineEdit, 0, 1)  # row 0, col 1
layout.addWidget(label2, 1, 0)  # row 1, col 0
layout.addWidget(comboBox, 1, 1)  # row 1, col 1

# Span multiple cells
layout.addWidget(bigWidget, 2, 0, 1, 2)  # row 2, col 0, span 1 row, 2 cols

Form Layout

from PySide6.QtWidgets import QFormLayout

layout = QFormLayout()
layout.addRow("Name:", nameLineEdit)
layout.addRow("Email:", emailLineEdit)
layout.addRow("Age:", ageSpinBox)

Stack Layout

from PySide6.QtWidgets import QStackedLayout

stack = QStackedLayout()
stack.addWidget(page1)
stack.addWidget(page2)
stack.addWidget(page3)
stack.setCurrentIndex(0)  # Show page1

Layout Properties

# Margins and spacing
layout.setContentsMargins(10, 10, 10, 10)  # left, top, right, bottom
layout.setSpacing(5)

# Widget alignment
layout.addWidget(label, alignment=Qt.AlignCenter)

# Stretch factors
layout.addWidget(widget1, stretch=1)
layout.addWidget(widget2, stretch=2)  # Gets twice the space

Common Widgets

Labels and Display

# Label
label = QLabel("Text")
label.setText("New text")
label.setPixmap(QPixmap("image.png"))
label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True)

# Progress bar
progress = QProgressBar()
progress.setValue(50)
progress.setRange(0, 100)
progress.setTextVisible(True)

# LCD Number
lcd = QLCDNumber()
lcd.display(123)

Input Widgets

# Line edit
lineEdit = QLineEdit()
lineEdit.setText("Default")
lineEdit.setPlaceholderText("Enter text...")
lineEdit.setEchoMode(QLineEdit.Password)
lineEdit.setMaxLength(100)
lineEdit.textChanged.connect(self.onTextChanged)

# Text edit
textEdit = QTextEdit()
textEdit.setPlainText("Plain text")
textEdit.setHtml("<b>HTML</b>")
textEdit.toPlainText()

# Spin box
spinBox = QSpinBox()
spinBox.setRange(0, 100)
spinBox.setValue(50)
spinBox.setSuffix(" px")
spinBox.valueChanged.connect(self.onValueChanged)

# Combo box
comboBox = QComboBox()
comboBox.addItems(["Option 1", "Option 2", "Option 3"])
comboBox.setCurrentIndex(0)
comboBox.currentTextChanged.connect(self.onSelectionChanged)

# Checkbox
checkBox = QCheckBox("Enable feature")
checkBox.setChecked(True)
checkBox.stateChanged.connect(self.onStateChanged)

# Radio button
radio1 = QRadioButton("Option A")
radio2 = QRadioButton("Option B")
radio1.setChecked(True)
radio1.toggled.connect(self.onToggled)

# Slider
slider = QSlider(Qt.Horizontal)
slider.setRange(0, 100)
slider.setValue(50)
slider.valueChanged.connect(self.onSliderChanged)

Buttons

# Push button
button = QPushButton("Click Me")
button.clicked.connect(self.onButtonClick)
button.setEnabled(False)
button.setDefault(True)

# Tool button
toolButton = QToolButton()
toolButton.setIcon(QIcon("icon.png"))
toolButton.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)

# Checkable button
checkButton = QPushButton("Toggle")
checkButton.setCheckable(True)
checkButton.toggled.connect(self.onToggle)

Container Widgets

# Group box
groupBox = QGroupBox("Settings")
groupBox.setCheckable(True)
groupBox.setChecked(True)

# Tab widget
tabWidget = QTabWidget()
tabWidget.addTab(page1, "Tab 1")
tabWidget.addTab(page2, "Tab 2")
tabWidget.setCurrentIndex(0)

# Scroll area
scrollArea = QScrollArea()
scrollArea.setWidget(contentWidget)
scrollArea.setWidgetResizable(True)

# Splitter
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(leftWidget)
splitter.addWidget(rightWidget)
splitter.setSizes([200, 400])

Item Views

# List widget
listWidget = QListWidget()
listWidget.addItems(["Item 1", "Item 2", "Item 3"])
listWidget.currentItemChanged.connect(self.onItemChanged)

# Tree widget
treeWidget = QTreeWidget()
treeWidget.setHeaderLabels(["Name", "Value"])
item = QTreeWidgetItem(["Parent", "0"])
child = QTreeWidgetItem(["Child", "1"])
item.addChild(child)
treeWidget.addTopLevelItem(item)

# Table widget
table = QTableWidget()
table.setRowCount(3)
table.setColumnCount(2)
table.setHorizontalHeaderLabels(["Column 1", "Column 2"])
table.setItem(0, 0, QTableWidgetItem("Cell"))

Event Handling

Override Event Handlers

class MyWidget(QWidget):
    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            print("Left click at", event.pos())
        event.accept()
    
    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Escape:
            self.close()
        elif event.key() == Qt.Key_Return:
            self.submit()
        event.accept()
    
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setPen(QPen(Qt.blue, 2))
        painter.drawRect(10, 10, 100, 100)
    
    def resizeEvent(self, event):
        print("Resized to", self.size())
    
    def closeEvent(self, event):
        reply = QMessageBox.question(
            self, 'Exit',
            'Are you sure?',
            QMessageBox.Yes | QMessageBox.No
        )
        if reply == QMessageBox.Yes:
            event.accept()
        else:
            event.ignore()

Event Filters

class MyWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.textEdit.installEventFilter(self)
    
    def eventFilter(self, obj, event):
        if obj == self.textEdit and event.type() == QEvent.KeyPress:
            if event.key() == Qt.Key_Tab:
                # Handle tab key specially
                return True
        return super().eventFilter(obj, event)

Shortcuts

from PySide6.QtGui import QKeySequence, QShortcut

# Create shortcut
shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
shortcut.activated.connect(self.save)

# Common sequences
QKeySequence.Save  # Ctrl+S
QKeySequence.Open  # Ctrl+O
QKeySequence.Copy  # Ctrl+C
QKeySequence.Paste # Ctrl+V
QKeySequence.Quit  # Ctrl+Q

Styling with QSS

Basic Syntax

/* Type selector */
QLabel {
    color: #333;
    font-size: 14px;
}

/* Class selector */
QPushButton[primary="true"] {
    background-color: #0078d4;
    color: white;
}

/* ID selector */
#myButton {
    border: 2px solid blue;
}

/* Pseudo-states */
QPushButton:hover {
    background-color: #e0e0e0;
}

QPushButton:pressed {
    background-color: #c0c0c0;
}

QPushButton:disabled {
    color: #999;
}

Applying Styles

# Application-wide
app.setStyleSheet("""
    QLabel { color: #333; }
    QPushButton { padding: 5px 10px; }
""")

# Widget-specific
button.setStyleSheet("background-color: blue; color: white;")

# From file
with open("style.qss", "r") as f:
    app.setStyleSheet(f.read())

Common Properties

/* Colors */
color: #333333;                    /* Text color */
background-color: white;           /* Background */
selection-color: white;            /* Selected text */
selection-background-color: blue;  /* Selection background */

/* Fonts */
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: bold;
font-style: italic;

/* Borders */
border: 1px solid #ccc;
border-radius: 4px;
border-top: none;

/* Spacing */
padding: 10px;
margin: 5px;
spacing: 5px;  /* Between widgets */

/* Size */
min-width: 100px;
max-height: 200px;

Custom Properties

# Set custom property for styling
button.setProperty("primary", True)
button.style().unpolish(button)  # Force style refresh
button.style().polish(button)
/* Use in QSS */
QPushButton[primary="true"] {
    background-color: #0078d4;
    color: white;
}

Dialogs

Standard Dialogs

# File dialog
filename, _ = QFileDialog.getOpenFileName(
    self,
    "Open File",
    "/home/user",
    "Images (*.png *.jpg);;All Files (*)"
)

# Save dialog
filename, _ = QFileDialog.getSaveFileName(
    self,
    "Save File",
    "/home/user/untitled.txt",
    "Text Files (*.txt)"
)

# Directory dialog
directory = QFileDialog.getExistingDirectory(
    self,
    "Select Directory",
    "/home/user"
)

# Message box
reply = QMessageBox.question(
    self,
    "Confirm",
    "Are you sure?",
    QMessageBox.Yes | QMessageBox.No,
    QMessageBox.No
)

if reply == QMessageBox.Yes:
    # User confirmed
    pass

# Information
QMessageBox.information(self, "Title", "Message")

# Warning
QMessageBox.warning(self, "Title", "Warning message")

# Error
QMessageBox.critical(self, "Title", "Error message")

# Input dialog
text, ok = QInputDialog.getText(
    self,
    "Input",
    "Enter name:",
    QLineEdit.Normal,
    "Default"
)

if ok and text:
    print(text)

# Color picker
color = QColorDialog.getColor()
if color.isValid():
    widget.setStyleSheet(f"background-color: {color.name()};")

# Font picker
font, ok = QFontDialog.getFont()
if ok:
    widget.setFont(font)

Custom Dialog

from PySide6.QtWidgets import QDialog, QDialogButtonBox

class CustomDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Custom Dialog")
        self.setMinimumWidth(400)
        
        layout = QVBoxLayout(self)
        
        # Content
        form = QFormLayout()
        self.nameEdit = QLineEdit()
        form.addRow("Name:", self.nameEdit)
        layout.addLayout(form)
        
        # Buttons
        buttons = QDialogButtonBox(
            QDialogButtonBox.Ok | QDialogButtonBox.Cancel
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)
    
    def getValues(self):
        return {"name": self.nameEdit.text()}

# Usage
dialog = CustomDialog(self)
if dialog.exec() == QDialog.Accepted:
    values = dialog.getValues()

Threading

Threading is essential for PyQt applications to keep the UI responsive while performing long-running operations. PyQt provides several approaches to multithreading.

Thread Safety Rules

CRITICAL: Qt/PyQt is NOT thread-safe for UI operations. You must follow these rules:

  1. Never access widgets from worker threads - Only the main thread can modify UI
  2. Use signals for cross-thread communication - Emit signals from worker, connect to slots in main thread
  3. Use Qt.QueuedConnection for thread-safe signal delivery - Default AutoConnection handles this automatically
  4. Never block the main thread - Long operations will freeze the UI
# ❌ WRONG: Direct UI access from thread
class BadWorker(QThread):
    def run(self):
        # This will crash or cause undefined behavior!
        self.label.setText("Done")

# ✅ CORRECT: Use signals
class GoodWorker(QThread):
    finished = Signal(str)
    
    def run(self):
        result = self.process_data()
        self.finished.emit(result)  # Signal emitted, UI updated in main thread

QThread with Worker Object (Recommended Pattern)

The most flexible pattern separates the worker logic from thread lifecycle:

from PySide6.QtCore import QThread, Signal, QObject, Slot

class Worker(QObject):
    """Worker object that does the actual work."""
    finished = Signal(object)
    progress = Signal(int)
    error = Signal(str)
    
    def __init__(self, data):
        super().__init__()
        self.data = data
        self._is_cancelled = False
    
    @Slot()
    def process(self):
        """Main processing method called from thread."""
        try:
            for i, item in enumerate(self.data):
                if self._is_cancelled:
                    return
                
                # Simulate heavy work
                result = self.process_item(item)
                self.progress.emit(int((i + 1) / len(self.data) * 100))
            
            self.finished.emit({"status": "success", "count": len(self.data)})
        except Exception as e:
            self.error.emit(str(e))
    
    def cancel(self):
        self._is_cancelled = True
    
    def process_item(self, item):
        # Override in subclass
        import time
        time.sleep(0.1)  # Simulate work
        return item * 2

class ThreadController(QObject):
    """Manages worker thread lifecycle."""
    def __init__(self):
        super().__init__()
        self.thread = None
        self.worker = None
    
    def start_work(self, data):
        # Create thread and worker
        self.thread = QThread()
        self.worker = Worker(data)
        
        # Move worker to thread
        self.worker.moveToThread(self.thread)
        
        # Connect signals
        self.worker.finished.connect(self.on_finished)
        self.worker.progress.connect(self.on_progress)
        self.worker.error.connect(self.on_error)
        
        # Thread lifecycle
        self.thread.started.connect(self.worker.process)
        self.thread.finished.connect(self.thread.deleteLater)
        
        # Start thread
        self.thread.start()
    
    def cancel_work(self):
        if self.worker:
            self.worker.cancel()
        if self.thread:
            self.thread.quit()
            self.thread.wait()
    
    @Slot()
    def on_finished(self, result):
        print(f"Work completed: {result}")
        self.cleanup()
    
    @Slot()
    def on_progress(self, percent):
        print(f"Progress: {percent}%")
    
    @Slot()
    def on_error(self, error):
        print(f"Error: {error}")
        self.cleanup()
    
    def cleanup(self):
        self.thread = None
        self.worker = None

# Usage in MainWindow
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.controller = ThreadController()
        self.setup_ui()
    
    def setup_ui(self):
        self.button = QPushButton("Start Work")
        self.progress_bar = QProgressBar()
        self.cancel_button = QPushButton("Cancel")
        
        self.button.clicked.connect(self.start_work)
        self.cancel_button.clicked.connect(self.controller.cancel_work)
        
        # Connect controller signals to UI
        self.controller.worker.progress.connect(self.progress_bar.setValue)
    
    def start_work(self):
        data = list(range(100))
        self.controller.start_work(data)

QThread Subclass (Simpler Pattern)

For simpler cases, subclass QThread directly:

from PySide6.QtCore import QThread, Signal

class DataProcessor(QThread):
    """Thread that processes data and emits progress."""
    # Define signals at class level
    progress = Signal(int)
    result_ready = Signal(list)
    error_occurred = Signal(str)
    finished = Signal()
    
    def __init__(self, input_data, parent=None):
        super().__init__(parent)
        self.input_data = input_data
        self._cancelled = False
    
    def run(self):
        """Thread entry point - called by start()."""
        try:
            results = []
            total = len(self.input_data)
            
            for i, item in enumerate(self.input_data):
                # Check for cancellation
                if self._cancelled:
                    self.error_occurred.emit("Cancelled")
                    return
                
                # Process item (heavy work here)
                processed = self.process_item(item)
                results.append(processed)
                
                # Emit progress
                progress_percent = int((i + 1) / total * 100)
                self.progress.emit(progress_percent)
            
            # Emit results
            self.result_ready.emit(results)
            
        except Exception as e:
            self.error_occurred.emit(str(e))
        finally:
            self.finished.emit()
    
    def process_item(self, item):
        """Override this method for custom processing."""
        import time
        time.sleep(0.05)  # Simulate work
        return item.upper()
    
    def cancel(self):
        """Request thread cancellation."""
        self._cancelled = True

# Usage
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.processor = None
        
        # UI setup
        self.progress = QProgressBar()
        self.start_btn = QPushButton("Start")
        self.cancel_btn = QPushButton("Cancel")
        
        self.start_btn.clicked.connect(self.start_processing)
        self.cancel_btn.clicked.connect(self.cancel_processing)
    
    def start_processing(self):
        data = ["item1", "item2", "item3", "item4", "item5"]
        
        self.processor = DataProcessor(data)
        
        # Connect signals
        self.processor.progress.connect(self.progress.setValue)
        self.processor.result_ready.connect(self.on_results)
        self.processor.error_occurred.connect(self.on_error)
        self.processor.finished.connect(self.on_finished)
        
        # Start thread
        self.processor.start()
        self.start_btn.setEnabled(False)
    
    def cancel_processing(self):
        if self.processor:
            self.processor.cancel()
    
    def on_results(self, results):
        print(f"Got {len(results)} results")
    
    def on_error(self, error):
        QMessageBox.warning(self, "Error", error)
    
    def on_finished(self):
        self.start_btn.setEnabled(True)
        self.progress.setValue(0)
        self.processor = None

QThreadPool with QRunnable

For parallel execution of independent tasks:

from PySide6.QtCore import QThreadPool, QRunnable, Signal, QObject
import time

class TaskSignals(QObject):
    """Signals for QRunnable (QRunnable cannot have signals directly)."""
    finished = Signal(object)
    error = Signal(str)
    progress = Signal(int)

class ParallelTask(QRunnable):
    """Runnable task for thread pool."""
    
    def __init__(self, task_id, data):
        super().__init__()
        self.task_id = task_id
        self.data = data
        self.signals = TaskSignals()
        self._cancelled = False
    
    def run(self):
        """Executed by thread pool."""
        try:
            # Simulate work
            time.sleep(0.5)
            
            if self._cancelled:
                return
            
            result = {
                "id": self.task_id,
                "processed": self.data.upper(),
                "thread": int(QThread.currentThreadId())
            }
            
            self.signals.finished.emit(result)
            
        except Exception as e:
            self.signals.error.emit(str(e))
    
    def cancel(self):
        self._cancelled = True

class ThreadPoolManager(QObject):
    """Manages parallel task execution."""
    all_finished = Signal(int)
    task_progress = Signal(int, int)  # task_id, progress
    
    def __init__(self, max_threads=4):
        super().__init__()
        self.pool = QThreadPool()
        self.pool.setMaxThreadCount(max_threads)
        self.active_tasks = {}
        self.completed_count = 0
        self.total_tasks = 0
    
    def run_parallel(self, tasks):
        """Run multiple tasks in parallel."""
        self.completed_count = 0
        self.total_tasks = len(tasks)
        self.active_tasks.clear()
        
        for task_id, data in enumerate(tasks):
            task = ParallelTask(task_id, data)
            task.signals.finished.connect(
                lambda result, tid=task_id: self.on_task_finished(result)
            )
            task.signals.error.connect(self.on_task_error)
            self.active_tasks[task_id] = task
            self.pool.start(task)
    
    def on_task_finished(self, result):
        self.completed_count += 1
        task_id = result["id"]
        del self.active_tasks[task_id]
        
        if self.completed_count >= self.total_tasks:
            self.all_finished.emit(self.completed_count)
    
    def on_task_error(self, error):
        print(f"Task error: {error}")
    
    def cancel_all(self):
        for task in self.active_tasks.values():
            task.cancel()
        self.active_tasks.clear()

# Usage
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.pool_manager = ThreadPoolManager(max_threads=4)
        
        # UI
        self.run_btn = QPushButton("Run Parallel Tasks")
        self.status_label = QLabel("Ready")
        
        self.run_btn.clicked.connect(self.run_tasks)
        self.pool_manager.all_finished.connect(self.on_all_done)
    
    def run_tasks(self):
        tasks = [f"data_{i}" for i in range(10)]
        self.status_label.setText(f"Running {len(tasks)} tasks...")
        self.pool_manager.run_parallel(tasks)
    
    def on_all_done(self, count):
        self.status_label.setText(f"Completed {count} tasks")

QTimer for Periodic Updates

For polling or periodic checks:

from PySide6.QtCore import QTimer, Slot

class PollingWidget(QWidget):
    """Widget that polls for updates periodically."""
    
    def __init__(self):
        super().__init__()
        
        # Create timer
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.on_timeout)
        
        # UI
        self.status_label = QLabel("Last update: Never")
        self.poll_btn = QPushButton("Start Polling")
        self.poll_btn.setCheckable(True)
        
        layout = QVBoxLayout(self)
        layout.addWidget(self.status_label)
        layout.addWidget(self.poll_btn)
        
        self.poll_btn.toggled.connect(self.toggle_polling)
    
    @Slot()
    def toggle_polling(self, checked):
        if checked:
            self.timer.start(1000)  # Poll every second
            self.poll_btn.setText("Stop Polling")
        else:
            self.timer.stop()
            self.poll_btn.setText("Start Polling")
    
    @Slot()
    def on_timeout(self):
        """Called every timeout milliseconds."""
        # Fetch updates (in real app, this might trigger a worker thread)
        from datetime import datetime
        self.status_label.setText(f"Last update: {datetime.now().strftime('%H:%M:%S')}")

Qt Concurrent (QtConcurrent)

For map/filter/reduce operations on collections:

from PySide6.QtConcurrent import QtConcurrent
from PySide6.QtCore import QFutureWatcher, QFuture

class ConcurrentProcessor(QObject):
    """Process data using QtConcurrent."""
    finished = Signal(list)
    
    def process_items(self, items):
        """Process items concurrently."""
        # Map function
        def process_item(item):
            import time
            time.sleep(0.1)  # Simulate work
            return item.upper()
        
        # Run concurrent map
        future = QtConcurrent.mapped(items, process_item)
        
        # Watch for completion
        self.watcher = QFutureWatcher()
        self.watcher.futureReady.connect(lambda: self.on_future_ready(future))
        self.watcher.setFuture(future)
    
    def on_future_ready(self, future):
        results = future.result()
        self.finished.emit(list(results))

Thread-Safe Data Sharing

For sharing data between threads safely:

from PySide6.QtCore import QMutex, QMutexLocker, QReadWriteLock

class SharedData:
    """Thread-safe data container."""
    
    def __init__(self):
        self._data = {}
        self._mutex = QMutex()
    
    def set_value(self, key, value):
        """Thread-safe write."""
        locker = QMutexLocker(self._mutex)
        self._data[key] = value
    
    def get_value(self, key, default=None):
        """Thread-safe read."""
        locker = QMutexLocker(self._mutex)
        return self._data.get(key, default)
    
    def get_all(self):
        """Thread-safe copy of all data."""
        locker = QMutexLocker(self._mutex)
        return dict(self._data)

class ReadWriteData:
    """Read-write lock for read-heavy workloads."""
    
    def __init__(self):
        self._data = {}
        self._lock = QReadWriteLock()
    
    def read_value(self, key):
        """Multiple readers can hold the lock."""
        self._lock.lockForRead()
        try:
            return self._data.get(key)
        finally:
            self._lock.unlock()
    
    def write_value(self, key, value):
        """Only one writer at a time."""
        self._lock.lockForWrite()
        try:
            self._data[key] = value
        finally:
            self._lock.unlock()

Best Practices

  1. Always use signals for cross-thread communication
  2. Keep worker objects thread-affinity aware - Don't assume they're in main thread
  3. Clean up threads properly - Use deleteLater() and quit() + wait()
  4. Handle cancellation - Check flags periodically in long operations
  5. Use QThreadPool for parallel independent tasks
  6. Use QThread.moveToThread() for single long operations
  7. Never use time.sleep() in main thread - Use timers or workers instead

Common Issues

| Issue | Cause | Solution | |-------|-------|----------| | UI freezes | Blocking operation in main thread | Move to worker thread | | Crashes on widget access | Accessing UI from worker thread | Use signals instead | | Memory leaks | Thread not cleaned up | Use deleteLater() and proper lifecycle | | Deadlocks | Multiple mutexes acquired in different order | Always acquire in same order, use timeout | | Race conditions | Shared data without locks | Use QMutex or atomic operations |

Testing Threaded Code

# test_threading.py
import pytest
from pytest_qt import QtBot
from PySide6.QtCore import QThread, Signal, QTimer
from unittest.mock import Mock

def test_worker_thread_emits_progress(qtbot):
    """Test that worker thread emits progress signals."""
    
    class TestWorker(QThread):
        progress = Signal(int)
        
        def run(self):
            for i in range(5):
                self.progress.emit(i * 20)
    
    worker = TestWorker()
    
    # Wait for signal
    with qtbot.waitSignal(worker.progress, timeout=1000):
        worker.start()
    
    # Check multiple signals
    signals = []
    worker.progress.connect(signals.append)
    
    worker.start()
    worker.wait()
    
    assert len(signals) == 5
    assert signals == [0, 20, 40, 60, 80]

def test_thread_cancellation(qtbot):
    """Test thread can be cancelled."""
    
    class CancellableWorker(QThread):
        finished = Signal()
        
        def __init__(self):
            super().__init__()
            self._cancelled = False
        
        def run(self):
            for i in range(100):
                if self._cancelled:
                    return
                import time
                time.sleep(0.01)
            self.finished.emit()
        
        def cancel(self):
            self._cancelled = True
    
    worker = CancellableWorker()
    worker.start()
    worker.cancel()
    worker.wait(100)  # Wait with timeout
    
    # Should not have emitted finished
    assert not hasattr(worker, '_finished_emitted')

Testing with pytest-qt

pytest-qt provides specialized fixtures and utilities for testing Qt applications.

Installation

pip install pytest-qt

qtbot Fixture

The qtbot fixture provides methods for interacting with Qt widgets:

import pytest
from pytest_qt import QtBot
from PySide6.QtWidgets import QApplication, QPushButton, QLabel
from PySide6.QtCore import Qt

def test_button_click(qtbot):
    """Test button click updates label."""
    button = QPushButton("Click Me")
    label = QLabel("Before")
    
    qtbot.addWidget(button)
    qtbot.addWidget(label)
    
    def on_click():
        label.setText("After")
    
    button.clicked.connect(on_click)
    
    # Simulate click
    qtbot.mouseClick(button, Qt.LeftButton)
    
    assert label.text() == "After"

def test_key_press(qtbot):
    """Test keyboard input."""
    from PySide6.QtWidgets import QLineEdit
    
    line_edit = QLineEdit()
    qtbot.addWidget(line_edit)
    
    # Type text
    qtbot.keyClicks(line_edit, "Hello World")
    
    assert line_edit.text() == "Hello World"

waitSignal Context Manager

Wait for signals to be emitted:

def test_async_operation(qtbot):
    """Test async operation completes."""
    from PySide6.QtCore import QThread, Signal
    
    class Worker(QThread):
        finished = Signal(str)
        
        def run(self):
            import time
            time.sleep(0.1)
            self.finished.emit("Done")
    
    worker = Worker()
    
    # Wait for signal with timeout
    with qtbot.waitSignal(worker.finished, timeout=1000) as blocker:
        worker.start()
    
    # Check signal argument
    assert blocker.args == ["Done"]

def test_multiple_signals(qtbot):
    """Wait for multiple signal emissions."""
    from PySide6.QtCore import QTimer
    
    timer = QTimer()
    timer.setInterval(100)
    
    # Wait for 3 emissions
    with qtbot.waitSignal(timer.timeout, timeout=500, raising=3):
        timer.start()
    
    timer.stop()

waitActive and waitExposed

Wait for window activation/exposure:

def test_window_activation(qtbot, qapp):
    """Test window becomes active."""
    from PySide6.QtWidgets import QWidget
    
    widget = QWidget()
    qtbot.addWidget(widget)
    
    widget.show()
    
    # Wait for window to be active
    with qtbot.waitActive(widget, timeout=1000):
        qapp.setActiveWindow(widget)

def test_window_exposed(qtbot):
    """Test window is exposed (visible on screen)."""
    from PySide6.QtWidgets import QWidget
    
    widget = QWidget()
    qtbot.addWidget(widget)
    
    # Show and wait for exposure
    with qtbot.waitExposed(widget, timeout=1000):
        widget.show()

Testing QDialogs

def test_dialog_acceptance(qtbot):
    """Test dialog accepted."""
    from PySide6.QtWidgets import QDialog, QDialogButtonBox
    
    class TestDialog(QDialog):
        def __init__(self):
            super().__init__()
            buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
 self.rejected.connect(self.reject)
            buttons.accepted.connect(self.accept)
            
            layout = QVBoxLayout(self)
            layout.addWidget(buttons)
    
    dialog = TestDialog()
    
    # Keep reference to buttons
    ok_button = dialog.findChild(QDialogButtonBox).button(QDialogButtonBox.Ok)
    
    # Click OK in next event loop
    QTimer.singleShot(100, lambda: qtbot.mouseClick(ok_button, Qt.LeftButton))
    
    result = dialog.exec()
    
    assert result == QDialog.Accepted

def test_custom_dialog_values(qtbot):
    """Test custom dialog returns values."""
    from PySide6.QtWidgets import QDialog, QLineEdit, QVBoxLayout, QDialogButtonBox
    
    class InputDialog(QDialog):
        def __init__(self):
            super().__init__()
            
            self.line_edit = QLineEdit()
            self.line_edit.setPlaceholder("Enter name")
            
            buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
            self.rejected.connect(self.reject)
            buttons.accepted.connect(self.accept)
            
            layout = QVBoxLayout(self)
            layout.addWidget(self.line_edit)
            layout.addWidget(buttons)
        
        def get_value(self):
            return self.line_edit.text()
    
    dialog = InputDialog()
    qtbot.addWidget(dialog)
    
    # Enter text
    qtbot.keyClicks(dialog.line_edit, "Test Name")
    
    # Accept dialog
    ok_button = dialog.findChild(QDialogButtonBox).button(QDialogButtonBox.Ok)
    QTimer.singleShot(100, lambda: qtbot.mouseClick(ok_button, Qt.LeftButton))
    
    result = dialog.exec()
    
    assert result == QDialog.Accepted
    assert dialog.get_value() == "Test Name"

Testing Model/View

def test_list_model(qtbot):
    """Test QAbstractListModel."""
    from PySide6.QtCore import QAbstractListModel, Qt
    
    class SimpleModel(QAbstractListModel):
        def __init__(self, data):
            super().__init__()
            self._data = data
        
        def rowCount(self, parent=None):
            return len(self._data)
        
        def data(self, index, role=Qt.DisplayRole):
            if 0 <= index < len(self._data):
                return self._data[index]
            return None
    
    model = SimpleModel(["Item 1", "Item 2", "Item 3"])
    
    assert model.rowCount() == 3
    assert model.data(0, Qt.DisplayRole) == "Item 2"

def test_model_updates(qtbot):
    """Test model signals data changes."""
    from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt
    
    class MutableModel(QAbstractListModel):
        def __init__(self):
            super().__init__()
            self._items = []
        
        def rowCount(self, parent=None):
            return len(self._items)
        
        def data(self, index, role=Qt.DisplayRole):
            if 1 <= index < len(self._items):
                return self._items[index]
            return None
        
        def add_item(self, item):
            self.beginInsertRows(QModelIndex(), len(self._items), len(self._items))
            self._items.append(item)
            self.endInsertRows()
    
    model = MutableModel()
    
    # Track dataChanged signal
    with qtbot.waitSignal(model.dataChanged, timeout=1000):
        model.add_item("New Item")

Best Practices for pytest-qt

  1. Always use qtbot.addWidget() to ensure proper cleanup
  2. Use waitSignal for async operations with appropriate timeouts
  3. Avoid real delays - use QTimer.singleShot for timing in tests
  4. Test signals not implementation - verify behavior, not internal state
  5. Use qapp fixture when you need QApplication instance
  6. Clean up resources - qtbot handles widget cleanup automatically

Common Testing Patterns

# conftest.py - Shared fixtures
import pytest
from pytest_qt import QtBot
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QDir, QSettings

@pytest.fixture
def app(qapp):
    """Create application instance."""
    return qapp

@pytest.fixture
def temp_dir(tmp_path):
    """Create temporary directory."""
    import pathlib
    d = pathlib.Path(tmp_path) / "test_data"
    d.mkdir(exist_ok=True)
    return d

@pytest.fixture
def main_window(app, qtbot, temp_dir):
    """Create main window with dependencies."""
    from myapp.main_window import MainWindow
    
    window = MainWindow()
    qtbot.addWidget(window)
    window.show()
    
    return window

def test_main_window_loads(main_window, qtbot):
    """Test main window initializes correctly."""
    assert main_window.windowTitle() == "My App"
    assert main_window.isVisible()

def test_settings_persistence(main_window, qtbot, temp_dir):
    """Test settings are persisted."""
    # Change setting
    main_window.settings.setValue("test_key", "test_value")
    
    # Verify saved
    settings = QSettings(main_window.settings.organization(), main_window.settings.application())
    assert settings.value("test_key") == "test_value"

Packaging & Distribution

PyInstaller

# Install
pip install pyinstaller

# Build executable
pyinstaller --onefile --windowed --name "MyApp" main.py

# With icon
pyinstaller --onefile --windowed --icon=icon.ico --name "MyApp" main.py

# Include data files
pyinstaller --onefile --add-data "resources:resources" main.py

# Create spec file for customization
pyi-makespec --onefile --windowed main.py
# Edit main.spec
pyinstaller main.spec

PyInstaller Spec File

# main.spec
a = Analysis(
    ['main.py'],
    pathex=[],
    binaries=[],
    datas=[('resources', 'resources')],
    hiddenimports=[],
    hookspath=[],
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=None,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=pyz_crypto)
exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='MyApp',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    console=False,
    icon='icon.ico',
)

cx_Freeze

# setup.py
from cx_Freeze import setup, Executable

build_exe_options = {
    "packages": ["PySide6"],
    "includes": [],
    "excludes": [],
    "include_files": ["resources/"]
}

base = None
if sys.platform == "win32":
    base = "Win32GUI"

setup(
    name="MyApp",
    version="1.0",
    description="My Application",
    options={"build_exe": build_exe_options},
    executables=[Executable("main.py", base=base, icon="icon.ico")]
)
# Build
python setup.py build

Nuitka

# Install
pip install nuitka

# Build
python -m nuitka --standalone --windows-console-mode=disable --output-dir=build main.py

Testing

pytest-qt

pip install pytest-qt
# test_main.py
import pytest
from PySide6.QtWidgets import QApplication
from main_window import MainWindow

@pytest.fixture
def app(qtbot):
    window = MainWindow()
    qtbot.addWidget(window)
    return window

def test_window_title(app):
    assert app.windowTitle() == "My Application"

def test_button_click(qtbot, app):
    qtbot.mouseClick(app.button, Qt.LeftButton)
    assert app.label.text() == "Button clicked"

def test_signal_emission(qtbot, app):
    with qtbot.waitSignal(app.valueChanged, timeout=1000):
        app.setValue(42)
# Run tests
pytest tests/

Manual Testing

# Add debug output
import logging
logging.basicConfig(level=logging.DEBUG)

# Check memory
from PySide6.QtCore import QObject
print(f"QObject children: {len(self.children())}")

# Dump widget tree
def dump_widgets(widget, indent=0):
    print(" " * indent + widget.objectName() or widget.__class__.__name__)
    for child in widget.findChildren(QObject):
        dump_widgets(child, indent + 2)

Best Practices

Application Setup

import sys
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt

def main():
    # High DPI support
    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
    
    app = QApplication(sys.argv)
    app.setApplicationName("MyApp")
    app.setOrganizationName("MyCompany")
    
    window = MainWindow()
    window.show()
    
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

Settings Management

from PySide6.QtCore import QSettings

class Settings:
    def __init__(self):
        self.settings = QSettings("MyCompany", "MyApp")
    
    @property
    def window_geometry(self):
        return self.settings.value("window/geometry")
    
    @window_geometry.setter
    def window_geometry(self, value):
        self.settings.setValue("window/geometry", value)
    
    def save_window_state(self, window):
        self.settings.setValue("window/geometry", window.saveGeometry())
        self.settings.setValue("window/state", window.saveState())
    
    def restore_window_state(self, window):
        geometry = self.settings.value("window/geometry")
        if geometry:
            window.restoreGeometry(geometry)
        state = self.settings.value("window/state")
        if state:
            window.restoreState(state)

Error Handling

import traceback
from PySide6.QtWidgets import QMessageBox

def excepthook(exc_type, exc_value, exc_tb):
    tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
    QMessageBox.critical(None, "Error", f"An error occurred:\n\n{tb}")

sys.excepthook = excepthook

Troubleshooting

Common Issues

| Issue | Solution | |-------|----------| | "module not found" | pip install PySide6 | | High DPI blur | Enable AA_UseHighDpiPixmaps | | Signals not working | Check Signal/Slot signatures match | | UI freezing | Use QThread for long operations | | Memory leak | Delete widgets with .deleteLater() | | Import error | Check venv is activated |

Debug Commands

# Check Qt version
python -c "from PySide6 import QtCore; print(QtCore.__version__)"

# List available modules
python -c "from PySide6 import QtWidgets; print(dir(QtWidgets))"

# Test installation
python -c "from PySide6.QtWidgets import QApplication; app = QApplication([])"

References