syd 0.1.7__py3-none-any.whl → 1.0.0__py3-none-any.whl

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