Skip to content

Commit

Permalink
Merge pull request #2 from jdranczewski/dev
Browse files Browse the repository at this point in the history
release 0.9.0
  • Loading branch information
jdranczewski authored Jan 31, 2024
2 parents f9c80f3 + cb8239a commit 90a3e27
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 11 deletions.
8 changes: 8 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/jdranczewski/puzzlepiece/tree/main/examples>`_
or `how to code a Piece <https://github.com/jdranczewski/puzzlepiece/blob/main/puzzlepiece/pieces/random_number.py>`_
(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:
Expand Down
59 changes: 58 additions & 1 deletion puzzlepiece/param.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -396,7 +397,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()))
Expand Down Expand Up @@ -534,6 +537,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):
"""
Expand Down Expand Up @@ -758,4 +800,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
105 changes: 98 additions & 7 deletions puzzlepiece/piece.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -118,6 +119,35 @@ def setup(self):
"""
pass

def open_popup(self, popup):
"""
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)
layout.addWidget(popup)

# Display the dialog
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.
Expand All @@ -130,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

Expand Down Expand Up @@ -218,4 +248,65 @@ def wrapped_main(self, *args, **kwargs):
return True
else:
ensure_function(self)
return ensure_decorator
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
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
42 changes: 40 additions & 2 deletions puzzlepiece/puzzle.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,39 @@ def __init__(self, app, name, debug=True, *args, **kwargs):

self.wrapper_layout.addLayout(self._button_layout(), 1, 0)

sys.excepthook = self._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

@property
def pieces(self):
Expand Down Expand Up @@ -130,7 +162,10 @@ 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()

# Only do custom exception handling in the main thread, otherwise the messagebox
# or other such things are likely to break things.
Expand Down Expand Up @@ -338,6 +373,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)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down

0 comments on commit 90a3e27

Please sign in to comment.