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.
@@ -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, List, Union, Callable, Type
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
- 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,
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
- Width of the figure in inches
95
+ Approximate width for template layout guidance (inches)
131
96
  figure_height : float, optional
132
- Height of the figure in inches
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
- static_folder : str, optional
136
- Custom path to static files
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
- # Use default folders if not specified
123
+ # Determine static and template folder paths
153
124
  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.
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
- __name__,
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
- return render_template("index.html", config=self.config)
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
- for name, param in self.viewer.parameters.items():
196
- param_info[name] = self._get_parameter_info(param)
197
-
198
- return jsonify({"params": param_info})
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 a plot based on the current state."""
203
- try:
204
- # Get parameters from request
205
- state = self._parse_request_args(request.args)
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
- # Update viewer state
208
- for name, value in state.items():
209
- if name in self.viewer.parameters:
210
- self.viewer.parameters[name].value = value
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"] = "no-cache"
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
- return f"Error generating plot: {str(e)}", 500
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
- """Update a parameter and run its callbacks."""
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
- is_action = data.get("action", False)
239
-
240
- if name not in self.viewer.parameters:
241
- return jsonify({"error": f"Parameter {name} not found"}), 404
242
-
243
- # Handle the parameter update or action
244
- if is_action:
245
- # For button actions, run the callback
246
- self._handle_action(name)
247
- else:
248
- # For normal parameters, update value and run callbacks
249
- parsed_value = self._parse_parameter_value(name, value)
250
- self._handle_parameter_update(name, parsed_value)
251
-
252
- # Return the updated state
253
- return jsonify({"success": True, "state": self.viewer.state})
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(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
-
277
- try:
278
- # Update the parameter value
279
- self.viewer.parameters[name].value = value
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
- # Run callbacks for this parameter
282
- self.in_callbacks = True
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
- # The viewer's perform_callbacks method will automatically
285
- # pass the state dictionary to the callbacks
286
- self.viewer.perform_callbacks(name)
287
- finally:
288
- self.in_callbacks = False
351
+ # --- Overridden Methods ---
289
352
 
290
- def _handle_action(self, name: str) -> None:
353
+ def deploy(self) -> None:
291
354
  """
292
- Handle a button action by executing its callback.
355
+ Deploy the viewer using Flask.
293
356
 
294
- Parameters
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
- # 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
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
- return {"type": "text", "value": param.value}
388
+ info.update({"type": "text"})
330
389
  elif isinstance(param, BooleanParameter):
331
- return {"type": "boolean", "value": param.value}
390
+ info.update({"type": "boolean"})
332
391
  elif isinstance(param, SelectionParameter):
333
- return {"type": "selection", "value": param.value, "options": param.options}
392
+ info.update({"type": "selection", "options": param.options})
334
393
  elif isinstance(param, MultipleSelectionParameter):
335
- return {
336
- "type": "multiple-selection",
337
- "value": param.value,
338
- "options": param.options,
339
- }
394
+ info.update(
395
+ {
396
+ "type": "multiple-selection",
397
+ "options": param.options,
398
+ }
399
+ )
340
400
  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
- }
401
+ info.update(
402
+ {
403
+ "type": "integer",
404
+ "min": param.min,
405
+ "max": param.max,
406
+ }
407
+ )
348
408
  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
- }
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
- return {
359
- "type": "integer-range",
360
- "value": param.value,
361
- "name": param.name,
362
- "min": param.min,
363
- "max": param.max,
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
- 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
- }
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
- return {"type": "unbounded-integer", "value": param.value}
435
+ info.update({"type": "unbounded-integer"})
376
436
  elif isinstance(param, UnboundedFloatParameter):
377
- return {"type": "unbounded-float", "value": param.value, "step": param.step}
437
+ info.update({"type": "unbounded-float", "step": param.step})
378
438
  elif isinstance(param, ButtonAction):
379
- return {"type": "button", "label": param.label, "is_action": True}
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
- 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
444
+ # Fallback for unknown types
445
+ info.update(
446
+ {"type": "unknown", "value": str(param.value)}
447
+ ) # Keep value as string
391
448
 
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:
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
- Parameters
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
- param = self.viewer.parameters[name]
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
- 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
462
+ param = self.viewer.parameters[name]
452
463
 
453
- # First check if the value is already in options (exact match)
454
- if value in param.options:
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
- # 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)
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
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
548
- try:
549
- s.bind(("127.0.0.1", port))
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
- except OSError:
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 after {max_attempts} attempts starting from {start_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