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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/CHANGELOG.md +13 -0
  2. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/PKG-INFO +1 -1
  3. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/pyproject.toml +1 -1
  4. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/_update.py +2 -2
  5. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/_validate.py +5 -4
  6. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/www/shiny-plotly.js +57 -0
  7. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/apps.py +105 -0
  8. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/conftest.py +2 -0
  9. shiny_plotly-0.4.3/tests/browser/test_arrays.py +92 -0
  10. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_update.py +5 -0
  11. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/.gitignore +0 -0
  12. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/LICENSE +0 -0
  13. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/README.md +0 -0
  14. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/core_app.py +0 -0
  15. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/dark_app.py +0 -0
  16. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/events_app.py +0 -0
  17. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/express_app.py +0 -0
  18. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/shinylive/app.py +0 -0
  19. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/shinylive/requirements.txt +0 -0
  20. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/examples/streaming_app.py +0 -0
  21. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/__init__.py +0 -0
  22. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/_deps.py +0 -0
  23. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/_html.py +0 -0
  24. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/_render.py +0 -0
  25. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/_serve.py +0 -0
  26. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/src/shiny_plotly/py.typed +0 -0
  27. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/__init__.py +0 -0
  28. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/test_browser.py +0 -0
  29. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/test_dark_mode.py +0 -0
  30. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/test_events.py +0 -0
  31. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/test_shinylive_demo.py +0 -0
  32. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/test_streaming_example.py +0 -0
  33. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/browser/test_update.py +0 -0
  34. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/helpers.py +0 -0
  35. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/newplot.py +0 -0
  36. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_compressed_js.py +0 -0
  37. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_examples.py +0 -0
  38. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_fig_to_ui.py +0 -0
  39. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_pages_contract.py +0 -0
  40. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_plotly_js.py +0 -0
  41. {shiny_plotly-0.4.1 → shiny_plotly-0.4.3}/tests/test_render_plotly.py +0 -0
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.3](https://github.com/rvben/shiny-plotly/compare/v0.4.2...v0.4.3) - 2026-09-25
11
+
12
+ ### Fixed
13
+
14
+ - `extend_traces` and `prepend_traces` work on traces built from numpy arrays or pandas columns with plotly 6. plotly 6 sends those arrays in binary form, which `Plotly.extendTraces` refuses ("cannot extend missing or non-array attribute"), so the update failed in the browser and the chart stopped updating. The new points now land as their own values, even ones the original array's type could not hold, such as 300 or 0.5 added to `np.arange(3)`. This covers held updates and 2-D arrays such as a heatmap's `z`.
15
+ - `extend_traces` and `prepend_traces` are typed to take numpy arrays as the per-trace values (`{"y": [np.array([...])]}`), which they always accepted at runtime.
16
+
17
+ ## [0.4.2](https://github.com/rvben/shiny-plotly/compare/v0.4.1...v0.4.2) - 2026-09-25
18
+
19
+ ### Fixed
20
+
21
+ - A numpy bool is refused as `indices`, `max_points` or `max_event_points`, like a Python `bool`. With numpy older than 2.3, `np.True_` was taken as the integer 1 (with only a DeprecationWarning), so `max_points=np.True_` capped a trace at one point instead of raising.
22
+
10
23
  ## [0.4.1](https://github.com/rvben/shiny-plotly/compare/v0.4.0...v0.4.1) - 2026-09-25
11
24
 
12
25
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shiny-plotly
3
- Version: 0.4.1
3
+ Version: 0.4.3
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shiny-plotly"
7
- version = "0.4.1"
7
+ version = "0.4.3"
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"
@@ -85,7 +85,7 @@ async def _send(id: str, method: str, args: Sequence[Any], session: Session | No
85
85
 
86
86
  async def extend_traces(
87
87
  id: str,
88
- data: Mapping[str, Sequence[Sequence[Any]]],
88
+ data: Mapping[str, Sequence[Any]],
89
89
  indices: Indices = None,
90
90
  *,
91
91
  max_points: int | None = None,
@@ -108,7 +108,7 @@ async def extend_traces(
108
108
 
109
109
  async def prepend_traces(
110
110
  id: str,
111
- data: Mapping[str, Sequence[Sequence[Any]]],
111
+ data: Mapping[str, Sequence[Any]],
112
112
  indices: Indices = None,
113
113
  *,
114
114
  max_points: int | None = None,
@@ -11,11 +11,12 @@ def as_int(value: Any) -> int:
11
11
  ``value`` as a Python int, for anything integer-like: ``int`` and numpy's integers.
12
12
 
13
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.
14
+ but serializes to a JSON ``true``, which plotly.js does not read as a number. numpy's
15
+ bool is refused by its dtype, since numpy before 2.3 still lets it pass as an index,
16
+ with only a DeprecationWarning, as 0 or 1. A float, even a whole one, is refused as
17
+ well, the way ``range()`` and list indexing refuse it.
17
18
  """
18
- if isinstance(value, bool):
19
+ if isinstance(value, bool) or getattr(getattr(value, "dtype", None), "kind", None) == "b":
19
20
  raise TypeError(f"expected an integer, got {value!r}")
20
21
  return operator.index(value)
21
22
 
@@ -403,12 +403,69 @@
403
403
  else queue.push(held);
404
404
  }
405
405
 
406
+ // plotly 6 sends a figure's numpy arrays as binary, {dtype, bdata[, shape]}, which
407
+ // plotly.js keeps on the trace as sent and draws from a typed array.
408
+ var TYPED_ARRAYS = {
409
+ i1: Int8Array, u1: Uint8Array, u1c: Uint8ClampedArray, i2: Int16Array, u2: Uint16Array,
410
+ i4: Int32Array, u4: Uint32Array, f4: Float32Array, f8: Float64Array
411
+ };
412
+
413
+ function isBinary(value) {
414
+ return value !== null && typeof value === "object" && typeof value.bdata === "string" &&
415
+ TYPED_ARRAYS.hasOwnProperty(value.dtype);
416
+ }
417
+
418
+ // The values of a 1-D binary array as a typed array, of a 2-D one as rows of them; null
419
+ // for more dimensions, which no update extends.
420
+ function decodeBinary(spec) {
421
+ var shape = spec.shape ? String(spec.shape).split(",").map(Number) : [];
422
+ if (shape.length > 2) return null;
423
+ var bytes = window.atob(spec.bdata);
424
+ var buffer = new Uint8Array(bytes.length);
425
+ for (var i = 0; i < bytes.length; i++) buffer[i] = bytes.charCodeAt(i);
426
+ var flat = new TYPED_ARRAYS[spec.dtype](buffer.buffer);
427
+ if (shape.length < 2) return flat;
428
+ var rows = [];
429
+ for (var r = 0; r < shape[0]; r++) rows.push(flat.subarray(r * shape[1], (r + 1) * shape[1]));
430
+ return rows;
431
+ }
432
+
433
+ // An array of values, or of rows of values, as plain arrays, whatever form it came in;
434
+ // anything else as it is.
435
+ function plainArray(value) {
436
+ var decoded = isBinary(value) ? decodeBinary(value) : value;
437
+ if (decoded === null) return value;
438
+ if (ArrayBuffer.isView(decoded)) return Array.prototype.slice.call(decoded);
439
+ if (Array.isArray(decoded)) {
440
+ return decoded.map(function (v) { return ArrayBuffer.isView(v) ? plainArray(v) : v; });
441
+ }
442
+ return decoded;
443
+ }
444
+
445
+ // extendTraces and prependTraces join plain arrays to plain arrays and a typed array only
446
+ // to one of its own type, so a trace built from numpy (an int8 np.arange, say) could take
447
+ // no new points at all. The attributes an extend or prepend reaches become plain arrays
448
+ // of their values first, as if the figure had been built from lists; a plain array holds
449
+ // a 300 or a 0.5 where an int8 array would wrap or truncate them.
450
+ function plainTargets(gd, data, indices) {
451
+ Object.keys(data).forEach(function (key) {
452
+ var path = key.split(".");
453
+ indices.forEach(function (index) {
454
+ var node = gd.data[index < 0 ? gd.data.length + index : index];
455
+ for (var i = 0; node && i < path.length - 1; i++) node = node[path[i]];
456
+ var last = path[path.length - 1];
457
+ if (node && node[last] !== undefined) node[last] = plainArray(node[last]);
458
+ });
459
+ });
460
+ }
461
+
406
462
  function applyUpdate(gd, update) {
407
463
  var args = update.args;
408
464
  if (update.method === "extendTraces" || update.method === "prependTraces") {
409
465
  // Both want an array of indices and no maxPoints rather than null.
410
466
  if (args[1] === null) args[1] = gd.data.map(function (_, i) { return i; });
411
467
  if (args[2] === null) args.length = 2;
468
+ plainTargets(gd, args[0], args[1]);
412
469
  }
413
470
  return window.Plotly[update.method].apply(window.Plotly, [gd].concat(args));
414
471
  }
@@ -642,3 +642,108 @@ def make_held_app() -> App:
642
642
  return f"sent {done()}"
643
643
 
644
644
  return App(app_ui, server)
645
+
646
+
647
+ def make_arrays_app() -> App:
648
+ """
649
+ Figures built from numpy, which plotly 6 sends as binary, updated in place.
650
+
651
+ Each button extends or prepends one output, with plain lists and with numpy arrays of
652
+ other dtypes than the figure's: the int8 a small ``np.arange`` travels as must take a
653
+ 300 and a 0.5, and no update may reach the figure in any other form than its values.
654
+ """
655
+ app_ui = ui.page_fluid(
656
+ ui.input_action_button("extend_plain", "extend plain"),
657
+ ui.input_action_button("extend_capped", "extend capped"),
658
+ ui.input_action_button("prepend", "prepend"),
659
+ ui.input_action_button("extend_numpy", "extend numpy"),
660
+ ui.input_action_button("replace", "replace"),
661
+ ui.input_action_button("burst", "burst"),
662
+ ui.input_action_button("add_row", "add row"),
663
+ ui.output_text("bursts"),
664
+ output_plotly("ints", height="200px", width="400px"),
665
+ output_plotly("floats", height="200px", width="400px"),
666
+ output_plotly("two", height="200px", width="400px"),
667
+ output_plotly("grid", height="200px", width="400px"),
668
+ ui.navset_tab(
669
+ ui.nav_panel("Empty", "nothing here"),
670
+ ui.nav_panel("Held", output_plotly("held", height="200px", width="400px")),
671
+ id="tab",
672
+ ),
673
+ )
674
+
675
+ def server(input: Inputs, output: Outputs, session: Session):
676
+ @render_plotly
677
+ def ints():
678
+ return go.Figure(go.Scatter(x=np.arange(3), y=np.arange(3)))
679
+
680
+ @render_plotly
681
+ def floats():
682
+ return go.Figure(go.Scatter(x=np.linspace(0, 1, 3), y=np.array([0.5, 1.5, 2.5])))
683
+
684
+ @render_plotly
685
+ def two():
686
+ return go.Figure([go.Scatter(y=np.arange(3)), go.Scatter(y=[5, 6])])
687
+
688
+ @render_plotly
689
+ def held():
690
+ return go.Figure(go.Scatter(y=np.arange(3)))
691
+
692
+ @render_plotly
693
+ def grid():
694
+ # A 2-D array travels as one binary block with its shape.
695
+ return go.Figure(go.Heatmap(z=np.arange(4).reshape(2, 2)))
696
+
697
+ @reactive.effect
698
+ @reactive.event(input.add_row)
699
+ async def _add_row():
700
+ await extend_traces("grid", {"z": [[[40, 50.5]]]})
701
+
702
+ @reactive.effect
703
+ @reactive.event(input.extend_plain)
704
+ async def _extend_plain():
705
+ await extend_traces("ints", {"x": [[3, 4]], "y": [[300, 0.5]]})
706
+
707
+ @reactive.effect
708
+ @reactive.event(input.extend_capped)
709
+ async def _extend_capped():
710
+ await extend_traces("floats", {"x": [[2]], "y": [[3.5]]}, max_points=3)
711
+
712
+ @reactive.effect
713
+ @reactive.event(input.prepend)
714
+ async def _prepend():
715
+ await prepend_traces("two", {"y": [[-1], [4]]}, max_points=3)
716
+
717
+ @reactive.effect
718
+ @reactive.event(input.extend_numpy)
719
+ async def _extend_numpy():
720
+ await extend_traces(
721
+ "ints",
722
+ {
723
+ "x": [np.array([5, 6, 7], dtype=np.uint8)],
724
+ "y": [np.array([2**40, -1, 7], dtype=np.int64)],
725
+ },
726
+ )
727
+ await extend_traces("floats", {"y": [np.array([0.25], dtype=np.float32)]})
728
+
729
+ @reactive.effect
730
+ @reactive.event(input.replace)
731
+ async def _replace():
732
+ await restyle("floats", {"y": [np.array([9.5, 10.5])]})
733
+ await add_traces("two", go.Scatter(y=np.array([-3, 3], dtype=np.int16)))
734
+ await update("two", restyle={"marker.size": [np.array([11.0, 12.0])]}, indices=[1])
735
+
736
+ sent = reactive.value(0)
737
+
738
+ @reactive.effect
739
+ @reactive.event(input.burst)
740
+ async def _burst():
741
+ for i in range(5):
742
+ await extend_traces("held", {"y": [np.array([10.0 * i + 0.5])]}, max_points=6)
743
+ sent.set(sent() + 1)
744
+
745
+ @render.text
746
+ def bursts():
747
+ return f"bursts {sent()}"
748
+
749
+ return App(app_ui, server)
@@ -15,6 +15,7 @@ from starlette.routing import Mount
15
15
 
16
16
  from .apps import (
17
17
  make_app,
18
+ make_arrays_app,
18
19
  make_dark_app,
19
20
  make_events_app,
20
21
  make_held_app,
@@ -52,6 +53,7 @@ def server_url() -> Iterator[str]:
52
53
  Mount("/hidden", app=make_hidden_app()),
53
54
  Mount("/post-script", app=make_post_script_app()),
54
55
  Mount("/held", app=make_held_app()),
56
+ Mount("/arrays", app=make_arrays_app()),
55
57
  Mount("/stream", app=load_example("streaming_app.py")),
56
58
  Mount("/demo", app=load_example("shinylive/app.py")),
57
59
  Mount("/", app=make_app()),
@@ -0,0 +1,92 @@
1
+ """In-place updates to figures built from numpy arrays, which plotly 6 sends as binary."""
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+
6
+ import pytest
7
+ from playwright.sync_api import Page, expect
8
+
9
+ pytestmark = pytest.mark.browser
10
+
11
+ SVG = "svg.main-svg"
12
+
13
+
14
+ @pytest.fixture
15
+ def app(page: Page, server_url: str, errors: list[str]) -> Iterator[Page]:
16
+ page.goto(server_url + "/arrays/")
17
+ for output_id in ("ints", "floats", "two", "grid"):
18
+ expect(page.locator(f"#{output_id} {SVG}").first).to_be_visible()
19
+ yield page
20
+ assert errors == []
21
+
22
+
23
+ def drawn(output_id: str, index: int, attribute: str) -> str:
24
+ """The values plotly draws for one attribute of one trace, as a plain list."""
25
+ trace = f"document.getElementById('{output_id}-plotly')._fullData[{index}]"
26
+ return f"Array.from({trace}.{attribute})"
27
+
28
+
29
+ def wait_for(page: Page, expression: str, expected: object) -> None:
30
+ want = json.dumps(json.dumps(expected, separators=(",", ":")))
31
+ page.wait_for_function(f"() => JSON.stringify({expression}) === {want}")
32
+
33
+
34
+ def points(page: Page, output_id: str, index: int) -> int:
35
+ return page.locator(f"#{output_id} .scatterlayer .trace").nth(index).locator(".point").count()
36
+
37
+
38
+ def test_a_trace_built_from_numpy_takes_points_of_any_value(app: Page):
39
+ """np.arange(3) travels as int8; a 300 and a 0.5 must still land as themselves."""
40
+ app.click("#extend_plain")
41
+
42
+ wait_for(app, drawn("ints", 0, "y"), [0, 1, 2, 300, 0.5])
43
+ assert app.evaluate(f"() => {drawn('ints', 0, 'x')}") == [0, 1, 2, 3, 4]
44
+ assert points(app, "ints", 0) == 5, "the drawing follows the data"
45
+
46
+
47
+ def test_extending_a_numpy_trace_keeps_the_rolling_window(app: Page):
48
+ app.click("#extend_capped")
49
+
50
+ wait_for(app, drawn("floats", 0, "y"), [1.5, 2.5, 3.5])
51
+ assert app.evaluate(f"() => {drawn('floats', 0, 'x')}") == [0.5, 1, 2]
52
+
53
+
54
+ def test_prepending_reaches_numpy_and_list_traces_alike(app: Page):
55
+ app.click("#prepend")
56
+
57
+ wait_for(app, drawn("two", 0, "y"), [-1, 0, 1])
58
+ wait_for(app, drawn("two", 1, "y"), [4, 5, 6])
59
+
60
+
61
+ def test_numpy_points_of_any_dtype_land_as_their_values(app: Page):
62
+ """uint8, an int64 past int32 and a float32, into int8 and float64 traces."""
63
+ app.click("#extend_numpy")
64
+
65
+ wait_for(app, drawn("ints", 0, "y"), [0, 1, 2, 2**40, -1, 7])
66
+ assert app.evaluate(f"() => {drawn('ints', 0, 'x')}") == [0, 1, 2, 5, 6, 7]
67
+ wait_for(app, drawn("floats", 0, "y"), [0.5, 1.5, 2.5, 0.25])
68
+
69
+
70
+ def test_numpy_arrays_in_restyle_add_traces_and_update_land_as_their_values(app: Page):
71
+ app.click("#replace")
72
+
73
+ wait_for(app, drawn("floats", 0, "y"), [9.5, 10.5])
74
+ wait_for(app, drawn("two", 2, "y"), [-3, 3])
75
+ wait_for(app, drawn("two", 1, "marker.size"), [11, 12])
76
+
77
+
78
+ def test_updates_held_for_a_hidden_numpy_figure_apply_once_it_is_drawn(app: Page):
79
+ app.click("#burst")
80
+ expect(app.locator("#bursts")).to_have_text("bursts 1")
81
+
82
+ app.get_by_role("tab", name="Held").click()
83
+ expect(app.locator(f"#held {SVG}").first).to_be_visible()
84
+
85
+ wait_for(app, drawn("held", 0, "y"), [2, 0.5, 10.5, 20.5, 30.5, 40.5])
86
+
87
+
88
+ def test_a_heatmap_built_from_a_2d_array_takes_a_new_row(app: Page):
89
+ app.click("#add_row")
90
+
91
+ rows = "document.getElementById('grid-plotly')._fullData[0].z.map((r) => Array.from(r))"
92
+ wait_for(app, rows, [[0, 1], [2, 3], [40, 50.5]])
@@ -196,6 +196,11 @@ def test_numpy_non_integers_are_refused_like_their_python_counterparts():
196
196
  run(extend_traces("fig", {"y": [[1]]}, max_points=np.bool_(True)))
197
197
  with pytest.raises(ValueError, match="indices"):
198
198
  run(restyle("fig", {"opacity": 1}, indices=np.array([0.5])))
199
+ # numpy before 2.3 still reads its bools as indices 0 and 1, warning only.
200
+ with pytest.raises(ValueError, match="indices"):
201
+ run(restyle("fig", {"opacity": 1}, indices=np.True_))
202
+ with pytest.raises(ValueError, match="indices"):
203
+ run(restyle("fig", {"opacity": 1}, indices=np.array([True, False])))
199
204
 
200
205
 
201
206
  def test_add_traces_requires_at_least_one_trace():
File without changes
File without changes
File without changes