diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8f10830..d03de25e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `ui.update_*()` functions now accept `ui.TagChild` (i.e., HTML) as input to the `label` and `icon` arguments. (#2020) +* The `.output_*()` methods of the `ClientData` class (e.g., `session.clientdata.output_height()`) can now be called without an `id` when called inside a `@render` function. (#1978) + * `playwright.controller.InputActionButton` gains a `expect_icon()` method. As a result, the already existing `expect_label()` no longer includes the icon. (#2020) ### Improvements @@ -25,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `timeout_secs` parameter to `create_app_fixture` to allow testing apps with longer startup times. (#2033) +* Added module support for `session.clientdata` methods. This allows you to access client data values in Shiny modules without needing to namespace the keys explicitly. (#1978) + ### Bug fixes * Fixed an issue with `ui.Chat()` sometimes wanting to scroll a parent element. (#1996) @@ -38,7 +42,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. diff --git a/shiny/session/_session.py b/shiny/session/_session.py index dd78a6819..f13a970b3 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -22,6 +22,7 @@ AsyncIterable, Awaitable, Callable, + Generator, Iterable, Literal, Optional, @@ -540,6 +541,7 @@ def __init__( self.user: str | None = None self.groups: list[str] | None = None + credentials_json: str = "" if "shiny-server-credentials" in self.http_conn.headers: credentials_json = self.http_conn.headers["shiny-server-credentials"] @@ -1218,6 +1220,7 @@ def __init__(self, root_session: Session, ns: ResolvedId) -> None: ns=ns, outputs=root_session.output._outputs, ) + self.clientdata = ClientData(self) self._outbound_message_queues = root_session._outbound_message_queues self._downloads = root_session._downloads @@ -1507,6 +1510,7 @@ class ClientData: def __init__(self, session: Session) -> None: self._session: Session = session + self._current_output_name: ResolvedId | None = None def url_hash(self) -> str: """ @@ -1556,7 +1560,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[Id] = None) -> float | None: """ Reactively read the height of an output. @@ -1573,7 +1577,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[Id] = None) -> float | None: """ Reactively read the width of an output. @@ -1590,7 +1594,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[Id] = None) -> bool | None: """ Reactively read whether an output is hidden. @@ -1606,7 +1610,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[Id] = None) -> str | None: """ Reactively read the background color of an output. @@ -1623,7 +1627,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[Id] = None) -> str | None: """ Reactively read the foreground color of an output. @@ -1640,7 +1644,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[Id] = None) -> str | None: """ Reactively read the accent color of an output. @@ -1657,7 +1661,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[Id] = None) -> str | None: """ Reactively read the font(s) of an output. @@ -1678,22 +1682,51 @@ def _read_input(self, key: str) -> str: self._check_current_context(key) id = ResolvedId(f".clientdata_{key}") - if id not in self._session.input: + if id not in self._session.root_scope().input: raise ValueError( f"ClientData value '{key}' not found. Please report this issue." ) - return self._session.input[id]() + return self._session.root_scope().input[id]() - def _read_output(self, id: str, key: str) -> str | None: + def _read_output(self, id: Id | None, key: str) -> str | None: self._check_current_context(f"output_{key}") + # No `id` provided support + if id is None and self._current_output_name is not None: + id = self._current_output_name + + if id is None: + raise ValueError( + "session.clientdata.output_*() requires an id when not called within " + "an output renderer." + ) + + # Module support + if not isinstance(id, ResolvedId): + id = self._session.ns(id) + input_id = ResolvedId(f".clientdata_output_{id}_{key}") - if input_id in self._session.input: - return self._session.input[input_id]() + if input_id in self._session.root_scope().input: + return self._session.root_scope().input[input_id]() else: return None + @contextlib.contextmanager + def _output_name_ctx(self, output_name: ResolvedId) -> Generator[None, None, None]: + """ + Context manager to temporarily set the output name. + + This is used to allow `session.clientdata.output_*()` methods to access the + current output name without needing to pass it explicitly. + """ + old_output_name = self._current_output_name + try: + self._current_output_name = output_name + yield + finally: + self._current_output_name = old_output_name + @staticmethod def _check_current_context(key: str) -> None: try: @@ -1798,8 +1831,12 @@ async def output_obs(): ) try: - value = await renderer.render() + with session.clientdata._output_name_ctx(output_name): + # Call the app's renderer function + value = await renderer.render() + session._outbound_message_queues.set_value(output_name, value) + except SilentOperationInProgressException: session._send_progress( "binding", {"id": output_name, "persistent": True} diff --git a/tests/playwright/shiny/session/current_output_info/app.py b/tests/playwright/shiny/session/current_output_info/app.py new file mode 100644 index 000000000..fbbbf6244 --- /dev/null +++ b/tests/playwright/shiny/session/current_output_info/app.py @@ -0,0 +1,33 @@ +from shiny import App, Inputs, Outputs, Session, module, render, ui + + +@module.ui +def mod_ui(): + return ui.output_text("info2").add_class("shiny-report-theme") + + +@module.server +def mod_server(input: Inputs, output: Outputs, session: Session): + @render.text + def info2(): + bg_color = session.clientdata.output_bg_color() + return f"BG color: {bg_color}" + + +app_ui = ui.page_fluid( + ui.input_dark_mode(mode="light", id="dark_mode"), + ui.output_text("info").add_class("shiny-report-theme"), + mod_ui("mod1"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + mod_server("mod1") + + @render.text + def info(): + bg_color = session.clientdata.output_bg_color() + return f"BG color: {bg_color}" + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/session/current_output_info/test_current_output_info.py b/tests/playwright/shiny/session/current_output_info/test_current_output_info.py new file mode 100644 index 000000000..41f3d63ee --- /dev/null +++ b/tests/playwright/shiny/session/current_output_info/test_current_output_info.py @@ -0,0 +1,25 @@ +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") + mod_info2 = controller.OutputText(page, "mod1-info2") + info.expect_value("BG color: rgb(255, 255, 255)") + mod_info2.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)") + mod_info2.expect_value("BG color: rgb(29, 31, 33)")