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.
Files changed (40) hide show
  1. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/.gitignore +1 -0
  2. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/CHANGELOG.md +15 -0
  3. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/PKG-INFO +6 -11
  4. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/README.md +4 -8
  5. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/pyproject.toml +7 -7
  6. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_html.py +10 -4
  7. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_render.py +1 -3
  8. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_serve.py +22 -4
  9. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/apps.py +78 -5
  10. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/conftest.py +2 -2
  11. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_browser.py +88 -11
  12. shiny_plotly-0.4.0/tests/helpers.py +25 -0
  13. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_compressed_js.py +63 -8
  14. shiny_plotly-0.4.0/tests/test_examples.py +50 -0
  15. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_fig_to_ui.py +10 -0
  16. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_render_plotly.py +51 -4
  17. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_update.py +1 -15
  18. shiny_plotly-0.3.3/tests/test_examples.py +0 -41
  19. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/LICENSE +0 -0
  20. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/core_app.py +0 -0
  21. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/dark_app.py +0 -0
  22. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/events_app.py +0 -0
  23. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/express_app.py +0 -0
  24. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/shinylive/app.py +0 -0
  25. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/shinylive/requirements.txt +0 -0
  26. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/examples/streaming_app.py +0 -0
  27. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/__init__.py +0 -0
  28. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_deps.py +0 -0
  29. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/_update.py +0 -0
  30. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/py.typed +0 -0
  31. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/src/shiny_plotly/www/shiny-plotly.js +0 -0
  32. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/__init__.py +0 -0
  33. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_dark_mode.py +0 -0
  34. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_events.py +0 -0
  35. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_shinylive_demo.py +0 -0
  36. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_streaming_example.py +0 -0
  37. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/browser/test_update.py +0 -0
  38. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/newplot.py +0 -0
  39. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_pages_contract.py +0 -0
  40. {shiny_plotly-0.3.3 → shiny_plotly-0.4.0}/tests/test_plotly_js.py +0 -0
@@ -6,5 +6,6 @@ __pycache__/
6
6
  *.pyc
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
+ .coverage
9
10
  tmp/
10
11
  site/
@@ -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.3
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. 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.
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 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.
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
- ```sh
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. 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.
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 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.
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
- ```sh
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.3.3"
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
- # Figure.to_dict() does no validation (the figure was validated when built), and a
107
- # dict is passed through as the caller's JSON; pio.to_html gets validate=False so it
108
- # never reconstructs a Figure from it.
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.setdefault("layout", {})
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 = fig_dict.get("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 - optional extra
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 shiny-plotly[brotli] for it, or silence "
107
- "this with logging.getLogger('shiny_plotly').setLevel(logging.ERROR).",
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.replace(" ", "").lower() == "q=0":
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 make_tabs_app() -> App:
381
- """Charts in nav panels: Shiny does not render an output that is hidden."""
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="200px")),
385
- ui.nav_panel("Second", output_plotly("second", height="200px")),
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("/tabs", app=make_tabs_app()),
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
- def test_a_chart_in_a_hidden_tab_is_drawn_when_its_tab_is_opened(page: Page, server_url, errors):
235
- """Shiny suspends a hidden output, so tabs spread the per-chart draw cost over time."""
236
- page.goto(server_url + "/tabs/")
237
- expect(page.locator(f"#first {SVG}").first).to_be_visible()
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
- assert page.evaluate("() => document.getElementById('second-plotly')") is None
240
- page.get_by_role("tab", name="Second").click()
314
+ case.reveal(page)
241
315
 
242
- expect(page.locator(f"#second {SVG}").first).to_be_visible()
243
- expect(page.locator("#second .bars .point")).to_have_count(3)
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
- assert headers["content-encoding"] in ("br", "gzip")
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 or gzip on the wire"
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 test_brotli_is_preferred_when_the_module_is_installed(client):
104
- pytest.importorskip("brotli")
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 == RAW
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"] in {"br", "gzip"}
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 "shiny-plotly[brotli]" in message, "the fix is named"
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(f"/lib/plotly-{plotly.__version__}/plotly.min.js")
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
- bundle = client.get(f"/lib/plotly-{plotly.__version__}/plotly.min.js")
596
- helper = client.get(f"/lib/shiny-plotly-{__version__}/shiny-plotly.js")
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
- T = TypeVar("T")
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