syd 0.2.0__py3-none-any.whl → 1.0.1__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.
@@ -1,27 +1,15 @@
1
- from typing import Dict, Any, Optional
1
+ from typing import Literal, Optional
2
2
  import warnings
3
- from functools import wraps
4
- from dataclasses import dataclass
3
+ import threading
5
4
  from contextlib import contextmanager
6
- from time import time
7
-
8
5
  import ipywidgets as widgets
9
6
  from IPython.display import display
10
7
  import matplotlib as mpl
11
8
  import matplotlib.pyplot as plt
12
9
 
13
- from ..parameters import ParameterUpdateWarning
10
+ from ..support import ParameterUpdateWarning, plot_context
14
11
  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()
12
+ from .widgets import create_widget, BaseWidget
25
13
 
26
14
 
27
15
  def get_backend_type():
@@ -40,47 +28,6 @@ def get_backend_type():
40
28
  return "other"
41
29
 
42
30
 
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 = 20
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
31
  class NotebookDeployer:
85
32
  """
86
33
  A deployment system for Viewer in Jupyter notebooks using ipywidgets.
@@ -90,50 +37,105 @@ class NotebookDeployer:
90
37
  def __init__(
91
38
  self,
92
39
  viewer: Viewer,
93
- controls_position: str = "left",
94
- figure_width: float = 8.0,
95
- figure_height: float = 6.0,
40
+ controls_position: Literal["left", "top", "right", "bottom"] = "left",
96
41
  controls_width_percent: int = 20,
97
42
  continuous: bool = False,
98
- suppress_warnings: bool = False,
43
+ suppress_warnings: bool = True,
44
+ update_threshold: float = 1.0,
99
45
  ):
100
46
  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
47
+ self.components: dict[str, BaseWidget] = {}
108
48
  self.suppress_warnings = suppress_warnings
49
+ self._updating = False # Flag to check circular updates
50
+ self.controls_position = controls_position
51
+ self.controls_width_percent = controls_width_percent
52
+ self.continuous = continuous
109
53
 
110
54
  # Initialize containers
111
55
  self.backend_type = get_backend_type()
112
56
  if self.backend_type not in ["inline", "widget"]:
113
57
  warnings.warn(
114
58
  f"The current backend ({self.backend_type}) is not supported. Please use %matplotlib widget or %matplotlib inline.\n"
115
- "The behavior of the viewer will almost definitely not work as expected."
59
+ "The behavior of the viewer will almost definitely not work as expected!"
116
60
  )
117
- self.parameter_widgets: Dict[str, BaseWidget] = {}
118
- self.plot_output = widgets.Output()
61
+ self._last_figure = None
62
+ self._update_event = threading.Event()
63
+ self.update_threshold = update_threshold
64
+ self._slow_loading_figure = None
65
+ self._display_lock = threading.Lock() # Lock for synchronizing display updates
66
+
67
+ def _show_slow_loading(self):
68
+ if self.backend_type == "inline":
69
+ if not self._update_event.wait(self.update_threshold):
70
+ if self._slow_loading_figure is None:
71
+ fig = plt.figure()
72
+ ax = fig.add_subplot(111)
73
+ ax.text(
74
+ 0.5,
75
+ 0.5,
76
+ "waiting for next figure...",
77
+ ha="center",
78
+ va="center",
79
+ fontsize=12,
80
+ weight="bold",
81
+ color="black",
82
+ )
83
+ ax.axis("off")
84
+ self._slow_loading_figure = fig
85
+ if not self._showing_new_figure:
86
+ self._display_figure(self._slow_loading_figure, store_figure=False)
87
+ self._showing_slow_loading_figure = True
88
+
89
+ @contextmanager
90
+ def _perform_update(self):
91
+ self._updating = True
92
+ self._showing_new_figure = False
93
+ self._showing_slow_loading_figure = False
94
+ self._update_event.clear()
95
+
96
+ thread = threading.Thread(target=self._show_slow_loading, daemon=True)
97
+ thread.start()
119
98
 
120
- # Create layout for controls
121
- self.layout_widgets = self._create_layout_controls()
99
+ try:
100
+ yield
101
+ finally:
102
+ self._updating = False
103
+ self._update_event.set()
104
+ thread.join()
105
+ if self._showing_slow_loading_figure:
106
+ self._display_figure(self._last_figure)
107
+ self._update_status("Ready!")
108
+
109
+ def deploy(self) -> None:
110
+ """Deploy the viewer."""
111
+ self.backend_type = get_backend_type()
112
+ self.build_components()
113
+ self.build_layout()
114
+ display(self.layout)
115
+ self.update_plot()
122
116
 
123
- # Flag to prevent circular updates
124
- self._updating = False
117
+ def build_components(self) -> None:
118
+ """Create widget instances for all parameters and equip callbacks."""
119
+ for name, param in self.viewer.parameters.items():
120
+ widget = create_widget(param, continuous=self.continuous)
121
+ self.components[name] = widget
122
+ callback = lambda _, n=name: self.handle_component_engagement(n)
123
+ widget.observe(callback)
125
124
 
126
- # Last figure to close when new figures are created
127
- self._last_figure = None
125
+ def build_layout(self) -> None:
126
+ """Create the main layout combining controls and plot."""
128
127
 
129
- def _create_layout_controls(self) -> Dict[str, widgets.Widget]:
130
- """Create widgets for controlling the layout."""
131
- controls: Dict[str, widgets.Widget] = {}
128
+ self.plot_output = widgets.Output()
132
129
 
133
130
  # 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,
131
+ self.controls = {}
132
+ self.controls["status"] = widgets.HTML(
133
+ value="<b>Syd Controls</b>",
134
+ layout=widgets.Layout(width="95%"),
135
+ )
136
+ if self.controls_position in ["left", "right"]:
137
+ self.controls["controls_width"] = widgets.IntSlider(
138
+ value=self.controls_width_percent,
137
139
  min=10,
138
140
  max=50,
139
141
  description="Controls Width %",
@@ -141,144 +143,42 @@ class NotebookDeployer:
141
143
  layout=widgets.Layout(width="95%"),
142
144
  style={"description_width": "initial"},
143
145
  )
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."
146
+ if self.backend_type == "inline":
147
+ self.controls["update_threshold"] = widgets.FloatSlider(
148
+ value=self.update_threshold,
149
+ min=0.1,
150
+ max=10.0,
151
+ description="Update Threshold",
152
+ layout=widgets.Layout(width="95%"),
153
+ style={"description_width": "initial"},
166
154
  )
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
- # Update widgets if plot function updated a parameter
231
- self._sync_widgets_with_state()
232
-
233
- # Close the last figure if it exists to keep matplotlib clean
234
- # (just moved this from after clear_output.... noting!)
235
- if self._last_figure is not None:
236
- plt.close(self._last_figure)
237
-
238
- self.plot_output.clear_output(wait=True)
239
- with self.plot_output:
240
- if self.backend_type == "inline":
241
- display(figure)
242
-
243
- # Also required to make sure a second figure window isn't opened
244
- plt.close(figure)
245
-
246
- elif self.backend_type == "widget":
247
- display(figure.canvas)
248
-
249
- else:
250
- raise ValueError(f"Unsupported backend type: {self.backend_type}")
251
-
252
- self._last_figure = figure
253
-
254
- def _create_layout(self) -> widgets.Widget:
255
- """Create the main layout combining controls and plot."""
256
- # Set up parameter widgets with their observe callbacks
257
- for name, widget in self.parameter_widgets.items():
258
- widget.observe(lambda change, n=name: self._handle_widget_engagement(n))
259
155
 
260
156
  # Create parameter controls section
261
157
  param_box = widgets.VBox(
262
158
  [widgets.HTML("<b>Parameters</b>")]
263
- + [w.widget for w in self.parameter_widgets.values()],
159
+ + [w.widget for w in self.components.values()],
264
160
  layout=widgets.Layout(margin="10px 0px"),
265
161
  )
266
162
 
267
163
  # Combine all controls
268
- if self.config.is_horizontal:
164
+ if self.controls_position in ["left", "right"]:
269
165
  # Create layout controls section if horizontal (might include for vertical later when we have more permanent controls...)
270
166
  layout_box = widgets.VBox(
271
- [widgets.HTML("<b>Layout Controls</b>")]
272
- + list(self.layout_widgets.values()),
167
+ list(self.controls.values()),
273
168
  layout=widgets.Layout(margin="10px 0px"),
274
169
  )
275
170
 
276
171
  # Register the controls_width slider's observer
277
- if "controls_width" in self.layout_widgets:
278
- self.layout_widgets["controls_width"].observe(
172
+ if "controls_width" in self.controls:
173
+ self.controls["controls_width"].observe(
279
174
  self._handle_container_width_change, names="value"
280
175
  )
281
176
 
177
+ if "update_threshold" in self.controls:
178
+ self.controls["update_threshold"].observe(
179
+ self._handle_update_threshold_change, names="value"
180
+ )
181
+
282
182
  widgets_elements = [param_box, layout_box]
283
183
  else:
284
184
  widgets_elements = [param_box]
@@ -287,8 +187,8 @@ class NotebookDeployer:
287
187
  widgets_elements,
288
188
  layout=widgets.Layout(
289
189
  width=(
290
- f"{self.config.controls_width_percent}%"
291
- if self.config.is_horizontal
190
+ f"{self.controls_width_percent}%"
191
+ if self.controls_position in ["left", "right"]
292
192
  else "100%"
293
193
  ),
294
194
  padding="10px",
@@ -303,8 +203,8 @@ class NotebookDeployer:
303
203
  [self.plot_output],
304
204
  layout=widgets.Layout(
305
205
  width=(
306
- f"{100 - self.config.controls_width_percent}%"
307
- if self.config.is_horizontal
206
+ f"{100 - self.controls_width_percent}%"
207
+ if self.controls_position in ["left", "right"]
308
208
  else "100%"
309
209
  ),
310
210
  padding="10px",
@@ -312,29 +212,114 @@ class NotebookDeployer:
312
212
  )
313
213
 
314
214
  # Create final layout based on configuration
315
- if self.config.controls_position == "left":
316
- return widgets.HBox([self.widgets_container, self.plot_container])
317
- elif self.config.controls_position == "right":
318
- return widgets.HBox([self.plot_container, self.widgets_container])
319
- elif self.config.controls_position == "bottom":
320
- return widgets.VBox([self.plot_container, self.widgets_container])
215
+ if self.controls_position == "left":
216
+ self.layout = widgets.HBox([self.widgets_container, self.plot_container])
217
+ elif self.controls_position == "right":
218
+ self.layout = widgets.HBox([self.plot_container, self.widgets_container])
219
+ elif self.controls_position == "bottom":
220
+ self.layout = widgets.VBox([self.plot_container, self.widgets_container])
321
221
  else:
322
- return widgets.VBox([self.widgets_container, self.plot_container])
222
+ self.layout = widgets.VBox([self.widgets_container, self.plot_container])
323
223
 
324
- def deploy(self) -> None:
325
- """Deploy the interactive viewer with proper state management."""
326
- self.backend_type = get_backend_type()
224
+ self._update_status("Ready!")
327
225
 
328
- # We used to use the deploy_app context, but notebook deployment works
329
- # differently because it's asynchronous and this doesn't really behave
330
- # as intended. (e.g. with self.viewer._deploy_app() ...)
226
+ def handle_component_engagement(self, name: str) -> None:
227
+ """Handle engagement with an interactive component."""
228
+ if self._updating:
229
+ print(
230
+ "Already updating -- there's a circular dependency!"
231
+ "This is probably caused by failing to disable callbacks for a parameter."
232
+ "It's a bug --- tell the developer on github issues please."
233
+ )
234
+ return
331
235
 
332
- # Create widgets
333
- self._create_parameter_widgets()
236
+ with self._perform_update():
237
+ self._update_status(f"Updating {name}")
238
+ # Optionally suppress warnings during parameter updates
239
+ with warnings.catch_warnings():
240
+ if self.suppress_warnings:
241
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
334
242
 
335
- # Create and display layout
336
- self.layout = self._create_layout()
337
- display(self.layout)
243
+ # Get the component
244
+ component = self.components[name]
245
+ if component.is_action:
246
+ # If the component is an action, call the callback
247
+ parameter = self.viewer.parameters[name]
248
+ parameter.callback(self.viewer.state)
249
+ else:
250
+ # Otherwise, update the parameter value
251
+ self.viewer.set_parameter_value(name, component.value)
252
+
253
+ # Update any components that changed due to dependencies
254
+ self.sync_components_with_state()
255
+
256
+ # Update the plot
257
+ self.update_plot()
258
+
259
+ def sync_components_with_state(self, exclude: Optional[str] = None) -> None:
260
+ """Sync component values with viewer state."""
261
+ for name, parameter in self.viewer.parameters.items():
262
+ if name == exclude:
263
+ continue
264
+
265
+ component = self.components[name]
266
+ if not component.matches_parameter(parameter):
267
+ component.update_from_parameter(parameter)
268
+
269
+ def update_plot(self) -> None:
270
+ """Update the plot with current state."""
271
+ state = self.viewer.state
272
+
273
+ with plot_context():
274
+ figure = self.viewer.plot(state)
275
+
276
+ # Update components if plot function updated a parameter
277
+ self.sync_components_with_state()
278
+
279
+ self._display_figure(figure)
280
+
281
+ self._showing_new_figure = True
282
+
283
+ def _display_figure(self, figure: plt.Figure, store_figure: bool = True) -> None:
284
+ with self._display_lock:
285
+ # Close the last figure if it exists to keep matplotlib clean
286
+ if self._last_figure is not None:
287
+ plt.close(self._last_figure)
288
+
289
+ self.plot_output.clear_output(wait=True)
290
+ with self.plot_output:
291
+ if self.backend_type == "inline":
292
+ display(figure)
293
+
294
+ # Also required to make sure a second figure window isn't opened
295
+ plt.close(figure)
296
+
297
+ elif self.backend_type == "widget":
298
+ display(figure.canvas)
299
+
300
+ else:
301
+ raise ValueError(f"Unsupported backend type: {self.backend_type}")
302
+
303
+ if store_figure:
304
+ self._last_figure = figure
305
+
306
+ def _handle_container_width_change(self, _) -> None:
307
+ """Handle changes to container width proportions."""
308
+ width_percent = self.controls["controls_width"].value
309
+ self.controls_width_percent = width_percent
310
+
311
+ # Update container widths
312
+ self.widgets_container.layout.width = f"{width_percent}%"
313
+ self.plot_container.layout.width = f"{100 - width_percent}%"
338
314
 
339
- # Create initial plot
340
- self._update_plot()
315
+ def _handle_update_threshold_change(self, _) -> None:
316
+ """Handle changes to update threshold."""
317
+ self.update_threshold = self.controls["update_threshold"].value
318
+
319
+ def _update_status(self, status: str) -> None:
320
+ """Update the status text."""
321
+ value = "<b>Syd Controls</b> "
322
+ value += "<span style='background-color: #e0e0e0; color: #000; padding: 2px 6px; border-radius: 4px; font-size: 90%;'>"
323
+ value += f"Status: {status}"
324
+ value += "</span>"
325
+ self.controls["status"].value = value
@@ -31,7 +31,7 @@ class BaseWidget(Generic[T, W], ABC):
31
31
 
32
32
  _widget: W
33
33
  _callbacks: List[Dict[str, Union[Callable, Union[str, List[str]]]]]
34
- _is_action: bool = False
34
+ is_action: bool = False
35
35
 
36
36
  def __init__(
37
37
  self,
@@ -465,7 +465,7 @@ class UnboundedFloatWidget(BaseWidget[UnboundedFloatParameter, widgets.FloatText
465
465
  class ButtonWidget(BaseWidget[ButtonAction, widgets.Button]):
466
466
  """Widget for button parameters."""
467
467
 
468
- _is_action: bool = True
468
+ is_action: bool = True
469
469
 
470
470
  def _create_widget(
471
471
  self,