Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion shinywidgets/_accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ class AccessorSpec:
not_ready
True for raw values that mean "the widget hasn't reported yet"; reads
then cancel (`req(False)`) instead of returning a value.
opt_in
Opt-in accessors only attach (and only read) when the renderer is
created with `listen_to=[name, ...]`; use for accessors whose payloads
are unbounded or stream continuously, so default apps don't pay for
events they never read.
"""

name: str
Expand All @@ -73,6 +78,7 @@ class AccessorSpec:
rate_policy: Optional[RatePolicy] = None
rate_delay: float = 0.25
not_ready: Callable[[Any], bool] = never_ready_check
opt_in: bool = False

def __post_init__(self) -> None:
if self.name == "value":
Expand Down Expand Up @@ -192,6 +198,7 @@ def __init__(
rate_policy: Optional[RatePolicy] = None,
rate_delay: Optional[float] = None,
schedule: Optional[Schedule] = None,
listen_to: Optional[Sequence[str]] = None,
) -> None:
self._widget: object = None
# Bumped on each render, so emits from an old widget's handlers are
Expand All @@ -205,8 +212,18 @@ def __init__(
self._rate_policy: Optional[RatePolicy] = rate_policy
self._rate_delay = rate_delay
self._schedule = schedule
self._listen_to = frozenset(listen_to or ())
for spec in specs:
self._add_slot(spec)
# Dynamic specs (render_panel's param()) are named at read time, so
# their names can't be validated here
if dynamic_specs is None:
unknown = self._listen_to.difference(self._slots)
if unknown:
raise ValueError(
f"listen_to names unknown accessor(s) {sorted(unknown)}; "
f"this renderer's accessors are {sorted(self._slots)}."
)

def _add_slot(self, spec: AccessorSpec) -> Slot:
slot = Slot(spec)
Expand All @@ -225,6 +242,9 @@ def _add_slot(self, spec: AccessorSpec) -> Slot:
def names(self) -> Tuple[str, ...]:
return tuple(self._slots)

def _listened(self, slot: "Slot") -> bool:
return not slot.spec.opt_in or slot.spec.name in self._listen_to

def on_render(self, widget: object) -> None:
self._generation += 1
self._widget = widget
Expand All @@ -236,7 +256,7 @@ def on_render(self, widget: object) -> None:
# Drop a value the limiter was holding: the new widget reports
# its own state, which must not be deduped against it.
slot.latest = self._published(slot)
if widget is not None:
if widget is not None and self._listened(slot):
try:
self._attach(slot)
except Exception:
Expand All @@ -250,6 +270,12 @@ def read(self, name: str, *, raw: bool = False) -> object:
slot = self._slots.get(name)
if slot is None:
slot = self._resolve_dynamic(name)
if not self._listened(slot):
raise RuntimeError(
f'`{name}()` requires opting in: pass `listen_to=["{name}"]` '
"to the renderer. Its payload can grow without bound or stream "
"continuously, so it's off unless declared."
)
if self._widget is not None and slot.detach is None:
# Attach before taking the dependency, so a value the attach
# emits right away doesn't invalidate this reader.
Expand Down
33 changes: 33 additions & 0 deletions shinywidgets/_render_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Iterable,
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
Expand Down Expand Up @@ -723,6 +724,12 @@ def atlas():
rate_delay
The rate-limit window in seconds (default 0.25). ``0`` disables rate
limiting.
listen_to
Names of opt-in accessors to enable (e.g. ``listen_to=["predicate"]``).
Accessors whose payloads can grow without bound or stream
continuously are off unless declared; reading one that wasn't
declared raises an error naming it. Additive to the always-on
accessors. Unknown names raise at decoration time.
width
A CSS length unit (e.g., ``"400px"`` or ``"100%"``) for the output's width.
height
Expand Down Expand Up @@ -757,6 +764,7 @@ def __init__(
fillable: Optional[bool] = None,
rate_policy: Optional[RatePolicy] = None,
rate_delay: Optional[float] = None,
listen_to: Optional[Sequence[str]] = None,
allow_unrestricted_connection: bool = False,
):
super().__init__(
Expand All @@ -767,6 +775,7 @@ def __init__(
fillable=fillable,
rate_policy=rate_policy,
rate_delay=rate_delay,
listen_to=listen_to,
)
self.allow_unrestricted_connection = allow_unrestricted_connection

Expand Down Expand Up @@ -3290,6 +3299,12 @@ def dashboard():
rate_delay
The rate-limit window in seconds (default 0.25). ``0`` disables rate
limiting.
listen_to
Names of opt-in accessors to enable (e.g. ``listen_to=["params"]``).
Accessors whose payloads can grow without bound or stream
continuously are off unless declared; reading one that wasn't
declared raises an error naming it. Additive to the always-on
accessors. Unknown names raise at decoration time.

Each accessor can also be read by name -- ``out.read("params")`` --
which also accepts the widget's bare synced-trait names.
Expand All @@ -3311,6 +3326,7 @@ def __init__(
fillable: Optional[bool] = None,
rate_policy: Optional[RatePolicy] = None,
rate_delay: Optional[float] = None,
listen_to: Optional[Sequence[str]] = None,
allow_unrestricted_connection: bool = False,
):
super().__init__(
Expand All @@ -3321,6 +3337,7 @@ def __init__(
fillable=fillable,
rate_policy=rate_policy,
rate_delay=rate_delay,
listen_to=listen_to,
)
self.allow_unrestricted_connection = allow_unrestricted_connection

Expand Down Expand Up @@ -5648,6 +5665,12 @@ def table():
rate_delay
The rate-limit window in seconds (default 0.25). ``0`` disables rate
limiting.
listen_to
Names of opt-in accessors to enable (e.g. ``listen_to=["sql"]``).
Accessors whose payloads can grow without bound or stream
continuously are off unless declared; reading one that wasn't
declared raises an error naming it. Additive to the always-on
accessors. Unknown names raise at decoration time.

Each accessor can also be read by name -- ``out.read("sql")`` --
which also accepts the widget's bare synced-trait names.
Expand All @@ -5669,6 +5692,7 @@ def __init__(
fillable: Optional[bool] = None,
rate_policy: Optional[RatePolicy] = None,
rate_delay: Optional[float] = None,
listen_to: Optional[Sequence[str]] = None,
allow_unrestricted_connection: bool = False,
):
super().__init__(
Expand All @@ -5679,6 +5703,7 @@ def __init__(
fillable=fillable,
rate_policy=rate_policy,
rate_delay=rate_delay,
listen_to=listen_to,
)
self.allow_unrestricted_connection = allow_unrestricted_connection

Expand Down Expand Up @@ -6058,6 +6083,12 @@ def grid():
rate_delay
The rate-limit window in seconds (default 0.25). ``0`` disables rate
limiting.
listen_to
Names of opt-in accessors to enable (e.g. ``listen_to=["clicked"]``).
Accessors whose payloads can grow without bound or stream
continuously are off unless declared; reading one that wasn't
declared raises an error naming it. Additive to the always-on
accessors. Unknown names raise at decoration time.
kwargs
Extra keyword arguments forwarded to mols2grid (split between
`MolGrid()` and its rendering, as described above). The split is by
Expand Down Expand Up @@ -6133,6 +6164,7 @@ def __init__(
fillable: Optional[bool] = None,
rate_policy: Optional[RatePolicy] = None,
rate_delay: Optional[int] = None,
listen_to: Optional[Sequence[str]] = None,
**kwargs: Any,
):
# The click channel only exists when the grid document is rendered
Expand All @@ -6155,6 +6187,7 @@ def __init__(
fillable=fillable,
rate_policy=rate_policy,
rate_delay=rate_delay,
listen_to=listen_to,
)
self.smiles_col = smiles_col
self.mol_col = mol_col
Expand Down
26 changes: 26 additions & 0 deletions shinywidgets/_render_widget_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Generic,
Iterator,
Optional,
Sequence,
Tuple,
TypeVar,
cast,
Expand Down Expand Up @@ -76,6 +77,14 @@ class render_widget_base(Renderer[ValueT], Generic[ValueT, WidgetT]):
Subclasses declare accessors (methods such as `clicked()` that read widget
events reactively) in `_accessor_specs`, and implement each one as
`self._read_accessor(name)`.

Parameters
----------
listen_to
Names of opt-in accessors to enable (e.g. ``listen_to=["selected"]``).
Accessors whose payloads can grow without bound or stream continuously
are off unless declared; reading one that wasn't declared raises an
error naming it. Additive to the always-on accessors.
"""

# Subclasses normally set this at class level; render_mols2grid also
Expand All @@ -102,6 +111,7 @@ def __init__(
fillable: Optional[bool] = None,
rate_policy: Optional[RatePolicy] = None,
rate_delay: Optional[float] = None,
listen_to: Optional[Sequence[str]] = None,
):
super().__init__(_fn)
self.width = width
Expand All @@ -118,6 +128,21 @@ def __init__(
self._value: ValueT | None = None
self._widget: WidgetT | None = None
self._contexts: set[Context] = set()
# Fail fast on listen_to typos where the accessor names are all known
# (subclasses with dynamic accessor names, e.g. render_panel's
# param(), can't be validated here)
if (
listen_to is not None
and type(self)._dynamic_accessor_spec
is render_widget_base._dynamic_accessor_spec
):
known = {spec.name for spec in self._accessor_specs}
unknown = set(listen_to) - known
if unknown:
raise ValueError(
f"listen_to names unknown accessor(s) {sorted(unknown)}; "
f"this renderer's accessors are {sorted(known)}."
)
# Instance attribute, so a renderer can make its set of accessors
# conditional on its own kwargs (render_mols2grid drops clicked()
# unless the click channel is enabled)
Expand All @@ -126,6 +151,7 @@ def __init__(
dynamic_specs=self._dynamic_accessor_spec,
rate_policy=rate_policy,
rate_delay=rate_delay,
listen_to=listen_to,
)
self._closes_accessors_on_destroy = False

Expand Down
68 changes: 68 additions & 0 deletions tests/unit/test_accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,3 +615,71 @@ async def main() -> Callable[[], None]:
cancel() # e.g. a re-render between the timer and the next cycle
session.queued[0]()
assert calls == []


def _attach_recorder(calls: List[str], name: str) -> acc.Attach:
def attach(widget: object, emit: acc.Emit) -> Callable[[], None]:
calls.append(name)
return lambda: None

return attach


def test_opt_in_accessor_attaches_and_reads_when_listened() -> None:
calls: List[str] = []
spec = acc.AccessorSpec(
name="selected",
kind="state",
attach=_attach_recorder(calls, "selected"),
opt_in=True,
)
a = acc.Accessors([spec], listen_to=["selected"])
a.on_render(object())
assert calls == ["selected"]


def test_opt_in_accessor_not_attached_without_listen_to() -> None:
calls: List[str] = []
spec = acc.AccessorSpec(
name="selected",
kind="state",
attach=_attach_recorder(calls, "selected"),
opt_in=True,
)
a = acc.Accessors([spec])
a.on_render(object())
assert calls == []


def test_opt_in_accessor_read_raises_without_listen_to() -> None:
spec = acc.AccessorSpec(
name="selected",
kind="state",
attach=_attach_recorder([], "selected"),
opt_in=True,
)
a = acc.Accessors([spec])
a.on_render(object())
with pytest.raises(RuntimeError, match="listen_to=.*selected"):
a.read("selected")


def test_listen_to_unknown_name_raises() -> None:
spec = acc.AccessorSpec(
name="selected",
kind="state",
attach=_attach_recorder([], "selected"),
opt_in=True,
)
with pytest.raises(ValueError, match="bogus"):
acc.Accessors([spec], listen_to=["bogus"])


def test_default_on_accessor_ignores_listen_to() -> None:
calls: List[str] = []
spec = acc.AccessorSpec(
name="clicked", kind="state", attach=_attach_recorder(calls, "clicked")
)
a = acc.Accessors([spec], listen_to=[])
a.on_render(object())
assert calls == ["clicked"]
8 changes: 8 additions & 0 deletions tests/unit/test_embedding_atlas_accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,11 @@ def test_selection_follows_a_rerender_with_new_data(widget_session: Session) ->
r, atlas_widget(widget_session), smaller, lambda: len(r.selected_rows())
)
assert seen == [4, 2]


def test_listen_to_accepted_and_validated() -> None:
from shinywidgets import render_embedding_atlas

render_embedding_atlas(listen_to=["predicate"])
with pytest.raises(ValueError, match="predicat"):
render_embedding_atlas(listen_to=["predicat"])
10 changes: 10 additions & 0 deletions tests/unit/test_mols2grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,3 +914,13 @@ def test_none_render_does_not_raise(widget_session: Session) -> None:
assert isinstance(r.grid, MolGrid)
assert render(r, lambda: None, widget_session) is None
assert r.grid is None


def test_listen_to_not_routed_to_widget_kwargs() -> None:
# listen_to is a renderer kwarg: it must not leak into the **kwargs split
# between MolGrid() and the grid's rendering
r = rw.render_mols2grid(listen_to=["selected"])
assert "listen_to" not in r.render_kwargs
assert "listen_to" not in r.grid_kwargs
with pytest.raises(ValueError, match="bogus"):
rw.render_mols2grid(listen_to=["bogus"])
6 changes: 6 additions & 0 deletions tests/unit/test_mosaic_widget_accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ def test_data_follows_a_rerender_with_new_data(widget_session: Session) -> None:
r, widget(widget_session), bigger, lambda: len(r.data().df())
)
assert seen == [3, 5]


def test_listen_to_accepted_and_validated() -> None:
render_mosaic_widget(listen_to=["params"])
with pytest.raises(ValueError, match="paramz"):
render_mosaic_widget(listen_to=["paramz"])
7 changes: 7 additions & 0 deletions tests/unit/test_plotly_accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,10 @@ def test_event_origin_accessors_are_retained() -> None:

for spec in (CLICKED, SELECTING, BRUSHING, RELAYOUT):
assert spec.kind == "state", spec.name


def test_listen_to_typo_raises_at_decoration() -> None:
from shinywidgets import render_plotly

with pytest.raises(ValueError, match="selcted"):
render_plotly(listen_to=["selcted"])
6 changes: 6 additions & 0 deletions tests/unit/test_quak_accessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ def test_data_follows_a_rerender_with_new_data(widget_session: Session) -> None:
r, widget(widget_session), bigger, lambda: len(r.data().df())
)
assert seen == [3, 5]


def test_listen_to_accepted_and_validated() -> None:
render_quak(listen_to=["sql"])
with pytest.raises(ValueError, match="sqll"):
render_quak(listen_to=["sqll"])
Loading