syd 0.1.7__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,62 +1,75 @@
1
- from typing import Dict, Any, Optional, List
2
- import warnings
3
- from functools import wraps
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
+ import os
8
+ import json
9
+ import logging
10
+ from typing import Dict, Any, Optional, List, Union, Callable, Type
4
11
  from dataclasses import dataclass
5
12
  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
13
  import matplotlib as mpl
15
14
  import matplotlib.pyplot as plt
15
+ from matplotlib.figure import Figure
16
+ import io
17
+ import numpy as np
18
+ import time
19
+ from functools import wraps
20
+ import webbrowser
21
+ import threading
22
+ import socket
16
23
 
17
- from ..parameters import ParameterUpdateWarning
18
- 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
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
30
35
 
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):
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):
47
58
  """
48
- Decorator to prevent a function from being called more than once every wait_time seconds.
59
+ Decorator to debounce function calls.
60
+ Prevents a function from being called too frequently.
49
61
  """
50
62
 
51
63
  def decorator(fn):
52
- last_called = [0.0] # Using list to maintain state in closure
64
+ last_call_time = [0]
53
65
 
54
66
  @wraps(fn)
55
67
  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
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
60
73
 
61
74
  return debounced
62
75
 
@@ -64,15 +77,13 @@ def debounce(wait_time):
64
77
 
65
78
 
66
79
  @dataclass
67
- class LayoutConfig:
68
- """Configuration for the viewer layout."""
80
+ class FlaskLayoutConfig:
81
+ """Configuration for the Flask viewer layout."""
69
82
 
70
83
  controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
71
84
  figure_width: float = 8.0
72
85
  figure_height: float = 6.0
73
86
  controls_width_percent: int = 30
74
- template_path: Optional[str] = None
75
- static_path: Optional[str] = None
76
87
 
77
88
  def __post_init__(self):
78
89
  valid_positions = ["left", "top", "right", "bottom"]
@@ -88,215 +99,543 @@ class LayoutConfig:
88
99
 
89
100
  class FlaskDeployer:
90
101
  """
91
- A deployment system for Viewer in Flask web applications.
92
- Built around the parameter component system for clean separation of concerns.
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.
93
104
  """
94
105
 
95
106
  def __init__(
96
107
  self,
97
108
  viewer: Viewer,
98
109
  controls_position: str = "left",
110
+ fig_dpi: int = 300,
99
111
  figure_width: float = 8.0,
100
112
  figure_height: float = 6.0,
101
113
  controls_width_percent: int = 30,
102
- continuous: bool = False,
103
- suppress_warnings: bool = False,
104
- host: str = "127.0.0.1",
105
- port: int = 5000,
106
- template_path: Optional[str] = None,
107
- static_path: Optional[str] = None,
114
+ static_folder: Optional[str] = None,
115
+ template_folder: Optional[str] = None,
116
+ debug: bool = False,
108
117
  ):
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
+ """
109
142
  self.viewer = viewer
110
- self.config = LayoutConfig(
143
+ self.config = FlaskLayoutConfig(
111
144
  controls_position=controls_position,
112
145
  figure_width=figure_width,
113
146
  figure_height=figure_height,
114
147
  controls_width_percent=controls_width_percent,
115
- template_path=template_path,
116
- static_path=static_path,
117
148
  )
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
133
-
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"
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,
138
176
  )
139
- static_path = self.config.static_path or str(Path(__file__).parent / "static")
140
177
 
141
- app = Flask(__name__, template_folder=template_path, static_folder=static_path)
178
+ # Configure logging
179
+ if not self.debug:
180
+ log = logging.getLogger("werkzeug")
181
+ log.setLevel(logging.ERROR)
182
+
183
+ # Define routes
142
184
 
143
- # Register routes
144
185
  @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,
165
- )
186
+ def home():
187
+ """Render the main page."""
188
+ return render_template("index.html", config=self.config)
166
189
 
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
190
+ @app.route("/init-data")
191
+ def init_data():
192
+ """Provide initial parameter information."""
193
+ param_info = {}
171
194
 
172
- 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
195
+ for name, param in self.viewer.parameters.items():
196
+ param_info[name] = self._get_parameter_info(param)
178
197
 
179
- @app.route("/state")
180
- def get_state():
181
- return jsonify(self.viewer.state)
198
+ return jsonify({"params": param_info})
182
199
 
183
200
  @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
201
+ 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)
189
206
 
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()
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
195
211
 
196
- return jsonify({"image": image_base64})
212
+ # Get the plot from the viewer
213
+ with _plot_context():
214
+ fig = self.viewer.plot(self.viewer.state)
197
215
 
198
- return app
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)
199
221
 
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
- )
222
+ # Return the image
223
+ response = make_response(send_file(buf, mimetype="image/png"))
224
+ response.headers["Cache-Control"] = "no-cache"
225
+ return response
207
226
 
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
227
+ except Exception as e:
228
+ app.logger.error(f"Error: {str(e)}")
229
+ return f"Error generating plot: {str(e)}", 500
215
230
 
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()]
231
+ @app.route("/update-param", methods=["POST"])
232
+ def update_param():
233
+ """Update a parameter and run its callbacks."""
234
+ try:
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)
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})
221
254
 
222
- @debounce(0.2)
255
+ 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)
223
262
  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."
230
- )
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:
231
275
  return
232
276
 
233
277
  try:
234
- self._updating = True
278
+ # Update the parameter value
279
+ self.viewer.parameters[name].value = value
235
280
 
236
- # Optionally suppress warnings during parameter updates
237
- with warnings.catch_warnings():
238
- if self.suppress_warnings:
239
- warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
281
+ # Run callbacks for this parameter
282
+ self.in_callbacks = True
240
283
 
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)
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
289
+
290
+ def _handle_action(self, name: str) -> None:
291
+ """
292
+ Handle a button action by executing its callback.
247
293
 
248
- # Update any components that changed due to dependencies
249
- self._sync_components_with_state(exclude=name)
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
250
308
 
309
+ # Pass the current state to the callback
310
+ param.callback(self.viewer.state)
251
311
  finally:
252
- self._updating = False
312
+ self.in_callbacks = False
253
313
 
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
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.
259
317
 
260
- component = self.parameter_components[name]
261
- if not component.matches_parameter(parameter):
262
- component.update_from_parameter(parameter)
318
+ Parameters
319
+ ----------
320
+ param : Parameter
321
+ The parameter to convert
263
322
 
264
- def _generate_plot(self) -> Optional[plt.Figure]:
265
- """Generate the current plot."""
266
- try:
267
- with _plot_context():
268
- figure = self.viewer.plot(self.viewer.state)
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.
269
386
 
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)
387
+ Parameters
388
+ ----------
389
+ args : MultiDict
390
+ Request arguments
273
391
 
274
- self._last_figure = figure
275
- return figure
276
- except Exception as e:
277
- print(f"Error generating plot: {e}")
278
- return None
392
+ Returns
393
+ -------
394
+ Dict[str, Any]
395
+ Parsed parameters
396
+ """
397
+ result = {}
279
398
 
280
- def deploy(self, open_browser: bool = True) -> None:
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
407
+
408
+ def _parse_parameter_value(self, name: str, value: Any) -> Any:
281
409
  """
282
- Deploy the viewer as a Flask web application.
410
+ Parse a parameter value based on its type.
283
411
 
284
412
  Parameters
285
413
  ----------
286
- open_browser : bool, optional
287
- Whether to automatically open the browser when deploying (default: True)
414
+ name : str
415
+ Parameter name
416
+ value : Any
417
+ Raw value
418
+
419
+ Returns
420
+ -------
421
+ Any
422
+ Parsed value
288
423
  """
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
- )
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