shiny-plotly 0.3.3__tar.gz → 0.4.0__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.0}/.gitignore +1 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/CHANGELOG.md +15 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/PKG-INFO +6 -11
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/README.md +4 -8
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/pyproject.toml +7 -7
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_html.py +10 -4
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_render.py +1 -3
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_serve.py +22 -4
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/apps.py +78 -5
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/conftest.py +2 -2
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_browser.py +88 -11
- shiny_plotly-0.4.0/tests/helpers.py +25 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_compressed_js.py +63 -8
- shiny_plotly-0.4.0/tests/test_examples.py +50 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_fig_to_ui.py +10 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_render_plotly.py +51 -4
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_update.py +1 -15
- shiny_plotly-0.3.3/tests/test_examples.py +0 -41
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/LICENSE +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/core_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/dark_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/events_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/shinylive/app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/streaming_app.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/__init__.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_update.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/www/shiny-plotly.js +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_dark_mode.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_events.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_shinylive_demo.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_streaming_example.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_update.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_pages_contract.py +0 -0
- {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0](https://github.com/rvben/shiny-plotly/compare/v0.3.3...v0.4.0) - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- 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]`.
|
|
24
|
+
|
|
10
25
|
## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
|
@@ -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 the same headers.
|
|
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
|
|
|
@@ -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 the same headers.
|
|
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.0"
|
|
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 {})}
|
|
@@ -331,9 +331,7 @@ class render_plotly(Renderer[Figure]):
|
|
|
331
331
|
if self._theme_templates:
|
|
332
332
|
# The browser picks the mode's template; the one the figure baked in at
|
|
333
333
|
# 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)
|
|
334
|
+
fig_dict["layout"].pop("template", None)
|
|
337
335
|
theme_keys = await self._cache_templates()
|
|
338
336
|
if theme_keys is not None:
|
|
339
337
|
themes_json = None
|
|
@@ -55,7 +55,7 @@ BROTLI_SAVING_PERCENT = 17
|
|
|
55
55
|
|
|
56
56
|
try:
|
|
57
57
|
import brotli
|
|
58
|
-
except ImportError: # pragma: no cover -
|
|
58
|
+
except ImportError: # pragma: no cover - a dependency, absent only where it was left out
|
|
59
59
|
brotli = None
|
|
60
60
|
|
|
61
61
|
|
|
@@ -103,8 +103,8 @@ class CompressedBundle:
|
|
|
103
103
|
# bundle is compressed, cached and immutable either way, just bigger.
|
|
104
104
|
logger.warning(
|
|
105
105
|
"shiny-plotly is serving plotly.min.js gzipped (%.2f MB); brotli would "
|
|
106
|
-
"be about %d%% smaller. Install
|
|
107
|
-
"
|
|
106
|
+
"be about %d%% smaller. Install brotli for it, or silence this with "
|
|
107
|
+
"logging.getLogger('shiny_plotly').setLevel(logging.ERROR).",
|
|
108
108
|
len(self.encodings["gzip"]) / 1e6,
|
|
109
109
|
BROTLI_SAVING_PERCENT,
|
|
110
110
|
)
|
|
@@ -127,6 +127,24 @@ def bundle() -> CompressedBundle:
|
|
|
127
127
|
return _bundle
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def refuses(params: str) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Whether the parameters of one ``Accept-Encoding`` entry rule its encoding out.
|
|
133
|
+
|
|
134
|
+
RFC 9110 writes a refusal as a qvalue of zero, which is ``q=0`` but equally ``q=0.0``
|
|
135
|
+
and ``q=0.000``. Anything else leaves the encoding on offer, an unparsable qvalue
|
|
136
|
+
included: serving a client the encoding it asked for beats guessing at a malformed
|
|
137
|
+
header.
|
|
138
|
+
"""
|
|
139
|
+
key, _, value = params.partition("=")
|
|
140
|
+
if key.strip().lower() != "q":
|
|
141
|
+
return False
|
|
142
|
+
try:
|
|
143
|
+
return float(value) == 0
|
|
144
|
+
except ValueError:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
130
148
|
def accepted_encodings(accept_encoding: str | None) -> Iterator[str]:
|
|
131
149
|
"""Encodings the client accepts, in our order of preference."""
|
|
132
150
|
if not accept_encoding:
|
|
@@ -134,7 +152,7 @@ def accepted_encodings(accept_encoding: str | None) -> Iterator[str]:
|
|
|
134
152
|
offered: set[str] = set()
|
|
135
153
|
for part in accept_encoding.split(","):
|
|
136
154
|
token, _, params = part.partition(";")
|
|
137
|
-
if params
|
|
155
|
+
if refuses(params):
|
|
138
156
|
continue
|
|
139
157
|
offered.add(token.strip().lower())
|
|
140
158
|
for encoding in ("br", "gzip"):
|
|
@@ -377,14 +377,57 @@ def make_theme_app() -> App:
|
|
|
377
377
|
return App(app_ui, server)
|
|
378
378
|
|
|
379
379
|
|
|
380
|
-
def
|
|
381
|
-
"""
|
|
380
|
+
def make_hidden_app() -> App:
|
|
381
|
+
"""One chart per way a page can hide one, plus one that is merely below the fold.
|
|
382
|
+
|
|
383
|
+
Shiny does not render an output the browser reports as hidden, which is what the
|
|
384
|
+
README tells readers to lean on. Every container that hides a chart is here, each
|
|
385
|
+
chart with its own bar count so a reveal proves which one arrived, and the chart
|
|
386
|
+
3000px down the page is the control: hiding is not scrolling, so that one is drawn
|
|
387
|
+
at load and its presence proves a test can see a chart when there is one.
|
|
388
|
+
"""
|
|
382
389
|
app_ui = ui.page_fluid(
|
|
390
|
+
ui.tags.script(
|
|
391
|
+
# shiny:idle fires once nothing is left to render, so a test can read "not
|
|
392
|
+
# drawn" without a sleep and know it did not simply look too early.
|
|
393
|
+
"$(document).on('shiny:idle', function () {"
|
|
394
|
+
" window.shinyIdleCount = (window.shinyIdleCount || 0) + 1; });"
|
|
395
|
+
),
|
|
383
396
|
ui.navset_tab(
|
|
384
|
-
ui.nav_panel("First", output_plotly("first", height="
|
|
385
|
-
ui.nav_panel("Second", output_plotly("second", height="
|
|
397
|
+
ui.nav_panel("First", output_plotly("first", height="150px")),
|
|
398
|
+
ui.nav_panel("Second", output_plotly("second", height="150px")),
|
|
386
399
|
id="tab",
|
|
387
|
-
)
|
|
400
|
+
),
|
|
401
|
+
ui.navset_card_tab(
|
|
402
|
+
ui.nav_panel("Card one", "nothing here"),
|
|
403
|
+
ui.nav_panel("Card two", output_plotly("card", height="150px")),
|
|
404
|
+
id="card_tab",
|
|
405
|
+
),
|
|
406
|
+
ui.navset_pill(
|
|
407
|
+
ui.nav_panel("Pill one", "nothing here"),
|
|
408
|
+
ui.nav_panel("Pill two", output_plotly("pill", height="150px")),
|
|
409
|
+
id="pill",
|
|
410
|
+
),
|
|
411
|
+
ui.accordion(
|
|
412
|
+
ui.accordion_panel("Open section", "nothing here", value="open"),
|
|
413
|
+
ui.accordion_panel("Folded section", output_plotly("folded", height="150px")),
|
|
414
|
+
id="accordion",
|
|
415
|
+
open="open",
|
|
416
|
+
multiple=True,
|
|
417
|
+
),
|
|
418
|
+
ui.input_switch("show_conditional", "Show the conditional chart", value=False),
|
|
419
|
+
ui.panel_conditional(
|
|
420
|
+
"input.show_conditional", output_plotly("conditional", height="150px")
|
|
421
|
+
),
|
|
422
|
+
ui.input_switch("show_swapped", "Swap the hidden navset to its chart", value=False),
|
|
423
|
+
ui.navset_hidden(
|
|
424
|
+
ui.nav_panel(None, "nothing here", value="nothing"),
|
|
425
|
+
ui.nav_panel(None, output_plotly("swapped", height="150px"), value="chart"),
|
|
426
|
+
id="swap",
|
|
427
|
+
selected="nothing",
|
|
428
|
+
),
|
|
429
|
+
ui.div(style="height: 3000px"),
|
|
430
|
+
output_plotly("below", height="150px"),
|
|
388
431
|
)
|
|
389
432
|
|
|
390
433
|
def server(input: Inputs, output: Outputs, session: Session):
|
|
@@ -396,6 +439,36 @@ def make_tabs_app() -> App:
|
|
|
396
439
|
def second():
|
|
397
440
|
return bars(3)
|
|
398
441
|
|
|
442
|
+
@render_plotly
|
|
443
|
+
def card():
|
|
444
|
+
return bars(4)
|
|
445
|
+
|
|
446
|
+
@render_plotly
|
|
447
|
+
def pill():
|
|
448
|
+
return bars(5)
|
|
449
|
+
|
|
450
|
+
@render_plotly
|
|
451
|
+
def folded():
|
|
452
|
+
return bars(6)
|
|
453
|
+
|
|
454
|
+
@render_plotly
|
|
455
|
+
def conditional():
|
|
456
|
+
return bars(7)
|
|
457
|
+
|
|
458
|
+
@render_plotly
|
|
459
|
+
def swapped():
|
|
460
|
+
return bars(8)
|
|
461
|
+
|
|
462
|
+
@render_plotly
|
|
463
|
+
def below():
|
|
464
|
+
return bars(9)
|
|
465
|
+
|
|
466
|
+
@reactive.effect
|
|
467
|
+
def _swap_hidden_navset():
|
|
468
|
+
# shiny 1.0, the dependency floor, calls this update_navs; 1.7 renamed it.
|
|
469
|
+
update = getattr(ui, "update_navset", None) or ui.update_navs
|
|
470
|
+
update("swap", selected="chart" if input.show_swapped() else "nothing")
|
|
471
|
+
|
|
399
472
|
return App(app_ui, server)
|
|
400
473
|
|
|
401
474
|
|
|
@@ -17,11 +17,11 @@ from .apps import (
|
|
|
17
17
|
make_app,
|
|
18
18
|
make_dark_app,
|
|
19
19
|
make_events_app,
|
|
20
|
+
make_hidden_app,
|
|
20
21
|
make_lazy_app,
|
|
21
22
|
make_live_app,
|
|
22
23
|
make_own_mode_app,
|
|
23
24
|
make_scoped_theme_app,
|
|
24
|
-
make_tabs_app,
|
|
25
25
|
make_theme_app,
|
|
26
26
|
)
|
|
27
27
|
|
|
@@ -47,7 +47,7 @@ def server_url() -> Iterator[str]:
|
|
|
47
47
|
Mount("/theme", app=make_theme_app()),
|
|
48
48
|
Mount("/scoped", app=make_scoped_theme_app()),
|
|
49
49
|
Mount("/own-mode", app=make_own_mode_app()),
|
|
50
|
-
Mount("/
|
|
50
|
+
Mount("/hidden", app=make_hidden_app()),
|
|
51
51
|
Mount("/stream", app=load_example("streaming_app.py")),
|
|
52
52
|
Mount("/demo", app=load_example("shinylive/app.py")),
|
|
53
53
|
Mount("/", app=make_app()),
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Real-browser checks: what the package promises only shows in a rendering engine."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from collections.abc import Iterator
|
|
4
|
+
from collections.abc import Callable, Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
8
|
+
from packaging.version import Version
|
|
7
9
|
from playwright.sync_api import Page, expect
|
|
10
|
+
from shiny import __version__ as SHINY_VERSION
|
|
8
11
|
|
|
9
12
|
pytestmark = pytest.mark.browser
|
|
10
13
|
|
|
@@ -231,16 +234,88 @@ def test_none_empties_the_output_and_a_figure_brings_it_back(app: Page):
|
|
|
231
234
|
expect(app.locator("#fig .bars .point")).to_have_count(3)
|
|
232
235
|
|
|
233
236
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
page
|
|
237
|
-
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class HiddenChart:
|
|
239
|
+
"""A chart the /hidden page keeps out of sight, and what brings it into view."""
|
|
240
|
+
|
|
241
|
+
container: str
|
|
242
|
+
output_id: str
|
|
243
|
+
points: int
|
|
244
|
+
reveal: Callable[[Page], None]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Shiny suspends an output inside a false ``ui.panel_conditional`` from 1.6.1 on; 1.6.0 and
|
|
248
|
+
# older, the declared floor among them, draw that chart at load. Measured by installing each
|
|
249
|
+
# release and running this case; every other container here defers at every supported version.
|
|
250
|
+
CONDITIONAL_IS_DEFERRED = Version(SHINY_VERSION) >= Version("1.6.1")
|
|
251
|
+
|
|
252
|
+
HIDDEN_CHARTS = [
|
|
253
|
+
HiddenChart(
|
|
254
|
+
"navset_tab", "second", 3, lambda page: page.get_by_role("tab", name="Second").click()
|
|
255
|
+
),
|
|
256
|
+
HiddenChart(
|
|
257
|
+
"navset_card_tab", "card", 4, lambda page: page.get_by_role("tab", name="Card two").click()
|
|
258
|
+
),
|
|
259
|
+
HiddenChart(
|
|
260
|
+
"navset_pill", "pill", 5, lambda page: page.get_by_role("tab", name="Pill two").click()
|
|
261
|
+
),
|
|
262
|
+
HiddenChart(
|
|
263
|
+
"accordion",
|
|
264
|
+
"folded",
|
|
265
|
+
6,
|
|
266
|
+
lambda page: page.get_by_role("button", name="Folded section").click(),
|
|
267
|
+
),
|
|
268
|
+
pytest.param(
|
|
269
|
+
HiddenChart(
|
|
270
|
+
"panel_conditional", "conditional", 7, lambda page: page.click("#show_conditional")
|
|
271
|
+
),
|
|
272
|
+
marks=pytest.mark.skipif(
|
|
273
|
+
not CONDITIONAL_IS_DEFERRED,
|
|
274
|
+
reason=f"shiny {SHINY_VERSION} draws this one at load; 1.6.1 is where it defers",
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
HiddenChart("navset_hidden", "swapped", 8, lambda page: page.click("#show_swapped")),
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
# The open tab panel, and the chart 3000px down the page, which the browser calls visible even
|
|
281
|
+
# though nobody can see it yet. Both are controls: they prove a snapshot of the page sees a
|
|
282
|
+
# chart when there is one, so an id missing from it is an output Shiny suspended rather than a
|
|
283
|
+
# test that looked too early.
|
|
284
|
+
DRAWN_AT_LOAD = sorted(
|
|
285
|
+
["first-plotly", "below-plotly"] + ([] if CONDITIONAL_IS_DEFERRED else ["conditional-plotly"])
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def open_the_hidden_page(page: Page, server_url: str) -> None:
|
|
290
|
+
"""Load /hidden and wait until every output that is going to render has rendered."""
|
|
291
|
+
page.goto(server_url + "/hidden/")
|
|
292
|
+
expect(page.locator(f"#below {SVG}").first).to_be_visible()
|
|
293
|
+
page.wait_for_function("() => window.shinyIdleCount >= 1")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_only_the_charts_the_visitor_can_see_are_drawn_at_load(page: Page, server_url, errors):
|
|
297
|
+
"""Shiny suspends a hidden output, the lever the README hands a crowded dashboard."""
|
|
298
|
+
open_the_hidden_page(page, server_url)
|
|
299
|
+
|
|
300
|
+
drawn = page.eval_on_selector_all(".plotly-graph-div", "els => els.map(e => e.id).sort()")
|
|
301
|
+
|
|
302
|
+
assert drawn == DRAWN_AT_LOAD
|
|
303
|
+
assert errors == []
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@pytest.mark.parametrize("case", HIDDEN_CHARTS, ids=lambda case: case.container)
|
|
307
|
+
def test_a_hidden_chart_is_drawn_when_its_container_shows_it(
|
|
308
|
+
case: HiddenChart, page: Page, server_url, errors
|
|
309
|
+
):
|
|
310
|
+
"""Every container that hides a chart defers it, and pays for it when it is opened."""
|
|
311
|
+
open_the_hidden_page(page, server_url)
|
|
312
|
+
assert page.evaluate(f"() => document.getElementById('{case.output_id}-plotly')") is None
|
|
238
313
|
|
|
239
|
-
|
|
240
|
-
page.get_by_role("tab", name="Second").click()
|
|
314
|
+
case.reveal(page)
|
|
241
315
|
|
|
242
|
-
expect(page.locator(f"#
|
|
243
|
-
|
|
316
|
+
expect(page.locator(f"#{case.output_id} {SVG}").first).to_be_visible()
|
|
317
|
+
# Its own bar count, so the reveal drew this chart rather than any of the others.
|
|
318
|
+
expect(page.locator(f"#{case.output_id} .bars .point")).to_have_count(case.points)
|
|
244
319
|
assert errors == []
|
|
245
320
|
|
|
246
321
|
|
|
@@ -286,10 +361,12 @@ def test_the_bundle_arrives_compressed_and_immutable_for_the_next_visitor(
|
|
|
286
361
|
finally:
|
|
287
362
|
visitor.close()
|
|
288
363
|
|
|
289
|
-
|
|
364
|
+
# Chromium offers br over plain http on localhost too, and brotli is a dependency, so
|
|
365
|
+
# this is the encoding a real visitor gets rather than one of two possibilities.
|
|
366
|
+
assert headers["content-encoding"] == "br"
|
|
290
367
|
assert headers["cache-control"] == "public, max-age=31536000, immutable"
|
|
291
368
|
assert fresh["decoded"] == _serve.bundle().size, "the whole bundle, whichever plotly"
|
|
292
|
-
assert fresh["encoded"] < fresh["decoded"] // 3, "brotli
|
|
369
|
+
assert fresh["encoded"] < fresh["decoded"] // 3, "brotli on the wire"
|
|
293
370
|
assert fresh["transfer"] > fresh["encoded"], "headers plus the compressed body"
|
|
294
371
|
# Chromium would keep a freshly installed bundle for a while on heuristics alone; the
|
|
295
372
|
# header above is what makes this hold for a year, for every install age.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Test helper: run a coroutine from a synchronous test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
from collections.abc import Coroutine
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(coro: Coroutine[Any, Any, T]) -> T:
|
|
15
|
+
"""
|
|
16
|
+
The coroutine on its own loop in a worker thread. The main thread cannot run one
|
|
17
|
+
while the browser tests' session-wide playwright fixture is alive (its sync API
|
|
18
|
+
keeps a loop running there), and nothing awaited here needs that loop anyway.
|
|
19
|
+
|
|
20
|
+
The caller's context goes with it, so a ``session_context`` around the call is the
|
|
21
|
+
session the coroutine sees; a plain thread would leave it looking like no session.
|
|
22
|
+
"""
|
|
23
|
+
context = contextvars.copy_context()
|
|
24
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
25
|
+
return pool.submit(context.run, asyncio.run, coro).result()
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""Compressed, immutable serving of plotly.min.js, from every app's first request on."""
|
|
2
2
|
|
|
3
3
|
import gzip
|
|
4
|
+
import importlib.metadata
|
|
4
5
|
import logging
|
|
5
6
|
import pathlib
|
|
6
7
|
import threading
|
|
7
8
|
import types
|
|
8
9
|
|
|
10
|
+
import brotli
|
|
11
|
+
import httpx2._decoders
|
|
9
12
|
import plotly
|
|
10
13
|
import plotly.graph_objects as go
|
|
11
14
|
import pytest
|
|
15
|
+
from packaging.requirements import Requirement
|
|
12
16
|
from shiny import App, Inputs, Outputs, Session, render, ui
|
|
13
17
|
from shiny.express import wrap_express_app
|
|
14
18
|
from starlette.testclient import TestClient
|
|
@@ -66,6 +70,19 @@ def client():
|
|
|
66
70
|
yield client
|
|
67
71
|
|
|
68
72
|
|
|
73
|
+
@pytest.fixture
|
|
74
|
+
def brotli_body_as_it_arrived(monkeypatch):
|
|
75
|
+
"""Hand a ``br`` response over undecoded, so the test reads the bytes off the wire.
|
|
76
|
+
|
|
77
|
+
httpx decodes ``br`` by calling the installed brotli with ``output_buffer_limit``, which
|
|
78
|
+
brotli only accepts from 1.2 on, so against brotli 1.1 (the floor this package declares)
|
|
79
|
+
the call raises before a test sees the response. Dropping the decoder is what httpx itself
|
|
80
|
+
does when brotli is missing, and what is under test is the encoding shiny-plotly serves,
|
|
81
|
+
not a client's ability to decode it; a browser doing that is covered by tests/browser.
|
|
82
|
+
"""
|
|
83
|
+
monkeypatch.delitem(httpx2._decoders.SUPPORTED_DECODERS, "br", raising=False)
|
|
84
|
+
|
|
85
|
+
|
|
69
86
|
def test_bundle_is_served_gzipped_and_immutable(client):
|
|
70
87
|
resp = client.get(BUNDLE_URL, headers={"Accept-Encoding": "gzip"})
|
|
71
88
|
|
|
@@ -100,13 +117,13 @@ def test_bundle_revalidation_answers_304_per_encoding(client):
|
|
|
100
117
|
assert other.headers["etag"] != etag
|
|
101
118
|
|
|
102
119
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
def test_brotli_is_the_encoding_a_browser_gets(client, brotli_body_as_it_arrived):
|
|
121
|
+
"""brotli is a dependency, so this is what every install serves, not a lucky one."""
|
|
106
122
|
resp = client.get(BUNDLE_URL, headers={"Accept-Encoding": "gzip, br"})
|
|
107
123
|
|
|
108
124
|
assert resp.headers["content-encoding"] == "br"
|
|
109
|
-
assert resp.content
|
|
125
|
+
assert len(resp.content) < len(RAW) // 3
|
|
126
|
+
assert brotli.decompress(resp.content) == RAW, "brotli of the exact bundle"
|
|
110
127
|
|
|
111
128
|
|
|
112
129
|
def test_head_requests_carry_the_same_headers(client):
|
|
@@ -146,7 +163,7 @@ def test_fig_to_ui_inside_render_ui_enables_it_too():
|
|
|
146
163
|
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
|
|
147
164
|
|
|
148
165
|
|
|
149
|
-
def test_an_app_serves_the_compressed_bundle_before_any_session_exists():
|
|
166
|
+
def test_an_app_serves_the_compressed_bundle_before_any_session_exists(brotli_body_as_it_arrived):
|
|
150
167
|
"""No call, no websocket: the browser asks for the bundle while the page is still loading."""
|
|
151
168
|
app = make_app()
|
|
152
169
|
names = [getattr(r, "name", None) for r in app.starlette_app.router.routes]
|
|
@@ -156,7 +173,7 @@ def test_an_app_serves_the_compressed_bundle_before_any_session_exists():
|
|
|
156
173
|
resp = client.get(BUNDLE_URL, headers={"Accept-Encoding": "gzip, br"})
|
|
157
174
|
|
|
158
175
|
assert names[0] == _serve.ROUTE_NAME, "installed as the app was built"
|
|
159
|
-
assert resp.headers["content-encoding"]
|
|
176
|
+
assert resp.headers["content-encoding"] == "br"
|
|
160
177
|
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
|
|
161
178
|
assert int(resp.headers["content-length"]) < len(RAW) // 2
|
|
162
179
|
|
|
@@ -279,6 +296,31 @@ def test_gzip_body_is_the_bundle():
|
|
|
279
296
|
assert gzip.decompress(bundle.encodings["gzip"]) == RAW
|
|
280
297
|
|
|
281
298
|
|
|
299
|
+
@pytest.mark.parametrize(
|
|
300
|
+
("accept_encoding", "accepted"),
|
|
301
|
+
[
|
|
302
|
+
("gzip, br", ["br", "gzip"]),
|
|
303
|
+
("br", ["br"]),
|
|
304
|
+
("gzip", ["gzip"]),
|
|
305
|
+
("GZIP, Br ", ["br", "gzip"]),
|
|
306
|
+
("*", ["br", "gzip"]),
|
|
307
|
+
("gzip;q=1.0, br;q=0.8", ["br", "gzip"]),
|
|
308
|
+
("gzip, br;q=0", ["gzip"]),
|
|
309
|
+
("gzip, br;q=0.0", ["gzip"]),
|
|
310
|
+
("gzip, br;q=0.000", ["gzip"]),
|
|
311
|
+
("*;q=0", []),
|
|
312
|
+
("identity", []),
|
|
313
|
+
("deflate, zstd", []),
|
|
314
|
+
("br;q=none-of-that", ["br"]),
|
|
315
|
+
("", []),
|
|
316
|
+
(None, []),
|
|
317
|
+
],
|
|
318
|
+
)
|
|
319
|
+
def test_the_encodings_a_client_will_take_are_read_from_its_header(accept_encoding, accepted):
|
|
320
|
+
"""Nothing yielded means the raw file: the route has no encoding the client accepts."""
|
|
321
|
+
assert list(_serve.accepted_encodings(accept_encoding)) == accepted
|
|
322
|
+
|
|
323
|
+
|
|
282
324
|
def stub_bundle(tmp_path: pathlib.Path) -> _serve.CompressedBundle:
|
|
283
325
|
"""A bundle over a small stand-in file, so a test compresses bytes rather than 4.8 MB."""
|
|
284
326
|
path = tmp_path / "plotly.min.js"
|
|
@@ -300,13 +342,26 @@ def test_serving_the_larger_encoding_is_announced_when_brotli_is_missing(
|
|
|
300
342
|
assert set(bundle.encodings) == {"gzip"}
|
|
301
343
|
assert len(caplog.records) == 1, "said once per process, not once per request"
|
|
302
344
|
message = caplog.records[0].getMessage()
|
|
303
|
-
assert "
|
|
345
|
+
assert "Install brotli" in message, "the fix is named"
|
|
304
346
|
assert f"{_serve.BROTLI_SAVING_PERCENT}%" in message
|
|
305
347
|
assert "logging.getLogger('shiny_plotly')" in message, "and so is the way to silence it"
|
|
306
348
|
|
|
307
349
|
|
|
350
|
+
def test_brotli_ships_with_the_package_but_not_where_pyodide_runs_it():
|
|
351
|
+
"""A deployment gets the smaller encoding without asking; a shinylive export does not.
|
|
352
|
+
|
|
353
|
+
Under pyodide the route is never installed (the bundle comes from the export), so an
|
|
354
|
+
unmarked dependency would put a 307 kB wasm wheel into every exported app for nothing.
|
|
355
|
+
"""
|
|
356
|
+
requirements = [Requirement(text) for text in importlib.metadata.requires("shiny-plotly") or []]
|
|
357
|
+
brotli_requirement = next(r for r in requirements if r.name == "brotli")
|
|
358
|
+
|
|
359
|
+
assert brotli_requirement.marker is not None, "unmarked, so pyodide pays for it"
|
|
360
|
+
assert brotli_requirement.marker.evaluate({"sys_platform": "linux"})
|
|
361
|
+
assert not brotli_requirement.marker.evaluate({"sys_platform": "emscripten"})
|
|
362
|
+
|
|
363
|
+
|
|
308
364
|
def test_nothing_is_announced_when_brotli_is_there(tmp_path, caplog):
|
|
309
|
-
pytest.importorskip("brotli")
|
|
310
365
|
bundle = stub_bundle(tmp_path)
|
|
311
366
|
|
|
312
367
|
with caplog.at_level(logging.WARNING, logger=_serve.logger.name):
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Every example in examples/ builds a runnable shiny App."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from shiny import App
|
|
8
|
+
from shiny.express import is_express_app, wrap_express_app
|
|
9
|
+
|
|
10
|
+
EXAMPLES = Path(__file__).parent.parent / "examples"
|
|
11
|
+
|
|
12
|
+
# Found rather than listed, so an example is covered from the moment it is written; Shiny's
|
|
13
|
+
# own detector decides which of the two ways each one is built.
|
|
14
|
+
ALL_EXAMPLES = sorted(
|
|
15
|
+
path.relative_to(EXAMPLES) for path in EXAMPLES.rglob("*.py") if "__pycache__" not in path.parts
|
|
16
|
+
)
|
|
17
|
+
EXPRESS_EXAMPLES = [p for p in ALL_EXAMPLES if is_express_app(p.name, str(EXAMPLES / p.parent))]
|
|
18
|
+
CORE_EXAMPLES = [p for p in ALL_EXAMPLES if p not in EXPRESS_EXAMPLES]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_the_examples_were_found_and_told_apart():
|
|
22
|
+
"""A glob that matched nothing would leave every test below passing on no examples."""
|
|
23
|
+
assert set(CORE_EXAMPLES) | set(EXPRESS_EXAMPLES) == set(ALL_EXAMPLES)
|
|
24
|
+
assert Path("core_app.py") in CORE_EXAMPLES
|
|
25
|
+
assert Path("shinylive/app.py") in CORE_EXAMPLES, "the search reaches into subdirectories"
|
|
26
|
+
assert Path("express_app.py") in EXPRESS_EXAMPLES
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("name", CORE_EXAMPLES, ids=str)
|
|
30
|
+
def test_core_example_builds_an_app(name: Path):
|
|
31
|
+
path = EXAMPLES / name
|
|
32
|
+
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
33
|
+
assert spec is not None and spec.loader is not None
|
|
34
|
+
module = importlib.util.module_from_spec(spec)
|
|
35
|
+
spec.loader.exec_module(module)
|
|
36
|
+
|
|
37
|
+
assert isinstance(module.app, App)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("name", EXPRESS_EXAMPLES, ids=str)
|
|
41
|
+
def test_express_example_builds_an_app(name: Path):
|
|
42
|
+
app = wrap_express_app(EXAMPLES / name)
|
|
43
|
+
|
|
44
|
+
assert isinstance(app, App)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_the_shinylive_example_pins_past_the_version_that_crashed_under_pyodide():
|
|
48
|
+
requirements = (EXAMPLES / "shinylive" / "requirements.txt").read_text()
|
|
49
|
+
|
|
50
|
+
assert "shiny-plotly>0.3.0" in requirements
|
|
@@ -125,6 +125,16 @@ def test_accepts_a_prebuilt_figure_dict_without_revalidating_it():
|
|
|
125
125
|
assert call.data[0]["not_a_real_attr"] == 1, "a dict goes to JSON as-is; validation would raise"
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def test_a_figure_dict_with_no_layout_is_given_one():
|
|
129
|
+
"""Margins and themes reach into layout without checking first; this is why they can."""
|
|
130
|
+
fig_dict = {"data": [{"type": "bar", "y": [1]}]}
|
|
131
|
+
|
|
132
|
+
call = parse_newplot(render_html(fig_to_ui(fig_dict, div_id="p", figurewidget_margins=True)))
|
|
133
|
+
|
|
134
|
+
assert call.layout["margin"] == FIGUREWIDGET_MARGINS
|
|
135
|
+
assert fig_dict == {"data": [{"type": "bar", "y": [1]}]}, "the caller's dict is untouched"
|
|
136
|
+
|
|
137
|
+
|
|
128
138
|
def test_rejects_values_that_are_not_figures():
|
|
129
139
|
with pytest.raises(TypeError, match=r"go\.Figure"):
|
|
130
140
|
fig_to_ui("not a figure") # type: ignore[arg-type]
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from collections.abc import Sequence
|
|
3
3
|
from contextlib import contextmanager
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
import plotly
|
|
7
7
|
import plotly.graph_objects as go
|
|
8
8
|
import pytest
|
|
9
9
|
from shiny import App, Inputs, Outputs, Session, module, ui
|
|
10
|
+
from shiny.express._stub_session import ExpressStubSession
|
|
10
11
|
from shiny.render.renderer import Renderer
|
|
12
|
+
from shiny.session import session_context
|
|
11
13
|
from starlette.testclient import TestClient
|
|
12
14
|
|
|
13
15
|
from shiny_plotly import __version__, output_plotly, plotly_js, render_plotly
|
|
14
16
|
from shiny_plotly._render import TEMPLATE_MESSAGE
|
|
15
17
|
|
|
18
|
+
from helpers import run
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
def bar() -> go.Figure:
|
|
18
22
|
return go.Figure(go.Bar(x=["a", "b"], y=[1, 2]))
|
|
@@ -560,6 +564,43 @@ def test_different_themes_get_different_keys_and_both_travel():
|
|
|
560
564
|
assert len(flushed.templates) == 3
|
|
561
565
|
|
|
562
566
|
|
|
567
|
+
@pytest.mark.parametrize(
|
|
568
|
+
"session",
|
|
569
|
+
[
|
|
570
|
+
pytest.param(None, id="no session at all"),
|
|
571
|
+
pytest.param(ExpressStubSession(), id="express before the browser connects"),
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
def test_a_value_with_no_session_to_cache_against_carries_its_templates_inline(session):
|
|
575
|
+
"""Naming keys would name them in a cache nothing is keeping, so the pair travels whole."""
|
|
576
|
+
|
|
577
|
+
@render_plotly(theme="auto")
|
|
578
|
+
def sales():
|
|
579
|
+
return bar()
|
|
580
|
+
|
|
581
|
+
with session_context(session):
|
|
582
|
+
value = cast(dict[str, Any], run(sales.transform(bar())))
|
|
583
|
+
|
|
584
|
+
assert value["theme_keys"] is None, "there is nothing to look a key up in"
|
|
585
|
+
themes = json.loads(value["themes"])
|
|
586
|
+
assert themes["light"]["layout"]["font"]["color"] == "#2a3f5f", "the plotly template"
|
|
587
|
+
assert themes["dark"]["layout"]["font"]["color"] == "#f2f5fa", "the plotly_dark template"
|
|
588
|
+
assert "template" not in json.loads(value["figure"])["layout"]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_a_themed_figure_given_as_a_dict_with_no_layout_renders():
|
|
592
|
+
"""Dropping the baked-in template reaches into layout; a dict need not have brought one."""
|
|
593
|
+
|
|
594
|
+
@render_plotly(theme="auto")
|
|
595
|
+
def sales():
|
|
596
|
+
return {"data": [{"type": "bar", "y": [1, 2]}]}
|
|
597
|
+
|
|
598
|
+
value = cast(dict[str, Any], run(sales.transform({"data": [{"type": "bar", "y": [1, 2]}]})))
|
|
599
|
+
|
|
600
|
+
assert json.loads(value["figure"])["layout"] == {}
|
|
601
|
+
assert json.loads(value["themes"])["light"]["layout"]["font"]["color"] == "#2a3f5f"
|
|
602
|
+
|
|
603
|
+
|
|
563
604
|
def test_a_theme_dict_template_is_not_mutated_by_the_transparency_fill():
|
|
564
605
|
custom = {"layout": {"font": {"color": "white"}}}
|
|
565
606
|
|
|
@@ -575,8 +616,13 @@ def flush_one(client: TestClient, output_id: str) -> dict:
|
|
|
575
616
|
|
|
576
617
|
|
|
577
618
|
def test_plotly_bundle_is_served_from_the_page_level_dependency():
|
|
619
|
+
# The route this reaches is about the path, not the encoding, so ask for the file as it is
|
|
620
|
+
# on disk; what it serves compressed has tests of its own in test_compressed_js.py.
|
|
578
621
|
with TestClient(make_app()) as client:
|
|
579
|
-
resp = client.get(
|
|
622
|
+
resp = client.get(
|
|
623
|
+
f"/lib/plotly-{plotly.__version__}/plotly.min.js",
|
|
624
|
+
headers={"Accept-Encoding": "identity"},
|
|
625
|
+
)
|
|
580
626
|
|
|
581
627
|
assert resp.status_code == 200
|
|
582
628
|
assert b"Plotly" in resp.content[:200_000]
|
|
@@ -592,8 +638,9 @@ def test_bundle_and_helper_are_served_from_the_output_tag_without_a_page_level_c
|
|
|
592
638
|
return bar()
|
|
593
639
|
|
|
594
640
|
with TestClient(App(app_ui, server)) as client:
|
|
595
|
-
|
|
596
|
-
|
|
641
|
+
raw = {"Accept-Encoding": "identity"}
|
|
642
|
+
bundle = client.get(f"/lib/plotly-{plotly.__version__}/plotly.min.js", headers=raw)
|
|
643
|
+
helper = client.get(f"/lib/shiny-plotly-{__version__}/shiny-plotly.js", headers=raw)
|
|
597
644
|
|
|
598
645
|
assert bundle.status_code == 200
|
|
599
646
|
assert len(bundle.content) > 1_000_000
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
"""extend_traces, restyle and relayout: in-place updates sent to a drawn figure."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
import json
|
|
5
|
-
from collections.abc import Coroutine
|
|
6
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
-
from typing import Any, TypeVar
|
|
8
4
|
|
|
9
5
|
import plotly.graph_objects as go
|
|
10
6
|
import pytest
|
|
@@ -23,17 +19,7 @@ from shiny_plotly import (
|
|
|
23
19
|
update,
|
|
24
20
|
)
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def run(coro: Coroutine[Any, Any, T]) -> T:
|
|
30
|
-
"""
|
|
31
|
-
The coroutine on its own loop in a worker thread. The main thread cannot run one
|
|
32
|
-
while the browser tests' session-wide playwright fixture is alive (its sync API
|
|
33
|
-
keeps a loop running there), and nothing awaited here needs that loop anyway.
|
|
34
|
-
"""
|
|
35
|
-
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
36
|
-
return pool.submit(asyncio.run, coro).result()
|
|
22
|
+
from helpers import run
|
|
37
23
|
|
|
38
24
|
|
|
39
25
|
def bar() -> go.Figure:
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""Every example in examples/ builds a runnable shiny App."""
|
|
2
|
-
|
|
3
|
-
import importlib.util
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
from shiny import App
|
|
8
|
-
from shiny.express import wrap_express_app
|
|
9
|
-
|
|
10
|
-
EXAMPLES = Path(__file__).parent.parent / "examples"
|
|
11
|
-
|
|
12
|
-
CORE_EXAMPLES = (
|
|
13
|
-
"core_app.py",
|
|
14
|
-
"events_app.py",
|
|
15
|
-
"dark_app.py",
|
|
16
|
-
"streaming_app.py",
|
|
17
|
-
"shinylive/app.py",
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@pytest.mark.parametrize("name", CORE_EXAMPLES)
|
|
22
|
-
def test_core_example_builds_an_app(name: str):
|
|
23
|
-
path = EXAMPLES / name
|
|
24
|
-
spec = importlib.util.spec_from_file_location(path.stem, path)
|
|
25
|
-
assert spec is not None and spec.loader is not None
|
|
26
|
-
module = importlib.util.module_from_spec(spec)
|
|
27
|
-
spec.loader.exec_module(module)
|
|
28
|
-
|
|
29
|
-
assert isinstance(module.app, App)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_express_example_builds_an_app():
|
|
33
|
-
app = wrap_express_app(EXAMPLES / "express_app.py")
|
|
34
|
-
|
|
35
|
-
assert isinstance(app, App)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_the_shinylive_example_pins_past_the_version_that_crashed_under_pyodide():
|
|
39
|
-
requirements = (EXAMPLES / "shinylive" / "requirements.txt").read_text()
|
|
40
|
-
|
|
41
|
-
assert "shiny-plotly>0.3.0" in requirements
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|