Skip to content

Commit c227002

Browse files
committed
Add experimental register_widget(); update readme and examples
1 parent d15e3f9 commit c227002

File tree

5 files changed

+331
-107
lines changed

5 files changed

+331
-107
lines changed

README.md

Lines changed: 235 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,255 @@
11
ipyshiny
22
================
33

4-
Render [ipywidgets](https://github.com/jupyter-widgets/ipywidgets) inside a [PyShiny](https://github.com/rstudio/prism) app
4+
Render [ipywidgets](https://ipywidgets.readthedocs.io/en/stable/) inside a
5+
[Shiny](https://pyshiny.netlify.app/) app.
56

67
## Installation
78

8-
First, [install Shiny](https://rstudio.github.io/pyshiny-site/install.html), then:
9-
109
```sh
11-
git clone https://github.com/rstudio/ipyshiny
12-
pip install -e ipyshiny
10+
pip install ipyshiny --extra-index-url=https://pyshiny.netlify.app/pypi
1311
```
1412

15-
## Usage
13+
## Overview
1614

17-
Coming soon. For now, see/run the `examples/`:
15+
Every Shiny app has two main parts: the user interface (UI) and server logic.
16+
`{ipyshiny}` provides `output_widget()` for defining where to place a widget in the UI
17+
and `register_widget()` (or `@render_widget()`) for supplying a widget-like object to
18+
the `output_widget()` container. More technically, widget-like means:
1819

19-
```sh
20-
shiny run examples/outputs/appy.py
20+
* Any object that subclasses `{ipywidgets}`'s `Widget` class.
21+
* Some other widget-like object that can be coerced into a `Widget`. Currently, we
22+
support objects from `{altair}`, `{bokeh}`, and `{pydeck}`, but [please let us
23+
know](https://github.com/rstudio/ipyshiny/issues/new) about other packages that we
24+
should support.
25+
26+
The recommended way to incorporate `{ipyshiny}` widgets into Shiny apps is to:
27+
28+
1. Initialize and `register_widget()` _once_ for each widget.
29+
* In most cases, initialization should happen when the user session starts (i.e.,
30+
the `server` function first executes), but if the widget is slow to initialize and
31+
doesn't need to be shown right away, you may want to delay that initialization
32+
until it's needed.
33+
2. Use Shiny's `@reactive.Effect()` to reactively modify the widget whenever relevant
34+
reactive values change.
35+
3. Use `{ipyshiny}`'s `reactive_read()` to update other outputs whenever the widget changes.
36+
* This way, relevant output(s) invalidate (i.e., recalculate) whenever the relevant
37+
widget attributes change (client-side or server-side).
38+
39+
The following app below uses `{ipyleaflet}` to demonstrate all these concepts:
40+
41+
```py
42+
from shiny import *
43+
from ipyshiny import output_widget, register_widget, reactive_read
44+
import ipyleaflet as L
45+
46+
app_ui = ui.page_fluid(
47+
ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10),
48+
output_widget("map"),
49+
ui.output_text("map_bounds"),
50+
)
51+
52+
def server(input, output, session):
53+
54+
# Initialize and display when the session starts (1)
55+
map = L.Map(center=(52, 360), zoom=4)
56+
register_widget("map", map)
57+
58+
# When the slider changes, update the map's zoom attribute (2)
59+
@reactive.Effect()
60+
def _():
61+
map.zoom = input.zoom()
62+
63+
# When zooming directly on the map, update the slider's value (2 and 3)
64+
@reactive.Effect()
65+
def _():
66+
ui.update_slider("zoom", value=reactive_read(map, "zoom"))
67+
68+
# Everytime the map's bounds change, update the output message (3)
69+
@output(name="map_bounds")
70+
@render_text()
71+
def _():
72+
b = reactive_read(map, "bounds")
73+
lat = [b[0][0], b[0][1]]
74+
lon = [b[1][0], b[1][1]]
75+
return f"The current latitude is {lat} and longitude is {lon}"
76+
77+
app = App(app_ui, server)
2178
```
2279

23-
## Development
80+
![<https://imgur.com/a/pUOEfwv.gifv>](https://imgur.com/a/pUOEfwv.gif)
2481

25-
If you want to do development, run:
2682

27-
```sh
28-
pip install -e .
29-
cd js && yarn watch
83+
The style of programming above (display and mutate) is great for efficiently performing
84+
partial updates to a widget. This is really useful when a widget needs to display lots
85+
of data and also quickly handle partial updates; for example, toggling the visibility of
86+
a fitted line on a scatterplot with lots of points:
87+
88+
```py
89+
from shiny import *
90+
from ipyshiny import output_widget, register_widget
91+
import plotly.graph_objs as go
92+
import numpy as np
93+
from sklearn.linear_model import LinearRegression
94+
95+
# Generate some data and fit a linear regression
96+
n = 10000
97+
d = np.random.RandomState(0).multivariate_normal([0, 0], [(1, 0.5), (0.5, 1)], n).T
98+
fit = LinearRegression().fit(d[0].reshape(-1, 1), d[1])
99+
xgrid = np.linspace(start=min(d[0]), stop=max(d[0]), num=30)
100+
101+
app_ui = ui.page_fluid(
102+
output_widget("scatterplot"),
103+
ui.input_checkbox("show_fit", "Show fitted line", value=True),
104+
)
105+
106+
def server(input, output, session):
107+
108+
scatterplot = go.FigureWidget(
109+
data=[
110+
go.Scattergl(
111+
x=d[0],
112+
y=d[1],
113+
mode="markers",
114+
marker=dict(color="rgba(0, 0, 0, 0.05)", size=5),
115+
),
116+
go.Scattergl(
117+
x=xgrid,
118+
y=fit.intercept_ + fit.coef_[0] * xgrid,
119+
mode="lines",
120+
line=dict(color="red", width=2),
121+
),
122+
]
123+
)
124+
125+
register_widget("scatterplot", scatterplot)
126+
127+
@reactive.Effect()
128+
def _():
129+
scatterplot.data[1].visible = input.show_fit()
130+
131+
app = App(app_ui, server)
30132
```
31133

32-
Additionally, you can install pre-commit hooks which will automatically reformat and lint the code when you make a commit:
134+
![<https://imgur.com/a/54ECn09.gifv>](https://imgur.com/a/54ECn09.gif)
33135

34-
```sh
35-
pre-commit install
136+
That being said, in a situation where:
137+
138+
* Performant updates aren't important
139+
* Other outputs don't depend on the widget's state
140+
* It's convenient to initialize a widget in a reactive context
141+
142+
Then it's ok to reach for `@render_widget()` (instead of `register_widget()`) which
143+
creates a reactive context (similar to Shiny's `@render_plot()`, `@render_text()`, etc.)
144+
where each time that context gets invalidated, the output gets redrawn from scratch. In
145+
a simple case like the one below, that redrawing may not be noticable, but if you we're
146+
to redraw the entire scatterplot above everytime the fitted line was toggled, there'd
147+
be noticeable delay.
148+
149+
```py
150+
from shiny import *
151+
from ipyshiny import output_widget, register_widget, reactive_read
152+
import ipyleaflet as L
153+
154+
app_ui = ui.page_fluid(
155+
ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10),
156+
output_widget("map")
157+
)
158+
159+
def server(input, output, session):
160+
@output(name="map")
161+
@render_widget()
162+
def _():
163+
return L.Map(center=(52, 360), zoom=input.zoom())
164+
165+
app = App(app_ui, server)
166+
```
167+
168+
## Frequently asked questions
36169

37-
# To disable:
38-
# pre-commit uninstall
170+
### How do I size the widget?
171+
172+
`{ipywidgets}`' `Widget` class has [it's own API for setting inline CSS
173+
styles](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Styling.html),
174+
including `height` and `width`. So, given a `Widget` instance `w`, you should be able to
175+
do something like:
176+
177+
```py
178+
w.layout.height = "600px"
179+
w.layout.width = "80%"
180+
```
181+
182+
### How do I hide/show a widget?
183+
184+
As mentioned above, a `Widget` class should have a `layout` attribute, which can be
185+
used to set all sorts of CSS styles, including display and visibility. So, if you wanted
186+
to hide a widget and still have space allocated for it:
187+
188+
```py
189+
w.layout.visibility = "hidden"
190+
```
191+
192+
Or, to not give it any space:
193+
194+
```py
195+
w.layout.display = "none"
196+
```
197+
198+
### Can I render widgets that contain other widgets?
199+
200+
Yes! In fact this a crucial aspect to how packages like `{ipyleaflet}` work. In
201+
`{ipyleaflet}`'s case, each [individual marker is a widget](https://ipyleaflet.readthedocs.io/en/latest/layers/circle_marker.html) which gets attached to a `Map()` via `.add_layer()`.
202+
203+
### Does `{ipyshiny}` work with `shiny static`?
204+
205+
Shiny's `shiny static` CLI command allows some Shiny apps to be statically served (i.e.,
206+
run entirely in the browser). [py-shinylive](https://github.com/rstudio/py-shinylive)
207+
(the Python package behind `shiny static`) does have some special support for
208+
`{ipyshiny}` and it's dependencies, which should make most widgets work out-of-the-box.
209+
210+
In some cases, the package(s) that you want to use may not come pre-bundled with
211+
`{ipyshiny}`; and in that case, you can [include a `requirements.txt`
212+
file](https://pyshiny.netlify.app/examples/#extra-packages) to pre-install those other
213+
packages
214+
215+
## Troubleshooting {#troubleshooting}
216+
217+
If after [installing](#installation) `{ipyshiny}`, you have trouble rendering widgets,
218+
first try running the "hello world" ipywidgets [example](https://github.com/rstudio/ipyshiny/blob/main/examples/ipywidgets/app.py). If that doesn't work, it could be that you have an unsupported version
219+
of a dependency like `{ipywidgets}` or `{shiny}`.
220+
221+
If you can run the "hello world" example, but "3rd party" widget(s) don't work, first
222+
check that the extension is properly configured with `jupyter nbextension list`. Even if
223+
the extension is properly configured, it still may not work right away, especially if
224+
that widget requires initialization code in a notebook environment. In this case,
225+
`{ipyshiny}` probably won't work without providing the equivalent setup information to
226+
Shiny. Some known cases of this are:
227+
228+
#### bokeh
229+
230+
To use `{bokeh}` in notebook, you have to run `bokeh.io.output_notebook()`. The
231+
equivalent thing in Shiny is to include the following in the UI definition:
232+
233+
```py
234+
from bokeh.resources import Resources
235+
head_content(HTML(Resources(mode="inline").render()))
236+
```
237+
238+
#### itables
239+
240+
TODO: Provide initialization code
241+
242+
#### Other widgets
243+
244+
Know of another widget that requires initialization code? [Please let us know about
245+
it](https://github.com/rstudio/ipyshiny/issues/new)!
246+
247+
248+
## Development
249+
250+
If you want to do development on `{ipyshiny}`, run:
251+
252+
```sh
253+
pip install -e .
254+
cd js && yarn watch
39255
```

examples/ipyleaflet/app.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from shiny import *
2+
from ipyshiny import output_widget, register_widget, reactive_read
3+
import ipyleaflet as L
4+
from htmltools import css
5+
6+
app_ui = ui.page_fluid(
7+
ui.div(
8+
ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10),
9+
ui.output_text("map_bounds"),
10+
style=css(display="flex", justify_content="center", align_items="center", gap="2rem"),
11+
),
12+
output_widget("map")
13+
)
14+
15+
def server(input, output, session):
16+
17+
# Initialize and display when the session starts (1)
18+
map = L.Map(center=(52, 360), zoom=4)
19+
register_widget("map", map)
20+
21+
# When the slider changes, update the map's zoom attribute (2)
22+
@reactive.Effect()
23+
def _():
24+
map.zoom = input.zoom()
25+
26+
# When zooming directly on the map, update the slider's value (2 and 3)
27+
@reactive.Effect()
28+
def _():
29+
ui.update_slider("zoom", value=reactive_read(map, "zoom"))
30+
31+
# Everytime the map's bounds change, update the output message (3)
32+
@output(name="map_bounds")
33+
@render_text()
34+
def _():
35+
b = reactive_read(map, "bounds")
36+
lat = [b[0][0], b[0][1]]
37+
lon = [b[1][0], b[1][1]]
38+
return f"The current latitude is {lat} and longitude is {lon}"
39+
40+
app = App(app_ui, server)

examples/ipywidgets/app.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,11 @@ def server(input: Inputs, output: Outputs, session: Session):
2121
readout_format="d",
2222
)
2323

24-
# This should print on every client-side change to the slider
25-
s.observe(lambda change: print(change.new), "value")
24+
register_widget("slider", s)
2625

27-
@output()
28-
@render_widget()
29-
def slider():
30-
return s
26+
@reactive.Effect()
27+
def _():
28+
return f"The value of the slider is: {reactive_read(s, 'value')}"
3129

3230

3331
app = App(app_ui, server)

0 commit comments

Comments
 (0)