# PyQt6 Scaffold A wrapper for PyQt6 designed for a more convenient workflow. [Русскоязычная Документация](docs/README.ru.md) ## License This project is licensed under the LGPLv3 license. See the LICENSE file for details. # Description This library provides a set of ready-to-use patterns and tools that can be immediately integrated into development to avoid common pitfalls in complex areas (such as database management or window navigation). The library is divided into two main modules: - Core - Contrib ## Installation Base installation (core only): ```bash pip install pyqt6-scaffold ``` With MySQL support: ```bash pip install pyqt6-scaffold[mysql] ``` With PostgreSQL support: ```bash pip install pyqt6-scaffold[postgres] ``` All drivers at once: ```bash pip install pyqt6-scaffold[all] ``` ## Quick Start ### 1. Configure Environment Variables Create a start.sh (Linux/macOS) or start.cmd (Windows) file: ```bash # start.sh export MYSQL_HOST=localhost export MYSQL_PORT=3306 export MYSQL_USER=root export MYSQL_DATABASE=mydb export MYSQL_PASSWORD=secret python main.py ``` ```powershell :: start.cmd set MYSQL_HOST=localhost set MYSQL_PORT=3306 set MYSQL_USER=root set MYSQL_DATABASE=mydb set MYSQL_PASSWORD=secret python main.py ``` ### 2. Implement the Database ```python from pyqt6_scaffold import BaseUser from pyqt6_scaffold.contrib import MysqlDatabase class AppDatabase(MysqlDatabase): def auth(self, login: str, password: str): with self.execute( f"SELECT id, name FROM users WHERE login = {self.placeholder} AND password = {self.placeholder}", (login, password) ) as cursor: row = cursor.fetchone() if not row: return None return BaseUser(id=row[0], name=row[1], role=None) ``` ### 3. Create Windows ```python from pyqt6_scaffold import BaseWindow from PyQt6.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget class LoginWindow(BaseWindow): def _define_widgets(self): self._login_input = QLineEdit() self._password_input = QLineEdit() self._password_input.setEchoMode(QLineEdit.EchoMode.Password) self._submit_btn = QPushButton("Login") def _tune_layouts(self): layout = QVBoxLayout() layout.addWidget(QLabel("Login:")) layout.addWidget(self._login_input) layout.addWidget(QLabel("Password:")) layout.addWidget(self._password_input) layout.addWidget(self._submit_btn) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def _connect_slots(self): self._submit_btn.clicked.connect(self._on_submit) def _apply_windows_settings(self): self.setWindowTitle("Login") def _on_submit(self): user = self._db.auth( self._login_input.text(), self._password_input.text() ) if user is None: return from pyqt6_scaffold import NavigateRequest, NavigationContext self._composer.navigate_request.emit( NavigateRequest( target="main", context=NavigationContext(data={"user": user}) ) ) class MainWindow(BaseWindow): def _apply_windows_settings(self): self.setWindowTitle("Main Window") ``` ### 4. Assemble the Application in main.py ```python import sys from PyQt6.QtWidgets import QApplication from pyqt6_scaffold import Composer def main(): app = QApplication(sys.argv) db = AppDatabase() db.connect() composer = Composer(app=app, db=db) composer.register("login", LoginWindow) composer.register("main", MainWindow) sys.exit(composer.run(start="login")) if __name__ == "__main__": main() ``` ## Core Module The module providing essential tools for development, usually in the form of abstract classes that implement a convenient pattern. For example, the BaseWindow class implements a minimally acceptable (in the author's opinion) pattern for an application window: ```python from PyQt6.QtWidgets import QMainWindow class BaseWindow(QMainWindow): def __init__(self, composer, db): super().__init__() self._db = db self._composer = composer self._define_widgets() self._tune_layouts() self._connect_slots() self._apply_windows_settings() def _define_widgets(self): pass def _tune_layouts(self): pass def _connect_slots(self): pass def _apply_windows_settings(self): pass ``` The main components of the Core module are the navigation and database interaction classes. ### Database The `AbstractDatabase` class is responsible for database management within the library. The class structure looks approximately like this: ![AbstractDatabase](https://www.plantuml.com/plantuml/svg/NP1HIiKm44N_iue1Vz9QDr0GdY2kmFzXcWuQJ9EQcMX5tBlfwwkLbw-RcplSapc9KjOo1UC2YS3389h9wICf3IGCtmRgkGDqASPTruntQixNMq3qqIkYtUmUXfG2tCDpBjnSCkiqExKjvHVfe6tVFbUrFuzUziJLX4_nOl32hYXRUGyzrAeEPieqIGzQvi2rq3OTKD7aqZJvW-E9Wlo1879KpeZsdxESyNbng5ypTx2g3mgRqA7PVde3U41knXp8yMiA8sVpOquSnxhANm00) This is an abstract class that must be overridden in a subclass. Let's break it down in detail: - **Class Constructor**: - Contains a single argument: **strict**. This affects how the system reacts when a user opens a new connection, as the default assumption is that the user will use only one connection for database interaction. If **strict** = True and the class already holds a reference to an existing connection (_conn != None), the execution will fail with an error. If **strict** = False, the old connection will be closed and the class will create a new connection to the database. - **connect()**: A context-independent method for creating a database connection. Internally, it calls the private method `_connect()`, which is context-dependent on the SQL dialect and must be overridden by the user in the subclass. - In the `_connect()` method, the user implements the specific database connection logic for their chosen SQL dialect. The method should return a connection object, typically resembling: `your_sql_dialect_connection.connect(**DB_CONFIG)` - **disconnect()**: A context-independent method for manually closing the database connection. - **connection**: A getter for the `_conn` field, returning the current database connection. - **execute()**: A context-independent method for executing database code. It handles opening a connection cursor and executing the user's query. It consists of several components: - **CursorContext**: A context-independent wrapper class for the cursor object. It is required for compatibility and using the cursor within a context manager, proxying all cursor methods to make it programmatically equivalent to a cursor object. - **placeholder**: A context-dependent getter for the SQL dialect's specific placeholder used for value substitution; must be overridden by the user. - **Method Arguments**: `execute()` takes only 2 arguments: `autocommit (bool)` and `params (tuple)`. The first regulates database behavior during data changes—whether to apply them immediately or require the user to do so manually. The second argument passes the values for substitution. - If query execution fails, the method will automatically roll back any changes in the database. #### Database Models As is common knowledge, interaction between the database and the frontend in PyQt6 is implemented through a system of models and views. This library redefines models for tables, lists, and cards, implementing a compatible interface for the database management logic described above. The model logic in the library is represented by three model classes and one auxiliary mixin class: - **DataMixin** A mixin for Qt models (`QAbstractTableModel` / `QAbstractListModel`). It contains shared logic for data storage, model updates, and row styling. `DataMixin` is used alongside Qt models and is not intended for standalone use. It simplifies the implementation of common model functions so that specific classes can focus on business logic. Provides methods for: - data storage (`_data`) - model reloading (`refresh`) - row access (`row_data`) - styling (`row_background`, `row_foreground`, `row_font`) - **BaseTableModel** A table model based on a list of row tuples. It supports displaying data in a table with column headers, access to raw data, and cell styling via mixin methods. Intended for scenarios where data is naturally represented as a table (e.g., lists of objects with fixed fields). - **BaseListModel** A list model where each entry is represented as a list item. Text display is handled via the `row_display()` method. Suitable for simple lists, comboboxes, and other views where a table structure is not required. - **BaseCardModel** A model for card-based or custom-drawn views. Does not implement `DisplayRole`—visualization is delegated to a `QStyledItemDelegate`. This allows for building complex UI components (cards, tiles, custom lists) while maintaining data access via `UserRole`. ### Composer The Composer class is the application's router and window lifecycle manager. It is responsible for window registration, navigation between them, and storing the active database connection. The Composer acts as the application's central hub, through which view switching and context passing occur. ![Composer](https://www.plantuml.com/plantuml/svg/NL31RiCW3Btp5LPFYPPgznocIki_x31Do2LMYqGWmyxIhjg_BmkGgU72y_F33xy32qOPUwUCGsPu3VqGc2BS5Snd3xex5MJ66CbBAN4O2enqjYpn1YqShP7t6JSB-jYyrKQkMQIMrXDu_B9d5DAHFaVYbTVQUYjQLxDF0zsfphm9NkWgkKhE52kFFJKmMT-5gG67txTwOr1bWyB7qLVBzdNv94zMp4Md8LMwrgQ9X4AvkTZLwaVbsDuM5kx_p145JyYKk3NM_VwbY5j88ncU8JaJFmhN6IrSWkluRtTFHcpmZyBTDiGTjC3sxD6f0_bkxDql) Let's examine its structure: - **Class Constructor**: - Contains 2 arguments: **app** and **db**. **app** is a reference to the PyQt6 event loop object, and **db** is a reference to the database object. - Internally initializes 5 fields: - **_db**: Stores a reference to the database object. - **_current**: Stores a reference to the active window. - **_app**: Stores a reference to the PyQt6 event loop object. - **_registry**: Stores a reference to the composer's registry, which is a "key-value" store (Python dict) containing mappings between a string name and a window class reference. - The entry template in the composer registry is defined in the `register()` method and looks like this: ```python { "class": window, "instance": None, "lazy": lazy } ``` - The **class** field is a reference to the window class. - The **instance** field is a reference to the window object (if created). - The **lazy** field is a setting for window object creation. If `lazy = True`, the window object is created at the moment of the switch request. If `lazy = False`, the object is created immediately during registration. The former is better if the window content is context-dependent. - **_current_ctx**: Stores the context of the active window as a "key-value" store. The context is unique to each window and is overwritten when switching. It can contain any key-value pairs used for window needs (e.g., if using RBAC, it is logical to store a user rights object here). The context is set during the `Maps()` slot call. It is assumed the starting window should not contain a context. - **register()**: A method for creating a new entry in the composer registry. If the class passed in the `window` argument is not a subclass of `BaseWindow`, a Python exception will be raised. - **navigate_request**: The composer's only signal. It should be emitted when the user needs to switch windows, passing a reference to the `NavigationRequest` dataclass. - **navigate()**: The method managing window switching. It is a PyQt6 slot and should not be called directly, as it is triggered automatically by the `Maps_request` signal. - **NavigationRequest** is a dataclass consisting of `target: str` and `context: NavigationContext` fields. It carries the switch request context, which consists of the target window name and the window class reference. In turn, the **NavigationContext** dataclass consists of a single `data` field which stores the window context. Switching between windows is performed via the Composer class's programmatic interface, a reference to which all windows should receive by default (see Window Template above). ## Contrib Module The `contrib` module contains ready-to-use implementations for common use cases. Unlike `core`, `contrib` is context-dependent and provides specific solutions that a user can use as-is or as a foundation. ### Backends Ready-made implementations of `AbstractDatabase` for three SQL dialects: ```python from pyqt6_scaffold.contrib import PostgresqlDatabase, MysqlDatabase, SqliteDatabase ``` Each backend reads configuration from environment variables: | Class | Environment Variables | |--------------------|--------------------------------------------------------------------| | PostgresqlDatabase | PG_HOST, PG_PORT, PG_USER, PG_DATABASE, PG_PASSWORD | | MysqlDatabase | MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_DATABASE, MYSQL_PASSWORD | | SqliteDatabase | SQLITE_PATH | ### Auth Authorization system based on RBAC (Role-Based Access Control): ```python from pyqt6_scaffold.contrib.auth import RBACMixin, Role, RoleLevel ``` **Role** and **RoleLevel** — a dataclass and an enum for describing user roles: ```python from pyqt6_scaffold.contrib.auth import Role, RoleLevel from pyqt6_scaffold import BaseUser role = Role(name="Manager", level=RoleLevel.MANAGER.value) user = BaseUser(id=1, name="Ivan", role=role) ``` **RBACMixin** — a mixin that adds the `can()` method to your database. It assumes the existence of a `permission_map` table in the database: ```sql CREATE TABLE permission_map ( perm VARCHAR(50), min_level INT ); INSERT INTO permission_map VALUES ('edit.products', 50); INSERT INTO permission_map VALUES ('delete.products', 100); INSERT INTO permission_map VALUES ('view.products', 0); ... ``` Usage: ```python from pyqt6_scaffold.contrib import MysqlDatabase from pyqt6_scaffold.contrib.auth import RBACMixin class AppDatabase(RBACMixin, MysqlDatabase): def auth(self, login, password): ... db = AppDatabase() db.connect() db.can(user, "edit.products") # True if user.role.level >= 50 db.can(user, "delete.products") # False if user.role.level < 100 ``` Table and column names can be overridden: ```python class AppDatabase(RBACMixin, MysqlDatabase): permission_table = "rights" permission_column = "action" level_column = "required_level" ```