From 34e2336da2ec5e5c25ebb4504f733f74182f163d Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Mon, 29 Jan 2024 16:11:32 +0000 Subject: [PATCH 1/8] Popup Piece functionality --- puzzlepiece/piece.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index d953979..a9e9f52 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -118,6 +118,21 @@ def setup(self): """ pass + def open_popup(self, popup): + dialog = QtWidgets.QDialog(self) + layout = QtWidgets.QVBoxLayout() + dialog.setLayout(layout) + + popup = popup(self, self.puzzle) + popup.setStyleSheet("QGroupBox {border:0;}") + layout.addWidget(popup) + + dialog.show() + dialog.raise_() + dialog.activateWindow() + + return popup + def call_stop(self): """ This method is called by the parent Puzzle when a global stop is called. @@ -218,4 +233,9 @@ def wrapped_main(self, *args, **kwargs): return True else: ensure_function(self) - return ensure_decorator \ No newline at end of file + return ensure_decorator + +class Popup(Piece): + def __init__(self, parent_piece, puzzle, custom_horizontal=False, *args, **kwargs): + self.parent_piece = parent_piece + super().__init__(puzzle, custom_horizontal, *args, **kwargs) \ No newline at end of file From f924b86cd2af33e23dc7c8ea07f555298a59cbbc Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Mon, 29 Jan 2024 16:39:08 +0000 Subject: [PATCH 2/8] Progress bar param --- puzzlepiece/param.py | 58 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index f804e25..9a4f76e 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -396,7 +396,9 @@ def _click_handler(self, _): try: if self._connected_click_handler is not None: self._connected_click_handler() - self.set_value() + if self._setter is not None: + # If there's a setter, we need to explicitly call set_value here + self.set_value() except Exception as e: # Flip back the checkbox if the click resulted in an error self.input.setChecked(not(self.input.isChecked())) @@ -534,6 +536,45 @@ def _input_set_value(self, value): def _input_get_value(self): """:meta private:""" return self.input.currentText() + +class ParamProgress(BaseParam): + """ + A param with a progress bar. See the :func:`~puzzlepiece.param.progress` decorator below + for how to use this in your Piece. + """ + _type = float + + def _make_input(self, value=None, connect=None): + """:meta private:""" + input = QtWidgets.QProgressBar() + input.setMinimum(0) + input.setMaximum(1000) + if value is not None: + input.setValue(value) + return input, True + + def _input_set_value(self, value): + """:meta private:""" + if value < 0: + self.input.setMaximum(0) + else: + self.input.setMaximum(1000) + self.input.setValue(int(value*1000)) + + def _input_get_value(self): + """:meta private:""" + return self.input.value() + + def iter(self, iterable): + if hasattr(iterable, '__len__'): + length = len(iterable) + else: + length = -1 + + for i, value in enumerate(iterable): + self.set_value(i/length) + yield value + self.set_value(1) def wrap_setter(piece, setter): """ @@ -758,4 +799,19 @@ def decorator(values): values = values(piece) piece.params[name] = ParamDropdown(name, value, values, None, None, visible) return piece.params[name] + return decorator + +def progress(piece, name, visible=True): + """ + A decorator generator for registering a :class:`~puzzlepiece.param.ParamProgress` in a Piece's + :func:`~puzzlepiece.piece.Piece.define_params` method with a given **getter**. + + This will display the current progress value on a scale of 0 to 1 with no option to edit it. + + See :func:`~puzzlepiece.param.base_param` for more details. + """ + def decorator(getter): + wrapper = wrap_getter(piece, getter) + piece.params[name] = ParamProgress(name, None, setter=None, getter=wrapper, visible=visible) + return piece.params[name] return decorator \ No newline at end of file From 8aaad4f454f3f221f6c4e3688685cdb8581eeada Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 30 Jan 2024 14:35:52 +0000 Subject: [PATCH 3/8] Cast to type in automatic getter call when setter returns None --- puzzlepiece/param.py | 1 + 1 file changed, 1 insertion(+) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 9a4f76e..5be7c07 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -130,6 +130,7 @@ def set_value(self, value=None): # If the setter did not return a value, see if there is a getter if self._getter is not None: new_value = self._getter() + new_value = self._type(new_value) else: # Otherwise the new value is just the value we're setting new_value = value From a386dd0deba39640c671150cf567f7e524e20659 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 30 Jan 2024 14:36:17 +0000 Subject: [PATCH 4/8] Low margins for Popups --- puzzlepiece/piece.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index a9e9f52..60b264b 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -238,4 +238,5 @@ def wrapped_main(self, *args, **kwargs): class Popup(Piece): def __init__(self, parent_piece, puzzle, custom_horizontal=False, *args, **kwargs): self.parent_piece = parent_piece - super().__init__(puzzle, custom_horizontal, *args, **kwargs) \ No newline at end of file + super().__init__(puzzle, custom_horizontal, *args, **kwargs) + self.layout.setContentsMargins(0,0,0,0) From 6ba092ed3ff3f1469f25072d97e684b0ff89deea Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 30 Jan 2024 15:23:40 +0000 Subject: [PATCH 5/8] Popup handle_close and docs --- docs/source/index.rst | 8 ++++ puzzlepiece/piece.py | 92 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 4d2735b..62c69e2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,6 +11,14 @@ with a piece of hardware into **standard inputs, outputs, and actions**. It then minimising the need for boilerplate code. Puzzlepiece allows the user to bring diverse controls into a single, consolidated application, and automate their interaction or experiment using a unified API, either through a built-in script language, or Interactive Python. +You can install puzzlepiece using pip:: + + pip install puzzlepiece + +Some examples are located on GitHub: `how to construct an app `_ +or `how to code a Piece `_ +(a single GUI module, representing an experimental device or task). The full source code is available at https://github.com/jdranczewski/puzzlepiece . + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index 60b264b..e330d3c 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -7,11 +7,12 @@ class Piece(QtWidgets.QGroupBox): A single `Piece` object is an unit of automation - an object that is meant to represent a single physical instrument (like a laser) or a particular functionality (like a plotter or a parameter scan). - Pieces can be assembled into a :class:`~puzzlepiece.puzzle.Puzzle`. + Pieces can be assembled into a :class:`~puzzlepiece.puzzle.Puzzle` using the Puzzle's + :func:`~puzzlepiece.puzzle.Puzzle.add_piece` method. :param puzzle: The parent :class:`~puzzlepiece.puzzle.Puzzle`. - :param custom_horizontal: A bool flat, the custom layout is displayed to the right of the main controls - if True. + :param custom_horizontal: A bool, the custom layout is displayed to the right of the main controls + if True. """ def __init__(self, puzzle, custom_horizontal=False, *args, **kwargs): super().__init__() @@ -72,7 +73,7 @@ def action_layout(self, wrap=2): """ Genereates a `QGridLayout` for the actions. Override to set a different wrapping. - :param wrap: the number of columns the actions are displayed in . + :param wrap: the number of columns the actions are displayed in. :rtype: QtWidgets.QGridLayout """ layout = QtWidgets.QGridLayout() @@ -119,14 +120,28 @@ def setup(self): pass def open_popup(self, popup): - dialog = QtWidgets.QDialog(self) + """ + Open a popup window for this Piece. A popup is a :class:`puzzlepiece.piece.Popup` + object, which is like a Piece but floats in a separate window attached to the main + :class:`~puzzlepiece.puzzle.Puzzle`. This can be used for handling additional tasks + that you don't want to clutter the main Piece. See :class:`puzzlepiece.piece.Popup` + for details on implementing a Popup. + + :param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate + :rtype: puzzlepiece.piece.Popup + """ + # Instantiate the popup + if isinstance(popup, type): + popup = popup(self, self.puzzle) + popup.setStyleSheet("QGroupBox {border:0;}") + + # Make a dialog window for the popup to live in + dialog = _QDialog(self, popup) layout = QtWidgets.QVBoxLayout() dialog.setLayout(layout) - - popup = popup(self, self.puzzle) - popup.setStyleSheet("QGroupBox {border:0;}") layout.addWidget(popup) + # Display the dialog dialog.show() dialog.raise_() dialog.activateWindow() @@ -145,8 +160,8 @@ def call_stop(self): def handle_close(self, event): """ - Only called if the :class:`~puzzlepiece.puzzle.Puzzle` debug flag is False. - Override to disconnect hardware etc when the main window closes. + Only called if the :class:`~puzzlepiece.puzzle.Puzzle` :attr:`~puzzlepiece.puzzle.Puzzle.debug` + flag is False. Override to disconnect hardware etc when the main window closes. """ pass @@ -235,8 +250,63 @@ def wrapped_main(self, *args, **kwargs): ensure_function(self) return ensure_decorator +class _QDialog(QtWidgets.QDialog): + """ + A variant of the QDialog specifically for popups, handles closing them + with a custom function. + """ + def __init__(self, parent, popup, *args, **kwargs): + self.popup = popup + super().__init__(parent, *args, **kwargs) + + def closeEvent(self, event): + self.popup.handle_close() + super().closeEvent(event) + class Popup(Piece): + """ + A Popup is similar to a Piece, but floats in a separate window attached to the main + :class:`~puzzlepiece.puzzle.Puzzle`. This can be used for handling additional tasks + that you don't want to clutter the main Piece. For example you can have a camera + Piece which can open a Popup to set the camera's region of interest with an interactive + plot window. + + A Popup can be created and displayed by calling :func:`puzzlepiece.piece.Piece.open_popup`. + + A Popup is attached to a specific Piece and knows it through its + :attr:`~puzzlepiece.piece.Popup.parent_piece` attribute, but it can also access other + Pieces through the Puzzle, which it knows through its :attr:`~puzzlepiece.piece.Piece.puzzle` + attribute. + + A Popup can have params, actions, and custom layouts just like a normal Piece, and are created by + overriding :func:`~puzzlepiece.piece.Piece.define_params`, :func:`~puzzlepiece.piece.Piece.define_actions`, + and :func:`~puzzlepiece.piece.Piece.custom_layout` like for a Piece. + + :param puzzle: The parent :class:`~puzzlepiece.puzzle.Puzzle`. + :param parent_piece: The parent :class:`~puzzlepiece.piece.Piece`. + :param custom_horizontal: A bool, the custom layout is displayed to the right of the main controls + if True. + """ def __init__(self, parent_piece, puzzle, custom_horizontal=False, *args, **kwargs): - self.parent_piece = parent_piece + self._parent_piece = parent_piece super().__init__(puzzle, custom_horizontal, *args, **kwargs) self.layout.setContentsMargins(0,0,0,0) + + @property + def parent_piece(self): + """ + A reference to this Popup's parent :class:`~puzzlepiece.piece.Piece`, + the one that created it through :func:`puzzlepiece.piece.Piece.open_popup`. + """ + return self._parent_piece + + def handle_close(self): + """ + Called when the Popup is closed. Override to perform actions when the user + closes this Popup - for example delete related plot elements. + + In contrast to :func:`puzzlepiece.piece.Piece.handle_close`, this is called even + if the :class:`~puzzlepiece.puzzle.Puzzle` :attr:`~puzzlepiece.puzzle.Puzzle.debug` + flag is True. + """ + pass \ No newline at end of file From d5ddd234a3ee203328b05626b772c5217548e9f3 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 30 Jan 2024 15:24:35 +0000 Subject: [PATCH 6/8] Stop threads when error encountered --- puzzlepiece/puzzle.py | 3 +++ pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index d69ab87..f16c4c4 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -132,6 +132,9 @@ def run_worker(self, worker): def _excepthook(self, exctype, value, traceback): sys.__excepthook__(exctype, value, traceback) + # Stop any threads that may be running + self._shutdown_threads.emit() + # Only do custom exception handling in the main thread, otherwise the messagebox # or other such things are likely to break things. if QtCore.QThread.currentThread() == self.app.thread(): diff --git a/pyproject.toml b/pyproject.toml index 2b88b21..9e52f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "puzzlepiece" -version = "0.8.0" +version = "0.9.0" authors = [ { name="Jakub Dranczewski", email="jakub.dranczewski@gmail.com" }, ] From c105eab5c953c7e4d1fc980c9812d4076e591214 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 30 Jan 2024 21:10:34 +0000 Subject: [PATCH 7/8] Excepthook workaround for IPython --- puzzlepiece/puzzle.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index f16c4c4..9fbaad7 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -37,7 +37,27 @@ def __init__(self, app, name, debug=True, *args, **kwargs): self.wrapper_layout.addLayout(self._button_layout(), 1, 0) - sys.excepthook = self._excepthook + # This hack allows us to handle exceptions through the Puzzle in IPython. + # Normally when a cell is executed in an IPython InteractiveShell, + # sys.excepthook is overwritten with shell.excepthook, and then restored + # to sys.excepthook after the cell run finishes. Any changes we make to + # sys.excepthook in here directly will thus be overwritten as soon as the + # cell that defines the Puzzle finishes running. + + # Instead, we schedule set_excepthook on a QTimer, meaning that it will + # execute in the Qt loop rather than in a cell, so it can modify + # sys.excepthook without risk of the changes being immediately overwritten, + + # For bonus points, we could set _old_excepthook to shell.excepthook, + # which would result in all tracebacks appearing in the Notebook rather + # than the console, but I think that is not desireable. + + # In normal Python, we could just say "sys.excepthook = self._excepthook" + # but this method works for both. + def set_excepthook(): + self._old_excepthook = sys.excepthook + sys.excepthook = self._excepthook + QtCore.QTimer.singleShot(0, set_excepthook) @property def pieces(self): @@ -130,7 +150,7 @@ def run_worker(self, worker): self._threadpool.start(worker) def _excepthook(self, exctype, value, traceback): - sys.__excepthook__(exctype, value, traceback) + self._old_excepthook(exctype, value, traceback) # Stop any threads that may be running self._shutdown_threads.emit() @@ -341,6 +361,9 @@ def closeEvent(self, event): if not self.debug: for piece_name in self.pieces: self.pieces[piece_name].handle_close(event) + + # Reinstate the original excepthook + sys.excepthook = self._old_excepthook super().closeEvent(event) From cb8239a52e298909542b4e62df7c83ea99a67213 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Wed, 31 Jan 2024 14:50:53 +0000 Subject: [PATCH 8/8] Explicit IPython handling for exceptions This is so that we always use the original excepthook in custom excepthook calls instead of using the excepthook possibly set in other cells. The reason this is important is if we create a Puzzle in one cell, but don't show it for some reason, and then create a new Puzzle in a different cell, the old excepthook would belong to the old Puzzle, and since we still call that, two error messages appear. --- puzzlepiece/puzzle.py | 52 ++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index 9fbaad7..803abd1 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -37,27 +37,39 @@ def __init__(self, app, name, debug=True, *args, **kwargs): self.wrapper_layout.addLayout(self._button_layout(), 1, 0) - # This hack allows us to handle exceptions through the Puzzle in IPython. - # Normally when a cell is executed in an IPython InteractiveShell, - # sys.excepthook is overwritten with shell.excepthook, and then restored - # to sys.excepthook after the cell run finishes. Any changes we make to - # sys.excepthook in here directly will thus be overwritten as soon as the - # cell that defines the Puzzle finishes running. - - # Instead, we schedule set_excepthook on a QTimer, meaning that it will - # execute in the Qt loop rather than in a cell, so it can modify - # sys.excepthook without risk of the changes being immediately overwritten, - - # For bonus points, we could set _old_excepthook to shell.excepthook, - # which would result in all tracebacks appearing in the Notebook rather - # than the console, but I think that is not desireable. - - # In normal Python, we could just say "sys.excepthook = self._excepthook" - # but this method works for both. - def set_excepthook(): - self._old_excepthook = sys.excepthook + try: + # If this doesn't raise a NameError, we're in IPython + shell = get_ipython() + # _orig_sys_module_state stores the original IPKernelApp excepthook, + # irrespective of possible modifications in other cells + self._old_excepthook = shell._orig_sys_module_state['excepthook'] + + # The following hack allows us to handle exceptions through the Puzzle in IPython. + # Normally when a cell is executed in an IPython InteractiveShell, + # sys.excepthook is overwritten with shell.excepthook, and then restored + # to sys.excepthook after the cell run finishes. Any changes we make to + # sys.excepthook in here directly will thus be overwritten as soon as the + # cell that defines the Puzzle finishes running. + + # Instead, we schedule set_excepthook on a QTimer, meaning that it will + # execute in the Qt loop rather than in a cell, so it can modify + # sys.excepthook without risk of the changes being immediately overwritten, + + # For bonus points, we could set _old_excepthook to shell.excepthook, + # which would result in all tracebacks appearing in the Notebook rather + # than the console, but I think that is not desireable. + def set_excepthook(): + sys.excepthook = self._excepthook + QtCore.QTimer.singleShot(0, set_excepthook) + except NameError: + # In normal Python (not IPython) this is comparatively easy. + # We use the original system hook here instead of sys.excepthook + # to avoid unexpected behaviour if multiple things try to override + # the hook in various ways. + # If you need to implement custom exception handling, please assign + # a value to your Puzzle's ``custom_excepthook`` method. + self._old_excepthook = sys.__excepthook__ sys.excepthook = self._excepthook - QtCore.QTimer.singleShot(0, set_excepthook) @property def pieces(self):