From 15be8adfb87df1a0f49de849a88631cc91ee6759 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 22 Apr 2025 18:02:41 -0500 Subject: [PATCH 01/18] feat(session): Add a current_output_id attribute for identifying currently executing output --- shiny/render/_express.py | 2 +- shiny/render/renderer/_renderer.py | 18 ++++++++++++++++-- shiny/session/_session.py | 4 ++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/shiny/render/_express.py b/shiny/render/_express.py index ab5139758..7c414929c 100644 --- a/shiny/render/_express.py +++ b/shiny/render/_express.py @@ -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." diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index b79009c91..30716b2da 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import contextmanager from typing import ( TYPE_CHECKING, Any, @@ -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 @@ -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( @@ -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_output_id(): + return await self._fn() def is_async(self) -> bool: """ @@ -404,3 +408,13 @@ def get_sync_fn(self) -> Callable[[], IT | None]: ) sync_fn = cast(Callable[[], IT], self._orig_fn) return sync_fn + + @contextmanager + def _current_output_id(self): + from ...session import get_current_session + + session = get_current_session() + if session is not None: + session.current_output_id = self._renderer.output_id + yield + session.current_output_id = None diff --git a/shiny/session/_session.py b/shiny/session/_session.py index dd78a6819..672b4455c 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,9 +50,8 @@ from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect +from ..reactive import Effect_, Value, effect, isolate from ..reactive import flush as reactive_flush -from ..reactive import isolate from ..reactive._core import lock from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT @@ -180,6 +179,7 @@ class Session(ABC): input: Inputs output: Outputs clientdata: ClientData + current_output_id: str | None # Could be done with a weak ref dict from root to all children. Then we could just # iterate over all modules and check the `.bookmark_exclude` list of each proxy From abaebbb77c91af7e6b42f6c0d3ab8302c613489d Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 22 Apr 2025 18:03:36 -0500 Subject: [PATCH 02/18] feat(session.clientdata): allow output_*() methods to be called without an id inside an output renderer --- shiny/session/_session.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 672b4455c..fa0512072 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -180,6 +180,7 @@ class Session(ABC): output: Outputs clientdata: ClientData current_output_id: str | None + "ID for the currently rendering output." # Could be done with a weak ref dict from root to all children. Then we could just # iterate over all modules and check the `.bookmark_exclude` list of each proxy @@ -1556,7 +1557,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. @@ -1573,7 +1574,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. @@ -1590,7 +1591,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. @@ -1606,7 +1607,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. @@ -1623,7 +1624,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. @@ -1640,7 +1641,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. @@ -1657,7 +1658,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. @@ -1685,9 +1686,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_*() must be either be supplied with an id " + "or called from 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]() From e97b0f05dde5e8190a57363b1fd4b2984eba2c00 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 22 Apr 2025 18:20:54 -0500 Subject: [PATCH 03/18] Update changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25256d205..957141c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### New features + +* The `Session` class gains a `current_output_id` attribute. If an output renderer is currently executing, this attribute will contain the relevant ID. (#1978) +* 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 * Improved the styling and readability of markdown tables rendered by `ui.Chat()` and `ui.MarkdownStream()`. (#1973) ## [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. From cb9fdde0c3a03b02455ffe5dd1931fe9debb3357 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 23 Apr 2025 09:36:10 -0500 Subject: [PATCH 04/18] Appease import lint --- shiny/session/_session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index fa0512072..7846de0c5 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,8 +50,9 @@ from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect, isolate +from ..reactive import Effect_, Value, effect from ..reactive import flush as reactive_flush +from ..reactive import isolate from ..reactive._core import lock from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT From 7f3e958d0f35681b594672ccab620279b0c8010c Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 23 Apr 2025 09:40:56 -0500 Subject: [PATCH 05/18] Make sure context manager always yields --- shiny/render/renderer/_renderer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index 30716b2da..a4e1f5915 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -414,7 +414,9 @@ def _current_output_id(self): from ...session import get_current_session session = get_current_session() - if session is not None: + if session is None: + yield + else: session.current_output_id = self._renderer.output_id yield session.current_output_id = None From 0908a4e0046b593aa2e1c9a0bfd7a4e08ea24022 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 23 Apr 2025 17:44:37 -0500 Subject: [PATCH 06/18] Make it a method instead of attribute. More careful cleanup --- shiny/render/renderer/_renderer.py | 10 +++++++--- shiny/session/_session.py | 14 +++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index a4e1f5915..385685f7a 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -416,7 +416,11 @@ def _current_output_id(self): session = get_current_session() if session is None: yield - else: - session.current_output_id = self._renderer.output_id + return + + old_id = session._current_output_id + try: + session._current_output_id = self._renderer.output_id yield - session.current_output_id = None + finally: + session._current_output_id = old_id diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 7846de0c5..5f5fd4a7d 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,9 +50,8 @@ from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect +from ..reactive import Effect_, Value, effect, isolate from ..reactive import flush as reactive_flush -from ..reactive import isolate from ..reactive._core import lock from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT @@ -180,8 +179,6 @@ class Session(ABC): input: Inputs output: Outputs clientdata: ClientData - current_output_id: str | None - "ID for the currently rendering output." # Could be done with a weak ref dict from root to all children. Then we could just # iterate over all modules and check the `.bookmark_exclude` list of each proxy @@ -190,6 +187,9 @@ class Session(ABC): user: str | None groups: list[str] | None + # Internal state for current_output_id() + _current_output_id: str | None + # TODO: not sure these should be directly exposed _outbound_message_queues: OutBoundMessageQueues _downloads: dict[str, DownloadInfo] @@ -380,6 +380,10 @@ 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)." + return self._current_output_id + @abstractmethod async def _unhandled_error(self, e: Exception) -> None: ... @@ -1691,7 +1695,7 @@ 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 + id = self._session.current_output_id() if id is None: raise ValueError( From 0088ff42553e4114b4459d9d64737ad3b4200560 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 23 Apr 2025 17:50:54 -0500 Subject: [PATCH 07/18] Tweak wording --- CHANGELOG.md | 2 +- shiny/session/_session.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 957141c86..7c8ded0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features -* The `Session` class gains a `current_output_id` attribute. If an output renderer is currently executing, this attribute will contain the relevant ID. (#1978) * 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) +* The `Session` class gains a `.current_output_id()` method. It returns the ID of the currently executing output renderer (if any). (#1978) ### Improvements diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 5f5fd4a7d..f226e6f25 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1699,8 +1699,8 @@ def _read_output(self, id: str | None, key: str) -> str | None: if id is None: raise ValueError( - "session.clientdata.output_*() must be either be supplied with an id " - "or called from within an output renderer." + "session.clientdata.output_*() requires an id when not called within " + "an output renderer." ) input_id = ResolvedId(f".clientdata_output_{id}_{key}") From ce79a5f299abfd7b0512f1fa53da1ef7813d29a5 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 23 Apr 2025 18:13:54 -0500 Subject: [PATCH 08/18] Bugfix and add test --- shiny/session/_session.py | 2 +- .../shiny/session/current_output_info/app.py | 30 +++++++++++++++++++ .../test_current_output_info.py | 29 ++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/playwright/shiny/session/current_output_info/app.py create mode 100644 tests/playwright/shiny/session/current_output_info/test_current_output_info.py diff --git a/shiny/session/_session.py b/shiny/session/_session.py index f226e6f25..33a1109d9 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -188,7 +188,7 @@ class Session(ABC): groups: list[str] | None # Internal state for current_output_id() - _current_output_id: str | None + _current_output_id: str | None = None # TODO: not sure these should be directly exposed _outbound_message_queues: OutBoundMessageQueues 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..e72d6cc55 --- /dev/null +++ b/tests/playwright/shiny/session/current_output_info/app.py @@ -0,0 +1,30 @@ +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 text1(): + id = session.current_output_id() or "None" + return f"Output ID: {id}" + + @output(id="text2") + @render.text + def _(): + id = session.current_output_id() or "None" + return f"Output ID: {id}" + + @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..d1fffa0d4 --- /dev/null +++ b/tests/playwright/shiny/session/current_output_info/test_current_output_info.py @@ -0,0 +1,29 @@ +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 the output ID is displayed correctly in the UI + text1 = controller.OutputText(page, "text1") + text2 = controller.OutputText(page, "text2") + + text1.expect_value("Output ID: text1") + text2.expect_value("Output ID: text2") + + # 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)") From 11e8a7092b024c4a8ef64f9ee9b938ed929fa9a5 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 23 Apr 2025 18:16:11 -0500 Subject: [PATCH 09/18] Import lint --- shiny/session/_session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 33a1109d9..3e6b3b269 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,8 +50,9 @@ from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect, isolate +from ..reactive import Effect_, Value, effect from ..reactive import flush as reactive_flush +from ..reactive import isolate from ..reactive._core import lock from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT From 1a9ed7f4c81db170573bf6360843c94affbb962d Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 1 May 2025 13:02:41 -0500 Subject: [PATCH 10/18] Address feedback --- CHANGELOG.md | 1 - shiny/render/renderer/_renderer.py | 10 +++++----- shiny/session/_session.py | 12 +++++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8ded0fb..6095738d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ 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) -* The `Session` class gains a `.current_output_id()` method. It returns the ID of the currently executing output renderer (if any). (#1978) ### Improvements diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index 385685f7a..76f65ce84 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -366,7 +366,7 @@ async def __call__(self) -> IT | None: """ Call the asynchronous function. """ - with self._current_output_id(): + with self._current_renderer(): return await self._fn() def is_async(self) -> bool: @@ -410,7 +410,7 @@ def get_sync_fn(self) -> Callable[[], IT | None]: return sync_fn @contextmanager - def _current_output_id(self): + def _current_renderer(self): from ...session import get_current_session session = get_current_session() @@ -418,9 +418,9 @@ def _current_output_id(self): yield return - old_id = session._current_output_id + old_renderer = session._current_renderer try: - session._current_output_id = self._renderer.output_id + session._current_renderer = self._renderer yield finally: - session._current_output_id = old_id + session._current_renderer = old_renderer diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 3e6b3b269..e8dad2471 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,9 +50,8 @@ from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect +from ..reactive import Effect_, Value, effect, isolate from ..reactive import flush as reactive_flush -from ..reactive import isolate from ..reactive._core import lock from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT @@ -189,7 +188,7 @@ class Session(ABC): groups: list[str] | None # Internal state for current_output_id() - _current_output_id: str | None = None + _current_renderer: Renderer[Any] | None = None # TODO: not sure these should be directly exposed _outbound_message_queues: OutBoundMessageQueues @@ -381,9 +380,12 @@ def on_flushed( A function that can be used to cancel the registration. """ - def current_output_id(self) -> str | None: + def _current_output_id(self) -> str | None: "Returns the id of the currently executing output renderer (if any)." - return self._current_output_id + if self._current_renderer is None: + return None + else: + return self._current_renderer.output_id @abstractmethod async def _unhandled_error(self, e: Exception) -> None: ... From 6b07a8e350c2b0e6180f22b78e3a1ddbd3d2a934 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 1 May 2025 13:07:22 -0500 Subject: [PATCH 11/18] Update tests --- .../shiny/session/current_output_info/app.py | 11 ----------- .../current_output_info/test_current_output_info.py | 7 ------- 2 files changed, 18 deletions(-) diff --git a/tests/playwright/shiny/session/current_output_info/app.py b/tests/playwright/shiny/session/current_output_info/app.py index e72d6cc55..58c7b338b 100644 --- a/tests/playwright/shiny/session/current_output_info/app.py +++ b/tests/playwright/shiny/session/current_output_info/app.py @@ -10,17 +10,6 @@ def server(input: Inputs, output: Outputs, session: Session): - @render.text - def text1(): - id = session.current_output_id() or "None" - return f"Output ID: {id}" - - @output(id="text2") - @render.text - def _(): - id = session.current_output_id() or "None" - return f"Output ID: {id}" - @render.text def info(): bg_color = session.clientdata.output_bg_color() 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 index d1fffa0d4..6d015b510 100644 --- 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 @@ -8,13 +8,6 @@ def test_current_output_info(page: Page, local_app: ShinyAppProc) -> None: page.goto(local_app.url) - # Check that the output ID is displayed correctly in the UI - text1 = controller.OutputText(page, "text1") - text2 = controller.OutputText(page, "text2") - - text1.expect_value("Output ID: text1") - text2.expect_value("Output ID: text2") - # Check that we can get background color from clientdata info = controller.OutputText(page, "info") info.expect_value("BG color: rgb(255, 255, 255)") From 58666000b1993a0b6114716bafaeeb112112eb68 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 1 May 2025 13:10:20 -0500 Subject: [PATCH 12/18] Run isort --- shiny/session/_session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index e8dad2471..0dc149759 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,8 +50,9 @@ from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect, isolate +from ..reactive import Effect_, Value, effect from ..reactive import flush as reactive_flush +from ..reactive import isolate from ..reactive._core import lock from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT From 4de9a7f6215c9383c81cfde7f5d662067c91e56c Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 1 May 2025 13:17:53 -0500 Subject: [PATCH 13/18] Fix --- shiny/session/_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 0dc149759..5aa551424 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1699,7 +1699,7 @@ 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() + id = self._session._current_output_id() if id is None: raise ValueError( From 25f81f355109129a62f158861bad813e0250c7df Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 24 Jul 2025 14:28:59 -0400 Subject: [PATCH 14/18] Move context manager from `AsyncValueFn`'s call to `Outputs`'s call --- shiny/render/_express.py | 2 +- shiny/render/renderer/_renderer.py | 24 ++---------------------- shiny/session/_session.py | 22 +++++++++++++++++++++- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/shiny/render/_express.py b/shiny/render/_express.py index 7c414929c..ab5139758 100644 --- a/shiny/render/_express.py +++ b/shiny/render/_express.py @@ -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, self) + async_fn = AsyncValueFn(fn) if async_fn.is_async(): raise TypeError( "@render.express does not support async functions. Use @render.ui instead." diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index 4e2037368..ce3cf3e26 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -1,6 +1,5 @@ from __future__ import annotations -from contextlib import contextmanager from typing import ( TYPE_CHECKING, Any, @@ -163,7 +162,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) + self.fn = AsyncValueFn(_fn) # Copy over function name as it is consistent with how Session and Output # retrieve function names @@ -348,7 +347,6 @@ class AsyncValueFn(Generic[IT]): def __init__( self, fn: Callable[[], IT | None] | Callable[[], Awaitable[IT | None]], - renderer: Renderer[Any], ): if isinstance(fn, AsyncValueFn): raise TypeError( @@ -357,14 +355,12 @@ 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. """ - with self._current_renderer(): - return await self._fn() + return await self._fn() def is_async(self) -> bool: """ @@ -405,19 +401,3 @@ 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 diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 5aa551424..d5e3b1842 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -22,6 +22,7 @@ AsyncIterable, Awaitable, Callable, + Generator, Iterable, Literal, Optional, @@ -550,6 +551,9 @@ def __init__( self.user: str | None = None self.groups: list[str] | None = None + + self._current_renderer = None + credentials_json: str = "" if "shiny-server-credentials" in self.http_conn.headers: credentials_json = self.http_conn.headers["shiny-server-credentials"] @@ -1817,8 +1821,13 @@ async def output_obs(): ) try: - value = await renderer.render() + # Temporarily set the renderer so `clientdata.output_*()` can access it without an `id` + with self._session_renderer(session, renderer): + # 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} @@ -1894,3 +1903,14 @@ def _should_suspend(self, name: str) -> bool: return self._outputs[name].suspend_when_hidden and self._session._is_hidden( name ) + + @contextlib.contextmanager + def _session_renderer( + self, session: Session, renderer: Renderer[Any] + ) -> Generator[None, None, None]: + old_renderer = session._current_renderer + try: + session._current_renderer = renderer + yield + finally: + session._current_renderer = old_renderer From e472bb29c7ad682ac696f4245db6b4d017f16331 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 24 Jul 2025 14:46:11 -0400 Subject: [PATCH 15/18] Move renderer context manager into clientdata where it is used --- shiny/session/_session.py | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index d5e3b1842..177856633 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -189,9 +189,6 @@ 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] @@ -382,13 +379,6 @@ 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: ... @@ -552,8 +542,6 @@ def __init__( self.user: str | None = None self.groups: list[str] | None = None - self._current_renderer = None - credentials_json: str = "" if "shiny-server-credentials" in self.http_conn.headers: credentials_json = self.http_conn.headers["shiny-server-credentials"] @@ -1519,8 +1507,18 @@ class ClientData: If a method is called outside of a reactive context. """ + @contextlib.contextmanager + def _renderer_ctx(self, renderer: Renderer[Any]) -> Generator[None, None, None]: + old_renderer = self._current_renderer + try: + self._current_renderer = renderer + yield + finally: + self._current_renderer = old_renderer + def __init__(self, session: Session) -> None: self._session: Session = session + self._current_renderer: Renderer[Any] | None = None def url_hash(self) -> str: """ @@ -1702,8 +1700,8 @@ def _read_input(self, key: str) -> str: 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 and self._current_renderer is not None: + id = self._current_renderer.output_id if id is None: raise ValueError( @@ -1822,7 +1820,7 @@ async def output_obs(): try: # Temporarily set the renderer so `clientdata.output_*()` can access it without an `id` - with self._session_renderer(session, renderer): + with session.clientdata._renderer_ctx(renderer): # Call the app's renderer function value = await renderer.render() @@ -1903,14 +1901,3 @@ def _should_suspend(self, name: str) -> bool: return self._outputs[name].suspend_when_hidden and self._session._is_hidden( name ) - - @contextlib.contextmanager - def _session_renderer( - self, session: Session, renderer: Renderer[Any] - ) -> Generator[None, None, None]: - old_renderer = session._current_renderer - try: - session._current_renderer = renderer - yield - finally: - session._current_renderer = old_renderer From 5040860cc1afb8f90cac885073fd0941529176ac Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 24 Jul 2025 14:50:37 -0400 Subject: [PATCH 16/18] Update _session.py --- shiny/session/_session.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 177856633..819aa3f16 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1507,15 +1507,6 @@ class ClientData: If a method is called outside of a reactive context. """ - @contextlib.contextmanager - def _renderer_ctx(self, renderer: Renderer[Any]) -> Generator[None, None, None]: - old_renderer = self._current_renderer - try: - self._current_renderer = renderer - yield - finally: - self._current_renderer = old_renderer - def __init__(self, session: Session) -> None: self._session: Session = session self._current_renderer: Renderer[Any] | None = None @@ -1715,6 +1706,32 @@ def _read_output(self, id: str | None, key: str) -> str | None: else: return None + @contextlib.contextmanager + def _renderer_ctx(self, renderer: Renderer[Any]) -> Generator[None, None, None]: + """ + Context manager to temporarily set the current renderer. + + This is used to allow `session.clientdata.output_*()` methods to access the + current renderer's output id without needing to pass it explicitly. + + Parameters + ---------- + renderer + The renderer to set as the current renderer. + + Yields + ------ + None + The context manager does not return any value, but temporarily sets the + current renderer to the provided renderer. + """ + old_renderer = self._current_renderer + try: + self._current_renderer = renderer + yield + finally: + self._current_renderer = old_renderer + @staticmethod def _check_current_context(key: str) -> None: try: @@ -1819,7 +1836,6 @@ async def output_obs(): ) try: - # Temporarily set the renderer so `clientdata.output_*()` can access it without an `id` with session.clientdata._renderer_ctx(renderer): # Call the app's renderer function value = await renderer.render() From 464394d1f062a336616b35e2026d76ef804e67bb Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 24 Jul 2025 15:48:51 -0400 Subject: [PATCH 17/18] Add and test for module support within client data --- shiny/session/_session.py | 61 +++++++++---------- .../shiny/session/current_output_info/app.py | 18 +++++- .../test_current_output_info.py | 3 + 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 819aa3f16..f13a970b3 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1220,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 @@ -1509,7 +1510,7 @@ class ClientData: def __init__(self, session: Session) -> None: self._session: Session = session - self._current_renderer: Renderer[Any] | None = None + self._current_output_name: ResolvedId | None = None def url_hash(self) -> str: """ @@ -1559,7 +1560,7 @@ def pixelratio(self) -> float: """ return cast(int, self._read_input("pixelratio")) - def output_height(self, id: Optional[str] = None) -> float | None: + def output_height(self, id: Optional[Id] = None) -> float | None: """ Reactively read the height of an output. @@ -1576,7 +1577,7 @@ def output_height(self, id: Optional[str] = None) -> float | None: """ return cast(float, self._read_output(id, "height")) - def output_width(self, id: Optional[str] = None) -> float | None: + def output_width(self, id: Optional[Id] = None) -> float | None: """ Reactively read the width of an output. @@ -1593,7 +1594,7 @@ def output_width(self, id: Optional[str] = None) -> float | None: """ return cast(float, self._read_output(id, "width")) - def output_hidden(self, id: Optional[str] = None) -> bool | None: + def output_hidden(self, id: Optional[Id] = None) -> bool | None: """ Reactively read whether an output is hidden. @@ -1609,7 +1610,7 @@ def output_hidden(self, id: Optional[str] = None) -> bool | None: """ return cast(bool, self._read_output(id, "hidden")) - def output_bg_color(self, id: Optional[str] = None) -> str | None: + def output_bg_color(self, id: Optional[Id] = None) -> str | None: """ Reactively read the background color of an output. @@ -1626,7 +1627,7 @@ def output_bg_color(self, id: Optional[str] = None) -> str | None: """ return cast(str, self._read_output(id, "bg")) - def output_fg_color(self, id: Optional[str] = None) -> str | None: + def output_fg_color(self, id: Optional[Id] = None) -> str | None: """ Reactively read the foreground color of an output. @@ -1643,7 +1644,7 @@ def output_fg_color(self, id: Optional[str] = None) -> str | None: """ return cast(str, self._read_output(id, "fg")) - def output_accent_color(self, id: Optional[str] = None) -> str | None: + def output_accent_color(self, id: Optional[Id] = None) -> str | None: """ Reactively read the accent color of an output. @@ -1660,7 +1661,7 @@ def output_accent_color(self, id: Optional[str] = None) -> str | None: """ return cast(str, self._read_output(id, "accent")) - def output_font(self, id: Optional[str] = None) -> str | None: + def output_font(self, id: Optional[Id] = None) -> str | None: """ Reactively read the font(s) of an output. @@ -1681,18 +1682,19 @@ 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 | None, key: str) -> str | None: + def _read_output(self, id: Id | None, key: str) -> str | None: self._check_current_context(f"output_{key}") - if id is None and self._current_renderer is not None: - id = self._current_renderer.output_id + # 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( @@ -1700,37 +1702,30 @@ def _read_output(self, id: str | None, key: str) -> str | None: "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 _renderer_ctx(self, renderer: Renderer[Any]) -> Generator[None, None, None]: + def _output_name_ctx(self, output_name: ResolvedId) -> Generator[None, None, None]: """ - Context manager to temporarily set the current renderer. + Context manager to temporarily set the output name. This is used to allow `session.clientdata.output_*()` methods to access the - current renderer's output id without needing to pass it explicitly. - - Parameters - ---------- - renderer - The renderer to set as the current renderer. - - Yields - ------ - None - The context manager does not return any value, but temporarily sets the - current renderer to the provided renderer. + current output name without needing to pass it explicitly. """ - old_renderer = self._current_renderer + old_output_name = self._current_output_name try: - self._current_renderer = renderer + self._current_output_name = output_name yield finally: - self._current_renderer = old_renderer + self._current_output_name = old_output_name @staticmethod def _check_current_context(key: str) -> None: @@ -1836,7 +1831,7 @@ async def output_obs(): ) try: - with session.clientdata._renderer_ctx(renderer): + with session.clientdata._output_name_ctx(output_name): # Call the app's renderer function value = await renderer.render() diff --git a/tests/playwright/shiny/session/current_output_info/app.py b/tests/playwright/shiny/session/current_output_info/app.py index 58c7b338b..2f85cedb7 100644 --- a/tests/playwright/shiny/session/current_output_info/app.py +++ b/tests/playwright/shiny/session/current_output_info/app.py @@ -1,14 +1,30 @@ -from shiny import App, Inputs, Outputs, Session, render, ui +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("text1"), ui.output_text("text2"), 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(): 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 index 6d015b510..41f3d63ee 100644 --- 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 @@ -10,7 +10,9 @@ def test_current_output_info(page: Page, local_app: ShinyAppProc) -> None: # 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") @@ -20,3 +22,4 @@ def test_current_output_info(page: Page, local_app: ShinyAppProc) -> None: # 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)") From a1a09621cb81bd965a2fbcb85efcf37020e29068 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 24 Jul 2025 15:50:40 -0400 Subject: [PATCH 18/18] changle and clean up app --- CHANGELOG.md | 2 ++ tests/playwright/shiny/session/current_output_info/app.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a9f8bed..d03de25e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,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) diff --git a/tests/playwright/shiny/session/current_output_info/app.py b/tests/playwright/shiny/session/current_output_info/app.py index 2f85cedb7..fbbbf6244 100644 --- a/tests/playwright/shiny/session/current_output_info/app.py +++ b/tests/playwright/shiny/session/current_output_info/app.py @@ -16,8 +16,6 @@ def info2(): 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"), mod_ui("mod1"), )