Skip to main content

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 a QColor or QFont for 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_querymodel.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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyqt6_scaffold-1.0.0.post1-py3-none-any.whl (43.2 kB view details)

Uploaded Python 3

File details

Details for the file pyqt6_scaffold-1.0.0.post1-py3-none-any.whl.

File metadata

File hashes

Hashes for pyqt6_scaffold-1.0.0.post1-py3-none-any.whl
Algorithm Hash digest
SHA256 a354d5272142a12d2f812ea2843814be2eb59b78c81e46afa7afbf632f87e3ee
MD5 68a6a9efb7d59081f7afabf2b1818896
BLAKE2b-256 dd34c1252331bf64fe4955fb5b8c99e73c863ef9185dfaebe8814d54b3884730

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page