Skip to content

Commit ab03209

Browse files
authored
better bound values (#95)
1 parent 43a5904 commit ab03209

File tree

4 files changed

+183
-68
lines changed

4 files changed

+183
-68
lines changed

magicgui/function_gui.py

+14-59
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import inspect
88
import warnings
9-
from typing import Any, Callable, Dict, Optional, Sequence, TypeVar, Union, overload
9+
from typing import Any, Callable, Dict, Optional, TypeVar, Union, overload
1010

1111
from magicgui.application import AppRef
1212
from magicgui.events import EventEmitter
@@ -46,11 +46,6 @@ class FunctionGui(Container):
4646
Will be passed to `magic_signature` by default ``None``
4747
name : str, optional
4848
A name to assign to the Container widget, by default `function.__name__`
49-
bind : dict, optional
50-
A mapping of parameter names to values. Values supplied here will be permanently
51-
bound to the corresponding parameters: their widgets will be hidden from the GUI
52-
and the value will be used for the corresponding parameter when calling the
53-
function.
5449
5550
Raises
5651
------
@@ -72,10 +67,8 @@ def __init__(
7267
result_widget: bool = False,
7368
param_options: Optional[dict] = None,
7469
name: str = None,
75-
bind: Dict[str, Any] = None,
7670
**kwargs,
7771
):
78-
bind = bind or dict()
7972
# consume extra Widget keywords
8073
extra = set(kwargs) - {"annotation", "gui_only"}
8174
if extra:
@@ -94,8 +87,6 @@ def __init__(
9487
self._param_options = param_options
9588
self.called = EventEmitter(self, type="called")
9689
self._result_name = ""
97-
self._bound: Dict[str, Any] = {}
98-
self.bind(bind)
9990
self._call_count: int = 0
10091

10192
self._call_button: Optional[PushButton] = None
@@ -128,36 +119,6 @@ def reset_call_count(self) -> None:
128119
"""Reset the call count to 0."""
129120
self._call_count = 0
130121

131-
def bind(self, kwargs: dict):
132-
"""Bind key/value pairs to the function signature.
133-
134-
Values supplied here will be permanently bound to the corresponding parameters:
135-
their widgets will be hidden from the GUI and the value will be used for the
136-
corresponding parameter when the function is called.
137-
138-
Parameters
139-
----------
140-
kwargs : dict, optional
141-
A mapping of parameter names to values to bind.
142-
"""
143-
self._bound.update(kwargs)
144-
for name, value in kwargs.items():
145-
getattr(self, name).hide()
146-
147-
def unbind(self, args: Sequence):
148-
"""Unbind keys from the function signature.
149-
150-
Parameters
151-
----------
152-
args : sequence
153-
A sequence of parameter names. If any are currently bound to a value, the
154-
binding will be cleared and the widget will be shown.
155-
"""
156-
for name in args:
157-
if name in self._bound:
158-
del self._bound[name]
159-
getattr(self, name).show()
160-
161122
def __getattr__(self, value):
162123
"""Catch deprecated _name_changed attribute."""
163124
if value.endswith("_changed"):
@@ -206,9 +167,7 @@ def __call__(self, *args: Any, **kwargs: Any):
206167
gui() # calls the original function with the current parameters
207168
"""
208169
sig = self.to_signature()
209-
_kwargs = self._bound.copy()
210-
_kwargs.update(kwargs)
211-
bound = sig.bind(*args, **_kwargs)
170+
bound = sig.bind(*args, **kwargs)
212171
bound.apply_defaults()
213172

214173
value = self._function(*bound.args, **bound.kwargs)
@@ -239,8 +198,8 @@ def result_name(self, value: str):
239198
"""Set the result name of this FunctionGui widget."""
240199
self._result_name = value
241200

242-
def copy(self, bind=None):
243-
"""Return a copy of this FunctionGui, with optionally bound arguments."""
201+
def copy(self) -> "FunctionGui":
202+
"""Return a copy of this FunctionGui."""
244203
return FunctionGui(
245204
function=self._function,
246205
call_button=bool(self._call_button),
@@ -250,11 +209,9 @@ def copy(self, bind=None):
250209
auto_call=self._auto_call,
251210
result_widget=bool(self._result_widget),
252211
app=None,
253-
bind=bind if bind is not None else self._bound,
254212
)
255213

256-
# Cache function guis bound to specific instances
257-
_instance_guis: Dict[int, FunctionGui] = {}
214+
_bound_instances: Dict[int, FunctionGui] = {}
258215

259216
def __get__(self, obj, objtype=None) -> FunctionGui:
260217
"""Provide descriptor protocol.
@@ -278,11 +235,16 @@ def __get__(self, obj, objtype=None) -> FunctionGui:
278235
{'self': <__main__.MyClass object at 0x7fb610e455e0>, 'x': 34}
279236
"""
280237
if obj is not None:
281-
if id(obj) not in self._instance_guis:
238+
obj_id = id(obj)
239+
if obj_id not in self._bound_instances:
282240
method = getattr(obj.__class__, self._function.__name__)
283-
params_names = list(inspect.signature(method).parameters)
284-
self._instance_guis[id(obj)] = self.copy(bind={params_names[0]: obj})
285-
return self._instance_guis[id(obj)]
241+
p0 = list(inspect.signature(method).parameters)[0]
242+
prior, self._param_options = self._param_options, {p0: {"bind": obj}}
243+
try:
244+
self._bound_instances[obj_id] = self.copy()
245+
finally:
246+
self._param_options = prior
247+
return self._bound_instances[obj_id]
286248
return self
287249

288250
def __set__(self, obj, value):
@@ -328,7 +290,6 @@ def magicgui(
328290
auto_call: bool = False,
329291
result_widget: bool = False,
330292
app: AppRef = None,
331-
bind: Dict[str, Any] = None,
332293
**param_options: dict,
333294
):
334295
"""Return a :class:`FunctionGui` for ``function``.
@@ -354,11 +315,6 @@ def magicgui(
354315
by default False
355316
app : magicgui.Application or str, optional
356317
A backend to use, by default ``None`` (use the default backend.)
357-
bind : dict, optional
358-
A mapping of parameter names to values. Values supplied here will be permanently
359-
bound to the corresponding parameters: their widgets will be hidden from the GUI
360-
and the value will be used for the corresponding parameter when calling the
361-
function.
362318
363319
**param_options : dict of dict
364320
Any additional keyword arguments will be used as parameter-specific options.
@@ -405,7 +361,6 @@ def inner_func(func: Callable) -> FunctionGui:
405361
auto_call=auto_call,
406362
result_widget=result_widget,
407363
app=app,
408-
bind=bind,
409364
)
410365
func_gui.__wrapped__ = func
411366
return func_gui

magicgui/type_map.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,12 @@ def register_type(
265265
"""
266266
type_ = _evaluate_forwardref(type_)
267267

268-
if not (return_callback or options.get("choices") or widget_type):
268+
if not (
269+
return_callback or options.get("bind") or options.get("choices") or widget_type
270+
):
269271
raise ValueError(
270-
"At least one of `widget_type`, `choices`, or "
271-
"`return_callback` must be provided."
272+
"At least one of `widget_type`, `return_callback`, `bind` or `choices` "
273+
"must be provided."
272274
)
273275

274276
if return_callback is not None:
@@ -295,6 +297,11 @@ def register_type(
295297
'"widget_type" must be either a string, Widget, or WidgetProtocol'
296298
)
297299
_TYPE_DEFS[type_] = (widget_type, _options)
300+
elif "bind" in _options:
301+
# TODO: make a dedicated hidden widget?
302+
# if we're binding a value to this parameter, it doesn't matter what type
303+
# of ValueWidget is used... it usually won't be shown
304+
_TYPE_DEFS[type_] = (widgets.Label, _options)
298305

299306
return None
300307

magicgui/widgets/_bases.py

+72-6
Original file line numberDiff line numberDiff line change
@@ -394,22 +394,35 @@ def width(self, value: int) -> None:
394394
self._widget._mgui_set_min_width(value)
395395

396396

397+
UNBOUND = object()
398+
399+
397400
class ValueWidget(Widget):
398401
"""Widget with a value, Wraps ValueWidgetProtocol.
399402
400403
Parameters
401404
----------
402405
value : Any, optional
403406
The starting value for the widget, by default ``None``
407+
bind : Any, optional
408+
A value or callback to bind this widget, then whenever `widget.value` is
409+
accessed, the value provided here will be returned. ``value`` can be a
410+
callable, in which case ``value(self)`` will be returned (i.e. your callback
411+
must accept a single parameter, which is this widget instance.).
412+
404413
"""
405414

406415
_widget: _protocols.ValueWidgetProtocol
407416
changed: EventEmitter
408417

409-
def __init__(self, value: Any = None, **kwargs):
418+
def __init__(self, value: Any = None, bind: Any = UNBOUND, **kwargs):
419+
self._bound_value: Any = bind
420+
self._call_bound: bool = True
410421
super().__init__(**kwargs)
411422
if value is not None:
412423
self.value = value
424+
if self._bound_value is not UNBOUND and "visible" not in kwargs:
425+
self.hide()
413426

414427
def _post_init(self):
415428
super()._post_init()
@@ -418,10 +431,31 @@ def _post_init(self):
418431
lambda *x: self.changed(value=x[0] if x else None)
419432
)
420433

434+
def get_value(self):
435+
"""Callable version of `self.value`.
436+
437+
The main API is to use `self.value`, however, this is here in order to provide
438+
an escape hatch if trying to access the widget's value inside of a callback
439+
bound to self._bound_value.
440+
"""
441+
return self._widget._mgui_get_value()
442+
421443
@property
422444
def value(self):
423445
"""Return current value of the widget. This may be interpreted by backends."""
424-
return self._widget._mgui_get_value()
446+
if self._bound_value is not UNBOUND:
447+
if callable(self._bound_value) and self._call_bound:
448+
try:
449+
return self._bound_value(self)
450+
except RecursionError as e:
451+
raise RuntimeError(
452+
"RecursionError in callback bound to "
453+
f"<{self.widget_type!r} name={self.name!r}>. If you need to "
454+
"access `widget.value` in your bound callback, use "
455+
"`widget.get_value()`"
456+
) from e
457+
return self._bound_value
458+
return self.get_value()
425459

426460
@value.setter
427461
def value(self, value):
@@ -437,6 +471,38 @@ def __repr__(self) -> str:
437471
else:
438472
return f"<Uninitialized {self.widget_type}>"
439473

474+
def bind(self, value: Any, call: bool = True) -> None:
475+
"""Binds ``value`` to self.value.
476+
477+
If a value is bound to this widget, then whenever `widget.value` is accessed,
478+
the value provided here will be returned. ``value`` can be a callable, in which
479+
case ``value(self)`` will be returned (i.e. your callback must accept a single
480+
parameter, which is this widget instance.).
481+
482+
If you provide a callable and you *don't* want it to be called (but rather just
483+
returned as a callable object, then use ``call=False`` when binding your value.
484+
485+
Note: if you need to access the "original" ``widget.value`` within your
486+
callback, please use ``widget.get_value()`` instead of the ``widget.value``
487+
property, in order to avoid a RuntimeError.
488+
489+
Parameters
490+
----------
491+
value : Any
492+
The value (or callback) to return when accessing this widget's value.
493+
call : bool, optional
494+
If ``value`` is a callable and ``call`` is ``True``, the callback will be
495+
called as ``callback(self)`` when accessing ``self.value``. If ``False``,
496+
the callback will simply be returned as a callable object, by default,
497+
``True``.
498+
"""
499+
self._call_bound = call
500+
self._bound_value = value
501+
502+
def unbind(self) -> None:
503+
"""Unbinds any bound values. (see ``ValueWidget.bind``)."""
504+
self._bound_value = UNBOUND
505+
440506

441507
class ButtonWidget(ValueWidget):
442508
"""Widget with a value, Wraps ButtonWidgetProtocol.
@@ -709,15 +775,15 @@ def _post_init(self):
709775
@property
710776
def value(self):
711777
"""Return current value of the widget."""
712-
return self._widget._mgui_get_value()
778+
return ValueWidget.value.fget(self) # type: ignore
713779

714780
@value.setter
715781
def value(self, value):
716782
if value not in self.choices:
717783
raise ValueError(
718784
f"{value!r} is not a valid choice. must be in {self.choices}"
719785
)
720-
return self._widget._mgui_set_value(value)
786+
return ValueWidget.value.fset(self, value) # type: ignore
721787

722788
@property
723789
def options(self) -> dict:
@@ -928,7 +994,7 @@ def __getitem__(self, key): # noqa: F811
928994
if key < 0:
929995
key += len(self)
930996
item = self._widget._mgui_get_index(key)
931-
if not item:
997+
if item is None:
932998
raise IndexError("Container index out of range")
933999
return getattr(item, "_inner_widget", item)
9341000

@@ -976,7 +1042,7 @@ def insert(self, key: int, widget: Widget):
9761042
widget.changed.connect(lambda x: self.changed(value=self))
9771043
_widget = widget
9781044

979-
if self.labels:
1045+
if self.labels and _widget.visible:
9801046
from ._concrete import _LabeledWidget
9811047

9821048
# no labels for button widgets (push buttons, checkboxes, have their own)

0 commit comments

Comments
 (0)