syd 0.1.5__py3-none-any.whl → 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
syd/interactive_viewer.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from typing import List, Any, Callable, Dict, Tuple, Union, Optional
2
- from functools import wraps
2
+ from functools import wraps, partial
3
+ import inspect
3
4
  from contextlib import contextmanager
4
- from abc import ABC, abstractmethod
5
5
  from matplotlib.figure import Figure
6
6
 
7
7
  from .parameters import (
8
8
  ParameterType,
9
+ ActionType,
9
10
  Parameter,
10
11
  ParameterAddError,
11
12
  ParameterUpdateError,
@@ -28,7 +29,7 @@ _NO_UPDATE = _NoUpdate()
28
29
 
29
30
 
30
31
  def validate_parameter_operation(
31
- operation: str, parameter_type: ParameterType
32
+ operation: str, parameter_type: Union[ParameterType, ActionType]
32
33
  ) -> Callable:
33
34
  """
34
35
  Decorator that validates parameter operations for the InteractiveViewer class.
@@ -64,14 +65,14 @@ def validate_parameter_operation(
64
65
  "Incorrect use of validate_parameter_operation decorator. Must be called with 'add' or 'update' as the first argument."
65
66
  )
66
67
 
68
+ # Validate operation matches method name (add/update)
69
+ if not func.__name__.startswith(operation):
70
+ raise ValueError(
71
+ f"Invalid operation type specified ({operation}) for method {func.__name__}"
72
+ )
73
+
67
74
  @wraps(func)
68
75
  def wrapper(self: "InteractiveViewer", name: Any, *args, **kwargs):
69
- # Validate operation matches method name (add/update)
70
- if not func.__name__.startswith(operation):
71
- raise ValueError(
72
- f"Invalid operation type specified ({operation}) for method {func.__name__}"
73
- )
74
-
75
76
  # Validate parameter name is a string
76
77
  if not isinstance(name, str):
77
78
  if operation == "add":
@@ -103,8 +104,8 @@ def validate_parameter_operation(
103
104
  parameter_type.name,
104
105
  "Parameter not found - you can only update registered parameters!",
105
106
  )
106
- if type(self.parameters[name]) != parameter_type.value:
107
- msg = f"Parameter called {name} was found but is registered as a different parameter type ({type(self.parameters[name])})"
107
+ if not isinstance(self.parameters[name], parameter_type.value):
108
+ msg = f"Parameter called {name} was found but is registered as a different parameter type ({type(self.parameters[name])}). Expecting {parameter_type.value}."
108
109
  raise ParameterUpdateError(name, parameter_type.name, msg)
109
110
 
110
111
  try:
@@ -122,7 +123,7 @@ def validate_parameter_operation(
122
123
  return decorator
123
124
 
124
125
 
125
- class InteractiveViewer(ABC):
126
+ class InteractiveViewer:
126
127
  """
127
128
  Base class for creating interactive matplotlib figures with GUI controls.
128
129
 
@@ -184,13 +185,29 @@ class InteractiveViewer(ABC):
184
185
  """
185
186
  return {name: param.value for name, param in self.parameters.items()}
186
187
 
187
- @abstractmethod
188
- def plot(self, **kwargs) -> Figure:
189
- """
190
- Create and return a matplotlib figure.
188
+ def plot(self, state: Dict[str, Any]) -> Figure:
189
+ """Create and return a matplotlib figure.
190
+
191
+ This is a placeholder. You must either:
192
+
193
+ 1. Call set_plot() with your plotting function
194
+ This will look like this:
195
+ >>> def plot(viewer, state):
196
+ >>> ... generate figure, plot stuff ...
197
+ >>> return fig
198
+ >>> viewer.set_plot(plot))
199
+
200
+ 2. Subclass InteractiveViewer and override this method
201
+ This will look like this:
202
+ >>> class YourViewer(InteractiveViewer):
203
+ >>> def plot(self, state):
204
+ >>> ... generate figure, plot stuff ...
205
+ >>> return fig
191
206
 
192
- This method must be implemented in your subclass. It should create a new
193
- figure using the current parameter values from self.parameters.
207
+ Parameters
208
+ ----------
209
+ state : dict
210
+ Current parameter values
194
211
 
195
212
  Returns
196
213
  -------
@@ -200,27 +217,36 @@ class InteractiveViewer(ABC):
200
217
  Notes
201
218
  -----
202
219
  - Create a new figure each time, don't reuse old ones
203
- - Access parameter values using self.parameters['name'].value
204
- - Return the figure object, don't call plt.show()
220
+ - Access parameter values using state['param_name']
221
+ - Access your viewer class using self (or viewer for the set_plot() method)
222
+ - Return the figure object, don't call plt.show()!
205
223
  """
206
- raise NotImplementedError("Subclasses must implement the plot method")
224
+ raise NotImplementedError(
225
+ "Plot method not implemented. Either subclass "
226
+ "InteractiveViewer and override plot(), or use "
227
+ "set_plot() to provide a plotting function."
228
+ )
229
+
230
+ def set_plot(self, func: Callable) -> None:
231
+ """Set the plot method for the viewer"""
232
+ self.plot = self._prepare_function(func, context="Setting plot:")
207
233
 
208
234
  def deploy(self, env: str = "notebook", **kwargs):
209
235
  """Deploy the app in a notebook or standalone environment"""
210
236
  if env == "notebook":
211
- from .notebook_deploy import NotebookDeployment
237
+ from .notebook_deployment import NotebookDeployment
212
238
 
213
239
  deployer = NotebookDeployment(self, **kwargs)
214
240
  deployer.deploy()
215
241
 
216
- return deployer
242
+ return self
217
243
  else:
218
244
  raise ValueError(
219
245
  f"Unsupported environment: {env}, only 'notebook' is supported right now."
220
246
  )
221
247
 
222
248
  @contextmanager
223
- def deploy_app(self):
249
+ def _deploy_app(self):
224
250
  """Internal context manager to control app deployment state"""
225
251
  self._app_deployed = True
226
252
  try:
@@ -228,6 +254,139 @@ class InteractiveViewer(ABC):
228
254
  finally:
229
255
  self._app_deployed = False
230
256
 
257
+ def _prepare_function(
258
+ self,
259
+ func: Callable,
260
+ context: Optional[str] = "",
261
+ ) -> Callable:
262
+ # Check if func is Callable
263
+ if not callable(func):
264
+ raise ValueError(f"Function {func} is not callable")
265
+
266
+ # Handle partial functions
267
+ if isinstance(func, partial):
268
+ # Create new partial with self as first arg if not already there
269
+ get_self = (
270
+ lambda func: hasattr(func.func, "__self__") and func.func.__self__
271
+ )
272
+ get_name = lambda func: func.func.__name__
273
+ else:
274
+ get_self = lambda func: hasattr(func, "__self__") and func.__self__
275
+ get_name = lambda func: func.__name__
276
+
277
+ # Check if it's a class method
278
+ class_method = get_self(func) is self.__class__
279
+ if class_method:
280
+ raise ValueError(context + "Class methods are not supported.")
281
+
282
+ # Check if it's a bound method to another instance other than this one
283
+ if get_self(func) and get_self(func) is not self:
284
+ raise ValueError(
285
+ context + "Bound methods to other instances are not supported."
286
+ )
287
+
288
+ # Get function signature
289
+ try:
290
+ params = list(inspect.signature(func).parameters.values())
291
+ except ValueError:
292
+ # Handle built-ins or other objects without signatures
293
+ raise ValueError(context + f"Cannot inspect function signature for {func}")
294
+
295
+ # Look through params and check if there are two positional parameters (including self for bound methods)
296
+ bound_method = get_self(func) is self
297
+ positional_params = 0 + bound_method
298
+ optional_part = ""
299
+ for param in params:
300
+ # Check if it's a positional parameter. If it is, count it.
301
+ # As long as we have less than 2 positional parameters, we're good.
302
+ # When we already have 2 positional parameters, we need to make sure any other positional parameters have defaults.
303
+ if param.kind in (
304
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
305
+ inspect.Parameter.POSITIONAL_ONLY,
306
+ ):
307
+ if positional_params < 2:
308
+ positional_params += 1
309
+ else:
310
+ if param.default == inspect.Parameter.empty:
311
+ positional_params += 1
312
+ else:
313
+ optional_part += f", {param.name}={param.default!r}"
314
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
315
+ optional_part += f", **{param.name}"
316
+ elif param.kind == inspect.Parameter.KEYWORD_ONLY:
317
+ optional_part += (
318
+ f", {param.name}={param.default!r}"
319
+ if param.default != inspect.Parameter.empty
320
+ else f", {param.name}"
321
+ )
322
+
323
+ if positional_params != 2:
324
+ func_name = get_name(func)
325
+ if isinstance(func, partial):
326
+ func_sig = str(inspect.signature(func))
327
+ if bound_method:
328
+ func_sig = "(" + "self, " + func_sig[1:]
329
+ msg = (
330
+ context
331
+ + "\n"
332
+ + f"Your partial function '{func_name}' has an incorrect signature.\n"
333
+ "Partial functions must have exactly two positional parameters.\n"
334
+ "The first parameter should be self -- it corresponds to the viewer.\n"
335
+ "The second parameter should be state -- a dictionary of the current state of the viewer.\n"
336
+ "\nYour partial function effectivelylooks like this:\n"
337
+ f"def {func_name}{func_sig}:\n"
338
+ " ... your function code ..."
339
+ )
340
+ raise ValueError(msg)
341
+
342
+ if bound_method:
343
+ original_method = getattr(get_self(func).__class__, get_name(func))
344
+ func_sig = str(inspect.signature(original_method))
345
+
346
+ msg = (
347
+ context + "\n"
348
+ f"Your bound method '{func_name}{func_sig}' has an incorrect signature.\n"
349
+ "Bound methods must have exactly one positional parameter in addition to self.\n"
350
+ "The first parameter should be self -- it corresponds to the viewer.\n"
351
+ "The second parameter should be state -- a dictionary of the current state of the viewer.\n"
352
+ "\nYour method looks like this:\n"
353
+ "class YourViewer(InteractiveViewer):\n"
354
+ f" def {func_name}{func_sig}:\n"
355
+ " ... your function code ...\n"
356
+ "\nIt should look like this:\n"
357
+ "class YourViewer(InteractiveViewer):\n"
358
+ f" def {func_name}(self, state{optional_part}):\n"
359
+ " ... your function code ..."
360
+ )
361
+ raise ValueError(msg)
362
+ else:
363
+ func_sig = str(inspect.signature(func))
364
+
365
+ msg = (
366
+ context + "\n"
367
+ f"Your function '{func_name}{func_sig}' has an incorrect signature.\n"
368
+ "External functions must have exactly two positional parameters.\n"
369
+ "The first parameter should be viewer -- it corresponds to the viewer.\n"
370
+ "The second parameter should be state -- a dictionary of the current state of the viewer.\n"
371
+ "\nYour function looks like this:\n"
372
+ f"def {func_name}{func_sig}:\n"
373
+ " ... your function code ...\n"
374
+ "\nIt should look like this:\n"
375
+ f"def {func_name}(viewer, state{optional_part}):\n"
376
+ " ... your function code ..."
377
+ )
378
+ raise ValueError(msg)
379
+
380
+ # If not a bound method, wrap it to inject self when called with just the state
381
+ if bound_method:
382
+ return func
383
+
384
+ @wraps(func)
385
+ def func_with_self(*args, **kwargs):
386
+ return func(self, *args, **kwargs)
387
+
388
+ return func_with_self
389
+
231
390
  def perform_callbacks(self, name: str) -> bool:
232
391
  """Perform callbacks for all parameters that have changed"""
233
392
  if self._in_callbacks:
@@ -266,6 +425,11 @@ class InteractiveViewer(ABC):
266
425
  if isinstance(parameter_name, str):
267
426
  parameter_name = [parameter_name]
268
427
 
428
+ callback = self._prepare_function(
429
+ callback,
430
+ context="Setting on_change callback:",
431
+ )
432
+
269
433
  for param_name in parameter_name:
270
434
  if param_name not in self.parameters:
271
435
  raise ValueError(f"Parameter '{param_name}' is not registered!")
@@ -742,7 +906,7 @@ class InteractiveViewer(ABC):
742
906
  else:
743
907
  self.parameters[name] = new_param
744
908
 
745
- @validate_parameter_operation("add", ParameterType.button)
909
+ @validate_parameter_operation("add", ActionType.button)
746
910
  def add_button(
747
911
  self,
748
912
  name: str,
@@ -774,11 +938,12 @@ class InteractiveViewer(ABC):
774
938
  """
775
939
  try:
776
940
 
777
- # Wrap the callback to include state as an input argument
778
- def wrapped_callback(button):
779
- callback(self.get_state())
941
+ callback = self._prepare_function(
942
+ callback,
943
+ context="Setting button callback:",
944
+ )
780
945
 
781
- new_param = ParameterType.button.value(name, label, wrapped_callback)
946
+ new_param = ActionType.button.value(name, label, callback)
782
947
  except Exception as e:
783
948
  raise ParameterAddError(name, "button", str(e)) from e
784
949
  else:
@@ -1228,7 +1393,7 @@ class InteractiveViewer(ABC):
1228
1393
  if updates:
1229
1394
  self.parameters[name].update(updates)
1230
1395
 
1231
- @validate_parameter_operation("update", ParameterType.button)
1396
+ @validate_parameter_operation("update", ActionType.button)
1232
1397
  def update_button(
1233
1398
  self,
1234
1399
  name: str,
@@ -1240,7 +1405,7 @@ class InteractiveViewer(ABC):
1240
1405
  Update a button parameter's label and/or callback function.
1241
1406
 
1242
1407
  Updates a parameter created by :meth:`~syd.interactive_viewer.InteractiveViewer.add_button`.
1243
- See :class:`~syd.parameters.ButtonParameter` for details.
1408
+ See :class:`~syd.parameters.ButtonAction` for details.
1244
1409
 
1245
1410
  Parameters
1246
1411
  ----------
@@ -1263,6 +1428,10 @@ class InteractiveViewer(ABC):
1263
1428
  if label is not _NO_UPDATE:
1264
1429
  updates["label"] = label
1265
1430
  if callback is not _NO_UPDATE:
1431
+ callback = self._prepare_function(
1432
+ callback,
1433
+ context="Updating button callback:",
1434
+ )
1266
1435
  updates["callback"] = callback
1267
1436
  if updates:
1268
1437
  self.parameters[name].update(updates)
@@ -4,9 +4,11 @@ from contextlib import contextmanager
4
4
  import ipywidgets as widgets
5
5
  from IPython.display import display
6
6
  import matplotlib.pyplot as plt
7
+ import warnings
8
+ from ..parameters import ParameterUpdateWarning
7
9
 
8
10
  from ..interactive_viewer import InteractiveViewer
9
- from .widgets import BaseParameterWidget, create_parameter_widget
11
+ from .widgets import BaseWidget, create_widget
10
12
 
11
13
 
12
14
  @contextmanager
@@ -26,7 +28,6 @@ class LayoutConfig:
26
28
  figure_width: float = 8.0
27
29
  figure_height: float = 6.0
28
30
  controls_width_percent: int = 30
29
- continuous_update: bool = False
30
31
 
31
32
  def __post_init__(self):
32
33
  valid_positions = ["left", "top"]
@@ -50,7 +51,8 @@ class NotebookDeployment:
50
51
  self,
51
52
  viewer: InteractiveViewer,
52
53
  layout_config: Optional[LayoutConfig] = None,
53
- continuous_update: bool = False,
54
+ continuous: bool = False,
55
+ suppress_warnings: bool = False,
54
56
  ):
55
57
  if not isinstance(viewer, InteractiveViewer): # type: ignore
56
58
  raise TypeError(
@@ -59,10 +61,11 @@ class NotebookDeployment:
59
61
 
60
62
  self.viewer = viewer
61
63
  self.config = layout_config or LayoutConfig()
62
- self.continuous_update = continuous_update
64
+ self.continuous = continuous
65
+ self.suppress_warnings = suppress_warnings
63
66
 
64
67
  # Initialize containers
65
- self.parameter_widgets: Dict[str, BaseParameterWidget] = {}
68
+ self.parameter_widgets: Dict[str, BaseWidget] = {}
66
69
  self.layout_widgets = self._create_layout_controls()
67
70
  self.plot_output = widgets.Output()
68
71
 
@@ -82,7 +85,7 @@ class NotebookDeployment:
82
85
  min=20,
83
86
  max=80,
84
87
  description="Controls Width %",
85
- continuous_update=True,
88
+ continuous=True,
86
89
  layout=widgets.Layout(width="95%"),
87
90
  style={"description_width": "initial"},
88
91
  )
@@ -95,38 +98,52 @@ class NotebookDeployment:
95
98
  def _create_parameter_widgets(self) -> None:
96
99
  """Create widget instances for all parameters."""
97
100
  for name, param in self.viewer.parameters.items():
98
- widget = create_parameter_widget(
101
+ widget = create_widget(
99
102
  param,
100
- continuous_update=self.continuous_update,
103
+ continuous=self.continuous,
101
104
  )
102
105
 
103
106
  # Store in widget dict
104
107
  self.parameter_widgets[name] = widget
105
108
 
106
- def _handle_parameter_change(self, name: str) -> None:
107
- """Handle changes to parameter widgets."""
109
+ def _handle_widget_engagement(self, name: str) -> None:
110
+ """Handle engagement with an interactive widget."""
108
111
  if self._updating:
112
+ print(
113
+ "Already updating -- there's a circular dependency!"
114
+ "This is probably caused by failing to disable callbacks for a parameter."
115
+ "It's a bug --- tell the developer on github issues please."
116
+ )
109
117
  return
110
118
 
111
119
  try:
112
120
  self._updating = True
113
- widget = self.parameter_widgets[name]
114
121
 
115
- if hasattr(widget, "_is_button") and widget._is_button:
116
- parameter = self.viewer.parameters[name]
117
- parameter.callback(parameter)
118
- else:
119
- self.viewer.set_parameter_value(name, widget.value)
122
+ # Optionally suppress warnings during parameter updates
123
+ with warnings.catch_warnings():
124
+ if self.suppress_warnings:
125
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
120
126
 
121
- # Update any widgets that changed due to dependencies
122
- self._sync_widgets_with_state(exclude=name)
127
+ widget = self.parameter_widgets[name]
123
128
 
124
- # Update the plot
125
- self._update_plot()
129
+ if widget._is_action:
130
+ parameter = self.viewer.parameters[name]
131
+ parameter.callback(self.viewer.get_state())
132
+ else:
133
+ self.viewer.set_parameter_value(name, widget.value)
134
+
135
+ # Update any widgets that changed due to dependencies
136
+ self._sync_widgets_with_state(exclude=name)
137
+
138
+ # Update the plot
139
+ self._update_plot()
126
140
 
127
141
  finally:
128
142
  self._updating = False
129
143
 
144
+ def _handle_action(self, name: str) -> None:
145
+ """Handle actions for parameter widgets."""
146
+
130
147
  def _sync_widgets_with_state(self, exclude: Optional[str] = None) -> None:
131
148
  """Sync widget values with viewer state."""
132
149
  for name, parameter in self.viewer.parameters.items():
@@ -181,7 +198,7 @@ class NotebookDeployment:
181
198
 
182
199
  # Set up parameter widgets with their observe callbacks
183
200
  for name, widget in self.parameter_widgets.items():
184
- widget.observe(lambda change, n=name: self._handle_parameter_change(n))
201
+ widget.observe(lambda change, n=name: self._handle_widget_engagement(n))
185
202
 
186
203
  # Create parameter controls section
187
204
  param_box = widgets.VBox(
@@ -225,7 +242,7 @@ class NotebookDeployment:
225
242
 
226
243
  def deploy(self) -> None:
227
244
  """Deploy the interactive viewer with proper state management."""
228
- with self.viewer.deploy_app():
245
+ with self.viewer._deploy_app():
229
246
  # Create widgets
230
247
  self._create_parameter_widgets()
231
248