Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: posit-dev/py-shinywidgets
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.3.2
Choose a base ref
...
head repository: posit-dev/py-shinywidgets
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref

Commits on Apr 16, 2024

  1. Start new version

    cpsievert committed Apr 16, 2024
    Copy the full SHA
    d4e0f7c View commit details

Commits on Jun 12, 2024

  1. Fix deprecated matplotlib call in superzip

    jcheng5 committed Jun 12, 2024
    Copy the full SHA
    47e5e85 View commit details

Commits on Jun 25, 2024

  1. fix(#148): remove outdated dependency on importlib-metadata (#149)

    daylinmorgan authored Jun 25, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    d86f74b View commit details

Commits on Jul 31, 2024

  1. Ensure binary data is a DataView (#152)

    Co-authored-by: Carson <cpsievert1@gmail.com>
    manzt and cpsievert authored Jul 31, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    d6684ea View commit details
  2. Throw a more informative error when pydeck's .show() method doesn't…

    … return a `Widget` (#154)
    cpsievert authored Jul 31, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    af3f310 View commit details
  3. Filling support for quak; add quak/mosaic to examples (#155)

    cpsievert authored Jul 31, 2024
    3

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    556236f View commit details

Commits on Aug 13, 2024

  1. v0.3.3 release

    cpsievert committed Aug 13, 2024
    Copy the full SHA
    1c97ac3 View commit details

Commits on Sep 17, 2024

  1. Close #159. Combine libembed and output scripts into single dependenc…

    …y to avoid Quarto dependency ordering bug
    cpsievert committed Sep 17, 2024
    Copy the full SHA
    5c01af7 View commit details

Commits on Sep 18, 2024

  1. Fix pip install commands

    cpsievert committed Sep 18, 2024
    Copy the full SHA
    cde915c View commit details

Commits on Oct 1, 2024

  1. Close #163. Don't run widget code if the shiny session is a stub session

    cpsievert committed Oct 1, 2024
    Copy the full SHA
    37c7178 View commit details

Commits on Oct 2, 2024

  1. Adds Deploy to Connect Cloud buttons to READMEs (#162)

    garrettgman authored Oct 2, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    3fd46bf View commit details

Commits on Oct 4, 2024

  1. Drop python 3.8, add python 3.12

    cpsievert committed Oct 4, 2024
    Copy the full SHA
    dabfac8 View commit details

Commits on Oct 29, 2024

  1. v0.3.4 release candidate

    cpsievert committed Oct 29, 2024
    Copy the full SHA
    988df1e View commit details
  2. Merge branch 'rc-v0.3.4'

    cpsievert committed Oct 29, 2024
    Copy the full SHA
    a881079 View commit details
  3. Start new version

    cpsievert committed Oct 29, 2024
    Copy the full SHA
    d625c3f View commit details

Commits on Nov 19, 2024

  1. Cleanup orphaned widget models (#167)

    cpsievert authored Nov 19, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    72d16f5 View commit details
  2. Various typing fixes/improvements; setup pyright to run on CI (#168)

    cpsievert authored Nov 19, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    fb077be View commit details

Commits on Nov 20, 2024

  1. Bump version

    cpsievert committed Nov 20, 2024
    Copy the full SHA
    d4bb85d View commit details

Commits on Nov 27, 2024

  1. Do additional cleanup to make up for plotly's lack of proper cleanup (#…

    cpsievert authored Nov 27, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    7dd569a View commit details

Commits on Dec 16, 2024

  1. v0.4.0 release

    cpsievert committed Dec 16, 2024
    Copy the full SHA
    a85f857 View commit details
  2. Start new version

    cpsievert committed Dec 16, 2024
    Copy the full SHA
    c384631 View commit details

Commits on Dec 17, 2024

  1. Only import TypeGuard when type checking

    cpsievert committed Dec 17, 2024
    Copy the full SHA
    d621210 View commit details
  2. v0.4.1

    cpsievert committed Dec 17, 2024
    Copy the full SHA
    b1d0bfe View commit details

Commits on Dec 18, 2024

  1. Ensure a session context is present when widgets are closed (#171)

    cpsievert authored Dec 18, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    63e1ea1 View commit details
  2. v0.4.2

    cpsievert committed Dec 18, 2024
    Copy the full SHA
    84b955a View commit details

Commits on Jan 21, 2025

  1. Update README.md (#175)

    randyzwitch authored Jan 21, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    62489d8 View commit details

Commits on Jan 23, 2025

  1. Remove widget container after plotly widget view gets destroyed (#178)

    cpsievert authored Jan 23, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    a315cc7 View commit details
  2. Allow for the model_id property to still be accessed after the widg…

    …et is closed (#179)
    cpsievert authored Jan 23, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    8925380 View commit details
  3. Trigger resize when a Bootstrap tab is shown (#180)

    cpsievert authored Jan 23, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    7c29101 View commit details

Commits on Jan 29, 2025

  1. Add some commentary about needing to identify an output context

    cpsievert committed Jan 29, 2025
    Copy the full SHA
    61215a5 View commit details
  2. Changes to accomodate plotly v6 (#182)

    cpsievert authored Jan 29, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    5ee8c97 View commit details
  3. Add anywidget as a hard dependency (#183)

    cpsievert authored Jan 29, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    f52d0f1 View commit details
  4. v0.5.0 release

    cpsievert committed Jan 29, 2025
    Copy the full SHA
    c55b555 View commit details
  5. Start new version

    cpsievert committed Jan 29, 2025
    Copy the full SHA
    92088db View commit details

Commits on Jan 30, 2025

  1. Close #184: only call _repr_mimebundle_ if it exists and is callable

    cpsievert committed Jan 30, 2025
    Copy the full SHA
    770dc94 View commit details
18 changes: 10 additions & 8 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11", "3.12"]
fail-fast: false

steps:
@@ -30,11 +30,11 @@ jobs:
- name: Install dev version of htmltools
run: |
pip install https://github.com/rstudio/py-htmltools/tarball/main
pip install git+https://github.com/posit-dev/py-htmltools
- name: Install dev version of shiny
run: |
pip install https://github.com/rstudio/py-shiny/tarball/main
pip install git+https://github.com/posit-dev/py-shiny
- name: Install dependencies
run: |
@@ -43,12 +43,14 @@ jobs:
- name: Install
run: |
make install
- name: pyright
run: |
make pyright
#- name: Run unit tests
# run: |
# make test
# - name: pyright, flake8, black and isort
# run: |
# make check

deploy:
name: "Deploy to PyPI"
@@ -57,10 +59,10 @@ jobs:
needs: [build]
steps:
- uses: actions/checkout@v3
- name: "Set up Python 3.8"
- name: "Set up Python 3.12"
uses: actions/setup-python@v4
with:
python-version: "3.8"
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -111,3 +111,7 @@ typings/
Untitled*.ipynb

rsconnect-python/

# Deploy to Connect Cloud Button
examples/*/README_FILES/*
examples/*/README.html
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -5,6 +5,39 @@ All notable changes to shinywidgets will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [UNRELEASED]

* Fixes 'AttributeError: object has no attribute "_repr_mimebundle_"'. (#184)

## [0.5.0] - 2025-01-29

* Updates to accomodate the new plotly v6.0.0 release. (#182)
* Fixed an issue with plotly graphs sometimes not getting fully removed from the DOM. (#178)
* Added `anywidget` as a package dependency since it's needed now for `altair` and `plotly` (and installing this packages won't necessarily install `anywidget`). (#183)
* Fixed an issue with ipyleaflet erroring out when attempting to read the `.model_id` property of a closed widget object. (#179)
* Fixed an issue where altair charts would sometimes render to a 0 height after being shown, hidden, and then shown again. (#180)

## [0.4.2] - 2024-12-18

* Fixed an issue where `@render_widget` would sometimes incorrectly render a new widget without removing the old one. (#167)

## [0.4.1] - 2024-12-17

* Fixed a Python 3.9 compatibility issue.

## [0.4.0] - 2024-12-16

* Fixed a memory leak issue. (#167)

## [0.3.4] - 2024-10-29

* Fixed an issue where widgets would sometimes fail to render in a Quarto document. (#159)
* Fixed an issue where importing shinywidgets before a ipywidget implementation can sometimes error in a Shiny Express app. (#163)

## [0.3.3] - 2024-08-13

* Fixed a bug with receiving binary data on the frontend, which gets [quak](https://github.com/manzt/quak) and [mosaic-widget](https://idl.uw.edu/mosaic/jupyter/) working with `@render_widget`. (#152)

## [0.3.2] - 2024-04-16

* Fixed a bug with multiple altair outputs not working inside a `@shiny.render.ui` decorator. (#140)
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -90,7 +90,6 @@ install: dist ## install the package to the active Python's site-packages
python3 -m pip install dist/shinywidgets*.whl

pyright: ## type check with pyright
pyright --pythonversion=3.7
pyright --pythonversion=3.11

check: pyright lint ## check code quality with pyright, flake8, black and isort
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ shinywidgets
Render [ipywidgets](https://ipywidgets.readthedocs.io/en/stable/) inside a
[Shiny](https://shiny.rstudio.com/py) (for Python) app.

See the [Jupyter Widgets](https://shiny.rstudio.com/py/docs/ipywidgets.html) article on the Shiny for Python website for more details.
See the [Jupyter Widgets](https://shiny.posit.co/py/docs/jupyter-widgets.html) article on the Shiny for Python website for more details.

## Installation

3 changes: 3 additions & 0 deletions examples/outputs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Outputs app

<a href='https://connect.posit.cloud/publish?framework=shiny&sourceRepositoryURL=https%3A%2F%2Fgithub.com%2Fposit-dev%2Fpy-shinywidgets&sourceRef=main&sourceRefType=branch&primaryFile=examples%2Foutputs%2Fapp.py&pythonVersion=3.11'><img src='https://cdn.connect.posit.cloud/assets/deploy-to-connect-blue.svg' align="right" /></a>
84 changes: 65 additions & 19 deletions examples/outputs/app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from pathlib import Path

import numpy as np
from shiny import *

from shinywidgets import *

app_dir = Path(__file__).parent

app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_radio_buttons(
@@ -13,6 +17,8 @@
"plotly",
"ipyleaflet",
"pydeck",
"quak",
"mosaic",
"ipysigma",
"bokeh",
"bqplot",
@@ -48,22 +54,26 @@ def _():

source = data.stocks()

return alt.Chart(source).transform_filter(
'datum.symbol==="GOOG"'
).mark_area(
tooltip=True,
line={'color': '#0281CD'},
color=alt.Gradient(
gradient='linear',
stops=[alt.GradientStop(color='white', offset=0),
alt.GradientStop(color='#0281CD', offset=1)],
x1=1, x2=1, y1=1, y2=0
return (
alt.Chart(source)
.transform_filter('datum.symbol==="GOOG"')
.mark_area(
tooltip=True,
line={"color": "#0281CD"},
color=alt.Gradient(
gradient="linear",
stops=[
alt.GradientStop(color="white", offset=0),
alt.GradientStop(color="#0281CD", offset=1),
],
x1=1,
x2=1,
y1=1,
y2=0,
),
)
).encode(
alt.X('date:T'),
alt.Y('price:Q')
).properties(
title={"text": ["Google's stock price over time"]}
.encode(alt.X("date:T"), alt.Y("price:Q"))
.properties(title={"text": ["Google's stock price over time"]})
)

@output(id="plotly")
@@ -73,8 +83,10 @@ def _():

return px.density_heatmap(
px.data.tips(),
x="total_bill", y="tip",
marginal_x="histogram", marginal_y="histogram"
x="total_bill",
y="tip",
marginal_x="histogram",
marginal_y="histogram",
)

@output(id="ipyleaflet")
@@ -119,14 +131,48 @@ def _():
# Combined all of it and render a viewport
return pdk.Deck(layers=[layer], initial_view_state=view_state)

@output(id="quak")
@render_widget
def _():
import polars as pl
import quak

df = pl.read_parquet(
"https://github.com/uwdata/mosaic/raw/main/data/athletes.parquet"
)
return quak.Widget(df)

@output(id="mosaic")
@render_widget
def _():
import polars as pl
import yaml
from mosaic_widget import MosaicWidget

flights = pl.read_parquet(
"https://github.com/uwdata/mosaic/raw/main/data/flights-200k.parquet"
)

# Load weather spec, remove data key to ensure load from Pandas
with open(app_dir / "flights.yaml") as f:
spec = yaml.safe_load(f)
_ = spec.pop("data")

return MosaicWidget(spec, data={"flights": flights})

@output(id="ipysigma")
@render_widget
def _():
import igraph as ig
from ipysigma import Sigma
g = ig.Graph.Famous('Zachary')
return Sigma(g, node_size=g.degree, node_color=g.betweenness(), node_color_gradient='Viridis')

g = ig.Graph.Famous("Zachary")
return Sigma(
g,
node_size=g.degree,
node_color=g.betweenness(),
node_color_gradient="Viridis",
)

@output(id="bokeh")
@render_widget
51 changes: 51 additions & 0 deletions examples/outputs/flights.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
meta:
title: Cross-Filter Flights (200k)
description: >
Histograms showing arrival delay, departure time, and distance flown for over 200,000 flights.
Select a histogram region to cross-filter the charts.
Each plot uses an `intervalX` interactor to populate a shared Selection
with `crossfilter` resolution.
data:
flights: { file: data/flights-200k.parquet }
params:
brush: { select: crossfilter }
vconcat:
- plot:
- mark: rectY
data: { from: flights, filterBy: $brush }
x: { bin: delay }
y: { count: }
fill: steelblue
inset: 0.5
- select: intervalX
as: $brush
xDomain: Fixed
yTickFormat: s
width: 1200
height: 250
- plot:
- mark: rectY
data: { from: flights, filterBy: $brush }
x: { bin: time }
y: { count: }
fill: steelblue
inset: 0.5
- select: intervalX
as: $brush
xDomain: Fixed
yTickFormat: s
width: 1200
height: 250
- plot:
- mark: rectY
data: { from: flights, filterBy: $brush }
x: { bin: distance }
y: { count: }
fill: steelblue
inset: 0.5
- select: intervalX
as: $brush
xDomain: Fixed
yTickFormat: s
width: 1200
height: 250
6 changes: 4 additions & 2 deletions examples/outputs/requirements.txt
Original file line number Diff line number Diff line change
@@ -3,15 +3,17 @@ shinywidgets
ipywidgets
numpy
pandas
qgrid
vega_datasets
bokeh
jupyter_bokeh
ipyleaflet
pydeck
pydeck==0.8.0
altair
plotly
bqplot
ipychart
ipywebrtc
vega
quak
mosaic-widget
polars
4 changes: 4 additions & 0 deletions examples/plotly/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## Plotly app

<a href='https://connect.posit.cloud/publish?framework=shiny&sourceRepositoryURL=https%3A%2F%2Fgithub.com%2Fposit-dev%2Fpy-shinywidgets&sourceRef=main&sourceRefType=branch&primaryFile=examples%2Fplotly%2Fapp.py&pythonVersion=3.11'><img src='https://cdn.connect.posit.cloud/assets/deploy-to-connect-blue.svg' align="right" /></a>

1 change: 1 addition & 0 deletions examples/plotly/requirements.txt
Original file line number Diff line number Diff line change
@@ -4,3 +4,4 @@ ipywidgets
numpy
pandas
plotly
scikit-learn
6 changes: 5 additions & 1 deletion examples/superzip/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Data

The `superzip.csv` is the result of [this script](https://github.com/rstudio/shinycoreci-apps/blob/main/apps/063-superzip-example/global.R)
<a href='https://connect.posit.cloud/publish?framework=shiny&sourceRepositoryURL=https%3A%2F%2Fgithub.com%2Fposit-dev%2Fpy-shinywidgets&sourceRef=main&sourceRefType=branch&primaryFile=examples%2Fsuperzip%2Fapp.py&pythonVersion=3.11'><img src='https://cdn.connect.posit.cloud/assets/deploy-to-connect-blue.svg' align="right" /></a>



The `superzip.csv` is the result of [this script](https://github.com/rstudio/shinycoreci-apps/blob/main/apps/063-superzip-example/global.R)
4 changes: 2 additions & 2 deletions examples/superzip/utils.py
Original file line number Diff line number Diff line change
@@ -2,13 +2,13 @@

import ipyleaflet as leaf
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import plotly.figure_factory as ff
import plotly.graph_objs as go
import shiny
from ipyleaflet import basemaps
from matplotlib import cm


def create_map(**kwargs):
@@ -91,7 +91,7 @@ def density_plot(
return go.FigureWidget(data=fig.data, layout=fig.layout)


color_palette = cm.get_cmap("viridis", 10)
color_palette = plt.get_cmap("viridis", 10)

# TODO: how to handle nas (pd.isna)?
def col_numeric(domain: Tuple[float, float], na_color: str = "#808080"):
1 change: 1 addition & 0 deletions js/src/comm.ts
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@ export class ShinyComm {
const msg = {
content: {comm_id: this.comm_id, data: data},
metadata: metadata,
// TODO: need to _encode_ any buffers into base64 (JSON.stringify just drops them)
buffers: buffers || [],
// this doesn't seem relevant to the widget?
header: {}
147 changes: 117 additions & 30 deletions js/src/output.ts
Original file line number Diff line number Diff line change
@@ -27,17 +27,20 @@ class OutputManager extends HTMLManager {
// Define our own custom module loader for Shiny
const shinyRequireLoader = async function(moduleName: string, moduleVersion: string): Promise<any> {

// shiny provides require.js and also sets `define.amd=false` to prevent <script>s
// with UMD loaders from triggering anonymous define() errors. shinywidgets should
// generally be able to avoid anonymous define errors though since there should only
// be one 'main' anonymous define() for the widget's module (located in a JS file that
// we've already require.config({paths: {...}})ed; and in that case, requirejs adds a
// data-requiremodule attribute to the <script> tag that shiny's custom define will
// recognize and use as the name).)
// shiny provides a shim of require.js which allows <script>s with anonymous
// define()s to be loaded without error. When an anonymous define() occurs,
// the shim uses the data-requiremodule attribute (set by require.js) on the script
// to determine the module name.
// https://github.com/posit-dev/py-shiny/blob/230940c/scripts/define-shims.js#L10-L16
// In the context of shinywidgets, when a widget gets rendered, it should
// come with another <script> tag that does `require.config({paths: {...}})`
// which maps the module name to a URL of the widget's JS file.
const oldAmd = (window as any).define.amd;

// The is the original value for define.amd that require.js sets
(window as any).define.amd = {jQuery: true};
// This is probably not necessary, but just in case -- especially now in a
// anywidget/ES6 world, we probably don't want to load AMD modules
// (plotly is one example of a widget that will fail to load if AMD is enabled)
(window as any).define.amd = false;

// Store jQuery global since loading we load a module, it may overwrite it
// (qgrid is one good example)
@@ -100,34 +103,43 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
const view = await manager.create_view(model, {});
await manager.display_view(view, {el: el});

// Don't allow more than one .lmWidget container, which can happen
// when the view is displayed more than once
// TODO: It's probably better to get view(s) from m.views and .remove() them
while (el.childNodes.length > 1) {
el.removeChild(el.childNodes[0]);
}

// The ipywidgets container (.lmWidget)
const lmWidget = el.children[0] as HTMLElement;

this._maybeResize(lmWidget);
if (fill) {
this._onImplementation(lmWidget, () => this._doAddFillClasses(lmWidget));
}
this._onImplementation(lmWidget, this._doResize);
}
_maybeResize(lmWidget: HTMLElement): void {
_onImplementation(lmWidget: HTMLElement, callback: () => void): void {
if (this._hasImplementation(lmWidget)) {
return this._doResize();
callback();
return;
}

// Some widget implementation (e.g., ipyleaflet, pydeck) won't actually
// have rendered to the DOM at this point, so wait until they do
const mo = new MutationObserver((mutations) => {
if (this._hasImplementation(lmWidget)) {
mo.disconnect();
this._doResize();
callback();
}
});

mo.observe(lmWidget, {childList: true});
}
// In most cases, we can get widgets to fill through Python/CSS, but some widgets
// (e.g., quak) don't have a Python API and use shadow DOM, which can only access
// from JS
_doAddFillClasses(lmWidget: HTMLElement): void {
const impl = lmWidget.children[0];
const isQuakWidget = impl && !!impl.shadowRoot?.querySelector(".quak");
if (isQuakWidget) {
impl.classList.add("html-fill-container", "html-fill-item");
const quakWidget = impl.shadowRoot.querySelector(".quak") as HTMLElement;
quakWidget.style.maxHeight = "unset";
}
}
_doResize(): void {
// Trigger resize event to force layout (setTimeout() is needed for altair)
// TODO: debounce this call?
@@ -137,7 +149,7 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
}
_hasImplementation(lmWidget: HTMLElement): boolean {
const impl = lmWidget.children[0];
return impl && impl.children.length > 0;
return impl && (impl.children.length > 0 || impl.shadowRoot?.children.length > 0);
}
}

@@ -171,23 +183,89 @@ Shiny.addCustomMessageHandler("shinywidgets_comm_open", (msg_txt) => {

// Handle any mutation of the model (e.g., add a marker to a map, without a full redraw)
// Basically out version of https://github.com/jupyterlab/jupyterlab/blob/d33de15/packages/services/src/kernel/default.ts#L1200-L1215
Shiny.addCustomMessageHandler("shinywidgets_comm_msg", (msg_txt) => {
Shiny.addCustomMessageHandler("shinywidgets_comm_msg", async (msg_txt) => {
const msg = jsonParse(msg_txt);
manager.get_model(msg.content.comm_id).then(m => {
const id = msg.content.comm_id;
const model = manager.get_model(id);
if (!model) {
console.error(`Couldn't handle message for model ${id} because it doesn't exist.`);
return;
}
try {
const m = await model;
// @ts-ignore for some reason IClassicComm doesn't have this method, but we do
m.comm.handle_msg(msg);
});
} catch (err) {
console.error("Error handling message:", err);
}
});

// TODO: test that this actually works
Shiny.addCustomMessageHandler("shinywidgets_comm_close", (msg_txt) => {

// Handle the closing of a widget/comm/model
Shiny.addCustomMessageHandler("shinywidgets_comm_close", async (msg_txt) => {
const msg = jsonParse(msg_txt);
manager.get_model(msg.content.comm_id).then(m => {
// @ts-ignore for some reason IClassicComm doesn't have this method, but we do
m.comm.handle_close(msg)
});
const id = msg.content.comm_id;
const model = manager.get_model(id);
if (!model) {
console.error(`Couldn't close model ${id} because it doesn't exist.`);
return;
}

try {
const m = await model;

// Before .close()ing the model (which will .remove() each view), do some
// additional cleanup that .remove() might miss
await Promise.all(
Object.values(m.views).map(async (viewPromise) => {
try {
const v = await viewPromise;

// Old versions of plotly need a .destroy() to properly clean up
// https://github.com/plotly/plotly.py/pull/3805/files#diff-259c92d
if (hasMethod<DestroyMethod>(v, 'destroy')) {
v.destroy();
// Also, empirically, when this destroy() is relevant, it also helps to
// delete the view's reference to the model, I think this is the only
// way to drop the resize event listener (see the diff in the link above)
// https://github.com/posit-dev/py-shinywidgets/issues/166
delete v.model;
// Ensure sure the lm-Widget container is also removed
v.remove();
}


} catch (err) {
console.error("Error cleaning up view:", err);
}
})
);

// Close model after all views are cleaned up
await m.close();

// Trigger comm:close event to remove manager's reference
m.trigger("comm:close");
} catch (err) {
console.error("Error during model cleanup:", err);
}
});

$(document).on("shiny:disconnected", () => {
manager.clear_state();
});

// When in filling layout, some widgets (specifically, altair) incorrectly think their
// height is 0 after it's shown, hidden, then shown again. As a workaround, trigger a
// resize event when a tab is shown.
// TODO: This covers the 95% use case, but it's definitely not an ideal way to handle
// this situation. A more robust solution would use IntersectionObserver to detect when
// the widget becomes visible. Or better yet, we'd get altair to handle this situation
// better.
// https://github.com/posit-dev/py-shinywidgets/issues/172
document.addEventListener('shown.bs.tab', event => {
window.dispatchEvent(new Event('resize'));
})

// Our version of https://github.com/jupyter-widgets/widget-cookiecutter/blob/9694718/%7B%7Bcookiecutter.github_project_name%7D%7D/js/lib/extension.js#L8
function setBaseURL(x: string = '') {
@@ -196,3 +274,12 @@ function setBaseURL(x: string = '') {
document.querySelector('body').setAttribute('data-base-url', x);
}
}

// TypeGuard to safely check if an object has a method
function hasMethod<T>(obj: any, methodName: keyof T): obj is T {
return typeof obj[methodName] === 'function';
}

interface DestroyMethod {
destroy(): void;
}
2 changes: 1 addition & 1 deletion js/src/utils.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { decode } from 'base64-arraybuffer';
// along to the comm logic
function jsonParse(x: string) {
const msg = JSON.parse(x);
msg.buffers = msg.buffers.map((b: any) => decode(b));
msg.buffers = msg.buffers.map((base64: string) => new DataView(decode(base64)));
return msg;
}

4 changes: 3 additions & 1 deletion pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"ignore": ["examples", "sandbox", "build", "dist", "typings"],
"typeCheckingMode": "strict",
"typeCheckingMode": "basic",
"reportPrivateUsage": "none",
"reportUnknownMemberType": "none",
"reportMissingTypeStubs": "none"
}
12 changes: 8 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -15,17 +15,17 @@ classifiers =
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Natural Language :: English
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
project_urls =
Bug Tracker = https://github.com/rstudio/py-shinywidgets/issues
Documentation = https://github.com/rstudio/py-shinywidgets/
Source Code = https://github.com/rstudio/py-shinywidgets/

[options]
python_requires = >=3.8
python_requires = >=3.9
packages = find:
test_suite = tests
include_package_data = True
@@ -36,8 +36,7 @@ install_requires =
jupyter_core
shiny>=0.6.1.9005
python-dateutil>=2.8.2
# Needed because of https://github.com/python/importlib_metadata/issues/411
importlib-metadata>=4.8.3,<5; python_version < "3.8"
anywidget
tests_require =
pytest>=3
zip_safe = False
@@ -52,6 +51,11 @@ dev =
isort>=5.11.2
pyright>=1.1.284
wheel
altair
bokeh
jupyter_bokeh
plotly
pydeck

[options.packages.find]
include = shinywidgets, shinywidgets.*
2 changes: 1 addition & 1 deletion shinywidgets/__init__.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

__author__ = """Carson Sievert"""
__email__ = "carson@posit.co"
__version__ = "0.3.2"
__version__ = "0.5.0.9000"

from ._as_widget import as_widget
from ._dependencies import bokeh_dependency
32 changes: 22 additions & 10 deletions shinywidgets/_as_widget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional

from ipywidgets.widgets.widget import Widget # pyright: ignore[reportMissingTypeStubs]
from ipywidgets.widgets.widget import Widget

from ._dependencies import widget_pkg

@@ -35,7 +35,7 @@ def as_widget(x: object) -> Widget:

def as_widget_altair(x: object) -> Optional[Widget]:
try:
from altair import JupyterChart # pyright: ignore[reportMissingTypeStubs]
from altair import JupyterChart
except ImportError:
raise RuntimeError(
"Failed to import altair.JupyterChart (do you need to pip install -U altair?)"
@@ -46,7 +46,7 @@ def as_widget_altair(x: object) -> Optional[Widget]:

def as_widget_bokeh(x: object) -> Optional[Widget]:
try:
from jupyter_bokeh import BokehModel # pyright: ignore[reportMissingTypeStubs]
from jupyter_bokeh import BokehModel
except ImportError:
raise ImportError(
"Install the jupyter_bokeh package to use bokeh with shinywidgets."
@@ -55,19 +55,19 @@ def as_widget_bokeh(x: object) -> Optional[Widget]:
# TODO: ideally we'd do this in set_layout_defaults() but doing
# `BokehModel(x)._model.sizing_mode = "stretch_both"`
# there, but that doesn't seem to work??
from bokeh.plotting import figure # pyright: ignore[reportMissingTypeStubs]
from bokeh.plotting import figure

if isinstance(x, figure): # type: ignore
x.sizing_mode = "stretch_both" # pyright: ignore[reportGeneralTypeIssues]
if isinstance(x, figure):
x.sizing_mode = "stretch_both" # type: ignore

return BokehModel(x) # type: ignore


def as_widget_plotly(x: object) -> Optional[Widget]:
# Don't need a try import here since this won't be called unless x is a plotly object
import plotly.graph_objects as go # pyright: ignore[reportMissingTypeStubs]
import plotly.graph_objects as go

if not isinstance(x, go.Figure): # type: ignore
if not isinstance(x, go.Figure):
raise TypeError(
f"Don't know how to coerce {x} into a plotly.graph_objects.FigureWidget object."
)
@@ -78,10 +78,22 @@ def as_widget_plotly(x: object) -> Optional[Widget]:
def as_widget_pydeck(x: object) -> Optional[Widget]:
if not hasattr(x, "show"):
raise TypeError(
f"Don't know how to coerce {x} (a pydeck object) into an ipywidget without a .show() method."
f"Don't know how to coerce {type(x)} (a pydeck object) into an ipywidget without a .show() method."
)

return x.show() # type: ignore
res = x.show() # type: ignore

if not isinstance(res, Widget):
raise TypeError(
"pydeck v0.9 removed ipywidgets support, thus it no longer works with "
"shinywidgets. Consider either downgrading to pydeck v0.8.0 or using shiny's "
"@render.ui decorator to display the map (and return Deck.to_html() in "
"that render function). Note that the latter strategy means you won't be "
"able to programmatically .update() the map or access user events."
"For more, see https://github.com/visgl/deck.gl/pull/8854"
)

return res


AS_WIDGET_MAP = {
48 changes: 42 additions & 6 deletions shinywidgets/_comm.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional

from shiny._utils import run_coro_hybrid
@@ -97,9 +98,10 @@ def close(
return
self._closed = True
data = self._closed_data if data is None else data
self._publish_msg(
"shinywidgets_comm_close", data=data, metadata=metadata, buffers=buffers
)
if get_current_session():
self._publish_msg(
"shinywidgets_comm_close", data=data, metadata=metadata, buffers=buffers
)
if not deleting:
# If deleting, the comm can't be unregistered
self.comm_manager.unregister_comm(self)
@@ -169,10 +171,17 @@ def _publish_msg(
def _send():
run_coro_hybrid(session.send_custom_message(msg_type, msg_txt)) # type: ignore

# N.B., if we don't do this on flush, then if you initialize a widget
# outside of a reactive context, run_coro_sync() will complain with
# N.B., if messages are sent immediately, run_coro_sync() could fail with
# 'async function yielded control; it did not finish in one iteration.'
session.on_flush(_send)
# if executed outside of a reactive context.
if msg_type == "shinywidgets_comm_close":
# The primary way widgets are closed are when a new widget is rendered in
# its place (see render_widget_base). By sending close on_flushed(), we
# ensure to close the 'old' widget after the new one is created. (avoiding a
# "flicker" of the old widget being removed before the new one is created)
session.on_flushed(_send)
else:
session.on_flush(_send)

# This is the method that ipywidgets.widgets.Widget uses to respond to client-side changes
def on_msg(self, callback: MsgCallback) -> None:
@@ -188,3 +197,30 @@ def handle_msg(self, msg: Dict[str, object]) -> None:
def handle_close(self, msg: Dict[str, object]) -> None:
if self._close_callback is not None:
self._close_callback(msg)


@dataclass
class OrphanedShinyComm:
"""
A 'mock' `ShinyComm`. It's only purpose is to allow one to get
the `model_id` (i.e., `comm_id`) of a widget after closing it.
"""

comm_id: str

def send(
self,
*args: object,
**kwargs: object,
) -> None:
pass

def close(
self,
*args: object,
**kwargs: object,
) -> None:
pass

def on_msg(self, callback: MsgCallback) -> None:
pass
54 changes: 23 additions & 31 deletions shinywidgets/_dependencies.py
Original file line number Diff line number Diff line change
@@ -10,50 +10,42 @@
import packaging.version
from htmltools import HTMLDependency, tags
from htmltools._core import HTMLDependencySource
from ipywidgets._version import (
__html_manager_version__, # pyright: ignore[reportUnknownVariableType]
)
from ipywidgets.widgets.domwidget import DOMWidget
from ipywidgets.widgets.widget import Widget
from jupyter_core.paths import jupyter_path # type: ignore
from jupyter_core.paths import jupyter_path
from shiny import Session, ui

from . import __version__


# TODO: scripts/static_download.R should produce/update these
def libembed_dependency() -> List[HTMLDependency]:
return [
# Jupyter Notebook/Lab both come "preloaded" with several @jupyter-widgets packages
# (i.e., base, controls, output), all of which are bundled into this extension.js file
# provided by the widgetsnbextension package, which is a dependency of ipywidgets.
# https://github.com/nteract/nes/tree/master/portable-widgets
# https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/html-manager/src/htmlmanager.ts#L115-L120
#
# Unfortunately, I don't think there is a good way for us to "pre-bundle" these dependencies
# since they could change depending on the version of ipywidgets (and ipywidgets itself
# doesn't include these dependencies in such a way that require("@jupyter-widget/base") would
# work robustly when used in other 3rd party widgets). Moreover, I don't think we can simply
# have @jupyter-widget/base point to https://unpkg.com/@jupyter-widgets/base@__version__/lib/index.js
# (or a local version of this) since it appears the lib entry points aren't usable in the browser.
#
# All this is to say that I think we are stuck with this mega 3.5MB file that contains all of the
# stuff we need to render widgets outside of the notebook.
HTMLDependency(
name="ipywidget-libembed-amd",
version=parse_version_safely(__html_manager_version__),
source={"package": "shinywidgets", "subdir": "static"},
script={"src": "libembed-amd.js"},
),
]


def output_binding_dependency() -> HTMLDependency:
# Jupyter Notebook/Lab both come "preloaded" with several @jupyter-widgets packages
# (i.e., base, controls, output), all of which are bundled into this extension.js file
# provided by the widgetsnbextension package, which is a dependency of ipywidgets.
# https://github.com/nteract/nes/tree/master/portable-widgets
# https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/html-manager/src/htmlmanager.ts#L115-L120
#
# Unfortunately, I don't think there is a good way for us to "pre-bundle" these dependencies
# since they could change depending on the version of ipywidgets (and ipywidgets itself
# doesn't include these dependencies in such a way that require("@jupyter-widget/base") would
# work robustly when used in other 3rd party widgets). Moreover, I don't think we can simply
# have @jupyter-widget/base point to https://unpkg.com/@jupyter-widgets/base@__version__/lib/index.js
# (or a local version of this) since it appears the lib entry points aren't usable in the browser.
#
# All this is to say that I think we are stuck with this mega 3.5MB file that contains all of the
# stuff we need to render widgets outside of the notebook.
return HTMLDependency(
name="ipywidget-output-binding",
version=__version__,
source={"package": "shinywidgets", "subdir": "static"},
script={"src": "output.js"},
script=[
{"src": "libembed-amd.js"},
# Bundle our output.js in the same dependency as libembded since Quarto
# has a bug where it doesn't renders dependencies in the order they are defined
# (i.e., this way we can ensure the output.js script always comes after the libembed-amd.js script tag)
{"src": "output.js"},
],
stylesheet={"href": "shinywidgets.css"},
)

3 changes: 1 addition & 2 deletions shinywidgets/_output_widget.py
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
from shiny.ui.fill import as_fill_item, as_fillable_container

from ._cdn import SHINYWIDGETS_CDN, SHINYWIDGETS_CDN_ONLY
from ._dependencies import libembed_dependency, output_binding_dependency
from ._dependencies import output_binding_dependency

__all__ = ("output_widget",)

@@ -23,7 +23,6 @@ def output_widget(
) -> Tag:
id = resolve_id(id)
res = tags.div(
*libembed_dependency(),
output_binding_dependency(),
head_content(
tags.script(
10 changes: 4 additions & 6 deletions shinywidgets/_render_widget.py
Original file line number Diff line number Diff line change
@@ -5,12 +5,10 @@
from htmltools import Tag

if TYPE_CHECKING:
from altair import JupyterChart # pyright: ignore[reportMissingTypeStubs]
from jupyter_bokeh import BokehModel # pyright: ignore[reportMissingTypeStubs]
from plotly.graph_objects import ( # pyright: ignore[reportMissingTypeStubs]
FigureWidget,
)
from pydeck.widget import DeckGLWidget # pyright: ignore[reportMissingTypeStubs]
from altair import JupyterChart
from jupyter_bokeh import BokehModel
from plotly.graph_objects import FigureWidget
from pydeck.widget import DeckGLWidget
else:
JupyterChart = BokehModel = FigureWidget = DeckGLWidget = object

23 changes: 8 additions & 15 deletions shinywidgets/_render_widget_base.py
Original file line number Diff line number Diff line change
@@ -4,15 +4,10 @@
from typing import Generic, Optional, Tuple, TypeVar, cast

from htmltools import Tag
from ipywidgets.widgets import ( # pyright: ignore[reportMissingTypeStubs]
DOMWidget,
Layout,
Widget,
)
from ipywidgets.widgets import DOMWidget, Layout, Widget
from shiny import req
from shiny.reactive._core import Context, get_current_context
from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
from traitlets import Unicode

from ._as_widget import as_widget
from ._dependencies import widget_pkg
@@ -94,12 +89,7 @@ async def render(self) -> Jsonifiable | None:
return None

return {
"model_id": str(
cast(
Unicode,
widget.model_id, # pyright: ignore[reportUnknownMemberType]
)
),
"model_id": str(widget.model_id),
"fill": fill,
}

@@ -168,7 +158,7 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]:
# If the ipywidget Layout() height is set to something other than "auto", then
# don't do filling layout https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Layout.html
if isinstance(layout, Layout):
if layout.height is not None and layout.height != "auto": # type: ignore
if layout.height is not None and layout.height != "auto":
fill = False

pkg = widget_pkg(widget)
@@ -178,7 +168,7 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]:
from plotly.graph_objs import Layout as PlotlyLayout # pyright: ignore

if isinstance(layout, PlotlyLayout):
if layout.height is not None: # pyright: ignore[reportUnknownMemberType]
if layout.height is not None:
fill = False
# Default margins are also way too big
layout.template.layout.margin = dict( # pyright: ignore
@@ -188,6 +178,9 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]:
# so change that 60px default to 32px
if layout.margin["t"] == 60: # pyright: ignore
layout.margin["t"] = 32 # pyright: ignore
# In plotly >=v6.0, the plot won't actually fill unless it's responsive
if fill:
widget._config = {"responsive": True, **widget._config} # type: ignore

widget.layout = layout

@@ -196,7 +189,7 @@ def set_layout_defaults(widget: Widget) -> Tuple[Widget, bool]:
# container since it'll be contained within the Layout() container, which has a
# full-fledged sizing API.
if pkg == "altair":
import altair as alt # pyright: ignore[reportMissingTypeStubs]
import altair as alt

# Since as_widget() has already happened, we only need to handle JupyterChart
if isinstance(widget, alt.JupyterChart):
209 changes: 148 additions & 61 deletions shinywidgets/_shinywidgets.py
Original file line number Diff line number Diff line change
@@ -3,11 +3,12 @@
import copy
import json
import os
from typing import Any, Optional, Sequence, Union, cast
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Optional, Sequence, Union, cast
from uuid import uuid4
from weakref import WeakSet

import ipywidgets # pyright: ignore[reportMissingTypeStubs]
import ipywidgets

from ._render_widget import render_widget

@@ -20,19 +21,25 @@
from shiny import Session, reactive
from shiny.http_staticfiles import StaticFiles
from shiny.reactive._core import get_current_context
from shiny.session import get_current_session, require_active_session
from shiny.session import get_current_session, require_active_session, session_context

from ._as_widget import as_widget
from ._cdn import SHINYWIDGETS_CDN_ONLY, SHINYWIDGETS_EXTENSION_WARNING
from ._comm import BufferType, ShinyComm, ShinyCommManager
from ._comm import BufferType, OrphanedShinyComm, ShinyComm, ShinyCommManager
from ._dependencies import require_dependency
from ._utils import is_instance_of_class, package_dir
from ._render_widget_base import has_current_context
from ._utils import package_dir

__all__ = (
"register_widget",
"reactive_read",
)

if TYPE_CHECKING:
from typing import TypeGuard

from traitlets.traitlets import Instance


# --------------------------------------------------------------------------------------------
# When a widget is initialized, also initialize a communication channel (via the Shiny
@@ -45,24 +52,49 @@ def init_shiny_widget(w: Widget):
raise RuntimeError(
"shinywidgets requires that all ipywidgets be constructed within an active Shiny session"
)
# Wait until we're in a "real" session before doing anything
# (i.e., on the 1st run of an Express app, it's too early to do anything)
if session.is_stub_session():
return
# Break out of any module-specific session. Otherwise, input.shinywidgets_comm_send
# will be some module-specific copy.
while hasattr(session, "_parent"):
session = cast(Session, session._parent) # pyright: ignore

# Previous versions of ipywidgets (< 8.0.5) had
# `Widget.comm = Instance('ipykernel.comm.Comm')`
# which meant we'd get a runtime error when setting `Widget.comm = ShinyComm()`.
# In more recent versions, this is no longer necessary since they've (correctly)
# changed comm from an Instance() to Any().
# https://github.com/jupyter-widgets/ipywidgets/pull/3533/files#diff-522bb5e7695975cba0199c6a3d6df5be827035f4dc18ed6da22ac216b5615c77R482
old_comm_klass = None
if is_instance_of_class(Widget.comm, "Instance", "traitlets.traitlets"): # type: ignore
old_comm_klass = copy.copy(Widget.comm.klass) # type: ignore
Widget.comm.klass = object # type: ignore
# If this is the first time we've seen this session, initialize some things
if session not in SESSIONS:
SESSIONS.add(session)

# Get the initial state of the widget
state, buffer_paths, buffers = _remove_buffers(w.get_state()) # type: ignore
# Somewhere inside ipywidgets, it makes requests for static files
# under the publicPath set by the webpack.config.js file.
session.app._dependency_handler.mount(
"/dist/",
StaticFiles(directory=os.path.join(package_dir("shinywidgets"), "static")),
name="shinywidgets-static-resources",
)

# Handle messages from the client. Note that widgets like qgrid send client->server messages
# to figure out things like what filter to be shown in the table.
@reactive.effect
@reactive.event(session.input.shinywidgets_comm_send)
def _():
msg_txt = session.input.shinywidgets_comm_send()
msg = json.loads(msg_txt)
comm_id = msg["content"]["comm_id"]
if comm_id in COMM_MANAGER.comms:
comm: ShinyComm = COMM_MANAGER.comms[comm_id]
comm.handle_msg(msg)

def _cleanup_session_state():
SESSIONS.remove(session)
# Cleanup any widgets that were created in this session
for id in SESSION_WIDGET_ID_MAP[session.id]:
widget = WIDGET_INSTANCE_MAP.get(id)
if widget:
widget.close()
del SESSION_WIDGET_ID_MAP[session.id]

session.on_ended(_cleanup_session_state)

# Make sure window.require() calls made by 3rd party widgets
# (via handle_comm_open() -> new_model() -> loadClass() -> requireLoader())
@@ -77,17 +109,70 @@ def init_shiny_widget(w: Widget):
if getattr(w, "_model_id", None) is None:
w._model_id = uuid4().hex

# Initialize the comm...this will also send the initial state of the widget
w.comm = ShinyComm(
comm_id=w._model_id, # pyright: ignore
comm_manager=COMM_MANAGER,
target_name="jupyter.widgets",
data={"state": state, "buffer_paths": buffer_paths},
buffers=cast(BufferType, buffers),
# TODO: should this be hard-coded?
metadata={"version": __protocol_version__},
html_deps=session._process_ui(TagList(widget_dep))["deps"],
)
id = cast(str, w._model_id)

# Since the actual ShinyComm() is initialized _after_ the Widget is initialized,
# and Widget.__init__() includes a call to Widget.open() which opens an unnecessary
# comm, we just set the comm to a dummy comm for now (to avoid unnecessary work)
w.comm = OrphanedShinyComm(id)

# Schedule the opening of the comm to happen sometime after this init function.
# This is important for widgets like plotly that do additional initialization that
# is required to get a valid widget state.
@reactive.effect(priority=99999)
def _open_shiny_comm():

# Call _repr_mimebundle_() before get_state() since it may modify the widget
# in an important way (unfortunately, it does for plotly)
# # https://github.com/plotly/plotly.py/blob/0089f32/packages/python/plotly/plotly/basewidget.py#L734-L738
if hasattr(w, "_repr_mimebundle_") and callable(w._repr_mimebundle_):
w._repr_mimebundle_()

# Now, get the state
state, buffer_paths, buffers = _remove_buffers(w.get_state())

# Initialize the comm -- this sends widget state to the frontend
with widget_comm_patch():
w.comm = ShinyComm(
comm_id=id,
comm_manager=COMM_MANAGER,
target_name="jupyter.widgets",
data={"state": state, "buffer_paths": buffer_paths},
buffers=cast(BufferType, buffers),
# TODO: should this be hard-coded?
metadata={"version": __protocol_version__},
html_deps=session._process_ui(TagList(widget_dep))["deps"],
)

_open_shiny_comm.destroy()

# If we're in a reactive context, close this widget when the context is invalidated
# TODO: this should probably only be done in an output context, but I'm pretty sure
# we don't have a decent way to determine that at the moment. In theory, doing this
# in _any_ reactive context be problematic if you have an effect() that adds one
# widget to another (i.e., a marker to a map) and want that marker to persist through
# the next invalidation. The example provided in #174 is one such example.
if has_current_context():
ctx = get_current_context()

def on_close():
with session_context(session):
w.close()
# By closing the widget, we also close the comm, which sets w.comm to
# None. Unfortunately, the w.model_id property looks up w.comm.comm_id
# at runtime, and some packages like ipyleaflet want to use this id
# to manage references between widgets.
w.comm = OrphanedShinyComm(id)
# Assigning a comm has the side-effect of adding back a reference to
# the widget instance, so remove it again
if id in WIDGET_INSTANCE_MAP:
del WIDGET_INSTANCE_MAP[id]

ctx.on_invalidate(on_close)

# Keep track of what session this widget belongs to (so we can close it when the
# session ends)
SESSION_WIDGET_ID_MAP.setdefault(session.id, []).append(id)

# Some widget's JS make external requests for static files (e.g.,
# ipyleaflet markers) under this resource path. Note that this assumes that
@@ -103,45 +188,21 @@ def init_shiny_widget(w: Widget):
name=f"{widget_dep.name}-nbextension-static-resources",
)

# everything after this point should be done once per session
if session in SESSIONS:
return
SESSIONS.add(session) # type: ignore

# Somewhere inside ipywidgets, it makes requests for static files
# under the publicPath set by the webpack.config.js file.
session.app._dependency_handler.mount(
"/dist/",
StaticFiles(directory=os.path.join(package_dir("shinywidgets"), "static")),
name="shinywidgets-static-resources",
)

# Handle messages from the client. Note that widgets like qgrid send client->server messages
# to figure out things like what filter to be shown in the table.
@reactive.Effect
@reactive.event(session.input.shinywidgets_comm_send)
def _():
msg_txt = session.input.shinywidgets_comm_send()
msg = json.loads(msg_txt)
comm_id = msg["content"]["comm_id"]
comm: ShinyComm = COMM_MANAGER.comms[comm_id]
comm.handle_msg(msg)

def _restore_state():
if old_comm_klass is not None:
Widget.comm.klass = old_comm_klass # type: ignore
SESSIONS.remove(session) # type: ignore

session.on_ended(_restore_state)


# TODO: can we restore the widget constructor in a sensible way?
Widget.on_widget_constructed(init_shiny_widget) # type: ignore

# Use WeakSet() over Set() so that the session can be garbage collected
SESSIONS = WeakSet() # type: ignore
SESSIONS: WeakSet[Session] = WeakSet()
COMM_MANAGER = ShinyCommManager()

# Dictionary mapping session id to widget ids
# The key is the session id, and the value is a list of widget ids
SESSION_WIDGET_ID_MAP: dict[str, list[str]] = {}

# Dictionary of all "active" widgets (ipywidgets automatically adds to this dictionary as
# new widgets are created, but they won't get removed until the widget is explictly closed)
WIDGET_INSTANCE_MAP = cast(dict[str, Widget], Widget.widgets)

# --------------------------------------
# Reactivity
@@ -176,7 +237,7 @@ def reactive_depend(
names = [names]

for name in names:
if not widget.has_trait(name): # pyright: ignore[reportUnknownMemberType]
if not widget.has_trait(name):
raise ValueError(
f"The '{name}' attribute of {widget.__class__.__name__} is not a "
"widget trait, and so it's not possible to reactively read it. "
@@ -212,6 +273,32 @@ def _():

return w

# Previous versions of ipywidgets (< 8.0.5) had
# `Widget.comm = Instance('ipykernel.comm.Comm')`
# which meant we'd get a runtime error when setting `Widget.comm = ShinyComm()`.
# In more recent versions, this is no longer necessary since they've (correctly)
# changed comm from an Instance() to Any().
# https://github.com/jupyter-widgets/ipywidgets/pull/3533/files#diff-522bb5e7695975cba0199c6a3d6df5be827035f4dc18ed6da22ac216b5615c77R482
@contextmanager
def widget_comm_patch():
if not is_traitlet_instance(Widget.comm):
yield
return

comm_klass = copy.copy(Widget.comm.klass)
Widget.comm.klass = object

yield

Widget.comm.klass = comm_klass


def is_traitlet_instance(x: object) -> "TypeGuard[Instance[Any]]":
try:
from traitlets.traitlets import Instance
except ImportError:
return False
return isinstance(x, Instance)

# It doesn't, at the moment, seem feasible to establish a comm with statically rendered widgets,
# and partially for this reason, it may not be sensible to provide an input-like API for them.
13 changes: 1 addition & 12 deletions shinywidgets/_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import importlib
import os
import tempfile
from typing import Optional


# similar to base::system.file()
def package_dir(package: str) -> str:
@@ -10,14 +10,3 @@ def package_dir(package: str) -> str:
if pkg_file is None:
raise ImportError(f"Couldn't load package {package}")
return os.path.dirname(pkg_file)


def is_instance_of_class(
x: object, class_name: str, module_name: Optional[str] = None
) -> bool:
typ = type(x)
res = typ.__name__ == class_name
if module_name is None:
return res
else:
return res and typ.__module__ == module_name
6 changes: 3 additions & 3 deletions shinywidgets/static/output.js

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions shinywidgets/static/shinywidgets.css
Original file line number Diff line number Diff line change
@@ -8,12 +8,18 @@

/*
* At least one exception is .vega-embed, which needs to be visible.
* This especially important for things like FacetChart, where responsive
* This especially important for things like FacetChart or MosaicWidget, where responsive
* sizing isn't supported.
* https://github.com/altair-viz/altair/blob/5dac297/altair/jupyter/jupyter_chart.py#L103-L106
*/
.shiny-ipywidget-output:has(> .vega-embed) {
.shiny-ipywidget-output:has(> .vega-embed, > .mosaic-widget) {
overflow: visible;
> * {
overflow: visible;
}
> .mosaic-widget > * {
align-items: center !important;
}
}

/*