Skip to content

Commit

Permalink
Progress bar tqdm wrapper, and manual control (#105)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 committed Jan 24, 2021
1 parent 8855f45 commit d13a829
Show file tree
Hide file tree
Showing 12 changed files with 436 additions and 3 deletions.
19 changes: 19 additions & 0 deletions examples/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from time import sleep

from magicgui import magicgui
from magicgui.tqdm import trange

# if magicui.tqdm.tqdm or trange are used outside of a @magicgui function, (such as in
# interactive use in IPython), then they fall back to the standard terminal output

# If use inside of a magicgui-decorated function
# a progress bar widget will be added to the magicgui container
@magicgui(call_button=True)
def long_running(steps=10, delay=0.1):
"""Long running computation with range iterator."""
# trange(steps) is a shortcut for `tqdm(range(steps))`
for i in trange(steps):
sleep(delay)


long_running.show(run=True)
14 changes: 14 additions & 0 deletions examples/progress_manual.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from magicgui import magicgui
from magicgui.widgets import ProgressBar


@magicgui(call_button="tick", pbar={"min": 0, "step": 2, "max": 20, "value": 0})
def manual(pbar: ProgressBar, increment: bool = True):
"""Example of manual progress bar control."""
if increment:
pbar.increment()
else:
pbar.decrement()


manual.show(run=True)
29 changes: 29 additions & 0 deletions examples/progress_nested.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import random
from time import sleep

from magicgui import magicgui
from magicgui.tqdm import tqdm, trange

# if magicui.tqdm.tqdm or trange are used outside of a @magicgui function, (such as in
# interactive use in IPython), then they fall back to the standard terminal output


# If use inside of a magicgui-decorated function
# a progress bar widget will be added to the magicgui container
@magicgui(call_button=True, layout="vertical")
def long_function(
steps=10, repeats=4, choices="ABCDEFGHIJKLMNOP12345679", char="", delay=0.05
):
"""Long running computation with nested iterators."""
# trange and tqdm accept all the kwargs from tqdm itself, as well as any
# valid kwargs for magicgui.widgets.ProgressBar, (such as "label")
for r in trange(repeats, label="repeats"):
letters = [random.choice(choices) for _ in range(steps)]
# `tqdm`, like `tqdm`, accepts any iterable
# this progress bar is nested and will be run & reset multiple times
for letter in tqdm(letters, label="steps"):
long_function.char.value = letter
sleep(delay)


long_function.show(run=True)
157 changes: 157 additions & 0 deletions magicgui/tqdm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""A wrapper around the tqdm.tqdm iterator that adds a ProgressBar to a magicgui."""
import inspect
from typing import Iterable, Optional

from magicgui.application import use_app
from magicgui.widgets import FunctionGui, ProgressBar

try:
from tqdm import tqdm as tqdm_std
except ImportError as e: # pragma: no cover
msg = (
f"{e}. To use magicgui with tqdm please `pip install tqdm`, "
"or use the tqdm extra: `pip install magicgui[tqdm]`"
)
raise type(e)(msg)


def _find_calling_function_gui(max_depth=6) -> Optional[FunctionGui]:
"""Traverse calling stack looking for a magicgui FunctionGui."""
for finfo in inspect.stack()[2:max_depth]:

# regardless of whether the function was decorated directly in the global module
# namespace, or if it was renamed on decoration (`new_name = magicgui(func)`),
# or if it was decorated inside of some local function scope...
# we will eventually hit the :meth:`FunctionGui.__call__` method, which will
# have the ``FunctionGui`` instance as ``self`` in its locals namespace.
if finfo.function == "__call__" and finfo.filename.endswith("function_gui.py"):
obj = finfo.frame.f_locals.get("self")
if isinstance(obj, FunctionGui):
return obj
return None # pragma: no cover

return None


_tqdm_kwargs = {
p.name
for p in inspect.signature(tqdm_std.__init__).parameters.values()
if p.kind is not inspect.Parameter.VAR_KEYWORD and p.name != "self"
}


class tqdm(tqdm_std):
"""magicgui version of tqdm.
See tqdm.tqdm API for valid args and kwargs: https://tqdm.github.io/docs/tqdm/
Also, any keyword arguments to the :class:`magicgui.widgets.ProgressBar` widget
are also accepted and will be passed to the ``ProgressBar``.
Examples
--------
When used inside of a magicgui-decorated function, ``tqdm`` (and the
``trange`` shortcut function) will append a visible progress bar to the gui
container.
>>> @magicgui(call_button=True)
... def long_running(steps=10, delay=0.1):
... for i in tqdm(range(steps)):
... sleep(delay)
nesting is also possible:
>>> @magicgui(call_button=True)
... def long_running(steps=10, repeats=4, delay=0.1):
... for r in trange(repeats):
... for s in trange(steps):
... sleep(delay)
"""

disable: bool

def __init__(self, iterable: Iterable = None, *args, **kwargs) -> None:
kwargs = kwargs.copy()
pbar_kwargs = {k: kwargs.pop(k) for k in set(kwargs) - _tqdm_kwargs}
self._mgui = _find_calling_function_gui()
if self._mgui is not None:
kwargs["gui"] = True
kwargs.setdefault("mininterval", 0.025)
super().__init__(iterable, *args, **kwargs)
if self._mgui is None:
return

self.sp = lambda x: None # no-op status printer, required for older tqdm compat
if self.disable:
return

# check if we're being instantiated inside of a magicgui container
self.progressbar = self._get_progressbar(**pbar_kwargs)
self._app = use_app()

if self.total is not None:
# initialize progress bar range
self.progressbar.range = (self.n, self.total)
self.progressbar.value = self.n
else:
# show a busy indicator instead of a percentage of steps
self.progressbar.range = (0, 0)
self.progressbar.show()

def _get_progressbar(self, **kwargs) -> ProgressBar:
"""Create ProgressBar or get from the parent gui `_tqdm_pbars` deque.
The deque allows us to create nested iterables inside of a magigui, while
resetting and reusing progress bars across ``FunctionGui`` calls. The nesting
depth (into the deque) is reset by :meth:`FunctionGui.__call__`, right before
the function is called. Then, as the function encounters `tqdm` instances,
this method gets or creates a progress bar and increment the
:attr:`FunctionGui._tqdm_depth` counter on the ``FunctionGui``.
"""
if self._mgui is None:
return ProgressBar(**kwargs)

if len(self._mgui._tqdm_pbars) > self._mgui._tqdm_depth:
pbar = self._mgui._tqdm_pbars[self._mgui._tqdm_depth]
else:
pbar = ProgressBar(**kwargs)
self._mgui._tqdm_pbars.append(pbar)
self._mgui.append(pbar)
self._mgui._tqdm_depth += 1
return pbar

def display(self, msg: str = None, pos: int = None) -> None:
"""Update the display."""
if self._mgui is None:
return super().display(msg=msg, pos=pos)

self.progressbar.value = self.n
self._app.process_events()

def close(self) -> None:
"""Cleanup and (if leave=False) close the progressbar."""
if self._mgui is None:
return super().close()
if self.disable:
return

# Prevent multiple closures
self.disable = True

# remove from tqdm instance set
with self._lock:
try:
self._instances.remove(self)
except KeyError: # pragma: no cover
pass

if not self.leave:
self._app.process_events()
self.progressbar.hide()

self._mgui._tqdm_depth -= 1


def trange(*args, **kwargs) -> tqdm:
"""Shortcut for tqdm(range(*args), **kwargs)."""
return tqdm(range(*args), **kwargs)
4 changes: 4 additions & 0 deletions magicgui/type_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ def type_matcher(func: TypeMatcher) -> TypeMatcher:
def simple_types(value, annotation) -> Optional[WidgetTuple]:
"""Check simple type mappings."""
dtype = _normalize_type(value, annotation)

if dtype is widgets.ProgressBar:
return widgets.ProgressBar, {"bind": lambda widget: widget, "visible": True}

simple = {
bool: widgets.CheckBox,
int: widgets.SpinBox,
Expand Down
1 change: 1 addition & 0 deletions magicgui/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,4 @@ class WidgetOptions(TypedDict, total=False):
orientation: str # for things like sliders
mode: Union[str, FileDialogMode]
tooltip: str
bind: Any
3 changes: 2 additions & 1 deletion magicgui/widgets/_bases/value_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ def value(self, value):

def __repr__(self) -> str:
"""Return representation of widget of instsance."""
val = self.value if self._bound_value is UNBOUND else self._bound_value
if hasattr(self, "_widget"):
return (
f"{self.widget_type}(value={self.value!r}, "
f"{self.widget_type}(value={val!r}, "
f"annotation={self.annotation!r}, name={self.name!r})"
)
else:
Expand Down
18 changes: 18 additions & 0 deletions magicgui/widgets/_concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,24 @@ class FloatSpinBox(RangedWidget):
class ProgressBar(SliderWidget):
"""A progress bar widget."""

def increment(self, val=None):
"""Increase current value by step size, or provided value."""
self.value = self.get_value() + (val if val is not None else self.step)

def decrement(self, val=None):
"""Decrease current value by step size, or provided value."""
self.value = self.get_value() - (val if val is not None else self.step)

# overriding because at least some backends don't have a step value for ProgressBar
@property
def step(self) -> float:
"""Step size for widget values."""
return self._step

@step.setter
def step(self, value: float):
self._step = value


@backend_widget
class Slider(SliderWidget):
Expand Down
21 changes: 19 additions & 2 deletions magicgui/widgets/_function_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@
import inspect
import re
import warnings
from collections import deque
from contextlib import contextmanager
from types import FunctionType
from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Optional, TypeVar, Union
from typing import (
TYPE_CHECKING,
Any,
Callable,
Deque,
Dict,
Generic,
Optional,
TypeVar,
Union,
)

from magicgui.application import AppRef
from magicgui.events import EventEmitter
from magicgui.signature import MagicSignature, magic_signature
from magicgui.widgets import Container, LineEdit, MainWindow, PushButton
from magicgui.widgets import Container, LineEdit, MainWindow, ProgressBar, PushButton
from magicgui.widgets._protocols import ContainerProtocol, MainWindowProtocol

if TYPE_CHECKING:
Expand Down Expand Up @@ -154,6 +165,11 @@ def __init__(
self._result_name = ""
self._call_count: int = 0

# a deque of Progressbars to be created by (possibly nested) tqdm_mgui iterators
self._tqdm_pbars: Deque[ProgressBar] = deque()
# the nesting level of tqdm_mgui iterators in a given __call__
self._tqdm_depth: int = 0

self._call_button: Optional[PushButton] = None
if call_button:
text = call_button if isinstance(call_button, str) else "Run"
Expand Down Expand Up @@ -241,6 +257,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> _R:

bound.apply_defaults()

self._tqdm_depth = 0 # reset the tqdm stack count
with _function_name_pointing_to_widget(self):
value = self._function(*bound.args, **bound.kwargs)

Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ PySide2 =
PySide2>=5.15 ; python_version=='3.9'
PyQt5 =
PyQt5>=5.12.0
tqdm =
tqdm>=4.30.0
testing =
tox
tox-conda
Expand All @@ -62,6 +64,7 @@ testing =
pytest-mypy-plugins
numpy
pandas ; python_version>'3.7'
tqdm
dev =
ipython
jedi<0.18.0
Expand Down
Loading

0 comments on commit d13a829

Please sign in to comment.