shiny-plotly 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ .wheel-venv/
3
+ dist/
4
+ __pycache__/
5
+ *.pyc
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ tmp/
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-08-19
11
+
12
+ ### Added
13
+
14
+ - `@render_plotly`: a `shiny.render.ui` subclass that renders a `plotly.graph_objects.Figure` (or its dict) as HTML drawn with `Plotly.newPlot`. Works in Core and Express, sync or async, bare or with options; `None` renders nothing.
15
+ - `output_plotly(id, width=, height=)`: fill-aware placeholder, a drop-in for `output_widget`. No height fills the container from a 400px basis; an explicit height is fixed and opts out of filling.
16
+ - `fig_to_ui(...)`: the lower-level fragment builder for use from `@render.ui` or any htmltools context.
17
+ - `plotly_js()`: `HTMLDependency` serving plotly.js straight from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`; every figure carries it, so a page-level call is optional.
18
+ - `shiny_plotly_js()` and the `shiny-plotly.js` helper: a `ResizeObserver` keeps each graph sized to its container when the container changes without a window resize, and purges a graph once Shiny replaces the output holding it.
19
+ - `figurewidget_margins=True` fills in unset margin sides with the values shinywidgets applies to a `FigureWidget` (l16/t32/r16/b16), for pixel-identical migrations.
20
+ - `config` (merged over `{"responsive": True}`) and `post_script` (with `{plot_id}` substitution) options for forwarding plotly events to Shiny inputs.
21
+ - Test suite: unit tests, in-process Shiny end-to-end tests over a real websocket, and a headless Chromium suite covering sizing, resize tracking, purge on re-render, full screen and `post_script` wiring.
22
+
23
+ [Unreleased]: https://github.com/rvben/shiny-plotly/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/rvben/shiny-plotly/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ruben J. Jongejan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.5
2
+ Name: shiny-plotly
3
+ Version: 0.1.0
4
+ Summary: Render plotly figures in Shiny for Python as plain HTML, without the shinywidgets layer.
5
+ Project-URL: Homepage, https://github.com/rvben/shiny-plotly
6
+ Project-URL: Repository, https://github.com/rvben/shiny-plotly
7
+ Project-URL: Changelog, https://github.com/rvben/shiny-plotly/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/rvben/shiny-plotly/issues
9
+ Author-email: "Ruben J. Jongejan" <ruben.jongejan@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: dashboard,html,plotly,render,shiny
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Visualization
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: htmltools>=0.5
25
+ Requires-Dist: plotly>=5.0
26
+ Requires-Dist: shiny>=1.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # shiny-plotly
30
+
31
+ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/) as plain HTML, without the shinywidgets layer.
32
+
33
+ *An independent project, not affiliated with or endorsed by Posit or Plotly.*
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
36
+ [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
37
+
38
+ ```python
39
+ from shiny_plotly import output_plotly, render_plotly
40
+
41
+ # UI
42
+ output_plotly("sales")
43
+
44
+
45
+ # server
46
+ @render_plotly
47
+ def sales():
48
+ return go.Figure(go.Bar(x=months, y=totals))
49
+ ```
50
+
51
+ That is the whole API surface for the common case. The figure is serialized with plotly's own `to_html`, sent to the browser as a UI fragment, and drawn with `Plotly.newPlot`. No ipywidgets, no kernel comm, no anywidget. Every render replaces the figure, which is how most dashboards already use `@render_widget`.
52
+
53
+ ## Why
54
+
55
+ `shinywidgets` renders a plotly figure by wrapping it in a `FigureWidget` and shipping it through the ipywidgets comm protocol. That machinery earns its keep when the app mutates a figure in place (`fig.data[0].y = ...`) and wants the browser to patch it. Most Shiny apps do not do that; they rebuild the figure inside a reactive function and let Shiny re-render the output. For those apps the widget layer is overhead:
56
+
57
+ - extra dependencies (`ipywidgets`, `anywidget`, `shinywidgets`) and their JavaScript bundles on every page;
58
+ - a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
59
+ - figures held as widget state on the server for the life of the session.
60
+
61
+ `shiny-plotly` renders the figure the way plotly itself does, as HTML plus `Plotly.newPlot`, and uses Shiny's `render.ui` for delivery. The plotly.js bundle is served straight from the installed `plotly` wheel, keyed by its version, so nothing is copied or vendored.
62
+
63
+ ## Install
64
+
65
+ ```sh
66
+ uv add shiny-plotly
67
+ # or
68
+ pip install shiny-plotly
69
+ ```
70
+
71
+ Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.0`.
72
+
73
+ ## Use
74
+
75
+ ### Core
76
+
77
+ ```python
78
+ import random
79
+ from itertools import accumulate
80
+
81
+ import plotly.graph_objects as go
82
+ from shiny import App, ui
83
+
84
+ from shiny_plotly import output_plotly, plotly_js, render_plotly
85
+
86
+ app_ui = ui.page_fillable(
87
+ ui.input_slider("n", "Points", 10, 500, 100),
88
+ ui.card(
89
+ ui.card_header("Fills the card; try full screen"),
90
+ output_plotly("walk"),
91
+ full_screen=True,
92
+ ),
93
+ plotly_js(), # optional: load plotly.js with the page instead of with the first figure
94
+ )
95
+
96
+
97
+ def server(input, output, session):
98
+ @render_plotly
99
+ def walk():
100
+ rng = random.Random(input.n())
101
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
102
+ return go.Figure(go.Scatter(y=y, mode="lines"))
103
+
104
+
105
+ app = App(app_ui, server)
106
+ ```
107
+
108
+ Anything that is a `plotly.graph_objects.Figure` works, including what `plotly.express` builds (install `plotly[express]` for that).
109
+
110
+ ### Express
111
+
112
+ ```python
113
+ import random
114
+ from itertools import accumulate
115
+
116
+ import plotly.graph_objects as go
117
+ from shiny.express import input, ui
118
+
119
+ from shiny_plotly import render_plotly
120
+
121
+ ui.page_opts(fillable=True)
122
+
123
+ with ui.sidebar():
124
+ ui.input_slider("n", "Points", 10, 500, 100)
125
+
126
+ with ui.card(full_screen=True):
127
+
128
+ @render_plotly
129
+ def walk():
130
+ rng = random.Random(input.n())
131
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
132
+ return go.Figure(go.Scatter(y=y, mode="lines"))
133
+ ```
134
+
135
+ The decorator creates its own output placeholder in Express, just like `@render_widget` does.
136
+
137
+ ### Options
138
+
139
+ ```python
140
+ @render_plotly(
141
+ height="300px", # fixed height; default None fills the container
142
+ width="100%",
143
+ figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
144
+ config={"displaylogo": False},
145
+ post_script=CLICK_TO_INPUT, # JavaScript run after the plot is drawn
146
+ )
147
+ def sales(): ...
148
+ ```
149
+
150
+ `None` from the render function renders nothing. The function may be sync or async. It may also return `fig.to_dict()` instead of a `Figure`.
151
+
152
+ ### Migrating from shinywidgets
153
+
154
+ | shinywidgets | shiny-plotly |
155
+ | --- | --- |
156
+ | `from shinywidgets import output_widget, render_widget` | `from shiny_plotly import output_plotly, render_plotly` |
157
+ | `output_widget("id")` | `output_plotly("id")` |
158
+ | `output_widget("id", height="300px")` | `output_plotly("id", height="300px")` |
159
+ | `@render_widget` | `@render_plotly` |
160
+ | (FigureWidget margins, applied implicitly) | `@render_plotly(figurewidget_margins=True)` |
161
+
162
+ Two things change on purpose:
163
+
164
+ - **Margins.** shinywidgets sets tight margins (`l=16, t=32, r=16, b=16`) on every FigureWidget; plotly's own defaults are `80/100/80/80`. `shiny-plotly` uses plotly's defaults unless you pass `figurewidget_margins=True`, which fills in only the sides your figure leaves unset. Set margins explicitly on the figure if you want something else.
165
+ - **In-place mutation.** A `FigureWidget` you keep on the server and mutate (`fig.data[0].y = ...`, `fig.add_trace(...)` after render) is exactly what shinywidgets is for. `shiny-plotly` has no channel for that; return a new figure from the render function and let Shiny re-render. If your app depends on in-place widget updates, stay on shinywidgets for those outputs. Both packages can coexist in one app.
166
+
167
+ ### Sizing
168
+
169
+ The rules mirror `output_widget`:
170
+
171
+ - `height=None` (default): the plot fills its container. Inside `ui.card(full_screen=True)`, a fillable page or a sidebar layout it grows and shrinks with the card, from a 400px basis. Outside a fill layout it is 400px tall.
172
+ - `height="300px"` (on the decorator or on `output_plotly`): the plot is exactly that tall and opts out of filling.
173
+
174
+ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every figure) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once Shiny has replaced the output holding it, which releases the window listener and layout state plotly would otherwise keep for every render.
175
+
176
+ ### Events back to Shiny
177
+
178
+ `post_script` runs after `Plotly.newPlot` resolves; `{plot_id}` is replaced with the graph div's id.
179
+
180
+ ```python
181
+ CLICK_TO_INPUT = """
182
+ document.getElementById('{plot_id}').on('plotly_click', function (ev) {
183
+ var p = ev.points[0];
184
+ Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
185
+ });
186
+ """
187
+
188
+
189
+ @render_plotly(post_script=CLICK_TO_INPUT)
190
+ def scatter(): ...
191
+
192
+
193
+ @render.text
194
+ def click_info():
195
+ if not input.clicked.is_set():
196
+ return "Click a point."
197
+ pt = input.clicked()
198
+ return f"x={pt['x']}, y={pt['y']}"
199
+ ```
200
+
201
+ `input.clicked()` raises a silent exception while the input has never been set, so check `is_set()` first when the output should show something before the first click.
202
+
203
+ ### Lower level
204
+
205
+ - `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns the `TagList` a render produces: the plotly.js dependency, the helper dependency and the `<div class="shiny-plotly">` holding the figure. Use it from a plain `@render.ui` that composes several things, or from any htmltools context.
206
+ - `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every figure carries it, so it is optional; add it to the page UI to load the bundle up front instead of with the first figure.
207
+ - `shiny_plotly_js()` is the helper's dependency. Every figure carries it too.
208
+ - `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
209
+
210
+ ## Examples
211
+
212
+ ```sh
213
+ uv run --with shiny-plotly shiny run examples/core_app.py
214
+ uv run --with shiny-plotly shiny run examples/express_app.py
215
+ ```
216
+
217
+ ## Development
218
+
219
+ ```sh
220
+ make sync # uv sync --all-groups
221
+ make browsers # playwright install chromium, once
222
+ make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
223
+ ```
224
+
225
+ `make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, purge on re-render, full screen, `post_script` click wiring and on-demand loading of plotly.js. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested.
226
+
227
+ ## License
228
+
229
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,201 @@
1
+ # shiny-plotly
2
+
3
+ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/) as plain HTML, without the shinywidgets layer.
4
+
5
+ *An independent project, not affiliated with or endorsed by Posit or Plotly.*
6
+
7
+ [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
8
+ [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
9
+
10
+ ```python
11
+ from shiny_plotly import output_plotly, render_plotly
12
+
13
+ # UI
14
+ output_plotly("sales")
15
+
16
+
17
+ # server
18
+ @render_plotly
19
+ def sales():
20
+ return go.Figure(go.Bar(x=months, y=totals))
21
+ ```
22
+
23
+ That is the whole API surface for the common case. The figure is serialized with plotly's own `to_html`, sent to the browser as a UI fragment, and drawn with `Plotly.newPlot`. No ipywidgets, no kernel comm, no anywidget. Every render replaces the figure, which is how most dashboards already use `@render_widget`.
24
+
25
+ ## Why
26
+
27
+ `shinywidgets` renders a plotly figure by wrapping it in a `FigureWidget` and shipping it through the ipywidgets comm protocol. That machinery earns its keep when the app mutates a figure in place (`fig.data[0].y = ...`) and wants the browser to patch it. Most Shiny apps do not do that; they rebuild the figure inside a reactive function and let Shiny re-render the output. For those apps the widget layer is overhead:
28
+
29
+ - extra dependencies (`ipywidgets`, `anywidget`, `shinywidgets`) and their JavaScript bundles on every page;
30
+ - a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
31
+ - figures held as widget state on the server for the life of the session.
32
+
33
+ `shiny-plotly` renders the figure the way plotly itself does, as HTML plus `Plotly.newPlot`, and uses Shiny's `render.ui` for delivery. The plotly.js bundle is served straight from the installed `plotly` wheel, keyed by its version, so nothing is copied or vendored.
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ uv add shiny-plotly
39
+ # or
40
+ pip install shiny-plotly
41
+ ```
42
+
43
+ Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.0`.
44
+
45
+ ## Use
46
+
47
+ ### Core
48
+
49
+ ```python
50
+ import random
51
+ from itertools import accumulate
52
+
53
+ import plotly.graph_objects as go
54
+ from shiny import App, ui
55
+
56
+ from shiny_plotly import output_plotly, plotly_js, render_plotly
57
+
58
+ app_ui = ui.page_fillable(
59
+ ui.input_slider("n", "Points", 10, 500, 100),
60
+ ui.card(
61
+ ui.card_header("Fills the card; try full screen"),
62
+ output_plotly("walk"),
63
+ full_screen=True,
64
+ ),
65
+ plotly_js(), # optional: load plotly.js with the page instead of with the first figure
66
+ )
67
+
68
+
69
+ def server(input, output, session):
70
+ @render_plotly
71
+ def walk():
72
+ rng = random.Random(input.n())
73
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
74
+ return go.Figure(go.Scatter(y=y, mode="lines"))
75
+
76
+
77
+ app = App(app_ui, server)
78
+ ```
79
+
80
+ Anything that is a `plotly.graph_objects.Figure` works, including what `plotly.express` builds (install `plotly[express]` for that).
81
+
82
+ ### Express
83
+
84
+ ```python
85
+ import random
86
+ from itertools import accumulate
87
+
88
+ import plotly.graph_objects as go
89
+ from shiny.express import input, ui
90
+
91
+ from shiny_plotly import render_plotly
92
+
93
+ ui.page_opts(fillable=True)
94
+
95
+ with ui.sidebar():
96
+ ui.input_slider("n", "Points", 10, 500, 100)
97
+
98
+ with ui.card(full_screen=True):
99
+
100
+ @render_plotly
101
+ def walk():
102
+ rng = random.Random(input.n())
103
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
104
+ return go.Figure(go.Scatter(y=y, mode="lines"))
105
+ ```
106
+
107
+ The decorator creates its own output placeholder in Express, just like `@render_widget` does.
108
+
109
+ ### Options
110
+
111
+ ```python
112
+ @render_plotly(
113
+ height="300px", # fixed height; default None fills the container
114
+ width="100%",
115
+ figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
116
+ config={"displaylogo": False},
117
+ post_script=CLICK_TO_INPUT, # JavaScript run after the plot is drawn
118
+ )
119
+ def sales(): ...
120
+ ```
121
+
122
+ `None` from the render function renders nothing. The function may be sync or async. It may also return `fig.to_dict()` instead of a `Figure`.
123
+
124
+ ### Migrating from shinywidgets
125
+
126
+ | shinywidgets | shiny-plotly |
127
+ | --- | --- |
128
+ | `from shinywidgets import output_widget, render_widget` | `from shiny_plotly import output_plotly, render_plotly` |
129
+ | `output_widget("id")` | `output_plotly("id")` |
130
+ | `output_widget("id", height="300px")` | `output_plotly("id", height="300px")` |
131
+ | `@render_widget` | `@render_plotly` |
132
+ | (FigureWidget margins, applied implicitly) | `@render_plotly(figurewidget_margins=True)` |
133
+
134
+ Two things change on purpose:
135
+
136
+ - **Margins.** shinywidgets sets tight margins (`l=16, t=32, r=16, b=16`) on every FigureWidget; plotly's own defaults are `80/100/80/80`. `shiny-plotly` uses plotly's defaults unless you pass `figurewidget_margins=True`, which fills in only the sides your figure leaves unset. Set margins explicitly on the figure if you want something else.
137
+ - **In-place mutation.** A `FigureWidget` you keep on the server and mutate (`fig.data[0].y = ...`, `fig.add_trace(...)` after render) is exactly what shinywidgets is for. `shiny-plotly` has no channel for that; return a new figure from the render function and let Shiny re-render. If your app depends on in-place widget updates, stay on shinywidgets for those outputs. Both packages can coexist in one app.
138
+
139
+ ### Sizing
140
+
141
+ The rules mirror `output_widget`:
142
+
143
+ - `height=None` (default): the plot fills its container. Inside `ui.card(full_screen=True)`, a fillable page or a sidebar layout it grows and shrinks with the card, from a 400px basis. Outside a fill layout it is 400px tall.
144
+ - `height="300px"` (on the decorator or on `output_plotly`): the plot is exactly that tall and opts out of filling.
145
+
146
+ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every figure) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once Shiny has replaced the output holding it, which releases the window listener and layout state plotly would otherwise keep for every render.
147
+
148
+ ### Events back to Shiny
149
+
150
+ `post_script` runs after `Plotly.newPlot` resolves; `{plot_id}` is replaced with the graph div's id.
151
+
152
+ ```python
153
+ CLICK_TO_INPUT = """
154
+ document.getElementById('{plot_id}').on('plotly_click', function (ev) {
155
+ var p = ev.points[0];
156
+ Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
157
+ });
158
+ """
159
+
160
+
161
+ @render_plotly(post_script=CLICK_TO_INPUT)
162
+ def scatter(): ...
163
+
164
+
165
+ @render.text
166
+ def click_info():
167
+ if not input.clicked.is_set():
168
+ return "Click a point."
169
+ pt = input.clicked()
170
+ return f"x={pt['x']}, y={pt['y']}"
171
+ ```
172
+
173
+ `input.clicked()` raises a silent exception while the input has never been set, so check `is_set()` first when the output should show something before the first click.
174
+
175
+ ### Lower level
176
+
177
+ - `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns the `TagList` a render produces: the plotly.js dependency, the helper dependency and the `<div class="shiny-plotly">` holding the figure. Use it from a plain `@render.ui` that composes several things, or from any htmltools context.
178
+ - `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every figure carries it, so it is optional; add it to the page UI to load the bundle up front instead of with the first figure.
179
+ - `shiny_plotly_js()` is the helper's dependency. Every figure carries it too.
180
+ - `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
181
+
182
+ ## Examples
183
+
184
+ ```sh
185
+ uv run --with shiny-plotly shiny run examples/core_app.py
186
+ uv run --with shiny-plotly shiny run examples/express_app.py
187
+ ```
188
+
189
+ ## Development
190
+
191
+ ```sh
192
+ make sync # uv sync --all-groups
193
+ make browsers # playwright install chromium, once
194
+ make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
195
+ ```
196
+
197
+ `make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, purge on re-render, full screen, `post_script` click wiring and on-demand loading of plotly.js. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested.
198
+
199
+ ## License
200
+
201
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,84 @@
1
+ """Shiny Core example: three plotly charts rendered without shinywidgets.
2
+
3
+ Run with: uv run --with shiny-plotly shiny run examples/core_app.py
4
+ """
5
+
6
+ import random
7
+ from itertools import accumulate
8
+
9
+ import plotly.graph_objects as go
10
+ from shiny import App, Inputs, Outputs, Session, reactive, render, ui
11
+
12
+ from shiny_plotly import output_plotly, plotly_js, render_plotly
13
+
14
+ # Forwards plotly click events to a Shiny input. {plot_id} is the graph div's id.
15
+ CLICK_TO_INPUT = """
16
+ document.getElementById('{plot_id}').on('plotly_click', function (ev) {
17
+ var p = ev.points[0];
18
+ Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
19
+ });
20
+ """
21
+
22
+ app_ui = ui.page_sidebar(
23
+ ui.sidebar(
24
+ ui.input_slider("n", "Points", min=10, max=500, value=100),
25
+ ui.input_select("kind", "Trace", {"scatter": "Scatter", "bar": "Bar"}),
26
+ ui.output_text("click_info"),
27
+ ),
28
+ ui.layout_columns(
29
+ ui.card(
30
+ ui.card_header("Fills the card; try full screen"),
31
+ output_plotly("main_plot"),
32
+ full_screen=True,
33
+ ),
34
+ ui.card(
35
+ ui.card_header("FigureWidget margins preset"),
36
+ output_plotly("compact_plot"),
37
+ full_screen=True,
38
+ ),
39
+ ),
40
+ ui.card(
41
+ ui.card_header("Fixed height, click a point"),
42
+ output_plotly("fixed_plot"),
43
+ ),
44
+ plotly_js(),
45
+ title="shiny-plotly",
46
+ fillable=True,
47
+ )
48
+
49
+
50
+ def server(input: Inputs, output: Outputs, session: Session):
51
+ @reactive.calc
52
+ def data():
53
+ rng = random.Random(input.n())
54
+ x = list(range(input.n()))
55
+ return x, list(accumulate(rng.gauss(0, 1) for _ in x))
56
+
57
+ @render_plotly
58
+ def main_plot():
59
+ x, y = data()
60
+ if input.kind() == "scatter":
61
+ trace = go.Scatter(x=x, y=y, mode="lines")
62
+ else:
63
+ trace = go.Bar(x=x, y=y)
64
+ return go.Figure(trace).update_layout(title=f"{input.n()} points")
65
+
66
+ @render_plotly(figurewidget_margins=True)
67
+ def compact_plot():
68
+ x, y = data()
69
+ return go.Figure(go.Scatter(x=x, y=[abs(v) for v in y], fill="tozeroy"))
70
+
71
+ @render_plotly(height="260px", post_script=CLICK_TO_INPUT, config={"displaylogo": False})
72
+ def fixed_plot():
73
+ x, y = data()
74
+ return go.Figure(go.Scatter(x=x, y=y, mode="markers"))
75
+
76
+ @render.text
77
+ def click_info():
78
+ if not input.clicked.is_set():
79
+ return "Click a point in the bottom chart."
80
+ pt = input.clicked()
81
+ return f"Clicked x={pt['x']}, y={pt['y']:.2f}"
82
+
83
+
84
+ app = App(app_ui, server)
@@ -0,0 +1,27 @@
1
+ """Shiny Express example: the decorator creates its own output placeholder.
2
+
3
+ Run with: uv run --with shiny-plotly shiny run examples/express_app.py
4
+ """
5
+
6
+ import random
7
+ from itertools import accumulate
8
+
9
+ import plotly.graph_objects as go
10
+ from shiny.express import input, ui
11
+
12
+ from shiny_plotly import plotly_js, render_plotly
13
+
14
+ ui.page_opts(title="shiny-plotly (Express)", fillable=True)
15
+ plotly_js()
16
+
17
+ with ui.sidebar():
18
+ ui.input_slider("n", "Points", min=10, max=500, value=100)
19
+
20
+ with ui.card(full_screen=True):
21
+ ui.card_header("Random walk")
22
+
23
+ @render_plotly
24
+ def walk():
25
+ rng = random.Random(input.n())
26
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
27
+ return go.Figure(go.Scatter(y=y, mode="lines"))
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shiny-plotly"
7
+ version = "0.1.0"
8
+ description = "Render plotly figures in Shiny for Python as plain HTML, without the shinywidgets layer."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Ruben J. Jongejan", email = "ruben.jongejan@gmail.com" },
14
+ ]
15
+ keywords = ["shiny", "plotly", "dashboard", "render", "html"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: Visualization",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "shiny>=1.0",
30
+ "plotly>=5.0",
31
+ "htmltools>=0.5",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/rvben/shiny-plotly"
36
+ Repository = "https://github.com/rvben/shiny-plotly"
37
+ Changelog = "https://github.com/rvben/shiny-plotly/blob/main/CHANGELOG.md"
38
+ Issues = "https://github.com/rvben/shiny-plotly/issues"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=8",
43
+ "pytest-asyncio>=0.24",
44
+ "ruff>=0.6",
45
+ "pyright>=1.1.380",
46
+ "httpx2>=2.12.0",
47
+ "pytest-playwright>=0.9.0",
48
+ ]
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/shiny_plotly"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ include = ["src/shiny_plotly", "tests", "examples", "README.md", "CHANGELOG.md", "LICENSE"]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ asyncio_mode = "auto"
59
+ asyncio_default_fixture_loop_scope = "function"
60
+ filterwarnings = ["error"]
61
+ markers = ["browser: drives the package in a real Chromium through playwright"]
62
+
63
+ [tool.ruff]
64
+ line-length = 100
65
+ target-version = "py310"
66
+
67
+ [tool.ruff.lint]
68
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
69
+
70
+ [tool.pyright]
71
+ include = ["src", "tests", "examples"]
72
+ pythonVersion = "3.10"
73
+ typeCheckingMode = "standard"
74
+
75
+ [tool.ruff.lint.isort]
76
+ known-local-folder = ["newplot"]