shiny-plotly 0.4.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/CHANGELOG.md +12 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/PKG-INFO +5 -5
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/README.md +4 -4
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/pyproject.toml +1 -1
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_render.py +2 -5
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_serve.py +59 -18
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_update.py +21 -17
- shiny_plotly-0.4.1/src/shiny_plotly/_validate.py +31 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/www/shiny-plotly.js +70 -10
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/apps.py +124 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/conftest.py +4 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_events.py +19 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_update.py +61 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_compressed_js.py +75 -2
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_render_plotly.py +11 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_update.py +31 -2
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/.gitignore +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/LICENSE +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/core_app.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/dark_app.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/events_app.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/express_app.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/shinylive/app.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/streaming_app.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/__init__.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_browser.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_dark_mode.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_shinylive_demo.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_streaming_example.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/helpers.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/newplot.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_examples.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_pages_contract.py +0 -0
- {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,18 @@ 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
|
+
|
|
10
22
|
## [0.4.0](https://github.com/rvben/shiny-plotly/compare/v0.3.3...v0.4.0) - 2026-08-21
|
|
11
23
|
|
|
12
24
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.4.
|
|
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
|
|
@@ -314,7 +314,7 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
314
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 |
|
|
315
315
|
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
316
316
|
|
|
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. `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.
|
|
318
318
|
|
|
319
319
|
#### Dense traces
|
|
320
320
|
|
|
@@ -352,7 +352,7 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
352
352
|
|
|
353
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.
|
|
354
354
|
|
|
355
|
-
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.
|
|
356
356
|
|
|
357
357
|
```python
|
|
358
358
|
ANNOTATION_TO_INPUT = """
|
|
@@ -401,7 +401,7 @@ async def _highlight():
|
|
|
401
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.
|
|
402
402
|
- `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
|
|
403
403
|
|
|
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. 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.
|
|
405
405
|
|
|
406
406
|
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
407
407
|
|
|
@@ -418,7 +418,7 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
418
418
|
|
|
419
419
|
### plotly.js on the wire
|
|
420
420
|
|
|
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
|
|
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.
|
|
422
422
|
|
|
423
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.
|
|
424
424
|
|
|
@@ -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,7 +388,7 @@ 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, 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
|
|
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
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.
|
|
394
394
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiny-plotly"
|
|
7
|
-
version = "0.4.
|
|
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"
|
|
@@ -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:
|
|
@@ -10,7 +10,8 @@ Importing ``shiny_plotly`` wraps ``shiny.App.__init__``, so every app built afte
|
|
|
10
10
|
route for the bundle's exact path in front of Shiny's mount, and a background thread starts
|
|
11
11
|
compressing the bundle once per process. Both happen while the app is being built, before it
|
|
12
12
|
can serve anything, so the first request of the process is already served here; until the
|
|
13
|
-
compression has finished the route serves the raw file
|
|
13
|
+
compression has finished the route serves the raw file, marked for revalidation rather than
|
|
14
|
+
immutable when the client asked for an encoding that is still on its way. An app
|
|
14
15
|
built before the import can still ask for the route with :func:`enable_compressed_plotly_js`,
|
|
15
16
|
and each session enables it for its own app, so it is there either way.
|
|
16
17
|
"""
|
|
@@ -44,6 +45,10 @@ __all__ = (
|
|
|
44
45
|
|
|
45
46
|
ROUTE_NAME = "shiny-plotly-bundle"
|
|
46
47
|
CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
48
|
+
# For a body that is not yet the one this client will get: the raw file, served while the
|
|
49
|
+
# encoding it asked for is still being compressed. Stored, but revalidated on every use,
|
|
50
|
+
# so the next visit swaps it for the compressed body instead of keeping it for a year.
|
|
51
|
+
PROVISIONAL_CACHE_CONTROL = "no-cache"
|
|
47
52
|
MEDIA_TYPE = "text/javascript; charset=utf-8"
|
|
48
53
|
|
|
49
54
|
logger = logging.getLogger(__name__)
|
|
@@ -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
|
|
|
@@ -132,17 +144,19 @@ def refuses(params: str) -> bool:
|
|
|
132
144
|
Whether the parameters of one ``Accept-Encoding`` entry rule its encoding out.
|
|
133
145
|
|
|
134
146
|
RFC 9110 writes a refusal as a qvalue of zero, which is ``q=0`` but equally ``q=0.0``
|
|
135
|
-
and ``q=0.000
|
|
136
|
-
|
|
137
|
-
header.
|
|
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.
|
|
138
150
|
"""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
146
160
|
|
|
147
161
|
|
|
148
162
|
def accepted_encodings(accept_encoding: str | None) -> Iterator[str]:
|
|
@@ -150,16 +164,34 @@ def accepted_encodings(accept_encoding: str | None) -> Iterator[str]:
|
|
|
150
164
|
if not accept_encoding:
|
|
151
165
|
return
|
|
152
166
|
offered: set[str] = set()
|
|
167
|
+
refused: set[str] = set()
|
|
153
168
|
for part in accept_encoding.split(","):
|
|
154
169
|
token, _, params = part.partition(";")
|
|
155
|
-
if refuses(params)
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
158
174
|
for encoding in ("br", "gzip"):
|
|
159
|
-
if encoding in offered or
|
|
175
|
+
if encoding in offered or (wildcard and encoding not in refused):
|
|
160
176
|
yield encoding
|
|
161
177
|
|
|
162
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
|
+
|
|
163
195
|
def response_for(
|
|
164
196
|
bundle: CompressedBundle,
|
|
165
197
|
*,
|
|
@@ -167,10 +199,19 @@ def response_for(
|
|
|
167
199
|
if_none_match: str | None,
|
|
168
200
|
method: str = "GET",
|
|
169
201
|
) -> Response:
|
|
170
|
-
|
|
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
|
|
171
208
|
etag = bundle.etag(encoding)
|
|
172
|
-
headers = {
|
|
173
|
-
|
|
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):
|
|
174
215
|
return Response(status_code=304, headers=headers)
|
|
175
216
|
if encoding is None:
|
|
176
217
|
# Streams the file from disk; HEAD is detected from the request scope.
|
|
@@ -8,9 +8,10 @@ dashboard wants for a new point every second. They ride on Shiny's custom messag
|
|
|
8
8
|
are applied by the browser helper to the graph div of the named output. One sent while
|
|
9
9
|
the output has no figure drawn (its first render is still running, it sits in a hidden
|
|
10
10
|
tab, it shows an error or was emptied by ``None``) is held and applied, in order, right
|
|
11
|
-
after the output's next draw
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
after the output's next draw; consecutive held extends (or prepends) to the same traces,
|
|
12
|
+
attributes and ``max_points`` are merged into one as they arrive. A re-render replaces
|
|
13
|
+
the figure, updates included, with what the render function returns. All of these are
|
|
14
|
+
coroutines: await them from an ``async def`` effect.
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
17
|
from __future__ import annotations
|
|
@@ -21,6 +22,8 @@ from typing import Any
|
|
|
21
22
|
from plotly.io.json import to_json_plotly
|
|
22
23
|
from shiny.session import Session, require_active_session
|
|
23
24
|
|
|
25
|
+
from ._validate import as_int, as_positive_int
|
|
26
|
+
|
|
24
27
|
__all__ = (
|
|
25
28
|
"add_traces",
|
|
26
29
|
"delete_traces",
|
|
@@ -40,20 +43,23 @@ Trace = Mapping[str, Any] | Any
|
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
def _indices(indices: Indices) -> list[int] | None:
|
|
46
|
+
"""Trace indices as a list of Python ints; numpy integers and arrays are taken too."""
|
|
43
47
|
if indices is None:
|
|
44
48
|
return None
|
|
45
|
-
|
|
46
|
-
return [indices]
|
|
47
|
-
|
|
49
|
+
try:
|
|
50
|
+
return [as_int(indices)]
|
|
51
|
+
except TypeError:
|
|
52
|
+
pass
|
|
53
|
+
try:
|
|
54
|
+
return [as_int(index) for index in indices] # type: ignore[union-attr]
|
|
55
|
+
except TypeError:
|
|
56
|
+
raise ValueError(f"indices must be an integer or integers, got {indices!r}") from None
|
|
48
57
|
|
|
49
58
|
|
|
50
|
-
def
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
if max_points is
|
|
54
|
-
isinstance(max_points, bool) or not isinstance(max_points, int) or max_points < 1
|
|
55
|
-
):
|
|
56
|
-
raise ValueError(f"max_points must be a positive integer, got {max_points!r}")
|
|
59
|
+
def _max_points(max_points: int | None) -> int | None:
|
|
60
|
+
# A JSON true, which a Python bool would become, reads to plotly.js as non-numeric and
|
|
61
|
+
# quietly means no cap at all; as_positive_int refuses it here instead.
|
|
62
|
+
return None if max_points is None else as_positive_int(max_points, "max_points")
|
|
57
63
|
|
|
58
64
|
|
|
59
65
|
def _trace_json(trace: Trace) -> Any:
|
|
@@ -97,8 +103,7 @@ async def extend_traces(
|
|
|
97
103
|
The update is sent to the session's client; outside a session it fails. Use it
|
|
98
104
|
from a reactive effect, for instance one driven by ``reactive.invalidate_later``.
|
|
99
105
|
"""
|
|
100
|
-
|
|
101
|
-
await _send(id, "extendTraces", [data, _indices(indices), max_points], session)
|
|
106
|
+
await _send(id, "extendTraces", [data, _indices(indices), _max_points(max_points)], session)
|
|
102
107
|
|
|
103
108
|
|
|
104
109
|
async def prepend_traces(
|
|
@@ -116,8 +121,7 @@ async def prepend_traces(
|
|
|
116
121
|
ones, and ``max_points`` drops the newest points past that many instead of the
|
|
117
122
|
oldest. Same ``data`` and ``indices`` shapes.
|
|
118
123
|
"""
|
|
119
|
-
|
|
120
|
-
await _send(id, "prependTraces", [data, _indices(indices), max_points], session)
|
|
124
|
+
await _send(id, "prependTraces", [data, _indices(indices), _max_points(max_points)], session)
|
|
121
125
|
|
|
122
126
|
|
|
123
127
|
async def add_traces(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Argument checks shared by the renderer and the in-place updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import operator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def as_int(value: Any) -> int:
|
|
10
|
+
"""
|
|
11
|
+
``value`` as a Python int, for anything integer-like: ``int`` and numpy's integers.
|
|
12
|
+
|
|
13
|
+
Raises TypeError for anything else. ``bool`` is refused by name: it is an int in Python
|
|
14
|
+
but serializes to a JSON ``true``, which plotly.js does not read as a number (numpy's
|
|
15
|
+
bool is no integer to begin with). A float, even a whole one, is refused as well, the
|
|
16
|
+
way ``range()`` and list indexing refuse it.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(value, bool):
|
|
19
|
+
raise TypeError(f"expected an integer, got {value!r}")
|
|
20
|
+
return operator.index(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def as_positive_int(value: Any, name: str) -> int:
|
|
24
|
+
"""``value`` as a Python int of at least 1; ValueError naming ``name`` otherwise."""
|
|
25
|
+
try:
|
|
26
|
+
number = as_int(value)
|
|
27
|
+
except TypeError:
|
|
28
|
+
number = 0
|
|
29
|
+
if number < 1:
|
|
30
|
+
raise ValueError(f"{name} must be a positive integer, got {value!r}")
|
|
31
|
+
return number
|
|
@@ -154,7 +154,8 @@
|
|
|
154
154
|
// --- events to inputs ---------------------------------------------------------------
|
|
155
155
|
// What plotly hands an event handler holds the full trace and axis objects, circular and
|
|
156
156
|
// large; what travels is each point's scalar fields plus the few objects worth having
|
|
157
|
-
// (bbox, pointNumbers, customdata
|
|
157
|
+
// (bbox, pointNumbers, customdata, and a pointNumber that is a [row, column] pair on a
|
|
158
|
+
// 2-D trace such as a heatmap), the same cut Dash makes. Typed arrays, which is how
|
|
158
159
|
// plotly holds decoded bdata, become plain arrays so they serialize as lists.
|
|
159
160
|
|
|
160
161
|
function plain(value) {
|
|
@@ -171,10 +172,10 @@
|
|
|
171
172
|
}
|
|
172
173
|
if (point.bbox) out.bbox = point.bbox;
|
|
173
174
|
if (point.pointNumbers) out.pointNumbers = point.pointNumbers;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
if (Array.isArray(point.pointNumber)) out.pointNumber = point.pointNumber.slice();
|
|
176
|
+
// Plotly puts the point's customdata on the point itself, looked up the way the trace
|
|
177
|
+
// type addresses its points; a trace without customdata leaves it undefined.
|
|
178
|
+
if (point.customdata !== undefined) out.customdata = plain(point.customdata);
|
|
178
179
|
return out;
|
|
179
180
|
}
|
|
180
181
|
|
|
@@ -288,10 +289,16 @@
|
|
|
288
289
|
return gd;
|
|
289
290
|
}
|
|
290
291
|
|
|
292
|
+
// The figure is drawn by the time the script runs, so a script that throws is reported
|
|
293
|
+
// and the chart carries on: held updates, theming and events do not depend on it.
|
|
291
294
|
function runPostScript(gd, script) {
|
|
292
295
|
if (!script) return;
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
try {
|
|
297
|
+
/* eslint-disable-next-line no-new-func */
|
|
298
|
+
new Function(script.replace(/\{plot_id\}/g, gd.id))();
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error("shiny-plotly: post_script of '" + gd.id + "' failed:", err);
|
|
301
|
+
}
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
function draw(el, value) {
|
|
@@ -342,9 +349,62 @@
|
|
|
342
349
|
// tab, an output showing an error or emptied by a None) is held on the output element and
|
|
343
350
|
// applied, in order, right after its next draw. One for an id with no output on the page
|
|
344
351
|
// is dropped with a warning.
|
|
352
|
+
//
|
|
353
|
+
// A stream into an output in a hidden tab would otherwise queue one update per tick, each
|
|
354
|
+
// a redraw on reveal, so a held extend or prepend is folded into the one held just before
|
|
355
|
+
// it when both name the same traces, attributes and cap: their points are joined and cut
|
|
356
|
+
// to the cap, which is exactly what the two calls would have left on the traces.
|
|
357
|
+
|
|
358
|
+
// Per-trace arrays for every attribute, one per index; anything else (a scalar, a typed
|
|
359
|
+
// array spelled as bdata) is applied as sent.
|
|
360
|
+
function perTraceArrays(data) {
|
|
361
|
+
var keys = Object.keys(data);
|
|
362
|
+
if (keys.length === 0) return null;
|
|
363
|
+
var count = null;
|
|
364
|
+
for (var i = 0; i < keys.length; i++) {
|
|
365
|
+
var column = data[keys[i]];
|
|
366
|
+
if (!Array.isArray(column) || !column.every(Array.isArray)) return null;
|
|
367
|
+
if (count !== null && column.length !== count) return null;
|
|
368
|
+
count = column.length;
|
|
369
|
+
}
|
|
370
|
+
return keys.sort();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function mergeable(held, next) {
|
|
374
|
+
if (held.method !== next.method) return false;
|
|
375
|
+
if (next.method !== "extendTraces" && next.method !== "prependTraces") return false;
|
|
376
|
+
var a = held.args, b = next.args;
|
|
377
|
+
if (JSON.stringify(a[1]) !== JSON.stringify(b[1])) return false;
|
|
378
|
+
if (a[2] !== b[2] || (a[2] !== null && typeof a[2] !== "number")) return false;
|
|
379
|
+
var keys = perTraceArrays(a[0]);
|
|
380
|
+
return keys !== null && JSON.stringify(keys) === JSON.stringify(perTraceArrays(b[0]));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Folds next into held: extend appends and keeps the last maxPoints, prepend puts the
|
|
384
|
+
// newer points in front and keeps the first maxPoints, as Plotly does to the trace.
|
|
385
|
+
function merge(held, next) {
|
|
386
|
+
var cap = held.args[2];
|
|
387
|
+
var extend = held.method === "extendTraces";
|
|
388
|
+
Object.keys(held.args[0]).forEach(function (key) {
|
|
389
|
+
var older = held.args[0][key], newer = next.args[0][key];
|
|
390
|
+
held.args[0][key] = older.map(function (points, i) {
|
|
391
|
+
var joined = extend ? points.concat(newer[i]) : newer[i].concat(points);
|
|
392
|
+
if (cap === null || joined.length <= cap) return joined;
|
|
393
|
+
return extend ? joined.slice(joined.length - cap) : joined.slice(0, cap);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function hold(el, update) {
|
|
399
|
+
var queue = el._shinyPlotlyPending || (el._shinyPlotlyPending = []);
|
|
400
|
+
var held = { method: update.method, args: JSON.parse(update.args) };
|
|
401
|
+
var last = queue[queue.length - 1];
|
|
402
|
+
if (last && mergeable(last, held)) merge(last, held);
|
|
403
|
+
else queue.push(held);
|
|
404
|
+
}
|
|
345
405
|
|
|
346
406
|
function applyUpdate(gd, update) {
|
|
347
|
-
var args =
|
|
407
|
+
var args = update.args;
|
|
348
408
|
if (update.method === "extendTraces" || update.method === "prependTraces") {
|
|
349
409
|
// Both want an array of indices and no maxPoints rather than null.
|
|
350
410
|
if (args[1] === null) args[1] = gd.data.map(function (_, i) { return i; });
|
|
@@ -369,10 +429,10 @@
|
|
|
369
429
|
}
|
|
370
430
|
var gd = graphDiv(el);
|
|
371
431
|
if (gd && gd._shinyPlotlyDrawn) {
|
|
372
|
-
applyUpdate(gd, update);
|
|
432
|
+
applyUpdate(gd, { method: update.method, args: JSON.parse(update.args) });
|
|
373
433
|
return;
|
|
374
434
|
}
|
|
375
|
-
(el
|
|
435
|
+
hold(el, update);
|
|
376
436
|
}
|
|
377
437
|
|
|
378
438
|
function register() {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import numpy as np
|
|
@@ -128,6 +129,7 @@ def make_events_app() -> App:
|
|
|
128
129
|
output_plotly("sel", height="300px", width="500px"),
|
|
129
130
|
output_plotly("dense", height="300px", width="500px"),
|
|
130
131
|
output_plotly("leg", height="300px", width="500px"),
|
|
132
|
+
output_plotly("heat", height="300px", width="500px"),
|
|
131
133
|
ui.output_text("click_out"),
|
|
132
134
|
ui.output_text("click_count"),
|
|
133
135
|
ui.output_text("hover_out"),
|
|
@@ -137,6 +139,7 @@ def make_events_app() -> App:
|
|
|
137
139
|
ui.output_text("legendclick_out"),
|
|
138
140
|
ui.output_text("legenddbl_out"),
|
|
139
141
|
ui.output_text("dbl_out"),
|
|
142
|
+
ui.output_text("heat_out"),
|
|
140
143
|
events_mod_ui("m"),
|
|
141
144
|
)
|
|
142
145
|
|
|
@@ -166,6 +169,14 @@ def make_events_app() -> App:
|
|
|
166
169
|
[go.Scatter(y=[1, 2], name="alpha"), go.Scatter(y=[2, 1], name="beta")]
|
|
167
170
|
)
|
|
168
171
|
|
|
172
|
+
@render_plotly(events="click")
|
|
173
|
+
def heat():
|
|
174
|
+
# A cell is addressed by [row, column], and its customdata is itself a list:
|
|
175
|
+
# 3-D numpy customdata, which travels as bdata.
|
|
176
|
+
return go.Figure(
|
|
177
|
+
go.Heatmap(z=[[1, 2], [3, 4]], customdata=np.arange(8).reshape(2, 2, 2))
|
|
178
|
+
)
|
|
179
|
+
|
|
169
180
|
@reactive.effect
|
|
170
181
|
@reactive.event(input.fig_click)
|
|
171
182
|
def _count():
|
|
@@ -209,6 +220,10 @@ def make_events_app() -> App:
|
|
|
209
220
|
def dbl_out():
|
|
210
221
|
return as_text(input.leg_doubleclick()) if input.leg_doubleclick.is_set() else "-"
|
|
211
222
|
|
|
223
|
+
@render.text
|
|
224
|
+
def heat_out():
|
|
225
|
+
return as_text(input.heat_click()) if input.heat_click.is_set() else "-"
|
|
226
|
+
|
|
212
227
|
events_mod_server("m")
|
|
213
228
|
|
|
214
229
|
return App(app_ui, server)
|
|
@@ -518,3 +533,112 @@ def make_scoped_theme_app() -> App:
|
|
|
518
533
|
return bars(3)
|
|
519
534
|
|
|
520
535
|
return App(app_ui, server)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def make_post_script_app() -> App:
|
|
539
|
+
"""A chart whose post_script throws, with every feature that runs after it switched on."""
|
|
540
|
+
app_ui = ui.page_fluid(
|
|
541
|
+
ui.input_action_button("grow", "grow"),
|
|
542
|
+
output_plotly("fig", height="300px", width="500px"),
|
|
543
|
+
ui.output_text("click_out"),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
547
|
+
@render_plotly(
|
|
548
|
+
post_script="throw new Error('post_script failed');", events="click", theme="auto"
|
|
549
|
+
)
|
|
550
|
+
def fig():
|
|
551
|
+
return bars(3)
|
|
552
|
+
|
|
553
|
+
@reactive.effect
|
|
554
|
+
@reactive.event(input.grow)
|
|
555
|
+
async def _grow():
|
|
556
|
+
await extend_traces("fig", {"x": [["x3"]], "y": [[4]]}, 0)
|
|
557
|
+
|
|
558
|
+
@render.text
|
|
559
|
+
def click_out():
|
|
560
|
+
return as_text(input.fig_click()) if input.fig_click.is_set() else "-"
|
|
561
|
+
|
|
562
|
+
return App(app_ui, server)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def burst_steps() -> list[Callable[[str], Awaitable[None]]]:
|
|
566
|
+
"""
|
|
567
|
+
Every kind of held update, each a call taking the output id.
|
|
568
|
+
|
|
569
|
+
Runs of extends and prepends that can merge, broken up by what must not merge with
|
|
570
|
+
them: a relayout, a restyle, a change of max_points, other data keys, other indices.
|
|
571
|
+
"""
|
|
572
|
+
steps: list[Callable[[str], Awaitable[None]]] = []
|
|
573
|
+
for i in range(20):
|
|
574
|
+
steps.append(
|
|
575
|
+
lambda id, i=i: extend_traces(id, {"x": [[f"e{i}"]], "y": [[i]]}, 0, max_points=10)
|
|
576
|
+
)
|
|
577
|
+
# Each run below differs from the one before it in one thing only.
|
|
578
|
+
for i in range(6): # the cap
|
|
579
|
+
steps.append(
|
|
580
|
+
lambda id, i=i: extend_traces(id, {"x": [[f"f{i}"]], "y": [[100 + i]]}, 0, max_points=4)
|
|
581
|
+
)
|
|
582
|
+
for i in range(3): # the attributes
|
|
583
|
+
steps.append(lambda id, i=i: extend_traces(id, {"y": [[200 + i]]}, 0, max_points=4))
|
|
584
|
+
steps.append(lambda id: relayout(id, {"title.text": "mid"}))
|
|
585
|
+
for i in range(8):
|
|
586
|
+
steps.append(lambda id, i=i: extend_traces(id, {"y": [[i], [-i]]}, [1, 2]))
|
|
587
|
+
for i in range(4): # the indices
|
|
588
|
+
steps.append(lambda id, i=i: extend_traces(id, {"y": [[10 + i], [-10 - i]]}, [2, 1]))
|
|
589
|
+
for i in range(10):
|
|
590
|
+
steps.append(
|
|
591
|
+
lambda id, i=i: prepend_traces(id, {"y": [[-100 - i, -200 - i]]}, 1, max_points=15)
|
|
592
|
+
)
|
|
593
|
+
steps.append(lambda id: restyle(id, {"opacity": [0.5]}, [2]))
|
|
594
|
+
for i in range(3):
|
|
595
|
+
steps.append(lambda id, i=i: prepend_traces(id, {"y": [[i]]}, [2]))
|
|
596
|
+
return steps
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def make_held_app() -> App:
|
|
600
|
+
"""One chart drawn, one in a hidden tab, and a button sending both the same burst."""
|
|
601
|
+
app_ui = ui.page_fluid(
|
|
602
|
+
ui.input_action_button("burst", "burst"),
|
|
603
|
+
ui.output_text("sent"),
|
|
604
|
+
output_plotly("shown", height="200px"),
|
|
605
|
+
ui.navset_tab(
|
|
606
|
+
ui.nav_panel("Empty", "nothing here"),
|
|
607
|
+
ui.nav_panel("Held", output_plotly("held", height="200px")),
|
|
608
|
+
id="tab",
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def figure() -> go.Figure:
|
|
613
|
+
return go.Figure(
|
|
614
|
+
[
|
|
615
|
+
go.Scatter(x=["a", "b"], y=[1, 2]),
|
|
616
|
+
go.Scatter(y=[5, 6, 7]),
|
|
617
|
+
go.Scatter(y=[8]),
|
|
618
|
+
]
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
622
|
+
@render_plotly
|
|
623
|
+
def shown():
|
|
624
|
+
return figure()
|
|
625
|
+
|
|
626
|
+
@render_plotly
|
|
627
|
+
def held():
|
|
628
|
+
return figure()
|
|
629
|
+
|
|
630
|
+
done = reactive.value(0)
|
|
631
|
+
|
|
632
|
+
@reactive.effect
|
|
633
|
+
@reactive.event(input.burst)
|
|
634
|
+
async def _burst():
|
|
635
|
+
for step in burst_steps():
|
|
636
|
+
await step("shown")
|
|
637
|
+
await step("held")
|
|
638
|
+
done.set(done() + 1)
|
|
639
|
+
|
|
640
|
+
@render.text
|
|
641
|
+
def sent():
|
|
642
|
+
return f"sent {done()}"
|
|
643
|
+
|
|
644
|
+
return App(app_ui, server)
|
|
@@ -17,10 +17,12 @@ from .apps import (
|
|
|
17
17
|
make_app,
|
|
18
18
|
make_dark_app,
|
|
19
19
|
make_events_app,
|
|
20
|
+
make_held_app,
|
|
20
21
|
make_hidden_app,
|
|
21
22
|
make_lazy_app,
|
|
22
23
|
make_live_app,
|
|
23
24
|
make_own_mode_app,
|
|
25
|
+
make_post_script_app,
|
|
24
26
|
make_scoped_theme_app,
|
|
25
27
|
make_theme_app,
|
|
26
28
|
)
|
|
@@ -48,6 +50,8 @@ def server_url() -> Iterator[str]:
|
|
|
48
50
|
Mount("/scoped", app=make_scoped_theme_app()),
|
|
49
51
|
Mount("/own-mode", app=make_own_mode_app()),
|
|
50
52
|
Mount("/hidden", app=make_hidden_app()),
|
|
53
|
+
Mount("/post-script", app=make_post_script_app()),
|
|
54
|
+
Mount("/held", app=make_held_app()),
|
|
51
55
|
Mount("/stream", app=load_example("streaming_app.py")),
|
|
52
56
|
Mount("/demo", app=load_example("shinylive/app.py")),
|
|
53
57
|
Mount("/", app=make_app()),
|
|
@@ -17,6 +17,7 @@ def app(page: Page, server_url: str, errors: list[str]) -> Iterator[Page]:
|
|
|
17
17
|
expect(page.locator(f"#fig {SVG}").first).to_be_visible()
|
|
18
18
|
expect(page.locator(f"#sel {SVG}").first).to_be_visible()
|
|
19
19
|
expect(page.locator(f"#leg {SVG}").first).to_be_visible()
|
|
20
|
+
expect(page.locator(f"#heat {SVG}").first).to_be_visible()
|
|
20
21
|
expect(page.locator(f"#m-fig {SVG}").first).to_be_visible()
|
|
21
22
|
expect(page.locator("#click_out")).to_have_text("-")
|
|
22
23
|
yield page
|
|
@@ -51,6 +52,24 @@ def test_click_hands_the_point_to_the_input_with_plain_customdata(app: Page):
|
|
|
51
52
|
assert not {"data", "fullData", "xaxis", "yaxis"} & point.keys(), "no circular plotly objects"
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
def test_a_heatmap_click_hands_back_the_cell_address_and_its_customdata(app: Page):
|
|
56
|
+
"""A cell's pointNumber is [row, column]; its customdata is looked up by both."""
|
|
57
|
+
drag_area = app.locator("#heat .nsewdrag")
|
|
58
|
+
drag_area.scroll_into_view_if_needed()
|
|
59
|
+
box = drag_area.bounding_box()
|
|
60
|
+
assert box is not None
|
|
61
|
+
# The top left cell: row 1 (rows count up from the bottom), column 0.
|
|
62
|
+
app.mouse.click(box["x"] + box["width"] * 0.25, box["y"] + box["height"] * 0.25)
|
|
63
|
+
|
|
64
|
+
wait_for_change(app, "heat_out", "-")
|
|
65
|
+
event = received(app, "heat_out")
|
|
66
|
+
assert isinstance(event, dict)
|
|
67
|
+
point = event["points"][0]
|
|
68
|
+
assert point["z"] == 3
|
|
69
|
+
assert point["pointNumber"] == [1, 0]
|
|
70
|
+
assert point["customdata"] == [4, 5], "the cell's own customdata, as a plain list"
|
|
71
|
+
|
|
72
|
+
|
|
54
73
|
def test_clicking_the_same_point_twice_fires_twice(app: Page):
|
|
55
74
|
bar(app, "fig", 0).click(force=True)
|
|
56
75
|
expect(app.locator("#click_count")).to_have_text("1")
|
|
@@ -200,3 +200,64 @@ def test_a_fixed_height_output_on_a_plain_page_keeps_the_graph_inside_it(app: Pa
|
|
|
200
200
|
assert output["height"] == 200
|
|
201
201
|
assert graph["height"] == 200
|
|
202
202
|
assert graph["width"] == 300
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_a_throwing_post_script_leaves_updates_theming_and_events_working(
|
|
206
|
+
page: Page, server_url: str, errors: list[str]
|
|
207
|
+
):
|
|
208
|
+
"""The script's error is reported; everything the chart does after its draw still runs."""
|
|
209
|
+
page.goto(server_url + "/post-script/")
|
|
210
|
+
expect(page.locator(f"#fig {SVG}").first).to_be_visible()
|
|
211
|
+
|
|
212
|
+
page.click("#grow")
|
|
213
|
+
wait_for(page, trace_y("fig", 0), [1, 2, 3, 4])
|
|
214
|
+
|
|
215
|
+
font = f"{gd('fig')}._fullLayout.font.color"
|
|
216
|
+
light = value(page, font)
|
|
217
|
+
page.evaluate("() => document.documentElement.setAttribute('data-bs-theme', 'dark')")
|
|
218
|
+
page.wait_for_function(f"() => {font} !== {json.dumps(light)}")
|
|
219
|
+
|
|
220
|
+
page.locator("#fig .bars .point").first.click(force=True)
|
|
221
|
+
expect(page.locator("#click_out")).not_to_have_text("-")
|
|
222
|
+
|
|
223
|
+
assert len(errors) == 1 and "post_script failed" in errors[0], errors
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
HELD_STATE = """
|
|
227
|
+
(() => {
|
|
228
|
+
const g = document.getElementById("%s-plotly");
|
|
229
|
+
return {
|
|
230
|
+
data: g.data.map((t) => ({x: t.x ? Array.from(t.x) : null, y: Array.from(t.y),
|
|
231
|
+
opacity: t.opacity === undefined ? null : t.opacity})),
|
|
232
|
+
title: (g.layout.title || {}).text,
|
|
233
|
+
};
|
|
234
|
+
})()
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_updates_held_for_a_hidden_output_are_merged_and_land_as_if_applied_one_by_one(
|
|
239
|
+
page: Page, server_url: str, errors: list[str]
|
|
240
|
+
):
|
|
241
|
+
"""A hidden output folds each run of extends or prepends into one, to the same result."""
|
|
242
|
+
page.goto(server_url + "/held/")
|
|
243
|
+
expect(page.locator(f"#shown {SVG}").first).to_be_visible()
|
|
244
|
+
|
|
245
|
+
page.click("#burst")
|
|
246
|
+
expect(page.locator("#sent")).to_have_text("sent 1")
|
|
247
|
+
|
|
248
|
+
# 57 extends and prepends in seven runs, plus a relayout and a restyle: nine.
|
|
249
|
+
assert value(page, "document.getElementById('held')._shinyPlotlyPending.length") == 9
|
|
250
|
+
# Held points are cut to the cap as they merge, so a stream held for an hour keeps a
|
|
251
|
+
# window of points, not an hour of them.
|
|
252
|
+
first_run = "document.getElementById('held')._shinyPlotlyPending[0].args[0]"
|
|
253
|
+
assert value(page, f"{first_run}.y[0].length") == 10
|
|
254
|
+
|
|
255
|
+
page.get_by_role("tab", name="Held").click()
|
|
256
|
+
expect(page.locator(f"#held {SVG}").first).to_be_visible()
|
|
257
|
+
wait_for(page, "document.getElementById('held')._shinyPlotlyPending", None)
|
|
258
|
+
|
|
259
|
+
applied_one_by_one = value(page, HELD_STATE % "shown")
|
|
260
|
+
assert isinstance(applied_one_by_one, dict)
|
|
261
|
+
assert value(page, HELD_STATE % "held") == applied_one_by_one
|
|
262
|
+
assert applied_one_by_one["title"] == "mid"
|
|
263
|
+
assert errors == []
|
|
@@ -278,17 +278,85 @@ def test_under_pyodide_the_route_is_skipped_and_the_app_still_renders(monkeypatc
|
|
|
278
278
|
assert _serve.ROUTE_NAME not in names
|
|
279
279
|
|
|
280
280
|
|
|
281
|
-
def
|
|
282
|
-
"""
|
|
281
|
+
def test_bundle_before_compression_has_finished_is_served_raw_and_revalidated():
|
|
282
|
+
"""
|
|
283
|
+
The route never waits for the background compression; it serves what it has, but a
|
|
284
|
+
client that will be offered a compressed encoding must not keep the raw file for a year.
|
|
285
|
+
"""
|
|
283
286
|
pending = _serve.CompressedBundle(_serve.bundle().path)
|
|
284
287
|
|
|
285
288
|
resp = _serve.response_for(pending, accept_encoding="gzip, br", if_none_match=None)
|
|
286
289
|
|
|
287
290
|
assert resp.status_code == 200
|
|
288
291
|
assert "content-encoding" not in resp.headers
|
|
292
|
+
assert resp.headers["cache-control"] == "no-cache"
|
|
293
|
+
assert resp.headers["etag"] == pending.etag(None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_a_client_revalidating_the_early_raw_file_gets_the_compressed_one_once_ready(tmp_path):
|
|
297
|
+
bundle = stub_bundle(tmp_path)
|
|
298
|
+
early = _serve.response_for(bundle, accept_encoding="br, gzip", if_none_match=None)
|
|
299
|
+
|
|
300
|
+
bundle.start()
|
|
301
|
+
assert bundle.wait(timeout=30)
|
|
302
|
+
later = _serve.response_for(
|
|
303
|
+
bundle, accept_encoding="br, gzip", if_none_match=early.headers["etag"]
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
assert later.status_code == 200, "the raw ETag no longer matches: the body is new"
|
|
307
|
+
assert later.headers["content-encoding"] == "br"
|
|
308
|
+
assert later.headers["cache-control"] == "public, max-age=31536000, immutable"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_raw_is_final_for_a_client_that_takes_no_encoding_even_before_compression():
|
|
312
|
+
pending = _serve.CompressedBundle(_serve.bundle().path)
|
|
313
|
+
|
|
314
|
+
resp = _serve.response_for(pending, accept_encoding="identity", if_none_match=None)
|
|
315
|
+
|
|
289
316
|
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
|
|
290
317
|
|
|
291
318
|
|
|
319
|
+
def test_the_encoding_served_mid_compression_is_final_when_it_is_the_one_preferred(tmp_path):
|
|
320
|
+
"""brotli is produced first; a client preferring it need not wait for gzip too."""
|
|
321
|
+
bundle = stub_bundle(tmp_path)
|
|
322
|
+
bundle.encodings["br"] = brotli.compress(bundle.path.read_bytes())
|
|
323
|
+
|
|
324
|
+
preferred = _serve.response_for(bundle, accept_encoding="gzip, br", if_none_match=None)
|
|
325
|
+
gzip_only = _serve.response_for(bundle, accept_encoding="gzip", if_none_match=None)
|
|
326
|
+
|
|
327
|
+
assert preferred.headers["content-encoding"] == "br"
|
|
328
|
+
assert preferred.headers["cache-control"] == "public, max-age=31536000, immutable"
|
|
329
|
+
assert "content-encoding" not in gzip_only.headers
|
|
330
|
+
assert gzip_only.headers["cache-control"] == "no-cache"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@pytest.mark.parametrize(
|
|
334
|
+
"if_none_match",
|
|
335
|
+
["*", "W/{etag}", '"other", {etag}', '"other",W/{etag}', " {etag} "],
|
|
336
|
+
)
|
|
337
|
+
def test_revalidation_matches_the_way_rfc_9110_compares_tags(client, if_none_match):
|
|
338
|
+
"""If-None-Match compares weakly, and ``*`` matches any current representation."""
|
|
339
|
+
etag = client.get(BUNDLE_URL, headers={"Accept-Encoding": "gzip"}).headers["etag"]
|
|
340
|
+
|
|
341
|
+
resp = client.get(
|
|
342
|
+
BUNDLE_URL,
|
|
343
|
+
headers={"Accept-Encoding": "gzip", "If-None-Match": if_none_match.format(etag=etag)},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
assert resp.status_code == 304
|
|
347
|
+
assert resp.headers["etag"] == etag
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@pytest.mark.parametrize("if_none_match", ['"other"', 'W/"other"', ""])
|
|
351
|
+
def test_revalidation_with_a_tag_that_does_not_match_gets_the_body(client, if_none_match):
|
|
352
|
+
resp = client.get(
|
|
353
|
+
BUNDLE_URL, headers={"Accept-Encoding": "gzip", "If-None-Match": if_none_match}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
assert resp.status_code == 200
|
|
357
|
+
assert resp.content == RAW
|
|
358
|
+
|
|
359
|
+
|
|
292
360
|
def test_gzip_body_is_the_bundle():
|
|
293
361
|
bundle = _serve.bundle()
|
|
294
362
|
assert bundle.wait(timeout=30)
|
|
@@ -309,6 +377,11 @@ def test_gzip_body_is_the_bundle():
|
|
|
309
377
|
("gzip, br;q=0.0", ["gzip"]),
|
|
310
378
|
("gzip, br;q=0.000", ["gzip"]),
|
|
311
379
|
("*;q=0", []),
|
|
380
|
+
("gzip;q=0, *", ["br"]),
|
|
381
|
+
("*, gzip;q=0", ["br"]),
|
|
382
|
+
("br;q=0, *;q=1", ["gzip"]),
|
|
383
|
+
("*;q=0, gzip", ["gzip"]),
|
|
384
|
+
("br;level=5;q=0, gzip", ["gzip"]),
|
|
312
385
|
("identity", []),
|
|
313
386
|
("deflate, zstd", []),
|
|
314
387
|
("br;q=none-of-that", ["br"]),
|
|
@@ -140,6 +140,17 @@ def test_max_event_points_must_be_a_positive_int_or_none(bad):
|
|
|
140
140
|
return bar()
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def test_max_event_points_takes_a_numpy_integer_as_a_python_int():
|
|
144
|
+
np = pytest.importorskip("numpy")
|
|
145
|
+
|
|
146
|
+
@render_plotly(max_event_points=np.int64(500))
|
|
147
|
+
def sales():
|
|
148
|
+
return bar()
|
|
149
|
+
|
|
150
|
+
assert sales.max_event_points == 500
|
|
151
|
+
assert type(sales.max_event_points) is int
|
|
152
|
+
|
|
153
|
+
|
|
143
154
|
def test_theme_auto_is_the_plotly_light_dark_pair():
|
|
144
155
|
@render_plotly(theme="auto")
|
|
145
156
|
def sales():
|
|
@@ -64,13 +64,16 @@ def make_app() -> App:
|
|
|
64
64
|
await prepend_traces("fig", {"y": [[0]]}, indices=0, max_points=4)
|
|
65
65
|
await update("fig", restyle={"marker.color": "red"}, relayout={"title.text": "u"})
|
|
66
66
|
await update("fig", restyle={"opacity": [0.5]}, indices=[0])
|
|
67
|
+
# What a dataframe or an event hands back: numpy integers, not Python ints.
|
|
68
|
+
await extend_traces("fig", {"y": [[5]]}, indices=np.int64(0), max_points=np.int32(3))
|
|
69
|
+
await restyle("fig", {"opacity": [1]}, indices=np.array([0]))
|
|
67
70
|
|
|
68
71
|
mod_server("m")
|
|
69
72
|
|
|
70
73
|
return App(app_ui, server)
|
|
71
74
|
|
|
72
75
|
|
|
73
|
-
MESSAGE_COUNT =
|
|
76
|
+
MESSAGE_COUNT = 14
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
def custom_messages(client: TestClient) -> list[dict]:
|
|
@@ -149,8 +152,15 @@ def test_update_sends_restyle_and_relayout_in_one_call(messages):
|
|
|
149
152
|
assert json.loads(restyle_only["args"]) == [{"opacity": [0.5]}, {}, [0]]
|
|
150
153
|
|
|
151
154
|
|
|
155
|
+
def test_numpy_integers_are_taken_as_indices_and_max_points(messages):
|
|
156
|
+
extend, restyle_np = messages[11], messages[12]
|
|
157
|
+
|
|
158
|
+
assert json.loads(extend["args"]) == [{"y": [[5]]}, [0], 3]
|
|
159
|
+
assert json.loads(restyle_np["args"]) == [{"opacity": [1]}, [0]]
|
|
160
|
+
|
|
161
|
+
|
|
152
162
|
def test_ids_are_namespaced_inside_a_module(messages):
|
|
153
|
-
msg = messages[
|
|
163
|
+
msg = messages[13]
|
|
154
164
|
|
|
155
165
|
assert msg["id"] == "m-fig"
|
|
156
166
|
assert json.loads(msg["args"]) == [{"title.text": "from the module"}]
|
|
@@ -169,6 +179,25 @@ def test_prepend_traces_max_points_must_be_a_positive_integer(bad):
|
|
|
169
179
|
run(prepend_traces("fig", {"y": [[1]]}, max_points=bad))
|
|
170
180
|
|
|
171
181
|
|
|
182
|
+
@pytest.mark.parametrize("bad", [0.5, "0", True, None])
|
|
183
|
+
def test_indices_must_be_integers(bad):
|
|
184
|
+
"""Caught here, where the call is, rather than as a plotly.js error in the browser."""
|
|
185
|
+
with pytest.raises(ValueError, match="indices"):
|
|
186
|
+
run(restyle("fig", {"opacity": 1}, indices=[0, bad]))
|
|
187
|
+
with pytest.raises(ValueError, match="indices"):
|
|
188
|
+
run(delete_traces("fig", bad if bad is not None else 1.5)) # type: ignore[arg-type]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_numpy_non_integers_are_refused_like_their_python_counterparts():
|
|
192
|
+
np = pytest.importorskip("numpy")
|
|
193
|
+
with pytest.raises(ValueError, match="max_points"):
|
|
194
|
+
run(extend_traces("fig", {"y": [[1]]}, max_points=np.float64(3)))
|
|
195
|
+
with pytest.raises(ValueError, match="max_points"):
|
|
196
|
+
run(extend_traces("fig", {"y": [[1]]}, max_points=np.bool_(True)))
|
|
197
|
+
with pytest.raises(ValueError, match="indices"):
|
|
198
|
+
run(restyle("fig", {"opacity": 1}, indices=np.array([0.5])))
|
|
199
|
+
|
|
200
|
+
|
|
172
201
|
def test_add_traces_requires_at_least_one_trace():
|
|
173
202
|
with pytest.raises(ValueError, match="trace"):
|
|
174
203
|
run(add_traces("fig", []))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|