Skip to content

Latest commit

 

History

History
1942 lines (1492 loc) · 47.1 KB

File metadata and controls

1942 lines (1492 loc) · 47.1 KB
name pyqt
description PyQt/PySide6 overview hub - installation, comparison, project structure. See sub-skills for detailed topics.
metadata
author version tags
OSS AI Skills
2.0.0
python
qt
pyqt
pyside
gui
desktop
hub

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-multimedia Audio, video, camera, recording multimedia/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

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