shiny-plotly 0.3.3__tar.gz → 0.4.1__tar.gz

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