syd 0.1.7__py3-none-any.whl → 1.0.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 +1 -1
- syd/flask_deployment/deployer.py +563 -238
- syd/flask_deployment/static/__init__.py +1 -0
- syd/flask_deployment/static/css/styles.css +280 -0
- syd/flask_deployment/static/js/viewer.js +795 -138
- 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/__init__.py +0 -1
- syd/notebook_deployment/deployer.py +124 -213
- syd/notebook_deployment/widgets.py +78 -60
- syd/parameters.py +299 -345
- syd/support.py +195 -0
- syd/viewer.py +310 -347
- syd-1.0.0.dist-info/METADATA +219 -0
- syd-1.0.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-1.0.0.dist-info}/WHEEL +0 -0
- {syd-0.1.7.dist-info → syd-1.0.0.dist-info}/licenses/LICENSE +0 -0
syd/flask_deployment/deployer.py
CHANGED
|
@@ -1,78 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
4
5
|
from dataclasses import dataclass
|
|
5
|
-
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
6
|
import matplotlib as mpl
|
|
15
7
|
import matplotlib.pyplot as plt
|
|
8
|
+
import io
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
import threading
|
|
12
|
+
import socket
|
|
13
|
+
import warnings
|
|
16
14
|
|
|
17
|
-
from
|
|
15
|
+
from flask import (
|
|
16
|
+
Flask,
|
|
17
|
+
send_file,
|
|
18
|
+
request,
|
|
19
|
+
make_response,
|
|
20
|
+
jsonify,
|
|
21
|
+
render_template,
|
|
22
|
+
)
|
|
23
|
+
from werkzeug.serving import run_simple
|
|
24
|
+
|
|
25
|
+
# Use Deployer base class
|
|
18
26
|
from ..viewer import Viewer
|
|
19
|
-
from
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if "agg" in backend:
|
|
37
|
-
return "agg"
|
|
38
|
-
elif "inline" in backend:
|
|
39
|
-
return "inline"
|
|
40
|
-
else:
|
|
41
|
-
# Force Agg backend for Flask
|
|
42
|
-
mpl.use("Agg")
|
|
43
|
-
return "agg"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def debounce(wait_time):
|
|
47
|
-
"""
|
|
48
|
-
Decorator to prevent a function from being called more than once every wait_time seconds.
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
def decorator(fn):
|
|
52
|
-
last_called = [0.0] # Using list to maintain state in closure
|
|
53
|
-
|
|
54
|
-
@wraps(fn)
|
|
55
|
-
def debounced(*args, **kwargs):
|
|
56
|
-
current_time = time()
|
|
57
|
-
if current_time - last_called[0] >= wait_time:
|
|
58
|
-
fn(*args, **kwargs)
|
|
59
|
-
last_called[0] = current_time
|
|
60
|
-
|
|
61
|
-
return debounced
|
|
62
|
-
|
|
63
|
-
return decorator
|
|
27
|
+
from ..parameters import (
|
|
28
|
+
Parameter,
|
|
29
|
+
TextParameter,
|
|
30
|
+
BooleanParameter,
|
|
31
|
+
SelectionParameter,
|
|
32
|
+
MultipleSelectionParameter,
|
|
33
|
+
IntegerParameter,
|
|
34
|
+
FloatParameter,
|
|
35
|
+
IntegerRangeParameter,
|
|
36
|
+
FloatRangeParameter,
|
|
37
|
+
UnboundedIntegerParameter,
|
|
38
|
+
UnboundedFloatParameter,
|
|
39
|
+
ButtonAction,
|
|
40
|
+
)
|
|
41
|
+
from ..support import ParameterUpdateWarning, plot_context
|
|
42
|
+
|
|
43
|
+
mpl.use("Agg")
|
|
64
44
|
|
|
65
45
|
|
|
66
46
|
@dataclass
|
|
67
|
-
class
|
|
68
|
-
"""Configuration for the viewer layout."""
|
|
47
|
+
class FlaskLayoutConfig:
|
|
48
|
+
"""Configuration for the Flask viewer layout."""
|
|
69
49
|
|
|
70
50
|
controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
|
|
71
|
-
figure_width: float = 8.0
|
|
72
|
-
figure_height: float = 6.0
|
|
73
51
|
controls_width_percent: int = 30
|
|
74
|
-
template_path: Optional[str] = None
|
|
75
|
-
static_path: Optional[str] = None
|
|
76
52
|
|
|
77
53
|
def __post_init__(self):
|
|
78
54
|
valid_positions = ["left", "top", "right", "bottom"]
|
|
@@ -88,215 +64,564 @@ class LayoutConfig:
|
|
|
88
64
|
|
|
89
65
|
class FlaskDeployer:
|
|
90
66
|
"""
|
|
91
|
-
A deployment system for Viewer
|
|
92
|
-
|
|
67
|
+
A deployment system for Viewer as a Flask web application using the Deployer base class.
|
|
68
|
+
Creates a Flask app with routes for the UI, data API, and plot generation.
|
|
93
69
|
"""
|
|
94
70
|
|
|
95
71
|
def __init__(
|
|
96
72
|
self,
|
|
97
73
|
viewer: Viewer,
|
|
98
74
|
controls_position: str = "left",
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
suppress_warnings: bool = False,
|
|
75
|
+
fig_dpi: int = 300,
|
|
76
|
+
controls_width_percent: int = 20,
|
|
77
|
+
suppress_warnings: bool = True,
|
|
78
|
+
debug: bool = False,
|
|
104
79
|
host: str = "127.0.0.1",
|
|
105
|
-
port: int =
|
|
106
|
-
|
|
107
|
-
static_path: Optional[str] = None,
|
|
80
|
+
port: Optional[int] = None,
|
|
81
|
+
open_browser: bool = True,
|
|
108
82
|
):
|
|
83
|
+
"""
|
|
84
|
+
Initialize the Flask deployer.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
viewer : Viewer
|
|
89
|
+
The viewer to deploy
|
|
90
|
+
controls_position : str, optional
|
|
91
|
+
Position of the controls ('left', 'top', 'right', 'bottom')
|
|
92
|
+
fig_dpi : int, optional
|
|
93
|
+
DPI of the figure - higher is better quality but takes longer to generate
|
|
94
|
+
figure_width : float, optional
|
|
95
|
+
Approximate width for template layout guidance (inches)
|
|
96
|
+
figure_height : float, optional
|
|
97
|
+
Approximate height for template layout guidance (inches)
|
|
98
|
+
controls_width_percent : int, optional
|
|
99
|
+
Width of the controls panel as a percentage of the total width
|
|
100
|
+
suppress_warnings : bool, optional
|
|
101
|
+
Whether to suppress ParameterUpdateWarning during updates
|
|
102
|
+
debug : bool, optional
|
|
103
|
+
Whether to enable debug mode for Flask
|
|
104
|
+
host : str, optional
|
|
105
|
+
Host address for the server (default: '127.0.0.1')
|
|
106
|
+
port : int, optional
|
|
107
|
+
Port for the server. If None, finds an available port (default: None).
|
|
108
|
+
open_browser : bool, optional
|
|
109
|
+
Whether to open the web application in a browser tab (default: True).
|
|
110
|
+
"""
|
|
109
111
|
self.viewer = viewer
|
|
110
|
-
self.
|
|
112
|
+
self.suppress_warnings = suppress_warnings
|
|
113
|
+
self._updating = False # Flag to check circular updates
|
|
114
|
+
|
|
115
|
+
# Flask specific configurations
|
|
116
|
+
self.config = FlaskLayoutConfig(
|
|
111
117
|
controls_position=controls_position,
|
|
112
|
-
figure_width=figure_width,
|
|
113
|
-
figure_height=figure_height,
|
|
114
118
|
controls_width_percent=controls_width_percent,
|
|
115
|
-
template_path=template_path,
|
|
116
|
-
static_path=static_path,
|
|
117
119
|
)
|
|
118
|
-
self.
|
|
119
|
-
self.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
self.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
self.
|
|
120
|
+
self.fig_dpi = fig_dpi
|
|
121
|
+
self.debug = debug
|
|
122
|
+
|
|
123
|
+
# Determine static and template folder paths
|
|
124
|
+
package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
125
|
+
self.static_folder = os.path.join(package_dir, "static")
|
|
126
|
+
self.template_folder = os.path.join(package_dir, "templates")
|
|
127
|
+
|
|
128
|
+
# Flask app instance - will be created in build_layout
|
|
129
|
+
self.app: Optional[Flask] = None
|
|
130
|
+
|
|
131
|
+
# Server details - will be set in display
|
|
132
|
+
self.host: Optional[str] = host
|
|
133
|
+
self.port: Optional[int] = port
|
|
134
|
+
self.url: Optional[str] = None
|
|
135
|
+
self.open_browser: bool = open_browser
|
|
136
|
+
|
|
137
|
+
def build_layout(self) -> None:
|
|
138
|
+
"""Create and configure the Flask application and its routes."""
|
|
139
|
+
# Avoid re-building the app if called multiple times
|
|
140
|
+
if self.app:
|
|
141
|
+
return
|
|
133
142
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
app = Flask(
|
|
144
|
+
"SydFlaskDeployer",
|
|
145
|
+
static_folder=self.static_folder,
|
|
146
|
+
template_folder=self.template_folder,
|
|
138
147
|
)
|
|
139
|
-
|
|
148
|
+
self.app = app
|
|
140
149
|
|
|
141
|
-
|
|
150
|
+
# Configure logging
|
|
151
|
+
if not self.debug:
|
|
152
|
+
log = logging.getLogger("werkzeug")
|
|
153
|
+
log.setLevel(logging.ERROR)
|
|
142
154
|
|
|
143
|
-
# Register routes
|
|
144
155
|
@app.route("/")
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
def home():
|
|
157
|
+
"""Render the main page using the index.html template."""
|
|
158
|
+
# Pass the layout config to the template
|
|
159
|
+
return render_template("index.html", title="Syd Viewer", config=self.config)
|
|
160
|
+
|
|
161
|
+
@app.route("/init-data")
|
|
162
|
+
def init_data():
|
|
163
|
+
"""Provide initial parameter information to the frontend."""
|
|
164
|
+
param_info = {
|
|
165
|
+
name: self._get_parameter_info(param)
|
|
166
|
+
for name, param in self.viewer.parameters.items()
|
|
167
|
+
}
|
|
168
|
+
# Get the order of parameters
|
|
169
|
+
param_order = list(self.viewer.parameters.keys())
|
|
170
|
+
# Also include the initial state
|
|
171
|
+
return jsonify(
|
|
172
|
+
{
|
|
173
|
+
"params": param_info,
|
|
174
|
+
"param_order": param_order,
|
|
175
|
+
"state": self.viewer.state,
|
|
176
|
+
}
|
|
165
177
|
)
|
|
166
178
|
|
|
167
|
-
@app.route("/
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
@app.route("/plot")
|
|
180
|
+
def plot():
|
|
181
|
+
"""Generate and return the plot image based on the current viewer state."""
|
|
182
|
+
if self._updating:
|
|
183
|
+
# Avoid plot generation during an update cycle if possible,
|
|
184
|
+
# though frontend usually waits for update response before fetching plot.
|
|
185
|
+
# Return a placeholder or error? For now, just proceed.
|
|
186
|
+
app.logger.warning("Plot requested while parameters are updating.")
|
|
171
187
|
|
|
172
188
|
try:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
189
|
+
# Generate the plot using the current state from the viewer instance
|
|
190
|
+
# The _plot_context ensures plt state is managed correctly.
|
|
191
|
+
with plot_context():
|
|
192
|
+
# Use the viewer's plot method with its current state
|
|
193
|
+
fig = self.viewer.plot(self.viewer.state)
|
|
194
|
+
if not isinstance(fig, mpl.figure.Figure):
|
|
195
|
+
raise TypeError(
|
|
196
|
+
f"viewer.plot() must return a matplotlib Figure, but got {type(fig)}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Save the plot to a buffer
|
|
200
|
+
buf = io.BytesIO()
|
|
201
|
+
fig.savefig(buf, format="png", bbox_inches="tight", dpi=self.fig_dpi)
|
|
202
|
+
buf.seek(0)
|
|
203
|
+
plt.close(fig) # Ensure figure is closed
|
|
204
|
+
|
|
205
|
+
# Return the image as a response
|
|
206
|
+
response = make_response(send_file(buf, mimetype="image/png"))
|
|
207
|
+
response.headers["Cache-Control"] = (
|
|
208
|
+
"no-cache, no-store, must-revalidate"
|
|
209
|
+
)
|
|
210
|
+
response.headers["Pragma"] = "no-cache"
|
|
211
|
+
response.headers["Expires"] = "0"
|
|
212
|
+
return response
|
|
178
213
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
214
|
+
except Exception as e:
|
|
215
|
+
app.logger.error(f"Error generating plot: {str(e)}", exc_info=True)
|
|
216
|
+
# Return a JSON error for easier frontend handling
|
|
217
|
+
return jsonify({"error": f"Error generating plot: {str(e)}"}), 500
|
|
218
|
+
|
|
219
|
+
@app.route("/update-param", methods=["POST"])
|
|
220
|
+
def update_param():
|
|
221
|
+
"""Handle parameter updates or actions triggered from the frontend."""
|
|
222
|
+
if self._updating:
|
|
223
|
+
# Prevent processing new updates if already updating (potential cycle)
|
|
224
|
+
app.logger.warning(
|
|
225
|
+
"Update requested while already processing an update."
|
|
226
|
+
)
|
|
227
|
+
# Return current state to avoid frontend hanging.
|
|
228
|
+
return (
|
|
229
|
+
jsonify(
|
|
230
|
+
{
|
|
231
|
+
"success": False,
|
|
232
|
+
"error": "Server busy processing previous update.",
|
|
233
|
+
"state": self.viewer.state,
|
|
234
|
+
}
|
|
235
|
+
),
|
|
236
|
+
429,
|
|
237
|
+
) # Too Many Requests
|
|
182
238
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
239
|
+
try:
|
|
240
|
+
self._updating = True # Set base class flag
|
|
241
|
+
|
|
242
|
+
data = request.get_json()
|
|
243
|
+
name = data.get("name")
|
|
244
|
+
value = data.get("value", None)
|
|
245
|
+
action = data.get("action", False)
|
|
246
|
+
|
|
247
|
+
if not name or name not in self.viewer.parameters:
|
|
248
|
+
app.logger.error(f"Invalid parameter name received: {name}")
|
|
249
|
+
return jsonify({"error": f"Parameter '{name}' not found"}), 404
|
|
250
|
+
|
|
251
|
+
parameter = self.viewer.parameters[name]
|
|
252
|
+
|
|
253
|
+
# Optionally suppress warnings during updates
|
|
254
|
+
with warnings.catch_warnings():
|
|
255
|
+
if self.suppress_warnings:
|
|
256
|
+
warnings.filterwarnings(
|
|
257
|
+
"ignore", category=ParameterUpdateWarning
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if action:
|
|
261
|
+
# Handle button actions: directly call the callback
|
|
262
|
+
if isinstance(parameter, ButtonAction) and parameter.callback:
|
|
263
|
+
# Pass the current state dictionary to the callback
|
|
264
|
+
parameter.callback(self.viewer.state)
|
|
265
|
+
else:
|
|
266
|
+
app.logger.warning(
|
|
267
|
+
f"Received action request for non-action parameter: {name}"
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
# Handle regular parameter updates: parse and set value
|
|
271
|
+
try:
|
|
272
|
+
parsed_value = self._parse_parameter_value(name, value)
|
|
273
|
+
# Use base class method to set value and trigger callbacks
|
|
274
|
+
self.viewer.set_parameter_value(name, parsed_value)
|
|
275
|
+
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
|
276
|
+
app.logger.error(
|
|
277
|
+
f"Error parsing value for parameter '{name}': {e}"
|
|
278
|
+
)
|
|
279
|
+
return (
|
|
280
|
+
jsonify(
|
|
281
|
+
{"error": f"Invalid value format for {name}: {e}"}
|
|
282
|
+
),
|
|
283
|
+
400,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# State might have changed due to callbacks, return the *final* state
|
|
287
|
+
final_state = self.viewer.state
|
|
288
|
+
final_param_info = {
|
|
289
|
+
name: self._get_parameter_info(param)
|
|
290
|
+
for name, param in self.viewer.parameters.items()
|
|
291
|
+
}
|
|
292
|
+
return jsonify(
|
|
293
|
+
{"success": True, "state": final_state, "params": final_param_info}
|
|
294
|
+
)
|
|
207
295
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
296
|
+
except Exception as e:
|
|
297
|
+
app.logger.error(
|
|
298
|
+
f"Error updating parameter '{name}': {str(e)}", exc_info=True
|
|
299
|
+
)
|
|
300
|
+
# Return the state *before* the error if possible? Or just error.
|
|
301
|
+
return (
|
|
302
|
+
jsonify(
|
|
303
|
+
{
|
|
304
|
+
"error": f"Server error updating parameter: {str(e)}",
|
|
305
|
+
"state": self.viewer.state,
|
|
306
|
+
}
|
|
307
|
+
),
|
|
308
|
+
500,
|
|
309
|
+
)
|
|
310
|
+
finally:
|
|
311
|
+
self._updating = False # Clear base class flag
|
|
312
|
+
|
|
313
|
+
def display(
|
|
314
|
+
self,
|
|
315
|
+
host: str = "127.0.0.1",
|
|
316
|
+
port: Optional[int] = None,
|
|
317
|
+
open_browser: bool = True,
|
|
318
|
+
**kwargs,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Starts the Flask development server."""
|
|
321
|
+
if not self.app:
|
|
322
|
+
raise RuntimeError(
|
|
323
|
+
"Flask app not built. Call build_layout() before display()."
|
|
230
324
|
)
|
|
231
|
-
return
|
|
232
325
|
|
|
233
|
-
|
|
234
|
-
|
|
326
|
+
# Find an available port if none is specified
|
|
327
|
+
self.port = port or _find_available_port()
|
|
328
|
+
self.host = host
|
|
329
|
+
self.url = f"http://{self.host}:{self.port}"
|
|
330
|
+
print(f" * Syd Flask server running on {self.url}")
|
|
331
|
+
|
|
332
|
+
if open_browser:
|
|
333
|
+
|
|
334
|
+
def open_browser_tab():
|
|
335
|
+
time.sleep(1.0)
|
|
336
|
+
webbrowser.open(self.url)
|
|
337
|
+
|
|
338
|
+
threading.Thread(target=open_browser_tab, daemon=True).start()
|
|
339
|
+
|
|
340
|
+
# Run the Flask server using Werkzeug's run_simple
|
|
341
|
+
# Pass debug status to run_simple for auto-reloading
|
|
342
|
+
run_simple(
|
|
343
|
+
self.host,
|
|
344
|
+
self.port,
|
|
345
|
+
self.app,
|
|
346
|
+
use_reloader=self.debug,
|
|
347
|
+
use_debugger=self.debug,
|
|
348
|
+
**kwargs,
|
|
349
|
+
)
|
|
235
350
|
|
|
236
|
-
|
|
237
|
-
with warnings.catch_warnings():
|
|
238
|
-
if self.suppress_warnings:
|
|
239
|
-
warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
|
|
351
|
+
# --- Overridden Methods ---
|
|
240
352
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
parameter.callback(self.viewer.state)
|
|
245
|
-
else:
|
|
246
|
-
self.viewer.set_parameter_value(name, value)
|
|
353
|
+
def deploy(self) -> None:
|
|
354
|
+
"""
|
|
355
|
+
Deploy the viewer using Flask.
|
|
247
356
|
|
|
248
|
-
|
|
249
|
-
|
|
357
|
+
Builds components (no-op), layout (Flask app/routes),
|
|
358
|
+
and then starts the server.
|
|
359
|
+
"""
|
|
360
|
+
# build_layout creates the Flask app and routes
|
|
361
|
+
self.build_layout()
|
|
362
|
+
|
|
363
|
+
# Initial plot generation is handled implicitly when the first client connects
|
|
364
|
+
# and requests /plot. We don't need an explicit initial self.update_plot() call here,
|
|
365
|
+
# though the base class might call it if not overridden. Let's rely on the
|
|
366
|
+
# frontend fetching the initial plot based on the initial state from /init-data.
|
|
367
|
+
|
|
368
|
+
print("Starting Flask server...")
|
|
369
|
+
# Display starts the server
|
|
370
|
+
self.display(
|
|
371
|
+
host=self.host,
|
|
372
|
+
port=self.port,
|
|
373
|
+
open_browser=self.open_browser,
|
|
374
|
+
)
|
|
250
375
|
|
|
251
|
-
|
|
252
|
-
|
|
376
|
+
def _get_parameter_info(self, param: Parameter) -> Dict[str, Any]:
|
|
377
|
+
"""
|
|
378
|
+
Convert a Parameter object to a dictionary of information for the frontend.
|
|
379
|
+
(Identical to original, kept for clarity)
|
|
380
|
+
"""
|
|
381
|
+
# Add name/label
|
|
382
|
+
info = {
|
|
383
|
+
"name": param.name,
|
|
384
|
+
"value": param.value,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if isinstance(param, TextParameter):
|
|
388
|
+
info.update({"type": "text"})
|
|
389
|
+
elif isinstance(param, BooleanParameter):
|
|
390
|
+
info.update({"type": "boolean"})
|
|
391
|
+
elif isinstance(param, SelectionParameter):
|
|
392
|
+
info.update({"type": "selection", "options": param.options})
|
|
393
|
+
elif isinstance(param, MultipleSelectionParameter):
|
|
394
|
+
info.update(
|
|
395
|
+
{
|
|
396
|
+
"type": "multiple-selection",
|
|
397
|
+
"options": param.options,
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
elif isinstance(param, IntegerParameter):
|
|
401
|
+
info.update(
|
|
402
|
+
{
|
|
403
|
+
"type": "integer",
|
|
404
|
+
"min": param.min,
|
|
405
|
+
"max": param.max,
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
elif isinstance(param, FloatParameter):
|
|
409
|
+
info.update(
|
|
410
|
+
{
|
|
411
|
+
"type": "float",
|
|
412
|
+
"min": param.min,
|
|
413
|
+
"max": param.max,
|
|
414
|
+
"step": param.step,
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
elif isinstance(param, IntegerRangeParameter):
|
|
418
|
+
info.update(
|
|
419
|
+
{
|
|
420
|
+
"type": "integer-range",
|
|
421
|
+
"min": param.min,
|
|
422
|
+
"max": param.max,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
elif isinstance(param, FloatRangeParameter):
|
|
426
|
+
info.update(
|
|
427
|
+
{
|
|
428
|
+
"type": "float-range",
|
|
429
|
+
"min": param.min,
|
|
430
|
+
"max": param.max,
|
|
431
|
+
"step": param.step,
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
elif isinstance(param, UnboundedIntegerParameter):
|
|
435
|
+
info.update({"type": "unbounded-integer"})
|
|
436
|
+
elif isinstance(param, UnboundedFloatParameter):
|
|
437
|
+
info.update({"type": "unbounded-float", "step": param.step})
|
|
438
|
+
elif isinstance(param, ButtonAction):
|
|
439
|
+
# Button doesn't have a 'value' in the same way, label is important
|
|
440
|
+
info.update({"type": "button", "is_action": True})
|
|
441
|
+
# Remove 'value' as it's not applicable
|
|
442
|
+
info.pop("value", None)
|
|
443
|
+
else:
|
|
444
|
+
# Fallback for unknown types
|
|
445
|
+
info.update(
|
|
446
|
+
{"type": "unknown", "value": str(param.value)}
|
|
447
|
+
) # Keep value as string
|
|
448
|
+
|
|
449
|
+
return info
|
|
450
|
+
|
|
451
|
+
def _parse_parameter_value(self, name: str, value: Any) -> Any:
|
|
452
|
+
"""
|
|
453
|
+
Parse a parameter value from the frontend based on its type.
|
|
454
|
+
Handles type conversions (e.g., string 'true' to bool True, string '5' to int 5).
|
|
253
455
|
|
|
254
|
-
|
|
255
|
-
"""
|
|
256
|
-
|
|
257
|
-
if
|
|
258
|
-
|
|
456
|
+
Raises ValueError or TypeError on parsing failure.
|
|
457
|
+
"""
|
|
458
|
+
if name not in self.viewer.parameters:
|
|
459
|
+
# Should not happen if checked before calling, but defensive check
|
|
460
|
+
raise ValueError(f"Parameter '{name}' not found during parsing.")
|
|
259
461
|
|
|
260
|
-
|
|
261
|
-
if not component.matches_parameter(parameter):
|
|
262
|
-
component.update_from_parameter(parameter)
|
|
462
|
+
param = self.viewer.parameters[name]
|
|
263
463
|
|
|
264
|
-
|
|
265
|
-
"""Generate the current plot."""
|
|
464
|
+
# Handle specific types
|
|
266
465
|
try:
|
|
267
|
-
|
|
268
|
-
|
|
466
|
+
if isinstance(param, TextParameter):
|
|
467
|
+
return str(value) # Ensure it's a string
|
|
468
|
+
elif isinstance(param, BooleanParameter):
|
|
469
|
+
# Handle 'true'/'false' strings robustly
|
|
470
|
+
if isinstance(value, str):
|
|
471
|
+
if value.lower() == "true":
|
|
472
|
+
return True
|
|
473
|
+
if value.lower() == "false":
|
|
474
|
+
return False
|
|
475
|
+
# Try converting string numbers to bool (e.g., "1" -> True)
|
|
476
|
+
try:
|
|
477
|
+
return bool(int(value))
|
|
478
|
+
except ValueError:
|
|
479
|
+
pass # Ignore if not int-like string
|
|
480
|
+
return bool(value) # Standard bool conversion
|
|
481
|
+
elif isinstance(param, (IntegerParameter, UnboundedIntegerParameter)):
|
|
482
|
+
return int(value)
|
|
483
|
+
elif isinstance(param, (FloatParameter, UnboundedFloatParameter)):
|
|
484
|
+
return float(value)
|
|
485
|
+
elif isinstance(param, (IntegerRangeParameter, FloatRangeParameter)):
|
|
486
|
+
# Expect a list/tuple from JSON, e.g., [min, max]
|
|
487
|
+
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
488
|
+
# Ensure types match the parameter type
|
|
489
|
+
if isinstance(param, IntegerRangeParameter):
|
|
490
|
+
return [int(v) for v in value]
|
|
491
|
+
else: # FloatRangeParameter
|
|
492
|
+
return [float(v) for v in value]
|
|
493
|
+
# Allow JSON string representation '[min, max]'
|
|
494
|
+
elif isinstance(value, str):
|
|
495
|
+
try:
|
|
496
|
+
parsed_list = json.loads(value)
|
|
497
|
+
if isinstance(parsed_list, list) and len(parsed_list) == 2:
|
|
498
|
+
if isinstance(param, IntegerRangeParameter):
|
|
499
|
+
return [int(v) for v in parsed_list]
|
|
500
|
+
else:
|
|
501
|
+
return [float(v) for v in parsed_list]
|
|
502
|
+
else:
|
|
503
|
+
raise ValueError(
|
|
504
|
+
"Range requires a list/tuple of two numbers."
|
|
505
|
+
)
|
|
506
|
+
except json.JSONDecodeError:
|
|
507
|
+
raise ValueError(f"Invalid JSON string for range: {value}")
|
|
508
|
+
else:
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"Invalid format for range parameter '{name}'. Expected list/tuple of two numbers or JSON string."
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
elif isinstance(param, MultipleSelectionParameter):
|
|
514
|
+
# Expect a list from JSON, e.g., ['a', 'b']
|
|
515
|
+
if isinstance(value, list):
|
|
516
|
+
# Ensure options are valid? Base Parameter class might do this.
|
|
517
|
+
# Return as is for now.
|
|
518
|
+
return value
|
|
519
|
+
# Allow JSON string representation '["a", "b"]'
|
|
520
|
+
elif isinstance(value, str):
|
|
521
|
+
try:
|
|
522
|
+
parsed_list = json.loads(value)
|
|
523
|
+
if isinstance(parsed_list, list):
|
|
524
|
+
return parsed_list
|
|
525
|
+
else: # Allow single non-list value to be wrapped? No, spec is list.
|
|
526
|
+
raise ValueError("Multiple selection requires a list.")
|
|
527
|
+
except json.JSONDecodeError:
|
|
528
|
+
# Handle case where a single string value might be sent for a multi-select
|
|
529
|
+
# if the frontend logic is imperfect. Treat as a list with one item?
|
|
530
|
+
# Let's be strict for now.
|
|
531
|
+
raise ValueError(
|
|
532
|
+
f"Invalid JSON string for multiple selection: {value}"
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
# If it's not a list or valid JSON string, treat as empty list? Or error?
|
|
536
|
+
# Error seems safer.
|
|
537
|
+
raise ValueError(
|
|
538
|
+
f"Invalid format for multiple selection parameter '{name}'. Expected list or JSON string list."
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
elif isinstance(param, SelectionParameter):
|
|
542
|
+
# Value needs to match one of the options *by type* if possible.
|
|
543
|
+
# The original logic was quite complex. Let's simplify:
|
|
544
|
+
# Try direct match first.
|
|
545
|
+
if value in param.options:
|
|
546
|
+
return value
|
|
547
|
+
|
|
548
|
+
# Try converting the incoming value to the types present in options.
|
|
549
|
+
option_types = {type(opt) for opt in param.options}
|
|
550
|
+
|
|
551
|
+
# Prioritize type conversion based on options
|
|
552
|
+
if float in option_types:
|
|
553
|
+
try:
|
|
554
|
+
float_val = float(value)
|
|
555
|
+
# Check if float matches any option (handle float inaccuracies)
|
|
556
|
+
for opt in param.options:
|
|
557
|
+
if (
|
|
558
|
+
isinstance(opt, (int, float))
|
|
559
|
+
and abs(float_val - float(opt)) < 1e-9
|
|
560
|
+
):
|
|
561
|
+
return opt # Return the original option instance
|
|
562
|
+
# If no close match, but float conversion worked, maybe return float_val?
|
|
563
|
+
# Let's stick to returning existing options.
|
|
564
|
+
except (ValueError, TypeError):
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
if int in option_types:
|
|
568
|
+
try:
|
|
569
|
+
int_val = int(value)
|
|
570
|
+
if int_val in param.options:
|
|
571
|
+
return int_val
|
|
572
|
+
except (ValueError, TypeError):
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
if str in option_types:
|
|
576
|
+
str_val = str(value)
|
|
577
|
+
if str_val in param.options:
|
|
578
|
+
return str_val
|
|
579
|
+
|
|
580
|
+
# If no match after trying conversions, raise error. Let Parameter handle validation.
|
|
581
|
+
# Returning the original value might bypass validation.
|
|
582
|
+
raise ValueError(
|
|
583
|
+
f"Value '{value}' is not a valid option for '{name}'. Valid options: {param.options}"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
elif isinstance(param, ButtonAction):
|
|
587
|
+
# Actions don't have a value to parse
|
|
588
|
+
return None # Or raise error? None seems okay.
|
|
269
589
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
590
|
+
else:
|
|
591
|
+
# Fallback for unknown - return as is, let Parameter validate
|
|
592
|
+
return value
|
|
273
593
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
594
|
+
except (ValueError, TypeError, json.JSONDecodeError) as e:
|
|
595
|
+
# Re-raise with more context
|
|
596
|
+
raise ValueError(
|
|
597
|
+
f"Failed to parse value '{value}' for parameter '{name}' ({type(param).__name__}): {e}"
|
|
598
|
+
)
|
|
279
599
|
|
|
280
|
-
def deploy(self, open_browser: bool = True) -> None:
|
|
281
|
-
"""
|
|
282
|
-
Deploy the viewer as a Flask web application.
|
|
283
600
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
601
|
+
def _find_available_port(start_port=5000, max_attempts=100):
|
|
602
|
+
"""
|
|
603
|
+
Find an available port starting from start_port.
|
|
604
|
+
(Identical to original)
|
|
605
|
+
"""
|
|
606
|
+
for port in range(start_port, start_port + max_attempts):
|
|
607
|
+
# Use localhost address explicitly
|
|
608
|
+
address = "127.0.0.1"
|
|
609
|
+
try:
|
|
610
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
611
|
+
# Check if the port is usable
|
|
612
|
+
s.bind((address, port))
|
|
613
|
+
# If bind succeeds, the port is available
|
|
614
|
+
return port
|
|
615
|
+
except OSError as e:
|
|
616
|
+
# If error is Address already in use, try next port
|
|
617
|
+
if e.errno == socket.errno.EADDRINUSE:
|
|
618
|
+
# print(f"Port {port} already in use.") # Optional debug msg
|
|
619
|
+
continue
|
|
620
|
+
else:
|
|
621
|
+
# Re-raise other OS errors
|
|
622
|
+
raise e
|
|
623
|
+
|
|
624
|
+
# If loop finishes without finding a port
|
|
625
|
+
raise RuntimeError(
|
|
626
|
+
f"Could not find an available port between {start_port} and {start_port + max_attempts - 1}"
|
|
627
|
+
)
|