streamlit-nightly 1.32.3.dev20240325__py2.py3-none-any.whl → 1.32.3.dev20240328__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. streamlit/__init__.py +4 -2
  2. streamlit/components/v1/__init__.py +3 -17
  3. streamlit/components/v1/{custom_component.py → components.py} +159 -11
  4. streamlit/delta_generator.py +3 -0
  5. streamlit/elements/media.py +52 -4
  6. streamlit/elements/widgets/time_widgets.py +5 -21
  7. streamlit/errors.py +0 -6
  8. streamlit/proto/AutoRerun_pb2.py +25 -0
  9. streamlit/proto/AutoRerun_pb2.pyi +48 -0
  10. streamlit/proto/ClientState_pb2.py +3 -3
  11. streamlit/proto/ClientState_pb2.pyi +4 -1
  12. streamlit/proto/Delta_pb2.py +2 -2
  13. streamlit/proto/Delta_pb2.pyi +4 -1
  14. streamlit/proto/ForwardMsg_pb2.py +10 -9
  15. streamlit/proto/ForwardMsg_pb2.pyi +12 -3
  16. streamlit/proto/NewSession_pb2.py +24 -24
  17. streamlit/proto/NewSession_pb2.pyi +8 -1
  18. streamlit/proto/PageProfile_pb2.py +6 -6
  19. streamlit/proto/PageProfile_pb2.pyi +4 -1
  20. streamlit/runtime/app_session.py +67 -24
  21. streamlit/runtime/caching/cache_data_api.py +3 -3
  22. streamlit/runtime/caching/cache_errors.py +0 -11
  23. streamlit/runtime/caching/cache_resource_api.py +2 -2
  24. streamlit/runtime/caching/cache_utils.py +1 -43
  25. streamlit/runtime/fragment.py +239 -0
  26. streamlit/runtime/metrics_util.py +17 -9
  27. streamlit/runtime/runtime.py +6 -12
  28. streamlit/runtime/runtime_util.py +54 -2
  29. streamlit/runtime/scriptrunner/script_requests.py +53 -37
  30. streamlit/runtime/scriptrunner/script_run_context.py +15 -2
  31. streamlit/runtime/scriptrunner/script_runner.py +63 -14
  32. streamlit/runtime/state/common.py +2 -0
  33. streamlit/runtime/state/session_state.py +51 -7
  34. streamlit/runtime/state/widgets.py +10 -2
  35. streamlit/static/asset-manifest.json +19 -19
  36. streamlit/static/index.html +1 -1
  37. streamlit/static/static/js/1074.73973756.chunk.js +1 -0
  38. streamlit/static/static/js/1451.3b0a3e31.chunk.js +1 -0
  39. streamlit/static/static/js/1792.b8efa879.chunk.js +1 -0
  40. streamlit/static/static/js/{3092.3d4df25e.chunk.js → 3092.ad569cc8.chunk.js} +1 -1
  41. streamlit/static/static/js/3513.e3e7300a.chunk.js +1 -0
  42. streamlit/static/static/js/4177.69f9f18d.chunk.js +1 -0
  43. streamlit/static/static/js/4319.a6745434.chunk.js +1 -0
  44. streamlit/static/static/js/{4477.2555c11a.chunk.js → 4477.e10e4373.chunk.js} +1 -1
  45. streamlit/static/static/js/{4666.99f3abc3.chunk.js → 4666.b694c5a9.chunk.js} +1 -1
  46. streamlit/static/static/js/5106.44f0ff51.chunk.js +1 -0
  47. streamlit/static/static/js/5379.6571574f.chunk.js +1 -0
  48. streamlit/static/static/js/6013.8e80e091.chunk.js +1 -0
  49. streamlit/static/static/js/6718.802da17e.chunk.js +1 -0
  50. streamlit/static/static/js/7175.be4076bc.chunk.js +1 -0
  51. streamlit/static/static/js/{7602.f0420392.chunk.js → 7602.6175e969.chunk.js} +1 -1
  52. streamlit/static/static/js/{8492.e6dab83f.chunk.js → 8492.f56c9d4c.chunk.js} +1 -1
  53. streamlit/static/static/js/8691.9ccf7f89.chunk.js +1 -0
  54. streamlit/static/static/js/main.722453f0.js +2 -0
  55. streamlit/testing/v1/local_script_runner.py +2 -0
  56. streamlit/time_util.py +88 -0
  57. streamlit/watcher/local_sources_watcher.py +2 -1
  58. streamlit/web/server/component_request_handler.py +2 -2
  59. streamlit/web/server/server.py +2 -1
  60. {streamlit_nightly-1.32.3.dev20240325.dist-info → streamlit_nightly-1.32.3.dev20240328.dist-info}/METADATA +1 -1
  61. {streamlit_nightly-1.32.3.dev20240325.dist-info → streamlit_nightly-1.32.3.dev20240328.dist-info}/RECORD +66 -68
  62. streamlit/components/lib/__init__.py +0 -13
  63. streamlit/components/lib/local_component_registry.py +0 -82
  64. streamlit/components/types/__init__.py +0 -13
  65. streamlit/components/types/base_component_registry.py +0 -98
  66. streamlit/components/types/base_custom_component.py +0 -137
  67. streamlit/components/v1/component_registry.py +0 -103
  68. streamlit/static/static/js/1074.71719df6.chunk.js +0 -1
  69. streamlit/static/static/js/1451.e3be1711.chunk.js +0 -1
  70. streamlit/static/static/js/1792.16c16498.chunk.js +0 -1
  71. streamlit/static/static/js/3513.57cff89c.chunk.js +0 -1
  72. streamlit/static/static/js/4177.ab9a7aa1.chunk.js +0 -1
  73. streamlit/static/static/js/4319.213fc321.chunk.js +0 -1
  74. streamlit/static/static/js/5106.22187bfc.chunk.js +0 -1
  75. streamlit/static/static/js/5379.e466522d.chunk.js +0 -1
  76. streamlit/static/static/js/6013.75c92264.chunk.js +0 -1
  77. streamlit/static/static/js/6718.97945fc6.chunk.js +0 -1
  78. streamlit/static/static/js/7175.8c1b4d38.chunk.js +0 -1
  79. streamlit/static/static/js/8691.24a5792f.chunk.js +0 -1
  80. streamlit/static/static/js/main.7fde7092.js +0 -2
  81. /streamlit/static/static/js/{main.7fde7092.js.LICENSE.txt → main.722453f0.js.LICENSE.txt} +0 -0
  82. {streamlit_nightly-1.32.3.dev20240325.data → streamlit_nightly-1.32.3.dev20240328.data}/scripts/streamlit.cmd +0 -0
  83. {streamlit_nightly-1.32.3.dev20240325.dist-info → streamlit_nightly-1.32.3.dev20240328.dist-info}/WHEEL +0 -0
  84. {streamlit_nightly-1.32.3.dev20240325.dist-info → streamlit_nightly-1.32.3.dev20240328.dist-info}/entry_points.txt +0 -0
  85. {streamlit_nightly-1.32.3.dev20240325.dist-info → streamlit_nightly-1.32.3.dev20240328.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,239 @@
1
+ # Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import hashlib
19
+ import inspect
20
+ from abc import abstractmethod
21
+ from copy import deepcopy
22
+ from datetime import timedelta
23
+ from functools import wraps
24
+ from typing import Any, Callable, Protocol, TypeVar, overload
25
+
26
+ from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
27
+ from streamlit.runtime.metrics_util import gather_metrics
28
+ from streamlit.runtime.scriptrunner import get_script_run_ctx
29
+ from streamlit.time_util import time_to_seconds
30
+
31
+ F = TypeVar("F", bound=Callable[..., Any])
32
+ Fragment = Callable[[], Any]
33
+
34
+
35
+ class FragmentStorage(Protocol):
36
+ """A key-value store for Fragments. Used to implement the @st.experimental_fragment
37
+ decorator.
38
+
39
+ We intentionally define this as its own protocol despite how generic it appears to
40
+ be at first glance. The reason why is that, in any case where fragments aren't just
41
+ stored as Python closures in memory, storing and retrieving Fragments will generally
42
+ involve serializing and deserializing function bytecode, which is a tricky aspect
43
+ to implementing FragmentStorages that won't generally appear with our other *Storage
44
+ protocols.
45
+ """
46
+
47
+ @abstractmethod
48
+ def get(self, key: str) -> Fragment:
49
+ """Returns the stored fragment for the given key."""
50
+ raise NotImplementedError
51
+
52
+ @abstractmethod
53
+ def set(self, key: str, value: Fragment) -> None:
54
+ """Saves a fragment under the given key."""
55
+ raise NotImplementedError
56
+
57
+ @abstractmethod
58
+ def delete(self, key: str) -> None:
59
+ """Delete the fragment corresponding to the given key."""
60
+ raise NotImplementedError
61
+
62
+ @abstractmethod
63
+ def clear(self) -> None:
64
+ """Remove all fragments saved in this FragmentStorage."""
65
+ raise NotImplementedError
66
+
67
+
68
+ # NOTE: Ideally, we'd like to add a MemoryFragmentStorageStatProvider implementation to
69
+ # keep track of memory usage due to fragments, but doing something like this ends up
70
+ # being difficult in practice as the memory usage of a closure is hard to measure (the
71
+ # vendored implementation of pympler.asizeof that we use elsewhere is unable to measure
72
+ # the size of a function).
73
+ class MemoryFragmentStorage(FragmentStorage):
74
+ """A simple, memory-backed implementation of FragmentStorage.
75
+
76
+ MemoryFragmentStorage is just a wrapper around a plain Python dict that complies with
77
+ the FragmentStorage protocol.
78
+ """
79
+
80
+ def __init__(self):
81
+ self._fragments: dict[str, Fragment] = {}
82
+
83
+ def get(self, key: str) -> Fragment:
84
+ return self._fragments[key]
85
+
86
+ def set(self, key: str, value: Fragment) -> None:
87
+ self._fragments[key] = value
88
+
89
+ def delete(self, key: str) -> None:
90
+ del self._fragments[key]
91
+
92
+ def clear(self) -> None:
93
+ self._fragments.clear()
94
+
95
+
96
+ @overload
97
+ def fragment(
98
+ func: F,
99
+ *,
100
+ run_every: int | float | timedelta | str | None = None,
101
+ ) -> F:
102
+ ...
103
+
104
+
105
+ # Support being able to pass parameters to this decorator (that is, being able to write
106
+ # `@fragment(run_every=5.0)`).
107
+ @overload
108
+ def fragment(
109
+ func: None = None,
110
+ *,
111
+ run_every: int | float | timedelta | str | None = None,
112
+ ) -> Callable[[F], F]:
113
+ ...
114
+
115
+
116
+ @gather_metrics("experimental_fragment")
117
+ def fragment(
118
+ func: F | None = None,
119
+ *,
120
+ run_every: int | float | timedelta | str | None = None,
121
+ ) -> Callable[[F], F] | F:
122
+ """Allow a function to be run independently of the full script.
123
+
124
+ Functions decorated with ``@st.experimental_fragment`` are handled specially within
125
+ an app: when a widget created within an invocation of the function (a fragment) is
126
+ interacted with, then only that fragment is rerun rather than the full streamlit app.
127
+
128
+ Parameters
129
+ ----------
130
+ run_every: int, float, timedelta, str, or None
131
+ If set, fragments created from this function rerun periodically at the specified
132
+ time interval.
133
+
134
+ Example
135
+ -------
136
+ The following example demonstrates basic usage of ``@st.experimental_fragment``. In
137
+ this app, clicking on the "rerun full script" button will increment both counters,
138
+ but the "rerun fragment" button will only increment the counter within the fragment.
139
+
140
+ ```python3
141
+ import streamlit as st
142
+
143
+ if "script_runs" not in st.session_state:
144
+ st.session_state.script_runs = 0
145
+ st.session_state.fragment_runs = 0
146
+
147
+ @st.experimental_fragment
148
+ def fragment():
149
+ st.button("rerun fragment")
150
+ st.write(f"fragment runs: {st.session_state.fragment_runs}")
151
+ st.session_state.fragment_runs += 1
152
+
153
+ fragment()
154
+
155
+ st.button("rerun full script")
156
+ st.write(f"full script runs: {st.session_state.script_runs}")
157
+ st.session_state.script_runs += 1
158
+ ```
159
+ """
160
+
161
+ if func is None:
162
+ # Support passing the params via function decorator
163
+ def wrapper(f: F) -> F:
164
+ return fragment(
165
+ func=f,
166
+ run_every=run_every,
167
+ )
168
+
169
+ return wrapper
170
+ else:
171
+ non_optional_func = func
172
+
173
+ @wraps(non_optional_func)
174
+ def wrap(*args, **kwargs):
175
+ from streamlit.delta_generator import dg_stack
176
+
177
+ ctx = get_script_run_ctx()
178
+ if ctx is None:
179
+ return
180
+
181
+ cursors_snapshot = deepcopy(ctx.cursors)
182
+ dg_stack_snapshot = deepcopy(dg_stack.get())
183
+ active_dg = dg_stack_snapshot[-1]
184
+ h = hashlib.new("md5")
185
+ h.update(
186
+ f"{non_optional_func.__module__}.{non_optional_func.__qualname__}{active_dg._get_delta_path_str()}".encode(
187
+ "utf-8"
188
+ )
189
+ )
190
+ fragment_id = h.hexdigest()
191
+
192
+ def wrapped_fragment():
193
+ import streamlit as st
194
+
195
+ # NOTE: We need to call get_script_run_ctx here again and can't just use the
196
+ # value of ctx from above captured by the closure because subsequent
197
+ # fragment runs will generally run in a new script run, thus we'll have a
198
+ # new ctx.
199
+ ctx = get_script_run_ctx(suppress_warning=True)
200
+ assert ctx is not None
201
+
202
+ if ctx.fragment_ids_this_run:
203
+ # This script run is a run of one or more fragments. We restore the
204
+ # state of ctx.cursors and dg_stack to the snapshots we took when this
205
+ # fragment was declared.
206
+ ctx.cursors = deepcopy(cursors_snapshot)
207
+ dg_stack.set(deepcopy(dg_stack_snapshot))
208
+ else:
209
+ # Otherwise, we must be in a full script run. We need to temporarily set
210
+ # ctx.current_fragment_id so that elements corresponding to this
211
+ # fragment get tagged with the appropriate ID. ctx.current_fragment_id
212
+ # gets reset after the fragment function finishes running.
213
+ ctx.current_fragment_id = fragment_id
214
+
215
+ try:
216
+ with st.container():
217
+ result = non_optional_func(*args, **kwargs)
218
+ finally:
219
+ ctx.current_fragment_id = None
220
+
221
+ return result
222
+
223
+ ctx.fragment_storage.set(fragment_id, wrapped_fragment)
224
+
225
+ if run_every:
226
+ msg = ForwardMsg()
227
+ msg.auto_rerun.interval = time_to_seconds(run_every)
228
+ msg.auto_rerun.fragment_id = fragment_id
229
+ ctx.enqueue(msg)
230
+
231
+ return wrapped_fragment()
232
+
233
+ with contextlib.suppress(AttributeError):
234
+ # Make this a well-behaved decorator by preserving important function
235
+ # attributes.
236
+ wrap.__dict__.update(non_optional_func.__dict__)
237
+ wrap.__signature__ = inspect.signature(non_optional_func) # type: ignore
238
+
239
+ return wrap
@@ -427,12 +427,17 @@ def create_page_profile_message(
427
427
  uncaught_exception: str | None = None,
428
428
  ) -> ForwardMsg:
429
429
  """Create and return the full PageProfile ForwardMsg."""
430
+ # Local import to prevent circular dependencies
431
+ from streamlit.runtime.scriptrunner import get_script_run_ctx
432
+
430
433
  msg = ForwardMsg()
431
- msg.page_profile.commands.extend(commands)
432
- msg.page_profile.exec_time = exec_time
433
- msg.page_profile.prep_time = prep_time
434
+ page_profile = msg.page_profile
435
+
436
+ page_profile.commands.extend(commands)
437
+ page_profile.exec_time = exec_time
438
+ page_profile.prep_time = prep_time
434
439
 
435
- msg.page_profile.headless = config.get_option("server.headless")
440
+ page_profile.headless = config.get_option("server.headless")
436
441
 
437
442
  # Collect all config options that have been manually set
438
443
  config_options: set[str] = set()
@@ -447,7 +452,7 @@ def create_page_profile_message(
447
452
  option_name = f"{option_name}:default"
448
453
  config_options.add(option_name)
449
454
 
450
- msg.page_profile.config.extend(config_options)
455
+ page_profile.config.extend(config_options)
451
456
 
452
457
  # Check the predefined set of modules for attribution
453
458
  attributions: set[str] = {
@@ -456,11 +461,14 @@ def create_page_profile_message(
456
461
  if attribution in sys.modules
457
462
  }
458
463
 
459
- msg.page_profile.os = str(sys.platform)
460
- msg.page_profile.timezone = str(time.tzname)
461
- msg.page_profile.attributions.extend(attributions)
464
+ page_profile.os = str(sys.platform)
465
+ page_profile.timezone = str(time.tzname)
466
+ page_profile.attributions.extend(attributions)
462
467
 
463
468
  if uncaught_exception:
464
- msg.page_profile.uncaught_exception = uncaught_exception
469
+ page_profile.uncaught_exception = uncaught_exception
470
+
471
+ if ctx := get_script_run_ctx():
472
+ page_profile.is_fragment_run = bool(ctx.current_fragment_id)
465
473
 
466
474
  return msg
@@ -22,8 +22,6 @@ from enum import Enum
22
22
  from typing import TYPE_CHECKING, Awaitable, Final, NamedTuple
23
23
 
24
24
  from streamlit import config
25
- from streamlit.components.lib.local_component_registry import LocalComponentRegistry
26
- from streamlit.components.types.base_component_registry import BaseComponentRegistry
27
25
  from streamlit.logger import get_logger
28
26
  from streamlit.proto.BackMsg_pb2 import BackMsg
29
27
  from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
@@ -98,11 +96,6 @@ class RuntimeConfig:
98
96
  default_factory=LocalDiskCacheStorageManager
99
97
  )
100
98
 
101
- # The ComponentRegistry instance to use.
102
- component_registry: BaseComponentRegistry = field(
103
- default_factory=LocalComponentRegistry
104
- )
105
-
106
99
  # The SessionManager class to be used.
107
100
  session_manager_class: type[SessionManager] = WebsocketSessionManager
108
101
 
@@ -112,6 +105,12 @@ class RuntimeConfig:
112
105
  # True if the command used to start Streamlit was `streamlit hello`.
113
106
  is_hello: bool = False
114
107
 
108
+ # TODO(vdonato): Eventually add a new fragment_storage_class field enabling the code
109
+ # creating a new Streamlit Runtime to configure the FragmentStorage instances
110
+ # created by each new AppSession. We choose not to do this for now to avoid adding
111
+ # additional complexity to RuntimeConfig/SessionManager/etc when it's unlikely
112
+ # we'll have a custom implementation of this class anytime soon.
113
+
115
114
 
116
115
  class RuntimeState(Enum):
117
116
  INITIAL = "INITIAL"
@@ -196,7 +195,6 @@ class Runtime:
196
195
  self._state = RuntimeState.INITIAL
197
196
 
198
197
  # Initialize managers
199
- self._component_registry = config.component_registry
200
198
  self._message_cache = ForwardMsgCache()
201
199
  self._uploaded_file_mgr = config.uploaded_file_manager
202
200
  self._media_file_mgr = MediaFileManager(storage=config.media_file_storage)
@@ -222,10 +220,6 @@ class Runtime:
222
220
  def state(self) -> RuntimeState:
223
221
  return self._state
224
222
 
225
- @property
226
- def component_registry(self) -> BaseComponentRegistry:
227
- return self._component_registry
228
-
229
223
  @property
230
224
  def message_cache(self) -> ForwardMsgCache:
231
225
  return self._message_cache
@@ -16,10 +16,12 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- from typing import Any
19
+ import math
20
+ from datetime import timedelta
21
+ from typing import Any, Literal, overload
20
22
 
21
23
  from streamlit import config
22
- from streamlit.errors import MarkdownFormattedException
24
+ from streamlit.errors import MarkdownFormattedException, StreamlitAPIException
23
25
  from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
24
26
  from streamlit.runtime.forward_msg_cache import populate_hash_if_needed
25
27
 
@@ -54,6 +56,17 @@ of the client's browser and the Streamlit server._
54
56
  )
55
57
 
56
58
 
59
+ class BadDurationStringError(StreamlitAPIException):
60
+ """Raised when a bad duration argument string is passed."""
61
+
62
+ def __init__(self, duration: str):
63
+ MarkdownFormattedException.__init__(
64
+ self,
65
+ "TTL string doesn't look right. It should be formatted as"
66
+ f"`'1d2h34m'` or `2 days`, for example. Got: {duration}",
67
+ )
68
+
69
+
57
70
  def is_cacheable_msg(msg: ForwardMsg) -> bool:
58
71
  """True if the given message qualifies for caching."""
59
72
  if msg.WhichOneof("type") in {"ref_hash", "initialize"}:
@@ -62,6 +75,45 @@ def is_cacheable_msg(msg: ForwardMsg) -> bool:
62
75
  return msg.ByteSize() >= int(config.get_option("global.minCachedMessageSize"))
63
76
 
64
77
 
78
+ @overload
79
+ def duration_to_seconds(
80
+ ttl: float | timedelta | str | None, *, coerce_none_to_inf: Literal[False]
81
+ ) -> float | None:
82
+ ...
83
+
84
+
85
+ @overload
86
+ def duration_to_seconds(ttl: float | timedelta | str | None) -> float:
87
+ ...
88
+
89
+
90
+ def duration_to_seconds(
91
+ ttl: float | timedelta | str | None, *, coerce_none_to_inf: bool = True
92
+ ) -> float | None:
93
+ """
94
+ Convert a ttl value to a float representing "number of seconds".
95
+ """
96
+ if coerce_none_to_inf and ttl is None:
97
+ return math.inf
98
+ if isinstance(ttl, timedelta):
99
+ return ttl.total_seconds()
100
+ if isinstance(ttl, str):
101
+ import numpy as np
102
+ import pandas as pd
103
+
104
+ try:
105
+ out: float = pd.Timedelta(ttl).total_seconds()
106
+ except ValueError as ex:
107
+ raise BadDurationStringError(ttl) from ex
108
+
109
+ if np.isnan(out):
110
+ raise BadDurationStringError(ttl)
111
+
112
+ return out
113
+
114
+ return ttl
115
+
116
+
65
117
  def serialize_forward_msg(msg: ForwardMsg) -> bytes:
66
118
  """Serialize a ForwardMsg to send to a client.
67
119
 
@@ -15,7 +15,7 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  import threading
18
- from dataclasses import dataclass
18
+ from dataclasses import dataclass, field
19
19
  from enum import Enum
20
20
  from typing import cast
21
21
 
@@ -46,6 +46,7 @@ class RerunData:
46
46
  widget_states: WidgetStates | None = None
47
47
  page_script_hash: str = ""
48
48
  page_name: str = ""
49
+ fragment_id_queue: list[str] = field(default_factory=list)
49
50
 
50
51
  def __repr__(self) -> str:
51
52
  return util.repr_(self)
@@ -103,41 +104,46 @@ class ScriptRequests:
103
104
  return False
104
105
 
105
106
  if self._state == ScriptRequestType.CONTINUE:
106
- # If we're running, we can handle a rerun request
107
- # unconditionally.
107
+ # The script is currently running, and we haven't received a request to
108
+ # rerun it as of yet. We can handle a rerun request unconditionally so
109
+ # just change self._state and set self._rerun_data.
108
110
  self._state = ScriptRequestType.RERUN
109
111
  self._rerun_data = new_data
110
112
  return True
111
113
 
112
114
  if self._state == ScriptRequestType.RERUN:
113
- # If we have an existing Rerun request, we coalesce this
114
- # new request into it.
115
- if self._rerun_data.widget_states is None:
116
- # The existing request's widget_states is None, which
117
- # means it wants to rerun with whatever the most
118
- # recent script execution's widget state was.
119
- # We have no meaningful state to merge with, and
120
- # so we simply overwrite the existing request.
121
- self._rerun_data = new_data
122
- return True
123
-
124
- if new_data.widget_states is not None:
125
- # Both the existing and the new request have
126
- # non-null widget_states. Merge them together.
127
- coalesced_states = coalesce_widget_states(
128
- self._rerun_data.widget_states, new_data.widget_states
129
- )
130
- self._rerun_data = RerunData(
131
- query_string=new_data.query_string,
132
- widget_states=coalesced_states,
133
- page_script_hash=new_data.page_script_hash,
134
- page_name=new_data.page_name,
135
- )
136
- return True
137
-
138
- # If old widget_states is NOT None, and new widget_states IS
139
- # None, then this new request is entirely redundant. Leave
140
- # our existing rerun_data as is.
115
+ # We already have an existing Rerun request, so we can coalesce the new
116
+ # rerun request into the existing one.
117
+
118
+ coalesced_states = coalesce_widget_states(
119
+ self._rerun_data.widget_states, new_data.widget_states
120
+ )
121
+
122
+ if new_data.fragment_id_queue:
123
+ # This RERUN request corresponds to a fragment run. We append the
124
+ # new fragment ID to the end of the current fragment_id_queue if it
125
+ # isn't already contained in it.
126
+ fragment_id_queue = [*self._rerun_data.fragment_id_queue]
127
+ if (
128
+ # new_data.fragment_id_queue is always a singleton
129
+ (new_fragment_id := new_data.fragment_id_queue[0])
130
+ not in fragment_id_queue
131
+ ):
132
+ fragment_id_queue.append(new_fragment_id)
133
+ else:
134
+ # Otherwise, this is a request to rerun the full script, so we want
135
+ # to clear out any fragments we have queued to run since they'll all
136
+ # be run with the full script anyway.
137
+ fragment_id_queue = []
138
+
139
+ self._rerun_data = RerunData(
140
+ query_string=new_data.query_string,
141
+ widget_states=coalesced_states,
142
+ page_script_hash=new_data.page_script_hash,
143
+ page_name=new_data.page_name,
144
+ fragment_id_queue=fragment_id_queue,
145
+ )
146
+
141
147
  return True
142
148
 
143
149
  # We'll never get here
@@ -146,22 +152,32 @@ class ScriptRequests:
146
152
  def on_scriptrunner_yield(self) -> ScriptRequest | None:
147
153
  """Called by the ScriptRunner when it's at a yield point.
148
154
 
149
- If we have no request, return None.
155
+ If we have no request or a RERUN request corresponding to one or more fragments,
156
+ return None.
150
157
 
151
- If we have a RERUN request, return the request and set our internal
158
+ If we have a (full script) RERUN request, return the request and set our internal
152
159
  state to CONTINUE.
153
160
 
154
161
  If we have a STOP request, return the request and remain stopped.
155
162
  """
156
- if self._state == ScriptRequestType.CONTINUE:
157
- # We avoid taking a lock in the common case. If a STOP or RERUN
158
- # request is received between the `if` and `return`, it will be
159
- # handled at the next `on_scriptrunner_yield`, or when
163
+ if self._state == ScriptRequestType.CONTINUE or (
164
+ # Reruns corresponding to fragments should *not* cancel the current script
165
+ # run as doing so will affect elements outside of the fragment.
166
+ self._state == ScriptRequestType.RERUN
167
+ and self._rerun_data.fragment_id_queue
168
+ ):
169
+ # We avoid taking the lock in the common cases of having no request and
170
+ # having a RERUN request corresponding to >=1 fragments. If a STOP or
171
+ # (full script) RERUN request is received between the `if` and `return`, it
172
+ # will be handled at the next `on_scriptrunner_yield`, or when
160
173
  # `on_scriptrunner_ready` is called.
161
174
  return None
162
175
 
163
176
  with self._lock:
164
177
  if self._state == ScriptRequestType.RERUN:
178
+ if self._rerun_data.fragment_id_queue:
179
+ return None
180
+
165
181
  self._state = ScriptRequestType.CONTINUE
166
182
  return ScriptRequest(ScriptRequestType.RERUN, self._rerun_data)
167
183
 
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  import collections
18
18
  import threading
19
19
  from dataclasses import dataclass, field
20
- from typing import Callable, Counter, Dict, Final, Union
20
+ from typing import TYPE_CHECKING, Callable, Counter, Dict, Final, Union
21
21
  from urllib import parse
22
22
 
23
23
  from typing_extensions import TypeAlias
@@ -31,6 +31,9 @@ from streamlit.runtime.scriptrunner.script_requests import ScriptRequests
31
31
  from streamlit.runtime.state import SafeSessionState
32
32
  from streamlit.runtime.uploaded_file_manager import UploadedFileManager
33
33
 
34
+ if TYPE_CHECKING:
35
+ from streamlit.runtime.fragment import FragmentStorage
36
+
34
37
  _LOGGER: Final = get_logger(__name__)
35
38
 
36
39
  UserInfo: TypeAlias = Dict[str, Union[str, None]]
@@ -59,6 +62,7 @@ class ScriptRunContext:
59
62
  main_script_path: str
60
63
  page_script_hash: str
61
64
  user_info: UserInfo
65
+ fragment_storage: "FragmentStorage"
62
66
 
63
67
  gather_usage_stats: bool = False
64
68
  command_tracking_deactivated: bool = False
@@ -71,12 +75,19 @@ class ScriptRunContext:
71
75
  form_ids_this_run: set[str] = field(default_factory=set)
72
76
  cursors: dict[int, "streamlit.cursor.RunningCursor"] = field(default_factory=dict)
73
77
  script_requests: ScriptRequests | None = None
78
+ current_fragment_id: str | None = None
79
+ fragment_ids_this_run: set[str] | None = None
74
80
 
75
81
  # TODO(willhuang1997): Remove this variable when experimental query params are removed
76
82
  _experimental_query_params_used = False
77
83
  _production_query_params_used = False
78
84
 
79
- def reset(self, query_string: str = "", page_script_hash: str = "") -> None:
85
+ def reset(
86
+ self,
87
+ query_string: str = "",
88
+ page_script_hash: str = "",
89
+ fragment_ids_this_run: set[str] | None = None,
90
+ ) -> None:
80
91
  self.cursors = {}
81
92
  self.widget_ids_this_run = set()
82
93
  self.widget_user_keys_this_run = set()
@@ -89,6 +100,8 @@ class ScriptRunContext:
89
100
  self.command_tracking_deactivated: bool = False
90
101
  self.tracked_commands = []
91
102
  self.tracked_commands_counter = collections.Counter()
103
+ self.current_fragment_id = None
104
+ self.fragment_ids_this_run = fragment_ids_this_run
92
105
 
93
106
  parsed_query_params = parse.parse_qs(query_string, keep_blank_values=True)
94
107
  with self.session_state.query_params() as qp: