"Use this skill when building Python desktop applications using PySide6 with strict MVC architecture where all UI is defined by .ui files. Covers architecture patterns, controller/model/view separation, signal handling, and .ui file workflows."
Install
npx skillscat add ds-codi/project-memory-mcp/pyside6-mvc Install via the SkillsCat registry.
SKILL.md
PySide6 MVC Architecture Instructions
Guidelines for building Python desktop applications using PySide6 with strict MVC architecture where all UI is defined by .ui files.
Architecture Overview
┌─────────────────────────────────────────┐
│ View Layer (.ui files) │
│ Load from Qt Designer, capture input │
└──────────────────┬──────────────────────┘
│ Signals
┌──────────────────▼──────────────────────┐
│ Controller Layer │
│ Coordinate models & services │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ Model Layer │
│ Data structures, validation │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ Services Layer │
│ Database, files, network, broker │
└─────────────────────────────────────────┘Project Structure
my_app/
├── app.py # Bootstrap & DI container
├── __main__.py # Entry point
├── controllers/
│ ├── base.py # BaseController
│ └── *_controller.py # Domain controllers
├── models/
│ ├── base.py # BaseModel with signals
│ └── *.py # Domain models
├── views/
│ ├── base.py # BaseView
│ └── *.py # View classes
├── services/
│ └── *.py # External interactions
├── resources/
│ └── ui/ # .ui files (Qt Designer)
└── utils/
└── signals.py # Central signal registryCore Principles
| Component | Responsibility | Does NOT |
|---|---|---|
| Model | Data, validation, serialization | Touch UI, call services |
| View | Load .ui files, capture input | Contain business logic |
| Controller | Coordinate models & services | Manipulate UI directly |
Base Model Pattern
from PySide6.QtCore import QObject, Signal
from datetime import datetime
from typing import Any
class BaseModel(QObject):
property_changed = Signal(str, object) # name, value
changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._updated_at = datetime.now()
def _set_property(self, name: str, old: Any, new: Any) -> bool:
if old != new:
self._updated_at = datetime.now()
self.property_changed.emit(name, new)
self.changed.emit()
return True
return False
def to_dict(self) -> dict:
raise NotImplementedError
@classmethod
def from_dict(cls, data: dict):
raise NotImplementedErrorBase View Pattern
from pathlib import Path
from PySide6.QtWidgets import QWidget, QVBoxLayout
from PySide6.QtCore import QFile, Signal
from PySide6.QtUiTools import QUiLoader
class BaseView(QWidget):
error_occurred = Signal(str, str) # title, message
def __init__(self, parent=None):
super().__init__(parent)
self._controller = None
self._init_ui()
self._connect_signals()
def _load_ui(self, ui_filename: str) -> QWidget:
ui_path = Path(__file__).parent.parent / "resources" / "ui" / ui_filename
loader = QUiLoader()
ui_file = QFile(str(ui_path))
if ui_file.open(QFile.ReadOnly):
ui = loader.load(ui_file, self)
ui_file.close()
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(ui)
return ui
raise FileNotFoundError(f"Cannot open: {ui_path}")
def _init_ui(self):
pass # Override: load .ui file
def _connect_signals(self):
pass # Override: connect handlers
def set_controller(self, controller):
self._controller = controller
def refresh(self):
pass # Override: update from modelBase Controller Pattern
from PySide6.QtCore import QObject
class BaseController(QObject):
def __init__(self, signals, parent=None):
super().__init__(parent)
self._signals = signals
self._views = []
def register_view(self, view):
if view not in self._views:
self._views.append(view)
view.set_controller(self)
def notify_views(self):
for view in self._views:
view.refresh()
def initialize(self):
pass # Override: setup logic
def cleanup(self):
self._views.clear()Central Signal Registry
from PySide6.QtCore import QObject, Signal
class SignalRegistry(QObject):
# Domain signals
job_changed = Signal(str) # job_id
job_created = Signal(str) # job_id
settings_changed = Signal(str, object) # key, value
# Connection signals
broker_connected = Signal(bool) # connected
# UI signals
error_occurred = Signal(str, str) # title, message
app_closing = Signal()
# Singleton
_registry = None
def get_signal_registry():
global _registry
if _registry is None:
_registry = SignalRegistry()
return _registryApplication Bootstrap (DI Container)
import sys
from PySide6.QtWidgets import QApplication
from my_app.utils.signals import get_signal_registry
class MyApplication:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
self._qt_app = None
self._services = {}
self._controllers = {}
self._signals = get_signal_registry()
def _register_services(self):
self._services["db"] = DatabaseService()
def _register_controllers(self):
self._controllers["job"] = JobController(
signals=self._signals,
db=self._services["db"],
)
for c in self._controllers.values():
c.initialize()
def run(self) -> int:
self._qt_app = QApplication(sys.argv)
self._register_services()
self._register_controllers()
window = MainWindow(self._signals, self._controllers)
window.show()
return self._qt_app.exec()Widget Naming Conventions (.ui files)
| Widget Type | Pattern | Example |
|---|---|---|
| Label | *_label |
job_number_label |
| Button | *_btn |
save_btn |
| Line Edit | *_input |
customer_input |
| List | *_list |
jobs_list |
| Table | *_table |
pieces_table |
| Combo | *_combo |
status_combo |
Signal Flow
User Action (View)
↓
View emits signal
↓
Controller handles action
↓
Service performs operation
↓
SignalRegistry.emit()
↓
Views call refresh()Best Practices
1. Never Create UI Programmatically
❌ Wrong:
layout = QVBoxLayout()
btn = QPushButton("Click")
layout.addWidget(btn)✅ Correct:
self._ui = self._load_ui("my_widget.ui")
self._btn = self._ui.findChild(QPushButton, "action_btn")2. Controllers Never Touch UI
❌ Wrong:
def activate_job(self):
self._view.label.setText(job.name) # NO!✅ Correct:
def activate_job(self):
self._signals.job_changed.emit(job_id) # Views subscribe3. Views Subscribe to Signals
def _connect_signals(self):
self._signals.job_changed.connect(self._on_job_changed)
def _on_job_changed(self, job_id):
self.refresh()4. Provide Fallback UI
def _init_ui(self):
try:
self._ui = self._load_ui("widget.ui")
except FileNotFoundError:
self._create_fallback_ui()5. Use Services for External Operations
Controllers delegate to services:
- Database queries → Repository services
- File operations → File service
- Network calls → API service
- IPC → Broker client
Common Imports
# Qt
from PySide6.QtWidgets import QWidget, QMainWindow
from PySide6.QtCore import Signal, Slot, QFile
from PySide6.QtUiTools import QUiLoader
# Project
from my_app.utils.signals import get_signal_registry
from my_app.controllers.base import BaseController
from my_app.views.base import BaseView
from my_app.models.base import BaseModel