syd 0.1.6__py3-none-any.whl → 0.1.7__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,81 @@
1
- from typing import Dict, Any, Optional
1
+ from typing import Dict, Any, Optional, List
2
+ import warnings
3
+ from functools import wraps
2
4
  from dataclasses import dataclass
5
+ from contextlib import contextmanager
6
+ from time import time
7
+ import base64
8
+ from io import BytesIO
3
9
  import threading
4
- from flask import Flask, render_template_string, jsonify, request
5
- import matplotlib
10
+ import webbrowser
11
+ from pathlib import Path
6
12
 
7
- matplotlib.use("Agg") # Use Agg backend for thread safety
13
+ from flask import Flask, render_template, jsonify, request, Response
14
+ import matplotlib as mpl
8
15
  import matplotlib.pyplot as plt
9
- import io
10
- import base64
11
- from contextlib import contextmanager
12
- import webbrowser
13
16
 
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
- """
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
+
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
116
64
 
117
65
 
118
66
  @dataclass
119
- class FlaskLayoutConfig:
120
- """Configuration for the viewer layout in Flask deployment."""
67
+ class LayoutConfig:
68
+ """Configuration for the viewer layout."""
121
69
 
122
- controls_position: str = "left" # Options are: 'left', 'top'
70
+ controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
123
71
  figure_width: float = 8.0
124
72
  figure_height: float = 6.0
125
73
  controls_width_percent: int = 30
126
- port: int = 5000
127
- host: str = "localhost"
74
+ template_path: Optional[str] = None
75
+ static_path: Optional[str] = None
128
76
 
129
77
  def __post_init__(self):
130
- valid_positions = ["left", "top"]
78
+ valid_positions = ["left", "top", "right", "bottom"]
131
79
  if self.controls_position not in valid_positions:
132
80
  raise ValueError(
133
81
  f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
@@ -135,204 +83,220 @@ class FlaskLayoutConfig:
135
83
 
136
84
  @property
137
85
  def is_horizontal(self) -> bool:
138
- return self.controls_position == "left"
86
+ return self.controls_position == "left" or self.controls_position == "right"
139
87
 
140
88
 
141
- class FlaskDeployment:
142
- """A deployment system for InteractiveViewer using Flask to create a web interface."""
89
+ class FlaskDeployer:
90
+ """
91
+ A deployment system for Viewer in Flask web applications.
92
+ Built around the parameter component system for clean separation of concerns.
93
+ """
143
94
 
144
95
  def __init__(
145
96
  self,
146
- viewer: InteractiveViewer,
147
- layout_config: Optional[FlaskLayoutConfig] = None,
97
+ viewer: Viewer,
98
+ 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,
104
+ host: str = "127.0.0.1",
105
+ port: int = 5000,
106
+ template_path: Optional[str] = None,
107
+ static_path: Optional[str] = None,
148
108
  ):
149
- if not isinstance(viewer, InteractiveViewer):
150
- raise TypeError(
151
- f"viewer must be an InteractiveViewer, got {type(viewer).__name__}"
152
- )
153
-
154
109
  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()
161
-
162
- # Store current figure
163
- self._current_figure = None
164
- self._figure_lock = threading.Lock()
110
+ self.config = LayoutConfig(
111
+ controls_position=controls_position,
112
+ figure_width=figure_width,
113
+ figure_height=figure_height,
114
+ controls_width_percent=controls_width_percent,
115
+ template_path=template_path,
116
+ static_path=static_path,
117
+ )
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"
138
+ )
139
+ static_path = self.config.static_path or str(Path(__file__).parent / "static")
165
140
 
166
- def _setup_routes(self):
167
- """Set up the Flask routes for the application."""
141
+ app = Flask(__name__, template_folder=template_path, static_folder=static_path)
168
142
 
169
- @self.app.route("/")
143
+ # Register routes
144
+ @app.route("/")
170
145
  def index():
171
- return self._render_page()
172
-
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")
178
-
179
- print(f"Received parameter update: {name} = {value}") # Debug log
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
+ )
180
166
 
167
+ @app.route("/update/<name>", methods=["POST"])
168
+ def update_parameter(name: str):
181
169
  if name not in self.viewer.parameters:
182
- print(f"Parameter {name} not found in viewer parameters") # Debug log
183
170
  return jsonify({"error": f"Parameter {name} not found"}), 404
184
171
 
185
172
  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
- )
173
+ value = request.json["value"]
174
+ self._handle_parameter_update(name, value)
175
+ return jsonify({"success": True})
205
176
  except Exception as e:
206
- print(f"Error updating parameter: {str(e)}") # Debug log
207
177
  return jsonify({"error": str(e)}), 400
208
178
 
209
- @self.app.route("/button_click", methods=["POST"])
210
- def button_click():
211
- data = request.json
212
- name = data.get("name")
179
+ @app.route("/state")
180
+ def get_state():
181
+ return jsonify(self.viewer.state)
213
182
 
214
- print(f"Received button click: {name}") # Debug log
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
215
189
 
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
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()
219
195
 
220
- 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())
225
- 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
- )
244
- except Exception as e:
245
- print(f"Error handling button click: {str(e)}") # Debug log
246
- return jsonify({"error": str(e)}), 400
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
+ )
247
207
 
248
- def _create_components(self):
249
- """Create web components for all parameters."""
250
208
  for name, param in self.viewer.parameters.items():
251
- self.components.add_component(name, param)
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."
230
+ )
231
+ return
252
232
 
253
- @contextmanager
254
- def _plot_context(self):
255
- """Context manager for thread-safe plotting."""
256
- plt.ioff()
257
233
  try:
258
- yield
234
+ self._updating = True
235
+
236
+ # Optionally suppress warnings during parameter updates
237
+ with warnings.catch_warnings():
238
+ if self.suppress_warnings:
239
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
240
+
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)
247
+
248
+ # Update any components that changed due to dependencies
249
+ self._sync_components_with_state(exclude=name)
250
+
259
251
  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
- ):
252
+ self._updating = False
253
+
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:
303
258
  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
259
 
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()
260
+ component = self.parameter_components[name]
261
+ if not component.matches_parameter(parameter):
262
+ component.update_from_parameter(parameter)
328
263
 
329
- # Open browser
330
- webbrowser.open(f"http://{self.config.host}:{self.config.port}")
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)
269
+
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)
273
+
274
+ self._last_figure = figure
275
+ return figure
276
+ except Exception as e:
277
+ print(f"Error generating plot: {e}")
278
+ return None
279
+
280
+ def deploy(self, open_browser: bool = True) -> None:
281
+ """
282
+ Deploy the viewer as a Flask web application.
283
+
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()
331
295
 
332
296
  # Start Flask app
333
297
  self.app.run(
334
- host=self.config.host,
335
- port=self.config.port,
298
+ host=self.host,
299
+ port=self.port,
336
300
  debug=False, # Debug mode doesn't work well with matplotlib
337
- use_reloader=False, # Prevent double startup
301
+ use_reloader=False, # Reloader causes issues with matplotlib
338
302
  )
@@ -0,0 +1,82 @@
1
+ /* General layout */
2
+ html, body {
3
+ height: 100vh;
4
+ margin: 0;
5
+ padding: 0;
6
+ overflow: hidden;
7
+ }
8
+
9
+ .viewer-container {
10
+ height: 100vh;
11
+ width: 100vw;
12
+ }
13
+
14
+ /* Controls container */
15
+ .controls-container {
16
+ background-color: #f8f9fa;
17
+ border-right: 1px solid #dee2e6;
18
+ overflow-y: auto;
19
+ }
20
+
21
+ .w-controls {
22
+ width: var(--controls-width);
23
+ }
24
+
25
+ /* Plot container */
26
+ .plot-container {
27
+ background-color: white;
28
+ min-height: 300px;
29
+ }
30
+
31
+ .plot-container img {
32
+ max-width: var(--figure-width);
33
+ max-height: var(--figure-height);
34
+ object-fit: contain;
35
+ }
36
+
37
+ /* Form controls */
38
+ .form-control {
39
+ margin-bottom: 1rem;
40
+ }
41
+
42
+ .form-label {
43
+ font-weight: 500;
44
+ margin-bottom: 0.5rem;
45
+ }
46
+
47
+ /* Range inputs */
48
+ input[type="range"] {
49
+ width: 100%;
50
+ }
51
+
52
+ output {
53
+ margin-left: 0.5rem;
54
+ }
55
+
56
+ /* Multiple select */
57
+ select[multiple] {
58
+ height: auto;
59
+ min-height: 100px;
60
+ }
61
+
62
+ /* Button styling */
63
+ .btn-primary {
64
+ width: 100%;
65
+ margin-bottom: 1rem;
66
+ }
67
+
68
+ /* Responsive adjustments */
69
+ @media (max-width: 768px) {
70
+ .viewer-container {
71
+ flex-direction: column !important;
72
+ }
73
+
74
+ .controls-container {
75
+ width: 100% !important;
76
+ max-height: 40vh;
77
+ }
78
+
79
+ .plot-container {
80
+ height: 60vh;
81
+ }
82
+ }