Initial Commit
This commit is contained in:
commit
4dbdc7d793
18 changed files with 2384 additions and 0 deletions
1
pyqt6_scaffold/core/__init__.py
Normal file
1
pyqt6_scaffold/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
127
pyqt6_scaffold/core/composer.py
Normal file
127
pyqt6_scaffold/core/composer.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from .objects import NavigateRequest
|
||||
from .windows import BaseWindow
|
||||
from .logging import setup_logger
|
||||
from .database import AbstractDatabase
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||
from typing import Dict
|
||||
|
||||
log = setup_logger(__name__)
|
||||
|
||||
class Composer(QObject):
|
||||
"""
|
||||
Application router and window lifecycle manager.
|
||||
|
||||
Manages window registration, navigation between windows,
|
||||
and holds the active database connection. Acts as the
|
||||
central hub of the application.
|
||||
|
||||
Args:
|
||||
app: QApplication instance.
|
||||
db: Connected AbstractDatabase instance.
|
||||
"""
|
||||
navigate_request = pyqtSignal(NavigateRequest)
|
||||
|
||||
def __init__(self, app: QApplication, db: AbstractDatabase):
|
||||
super().__init__()
|
||||
self._db = db
|
||||
self._current = None
|
||||
self._app = app
|
||||
self._registry: Dict[str, Dict[str, object]] = dict()
|
||||
self._current_ctx: Dict[str, Dict[str, object]] = dict()
|
||||
|
||||
self.navigate_request.connect(self.navigate)
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
"""
|
||||
Return the current navigation context.
|
||||
"""
|
||||
return self._current_ctx
|
||||
|
||||
def register(self, name: str,
|
||||
window: type[BaseWindow],
|
||||
lazy: bool = True):
|
||||
"""
|
||||
Register a window class under a given name.
|
||||
|
||||
Args:
|
||||
name: Unique string identifier for the window.
|
||||
window: A subclass of BaseWindow.
|
||||
lazy: If True, the window is instantiated on first navigation.
|
||||
If False, the window is instantiated immediately.
|
||||
|
||||
Raises:
|
||||
TypeError: If window is not a subclass of BaseWindow.
|
||||
"""
|
||||
if not issubclass(window, BaseWindow):
|
||||
raise TypeError(f"{window} is not a descendant of BaseWindow")
|
||||
|
||||
entry = {
|
||||
"class": window,
|
||||
"instance": None,
|
||||
"lazy": lazy
|
||||
}
|
||||
|
||||
if not lazy:
|
||||
entry["instance"] = window(self, self._db)
|
||||
|
||||
self._registry[name] = entry
|
||||
|
||||
def _switch_window(self, window):
|
||||
"""
|
||||
Close the current window and show the new one.
|
||||
"""
|
||||
if self._current:
|
||||
self._current.close()
|
||||
self._current = window
|
||||
self._current.show()
|
||||
|
||||
def _create_window(self, name: str) -> BaseWindow:
|
||||
"""
|
||||
Instantiate or retrieve the window registered under name.
|
||||
|
||||
For lazy windows, creates a new instance on every call.
|
||||
For eager windows, returns the existing instance.
|
||||
"""
|
||||
entry = self._registry[name]
|
||||
|
||||
if not entry["lazy"]:
|
||||
return entry["instance"]
|
||||
|
||||
instance = entry["class"](self, self._db)
|
||||
return instance
|
||||
|
||||
@pyqtSlot(NavigateRequest)
|
||||
def navigate(self, request: NavigateRequest):
|
||||
"""
|
||||
Handle a NavigateRequest signal and switch to the target window.
|
||||
|
||||
Raises:
|
||||
KeyError: If the target window is not registered.
|
||||
"""
|
||||
if request.target not in self._registry:
|
||||
raise KeyError("Window is not registered")
|
||||
|
||||
self._current_ctx = request.context
|
||||
window = self._create_window(request.target)
|
||||
self._switch_window(window)
|
||||
|
||||
def run(self, start: str):
|
||||
"""
|
||||
Start the application from the given window.
|
||||
|
||||
Args:
|
||||
start: Name of the window to show first.
|
||||
|
||||
Raises:
|
||||
KeyError: If the start window is not registered.
|
||||
"""
|
||||
if start not in self._registry:
|
||||
raise KeyError("Window is not registered")
|
||||
|
||||
window = self._create_window(start)
|
||||
self._switch_window(window)
|
||||
return self._app.exec()
|
||||
171
pyqt6_scaffold/core/database.py
Normal file
171
pyqt6_scaffold/core/database.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from .logging import setup_logger
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
log = setup_logger(__name__)
|
||||
|
||||
class CursorContext:
|
||||
"""
|
||||
Proxy wrapper around a database cursor.
|
||||
Manages cursor lifecycle as a context manager and delegates
|
||||
all attribute access to the underlying cursor object.
|
||||
"""
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Return the underlying cursor.
|
||||
"""
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""
|
||||
Close the cursor on exit.
|
||||
"""
|
||||
self._cursor.close()
|
||||
return False
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._cursor, item)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cursor)
|
||||
|
||||
def __next__(self):
|
||||
return next(self._cursor)
|
||||
|
||||
class AbstractDatabase(ABC):
|
||||
"""
|
||||
Abstract base class for database connections.
|
||||
|
||||
Provides a unified interface for connecting, executing queries,
|
||||
and managing transactions. Subclasses must implement _connect()
|
||||
and placeholder.
|
||||
|
||||
Args:
|
||||
strict: If True, raises RuntimeError when connect() is called
|
||||
on an already open connection. If False, closes the
|
||||
existing connection silently.
|
||||
"""
|
||||
def __init__(self, strict: bool = True):
|
||||
self._conn = None
|
||||
self._strict = strict
|
||||
|
||||
@abstractmethod
|
||||
def _connect(self):
|
||||
"""
|
||||
Establish and return a database connection.
|
||||
|
||||
Must be implemented by subclasses. Read configuration from
|
||||
environment variables or any other source, then return a
|
||||
DB-API 2.0 compatible connection object.
|
||||
|
||||
Returns:
|
||||
A database connection object.
|
||||
"""
|
||||
pass
|
||||
|
||||
def connect(self, *args, **kwargs):
|
||||
"""
|
||||
Open the database connection.
|
||||
|
||||
Calls _connect() internally. Should not be overridden.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection is already open and strict=True.
|
||||
RuntimeError: If _connect() returns None.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
if self._strict:
|
||||
raise RuntimeError("Database connection already open")
|
||||
else:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception as e:
|
||||
log.error(f"Query execution failed: {e}")
|
||||
return None
|
||||
|
||||
conn = self._connect(*args, **kwargs)
|
||||
|
||||
if conn is None:
|
||||
raise RuntimeError("_connect() must return a connection object")
|
||||
|
||||
self._conn = conn
|
||||
return conn
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
Close the database connection and release resources.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
try:
|
||||
self._conn.close()
|
||||
finally:
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def connection(self):
|
||||
"""
|
||||
Return the active connection object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the database is not connected.
|
||||
"""
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database not connected")
|
||||
return self._conn
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def placeholder(self) -> str:
|
||||
"""
|
||||
SQL parameter placeholder for the target dialect.
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
def execute(self, sql: str, params: tuple = (), autocommit: bool = False) -> CursorContext | None:
|
||||
"""
|
||||
Execute a SQL query and return a CursorContext.
|
||||
|
||||
Args:
|
||||
sql: SQL query string with placeholders.
|
||||
params: Query parameters as a tuple.
|
||||
autocommit: If True, commits the transaction after execution.
|
||||
|
||||
Returns:
|
||||
CursorContext wrapping the cursor, or raises on failure.
|
||||
|
||||
Raises:
|
||||
Exception: Re-raises any database error after rollback.
|
||||
"""
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(sql, params)
|
||||
|
||||
if autocommit:
|
||||
self.connection.commit()
|
||||
|
||||
return CursorContext(cursor)
|
||||
|
||||
except Exception:
|
||||
self.connection.rollback()
|
||||
log.exception("Query execution failed")
|
||||
raise
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Support use as a context manager for connection lifecycle.
|
||||
Calls disconnect() on exit.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""
|
||||
Support use as a context manager for connection lifecycle.
|
||||
Calls disconnect() on exit.
|
||||
"""
|
||||
self.disconnect()
|
||||
32
pyqt6_scaffold/core/logging.py
Normal file
32
pyqt6_scaffold/core/logging.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
|
||||
def setup_logger(name: str = "scaffold"):
|
||||
"""
|
||||
Create and configure a logger with console output.
|
||||
|
||||
Returns an existing logger if one with the given name
|
||||
already exists to avoid duplicate handlers.
|
||||
|
||||
Args:
|
||||
name: Logger name, typically __name__.
|
||||
|
||||
Returns:
|
||||
Configured logging.Logger instance.
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="[%(asctime)s] %(levelname)-8s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S"
|
||||
)
|
||||
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
|
||||
logger.addHandler(console)
|
||||
return logger
|
||||
248
pyqt6_scaffold/core/models.py
Normal file
248
pyqt6_scaffold/core/models.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from abc import ABC
|
||||
from typing import List, Sequence, Any
|
||||
from PyQt6.QtGui import QColor, QBrush, QFont
|
||||
from PyQt6.QtCore import (
|
||||
Qt,
|
||||
QAbstractTableModel,
|
||||
QAbstractListModel,
|
||||
QModelIndex,
|
||||
QVariant
|
||||
)
|
||||
|
||||
class DataMixin(ABC):
|
||||
"""
|
||||
Mixin for Qt data models providing common data storage and display logic.
|
||||
|
||||
Intended to be used alongside QAbstractTableModel or QAbstractListModel.
|
||||
The refresh() method relies on beginResetModel() and endResetModel()
|
||||
provided by the Qt base class.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._data: List[Sequence[Any]] = list()
|
||||
|
||||
def row_background(self, data: tuple) -> QColor | None:
|
||||
"""
|
||||
Return a background color for the given row, or None for default.
|
||||
|
||||
Override in subclass to apply conditional row highlighting.
|
||||
|
||||
Args:
|
||||
data: The full row tuple from the dataset.
|
||||
"""
|
||||
pass
|
||||
|
||||
def row_foreground(self, data: tuple) -> QColor | None:
|
||||
"""
|
||||
Return a foreground (text) color for the given row, or None for default.
|
||||
|
||||
Override in subclass to apply conditional text coloring.
|
||||
|
||||
Args:
|
||||
data: The full row tuple from the dataset.
|
||||
"""
|
||||
pass
|
||||
|
||||
def row_font(self, data: tuple) -> QFont | None:
|
||||
"""
|
||||
Return a QFont for the given row, or None for default.
|
||||
|
||||
Override in subclass to apply conditional font styling,
|
||||
such as strikethrough or bold.
|
||||
|
||||
Args:
|
||||
data: The full row tuple from the dataset.
|
||||
"""
|
||||
pass
|
||||
|
||||
def refresh(self, data: List[Sequence[Any]]):
|
||||
"""
|
||||
Replace the model data and notify the view.
|
||||
|
||||
Args:
|
||||
data: New dataset as a list of sequences.
|
||||
"""
|
||||
self.beginResetModel()
|
||||
self._data = data
|
||||
self.endResetModel()
|
||||
|
||||
def row_data(self, row: int) -> tuple | None:
|
||||
"""
|
||||
Return the raw row tuple at the given index, or None if out of range.
|
||||
"""
|
||||
if 0 <= row < len(self._data):
|
||||
return self._data[row]
|
||||
return None
|
||||
|
||||
def row_display(self, data: Sequence[Any]):
|
||||
"""
|
||||
Return the string representation of a row for DisplayRole.
|
||||
|
||||
Override in subclass to customize how rows appear in the view.
|
||||
|
||||
Args:
|
||||
data: The full row tuple from the dataset.
|
||||
"""
|
||||
return str(data[0]) if data else ""
|
||||
|
||||
class BaseTableModel(DataMixin, QAbstractTableModel):
|
||||
"""
|
||||
Table model backed by a list of row tuples.
|
||||
|
||||
Subclasses must define the headers class attribute.
|
||||
Override row_display(), row_background(), row_foreground(),
|
||||
and row_font() to customize cell appearance.
|
||||
|
||||
Attributes:
|
||||
headers: Column header labels. Must be defined in subclass.
|
||||
"""
|
||||
# Column header labels displayed in the table view.
|
||||
# Define this in your subclass: headers = ["ID", "Name", "Price"]
|
||||
headers: List[str] = []
|
||||
|
||||
def rowCount(self, parent = QModelIndex()) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def columnCount(self, parent = QModelIndex()) -> int:
|
||||
return len(self.headers)
|
||||
|
||||
def row_display(self, data):
|
||||
"""
|
||||
Return the string representation of a single cell value.
|
||||
|
||||
Args:
|
||||
data: A single cell value from the dataset.
|
||||
"""
|
||||
return str(data) if data else ""
|
||||
|
||||
def data(self, index: QModelIndex, role = Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return QVariant()
|
||||
|
||||
match role:
|
||||
case Qt.ItemDataRole.DisplayRole:
|
||||
value = self._data[index.row()][index.column()]
|
||||
if value is not None:
|
||||
return self.row_display(value)
|
||||
else:
|
||||
return ""
|
||||
|
||||
case Qt.ItemDataRole.UserRole:
|
||||
return self._data[index.row()]
|
||||
|
||||
case Qt.ItemDataRole.BackgroundRole:
|
||||
color = self.row_background(self._data[index.row()])
|
||||
if color is None:
|
||||
return QVariant()
|
||||
brush = QBrush(color)
|
||||
return brush
|
||||
|
||||
case Qt.ItemDataRole.ForegroundRole:
|
||||
color = self.row_foreground(self._data[index.row()])
|
||||
if color is None:
|
||||
return QVariant()
|
||||
brush = QBrush(color)
|
||||
return brush
|
||||
|
||||
case Qt.ItemDataRole.FontRole:
|
||||
font = self.row_font(self._data[index.row()])
|
||||
if font is None:
|
||||
return QVariant()
|
||||
return font
|
||||
|
||||
return QVariant()
|
||||
|
||||
class BaseListModel(DataMixin, QAbstractListModel):
|
||||
"""
|
||||
List model backed by a list of row tuples.
|
||||
|
||||
Each row is treated as a single list item. DisplayRole
|
||||
is rendered via row_display() which returns the first
|
||||
element by default.
|
||||
|
||||
Override row_display() to customize how items appear in the view.
|
||||
"""
|
||||
def rowCount(self, parent = QModelIndex()) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def data(self, index: QModelIndex, role = Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return QVariant()
|
||||
|
||||
match role:
|
||||
case Qt.ItemDataRole.DisplayRole:
|
||||
value = self._data[index.row()]
|
||||
if value is not None:
|
||||
return self.row_display(value)
|
||||
return ""
|
||||
|
||||
case Qt.ItemDataRole.UserRole:
|
||||
return self._data[index.row()]
|
||||
|
||||
case Qt.ItemDataRole.BackgroundRole:
|
||||
color = self.row_background(self._data[index.row()])
|
||||
if color is None:
|
||||
return QVariant()
|
||||
brush = QBrush(color)
|
||||
return brush
|
||||
|
||||
case Qt.ItemDataRole.ForegroundRole:
|
||||
color = self.row_foreground(self._data[index.row()])
|
||||
if color is None:
|
||||
return QVariant()
|
||||
brush = QBrush(color)
|
||||
return brush
|
||||
|
||||
case Qt.ItemDataRole.FontRole:
|
||||
font = self.row_font(self._data[index.row()])
|
||||
if font is None:
|
||||
return QVariant()
|
||||
return font
|
||||
|
||||
return QVariant()
|
||||
|
||||
class BaseCardModel(DataMixin, QAbstractListModel):
|
||||
"""
|
||||
List model for card-based views using a custom QStyledItemDelegate.
|
||||
|
||||
Does not implement DisplayRole — visual rendering is handled
|
||||
entirely by a delegate. Raw row data is available via UserRole.
|
||||
|
||||
Note:
|
||||
You must provide a QStyledItemDelegate subclass to render items.
|
||||
Without a delegate, the view will appear empty.
|
||||
"""
|
||||
def rowCount(self, parent = QModelIndex()) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def data(self, index: QModelIndex, role = Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return QVariant()
|
||||
|
||||
match role:
|
||||
case Qt.ItemDataRole.UserRole:
|
||||
return self._data[index.row()]
|
||||
|
||||
case Qt.ItemDataRole.BackgroundRole:
|
||||
color = self.row_background(self._data[index.row()])
|
||||
if color is None:
|
||||
return QVariant()
|
||||
brush = QBrush(color)
|
||||
return brush
|
||||
|
||||
case Qt.ItemDataRole.ForegroundRole:
|
||||
color = self.row_foreground(self._data[index.row()])
|
||||
if color is None:
|
||||
return QVariant()
|
||||
brush = QBrush(color)
|
||||
return brush
|
||||
|
||||
case Qt.ItemDataRole.FontRole:
|
||||
font = self.row_font(self._data[index.row()])
|
||||
if font is None:
|
||||
return QVariant()
|
||||
return font
|
||||
|
||||
return QVariant()
|
||||
35
pyqt6_scaffold/core/objects.py
Normal file
35
pyqt6_scaffold/core/objects.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from typing import Any, Dict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class BaseUser:
|
||||
"""
|
||||
Minimal user representation.
|
||||
|
||||
id and role are typed as Any to support different
|
||||
authentication schemes (integer PK, UUID, custom role objects).
|
||||
"""
|
||||
id: Any
|
||||
name: str
|
||||
role: Any
|
||||
|
||||
@dataclass
|
||||
class NavigationContext:
|
||||
"""
|
||||
Container for data passed between windows during navigation.
|
||||
"""
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class NavigateRequest:
|
||||
"""
|
||||
Navigation event emitted by windows to request a screen transition.
|
||||
|
||||
Attributes:
|
||||
target: Registered name of the destination window.
|
||||
context: Data to pass to the destination window.
|
||||
"""
|
||||
target: str
|
||||
context: NavigationContext
|
||||
32
pyqt6_scaffold/core/windows.py
Normal file
32
pyqt6_scaffold/core/windows.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
class BaseWindow(QMainWindow):
|
||||
"""
|
||||
Base class for all application windows.
|
||||
|
||||
Provides a structured initialization sequence through four
|
||||
template methods that subclasses should override as needed.
|
||||
The composer and database are available via self._composer
|
||||
and self._db respectively.
|
||||
|
||||
_define_widgets: Create and initialize all widgets.
|
||||
_tune_layouts: Arrange widgets into layouts.
|
||||
_connect_slots: Connect signals to slots.
|
||||
_apply_windows_settings: Set window title, size, icons, and other properties.
|
||||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue