diff --git a/docs/requirements.txt b/docs/requirements.txt index e1ef30c..669e8cc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,5 @@ pyqtgraph==0.13.3 numpy==1.26.2 sphinx==7.2.5 sphinx-rtd-theme==1.3.0rc1 +nbsphinx==0.9.5 +ipykernel==6.29.5 \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index d4ea0e2..d83e98c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,7 +19,9 @@ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon"] +extensions = [ + "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon", + "nbsphinx", "sphinx.ext.autosectionlabel"] templates_path = ["_templates"] exclude_patterns = [] diff --git a/docs/source/index.rst b/docs/source/index.rst index 8ba6b4b..8c3d5db 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -150,6 +150,8 @@ you may want to update ``ipykernel`` if your cells are not running after an exce Next steps ========== +The :ref:`Tutorial` is a great place to start - have a look or run it yourself to learn interactively! + Some more detailed examples are located on GitHub: `how to construct an app `_ or `how to code a Piece `_. Examples of more complex Pieces `are also available `_. @@ -162,6 +164,7 @@ This documentation is meant as a good way to familiarise yourself with the libra :caption: Contents: modules + tutorial Indices and tables diff --git a/docs/source/puzzlepiece.extras.ipython_shims.rst b/docs/source/puzzlepiece.extras.ipython_shims.rst new file mode 100644 index 0000000..cda583f --- /dev/null +++ b/docs/source/puzzlepiece.extras.ipython_shims.rst @@ -0,0 +1,41 @@ +puzzlepiece.extras.ipython_shims module +======================================= + +Import this module to get access to useful puzzlepiece-related Magics:: + + from puzzlepiece.extras import ipython_shims + +%%pzp_script +------------ + +Magic for running the cell as puzzlepiece script:: + + %%pzp_script + set:piece_name:param_name:25 + prompt:Param changed + +Assumes the :class:`~puzzlepiece.puzzle.Puzzle` is in the global namespace +as ``puzzle``. A different name can be provided as an argument to the magic: +``%%pzp_script puzzle_variable``. + +See :func:`puzzlepiece.parse.run` for the full scripting syntax. + +%%safe_run +------------ + +Magic for running the cell with a safety function in case of a crash:: + + # Cell 1 + def close_shutter(): + puzzle["shutter"]["open"].set_value(0) + + # Cell 2 + %%safe_run close_shutter + raise Exception + +Running cell 2 will raise an exception, but the magic catches the exception, +closes the shutter, and then the original exception is re-raised. Another +useful usecase is sending a Slack message when a crash occurs. + +This is similar in spirit to :func:`puzzlepiece.puzzle.Puzzle.custom_excepthook`, +but works in Notebooks in constrast to that. \ No newline at end of file diff --git a/docs/source/puzzlepiece.extras.rst b/docs/source/puzzlepiece.extras.rst index 202e87e..33e2496 100644 --- a/docs/source/puzzlepiece.extras.rst +++ b/docs/source/puzzlepiece.extras.rst @@ -2,8 +2,9 @@ puzzlepiece.extras module ========================= The extras module contains additional features that are useful, but not part of the -core puzzlepiece functionality. Currently this is just the ``datagrid`` - a Widget that -can be added to your Pieces, with multiple Piece-like Rows that use params for data storage +core puzzlepiece functionality. Currently these are ``ipython_shims`` (a small collection +of Notebook magics) and the ``datagrid`` - a Widget that can be added to your Pieces, +with multiple Piece-like Rows that use params for data storage and manipulation. The extras modules have to be imported explicitly:: @@ -17,4 +18,5 @@ The extras modules have to be imported explicitly:: .. toctree:: :maxdepth: 2 + puzzlepiece.extras.ipython_shims puzzlepiece.extras.datagrid diff --git a/docs/source/tutorial.ipynb b/docs/source/tutorial.ipynb new file mode 100644 index 0000000..99fa499 --- /dev/null +++ b/docs/source/tutorial.ipynb @@ -0,0 +1,822 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1ee71176-df4c-4692-b0f1-457780083eca", + "metadata": {}, + "source": [ + "# Tutorial\n", + "This is a thorough introduction to the basics of puzzlepiece. You can read through this Notebook here, but it's probably nicer to run it yourself! You can download it from https://github.com/jdranczewski/puzzlepiece/blob/main/docs/source/tutorial.ipynb\n", + "\n", + "You need puzzlepiece and some version of Qt (PySide6 or PyQt6 for example) installed to run this. Note that Anaconda installation comes with PyQt5 already!\n", + "\n", + "```pip install puzzlepiece```\n", + "\n", + "Other requirements include `numpy` and `tqdm`.\n", + "\n", + "Docs are at https://puzzlepiece.readthedocs.io/en/stable/index.html - you can also press shift-tab when your cursor is \"inside\" at method (for example `pzp.param.spinb|ox` or `pzp.param.spinbox(|`) to bring up the help text for that specific function. Good luck!" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b3f67270-598a-4913-b506-357e00d9e6e2", + "metadata": {}, + "outputs": [], + "source": [ + "# Enable the GUI integration for this Notebook\n", + "%gui qt" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5b35ecdb-554d-4abc-8e13-58ead857dce9", + "metadata": {}, + "outputs": [], + "source": [ + "# Main GUI framework\n", + "import puzzlepiece as pzp\n", + "# Plotting framework\n", + "import pyqtgraph as pg\n", + "# Progress bar library\n", + "from tqdm import tqdm\n", + "# A way to access Qt Widgets (independent of whether the user has PyQt or PySide installed)\n", + "from qtpy import QtWidgets\n", + "# Other libraries\n", + "import numpy as np\n", + "import time" + ] + }, + { + "cell_type": "markdown", + "id": "25a3a3ab-f9ab-4b41-b479-f615468d5ee7", + "metadata": {}, + "source": [ + "## Our first Piece\n", + "This one is pretty boring and empty." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "de86e228-ec60-4d25-bff6-92f36608d3ea", + "metadata": {}, + "outputs": [], + "source": [ + "class Piece(pzp.Piece):\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "dd7245f6-26cc-4893-a6f5-f5e5eaeeee75", + "metadata": {}, + "source": [ + "Let's add it to a Puzzle. A tiny new window should appear. You can close this window when moving on to the next section." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1008ffda-fda3-40dd-ae01-4af99e3d718c", + "metadata": {}, + "outputs": [], + "source": [ + "puzzle = pzp.Puzzle()\n", + "# The Pieces are added on a grid\n", + "puzzle.add_piece(\"piece_name\", Piece, row=0, column=0)\n", + "# In Qt you need to show a Widget for it to appear\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "c2edb301-125d-4bd8-96a0-70ffd667418d", + "metadata": {}, + "source": [ + "## Adding params\n", + "This Piece will show a text box that you can edit" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "acaa5648-bb66-4e48-aa01-8a9ef4862ef3", + "metadata": {}, + "outputs": [], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " # You will define your params in here\n", + " # The (None) indicates that this param has no getter or setter (we'll get to these)\n", + " pzp.param.text(self, \"name\", \"Jakub\")(None)\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "62af433f-ee16-4b6d-93b8-21d112543cde", + "metadata": {}, + "source": [ + "## Params with getters\n", + "Some params can call a function to get a value (intensity from a powermeter, say).\n", + "\n", + "Click the \"refresh\" button to get a value." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5bbd6f4a-af0f-4eea-b5e7-e66da0b9de9e", + "metadata": {}, + "outputs": [], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " pzp.param.text(self, \"name\", \"Jakub\")(None)\n", + "\n", + " # A spinbox is a number input\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " # This param has a getter - a function to obtain its value\n", + " # This is achieved by using `readout` as a \"decorator\" on a function (spot the @, and lack of (None))\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " # This method accesses the other two params to compute an age\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "1ea423d8-d1a0-42b0-8304-45b45f84b395", + "metadata": {}, + "source": [ + "## Params with setters\n", + "Some params call a function to set a value (for example the integration time).\n", + "\n", + "Note that the text field gets red when you edit it - this indicates that the text in the box changed, but the setter has not yet been called." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "3209b136-3981-44be-a752-fefd0ce1cd9d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now Jakub\n" + ] + } + ], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " # Notice that we're using `text` as a decorator again - whether this makes the method below a getter or a setter depends on the input type.\n", + " # Text boxes and spinboxes have setters by default, readouts and arrays have getters. Check https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html\n", + " # for all available input decorators and their default behaviour\n", + " @pzp.param.text(self, \"name\", \"Jakub\")\n", + " def name(self, value):\n", + " # `value` is the new value of the param\n", + " print(\"The user's name is now\", value)\n", + "\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "86e994b0-9840-46e3-9212-2bfe14a89d9d", + "metadata": {}, + "source": [ + "## Params with getters and setters\n", + "Some params may have a getter and a setter simultaneously (you can set an integration time, but you can also ask the device what it is)." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ca11d976-9c37-47b9-b667-15d8c4322a9c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now William\n" + ] + } + ], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " @pzp.param.text(self, \"name\", \"Jakub\")\n", + " def name(self, value):\n", + " print(\"The user's name is now\", value)\n", + " # We need to return the value here to acknowledge that we've set it, otherwise the getter will be called\n", + " # to double check it. See https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html#puzzlepiece.param.BaseParam.set_value\n", + " # for the details of this logic.\n", + " return value\n", + "\n", + " # Here, we're using the `set_getter` method of the name param to add a getter to it (it already has a setter) \n", + " @name.set_getter(self)\n", + " def name(self):\n", + " return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])\n", + "\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "3c88ce74-73aa-4186-89a5-b2aeaa24798e", + "metadata": {}, + "source": [ + "## Actions\n", + "Sometimes you need to do something, like save an image from a camera.\n", + "\n", + "Note that the gretting prints \"Hello None\" until the name is explicitly set - params with setters hold no value internally until the setter is called." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "511b2cd8-eaca-4543-959d-321e6d93e0cf", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello None your age is 25\n", + "The user's name is now Jakub\n", + "Hello Jakub your age is 25\n" + ] + } + ], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " @pzp.param.text(self, \"name\", \"Jakub\")\n", + " def name(self, value):\n", + " print(\"The user's name is now\", value)\n", + " return value\n", + " \n", + " @name.set_getter(self)\n", + " def name(self):\n", + " return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])\n", + "\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + " def define_actions(self):\n", + " # we define our actions here, using decorators on the functions\n", + " @pzp.action.define(self, \"Greet\")\n", + " def greet(self):\n", + " # Note the difference between .value and .get_value()\n", + " # .value accesses the interally stored param value, not calling the getter (which would return a random name here)\n", + " # .get_value calls the getter if there's one (in this case to calculate the age)\n", + " print(\"Hello\", self[\"name\"].value, \"your age is\", self[\"age\"].get_value())\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6f9e2ada-30bd-47d2-960f-2697c3263179", + "metadata": {}, + "source": [ + "## Accessing params and actions from code\n", + "You've seen glimpses of this already, but there's two ways to interact with a Piece. We can click through the GUI, or we can use the API from code to set, get, and run actions.\n", + "\n", + "Keep the Puzzle created below open while you run the subsequent cells and observe how it changes." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "b63aa431-619f-465a-af93-565d4f6bc276", + "metadata": {}, + "outputs": [], + "source": [ + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece1\", Piece, 0, 0)\n", + "puzzle.add_piece(\"piece2\", Piece, 0, column=1)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a0391f4a-fcf7-40bf-84b4-31d727258e27", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now James\n" + ] + }, + { + "data": { + "text/plain": [ + "'James'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Note that this will also return the new value\n", + "puzzle[\"piece1\"][\"name\"].set_value(\"James\")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "dca1d95a-6b37-48bb-8bb0-d1b3a439b01f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now John\n" + ] + }, + { + "data": { + "text/plain": [ + "'John'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "puzzle[\"piece2\"][\"name\"].set_value(\"John\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "9b899070-d391-410e-86ee-f9d6084e0be8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'James'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# doesn't call the getter\n", + "puzzle[\"piece1\"][\"name\"].value" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "66393a1c-5378-4b7a-956c-4cb377bb79bd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'William'" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# does call the getter\n", + "puzzle[\"piece1\"][\"name\"].get_value()" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "667aece3-9369-4c37-825a-cd2ab46b53f1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1999" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "puzzle[\"piece2\"][\"born\"].get_value()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "ad1d50ca-e9b9-4ea7-a2d8-0175fbaddc9a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "124" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "puzzle[\"piece2\"][\"born\"].set_value(1900)\n", + "puzzle[\"piece2\"][\"age\"].get_value()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "f900da6d-2d95-46f3-abe1-e5e263544b0f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "124\n", + "114\n", + "104\n", + "94\n", + "84\n", + "74\n", + "64\n" + ] + } + ], + "source": [ + "for year in range(1900, 1961, 10):\n", + " puzzle[\"piece2\"][\"born\"].set_value(year)\n", + " print(puzzle[\"piece2\"][\"age\"].get_value())" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "ead8a687-4b9c-4ec1-8e12-b8b669a6eb64", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "124\n", + "114\n", + "104\n", + "94\n", + "84\n", + "74\n", + "64\n" + ] + } + ], + "source": [ + "for year in range(1900, 1961, 10):\n", + " puzzle[\"piece2\"][\"born\"].set_value(year)\n", + " print(puzzle[\"piece2\"][\"age\"].get_value())\n", + " # Note that while a function or Notebook cell is running, the Puzzle will only\n", + " # update the GUI if you explicitly tell it too\n", + " puzzle.process_events()\n", + " # delay added to make changes visible\n", + " time.sleep(.1)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "29d81b2f-323a-437b-8736-162d306536b6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello William your age is 25\n", + "Hello John your age is 64\n" + ] + } + ], + "source": [ + "puzzle[\"piece1\"].actions[\"Greet\"]()\n", + "puzzle[\"piece2\"].actions[\"Greet\"]()" + ] + }, + { + "cell_type": "markdown", + "id": "6e0ce313-aa3e-406a-9d13-cc2b9a909261", + "metadata": {}, + "source": [ + "## Custom layouts\n", + "You can make any Qt Layout appear within your Piece. https://www.pythonguis.com/ is a decent primer on how these work. Here's a TL;DR:" + ] + }, + { + "cell_type": "markdown", + "id": "bf5bb99d-8f91-473f-89df-90789ac82df1", + "metadata": {}, + "source": [ + "* every GUI component in Qt (a button, a text box, a label) is a 'Widget'\n", + "* Widgets go into Layouts - the Layout describes how the Widgets are laid out\n", + "* a Widget is actually a very general concept - any element of your app that's on screen is probably a Widget. For example, a form can be a Widget that contains multiple input box Widgets. It all nests into multiple layers of Widgets containing other Widgets\n", + "* a Widget can contain a Layout as well, which is how this nesting is achieved. So a Widget has a Layout, and other Widgets are placed within this Layout\n", + "* nearly everything in puzzlepiece is secretly a Widget - for example the param objects are Widgets so that they can be displayed inside a Piece\n", + "* Widgets can have Signals and you can 'connect' functions to those signals - the function is then called when the Signal is 'emitted'. For example, the params in puzzlepiece have a 'changed' Signal, which is emitted whenever the param value changes. You can connect functions to this Signal so that they are called each time the param value changes." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "797d800b-9cc3-45e7-965a-cba20e753ec6", + "metadata": {}, + "outputs": [], + "source": [ + "class RandomPiece(pzp.Piece):\n", + " def define_params(self):\n", + " pzp.param.spinbox(self, \"N\", 100)(None)\n", + " @pzp.param.array(self, \"random\")\n", + " def random(self):\n", + " return np.random.random(self[\"N\"].value)\n", + "\n", + " def custom_layout(self):\n", + " # this method should return a QT Layout that will be placed inside the Piece\n", + " layout = QtWidgets.QVBoxLayout()\n", + "\n", + " # We create a plot Widget (from pyqtgraph) and add it to the Layout\n", + " pw = pg.PlotWidget()\n", + " layout.addWidget(pw)\n", + " # pyqtgraph thinks of things as \"Items\" - the plot is an item, the lines within it are Items,\n", + " # images are ImageItems, etc - for a list see https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/index.html\n", + " self.plot = pw.getPlotItem()\n", + " # Add an empty line to the plot\n", + " self.plot_line = self.plot.plot([], [], symbol='o', symbolSize=3)\n", + "\n", + " def update_plot():\n", + " self.plot_line.setData(self[\"random\"].value)\n", + " # We connect `update_plot` to a `Signal` here - whenever the value of the `random`\n", + " # param changes, update_plot is called to update the plot.\n", + " # Click the refresh button next to `random` to see it happen, and change N to see what happens.\n", + " self[\"random\"].changed.connect(update_plot)\n", + " # for bonus points, we should really do\n", + " # self[\"random\"].changed.connect(pzp.threads.CallLater(update_plot))\n", + " # which would only update the plot once when the GUI refreshes\n", + "\n", + " return layout\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", RandomPiece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2908e15c-e3b9-4221-a5e9-8656c605c374", + "metadata": {}, + "source": [ + "## Developing your measurement\n", + "You can of course just develop your measurement as a Python method to be run from a Notebook. Notice how the GUI updates only once the measurement is done - we'd need to add a `puzzle.process_events()` to refresh it explicitly." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "5e659941-cd1d-4b48-bb2f-935f992f264e", + "metadata": {}, + "outputs": [], + "source": [ + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"random_numbers\", RandomPiece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "bf4d9b9c-455a-4315-8a97-5a43757237dd", + "metadata": {}, + "outputs": [], + "source": [ + "def measure(M):\n", + " a = []\n", + " for i in tqdm(range(M)):\n", + " a.append(puzzle[\"random_numbers\"][\"random\"].get_value())\n", + " time.sleep(.1)\n", + " return np.asarray(a)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "ada53eaf-9f39-4f3d-9eee-d68f0d739f13", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████████████████████████████████████████████████████████████████████████████| 12/12 [00:01<00:00, 9.69it/s]\n" + ] + }, + { + "data": { + "text/plain": [ + "(12, 100)" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "measure(12).shape" + ] + }, + { + "cell_type": "markdown", + "id": "5e86bed0-560d-42d8-b550-8e719e094da6", + "metadata": {}, + "source": [ + "You can alternatively put your measurement into a Piece, or have a bunch of Pieces to perform various functions:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "49a7a88c-6179-4dd8-925e-653c7bf03efd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'test_data_sample{metadata:sample}_{metadata:angle}deg.csv'" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "class Measurement(pzp.Piece):\n", + " def define_params(self):\n", + " pzp.param.spinbox(self, \"M\", 500)(None)\n", + " pzp.param.checkbox(self, \"gui_update\", 1)(None)\n", + " pzp.param.progress(self, \"progress\")(None)\n", + " pzp.param.text(self, \"filename\", \"\")(None)\n", + " pzp.param.array(self, \"result\")(None)\n", + "\n", + " def define_actions(self):\n", + " @pzp.action.define(self, \"Measure\")\n", + " def measure(self):\n", + " a = []\n", + " # Reset the stop flag\n", + " self.stop = False\n", + " # Indicate progress by using the bar's `iter` method\n", + " for i in self[\"progress\"].iter(range(self[\"M\"].value)):\n", + " a.append(puzzle[\"random_numbers\"][\"random\"].get_value())\n", + " # Break is STOP pressed\n", + " if self.stop:\n", + " break\n", + " # Update the GUI if set to do that\n", + " if self[\"gui_update\"].value:\n", + " puzzle.process_events()\n", + " result = self[\"result\"].set_value(a)\n", + " return result\n", + "\n", + " @pzp.action.define(self, \"Save\")\n", + " def save(self):\n", + " # Use `format` to include metadata in the filename\n", + " fname = pzp.parse.format(self[\"filename\"].value, self.puzzle)\n", + " np.savetxt(\n", + " fname,\n", + " self[\"result\"].value\n", + " )\n", + " puzzle.run(\"prompt:File saved as \" + fname)\n", + "\n", + "class Metadata(pzp.Piece):\n", + " # By making a Metadata Piece, you decouple the exact metadata you want to save (in the filename\n", + " # or wherever) from the Measurement.\n", + " def define_params(self):\n", + " pzp.param.dropdown(self, \"sample\", \"A\")([\"A\", \"B\", \"C\"])\n", + " pzp.param.spinbox(self, \"angle\", 0, v_step=10)(None)\n", + "\n", + "class FilenameHelper(pzp.Piece):\n", + " def define_params(self):\n", + " @pzp.param.text(self, \"filename\", \"\")\n", + " def filename(self, value):\n", + " self.puzzle[\"measurement\"][\"filename\"].set_value(value)\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"random_numbers\", RandomPiece, 0, 0, rowspan=2)\n", + "puzzle.add_piece(\"measurement\", Measurement, 0, 1)\n", + "puzzle.add_piece(\"metadata\", Metadata, 1, 1)\n", + "puzzle.add_piece(\"filename\", FilenameHelper, 2, 0, colspan=2)\n", + "puzzle.show()\n", + "\n", + "puzzle[\"filename\"][\"filename\"].set_value(\"test_data_sample{metadata:sample}_{metadata:angle}deg.csv\")" + ] + }, + { + "cell_type": "markdown", + "id": "9bfddc00-c47e-42fa-a9b2-2876695fdf97", + "metadata": {}, + "source": [ + "* Try to Measure and then Save.\n", + "* Note how `pzp.parse.format` is used to replace the `{}` expressions in the filename with values from the metadata Piece\n", + "* The filename Piece is there mostly to give us a wider textfield compared to the tiny one in the measurement Piece.\n", + "* Notice how `self[\"progress\"].iter` wraps the `range` in the measurement iteration - similar to how `tqdm` can normally be used for progress bars (https://tqdm.github.io/)\n", + "* Note how `self.stop` is used to integrate with the built-in STOP button. A result is still saved to the `result` param if you press STOP!\n", + "* Notice how `puzzle.process_events()` is used to make the plot and progress bar update every iteration - the GUI could freeze without that, but the measurement would run a bit faster. Try either option by toggling the `gui_update` checkbox before measuring." + ] + }, + { + "cell_type": "markdown", + "id": "8fe7cbcd-db32-4809-b2d6-edfd985ab471", + "metadata": {}, + "source": [ + "My advice generally would be to use simple Notebook functions during the development, where the exact measurement you want to do is not clear-cut and you may want to change things about how exactly it works.\n", + "\n", + "Once you have a well-defined measurement, you can put it in a Piece for repeat use!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/puzzlepiece/action.py b/puzzlepiece/action.py index eea7710..ca5b759 100644 --- a/puzzlepiece/action.py +++ b/puzzlepiece/action.py @@ -46,6 +46,16 @@ def visible(self): """ return self._visible + def make_child_action(self): + """ + Create and return a child action that calls the same callable. + + See :func:`puzzlepiece.piece.Popup.add_child_actions` for a quick way of adding child + actions to a popup. + """ + child = Action(self.function, self.parent, self.shortcut) + return child + def define(piece, name, shortcut=None, visible=True): """ diff --git a/puzzlepiece/extras/datagrid.py b/puzzlepiece/extras/datagrid.py index b9fd27e..aa99bca 100644 --- a/puzzlepiece/extras/datagrid.py +++ b/puzzlepiece/extras/datagrid.py @@ -24,16 +24,20 @@ class DataGrid(QtWidgets.QWidget): #: A Qt signal emitted when any data in the DataGrid changes (including when rows are added/removed). data_changed = QtCore.Signal() - def __init__(self, row_class, puzzle=None): + def __init__(self, row_class, puzzle=None, parent_piece=None): super().__init__() #: Reference to the parent :class:`~puzzlepiece.puzzle.Puzzle`. self.puzzle = puzzle or pzp.puzzle.PretendPuzzle() + self.parent_piece = parent_piece self._row_class = row_class row_example = row_class(self.puzzle) self.param_names = row_example.params.keys() self._tree = QtWidgets.QTreeWidget() - self._tree.setHeaderLabels(("ID", *row_example.params.keys(), "actions")) + if len(row_example.actions.keys()): + self._tree.setHeaderLabels(("ID", *row_example.params.keys(), "actions")) + else: + self._tree.setHeaderLabels(("ID", *row_example.params.keys())) layout = QtWidgets.QVBoxLayout() layout.addWidget(self._tree) @@ -53,21 +57,25 @@ def values(self): """ return [{key: x[key].value for key in x.params} for x in self.rows] - def add_row(self, **kwargs): + def add_row(self, row_class=None, **kwargs): """ Add a Row with default param values. + :param row_class: row class to use, if not specified uses the one provided + when the DataGrid was created. The class provided here should have the same + params and actions as the original one! :param kwargs: keyword arguments matching param names can be passed to set param values in the new row """ item = QtWidgets.QTreeWidgetItem(self._tree, (str(len(self.rows)),)) - row = self._row_class(self, self.puzzle) + row_class = row_class or self._row_class + row = row_class(self, self.puzzle) row._populate_item(self._tree, item) self.rows.append(row) self._items.append(item) for key in kwargs: row.params[key].set_value(kwargs[key]) - for param_name in self.param_names: + for param_name in row.params: if param_name in self._slots: for slot in self._slots[param_name]: row.params[param_name].changed.connect(slot) @@ -88,6 +96,9 @@ def remove_row(self, id): self._items[i].setText(0, str(i)) self.rows_changed.emit() + def select_row(self, id): + self._tree.setCurrentItem(self._items[id]) + def get_index(self, row): """ Get the current index of a given :class:`~puzzlepiece.extras.datagrid.Row` object. @@ -158,6 +169,35 @@ def define_actions(self): """ pass + def open_popup(self, popup, name=None): + """ + Open a popup window for this Row. + See :func:`puzzlepiece.piece.Piece.open_popup`. + + :param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate + :param name: text to show as the window title + :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 = pzp.piece._QDialog(self.parent, popup) + layout = QtWidgets.QVBoxLayout() + dialog.setLayout(layout) + layout.addWidget(popup) + dialog.setWindowTitle(name or "Popup") + + # Display the dialog + dialog.show() + dialog.raise_() + dialog.activateWindow() + self.puzzle._close_popups.connect(dialog.accept) + + return popup + def elevate(self): """ For compatibility with the Piece's API. @@ -167,9 +207,11 @@ def elevate(self): pass def _populate_item(self, tree, item): - for i, key in enumerate(self.params): + visible_params = [x for x in self.params if self.params[x].visible] + for i, key in enumerate(visible_params): tree.setItemWidget(item, i + 1, self.params[key]) - tree.setItemWidget(item, i + 2, self._action_buttons()) + if len(self.actions.keys()): + tree.setItemWidget(item, i + 2, self._action_buttons()) def _action_buttons(self): widget = QtWidgets.QWidget() diff --git a/puzzlepiece/extras/ipython_shims.py b/puzzlepiece/extras/ipython_shims.py new file mode 100644 index 0000000..3d8575e --- /dev/null +++ b/puzzlepiece/extras/ipython_shims.py @@ -0,0 +1,43 @@ +try: + from IPython.core.magic import cell_magic, Magics, magics_class + from IPython import get_ipython +except ModuleNotFoundError: + raise Exception("This module needs to be imported within an IPython environment.") +import traceback + + +@magics_class +class CustomMagics(Magics): + @cell_magic + def pzp_script(self, line, cell): + puzzle = line if len(line) else "puzzle" + self.shell.ex(f'{puzzle}.run("""{cell}""")') + + # The skeleton for this magic comes from https://stackoverflow.com/a/54890975 + @cell_magic + def safe_run(self, line, cell): + try: + self.shell.ex(cell) # This executes the cell in the current namespace + except Exception as e: + try: + # Check the provided line is callable + if len(line) and ip.ev(f"callable({line})"): + # Save the error to the global namespace and execute the provided handler + self.shell.user_ns["error"] = e + ip.ev(f"{line}(error)") + except Exception as ee: + print("The provided exception handler failed to run:", str(ee)) + # Re-raise the error + raise e + + +# Register +ip = get_ipython() +ip.register_magics(CustomMagics) + + +def format_exception(error): + return ( + "".join(traceback.format_tb(error.__traceback__)) + + f"\n{type(error).__name__}: {error}" + ) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 8ce37b2..ff2a8f6 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -1,8 +1,14 @@ -from pyqtgraph.Qt import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from functools import wraps import numpy as np +_red_bg_palette = QtGui.QPalette() +_red_bg_palette.setColor( + _red_bg_palette.ColorRole.Window, QtGui.QColor(252, 217, 202, 255) +) + + class BaseParam(QtWidgets.QWidget): """ A param object represents a gettable/settable value within a :class:`~puzzlepiece.piece.Piece`. This can be @@ -47,6 +53,7 @@ def __init__( **kwargs, ): super().__init__(*args, **kwargs) + self._name = name self._setter = setter self._getter = getter self._value = None @@ -65,6 +72,7 @@ def __init__( self._main_layout = layout = QtWidgets.QGridLayout() layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) + self.setPalette(_red_bg_palette) # Give the param a label self.label = QtWidgets.QLabel() @@ -79,7 +87,7 @@ def __init__( self._value = self._type(value) if self._value is None: # Highlight that the setter or getter haven't been called yet - self.input.setStyleSheet("QWidget { background-color: #fcd9ca; }") + self.setAutoFillBackground(True) layout.addWidget(self.input, 0, 1) # self.set_value(value) @@ -111,8 +119,8 @@ def _make_get_button(self): def _value_change_handler(self): if self._setter is not None: - # Highlight the input box if a setter is set - self.input.setStyleSheet("QWidget { background-color: #fcd9ca; }") + # Highlight the param box if a setter is set + self.setAutoFillBackground(True) else: # If there's no setter, we call set_value to set the value from input self.set_value() @@ -160,7 +168,7 @@ def set_value(self, value=None): self._value = value # Clear the highlight and emit the changed signal - self.input.setStyleSheet("") + self.setAutoFillBackground(False) self.changed.emit() return self._value @@ -179,7 +187,7 @@ def get_value(self): # Set the value to the input and emit signal if needed self._input_set_value(new_value) - self.input.setStyleSheet("") + self.setAutoFillBackground(False) self.changed.emit() return new_value @@ -268,6 +276,58 @@ def _input_get_value(self): """ return self._type(self.input.text()) + def make_child_param(self, kwargs=None): + """ + Create and return a child param. Changing the value of the child changes the value + of the parent, but not vice versa - each child has a getter that allows for refreshing + the value from the parent. + + The child will be of the same type as the parent - a checkbox, spinbox, etc. + + The parent's getter will be called when you :func:`~puzzlepiece.param.BaseParam.get_value` + on the child. The parent's setter will be called when you + :func:`~puzzlepiece.param.BaseParam.set_value` on a child. + + You may need to override this method when creating params that have a different call + signature for ``__init__``. Additional arguments can then be provided with ``kwargs``. + + See :func:`puzzlepiece.piece.Popup.add_child_params` for a quick way of adding child + params to a popup. + + :param kwargs: Additional arguments to pass when creating the child. + """ + # Only make an explicit setter if this param has an explicit setter. + # The other case is handled via a Signal below, once the child + # param is created. + setter = None if self._setter is None else (lambda value: self.set_value(value)) + + # child params always have a getter, to make the direction of data flow clear. + def getter(): + return self.get_value() + + kwargs = kwargs or {} + + child = type(self)( + self._name, + self._value, + setter=setter, + getter=getter, + format=self._format, + _type=self._type, + **kwargs, + ) + + if self._setter is None: + # If no explicit setter, just set the parent param whenever the child updates + child.changed.connect(lambda: self.set_value(child.value)) + elif self._value is not None: + # When a param is created and has an explicit setter, it will be highlighted + # red to indicate the setter has not been called. Here we remove the highlight + # for the child if the parent's setter has been called already. + child.setAutoFillBackground(False) + + return child + @property def type(self): """ @@ -351,6 +411,15 @@ def _input_get_value(self): """:meta private:""" return self.input.value() + def make_child_param(self, kwargs=None): + return super().make_child_param( + kwargs={ + "v_min": self._v_min, + "v_max": self._v_max, + "v_step": self._v_step, + } + ) + class ParamFloat(ParamInt): """ @@ -670,6 +739,13 @@ def _input_get_value(self): """:meta private:""" return self.input.currentText() + def make_child_param(self, kwargs=None): + return super().make_child_param( + kwargs={ + "values": self._values, + } + ) + class ParamProgress(BaseParam): """ diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index c3bd4ad..652764e 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -125,7 +125,7 @@ def setup(self): """ pass - def open_popup(self, popup): + def open_popup(self, popup, name=None): """ 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 @@ -134,6 +134,7 @@ def open_popup(self, popup): for details on implementing a Popup. :param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate + :param name: text to show as the window title :rtype: puzzlepiece.piece.Popup """ # Instantiate the popup @@ -146,6 +147,7 @@ def open_popup(self, popup): layout = QtWidgets.QVBoxLayout() dialog.setLayout(layout) layout.addWidget(popup) + dialog.setWindowTitle(name or "Popup") # Display the dialog dialog.show() @@ -321,6 +323,36 @@ def parent_piece(self): """ return self._parent_piece + def add_child_params(self, param_names): + """ + Given a list of param names referring to params of the parent :class:`~puzzlepiece.piece.Piece`, + add corresponding child params to this Popup. + + This lets you quickly make a Settings popup that adjusts the hidden params of a Piece. + + See :func:`puzzlepiece.param.BaseParam.make_child_param` for details. + + :param param_names: List of the parent_piece's param names to make children from. + """ + for name in param_names: + self.params[name] = self.parent_piece.params[name].make_child_param() + + def add_child_actions(self, action_names): + """ + Given a list of action names referring to actions of the parent :class:`~puzzlepiece.piece.Piece`, + add corresponding child actions to this Popup. + + This lets you surface additional actions in a Popup without cluttering the main Piece. + + See :func:`puzzlepiece.action.Action.make_child_action` for details. + + :param action_names: List of the parent_piece's action names to make children from. + """ + for name in action_names: + self.actions[name] = self.parent_piece.actions[name].make_child_action() + + # TODO: A way to close the Popup from 'within' + def handle_close(self): """ Called when the Popup is closed. Override to perform actions when the user diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index b21c602..15c42d0 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -1,5 +1,6 @@ -from pyqtgraph.Qt import QtWidgets, QtCore from . import parse + +from pyqtgraph.Qt import QtWidgets, QtCore import sys @@ -18,7 +19,9 @@ class Puzzle(QtWidgets.QWidget): :type bottom_buttons: bool """ - def __init__(self, app=None, name="Puzzle", debug=True, bottom_buttons=True, *args, **kwargs): + def __init__( + self, app=None, name="Puzzle", debug=True, bottom_buttons=True, *args, **kwargs + ): super().__init__(*args, **kwargs) # Pieces can handle the debug flag as they wish self._debug = debug @@ -93,7 +96,19 @@ def pieces(self): A :class:`~puzzlepiece.puzzle.PieceDict`, effectively a dictionary of :class:`~puzzlepiece.piece.Piece` objects. Can be used to access Pieces from within other Pieces. - You can also directly index the Puzzle object with the Piece name. + You can also directly index the Puzzle object with the :class:`~puzzlepiece.piece.Piece` name, + or even with a :class:`~puzzlepiece.piece.Piece` and a :class:`~puzzlepiece.param.BaseParam`:: + + # These two are equivalent + puzzle.pieces["piece_name"] + puzzle["piece_name"] + + # These three are equivalent + puzzle.pieces["piece_name"].params["piece_name"] + puzzle["piece_name"]["param_name"] + puzzle["piece_name:param_name"] + + The valid keys for indexing a Puzzle object are available when autocompleting the key in IPython. """ return self._pieces @@ -134,6 +149,42 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): return piece + def replace_piece(self, name, new_piece): + """ + Replace a named :class:`~puzzlepiece.piece.Piece` with a new one. Can be + combined with ``importlib.reload`` to do live development on Pieces. + + This method is **experimental** and can sometimes fail. It's useful for development, + but shouldn't really be used in production applications. + + :param name: Name of the Piece to be replaced. + :param piece: A :class:`~puzzlepiece.piece.Piece` object or a class defining one (which will + be automatically instantiated). + """ + old_piece = self.pieces[name] + if isinstance(new_piece, type): + new_piece = new_piece(self) + + if old_piece in self._toplevel: + self.layout.replaceWidget( + old_piece, + new_piece, + options=QtCore.Qt.FindChildOption.FindDirectChildrenOnly, + ) + new_piece.setTitle(name) + self._toplevel.remove(old_piece) + self._toplevel.append(new_piece) + else: + for widget in self._toplevel: + if isinstance(widget, Folder): + widget._replace_piece(name, old_piece, new_piece) + + self._pieces._replace_item(name, new_piece) + old_piece.handle_close(None) + # old_piece.deleteLater() + old_piece.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) + old_piece.close() + def add_folder(self, row, column, rowspan=1, colspan=1): """ Adds a tabbed :class:`~puzzlepiece.puzzle.Folder` to the grid layout, and returns it. @@ -311,6 +362,7 @@ def __save_export(): def _call_stop(self): for piece_name in self.pieces: self.pieces[piece_name].call_stop() + self._shutdown_threads.emit() def _button_layout(self): layout = QtWidgets.QHBoxLayout() @@ -336,7 +388,10 @@ def __getitem__(self, name): return self.pieces[name] def _ipython_key_completions_(self): - return self.pieces.keys() + values = list(self.pieces.keys()) + for piece in self.pieces.keys(): + values.extend([f"{piece}:{param}" for param in self.pieces[piece].params]) + return values def run(self, text): """ @@ -455,6 +510,8 @@ def add_piece(self, name, piece): # No title or border displayed when Piece in Folder piece.setTitle(None) piece.setStyleSheet("QGroupBox {border:0;}") + # Remove most of the border if the stylesheet fails + piece.setFlat(True) return piece @@ -483,6 +540,23 @@ def handle_shortcut(self, event): """ self.currentWidget().handle_shortcut(event) + def _replace_piece(self, name, old_piece, new_piece): + if old_piece in self.pieces: + index = self.indexOf(old_piece) + self.insertTab(index, new_piece, name) + new_piece.folder = self + # No title or border displayed when Piece in Folder + new_piece.setTitle(None) + new_piece.setStyleSheet("QGroupBox {border:0;}") + new_piece.setFlat(True) + + self.pieces.remove(old_piece) + self.pieces.append(new_piece) + else: + for widget in self.pieces: + if isinstance(widget, Grid): + widget._replace_piece(name, old_piece, new_piece) + class Grid(QtWidgets.QWidget): """ @@ -519,9 +593,17 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): self.pieces.append(piece) self.puzzle.register_piece(name, piece) piece.folder = self - + return piece + def _replace_piece(self, name, old_piece, new_piece): + if old_piece in self.pieces: + self.layout.replaceWidget(old_piece, new_piece) + new_piece.setTitle(name) + new_piece.folder = self + self.pieces.remove(old_piece) + self.pieces.append(new_piece) + def handle_shortcut(self, event): """ Pass down keypress events to child Pieces. @@ -546,6 +628,8 @@ class PieceDict: """ A dictionary wrapper that enforces single-use of keys, and raises a more useful error when a Piece tries to use another Piece that hasn't been registered. + + It also allows indexing params directly by using this key format: ``[piece_name]:[param_name]``. """ def __init__(self): @@ -562,11 +646,20 @@ def __iter__(self): def __getitem__(self, key): if key not in self._dict: + try: + piece, param = key.split(":") + return self._dict[piece][param] + except ValueError: + # key is not in the piece:param format + pass raise KeyError( "A Piece with id '{}' is required, but doesn't exist".format(key) ) return self._dict[key] + def _replace_item(self, key, value): + self._dict[key] = value + def __contains__(self, item): return item in self._dict diff --git a/puzzlepiece/threads.py b/puzzlepiece/threads.py index f2ae132..d4ea825 100644 --- a/puzzlepiece/threads.py +++ b/puzzlepiece/threads.py @@ -89,6 +89,10 @@ def run(self): self.done = True +class _Done_Emitter(QtCore.QObject): + signal = QtCore.Signal() + + class LiveWorker(Worker): """ A Worker that calls a function repeatedly in a thread, @@ -106,6 +110,10 @@ def __init__(self, function, sleep=0.1, args=None, kwargs=None): super().__init__(function, args, kwargs) #: A Qt signal emitted each time the function returns, passes the returned value to the connected Slot. self.returned = self.returned + # The above line is there for documentation to compile correctly + self._done_emitter = _Done_Emitter() + #: A Qt signal emitted when the LiveWorker is stopped. + self.done_signal = self._done_emitter.signal def stop(self): """ @@ -128,6 +136,7 @@ def run(self): time.sleep(self.sleep) finally: self.done = True + self.done_signal.emit() class PuzzleTimer(QtWidgets.QWidget): @@ -170,6 +179,7 @@ def _state_handler(self, state): if state and (self.worker is None or self.worker.done): self.worker = LiveWorker(self.function, self._sleep, self.args, self.kwargs) self.worker.returned.connect(self._return_handler) + self.worker.done_signal.connect(self.stop) self.puzzle.run_worker(self.worker) elif self.worker is not None: self.worker.stop() diff --git a/pyproject.toml b/pyproject.toml index f2f71a1..345c626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "puzzlepiece" -version = "0.10.0" +version = "0.11.0" authors = [ { name="Jakub Dranczewski", email="jakub.dranczewski@gmail.com" }, ]