Initial Commit

This commit is contained in:
Daniel Haus 2026-03-06 16:05:24 +03:00
commit 4dbdc7d793
18 changed files with 2384 additions and 0 deletions

View file

@ -0,0 +1 @@
# SPDX-License-Identifier: LGPL-3.0-or-later

View 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()

View 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()

View 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

View 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()

View 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

View 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