shiny-plotly 0.3.3__tar.gz → 0.4.1__tar.gz
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.
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/.gitignore +1 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/CHANGELOG.md +27 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/PKG-INFO +9 -14
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/README.md +7 -11
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/pyproject.toml +7 -7
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/_html.py +10 -4
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/_render.py +3 -8
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/_serve.py +70 -11
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/_update.py +21 -17
- shiny_plotly-0.4.1/src/shiny_plotly/_validate.py +31 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/www/shiny-plotly.js +70 -10
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/apps.py +202 -5
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/conftest.py +6 -2
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/test_browser.py +88 -11
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/test_events.py +19 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/test_update.py +61 -0
- shiny_plotly-0.4.1/tests/helpers.py +25 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/test_compressed_js.py +138 -10
- shiny_plotly-0.4.1/tests/test_examples.py +50 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/test_fig_to_ui.py +10 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/test_render_plotly.py +62 -4
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/test_update.py +32 -17
- shiny_plotly-0.3.3/tests/test_examples.py +0 -41
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/LICENSE +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/core_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/dark_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/events_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/shinylive/app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/examples/streaming_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/__init__.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/test_dark_mode.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/test_shinylive_demo.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/browser/test_streaming_example.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/test_pages_contract.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.1}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1](https://github.com/rvben/shiny-plotly/compare/v0.4.0...v0.4.1) - 2026-09-25
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A click, hover or selection on a heatmap (or any trace that addresses a point by row and column) now carries the point's `pointNumber` as `[row, column]` and its `customdata`. Both were dropped: the pair is not a scalar, so it was left out, and looking customdata up by it came back empty. Customdata is now the value plotly itself puts on the point, for every trace type.
|
|
15
|
+
- A `post_script` that throws no longer stops the chart it belongs to. The error is logged to the browser console with the output's id; until now it also left the output never marked as drawn, so updates sent to it were held forever, a themed chart stopped following the color mode, and Shiny reported a client error.
|
|
16
|
+
- Updates held for an output that has no figure drawn, such as one in a closed tab, no longer pile up one per call. Consecutive `extend_traces` calls to the same traces with the same attributes and `max_points` are merged as they arrive, cut to `max_points`, and so are consecutive `prepend_traces` calls. A chart streaming at 50 Hz in a tab closed for ten seconds held 500 updates and drew each one when the tab opened; it now holds one and draws once, to the same result.
|
|
17
|
+
- The in-place updates take a numpy integer for `indices`, where it failed with `TypeError: 'numpy.int64' object is not iterable`, and `extend_traces` and `prepend_traces` take one for `max_points`, as `render_plotly` does for `max_event_points`, where both were refused as not a positive integer. Indices that are not integers (a float, a string, a `bool`) are refused with a `ValueError` naming the argument instead of being sent to the browser.
|
|
18
|
+
- A client that refuses an encoding by name in `Accept-Encoding` is no longer sent it because the header also has a `*`: `gzip;q=0, *` asked for anything but gzip and got gzip. A refusal is also read wherever `q` stands among the entry's parameters.
|
|
19
|
+
- `If-None-Match` for plotly.min.js now matches a weak validator (`W/"..."`, as a proxy may send it) and `*`, returning 304 instead of the whole bundle again.
|
|
20
|
+
- plotly.min.js served raw while its compression is still running, which happens only on the first requests of a process, is marked `Cache-Control: no-cache` instead of immutable for a year. A browser that asked for brotli or gzip revalidates it on the next visit and gets the compressed file, instead of keeping the 4.8 MB raw file in its cache.
|
|
21
|
+
|
|
22
|
+
## [0.4.0](https://github.com/rvben/shiny-plotly/compare/v0.3.3...v0.4.0) - 2026-08-21
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `brotli` is a dependency, so a plain `uv add shiny-plotly` serves plotly.min.js in the smaller encoding (1.2 MB on the wire against 1.5 MB gzipped) instead of only where someone thought to ask for it. It carries the marker `sys_platform != 'emscripten'`: under pyodide the compressing route is never installed, so a shinylive export would have downloaded a 307 kB wasm wheel to compress nothing. An install that still ends up without brotli (a lock file older than this release, a platform with no wheel) falls back to gzip and logs the same one-off warning as before, now naming the fix.
|
|
27
|
+
- README: the note on hidden outputs now names every container Shiny defers a chart in (an inactive `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden` panel, a closed `ui.accordion` section, and a false `ui.panel_conditional` from Shiny 1.6.1 on, which is where that last one changed), and says what it does not cover: a chart scrolled below the fold is drawn with the rest at load. A browser test holds every case, so a Shiny release that changes the behavior breaks the suite rather than the advice.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A client that refuses an encoding with `Accept-Encoding: br;q=0.0` is no longer served it. RFC 9110 lets a qvalue of zero be written `q=0`, `q=0.0` or `q=0.000`, and only the first spelling was read as a refusal, so plotly.min.js could arrive in an encoding the client had said it would not take.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
35
|
+
- The `brotli` extra, now that brotli is a plain dependency. An install that still names it (`shiny-plotly[brotli]`) gets the same package, plus a warning from pip or uv that the extra does not exist; drop the `[brotli]`.
|
|
36
|
+
|
|
10
37
|
## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
|
|
11
38
|
|
|
12
39
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rvben/shiny-plotly
|
|
6
6
|
Project-URL: Repository, https://github.com/rvben/shiny-plotly
|
|
@@ -22,11 +22,10 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
22
22
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: brotli>=1.1; sys_platform != 'emscripten'
|
|
25
26
|
Requires-Dist: htmltools>=0.5.2
|
|
26
27
|
Requires-Dist: plotly>=5.5
|
|
27
28
|
Requires-Dist: shiny>=1.0
|
|
28
|
-
Provides-Extra: brotli
|
|
29
|
-
Requires-Dist: brotli>=1.1; extra == 'brotli'
|
|
30
29
|
Description-Content-Type: text/markdown
|
|
31
30
|
|
|
32
31
|
# shiny-plotly
|
|
@@ -210,7 +209,7 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
|
|
|
210
209
|
|
|
211
210
|
Drawing a plotly figure costs the browser a fixed amount of main-thread work per graph, tens of milliseconds for a small one on a current desktop, and the browser draws them one after another. On a dashboard of a dozen charts that per-chart work, not the bytes on the wire, is what the first second is spent on, and it is plotly's own cost: the same figure drawn from shinywidgets or from a static `to_html` export costs the same.
|
|
212
211
|
|
|
213
|
-
The lever is drawing fewer charts at once
|
|
212
|
+
The lever is drawing fewer charts at once, and Shiny pulls it for you: an output the browser reports as hidden is suspended, so its figure is not rendered at all until it is shown. That covers every container that hides one, whether an inactive panel of `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden`, a closed `ui.accordion` section, or a `ui.panel_conditional` whose condition is false (that last one from Shiny 1.6.1 on; older Shiny drew it at load); each panel then pays only for its own charts, and pays when it is opened. Scrolling is not hiding, though: a chart 3000px down the page is visible as far as the browser is concerned, and is drawn with the rest at load. Charts that must all be visible at once are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
214
213
|
|
|
215
214
|
### Dark mode
|
|
216
215
|
|
|
@@ -315,7 +314,7 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
315
314
|
| `legendclick` | `{"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}` for the trace whose legend item was clicked, `visible` as it stood before the click's toggle (`True` or `"legendonly"`); trace types whose legend items are labels (pie, funnelarea) add `label`; fires on every click, and the default toggle still happens |
|
|
316
315
|
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
317
316
|
|
|
318
|
-
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
317
|
+
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. On a 2-D trace such as a heatmap `pointNumber` is the cell's `[row, column]`. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
319
318
|
|
|
320
319
|
#### Dense traces
|
|
321
320
|
|
|
@@ -353,7 +352,7 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
353
352
|
|
|
354
353
|
`click` and `hover` carry one point per trace under the pointer, so the cap matters for `selected`; hover is also debounced (100 ms), so a pointer sweeping across a dense trace sends one event when it rests, not one per point.
|
|
355
354
|
|
|
356
|
-
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
355
|
+
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. A script that throws is logged to the browser console and the chart carries on: events, theming and held updates do not depend on it. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
357
356
|
|
|
358
357
|
```python
|
|
359
358
|
ANNOTATION_TO_INPUT = """
|
|
@@ -402,7 +401,7 @@ async def _highlight():
|
|
|
402
401
|
- `add_traces(id, traces, indices=None)`: whole new traces, each a graph object (`go.Scatter(y=[1, 2])`) or a dict (`{"y": [1], "type": "bar"}`); `indices` says where they land in the trace order, appended without it.
|
|
403
402
|
- `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
|
|
404
403
|
|
|
405
|
-
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
404
|
+
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. Consecutive held `extend_traces` calls with the same traces, attributes and `max_points` are merged into one as they arrive, and so are consecutive `prepend_traces` calls, cut to `max_points` as they go: a stream into a chart in a closed tab costs one redraw when the tab opens and, with a cap, holds no more points than the chart will keep. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
406
405
|
|
|
407
406
|
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
408
407
|
|
|
@@ -419,13 +418,9 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
419
418
|
|
|
420
419
|
### plotly.js on the wire
|
|
421
420
|
|
|
422
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli
|
|
421
|
+
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli, 1.2 MB on the wire, or gzip at 1.5 MB where brotli is not installed) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file, with `Cache-Control: no-cache` to a browser that asked for an encoding still being compressed, so its next visit revalidates and swaps in the compressed body rather than keeping the raw one for a year.
|
|
423
422
|
|
|
424
|
-
|
|
425
|
-
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
Without it the process logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
|
|
423
|
+
`brotli` is a dependency, so a plain `uv add shiny-plotly` serves the smaller encoding. It is skipped under pyodide, where there is nothing to compress: a shinylive export carries its own assets and the route is not installed at all. An install that ends up without it (a lock file that predates the dependency, a platform with no wheel) falls back to gzip and logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
|
|
429
424
|
|
|
430
425
|
The route asks nothing of the app. Importing `shiny_plotly` wraps `shiny.App.__init__`, so every app built afterwards has it, Core and Express alike, and the compression starts while the app is still being built rather than when someone first visits it. The timing is the whole point: the browser asks for plotly.js while the page is loading, well before the session that page opens exists, so a route that waited for a session would arrive one visitor too late, and that visitor would take 4.9 MB with no `Cache-Control` at all.
|
|
431
426
|
|
|
@@ -458,7 +453,7 @@ make bench # the shinywidgets comparison above, on this machine
|
|
|
458
453
|
make bench-events # what a selection over a dense trace costs, capped and uncapped
|
|
459
454
|
```
|
|
460
455
|
|
|
461
|
-
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
456
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. It fails below 100% line and branch coverage of the package: every line is reachable without a browser, and the gate is what keeps behavior that only the Chromium suite can reach from growing. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
462
457
|
|
|
463
458
|
## License
|
|
464
459
|
|
|
@@ -179,7 +179,7 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
|
|
|
179
179
|
|
|
180
180
|
Drawing a plotly figure costs the browser a fixed amount of main-thread work per graph, tens of milliseconds for a small one on a current desktop, and the browser draws them one after another. On a dashboard of a dozen charts that per-chart work, not the bytes on the wire, is what the first second is spent on, and it is plotly's own cost: the same figure drawn from shinywidgets or from a static `to_html` export costs the same.
|
|
181
181
|
|
|
182
|
-
The lever is drawing fewer charts at once
|
|
182
|
+
The lever is drawing fewer charts at once, and Shiny pulls it for you: an output the browser reports as hidden is suspended, so its figure is not rendered at all until it is shown. That covers every container that hides one, whether an inactive panel of `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden`, a closed `ui.accordion` section, or a `ui.panel_conditional` whose condition is false (that last one from Shiny 1.6.1 on; older Shiny drew it at load); each panel then pays only for its own charts, and pays when it is opened. Scrolling is not hiding, though: a chart 3000px down the page is visible as far as the browser is concerned, and is drawn with the rest at load. Charts that must all be visible at once are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
183
183
|
|
|
184
184
|
### Dark mode
|
|
185
185
|
|
|
@@ -284,7 +284,7 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
284
284
|
| `legendclick` | `{"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}` for the trace whose legend item was clicked, `visible` as it stood before the click's toggle (`True` or `"legendonly"`); trace types whose legend items are labels (pie, funnelarea) add `label`; fires on every click, and the default toggle still happens |
|
|
285
285
|
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
286
286
|
|
|
287
|
-
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
287
|
+
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. On a 2-D trace such as a heatmap `pointNumber` is the cell's `[row, column]`. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
288
288
|
|
|
289
289
|
#### Dense traces
|
|
290
290
|
|
|
@@ -322,7 +322,7 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
322
322
|
|
|
323
323
|
`click` and `hover` carry one point per trace under the pointer, so the cap matters for `selected`; hover is also debounced (100 ms), so a pointer sweeping across a dense trace sends one event when it rests, not one per point.
|
|
324
324
|
|
|
325
|
-
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
325
|
+
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. A script that throws is logged to the browser console and the chart carries on: events, theming and held updates do not depend on it. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
326
326
|
|
|
327
327
|
```python
|
|
328
328
|
ANNOTATION_TO_INPUT = """
|
|
@@ -371,7 +371,7 @@ async def _highlight():
|
|
|
371
371
|
- `add_traces(id, traces, indices=None)`: whole new traces, each a graph object (`go.Scatter(y=[1, 2])`) or a dict (`{"y": [1], "type": "bar"}`); `indices` says where they land in the trace order, appended without it.
|
|
372
372
|
- `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
|
|
373
373
|
|
|
374
|
-
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
374
|
+
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. Consecutive held `extend_traces` calls with the same traces, attributes and `max_points` are merged into one as they arrive, and so are consecutive `prepend_traces` calls, cut to `max_points` as they go: a stream into a chart in a closed tab costs one redraw when the tab opens and, with a cap, holds no more points than the chart will keep. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
375
375
|
|
|
376
376
|
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
377
377
|
|
|
@@ -388,13 +388,9 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
388
388
|
|
|
389
389
|
### plotly.js on the wire
|
|
390
390
|
|
|
391
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli
|
|
391
|
+
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli, 1.2 MB on the wire, or gzip at 1.5 MB where brotli is not installed) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file, with `Cache-Control: no-cache` to a browser that asked for an encoding still being compressed, so its next visit revalidates and swaps in the compressed body rather than keeping the raw one for a year.
|
|
392
392
|
|
|
393
|
-
|
|
394
|
-
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
Without it the process logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
|
|
393
|
+
`brotli` is a dependency, so a plain `uv add shiny-plotly` serves the smaller encoding. It is skipped under pyodide, where there is nothing to compress: a shinylive export carries its own assets and the route is not installed at all. An install that ends up without it (a lock file that predates the dependency, a platform with no wheel) falls back to gzip and logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
|
|
398
394
|
|
|
399
395
|
The route asks nothing of the app. Importing `shiny_plotly` wraps `shiny.App.__init__`, so every app built afterwards has it, Core and Express alike, and the compression starts while the app is still being built rather than when someone first visits it. The timing is the whole point: the browser asks for plotly.js while the page is loading, well before the session that page opens exists, so a route that waited for a session would arrive one visitor too late, and that visitor would take 4.9 MB with no `Cache-Control` at all.
|
|
400
396
|
|
|
@@ -427,7 +423,7 @@ make bench # the shinywidgets comparison above, on this machine
|
|
|
427
423
|
make bench-events # what a selection over a dense trace costs, capped and uncapped
|
|
428
424
|
```
|
|
429
425
|
|
|
430
|
-
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
426
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. It fails below 100% line and branch coverage of the package: every line is reachable without a browser, and the gate is what keeps behavior that only the Chromium suite can reach from growing. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
431
427
|
|
|
432
428
|
## License
|
|
433
429
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiny-plotly"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -33,12 +33,12 @@ dependencies = [
|
|
|
33
33
|
"shiny>=1.0",
|
|
34
34
|
"plotly>=5.5",
|
|
35
35
|
"htmltools>=0.5.2",
|
|
36
|
+
# plotly.min.js is served brotli-compressed, about 17% smaller than gzip. Under pyodide
|
|
37
|
+
# there is nothing to compress (the bundle comes from the shinylive export) and the route
|
|
38
|
+
# is skipped, so the marker keeps a 307 kB wasm wheel out of every exported app.
|
|
39
|
+
"brotli>=1.1; sys_platform != 'emscripten'",
|
|
36
40
|
]
|
|
37
41
|
|
|
38
|
-
[project.optional-dependencies]
|
|
39
|
-
# plotly.min.js is served brotli-compressed (about 15% smaller than gzip) when present.
|
|
40
|
-
brotli = ["brotli>=1.1"]
|
|
41
|
-
|
|
42
42
|
[project.urls]
|
|
43
43
|
Homepage = "https://github.com/rvben/shiny-plotly"
|
|
44
44
|
Repository = "https://github.com/rvben/shiny-plotly"
|
|
@@ -52,12 +52,12 @@ bench = [
|
|
|
52
52
|
]
|
|
53
53
|
dev = [
|
|
54
54
|
"pytest>=8",
|
|
55
|
+
"pytest-cov>=5",
|
|
55
56
|
"ruff>=0.6",
|
|
56
57
|
"pyright>=1.1.380",
|
|
57
58
|
"httpx2>=2.12.0",
|
|
58
59
|
"pytest-playwright>=0.9.0",
|
|
59
60
|
"numpy>=1.26",
|
|
60
|
-
"brotli>=1.1",
|
|
61
61
|
"shinylive-check>=0.1.0",
|
|
62
62
|
]
|
|
63
63
|
|
|
@@ -91,4 +91,4 @@ pythonVersion = "3.10"
|
|
|
91
91
|
typeCheckingMode = "standard"
|
|
92
92
|
|
|
93
93
|
[tool.ruff.lint.isort]
|
|
94
|
-
known-local-folder = ["newplot"]
|
|
94
|
+
known-local-folder = ["helpers", "newplot"]
|
|
@@ -103,9 +103,15 @@ def fig_to_ui(
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def as_fig_dict(fig: Figure) -> dict[str, Any]:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
"""
|
|
107
|
+
The figure as a plain dict carrying a ``layout`` dict, whichever way it was given.
|
|
108
|
+
|
|
109
|
+
Everything downstream reaches into ``layout`` (to fill in margins, to drop a baked-in
|
|
110
|
+
template), so it is made a dict here rather than guarded against at each of them.
|
|
111
|
+
"""
|
|
112
|
+
# Figure.to_dict() does no validation (the figure was validated when built), and always
|
|
113
|
+
# carries a layout; a dict is passed through as the caller's JSON, so pio.to_html gets
|
|
114
|
+
# validate=False and never reconstructs a Figure from it.
|
|
109
115
|
if isinstance(fig, BaseFigure):
|
|
110
116
|
return fig.to_dict()
|
|
111
117
|
if isinstance(fig, dict):
|
|
@@ -116,5 +122,5 @@ def as_fig_dict(fig: Figure) -> dict[str, Any]:
|
|
|
116
122
|
|
|
117
123
|
|
|
118
124
|
def fill_in_margins(fig_dict: dict[str, Any]) -> None:
|
|
119
|
-
layout = fig_dict
|
|
125
|
+
layout = fig_dict["layout"]
|
|
120
126
|
layout["margin"] = {**FIGUREWIDGET_MARGINS, **(layout.get("margin") or {})}
|
|
@@ -16,6 +16,7 @@ from shiny.ui.fill import as_fill_item, as_fillable_container
|
|
|
16
16
|
from ._deps import plotly_js, shiny_plotly_js
|
|
17
17
|
from ._html import DEFAULT_CONFIG, Figure, as_fig_dict, fill_in_margins
|
|
18
18
|
from ._serve import enable_compressed_plotly_js
|
|
19
|
+
from ._validate import as_positive_int
|
|
19
20
|
|
|
20
21
|
__all__ = ("DEFAULT_MAX_EVENT_POINTS", "EVENTS", "output_plotly", "render_plotly")
|
|
21
22
|
|
|
@@ -127,11 +128,7 @@ def template_key(template_json: str) -> str:
|
|
|
127
128
|
|
|
128
129
|
|
|
129
130
|
def normalize_max_event_points(value: int | None) -> int | None:
|
|
130
|
-
if value is None
|
|
131
|
-
return None
|
|
132
|
-
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
133
|
-
raise ValueError(f"max_event_points must be a positive int or None, got {value!r}")
|
|
134
|
-
return value
|
|
131
|
+
return None if value is None else as_positive_int(value, "max_event_points")
|
|
135
132
|
|
|
136
133
|
|
|
137
134
|
def output_plotly(id: str, *, width: str | None = None, height: str | None = None) -> Tag:
|
|
@@ -331,9 +328,7 @@ class render_plotly(Renderer[Figure]):
|
|
|
331
328
|
if self._theme_templates:
|
|
332
329
|
# The browser picks the mode's template; the one the figure baked in at
|
|
333
330
|
# construction would only add dead weight and a flash of the wrong theme.
|
|
334
|
-
layout
|
|
335
|
-
if isinstance(layout, dict):
|
|
336
|
-
layout.pop("template", None)
|
|
331
|
+
fig_dict["layout"].pop("template", None)
|
|
337
332
|
theme_keys = await self._cache_templates()
|
|
338
333
|
if theme_keys is not None:
|
|
339
334
|
themes_json = None
|
|
@@ -10,7 +10,8 @@ Importing ``shiny_plotly`` wraps ``shiny.App.__init__``, so every app built afte
|
|
|
10
10
|
route for the bundle's exact path in front of Shiny's mount, and a background thread starts
|
|
11
11
|
compressing the bundle once per process. Both happen while the app is being built, before it
|
|
12
12
|
can serve anything, so the first request of the process is already served here; until the
|
|
13
|
-
compression has finished the route serves the raw file
|
|
13
|
+
compression has finished the route serves the raw file, marked for revalidation rather than
|
|
14
|
+
immutable when the client asked for an encoding that is still on its way. An app
|
|
14
15
|
built before the import can still ask for the route with :func:`enable_compressed_plotly_js`,
|
|
15
16
|
and each session enables it for its own app, so it is there either way.
|
|
16
17
|
"""
|
|
@@ -44,6 +45,10 @@ __all__ = (
|
|
|
44
45
|
|
|
45
46
|
ROUTE_NAME = "shiny-plotly-bundle"
|
|
46
47
|
CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
48
|
+
# For a body that is not yet the one this client will get: the raw file, served while the
|
|
49
|
+
# encoding it asked for is still being compressed. Stored, but revalidated on every use,
|
|
50
|
+
# so the next visit swaps it for the compressed body instead of keeping it for a year.
|
|
51
|
+
PROVISIONAL_CACHE_CONTROL = "no-cache"
|
|
47
52
|
MEDIA_TYPE = "text/javascript; charset=utf-8"
|
|
48
53
|
|
|
49
54
|
logger = logging.getLogger(__name__)
|
|
@@ -55,7 +60,7 @@ BROTLI_SAVING_PERCENT = 17
|
|
|
55
60
|
|
|
56
61
|
try:
|
|
57
62
|
import brotli
|
|
58
|
-
except ImportError: # pragma: no cover -
|
|
63
|
+
except ImportError: # pragma: no cover - a dependency, absent only where it was left out
|
|
59
64
|
brotli = None
|
|
60
65
|
|
|
61
66
|
|
|
@@ -72,6 +77,8 @@ class CompressedBundle:
|
|
|
72
77
|
f"{stat.st_size}-{stat.st_mtime_ns}".encode(), usedforsecurity=False
|
|
73
78
|
).hexdigest()[:16]
|
|
74
79
|
self.encodings: dict[str, bytes] = {}
|
|
80
|
+
# Every encoding the compression will produce, in the order it produces them.
|
|
81
|
+
self.produces: tuple[str, ...] = ("br", "gzip") if brotli is not None else ("gzip",)
|
|
75
82
|
self._ready = threading.Event()
|
|
76
83
|
self._started = False
|
|
77
84
|
self._lock = threading.Lock()
|
|
@@ -86,6 +93,11 @@ class CompressedBundle:
|
|
|
86
93
|
def wait(self, timeout: float | None = None) -> bool:
|
|
87
94
|
return self._ready.wait(timeout)
|
|
88
95
|
|
|
96
|
+
@property
|
|
97
|
+
def ready(self) -> bool:
|
|
98
|
+
"""Whether compression has finished, every encoding it will produce in place."""
|
|
99
|
+
return self._ready.is_set()
|
|
100
|
+
|
|
89
101
|
def etag(self, encoding: str | None) -> str:
|
|
90
102
|
return f'"{self.etag_base}"' if encoding is None else f'"{self.etag_base}-{encoding}"'
|
|
91
103
|
|
|
@@ -103,8 +115,8 @@ class CompressedBundle:
|
|
|
103
115
|
# bundle is compressed, cached and immutable either way, just bigger.
|
|
104
116
|
logger.warning(
|
|
105
117
|
"shiny-plotly is serving plotly.min.js gzipped (%.2f MB); brotli would "
|
|
106
|
-
"be about %d%% smaller. Install
|
|
107
|
-
"
|
|
118
|
+
"be about %d%% smaller. Install brotli for it, or silence this with "
|
|
119
|
+
"logging.getLogger('shiny_plotly').setLevel(logging.ERROR).",
|
|
108
120
|
len(self.encodings["gzip"]) / 1e6,
|
|
109
121
|
BROTLI_SAVING_PERCENT,
|
|
110
122
|
)
|
|
@@ -127,21 +139,59 @@ def bundle() -> CompressedBundle:
|
|
|
127
139
|
return _bundle
|
|
128
140
|
|
|
129
141
|
|
|
142
|
+
def refuses(params: str) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Whether the parameters of one ``Accept-Encoding`` entry rule its encoding out.
|
|
145
|
+
|
|
146
|
+
RFC 9110 writes a refusal as a qvalue of zero, which is ``q=0`` but equally ``q=0.0``
|
|
147
|
+
and ``q=0.000``, wherever it stands among the entry's ``;``-separated parameters.
|
|
148
|
+
Anything else leaves the encoding on offer, an unparsable qvalue included: serving a
|
|
149
|
+
client the encoding it asked for beats guessing at a malformed header.
|
|
150
|
+
"""
|
|
151
|
+
for param in params.split(";"):
|
|
152
|
+
key, _, value = param.partition("=")
|
|
153
|
+
if key.strip().lower() != "q":
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
return float(value) == 0
|
|
157
|
+
except ValueError:
|
|
158
|
+
return False
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
130
162
|
def accepted_encodings(accept_encoding: str | None) -> Iterator[str]:
|
|
131
163
|
"""Encodings the client accepts, in our order of preference."""
|
|
132
164
|
if not accept_encoding:
|
|
133
165
|
return
|
|
134
166
|
offered: set[str] = set()
|
|
167
|
+
refused: set[str] = set()
|
|
135
168
|
for part in accept_encoding.split(","):
|
|
136
169
|
token, _, params = part.partition(";")
|
|
137
|
-
if params
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
(refused if refuses(params) else offered).add(token.strip().lower())
|
|
171
|
+
# "*" stands for every encoding the header does not name (RFC 9110, 12.5.3), so a
|
|
172
|
+
# named refusal holds against it, and a named acceptance holds against "*;q=0".
|
|
173
|
+
wildcard = "*" in offered
|
|
140
174
|
for encoding in ("br", "gzip"):
|
|
141
|
-
if encoding in offered or
|
|
175
|
+
if encoding in offered or (wildcard and encoding not in refused):
|
|
142
176
|
yield encoding
|
|
143
177
|
|
|
144
178
|
|
|
179
|
+
def etag_matches(if_none_match: str, etag: str) -> bool:
|
|
180
|
+
"""
|
|
181
|
+
Whether an ``If-None-Match`` header names ``etag``, compared as RFC 9110 requires.
|
|
182
|
+
|
|
183
|
+
The comparison is weak (13.1.2): ``W/"x"`` matches ``"x"``, since a proxy may weaken a
|
|
184
|
+
tag it passes on. ``*`` matches any current representation, which this route always has.
|
|
185
|
+
"""
|
|
186
|
+
for candidate in if_none_match.split(","):
|
|
187
|
+
tag = candidate.strip()
|
|
188
|
+
if tag == "*":
|
|
189
|
+
return True
|
|
190
|
+
if tag.removeprefix("W/") == etag:
|
|
191
|
+
return True
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
145
195
|
def response_for(
|
|
146
196
|
bundle: CompressedBundle,
|
|
147
197
|
*,
|
|
@@ -149,10 +199,19 @@ def response_for(
|
|
|
149
199
|
if_none_match: str | None,
|
|
150
200
|
method: str = "GET",
|
|
151
201
|
) -> Response:
|
|
152
|
-
|
|
202
|
+
accepted = list(accepted_encodings(accept_encoding))
|
|
203
|
+
encoding = next((e for e in accepted if e in bundle.encodings), None)
|
|
204
|
+
# The encoding this client gets once compression is done. Until then it may be served
|
|
205
|
+
# the raw file instead, which must not be the body it caches for a year.
|
|
206
|
+
eventual = next((e for e in accepted if e in bundle.produces), None)
|
|
207
|
+
final = bundle.ready or encoding == eventual
|
|
153
208
|
etag = bundle.etag(encoding)
|
|
154
|
-
headers = {
|
|
155
|
-
|
|
209
|
+
headers = {
|
|
210
|
+
"Cache-Control": CACHE_CONTROL if final else PROVISIONAL_CACHE_CONTROL,
|
|
211
|
+
"Vary": "Accept-Encoding",
|
|
212
|
+
"ETag": etag,
|
|
213
|
+
}
|
|
214
|
+
if if_none_match is not None and etag_matches(if_none_match, etag):
|
|
156
215
|
return Response(status_code=304, headers=headers)
|
|
157
216
|
if encoding is None:
|
|
158
217
|
# Streams the file from disk; HEAD is detected from the request scope.
|
|
@@ -8,9 +8,10 @@ dashboard wants for a new point every second. They ride on Shiny's custom messag
|
|
|
8
8
|
are applied by the browser helper to the graph div of the named output. One sent while
|
|
9
9
|
the output has no figure drawn (its first render is still running, it sits in a hidden
|
|
10
10
|
tab, it shows an error or was emptied by ``None``) is held and applied, in order, right
|
|
11
|
-
after the output's next draw
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
after the output's next draw; consecutive held extends (or prepends) to the same traces,
|
|
12
|
+
attributes and ``max_points`` are merged into one as they arrive. A re-render replaces
|
|
13
|
+
the figure, updates included, with what the render function returns. All of these are
|
|
14
|
+
coroutines: await them from an ``async def`` effect.
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
17
|
from __future__ import annotations
|
|
@@ -21,6 +22,8 @@ from typing import Any
|
|
|
21
22
|
from plotly.io.json import to_json_plotly
|
|
22
23
|
from shiny.session import Session, require_active_session
|
|
23
24
|
|
|
25
|
+
from ._validate import as_int, as_positive_int
|
|
26
|
+
|
|
24
27
|
__all__ = (
|
|
25
28
|
"add_traces",
|
|
26
29
|
"delete_traces",
|
|
@@ -40,20 +43,23 @@ Trace = Mapping[str, Any] | Any
|
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
def _indices(indices: Indices) -> list[int] | None:
|
|
46
|
+
"""Trace indices as a list of Python ints; numpy integers and arrays are taken too."""
|
|
43
47
|
if indices is None:
|
|
44
48
|
return None
|
|
45
|
-
|
|
46
|
-
return [indices]
|
|
47
|
-
|
|
49
|
+
try:
|
|
50
|
+
return [as_int(indices)]
|
|
51
|
+
except TypeError:
|
|
52
|
+
pass
|
|
53
|
+
try:
|
|
54
|
+
return [as_int(index) for index in indices] # type: ignore[union-attr]
|
|
55
|
+
except TypeError:
|
|
56
|
+
raise ValueError(f"indices must be an integer or integers, got {indices!r}") from None
|
|
48
57
|
|
|
49
58
|
|
|
50
|
-
def
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
if max_points is
|
|
54
|
-
isinstance(max_points, bool) or not isinstance(max_points, int) or max_points < 1
|
|
55
|
-
):
|
|
56
|
-
raise ValueError(f"max_points must be a positive integer, got {max_points!r}")
|
|
59
|
+
def _max_points(max_points: int | None) -> int | None:
|
|
60
|
+
# A JSON true, which a Python bool would become, reads to plotly.js as non-numeric and
|
|
61
|
+
# quietly means no cap at all; as_positive_int refuses it here instead.
|
|
62
|
+
return None if max_points is None else as_positive_int(max_points, "max_points")
|
|
57
63
|
|
|
58
64
|
|
|
59
65
|
def _trace_json(trace: Trace) -> Any:
|
|
@@ -97,8 +103,7 @@ async def extend_traces(
|
|
|
97
103
|
The update is sent to the session's client; outside a session it fails. Use it
|
|
98
104
|
from a reactive effect, for instance one driven by ``reactive.invalidate_later``.
|
|
99
105
|
"""
|
|
100
|
-
|
|
101
|
-
await _send(id, "extendTraces", [data, _indices(indices), max_points], session)
|
|
106
|
+
await _send(id, "extendTraces", [data, _indices(indices), _max_points(max_points)], session)
|
|
102
107
|
|
|
103
108
|
|
|
104
109
|
async def prepend_traces(
|
|
@@ -116,8 +121,7 @@ async def prepend_traces(
|
|
|
116
121
|
ones, and ``max_points`` drops the newest points past that many instead of the
|
|
117
122
|
oldest. Same ``data`` and ``indices`` shapes.
|
|
118
123
|
"""
|
|
119
|
-
|
|
120
|
-
await _send(id, "prependTraces", [data, _indices(indices), max_points], session)
|
|
124
|
+
await _send(id, "prependTraces", [data, _indices(indices), _max_points(max_points)], session)
|
|
121
125
|
|
|
122
126
|
|
|
123
127
|
async def add_traces(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Argument checks shared by the renderer and the in-place updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import operator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def as_int(value: Any) -> int:
|
|
10
|
+
"""
|
|
11
|
+
``value`` as a Python int, for anything integer-like: ``int`` and numpy's integers.
|
|
12
|
+
|
|
13
|
+
Raises TypeError for anything else. ``bool`` is refused by name: it is an int in Python
|
|
14
|
+
but serializes to a JSON ``true``, which plotly.js does not read as a number (numpy's
|
|
15
|
+
bool is no integer to begin with). A float, even a whole one, is refused as well, the
|
|
16
|
+
way ``range()`` and list indexing refuse it.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(value, bool):
|
|
19
|
+
raise TypeError(f"expected an integer, got {value!r}")
|
|
20
|
+
return operator.index(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def as_positive_int(value: Any, name: str) -> int:
|
|
24
|
+
"""``value`` as a Python int of at least 1; ValueError naming ``name`` otherwise."""
|
|
25
|
+
try:
|
|
26
|
+
number = as_int(value)
|
|
27
|
+
except TypeError:
|
|
28
|
+
number = 0
|
|
29
|
+
if number < 1:
|
|
30
|
+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
|
|
31
|
+
return number
|