Skip to content

feat(clientdata): id is now optional on clientdata.output_*() methods #1978

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### New features

* The `.output_*()` methods of the `ClientData` class (e.g., `session.clientdata.output_height()`) can now be called without an `id` inside a output renderer. (#1978)

### Improvements

* `selectize`, `remove_button`, and `options` parameters of `ui.input_select()` have been deprecated; use `ui.input_selectize()` instead. (Thanks, @ErdaradunGaztea!) (#1947)
Expand All @@ -24,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.4.0] - 2025-04-08

## New features
### New features

* Added support for bookmarking Shiny applications. Bookmarking allows users to save the current state of an application and return to it later. This feature is available in both Shiny Core and Shiny Express. (#1870, #1915, #1919, #1920, #1922, #1934, #1938, #1945, #1955)
* To enable bookmarking in Express mode, set `shiny.express.app_opts(bookmark_store=)` during the app's initial construction.
Expand Down
2 changes: 1 addition & 1 deletion shiny/render/_express.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def __call__(self, fn: ValueFn[None]) -> Self:
if fn is None: # pyright: ignore[reportUnnecessaryComparison]
raise TypeError("@render.express requires a function when called")

async_fn = AsyncValueFn(fn)
async_fn = AsyncValueFn(fn, self)
if async_fn.is_async():
raise TypeError(
"@render.express does not support async functions. Use @render.ui instead."
Expand Down
24 changes: 22 additions & 2 deletions shiny/render/renderer/_renderer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from contextlib import contextmanager
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -162,7 +163,7 @@ def __call__(self, _fn: ValueFn[IT]) -> Self:
raise TypeError("Value function must be callable")

# Set value function with extra meta information
self.fn = AsyncValueFn(_fn)
self.fn = AsyncValueFn(_fn, self)

# Copy over function name as it is consistent with how Session and Output
# retrieve function names
Expand Down Expand Up @@ -350,6 +351,7 @@ class AsyncValueFn(Generic[IT]):
def __init__(
self,
fn: Callable[[], IT | None] | Callable[[], Awaitable[IT | None]],
renderer: Renderer[Any],
):
if isinstance(fn, AsyncValueFn):
raise TypeError(
Expand All @@ -358,12 +360,14 @@ def __init__(
self._is_async = is_async_callable(fn)
self._fn = wrap_async(fn)
self._orig_fn = fn
self._renderer = renderer

async def __call__(self) -> IT | None:
"""
Call the asynchronous function.
"""
return await self._fn()
with self._current_renderer():
return await self._fn()

def is_async(self) -> bool:
"""
Expand Down Expand Up @@ -404,3 +408,19 @@ def get_sync_fn(self) -> Callable[[], IT | None]:
)
sync_fn = cast(Callable[[], IT], self._orig_fn)
return sync_fn

@contextmanager
def _current_renderer(self):
from ...session import get_current_session

session = get_current_session()
if session is None:
yield
return

old_renderer = session._current_renderer
try:
session._current_renderer = self._renderer
yield
finally:
session._current_renderer = old_renderer
35 changes: 27 additions & 8 deletions shiny/session/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ class Session(ABC):
user: str | None
groups: list[str] | None

# Internal state for current_output_id()
_current_renderer: Renderer[Any] | None = None

# TODO: not sure these should be directly exposed
_outbound_message_queues: OutBoundMessageQueues
_downloads: dict[str, DownloadInfo]
Expand Down Expand Up @@ -378,6 +381,13 @@ def on_flushed(
A function that can be used to cancel the registration.
"""

def _current_output_id(self) -> str | None:
"Returns the id of the currently executing output renderer (if any)."
if self._current_renderer is None:
return None
else:
return self._current_renderer.output_id

@abstractmethod
async def _unhandled_error(self, e: Exception) -> None: ...

Expand Down Expand Up @@ -1556,7 +1566,7 @@ def pixelratio(self) -> float:
"""
return cast(int, self._read_input("pixelratio"))

def output_height(self, id: str) -> float | None:
def output_height(self, id: Optional[str] = None) -> float | None:
"""
Reactively read the height of an output.

Expand All @@ -1573,7 +1583,7 @@ def output_height(self, id: str) -> float | None:
"""
return cast(float, self._read_output(id, "height"))

def output_width(self, id: str) -> float | None:
def output_width(self, id: Optional[str] = None) -> float | None:
"""
Reactively read the width of an output.

Expand All @@ -1590,7 +1600,7 @@ def output_width(self, id: str) -> float | None:
"""
return cast(float, self._read_output(id, "width"))

def output_hidden(self, id: str) -> bool | None:
def output_hidden(self, id: Optional[str] = None) -> bool | None:
"""
Reactively read whether an output is hidden.

Expand All @@ -1606,7 +1616,7 @@ def output_hidden(self, id: str) -> bool | None:
"""
return cast(bool, self._read_output(id, "hidden"))

def output_bg_color(self, id: str) -> str | None:
def output_bg_color(self, id: Optional[str] = None) -> str | None:
"""
Reactively read the background color of an output.

Expand All @@ -1623,7 +1633,7 @@ def output_bg_color(self, id: str) -> str | None:
"""
return cast(str, self._read_output(id, "bg"))

def output_fg_color(self, id: str) -> str | None:
def output_fg_color(self, id: Optional[str] = None) -> str | None:
"""
Reactively read the foreground color of an output.

Expand All @@ -1640,7 +1650,7 @@ def output_fg_color(self, id: str) -> str | None:
"""
return cast(str, self._read_output(id, "fg"))

def output_accent_color(self, id: str) -> str | None:
def output_accent_color(self, id: Optional[str] = None) -> str | None:
"""
Reactively read the accent color of an output.

Expand All @@ -1657,7 +1667,7 @@ def output_accent_color(self, id: str) -> str | None:
"""
return cast(str, self._read_output(id, "accent"))

def output_font(self, id: str) -> str | None:
def output_font(self, id: Optional[str] = None) -> str | None:
"""
Reactively read the font(s) of an output.

Expand Down Expand Up @@ -1685,9 +1695,18 @@ def _read_input(self, key: str) -> str:

return self._session.input[id]()

def _read_output(self, id: str, key: str) -> str | None:
def _read_output(self, id: str | None, key: str) -> str | None:
self._check_current_context(f"output_{key}")

if id is None:
id = self._session._current_output_id()

if id is None:
raise ValueError(
"session.clientdata.output_*() requires an id when not called within "
"an output renderer."
)

input_id = ResolvedId(f".clientdata_output_{id}_{key}")
if input_id in self._session.input:
return self._session.input[input_id]()
Expand Down
19 changes: 19 additions & 0 deletions tests/playwright/shiny/session/current_output_info/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from shiny import App, Inputs, Outputs, Session, render, ui

app_ui = ui.page_fluid(
ui.input_dark_mode(mode="light", id="dark_mode"),
ui.output_text("text1"),
ui.output_text("text2"),
ui.output_text("info").add_class("shiny-report-theme"),
)


def server(input: Inputs, output: Outputs, session: Session):

@render.text
def info():
bg_color = session.clientdata.output_bg_color()
return f"BG color: {bg_color}"


app = App(app_ui, server)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from playwright.sync_api import Page

from shiny.playwright import controller
from shiny.run import ShinyAppProc


def test_current_output_info(page: Page, local_app: ShinyAppProc) -> None:

page.goto(local_app.url)

# Check that we can get background color from clientdata
info = controller.OutputText(page, "info")
info.expect_value("BG color: rgb(255, 255, 255)")

# Click the dark mode button to change the background color
dark_mode = controller.InputDarkMode(page, "dark_mode")
dark_mode.expect_mode("light")
dark_mode.click()
dark_mode.expect_mode("dark")

# Check that the background color has changed
info.expect_value("BG color: rgb(29, 31, 33)")
Loading