Best Practices
A PyQt codebase is maintainable when the structure follows a few consistent rules: the app is organized as modules (the main, the window, the widgets, the logic), the signals and slots are the communication (the widget to the widget, the never the direct call across layers), the heavy work is off the GUI thread (the QThread worker the threads chapter), and the state lives in the model (the model/view, the QSettings) not in the widget's local variables. This chapter collects the rules the previous chapters built up: the project structure, the signal/slot discipline, the thread discipline, the style sheet and the resource discipline, the naming and the error handling, and the testing and the packaging the finished app ships.
The Project Structure
# the LAYOUT: the app as the modules (the import is the
# direction - the main imports the window, the window
# imports the widgets, the widgets import the logic,
# never the reverse)
#
# myapp/
# main.py - the QApplication + the loop
# window.py - the QMainWindow (the toolbar,
# the menu, the central widget)
# widgets/ - the custom widgets (the edit,
# the list, the panel)
# __init__.py
# editor.py
# logic/ - the domain (the save, the load,
# the convert - the NO Qt import
# where the domain is the plain)
# __init__.py
# document.py
# resources/ - the .qrc + the assets
# resources_rc.py - the pyrcc output (the generated)
# ui_main.py - the pyuic output (the generated)
# test/
# test_document.py
#
# the main.py: the thin (the app, the window, the run)
from PyQt5.QtWidgets import QApplication
import sys
from window import MainWindow
def main():
app = QApplication(sys.argv)
app.setApplicationName("NotePad")
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
The structure is the module split with the import direction as the rule: the main creates the QApplication and the MainWindow and runs the loop (the thin entry point), the window is the QMainWindow (the toolbar, the menu, the central widget), the widgets are the custom widgets (the editor, the list, the panel), and the logic is the domain (the save, the load, the convert). The import direction is one way: the main imports the window, the window imports the widgets, the widgets import the logic, and the logic imports none of them (the domain is the plain Python where it can be - the testable without the QApplication). The generated files (ui_main, the resources_rc) are the build's output (the not hand-edited), and the main() guard makes the module importable for the test (the testing chapter). The structure is the decision the app makes once, and the rest of the rules keep the code inside it.
The Signal and Slot Discipline
Signals are the communication, and the rules keep them clean. The widget announces through the signal (the valueChanged, the saved), and the owner connects (the window connects the editor's signal to its own slot) - the child never imports the parent, the signal is the decoupling. The connect is in the owner's __init__ (the visible, the one place), and the slot is small (the read the signal's value, the call the logic, the update the state - the long work goes to the QThread, not the slot). The pyqtSignal is declared at the class level (the typed, the documented), and the emit is the only way out (the method's return is the data, the signal is the event). Two widgets that both need the same data do not talk to each other directly: the data goes to the model (the model/view chapter) or to the owner, and the owner signals. The discipline is the graph: the signal flows up (the child to the parent through the connect) and the property flows down (the parent sets the child's state) - the two directions, no cross-talk.
The Thread Discipline
from PyQt5.QtCore import QThread, pyqtSignal
from PyQt5.QtWidgets import QApplication, QPushButton, QLabel
import sys
app = QApplication(sys.argv)
# the WORKER: the QThread subclass (the threads chapter)
class Worker(QThread):
progress = pyqtSignal(int) # the worker -> the gui
finished_ok = pyqtSignal(str) # the worker -> the gui
def run(self):
# the heavy loop: the NO widget access here
for i in range(100):
self.progress.emit(i + 1)
self.finished_ok.emit("the done")
# the WIRE: the connect BEFORE the start (the signal
# is queued across the thread, the slot runs on the
# gui thread - the widget is safe)
worker = Worker()
label = QLabel("waiting")
def on_progress(v):
label.setText("the %d%%" % v) # the gui thread
def on_done(msg):
label.setText(msg)
worker.deleteLater()
worker.progress.connect(on_progress)
worker.finished_ok.connect(on_done)
worker.start()
sys.exit(app.exec_())
The thread rule is the one the threads chapter stated and the app must keep: the GUI thread owns the widgets, the QThread worker owns the heavy work, and the pyqtSignal is the only crossing (the queued connection runs the slot on the GUI thread, so the slot's widget access is safe). The worker's run never touches a widget (the value goes into the signal, the slot updates the label), the connect is before the start (no lost signal), and the deleteLater is on the finish (the worker's cleanup). The synchronous block (the waitForDone) is the rare case, not the pattern, and the exec loop is the dispatcher the whole app runs on - the thread discipline is what keeps the window responsive, and it is the rule the packaging and the test inherit.
The Style Sheet and Resource Discipline
The visual layer follows the same split as the code. The style is chosen once (the app.setStyle("Fusion") for the controlled cross-platform look), the global style sheet carries the app's identity (the palette, the fonts, the common widget look), and the per-widget sheet carries the specific (the one button, the #objectName the target). The sheet lives in the .qss file, read from the resource system (the :/style/app.qss) so the frozen app carries it, and the reset is the empty string. The object name is the contract (the setObjectName in the code, the #name in the sheet, the self.name attribute in the Designer-compiled code), and the generated files (ui_*.py, the resources_rc.py) are the build's output - the change is the .ui and the .qrc, never the generated module. The naming is consistent: the camelCase for the method and the attribute (the Qt's convention), the PascalCase for the class, the snake_case for the module and the plain variable, and the slot is named for the signal (the on_widget_signal the auto-wire expects, or the descriptive handle_ prefix for the explicit). The error handling is the lastError of the QSqlQuery, the error of the QNetworkReply, the except of the file read - the fail is logged with the text, and the user is told (the QMessageBox), never the silent crash.
The Working Rules
- The module split, the import direction one way. The
mainis the thin entry point, thewindowtheQMainWindow, thewidgetsthe custom, thelogicthe domain (the plain Python where it can be) - the import goesmaintowindowtowidgetstologic, never the reverse, and themain()guard keeps the module importable for the test. - The signal is the communication, the connect is in the owner's
__init__. The child signals, the owner connects, the data flows through the model or the owner - no direct call across layers, and the slot is small (the long work to theQThread). The signal is the event, the return is the data. - The GUI thread owns the widget, the
QThreadowns the work, thepyqtSignalis the crossing. The worker'srunnever touches a widget, theconnectis before thestart, and thedeleteLateris on the finish - the queued connection runs the slot on the GUI thread, the window stays responsive. - The style sheet is the
.qssasset, the#objectNamethe target. ThesetStyle("Fusion")once for the base, the global sheet the identity, the widget sheet the specific, and the.qssread from the resource so the frozen app carries it - the generatedui_*.pyand theresources_rcare the build's output, not hand-edited. - The naming follows the Qt convention, the error is the
lastErrorplus the message. ThecamelCasemethod, thePascalCaseclass, thesnake_casemodule, and the slot named for the signal - the fail is logged with thelastError's text and the user is told (theQMessageBox), never the silent crash.
Summary
The best practice is the structure the rules keep: the module split (the main the thin entry, the window the QMainWindow, the widgets the custom, the logic the domain) with the import direction one way and the main() guard the test's import; the signal and slot as the communication (the child signals, the owner connects in its __init__, the small slot, the long work to the QThread); the thread discipline (the GUI thread owns the widget, the worker owns the work, the pyqtSignal the only crossing, the connect before the start, the deleteLater on the finish); and the asset discipline (the setStyle("Fusion") the base, the global sheet the identity, the #objectName the specific, the .qss the resource the frozen app carries, the generated file the build's output). The naming follows the Qt convention (the camelCase method, the PascalCase class), and the error is the lastError plus the QMessageBox - the maintainable PyQt app is the structure, the signal, the thread, and the asset, each kept by its rule, and the test plus the packaging the ship the finished app makes.