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,376 @@
1
+ from typing import Any, Dict, List, Optional, Literal
2
+ import uuid
3
+ import warnings
4
+ from dataclasses import dataclass
5
+ from functools import wraps
6
+ from time import time
7
+ from contextlib import contextmanager
8
+ import socket
9
+
10
+ from dash import Dash, html, Input, Output, callback, dcc, ALL, callback_context
11
+ import plotly.graph_objects as go
12
+ from plotly.tools import mpl_to_plotly
13
+ import matplotlib as mpl
14
+ from matplotlib import pyplot as plt
15
+ from flask import Flask
16
+
17
+
18
+ from ..parameters import ButtonAction, ParameterUpdateWarning
19
+ from ..viewer import Viewer
20
+ from .components import BaseComponent, create_component
21
+
22
+
23
+ def debounce(wait_time):
24
+ """
25
+ Decorator to prevent a function from being called more than once every wait_time seconds.
26
+ """
27
+
28
+ def decorator(fn):
29
+ last_called = [0.0] # Using list to maintain state in closure
30
+
31
+ @wraps(fn)
32
+ def debounced(*args, **kwargs):
33
+ current_time = time()
34
+ if current_time - last_called[0] >= wait_time:
35
+ fn(*args, **kwargs)
36
+ last_called[0] = current_time
37
+
38
+ return debounced
39
+
40
+ return decorator
41
+
42
+
43
+ @dataclass
44
+ class LayoutConfig:
45
+ """Configuration for the viewer layout."""
46
+
47
+ controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
48
+ controls_width_percent: int = 30
49
+
50
+ def __post_init__(self):
51
+ valid_positions = ["left", "top", "right", "bottom"]
52
+ if self.controls_position not in valid_positions:
53
+ raise ValueError(
54
+ f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
55
+ )
56
+
57
+ @property
58
+ def is_horizontal(self) -> bool:
59
+ return self.controls_position in ["left", "right"]
60
+
61
+
62
+ @contextmanager
63
+ def _plot_context():
64
+ """Context manager to temporarily switch matplotlib backend."""
65
+ original_backend = mpl.get_backend()
66
+ plt.switch_backend("Agg") # Switch to non-interactive backend
67
+ try:
68
+ yield
69
+ finally:
70
+ plt.switch_backend(original_backend)
71
+
72
+
73
+ class PlotlyDeployer:
74
+ """
75
+ A deployment system for Viewer using Plotly/Dash.
76
+ Built around the parameter component system for clean separation of concerns.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ viewer: Viewer,
82
+ controls_position: str = "left",
83
+ controls_width_percent: int = 30,
84
+ component_width: str = "300px",
85
+ component_margin: str = "10px",
86
+ label_width: str = "150px",
87
+ continuous: bool = False,
88
+ suppress_warnings: bool = False,
89
+ title: str = "Syd Plotly App",
90
+ server: Optional[Flask] = None,
91
+ ):
92
+ """
93
+ Initialize the Plotly deployer.
94
+
95
+ Args:
96
+ viewer: The viewer instance to deploy
97
+ controls_position: Position of controls ('left', 'top', 'right', 'bottom')
98
+ controls_width_percent: Width of controls as percentage when horizontal
99
+ component_width: Default width for components
100
+ component_margin: Default margin for components
101
+ label_width: Default width for labels
102
+ continuous: Whether to update continuously during user interaction
103
+ suppress_warnings: Whether to suppress parameter update warnings
104
+ title: Title of the Dash application
105
+ server: Optional Flask server to use
106
+ """
107
+ self.viewer = viewer
108
+ self.config = LayoutConfig(
109
+ controls_position=controls_position,
110
+ controls_width_percent=controls_width_percent,
111
+ )
112
+ self.continuous = continuous
113
+ self.suppress_warnings = suppress_warnings
114
+
115
+ # Initialize Dash app
116
+ self.app = Dash(__name__, server=server or True, title=title)
117
+
118
+ # Component styling
119
+ self._component_width = component_width
120
+ self._component_margin = component_margin
121
+ self._label_width = label_width
122
+
123
+ # Initialize containers
124
+ self._components: Dict[str, BaseComponent] = {}
125
+ self._layout_ready = False
126
+ self._callback_registered = False
127
+ self._updating = False # Flag to prevent circular updates
128
+
129
+ def _create_parameter_components(self) -> None:
130
+ """Create component instances for all parameters."""
131
+ for name, param in self.viewer.parameters.items():
132
+ component_id = f"{name}-{str(uuid.uuid4())[:8]}"
133
+ component = create_component(
134
+ param,
135
+ component_id,
136
+ width=self._component_width,
137
+ margin=self._component_margin,
138
+ label_width=self._label_width,
139
+ )
140
+ self._components[name] = component
141
+
142
+ @debounce(0.2)
143
+ def _handle_parameter_update(self, name: str, value: Any) -> None:
144
+ """Handle updates to parameter values."""
145
+ if self._updating:
146
+ print(
147
+ "Already updating -- there's a circular dependency! "
148
+ "This is probably caused by failing to disable callbacks for a parameter. "
149
+ "It's a bug --- tell the developer on github issues please."
150
+ )
151
+ return
152
+
153
+ try:
154
+ self._updating = True
155
+
156
+ # Optionally suppress warnings during parameter updates
157
+ with warnings.catch_warnings():
158
+ if self.suppress_warnings:
159
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
160
+
161
+ component = self._components[name]
162
+ if component._is_action:
163
+ parameter = self.viewer.parameters[name]
164
+ parameter.callback(self.viewer.state)
165
+ else:
166
+ self.viewer.set_parameter_value(name, value)
167
+
168
+ # Update any components that changed due to dependencies
169
+ self._sync_components_with_state(exclude=name)
170
+
171
+ finally:
172
+ self._updating = False
173
+
174
+ def _sync_components_with_state(self, exclude: Optional[str] = None) -> None:
175
+ """Sync component values with viewer state."""
176
+ for name, parameter in self.viewer.parameters.items():
177
+ if name == exclude:
178
+ continue
179
+
180
+ component = self._components[name]
181
+ if not component.matches_parameter(parameter):
182
+ component.update_from_parameter(parameter)
183
+
184
+ def create_layout(self) -> html.Div:
185
+ """Create the layout for the Dash application."""
186
+ # Create parameter controls section
187
+ param_components = [comp.component for comp in self._components.values()]
188
+ controls = html.Div(
189
+ [html.H3("Parameters", style={"marginBottom": "10px"})] + param_components,
190
+ style={
191
+ "width": (
192
+ f"{self.config.controls_width_percent}%"
193
+ if self.config.is_horizontal
194
+ else "100%"
195
+ ),
196
+ "padding": "20px",
197
+ "borderRight": (
198
+ "1px solid #e5e7eb"
199
+ if self.config.controls_position == "left"
200
+ else None
201
+ ),
202
+ "borderLeft": (
203
+ "1px solid #e5e7eb"
204
+ if self.config.controls_position == "right"
205
+ else None
206
+ ),
207
+ },
208
+ )
209
+
210
+ # Create plot container
211
+ plot_container = html.Div(
212
+ id="plot-container",
213
+ style={
214
+ "width": (
215
+ f"{100 - self.config.controls_width_percent}%"
216
+ if self.config.is_horizontal
217
+ else "100%"
218
+ ),
219
+ "padding": "20px",
220
+ },
221
+ )
222
+
223
+ # Add hidden div for state synchronization
224
+ state_sync = html.Div(
225
+ id={"type": "state_sync", "id": "sync"}, style={"display": "none"}
226
+ )
227
+
228
+ # Create final layout based on configuration
229
+ if self.config.controls_position == "left":
230
+ container = html.Div(
231
+ [controls, plot_container, state_sync], style={"display": "flex"}
232
+ )
233
+ elif self.config.controls_position == "right":
234
+ container = html.Div(
235
+ [plot_container, controls, state_sync], style={"display": "flex"}
236
+ )
237
+ elif self.config.controls_position == "top":
238
+ container = html.Div([controls, plot_container, state_sync])
239
+ else: # bottom
240
+ container = html.Div([plot_container, controls, state_sync])
241
+
242
+ return html.Div(
243
+ container,
244
+ style={
245
+ "maxWidth": "1200px",
246
+ "margin": "auto",
247
+ "fontFamily": "sans-serif",
248
+ },
249
+ )
250
+
251
+ def setup_callbacks(self) -> None:
252
+ """Set up callbacks for all components."""
253
+ if self._callback_registered:
254
+ return
255
+
256
+ # Create callback for each parameter
257
+ for name, component in self._components.items():
258
+ parameter = self.viewer.parameters[name]
259
+
260
+ if not isinstance(parameter, ButtonAction):
261
+
262
+ @callback(
263
+ Output(component.id, "value"),
264
+ [
265
+ Input(component.id, "value"),
266
+ Input({"type": "state_sync", "id": ALL}, "data"),
267
+ ],
268
+ prevent_initial_call=True,
269
+ )
270
+ def update_parameter(value: Any, sync_data: List[Dict], n=name) -> Any:
271
+ # If this is a state sync update, get the new value from viewer
272
+ triggered = [p["prop_id"] for p in callback_context.triggered]
273
+ if any(p.startswith("{") for p in triggered):
274
+ return self.viewer.parameters[n].value
275
+
276
+ # Otherwise handle parameter update normally
277
+ self._handle_parameter_update(n, value)
278
+ return value
279
+
280
+ else:
281
+
282
+ @callback(
283
+ Output(component.id, "n_clicks"),
284
+ Input(component.id, "n_clicks"),
285
+ prevent_initial_call=True,
286
+ )
287
+ def handle_click(n_clicks: int, n=name) -> int:
288
+ if n_clicks is not None and n_clicks > 0:
289
+ self._handle_parameter_update(n, None)
290
+ return 0 # Reset clicks
291
+
292
+ # Create callback for plot updates
293
+ @callback(
294
+ [
295
+ Output("plot-container", "children"),
296
+ Output({"type": "state_sync", "id": "sync"}, "data"),
297
+ ],
298
+ [Input(comp.id, "value") for comp in self._components.values()],
299
+ )
300
+ def update_plot(*values):
301
+ with _plot_context():
302
+ fig = self.viewer.plot(self.viewer.state)
303
+
304
+ # If it's already a Plotly figure, use it directly
305
+ if isinstance(fig, (go.Figure, dict)):
306
+ plotly_fig = fig
307
+ else:
308
+ # Convert matplotlib figure to plotly
309
+ plotly_fig = mpl_to_plotly(fig)
310
+ plt.close(fig) # Clean up the matplotlib figure
311
+
312
+ return dcc.Graph(figure=plotly_fig), {"timestamp": time()}
313
+
314
+ self._callback_registered = True
315
+
316
+ def find_available_port(self, start_port=8050, max_attempts=100):
317
+ """Find an available port starting from start_port"""
318
+ for port in range(start_port, start_port + max_attempts):
319
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
320
+ try:
321
+ s.bind(("", port))
322
+ return port
323
+ except socket.error:
324
+ continue
325
+ raise RuntimeError(
326
+ f"No available ports found between {start_port} and {start_port + max_attempts}"
327
+ )
328
+
329
+ def deploy(
330
+ self,
331
+ mode: Literal["notebook", "server"] = "notebook",
332
+ host: str = "127.0.0.1",
333
+ port: int = 8050,
334
+ debug: bool = False,
335
+ max_port_attempts: int = 100,
336
+ ) -> None:
337
+ """
338
+ Deploy the Dash application.
339
+
340
+ Args:
341
+ mode: How to deploy the app - 'notebook' for Jupyter integration or 'server' for standalone web server
342
+ host: Host address to run the server on (only used in server mode)
343
+ port: Starting port to try running the server on (only used in server mode)
344
+ debug: Whether to run in debug mode
345
+ max_port_attempts: Maximum number of ports to try if initial port is taken
346
+ """
347
+ with self.viewer._deploy_app():
348
+ # Create components
349
+ self._create_parameter_components()
350
+
351
+ # Set up layout
352
+ if not self._layout_ready:
353
+ self.app.layout = self.create_layout()
354
+ self._layout_ready = True
355
+
356
+ # Set up callbacks
357
+ if not self._callback_registered:
358
+ self.setup_callbacks()
359
+
360
+ # Find available port if in server mode
361
+ if mode == "server":
362
+ port = self.find_available_port(
363
+ start_port=port, max_attempts=max_port_attempts
364
+ )
365
+
366
+ # Handle warnings based on debug mode
367
+ with warnings.catch_warnings():
368
+ if not debug:
369
+ warnings.filterwarnings("ignore", category=UserWarning)
370
+
371
+ if mode == "notebook":
372
+ # Configure JupyterDash for notebook display
373
+ self.app.run_server(mode="inline", port=port, debug=debug)
374
+ else: # server mode
375
+ # Run as a standalone server
376
+ self.app.run(jupyter_mode="tab", host=host, port=port, debug=debug)