1️⃣ Model — data & business logic
- Holds application state
- Knows nothing about Qt or UI
- Example: data classes, domain logic, timers, IO, calculations
2️⃣ View — UI only
- Widgets, layouts, rendering
- Emits user intent (signals like clicked, textChanged)
- Has no business logic
- Does not pull data from Model
3️⃣ Presenter — the glue
- Listens to View events
- Updates the Model
- Listens to Model changes
- Pushes updates to the View
"""PyQt6 MVP exampleService -> Model -> pure Python event -> Presenter -> Qt signal -> ViewRun: pip install PyQt6 python app.py"""from__future__importannotationsimportsysimportthreadingimporttimefromdataclassesimportdataclass,fieldfromtypingimportCallablefromPyQt6.QtCoreimportQObject,pyqtSignal,pyqtSlotfromPyQt6.QtWidgetsimportQApplication,QLabel,QPushButton,QVBoxLayout,QWidget# -------------------------# Pure Python Event# -------------------------classEvent:def__init__(self)->None:self._subscribers:list[Callable[[int],None]]=[]defsubscribe(self,callback:Callable[[int],None])->None:self._subscribers.append(callback)deffire(self,value:int)->None:forcallbackinself._subscribers:callback(value)# -------------------------# Model: pure Python, no Qt# -------------------------@dataclassclassCounterModel:value:int=0value_changed:Event=field(default_factory=Event)defset_value(self,value:int)->None:ifself.value==value:returnself.value=valueself.value_changed.fire(value)# -------------------------# Service: pure Python, no Qt# -------------------------classNumberService:def__init__(self,model:CounterModel)->None:self.model=modelself._running=Falseself._thread:threading.Thread|None=Nonedefstart(self)->None:ifself._running:returnself._running=Trueself._thread=threading.Thread(target=self._run,daemon=True)self._thread.start()defstop(self)->None:self._running=Falsedef_run(self)->None:counter=0whileself._running:counter+=1self.model.set_value(counter)time.sleep(0.5)# -------------------------# View: Qt only, no logic# -------------------------classCounterView(QWidget):start_clicked=pyqtSignal()stop_clicked=pyqtSignal()def__init__(self)->None:super().__init__()self.value_label=QLabel("Value: 0")self.start_button=QPushButton("Start")self.stop_button=QPushButton("Stop")layout=QVBoxLayout(self)layout.addWidget(self.value_label)layout.addWidget(self.start_button)layout.addWidget(self.stop_button)self.start_button.clicked.connect(self.start_clicked.emit)self.stop_button.clicked.connect(self.stop_clicked.emit)defset_value(self,value:int)->None:self.value_label.setText(f"Value: {value}")defset_running(self,running:bool)->None:self.start_button.setEnabled(notrunning)self.stop_button.setEnabled(running)# -------------------------# Presenter: Qt boundary# -------------------------classCounterPresenter(QObject):model_value_changed=pyqtSignal(int)def__init__(self,model:CounterModel,service:NumberService,view:CounterView,)->None:super().__init__()self.model=modelself.service=serviceself.view=view# View -> Presenterself.view.start_clicked.connect(self.on_start_clicked)self.view.stop_clicked.connect(self.on_stop_clicked)# Model pure Python callback -> Qt signalself.model.value_changed.subscribe(self.on_model_value_changed)# Qt signal -> Qt slotself.model_value_changed.connect(self.on_model_value_changed_qt)self.render()self.view.set_running(False)defrender(self)->None:self.view.set_value(self.model.value)defon_model_value_changed(self,value:int)->None:""" Pure Python callback. This may be called from the service thread. Do NOT update the Qt UI here. """self.model_value_changed.emit(value)@pyqtSlot(int)defon_model_value_changed_qt(self,value:int)->None:""" Qt slot. This runs safely in the Qt main thread. """self.view.set_value(value)@pyqtSlot()defon_start_clicked(self)->None:self.service.start()self.view.set_running(True)@pyqtSlot()defon_stop_clicked(self)->None:self.service.stop()self.view.set_running(False)# -------------------------# Composition Root# -------------------------classAppCompositionRoot:def__init__(self)->None:self.model=CounterModel()self.service=NumberService(self.model)self.view=CounterView()self.presenter=CounterPresenter(model=self.model,service=self.service,view=self.view,)defshow(self)->None:self.view.setWindowTitle("Service -> Model -> Presenter -> View")self.view.resize(300,120)self.view.show()# -------------------------# Main# -------------------------defmain()->int:app=QApplication(sys.argv)composition=AppCompositionRoot()composition.show()returnapp.exec()if__name__=="__main__":raiseSystemExit(main())
fromPyQt6.QtCoreimportQObject,pyqtSignalclassGreetingView(QWidget):# User intent exposed as signalsnameChanged=pyqtSignal(str)def__init__(self)->None:super().__init__()self.edit=QLineEdit()self.label=QLabel("Hello!")#...# UI → View signalsself.edit.textChanged.connect(self.nameChanged.emit)