syd 0.1.6__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 +3 -3
- syd/flask_deployment/__init__.py +7 -0
- syd/flask_deployment/deployer.py +594 -291
- syd/flask_deployment/static/__init__.py +1 -0
- syd/flask_deployment/static/css/styles.css +226 -19
- syd/flask_deployment/static/js/viewer.js +744 -0
- syd/flask_deployment/templates/__init__.py +1 -0
- syd/flask_deployment/templates/index.html +34 -0
- syd/flask_deployment/testing_principles.md +300 -0
- syd/notebook_deployment/__init__.py +1 -1
- syd/notebook_deployment/deployer.py +139 -53
- syd/notebook_deployment/widgets.py +214 -123
- syd/parameters.py +295 -393
- syd/support.py +168 -0
- syd/{interactive_viewer.py → viewer.py} +393 -470
- syd-0.2.0.dist-info/METADATA +126 -0
- syd-0.2.0.dist-info/RECORD +19 -0
- syd/flask_deployment/components.py +0 -497
- syd/flask_deployment/static/js/components.js +0 -51
- syd/flask_deployment/templates/base.html +0 -26
- syd/flask_deployment/templates/viewer.html +0 -97
- syd-0.1.6.dist-info/METADATA +0 -106
- syd-0.1.6.dist-info/RECORD +0 -18
- {syd-0.1.6.dist-info → syd-0.2.0.dist-info}/WHEEL +0 -0
- {syd-0.1.6.dist-info → syd-0.2.0.dist-info}/licenses/LICENSE +0 -0
syd/flask_deployment/deployer.py
CHANGED
|
@@ -1,133 +1,92 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
"""
|
|
2
|
+
Flask deployer for Syd Viewer objects.
|
|
3
|
+
|
|
4
|
+
This module provides tools to deploy Syd viewers as Flask web applications.
|
|
5
|
+
"""
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Dict, Any, Optional, List, Union, Callable, Type
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
import matplotlib as mpl
|
|
8
14
|
import matplotlib.pyplot as plt
|
|
15
|
+
from matplotlib.figure import Figure
|
|
9
16
|
import io
|
|
10
|
-
import
|
|
11
|
-
|
|
17
|
+
import numpy as np
|
|
18
|
+
import time
|
|
19
|
+
from functools import wraps
|
|
12
20
|
import webbrowser
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
},
|
|
70
|
-
body: JSON.stringify({name, value})
|
|
71
|
-
})
|
|
72
|
-
.then(response => response.json())
|
|
73
|
-
.then(data => {
|
|
74
|
-
if (data.error) {
|
|
75
|
-
console.error(data.error);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
// Update plot
|
|
79
|
-
document.getElementById('plot').src = data.plot;
|
|
80
|
-
// Apply any parameter updates
|
|
81
|
-
for (const [param, js] of Object.entries(data.updates)) {
|
|
82
|
-
eval(js);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function buttonClick(name) {
|
|
88
|
-
fetch('/button_click', {
|
|
89
|
-
method: 'POST',
|
|
90
|
-
headers: {
|
|
91
|
-
'Content-Type': 'application/json',
|
|
92
|
-
},
|
|
93
|
-
body: JSON.stringify({name})
|
|
94
|
-
})
|
|
95
|
-
.then(response => response.json())
|
|
96
|
-
.then(data => {
|
|
97
|
-
if (data.error) {
|
|
98
|
-
console.error(data.error);
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
// Update plot
|
|
102
|
-
document.getElementById('plot').src = data.plot;
|
|
103
|
-
// Apply any parameter updates
|
|
104
|
-
for (const [param, js] of Object.entries(data.updates)) {
|
|
105
|
-
eval(js);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Initialize components
|
|
111
|
-
{{ components_init | safe }}
|
|
112
|
-
</script>
|
|
113
|
-
</body>
|
|
114
|
-
</html>
|
|
115
|
-
"""
|
|
21
|
+
import threading
|
|
22
|
+
import socket
|
|
23
|
+
|
|
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
|
|
35
|
+
|
|
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):
|
|
58
|
+
"""
|
|
59
|
+
Decorator to debounce function calls.
|
|
60
|
+
Prevents a function from being called too frequently.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def decorator(fn):
|
|
64
|
+
last_call_time = [0]
|
|
65
|
+
|
|
66
|
+
@wraps(fn)
|
|
67
|
+
def debounced(*args, **kwargs):
|
|
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
|
|
73
|
+
|
|
74
|
+
return debounced
|
|
75
|
+
|
|
76
|
+
return decorator
|
|
116
77
|
|
|
117
78
|
|
|
118
79
|
@dataclass
|
|
119
80
|
class FlaskLayoutConfig:
|
|
120
|
-
"""Configuration for the viewer layout
|
|
81
|
+
"""Configuration for the Flask viewer layout."""
|
|
121
82
|
|
|
122
|
-
controls_position: str = "left" # Options are: 'left', 'top'
|
|
83
|
+
controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
|
|
123
84
|
figure_width: float = 8.0
|
|
124
85
|
figure_height: float = 6.0
|
|
125
86
|
controls_width_percent: int = 30
|
|
126
|
-
port: int = 5000
|
|
127
|
-
host: str = "localhost"
|
|
128
87
|
|
|
129
88
|
def __post_init__(self):
|
|
130
|
-
valid_positions = ["left", "top"]
|
|
89
|
+
valid_positions = ["left", "top", "right", "bottom"]
|
|
131
90
|
if self.controls_position not in valid_positions:
|
|
132
91
|
raise ValueError(
|
|
133
92
|
f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
|
|
@@ -135,204 +94,548 @@ class FlaskLayoutConfig:
|
|
|
135
94
|
|
|
136
95
|
@property
|
|
137
96
|
def is_horizontal(self) -> bool:
|
|
138
|
-
return self.controls_position == "left"
|
|
97
|
+
return self.controls_position == "left" or self.controls_position == "right"
|
|
139
98
|
|
|
140
99
|
|
|
141
|
-
class
|
|
142
|
-
"""
|
|
100
|
+
class FlaskDeployer:
|
|
101
|
+
"""
|
|
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.
|
|
104
|
+
"""
|
|
143
105
|
|
|
144
106
|
def __init__(
|
|
145
107
|
self,
|
|
146
|
-
viewer:
|
|
147
|
-
|
|
108
|
+
viewer: Viewer,
|
|
109
|
+
controls_position: str = "left",
|
|
110
|
+
fig_dpi: int = 300,
|
|
111
|
+
figure_width: float = 8.0,
|
|
112
|
+
figure_height: float = 6.0,
|
|
113
|
+
controls_width_percent: int = 30,
|
|
114
|
+
static_folder: Optional[str] = None,
|
|
115
|
+
template_folder: Optional[str] = None,
|
|
116
|
+
debug: bool = False,
|
|
148
117
|
):
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
"""
|
|
154
142
|
self.viewer = viewer
|
|
155
|
-
self.config =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
143
|
+
self.config = FlaskLayoutConfig(
|
|
144
|
+
controls_position=controls_position,
|
|
145
|
+
figure_width=figure_width,
|
|
146
|
+
figure_height=figure_height,
|
|
147
|
+
controls_width_percent=controls_width_percent,
|
|
148
|
+
)
|
|
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,
|
|
176
|
+
)
|
|
161
177
|
|
|
162
|
-
#
|
|
163
|
-
self.
|
|
164
|
-
|
|
178
|
+
# Configure logging
|
|
179
|
+
if not self.debug:
|
|
180
|
+
log = logging.getLogger("werkzeug")
|
|
181
|
+
log.setLevel(logging.ERROR)
|
|
165
182
|
|
|
166
|
-
|
|
167
|
-
"""Set up the Flask routes for the application."""
|
|
183
|
+
# Define routes
|
|
168
184
|
|
|
169
|
-
@
|
|
170
|
-
def
|
|
171
|
-
|
|
185
|
+
@app.route("/")
|
|
186
|
+
def home():
|
|
187
|
+
"""Render the main page."""
|
|
188
|
+
return render_template("index.html", config=self.config)
|
|
172
189
|
|
|
173
|
-
@
|
|
174
|
-
def
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
value = data.get("value")
|
|
190
|
+
@app.route("/init-data")
|
|
191
|
+
def init_data():
|
|
192
|
+
"""Provide initial parameter information."""
|
|
193
|
+
param_info = {}
|
|
178
194
|
|
|
179
|
-
|
|
195
|
+
for name, param in self.viewer.parameters.items():
|
|
196
|
+
param_info[name] = self._get_parameter_info(param)
|
|
180
197
|
|
|
181
|
-
|
|
182
|
-
print(f"Parameter {name} not found in viewer parameters") # Debug log
|
|
183
|
-
return jsonify({"error": f"Parameter {name} not found"}), 404
|
|
198
|
+
return jsonify({"params": param_info})
|
|
184
199
|
|
|
200
|
+
@app.route("/plot")
|
|
201
|
+
def plot():
|
|
202
|
+
"""Generate and return a plot based on the current state."""
|
|
185
203
|
try:
|
|
186
|
-
|
|
187
|
-
self.
|
|
188
|
-
|
|
189
|
-
# Update the plot with new parameter values
|
|
190
|
-
print("Updating plot with new parameters...") # Debug log
|
|
191
|
-
self._update_plot()
|
|
192
|
-
|
|
193
|
-
updates = self._get_parameter_updates()
|
|
194
|
-
plot_data = self._get_current_plot_data()
|
|
195
|
-
# Debug log
|
|
196
|
-
print(f"Generated updates for parameters: {list(updates.keys())}")
|
|
197
|
-
|
|
198
|
-
return jsonify(
|
|
199
|
-
{
|
|
200
|
-
"success": True,
|
|
201
|
-
"updates": updates,
|
|
202
|
-
"plot": plot_data,
|
|
203
|
-
}
|
|
204
|
-
)
|
|
205
|
-
except Exception as e:
|
|
206
|
-
print(f"Error updating parameter: {str(e)}") # Debug log
|
|
207
|
-
return jsonify({"error": str(e)}), 400
|
|
204
|
+
# Get parameters from request
|
|
205
|
+
state = self._parse_request_args(request.args)
|
|
208
206
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
213
211
|
|
|
214
|
-
|
|
212
|
+
# Get the plot from the viewer
|
|
213
|
+
with _plot_context():
|
|
214
|
+
fig = self.viewer.plot(self.viewer.state)
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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)
|
|
221
|
+
|
|
222
|
+
# Return the image
|
|
223
|
+
response = make_response(send_file(buf, mimetype="image/png"))
|
|
224
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
225
|
+
return response
|
|
219
226
|
|
|
227
|
+
except Exception as e:
|
|
228
|
+
app.logger.error(f"Error: {str(e)}")
|
|
229
|
+
return f"Error generating plot: {str(e)}", 500
|
|
230
|
+
|
|
231
|
+
@app.route("/update-param", methods=["POST"])
|
|
232
|
+
def update_param():
|
|
233
|
+
"""Update a parameter and run its callbacks."""
|
|
220
234
|
try:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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)
|
|
225
247
|
else:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
plot_data = self._get_current_plot_data()
|
|
234
|
-
# Debug log
|
|
235
|
-
print(f"Generated updates for parameters: {list(updates.keys())}")
|
|
236
|
-
|
|
237
|
-
return jsonify(
|
|
238
|
-
{
|
|
239
|
-
"success": True,
|
|
240
|
-
"updates": updates,
|
|
241
|
-
"plot": plot_data,
|
|
242
|
-
}
|
|
243
|
-
)
|
|
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})
|
|
254
|
+
|
|
244
255
|
except Exception as e:
|
|
245
|
-
|
|
246
|
-
return jsonify({"error": str(e)}),
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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)
|
|
262
|
+
def _handle_parameter_update(self, name: str, value: Any) -> None:
|
|
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:
|
|
275
|
+
return
|
|
276
|
+
|
|
257
277
|
try:
|
|
258
|
-
|
|
278
|
+
# Update the parameter value
|
|
279
|
+
self.viewer.parameters[name].value = value
|
|
280
|
+
|
|
281
|
+
# Run callbacks for this parameter
|
|
282
|
+
self.in_callbacks = True
|
|
283
|
+
|
|
284
|
+
# The viewer's perform_callbacks method will automatically
|
|
285
|
+
# pass the state dictionary to the callbacks
|
|
286
|
+
self.viewer.perform_callbacks(name)
|
|
259
287
|
finally:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def
|
|
263
|
-
"""
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
288
|
+
self.in_callbacks = False
|
|
289
|
+
|
|
290
|
+
def _handle_action(self, name: str) -> None:
|
|
291
|
+
"""
|
|
292
|
+
Handle a button action by executing its callback.
|
|
293
|
+
|
|
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
|
|
308
|
+
|
|
309
|
+
# Pass the current state to the callback
|
|
310
|
+
param.callback(self.viewer.state)
|
|
311
|
+
finally:
|
|
312
|
+
self.in_callbacks = False
|
|
313
|
+
|
|
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.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
param : Parameter
|
|
321
|
+
The parameter to convert
|
|
322
|
+
|
|
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.
|
|
386
|
+
|
|
387
|
+
Parameters
|
|
388
|
+
----------
|
|
389
|
+
args : MultiDict
|
|
390
|
+
Request arguments
|
|
391
|
+
|
|
392
|
+
Returns
|
|
393
|
+
-------
|
|
394
|
+
Dict[str, Any]
|
|
395
|
+
Parsed parameters
|
|
396
|
+
"""
|
|
397
|
+
result = {}
|
|
398
|
+
|
|
399
|
+
for name, value in args.items():
|
|
400
|
+
# Skip if parameter doesn't exist
|
|
401
|
+
if name not in self.viewer.parameters:
|
|
303
402
|
continue
|
|
304
|
-
updates[name] = self.components.get_update_js(name, value)
|
|
305
|
-
return updates
|
|
306
|
-
|
|
307
|
-
def _render_page(self) -> str:
|
|
308
|
-
"""Render the complete HTML page."""
|
|
309
|
-
# Create initial plot
|
|
310
|
-
self._update_plot()
|
|
311
|
-
|
|
312
|
-
return render_template_string(
|
|
313
|
-
PAGE_TEMPLATE,
|
|
314
|
-
config=self.config,
|
|
315
|
-
components_html=self.components.get_all_html(),
|
|
316
|
-
components_init=self.components.get_init_js(),
|
|
317
|
-
initial_plot=self._get_current_plot_data(),
|
|
318
|
-
required_css=self.components.get_required_css(),
|
|
319
|
-
required_js=self.components.get_required_js(),
|
|
320
|
-
custom_styles=self.components.get_custom_styles(),
|
|
321
|
-
)
|
|
322
403
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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:
|
|
409
|
+
"""
|
|
410
|
+
Parse a parameter value based on its type.
|
|
411
|
+
|
|
412
|
+
Parameters
|
|
413
|
+
----------
|
|
414
|
+
name : str
|
|
415
|
+
Parameter name
|
|
416
|
+
value : Any
|
|
417
|
+
Raw value
|
|
418
|
+
|
|
419
|
+
Returns
|
|
420
|
+
-------
|
|
421
|
+
Any
|
|
422
|
+
Parsed value
|
|
423
|
+
"""
|
|
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
|