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.
- syd/__init__.py +7 -11
- syd/flask_deployment/__init__.py +1 -0
- syd/flask_deployment/components.py +510 -0
- syd/flask_deployment/deployer.py +302 -0
- syd/flask_deployment/static/css/viewer.css +82 -0
- syd/flask_deployment/static/js/viewer.js +174 -0
- syd/flask_deployment/templates/base.html +29 -0
- syd/flask_deployment/templates/viewer.html +51 -0
- syd/flask_deployment/testing_principles.md +300 -0
- syd/notebook_deployment/__init__.py +1 -0
- syd/{notebook_deploy/deployer.py → notebook_deployment/_ipympl_deployer.py} +57 -36
- syd/notebook_deployment/deployer.py +330 -0
- syd/{notebook_deploy → notebook_deployment}/widgets.py +192 -112
- syd/parameters.py +390 -194
- syd/plotly_deployment/__init__.py +1 -0
- syd/plotly_deployment/components.py +531 -0
- syd/plotly_deployment/deployer.py +376 -0
- syd/{interactive_viewer.py → viewer.py} +309 -176
- syd-0.1.7.dist-info/METADATA +120 -0
- syd-0.1.7.dist-info/RECORD +22 -0
- syd/notebook_deploy/__init__.py +0 -1
- syd-0.1.5.dist-info/METADATA +0 -41
- syd-0.1.5.dist-info/RECORD +0 -10
- {syd-0.1.5.dist-info → syd-0.1.7.dist-info}/WHEEL +0 -0
- {syd-0.1.5.dist-info → syd-0.1.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|