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.
@@ -1,133 +1,92 @@
1
- from typing import Dict, Any, Optional
2
- from dataclasses import dataclass
3
- import threading
4
- from flask import Flask, render_template_string, jsonify, request
5
- import matplotlib
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
- matplotlib.use("Agg") # Use Agg backend for thread safety
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 base64
11
- from contextlib import contextmanager
17
+ import numpy as np
18
+ import time
19
+ from functools import wraps
12
20
  import webbrowser
13
-
14
- from ..interactive_viewer import InteractiveViewer
15
- from .components import WebComponentCollection
16
-
17
- # Create a template constant to hold our HTML template
18
- PAGE_TEMPLATE = """
19
- <!DOCTYPE html>
20
- <html>
21
- <head>
22
- <title>Interactive Viewer</title>
23
- {% for css in required_css %}
24
- <link rel="stylesheet" href="{{ css }}">
25
- {% endfor %}
26
- <style>
27
- {{ custom_styles | safe }}
28
- .controls {
29
- {% if config.is_horizontal %}
30
- width: {{ config.controls_width_percent }}%;
31
- float: left;
32
- padding-right: 20px;
33
- {% endif %}
34
- }
35
- .plot-container {
36
- {% if config.is_horizontal %}
37
- width: {{ 100 - config.controls_width_percent }}%;
38
- float: left;
39
- {% endif %}
40
- }
41
- #plot {
42
- width: 100%;
43
- height: auto;
44
- }
45
- </style>
46
- </head>
47
- <body>
48
- <div class="container-fluid">
49
- <div class="row">
50
- <div class="controls">
51
- {{ components_html | safe }}
52
- </div>
53
- <div class="plot-container">
54
- <img id="plot" src="{{ initial_plot }}">
55
- </div>
56
- </div>
57
- </div>
58
-
59
- {% for js in required_js %}
60
- <script src="{{ js }}"></script>
61
- {% endfor %}
62
-
63
- <script>
64
- function updateParameter(name, value) {
65
- fetch('/update_parameter', {
66
- method: 'POST',
67
- headers: {
68
- 'Content-Type': 'application/json',
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 in Flask deployment."""
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 FlaskDeployment:
142
- """A deployment system for InteractiveViewer using Flask to create a web interface."""
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: InteractiveViewer,
147
- layout_config: Optional[FlaskLayoutConfig] = None,
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
- if not isinstance(viewer, InteractiveViewer):
150
- raise TypeError(
151
- f"viewer must be an InteractiveViewer, got {type(viewer).__name__}"
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 = layout_config or FlaskLayoutConfig()
156
- self.components = WebComponentCollection()
157
-
158
- # Initialize Flask app
159
- self.app = Flask(__name__)
160
- self._setup_routes()
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
- # Store current figure
163
- self._current_figure = None
164
- self._figure_lock = threading.Lock()
178
+ # Configure logging
179
+ if not self.debug:
180
+ log = logging.getLogger("werkzeug")
181
+ log.setLevel(logging.ERROR)
165
182
 
166
- def _setup_routes(self):
167
- """Set up the Flask routes for the application."""
183
+ # Define routes
168
184
 
169
- @self.app.route("/")
170
- def index():
171
- return self._render_page()
185
+ @app.route("/")
186
+ def home():
187
+ """Render the main page."""
188
+ return render_template("index.html", config=self.config)
172
189
 
173
- @self.app.route("/update_parameter", methods=["POST"])
174
- def update_parameter():
175
- data = request.json
176
- name = data.get("name")
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
- print(f"Received parameter update: {name} = {value}") # Debug log
195
+ for name, param in self.viewer.parameters.items():
196
+ param_info[name] = self._get_parameter_info(param)
180
197
 
181
- if name not in self.viewer.parameters:
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
- print(f"Setting parameter value: {name} = {value}") # Debug log
187
- self.viewer.set_parameter_value(name, value)
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
- @self.app.route("/button_click", methods=["POST"])
210
- def button_click():
211
- data = request.json
212
- name = data.get("name")
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
- print(f"Received button click: {name}") # Debug log
212
+ # Get the plot from the viewer
213
+ with _plot_context():
214
+ fig = self.viewer.plot(self.viewer.state)
215
215
 
216
- if name not in self.viewer.parameters:
217
- print(f"Button {name} not found in viewer parameters") # Debug log
218
- return jsonify({"error": f"Parameter {name} not found"}), 404
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
- parameter = self.viewer.parameters[name]
222
- if hasattr(parameter, "callback"):
223
- print(f"Executing callback for button: {name}") # Debug log
224
- parameter.callback(self.viewer.get_state())
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
- print(f"No callback found for button: {name}") # Debug log
227
-
228
- # Update the plot after button click
229
- print("Updating plot after button click...") # Debug log
230
- self._update_plot()
231
-
232
- updates = self._get_parameter_updates()
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
- print(f"Error handling button click: {str(e)}") # Debug log
246
- return jsonify({"error": str(e)}), 400
247
-
248
- def _create_components(self):
249
- """Create web components for all parameters."""
250
- for name, param in self.viewer.parameters.items():
251
- self.components.add_component(name, param)
252
-
253
- @contextmanager
254
- def _plot_context(self):
255
- """Context manager for thread-safe plotting."""
256
- plt.ioff()
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
- yield
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
- plt.ion()
261
-
262
- def _update_plot(self) -> None:
263
- """Update the plot with current state."""
264
- state = self.viewer.get_state()
265
- print(f"Updating plot with state: {state}") # Debug log
266
-
267
- with self._plot_context(), self._figure_lock:
268
- new_fig = self.viewer.plot(state)
269
- plt.close(self._current_figure) # Close old figure
270
- self._current_figure = new_fig
271
- print("Plot updated successfully") # Debug log
272
-
273
- def _get_current_plot_data(self) -> str:
274
- """Get the current plot as a base64 encoded PNG."""
275
- with self._figure_lock:
276
- if self._current_figure is None:
277
- return ""
278
-
279
- buffer = io.BytesIO()
280
- self._current_figure.savefig(
281
- buffer,
282
- format="png",
283
- bbox_inches="tight",
284
- dpi=300,
285
- )
286
- buffer.seek(0)
287
- image_png = buffer.getvalue()
288
- buffer.close()
289
-
290
- graphic = base64.b64encode(image_png).decode("utf-8")
291
- return f"data:image/png;base64,{graphic}"
292
-
293
- def _get_parameter_updates(self) -> Dict[str, Any]:
294
- """Get JavaScript updates for all parameters."""
295
- updates = {}
296
- state = self.viewer.get_state()
297
- for name, value in state.items():
298
- # Skip button parameters since they don't have a meaningful value to update
299
- if (
300
- hasattr(self.viewer.parameters[name], "_is_button")
301
- and self.viewer.parameters[name]._is_button
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
- def deploy(self) -> None:
324
- """Deploy the interactive viewer as a web application."""
325
- with self.viewer._deploy_app():
326
- # Create components
327
- self._create_components()
328
-
329
- # Open browser
330
- webbrowser.open(f"http://{self.config.host}:{self.config.port}")
331
-
332
- # Start Flask app
333
- self.app.run(
334
- host=self.config.host,
335
- port=self.config.port,
336
- debug=False, # Debug mode doesn't work well with matplotlib
337
- use_reloader=False, # Prevent double startup
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