# -*- coding: utf-8 -*-
"""
GUI Widget Helpers
==================
Provides a collection of custom :class:`~PyQt5.QtWidgets.QWidget` subclasses
that provide specific functionalities.
"""
# %% IMPORTS
# Built-in imports
import sys
import threading
from time import sleep
from traceback import format_exception_only, format_tb
# Package imports
import e13tools as e13
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from qtpy import QtCore as QC, QtGui as QG, QtWidgets as QW
# PRISM imports
from prism._docstrings import qt_slot_doc
from prism._gui import APP_NAME
# All declaration
__all__ = ['ExceptionDialog', 'FigureCanvas', 'OverviewListWidget',
'ThreadedProgressDialog', 'show_exception_details']
# %% CLASS DEFINITIONS
# Make special class for showing exception details
[docs]class ExceptionDialog(QW.QDialog):
"""
Defines the :class:`~ExceptionDialog` class for the Projection GUI.
This class takes a set of exception details and converts it into a format
that can be shown using a dialog.
"""
[docs] def __init__(self, parent, etype, value, tb):
"""
Initialize an instance of the :class:`~ExceptionDialog` class.
Parameters
----------
parent : :obj:`~PyQt5.QtWidgets.QWidget` object or None
The parent widget for this dialog or *None* for no parent.
etype : :class:`~Exception` class
The :class:`~Exception` class that is associated with this error.
value : :obj:`~Exception` object
The :class:`~Exception` instance that is associated with this
error.
tb : traceback object
The corresponding traceback object.
"""
# Save the provided values
self.etype = etype
self.value = value
self.tb = tb
# Call the super constructor
super().__init__(parent)
# Initialize the exception dialog
self.init()
# This function creates the exception dialog
[docs] def init(self):
"""
Sets up the exception dialog after it has been initialized.
This function is mainly responsible for gathering all required
information; formatting it; and drawing the dialog.
"""
# Create a window layout
grid_layout = QW.QGridLayout(self)
grid_layout.setColumnStretch(2, 1)
grid_layout.setRowStretch(3, 1)
# Set properties of message box
self.setWindowModality(QC.Qt.ApplicationModal)
self.setAttribute(QC.Qt.WA_DeleteOnClose)
self.setWindowTitle("ERROR")
self.setWindowFlags(
QC.Qt.MSWindowsOwnDC |
QC.Qt.Dialog |
QC.Qt.WindowTitleHint |
QC.Qt.WindowSystemMenuHint |
QC.Qt.WindowCloseButtonHint)
# Set the icon of the exception on the left
icon_label = QW.QLabel()
pixmap = QW.QMessageBox.standardIcon(QW.QMessageBox.Critical)
icon_label.setPixmap(pixmap)
grid_layout.addWidget(icon_label, 0, 0, 2, 1, QC.Qt.AlignTop)
# Add a spacer item
spacer_item = QW.QSpacerItem(7, 1, QW.QSizePolicy.Fixed,
QW.QSizePolicy.Fixed)
grid_layout.addItem(spacer_item, 0, 1, 2, 1)
# Set the text of the exception
exc_str = self.format_exception()
exc_label = QW.QLabel(exc_str)
grid_layout.addWidget(exc_label, 0, 2, 1, 1)
# Create a button box for the buttons
button_box = QW.QDialogButtonBox()
grid_layout.addWidget(button_box, 2, 0, 1, grid_layout.columnCount())
# Create traceback box
self.tb_box = self.create_traceback_box()
grid_layout.addWidget(self.tb_box, 3, 0, 1, grid_layout.columnCount())
# Create traceback button
self.tb_but =\
button_box.addButton(self.tb_labels[self.tb_box.isHidden()],
button_box.ActionRole)
self.tb_but.clicked.connect(self.toggle_traceback_box)
# Create an 'ok' button
ok_but = button_box.addButton(button_box.Ok)
ok_but.clicked.connect(self.close)
ok_but.setDefault(True)
# Update the size
self.update_size()
# This function formats the exception string
# This function formats the traceback string
# This function creates the traceback box
[docs] def create_traceback_box(self):
"""
Creates a special box for the exception dialog that contains the
traceback information and returns it.
"""
# Create a traceback box
traceback_box = QW.QWidget(self)
traceback_box.setHidden(True)
# Create layout
layout = QW.QVBoxLayout()
layout.setContentsMargins(QC.QMargins())
traceback_box.setLayout(layout)
# Add a horizontal line to the layout
frame = QW.QFrame(traceback_box)
frame.setFrameShape(frame.HLine)
frame.setFrameShadow(frame.Sunken)
layout.addWidget(frame)
# Format the traceback
tb_str = self.format_traceback()
# Add a textedit to the layout
tb_text_box = QW.QTextEdit(traceback_box)
tb_text_box.setMinimumHeight(100)
tb_text_box.setFocusPolicy(QC.Qt.NoFocus)
tb_text_box.setReadOnly(True)
tb_text_box.setText(tb_str)
layout.addWidget(tb_text_box)
# Create a 'show traceback' button
self.tb_labels = ['Hide Traceback...', 'Show Traceback...']
# Return traceback box
return(traceback_box)
# This function shows or hides the traceback box
[docs] @QC.Slot()
@e13.docstring_substitute(qt_slot=qt_slot_doc)
def toggle_traceback_box(self):
"""
Toggles the visibility of the traceback box and updates the dimensions
of the exception dialog accordingly.
%(qt_slot)s
"""
# Toggle the visibility of the traceback box
self.tb_box.setHidden(not self.tb_box.isHidden())
self.tb_but.setText(self.tb_labels[self.tb_box.isHidden()])
# Update the size of the message box
self.update_size()
# This function updates the size of the dialog
[docs] def update_size(self):
"""
Updates the dimensions of the exception dialog depending on its current
state (traceback box visibility).
"""
# Determine the minimum/maximum size required for making the dialog
min_size = self.layout().minimumSize()
max_size = self.layout().maximumSize()
# Set the fixed width
self.setFixedWidth(min_size.width())
# If the traceback box is shown, set minimum/maximum height
if self.tb_box.isVisible():
self.setMinimumHeight(min_size.height())
self.setMaximumHeight(max_size.height())
# Else, set fixed height
else:
self.setFixedHeight(min_size.height())
# Class used for holding the projection figures in the projection viewing area
class FigureCanvas(FigureCanvasQTAgg):
pass
# Class used for making the overview lists in the GUI
# Class that provides a special threaded progress dialog
# FIXME: Figure out why this dialog can stall forever when used in MPI on Linux
# This always happens on Travis CI, very rarely on Azure Pipelines and never on
# any Linux machine (supercomputer or personal) I have access to.
[docs]class ThreadedProgressDialog(QW.QProgressDialog):
"""
Defines the :class:`~ThreadedProgressDialog` class for the Projection GUI.
This class provides a :class:`~PyQt5.QtWidgets.QProgressDialog` class that
automatically executes a provided operation on a separate thread, allowing
for the user to interrupt it.
"""
# Make a signal that is emitted whenever the progress dialog finishes
finished = QC.Signal()
[docs] def __init__(self, main_window_obj, *args, **kwargs):
"""
Initialize an instance of the :class:`~ThreadedProgressDialog` class.
Parameters
----------
main_window_obj : :obj:`~prism._gui.widgets.MainViewerWindow` object
Instance of the :class:`~prism._gui.widgets.MainViewerWindow` class
that acts as the parent of progress dialog.
args : positional arguments
The positional arguments that need to be passed to :meth:`~init`.
kwargs : keyword arguments
The keyword arguments that need to be passed to :meth:`~init`.
"""
# Save provided MainWindow obj
self.main = main_window_obj
self.pipe = self.main.pipe
# Call the super constructor
super().__init__(self.main)
# Create the progress dialog
self.init(*args, **kwargs)
# Create the threaded progress dialog
[docs] def init(self, label, func, *iterables):
"""
Sets up the progress dialog after it has been initialized.
This function is mainly responsible for preparing the dialog to be
opened and the `func` function to be executed.
Parameters
----------
label : str
The label that is used as the description of what operation is
currently being executed.
func : function
The function that must be called iteratively using the arguments
provided in `iterables`.
iterables : positional arguments
All iterables that must be used to call `func` with.
"""
# Set the label and cancel button
self.setLabelText(label)
self.setCancelButtonText("Abort")
# Determine the minimum length of iterables
min_len = min([len(iterable) for iterable in iterables])
# Set the range of this progress dialog
self.setRange(0, min_len)
# Make this progress dialog application modal
self.setWindowModality(QC.Qt.ApplicationModal)
self.setWindowTitle(APP_NAME)
self.setWindowFlags(
QC.Qt.WindowTitleHint |
QC.Qt.Dialog |
QC.Qt.CustomizeWindowHint)
self.setAttribute(QC.Qt.WA_DeleteOnClose)
self.setAutoReset(False)
# Setup the run_map that will be used
self.run_map = map(func, *iterables)
# This function simply calls open()
[docs] def __call__(self):
"""
Calls and returns the result of :meth:`~open`.
"""
return(self.open())
# This function executes the entire run_map until finished or aborted
[docs] def open(self):
"""
Opens the progress dialog and starts the execution of the requested
operation.
Returns
-------
result : bool
Whether or not the operations ended successfully, which can be used
by other functions to determine if it should continue.
"""
# Initialize the traced thread
self.thread = TracedControllerThread(self, self.run_map)
# Connect the proper signals with each other
self.thread.n_finished.connect(self.setValue)
self.thread.finished.connect(self.set_successful_finish)
self.thread.exception.connect(self.raise_exception)
super().open(self.kill_threads)
# Save that progress dialog has currently not finished successfully
self.successful = False
# Start the threads for all other MPI ranks
self.pipe._make_call_workers(_run_traced_worker_threads, 'pipe')
# Determine what the current QApplication instance is
qapp = QW.QApplication.instance()
# Start the thread
self.thread.start()
# While the thread is running, keep processing user input events
while self.thread.is_alive():
qapp.processEvents()
self.thread.join(0.1)
# Process user input events one last time
qapp.processEvents()
# Emit that the progress dialog has finished
self.finished.emit()
# Return if dialog finished successfully or not
return(self.successful)
# This function sets an attribute and serves as a slot
[docs] @QC.Slot()
def set_successful_finish(self):
"""
Qt slot that marks the operation as 'successful'.
"""
self.successful = True
# This function raises an exception caught in the controller thread
[docs] @QC.Slot(Exception)
def raise_exception(self, exception):
"""
Qt slot that raises a provided exception.
"""
raise exception
# This function kills all worker threads and then the controller thread
[docs] @QC.Slot()
@e13.docstring_substitute(qt_slot=qt_slot_doc)
def kill_threads(self):
"""
Terminates all currently running threads besides the main thread (on
all MPI ranks) and returns control to the main thread.
This function is the sole way to abort the operation.
%(qt_slot)s
"""
# Set all worker threads to 'killed'
for rank in range(1, self.pipe._comm.size):
self.pipe._comm.send(True, rank, 671589+rank)
# Set this thread to killed
self.thread.killed = True
# Make all workers wait for 1 second to force their system trace
self.pipe._make_call_workers(sleep, 1)
# Wait for this thread to be killed
self.thread.join()
# Use an MPI Barrier to make sure that all threads were killed
# This means that the controller also has to wait for a second
self.pipe._make_call('_comm.Barrier')
# Special system traced thread that stops whenever killed is set to True
class TracedThread(threading.Thread):
"""
Defines the :class:`~TracedThread` base class.
This class is used to create traceable threads, which allows for those
threads to be terminated from the outside.
"""
def __init__(self):
"""
Initialize an instance of the :class:`~TracedThread` class.
"""
# Set killed to False
self.killed = False
# Call super constructor
super().__init__(None)
# Make a custom system tracer
def global_trace(self, frame, event, arg): # pragma: no cover
"""
Provides the global system tracer function that automatically
terminates this thread if its :attr:`~killed` attribute is set to
*True*.
"""
# If killed is True, kill thread at the next function call
if self.killed and (event == 'call'):
raise SystemExit
# https://www.geeksforgeeks.org/python-different-ways-to-kill-a-thread/
# System traced controller thread that loops over a provided map iterator
class TracedControllerThread(QC.QObject, TracedThread):
"""
Defines the :class:`~TracedControllerThread` class.
This class creates a traceable thread that simultaneously can also be used
by Qt.
"""
# Define a signal that sends out the number of finished iterations
n_finished = QC.Signal(int)
# Define a signal that is emitted when the thread finishes executing
finished = QC.Signal()
# Define a signal that is emitted whenever an exception occurs
exception = QC.Signal(Exception)
def __init__(self, parent, run_map):
"""
Initialize an instance of the :class:`~TracedControllerThread` class.
Parameters
----------
parent : :obj:`~PyQt5.QtWidgets.QWidget` object or None
The parent widget for this dialog or *None* for no parent.
run_map : iterator
The iterator that must be iterated over on the separate thread.
"""
# Save provided map iterator
self.run_map = run_map
# Call the super constructors
super().__init__(parent)
TracedThread.__init__(self)
# This function gets called when TracedThread.start() is called
def run(self):
"""
Executes the operations whenever this thread is started.
"""
# Set the system tracer
sys.settrace(self.global_trace)
# Emit that currently the number of finished iteration is 0
self.n_finished.emit(0)
# Loop over the map iterator and send a signal after each iteration
try:
for i, _ in enumerate(self.run_map):
self.n_finished.emit(i+1)
except Exception as error:
self.exception.emit(error)
# Emit signal that execution has finished
else:
self.finished.emit()
# https://www.geeksforgeeks.org/python-different-ways-to-kill-a-thread/
# Special system traced worker thread that connects to the controller thread
class TracedWorkerThread(TracedThread):
"""
Defines the :class:`~TracedWorkerThread` class.
This class creates a traceable thread that simultaneously can also be used
by *PRISM* in worker mode.
"""
def __init__(self, pipeline_obj):
"""
Initialize an instance of the :class:`~TracedWorkerThread` class.
Parameters
----------
pipeline_obj : :obj:`~prism.Pipeline` object
The :class:`~prism.Pipeline` instance this worker thread must use.
This is required for entering the proper worker mode.
"""
# Save provided pipeline_obj
self.pipe = pipeline_obj
# Call the super constructor
super().__init__()
# This function gets called when TracedThread.start() is called
def run(self):
"""
Executes the operations whenever this thread is started.
"""
# Set the system tracer
sys.settrace(self.global_trace)
# Start listening for calls on this thread as well
worker_mode = self.pipe.worker_mode
worker_mode._WorkerMode__key = -1
worker_mode.listen_for_calls()
# %% FUNCTION DEFINITIONS
# This function starts up the threads for all workers
def _run_traced_worker_threads(pipeline_obj):
"""
All workers defined in the provided `pipeline_obj` create a
:obj:`~TracedWorkerThread` object and use it to listen for calls from the
controller rank.
Parameters
----------
pipeline_obj : :obj:`~prism.Pipeline` object
The :class:`~prism.Pipeline` object all created worker threads must
use. This is required for entering the proper worker mode.
"""
# Abbreviate pipeline_obj
pipe = pipeline_obj
# Initialize a worker thread
thread = TracedWorkerThread(pipe)
# Start executing on this thread
thread.start()
# Keep listening for the controller telling to stop the worker thread
thread.killed = pipe._comm.recv(None, 0, 671589+pipe._comm.rank)
# Connect to the thread to make sure it ended properly
thread.join()
# This function creates a message box with exception information
[docs]def show_exception_details(parent, *args, **kwargs):
"""
Creates an instance of the :class:`~ExceptionDialog` class and shows it.
Parameters
----------
parent : :obj:`~PyQt5.QtWidgets.QWidget` object or None
The parent widget for this dialog or *None* for no parent.
Optional
--------
args : positional arguments
The positional arguments that must be passed to the constructor of
the :class:`~prism._gui.widgets.helpers.ExceptionDialog` class.
kwargs : keyword arguments
The keyword arguments that must be passed to the constructor of the
:class:`~prism._gui.widgets.helpers.ExceptionDialog` class.
"""
# Create exception message box
exception_box = ExceptionDialog(parent, *args, **kwargs)
# Emit the exception signal of the parent if it has it
if hasattr(parent, 'exception'):
parent.exception.emit()
# Show the exception message box
exception_box.show()