From 2dcfc36e899b169672f4d033f7589200b2a659ce Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Thu, 13 Apr 2023 18:27:54 -0400 Subject: [PATCH] Swap to ruff and fix many styling issues (#271) * style: manual fixes * style: auto fixes from ruff * style: more manual fixes * fix: manual fixes * fix: restore import structure * remove unused functions * style: more styling * style: more fixes * style: more fixes --- .gitignore | 1 + .pre-commit-config.yaml | 34 +-- docs/examples/custom-callbacks.ipynb | 4 + docs/examples/devlop/devlop-base.ipynb | 171 ++++++++++++- docs/examples/devlop/devlop-controller.ipynb | 2 - docs/examples/hyperslicer.ipynb | 7 - docs/examples/rossler-attractor.ipynb | 7 - docs/examples/scatter-selector.ipynb | 7 - docs/examples/text-annotations.ipynb | 7 - docs/examples/usage.ipynb | 4 + mpl_interactions/__init__.py | 1 - mpl_interactions/controller.py | 74 +++--- mpl_interactions/generic.py | 57 ++--- mpl_interactions/helpers.py | 247 ++++++------------- mpl_interactions/mpl_kwargs.py | 5 +- mpl_interactions/pyplot.py | 129 ++++++---- mpl_interactions/utils.py | 30 +-- mpl_interactions/widgets.py | 86 ++++--- mpl_interactions/xarray_helpers.py | 16 +- pyproject.toml | 38 +++ tests/test_pyplot.py | 8 - 21 files changed, 510 insertions(+), 425 deletions(-) diff --git a/.gitignore b/.gitignore index 1c552b2..87c62ed 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,4 @@ _version.py # mpl testing result_images/ +docs/examples/devlop diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 782cc34..11cbac6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,24 +2,24 @@ ci: autoupdate_schedule: "quarterly" repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-docstring-first - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 23.3.0 hooks: - id: black - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/nbQA-dev/nbQA - rev: 1.5.2 + rev: 1.7.0 hooks: - id: nbqa-black - id: nbqa-isort @@ -29,26 +29,8 @@ repos: hooks: - id: nbstripout - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.0 + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.256 hooks: - - id: prettier - - - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 - hooks: - - id: flake8 - additional_dependencies: [flake8-typing-imports==1.7.0] - - - repo: https://github.com/PyCQA/autoflake - rev: v1.6.1 - hooks: - - id: autoflake - args: - [ - "--exclude=mpl_interactions/ipyplot.py", - "--in-place", - "--remove-all-unused-imports", - "--ignore-init-module-imports", - "--remove-unused-variables", - ] + - id: ruff + args: [--fix] diff --git a/docs/examples/custom-callbacks.ipynb b/docs/examples/custom-callbacks.ipynb index d85f20f..2d620e9 100644 --- a/docs/examples/custom-callbacks.ipynb +++ b/docs/examples/custom-callbacks.ipynb @@ -31,6 +31,8 @@ "outputs": [], "source": [ "# define the function\n", + "\n", + "\n", "def f(x, tau, beta):\n", " return np.sin(x * tau) * beta\n", "\n", @@ -158,6 +160,8 @@ "# attach a custom callback\n", "\n", "# if running from a script you can just delete the widgets.Output and associated code\n", + "\n", + "\n", "def my_callback(tau, beta):\n", " if tau < 7.5:\n", " ax.tick_params(axis=\"x\", colors=\"red\")\n", diff --git a/docs/examples/devlop/devlop-base.ipynb b/docs/examples/devlop/devlop-base.ipynb index d9f9dc3..35c101b 100644 --- a/docs/examples/devlop/devlop-base.ipynb +++ b/docs/examples/devlop/devlop-base.ipynb @@ -3,7 +3,9 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "%matplotlib ipympl\n", @@ -16,11 +18,174 @@ "import mpl_interactions.ipyplot as iplt\n", "from mpl_interactions import *" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import xarray as xr\n", + "\n", + "from mpl_interactions import hyperslicer\n", + "\n", + "arr = np.zeros([10, 150, 200])\n", + "arr[:, 50:100, 50:150] = 1\n", + "\n", + "arr_xr = xr.DataArray(arr, dims=(\"whatever\", \"y\", \"x\"))\n", + "\n", + "fig, axs = plt.subplots(2, 2)\n", + "\n", + "im_upper = axs[0, 0].imshow(arr[0], origin=\"upper\")\n", + "axs[0, 0].set_title(\"imshow - upper\")\n", + "axs[0, 1].set_title(\"hypeslicer - upper\")\n", + "x = np.linspace(0, 100)\n", + "axs[0, 0].scatter(x, x)\n", + "axs[0, 1].scatter(x, x)\n", + "ctrls = hyperslicer(arr_xr, ax=axs[0, 1], origin=\"upper\")\n", + "\n", + "im_lower = axs[1, 0].imshow(arr[0], origin=\"lower\")\n", + "axs[1, 0].set_title(\"imshow - lower\")\n", + "x = np.linspace(0, 100)\n", + "axs[1, 0].scatter(x, x)\n", + "axs[1, 1].scatter(x, x)\n", + "axs[1, 1].set_title(\"hyperslicer - lower\")\n", + "ctrls2 = hyperslicer(arr_xr, ax=axs[1, 1], origin=\"lower\")\n", + "plt.tight_layout()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "axs.flatten()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "for ax in axs.flatten():\n", + " print(ax.get_xlim())\n", + " print(ax.get_ylim())\n", + " print(\"------------\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "im_lower.get_extent()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "def hyperslicer_1d(arr, split_dim=None, **kwargs):\n", + " arr = np.asanyarray(arr)\n", + " if split_dim == -1 or split_dim == arr.ndim:\n", + " raise ValueError(\"nope split_dim must not be the last axis\")\n", + " axes = {}\n", + " for i, s in enumerate(arr.shape[:-1]):\n", + " axes[f\"axis_{i}\"] = np.arange(s)\n", + " slices = [0 for i in range(arr.ndim - 1)]\n", + " slices.append(slice(None))\n", + "\n", + " def picker(**kwargs):\n", + " for i in range(arr.ndim - 1):\n", + " slices[i] = int(kwargs[f\"axis_{i}\"])\n", + " if split_dim is not None:\n", + " slices[split_dim] = slice(None)\n", + " return arr[tuple(slices)].T\n", + "\n", + " ctrls = kwargs.get(\"controls\", None)\n", + " if ctrls:\n", + " return iplt.plot(picker, **kwargs)\n", + " else:\n", + " return iplt.plot(picker, **axes, **kwargs)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "arr1 = np.random.rand(11, 1340)\n", + "arr2 = np.random.rand(11, 1340)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plt.subplots()\n", + "ctrls = hyperslicer_1d(arr1)\n", + "hyperslicer_1d(arr2, controls=ctrls)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import xarray as xr\n", + "\n", + "from mpl_interactions import hyperslicer\n", + "\n", + "arr = np.zeros([10, 150, 200])\n", + "arr[:, 50:100, 50:150] = 1\n", + "\n", + "\n", + "fig, axs = plt.subplots(1, 2)\n", + "\n", + "with hyperslicer(arr, ax=axs[0]) as ctrls:\n", + " hyperslicer(arr, ax=axs[1])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -34,7 +199,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.0" + "version": "3.11.0" } }, "nbformat": 4, diff --git a/docs/examples/devlop/devlop-controller.ipynb b/docs/examples/devlop/devlop-controller.ipynb index f593b32..379c310 100644 --- a/docs/examples/devlop/devlop-controller.ipynb +++ b/docs/examples/devlop/devlop-controller.ipynb @@ -310,7 +310,6 @@ " )\n", "\n", " def update(params, indices):\n", - "\n", " # update plot\n", " for i, f in enumerate(funcs):\n", " if x is not None and not indexed_x:\n", @@ -379,7 +378,6 @@ "\n", " lines = []\n", " for i, f in enumerate(funcs):\n", - "\n", " if x is not None and not indexed_x:\n", " lines.append(ax.plot(x, f(x, **params), **plot_kwargs[i])[0])\n", " elif indexed_x:\n", diff --git a/docs/examples/hyperslicer.ipynb b/docs/examples/hyperslicer.ipynb index 4cf2d53..374d572 100644 --- a/docs/examples/hyperslicer.ipynb +++ b/docs/examples/hyperslicer.ipynb @@ -356,13 +356,6 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.0" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } } }, "nbformat": 4, diff --git a/docs/examples/rossler-attractor.ipynb b/docs/examples/rossler-attractor.ipynb index 59c43f9..8aecb48 100644 --- a/docs/examples/rossler-attractor.ipynb +++ b/docs/examples/rossler-attractor.ipynb @@ -189,13 +189,6 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.0" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } } }, "nbformat": 4, diff --git a/docs/examples/scatter-selector.ipynb b/docs/examples/scatter-selector.ipynb index 17db209..6b55a9e 100644 --- a/docs/examples/scatter-selector.ipynb +++ b/docs/examples/scatter-selector.ipynb @@ -241,13 +241,6 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.0" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } } }, "nbformat": 4, diff --git a/docs/examples/text-annotations.ipynb b/docs/examples/text-annotations.ipynb index 7f1822d..dbfa6ce 100644 --- a/docs/examples/text-annotations.ipynb +++ b/docs/examples/text-annotations.ipynb @@ -165,13 +165,6 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.0" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } } }, "nbformat": 4, diff --git a/docs/examples/usage.ipynb b/docs/examples/usage.ipynb index a76f59f..002840b 100644 --- a/docs/examples/usage.ipynb +++ b/docs/examples/usage.ipynb @@ -46,6 +46,8 @@ "outputs": [], "source": [ "# define the function\n", + "\n", + "\n", "def f(x, tau, beta):\n", " return np.sin(x * tau) * x**beta\n", "\n", @@ -453,6 +455,8 @@ "\n", "iplt.title(\"the value of tau is: {tau:.2f}\", controls=controls[\"tau\"])\n", "# you can still use plt commands if this is the active figure\n", + "\n", + "\n", "def ylabel(tau):\n", " return f\"tau/2 is {np.round(tau/2,3)}\"\n", "\n", diff --git a/mpl_interactions/__init__.py b/mpl_interactions/__init__.py index 3109beb..9dd1bf5 100644 --- a/mpl_interactions/__init__.py +++ b/mpl_interactions/__init__.py @@ -2,7 +2,6 @@ from ._version import __version__ except ImportError: __version__ = "unkown" -from .deprecations import mpl_interactions_DeprecationWarning from .generic import * from .helpers import * from .pyplot import * diff --git a/mpl_interactions/controller.py b/mpl_interactions/controller.py index c445f10..8cd299a 100644 --- a/mpl_interactions/controller.py +++ b/mpl_interactions/controller.py @@ -24,6 +24,8 @@ class Controls: + """Manager of many interactive functions.""" + def __init__( self, slider_formats=None, @@ -45,7 +47,8 @@ def __init__( if self.use_ipywidgets: if _not_ipython: raise ValueError( - "You need to be in an Environment with IPython.display available to use ipywidgets" + "You need to be in an Environment with IPython.display" + " available to use ipywidgets" ) self.vbox = widgets.VBox([]) else: @@ -65,9 +68,10 @@ def __init__( self.add_kwargs(kwargs, slider_formats, play_buttons) def add_kwargs(self, kwargs, slider_formats=None, play_buttons=None, allow_duplicates=False): - """ + """Add kwargs to the controller. + If you pass a redundant kwarg it will just be overwritten - maybe should only raise a warning rather than an error? + maybe should only raise a warning rather than an error?. need to implement matplotlib widgets also a big question is how to dynamically update the display of matplotlib widgets. @@ -109,7 +113,7 @@ def add_kwargs(self, kwargs, slider_formats=None, play_buttons=None, allow_dupli ) if control: self.controls[k] = control - self.vbox.children = list(self.vbox.children) + [control] + self.vbox.children = [*list(self.vbox.children), control] if k == "vmin_vmax": self.params["vmin"] = self.params["vmin_vmax"][0] self.params["vmax"] = self.params["vmin_vmax"][1] @@ -140,9 +144,8 @@ def add_kwargs(self, kwargs, slider_formats=None, play_buttons=None, allow_dupli self.params["vmax"] = self.params["vmin_vmax"][1] def _slider_updated(self, change, key, values): - """ - gotta also give the indices in order to support hyperslicer without horrifying contortions - """ + # Gotta also give the indices in order to support + # hyperslicer without horrifying contortions if values is None: self.params[key] = change["new"] else: @@ -180,10 +183,13 @@ def _slider_updated(self, change, key, values): f.canvas.draw_idle() def slider_updated(self, change, key, values): - """ + """Call the slider updated function special casing vmin/vmax. + + Not sure why this is public - users should NOT call this directly. + thin wrapper to enable splitting of special cased range sliders. e.g. of ``vmin_vmax`` -> ``vmin`` and ``vmax``. In the future maybe - generalize this to any range slider with an underscore in the name? + generalize this to any range slider with an underscore in the name?. """ self._slider_updated(change, key, values) if key == "vmin_vmax": @@ -215,9 +221,7 @@ def register_callback(self, callback, params=None, eager=False): self._register_function(callback, fig=None, params=params) def _register_function(self, f, fig=None, params=None): - """ - if params is None use the entire current set of params - """ + """If params is None use the entire current set of params.""" if params is None: params = self.params.keys() # listify to ensure it's not a reference to dicts keys @@ -234,7 +238,7 @@ def _register_function(self, f, fig=None, params=None): # the figure def save_animation( - self, filename, fig, param, interval=20, func_anim_kwargs={}, N_frames=None, **kwargs + self, filename, fig, param, interval=20, func_anim_kwargs=None, N_frames=None, **kwargs ): """ Save an animation over one of the parameters controlled by this `Controls` object. @@ -242,12 +246,14 @@ def save_animation( Parameters ---------- filename : str + Where to save the animation fig : figure + The figure to animate. param : str the name of the kwarg to use to animate interval : int, default: 2o interval between frames in ms - func_anim_kwargs : dict + func_anim_kwargs : dict, optional kwargs to pass the creation of the underlying FuncAnimation N_frames : int Only used if the param is a matplotlib slider that was created without a @@ -261,6 +267,8 @@ def save_animation( ------- anim : matplotlib.animation.FuncAniation """ + if func_anim_kwargs is None: + func_anim_kwargs = {} slider = self.controls[param] ipywidgets_slider = False if "Box" in str(slider.__class__): @@ -306,9 +314,7 @@ def f(i): return anim def display(self): - """ - Display the display the ipywidgets controls or show the control figures - """ + """Display the display the ipywidgets controls or show the control figures.""" if self.use_ipywidgets: ipy_display(self.vbox) else: @@ -317,9 +323,7 @@ def display(self): fig.show() def show(self): - """ - Show the control figures or display the ipywidgets controls - """ + """Show the control figures or display the ipywidgets controls.""" self.display() def _ipython_display_(self): @@ -330,15 +334,14 @@ def __getitem__(self, key): hack to allow calls like interactive_plot(...beta=(0,1), controls = controls["tau"]) also allows [None] to grab None of the current params - to imply that we only want tau from the existing set of commands + to imply that we only want tau from the existing set of commands. I think ideally this would give another controls object with just the given params that has this one as a parent - I think that that is most consistent with the idea of indexing (e.g. indexing a numpy array gives you a numpy array). But it's not clear how to implement that with all the sliders and such that get created. So for now do a sort of half-measure by returing the controls_proxy object. - """ - + """ # noqa: D205 # make sure keys is a list # bc in gogogo_controls it may get added to another list keys = key @@ -352,10 +355,12 @@ def __getitem__(self, key): return _controls_proxy(self, context=False, keys=keys) def __enter__(self): + """Have this controller act as the active controller.""" self._context = _controls_proxy(self, context=True, keys=list(self.params.keys())) return self._context def __exit__(self, exc_type, exc_value, traceback): + """Remove this controller from the controls stack.""" self._context._stack.remove(self._context) @@ -387,6 +392,11 @@ def gogogo_controls( extra_controls=None, allow_dupes=False, ): + """ + Create a new controls object. + + This should be private - users should NOT use this. + """ # check if we're in a controls context manager if len(_controls_proxy._stack) > 0: ctrl_context = _controls_proxy._stack[-1] @@ -448,10 +458,11 @@ def f(*args, **kwargs): return f -def _gen_param_excluder(added_kwargs): - """ +def _gen_param_excluder(added_kwargs): # noqa: D417 + """Make a function to handle remove params from kwargs. + Pass through all the original keys, but exclude any kwargs that we added - manually through prep_scalar + manually through prep_scalar. Parameters ---------- @@ -462,13 +473,13 @@ def _gen_param_excluder(added_kwargs): excluder : callable """ - def excluder(params, except_=None): + def excluder(params, except_=None): # noqa: D417 """ Parameters ---------- params : dict except : str or list[str] - """ + """ # noqa: D205 if isinstance(except_, str) or except_ is None: except_ = [except_] @@ -478,9 +489,10 @@ def excluder(params, except_=None): def prep_scalars(kwarg_dict, **kwargs): - """ - Process potentially scalar arguments. This allows for passing in - slider shorthands for these arguments, and for passing indexed controls objects for them. + """Process potentially scalar arguments. + + This allows for passing in slider shorthands for these arguments, + and for passing indexed controls objects for them. Parameters ---------- diff --git a/mpl_interactions/generic.py b/mpl_interactions/generic.py index 35254b6..6b8bace 100644 --- a/mpl_interactions/generic.py +++ b/mpl_interactions/generic.py @@ -50,6 +50,7 @@ def heatmap_slicer( Parameters ---------- X,Y : 1D array + The values for the x and y axes. heatmaps : array_like must be 2-D or 3-D. If 3-D the last two axes should be (X,Y) slices : {'horizontal', 'vertical', 'both'} @@ -171,10 +172,8 @@ def heatmap_slicer( axes[horiz_axis].legend() def _gen_idxs(orig, centered, same_shape, event_data): - """ - is there a better way? probably, but this gets the job done - so here we are... - """ + # is there a better way? probably, but this gets the job done + # so here we are... if same_shape: data_idx = nearest_idx(orig, event_data) disp_idx = nearest_idx(orig, event_data) @@ -208,7 +207,8 @@ def update_lines(event): else: close(fig) raise ValueError( - f"{interaction_type} is not a valid option for interaction_type, valid options are 'click' or 'move'" + f"{interaction_type} is not a valid option for interaction_type, valid options" + "are 'click' or 'move'" ) return fig, axes @@ -332,14 +332,13 @@ def __init__(self, fig, button=3): @property def enabled(self) -> bool: - """ - Status of the panhandler, whether it's enabled or disabled. - """ + """Status of the panhandler, whether it's enabled or disabled.""" return self._id_press is not None and self._id_release is not None def enable(self): - """ - Enable the panhandler. It should not be necessary to call this function + """Enable the panhandler. + + It should not be necessary to call this function unless it's used after a call to :meth:`panhandler.disable`. Raises @@ -350,8 +349,8 @@ def enable(self): if self.enabled: raise RuntimeError("The panhandler is already enabled") - self._id_press = self.fig.canvas.mpl_connect("button_press_event", self.press) - self._id_release = self.fig.canvas.mpl_connect("button_release_event", self.release) + self._id_press = self.fig.canvas.mpl_connect("button_press_event", self._press) + self._id_release = self.fig.canvas.mpl_connect("button_release_event", self._release) def disable(self): """ @@ -377,7 +376,7 @@ def _cancel_action(self): self.fig.canvas.mpl_disconnect(self._id_drag) self._id_drag = None - def press(self, event): + def _press(self, event): if event.button != self.button: self._cancel_action() return @@ -397,7 +396,7 @@ def press(self, event): self._xypress.append((a, i)) self._id_drag = self.fig.canvas.mpl_connect("motion_notify_event", self._mouse_move) - def release(self, event): + def _release(self, event): self._cancel_action() self.fig.canvas.mpl_disconnect(self._id_drag) @@ -417,9 +416,7 @@ def _mouse_move(self, event): class image_segmenter: - """ - Manually segment an image with the lasso selector. - """ + """Manually segment an image with the lasso selector.""" def __init__( self, @@ -436,8 +433,9 @@ def __init__( figsize=(10, 10), **kwargs, ): - """ - Create an image segmenter. Any ``kwargs`` will be passed through to the ``imshow`` + """Create an image segmenter. + + Any ``kwargs`` will be passed through to the ``imshow`` call that displays *img*. Parameters @@ -445,6 +443,7 @@ def __init__( img : array_like A valid argument to imshow nclasses : int, default 1 + How many classes. mask : arraylike, optional If you want to pre-seed the mask mask_colors : None, color, or array of colors, optional @@ -563,7 +562,7 @@ def _onselect(self, verts): self.fig.canvas.draw_idle() def _ipython_display_(self): - display(self.fig.canvas) # noqa: F405, F821 + display(self.fig.canvas) # noqa: F821 def hyperslicer( @@ -593,9 +592,10 @@ def hyperslicer( display_controls=True, **kwargs, ): - """ - View slices from a hyperstack of images selected by sliders. Also accepts Xarray.DataArrays - in which case the axes names and coordinates will be inferred from the xarray dims and coords. + """View slices from a hyperstack of images selected by sliders. + + Also accepts Xarray.DataArrays in which case the axes names and coordinates + will be inferred from the xarray dims and coords. Parameters ---------- @@ -626,7 +626,8 @@ def hyperslicer( If None a default value of decimal points will be used. Uses the new {} style formatting title : None or string If a string then you can have it update automatically using string formatting of the names - of the parameters. i.e. to include the current value of tau: title='the value of tau is: {tau:.2f}' + of the parameters. i.e. to include the current value of tau: title='the value + of tau is: {tau:.2f}' force_ipywidgets : boolean If True ipywidgets will always be used, even if not using the ipympl backend. If False the function will try to detect if it is ok to use ipywidgets @@ -643,7 +644,8 @@ def hyperslicer( - 'right': sliders on the right is_color_image : boolean - If True, will treat the last 3 dimensions as comprising a color images and will only set up sliders for the first arr.ndim - 3 dimensions. + If True, will treat the last 3 dimensions as comprising a color images and will only set up + sliders for the first arr.ndim - 3 dimensions. controls : mpl_interactions.controller.Controls An existing controls object if you want to tie multiple plot elements to the same set of controls @@ -654,7 +656,6 @@ def hyperslicer( ------- controls """ - arr = np.squeeze(arr) arr_type = "numpy" @@ -665,7 +666,8 @@ def hyperslicer( if arr.ndim < 3 + is_color_image: raise ValueError( - f"arr must be at least {3+is_color_image}D but it is {arr.ndim}D. mpl_interactions.imshow for 2D images." + f"arr must be at least {3+is_color_image}D but it is {arr.ndim}D." + " mpl_interactions.imshow for 2D images." ) if is_color_image: @@ -675,7 +677,6 @@ def hyperslicer( ipympl = notebook_backend() fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_format_strings = create_slider_format_dict(slider_formats) name_to_dim = {} diff --git a/mpl_interactions/helpers.py b/mpl_interactions/helpers.py index 2812f71..c169664 100644 --- a/mpl_interactions/helpers.py +++ b/mpl_interactions/helpers.py @@ -1,20 +1,17 @@ from collections import defaultdict from collections.abc import Callable, Iterable from functools import partial -from numbers import Number import matplotlib.widgets as mwidgets import numpy as np try: import ipywidgets as widgets - from IPython.display import display as ipy_display except ImportError: pass from matplotlib import get_backend from matplotlib.pyplot import figure, gca, gcf, ioff from matplotlib.pyplot import sca as mpl_sca -from numpy.distutils.misc_util import is_sequence from .widgets import RangeSlider @@ -23,11 +20,6 @@ "decompose_bbox", "update_datalim_from_xy", "update_datalim_from_bbox", - "is_jagged", - "broadcast_to", - "prep_broadcast", - "broadcast_arrays", - "broadcast_many", "notebook_backend", "callable_else_value", "callable_else_value_no_cast", @@ -37,7 +29,6 @@ "changeify", "create_slider_format_dict", "gogogo_figure", - "gogogo_display", "create_mpl_controls_fig", "eval_xy", "choose_fmt_str", @@ -45,9 +36,7 @@ def sca(ax): - """ - sca that won't fail if figure not managed by pyplot - """ + """Sca that won't fail if figure not managed by pyplot.""" try: mpl_sca(ax) except ValueError as e: @@ -56,6 +45,7 @@ def sca(ax): def decompose_bbox(bbox): + """Break bbox into it's 4 components.""" return bbox.x0, bbox.y0, bbox.x1, bbox.y1 @@ -79,16 +69,23 @@ def _update_limits(ax, x0, y0, x1, y1, x0_, y0_, x1_, y1_, stretch_x, stretch_y) def update_datalim_from_bbox(ax, bbox, stretch_x=True, stretch_y=True): + """Update an axis datalim from a bbox.""" _update_limits(ax, *decompose_bbox(ax.dataLim), *decompose_bbox(bbox), stretch_x, stretch_y) def update_datalim_from_xy(ax, x, y, stretch_x=True, stretch_y=True): - """ - current : ax.dataLim + """Update an axis datalim while maybe stretching it. + + Parameters + ---------- + ax : matplotlib axis + The axis to update. x : array the new x datavalues to include y : array - the new y datavalues to include + the new y datavalues to include. + stretch_x, stretch_y : bool + Whether to stretch """ # this part bc scatter not affect by relim # so need this to keep stretchign working for scatter @@ -99,91 +96,8 @@ def update_datalim_from_xy(ax, x, y, stretch_x=True, stretch_y=True): _update_limits(ax, *decompose_bbox(ax.dataLim), x0_, y0_, x1_, y1_, stretch_x, stretch_y) -def is_jagged(seq): - """ - checks for jaggedness up to two dimensions - don't need more because more doesn't make any sense for this library - need this bc numpy is unhappy about being passed jagged arrays now :( - """ - lens = [] - if is_sequence(seq): - for y in seq: - if isinstance(y, Number) or isinstance(y, Callable): - lens.append(0) - continue - try: - lens.append(len(y)) - except TypeError: - return True - if not all(lens[0] == l for l in lens): # noqa: E741 - return True - return False - - -def prep_broadcast(arr): - if arr is None: - return np.atleast_1d(None) - if is_jagged(arr): - arr = np.asarray(arr, dtype=np.object) - elif isinstance(arr, Number) or isinstance(arr, Callable): - arr = np.atleast_1d(arr) - else: - arr = np.atleast_1d(arr) - if np.issubdtype(arr.dtype, np.number) and arr.ndim == 1: - arr = arr[None, :] - return arr - - -def broadcast_to(arr, to_shape, names): - """ - happily this doesn't increase memory footprint e.g: - import sys - xs = np.arange(5) - print(sys.getsizeof(xs.nbytes)) - print(sys.getsizeof(np.broadcast_to(xs, (19000, xs.shape[0])))) - - gives 28 and 112. Note 112/28 != 19000 - """ - if arr.shape[0] == to_shape[0]: - return arr - - if arr.ndim > 1: - if arr.shape[0] == 1: - return np.broadcast_to(arr, (to_shape[0], *arr.shape[1:])) - else: - raise ValueError(f"can't broadcast {names[0]} {arr.shape} onto {names[1]} {to_shape}") - elif arr.shape[0] == 1: - return np.broadcast_to(arr, (to_shape[0],)) - else: - raise ValueError(f"can't broadcast {names[0]} {arr.shape} onto {names[1]} {to_shape}") - - -def broadcast_arrays(*args): - """ - This is a modified version the numpy `broadcast_arrays` function - that uses a version of _broadcast_to that only considers the first axis - """ - - shapes = [array.shape[0] for (array, name) in args] - idx = np.argmax(shapes) - if all([shapes[0] == s for s in shapes]): - # case where nothing needs to be broadcasted. - return [array for (array, name) in args] - return [broadcast_to(array, args[idx][0].shape, [name, args[idx][1]]) for (array, name) in args] - - -def broadcast_many(*args): - """ - helper to call prep_broadcast followed by broadcast arrays - keep as a separate function to keep the idea of broadcast_arrays the same - """ - return broadcast_arrays(*[(prep_broadcast(arg[0]), arg[1]) for arg in args]) - - def notebook_backend(): - """ - returns True if the backend is ipympl or nbagg, otherwise False - """ + """Return True if the backend is ipympl or nbagg, otherwise False.""" backend = get_backend().lower() if "ipympl" in backend: return True @@ -194,7 +108,10 @@ def notebook_backend(): def callable_else_value(arg, params, cache=None): """ - returns as a numpy array + Convert callables to arrays passing existing values through as numpy arrays. + + Always returns a numpy array - use callable_else_value_no_cast + if it's important that the value not be a numpy array. """ if isinstance(arg, Callable): if cache: @@ -207,10 +124,7 @@ def callable_else_value(arg, params, cache=None): def callable_else_value_no_cast(arg, params, cache=None): - """ - doesn't cast to numpy. Useful when working with parametric functions that might - return (x, y) where it's handy to check if the return is a tuple - """ + """Convert callables to arrays passing existing values through.""" if isinstance(arg, Callable): if cache: if arg not in cache: @@ -221,22 +135,9 @@ def callable_else_value_no_cast(arg, params, cache=None): return arg -def callable_else_value_wrapper(arg, params, cache=None): - def f(params): - if isinstance(arg, Callable): - if cache: - if arg not in cache: - cache[arg] = np.asanyarray(arg(**params)) - return cache[arg] - else: - return np.asanyarray(arg(**params)) - return np.asanyarray(arg) - - return f - - def eval_xy(x_, y_, params, cache=None): - """ + """Evaluate x and y as needed, passing them the approriate arguments. + for when y requires x as an argument and either, neither or both of x and y may be a function. This will automatically do the param exclusion for 'x' and 'y'. @@ -278,10 +179,12 @@ def eval_xy(x_, y_, params, cache=None): def kwarg_to_ipywidget(key, val, update, slider_format_string, play_button=None): - """ + """Convert a kwarg to an ipywidget. + Parameters ---------- key : str + The name of the kwarg. val : str or number or tuple, or set or array-like The value to be interpreted and possibly transformed into an ipywidget update : callable @@ -302,7 +205,6 @@ def kwarg_to_ipywidget(key, val, update, slider_format_string, play_button=None) widget (e.g. HBox) depending on what widget was generated. If a fixed value is returned then control will be *None* """ - control = None if isinstance(val, set): if len(val) == 1: @@ -401,17 +303,15 @@ def kwarg_to_ipywidget(key, val, update, slider_format_string, play_button=None) def extract_num_options(val): - """ - convert a categorical to a number of options - """ + """Convert a categorical to a number of options.""" if len(val) == 1: for v in val: if isinstance(v, tuple): # this looks nightmarish... # but i think it should always work # should also check if the tuple has length one here. - # that will only be an issue if a trailing comma was used to make the tuple ('beep',) - # but not ('beep') - the latter is not actually a tuple + # that will only be an issue if a trailing comma was used to make the tuple + # i.e ('beep',) but not ('beep') - the latter is not actually a tuple return len(v) else: return 0 @@ -420,18 +320,19 @@ def extract_num_options(val): def changeify(val, update): - """ - make matplotlib update functions return a dict with key 'new'. - Do this for compatibility with ipywidgets + """Make matplotlib update functions return a dict with key 'new'. + + This makes it compatible with the ipywidget callback style. """ update({"new": val}) def changeify_radio(val, labels, update): - r""" + r"""Convert matplotlib radio button callback into the expected dictionary form. + matplolib radio buttons don't keep track what index is selected. So this figures out what the index is - made a whole function bc its easier to use with partial then + made a whole function bc its easier to use with partial then. There doesn't seem to be a good way to determine which one was clicked if the radio button has multiple identical values but that's wildly niche @@ -442,6 +343,8 @@ def changeify_radio(val, labels, update): def create_mpl_controls_fig(kwargs): """ + Create a figure to hold matplotlib widgets. + Returns ------- fig : matplotlib figure @@ -465,7 +368,7 @@ def create_mpl_controls_fig(kwargs): n_opts = 0 n_radio = 0 n_sliders = 0 - for key, val in kwargs.items(): + for _key, val in kwargs.items(): if isinstance(val, set): new_opts = extract_num_options(val) if new_opts > 0: @@ -492,7 +395,7 @@ def create_mpl_controls_fig(kwargs): slider_height = 0 radio_height = 0 gap_height = 0 - if not all(map(lambda x: isinstance(x, mwidgets.AxesWidget), kwargs.values())): + if not all(isinstance(x, mwidgets.AxesWidget) for x in kwargs.values()): # if the only kwargs are existing matplotlib widgets don't make a new figure with ioff(): fig = figure() @@ -510,9 +413,7 @@ def create_mpl_controls_fig(kwargs): def create_mpl_selection_slider(ax, label, values, slider_format_string): - """ - creates a slider that behaves similarly to the ipywidgets selection slider - """ + """Create a slider that behaves similarly to the ipywidgets selection slider.""" slider = mwidgets.Slider(ax, label, 0, len(values) - 1, valinit=0, valstep=1) def update_text(val): @@ -525,9 +426,7 @@ def update_text(val): def create_mpl_range_selection_slider(ax, label, values, slider_format_string): - """ - creates a slider that behaves similarly to the ipywidgets selection slider - """ + """Create a slider that behaves similarly to the ipywidgets selection slider.""" slider = RangeSlider(ax, label, 0, len(values) - 1, valinit=(0, len(values) - 1), valstep=1) def update_text(val): @@ -544,15 +443,16 @@ def update_text(val): def process_mpl_widget(val, update): - """ - handle the case of a kwarg being an existing matplotlib widget. + """Handle the case of a kwarg being an existing matplotlib widget. + This needs to be separate so that the controller can call it when mixing ipywidets and - a widget like scatter_selector without having to create a control figure + a widget like scatter_selector without having to create a control figure. """ if isinstance(val, mwidgets.RadioButtons): - # gotta set it to the zeroth index bc there's no reasonable way to determine the current value - # the only way the current value is stored is through the color of the circles. - # so could query that an extract but oh boy do I ever not want to + # gotta set it to the zeroth index bc there's no reasonable way to determine + # the current value the only way the current value is stored is through + # the color of the circles. so could query that an extract but + # oh boy do I ever not want to val.set_active(0) cb = val.on_clicked(partial(changeify_radio, labels=val.labels, update=update)) return val.labels[0], val, cb @@ -579,17 +479,39 @@ def kwarg_to_mpl_widget( play_button=False, play_button_pos="right", ): - """ + """Convert a kwarg to a matplotlib widget. + + Parameters + ---------- + fig : matplotlib figure + The figure in which to place the widgets. heights : tuple with slider_height, radio_height, gap_height - returns + widget_y : float + How much vertical space the widget should take up + key : str + The name of the kwarg. + val : str or number or tuple, or set or array-like + The value to be interpreted and possibly transformed into an ipywidget + update : callable + The function to be called when the value of the generated widget changes. + Must accept a dictionary *change* and an array-like *values* + slider_format_string : str + The format string to use for slider labels + play_button : bool or None or str, default: None + If true and the output widget is a slider then added a play button widget + on the left. Also accepts 'left' or 'right' to specify the play button position. + play_button_pos : str + Where to place the play button. + + Returns ------- init_val widget cb the callback id new_y - The widget_y to use for the next pass + The widget_y to use for the next pass. """ slider_height, radio_height, gap_height = heights @@ -608,7 +530,7 @@ def kwarg_to_mpl_widget( val = list(val) n = len(val) - longest_len = max(list(map(lambda x: len(list(x)), map(str, val)))) + longest_len = max([len(list(x)) for x in map(str, val)]) # should probably use something based on fontsize rather that .015 width = max(0.15, 0.015 * longest_len) radio_ax = fig.add_axes([0.2, 0.9 - widget_y - radio_height * n, width, radio_height * n]) @@ -668,6 +590,7 @@ def update_text(val): def create_slider_format_dict(slider_format_string): + """Create a dictionray of format strings based on the slider contents.""" if isinstance(slider_format_string, defaultdict): return slider_format_string elif isinstance(slider_format_string, dict) or slider_format_string is None: @@ -683,15 +606,14 @@ def f(): slider_format_strings = defaultdict(f) else: raise ValueError( - f"slider_format_string must be a dict or a string but it is a {type(slider_format_string)}" + "slider_format_string must be a dict or a string" + f" but it is a {type(slider_format_string)}" ) return slider_format_strings def gogogo_figure(ipympl, ax=None): - """ - gogogo the greatest function name of all - """ + """Gogogo the greatest function name of all.""" if ax is None: if ipympl: with ioff(): @@ -705,25 +627,6 @@ def gogogo_figure(ipympl, ax=None): return ax.get_figure(), ax -def gogogo_display(ipympl, use_ipywidgets, display, controls, fig): - if use_ipywidgets: - controls = widgets.VBox(controls) - if display: - if ipympl: - ipy_display(widgets.VBox([controls, fig.canvas])) - else: - # for the case of using %matplotlib qt - # but also want ipywidgets sliders - # ie with force_ipywidgets = True - ipy_display(controls) - fig.show() - else: - if display: - fig.show() - controls[0].show() - return controls - - def choose_fmt_str(dtype=None): """ Choose the appropriate string formatting for different dtypes. diff --git a/mpl_interactions/mpl_kwargs.py b/mpl_interactions/mpl_kwargs.py index 5bf3068..e2a9fd9 100644 --- a/mpl_interactions/mpl_kwargs.py +++ b/mpl_interactions/mpl_kwargs.py @@ -118,8 +118,9 @@ def kwarg_popper(kwargs, mpl_kwargs): - """ - This will not modify kwargs for you. + """Process a kwargs list to remove the matplotlib object kwargs. + + This will not modify kwargs in place. Examples -------- diff --git a/mpl_interactions/pyplot.py b/mpl_interactions/pyplot.py index 46bf133..8a5661a 100644 --- a/mpl_interactions/pyplot.py +++ b/mpl_interactions/pyplot.py @@ -1,7 +1,10 @@ """Control the output of standard plotting functions such as :func:`~matplotlib.pyplot.plot` and -:func:`~matplotlib.pyplot.hist` using sliders and other widgets. When using the ``ipympl`` backend -these functions will leverage ipywidgets for the controls, otherwise they will use the built-in -Matplotlib widgets.""" +:func:`~matplotlib.pyplot.hist` using sliders and other widgets. + + When using the ``ipympl`` backend these functions will leverage ipywidgets for the controls, +otherwise they will use the built-in +Matplotlib widgets. +""" # noqa: D205 from collections.abc import Callable @@ -46,7 +49,7 @@ ] -def interactive_plot( +def interactive_plot( # noqa: D417 - not my fault *args, parametric=False, ax=None, @@ -60,7 +63,7 @@ def interactive_plot( **kwargs, ): """ - Control a plot using widgets + Control a plot using widgets. interactive_plot([x], y, [fmt]) @@ -79,8 +82,8 @@ def interactive_plot( for full documentation. as xlim parametric : boolean - If True then the function expects to have only received a value for y and that that function will - return an array for both x and y, or will return an array with shape (N, 2) + If True then the function expects to have only received a value for y and that that + function will return an array for both x and y, or will return an array with shape (N, 2) ax : matplotlib axis, optional The axis on which to plot. If none the current axis will be used. slider_formats : None, string, or dict @@ -111,6 +114,8 @@ def interactive_plot( controls display_controls : boolean Whether the controls should display on creation. Ignored if controls is specified. + **kwargs: + Interpreted as widgets and remainder are passed through to `ax.plot`. Returns ------- @@ -158,8 +163,7 @@ def f(x, tau): else: raise ValueError(f"You passed in {len(args)} args, but no more than 3 is supported.") - ipympl = notebook_backend() - ipympl or force_ipywidgets + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax=ax) slider_formats = create_slider_format_dict(slider_formats) controls, params = gogogo_controls( @@ -271,7 +275,7 @@ def update(params, indices, cache): return controls -def simple_hist(arr, bins="auto", density=None, weights=None): +def _simple_hist(arr, bins="auto", density=None, weights=None): heights, bins = np.histogram(arr, bins=bins, density=density, weights=weights) width = bins[1] - bins[0] new_patches = [] @@ -283,7 +287,7 @@ def simple_hist(arr, bins="auto", density=None, weights=None): return xlims, ylims, new_patches -def stretch(ax, xlims, ylims): +def _stretch(ax, xlims, ylims): cur_xlims = ax.get_xlim() cur_ylims = ax.get_ylim() new_lims = ylims @@ -355,6 +359,9 @@ def interactive_hist( controls display_controls : boolean Whether the controls should display on creation. Ignored if controls is specified. + **kwargs : + Converted to widgets to control the parameters. Note, unlike other functions the remaining + will NOT be passed through to *hist*. Returns ------- @@ -376,10 +383,8 @@ def f(loc, scale): return np.random.randn(1000)*scale + loc interactive_hist(f, loc=(-5, 5, 500), scale=(1, 10, 100)) """ - - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax=ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) controls, params = gogogo_controls( kwargs, controls, display_controls, slider_formats, play_buttons @@ -389,14 +394,14 @@ def f(loc, scale): def update(params, indices, cache): arr_ = callable_else_value(arr, params, cache) - new_x, new_y, new_patches = simple_hist(arr_, density=density, bins=bins, weights=weights) - stretch(ax, new_x, new_y) + new_x, new_y, new_patches = _simple_hist(arr_, density=density, bins=bins, weights=weights) + _stretch(ax, new_x, new_y) pc.set_paths(new_patches) ax.autoscale_view() controls._register_function(update, fig, params.keys()) - new_x, new_y, new_patches = simple_hist( + new_x, new_y, new_patches = _simple_hist( callable_else_value(arr, params), density=density, bins=bins, weights=weights ) sca(ax) @@ -455,8 +460,8 @@ def interactive_scatter( label : string Passed through to Matplotlib parametric : boolean - If True then the function expects to have only received a value for y and that that function will - return an array for both x and y, or will return an array with shape (N, 2) + If True then the function expects to have only received a value for y and that that function + will return an array for both x and y, or will return an array with shape (N, 2) ax : matplotlib axis, optional The axis on which to plot. If none the current axis will be used. slider_formats : None, string, or dict @@ -492,7 +497,6 @@ def interactive_scatter( ------- controls """ - if isinstance(xlim, str): stretch_x = xlim == "stretch" else: @@ -509,9 +513,8 @@ def interactive_scatter( kwargs, collection_kwargs = kwarg_popper(kwargs, collection_kwargs_list) - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) extra_ctrls = [] @@ -552,8 +555,8 @@ def update(params, indices, cache): except ValueError: try: c_ = scatter.cmap(c_) - except TypeError: - raise ValueError( + except TypeError as te: + raise ValueError from te( "If c is a function it must return either an RGB(A) array" "or a 1D array of valid color names or values to be colormapped" ) @@ -715,9 +718,8 @@ def interactive_imshow( ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, imshow_kwargs = kwarg_popper(kwargs, imshow_kwargs_list) @@ -854,9 +856,8 @@ def interactive_axhline( ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, line_kwargs = kwarg_popper(kwargs, Line2D_kwargs_list) @@ -947,9 +948,8 @@ def interactive_axvline( ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, line_kwargs = kwarg_popper(kwargs, Line2D_kwargs_list) @@ -999,9 +999,10 @@ def interactive_title( force_ipywidgets=False, **kwargs, ): - """ - Set an title that will update interactively. kwargs for `matplotlib.text.Text` will be passed through, - other kwargs will be used to create interactive controls. + """Set the title to update interactively. + + kwargs for `matplotlib.text.Text` will be passed through, other kwargs will be used to create + interactive controls. Parameters ---------- @@ -1012,6 +1013,9 @@ def interactive_title( controls ax : `matplotlib.axes.Axes`, optional The axis on which to plot. If none the current axis will be used. + fontdict : dict[str] + Passed through to the Text object. Currently not dynamically updateable. See + https://github.com/mpl-extensions/mpl-interactions/issues/247 loc : {'center', 'left', 'right'}, default: `axes.titlelocation ` Which title to set. y : float, default: `axes.titley ` @@ -1034,19 +1038,19 @@ def interactive_title( - False: no sliders - 'left': sliders on the left - 'right': sliders on the right - force_ipywidgets : boolean If True ipywidgets will always be used, even if not using the ipympl backend. If False the function will try to detect if it is ok to use ipywidgets If ipywidgets are not used the function will fall back on matplotlib widgets + **kwargs: + Passed through to `ax.set_title` Returns ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, text_kwargs = kwarg_popper(kwargs, Text_kwargs_list) @@ -1091,9 +1095,10 @@ def interactive_xlabel( force_ipywidgets=False, **kwargs, ): - """ - Set an xlabel that will update interactively. kwargs for `matplotlib.text.Text` will be passed through, - other kwargs will be used to create interactive controls. + """Set an xlabel that will update interactively. + + kwargs for `matplotlib.text.Text` will be passed through, other kwargs + will be used to create interactive controls. Parameters ---------- @@ -1104,6 +1109,9 @@ def interactive_xlabel( controls ax : matplotlib axis, optional The axis on which to plot. If none the current axis will be used. + fontdict : dict[str] + Passed through to the Text object. Currently not dynamically updateable. See + https://github.com/mpl-extensions/mpl-interactions/issues/247 labelpad : float, default: None Spacing in points from the axes bounding box including ticks and tick labels. @@ -1124,19 +1132,21 @@ def interactive_xlabel( - False: no sliders - 'left': sliders on the left - 'right': sliders on the right - force_ipywidgets : boolean If True ipywidgets will always be used, even if not using the ipympl backend. If False the function will try to detect if it is ok to use ipywidgets If ipywidgets are not used the function will fall back on matplotlib widgets + **kwargs : + Used to create widgets to control parameters. Kwargs for Text objects will passed + through. + Returns ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, text_kwargs = kwarg_popper(kwargs, Text_kwargs_list) @@ -1178,9 +1188,10 @@ def interactive_ylabel( force_ipywidgets=False, **kwargs, ): - """ - Set a ylabel that will update interactively. kwargs for `matplotlib.text.Text` will be passed through, - other kwargs will be used to create interactive controls. + """Set a ylabel that will update interactively. + + kwargs for `matplotlib.text.Text` will be passed through, other kwargs will + be used to create interactive controls. Parameters ---------- @@ -1191,6 +1202,9 @@ def interactive_ylabel( controls ax : matplotlib axis, optional The axis on which to plot. If none the current axis will be used. + fontdict : dict[str] + Passed through to the Text object. Currently not dynamically updateable. See + https://github.com/mpl-extensions/mpl-interactions/issues/247 labelpad : float, default: None Spacing in points from the axes bounding box including ticks and tick labels. @@ -1211,19 +1225,20 @@ def interactive_ylabel( - False: no sliders - 'left': sliders on the left - 'right': sliders on the right - force_ipywidgets : boolean If True ipywidgets will always be used, even if not using the ipympl backend. If False the function will try to detect if it is ok to use ipywidgets If ipywidgets are not used the function will fall back on matplotlib widgets + **kwargs : + Used to create widgets to control parameters. Kwargs for Text objects will passed + through. Returns ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, text_kwargs = kwarg_popper(kwargs, Text_kwargs_list) @@ -1265,9 +1280,10 @@ def interactive_text( force_ipywidgets=False, **kwargs, ): - """ - Create a text object that will update interactively. - kwargs for `matplotlib.text.Text` will be passed through, other kwargs will be used to create interactive controls. + """Create a text object that will update interactively. + + kwargs for `matplotlib.text.Text` will be passed through, other kwargs will be used to create + interactive controls. .. note:: @@ -1289,6 +1305,10 @@ def interactive_text( controls ax : matplotlib axis, optional The axis on which to plot. If none the current axis will be used. + slider_formats : None, string, or dict + If None a default value of decimal points will be used. Uses {} style formatting + display_controls : boolean + Whether the controls should display on creation. Ignored if controls is specified. play_buttons : bool or str or dict, optional Whether to attach an ipywidgets.Play widget to any sliders that get created. If a boolean it will apply to all kwargs, if a dictionary you choose which sliders you @@ -1299,19 +1319,20 @@ def interactive_text( - False: no sliders - 'left': sliders on the left - 'right': sliders on the right - force_ipywidgets : boolean If True ipywidgets will always be used, even if not using the ipympl backend. If False the function will try to detect if it is ok to use ipywidgets If ipywidgets are not used the function will fall back on matplotlib widgets + **kwargs : + Used to create widgets to control parameters. Kwargs for Text objects will passed + through. Returns ------- controls """ - ipympl = notebook_backend() + ipympl = notebook_backend() or force_ipywidgets fig, ax = gogogo_figure(ipympl, ax) - ipympl or force_ipywidgets slider_formats = create_slider_format_dict(slider_formats) kwargs, text_kwargs = kwarg_popper(kwargs, Text_kwargs_list) diff --git a/mpl_interactions/utils.py b/mpl_interactions/utils.py index 5dcde3a..e1ba8cc 100644 --- a/mpl_interactions/utils.py +++ b/mpl_interactions/utils.py @@ -20,18 +20,19 @@ class _ioff_class: - """ - A context manager for turning interactive mode off. Now - that https://github.com/matplotlib/matplotlib/pull/17371 has been merged this will - be available via ``plt.ioff`` starting in Matplotlib 3.4 + """A context manager for turning interactive mode off. + + Now that https://github.com/matplotlib/matplotlib/pull/17371 has been merged this will + be available via ``plt.ioff`` starting in Matplotlib 3.4. """ def __call__(self): """Turn the interactive mode off.""" warnings.warn( - "ioff is deprecated in mpl-interactions. Please use `with plt.ioff():` directly from matplotlib instead.", + "ioff is deprecated in mpl-interactions." + " Please use `with plt.ioff():` directly from matplotlib instead.", mpl_interactions_DeprecationWarning, - 3, + stacklevel=3, ) interactive(False) uninstall_repl_displayhook() @@ -64,18 +65,20 @@ def figure(figsize=1, *args, **kwargs): """ if not isinstance(figsize, Iterable) and figsize is not None: figsize = [figsize * x for x in rcParams["figure.figsize"]] - return mpl_figure(figsize=figsize, *args, **kwargs) + return mpl_figure(*args, figsize=figsize, **kwargs) def nearest_idx(array, value, axis=None): - """ - Return the index of the array that is closest to value. Equivalent to - `np.argmin(np.abs(array-value), axis=axis) `. + """Return the index of the array that is closest to value. + + Equivalent to `np.argmin(np.abs(array-value), axis=axis) `. Parameters ---------- array : arraylike + The array to search through value : Scalar + The value to search for. axis : int, optional From np.argmin: "By default, the index is into the flattened array, otherwise along the specified axis." @@ -90,9 +93,9 @@ def nearest_idx(array, value, axis=None): def indexer(arr, index_name=None, axis=0): - """ - Utility function for when you want to index an array as part of an interactive function. - For example: ``iplt.plot(indexor(arr), idx = np.arange(5))`` + """Index an array as part of an interactive function. + + For example: ``iplt.plot(indexor(arr), idx = np.arange(5))``. Parameters ---------- @@ -109,7 +112,6 @@ def indexer(arr, index_name=None, axis=0): f : function Function to be passed as an argument to an interactive plotting function """ - if index_name is None: idxs = ["idx", "index", "indx", "ind"] else: diff --git a/mpl_interactions/widgets.py b/mpl_interactions/widgets.py index 34b4669..0cddd93 100644 --- a/mpl_interactions/widgets.py +++ b/mpl_interactions/widgets.py @@ -7,9 +7,7 @@ class scatter_selector(AxesWidget): - """ - A widget for selecting a point in a scatter plot. callback will receive (index, (x, y)) - """ + """A widget for selecting a point in a scatter plot. callback will receive (index, (x, y)).""" def __init__(self, ax, x, y, pickradius=5, which_button=1, **kwargs): """ @@ -25,6 +23,8 @@ def __init__(self, ax, x, y, pickradius=5, which_button=1, **kwargs): Pick radius, in points. which_button : int, default: 1 Where 1=left, 2=middle, 3=right + **kwargs: + Passed through to scatter. Other Parameters ---------------- @@ -56,8 +56,7 @@ def _process(self, idx, val): self._observers.process("picked", idx, val) def on_changed(self, func): - """ - When a point is clicked calll *func* with the newly selected point + """When a point is clicked calll *func* with the newly selected point. Parameters ---------- @@ -74,9 +73,9 @@ def on_changed(self, func): class scatter_selector_index(scatter_selector): - """ - A widget for selecting a point in a scatter plot. callback will receive the index of - the selected point as an argument. + """A widget for selecting a point in a scatter plot. + + Callbacks will receive the index of the selected point as an argument. """ def _init_val(self): @@ -86,7 +85,8 @@ def _process(self, idx, val): self._observers.process("picked", idx) def on_changed(self, func): - """ + """Attach a callback for when points are selected. + When a point is clicked calll *func* with the newly selected point's index and position as arguments. @@ -105,9 +105,9 @@ def on_changed(self, func): class scatter_selector_value(scatter_selector): - """ - A widget for selecting a point in a scatter plot. callbacks will receive the x,y position of - the selected point as arguments. + """A widget for selecting a point in a scatter plot. + + Callbacks will receive the x,y position of the selected point as arguments. """ def _init_val(self): @@ -117,7 +117,8 @@ def _process(self, idx, val): self._observers.process("picked", val) def on_changed(self, func): - """ + """Attach a callback for when points are selected. + When a point is clicked calll *func* with the newly selected point's index as arguments. @@ -138,6 +139,8 @@ def on_changed(self, func): # slider widgets are taken almost verbatim from https://github.com/matplotlib/matplotlib/pull/18829/files # which was written by me - but incorporates much of the existing matplotlib slider infrastructure class SliderBase(AxesWidget): + """Base Class for all sliders.""" + def __init__( self, ax, orientation, closedmin, closedmax, valmin, valmax, valfmt, dragging, valstep ): @@ -184,8 +187,7 @@ def _stepped_value(self, val): return val def disconnect(self, cid): - """ - Remove the observer with connection id *cid* + """Remove the observer with connection id *cid*. Parameters ---------- @@ -195,7 +197,7 @@ def disconnect(self, cid): self._observers.disconnect(cid) def reset(self): - """Reset the slider to the initial value""" + """Reset the slider to the initial value.""" if self.val != self.valinit: self.set_val(self.valinit) @@ -230,6 +232,8 @@ def __init__( **kwargs, ): """ + Create a RangeSlider. + Parameters ---------- ax : Axes @@ -256,6 +260,8 @@ def __init__( If given, the slider will snap to multiples of *valstep*. orientation : {'horizontal', 'vertical'}, default: 'horizontal' The orientation of the slider. + **kwargs: + Passed to axhspan Notes ----- @@ -320,38 +326,32 @@ def __init__( self.set_val(valinit) def _min_in_bounds(self, min): - """ - Ensure the new min value is between valmin and self.val[1] - """ + """Ensure the new min value is between valmin and self.val[1].""" if min <= self.valmin: if not self.closedmin: return self.val[0] - min = self.valmin + min = self.valmin # noqa: A001 if min > self.val[1]: - min = self.val[1] + min = self.val[1] # noqa: A001 return self._stepped_value(min) def _max_in_bounds(self, max): - """ - Ensure the new max value is between valmax and self.val[0] - """ + """Ensure the new max value is between valmax and self.val[0].""" if max >= self.valmax: if not self.closedmax: return self.val[1] - max = self.valmax + max = self.valmax # noqa: A001 if max <= self.val[0]: - max = self.val[0] + max = self.val[0] # noqa: A001 return self._stepped_value(max) def _value_in_bounds(self, val): return (self._min_in_bounds(val[0]), self._max_in_bounds(val[1])) def _update_val_from_pos(self, pos): - """ - Given a position update the *val* - """ + """Given a position update the *val*.""" idx = np.argmin(np.abs(self.val - pos)) if idx == 0: val = self._min_in_bounds(pos) @@ -396,33 +396,33 @@ def _format(self, val): # use raw string to avoid issues with backslashes from return rf"({s1}, {s2})" - def set_min(self, min): - """ - Set the lower value of the slider to *min* + def set_min(self, val): + """Set the lower value of the slider to *val*. Parameters ---------- - min : float + val : float + The value to set the min to. """ - self.set_val((min, self.val[1])) + self.set_val((val, self.val[1])) - def set_max(self, max): - """ - Set the lower value of the slider to *max* + def set_max(self, val): + """Set the lower value of the slider to *val*. Parameters ---------- - max : float + val : float + The value to set the max to. """ - self.set_val((self.val[0], max)) + self.set_val((self.val[0], val)) def set_val(self, val): - """ - Set slider value to *val* + """Set slider value to *val*. Parameters ---------- val : tuple or arraylike of float + The position to move the slider to. """ val = np.sort(np.asanyarray(val)) if val.shape != (2,): @@ -451,9 +451,7 @@ def set_val(self, val): self._observers.process("changed", val) def on_changed(self, func): - """ - When the slider value is changed call *func* with the new - slider value + """Attach a callback to when the slider is changed. Parameters ---------- diff --git a/mpl_interactions/xarray_helpers.py b/mpl_interactions/xarray_helpers.py index 51374ab..f973873 100644 --- a/mpl_interactions/xarray_helpers.py +++ b/mpl_interactions/xarray_helpers.py @@ -20,7 +20,6 @@ def choose_datetime_nonsense(arr, timeunit="m"): Array modified to format decently in a slider. """ - if np.issubdtype(arr.dtype, "datetime64"): out = arr.astype(f"datetime64[{timeunit}]") elif np.issubdtype(arr.dtype, "timedelta64"): @@ -31,9 +30,7 @@ def choose_datetime_nonsense(arr, timeunit="m"): def get_hs_axes(xarr, is_color_image=False, timeunit="m"): - """ - Read the dims and coordinates from an xarray and construct the - axes argument for hyperslicer. Called internally by hyperslicer. + """Read the dims and coordinates from an xarray and construct the axes argument for hyperslicer. Parameters ---------- @@ -60,9 +57,7 @@ def get_hs_axes(xarr, is_color_image=False, timeunit="m"): def get_hs_extent(xarr, is_color_image=False, origin="upper"): - """ - Read the "YX" coordinates of an xarray.DataArray to set extent of image for - imshow. + """Read the "YX" coordinates of an xarray.DataArray to set extent of image for imshow. Parameters ---------- @@ -80,7 +75,6 @@ def get_hs_extent(xarr, is_color_image=False, origin="upper"): Extent argument for imshow. """ - if not is_color_image: dims = xarr.dims[-2:] else: @@ -101,9 +95,7 @@ def get_hs_extent(xarr, is_color_image=False, origin="upper"): def get_hs_fmts(xarr, units=None, is_color_image=False): - """ - Get appropriate slider format strings from xarray coordinates - based the dtype of corresponding values. + """Get appropriate slider format strings from xarray coordinates. Parameters ---------- @@ -129,7 +121,7 @@ def get_hs_fmts(xarr, units=None, is_color_image=False): fmt_strs[d] = choose_fmt_str(xarr[d].dtype) if units is not None and units[i] is not None: try: - fmt_strs[d] += " {}".format(units[i]) + fmt_strs[d] += f" {units[i]}" except KeyError: continue return fmt_strs diff --git a/pyproject.toml b/pyproject.toml index 5f0b1ac..dd362c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,41 @@ addopts = [ "--mpl", "--nbval", ] + + +# https://github.com/charliermarsh/ruff +[tool.ruff] +line-length = 100 +target-version = "py38" +extend-select = [ + "E", # style errors + "F", # flakes + "D", # pydocstyle + "I001", # isort + "U", # pyupgrade + # "N", # pep8-naming + # "S", # bandit + "C", # flake8-comprehensions + "B", # flake8-bugbear + "A001", # flake8-builtins + "RUF", # ruff-specific rules + "M001", # Unused noqa directive +] +extend-ignore = [ + "D100", # Missing docstring in public module + "D107", # Missing docstring in __init__ + "D203", # 1 blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D413", # Missing blank line after last section + "D416", # Section name should end with a colon + "C901", # function too complex +] + + +[tool.ruff.per-file-ignores] +"tests/*.py" = ["D"] +"__init__.py" = ["E402", "F403", "D104"] +"docs/conf.py" = ["A001", "C901", "D200", "D400", "D415"] +"docs/examples/**/*.py" = ["D400", "D415", "D205", "D103"] +"mpl_interactions/ipyplot.py" = ["F401"] diff --git a/tests/test_pyplot.py b/tests/test_pyplot.py index bb98259..df65bd0 100644 --- a/tests/test_pyplot.py +++ b/tests/test_pyplot.py @@ -25,14 +25,6 @@ def f_hist(loc, scale): # _ = interactive_hist(f_hist, density=True, loc=(5.5, 100), scale=(10, 15), ax=test_ax) -def f1(x, tau, beta): - return np.sin(x * tau) * x * beta - - -def f2(x, tau, beta): - return np.sin(x * beta) * x * tau - - def f1(x, tau, beta): return np.sin(x * tau) * x * beta