syd 0.2.0__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 +0 -6
- syd/flask_deployment/deployer.py +446 -460
- syd/flask_deployment/static/css/styles.css +50 -16
- syd/flask_deployment/static/js/viewer.js +154 -67
- syd/flask_deployment/templates/index.html +1 -1
- syd/notebook_deployment/__init__.py +0 -1
- syd/notebook_deployment/deployer.py +119 -218
- syd/notebook_deployment/widgets.py +2 -2
- syd/parameters.py +69 -104
- syd/support.py +27 -0
- syd/viewer.py +10 -6
- syd-1.0.0.dist-info/METADATA +219 -0
- syd-1.0.0.dist-info/RECORD +19 -0
- syd-0.2.0.dist-info/METADATA +0 -126
- syd-0.2.0.dist-info/RECORD +0 -19
- {syd-0.2.0.dist-info → syd-1.0.0.dist-info}/WHEEL +0 -0
- {syd-0.2.0.dist-info → syd-1.0.0.dist-info}/licenses/LICENSE +0 -0
syd/flask_deployment/deployer.py
CHANGED
|
@@ -1,26 +1,16 @@
|
|
|
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
1
|
import os
|
|
8
2
|
import json
|
|
9
3
|
import logging
|
|
10
|
-
from typing import Dict, Any, Optional
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
11
5
|
from dataclasses import dataclass
|
|
12
|
-
from contextlib import contextmanager
|
|
13
6
|
import matplotlib as mpl
|
|
14
7
|
import matplotlib.pyplot as plt
|
|
15
|
-
from matplotlib.figure import Figure
|
|
16
8
|
import io
|
|
17
|
-
import numpy as np
|
|
18
9
|
import time
|
|
19
|
-
from functools import wraps
|
|
20
10
|
import webbrowser
|
|
21
11
|
import threading
|
|
22
12
|
import socket
|
|
23
|
-
|
|
13
|
+
import warnings
|
|
24
14
|
|
|
25
15
|
from flask import (
|
|
26
16
|
Flask,
|
|
@@ -29,10 +19,10 @@ from flask import (
|
|
|
29
19
|
make_response,
|
|
30
20
|
jsonify,
|
|
31
21
|
render_template,
|
|
32
|
-
url_for,
|
|
33
22
|
)
|
|
34
23
|
from werkzeug.serving import run_simple
|
|
35
24
|
|
|
25
|
+
# Use Deployer base class
|
|
36
26
|
from ..viewer import Viewer
|
|
37
27
|
from ..parameters import (
|
|
38
28
|
Parameter,
|
|
@@ -47,42 +37,17 @@ from ..parameters import (
|
|
|
47
37
|
UnboundedIntegerParameter,
|
|
48
38
|
UnboundedFloatParameter,
|
|
49
39
|
ButtonAction,
|
|
50
|
-
ParameterType,
|
|
51
|
-
ActionType,
|
|
52
40
|
)
|
|
41
|
+
from ..support import ParameterUpdateWarning, plot_context
|
|
53
42
|
|
|
54
43
|
mpl.use("Agg")
|
|
55
44
|
|
|
56
45
|
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
46
|
@dataclass
|
|
80
47
|
class FlaskLayoutConfig:
|
|
81
48
|
"""Configuration for the Flask viewer layout."""
|
|
82
49
|
|
|
83
50
|
controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
|
|
84
|
-
figure_width: float = 8.0
|
|
85
|
-
figure_height: float = 6.0
|
|
86
51
|
controls_width_percent: int = 30
|
|
87
52
|
|
|
88
53
|
def __post_init__(self):
|
|
@@ -99,7 +64,7 @@ class FlaskLayoutConfig:
|
|
|
99
64
|
|
|
100
65
|
class FlaskDeployer:
|
|
101
66
|
"""
|
|
102
|
-
A deployment system for Viewer as a Flask web application.
|
|
67
|
+
A deployment system for Viewer as a Flask web application using the Deployer base class.
|
|
103
68
|
Creates a Flask app with routes for the UI, data API, and plot generation.
|
|
104
69
|
"""
|
|
105
70
|
|
|
@@ -108,12 +73,12 @@ class FlaskDeployer:
|
|
|
108
73
|
viewer: Viewer,
|
|
109
74
|
controls_position: str = "left",
|
|
110
75
|
fig_dpi: int = 300,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
controls_width_percent: int = 30,
|
|
114
|
-
static_folder: Optional[str] = None,
|
|
115
|
-
template_folder: Optional[str] = None,
|
|
76
|
+
controls_width_percent: int = 20,
|
|
77
|
+
suppress_warnings: bool = True,
|
|
116
78
|
debug: bool = False,
|
|
79
|
+
host: str = "127.0.0.1",
|
|
80
|
+
port: Optional[int] = None,
|
|
81
|
+
open_browser: bool = True,
|
|
117
82
|
):
|
|
118
83
|
"""
|
|
119
84
|
Initialize the Flask deployer.
|
|
@@ -127,515 +92,536 @@ class FlaskDeployer:
|
|
|
127
92
|
fig_dpi : int, optional
|
|
128
93
|
DPI of the figure - higher is better quality but takes longer to generate
|
|
129
94
|
figure_width : float, optional
|
|
130
|
-
|
|
95
|
+
Approximate width for template layout guidance (inches)
|
|
131
96
|
figure_height : float, optional
|
|
132
|
-
|
|
97
|
+
Approximate height for template layout guidance (inches)
|
|
133
98
|
controls_width_percent : int, optional
|
|
134
|
-
Width of the controls as a percentage of the total width
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
template_folder : str, optional
|
|
138
|
-
Custom path to template files
|
|
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
|
|
139
102
|
debug : bool, optional
|
|
140
|
-
Whether to enable debug mode
|
|
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).
|
|
141
110
|
"""
|
|
142
111
|
self.viewer = viewer
|
|
112
|
+
self.suppress_warnings = suppress_warnings
|
|
113
|
+
self._updating = False # Flag to check circular updates
|
|
114
|
+
|
|
115
|
+
# Flask specific configurations
|
|
143
116
|
self.config = FlaskLayoutConfig(
|
|
144
117
|
controls_position=controls_position,
|
|
145
|
-
figure_width=figure_width,
|
|
146
|
-
figure_height=figure_height,
|
|
147
118
|
controls_width_percent=controls_width_percent,
|
|
148
119
|
)
|
|
149
120
|
self.fig_dpi = fig_dpi
|
|
150
121
|
self.debug = debug
|
|
151
122
|
|
|
152
|
-
#
|
|
123
|
+
# Determine static and template folder paths
|
|
153
124
|
package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
154
|
-
self.static_folder =
|
|
155
|
-
self.template_folder =
|
|
156
|
-
|
|
157
|
-
#
|
|
158
|
-
self.
|
|
159
|
-
|
|
160
|
-
#
|
|
161
|
-
self.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
166
142
|
|
|
167
|
-
Returns
|
|
168
|
-
-------
|
|
169
|
-
Flask
|
|
170
|
-
The configured Flask application
|
|
171
|
-
"""
|
|
172
143
|
app = Flask(
|
|
173
|
-
|
|
144
|
+
"SydFlaskDeployer",
|
|
174
145
|
static_folder=self.static_folder,
|
|
175
146
|
template_folder=self.template_folder,
|
|
176
147
|
)
|
|
148
|
+
self.app = app
|
|
177
149
|
|
|
178
150
|
# Configure logging
|
|
179
151
|
if not self.debug:
|
|
180
152
|
log = logging.getLogger("werkzeug")
|
|
181
153
|
log.setLevel(logging.ERROR)
|
|
182
154
|
|
|
183
|
-
# Define routes
|
|
184
|
-
|
|
185
155
|
@app.route("/")
|
|
186
156
|
def home():
|
|
187
|
-
"""Render the main page."""
|
|
188
|
-
|
|
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)
|
|
189
160
|
|
|
190
161
|
@app.route("/init-data")
|
|
191
162
|
def init_data():
|
|
192
|
-
"""Provide initial parameter information."""
|
|
193
|
-
param_info = {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
|
177
|
+
)
|
|
199
178
|
|
|
200
179
|
@app.route("/plot")
|
|
201
180
|
def plot():
|
|
202
|
-
"""Generate and return
|
|
203
|
-
|
|
204
|
-
#
|
|
205
|
-
|
|
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.")
|
|
206
187
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
# Get the plot from the viewer
|
|
213
|
-
with _plot_context():
|
|
188
|
+
try:
|
|
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
|
|
214
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
|
+
)
|
|
215
198
|
|
|
216
199
|
# Save the plot to a buffer
|
|
217
200
|
buf = io.BytesIO()
|
|
218
201
|
fig.savefig(buf, format="png", bbox_inches="tight", dpi=self.fig_dpi)
|
|
219
202
|
buf.seek(0)
|
|
220
|
-
plt.close(fig)
|
|
203
|
+
plt.close(fig) # Ensure figure is closed
|
|
221
204
|
|
|
222
|
-
# Return the image
|
|
205
|
+
# Return the image as a response
|
|
223
206
|
response = make_response(send_file(buf, mimetype="image/png"))
|
|
224
|
-
response.headers["Cache-Control"] =
|
|
207
|
+
response.headers["Cache-Control"] = (
|
|
208
|
+
"no-cache, no-store, must-revalidate"
|
|
209
|
+
)
|
|
210
|
+
response.headers["Pragma"] = "no-cache"
|
|
211
|
+
response.headers["Expires"] = "0"
|
|
225
212
|
return response
|
|
226
213
|
|
|
227
214
|
except Exception as e:
|
|
228
|
-
app.logger.error(f"Error: {str(e)}")
|
|
229
|
-
|
|
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
|
|
230
218
|
|
|
231
219
|
@app.route("/update-param", methods=["POST"])
|
|
232
220
|
def update_param():
|
|
233
|
-
"""
|
|
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
|
|
238
|
+
|
|
234
239
|
try:
|
|
240
|
+
self._updating = True # Set base class flag
|
|
241
|
+
|
|
235
242
|
data = request.get_json()
|
|
236
243
|
name = data.get("name")
|
|
237
|
-
value = data.get("value")
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if name not in self.viewer.parameters:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
)
|
|
254
295
|
|
|
255
296
|
except Exception as e:
|
|
256
|
-
app.logger.error(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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()."
|
|
324
|
+
)
|
|
280
325
|
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
)
|
|
283
350
|
|
|
284
|
-
|
|
285
|
-
# pass the state dictionary to the callbacks
|
|
286
|
-
self.viewer.perform_callbacks(name)
|
|
287
|
-
finally:
|
|
288
|
-
self.in_callbacks = False
|
|
351
|
+
# --- Overridden Methods ---
|
|
289
352
|
|
|
290
|
-
def
|
|
353
|
+
def deploy(self) -> None:
|
|
291
354
|
"""
|
|
292
|
-
|
|
355
|
+
Deploy the viewer using Flask.
|
|
293
356
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
name : str
|
|
297
|
-
The name of the button parameter
|
|
357
|
+
Builds components (no-op), layout (Flask app/routes),
|
|
358
|
+
and then starts the server.
|
|
298
359
|
"""
|
|
299
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
self.
|
|
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
|
+
)
|
|
313
375
|
|
|
314
376
|
def _get_parameter_info(self, param: Parameter) -> Dict[str, Any]:
|
|
315
377
|
"""
|
|
316
378
|
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
|
|
379
|
+
(Identical to original, kept for clarity)
|
|
327
380
|
"""
|
|
381
|
+
# Add name/label
|
|
382
|
+
info = {
|
|
383
|
+
"name": param.name,
|
|
384
|
+
"value": param.value,
|
|
385
|
+
}
|
|
386
|
+
|
|
328
387
|
if isinstance(param, TextParameter):
|
|
329
|
-
|
|
388
|
+
info.update({"type": "text"})
|
|
330
389
|
elif isinstance(param, BooleanParameter):
|
|
331
|
-
|
|
390
|
+
info.update({"type": "boolean"})
|
|
332
391
|
elif isinstance(param, SelectionParameter):
|
|
333
|
-
|
|
392
|
+
info.update({"type": "selection", "options": param.options})
|
|
334
393
|
elif isinstance(param, MultipleSelectionParameter):
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
394
|
+
info.update(
|
|
395
|
+
{
|
|
396
|
+
"type": "multiple-selection",
|
|
397
|
+
"options": param.options,
|
|
398
|
+
}
|
|
399
|
+
)
|
|
340
400
|
elif isinstance(param, IntegerParameter):
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
401
|
+
info.update(
|
|
402
|
+
{
|
|
403
|
+
"type": "integer",
|
|
404
|
+
"min": param.min,
|
|
405
|
+
"max": param.max,
|
|
406
|
+
}
|
|
407
|
+
)
|
|
348
408
|
elif isinstance(param, FloatParameter):
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
409
|
+
info.update(
|
|
410
|
+
{
|
|
411
|
+
"type": "float",
|
|
412
|
+
"min": param.min,
|
|
413
|
+
"max": param.max,
|
|
414
|
+
"step": param.step,
|
|
415
|
+
}
|
|
416
|
+
)
|
|
357
417
|
elif isinstance(param, IntegerRangeParameter):
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
418
|
+
info.update(
|
|
419
|
+
{
|
|
420
|
+
"type": "integer-range",
|
|
421
|
+
"min": param.min,
|
|
422
|
+
"max": param.max,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
365
425
|
elif isinstance(param, FloatRangeParameter):
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
426
|
+
info.update(
|
|
427
|
+
{
|
|
428
|
+
"type": "float-range",
|
|
429
|
+
"min": param.min,
|
|
430
|
+
"max": param.max,
|
|
431
|
+
"step": param.step,
|
|
432
|
+
}
|
|
433
|
+
)
|
|
374
434
|
elif isinstance(param, UnboundedIntegerParameter):
|
|
375
|
-
|
|
435
|
+
info.update({"type": "unbounded-integer"})
|
|
376
436
|
elif isinstance(param, UnboundedFloatParameter):
|
|
377
|
-
|
|
437
|
+
info.update({"type": "unbounded-float", "step": param.step})
|
|
378
438
|
elif isinstance(param, ButtonAction):
|
|
379
|
-
|
|
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)
|
|
380
443
|
else:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
Parse request arguments into appropriate Python types based on parameter types.
|
|
386
|
-
|
|
387
|
-
Parameters
|
|
388
|
-
----------
|
|
389
|
-
args : MultiDict
|
|
390
|
-
Request arguments
|
|
444
|
+
# Fallback for unknown types
|
|
445
|
+
info.update(
|
|
446
|
+
{"type": "unknown", "value": str(param.value)}
|
|
447
|
+
) # Keep value as string
|
|
391
448
|
|
|
392
|
-
|
|
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:
|
|
402
|
-
continue
|
|
403
|
-
|
|
404
|
-
result[name] = self._parse_parameter_value(name, value)
|
|
405
|
-
|
|
406
|
-
return result
|
|
449
|
+
return info
|
|
407
450
|
|
|
408
451
|
def _parse_parameter_value(self, name: str, value: Any) -> Any:
|
|
409
452
|
"""
|
|
410
|
-
Parse a parameter value based on its type.
|
|
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).
|
|
411
455
|
|
|
412
|
-
|
|
413
|
-
----------
|
|
414
|
-
name : str
|
|
415
|
-
Parameter name
|
|
416
|
-
value : Any
|
|
417
|
-
Raw value
|
|
418
|
-
|
|
419
|
-
Returns
|
|
420
|
-
-------
|
|
421
|
-
Any
|
|
422
|
-
Parsed value
|
|
456
|
+
Raises ValueError or TypeError on parsing failure.
|
|
423
457
|
"""
|
|
424
|
-
|
|
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.")
|
|
425
461
|
|
|
426
|
-
|
|
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
|
|
462
|
+
param = self.viewer.parameters[name]
|
|
452
463
|
|
|
453
|
-
|
|
454
|
-
|
|
464
|
+
# Handle specific types
|
|
465
|
+
try:
|
|
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.
|
|
589
|
+
|
|
590
|
+
else:
|
|
591
|
+
# Fallback for unknown - return as is, let Parameter validate
|
|
455
592
|
return value
|
|
456
593
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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)
|
|
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
|
+
)
|
|
523
599
|
|
|
524
600
|
|
|
525
601
|
def _find_available_port(start_port=5000, max_attempts=100):
|
|
526
602
|
"""
|
|
527
603
|
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
|
|
604
|
+
(Identical to original)
|
|
545
605
|
"""
|
|
546
606
|
for port in range(start_port, start_port + max_attempts):
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
550
614
|
return port
|
|
551
|
-
|
|
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
|
|
552
619
|
continue
|
|
620
|
+
else:
|
|
621
|
+
# Re-raise other OS errors
|
|
622
|
+
raise e
|
|
553
623
|
|
|
624
|
+
# If loop finishes without finding a port
|
|
554
625
|
raise RuntimeError(
|
|
555
|
-
f"Could not find an available port
|
|
626
|
+
f"Could not find an available port between {start_port} and {start_port + max_attempts - 1}"
|
|
556
627
|
)
|
|
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
|