pyqt · v1.0.0 · 2026-03-27 · sha256 163532ea9306c288
pyqt v1.0.0A
Immutable. This exact content is served forever at /api/v1/blob/163532ea9306c288.
---
name: pyqt
description: Comprehensive guide for developing desktop applications with PyQt5, PyQt6, and PySide6, including installation, widgets, signals/slots, layouts, styling, and packaging.
metadata:
author: OSS AI Skills
version: 1.0.0
tags:
- python
- qt
- pyqt
- pyside
- gui
- desktop
- pyqt6
- pyqt5
---
# PyQt/PySide Development
Complete reference for building cross-platform desktop applications with Python and Qt.
## Overview
PyQt and PySide are Python bindings for the Qt application framework. They enable building professional desktop applications with native look and feel across Windows, macOS, and Linux.
**Key Characteristics:**
- Cross-platform (Windows, macOS, Linux)
- Rich widget library
- Signal-slot mechanism for event handling
- QSS styling (CSS-like)
- Support for OpenGL, multimedia, networking, databases
## 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)
```bash
pip install PySide6
```
### PyQt6
```bash
pip install PyQt6
```
### PyQt5 (Legacy)
```bash
pip install PyQt5
```
### Additional Dependencies
```bash
# 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
```
## Application Structure
### Basic Application
```python
#!/usr/bin/env python3
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QLabel
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.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
│ │ └── dialog.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── data_model.py
│ ├── resources/
│ │ ├── icons/
│ │ ├── styles/
│ │ │ └── style.qss
│ │ └── resources.qrc
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── tests/
│ ├── __init__.py
│ └── test_main.py
├── requirements.txt
├── pyproject.toml
└── README.md
```
## Core Modules
### QtWidgets - UI Components
```python
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, QFileDialog, QMessageBox
)
```
### QtCore - Core Non-GUI
```python
from PySide6.QtCore import (
Qt, QObject, QTimer, QThread,
Signal, Slot, Property,
QSize, QPoint, QRect,
QSettings, QFile, QDir,
QUrl, QMimeData,
QDateTime, QDate, QTime,
QAbstractItemModel, QModelIndex
)
```
### QtGui - Graphics
```python
from PySide6.QtGui import (
QIcon, QPixmap, QImage,
QPainter, QPen, QBrush, QColor,
QFont, QCursor,
QKeySequence, QShortcut,
QDragEnterEvent, QDropEvent
)
```
## Signals and Slots
### Signal Declaration
```python
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
```python
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
```python
# 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)
```python
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
class MyObject(QObject):
valueChanged = pyqtSignal(int)
@pyqtSlot(int)
def setValue(self, value):
pass
```
## Layout Management
### Box Layouts
```python
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
```python
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
```python
from PySide6.QtWidgets import QFormLayout
layout = QFormLayout()
layout.addRow("Name:", nameLineEdit)
layout.addRow("Email:", emailLineEdit)
layout.addRow("Age:", ageSpinBox)
```
### Stack Layout
```python
from PySide6.QtWidgets import QStackedLayout
stack = QStackedLayout()
stack.addWidget(page1)
stack.addWidget(page2)
stack.addWidget(page3)
stack.setCurrentIndex(0) # Show page1
```
### Layout Properties
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
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
```python
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
```python
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
```css
/* 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
```python
# 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
```css
/* 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
```python
# Set custom property for styling
button.setProperty("primary", True)
button.style().unpolish(button) # Force style refresh
button.style().polish(button)
```
```css
/* Use in QSS */
QPushButton[primary="true"] {
background-color: #0078d4;
color: white;
}
```
## Dialogs
### Standard Dialogs
```python
# 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
```python
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
```python
# ❌ 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:
```python
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:
```python
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:
```python
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:
```python
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:
```python
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:
```python
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
```python
# 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
```bash
pip install pytest-qt
```
### qtbot Fixture
The `qtbot` fixture provides methods for interacting with Qt widgets:
```python
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:
```python
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:
```python
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
```python
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
```python
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
```python
# 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
```bash
# 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
```python
# 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
```python
# 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")]
)
```
```bash
# Build
python setup.py build
```
### Nuitka
```bash
# Install
pip install nuitka
# Build
python -m nuitka --standalone --windows-console-mode=disable --output-dir=build main.py
```
## Testing
### pytest-qt
```bash
pip install pytest-qt
```
```python
# 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)
```
```bash
# Run tests
pytest tests/
```
### Manual Testing
```python
# 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
```python
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
```python
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
```python
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
```bash
# 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
- [Qt Documentation](https://doc.qt.io/qt-6/)
- [PySide6 Documentation](https://doc.qt.io/qtforpython/)
- [PyQt6 Documentation](https://www.riverbankcomputing.com/static/Docs/PyQt6/)
- [Qt Examples](https://doc.qt.io/qt-6/qtexamplesandtutorials.html)
- [Python GUI Programming](https://realpython.com/python-pyqt-gui-calculator/)