syd 0.1.7__py3-none-any.whl → 0.2.0__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 +1 -1
- syd/flask_deployment/__init__.py +7 -1
- syd/flask_deployment/deployer.py +548 -209
- syd/flask_deployment/static/__init__.py +1 -0
- syd/flask_deployment/static/css/styles.css +246 -0
- syd/flask_deployment/static/js/viewer.js +710 -140
- syd/flask_deployment/templates/__init__.py +1 -0
- syd/flask_deployment/templates/index.html +34 -0
- syd/flask_deployment/testing_principles.md +4 -4
- syd/notebook_deployment/deployer.py +15 -5
- syd/notebook_deployment/widgets.py +76 -58
- syd/parameters.py +239 -250
- syd/support.py +168 -0
- syd/viewer.py +305 -346
- syd-0.2.0.dist-info/METADATA +126 -0
- syd-0.2.0.dist-info/RECORD +19 -0
- syd/flask_deployment/components.py +0 -510
- syd/flask_deployment/static/css/viewer.css +0 -82
- syd/flask_deployment/templates/base.html +0 -29
- syd/flask_deployment/templates/viewer.html +0 -51
- syd/notebook_deployment/_ipympl_deployer.py +0 -258
- syd/plotly_deployment/__init__.py +0 -1
- syd/plotly_deployment/components.py +0 -531
- syd/plotly_deployment/deployer.py +0 -376
- syd-0.1.7.dist-info/METADATA +0 -120
- syd-0.1.7.dist-info/RECORD +0 -22
- {syd-0.1.7.dist-info → syd-0.2.0.dist-info}/WHEEL +0 -0
- {syd-0.1.7.dist-info → syd-0.2.0.dist-info}/licenses/LICENSE +0 -0
syd/flask_deployment/deployer.py
CHANGED
|
@@ -1,62 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
"""
|
|
2
|
+
Flask deployer for Syd Viewer objects.
|
|
3
|
+
|
|
4
|
+
This module provides tools to deploy Syd viewers as Flask web applications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Dict, Any, Optional, List, Union, Callable, Type
|
|
4
11
|
from dataclasses import dataclass
|
|
5
12
|
from contextlib import contextmanager
|
|
6
|
-
from time import time
|
|
7
|
-
import base64
|
|
8
|
-
from io import BytesIO
|
|
9
|
-
import threading
|
|
10
|
-
import webbrowser
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
from flask import Flask, render_template, jsonify, request, Response
|
|
14
13
|
import matplotlib as mpl
|
|
15
14
|
import matplotlib.pyplot as plt
|
|
15
|
+
from matplotlib.figure import Figure
|
|
16
|
+
import io
|
|
17
|
+
import numpy as np
|
|
18
|
+
import time
|
|
19
|
+
from functools import wraps
|
|
20
|
+
import webbrowser
|
|
21
|
+
import threading
|
|
22
|
+
import socket
|
|
16
23
|
|
|
17
|
-
from ..parameters import ParameterUpdateWarning
|
|
18
|
-
from ..viewer import Viewer
|
|
19
|
-
from .components import BaseComponent, ComponentStyle, create_component
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@contextmanager
|
|
23
|
-
def _plot_context():
|
|
24
|
-
plt.ioff()
|
|
25
|
-
try:
|
|
26
|
-
yield
|
|
27
|
-
finally:
|
|
28
|
-
plt.ion()
|
|
29
24
|
|
|
25
|
+
from flask import (
|
|
26
|
+
Flask,
|
|
27
|
+
send_file,
|
|
28
|
+
request,
|
|
29
|
+
make_response,
|
|
30
|
+
jsonify,
|
|
31
|
+
render_template,
|
|
32
|
+
url_for,
|
|
33
|
+
)
|
|
34
|
+
from werkzeug.serving import run_simple
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
from ..viewer import Viewer
|
|
37
|
+
from ..parameters import (
|
|
38
|
+
Parameter,
|
|
39
|
+
TextParameter,
|
|
40
|
+
BooleanParameter,
|
|
41
|
+
SelectionParameter,
|
|
42
|
+
MultipleSelectionParameter,
|
|
43
|
+
IntegerParameter,
|
|
44
|
+
FloatParameter,
|
|
45
|
+
IntegerRangeParameter,
|
|
46
|
+
FloatRangeParameter,
|
|
47
|
+
UnboundedIntegerParameter,
|
|
48
|
+
UnboundedFloatParameter,
|
|
49
|
+
ButtonAction,
|
|
50
|
+
ParameterType,
|
|
51
|
+
ActionType,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
mpl.use("Agg")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def debounce(wait_time=0.1):
|
|
47
58
|
"""
|
|
48
|
-
Decorator to
|
|
59
|
+
Decorator to debounce function calls.
|
|
60
|
+
Prevents a function from being called too frequently.
|
|
49
61
|
"""
|
|
50
62
|
|
|
51
63
|
def decorator(fn):
|
|
52
|
-
|
|
64
|
+
last_call_time = [0]
|
|
53
65
|
|
|
54
66
|
@wraps(fn)
|
|
55
67
|
def debounced(*args, **kwargs):
|
|
56
|
-
current_time = time()
|
|
57
|
-
if current_time -
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
current_time = time.time()
|
|
69
|
+
if current_time - last_call_time[0] > wait_time:
|
|
70
|
+
last_call_time[0] = current_time
|
|
71
|
+
return fn(*args, **kwargs)
|
|
72
|
+
return None
|
|
60
73
|
|
|
61
74
|
return debounced
|
|
62
75
|
|
|
@@ -64,15 +77,13 @@ def debounce(wait_time):
|
|
|
64
77
|
|
|
65
78
|
|
|
66
79
|
@dataclass
|
|
67
|
-
class
|
|
68
|
-
"""Configuration for the viewer layout."""
|
|
80
|
+
class FlaskLayoutConfig:
|
|
81
|
+
"""Configuration for the Flask viewer layout."""
|
|
69
82
|
|
|
70
83
|
controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
|
|
71
84
|
figure_width: float = 8.0
|
|
72
85
|
figure_height: float = 6.0
|
|
73
86
|
controls_width_percent: int = 30
|
|
74
|
-
template_path: Optional[str] = None
|
|
75
|
-
static_path: Optional[str] = None
|
|
76
87
|
|
|
77
88
|
def __post_init__(self):
|
|
78
89
|
valid_positions = ["left", "top", "right", "bottom"]
|
|
@@ -88,215 +99,543 @@ class LayoutConfig:
|
|
|
88
99
|
|
|
89
100
|
class FlaskDeployer:
|
|
90
101
|
"""
|
|
91
|
-
A deployment system for Viewer
|
|
92
|
-
|
|
102
|
+
A deployment system for Viewer as a Flask web application.
|
|
103
|
+
Creates a Flask app with routes for the UI, data API, and plot generation.
|
|
93
104
|
"""
|
|
94
105
|
|
|
95
106
|
def __init__(
|
|
96
107
|
self,
|
|
97
108
|
viewer: Viewer,
|
|
98
109
|
controls_position: str = "left",
|
|
110
|
+
fig_dpi: int = 300,
|
|
99
111
|
figure_width: float = 8.0,
|
|
100
112
|
figure_height: float = 6.0,
|
|
101
113
|
controls_width_percent: int = 30,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
port: int = 5000,
|
|
106
|
-
template_path: Optional[str] = None,
|
|
107
|
-
static_path: Optional[str] = None,
|
|
114
|
+
static_folder: Optional[str] = None,
|
|
115
|
+
template_folder: Optional[str] = None,
|
|
116
|
+
debug: bool = False,
|
|
108
117
|
):
|
|
118
|
+
"""
|
|
119
|
+
Initialize the Flask deployer.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
viewer : Viewer
|
|
124
|
+
The viewer to deploy
|
|
125
|
+
controls_position : str, optional
|
|
126
|
+
Position of the controls ('left', 'top', 'right', 'bottom')
|
|
127
|
+
fig_dpi : int, optional
|
|
128
|
+
DPI of the figure - higher is better quality but takes longer to generate
|
|
129
|
+
figure_width : float, optional
|
|
130
|
+
Width of the figure in inches
|
|
131
|
+
figure_height : float, optional
|
|
132
|
+
Height of the figure in inches
|
|
133
|
+
controls_width_percent : int, optional
|
|
134
|
+
Width of the controls as a percentage of the total width
|
|
135
|
+
static_folder : str, optional
|
|
136
|
+
Custom path to static files
|
|
137
|
+
template_folder : str, optional
|
|
138
|
+
Custom path to template files
|
|
139
|
+
debug : bool, optional
|
|
140
|
+
Whether to enable debug mode
|
|
141
|
+
"""
|
|
109
142
|
self.viewer = viewer
|
|
110
|
-
self.config =
|
|
143
|
+
self.config = FlaskLayoutConfig(
|
|
111
144
|
controls_position=controls_position,
|
|
112
145
|
figure_width=figure_width,
|
|
113
146
|
figure_height=figure_height,
|
|
114
147
|
controls_width_percent=controls_width_percent,
|
|
115
|
-
template_path=template_path,
|
|
116
|
-
static_path=static_path,
|
|
117
148
|
)
|
|
118
|
-
self.
|
|
119
|
-
self.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
self.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
self.fig_dpi = fig_dpi
|
|
150
|
+
self.debug = debug
|
|
151
|
+
|
|
152
|
+
# Use default folders if not specified
|
|
153
|
+
package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
154
|
+
self.static_folder = static_folder or os.path.join(package_dir, "static")
|
|
155
|
+
self.template_folder = template_folder or os.path.join(package_dir, "templates")
|
|
156
|
+
|
|
157
|
+
# State tracking
|
|
158
|
+
self.in_callbacks = False
|
|
159
|
+
|
|
160
|
+
# Create Flask app
|
|
161
|
+
self.app = self.create_app()
|
|
162
|
+
|
|
163
|
+
def create_app(self) -> Flask:
|
|
164
|
+
"""
|
|
165
|
+
Create and configure the Flask application.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
Flask
|
|
170
|
+
The configured Flask application
|
|
171
|
+
"""
|
|
172
|
+
app = Flask(
|
|
173
|
+
__name__,
|
|
174
|
+
static_folder=self.static_folder,
|
|
175
|
+
template_folder=self.template_folder,
|
|
138
176
|
)
|
|
139
|
-
static_path = self.config.static_path or str(Path(__file__).parent / "static")
|
|
140
177
|
|
|
141
|
-
|
|
178
|
+
# Configure logging
|
|
179
|
+
if not self.debug:
|
|
180
|
+
log = logging.getLogger("werkzeug")
|
|
181
|
+
log.setLevel(logging.ERROR)
|
|
182
|
+
|
|
183
|
+
# Define routes
|
|
142
184
|
|
|
143
|
-
# Register routes
|
|
144
185
|
@app.route("/")
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if initial_plot is not None:
|
|
149
|
-
buffer = BytesIO()
|
|
150
|
-
initial_plot.savefig(buffer, format="png", bbox_inches="tight")
|
|
151
|
-
buffer.seek(0)
|
|
152
|
-
initial_plot_data = base64.b64encode(buffer.getvalue()).decode()
|
|
153
|
-
else:
|
|
154
|
-
initial_plot_data = ""
|
|
155
|
-
|
|
156
|
-
return render_template(
|
|
157
|
-
"viewer.html",
|
|
158
|
-
components=self._get_component_html(),
|
|
159
|
-
controls_position=self.config.controls_position,
|
|
160
|
-
controls_width=self.config.controls_width_percent,
|
|
161
|
-
figure_width=self.config.figure_width,
|
|
162
|
-
figure_height=self.config.figure_height,
|
|
163
|
-
continuous=self.continuous,
|
|
164
|
-
initial_plot=initial_plot_data,
|
|
165
|
-
)
|
|
186
|
+
def home():
|
|
187
|
+
"""Render the main page."""
|
|
188
|
+
return render_template("index.html", config=self.config)
|
|
166
189
|
|
|
167
|
-
@app.route("/
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
190
|
+
@app.route("/init-data")
|
|
191
|
+
def init_data():
|
|
192
|
+
"""Provide initial parameter information."""
|
|
193
|
+
param_info = {}
|
|
171
194
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
self._handle_parameter_update(name, value)
|
|
175
|
-
return jsonify({"success": True})
|
|
176
|
-
except Exception as e:
|
|
177
|
-
return jsonify({"error": str(e)}), 400
|
|
195
|
+
for name, param in self.viewer.parameters.items():
|
|
196
|
+
param_info[name] = self._get_parameter_info(param)
|
|
178
197
|
|
|
179
|
-
|
|
180
|
-
def get_state():
|
|
181
|
-
return jsonify(self.viewer.state)
|
|
198
|
+
return jsonify({"params": param_info})
|
|
182
199
|
|
|
183
200
|
@app.route("/plot")
|
|
184
|
-
def
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
201
|
+
def plot():
|
|
202
|
+
"""Generate and return a plot based on the current state."""
|
|
203
|
+
try:
|
|
204
|
+
# Get parameters from request
|
|
205
|
+
state = self._parse_request_args(request.args)
|
|
189
206
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
image_base64 = base64.b64encode(buffer.getvalue()).decode()
|
|
207
|
+
# Update viewer state
|
|
208
|
+
for name, value in state.items():
|
|
209
|
+
if name in self.viewer.parameters:
|
|
210
|
+
self.viewer.parameters[name].value = value
|
|
195
211
|
|
|
196
|
-
|
|
212
|
+
# Get the plot from the viewer
|
|
213
|
+
with _plot_context():
|
|
214
|
+
fig = self.viewer.plot(self.viewer.state)
|
|
197
215
|
|
|
198
|
-
|
|
216
|
+
# Save the plot to a buffer
|
|
217
|
+
buf = io.BytesIO()
|
|
218
|
+
fig.savefig(buf, format="png", bbox_inches="tight", dpi=self.fig_dpi)
|
|
219
|
+
buf.seek(0)
|
|
220
|
+
plt.close(fig)
|
|
199
221
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
margin="10px 0",
|
|
205
|
-
description_width="auto",
|
|
206
|
-
)
|
|
222
|
+
# Return the image
|
|
223
|
+
response = make_response(send_file(buf, mimetype="image/png"))
|
|
224
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
225
|
+
return response
|
|
207
226
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
continuous=self.continuous,
|
|
212
|
-
style=style,
|
|
213
|
-
)
|
|
214
|
-
self.parameter_components[name] = component
|
|
227
|
+
except Exception as e:
|
|
228
|
+
app.logger.error(f"Error: {str(e)}")
|
|
229
|
+
return f"Error generating plot: {str(e)}", 500
|
|
215
230
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
231
|
+
@app.route("/update-param", methods=["POST"])
|
|
232
|
+
def update_param():
|
|
233
|
+
"""Update a parameter and run its callbacks."""
|
|
234
|
+
try:
|
|
235
|
+
data = request.get_json()
|
|
236
|
+
name = data.get("name")
|
|
237
|
+
value = data.get("value")
|
|
238
|
+
is_action = data.get("action", False)
|
|
239
|
+
|
|
240
|
+
if name not in self.viewer.parameters:
|
|
241
|
+
return jsonify({"error": f"Parameter {name} not found"}), 404
|
|
242
|
+
|
|
243
|
+
# Handle the parameter update or action
|
|
244
|
+
if is_action:
|
|
245
|
+
# For button actions, run the callback
|
|
246
|
+
self._handle_action(name)
|
|
247
|
+
else:
|
|
248
|
+
# For normal parameters, update value and run callbacks
|
|
249
|
+
parsed_value = self._parse_parameter_value(name, value)
|
|
250
|
+
self._handle_parameter_update(name, parsed_value)
|
|
251
|
+
|
|
252
|
+
# Return the updated state
|
|
253
|
+
return jsonify({"success": True, "state": self.viewer.state})
|
|
221
254
|
|
|
222
|
-
|
|
255
|
+
except Exception as e:
|
|
256
|
+
app.logger.error(f"Error updating parameter: {str(e)}")
|
|
257
|
+
return jsonify({"error": str(e)}), 500
|
|
258
|
+
|
|
259
|
+
return app
|
|
260
|
+
|
|
261
|
+
@debounce(0.1)
|
|
223
262
|
def _handle_parameter_update(self, name: str, value: Any) -> None:
|
|
224
|
-
"""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
263
|
+
"""
|
|
264
|
+
Handle a parameter update, including running callbacks.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
name : str
|
|
269
|
+
The name of the parameter to update
|
|
270
|
+
value : Any
|
|
271
|
+
The new value for the parameter
|
|
272
|
+
"""
|
|
273
|
+
# Prevent recursive callback cycles
|
|
274
|
+
if self.in_callbacks:
|
|
231
275
|
return
|
|
232
276
|
|
|
233
277
|
try:
|
|
234
|
-
|
|
278
|
+
# Update the parameter value
|
|
279
|
+
self.viewer.parameters[name].value = value
|
|
235
280
|
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
if self.suppress_warnings:
|
|
239
|
-
warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
|
|
281
|
+
# Run callbacks for this parameter
|
|
282
|
+
self.in_callbacks = True
|
|
240
283
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
284
|
+
# The viewer's perform_callbacks method will automatically
|
|
285
|
+
# pass the state dictionary to the callbacks
|
|
286
|
+
self.viewer.perform_callbacks(name)
|
|
287
|
+
finally:
|
|
288
|
+
self.in_callbacks = False
|
|
289
|
+
|
|
290
|
+
def _handle_action(self, name: str) -> None:
|
|
291
|
+
"""
|
|
292
|
+
Handle a button action by executing its callback.
|
|
247
293
|
|
|
248
|
-
|
|
249
|
-
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
name : str
|
|
297
|
+
The name of the button parameter
|
|
298
|
+
"""
|
|
299
|
+
# Prevent recursive callback cycles
|
|
300
|
+
if self.in_callbacks:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Execute the button callback
|
|
305
|
+
param = self.viewer.parameters[name]
|
|
306
|
+
if isinstance(param, ButtonAction) and param.callback:
|
|
307
|
+
self.in_callbacks = True
|
|
250
308
|
|
|
309
|
+
# Pass the current state to the callback
|
|
310
|
+
param.callback(self.viewer.state)
|
|
251
311
|
finally:
|
|
252
|
-
self.
|
|
312
|
+
self.in_callbacks = False
|
|
253
313
|
|
|
254
|
-
def
|
|
255
|
-
"""
|
|
256
|
-
|
|
257
|
-
if name == exclude:
|
|
258
|
-
continue
|
|
314
|
+
def _get_parameter_info(self, param: Parameter) -> Dict[str, Any]:
|
|
315
|
+
"""
|
|
316
|
+
Convert a Parameter object to a dictionary of information for the frontend.
|
|
259
317
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
param : Parameter
|
|
321
|
+
The parameter to convert
|
|
263
322
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
Dict[str, Any]
|
|
326
|
+
Parameter information for the frontend
|
|
327
|
+
"""
|
|
328
|
+
if isinstance(param, TextParameter):
|
|
329
|
+
return {"type": "text", "value": param.value}
|
|
330
|
+
elif isinstance(param, BooleanParameter):
|
|
331
|
+
return {"type": "boolean", "value": param.value}
|
|
332
|
+
elif isinstance(param, SelectionParameter):
|
|
333
|
+
return {"type": "selection", "value": param.value, "options": param.options}
|
|
334
|
+
elif isinstance(param, MultipleSelectionParameter):
|
|
335
|
+
return {
|
|
336
|
+
"type": "multiple-selection",
|
|
337
|
+
"value": param.value,
|
|
338
|
+
"options": param.options,
|
|
339
|
+
}
|
|
340
|
+
elif isinstance(param, IntegerParameter):
|
|
341
|
+
return {
|
|
342
|
+
"type": "integer",
|
|
343
|
+
"value": param.value,
|
|
344
|
+
"name": param.name,
|
|
345
|
+
"min": param.min,
|
|
346
|
+
"max": param.max,
|
|
347
|
+
}
|
|
348
|
+
elif isinstance(param, FloatParameter):
|
|
349
|
+
return {
|
|
350
|
+
"type": "float",
|
|
351
|
+
"value": param.value,
|
|
352
|
+
"name": param.name,
|
|
353
|
+
"min": param.min,
|
|
354
|
+
"max": param.max,
|
|
355
|
+
"step": param.step,
|
|
356
|
+
}
|
|
357
|
+
elif isinstance(param, IntegerRangeParameter):
|
|
358
|
+
return {
|
|
359
|
+
"type": "integer-range",
|
|
360
|
+
"value": param.value,
|
|
361
|
+
"name": param.name,
|
|
362
|
+
"min": param.min,
|
|
363
|
+
"max": param.max,
|
|
364
|
+
}
|
|
365
|
+
elif isinstance(param, FloatRangeParameter):
|
|
366
|
+
return {
|
|
367
|
+
"type": "float-range",
|
|
368
|
+
"value": param.value,
|
|
369
|
+
"name": param.name,
|
|
370
|
+
"min": param.min,
|
|
371
|
+
"max": param.max,
|
|
372
|
+
"step": param.step,
|
|
373
|
+
}
|
|
374
|
+
elif isinstance(param, UnboundedIntegerParameter):
|
|
375
|
+
return {"type": "unbounded-integer", "value": param.value}
|
|
376
|
+
elif isinstance(param, UnboundedFloatParameter):
|
|
377
|
+
return {"type": "unbounded-float", "value": param.value, "step": param.step}
|
|
378
|
+
elif isinstance(param, ButtonAction):
|
|
379
|
+
return {"type": "button", "label": param.label, "is_action": True}
|
|
380
|
+
else:
|
|
381
|
+
return {"type": "unknown", "value": str(param.value)}
|
|
382
|
+
|
|
383
|
+
def _parse_request_args(self, args) -> Dict[str, Any]:
|
|
384
|
+
"""
|
|
385
|
+
Parse request arguments into appropriate Python types based on parameter types.
|
|
269
386
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
387
|
+
Parameters
|
|
388
|
+
----------
|
|
389
|
+
args : MultiDict
|
|
390
|
+
Request arguments
|
|
273
391
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
392
|
+
Returns
|
|
393
|
+
-------
|
|
394
|
+
Dict[str, Any]
|
|
395
|
+
Parsed parameters
|
|
396
|
+
"""
|
|
397
|
+
result = {}
|
|
279
398
|
|
|
280
|
-
|
|
399
|
+
for name, value in args.items():
|
|
400
|
+
# Skip if parameter doesn't exist
|
|
401
|
+
if name not in self.viewer.parameters:
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
result[name] = self._parse_parameter_value(name, value)
|
|
405
|
+
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
def _parse_parameter_value(self, name: str, value: Any) -> Any:
|
|
281
409
|
"""
|
|
282
|
-
|
|
410
|
+
Parse a parameter value based on its type.
|
|
283
411
|
|
|
284
412
|
Parameters
|
|
285
413
|
----------
|
|
286
|
-
|
|
287
|
-
|
|
414
|
+
name : str
|
|
415
|
+
Parameter name
|
|
416
|
+
value : Any
|
|
417
|
+
Raw value
|
|
418
|
+
|
|
419
|
+
Returns
|
|
420
|
+
-------
|
|
421
|
+
Any
|
|
422
|
+
Parsed value
|
|
288
423
|
"""
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
424
|
+
param = self.viewer.parameters[name]
|
|
425
|
+
|
|
426
|
+
if isinstance(param, TextParameter):
|
|
427
|
+
return str(value)
|
|
428
|
+
elif isinstance(param, BooleanParameter):
|
|
429
|
+
return value.lower() == "true" if isinstance(value, str) else bool(value)
|
|
430
|
+
elif isinstance(param, (IntegerParameter, UnboundedIntegerParameter)):
|
|
431
|
+
return int(value)
|
|
432
|
+
elif isinstance(param, (FloatParameter, UnboundedFloatParameter)):
|
|
433
|
+
return float(value)
|
|
434
|
+
elif isinstance(param, (IntegerRangeParameter, FloatRangeParameter)):
|
|
435
|
+
# Parse JSON array for range parameters
|
|
436
|
+
if isinstance(value, str):
|
|
437
|
+
try:
|
|
438
|
+
return json.loads(value)
|
|
439
|
+
except json.JSONDecodeError:
|
|
440
|
+
raise ValueError(f"Invalid range format: {value}")
|
|
441
|
+
return value
|
|
442
|
+
elif isinstance(param, MultipleSelectionParameter):
|
|
443
|
+
# Parse JSON array for multiple selection
|
|
444
|
+
if isinstance(value, str):
|
|
445
|
+
try:
|
|
446
|
+
return json.loads(value)
|
|
447
|
+
except json.JSONDecodeError:
|
|
448
|
+
return [value] if value else []
|
|
449
|
+
return value
|
|
450
|
+
elif isinstance(param, SelectionParameter):
|
|
451
|
+
# For SelectionParameter, we need to handle various type conversion scenarios
|
|
452
|
+
|
|
453
|
+
# First check if the value is already in options (exact match)
|
|
454
|
+
if value in param.options:
|
|
455
|
+
return value
|
|
456
|
+
|
|
457
|
+
# Handle string conversion if value is a string but options might be numeric
|
|
458
|
+
if isinstance(value, str):
|
|
459
|
+
# Try to convert to integer if it looks like an integer
|
|
460
|
+
if value.isdigit():
|
|
461
|
+
int_value = int(value)
|
|
462
|
+
if int_value in param.options:
|
|
463
|
+
return int_value
|
|
464
|
+
|
|
465
|
+
# Try to convert to float if it has a decimal point
|
|
466
|
+
try:
|
|
467
|
+
float_value = float(value)
|
|
468
|
+
# Check for direct float match
|
|
469
|
+
if float_value in param.options:
|
|
470
|
+
return float_value
|
|
471
|
+
|
|
472
|
+
# Check for float equality with integer or other float options
|
|
473
|
+
for option in param.options:
|
|
474
|
+
if (
|
|
475
|
+
isinstance(option, (int, float))
|
|
476
|
+
and abs(float_value - float(option)) < 1e-10
|
|
477
|
+
):
|
|
478
|
+
return option
|
|
479
|
+
except ValueError:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
# Handle numeric conversion - when value is numeric but needs type matching
|
|
483
|
+
if isinstance(value, (int, float)):
|
|
484
|
+
for option in param.options:
|
|
485
|
+
# Convert both to float for comparison to handle int/float mismatches
|
|
486
|
+
if (
|
|
487
|
+
isinstance(option, (int, float))
|
|
488
|
+
and abs(float(value) - float(option)) < 1e-10
|
|
489
|
+
):
|
|
490
|
+
return option
|
|
491
|
+
|
|
492
|
+
# Also try string conversion as a fallback
|
|
493
|
+
if isinstance(option, str):
|
|
494
|
+
try:
|
|
495
|
+
if abs(float(value) - float(option)) < 1e-10:
|
|
496
|
+
return option
|
|
497
|
+
except ValueError:
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
# If we couldn't find a match, return the original value (will likely cause an error)
|
|
501
|
+
return value
|
|
502
|
+
else:
|
|
503
|
+
return value
|
|
504
|
+
|
|
505
|
+
def run(self, host: str = "127.0.0.1", port: Optional[int] = None, **kwargs):
|
|
506
|
+
"""
|
|
507
|
+
Run the Flask application server.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
host : str, optional
|
|
512
|
+
Host to run the server on
|
|
513
|
+
port : int, optional
|
|
514
|
+
Port to run the server on. If None, an available port will be automatically found.
|
|
515
|
+
**kwargs
|
|
516
|
+
Additional arguments to pass to app.run()
|
|
517
|
+
"""
|
|
518
|
+
# Find an available port if none is specified
|
|
519
|
+
if port is None:
|
|
520
|
+
port = _find_available_port()
|
|
521
|
+
|
|
522
|
+
run_simple(host, port, self.app, use_reloader=self.debug, **kwargs)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _find_available_port(start_port=5000, max_attempts=100):
|
|
526
|
+
"""
|
|
527
|
+
Find an available port starting from start_port.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
start_port : int, optional
|
|
532
|
+
Port to start searching from
|
|
533
|
+
max_attempts : int, optional
|
|
534
|
+
Maximum number of ports to try
|
|
535
|
+
|
|
536
|
+
Returns
|
|
537
|
+
-------
|
|
538
|
+
int
|
|
539
|
+
An available port number
|
|
540
|
+
|
|
541
|
+
Raises
|
|
542
|
+
------
|
|
543
|
+
RuntimeError
|
|
544
|
+
If no available port is found after max_attempts
|
|
545
|
+
"""
|
|
546
|
+
for port in range(start_port, start_port + max_attempts):
|
|
547
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
548
|
+
try:
|
|
549
|
+
s.bind(("127.0.0.1", port))
|
|
550
|
+
return port
|
|
551
|
+
except OSError:
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
raise RuntimeError(
|
|
555
|
+
f"Could not find an available port after {max_attempts} attempts starting from {start_port}"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@contextmanager
|
|
560
|
+
def _plot_context():
|
|
561
|
+
"""Context manager for creating matplotlib plots."""
|
|
562
|
+
try:
|
|
563
|
+
fig = plt.figure()
|
|
564
|
+
yield fig
|
|
565
|
+
finally:
|
|
566
|
+
plt.close(fig)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def deploy_flask(
|
|
570
|
+
viewer: Viewer,
|
|
571
|
+
host: str = "127.0.0.1",
|
|
572
|
+
port: Optional[int] = None,
|
|
573
|
+
controls_position: str = "left",
|
|
574
|
+
figure_width: float = 8.0,
|
|
575
|
+
figure_height: float = 6.0,
|
|
576
|
+
controls_width_percent: int = 30,
|
|
577
|
+
debug: bool = False,
|
|
578
|
+
open_browser: bool = True,
|
|
579
|
+
**kwargs,
|
|
580
|
+
):
|
|
581
|
+
"""
|
|
582
|
+
Deploy a Viewer as a Flask web application.
|
|
583
|
+
|
|
584
|
+
Parameters
|
|
585
|
+
----------
|
|
586
|
+
viewer : Viewer
|
|
587
|
+
The viewer to deploy
|
|
588
|
+
host : str, optional
|
|
589
|
+
Host to run the server on
|
|
590
|
+
port : int, optional
|
|
591
|
+
Port to run the server on. If None, an available port will be automatically found.
|
|
592
|
+
controls_position : str, optional
|
|
593
|
+
Position of the controls ('left', 'top', 'right', 'bottom')
|
|
594
|
+
figure_width : float, optional
|
|
595
|
+
Width of the figure in inches
|
|
596
|
+
figure_height : float, optional
|
|
597
|
+
Height of the figure in inches
|
|
598
|
+
controls_width_percent : int, optional
|
|
599
|
+
Width of the controls as a percentage of the total width
|
|
600
|
+
debug : bool, optional
|
|
601
|
+
Whether to enable debug mode
|
|
602
|
+
open_browser : bool, optional
|
|
603
|
+
Whether to open the browser automatically
|
|
604
|
+
**kwargs
|
|
605
|
+
Additional arguments to pass to app.run()
|
|
606
|
+
|
|
607
|
+
Returns
|
|
608
|
+
-------
|
|
609
|
+
FlaskDeployer
|
|
610
|
+
The deployer instance
|
|
611
|
+
"""
|
|
612
|
+
deployer = FlaskDeployer(
|
|
613
|
+
viewer,
|
|
614
|
+
controls_position=controls_position,
|
|
615
|
+
figure_width=figure_width,
|
|
616
|
+
figure_height=figure_height,
|
|
617
|
+
controls_width_percent=controls_width_percent,
|
|
618
|
+
debug=debug,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# Find an available port if none is specified
|
|
622
|
+
if port is None:
|
|
623
|
+
port = _find_available_port()
|
|
624
|
+
|
|
625
|
+
url = f"http://{host}:{port}"
|
|
626
|
+
print(f"Interactive plot server running on {url}")
|
|
627
|
+
|
|
628
|
+
if open_browser:
|
|
629
|
+
# Open browser in a separate thread after a small delay
|
|
630
|
+
# to ensure the server has started
|
|
631
|
+
def open_browser_tab():
|
|
632
|
+
time.sleep(1.0) # Short delay to allow server to start
|
|
633
|
+
webbrowser.open(url)
|
|
634
|
+
|
|
635
|
+
threading.Thread(target=open_browser_tab).start()
|
|
636
|
+
|
|
637
|
+
# This is included as an argument in some deployers but will break the Flask deployer
|
|
638
|
+
kwargs.pop("continuous", None)
|
|
639
|
+
deployer.run(host=host, port=port, **kwargs)
|
|
640
|
+
|
|
641
|
+
return deployer
|