Skip to content

Commit

Permalink
Use QtPy abstraction layer (#144). GHA tests with PySide2 and PyQt5 (#…
Browse files Browse the repository at this point in the history
…146)

- Use `qtpy` as virtual Qt binding package. GHA unit tests are run with PySide2 and PyQt5 (#146)
- Add `pyqt_env.yml`  and `pyside_env.yml` environment files (#146)
- Update `CONTRIBUTING.md`, `README.md` and add documentation file (#146)

Co-authored-by: Sam Tygier <[email protected]> Danica Sugic <[email protected]>
  • Loading branch information
paskino and DanicaSTFC authored Jan 6, 2025
1 parent 6905536 commit 19be963
Show file tree
Hide file tree
Showing 40 changed files with 179 additions and 144 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ jobs:
strategy:
matrix:
python: [3.8, 3.11]
qtbindings: ['PySide2', 'PyQt5']
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
qt-bindings: ${{ matrix.qtbindings }}
- run: pip install -U .[dev]
- run: pip install ${{ matrix.qtbindings }}
- run: pytest
deploy:
needs: [test]
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Version 2.0.0
- Use `qtpy` as virtual Qt binding package. GHA unit tests are run with PySide2 and PyQt5 (#146)
- Add `pyqt_env.yml` and `pyside_env.yml` environment files (#146)
- Update `CONTRIBUTING.md`, `README.md` and add documentation file (#146)

# Version 1.0.2
- Upgrade python to 3.8 in `test.yml` (#171)
- Rename `/scripts` directory to `/recipe` (#161)
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ cd eqt
mamba env create -f recipe/eqt_env.yml
```

`eqt` uses the [`qtpy`](https://github.com/spyder-ide/qtpy) abstraction layer for Qt bindings, meaning that it works with either PySide or PyQt bindings. Thus, `eqt_env` does not depend on either. The environment can be updated with either `pyside2` or `pyqt5`, as follows.
```sh
mamba env update --name eqt_env --file pyside_env.yml
```
or
```sh
mamba env update --name eqt_env --file pyqt_env.yml
```

4. Activate the environment:
```sh
mamba activate eqt_env
Expand Down
78 changes: 78 additions & 0 deletions Documentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Documentation for eqt

## Running asynchronous tasks

To run a function in a separate thread we use a `Worker` which is a subclass of a `QRunnable`.

For the `Worker` to work one needs to define:

1. the function that does what you need
2. Optional callback methods to get the status of the thread by means of `QtCore.QSignal`s

On [initialisation](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L32-L38) of the `Worker` the user needs to pass the function that has to run in the thread, i.e. `fn` below, and additional optional positional and keyword arguments, which will be passed on to the actual function that is run in the `QRunnable`.

```python
class Worker(QtCore.QRunnable):
def __init__(self, fn, *args, **kwargs):
self.fn = fn
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
```

In practice the user will need to pass to the `Worker` as many parameters as there are listed in the [function](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L56) to be run.

```python
result = self.fn(*self.args, **self.kwargs)
```

But `Worker` will [add](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L41-L43) to the `**kwargs` the following `QSignal`.

```python
# Add progress callback to kwargs
self.kwargs['progress_callback'] = self.signals.progress
self.kwargs['message_callback'] = self.signals.message
self.kwargs['status_callback'] = self.signals.status
```

Therefore it is advisable to always have `**kwargs` in the function `fn` signature so that you can access the `QSignal` and emit the signal required. For instance one could emit a progress by:

```python
def fn(num_iter, **kwargs):
progress_callback = kwargs.get('progress_callback', None)
for i in range(num_iter):
do_something
if progress_callback is not None:
progress_callback.emit( i )
```

### Passing a signal to a Worker

This is done just after one has defined the `Worker`:

```python
def handle_progress(num_iter):
# do something with the progress
print ("Current progress is ", num_iter)

worker = Worker(fn, 10)
worker.signals.progress.connect(handle_progress)
```

So, each time `fn` comes to `progress_callback.emit( i )` the function `handle_progress` will be called with the parameter `i` of its `for` loop.

### Signals available

The signals that are available in the `Worker` class are defined in [`WorkerSignal`](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L66) and are the following. Below you can also see the type of data that each signal can emit.

```python
finished = QtCore.Signal()
error = QtCore.Signal(tuple)
result = QtCore.Signal(object)

progress = QtCore.Signal(int)
message = QtCore.Signal(str)
status = QtCore.Signal(tuple)
```

Read more on [Qt signals and slots](https://doc.qt.io/qt-5/signalsandslots.html) and on how to use them in [PySide2](https://wiki.qt.io/Qt_for_Python_Signals_and_Slots).
87 changes: 9 additions & 78 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

Templates & tools to develop Qt GUIs in Python.

One use case is accepting user input while running another task asynchronously (so that the UI is still responsive).

Some example classes are
1. `UIFormWidget`: a class to help creating Qt forms programmatically, useable in `QDockWidgets` and `QWidget`
2. `FormDialog`: a `QDialog` with a form inside with <kbd>OK</kbd> and <kbd>Cancel</kbd> buttons
3. `Worker`: a class that defines a `QRunnable` to handle worker thread setup, signals and wrap up

One use case is accepting a user input while running another task asynchronously (so that the UI is still responsive).

## Installation

Via `pip`/`conda`/`mamba`, i.e. any of the following:
Expand All @@ -18,86 +19,16 @@ Via `pip`/`conda`/`mamba`, i.e. any of the following:
- `conda install -c conda-forge eqt`
- `mamba install -c conda-forge eqt`

## Examples

See the [`examples`](examples) directory, e.g. how to launch a `QDialog` with a form inside using `eqt`'s [`QWidget`](examples/dialog_example.py) or [`FormDialog`](examples/dialog_example_2.py).

### Running asynchronous tasks

To run a function in a separate thread we use a `Worker` which is a subclass of a `QRunnable`.

For the `Worker` to work one needs to define:

1. the function that does what you need
2. Optional callback methods to get the status of the thread by means of `QtCore.QSignal`s

On [initialisation](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L32-L38) of the `Worker` the user needs to pass the function that has to run in the thread, i.e. `fn` below, and additional optional positional and keyword arguments, which will be passed on to the actual function that is run in the `QRunnable`.

```python
class Worker(QtCore.QRunnable):
def __init__(self, fn, *args, **kwargs):
self.fn = fn
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
```

In practice the user will need to pass to the `Worker` as many parameters as there are listed in the [function](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L56) to be run.

```python
result = self.fn(*self.args, **self.kwargs)
```

But `Worker` will [add](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L41-L43) to the `**kwargs` the following `QSignal`.
#### Note:
`eqt` uses the [`qtpy`](https://github.com/spyder-ide/qtpy) abstraction layer for Qt bindings, meaning that it works with either PySide or PyQt bindings. Thus, the package does not depend on either. If the environment does not already have a Qt binding then the user *must* install either `pyside2` or `pyqt5`.

```python
# Add progress callback to kwargs
self.kwargs['progress_callback'] = self.signals.progress
self.kwargs['message_callback'] = self.signals.message
self.kwargs['status_callback'] = self.signals.status
```

Therefore it is advisable to always have `**kwargs` in the function `fn` signature so that you can access the `QSignal` and emit the signal required. For instance one could emit a progress by:

```python
def fn(num_iter, **kwargs):
progress_callback = kwargs.get('progress_callback', None)
for i in range(num_iter):
do_something
if progress_callback is not None:
progress_callback.emit( i )
```

### Passing a signal to a Worker

This is done just after one has defined the `Worker`:

```python
def handle_progress(num_iter):
# do something with the progress
print ("Current progress is ", num_iter)

worker = Worker(fn, 10)
worker.signals.progress.connect(handle_progress)
```

So, each time `fn` comes to `progress_callback.emit( i )` the function `handle_progress` will be called with the parameter `i` of its `for` loop.

### Signals available

The signals that are available in the `Worker` class are defined in [`WorkerSignal`](https://github.com/TomographicImaging/eqt/blob/535e487d09d928713d7d6aa1123657597627c4b0/eqt/threading/QtThreading.py#L66) and are the following. Below you can also see the type of data that each signal can emit.

```python
finished = QtCore.Signal()
error = QtCore.Signal(tuple)
result = QtCore.Signal(object)
## Examples

progress = QtCore.Signal(int)
message = QtCore.Signal(str)
status = QtCore.Signal(tuple)
```
See the [`examples`](examples) directory, e.g. how to launch a `QDialog` with a form inside using `eqt`'s [`QWidget`](examples/dialog_example.py) or [`FormDialog`](examples/dialog_example_2.py).

Read more on [Qt signals and slots](https://doc.qt.io/qt-5/signalsandslots.html) and on how to use them in [PySide2](https://wiki.qt.io/Qt_for_Python_Signals_and_Slots).
## Documentation
See [Documentation.md](./Documentation.md).

## Developer Contribution Guide
See [CONTRIBUTING.md](./CONTRIBUTING.md).
4 changes: 2 additions & 2 deletions eqt/threading/QtThreading.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
# https://www.geeksforgeeks.org/migrate-pyqt5-app-to-pyside2
import traceback

from PySide2 import QtCore
from PySide2.QtCore import Slot
from qtpy import QtCore
from qtpy.QtCore import Slot


class Worker(QtCore.QRunnable):
Expand Down
2 changes: 1 addition & 1 deletion eqt/ui/FormDialog.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from PySide2 import QtCore, QtWidgets
from qtpy import QtCore, QtWidgets

from . import UIFormFactory

Expand Down
6 changes: 3 additions & 3 deletions eqt/ui/MainWindowWithProgressDialogs.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import qdarkstyle
from PySide2.QtCore import QSettings, QThreadPool
from PySide2.QtGui import QKeySequence
from PySide2.QtWidgets import QAction, QMainWindow
from qdarkstyle.dark.palette import DarkPalette
from qdarkstyle.light.palette import LightPalette
from qtpy.QtCore import QSettings, QThreadPool
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QAction, QMainWindow

from .ProgressTimerDialog import ProgressTimerDialog
from .SessionDialogs import AppSettingsDialog
Expand Down
4 changes: 2 additions & 2 deletions eqt/ui/MainWindowWithSessionManagement.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from datetime import datetime
from functools import partial

from PySide2.QtGui import QCloseEvent, QKeySequence
from PySide2.QtWidgets import QAction
from qtpy.QtGui import QCloseEvent, QKeySequence
from qtpy.QtWidgets import QAction

from ..io import zip_directory
from ..threading import Worker
Expand Down
2 changes: 1 addition & 1 deletion eqt/ui/NoBorderScrollArea.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re

import qdarkstyle
from PySide2.QtWidgets import QPushButton, QScrollArea, QWidget
from qtpy.QtWidgets import QPushButton, QScrollArea, QWidget


class NoBorderScrollArea(QScrollArea):
Expand Down
6 changes: 3 additions & 3 deletions eqt/ui/ProgressTimerDialog.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import time
from time import sleep

from PySide2 import QtCore
from PySide2.QtCore import Qt, QThreadPool
from PySide2.QtWidgets import QProgressDialog
from qtpy import QtCore
from qtpy.QtCore import Qt, QThreadPool
from qtpy.QtWidgets import QProgressDialog

from ..threading import Worker

Expand Down
2 changes: 1 addition & 1 deletion eqt/ui/ReOrderableListWidget.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from PySide2 import QtCore, QtWidgets
from qtpy import QtCore, QtWidgets


class ReOrderableListWidget(QtWidgets.QTableWidget):
Expand Down
4 changes: 2 additions & 2 deletions eqt/ui/SessionDialogs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os

from PySide2 import QtWidgets
from PySide2.QtWidgets import (
from qtpy import QtWidgets
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
Expand Down
2 changes: 1 addition & 1 deletion eqt/ui/UIFormWidget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from warnings import warn

from PySide2 import QtWidgets
from qtpy import QtWidgets

from .UISliderWidget import UISliderWidget

Expand Down
6 changes: 3 additions & 3 deletions eqt/ui/UIMultiStepWidget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from PySide2 import QtWidgets
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QPushButton
from qtpy import QtWidgets
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QPushButton


class UIMultiStepWidget(object):
Expand Down
4 changes: 2 additions & 2 deletions eqt/ui/UISliderWidget.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from PySide2 import QtCore
from PySide2.QtWidgets import QSlider
from qtpy import QtCore
from qtpy.QtWidgets import QSlider


class UISliderWidget(QSlider):
Expand Down
6 changes: 3 additions & 3 deletions eqt/ui/UIStackedWidget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from PySide2 import QtWidgets
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QHBoxLayout, QListWidget, QStackedWidget, QVBoxLayout, QWidget
from qtpy import QtWidgets
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QListWidget, QStackedWidget, QVBoxLayout, QWidget

from .UIFormWidget import UIFormFactory

Expand Down
4 changes: 2 additions & 2 deletions examples/MainWindowWithSessionManagement_example.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys

from PySide2 import QtWidgets
from PySide2.QtWidgets import QApplication
from qtpy import QtWidgets
from qtpy.QtWidgets import QApplication

from eqt import __version__
from eqt.ui.MainWindowWithSessionManagement import MainWindowWithSessionManagement
Expand Down
4 changes: 2 additions & 2 deletions examples/NoBorderScrollArea_example.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys

from PySide2 import QtWidgets
from PySide2.QtWidgets import QPushButton
from qtpy import QtWidgets
from qtpy.QtWidgets import QPushButton

from eqt.ui.NoBorderScrollArea import NoBorderScrollArea

Expand Down
2 changes: 1 addition & 1 deletion examples/advanced_dialog_example.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys

import utilitiesForExamples
from PySide2 import QtWidgets
from qtpy import QtWidgets

from eqt.ui.FormDialog import AdvancedFormDialog
from eqt.ui.UIFormWidget import FormWidget
Expand Down
2 changes: 1 addition & 1 deletion examples/dialog_example.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import sys

from PySide2 import QtWidgets
from qtpy import QtWidgets

from eqt.ui import UIFormFactory

Expand Down
2 changes: 1 addition & 1 deletion examples/dialog_example_2.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import sys

from PySide2 import QtWidgets
from qtpy import QtWidgets

from eqt.ui import FormDialog

Expand Down
2 changes: 1 addition & 1 deletion examples/dialog_example_3_save_default.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys

import utilitiesForExamples as utex
from PySide2 import QtWidgets
from qtpy import QtWidgets

from eqt.ui import FormDialog

Expand Down
Loading

0 comments on commit 19be963

Please sign in to comment.