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:
- Never access widgets from worker threads - Only the main thread can modify UI
- Use signals for cross-thread communication - Emit signals from worker, connect to slots in main thread
- Use Qt.QueuedConnection for thread-safe signal delivery - Default AutoConnection handles this automatically
- 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
- Always use signals for cross-thread communication
- Keep worker objects thread-affinity aware - Don't assume they're in main thread
- Clean up threads properly - Use deleteLater() and quit() + wait()
- Handle cancellation - Check flags periodically in long operations
- Use QThreadPool for parallel independent tasks
- Use QThread.moveToThread() for single long operations
- 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
- Always use qtbot.addWidget() to ensure proper cleanup
- Use waitSignal for async operations with appropriate timeouts
- Avoid real delays - use QTimer.singleShot for timing in tests
- Test signals not implementation - verify behavior, not internal state
- Use qapp fixture when you need QApplication instance
- 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([])"