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.
Files changed (40) hide show
  1. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/CHANGELOG.md +12 -0
  2. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/PKG-INFO +5 -5
  3. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/README.md +4 -4
  4. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/pyproject.toml +1 -1
  5. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_render.py +2 -5
  6. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_serve.py +59 -18
  7. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_update.py +21 -17
  8. shiny_plotly-0.4.1/src/shiny_plotly/_validate.py +31 -0
  9. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/www/shiny-plotly.js +70 -10
  10. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/apps.py +124 -0
  11. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/conftest.py +4 -0
  12. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_events.py +19 -0
  13. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_update.py +61 -0
  14. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_compressed_js.py +75 -2
  15. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_render_plotly.py +11 -0
  16. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_update.py +31 -2
  17. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/.gitignore +0 -0
  18. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/LICENSE +0 -0
  19. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/core_app.py +0 -0
  20. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/dark_app.py +0 -0
  21. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/events_app.py +0 -0
  22. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/express_app.py +0 -0
  23. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/shinylive/app.py +0 -0
  24. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/shinylive/requirements.txt +0 -0
  25. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/examples/streaming_app.py +0 -0
  26. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/__init__.py +0 -0
  27. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_deps.py +0 -0
  28. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/_html.py +0 -0
  29. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/src/shiny_plotly/py.typed +0 -0
  30. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/__init__.py +0 -0
  31. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_browser.py +0 -0
  32. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_dark_mode.py +0 -0
  33. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_shinylive_demo.py +0 -0
  34. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/browser/test_streaming_example.py +0 -0
  35. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/helpers.py +0 -0
  36. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/newplot.py +0 -0
  37. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_examples.py +0 -0
  38. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_fig_to_ui.py +0 -0
  39. {shiny_plotly-0.4.0 → shiny_plotly-0.4.1}/tests/test_pages_contract.py +0 -0
  40. {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.0
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 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.
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 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
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.0"
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 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__)
@@ -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``. Anything else leaves the encoding on offer, an unparsable qvalue
136
- included: serving a client the encoding it asked for beats guessing at a malformed
137
- header.
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
- key, _, value = params.partition("=")
140
- if key.strip().lower() != "q":
141
- return False
142
- try:
143
- return float(value) == 0
144
- except ValueError:
145
- return False
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
- continue
157
- 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
158
174
  for encoding in ("br", "gzip"):
159
- if encoding in offered or "*" in offered:
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
- 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
171
208
  etag = bundle.etag(encoding)
172
- headers = {"Cache-Control": CACHE_CONTROL, "Vary": "Accept-Encoding", "ETag": etag}
173
- 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):
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. 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
@@ -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), the same cut Dash makes. Typed arrays, which is how
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
- var trace = gd._fullData && gd._fullData[point.curveNumber];
175
- if (trace && trace.customdata != null && point.pointNumber !== undefined) {
176
- out.customdata = plain(trace.customdata[point.pointNumber]);
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
- /* eslint-disable-next-line no-new-func */
294
- new Function(script.replace(/\{plot_id\}/g, gd.id))();
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 = JSON.parse(update.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._shinyPlotlyPending || (el._shinyPlotlyPending = [])).push(update);
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 test_bundle_before_compression_has_finished_is_served_raw_and_cacheable():
282
- """The route never waits for the background compression; it serves what it has."""
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 = 12
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[11]
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