whisker.qt
whisker/qt.py
Copyright © 2011-2020 Rudolf Cardinal (rudolf@pobox.com).
This file is part of the Whisker Python client library.
Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
Qt classes for Whisker GUI tasks.
- class whisker.qt.DatabaseModelMixin(session: Session, listdata: List[Any], **kwargs)[source]
Mixin to provide functions that operate on items within a Qt Model and talk to an underlying SQLAlchemy database session.
Typically mixed in like this:
class MyListModel(QAbstractListModel, DatabaseModelMixin): pass
- Parameters:
session – SQLAlchemy
Session
listdata – data; a collection of SQLAlchemy ORM objects in a format that behaves like a list
kwargs – additional parameters for superclass (required for mixins)
- delete_item(row_index: bool, delete_from_session: bool = True) None [source]
Deletes an item from the collection (and optionally from the SQLAlchemy session).
- Parameters:
row_index – index
delete_from_session – also delete the ORM object from the SQLAlchemy session? Default is
True
.
- Raises:
ValueError – if index invalid
- get_object(index: int) Any [source]
- Parameters:
index – integer index
- Returns:
ORM object at the index, or
None
if the index is out of bounds
- insert_at_index(obj: object, index: int | None = None, add_to_session: bool = True, flush: bool = True) None [source]
Inserts an ORM object into the data list.
- Parameters:
obj – SQLAlchemy ORM object to insert
index – add it at this index location; specifying
None
adds it at the end of the listadd_to_session – also add the object to the SQLAlchemy session? Default is
True
.flush – flush the SQLAlchemy session after adding the object? Only applicable if
add_to_session
is true.
- Raises:
ValueError – if index invalid
- item_deletable(rowindex: int) bool [source]
Override this if you need to prevent rows being deleted.
- exception whisker.qt.EditCancelledException[source]
Exception to represent that the user cancelled editing.
- class whisker.qt.GarbageCollector(parent: SimpleClass, debug: bool = False, interval_ms=10000)[source]
Disable automatic garbage collection and instead collect manually every INTERVAL milliseconds.
This is done to ensure that garbage collection only happens in the GUI thread, as otherwise Qt can crash.
See:
https://riverbankcomputing.com/pipermail/pyqt/2011-August/030378.html
http://pydev.blogspot.co.uk/2014/03/should-python-garbage-collector-be.html
- Parameters:
parent – parent
QObject
debug – be verbose about garbage collection
interval_ms – period/interval (in ms) at which to perform garbage collection
- class whisker.qt.GenericAttrTableModel(data: ~typing.List[~typing.Any], header: ~typing.List[~typing.Tuple[str, str]], session: ~sqlalchemy.orm.session.Session, default_sort_column_name: str | None = None, default_sort_order: int = <MagicMock name='mock.AscendingOrder' id='139817544672544'>, deletable: bool = True, parent: SimpleClass | None = None, **kwargs)[source]
Takes a list of objects and a list of column headers; provides a view on it using func:str. That is, for a list of objects
[a, b, c]
and a list of headers["h1", "h2", "h3"]
, it provides a table view likeh1
h2
h3
str(a.h1)
str(a.h2)
str(a.h3)
str(b.h1)
str(b.h2)
str(b.h3)
str(c.h1)
str(c.h2)
str(c.h3)
Note that it MODIFIES THE LIST PASSED TO IT.
Sorting: consider
QSortFilterProxyModel
… but not clear that can do arbitrary column sorts, since its sorting is via itslessThan()
function.The tricky part is keeping selections persistent after sorting. Not achieved yet. Simpler to wipe the selections.
- Parameters:
data – data; a collection of SQLAlchemy ORM objects in a format that behaves like a list
header – list of
(colname, attr_or_func)
tuples. The first part,colname
, is displayed to the user. The second part,attr_or_func
, is used to retrieve data. For each objectobj
, the view will callthing = getattr(obj, attr_or_func)
. Ifthing
is callable (i.e. is a member function), the view will displaystr(thing())
; otherwise, it will displaystr(thing)
.session – SQLAlchemy
Session
default_sort_column_name – column to sort by to begin with, or
None
to respect the original order ofdata
default_sort_order – default sort order (default is ascending order)
deletable – may the user delete objects from the collection?
parent – parent (owning)
QObject
kwargs – additional parameters for superclass (required for mixins)
- columnCount(parent: <MagicMock id='139817549352480'> = <MagicMock name='mock()' id='139817544632016'>, *args, **kwargs)[source]
Qt override.
- Parameters:
parent – parent object, specified by
QModelIndex
args – unnecessary? Certainly unused.
kwargs – unnecessary? Certainly unused.
- Returns:
number of columns
- data(index: <MagicMock id='139817549352480'>, role: int = <MagicMock name='mock.DisplayRole' id='139817544656256'>) str | None [source]
Returns the data for a given row/column (in this case as a string).
Overrides
QAbstractTableModel.data()
; see http://doc.qt.io/qt-5/qabstractitemmodel.html.- Parameters:
index –
QModelIndex
, which specifies the row/column numberrole – a way of specifying the type of view on the data; here, we only accept
Qt.DisplayRole
- Returns:
string representation of the relevant attribute (or member function result) for the relevant SQLAlchemy ORM object, or
None
- get_default_sort() Tuple[int, int] [source]
- Returns:
(default_sort_column_number, default_sort_order)
where the column number is zero-based and the sort order is as per__init__()
- headerData(col: int, orientation: int, role: int = <MagicMock name='mock.DisplayRole' id='139817544656256'>) str | None [source]
Qt override.
Returns the column heading for a particular column.
- Parameters:
col – column number
orientation – required to be
Qt.Horizontal
role – required to be
Qt.DisplayRole
- Returns:
column header string, or
None
- rowCount(parent: <MagicMock id='139817549352480'> = <MagicMock name='mock()' id='139817544632016'>, *args, **kwargs)[source]
Counts the number of rows.
Overrides
QAbstractTableModel.rowCount()
; see http://doc.qt.io/qt-5/qabstractitemmodel.html.- Parameters:
parent – parent object, specified by
QModelIndex
args – unnecessary? Certainly unused.
kwargs – unnecessary? Certainly unused.
- Returns:
“number of rows under the given parent. When the parent is valid… the number of children of
parent
.” Here: the number of objects in the list.- Return type:
In the Qt original
- class whisker.qt.GenericAttrTableView(session: Session, modal_dialog_class, parent: SimpleClass | None = None, sortable: bool = True, stretch_last_section: bool = True, readonly: bool = False, **kwargs)[source]
Provides a
QTableView
view on SQLAlchemy data.Signals:
selection_changed(QItemSelection, QItemSelection)
- Parameters:
session – SQLAlchemy
Session
modal_dialog_class – class that is a subclass of
TransactionalEditDialogMixin
parent – parent (owning)
QObject
sortable – should we sort the data?
stretch_last_section – stretch the last column (section) horizontally?
readonly – read-only view?
kwargs – additional parameters for superclass (required for mixins)
- get_selected_modelindex() <MagicMock id='139817549352480'> | None [source]
Gets the model index of the selected item.
- Returns:
a
QModelIndex
orNone
- class whisker.qt.GenericListModel(data, session: Session, parent: SimpleClass | None = None, **kwargs)[source]
Takes a list and provides a view on it using
str()
. That is, for a list of object[a, b, c]
, it displays a list view with items (rows) like this:str(a)
str(b)
str(c)
Note that it MODIFIES THE LIST PASSED TO IT.
- data(index: <MagicMock id='139817549352480'>, role: int = <MagicMock name='mock.DisplayRole' id='139817544656256'>) str | None [source]
Returns the data for a given row (in this case as a string).
Overrides
QAbstractListModel.data()
; see http://doc.qt.io/qt-5/qabstractitemmodel.html.- Parameters:
index –
QModelIndex
, which specifies the row numberrole – a way of specifying the type of view on the data; here, we only accept
Qt.DisplayRole
- Returns:
string representation of the object, or
None
- rowCount(parent: <MagicMock id='139817549352480'> = <MagicMock name='mock()' id='139817544632016'>, *args, **kwargs) int [source]
Counts the number of rows.
Overrides
QAbstractListModel.rowCount()
; see http://doc.qt.io/qt-5/qabstractitemmodel.html.- Parameters:
parent – parent object, specified by
QModelIndex
args – unnecessary? Certainly unused.
kwargs – unnecessary? Certainly unused.
- Returns:
“number of rows under the given parent. When the parent is valid… the number of children of
parent
.” Here: the number of objects in the list.- Return type:
In the Qt original
- class whisker.qt.ModalEditListView(session: Session, modal_dialog_class: Type[TransactionalEditDialogMixin], *args, readonly: bool = False, **kwargs)[source]
A version of
QListView
that supports SQLAlchemy handling of its objects.- Parameters:
session – SQLAlchemy
Session
modal_dialog_class – class that is a subclass of
TransactionalEditDialogMixin
readonly – read-only view?
args – positional arguments to superclass
kwargs – keyword arguments to superclass
- class whisker.qt.RadioGroup(value_text_tuples: Iterable[Tuple[str, Any]], default: Any | None = None)[source]
Framework for radio buttons.
- Parameters:
value_text_tuples – list of
(value, text)
tuples, wherevalue
is the value stored to Python object andtext
is the text shown to the userdefault – default value
- add_buttons_to_layout(layout: <MagicMock id='139817549505488'>) None [source]
Adds its button widgets to the specified Qt layout.
- class whisker.qt.StatusMixin(name: str, logger: Logger, thread_info: bool = True, caller_info: bool = True, **kwargs)[source]
Add this to a
QObject
to provide easy Python logging and Qt signal-based status/error messaging.It emits status messages to a Python log and to Qt signals.
Uses the same function names as Python logging, for predictability.
Signals:
status_sent(str, str)
error_sent(str, str)
- Parameters:
name – name to add to log output
logger – Python logger to send output to
thread_info – show thread information as part of the output?
caller_info – add information about the function that sent the message?
kwargs – additional parameters for superclass (required for mixins)
- class whisker.qt.TextLogElement(maximum_block_count: int = 1000, font_size_pt: int = 10, font_family: str = 'Courier', title: str = 'Log')[source]
Add a text log to your dialog box.
Encapsulates a
QWidget
representing a textual log in a group box.Use this to embed a log within another Qt dialogue.
(This is nothing to do with the Python
logging
logs.)- Parameters:
maximum_block_count – the maximum number of blocks (log entries) to show in this window
font_size_pt – font size, in points
font_family – font family
title – title for group box
- class whisker.qt.TransactionalEditDialogMixin(session: ~sqlalchemy.orm.session.Session, obj: object, layout: <MagicMock id='139817549505488'>, readonly: bool = False, **kwargs)[source]
Mixin for a config-editing dialogue. Use it as:
class MyEditingDialog(TransactionalEditDialogMixin, QDialog): pass
Wraps the editing in a
SAVEPOINT
transaction. The caller must stillcommit()
afterwards, but any rollbacks are automatic.See also http://pyqt.sourceforge.net/Docs/PyQt5/multiinheritance.html for the
super().__init__(..., **kwargs)
chain.Signals:
ok()
- Parameters:
session – SQLAlchemy
Session
obj – SQLAlchemy ORM object being edited
layout – Qt class`QLayout` to encapsulate within the class:QDialog’s main layout. This should contain all the editing widgets. The mixin wraps that with OK/cancel buttons.
readonly – make this a read-only view
kwargs – additional parameters to superclass
- dialog_to_object(obj: object) None [source]
The class using the mixin must override this to write information from the dialogue box to the SQLAlchemy ORM object. It should raise an exception if validation fails. (This class isn’t fussy about which exception that is; it checks for
Exception
.)
- edit_in_nested_transaction() int [source]
Pops up the dialog, allowing editing.
Does so within a database transaction.
If the user clicks OK and the data validates, commits the transaction.
If the user cancels, rolls back the transaction.
We want it nestable, so that the config dialog box can edit part of the config, reversibly, without too much faffing around.
Development notes:
We could nest using SQLAlchemy’s support for nested transactions, which works whether or not the database itself supports nested transactions via the
SAVEPOINT
method.With sessions, one must use
autocommit=True
and thesubtransactions
flag; these are virtual transactions handled by SQLAlchemy.Alternatively one can use
begin_nested()
orbegin(nested=True)
, which usesSAVEPOINT
.The following databases support the
SAVEPOINT
method:MySQL with InnoDB
SQLite, from v3.6.8 (2009)
PostgreSQL
Which is better? The author suggests
SAVEPOINT
for most applications (https://groups.google.com/forum/#!msg/sqlalchemy/CaZyyMx7_8Y/otM0BzDyaigJ). Regarding subtransactions: “When a rollback is issued, the subtransaction will directly roll back the innermost real transaction, however each subtransaction still must be explicitly rolled back to maintain proper stacking of subtransactions.” So it’s not as simple as you might guess.See:
http://docs.sqlalchemy.org/en/latest/orm/session_transaction.html
http://stackoverflow.com/questions/2336950/transaction-within-transaction
http://stackoverflow.com/questions/1306869/are-nested-transactions-allowed-in-mysql
http://stackoverflow.com/questions/1654857/nested-transactions-with-sqlalchemy-and-sqlite
Let’s use the
SAVEPOINT
technique.No. Even this fails:
with self.session.begin_nested(): self.config.port = 5000
We were aiming for this:
try: with self.session.begin_nested(): result = self.exec_() # enforces modal if result == QDialog.Accepted: logger.debug("Config changes accepted; will be committed") else: logger.debug("Config changes cancelled") raise EditCancelledException() except EditCancelledException: logger.debug("Config changes rolled back.") except: logger.debug("Exception within nested transaction. " "Config changes will be rolled back.") raise # Other exceptions will be handled as normal.
No… the commit fails, and this SQL is emitted:
SAVEPOINT sa_savepoint_1 UPDATE table SET field=? RELEASE SAVEPOINT sa_savepoint_1 -- sensible ROLLBACK TO SAVEPOINT sa_savepoint_1 -- not sensible -- raises sqlite3.OperationalError: no such savepoint: sa_savepoint_1
May be this bug:
- The bugs are detailed in
sqlalchemy/dialects/sqlite/pysqlite.py
; see “Serializable isolation / Savepoints / Transactional DDL”
- The bugs are detailed in
We work around it by adding hooks to the engine as per that advice; see
db.py
- class whisker.qt.ViewAssistMixin(session: Session, modal_dialog_class: Type[TransactionalEditDialogMixin], readonly: bool = False, *args, **kwargs)[source]
Mixin to add SQLAlchemy database-handling functions to a
ViewAssistMixin
.Typically used like this:
class MyListView(QListView, ViewAssistMixin): pass
Signals:
selection_changed(QItemSelection, QItemSelection)
selected_maydelete(bool, bool)
- Parameters:
session – SQLAlchemy
Session
modal_dialog_class – class that is a subclass of
TransactionalEditDialogMixin
readonly – read-only view?
kwargs – additional parameters for superclass (required for mixins)
- add_in_nested_transaction(new_object: object, at_index: int | None = None) int | None [source]
Starts an SQLAlchemy nested transaction (which uses
SAVEPOINT
SQL); adds the new object into the session; edits the object. If the editing is cancelled, cancel the addition. Otherwise, the object is added to the session (in a nested transaction that might be cancelled by a caller) and to the model/list.- Parameters:
new_object – SQLAlchemy ORM object to insert
at_index – add it at this index location; specifying
None
(the default) adds it at the end of the list;0
is the start
- edit(index: <MagicMock id='139817549352480'>, trigger: <MagicMock name='mock.EditTrigger' id='139817544607632'>, event: <MagicMock id='139817549404816'>) bool [source]
Edits the specified object.
Overrides
QAbstractItemView::edit()
; see http://doc.qt.io/qt-5/qabstractitemview.html#edit-1.- Parameters:
index –
QModelIndex
of the object to edittrigger – action that caused the editing process
event – associated
QEvent
- Returns:
True
if the view’sState
is nowEditingState
; otherwiseFalse
- edit_by_modelindex(index: <MagicMock id='139817549352480'>, readonly: bool | None = None) None [source]
Edits the specified object.
- Parameters:
index –
QModelIndex
of the object to editreadonly – read-only view? If
None
(the default), uses the setting fromself
.
- edit_selected(readonly: bool | None = None) None [source]
Edits the currently selected object.
- Parameters:
readonly – read-only view? If
None
(the default), uses the setting fromself
.
- get_selected_modelindex() <MagicMock id='139817549352480'> | None [source]
Returns the
QModelIndex
of the currently selected row, orNone
.Should be overridden in derived classes.
- get_selected_object() object | None [source]
Returns the SQLAlchemy ORM object that’s currently selected, or
None
.
- get_selected_row_index() int | None [source]
Gets the index of the currently selected row.
- Returns:
index, or
None
- go_to(row: int | None) None [source]
Makes a specific row (by index) the currently selected row.
- Parameters:
row – row index, or
None
to select the last row if there is one
- insert_at_end(obj: object, add_to_session: bool = True, flush: bool = True) None [source]
Insert a SQLAlchemy ORM object into the model at the end of the list (and, optionally, into the SQLAlchemy session).
- Parameters:
obj – SQLAlchemy ORM object to insert
add_to_session – also add the object to the SQLAlchemy session? Default is
True
.flush – flush the SQLAlchemy session after adding the object? Only applicable if
add_to_session
is true.
- insert_at_index(obj: object, index: int | None = None, add_to_session: bool = True, flush: bool = True) None [source]
Insert an SQLAlchemy ORM object into the model at a specific location (and, optionally, into the SQLAlchemy session).
- Parameters:
obj – SQLAlchemy ORM object to insert
index – index to insert at;
None
for end,0
for startadd_to_session – also add the object to the SQLAlchemy session? Default is
True
.flush – flush the SQLAlchemy session after adding the object? Only applicable if
add_to_session
is true.
- insert_at_start(obj: object, add_to_session: bool = True, flush: bool = True) None [source]
Insert a SQLAlchemy ORM object into the model at the start of the list (and, optionally, into the SQLAlchemy session).
- Parameters:
obj – SQLAlchemy ORM object to insert
add_to_session – also add the object to the SQLAlchemy session? Default is
True
.flush – flush the SQLAlchemy session after adding the object? Only applicable if
add_to_session
is true.
- remove_by_index(row_index: int, delete_from_session: bool = True) None [source]
Remove a specified SQLAlchemy ORM object from the list.
- Parameters:
row_index – index of object to remove
delete_from_session – also delete it from the SQLAlchemy session? (default:
True
)
- whisker.qt.exit_on_exception(func)[source]
Decorator to stop whole program on exceptions (use for threaded slots).
See
- Parameters:
func – the function to decorate
- Returns:
the decorated function