From 3ec1a0bbc7a86a4ab4ac7ed07de3960c9ce032dc Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Oct 2025 18:09:58 -0500 Subject: [PATCH 1/5] feat: Add default param to reactive.value()'s .get() method; log a warning when reactive values are read without being set --- shiny/_main.py | 14 +++++ shiny/reactive/_reactives.py | 117 +++++++++++++++++++++++++++++++---- shiny/session/_session.py | 2 +- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/shiny/_main.py b/shiny/_main.py index f43421a71..266cbd6e8 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -4,6 +4,7 @@ import importlib import importlib.util import inspect +import logging import os import platform import re @@ -333,6 +334,8 @@ def run_app( log_config: dict[str, Any] = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) + setup_shiny_logger(log_config) + if reload_dirs is None: reload_dirs = [] if app_dir is not None: @@ -421,6 +424,17 @@ def run_app( ) +def setup_shiny_logger(log_config: dict[str, Any]) -> None: + log_config["loggers"]["shiny"] = { + "level": "INFO", + "handlers": ["default"], + } + + +def get_shiny_logger(): + return logging.getLogger("shiny") + + def setup_hot_reload( log_config: dict[str, Any], autoreload_port: int, diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 3b38a0762..029c5412b 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -72,7 +72,8 @@ class Value(Generic[T]): value An optional initial value. read_only - If ``True``, then the reactive value cannot be `set()`. + If ``True``, then the reactive value cannot be `set()`. For internal use, + this value may also be a string (of the input ID). Returns ------- @@ -107,29 +108,82 @@ class Value(Generic[T]): # - Value(1) works, with T is inferred to be int. @overload def __init__( - self, value: MISSING_TYPE = MISSING, *, read_only: bool = False + self, value: MISSING_TYPE = MISSING, *, read_only: bool | str = False ) -> None: ... @overload - def __init__(self, value: T, *, read_only: bool = False) -> None: ... + def __init__(self, value: T, *, read_only: bool | str = False) -> None: ... # If `value` is MISSING, then `get()` will raise a SilentException, until a new # value is set. Calling `unset()` will set the value to MISSING. def __init__( - self, value: T | MISSING_TYPE = MISSING, *, read_only: bool = False + self, + value: T | MISSING_TYPE = MISSING, + *, + read_only: bool | str = False, ) -> None: self._value: T | MISSING_TYPE = value - self._read_only: bool = read_only self._value_dependents: Dependents = Dependents() self._is_set_dependents: Dependents = Dependents() + if isinstance(read_only, str): + self._read_only = True + self._id = read_only + else: + self._read_only = read_only + self._id = None - def __call__(self) -> T: - return self.get() + @overload + def __call__( + self, *, default: MISSING_TYPE = MISSING, log_if_missing: bool = True + ) -> T: ... + + @overload + def __call__( + self, *, default: None = None, log_if_missing: bool = True + ) -> T | None: ... + + @overload + def __call__(self, *, default: T, log_if_missing: bool = True) -> T: ... + + def __call__( + self, + *, + default: MISSING_TYPE | T | None = MISSING, + log_if_missing: bool = True, + ) -> T | None: + return self.get(default=default, log_if_missing=log_if_missing) + + @overload + def get( + self, *, default: MISSING_TYPE = MISSING, log_if_missing: bool = True + ) -> T: ... - def get(self) -> T: + @overload + def get(self, *, default: None = None, log_if_missing: bool = True) -> T | None: ... + + @overload + def get(self, *, default: T, log_if_missing: bool = True) -> T: ... + + def get( + self, + *, + default: MISSING_TYPE | T | None = MISSING, + log_if_missing: bool = True, + ) -> T | None: """ Read the reactive value. + Parameters + ---------- + default + A default value to return if the reactive value is not set. If no default is + provided and the value is not set, then a + :class:`~shiny.types.SilentException` is raised. + log_if_missing + If ``True`` (the default), log an INFO message when a + :class:`~shiny.types.SilentException` is raised because the value is not + set. Set to ``False`` to suppress this logging. + Returns ------- : @@ -138,17 +192,21 @@ def get(self) -> T: Raises ------ :class:`~shiny.types.SilentException` - If the value is not set. + If the value is not set and no default is provided. RuntimeError If called from outside a reactive function. """ self._value_dependents.register() - if isinstance(self._value, MISSING_TYPE): - raise SilentException + if not isinstance(self._value, MISSING_TYPE): + return self._value + if not isinstance(default, MISSING_TYPE): + return default - return self._value + if log_if_missing: + self._log_missing_value_access(self._id) + raise SilentException def set(self, value: T) -> bool: """ @@ -221,6 +279,41 @@ def freeze(self) -> None: """ self._value = MISSING + @staticmethod + def _log_missing_value_access(id: str | None): + from .._main import get_shiny_logger + from ..session import get_current_session + + logger = get_shiny_logger() + + # Try to provide some context about where the missing value access occurred. + output_ctx = "" + session = get_current_session() + if session and not session.is_stub_session(): + name = session.clientdata._current_output_name + if name: + output_ctx = f"inside of the '{name}' render function" + + if id is None: + logger.warning( + f"Attempted to read a `reactive.value` {output_ctx}, but it is not " + "currently set to a value. As a result, a SilentException occurred. " + "To avoid the SilentException, either: 1. check if the value `.is_set()` " + "before reading, or 2. provide a default value when reading " + "e.g., `.get(default=0)`." + ) + else: + logger.warning( + f"Attempted to read `input.{id}()` {output_ctx}, before an input value " + "was actually available. As a result, a SilentException occurred. " + "This may be desirable behavior, but if not, consider the following:\n" + "1. Check that the input ID is correct and matches the ID used in the UI.\n" + "2. To change logic depending on whether or not an input is available, " + f"use `if '{id}' in input:` before reading the value.\n" + f"3. If a default value makes sense, try `input.{id}(default=...)`.\n" + f"To silence this message, do `input.{id}(log_if_missing=False)`." + ) + value = Value diff --git a/shiny/session/_session.py b/shiny/session/_session.py index f13a970b3..5072d8b25 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1388,7 +1388,7 @@ def __getitem__(self, key: str) -> Value[Any]: # dependencies on input values that haven't been received from client # yet. if key not in self._map: - self._map[key] = Value[Any](read_only=True) + self._map[key] = Value[Any](read_only=key) return self._map[key] From 15ee83de1c93a8bbd1fc1271204573497885fdf5 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 15 Oct 2025 18:16:35 -0500 Subject: [PATCH 2/5] Use an official warning instead of logging a warning --- shiny/_main.py | 14 ----------- shiny/reactive/_reactives.py | 46 +++++++++++++++++++----------------- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/shiny/_main.py b/shiny/_main.py index 266cbd6e8..f43421a71 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -4,7 +4,6 @@ import importlib import importlib.util import inspect -import logging import os import platform import re @@ -334,8 +333,6 @@ def run_app( log_config: dict[str, Any] = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) - setup_shiny_logger(log_config) - if reload_dirs is None: reload_dirs = [] if app_dir is not None: @@ -424,17 +421,6 @@ def run_app( ) -def setup_shiny_logger(log_config: dict[str, Any]) -> None: - log_config["loggers"]["shiny"] = { - "level": "INFO", - "handlers": ["default"], - } - - -def get_shiny_logger(): - return logging.getLogger("shiny") - - def setup_hot_reload( log_config: dict[str, Any], autoreload_port: int, diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 029c5412b..4e71785a1 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -134,41 +134,43 @@ def __init__( @overload def __call__( - self, *, default: MISSING_TYPE = MISSING, log_if_missing: bool = True + self, *, default: MISSING_TYPE = MISSING, warn_if_missing: bool = True ) -> T: ... @overload def __call__( - self, *, default: None = None, log_if_missing: bool = True + self, *, default: None = None, warn_if_missing: bool = True ) -> T | None: ... @overload - def __call__(self, *, default: T, log_if_missing: bool = True) -> T: ... + def __call__(self, *, default: T, warn_if_missing: bool = True) -> T: ... def __call__( self, *, default: MISSING_TYPE | T | None = MISSING, - log_if_missing: bool = True, + warn_if_missing: bool = True, ) -> T | None: - return self.get(default=default, log_if_missing=log_if_missing) + return self.get(default=default, warn_if_missing=warn_if_missing) @overload def get( - self, *, default: MISSING_TYPE = MISSING, log_if_missing: bool = True + self, *, default: MISSING_TYPE = MISSING, warn_if_missing: bool = True ) -> T: ... @overload - def get(self, *, default: None = None, log_if_missing: bool = True) -> T | None: ... + def get( + self, *, default: None = None, warn_if_missing: bool = True + ) -> T | None: ... @overload - def get(self, *, default: T, log_if_missing: bool = True) -> T: ... + def get(self, *, default: T, warn_if_missing: bool = True) -> T: ... def get( self, *, default: MISSING_TYPE | T | None = MISSING, - log_if_missing: bool = True, + warn_if_missing: bool = True, ) -> T | None: """ Read the reactive value. @@ -179,7 +181,7 @@ def get( A default value to return if the reactive value is not set. If no default is provided and the value is not set, then a :class:`~shiny.types.SilentException` is raised. - log_if_missing + warn_if_missing If ``True`` (the default), log an INFO message when a :class:`~shiny.types.SilentException` is raised because the value is not set. Set to ``False`` to suppress this logging. @@ -204,8 +206,8 @@ def get( if not isinstance(default, MISSING_TYPE): return default - if log_if_missing: - self._log_missing_value_access(self._id) + if warn_if_missing: + self._throw_missing_value_warning(self._id) raise SilentException def set(self, value: T) -> bool: @@ -280,12 +282,9 @@ def freeze(self) -> None: self._value = MISSING @staticmethod - def _log_missing_value_access(id: str | None): - from .._main import get_shiny_logger + def _throw_missing_value_warning(id: str | None): from ..session import get_current_session - logger = get_shiny_logger() - # Try to provide some context about where the missing value access occurred. output_ctx = "" session = get_current_session() @@ -295,15 +294,16 @@ def _log_missing_value_access(id: str | None): output_ctx = f"inside of the '{name}' render function" if id is None: - logger.warning( + warnings.warn( f"Attempted to read a `reactive.value` {output_ctx}, but it is not " "currently set to a value. As a result, a SilentException occurred. " - "To avoid the SilentException, either: 1. check if the value `.is_set()` " - "before reading, or 2. provide a default value when reading " - "e.g., `.get(default=0)`." + "To avoid this warning, provide a default value when creating (or reading)" + "a reactive value, e.g., `val = reactive.value(None)` ", + ReactiveWarning, + stacklevel=4, ) else: - logger.warning( + warnings.warn( f"Attempted to read `input.{id}()` {output_ctx}, before an input value " "was actually available. As a result, a SilentException occurred. " "This may be desirable behavior, but if not, consider the following:\n" @@ -311,7 +311,9 @@ def _log_missing_value_access(id: str | None): "2. To change logic depending on whether or not an input is available, " f"use `if '{id}' in input:` before reading the value.\n" f"3. If a default value makes sense, try `input.{id}(default=...)`.\n" - f"To silence this message, do `input.{id}(log_if_missing=False)`." + f"To silence this message, set `input.{id}(warn_if_missing=False)`.", + ReactiveWarning, + stacklevel=4, ) From 883febfe3eb362deddea5a646123e594b55c1bce Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Oct 2025 09:46:19 -0500 Subject: [PATCH 3/5] Add tests, update changelog --- CHANGELOG.md | 10 ++ shiny/reactive/_reactives.py | 4 +- tests/pytest/test_reactives.py | 177 ++++++++++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25444d59d..bf21b6260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Shiny for Python 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] + +### New features + +* A `default` parameter is now available to reactive reads (e.g., `input.val(default=None)`). When provided, and the read results in a `MISSING` value, the `default` value is returned instead of raising a `SilentException` (#2100) + +### Improvements + +* A warning now occurs when a reactive read (e.g., `input.val()`) results in a `SilentException`. This most commonly occurs when attempting to read an input that doesn't exist in the UI. Since this behavior is sometimes desirable (mainly for dynamic UI reasons), the warning can be suppressed via `input.val(warn_if_missing=False)`. (#2100) + ## [1.5.0] - 2025-09-11 ### New features diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 4e71785a1..53077e6b0 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -72,8 +72,8 @@ class Value(Generic[T]): value An optional initial value. read_only - If ``True``, then the reactive value cannot be `set()`. For internal use, - this value may also be a string (of the input ID). + If ``True``, then the reactive value cannot be `set()`. For internal purposes, + a string containing an input ID can also be provided. Returns ------- diff --git a/tests/pytest/test_reactives.py b/tests/pytest/test_reactives.py index 44ef9b48a..7fab30f2e 100644 --- a/tests/pytest/test_reactives.py +++ b/tests/pytest/test_reactives.py @@ -101,14 +101,14 @@ async def test_reactive_value_unset(): with isolate(): assert v.is_set() is False with pytest.raises(SilentException): - v() + v(warn_if_missing=False) val: int = 0 @effect() def o(): nonlocal val - val = v() + val = v(warn_if_missing=False) await flush() assert o._exec_count == 1 @@ -128,7 +128,7 @@ def o(): with isolate(): assert v.is_set() is False with pytest.raises(SilentException): - v() + v(warn_if_missing=False) # ====================================================================== @@ -1021,7 +1021,7 @@ async def test_event_silent_exception(): x = Value[bool]() @effect() - @event(x) + @event(lambda: x(warn_if_missing=False)) def _(): nonlocal n_times n_times += 1 @@ -1052,7 +1052,7 @@ async def test_event_silent_exception_async(): async def req_fn() -> int: await asyncio.sleep(0) - x() + x(warn_if_missing=False) return 1234 @effect() @@ -1365,3 +1365,170 @@ async def obs(): a.set(4) await flush() assert obs._exec_count == 2 + + +# ====================================================================== +# reactive.Value.get() with default parameter +# ====================================================================== +@pytest.mark.asyncio +async def test_reactive_value_get_with_default(): + # Test with unset value - should return default + v = Value[int]() + + with isolate(): + assert v.get(default=42) == 42 + assert v.get(default=0) == 0 + assert v.get(default=None) is None + + # Test after setting value - should return value, not default + v.set(100) + with isolate(): + assert v.get(default=42) == 100 + assert v.get(default=0) == 100 + + # Test after unsetting - should return default again + v.unset() + with isolate(): + assert v.get(default=99) == 99 + + +@pytest.mark.asyncio +async def test_reactive_value_call_with_default(): + # Test that __call__ also supports default parameter + v = Value[str]() + + with isolate(): + assert v(default="hello") == "hello" + assert v(default=None) is None + + v.set("world") + with isolate(): + assert v(default="hello") == "world" + + v.unset() + with isolate(): + assert v(default="fallback") == "fallback" + + +@pytest.mark.asyncio +async def test_reactive_value_default_in_reactive_context(): + # Test using default parameter within reactive contexts + v = Value[int]() + result = None + + @effect() + def obs(): + nonlocal result + result = v.get(default=10) + + await flush() + assert result == 10 + assert obs._exec_count == 1 + + # Setting the value should invalidate and return new value + v.set(50) + await flush() + assert result == 50 + assert obs._exec_count == 2 + + # Unsetting should use default again + v.unset() + await flush() + assert result == 10 + assert obs._exec_count == 3 + + +@pytest.mark.asyncio +async def test_reactive_value_no_default_raises_silent_exception(): + # Test that without default, unset value still raises SilentException + v = Value[int]() + + with isolate(): + with pytest.raises(SilentException): + v.get() + with pytest.raises(SilentException): + v() + + +# ====================================================================== +# reactive.Value warning behavior +# ====================================================================== +@pytest.mark.asyncio +async def test_reactive_value_missing_warning(): + # Test that reading unset value with warn_if_missing=True logs warning + v = Value[int]() + + with isolate(): + with pytest.warns( + ReactiveWarning, match="Attempted to read a `reactive.value`" + ): + try: + v.get() + except SilentException: + pass + + +@pytest.mark.asyncio +async def test_reactive_value_suppress_warning(): + # Test that warn_if_missing=False suppresses the warning + v = Value[int]() + + with isolate(): + # Should not produce a warning + with pytest.raises(SilentException): + v.get(warn_if_missing=False) + + +@pytest.mark.asyncio +async def test_reactive_value_default_no_warning(): + # Test that providing a default doesn't generate warning + v = Value[int]() + + with isolate(): + # Should not raise or warn when default is provided + result = v.get(default=42) + assert result == 42 + + +@pytest.mark.asyncio +async def test_input_value_missing_warning(): + # Test that reading unset input value shows input-specific warning + conn = MockConnection() + session = App(ui.TagList(), None)._create_session(conn) + input = session.input + + # Access an input that doesn't exist yet + with isolate(): + with pytest.warns( + ReactiveWarning, match=r"Attempted to read `input\.test_input\(\)`" + ): + try: + input.test_input() + except SilentException: + pass + + +@pytest.mark.asyncio +async def test_input_value_with_default(): + # Test that input values can use default parameter + conn = MockConnection() + session = App(ui.TagList(), None)._create_session(conn) + input = session.input + + with isolate(): + # Should return default without warning or exception + result = input.test_input(default="fallback") + assert result == "fallback" + + +@pytest.mark.asyncio +async def test_input_value_suppress_warning(): + # Test that warn_if_missing=False suppresses input warning + conn = MockConnection() + session = App(ui.TagList(), None)._create_session(conn) + input = session.input + + with isolate(): + # Should not produce a warning + with pytest.raises(SilentException): + input.test_input(warn_if_missing=False) From 138622988a0daa82bd22cb6d58344dafdbbb9e9d Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Oct 2025 10:08:36 -0500 Subject: [PATCH 4/5] Don't warn in DataFrame's internal reactive reads --- shiny/render/_data_frame.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/shiny/render/_data_frame.py b/shiny/render/_data_frame.py index b70534c1c..881500513 100644 --- a/shiny/render/_data_frame.py +++ b/shiny/render/_data_frame.py @@ -498,7 +498,9 @@ def cell_selection(self) -> CellSelection: """ browser_cell_selection = cast( BrowserCellSelection, - self._get_session().input[f"{self.output_id}_cell_selection"](), + self._get_session().input[f"{self.output_id}_cell_selection"]( + warn_if_missing=False + ), ) cell_selection = as_cell_selection( @@ -527,7 +529,7 @@ def data_view_rows(self) -> tuple[int, ...]: """ input_data_view_rows = self._get_session().input[ f"{self.output_id}_data_view_rows" - ]() + ](warn_if_missing=False) return tuple(input_data_view_rows) # @reactive_calc_method @@ -540,7 +542,9 @@ def sort(self) -> tuple[ColumnSort, ...]: : An array of `col`umn number and _is `desc`ending_ information. """ - column_sort = self._get_session().input[f"{self.output_id}_column_sort"]() + column_sort = self._get_session().input[f"{self.output_id}_column_sort"]( + warn_if_missing=False + ) return tuple(column_sort) # @reactive_calc_method @@ -553,7 +557,9 @@ def filter(self) -> tuple[ColumnFilter, ...]: : An array of `col`umn number and `value` information. If the column type is a number, a tuple of `(min, max)` is used for `value`. If no min (or max) value is set, `None` is used in its place. If the column type is a string, the string value is used for `value`. """ - column_filter = self._get_session().input[f"{self.output_id}_column_filter"]() + column_filter = self._get_session().input[f"{self.output_id}_column_filter"]( + warn_if_missing=False + ) return tuple(column_filter) def _reset_reactives(self) -> None: From 8658b644d667316e008c278d5b0365faad6711f7 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Oct 2025 10:28:39 -0500 Subject: [PATCH 5/5] Fix message spacing --- shiny/reactive/_reactives.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index 53077e6b0..c2eab374d 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -297,8 +297,8 @@ def _throw_missing_value_warning(id: str | None): warnings.warn( f"Attempted to read a `reactive.value` {output_ctx}, but it is not " "currently set to a value. As a result, a SilentException occurred. " - "To avoid this warning, provide a default value when creating (or reading)" - "a reactive value, e.g., `val = reactive.value(None)` ", + "To avoid this warning, provide a default value when creating (or " + "reading) a reactive value, e.g., `val = reactive.value(None)` ", ReactiveWarning, stacklevel=4, )