shiny-plotly 0.3.2__tar.gz → 0.3.3__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.2 → shiny_plotly-0.3.3}/CHANGELOG.md +13 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/PKG-INFO +47 -11
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/README.md +46 -10
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/core_app.py +1 -4
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/pyproject.toml +2 -1
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/__init__.py +6 -1
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_render.py +83 -20
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_serve.py +75 -15
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/www/shiny-plotly.js +65 -26
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/apps.py +70 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/conftest.py +6 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_browser.py +13 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_dark_mode.py +82 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_compressed_js.py +118 -5
- shiny_plotly-0.3.3/tests/test_pages_contract.py +50 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_render_plotly.py +193 -29
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/.gitignore +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/LICENSE +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/dark_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/events_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/shinylive/app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/streaming_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_update.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_events.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_shinylive_demo.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_streaming_example.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_update.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_examples.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_plotly_js.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_update.py +0 -0
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A themed chart takes its color mode from the nearest ancestor that carries `data-bs-theme`, itself included, instead of from `<html>` alone. Bootstrap honors the attribute on any element, so `ui.div(..., data_bs_theme="dark")` around part of a page now themes the charts inside it while the rest of the page follows the page mode. The observer watches the whole subtree, so the attribute can be set on a container after the charts are drawn; that is all a control of your own needs to drive the mode, and the README carries the recipe.
|
|
15
|
+
- A warning, said once per process, when `plotly.min.js` is served gzipped because `brotli` is not installed: it names the size being served and the roughly 17% brotli would save. Without it a deployment has no way to notice, since the bundle is compressed, cached and immutable either way, just bigger. Silence it with `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)`.
|
|
16
|
+
- README: what each chart on a page costs to draw, and that Shiny suspends outputs hidden in an inactive `ui.nav_panel`, so charts in tabs are drawn when their tab is first opened rather than on load.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Every app serves plotly.min.js compressed and immutable from its first request, with no call of its own: importing `shiny_plotly` wraps `shiny.App.__init__`, so the route is added as each app is built, Core and Express alike. Until now it arrived with the first session, which is after the page load that opened that session had already pulled 4.9 MB from Shiny's static mount with no `Cache-Control` at all; the background compression now starts while the app is being built instead of when the first visitor is already waiting. `enable_compressed_plotly_js(app)` stays for an app constructed before the import, `SHINY_PLOTLY_NO_COMPRESS=1` still leaves Shiny's static serving untouched, and a failure to install the route is logged rather than raised: it can never keep an app from being built.
|
|
21
|
+
- The templates behind `@render_plotly(theme=...)` travel once per session instead of once per figure. A template is about 6.5 kB, and every chart on a page usually shares one pair, so the first themed value carries the pair and every value afterwards names it by content hash: a page of ten charts sends about 13 kB of templates instead of 135 kB. Two outputs on different themes each send their own pair, a module shares the cache with the page around it, and a reconnecting browser gets a new session and so a fresh copy. A render with no session to cache against (a value produced outside a session, or Express's stub session before it connects) still carries its templates inline.
|
|
22
|
+
|
|
10
23
|
## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -206,6 +206,12 @@ The rules mirror `output_widget`:
|
|
|
206
206
|
|
|
207
207
|
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
|
|
208
208
|
|
|
209
|
+
### Many charts on one page
|
|
210
|
+
|
|
211
|
+
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
|
+
|
|
213
|
+
The lever is drawing fewer charts at once. Shiny suspends an output that is hidden, so charts inside `ui.navset_tab` panels or `ui.accordion` sections are not rendered at all until their panel is shown, and each panel then pays only for its own charts. Charts that must all be visible are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
214
|
+
|
|
209
215
|
### Dark mode
|
|
210
216
|
|
|
211
217
|
Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
|
|
@@ -229,7 +235,40 @@ def sales():
|
|
|
229
235
|
def sales(): ...
|
|
230
236
|
```
|
|
231
237
|
|
|
232
|
-
How it works: both templates
|
|
238
|
+
How it works: both templates get their `paper_bgcolor` and `plot_bgcolor` made transparent, so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
|
|
239
|
+
|
|
240
|
+
A template is about 6.5 kB of JSON, so it is sent **once per session** rather than once per render: the first themed value of a session brings the templates over their own message, and every value after it names them by content hash. A dashboard of twelve charts on one theme therefore pays for the theme once, and a re-render of a small figure is not dominated by a template that has not changed. Two outputs whose templates are equal share the copy automatically; nothing has to be declared.
|
|
241
|
+
|
|
242
|
+
The mode comes from `data-bs-theme` on the **nearest ancestor of the output that sets one**. Usually that is `<html>`, which is what `ui.input_dark_mode()` maintains, and a page without the attribute anywhere follows the OS `prefers-color-scheme`. Because the lookup is per output, a container can theme the charts inside it against the rest of the page:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
ui.div(
|
|
246
|
+
ui.card(output_plotly("preview")), # always dark, whatever the page does
|
|
247
|
+
data_bs_theme="dark",
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Driving the mode yourself.** The attribute is the whole contract, so anything that sets it switches the charts: no shiny-plotly API is involved. To drive it from your own control, write it and the graphs follow on the next frame:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
app_ui = ui.page_fluid(
|
|
255
|
+
ui.input_switch("night", "Night mode"),
|
|
256
|
+
ui.tags.script("""
|
|
257
|
+
Shiny.addCustomMessageHandler("color-mode", function (message) {
|
|
258
|
+
document.documentElement.setAttribute("data-bs-theme", message.mode);
|
|
259
|
+
});
|
|
260
|
+
"""),
|
|
261
|
+
ui.card(output_plotly("sales")),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@reactive.effect
|
|
266
|
+
async def _apply_mode():
|
|
267
|
+
mode = "dark" if input.night() else "light"
|
|
268
|
+
await session.send_custom_message("color-mode", {"mode": mode})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The same holds in reverse: a page that already themes itself (a CSS framework, a cookie read at startup, `ui.input_dark_mode()` in a nav bar) needs nothing added, and a chart rendered while the attribute is already `dark` comes up dark on its first draw rather than flashing light first.
|
|
233
272
|
|
|
234
273
|
The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
|
|
235
274
|
|
|
@@ -373,29 +412,26 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
373
412
|
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
|
|
374
413
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
375
414
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
376
|
-
- `enable_compressed_plotly_js(app)`
|
|
415
|
+
- `enable_compressed_plotly_js(app)` adds compressed, immutable serving of plotly.js to a `shiny.App` explicitly. Every app built after `shiny_plotly` is imported already has it; this is the way in for one that was constructed before the import (see below).
|
|
377
416
|
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
378
417
|
|
|
379
418
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
380
419
|
|
|
381
420
|
### plotly.js on the wire
|
|
382
421
|
|
|
383
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so
|
|
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 when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) 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 the same headers.
|
|
384
423
|
|
|
385
424
|
```sh
|
|
386
425
|
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
387
426
|
```
|
|
388
427
|
|
|
389
|
-
|
|
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.
|
|
390
429
|
|
|
391
|
-
|
|
392
|
-
from shiny_plotly import enable_compressed_plotly_js
|
|
430
|
+
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.
|
|
393
431
|
|
|
394
|
-
app
|
|
395
|
-
enable_compressed_plotly_js(app)
|
|
396
|
-
```
|
|
432
|
+
An app constructed before `shiny_plotly` is imported is the one case the constructor cannot reach; `enable_compressed_plotly_js(app)` adds the route to it, and returns `False` if it is already there.
|
|
397
433
|
|
|
398
|
-
|
|
434
|
+
If a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment: no route is added, and `enable_compressed_plotly_js` returns `False`.
|
|
399
435
|
|
|
400
436
|
### Shinylive
|
|
401
437
|
|
|
@@ -175,6 +175,12 @@ The rules mirror `output_widget`:
|
|
|
175
175
|
|
|
176
176
|
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
|
|
177
177
|
|
|
178
|
+
### Many charts on one page
|
|
179
|
+
|
|
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
|
+
|
|
182
|
+
The lever is drawing fewer charts at once. Shiny suspends an output that is hidden, so charts inside `ui.navset_tab` panels or `ui.accordion` sections are not rendered at all until their panel is shown, and each panel then pays only for its own charts. Charts that must all be visible are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
183
|
+
|
|
178
184
|
### Dark mode
|
|
179
185
|
|
|
180
186
|
Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
|
|
@@ -198,7 +204,40 @@ def sales():
|
|
|
198
204
|
def sales(): ...
|
|
199
205
|
```
|
|
200
206
|
|
|
201
|
-
How it works: both templates
|
|
207
|
+
How it works: both templates get their `paper_bgcolor` and `plot_bgcolor` made transparent, so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
|
|
208
|
+
|
|
209
|
+
A template is about 6.5 kB of JSON, so it is sent **once per session** rather than once per render: the first themed value of a session brings the templates over their own message, and every value after it names them by content hash. A dashboard of twelve charts on one theme therefore pays for the theme once, and a re-render of a small figure is not dominated by a template that has not changed. Two outputs whose templates are equal share the copy automatically; nothing has to be declared.
|
|
210
|
+
|
|
211
|
+
The mode comes from `data-bs-theme` on the **nearest ancestor of the output that sets one**. Usually that is `<html>`, which is what `ui.input_dark_mode()` maintains, and a page without the attribute anywhere follows the OS `prefers-color-scheme`. Because the lookup is per output, a container can theme the charts inside it against the rest of the page:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
ui.div(
|
|
215
|
+
ui.card(output_plotly("preview")), # always dark, whatever the page does
|
|
216
|
+
data_bs_theme="dark",
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Driving the mode yourself.** The attribute is the whole contract, so anything that sets it switches the charts: no shiny-plotly API is involved. To drive it from your own control, write it and the graphs follow on the next frame:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
app_ui = ui.page_fluid(
|
|
224
|
+
ui.input_switch("night", "Night mode"),
|
|
225
|
+
ui.tags.script("""
|
|
226
|
+
Shiny.addCustomMessageHandler("color-mode", function (message) {
|
|
227
|
+
document.documentElement.setAttribute("data-bs-theme", message.mode);
|
|
228
|
+
});
|
|
229
|
+
"""),
|
|
230
|
+
ui.card(output_plotly("sales")),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@reactive.effect
|
|
235
|
+
async def _apply_mode():
|
|
236
|
+
mode = "dark" if input.night() else "light"
|
|
237
|
+
await session.send_custom_message("color-mode", {"mode": mode})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The same holds in reverse: a page that already themes itself (a CSS framework, a cookie read at startup, `ui.input_dark_mode()` in a nav bar) needs nothing added, and a chart rendered while the attribute is already `dark` comes up dark on its first draw rather than flashing light first.
|
|
202
241
|
|
|
203
242
|
The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
|
|
204
243
|
|
|
@@ -342,29 +381,26 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
342
381
|
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
|
|
343
382
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
344
383
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
345
|
-
- `enable_compressed_plotly_js(app)`
|
|
384
|
+
- `enable_compressed_plotly_js(app)` adds compressed, immutable serving of plotly.js to a `shiny.App` explicitly. Every app built after `shiny_plotly` is imported already has it; this is the way in for one that was constructed before the import (see below).
|
|
346
385
|
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
347
386
|
|
|
348
387
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
349
388
|
|
|
350
389
|
### plotly.js on the wire
|
|
351
390
|
|
|
352
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so
|
|
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 when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) 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 the same headers.
|
|
353
392
|
|
|
354
393
|
```sh
|
|
355
394
|
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
356
395
|
```
|
|
357
396
|
|
|
358
|
-
|
|
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.
|
|
359
398
|
|
|
360
|
-
|
|
361
|
-
from shiny_plotly import enable_compressed_plotly_js
|
|
399
|
+
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.
|
|
362
400
|
|
|
363
|
-
app
|
|
364
|
-
enable_compressed_plotly_js(app)
|
|
365
|
-
```
|
|
401
|
+
An app constructed before `shiny_plotly` is imported is the one case the constructor cannot reach; `enable_compressed_plotly_js(app)` adds the route to it, and returns `False` if it is already there.
|
|
366
402
|
|
|
367
|
-
|
|
403
|
+
If a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment: no route is added, and `enable_compressed_plotly_js` returns `False`.
|
|
368
404
|
|
|
369
405
|
### Shinylive
|
|
370
406
|
|
|
@@ -9,7 +9,7 @@ from itertools import accumulate
|
|
|
9
9
|
import plotly.graph_objects as go
|
|
10
10
|
from shiny import App, Inputs, Outputs, Session, reactive, render, ui
|
|
11
11
|
|
|
12
|
-
from shiny_plotly import
|
|
12
|
+
from shiny_plotly import extend_traces, output_plotly, render_plotly
|
|
13
13
|
|
|
14
14
|
app_ui = ui.page_sidebar(
|
|
15
15
|
ui.sidebar(
|
|
@@ -99,6 +99,3 @@ def server(input: Inputs, output: Outputs, session: Session):
|
|
|
99
99
|
|
|
100
100
|
|
|
101
101
|
app = App(app_ui, server)
|
|
102
|
-
# Optional: serve plotly.js compressed from the very first request instead of from the
|
|
103
|
-
# first session on (see README, "plotly.js on the wire").
|
|
104
|
-
enable_compressed_plotly_js(app)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiny-plotly"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
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"
|
|
@@ -58,6 +58,7 @@ dev = [
|
|
|
58
58
|
"pytest-playwright>=0.9.0",
|
|
59
59
|
"numpy>=1.26",
|
|
60
60
|
"brotli>=1.1",
|
|
61
|
+
"shinylive-check>=0.1.0",
|
|
61
62
|
]
|
|
62
63
|
|
|
63
64
|
# uv's default cache keys for a local build are the metadata files only, so a source-only
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from ._deps import __version__, plotly_js, shiny_plotly_js
|
|
4
4
|
from ._html import FIGUREWIDGET_MARGINS, fig_to_ui
|
|
5
5
|
from ._render import DEFAULT_MAX_EVENT_POINTS, output_plotly, render_plotly
|
|
6
|
-
from ._serve import enable_compressed_plotly_js
|
|
6
|
+
from ._serve import enable_compressed_plotly_js, enable_for_new_apps
|
|
7
7
|
from ._update import (
|
|
8
8
|
add_traces,
|
|
9
9
|
delete_traces,
|
|
@@ -32,3 +32,8 @@ __all__ = (
|
|
|
32
32
|
"shiny_plotly_js",
|
|
33
33
|
"update",
|
|
34
34
|
)
|
|
35
|
+
|
|
36
|
+
# From here on every app serves plotly.min.js compressed and immutable, from its first
|
|
37
|
+
# request, with no call of its own; the compression starts while the app is being built.
|
|
38
|
+
# SHINY_PLOTLY_NO_COMPRESS=1 in the environment leaves Shiny's static serving untouched.
|
|
39
|
+
enable_for_new_apps()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import hashlib
|
|
4
|
+
import weakref
|
|
5
|
+
from collections.abc import Iterable, Mapping, MutableMapping
|
|
4
6
|
from typing import Any
|
|
5
7
|
|
|
6
8
|
import plotly.io as pio
|
|
@@ -8,6 +10,7 @@ from htmltools import Tag, css, tags
|
|
|
8
10
|
from plotly.io.json import to_json_plotly
|
|
9
11
|
from shiny.module import resolve_id
|
|
10
12
|
from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
|
|
13
|
+
from shiny.session import Session, get_current_session
|
|
11
14
|
from shiny.ui.fill import as_fill_item, as_fillable_container
|
|
12
15
|
|
|
13
16
|
from ._deps import plotly_js, shiny_plotly_js
|
|
@@ -102,6 +105,27 @@ def _template_json(spec: TemplateSpec) -> dict[str, Any]:
|
|
|
102
105
|
return out
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
# Name of the custom message carrying templates the session has not seen yet.
|
|
109
|
+
TEMPLATE_MESSAGE = "shiny-plotly-template"
|
|
110
|
+
|
|
111
|
+
# Keys of the templates each session has already been sent, by root session. A template is
|
|
112
|
+
# about 6.5 kB and every chart on a page usually shares the same one, so it travels once
|
|
113
|
+
# per session and the values afterwards carry only its key. The session holds the set, and
|
|
114
|
+
# a browser that reconnects gets a new session, so the server can never believe the browser
|
|
115
|
+
# has a template it does not; a weak key lets both go when the session ends.
|
|
116
|
+
_sent_templates: MutableMapping[Session, set[str]] = weakref.WeakKeyDictionary()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def template_body(spec: TemplateSpec) -> str:
|
|
120
|
+
"""One template as the JSON it travels as, through plotly's own encoder."""
|
|
121
|
+
return str(to_json_plotly(_template_json(spec)))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def template_key(template_json: str) -> str:
|
|
125
|
+
"""Content address of one serialized template; equal templates share one key."""
|
|
126
|
+
return hashlib.sha256(template_json.encode(), usedforsecurity=False).hexdigest()[:16]
|
|
127
|
+
|
|
128
|
+
|
|
105
129
|
def normalize_max_event_points(value: int | None) -> int | None:
|
|
106
130
|
if value is None:
|
|
107
131
|
return None
|
|
@@ -211,14 +235,17 @@ class render_plotly(Renderer[Figure]):
|
|
|
211
235
|
round-trip. ``"auto"`` uses plotly's own pair: the ``"plotly"`` template in light
|
|
212
236
|
mode and ``"plotly_dark"`` in dark. A ``(light, dark)`` pair picks the templates,
|
|
213
237
|
each a registered name (``"seaborn"``), a plotly ``Template`` object or a template
|
|
214
|
-
dict. Both templates
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
238
|
+
dict. Both templates get transparent ``paper_bgcolor`` and ``plot_bgcolor`` so the
|
|
239
|
+
page shows through (a figure-level background still wins), and a template the
|
|
240
|
+
figure baked in via ``layout.template`` is dropped. Each template travels once per
|
|
241
|
+
session, so a page of charts sharing a theme sends one copy of it and every render
|
|
242
|
+
after that carries only its key. The browser applies the mode's template before the
|
|
243
|
+
first draw and switches it with ``Plotly.relayout`` when the mode flips: the mode is
|
|
244
|
+
the ``data-bs-theme`` of the output's nearest ancestor that sets one (what
|
|
245
|
+
``ui.input_dark_mode()`` sets on ``<html>``, or a container that themes part of a
|
|
246
|
+
page), the OS ``prefers-color-scheme`` otherwise. Template names resolve when the
|
|
247
|
+
decorator runs; an unknown name raises right there. Default ``None``: the figure's
|
|
248
|
+
own template, fixed.
|
|
222
249
|
"""
|
|
223
250
|
|
|
224
251
|
def __init__(
|
|
@@ -242,15 +269,21 @@ class render_plotly(Renderer[Figure]):
|
|
|
242
269
|
self.events = normalize_events(events)
|
|
243
270
|
self.max_event_points = normalize_max_event_points(max_event_points)
|
|
244
271
|
self.theme = normalize_theme(theme)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
272
|
+
# Resolved and serialized once, here, so an unknown name fails at decoration time
|
|
273
|
+
# and a render costs nothing extra.
|
|
274
|
+
self._theme_templates: dict[str, str] = {}
|
|
275
|
+
self._theme_keys: dict[str, Any] | None = None
|
|
276
|
+
self._themes_json: str | None = None
|
|
277
|
+
if self.theme is not None:
|
|
278
|
+
light, dark = (template_body(spec) for spec in self.theme)
|
|
279
|
+
self._theme_keys = {"light": template_key(light), "dark": template_key(dark)}
|
|
280
|
+
self._theme_templates = {
|
|
281
|
+
self._theme_keys["light"]: light,
|
|
282
|
+
self._theme_keys["dark"]: dark,
|
|
283
|
+
}
|
|
284
|
+
# The inline form, for a render with no session to cache against. Built from
|
|
285
|
+
# the same two strings, so both paths carry byte-identical templates.
|
|
286
|
+
self._themes_json = f'{{"light":{light},"dark":{dark}}}'
|
|
254
287
|
# Registers _fn (sets output_id from its name) when used as a bare decorator.
|
|
255
288
|
super().__init__(_fn) # type: ignore[arg-type]
|
|
256
289
|
|
|
@@ -265,16 +298,45 @@ class render_plotly(Renderer[Figure]):
|
|
|
265
298
|
def auto_output_ui(self) -> Tag:
|
|
266
299
|
return output_plotly(self.output_id)
|
|
267
300
|
|
|
301
|
+
async def _cache_templates(self) -> dict[str, Any] | None:
|
|
302
|
+
"""
|
|
303
|
+
The keys of this renderer's templates, once the session is known to hold them.
|
|
304
|
+
|
|
305
|
+
``None`` when there is no session to cache against (a render outside a session, or
|
|
306
|
+
Express's stub session, which drops custom messages), and the templates then travel
|
|
307
|
+
inline with the value instead.
|
|
308
|
+
"""
|
|
309
|
+
session = get_current_session()
|
|
310
|
+
if session is None or session.is_stub_session():
|
|
311
|
+
return None
|
|
312
|
+
sent = _sent_templates.setdefault(session.root_scope(), set())
|
|
313
|
+
new = {key: body for key, body in self._theme_templates.items() if key not in sent}
|
|
314
|
+
if new:
|
|
315
|
+
# Sent from inside the render, so it is its own websocket message ahead of the
|
|
316
|
+
# one carrying the values: the browser has the template before it is asked to
|
|
317
|
+
# draw with it. Batching it into the value message would invert that, since
|
|
318
|
+
# Shiny dispatches its custom handlers after its output values.
|
|
319
|
+
await session.send_custom_message(TEMPLATE_MESSAGE, {"templates": new})
|
|
320
|
+
# Recorded only once the send has gone through. A repeat send costs a message
|
|
321
|
+
# the browser overwrites with the same bytes, while a key recorded for a
|
|
322
|
+
# template that never arrived would leave the figure unthemed for the session.
|
|
323
|
+
sent.update(new)
|
|
324
|
+
return self._theme_keys
|
|
325
|
+
|
|
268
326
|
async def transform(self, value: Figure) -> Jsonifiable:
|
|
269
327
|
fig_dict = as_fig_dict(value)
|
|
270
328
|
if self.figurewidget_margins:
|
|
271
329
|
fill_in_margins(fig_dict)
|
|
272
|
-
|
|
330
|
+
themes_json, theme_keys = self._themes_json, None
|
|
331
|
+
if self._theme_templates:
|
|
273
332
|
# The browser picks the mode's template; the one the figure baked in at
|
|
274
333
|
# construction would only add dead weight and a flash of the wrong theme.
|
|
275
334
|
layout = fig_dict.get("layout")
|
|
276
335
|
if isinstance(layout, dict):
|
|
277
336
|
layout.pop("template", None)
|
|
337
|
+
theme_keys = await self._cache_templates()
|
|
338
|
+
if theme_keys is not None:
|
|
339
|
+
themes_json = None
|
|
278
340
|
return {
|
|
279
341
|
# Serialised by plotly, not Shiny: numpy and pandas values, datetimes and the
|
|
280
342
|
# compact base64 array encoding only work through plotly's encoder.
|
|
@@ -285,5 +347,6 @@ class render_plotly(Renderer[Figure]):
|
|
|
285
347
|
"post_script": self.post_script,
|
|
286
348
|
"events": list(self.events),
|
|
287
349
|
"max_event_points": self.max_event_points,
|
|
288
|
-
"themes":
|
|
350
|
+
"themes": themes_json,
|
|
351
|
+
"theme_keys": theme_keys,
|
|
289
352
|
}
|
|
@@ -6,24 +6,27 @@ no ``Cache-Control``, so each visit moves the 4.8 MB bundle or at least revalida
|
|
|
6
6
|
The bundle's URL is keyed by the plotly version (``/lib/plotly-<version>/plotly.min.js``),
|
|
7
7
|
so it can be served with a year-long ``immutable`` cache and pre-compressed once.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
has finished
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
Importing ``shiny_plotly`` wraps ``shiny.App.__init__``, so every app built after it gets a
|
|
10
|
+
route for the bundle's exact path in front of Shiny's mount, and a background thread starts
|
|
11
|
+
compressing the bundle once per process. Both happen while the app is being built, before it
|
|
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 with the same cache headers. An app
|
|
14
|
+
built before the import can still ask for the route with :func:`enable_compressed_plotly_js`,
|
|
15
|
+
and each session enables it for its own app, so it is there either way.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import functools
|
|
20
21
|
import gzip
|
|
21
22
|
import hashlib
|
|
23
|
+
import logging
|
|
22
24
|
import os
|
|
23
25
|
import sys
|
|
24
26
|
import threading
|
|
25
27
|
from collections.abc import Iterator
|
|
26
28
|
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
27
30
|
|
|
28
31
|
from starlette.requests import Request
|
|
29
32
|
from starlette.responses import FileResponse, Response
|
|
@@ -31,12 +34,25 @@ from starlette.routing import Route
|
|
|
31
34
|
|
|
32
35
|
from ._deps import plotly_js
|
|
33
36
|
|
|
34
|
-
__all__ = (
|
|
37
|
+
__all__ = (
|
|
38
|
+
"ROUTE_NAME",
|
|
39
|
+
"CompressedBundle",
|
|
40
|
+
"bundle",
|
|
41
|
+
"enable_compressed_plotly_js",
|
|
42
|
+
"enable_for_new_apps",
|
|
43
|
+
)
|
|
35
44
|
|
|
36
45
|
ROUTE_NAME = "shiny-plotly-bundle"
|
|
37
46
|
CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
38
47
|
MEDIA_TYPE = "text/javascript; charset=utf-8"
|
|
39
48
|
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
# How much smaller brotli q9 is than gzip -9 on plotly.min.js (1.22 MB against 1.46 MB on
|
|
52
|
+
# plotly 6.9.0). Only used to size the warning below, which is raised precisely when brotli
|
|
53
|
+
# is absent and the real figure cannot be measured.
|
|
54
|
+
BROTLI_SAVING_PERCENT = 17
|
|
55
|
+
|
|
40
56
|
try:
|
|
41
57
|
import brotli
|
|
42
58
|
except ImportError: # pragma: no cover - optional extra
|
|
@@ -81,6 +97,17 @@ class CompressedBundle:
|
|
|
81
97
|
if brotli is not None:
|
|
82
98
|
self.encodings["br"] = brotli.compress(raw, quality=9)
|
|
83
99
|
self.encodings["gzip"] = gzip.compress(raw, compresslevel=9, mtime=0)
|
|
100
|
+
if brotli is None:
|
|
101
|
+
# Compression runs once per process, so this is said once. Without it a
|
|
102
|
+
# deployment has no way to notice it is serving the larger encoding: the
|
|
103
|
+
# bundle is compressed, cached and immutable either way, just bigger.
|
|
104
|
+
logger.warning(
|
|
105
|
+
"shiny-plotly is serving plotly.min.js gzipped (%.2f MB); brotli would "
|
|
106
|
+
"be about %d%% smaller. Install shiny-plotly[brotli] for it, or silence "
|
|
107
|
+
"this with logging.getLogger('shiny_plotly').setLevel(logging.ERROR).",
|
|
108
|
+
len(self.encodings["gzip"]) / 1e6,
|
|
109
|
+
BROTLI_SAVING_PERCENT,
|
|
110
|
+
)
|
|
84
111
|
finally:
|
|
85
112
|
self._ready.set()
|
|
86
113
|
|
|
@@ -152,13 +179,9 @@ def enable_compressed_plotly_js(app: object) -> bool:
|
|
|
152
179
|
"""
|
|
153
180
|
Serve plotly.min.js compressed and immutable from ``app``, starting now.
|
|
154
181
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
process gets the compressed bundle too::
|
|
159
|
-
|
|
160
|
-
app = App(app_ui, server)
|
|
161
|
-
enable_compressed_plotly_js(app)
|
|
182
|
+
Importing ``shiny_plotly`` already does this for every ``shiny.App`` built afterwards,
|
|
183
|
+
so an app needs no call of its own. It stays the way in for an app that was constructed
|
|
184
|
+
before the import, and it is what each session calls for its own app.
|
|
162
185
|
|
|
163
186
|
Returns True when the route was added, False when it was already there, ``app`` is
|
|
164
187
|
not a Shiny app, or ``SHINY_PLOTLY_NO_COMPRESS`` is set in the environment (the escape
|
|
@@ -188,6 +211,43 @@ def enable_compressed_plotly_js(app: object) -> bool:
|
|
|
188
211
|
return True
|
|
189
212
|
|
|
190
213
|
|
|
214
|
+
# Marks the wrapper below, so a second import (or a module reload) wraps nothing twice.
|
|
215
|
+
_WRAPPED = "_shiny_plotly_wrapped"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def enable_for_new_apps() -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Serve the compressed bundle from every ``shiny.App`` built from now on.
|
|
221
|
+
|
|
222
|
+
Wraps ``shiny.App.__init__`` once, at import of this package, and enables the route on
|
|
223
|
+
each app it builds. Shiny offers a package no other hook into an app that early, and
|
|
224
|
+
early is the point: the browser asks for plotly.min.js while the page is loading, long
|
|
225
|
+
before the session that page opens exists, so a route installed by the first session is
|
|
226
|
+
installed one visitor too late. Express constructs the same ``App``, so it is covered too.
|
|
227
|
+
|
|
228
|
+
Returns True when the wrapper was installed, False when it was already there.
|
|
229
|
+
"""
|
|
230
|
+
from shiny import App
|
|
231
|
+
|
|
232
|
+
original = App.__init__
|
|
233
|
+
if getattr(original, _WRAPPED, False):
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
@functools.wraps(original)
|
|
237
|
+
def __init__(self: App, *args: Any, **kwargs: Any) -> None:
|
|
238
|
+
original(self, *args, **kwargs)
|
|
239
|
+
try:
|
|
240
|
+
enable_compressed_plotly_js(self)
|
|
241
|
+
except Exception:
|
|
242
|
+
# This app never asked for the route, so nothing here may keep it from being
|
|
243
|
+
# built; without it the bundle is served by Shiny's own mount, as before.
|
|
244
|
+
logger.warning("shiny-plotly could not serve plotly.min.js compressed", exc_info=True)
|
|
245
|
+
|
|
246
|
+
setattr(__init__, _WRAPPED, True)
|
|
247
|
+
App.__init__ = __init__
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
191
251
|
def enable_for_current_session() -> None:
|
|
192
252
|
"""Enable for the app of the active session, if any (no-op outside a session)."""
|
|
193
253
|
from shiny.session import get_current_session
|