pyqt · diff
v2.0.0 to v3.0.0
124 added, 1645 removed. Audit A to A.
---
name: pyqt
- description: "PyQt/PySide6 overview hub - installation, comparison, project structure. See sub-skills for detailed topics."
+ description: Use when building cross-platform desktop apps with PyQt6 or PySide6 - hub for installation, project structure, signals and slots basics, and pointers to widgets, styling, dialogs, threading, and testing sub-skills
metadata:
author: mte90
- version: 2.0.0
+ version: 3.0.0
tags:
- 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](core/SKILL.md) |
| **pyqt-widgets** | All widgets and layouts | [widgets/SKILL.md](widgets/SKILL.md) |
| **pyqt-threading** | QThread, thread pools, concurrency | [threading/SKILL.md](threading/SKILL.md) |
| **pyqt-dialogs** | Standard and custom dialogs | [dialogs/SKILL.md](dialogs/SKILL.md) |
| **pyqt-testing** | pytest-qt testing patterns | [testing/SKILL.md](testing/SKILL.md) |
| **pyqt-styling** | QSS styling and themes | [styling/SKILL.md](styling/SKILL.md) |
| **pyqt-multimedia** | Audio, video, camera, recording | [multimedia/SKILL.md](multimedia/SKILL.md) |
+ ## Deep Dives
+
+ Load these sub-skills for specialized topics:
+
+ - **Signals/slots deep dive** → `pyqt/core` - Signal declaration, slot decorators, connections, typed signals
+ - **Widgets, layouts, item views, event handling** → `pyqt/widgets` - Display/input/container widgets, QVBoxLayout/QHBoxLayout/QGridLayout, event filters, shortcuts
+ - **QSS styling and dark theme** → `pyqt/styling` - QSS syntax, pseudo-states, widget-specific styles, property-based styling
+ - **Dialogs** → `pyqt/dialogs` - Standard dialogs (QFileDialog, QMessageBox, QInputDialog), custom QDialog patterns, modal/modeless
+ - **Threading patterns** → `pyqt/threading` - QThread worker-object pattern, QThreadPool/QRunnable, thread safety, cancellation
+ - **pytest-qt testing** → `pyqt/testing` - qtbot fixture, waitSignal, mouse/keyboard simulation, dialog testing, model/view testing
+
## 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
```
## Basic Application
```python
#!/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
```python
- # 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
- )
+ from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox, QSpinBox, QCheckBox, QSlider, QProgressBar, QGroupBox, QTabWidget, QStackedWidget, QSplitter, QListWidget, QTreeWidget, QTableWidget, QScrollArea, QToolBar, QStatusBar
+ from PySide6.QtCore import Qt, QObject, QTimer, QThread, Signal, Slot, Property, QSize, QPoint, QRect, QSettings, QFile, QDir, QUrl, QMimeData, QDateTime
+ from PySide6.QtGui import QIcon, QPixmap, QImage, QPainter, QPen, QBrush, QColor, QFont, QCursor, QKeySequence, QShortcut
```
### Signal/Slot Basics
```python
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
```python
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)
```
- ## 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;
- }
+ widget.setEnabled(True) # Enable/disable
+ widget.setVisible(False) # Visibility
+ widget.setToolTip("Help") # Tooltip
+ widget.setObjectName("myButton") # QSS selector
+ widget.setProperty("primary", True) # Custom property
```
- ## Dialogs
-
- ### Standard Dialogs
+ ### 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
+ filename, _ = QFileDialog.getOpenFileName(self, "Open", "", "Files (*)")
+ filename, _ = QFileDialog.getSaveFileName(self, "Save", "", "Text (*.txt)")
+ directory = QFileDialog.getExistingDirectory(self, "Select")
+ reply = QMessageBox.question(self, "Confirm", "Continue?", QMessageBox.Yes | QMessageBox.No)
+ text, ok = QInputDialog.getText(self, "Input", "Name:")
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:
+ ### Threading
```python
- from PySide6.QtCore import QThread, Signal, QObject, Slot
-
+ # Worker pattern
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 process(self): self.finished.emit(result)
- def test_main_window_loads(main_window, qtbot):
- """Test main window initializes correctly."""
- assert main_window.windowTitle() == "My App"
- assert main_window.isVisible()
+ thread = QThread()
+ worker = Worker()
+ worker.moveToThread(thread)
+ thread.started.connect(worker.process)
+ worker.finished.connect(thread.quit)
+ thread.start()
- 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"
+ # Thread pool
+ pool = QThreadPool()
+ pool.start(QRunnable(task))
```
## 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
+ # Single executable
+ pyinstaller --onefile --windowed app.py
- # Create spec file for customization
- pyi-makespec --onefile --windowed main.py
- # Edit main.spec
- pyinstaller main.spec
+ # With icon and data files
+ pyinstaller --onefile --windowed --icon=app.ico --add-data "resources:resources" app.py
```
### PyInstaller Spec File
```python
- # main.spec
+ # app.spec
a = Analysis(
- ['main.py'],
+ ['app.py'],
pathex=[],
binaries=[],
datas=[('resources', 'resources')],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
+ noarchive=False,
)
- pyz = PYZ(a.pure, a.zipped_data, cipher=pyz_crypto)
+ pyz = PYZ(a.pure, a.zipped_data)
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',
+ icon='app.ico',
)
```
### cx_Freeze
```python
# setup.py
from cx_Freeze import setup, Executable
- build_exe_options = {
- "packages": ["PySide6"],
- "includes": [],
- "excludes": [],
- "include_files": ["resources/"]
+ build_options = {
+ 'packages': ['PySide6'],
+ 'excludes': ['tkinter'],
+ 'include_files': [('resources', '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")]
+ name='MyApp',
+ version='1.0',
+ description='My Qt Application',
+ options={'build_exe': build_options},
+ executables=[Executable('app.py', base='Win32GUI', icon='app.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
+ ### Build Commands
```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"
+ # PyInstaller
+ pyinstaller --clean app.spec
- def test_signal_emission(qtbot, app):
- with qtbot.waitSignal(app.valueChanged, timeout=1000):
- app.setValue(42)
- ```
+ # cx_Freeze
+ python setup.py build
- ```bash
- # Run tests
- pytest tests/
+ # Create distribution
+ pyinstaller --clean --distpath dist app.spec
```
- ### 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())}")
+ ### Distribution Checklist
- # 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)
- ```
+ - [ ] Test on clean VM (no Python installed)
+ - [ ] Verify all assets bundled (icons, QSS, translations)
+ - [ ] Check executable size (strip debug symbols if needed)
+ - [ ] Test on target OS versions
+ - [ ] Sign executable (Windows/macOS)
+ - [ ] Create installer (optional: NSIS, Inno Setup, dmgbuild)
## 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()
- ```
+ ### Architecture
- ### Settings Management
+ 1. **Separate UI from business logic** - Use MVC or MVVM patterns
+ 2. **Use dependency injection** - Pass dependencies to constructors
+ 3. **Keep widgets stateless** - Store state in models, not widgets
+ 4. **Use signals for decoupling** - Components communicate via signals
+ 5. **Lazy load heavy resources** - Load images/data on demand
- ```python
- from PySide6.QtCore import QSettings
+ ### Performance
- 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)
- ```
+ 1. **Avoid blocking the main thread** - Use workers for long operations
+ 2. **Use view delegates for complex item rendering** - Don't subclass QItemDelegate unnecessarily
+ 3. **Batch UI updates** - Block signals during bulk changes
+ 4. **Reuse widgets** - Pool frequently created widgets
+ 5. **Profile before optimizing** - Use `python -m cProfile`
- ### Error Handling
+ ### Memory Management
- ```python
- import traceback
- from PySide6.QtWidgets import QMessageBox
+ 1. **Use parent-child relationships** - Qt auto-cleans children
+ 2. **Call `deleteLater()` for dynamic widgets** - Don't use `del` directly
+ 3. **Break signal/slot connections** - Disconnect before deleting
+ 4. **Avoid circular references** - Use weak references where needed
+ 5. **Monitor with `tracemalloc`** - Find memory leaks
- 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}")
+ ### Code Organization
- sys.excepthook = excepthook
- ```
+ 1. **One class per file** - Keep modules focused
+ 2. **Group related widgets** - Custom widgets in separate modules
+ 3. **Centralize constants** - Use enums for fixed values
+ 4. **Use type hints** - Helps with IDE support and debugging
+ 5. **Document public APIs** - Docstrings for methods and classes
## Troubleshooting
- ### 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 | Objects not cleaned up | Use parent-child relationships, `deleteLater()` |
+ | QSS not applying | Wrong selector or syntax | Check widget objectName, use `style().polish()` |
+ | Signals not firing | Wrong connection type | Verify signal declaration, check thread affinity |
+ | High CPU usage | Tight loop in main thread | Use timers or workers |
+ | Window not showing | Missing `show()` call | Call `window.show()` before `app.exec()` |
+ | Icons not loading | Wrong path or format | Use `QResource` for bundled assets |
- | 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 |
+ ### Debugging Tips
- ### Debug Commands
+ ```python
+ # Enable Qt logging
+ import logging
+ logging.basicConfig(level=logging.DEBUG)
- ```bash
- # Check Qt version
- python -c "from PySide6 import QtCore; print(QtCore.__version__)"
+ # Check for unhandled exceptions
+ import sys
+ sys.excepthook = lambda exc: print(f"Unhandled: {exc}")
- # List available modules
- python -c "from PySide6 import QtWidgets; print(dir(QtWidgets))"
+ # Print widget hierarchy
+ print(window.findChildren(QWidget))
- # Test installation
- python -c "from PySide6.QtWidgets import QApplication; app = QApplication([])"
+ # Check signal connections
+ print(button.receivers(button.clicked))
+
+ # Dump QSS errors
+ app.setStyleSheet("INVALID {") # Will print parse errors
```
## References
- - [Qt Documentation](https://doc.qt.io/qt-6/)
- - [PySide6 Documentation](https://doc.qt.io/qtforpython/)
+ - [Official Qt Documentation](https://doc.qt.io/)
+ - [PySide6 Documentation](https://doc.qt.io/qtforpython-6/)
- [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/)
- - [PySide6 GitHub](https://github.com/pyside/pyside-setup)
- - [pytest-qt](https://pytest-qt.readthedocs.io/)
-
+ - [Qt Examples](https://doc.qt.io/qt-6/examples-and-tutorials.html)
+ - [pytest-qt Documentation](https://pytest-qt.readthedocs.io/)