syd 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syd/__init__.py +3 -3
- syd/flask_deployment/__init__.py +7 -0
- syd/flask_deployment/deployer.py +594 -291
- syd/flask_deployment/static/__init__.py +1 -0
- syd/flask_deployment/static/css/styles.css +226 -19
- syd/flask_deployment/static/js/viewer.js +744 -0
- syd/flask_deployment/templates/__init__.py +1 -0
- syd/flask_deployment/templates/index.html +34 -0
- syd/flask_deployment/testing_principles.md +300 -0
- syd/notebook_deployment/__init__.py +1 -1
- syd/notebook_deployment/deployer.py +139 -53
- syd/notebook_deployment/widgets.py +214 -123
- syd/parameters.py +295 -393
- syd/support.py +168 -0
- syd/{interactive_viewer.py → viewer.py} +393 -470
- syd-0.2.0.dist-info/METADATA +126 -0
- syd-0.2.0.dist-info/RECORD +19 -0
- syd/flask_deployment/components.py +0 -497
- syd/flask_deployment/static/js/components.js +0 -51
- syd/flask_deployment/templates/base.html +0 -26
- syd/flask_deployment/templates/viewer.html +0 -97
- syd-0.1.6.dist-info/METADATA +0 -106
- syd-0.1.6.dist-info/RECORD +0 -18
- {syd-0.1.6.dist-info → syd-0.2.0.dist-info}/WHEEL +0 -0
- {syd-0.1.6.dist-info → syd-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file exists to make the directory a proper Python package
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Syd Viewer</title>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="viewer-container" data-controls-position="{{ config.controls_position }}">
|
|
11
|
+
<div class="controls-container" data-width-percent="{{ config.controls_width_percent }}"
|
|
12
|
+
{% if config.is_horizontal %}style="width: {{ config.controls_width_percent }}%;"{% else %}style="height: {{ config.controls_width_percent }}%;"{% endif %}>
|
|
13
|
+
<div id="controls-container">
|
|
14
|
+
<!-- Controls will be dynamically generated via JavaScript -->
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="plot-container"
|
|
19
|
+
{% if config.is_horizontal %}style="width: calc(100% - {{ config.controls_width_percent }}%);"{% else %}style="height: calc(100% - {{ config.controls_width_percent }}%);"{% endif %}>
|
|
20
|
+
<img id="plot-image" width="100%" height="100%">
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Store config as data attributes for JS to access -->
|
|
25
|
+
<div id="viewer-config"
|
|
26
|
+
data-figure-width="{{ config.figure_width }}"
|
|
27
|
+
data-figure-height="{{ config.figure_height }}"
|
|
28
|
+
data-controls-position="{{ config.controls_position }}"
|
|
29
|
+
data-controls-width-percent="{{ config.controls_width_percent }}"
|
|
30
|
+
style="display:none;"></div>
|
|
31
|
+
|
|
32
|
+
<script src="{{ url_for('static', filename='js/viewer.js') }}"></script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Testing Principles for SYD Flask Deployment
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Testing an interactive GUI application like the SYD Flask deployment presents unique challenges. While traditional test suites focus on programmatic API testing, interactive GUIs require a different approach that combines several testing methodologies. This document outlines a comprehensive testing strategy for the SYD Flask deployment.
|
|
6
|
+
|
|
7
|
+
## Testing Pyramid for Interactive GUIs
|
|
8
|
+
|
|
9
|
+
For the SYD Flask deployment, we recommend implementing a modified testing pyramid with the following layers:
|
|
10
|
+
|
|
11
|
+
1. **Unit Tests** - Test isolated components and functions
|
|
12
|
+
2. **Integration Tests** - Test interactions between components
|
|
13
|
+
3. **API Tests** - Test Flask API endpoints
|
|
14
|
+
4. **Headless Browser Tests** - Test GUI interactions programmatically
|
|
15
|
+
5. **Visual Regression Tests** - Ensure UI appearance remains consistent
|
|
16
|
+
6. **Manual Testing Checklists** - Structured human verification
|
|
17
|
+
|
|
18
|
+
## 1. Unit Tests
|
|
19
|
+
|
|
20
|
+
### What to Test
|
|
21
|
+
- Individual Flask routes
|
|
22
|
+
- Component HTML generation functions
|
|
23
|
+
- Parameter update handlers
|
|
24
|
+
- State synchronization logic
|
|
25
|
+
- Plot generation utilities
|
|
26
|
+
|
|
27
|
+
### Implementation Strategy
|
|
28
|
+
```python
|
|
29
|
+
import unittest
|
|
30
|
+
from unittest import mock
|
|
31
|
+
from syd.flask_deployment.deployer import FlaskDeployer
|
|
32
|
+
from syd.flask_deployment.components import create_component
|
|
33
|
+
|
|
34
|
+
class TestFlaskDeployerComponents(unittest.TestCase):
|
|
35
|
+
def setUp(self):
|
|
36
|
+
self.mock_viewer = mock.MagicMock()
|
|
37
|
+
self.deployer = FlaskDeployer(self.mock_viewer)
|
|
38
|
+
|
|
39
|
+
def test_create_float_component(self):
|
|
40
|
+
mock_param = mock.MagicMock()
|
|
41
|
+
mock_param.name = "test_param"
|
|
42
|
+
mock_param.value = 5.0
|
|
43
|
+
component = create_component(mock_param)
|
|
44
|
+
self.assertIn("test_param", component.html)
|
|
45
|
+
self.assertIn("5.0", component.html)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 2. Integration Tests
|
|
49
|
+
|
|
50
|
+
### What to Test
|
|
51
|
+
- Parameter creation to HTML rendering pipeline
|
|
52
|
+
- Parameter update → state sync → component update flow
|
|
53
|
+
- Plot generation and serving workflow
|
|
54
|
+
|
|
55
|
+
### Implementation Strategy
|
|
56
|
+
```python
|
|
57
|
+
def test_parameter_update_state_sync():
|
|
58
|
+
# Create real viewer with test parameters
|
|
59
|
+
viewer = Viewer()
|
|
60
|
+
viewer.add_float('test_param', value=1.0, min=0, max=10)
|
|
61
|
+
|
|
62
|
+
# Create deployer with this viewer
|
|
63
|
+
deployer = FlaskDeployer(viewer)
|
|
64
|
+
|
|
65
|
+
# Update parameter and check if components reflect the change
|
|
66
|
+
deployer._handle_parameter_update('test_param', 5.0)
|
|
67
|
+
|
|
68
|
+
# Verify component has updated
|
|
69
|
+
component = deployer.parameter_components['test_param']
|
|
70
|
+
self.assertEqual(component.value, 5.0)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 3. API Tests
|
|
74
|
+
|
|
75
|
+
### What to Test
|
|
76
|
+
- All Flask endpoints (`/`, `/update/<name>`, `/state`, `/plot`)
|
|
77
|
+
- Response formats and status codes
|
|
78
|
+
- Error handling
|
|
79
|
+
- Race conditions with concurrent requests
|
|
80
|
+
|
|
81
|
+
### Implementation Strategy
|
|
82
|
+
```python
|
|
83
|
+
def test_update_parameter_endpoint():
|
|
84
|
+
viewer = Viewer()
|
|
85
|
+
viewer.add_float('test_param', value=1.0, min=0, max=10)
|
|
86
|
+
deployer = FlaskDeployer(viewer)
|
|
87
|
+
app = deployer.app
|
|
88
|
+
|
|
89
|
+
with app.test_client() as client:
|
|
90
|
+
# Test successful update
|
|
91
|
+
response = client.post('/update/test_param',
|
|
92
|
+
json={'value': 5.0})
|
|
93
|
+
self.assertEqual(response.status_code, 200)
|
|
94
|
+
self.assertEqual(viewer.state['test_param'], 5.0)
|
|
95
|
+
|
|
96
|
+
# Test parameter not found
|
|
97
|
+
response = client.post('/update/nonexistent',
|
|
98
|
+
json={'value': 5.0})
|
|
99
|
+
self.assertEqual(response.status_code, 404)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 4. Headless Browser Tests
|
|
103
|
+
|
|
104
|
+
### What to Test
|
|
105
|
+
- User interactions with the web interface
|
|
106
|
+
- Parameter widget interactions
|
|
107
|
+
- UI updates in response to parameter changes
|
|
108
|
+
- Plot refreshes
|
|
109
|
+
|
|
110
|
+
### Implementation Strategy
|
|
111
|
+
|
|
112
|
+
Use Selenium or Playwright to automate browser interactions:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from selenium import webdriver
|
|
116
|
+
from selenium.webdriver.common.by import By
|
|
117
|
+
|
|
118
|
+
def test_slider_interaction():
|
|
119
|
+
# Start the Flask app in a separate thread
|
|
120
|
+
viewer = create_test_viewer()
|
|
121
|
+
deployer = FlaskDeployer(viewer)
|
|
122
|
+
thread = threading.Thread(target=deployer.app.run)
|
|
123
|
+
thread.daemon = True
|
|
124
|
+
thread.start()
|
|
125
|
+
|
|
126
|
+
# Use Selenium to interact with the web interface
|
|
127
|
+
driver = webdriver.Chrome()
|
|
128
|
+
driver.get('http://localhost:5000')
|
|
129
|
+
|
|
130
|
+
# Find a slider and interact with it
|
|
131
|
+
slider = driver.find_element(By.ID, 'param_amplitude')
|
|
132
|
+
slider.send_keys(webdriver.Keys.ARROW_RIGHT * 5) # Increase value
|
|
133
|
+
|
|
134
|
+
# Verify the plot updates
|
|
135
|
+
# This requires some way to detect that the plot has changed
|
|
136
|
+
# Could check the src attribute of the image element changes
|
|
137
|
+
time.sleep(1) # Allow time for update
|
|
138
|
+
plot_src = driver.find_element(By.ID, 'plot').get_attribute('src')
|
|
139
|
+
self.assertTrue(len(plot_src) > 0)
|
|
140
|
+
|
|
141
|
+
driver.quit()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## 5. Visual Regression Tests
|
|
145
|
+
|
|
146
|
+
### What to Test
|
|
147
|
+
- UI appearance remains consistent across changes
|
|
148
|
+
- Components render correctly with different values
|
|
149
|
+
- Layout adapts properly to different screen sizes
|
|
150
|
+
|
|
151
|
+
### Implementation Strategy
|
|
152
|
+
|
|
153
|
+
Use tools like Percy or BackstopJS to capture and compare screenshots:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
def test_visual_appearance():
|
|
157
|
+
# Set up test app with standard parameters
|
|
158
|
+
app = create_standard_test_app()
|
|
159
|
+
|
|
160
|
+
# Use a tool like Percy
|
|
161
|
+
percy_snapshot(driver, 'Main view')
|
|
162
|
+
|
|
163
|
+
# Change to different layout
|
|
164
|
+
driver.get('http://localhost:5000?controls_position=right')
|
|
165
|
+
percy_snapshot(driver, 'Right controls layout')
|
|
166
|
+
|
|
167
|
+
# Test responsive views
|
|
168
|
+
driver.set_window_size(400, 800) # Mobile size
|
|
169
|
+
percy_snapshot(driver, 'Mobile view')
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## 6. Manual Testing Checklists
|
|
173
|
+
|
|
174
|
+
For some aspects of interactive GUI applications, manual testing remains necessary. Create structured checklists for testers:
|
|
175
|
+
|
|
176
|
+
```markdown
|
|
177
|
+
# Manual Testing Checklist
|
|
178
|
+
|
|
179
|
+
## Parameter Interactions
|
|
180
|
+
- [ ] Sliders respond smoothly to mouse drag
|
|
181
|
+
- [ ] Number inputs accept keyboard input
|
|
182
|
+
- [ ] Range sliders correctly display and update both values
|
|
183
|
+
- [ ] Toggles switch state when clicked
|
|
184
|
+
- [ ] Dropdowns show all options and select correctly
|
|
185
|
+
|
|
186
|
+
## Plot Updates
|
|
187
|
+
- [ ] Plot updates immediately after parameter change when continuous=True
|
|
188
|
+
- [ ] Plot updates only after interaction ends when continuous=False
|
|
189
|
+
- [ ] Plot maintains aspect ratio when window is resized
|
|
190
|
+
- [ ] No visual glitches during plot transitions
|
|
191
|
+
|
|
192
|
+
## Layouts
|
|
193
|
+
- [ ] Test all layouts: left, right, top, bottom
|
|
194
|
+
- [ ] Verify responsive behavior on different screen sizes
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Automated Testing Framework
|
|
198
|
+
|
|
199
|
+
To bring this all together, we recommend implementing a pytest-based testing framework:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
# conftest.py
|
|
203
|
+
import pytest
|
|
204
|
+
from syd import make_viewer
|
|
205
|
+
import numpy as np
|
|
206
|
+
import matplotlib.pyplot as plt
|
|
207
|
+
|
|
208
|
+
@pytest.fixture
|
|
209
|
+
def standard_test_viewer():
|
|
210
|
+
"""Create a standard viewer for testing."""
|
|
211
|
+
viewer = make_viewer()
|
|
212
|
+
|
|
213
|
+
def plot(state):
|
|
214
|
+
fig, ax = plt.subplots()
|
|
215
|
+
x = np.linspace(0, 10, 1000)
|
|
216
|
+
y = state['amplitude'] * np.sin(state['frequency'] * x)
|
|
217
|
+
ax.plot(x, y)
|
|
218
|
+
return fig
|
|
219
|
+
|
|
220
|
+
viewer.set_plot(plot)
|
|
221
|
+
viewer.add_float('amplitude', value=1.0, min=0, max=2)
|
|
222
|
+
viewer.add_float('frequency', value=1.0, min=0.1, max=5)
|
|
223
|
+
|
|
224
|
+
return viewer
|
|
225
|
+
|
|
226
|
+
@pytest.fixture
|
|
227
|
+
def flask_app(standard_test_viewer):
|
|
228
|
+
"""Create a Flask app for testing."""
|
|
229
|
+
from syd.flask_deployment.deployer import FlaskDeployer
|
|
230
|
+
deployer = FlaskDeployer(standard_test_viewer)
|
|
231
|
+
return deployer.app
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Continuous Integration Setup
|
|
235
|
+
|
|
236
|
+
Integrate these tests into a CI pipeline:
|
|
237
|
+
|
|
238
|
+
```yaml
|
|
239
|
+
# .github/workflows/flask-tests.yml
|
|
240
|
+
name: Flask Deployment Tests
|
|
241
|
+
|
|
242
|
+
on:
|
|
243
|
+
push:
|
|
244
|
+
paths:
|
|
245
|
+
- 'syd/flask_deployment/**'
|
|
246
|
+
- 'tests/flask/**'
|
|
247
|
+
|
|
248
|
+
jobs:
|
|
249
|
+
test:
|
|
250
|
+
runs-on: ubuntu-latest
|
|
251
|
+
steps:
|
|
252
|
+
- uses: actions/checkout@v2
|
|
253
|
+
- name: Set up Python
|
|
254
|
+
uses: actions/setup-python@v2
|
|
255
|
+
with:
|
|
256
|
+
python-version: '3.9'
|
|
257
|
+
- name: Install dependencies
|
|
258
|
+
run: |
|
|
259
|
+
python -m pip install --upgrade pip
|
|
260
|
+
pip install -e ".[test]"
|
|
261
|
+
- name: Run unit and integration tests
|
|
262
|
+
run: pytest tests/flask/test_unit_integration.py
|
|
263
|
+
- name: Run API tests
|
|
264
|
+
run: pytest tests/flask/test_api.py
|
|
265
|
+
- name: Set up Chrome WebDriver
|
|
266
|
+
uses: browser-actions/setup-chrome@latest
|
|
267
|
+
- name: Run headless browser tests
|
|
268
|
+
run: pytest tests/flask/test_browser.py
|
|
269
|
+
- name: Upload visual test artifacts
|
|
270
|
+
uses: actions/upload-artifact@v2
|
|
271
|
+
with:
|
|
272
|
+
name: visual-test-results
|
|
273
|
+
path: tests/flask/visual/results
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Practical Implementation Plan
|
|
277
|
+
|
|
278
|
+
For the SYD Flask deployment, we recommend implementing tests in this order:
|
|
279
|
+
|
|
280
|
+
1. **Start with Unit Tests** - Focus on individual components and functions
|
|
281
|
+
2. **Add API Tests** - Ensure all endpoints work correctly
|
|
282
|
+
3. **Implement Integration Tests** - Test component interactions
|
|
283
|
+
4. **Add Visual Testing** - Simple screenshot comparisons
|
|
284
|
+
5. **Create Browser Tests** - For critical UI workflows
|
|
285
|
+
|
|
286
|
+
## Testing Challenges and Solutions
|
|
287
|
+
|
|
288
|
+
| Challenge | Solution |
|
|
289
|
+
|-----------|----------|
|
|
290
|
+
| Testing real-time updates | Use JavaScript triggers and MutationObserver in browser tests |
|
|
291
|
+
| Asynchronous plot generation | Implement wait utilities with timeouts |
|
|
292
|
+
| Visual inconsistencies across platforms | Use Percy or similar to handle cross-platform rendering |
|
|
293
|
+
| Slow browser tests | Run only critical UI tests in CI, more comprehensive ones nightly |
|
|
294
|
+
| Matplotlib backend differences | Mock or standardize backends during testing |
|
|
295
|
+
|
|
296
|
+
## Conclusion
|
|
297
|
+
|
|
298
|
+
Testing an interactive GUI like the SYD Flask deployment requires a multi-layered approach. By combining traditional unit and integration tests with specialized GUI testing tools, we can achieve good test coverage while still accommodating the unique challenges of interactive web applications. The most effective strategy is to automate as much as possible while maintaining a structured approach to manual testing for aspects that are difficult to verify programmatically.
|
|
299
|
+
|
|
300
|
+
For the initial implementation of the testing framework, focus on establishing solid unit and API tests, as these will provide the foundation for more complex tests and help catch the most common issues early in the development process.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
from .deployer import
|
|
1
|
+
from .deployer import NotebookDeployer
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
from typing import Dict, Any, Optional
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
import warnings
|
|
3
|
+
from functools import wraps
|
|
2
4
|
from dataclasses import dataclass
|
|
3
5
|
from contextlib import contextmanager
|
|
6
|
+
from time import time
|
|
7
|
+
|
|
4
8
|
import ipywidgets as widgets
|
|
5
9
|
from IPython.display import display
|
|
10
|
+
import matplotlib as mpl
|
|
6
11
|
import matplotlib.pyplot as plt
|
|
7
|
-
import warnings
|
|
8
|
-
from ..parameters import ParameterUpdateWarning
|
|
9
12
|
|
|
10
|
-
from ..
|
|
13
|
+
from ..parameters import ParameterUpdateWarning
|
|
14
|
+
from ..viewer import Viewer
|
|
11
15
|
from .widgets import BaseWidget, create_widget
|
|
12
16
|
|
|
13
17
|
|
|
@@ -20,17 +24,53 @@ def _plot_context():
|
|
|
20
24
|
plt.ion()
|
|
21
25
|
|
|
22
26
|
|
|
27
|
+
def get_backend_type():
|
|
28
|
+
"""
|
|
29
|
+
Determines the current matplotlib backend type and returns relevant info
|
|
30
|
+
"""
|
|
31
|
+
backend = mpl.get_backend().lower()
|
|
32
|
+
|
|
33
|
+
if "inline" in backend:
|
|
34
|
+
return "inline"
|
|
35
|
+
elif "widget" in backend or "ipympl" in backend:
|
|
36
|
+
return "widget"
|
|
37
|
+
elif "qt" in backend:
|
|
38
|
+
return "qt"
|
|
39
|
+
else:
|
|
40
|
+
return "other"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def debounce(wait_time):
|
|
44
|
+
"""
|
|
45
|
+
Decorator to prevent a function from being called more than once every wait_time seconds.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def decorator(fn):
|
|
49
|
+
last_called = [0.0] # Using list to maintain state in closure
|
|
50
|
+
|
|
51
|
+
@wraps(fn)
|
|
52
|
+
def debounced(*args, **kwargs):
|
|
53
|
+
current_time = time()
|
|
54
|
+
if current_time - last_called[0] >= wait_time:
|
|
55
|
+
fn(*args, **kwargs)
|
|
56
|
+
last_called[0] = current_time
|
|
57
|
+
|
|
58
|
+
return debounced
|
|
59
|
+
|
|
60
|
+
return decorator
|
|
61
|
+
|
|
62
|
+
|
|
23
63
|
@dataclass
|
|
24
64
|
class LayoutConfig:
|
|
25
65
|
"""Configuration for the viewer layout."""
|
|
26
66
|
|
|
27
|
-
controls_position: str = "left" # Options are: 'left', 'top'
|
|
67
|
+
controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
|
|
28
68
|
figure_width: float = 8.0
|
|
29
69
|
figure_height: float = 6.0
|
|
30
|
-
controls_width_percent: int =
|
|
70
|
+
controls_width_percent: int = 20
|
|
31
71
|
|
|
32
72
|
def __post_init__(self):
|
|
33
|
-
valid_positions = ["left", "top"]
|
|
73
|
+
valid_positions = ["left", "top", "right", "bottom"]
|
|
34
74
|
if self.controls_position not in valid_positions:
|
|
35
75
|
raise ValueError(
|
|
36
76
|
f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
|
|
@@ -38,42 +78,54 @@ class LayoutConfig:
|
|
|
38
78
|
|
|
39
79
|
@property
|
|
40
80
|
def is_horizontal(self) -> bool:
|
|
41
|
-
return self.controls_position == "left"
|
|
81
|
+
return self.controls_position == "left" or self.controls_position == "right"
|
|
42
82
|
|
|
43
83
|
|
|
44
|
-
class
|
|
84
|
+
class NotebookDeployer:
|
|
45
85
|
"""
|
|
46
|
-
A deployment system for
|
|
86
|
+
A deployment system for Viewer in Jupyter notebooks using ipywidgets.
|
|
47
87
|
Built around the parameter widget system for clean separation of concerns.
|
|
48
88
|
"""
|
|
49
89
|
|
|
50
90
|
def __init__(
|
|
51
91
|
self,
|
|
52
|
-
viewer:
|
|
53
|
-
|
|
92
|
+
viewer: Viewer,
|
|
93
|
+
controls_position: str = "left",
|
|
94
|
+
figure_width: float = 8.0,
|
|
95
|
+
figure_height: float = 6.0,
|
|
96
|
+
controls_width_percent: int = 20,
|
|
54
97
|
continuous: bool = False,
|
|
55
98
|
suppress_warnings: bool = False,
|
|
56
99
|
):
|
|
57
|
-
if not isinstance(viewer, InteractiveViewer): # type: ignore
|
|
58
|
-
raise TypeError(
|
|
59
|
-
f"viewer must be an InteractiveViewer, got {type(viewer).__name__}"
|
|
60
|
-
)
|
|
61
|
-
|
|
62
100
|
self.viewer = viewer
|
|
63
|
-
self.config =
|
|
101
|
+
self.config = LayoutConfig(
|
|
102
|
+
controls_position=controls_position,
|
|
103
|
+
figure_width=figure_width,
|
|
104
|
+
figure_height=figure_height,
|
|
105
|
+
controls_width_percent=controls_width_percent,
|
|
106
|
+
)
|
|
64
107
|
self.continuous = continuous
|
|
65
108
|
self.suppress_warnings = suppress_warnings
|
|
66
109
|
|
|
67
110
|
# Initialize containers
|
|
111
|
+
self.backend_type = get_backend_type()
|
|
112
|
+
if self.backend_type not in ["inline", "widget"]:
|
|
113
|
+
warnings.warn(
|
|
114
|
+
f"The current backend ({self.backend_type}) is not supported. Please use %matplotlib widget or %matplotlib inline.\n"
|
|
115
|
+
"The behavior of the viewer will almost definitely not work as expected."
|
|
116
|
+
)
|
|
68
117
|
self.parameter_widgets: Dict[str, BaseWidget] = {}
|
|
69
|
-
self.layout_widgets = self._create_layout_controls()
|
|
70
118
|
self.plot_output = widgets.Output()
|
|
71
119
|
|
|
72
|
-
#
|
|
73
|
-
self.
|
|
120
|
+
# Create layout for controls
|
|
121
|
+
self.layout_widgets = self._create_layout_controls()
|
|
122
|
+
|
|
74
123
|
# Flag to prevent circular updates
|
|
75
124
|
self._updating = False
|
|
76
125
|
|
|
126
|
+
# Last figure to close when new figures are created
|
|
127
|
+
self._last_figure = None
|
|
128
|
+
|
|
77
129
|
def _create_layout_controls(self) -> Dict[str, widgets.Widget]:
|
|
78
130
|
"""Create widgets for controlling the layout."""
|
|
79
131
|
controls: Dict[str, widgets.Widget] = {}
|
|
@@ -82,16 +134,13 @@ class NotebookDeployment:
|
|
|
82
134
|
if self.config.is_horizontal:
|
|
83
135
|
controls["controls_width"] = widgets.IntSlider(
|
|
84
136
|
value=self.config.controls_width_percent,
|
|
85
|
-
min=
|
|
86
|
-
max=
|
|
137
|
+
min=10,
|
|
138
|
+
max=50,
|
|
87
139
|
description="Controls Width %",
|
|
88
140
|
continuous=True,
|
|
89
141
|
layout=widgets.Layout(width="95%"),
|
|
90
142
|
style={"description_width": "initial"},
|
|
91
143
|
)
|
|
92
|
-
controls["controls_width"].observe(
|
|
93
|
-
self._handle_container_width_change, names="value"
|
|
94
|
-
)
|
|
95
144
|
|
|
96
145
|
return controls
|
|
97
146
|
|
|
@@ -106,6 +155,7 @@ class NotebookDeployment:
|
|
|
106
155
|
# Store in widget dict
|
|
107
156
|
self.parameter_widgets[name] = widget
|
|
108
157
|
|
|
158
|
+
@debounce(0.1)
|
|
109
159
|
def _handle_widget_engagement(self, name: str) -> None:
|
|
110
160
|
"""Handle engagement with an interactive widget."""
|
|
111
161
|
if self._updating:
|
|
@@ -128,12 +178,12 @@ class NotebookDeployment:
|
|
|
128
178
|
|
|
129
179
|
if widget._is_action:
|
|
130
180
|
parameter = self.viewer.parameters[name]
|
|
131
|
-
parameter.callback(self.viewer.
|
|
181
|
+
parameter.callback(self.viewer.state)
|
|
132
182
|
else:
|
|
133
183
|
self.viewer.set_parameter_value(name, widget.value)
|
|
134
184
|
|
|
135
185
|
# Update any widgets that changed due to dependencies
|
|
136
|
-
self._sync_widgets_with_state(
|
|
186
|
+
self._sync_widgets_with_state()
|
|
137
187
|
|
|
138
188
|
# Update the plot
|
|
139
189
|
self._update_plot()
|
|
@@ -172,30 +222,37 @@ class NotebookDeployment:
|
|
|
172
222
|
|
|
173
223
|
def _update_plot(self) -> None:
|
|
174
224
|
"""Update the plot with current state."""
|
|
175
|
-
state = self.viewer.
|
|
225
|
+
state = self.viewer.state
|
|
176
226
|
|
|
177
227
|
with _plot_context():
|
|
178
|
-
|
|
179
|
-
plt.close(self._current_figure) # Close old figure
|
|
180
|
-
self._current_figure = new_fig
|
|
228
|
+
figure = self.viewer.plot(state)
|
|
181
229
|
|
|
182
|
-
|
|
230
|
+
# Update widgets if plot function updated a parameter
|
|
231
|
+
self._sync_widgets_with_state()
|
|
232
|
+
|
|
233
|
+
# Close the last figure if it exists to keep matplotlib clean
|
|
234
|
+
# (just moved this from after clear_output.... noting!)
|
|
235
|
+
if self._last_figure is not None:
|
|
236
|
+
plt.close(self._last_figure)
|
|
183
237
|
|
|
184
|
-
def _redraw_plot(self) -> None:
|
|
185
|
-
"""Clear and redraw the plot in the output widget."""
|
|
186
238
|
self.plot_output.clear_output(wait=True)
|
|
187
239
|
with self.plot_output:
|
|
188
|
-
|
|
240
|
+
if self.backend_type == "inline":
|
|
241
|
+
display(figure)
|
|
242
|
+
|
|
243
|
+
# Also required to make sure a second figure window isn't opened
|
|
244
|
+
plt.close(figure)
|
|
245
|
+
|
|
246
|
+
elif self.backend_type == "widget":
|
|
247
|
+
display(figure.canvas)
|
|
248
|
+
|
|
249
|
+
else:
|
|
250
|
+
raise ValueError(f"Unsupported backend type: {self.backend_type}")
|
|
251
|
+
|
|
252
|
+
self._last_figure = figure
|
|
189
253
|
|
|
190
254
|
def _create_layout(self) -> widgets.Widget:
|
|
191
255
|
"""Create the main layout combining controls and plot."""
|
|
192
|
-
# Create layout controls section
|
|
193
|
-
layout_box = widgets.VBox(
|
|
194
|
-
[widgets.HTML("<b>Layout Controls</b>")]
|
|
195
|
-
+ list(self.layout_widgets.values()),
|
|
196
|
-
layout=widgets.Layout(margin="10px 0px"),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
256
|
# Set up parameter widgets with their observe callbacks
|
|
200
257
|
for name, widget in self.parameter_widgets.items():
|
|
201
258
|
widget.observe(lambda change, n=name: self._handle_widget_engagement(n))
|
|
@@ -208,8 +265,26 @@ class NotebookDeployment:
|
|
|
208
265
|
)
|
|
209
266
|
|
|
210
267
|
# Combine all controls
|
|
268
|
+
if self.config.is_horizontal:
|
|
269
|
+
# Create layout controls section if horizontal (might include for vertical later when we have more permanent controls...)
|
|
270
|
+
layout_box = widgets.VBox(
|
|
271
|
+
[widgets.HTML("<b>Layout Controls</b>")]
|
|
272
|
+
+ list(self.layout_widgets.values()),
|
|
273
|
+
layout=widgets.Layout(margin="10px 0px"),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Register the controls_width slider's observer
|
|
277
|
+
if "controls_width" in self.layout_widgets:
|
|
278
|
+
self.layout_widgets["controls_width"].observe(
|
|
279
|
+
self._handle_container_width_change, names="value"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
widgets_elements = [param_box, layout_box]
|
|
283
|
+
else:
|
|
284
|
+
widgets_elements = [param_box]
|
|
285
|
+
|
|
211
286
|
self.widgets_container = widgets.VBox(
|
|
212
|
-
|
|
287
|
+
widgets_elements,
|
|
213
288
|
layout=widgets.Layout(
|
|
214
289
|
width=(
|
|
215
290
|
f"{self.config.controls_width_percent}%"
|
|
@@ -217,7 +292,9 @@ class NotebookDeployment:
|
|
|
217
292
|
else "100%"
|
|
218
293
|
),
|
|
219
294
|
padding="10px",
|
|
220
|
-
overflow_y="
|
|
295
|
+
overflow_y="scroll",
|
|
296
|
+
border="1px solid #e5e7eb",
|
|
297
|
+
border_radius="4px 4px 0px 0px",
|
|
221
298
|
),
|
|
222
299
|
)
|
|
223
300
|
|
|
@@ -237,18 +314,27 @@ class NotebookDeployment:
|
|
|
237
314
|
# Create final layout based on configuration
|
|
238
315
|
if self.config.controls_position == "left":
|
|
239
316
|
return widgets.HBox([self.widgets_container, self.plot_container])
|
|
317
|
+
elif self.config.controls_position == "right":
|
|
318
|
+
return widgets.HBox([self.plot_container, self.widgets_container])
|
|
319
|
+
elif self.config.controls_position == "bottom":
|
|
320
|
+
return widgets.VBox([self.plot_container, self.widgets_container])
|
|
240
321
|
else:
|
|
241
322
|
return widgets.VBox([self.widgets_container, self.plot_container])
|
|
242
323
|
|
|
243
324
|
def deploy(self) -> None:
|
|
244
325
|
"""Deploy the interactive viewer with proper state management."""
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
326
|
+
self.backend_type = get_backend_type()
|
|
327
|
+
|
|
328
|
+
# We used to use the deploy_app context, but notebook deployment works
|
|
329
|
+
# differently because it's asynchronous and this doesn't really behave
|
|
330
|
+
# as intended. (e.g. with self.viewer._deploy_app() ...)
|
|
331
|
+
|
|
332
|
+
# Create widgets
|
|
333
|
+
self._create_parameter_widgets()
|
|
248
334
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
335
|
+
# Create and display layout
|
|
336
|
+
self.layout = self._create_layout()
|
|
337
|
+
display(self.layout)
|
|
252
338
|
|
|
253
|
-
|
|
254
|
-
|
|
339
|
+
# Create initial plot
|
|
340
|
+
self._update_plot()
|