syd 0.1.6__py3-none-any.whl → 0.1.7__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.
@@ -0,0 +1,258 @@
1
+ from typing import Dict, Any, Optional
2
+ from dataclasses import dataclass
3
+ from contextlib import contextmanager
4
+ import ipywidgets as widgets
5
+ from IPython.display import display
6
+ import matplotlib.pyplot as plt
7
+ import warnings
8
+ from ..parameters import ParameterUpdateWarning
9
+
10
+ from ..viewer import Viewer
11
+ from .widgets import BaseWidget, create_widget
12
+
13
+
14
+ @contextmanager
15
+ def _plot_context():
16
+ plt.ioff()
17
+ try:
18
+ yield
19
+ finally:
20
+ plt.ion()
21
+
22
+
23
+ @dataclass
24
+ class LayoutConfig:
25
+ """Configuration for the viewer layout."""
26
+
27
+ controls_position: str = "left" # Options are: 'left', 'top'
28
+ figure_width: float = 8.0
29
+ figure_height: float = 6.0
30
+ controls_width_percent: int = 30
31
+
32
+ def __post_init__(self):
33
+ valid_positions = ["left", "top"]
34
+ if self.controls_position not in valid_positions:
35
+ raise ValueError(
36
+ f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
37
+ )
38
+
39
+ @property
40
+ def is_horizontal(self) -> bool:
41
+ return self.controls_position == "left"
42
+
43
+
44
+ class NotebookDeployer:
45
+ """
46
+ A deployment system for Viewer in Jupyter notebooks using ipywidgets.
47
+ Built around the parameter widget system for clean separation of concerns.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ viewer: Viewer,
53
+ layout_config: Optional[LayoutConfig] = None,
54
+ continuous: bool = False,
55
+ suppress_warnings: bool = False,
56
+ ):
57
+ if not isinstance(viewer, Viewer): # type: ignore
58
+ raise TypeError(f"viewer must be an Viewer, got {type(viewer).__name__}")
59
+
60
+ self.viewer = viewer
61
+ self.config = layout_config or LayoutConfig()
62
+ self.continuous = continuous
63
+ self.suppress_warnings = suppress_warnings
64
+
65
+ # Initialize containers
66
+ self.parameter_widgets: Dict[str, BaseWidget] = {}
67
+ self.layout_widgets = self._create_layout_controls()
68
+ self.plot_output = widgets.Output()
69
+ self._canvas_widget = None
70
+
71
+ # Store current figure
72
+ self._current_figure = None
73
+ # Flag to prevent circular updates
74
+ self._updating = False
75
+
76
+ def _create_layout_controls(self) -> Dict[str, widgets.Widget]:
77
+ """Create widgets for controlling the layout."""
78
+ controls: Dict[str, widgets.Widget] = {}
79
+
80
+ # Controls width slider for horizontal layouts
81
+ if self.config.is_horizontal:
82
+ controls["controls_width"] = widgets.IntSlider(
83
+ value=self.config.controls_width_percent,
84
+ min=20,
85
+ max=80,
86
+ description="Controls Width %",
87
+ continuous=True,
88
+ layout=widgets.Layout(width="95%"),
89
+ style={"description_width": "initial"},
90
+ )
91
+ controls["controls_width"].observe(
92
+ self._handle_container_width_change, names="value"
93
+ )
94
+
95
+ return controls
96
+
97
+ def _create_parameter_widgets(self) -> None:
98
+ """Create widget instances for all parameters."""
99
+ for name, param in self.viewer.parameters.items():
100
+ widget = create_widget(
101
+ param,
102
+ continuous=self.continuous,
103
+ )
104
+
105
+ # Store in widget dict
106
+ self.parameter_widgets[name] = widget
107
+
108
+ def _handle_widget_engagement(self, name: str) -> None:
109
+ """Handle engagement with an interactive widget."""
110
+ if self._updating:
111
+ print(
112
+ "Already updating -- there's a circular dependency!"
113
+ "This is probably caused by failing to disable callbacks for a parameter."
114
+ "It's a bug --- tell the developer on github issues please."
115
+ )
116
+ return
117
+
118
+ try:
119
+ self._updating = True
120
+
121
+ # Optionally suppress warnings during parameter updates
122
+ with warnings.catch_warnings():
123
+ if self.suppress_warnings:
124
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
125
+
126
+ widget = self.parameter_widgets[name]
127
+
128
+ if widget._is_action:
129
+ parameter = self.viewer.parameters[name]
130
+ parameter.callback(self.viewer.state)
131
+ else:
132
+ self.viewer.set_parameter_value(name, widget.value)
133
+
134
+ # Update any widgets that changed due to dependencies
135
+ self._sync_widgets_with_state(exclude=name)
136
+
137
+ # Update the plot
138
+ self._update_plot()
139
+
140
+ finally:
141
+ self._updating = False
142
+
143
+ def _handle_action(self, name: str) -> None:
144
+ """Handle actions for parameter widgets."""
145
+
146
+ def _sync_widgets_with_state(self, exclude: Optional[str] = None) -> None:
147
+ """Sync widget values with viewer state."""
148
+ for name, parameter in self.viewer.parameters.items():
149
+ if name == exclude:
150
+ continue
151
+
152
+ widget = self.parameter_widgets[name]
153
+ if not widget.matches_parameter(parameter):
154
+ widget.update_from_parameter(parameter)
155
+
156
+ def _handle_figure_size_change(self, change: Dict[str, Any]) -> None:
157
+ """Handle changes to figure dimensions."""
158
+ if self._current_figure is None:
159
+ return
160
+
161
+ self._redraw_plot()
162
+
163
+ def _handle_container_width_change(self, change: Dict[str, Any]) -> None:
164
+ """Handle changes to container width proportions."""
165
+ width_percent = self.layout_widgets["controls_width"].value
166
+ self.config.controls_width_percent = width_percent
167
+
168
+ # Update container widths
169
+ self.widgets_container.layout.width = f"{width_percent}%"
170
+ self.plot_container.layout.width = f"{100 - width_percent}%"
171
+
172
+ def _update_plot(self) -> None:
173
+ """Update the plot with current state."""
174
+ state = self.viewer.state
175
+
176
+ with _plot_context():
177
+ new_fig = self.viewer.plot(state)
178
+ plt.close(self._current_figure) # Close old figure
179
+ self._current_figure = new_fig
180
+
181
+ # Clear previous output and display new figure
182
+ self.plot_output.clear_output(wait=True)
183
+ with self.plot_output:
184
+ # Make sure the canvas is created and displayed
185
+ if self._canvas_widget is None:
186
+ self._canvas_widget = self._current_figure.canvas
187
+ display(self._current_figure.canvas)
188
+
189
+ def _redraw_plot(self) -> None:
190
+ """Clear and redraw the plot in the output widget."""
191
+ if self._canvas_widget is not None:
192
+ self._canvas_widget.draw()
193
+
194
+ def _create_layout(self) -> widgets.Widget:
195
+ """Create the main layout combining controls and plot."""
196
+ # Create layout controls section
197
+ layout_box = widgets.VBox(
198
+ [widgets.HTML("<b>Layout Controls</b>")]
199
+ + list(self.layout_widgets.values()),
200
+ layout=widgets.Layout(margin="10px 0px"),
201
+ )
202
+
203
+ # Set up parameter widgets with their observe callbacks
204
+ for name, widget in self.parameter_widgets.items():
205
+ widget.observe(lambda change, n=name: self._handle_widget_engagement(n))
206
+
207
+ # Create parameter controls section
208
+ param_box = widgets.VBox(
209
+ [widgets.HTML("<b>Parameters</b>")]
210
+ + [w.widget for w in self.parameter_widgets.values()],
211
+ layout=widgets.Layout(margin="10px 0px"),
212
+ )
213
+
214
+ # Combine all controls
215
+ self.widgets_container = widgets.VBox(
216
+ [param_box, layout_box],
217
+ layout=widgets.Layout(
218
+ width=(
219
+ f"{self.config.controls_width_percent}%"
220
+ if self.config.is_horizontal
221
+ else "100%"
222
+ ),
223
+ padding="10px",
224
+ overflow_y="auto",
225
+ ),
226
+ )
227
+
228
+ # Create plot container
229
+ self.plot_container = widgets.VBox(
230
+ [self.plot_output],
231
+ layout=widgets.Layout(
232
+ width=(
233
+ f"{100 - self.config.controls_width_percent}%"
234
+ if self.config.is_horizontal
235
+ else "100%"
236
+ ),
237
+ padding="10px",
238
+ ),
239
+ )
240
+
241
+ # Create final layout based on configuration
242
+ if self.config.controls_position == "left":
243
+ return widgets.HBox([self.widgets_container, self.plot_container])
244
+ else:
245
+ return widgets.VBox([self.widgets_container, self.plot_container])
246
+
247
+ def deploy(self) -> None:
248
+ """Deploy the interactive viewer with proper state management."""
249
+ with self.viewer._deploy_app():
250
+ # Create widgets
251
+ self._create_parameter_widgets()
252
+
253
+ # Create and display layout
254
+ layout = self._create_layout()
255
+ display(layout)
256
+
257
+ # Create initial plot
258
+ self._update_plot()
@@ -1,13 +1,17 @@
1
- from typing import Dict, Any, Optional, cast
1
+ from typing import Dict, Any, Optional
2
+ import warnings
3
+ from functools import wraps
2
4
  from dataclasses import dataclass
3
5
  from contextlib import contextmanager
6
+ from time import time
7
+
4
8
  import ipywidgets as widgets
5
9
  from IPython.display import display
10
+ import matplotlib as mpl
6
11
  import matplotlib.pyplot as plt
7
- import warnings
8
- from ..parameters import ParameterUpdateWarning
9
12
 
10
- from ..interactive_viewer import InteractiveViewer
13
+ from ..parameters import ParameterUpdateWarning
14
+ from ..viewer import Viewer
11
15
  from .widgets import BaseWidget, create_widget
12
16
 
13
17
 
@@ -20,17 +24,53 @@ def _plot_context():
20
24
  plt.ion()
21
25
 
22
26
 
27
+ def get_backend_type():
28
+ """
29
+ Determines the current matplotlib backend type and returns relevant info
30
+ """
31
+ backend = mpl.get_backend().lower()
32
+
33
+ if "inline" in backend:
34
+ return "inline"
35
+ elif "widget" in backend or "ipympl" in backend:
36
+ return "widget"
37
+ elif "qt" in backend:
38
+ return "qt"
39
+ else:
40
+ return "other"
41
+
42
+
43
+ def debounce(wait_time):
44
+ """
45
+ Decorator to prevent a function from being called more than once every wait_time seconds.
46
+ """
47
+
48
+ def decorator(fn):
49
+ last_called = [0.0] # Using list to maintain state in closure
50
+
51
+ @wraps(fn)
52
+ def debounced(*args, **kwargs):
53
+ current_time = time()
54
+ if current_time - last_called[0] >= wait_time:
55
+ fn(*args, **kwargs)
56
+ last_called[0] = current_time
57
+
58
+ return debounced
59
+
60
+ return decorator
61
+
62
+
23
63
  @dataclass
24
64
  class LayoutConfig:
25
65
  """Configuration for the viewer layout."""
26
66
 
27
- controls_position: str = "left" # Options are: 'left', 'top'
67
+ controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
28
68
  figure_width: float = 8.0
29
69
  figure_height: float = 6.0
30
70
  controls_width_percent: int = 30
31
71
 
32
72
  def __post_init__(self):
33
- valid_positions = ["left", "top"]
73
+ valid_positions = ["left", "top", "right", "bottom"]
34
74
  if self.controls_position not in valid_positions:
35
75
  raise ValueError(
36
76
  f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
@@ -38,42 +78,54 @@ class LayoutConfig:
38
78
 
39
79
  @property
40
80
  def is_horizontal(self) -> bool:
41
- return self.controls_position == "left"
81
+ return self.controls_position == "left" or self.controls_position == "right"
42
82
 
43
83
 
44
- class NotebookDeployment:
84
+ class NotebookDeployer:
45
85
  """
46
- A deployment system for InteractiveViewer in Jupyter notebooks using ipywidgets.
86
+ A deployment system for Viewer in Jupyter notebooks using ipywidgets.
47
87
  Built around the parameter widget system for clean separation of concerns.
48
88
  """
49
89
 
50
90
  def __init__(
51
91
  self,
52
- viewer: InteractiveViewer,
53
- layout_config: Optional[LayoutConfig] = None,
92
+ viewer: Viewer,
93
+ controls_position: str = "left",
94
+ figure_width: float = 8.0,
95
+ figure_height: float = 6.0,
96
+ controls_width_percent: int = 30,
54
97
  continuous: bool = False,
55
98
  suppress_warnings: bool = False,
56
99
  ):
57
- if not isinstance(viewer, InteractiveViewer): # type: ignore
58
- raise TypeError(
59
- f"viewer must be an InteractiveViewer, got {type(viewer).__name__}"
60
- )
61
-
62
100
  self.viewer = viewer
63
- self.config = layout_config or LayoutConfig()
101
+ self.config = LayoutConfig(
102
+ controls_position=controls_position,
103
+ figure_width=figure_width,
104
+ figure_height=figure_height,
105
+ controls_width_percent=controls_width_percent,
106
+ )
64
107
  self.continuous = continuous
65
108
  self.suppress_warnings = suppress_warnings
66
109
 
67
110
  # Initialize containers
111
+ self.backend_type = get_backend_type()
112
+ if self.backend_type not in ["inline", "widget"]:
113
+ warnings.warn(
114
+ "The current backend is not supported. Please use %matplotlib widget or %matplotlib inline.\n"
115
+ "The behavior of the viewer will almost definitely not work as expected."
116
+ )
68
117
  self.parameter_widgets: Dict[str, BaseWidget] = {}
69
- self.layout_widgets = self._create_layout_controls()
70
118
  self.plot_output = widgets.Output()
71
119
 
72
- # Store current figure
73
- self._current_figure = None
120
+ # Create layout for controls
121
+ self.layout_widgets = self._create_layout_controls()
122
+
74
123
  # Flag to prevent circular updates
75
124
  self._updating = False
76
125
 
126
+ # Last figure to close when new figures are created
127
+ self._last_figure = None
128
+
77
129
  def _create_layout_controls(self) -> Dict[str, widgets.Widget]:
78
130
  """Create widgets for controlling the layout."""
79
131
  controls: Dict[str, widgets.Widget] = {}
@@ -89,9 +141,6 @@ class NotebookDeployment:
89
141
  layout=widgets.Layout(width="95%"),
90
142
  style={"description_width": "initial"},
91
143
  )
92
- controls["controls_width"].observe(
93
- self._handle_container_width_change, names="value"
94
- )
95
144
 
96
145
  return controls
97
146
 
@@ -106,6 +155,7 @@ class NotebookDeployment:
106
155
  # Store in widget dict
107
156
  self.parameter_widgets[name] = widget
108
157
 
158
+ @debounce(0.1)
109
159
  def _handle_widget_engagement(self, name: str) -> None:
110
160
  """Handle engagement with an interactive widget."""
111
161
  if self._updating:
@@ -128,12 +178,12 @@ class NotebookDeployment:
128
178
 
129
179
  if widget._is_action:
130
180
  parameter = self.viewer.parameters[name]
131
- parameter.callback(self.viewer.get_state())
181
+ parameter.callback(self.viewer.state)
132
182
  else:
133
183
  self.viewer.set_parameter_value(name, widget.value)
134
184
 
135
185
  # Update any widgets that changed due to dependencies
136
- self._sync_widgets_with_state(exclude=name)
186
+ self._sync_widgets_with_state()
137
187
 
138
188
  # Update the plot
139
189
  self._update_plot()
@@ -172,30 +222,34 @@ class NotebookDeployment:
172
222
 
173
223
  def _update_plot(self) -> None:
174
224
  """Update the plot with current state."""
175
- state = self.viewer.get_state()
225
+ state = self.viewer.state
176
226
 
177
227
  with _plot_context():
178
- new_fig = self.viewer.plot(state)
179
- plt.close(self._current_figure) # Close old figure
180
- self._current_figure = new_fig
228
+ figure = self.viewer.plot(state)
181
229
 
182
- self._redraw_plot()
230
+ # Close the last figure if it exists to keep matplotlib clean
231
+ # (just moved this from after clear_output.... noting!)
232
+ if self._last_figure is not None:
233
+ plt.close(self._last_figure)
183
234
 
184
- def _redraw_plot(self) -> None:
185
- """Clear and redraw the plot in the output widget."""
186
235
  self.plot_output.clear_output(wait=True)
187
236
  with self.plot_output:
188
- display(self._current_figure)
237
+ if self.backend_type == "inline":
238
+ display(figure)
239
+
240
+ # Also required to make sure a second figure window isn't opened
241
+ plt.close(figure)
242
+
243
+ elif self.backend_type == "widget":
244
+ display(figure.canvas)
245
+
246
+ else:
247
+ raise ValueError(f"Unsupported backend type: {self.backend_type}")
248
+
249
+ self._last_figure = figure
189
250
 
190
251
  def _create_layout(self) -> widgets.Widget:
191
252
  """Create the main layout combining controls and plot."""
192
- # Create layout controls section
193
- layout_box = widgets.VBox(
194
- [widgets.HTML("<b>Layout Controls</b>")]
195
- + list(self.layout_widgets.values()),
196
- layout=widgets.Layout(margin="10px 0px"),
197
- )
198
-
199
253
  # Set up parameter widgets with their observe callbacks
200
254
  for name, widget in self.parameter_widgets.items():
201
255
  widget.observe(lambda change, n=name: self._handle_widget_engagement(n))
@@ -208,8 +262,19 @@ class NotebookDeployment:
208
262
  )
209
263
 
210
264
  # Combine all controls
265
+ if self.config.is_horizontal:
266
+ # Create layout controls section if horizontal (might include for vertical later when we have more permanent controls...)
267
+ layout_box = widgets.VBox(
268
+ [widgets.HTML("<b>Layout Controls</b>")]
269
+ + list(self.layout_widgets.values()),
270
+ layout=widgets.Layout(margin="10px 0px"),
271
+ )
272
+ widgets_elements = [param_box, layout_box]
273
+ else:
274
+ widgets_elements = [param_box]
275
+
211
276
  self.widgets_container = widgets.VBox(
212
- [param_box, layout_box],
277
+ widgets_elements,
213
278
  layout=widgets.Layout(
214
279
  width=(
215
280
  f"{self.config.controls_width_percent}%"
@@ -217,7 +282,9 @@ class NotebookDeployment:
217
282
  else "100%"
218
283
  ),
219
284
  padding="10px",
220
- overflow_y="auto",
285
+ overflow_y="scroll",
286
+ border="1px solid #e5e7eb",
287
+ border_radius="4px 4px 0px 0px",
221
288
  ),
222
289
  )
223
290
 
@@ -237,18 +304,27 @@ class NotebookDeployment:
237
304
  # Create final layout based on configuration
238
305
  if self.config.controls_position == "left":
239
306
  return widgets.HBox([self.widgets_container, self.plot_container])
307
+ elif self.config.controls_position == "right":
308
+ return widgets.HBox([self.plot_container, self.widgets_container])
309
+ elif self.config.controls_position == "bottom":
310
+ return widgets.VBox([self.plot_container, self.widgets_container])
240
311
  else:
241
312
  return widgets.VBox([self.widgets_container, self.plot_container])
242
313
 
243
314
  def deploy(self) -> None:
244
315
  """Deploy the interactive viewer with proper state management."""
245
- with self.viewer._deploy_app():
246
- # Create widgets
247
- self._create_parameter_widgets()
316
+ self.backend_type = get_backend_type()
317
+
318
+ # We used to use the deploy_app context, but notebook deployment works
319
+ # differently because it's asynchronous and this doesn't really behave
320
+ # as intended. (e.g. with self.viewer._deploy_app() ...)
321
+
322
+ # Create widgets
323
+ self._create_parameter_widgets()
248
324
 
249
- # Create and display layout
250
- layout = self._create_layout()
251
- display(layout)
325
+ # Create and display layout
326
+ self.layout = self._create_layout()
327
+ display(self.layout)
252
328
 
253
- # Create initial plot
254
- self._update_plot()
329
+ # Create initial plot
330
+ self._update_plot()