syd 0.1.5__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,330 @@
1
+ from typing import Dict, Any, Optional
2
+ import warnings
3
+ from functools import wraps
4
+ from dataclasses import dataclass
5
+ from contextlib import contextmanager
6
+ from time import time
7
+
8
+ import ipywidgets as widgets
9
+ from IPython.display import display
10
+ import matplotlib as mpl
11
+ import matplotlib.pyplot as plt
12
+
13
+ from ..parameters import ParameterUpdateWarning
14
+ from ..viewer import Viewer
15
+ from .widgets import BaseWidget, create_widget
16
+
17
+
18
+ @contextmanager
19
+ def _plot_context():
20
+ plt.ioff()
21
+ try:
22
+ yield
23
+ finally:
24
+ plt.ion()
25
+
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
+
63
+ @dataclass
64
+ class LayoutConfig:
65
+ """Configuration for the viewer layout."""
66
+
67
+ controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
68
+ figure_width: float = 8.0
69
+ figure_height: float = 6.0
70
+ controls_width_percent: int = 30
71
+
72
+ def __post_init__(self):
73
+ valid_positions = ["left", "top", "right", "bottom"]
74
+ if self.controls_position not in valid_positions:
75
+ raise ValueError(
76
+ f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
77
+ )
78
+
79
+ @property
80
+ def is_horizontal(self) -> bool:
81
+ return self.controls_position == "left" or self.controls_position == "right"
82
+
83
+
84
+ class NotebookDeployer:
85
+ """
86
+ A deployment system for Viewer in Jupyter notebooks using ipywidgets.
87
+ Built around the parameter widget system for clean separation of concerns.
88
+ """
89
+
90
+ def __init__(
91
+ self,
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,
97
+ continuous: bool = False,
98
+ suppress_warnings: bool = False,
99
+ ):
100
+ self.viewer = viewer
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
+ )
107
+ self.continuous = continuous
108
+ self.suppress_warnings = suppress_warnings
109
+
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
+ )
117
+ self.parameter_widgets: Dict[str, BaseWidget] = {}
118
+ self.plot_output = widgets.Output()
119
+
120
+ # Create layout for controls
121
+ self.layout_widgets = self._create_layout_controls()
122
+
123
+ # Flag to prevent circular updates
124
+ self._updating = False
125
+
126
+ # Last figure to close when new figures are created
127
+ self._last_figure = None
128
+
129
+ def _create_layout_controls(self) -> Dict[str, widgets.Widget]:
130
+ """Create widgets for controlling the layout."""
131
+ controls: Dict[str, widgets.Widget] = {}
132
+
133
+ # Controls width slider for horizontal layouts
134
+ if self.config.is_horizontal:
135
+ controls["controls_width"] = widgets.IntSlider(
136
+ value=self.config.controls_width_percent,
137
+ min=20,
138
+ max=80,
139
+ description="Controls Width %",
140
+ continuous=True,
141
+ layout=widgets.Layout(width="95%"),
142
+ style={"description_width": "initial"},
143
+ )
144
+
145
+ return controls
146
+
147
+ def _create_parameter_widgets(self) -> None:
148
+ """Create widget instances for all parameters."""
149
+ for name, param in self.viewer.parameters.items():
150
+ widget = create_widget(
151
+ param,
152
+ continuous=self.continuous,
153
+ )
154
+
155
+ # Store in widget dict
156
+ self.parameter_widgets[name] = widget
157
+
158
+ @debounce(0.1)
159
+ def _handle_widget_engagement(self, name: str) -> None:
160
+ """Handle engagement with an interactive widget."""
161
+ if self._updating:
162
+ print(
163
+ "Already updating -- there's a circular dependency!"
164
+ "This is probably caused by failing to disable callbacks for a parameter."
165
+ "It's a bug --- tell the developer on github issues please."
166
+ )
167
+ return
168
+
169
+ try:
170
+ self._updating = True
171
+
172
+ # Optionally suppress warnings during parameter updates
173
+ with warnings.catch_warnings():
174
+ if self.suppress_warnings:
175
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
176
+
177
+ widget = self.parameter_widgets[name]
178
+
179
+ if widget._is_action:
180
+ parameter = self.viewer.parameters[name]
181
+ parameter.callback(self.viewer.state)
182
+ else:
183
+ self.viewer.set_parameter_value(name, widget.value)
184
+
185
+ # Update any widgets that changed due to dependencies
186
+ self._sync_widgets_with_state()
187
+
188
+ # Update the plot
189
+ self._update_plot()
190
+
191
+ finally:
192
+ self._updating = False
193
+
194
+ def _handle_action(self, name: str) -> None:
195
+ """Handle actions for parameter widgets."""
196
+
197
+ def _sync_widgets_with_state(self, exclude: Optional[str] = None) -> None:
198
+ """Sync widget values with viewer state."""
199
+ for name, parameter in self.viewer.parameters.items():
200
+ if name == exclude:
201
+ continue
202
+
203
+ widget = self.parameter_widgets[name]
204
+ if not widget.matches_parameter(parameter):
205
+ widget.update_from_parameter(parameter)
206
+
207
+ def _handle_figure_size_change(self, change: Dict[str, Any]) -> None:
208
+ """Handle changes to figure dimensions."""
209
+ if self._current_figure is None:
210
+ return
211
+
212
+ self._redraw_plot()
213
+
214
+ def _handle_container_width_change(self, change: Dict[str, Any]) -> None:
215
+ """Handle changes to container width proportions."""
216
+ width_percent = self.layout_widgets["controls_width"].value
217
+ self.config.controls_width_percent = width_percent
218
+
219
+ # Update container widths
220
+ self.widgets_container.layout.width = f"{width_percent}%"
221
+ self.plot_container.layout.width = f"{100 - width_percent}%"
222
+
223
+ def _update_plot(self) -> None:
224
+ """Update the plot with current state."""
225
+ state = self.viewer.state
226
+
227
+ with _plot_context():
228
+ figure = self.viewer.plot(state)
229
+
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)
234
+
235
+ self.plot_output.clear_output(wait=True)
236
+ with self.plot_output:
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
250
+
251
+ def _create_layout(self) -> widgets.Widget:
252
+ """Create the main layout combining controls and plot."""
253
+ # Set up parameter widgets with their observe callbacks
254
+ for name, widget in self.parameter_widgets.items():
255
+ widget.observe(lambda change, n=name: self._handle_widget_engagement(n))
256
+
257
+ # Create parameter controls section
258
+ param_box = widgets.VBox(
259
+ [widgets.HTML("<b>Parameters</b>")]
260
+ + [w.widget for w in self.parameter_widgets.values()],
261
+ layout=widgets.Layout(margin="10px 0px"),
262
+ )
263
+
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
+
276
+ self.widgets_container = widgets.VBox(
277
+ widgets_elements,
278
+ layout=widgets.Layout(
279
+ width=(
280
+ f"{self.config.controls_width_percent}%"
281
+ if self.config.is_horizontal
282
+ else "100%"
283
+ ),
284
+ padding="10px",
285
+ overflow_y="scroll",
286
+ border="1px solid #e5e7eb",
287
+ border_radius="4px 4px 0px 0px",
288
+ ),
289
+ )
290
+
291
+ # Create plot container
292
+ self.plot_container = widgets.VBox(
293
+ [self.plot_output],
294
+ layout=widgets.Layout(
295
+ width=(
296
+ f"{100 - self.config.controls_width_percent}%"
297
+ if self.config.is_horizontal
298
+ else "100%"
299
+ ),
300
+ padding="10px",
301
+ ),
302
+ )
303
+
304
+ # Create final layout based on configuration
305
+ if self.config.controls_position == "left":
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])
311
+ else:
312
+ return widgets.VBox([self.widgets_container, self.plot_container])
313
+
314
+ def deploy(self) -> None:
315
+ """Deploy the interactive viewer with proper state management."""
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()
324
+
325
+ # Create and display layout
326
+ self.layout = self._create_layout()
327
+ display(self.layout)
328
+
329
+ # Create initial plot
330
+ self._update_plot()