pyqt6-scaffold/README.md

315 lines
No EOL
15 KiB
Markdown

# 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"
```