Lightweight scaffold framework for PyQt6 applications
Project description
PyQt6 Scaffold
A wrapper for PyQt6 designed for a more convenient workflow.
License
This project is licensed under the GPLv3 license. See the LICENSES/GPL-3.0-only.txt file for details.
This license is required because project depends on PyQt6, which is distributed under the GPLv3.
If GPLv3 does not suit your needs, you may purchase a commercial PyQt license directly from Riverbank Computing. Visit their website for details.
Installation
pip install pyqt6-scaffold # core only
pip install pyqt6-scaffold[postgres] # with PostgreSQL support
pip install pyqt6-scaffold[mysql] # with MySQL support
pip install pyqt6-scaffold[all] # all drivers
Description
The library provides patterns and tools that remove boilerplate from common PyQt6 tasks: database connection management, window navigation, role-based access control, and building list/card UIs backed by a database.
It is divided into two modules:
- Core - abstract base classes:
BaseWindow,Composer,AbstractDatabase, and the model hierarchy. - Contrib - ready-to-use implementations: database backends, RBAC auth, pre-built window bases, tab primitives, and a configurable card delegate.
Quick Start
The shortest possible application using the full contrib stack.
1. Configure environment variables
# start.sh
export PG_HOST=localhost
export PG_PORT=5432
export PG_USER=postgres
export PG_DATABASE=mydb
export PG_PASSWORD=secret
python main.py
2. Implement the database
from pyqt6_scaffold import BaseUser
from pyqt6_scaffold.contrib.auth import RBACMixin, Role
from pyqt6_scaffold.contrib.backends import PostgresqlDatabase
class AppDatabase(RBACMixin, PostgresqlDatabase):
def find_user(self, login, password):
with self.execute(
"SELECT u.id, u.name, r.name, r.level"
" FROM users u JOIN roles r ON r.id = u.role_id"
" WHERE u.login = %s AND u.password = %s",
(login, password)
) as cursor:
row = cursor.fetchone()
if row is None:
return None
return BaseUser(id=row[0], name=row[1], role=Role(row[2], row[3]))
def get_items(self):
with self.execute("SELECT id, name, description, price FROM item") as c:
return c.fetchall()
3. Define a card delegate
from pyqt6_scaffold.contrib.delegates import CardDelegate, Section, AsideBlock
class ItemDelegate(CardDelegate):
card_height = 100
body = [
Section(cols=[1], bold=True),
Section(cols=[2], prefix="Description: "),
Section(cols=[3], prefix="Price: ", suffix=" USD",
price=True, price_discount_col=None),
]
4. Define a tab
from pyqt6_scaffold import BaseCardModel
from pyqt6_scaffold.contrib.tabs import CardListTab
class ItemModel(BaseCardModel):
pass
class ItemTab(CardListTab):
model_class = ItemModel
delegate_class = ItemDelegate
count_template = "Items: {n}"
refresh_label = "Refresh"
def _query(self):
return self._db.get_items()
5. Define windows
from pyqt6_scaffold.contrib.auth import RoleLevel
from pyqt6_scaffold.contrib.windows import BaseLoginWindow, BaseMainWindow
class LoginWindow(BaseLoginWindow):
window_title = "My App - Login"
login_success_target = "main"
def _authenticate(self, login, password):
return self._db.find_user(login, password)
class MainWindow(BaseMainWindow):
window_title = "My App"
tabs = [
("Items", 0, ItemTab),
]
6. Assemble in main.py
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
BaseWindow
The base class for all application windows. Subclass and override the four template methods - they are called in order during __init__.
from pyqt6_scaffold import BaseWindow
class MyWindow(BaseWindow):
def _define_widgets(self): ... # create widgets
def _tune_layouts(self): ... # arrange them
def _connect_slots(self): ... # connect signals
def _apply_windows_settings(self): ... # title, size, etc.
Inside any method self._db is the database object and self._composer is the Composer.
Composer
The application router. Registers windows by name, manages navigation, and holds the active database connection.
from pyqt6_scaffold import Composer, NavigateRequest, NavigationContext
composer = Composer(app=app, db=db)
composer.register("login", LoginWindow) # lazy=True by default
composer.register("splash", SplashWindow, lazy=False)
composer.run(start="login")
Navigation is triggered by emitting a signal - never by calling navigate() directly:
self._composer.navigate_request.emit(
NavigateRequest(
target="main",
context=NavigationContext(data={"user": user})
)
)
The context of the current window is accessible via self._composer.context.data.
lazy=True (default) creates a new window instance on every navigation - the window rebuilds itself from the current context. Use lazy=False only for windows that are expensive to construct and have no context dependency.
AbstractDatabase
Abstract base class for database interaction. Subclass it (or use a contrib backend) and override _connect() and placeholder.
from pyqt6_scaffold import AbstractDatabase
class MyDatabase(AbstractDatabase):
@property
def placeholder(self):
return "%s"
def _connect(self):
import psycopg2
return psycopg2.connect(...)
execute(sql, params=(), autocommit=False) - executes a query and returns a CursorContext for use as a context manager. On failure, changes are rolled back automatically.
with self.execute("SELECT * FROM items WHERE id = %s", (item_id,)) as cursor:
row = cursor.fetchone()
self.execute("INSERT INTO items(name) VALUES(%s)", ("foo",), autocommit=True)
Models
| Class | Use for |
|---|---|
BaseTableModel |
tabular data in QTableView |
BaseListModel |
simple text lists in QListView / QComboBox |
BaseCardModel |
card/custom-drawn views via a delegate |
All three inherit DataMixin which provides:
refresh(rows)- replace data and trigger a view update.row_background(data)/row_foreground(data)/row_font(data)- override to return aQColororQFontfor conditional row styling.
from pyqt6_scaffold import BaseCardModel
from PyQt6.QtGui import QColor
class ProductModel(BaseCardModel):
def row_background(self, data):
if data[9] == 0: return QColor("#87CEEB") # out of stock
if data[8] > 15: return QColor("#2E8B57") # large discount
Contrib Module
Backends
Ready-made AbstractDatabase implementations for three SQL dialects.
from pyqt6_scaffold.contrib.backends import PostgresqlDatabase, MysqlDatabase, SqliteDatabase
Configuration is read from environment variables:
| Class | 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
from pyqt6_scaffold.contrib.auth import RBACMixin, Role, RoleLevel
Role(name, level) - dataclass describing a user role.
RoleLevel - enum with predefined levels: GUEST=0, CLIENT=25, EMPLOYEE=50, MANAGER=75, ADMIN=100.
RBACMixin - adds can(user, perm) → bool to any database class. Requires a permission_map table:
CREATE TABLE permission_map (
perm VARCHAR(50) PRIMARY KEY,
min_level INT NOT NULL
);
INSERT INTO permission_map VALUES ('products.edit', 100);
INSERT INTO permission_map VALUES ('products.filter', 75);
INSERT INTO permission_map VALUES ('orders.view', 75);
class AppDatabase(RBACMixin, PostgresqlDatabase):
...
db.can(user, "products.edit") # True if user.role.level >= 100
db.can(user, "products.filter") # True if user.role.level >= 75
Table and column names can be overridden via class attributes:
class AppDatabase(RBACMixin, PostgresqlDatabase):
permission_table = "rights"
permission_column = "action"
level_column = "required_level"
Windows
from pyqt6_scaffold.contrib.windows import BaseLoginWindow, BaseMainWindow
BaseLoginWindow
A login form with username/password fields and an optional guest button. Subclass and set class attributes; override only _authenticate and _create_guest.
class LoginWindow(BaseLoginWindow):
window_title = "My App - Login"
group_title = "Sign in to My App"
login_placeholder = "Username"
password_placeholder = "Password"
login_btn_label = "Sign in"
guest_btn_label = "Continue as guest" # empty string = button hidden
empty_fields_msg = "Please enter your username and password."
bad_credentials_msg = "Invalid username or password."
login_success_target = "main"
def _authenticate(self, login, password):
return self._db.find_user(login, password) # return user or None
def _create_guest(self):
from pyqt6_scaffold import BaseUser
from pyqt6_scaffold.contrib.auth import Role, RoleLevel
return BaseUser(id=0, name="Guest", role=Role("Guest", RoleLevel.GUEST.value))
Navigation to login_success_target with {"user": user} in context happens automatically. Override _on_login_success(user) to change this behaviour.
BaseMainWindow
A QMainWindow with a QTabWidget populated from a role-filtered tab registry. User name and a logout button are shown in the status bar.
class MainWindow(BaseMainWindow):
window_title = "My App"
logo_path = "resources/logo.png" # shown in status bar; empty = no logo
logout_label = "Log out"
logout_confirm_msg = "Are you sure?" # empty = no confirmation dialog
logout_target = "login"
tabs = [
("Catalogue", 0, CatalogueTab),
("Management", 100, ManagementTab),
("Orders", 75, OrdersTab),
]
Each entry in tabs is a (title, min_level, TabClass) triple. A tab is added only when user.role.level >= min_level. If the tab list depends on runtime state, override tabs as a property:
@property
def tabs(self):
level = self._user.role.level
return [
("Catalogue", 0, CatalogueTab),
*([("Management", 100, ManagementTab)] if level >= 100 else []),
]
Tabs
from pyqt6_scaffold.contrib.tabs import BaseTab, CardListTab, FilterField, CRUDTab, AnalyticsTab
All tab classes follow the same four-method template as BaseWindow and receive db and user in their constructor.
BaseTab
Minimal base with _define_widgets, _tune_layouts, _connect_slots, _load_data. Use when none of the specialised subclasses fit.
CardListTab
A QListView driven by a BaseCardModel + CardDelegate pair, with an optional filter bar built from FilterField descriptors.
class ProductListTab(CardListTab):
model_class = ProductModel
delegate_class = ProductDelegate
permission = "products.filter" # filter bar shown only if user has this perm
count_template = "Found: {n} items" # empty = no count label
refresh_label = "Refresh"
filters = [
FilterField("search", "_search",
placeholder="Search...", stretch=1),
FilterField("sort", "_sort",
choices=[("Default", None), ("Stock ↑", "asc"), ("Stock ↓", "desc")]),
FilterField("combo", "_supplier",
ref_table="supplier", ref_id="supplier_id",
ref_name="supplier_name", all_label="All suppliers"),
]
def _query(self):
return self._db.get_products(
search=self._filter_value("_search") or "",
supplier_id=self._filter_value("_supplier"),
sort_order=self._filter_value("_sort"),
)
FilterField kinds:
kind |
Widget created | Signal connected |
|---|---|---|
"search" |
QLineEdit |
textChanged |
"combo" |
QComboBox populated from db.get_reference() |
currentIndexChanged |
"sort" |
QComboBox with static choices list |
currentIndexChanged |
All signals trigger _load_data → _query → model.refresh. _filter_value(attr) returns None for all filters when the user lacks the declared permission.
selected_row() returns the UserRole tuple of the currently selected item, or None.
CRUDTab
CardListTab extended with a right-side form (insert / update / delete). The list and form sit inside a horizontal QSplitter.
class ProductFormTab(CRUDTab):
model_class = ProductModel
delegate_class = ProductDelegate
form_title = "Product"
add_label = "Add"
save_label = "Save"
delete_label = "Delete"
clear_label = "Clear"
no_selection_msg = "Select a row first."
delete_confirm_msg = "Delete this record? This cannot be undone."
def _query(self):
return self._db.get_products()
def _define_form_widgets(self):
self._name = QLineEdit()
self._price = QDoubleSpinBox()
def _tune_form_layout(self):
fl = QFormLayout()
fl.addRow("Name:", self._name)
fl.addRow("Price:", self._price)
return fl
def _fill_form(self, row):
self._name.setText(row[1])
self._price.setValue(float(row[3]))
def _clear_form(self):
self._name.clear()
self._price.setValue(0)
def _validate(self):
if not self._name.text().strip():
QMessageBox.warning(self, "Error", "Name is required.")
return False
return True
def _get_insert_query(self):
return ("INSERT INTO product(name, price) VALUES(%s, %s)",
(self._name.text().strip(), self._price.value()))
def _get_update_query(self):
return ("UPDATE product SET name=%s, price=%s WHERE id=%s",
(self._name.text().strip(), self._price.value(), self._editing_id))
def _get_delete_query(self, row_id):
return ("DELETE FROM product WHERE id=%s", (row_id,))
When the default _on_add / _on_save / _on_delete handlers are insufficient (e.g. the insert returns a generated ID needed for a second query, or deletion requires a pre-check), override them completely.
self._editing_id holds the primary key of the currently selected row (None when in add mode).
AnalyticsTab
A summary label and two side-by-side read-only QTableWidgets.
class SalesTab(AnalyticsTab):
stats_title = "By category"
top_title = "Top products"
refresh_label = "Refresh"
def _load_summary(self):
with self._db.execute("SELECT COUNT(*) FROM orders") as c:
self.summary_label.setText(f"Total orders: {c.fetchone()[0]}")
def _load_stats(self):
with self._db.execute("SELECT category, COUNT(*) FROM orders GROUP BY 1") as c:
self._fill_table(self.stats_table, c.fetchall(), ["Category", "Count"])
def _load_top(self):
with self._db.execute("SELECT name, revenue FROM top_products LIMIT 10") as c:
self._fill_table(self.top_table, c.fetchall(), ["Product", "Revenue"])
Delegates
from pyqt6_scaffold.contrib.delegates import CardDelegate, Section, AsideBlock
A QStyledItemDelegate that renders cards driven entirely by class attributes. Pair it with BaseCardModel and QListView.
Section
One body row of a card.
| Attribute | Type | Description |
|---|---|---|
cols |
list[int] |
Row tuple indices joined into one line |
bold |
bool |
Draw in bold font |
prefix |
str |
Text prepended to the first column value |
suffix |
str |
Text appended to the last column value |
separator |
str |
Separator between multiple columns (default " ") |
date |
bool |
Format value as dd.mm.yyyy |
price |
bool |
Strikethrough-price mode: old price in red, discounted in black |
price_discount_col |
int | None |
Index of the discount column (0–100); required with price=True |
color |
str | None |
CSS colour string; None = inherit |
AsideBlock
Right-side block of a card (discount badge, delivery date, status, etc.).
| Attribute | Type | Description |
|---|---|---|
col |
int |
Row tuple index for the main value |
label |
str |
Small header above the value (\n for line breaks) |
suffix |
str |
Text appended to the value |
date |
bool |
Format value as dd.mm.yyyy |
width |
int |
Block width in pixels |
large |
bool |
Large bold value (e.g. discount %). False = label + value stacked and centred |
CardDelegate
class ProductDelegate(CardDelegate):
card_height = 150
photo_col = 10 # row index for image path; None = no photo
photo_width = 120
placeholder = "resources/picture.png"
body = [
Section(cols=[3, 1], bold=True, separator=" | "),
Section(cols=[2], prefix="Description: "),
Section(cols=[4], prefix="Manufacturer: "),
Section(cols=[7], prefix="Price: ", suffix=" USD",
price=True, price_discount_col=8),
Section(cols=[9], prefix="In stock: "),
]
aside = AsideBlock(col=8, label="Discount\n", suffix="%", width=110, large=True)
Additional class attributes for styling:
| Attribute | Default | Description |
|---|---|---|
border_selected |
"#1F6FB2" |
Border colour when selected |
border_normal |
"#C0C0C0" |
Border colour in default state |
bg_default |
"white" |
Card background when model returns no BackgroundRole |
padding |
8 |
Inner padding in pixels |
Photo files are cached per subclass. If photo_col points to an empty string or an invalid path, placeholder is used instead.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyqt6_scaffold-1.0.1.tar.gz.
File metadata
- Download URL: pyqt6_scaffold-1.0.1.tar.gz
- Upload date:
- Size: 44.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9be0c1b2ee6e9a3a51c49fe8781b22a782a494960e2c7d9ea57c9de77ae86299
|
|
| MD5 |
3d6b3e4bcf55499fa2d7b93cb04ed6e3
|
|
| BLAKE2b-256 |
6cb4679bcaa2bc59c10fa9be23fa1fb5003c65dcdf76082b9bafcb895c33da61
|
File details
Details for the file pyqt6_scaffold-1.0.1-py3-none-any.whl.
File metadata
- Download URL: pyqt6_scaffold-1.0.1-py3-none-any.whl
- Upload date:
- Size: 44.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
775079f3b94a5f55beb16af2dc1d6b4b4caad8a4b0588efc56033ccb708f9595
|
|
| MD5 |
93cb2883eac3246784392500b33eb19a
|
|
| BLAKE2b-256 |
9e17647d88aa94026cc85acbffe89bfce5a12ec83b6b274392ed231ab5a695d7
|