Skip to content

Commit 90a3e27

Browse files
authored
Merge pull request #2 from jdranczewski/dev
release 0.9.0
2 parents f9c80f3 + cb8239a commit 90a3e27

File tree

5 files changed

+205
-11
lines changed

5 files changed

+205
-11
lines changed

docs/source/index.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ with a piece of hardware into **standard inputs, outputs, and actions**. It then
1111
minimising the need for boilerplate code. Puzzlepiece allows the user to bring diverse controls into a single, consolidated application,
1212
and automate their interaction or experiment using a unified API, either through a built-in script language, or Interactive Python.
1313

14+
You can install puzzlepiece using pip::
15+
16+
pip install puzzlepiece
17+
18+
Some examples are located on GitHub: `how to construct an app <https://github.com/jdranczewski/puzzlepiece/tree/main/examples>`_
19+
or `how to code a Piece <https://github.com/jdranczewski/puzzlepiece/blob/main/puzzlepiece/pieces/random_number.py>`_
20+
(a single GUI module, representing an experimental device or task). The full source code is available at https://github.com/jdranczewski/puzzlepiece .
21+
1422
.. toctree::
1523
:maxdepth: 2
1624
:caption: Contents:

puzzlepiece/param.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def set_value(self, value=None):
130130
# If the setter did not return a value, see if there is a getter
131131
if self._getter is not None:
132132
new_value = self._getter()
133+
new_value = self._type(new_value)
133134
else:
134135
# Otherwise the new value is just the value we're setting
135136
new_value = value
@@ -396,7 +397,9 @@ def _click_handler(self, _):
396397
try:
397398
if self._connected_click_handler is not None:
398399
self._connected_click_handler()
399-
self.set_value()
400+
if self._setter is not None:
401+
# If there's a setter, we need to explicitly call set_value here
402+
self.set_value()
400403
except Exception as e:
401404
# Flip back the checkbox if the click resulted in an error
402405
self.input.setChecked(not(self.input.isChecked()))
@@ -534,6 +537,45 @@ def _input_set_value(self, value):
534537
def _input_get_value(self):
535538
""":meta private:"""
536539
return self.input.currentText()
540+
541+
class ParamProgress(BaseParam):
542+
"""
543+
A param with a progress bar. See the :func:`~puzzlepiece.param.progress` decorator below
544+
for how to use this in your Piece.
545+
"""
546+
_type = float
547+
548+
def _make_input(self, value=None, connect=None):
549+
""":meta private:"""
550+
input = QtWidgets.QProgressBar()
551+
input.setMinimum(0)
552+
input.setMaximum(1000)
553+
if value is not None:
554+
input.setValue(value)
555+
return input, True
556+
557+
def _input_set_value(self, value):
558+
""":meta private:"""
559+
if value < 0:
560+
self.input.setMaximum(0)
561+
else:
562+
self.input.setMaximum(1000)
563+
self.input.setValue(int(value*1000))
564+
565+
def _input_get_value(self):
566+
""":meta private:"""
567+
return self.input.value()
568+
569+
def iter(self, iterable):
570+
if hasattr(iterable, '__len__'):
571+
length = len(iterable)
572+
else:
573+
length = -1
574+
575+
for i, value in enumerate(iterable):
576+
self.set_value(i/length)
577+
yield value
578+
self.set_value(1)
537579

538580
def wrap_setter(piece, setter):
539581
"""
@@ -758,4 +800,19 @@ def decorator(values):
758800
values = values(piece)
759801
piece.params[name] = ParamDropdown(name, value, values, None, None, visible)
760802
return piece.params[name]
803+
return decorator
804+
805+
def progress(piece, name, visible=True):
806+
"""
807+
A decorator generator for registering a :class:`~puzzlepiece.param.ParamProgress` in a Piece's
808+
:func:`~puzzlepiece.piece.Piece.define_params` method with a given **getter**.
809+
810+
This will display the current progress value on a scale of 0 to 1 with no option to edit it.
811+
812+
See :func:`~puzzlepiece.param.base_param` for more details.
813+
"""
814+
def decorator(getter):
815+
wrapper = wrap_getter(piece, getter)
816+
piece.params[name] = ParamProgress(name, None, setter=None, getter=wrapper, visible=visible)
817+
return piece.params[name]
761818
return decorator

puzzlepiece/piece.py

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ class Piece(QtWidgets.QGroupBox):
77
A single `Piece` object is an unit of automation - an object that is meant to represent a single
88
physical instrument (like a laser) or a particular functionality (like a plotter or a parameter scan).
99
10-
Pieces can be assembled into a :class:`~puzzlepiece.puzzle.Puzzle`.
10+
Pieces can be assembled into a :class:`~puzzlepiece.puzzle.Puzzle` using the Puzzle's
11+
:func:`~puzzlepiece.puzzle.Puzzle.add_piece` method.
1112
1213
:param puzzle: The parent :class:`~puzzlepiece.puzzle.Puzzle`.
13-
:param custom_horizontal: A bool flat, the custom layout is displayed to the right of the main controls
14-
if True.
14+
:param custom_horizontal: A bool, the custom layout is displayed to the right of the main controls
15+
if True.
1516
"""
1617
def __init__(self, puzzle, custom_horizontal=False, *args, **kwargs):
1718
super().__init__()
@@ -72,7 +73,7 @@ def action_layout(self, wrap=2):
7273
"""
7374
Genereates a `QGridLayout` for the actions. Override to set a different wrapping.
7475
75-
:param wrap: the number of columns the actions are displayed in .
76+
:param wrap: the number of columns the actions are displayed in.
7677
:rtype: QtWidgets.QGridLayout
7778
"""
7879
layout = QtWidgets.QGridLayout()
@@ -118,6 +119,35 @@ def setup(self):
118119
"""
119120
pass
120121

122+
def open_popup(self, popup):
123+
"""
124+
Open a popup window for this Piece. A popup is a :class:`puzzlepiece.piece.Popup`
125+
object, which is like a Piece but floats in a separate window attached to the main
126+
:class:`~puzzlepiece.puzzle.Puzzle`. This can be used for handling additional tasks
127+
that you don't want to clutter the main Piece. See :class:`puzzlepiece.piece.Popup`
128+
for details on implementing a Popup.
129+
130+
:param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate
131+
:rtype: puzzlepiece.piece.Popup
132+
"""
133+
# Instantiate the popup
134+
if isinstance(popup, type):
135+
popup = popup(self, self.puzzle)
136+
popup.setStyleSheet("QGroupBox {border:0;}")
137+
138+
# Make a dialog window for the popup to live in
139+
dialog = _QDialog(self, popup)
140+
layout = QtWidgets.QVBoxLayout()
141+
dialog.setLayout(layout)
142+
layout.addWidget(popup)
143+
144+
# Display the dialog
145+
dialog.show()
146+
dialog.raise_()
147+
dialog.activateWindow()
148+
149+
return popup
150+
121151
def call_stop(self):
122152
"""
123153
This method is called by the parent Puzzle when a global stop is called.
@@ -130,8 +160,8 @@ def call_stop(self):
130160

131161
def handle_close(self, event):
132162
"""
133-
Only called if the :class:`~puzzlepiece.puzzle.Puzzle` debug flag is False.
134-
Override to disconnect hardware etc when the main window closes.
163+
Only called if the :class:`~puzzlepiece.puzzle.Puzzle` :attr:`~puzzlepiece.puzzle.Puzzle.debug`
164+
flag is False. Override to disconnect hardware etc when the main window closes.
135165
"""
136166
pass
137167

@@ -218,4 +248,65 @@ def wrapped_main(self, *args, **kwargs):
218248
return True
219249
else:
220250
ensure_function(self)
221-
return ensure_decorator
251+
return ensure_decorator
252+
253+
class _QDialog(QtWidgets.QDialog):
254+
"""
255+
A variant of the QDialog specifically for popups, handles closing them
256+
with a custom function.
257+
"""
258+
def __init__(self, parent, popup, *args, **kwargs):
259+
self.popup = popup
260+
super().__init__(parent, *args, **kwargs)
261+
262+
def closeEvent(self, event):
263+
self.popup.handle_close()
264+
super().closeEvent(event)
265+
266+
class Popup(Piece):
267+
"""
268+
A Popup is similar to a Piece, but floats in a separate window attached to the main
269+
:class:`~puzzlepiece.puzzle.Puzzle`. This can be used for handling additional tasks
270+
that you don't want to clutter the main Piece. For example you can have a camera
271+
Piece which can open a Popup to set the camera's region of interest with an interactive
272+
plot window.
273+
274+
A Popup can be created and displayed by calling :func:`puzzlepiece.piece.Piece.open_popup`.
275+
276+
A Popup is attached to a specific Piece and knows it through its
277+
:attr:`~puzzlepiece.piece.Popup.parent_piece` attribute, but it can also access other
278+
Pieces through the Puzzle, which it knows through its :attr:`~puzzlepiece.piece.Piece.puzzle`
279+
attribute.
280+
281+
A Popup can have params, actions, and custom layouts just like a normal Piece, and are created by
282+
overriding :func:`~puzzlepiece.piece.Piece.define_params`, :func:`~puzzlepiece.piece.Piece.define_actions`,
283+
and :func:`~puzzlepiece.piece.Piece.custom_layout` like for a Piece.
284+
285+
:param puzzle: The parent :class:`~puzzlepiece.puzzle.Puzzle`.
286+
:param parent_piece: The parent :class:`~puzzlepiece.piece.Piece`.
287+
:param custom_horizontal: A bool, the custom layout is displayed to the right of the main controls
288+
if True.
289+
"""
290+
def __init__(self, parent_piece, puzzle, custom_horizontal=False, *args, **kwargs):
291+
self._parent_piece = parent_piece
292+
super().__init__(puzzle, custom_horizontal, *args, **kwargs)
293+
self.layout.setContentsMargins(0,0,0,0)
294+
295+
@property
296+
def parent_piece(self):
297+
"""
298+
A reference to this Popup's parent :class:`~puzzlepiece.piece.Piece`,
299+
the one that created it through :func:`puzzlepiece.piece.Piece.open_popup`.
300+
"""
301+
return self._parent_piece
302+
303+
def handle_close(self):
304+
"""
305+
Called when the Popup is closed. Override to perform actions when the user
306+
closes this Popup - for example delete related plot elements.
307+
308+
In contrast to :func:`puzzlepiece.piece.Piece.handle_close`, this is called even
309+
if the :class:`~puzzlepiece.puzzle.Puzzle` :attr:`~puzzlepiece.puzzle.Puzzle.debug`
310+
flag is True.
311+
"""
312+
pass

puzzlepiece/puzzle.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,39 @@ def __init__(self, app, name, debug=True, *args, **kwargs):
3737

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

40-
sys.excepthook = self._excepthook
40+
try:
41+
# If this doesn't raise a NameError, we're in IPython
42+
shell = get_ipython()
43+
# _orig_sys_module_state stores the original IPKernelApp excepthook,
44+
# irrespective of possible modifications in other cells
45+
self._old_excepthook = shell._orig_sys_module_state['excepthook']
46+
47+
# The following hack allows us to handle exceptions through the Puzzle in IPython.
48+
# Normally when a cell is executed in an IPython InteractiveShell,
49+
# sys.excepthook is overwritten with shell.excepthook, and then restored
50+
# to sys.excepthook after the cell run finishes. Any changes we make to
51+
# sys.excepthook in here directly will thus be overwritten as soon as the
52+
# cell that defines the Puzzle finishes running.
53+
54+
# Instead, we schedule set_excepthook on a QTimer, meaning that it will
55+
# execute in the Qt loop rather than in a cell, so it can modify
56+
# sys.excepthook without risk of the changes being immediately overwritten,
57+
58+
# For bonus points, we could set _old_excepthook to shell.excepthook,
59+
# which would result in all tracebacks appearing in the Notebook rather
60+
# than the console, but I think that is not desireable.
61+
def set_excepthook():
62+
sys.excepthook = self._excepthook
63+
QtCore.QTimer.singleShot(0, set_excepthook)
64+
except NameError:
65+
# In normal Python (not IPython) this is comparatively easy.
66+
# We use the original system hook here instead of sys.excepthook
67+
# to avoid unexpected behaviour if multiple things try to override
68+
# the hook in various ways.
69+
# If you need to implement custom exception handling, please assign
70+
# a value to your Puzzle's ``custom_excepthook`` method.
71+
self._old_excepthook = sys.__excepthook__
72+
sys.excepthook = self._excepthook
4173

4274
@property
4375
def pieces(self):
@@ -130,7 +162,10 @@ def run_worker(self, worker):
130162
self._threadpool.start(worker)
131163

132164
def _excepthook(self, exctype, value, traceback):
133-
sys.__excepthook__(exctype, value, traceback)
165+
self._old_excepthook(exctype, value, traceback)
166+
167+
# Stop any threads that may be running
168+
self._shutdown_threads.emit()
134169

135170
# Only do custom exception handling in the main thread, otherwise the messagebox
136171
# or other such things are likely to break things.
@@ -338,6 +373,9 @@ def closeEvent(self, event):
338373
if not self.debug:
339374
for piece_name in self.pieces:
340375
self.pieces[piece_name].handle_close(event)
376+
377+
# Reinstate the original excepthook
378+
sys.excepthook = self._old_excepthook
341379
super().closeEvent(event)
342380

343381

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "puzzlepiece"
7-
version = "0.8.0"
7+
version = "0.9.0"
88
authors = [
99
{ name="Jakub Dranczewski", email="jakub.dranczewski@gmail.com" },
1010
]

0 commit comments

Comments
 (0)