From 406d68b46e870f1b18bc7aeab026a2dbb190c2c0 Mon Sep 17 00:00:00 2001 From: Ken McGrady Date: Mon, 20 Oct 2025 07:39:21 -0700 Subject: [PATCH] Migrate spinner to a Mixin --- lib/streamlit/__init__.py | 10 +- lib/streamlit/delta_generator.py | 2 + lib/streamlit/elements/spinner.py | 216 ++++++++++--------- lib/streamlit/runtime/caching/cache_utils.py | 7 +- lib/tests/streamlit/delta_generator_test.py | 1 - lib/tests/streamlit/spinner_test.py | 18 +- 6 files changed, 132 insertions(+), 122 deletions(-) diff --git a/lib/streamlit/__init__.py b/lib/streamlit/__init__.py index 428075818e8..9e643fac495 100644 --- a/lib/streamlit/__init__.py +++ b/lib/streamlit/__init__.py @@ -87,10 +87,10 @@ status_container_cls=_StatusContainer, dialog_container_cls=_Dialog, ) -_main = _dg_singleton._main_dg -sidebar = _dg_singleton._sidebar_dg -_event = _dg_singleton._event_dg -_bottom = _dg_singleton._bottom_dg +_main: _DeltaGenerator = _dg_singleton._main_dg +sidebar: _DeltaGenerator = _dg_singleton._sidebar_dg +_event: _DeltaGenerator = _dg_singleton._event_dg +_bottom: _DeltaGenerator = _dg_singleton._bottom_dg from streamlit.elements.dialog_decorator import dialog_decorator as _dialog_decorator @@ -135,7 +135,6 @@ from streamlit.commands.logo import logo as logo from streamlit.commands.navigation import navigation as navigation from streamlit.navigation.page import Page as Page -from streamlit.elements.spinner import spinner as spinner from streamlit.commands.page_config import set_page_config as set_page_config from streamlit.commands.execution_control import ( @@ -222,6 +221,7 @@ def _update_logger() -> None: slider = _main.slider snow = _main.snow space = _main.space +spinner = _main.spinner subheader = _main.subheader success = _main.success table = _main.table diff --git a/lib/streamlit/delta_generator.py b/lib/streamlit/delta_generator.py index ecdd524d122..4c8d90be86d 100644 --- a/lib/streamlit/delta_generator.py +++ b/lib/streamlit/delta_generator.py @@ -78,6 +78,7 @@ from streamlit.elements.pyplot import PyplotMixin from streamlit.elements.snow import SnowMixin from streamlit.elements.space import SpaceMixin +from streamlit.elements.spinner import SpinnerMixin from streamlit.elements.text import TextMixin from streamlit.elements.toast import ToastMixin from streamlit.elements.vega_charts import VegaChartsMixin @@ -210,6 +211,7 @@ class DeltaGenerator( SliderMixin, SnowMixin, SpaceMixin, + SpinnerMixin, JsonMixin, TextMixin, TextWidgetsMixin, diff --git a/lib/streamlit/elements/spinner.py b/lib/streamlit/elements/spinner.py index 38447d0f94f..78cae2ca8b2 100644 --- a/lib/streamlit/elements/spinner.py +++ b/lib/streamlit/elements/spinner.py @@ -16,9 +16,8 @@ import contextlib import threading -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, cast -import streamlit as st from streamlit.elements.lib.layout_utils import ( LayoutConfig, Width, @@ -29,110 +28,119 @@ if TYPE_CHECKING: from collections.abc import Iterator + from streamlit.delta_generator import DeltaGenerator + # Set the message 0.5 seconds in the future to avoid annoying # flickering if this spinner runs too quickly. DELAY_SECS: Final = 0.5 -@contextlib.contextmanager -def spinner( - text: str = "In progress...", - *, - show_time: bool = False, - _cache: bool = False, - width: Width = "content", -) -> Iterator[None]: - """Display a loading spinner while executing a block of code. - - Parameters - ---------- - text : str - The text to display next to the spinner. This defaults to - ``"In progress..."``. - - The text can optionally contain GitHub-flavored Markdown. Syntax - information can be found at: https://github.github.com/gfm. - - See the ``body`` parameter of |st.markdown|_ for additional, supported - Markdown directives. - - .. |st.markdown| replace:: ``st.markdown`` - .. _st.markdown: https://docs.streamlit.io/develop/api-reference/text/st.markdown - - show_time : bool - Whether to show the elapsed time next to the spinner text. If this is - ``False`` (default), no time is displayed. If this is ``True``, - elapsed time is displayed with a precision of 0.1 seconds. The time - format is not configurable. - - width : "content", "stretch", or int - The width of the spinner element. This can be one of the following: - - - ``"content"`` (default): The width of the element matches the - width of its content, but doesn't exceed the width of the parent - container. - - ``"stretch"``: The width of the element matches the width of the - parent container. - - An integer specifying the width in pixels: The element has a - fixed width. If the specified width is greater than the width of - the parent container, the width of the element matches the width - of the parent container. - - Example - ------- - >>> import streamlit as st - >>> import time - >>> - >>> with st.spinner("Wait for it...", show_time=True): - >>> time.sleep(5) - >>> st.success("Done!") - >>> st.button("Rerun") - - .. output :: - https://doc-spinner.streamlit.app/ - height: 210px - - """ - from streamlit.proto.Spinner_pb2 import Spinner as SpinnerProto - from streamlit.string_util import clean_text - - validate_width(width, allow_content=True) - layout_config = LayoutConfig(width=width) - - message = st.empty() - - display_message = True - display_message_lock = threading.Lock() - - try: - - def set_message() -> None: - with display_message_lock: - if display_message: - spinner_proto = SpinnerProto() - spinner_proto.text = clean_text(text) - spinner_proto.cache = _cache - spinner_proto.show_time = show_time - message._enqueue( - "spinner", spinner_proto, layout_config=layout_config - ) - - add_script_run_ctx(threading.Timer(DELAY_SECS, set_message)).start() - - # Yield control back to the context. - yield - finally: - if display_message_lock: - with display_message_lock: - display_message = False - if "chat_message" in set(message._active_dg._ancestor_block_types): - # Temporary stale element fix: - # For chat messages, we are resetting the spinner placeholder to an - # empty container instead of an empty placeholder (st.empty) to have - # it removed from the delta path. Empty containers are ignored in the - # frontend since they are configured with allow_empty=False. This - # prevents issues with stale elements caused by the spinner being - # rendered only in some situations (e.g. for caching). - message.container() - else: - message.empty() +class SpinnerMixin: + @contextlib.contextmanager + def spinner( + self, + text: str = "In progress...", + *, + show_time: bool = False, + _cache: bool = False, + width: Width = "content", + ) -> Iterator[None]: + """Display a loading spinner while executing a block of code. + + Parameters + ---------- + text : str + The text to display next to the spinner. This defaults to + ``"In progress..."``. + + The text can optionally contain GitHub-flavored Markdown. Syntax + information can be found at: https://github.github.com/gfm. + + See the ``body`` parameter of |st.markdown|_ for additional, supported + Markdown directives. + + .. |st.markdown| replace:: ``st.markdown`` + .. _st.markdown: https://docs.streamlit.io/develop/api-reference/text/st.markdown + + show_time : bool + Whether to show the elapsed time next to the spinner text. If this is + ``False`` (default), no time is displayed. If this is ``True``, + elapsed time is displayed with a precision of 0.1 seconds. The time + format is not configurable. + + width : "content", "stretch", or int + The width of the spinner element. This can be one of the following: + + - ``"content"`` (default): The width of the element matches the + width of its content, but doesn't exceed the width of the parent + container. + - ``"stretch"``: The width of the element matches the width of the + parent container. + - An integer specifying the width in pixels: The element has a + fixed width. If the specified width is greater than the width of + the parent container, the width of the element matches the width + of the parent container. + + Example + ------- + >>> import streamlit as st + >>> import time + >>> + >>> with st.spinner("Wait for it...", show_time=True): + >>> time.sleep(5) + >>> st.success("Done!") + >>> st.button("Rerun") + + .. output :: + https://doc-spinner.streamlit.app/ + height: 210px + + """ + from streamlit.proto.Spinner_pb2 import Spinner as SpinnerProto + from streamlit.string_util import clean_text + + validate_width(width, allow_content=True) + layout_config = LayoutConfig(width=width) + + message = self.dg.empty() + + display_message = True + display_message_lock = threading.Lock() + + try: + + def set_message() -> None: + with display_message_lock: + if display_message: + spinner_proto = SpinnerProto() + spinner_proto.text = clean_text(text) + spinner_proto.cache = _cache + spinner_proto.show_time = show_time + message._enqueue( + "spinner", spinner_proto, layout_config=layout_config + ) + + add_script_run_ctx(threading.Timer(DELAY_SECS, set_message)).start() + + # Yield control back to the context. + yield + finally: + if display_message_lock: + with display_message_lock: + display_message = False + if "chat_message" in set(message._active_dg._ancestor_block_types): + # Temporary stale element fix: + # For chat messages, we are resetting the spinner placeholder to an + # empty container instead of an empty placeholder (st.empty) to have + # it removed from the delta path. Empty containers are ignored in the + # frontend since they are configured with allow_empty=False. This + # prevents issues with stale elements caused by the spinner being + # rendered only in some situations (e.g. for caching). + message.container() + else: + message.empty() + + @property + def dg(self) -> DeltaGenerator: + """Get our DeltaGenerator.""" + return cast("DeltaGenerator", self) diff --git a/lib/streamlit/runtime/caching/cache_utils.py b/lib/streamlit/runtime/caching/cache_utils.py index 29c77bad05d..ae336639035 100644 --- a/lib/streamlit/runtime/caching/cache_utils.py +++ b/lib/streamlit/runtime/caching/cache_utils.py @@ -30,7 +30,7 @@ from streamlit import type_util from streamlit.dataframe_util import is_unevaluated_data_object -from streamlit.elements.spinner import spinner +from streamlit.delta_generator_singletons import get_dg_singleton_instance from streamlit.logger import get_logger from streamlit.runtime.caching.cache_errors import ( CacheError, @@ -261,8 +261,11 @@ def _get_or_create_cached_value( # basically like auto-setting "show_spinner=False" on the @st.cache decorators # on behalf of the user. is_nested_cache_function = in_cached_function.get() + spinner_or_no_context = ( - spinner(spinner_message, _cache=True, show_time=self._info.show_time) + get_dg_singleton_instance().main_dg.spinner( + spinner_message, _cache=True, show_time=self._info.show_time + ) if spinner_message is not None and not is_nested_cache_function else contextlib.nullcontext() ) diff --git a/lib/tests/streamlit/delta_generator_test.py b/lib/tests/streamlit/delta_generator_test.py index 107fcd8aed6..506657b6dd8 100644 --- a/lib/tests/streamlit/delta_generator_test.py +++ b/lib/tests/streamlit/delta_generator_test.py @@ -97,7 +97,6 @@ def test_public_api(self): # Remove commands that are only exposed in the top-level namespace (st.*) # and cannot be called on a DeltaGenerator object. expected_api = expected_api - { - "spinner", "dialog", "echo", "logo", diff --git a/lib/tests/streamlit/spinner_test.py b/lib/tests/streamlit/spinner_test.py index a44ecb858d0..fd79d25efc0 100644 --- a/lib/tests/streamlit/spinner_test.py +++ b/lib/tests/streamlit/spinner_test.py @@ -18,7 +18,7 @@ import pytest -from streamlit.elements.spinner import spinner +import streamlit as st from streamlit.errors import StreamlitAPIException from tests.delta_generator_test_case import DeltaGeneratorTestCase from tests.streamlit.elements.layout_test_utils import WidthConfigFields @@ -27,7 +27,7 @@ class SpinnerTest(DeltaGeneratorTestCase): def test_spinner(self): """Test st.spinner.""" - with spinner("some text"): + with st.spinner("some text"): # Without the timeout, the spinner is sometimes not available time.sleep(0.7) el = self.get_delta_from_queue().new_element @@ -41,9 +41,7 @@ def test_spinner(self): def test_spinner_within_chat_message(self): """Test st.spinner in st.chat_message resets to empty container block.""" - import streamlit as st - - with st.chat_message("user"), spinner("some text"): + with st.chat_message("user"), st.spinner("some text"): # Without the timeout, the spinner is sometimes not available time.sleep(0.7) el = self.get_delta_from_queue().new_element @@ -60,7 +58,7 @@ def test_spinner_within_chat_message(self): def test_spinner_for_caching(self): """Test st.spinner in cache functions.""" - with spinner("some text", _cache=True): + with st.spinner("some text", _cache=True): # Without the timeout, the spinner is sometimes not available time.sleep(0.7) el = self.get_delta_from_queue().new_element @@ -73,7 +71,7 @@ def test_spinner_for_caching(self): def test_spinner_time(self): """Test st.spinner with show_time.""" - with spinner("some text", show_time=True): + with st.spinner("some text", show_time=True): time.sleep(0.7) el = self.get_delta_from_queue().new_element assert el.spinner.text == "some text" @@ -98,7 +96,7 @@ def test_spinner_with_width(self): field_value, ) in enumerate(test_cases): with self.subTest(width_value=width_value): - with spinner(f"test text {index}", width=width_value): + with st.spinner(f"test text {index}", width=width_value): time.sleep(0.7) el = self.get_delta_from_queue().new_element assert el.spinner.text == f"test text {index}" @@ -132,13 +130,13 @@ def test_spinner_with_invalid_width(self): for width_value, expected_error_message in test_cases: with self.subTest(width_value=width_value): with pytest.raises(StreamlitAPIException) as exc: - with spinner("test text", width=width_value): + with st.spinner("test text", width=width_value): time.sleep(0.1) assert str(exc.value) == expected_error_message def test_spinner_default_width(self): """Test that st.spinner defaults to content width.""" - with spinner("test text"): + with st.spinner("test text"): time.sleep(0.7) el = self.get_delta_from_queue().new_element assert el.spinner.text == "test text"