321 lines
No EOL
21 KiB
Markdown
321 lines
No EOL
21 KiB
Markdown
# PyQt6 Scaffold
|
||
Обёртка для PyQt6, делающая работу удобнее
|
||
|
||
[English documentation](../README.md)
|
||
|
||
## Лицензия
|
||
|
||
Этот проект лицензирован под лицензией LGPLv3.
|
||
Для подробностей ознакомьтесь с файлом LICENSE.
|
||
|
||
# Описание
|
||
Эта библиотека предоставляет набор готовых паттернов и инструментов, которые можно сразу использовать для разработки и избежать ошибок в сложных местах (вроде базы данных или навигации между окнами)
|
||
|
||
Библиотека делится на 2 больших модуля:
|
||
- Core
|
||
- Contrib
|
||
|
||
## Установка
|
||
|
||
Базовая установка (только ядро):
|
||
```bash
|
||
pip install pyqt6-scaffold
|
||
```
|
||
|
||
С поддержкой MySQL:
|
||
```bash
|
||
pip install pyqt6-scaffold[mysql]
|
||
```
|
||
|
||
С поддержкой PostgreSQL:
|
||
```bash
|
||
pip install pyqt6-scaffold[postgres]
|
||
```
|
||
|
||
Все драйверы сразу:
|
||
```bash
|
||
pip install pyqt6-scaffold[all]
|
||
```
|
||
|
||
## Быстрый старт
|
||
|
||
### 1. Настройте переменные окружения
|
||
|
||
Создайте файл `start.sh` (Linux/macOS) или `start.cmd` (Windows):
|
||
```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
|
||
```
|
||
```cmd
|
||
:: 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. Реализуйте базу данных
|
||
```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. Создайте окна
|
||
```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._pas3. Create Windowssword_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self._submit_btn = QPushButton("Войти")
|
||
|
||
def _tune_layouts(self):
|
||
layout = QVBoxLayout()
|
||
layout.addWidget(QLabel("Логин:"))
|
||
layout.addWidget(self._login_input)
|
||
layout.addWidget(QLabel("Пароль:"))
|
||
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("Вход")
|
||
|
||
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("Главное окно")
|
||
```
|
||
|
||
### 4. Соберите приложение в 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()
|
||
```
|
||
|
||
## Модуль Сore
|
||
Модуль, предоставляющий основные инструменты для работы, как правило это абстрактные классы, реализующие удобный шаблон для разработки. Например, класс BaseWindow реализующий минимально приемлемый (по мнению автора) шаблон для окна приложения:
|
||
|
||
```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
|
||
```
|
||
|
||
Главные составляюшие модуля Core это классы навигации и взаимодействия с базой данных.
|
||
|
||
### База Данных
|
||
За управление базой данных в библиотеке отвечает класс ```AbstractDatabase```.
|
||
Этот класс выглядит примерно так:
|
||
|
||

|
||
|
||
Это абстрактный класс который необходимо переопределить в классе наследнике. Разберём его подробнее:
|
||
- Конструктор класса:
|
||
- Содержит всего один аргумент: **strict**, он влияет на то, как реагировать на открытие пользователем нового соединения, так как по умолчанию предполагается что пользователь будет использовать только одно соедниение для взаимодействия с базой данных. Если **strict** = True, то в случае если класс уже хранит ссылку на существующее соедниение (_conn != None), то выполнение кода завершится с ошибкой. Если **strict** = False, то старое соединение закроется и класс создаст новое соедниение с базой данных.
|
||
- *connect()* это контекстно независимый метод для создания соединения с базой данных. Внутри себя он вызывает приватный метод *_connect()*, который является контекстно зависимым от диалекта SQL и должен быть переопределён пользователем в классе наследнике
|
||
- В методе *_connect()* пользователь реализует специфичную для используемого им диалекта SQL логику подключения к базе данных. Метод должен возвращать объект содениения, как правило он выглядит как: ```your_sql_dialect_connection.connect(**DB_CONFIG)```
|
||
- *disconnect()* это контекстно независимый метод для ручного закрытия соединения с базой данных
|
||
- *connection* это геттер для поля _conn, возвращает текущее соедниение с базой данных.
|
||
- *execute()* это контекстно независимый метод для выполнения кода базы данных, состоит из открытия курсора соединения и выполнения запроса пользователя через курсор, состоит из нескольких компонентов:
|
||
- *CursorContext* - контекстно независимый класс-обёртка над объектом курсора, необходим для совместимости и использовании курсора в контекстном менеджере, проксирует все методы курсора делая его программно эквивалентом объекта курсора
|
||
- *placeholder* - контекстно зависимый геттер для placeholder'а специфичного диалекта SQL для подстановки значений, должен быть переопределён пользователем.
|
||
- Аргументы метода: execute() принимает всего 2 аргумента: autocommit(bool) и params(tuple). Первый регулирует поведение базы данных при изменении данных - применять ли их сразу или просить пользтвателя сделать это вручную. Второй аргумент передаёт значения для подстановки.
|
||
- В случае если выполнение запроса завершится с ошибкой, метод автоматически откатит изменения в базе данных (если они были)
|
||
|
||
#### Модели базы данных
|
||
Как известно, взаимодействия между базой данных и фронтендом в PyQt6 реализовано через систему моделей (model) и представлений (view), в данной библиотеке заного определены модели для таблиц, списков и карточек, реализующие совместимый интерфейс для логики организации управления базами данных данной библиотеки, описанной выше.
|
||
|
||
Логика моделей в библиотеке представлена 3-мя классами модели и одним вспомогательным классом миксином:
|
||
- *DataMixin*
|
||
Миксин для моделей Qt (QAbstractTableModel / QAbstractListModel). Содержит общую логику хранения данных, обновления модели и стилизации строк. Миксин DataMixin используется совместно с Qt-моделями и не предназначен для самостоятельного использования. Он упрощает реализацию общих функций модели, чтобы конкретные классы сосредоточились на бизнес-логике.
|
||
Предоставляет методы для:
|
||
- хранения данных (_data)
|
||
- перезагрузки модели (refresh)
|
||
- доступа к строкам (row_data)
|
||
- стилизации (row_background, row_foreground, row_font)
|
||
- *BaseTableModel*
|
||
Табличная модель, основанная на списке кортежей (row tuples). Поддерживает отображение данных в таблице с заголовками колонок, доступ к сырым данным и стилизацию ячеек через методы миксина. Предназначена для сценариев, где данные естественно представимы в виде таблицы (например, списки объектов с фиксированными полями).
|
||
- *BaseListModel*
|
||
Листовая модель, в которой каждая запись представлена как элемент списка. Отображение текста выполняется через метод row_display(). Подходит для простых списков, комбобоксов и других представлений, где не требуется табличная структура.
|
||
- *BaseCardModel*
|
||
Модель для карточных или кастомно отрисованных представлений.
|
||
Не реализует DisplayRole — визуализация делегируется QStyledItemDelegate. Позволяет строить сложные UI-компоненты (карточки, плитки, кастомные списки), сохраняя доступ к данным через UserRole.
|
||
|
||
### Composer
|
||
Класс-композитор — это маршрутизатор приложения и менеджер жизненного цикла окон. Он отвечает за регистрацию окон, навигацию между ними и хранение активного соединения с базой данных. Composer выступает центральным хабом приложения, через который происходит переключение представлений и передача контекста.
|
||
|
||

|
||
|
||
Разберём его устройство:
|
||
- Конструктор класса:
|
||
- Содержит 2 аргумента: **app** и **db**. **app** это ссылка на объект цикла событий PyQt6, **db** это ссылка на объект базы данных.
|
||
- Внутри себя инициализирует 5 полей:
|
||
- *_db* - хранит ссылку на объект базы данных
|
||
- *_current* - хранит ссылку на активное окно
|
||
- *_app* - хранит ссылку на объект цикла событий PyQt6
|
||
- *_registry* - хранит ссылку регистр композитора, который в свою очередь хранит базу данных формата "ключ-значение" (словарь/dict python), содержащую сопоставление строкового названия окна и ссылку на класс окна.
|
||
- Шаблон записи в регистре композитора определён в методе *register()* и выглядит так:
|
||
```
|
||
{
|
||
"class": window,
|
||
"instance": None,
|
||
"lazy": lazy
|
||
}
|
||
```
|
||
- Поле **class** это ссылка на класс окна
|
||
- Поле **instance** это ссылка на объект окна (если он создан)
|
||
- Поле **lazy** это настройка создания объекта окна. Если lazy = True, тогда объект окна создаётся в момент запроса на переключение на это окно. Если lazy = False, тогда объект окна создаётся сразу при регистрации окна в регистре композитора. Первый вариант больше подходит если содержимое окна контекстно зависимо.
|
||
- *_current_ctx* - хранит контекст активного окна, представляет собой базу данных формата "ключ-значение" (словарь/dict python). Контекст уникален для каждого окна и перезаписывается при переключении окон. Может содержать любые записи "ключ-значение", информация из контекста используется для нужд окна (например, если пользователь использует RBAC как систему аутентификации, логично хранить объект с правами пользователя в контексте окна). Контекст задаётся во время вызова слота *navigate()*. Предполагается что стартовое окно контекст содержать не должно
|
||
- *register()* - метод создающий новую запись в регистре композитора. Если класс передающийся в аргумент window не является наследником BaseWindow, произойдёт исключение Python.
|
||
- *navigate_request* - единственный сигнал класса композитора, эмит сигнала должен происходить в случае если пользователю требуется переключить окно, передаёт ссылку на датакласс NavigationRequest.
|
||
- *navigate()* - метод управляющий переключением окон. Является слотом PyQt6 и напрямую не должен вызываться, так как вызывается автоматически при эмитте сигнала *navigate_request*.
|
||
- *NavigationRequest* это датакласс, который состоит из полей *target: str* и *context: NavigationContext*, и передаёт контекст запроса на переключение окон, состоящий из строкового названия окна и ссылки на класс окна (и возможно объекта класса окна). В свою очередь, датакласс *NavigationContext* состоит из одного поля *data*, которое хранит контекст окна.
|
||
|
||
Переключение между окнами осуществляется через программный интерфейс класса композитора, ссылку на объект которого по умолчанию додлжны получить все окна (Шаблон окна выше).
|
||
|
||
## Модуль Contrib
|
||
|
||
Модуль contrib содержит готовые реализации для типовых случаев.
|
||
В отличие от core, contrib контекстно зависим и предполагает
|
||
конкретные решения которые пользователь может использовать как есть
|
||
или взять за основу.
|
||
|
||
### Backends
|
||
|
||
Готовые реализации AbstractDatabase для трёх диалектов SQL:
|
||
```python
|
||
from pyqt6_scaffold.contrib import PostgresqlDatabase, MysqlDatabase, SqliteDatabase
|
||
```
|
||
|
||
Каждый backend читает конфигурацию из переменных окружения:
|
||
|
||
| Класс | Переменные окружения |
|
||
|--------------------|--------------------------------------------------------------------|
|
||
| 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
|
||
Система авторизации на основе RBAC (Role-Based Access Control):
|
||
|
||
```python
|
||
from pyqt6_scaffold.contrib.auth import RBACMixin, Role, RoleLevel
|
||
```
|
||
|
||
**Role** и **RoleLevel** — датакласс и enum для описания ролей пользователя:
|
||
```python
|
||
from pyqt6_scaffold.contrib.auth import Role, RoleLevel
|
||
from pyqt6_scaffold import BaseUser
|
||
|
||
role = Role(name="Менеджер", level=RoleLevel.MANAGER.value)
|
||
user = BaseUser(id=1, name="Иван", role=role)
|
||
```
|
||
|
||
**RBACMixin** — миксин добавляющий метод `can()` в вашу базу данных.
|
||
Предполагает наличие таблицы `permission_map` в базе данных:
|
||
|
||
```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);
|
||
...
|
||
```
|
||
|
||
Использование:
|
||
```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 если user.role.level >= 50
|
||
db.can(user, "delete.products") # False если user.role.level < 100
|
||
```
|
||
|
||
Названия таблицы и полей можно переопределить:
|
||
```python
|
||
class AppDatabase(RBACMixin, MysqlDatabase):
|
||
permission_table = "rights"
|
||
permission_column = "action"
|
||
level_column = "required_level"
|
||
``` |