streamlit-nightly 1.53.2.dev20260125__py3-none-any.whl → 1.53.2.dev20260127__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 (119) hide show
  1. streamlit/commands/logo.py +81 -25
  2. streamlit/deprecation_util.py +19 -1
  3. streamlit/elements/arrow.py +2 -1
  4. streamlit/elements/lib/built_in_chart_utils.py +2 -2
  5. streamlit/elements/lib/options_selector_utils.py +72 -22
  6. streamlit/elements/widgets/select_slider.py +123 -37
  7. streamlit/hello/plotting_demo.py +19 -12
  8. streamlit/proto/Logo_pb2.py +5 -3
  9. streamlit/proto/Logo_pb2.pyi +25 -1
  10. streamlit/proto/Slider_pb2.py +6 -6
  11. streamlit/proto/Slider_pb2.pyi +9 -1
  12. streamlit/runtime/scriptrunner/script_runner.py +17 -0
  13. streamlit/runtime/scriptrunner_utils/script_run_context.py +13 -10
  14. streamlit/runtime/state/__init__.py +7 -1
  15. streamlit/runtime/state/common.py +13 -0
  16. streamlit/runtime/state/query_params.py +494 -6
  17. streamlit/runtime/state/session_state.py +178 -3
  18. streamlit/runtime/state/widgets.py +26 -1
  19. streamlit/static/index.html +1 -1
  20. streamlit/static/manifest.json +304 -304
  21. streamlit/static/static/js/{ErrorOutline.esm.CIFYUdwC.js → ErrorOutline.esm.DiaGWPsE.js} +1 -1
  22. streamlit/static/static/js/{FileDownload.esm.DWVTnTHm.js → FileDownload.esm.D-Qgpk5d.js} +1 -1
  23. streamlit/static/static/js/{FileHelper.BPYQIPd1.js → FileHelper.DmtDDGp0.js} +1 -1
  24. streamlit/static/static/js/{FormClearHelper.CypmvhYZ.js → FormClearHelper.BM3uDLnU.js} +1 -1
  25. streamlit/static/static/js/{InputInstructions.Bi62hDTQ.js → InputInstructions.B2YZ5Lle.js} +1 -1
  26. streamlit/static/static/js/{Particles.yebG0VuV.js → Particles.BzfZrl-0.js} +1 -1
  27. streamlit/static/static/js/{ProgressBar.Dy9CI6w4.js → ProgressBar.DWErD3j3.js} +1 -1
  28. streamlit/static/static/js/{StreamlitSyntaxHighlighter.Btk92CPv.js → StreamlitSyntaxHighlighter.DB31rUPf.js} +1 -1
  29. streamlit/static/static/js/{TableChart.esm.DBeVaFNt.js → TableChart.esm.D3oUYYYd.js} +1 -1
  30. streamlit/static/static/js/{Toolbar.DC2Tp-qb.js → Toolbar.CPyjMABY.js} +1 -1
  31. streamlit/static/static/js/{WidgetLabelHelpIconInline.3DnEd9BK.js → WidgetLabelHelpIconInline.C1NBCx-y.js} +1 -1
  32. streamlit/static/static/js/{base-input.7Sj6pVk0.js → base-input.C_CrNgNd.js} +1 -1
  33. streamlit/static/static/js/{checkbox.CcUx3XuQ.js → checkbox.S6O8DPFl.js} +1 -1
  34. streamlit/static/static/js/{createDownloadLinkElement.DZuwkCqy.js → createDownloadLinkElement.B-5tIJBw.js} +1 -1
  35. streamlit/static/static/js/{data-grid-overlay-editor.Dw-AewlN.js → data-grid-overlay-editor.BW5aAEJN.js} +1 -1
  36. streamlit/static/static/js/{downloader.Bsx5M2Du.js → downloader.BswLlj7p.js} +1 -1
  37. streamlit/static/static/js/embed.QjYZXbaW.js +193 -0
  38. streamlit/static/static/js/{es6.BpAqZaR_.js → es6.Dm_kl6Cp.js} +2 -2
  39. streamlit/static/static/js/{formatNumber.DjehVPVS.js → formatNumber.CH39oSMw.js} +1 -1
  40. streamlit/static/static/js/{iconPosition.D02OPE-d.js → iconPosition.Dp9f2jam.js} +1 -1
  41. streamlit/static/static/js/{iframeResizer.contentWindow.xtstqPd7.js → iframeResizer.contentWindow.DOprY46-.js} +1 -1
  42. streamlit/static/static/js/{index.S-mjkUeF.js → index.-7yn3zqE.js} +1 -1
  43. streamlit/static/static/js/{index.Bri1T2TS.js → index.4QSKV4yi.js} +1 -1
  44. streamlit/static/static/js/{index.ZIA43eTF.js → index.6OwBTJdz.js} +1 -1
  45. streamlit/static/static/js/{index.x1B588Xu.js → index.6UQ__fP7.js} +1 -1
  46. streamlit/static/static/js/{index.CEwnDCn9.js → index.6iOQDEVC.js} +1 -1
  47. streamlit/static/static/js/index.B0Wcvg8a.js +2 -0
  48. streamlit/static/static/js/{index.DO2T-QzF.js → index.B6Sk5Jwr.js} +1 -1
  49. streamlit/static/static/js/{index.D_TIyPF4.js → index.B9XI6y3j.js} +1 -1
  50. streamlit/static/static/js/{index.B9gbSNsw.js → index.BBVcEzWu.js} +1 -1
  51. streamlit/static/static/js/{index.CA0RmxJF.js → index.BFxjXeYb.js} +1 -1
  52. streamlit/static/static/js/{index.B5tD5YeV.js → index.BNsCI-Q1.js} +1 -1
  53. streamlit/static/static/js/{index.DJjSqPAx.js → index.BQu5Qf_i.js} +1 -1
  54. streamlit/static/static/js/{index.BvZbnSMC.js → index.BUOjbZl6.js} +1 -1
  55. streamlit/static/static/js/{index.XFMDBL5n.js → index.BUbgW8RJ.js} +1 -1
  56. streamlit/static/static/js/{index.BOkpEbJS.js → index.BYjec3-R.js} +1 -1
  57. streamlit/static/static/js/{index.D6Z9hKJY.js → index.BaKZ4ira.js} +1 -1
  58. streamlit/static/static/js/{index.BIcJe97b.js → index.BcUwq6kI.js} +1 -1
  59. streamlit/static/static/js/{index.C9v49R-a.js → index.BlchQrZL.js} +1 -1
  60. streamlit/static/static/js/{index.D9RL5sRp.js → index.BlgpfTUq.js} +1 -1
  61. streamlit/static/static/js/{index.BV6XgCij.js → index.Bwaj-9Zv.js} +1 -1
  62. streamlit/static/static/js/{index.DDu_qTm0.js → index.BxtweGcn.js} +1 -1
  63. streamlit/static/static/js/{index.CGbvkEtg.js → index.C57ViuAX.js} +1 -1
  64. streamlit/static/static/js/{index.CdRwiHPm.js → index.CAzk7fUP.js} +1 -1
  65. streamlit/static/static/js/{index.BK9S5qug.js → index.CDZffl1q.js} +1 -1
  66. streamlit/static/static/js/{index.JL0uGAeJ.js → index.CYxcFtYt.js} +1 -1
  67. streamlit/static/static/js/index.Cb7V19H9.js +2 -0
  68. streamlit/static/static/js/{index.CgARjn28.js → index.CfXTal6g.js} +1 -1
  69. streamlit/static/static/js/{index.CyDHwK5P.js → index.Ci3x7GcT.js} +1 -1
  70. streamlit/static/static/js/{index.BVhVdVeE.js → index.CivdhE_9.js} +1 -1
  71. streamlit/static/static/js/{index.m3dn5Bai.js → index.CkSzTTpO.js} +5 -5
  72. streamlit/static/static/js/{index.8FPw0_gD.js → index.CuJ82OKQ.js} +1 -1
  73. streamlit/static/static/js/{index.BDlI2pRp.js → index.Cw5Xlisw.js} +1 -1
  74. streamlit/static/static/js/{index.DdxofXV8.js → index.D1tkD8Lg.js} +3 -3
  75. streamlit/static/static/js/{index.iXzAofuY.js → index.DK9Im19R.js} +2 -2
  76. streamlit/static/static/js/{index.iF5zYERg.js → index.DMwRlpNi.js} +1 -1
  77. streamlit/static/static/js/index.DTUAvbJ0.js +1 -0
  78. streamlit/static/static/js/{index.DSSapl3Q.js → index.DoRYpzHm.js} +1 -1
  79. streamlit/static/static/js/{index.D6J2UPzF.js → index.DsZNm1_D.js} +1 -1
  80. streamlit/static/static/js/{index.5H98WqjT.js → index.DypwtfIh.js} +1 -1
  81. streamlit/static/static/js/{index.DgLRJfs3.js → index.JKOtpaMf.js} +1 -1
  82. streamlit/static/static/js/{index.B8-HOwf1.js → index.OI2eh_me.js} +1 -1
  83. streamlit/static/static/js/{index.CKUBdVQ9.js → index.TTO_Lb69.js} +1 -1
  84. streamlit/static/static/js/{index.-faJDV20.js → index.X1r5cenD.js} +1 -1
  85. streamlit/static/static/js/{index.BqfJJr3c.js → index.gsc49XzN.js} +1 -1
  86. streamlit/static/static/js/{index.BB_iwaVr.js → index.lMhsw-5K.js} +32 -32
  87. streamlit/static/static/js/{index.DZv5AoR1.js → index.s9zpEF8P.js} +1 -1
  88. streamlit/static/static/js/{index.BGTMh3Uu.js → index.tjRGlTlQ.js} +1 -1
  89. streamlit/static/static/js/{index.Bo1ztye0.js → index.wKGUZfH4.js} +1 -1
  90. streamlit/static/static/js/{input.VYKyGuhi.js → input.CIKqvWjB.js} +1 -1
  91. streamlit/static/static/js/{main.u5Bb3MY7.js → main.BkNqoTrd.js} +1 -1
  92. streamlit/static/static/js/{memory.BOMt4yAV.js → memory.DNcbFok2.js} +1 -1
  93. streamlit/static/static/js/{number-overlay-editor.CihlAHgl.js → number-overlay-editor.DwjiYKav.js} +1 -1
  94. streamlit/static/static/js/{pandasStylerUtils.BuqSgXpk.js → pandasStylerUtils.BSc50we3.js} +1 -1
  95. streamlit/static/static/js/{sandbox.COGR4pqz.js → sandbox.DJL9Gdcf.js} +1 -1
  96. streamlit/static/static/js/{styled-components.BEf3c4IJ.js → styled-components.D4jE1G9j.js} +1 -1
  97. streamlit/static/static/js/{throttle.Bl-XsA9N.js → throttle.dHeXiPIK.js} +1 -1
  98. streamlit/static/static/js/{timepicker.B-HgBYlK.js → timepicker.CIpoSUyW.js} +1 -1
  99. streamlit/static/static/js/{toConsumableArray.BrQebwtE.js → toConsumableArray.AOXQx2YY.js} +1 -1
  100. streamlit/static/static/js/uniqueId.DRPc21MO.js +1 -0
  101. streamlit/static/static/js/{useBasicWidgetState.8WwISl9r.js → useBasicWidgetState.BwQxG7iA.js} +1 -1
  102. streamlit/static/static/js/{useIntlLocale.D37LWdCR.js → useIntlLocale.JXPZPWaJ.js} +1 -1
  103. streamlit/static/static/js/{useTextInputAutoExpand.Bb_KqJvq.js → useTextInputAutoExpand.CD_nhoE_.js} +1 -1
  104. streamlit/static/static/js/{useUpdateUiValue.D1BLS5t7.js → useUpdateUiValue.Cl1Y22Ao.js} +1 -1
  105. streamlit/static/static/js/{useWaveformController.Ce0-qTws.js → useWaveformController.DJhScSDn.js} +1 -1
  106. streamlit/static/static/js/{withCalculatedWidth.BX2K3UVv.js → withCalculatedWidth.CrAfFzO9.js} +1 -1
  107. streamlit/static/static/js/{withFullScreenWrapper.CqfGs8T2.js → withFullScreenWrapper.EdE3zOz5.js} +1 -1
  108. streamlit/testing/v1/element_tree.py +23 -8
  109. {streamlit_nightly-1.53.2.dev20260125.dist-info → streamlit_nightly-1.53.2.dev20260127.dist-info}/METADATA +1 -1
  110. {streamlit_nightly-1.53.2.dev20260125.dist-info → streamlit_nightly-1.53.2.dev20260127.dist-info}/RECORD +114 -114
  111. streamlit/static/static/js/embed.C7by6AoE.js +0 -195
  112. streamlit/static/static/js/index.Bhy8EBYI.js +0 -2
  113. streamlit/static/static/js/index.C5ehUqNt.js +0 -2
  114. streamlit/static/static/js/index.m4WkwGMu.js +0 -1
  115. streamlit/static/static/js/uniqueId.8R4hbkYl.js +0 -1
  116. {streamlit_nightly-1.53.2.dev20260125.data → streamlit_nightly-1.53.2.dev20260127.data}/scripts/streamlit.cmd +0 -0
  117. {streamlit_nightly-1.53.2.dev20260125.dist-info → streamlit_nightly-1.53.2.dev20260127.dist-info}/WHEEL +0 -0
  118. {streamlit_nightly-1.53.2.dev20260125.dist-info → streamlit_nightly-1.53.2.dev20260127.dist-info}/entry_points.txt +0 -0
  119. {streamlit_nightly-1.53.2.dev20260125.dist-info → streamlit_nightly-1.53.2.dev20260127.dist-info}/top_level.txt +0 -0
@@ -14,9 +14,10 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import math
17
18
  from collections.abc import Iterable, Iterator, Mapping, MutableMapping
18
19
  from dataclasses import dataclass, field
19
- from typing import TYPE_CHECKING, Final, cast
20
+ from typing import TYPE_CHECKING, Any, Final, cast
20
21
  from urllib import parse
21
22
 
22
23
  from streamlit.errors import StreamlitAPIException, StreamlitQueryParamDictValueError
@@ -37,15 +38,114 @@ EMBED_QUERY_PARAMS_KEYS: Final[list[str]] = [
37
38
  EMBED_OPTIONS_QUERY_PARAM,
38
39
  ]
39
40
 
41
+ # Protected parameters that cannot be bound to widgets
42
+ PROTECTED_QUERY_PARAMS: Final[frozenset[str]] = frozenset(
43
+ [EMBED_QUERY_PARAM, EMBED_OPTIONS_QUERY_PARAM]
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class WidgetBinding:
49
+ """Represents a binding between a widget and a query parameter."""
50
+
51
+ widget_id: str
52
+ param_key: str
53
+ value_type: str # e.g., "bool_value", "string_value", etc.
54
+ script_hash: str # For MPA: identifies main vs page script
55
+
56
+
57
+ def parse_url_param(value: str | list[str], value_type: str) -> Any:
58
+ """Convert URL param to Python value based on WidgetState value type.
59
+
60
+ Parameters
61
+ ----------
62
+ value : str | list[str]
63
+ The URL parameter value(s).
64
+ value_type : str
65
+ The WidgetState value type (e.g., "bool_value", "string_value").
66
+
67
+ Returns
68
+ -------
69
+ Any
70
+ The parsed Python value appropriate for the widget type.
71
+
72
+ Raises
73
+ ------
74
+ ValueError
75
+ If the value cannot be parsed for the given type.
76
+ """
77
+ # For single-value types, get the last value if it's a list
78
+ val = value[-1] if isinstance(value, list) else value
79
+
80
+ match value_type:
81
+ case "bool_value":
82
+ lower_val = val.lower()
83
+ if lower_val == "true":
84
+ return True
85
+ if lower_val == "false":
86
+ return False
87
+ raise ValueError(f"Invalid boolean value: {val}")
88
+ case "int_value":
89
+ # Try to parse as int, but return string if it fails.
90
+ # This intentionally differs from double_value (which raises on failure)
91
+ # because int_value is used for selection widgets where URLs may contain
92
+ # human-readable option strings (e.g., ?fruit=apple instead of ?fruit=0).
93
+ # The deserializer will match the string against widget options.
94
+ try:
95
+ return int(val)
96
+ except ValueError:
97
+ return val
98
+ case "double_value":
99
+ return float(val)
100
+ case "string_value":
101
+ return val
102
+ case "string_array_value":
103
+ # Repeated params: ?foo=a&foo=b -> ["a", "b"]
104
+ return list(value) if isinstance(value, list) else [value]
105
+ case "double_array_value":
106
+ # Repeated params: ?foo=1.5&foo=2.5 -> [1.5, 2.5]
107
+ # Also handles string values for select_slider option matching
108
+ parts = list(value) if isinstance(value, list) else [value]
109
+ result_double: list[float | str] = []
110
+ for part in parts:
111
+ try:
112
+ result_double.append(float(part))
113
+ except ValueError: # noqa: PERF203
114
+ result_double.append(part) # Keep as string for select_slider
115
+ return result_double
116
+ case "int_array_value":
117
+ # Repeated params: ?foo=1&foo=2 -> [1, 2]
118
+ # Also handles string values for option matching (pills, etc.)
119
+ parts = list(value) if isinstance(value, list) else [value]
120
+ result_int: list[int | str] = []
121
+ for part in parts:
122
+ try:
123
+ result_int.append(int(part))
124
+ except ValueError: # noqa: PERF203
125
+ result_int.append(part) # Keep as string
126
+ return result_int
127
+ case _:
128
+ # Unknown type, return as-is
129
+ return val
130
+
40
131
 
41
132
  @dataclass
42
133
  class QueryParams(MutableMapping[str, str]):
43
134
  """A lightweight wrapper of a dict that sends forwardMsgs when state changes.
44
135
  It stores str keys with str and List[str] values.
136
+
137
+ Also manages widget bindings to query parameters for the bind="query-params" feature.
45
138
  """
46
139
 
47
140
  _query_params: dict[str, list[str] | str] = field(default_factory=dict)
48
141
 
142
+ # Widget binding registries
143
+ _bindings_by_param: dict[str, WidgetBinding] = field(default_factory=dict)
144
+ _bindings_by_widget: dict[str, WidgetBinding] = field(default_factory=dict)
145
+
146
+ # Store initial query params from URL at page load for seeding session state
147
+ _initial_query_params: dict[str, list[str]] = field(default_factory=dict)
148
+
49
149
  def __iter__(self) -> Iterator[str]:
50
150
  return iter(
51
151
  key
@@ -74,6 +174,12 @@ class QueryParams(MutableMapping[str, str]):
74
174
  raise KeyError(missing_key_error_message(key))
75
175
 
76
176
  def __setitem__(self, key: str, value: str | Iterable[str]) -> None:
177
+ # Prevent direct manipulation of bound query params
178
+ if self.is_bound(key):
179
+ raise StreamlitAPIException(
180
+ f"Cannot directly set query parameter '{key}' - "
181
+ f"it is bound to a widget. Modify the widget value instead."
182
+ )
77
183
  self._set_item_internal(key, value)
78
184
  self._send_query_param_msg()
79
185
 
@@ -83,6 +189,12 @@ class QueryParams(MutableMapping[str, str]):
83
189
  def __delitem__(self, key: str) -> None:
84
190
  if key.lower() in EMBED_QUERY_PARAMS_KEYS:
85
191
  raise KeyError(missing_key_error_message(key))
192
+ # Prevent direct deletion of bound query params
193
+ if self.is_bound(key):
194
+ raise StreamlitAPIException(
195
+ f"Cannot directly delete query parameter '{key}' - "
196
+ f"it is bound to a widget. Modify the widget value instead."
197
+ )
86
198
  try:
87
199
  del self._query_params[key]
88
200
  self._send_query_param_msg()
@@ -98,13 +210,34 @@ class QueryParams(MutableMapping[str, str]):
98
210
  ) -> None:
99
211
  # This overrides the `update` provided by MutableMapping
100
212
  # to ensure only one one ForwardMsg is sent.
213
+
214
+ # Consume dict-like objects into a list upfront to avoid iterating twice
215
+ # (once for keys, once for values). This prevents potential issues if
216
+ # `other` is modified during iteration.
217
+ other_as_list: list[tuple[str, str | Iterable[str]]]
101
218
  if hasattr(other, "keys") and hasattr(other, "__getitem__"):
102
- other = cast("SupportsKeysAndGetItem[str, str | Iterable[str]]", other)
103
- for key in other.keys(): # noqa: SIM118
104
- self._set_item_internal(key, other[key])
219
+ other_dict = cast("SupportsKeysAndGetItem[str, str | Iterable[str]]", other)
220
+ keys = list(other_dict.keys())
221
+ other_as_list = [(k, other_dict[k]) for k in keys]
105
222
  else:
106
- for key, value in other:
107
- self._set_item_internal(key, value)
223
+ # other is an iterable of tuples - consume into list
224
+ other_as_list = list(other)
225
+
226
+ # Collect all keys to check for bound params before making any changes
227
+ keys_to_update = [key for key, _ in other_as_list]
228
+ keys_to_update.extend(kwds.keys())
229
+
230
+ # Check for bound params
231
+ for key in keys_to_update:
232
+ if self.is_bound(key):
233
+ raise StreamlitAPIException(
234
+ f"Cannot directly set query parameter '{key}' - "
235
+ f"it is bound to a widget. Modify the widget value instead."
236
+ )
237
+
238
+ # Now apply the updates
239
+ for key, value in other_as_list:
240
+ self._set_item_internal(key, value)
108
241
  for key, value in kwds.items():
109
242
  self._set_item_internal(key, value)
110
243
  self._send_query_param_msg()
@@ -140,6 +273,14 @@ class QueryParams(MutableMapping[str, str]):
140
273
  ctx.enqueue(msg)
141
274
 
142
275
  def clear(self) -> None:
276
+ # Check if any bound params exist
277
+ bound_params = [key for key in self._query_params if self.is_bound(key)]
278
+ if bound_params:
279
+ raise StreamlitAPIException(
280
+ f"Cannot clear query parameters - the following are bound to widgets: "
281
+ f"{', '.join(repr(k) for k in bound_params)}. "
282
+ f"Modify the widget values instead, or remove the bind parameter."
283
+ )
143
284
  self.clear_with_no_forward_msg(preserve_embed=True)
144
285
  self._send_query_param_msg()
145
286
 
@@ -175,6 +316,353 @@ class QueryParams(MutableMapping[str, str]):
175
316
  if key.lower() in EMBED_QUERY_PARAMS_KEYS and preserve_embed
176
317
  }
177
318
 
319
+ def bind_widget(
320
+ self,
321
+ param_key: str,
322
+ widget_id: str,
323
+ value_type: str,
324
+ script_hash: str,
325
+ ) -> None:
326
+ """Register a widget binding to a query parameter.
327
+
328
+ If another widget was previously bound to this param_key, its binding
329
+ is replaced. The old widget's entry in _bindings_by_widget is cleaned up
330
+ to prevent orphaned references.
331
+
332
+ Parameters
333
+ ----------
334
+ param_key : str
335
+ The query parameter key (same as the widget's user key).
336
+ widget_id : str
337
+ The unique widget ID.
338
+ value_type : str
339
+ The WidgetState value type (e.g., "bool_value", "string_value").
340
+ script_hash : str
341
+ The script hash for MPA support.
342
+
343
+ Raises
344
+ ------
345
+ StreamlitAPIException
346
+ If the parameter is protected (embed, embed_options).
347
+ """
348
+ if param_key.lower() in PROTECTED_QUERY_PARAMS:
349
+ raise StreamlitAPIException(
350
+ f"Cannot bind to reserved query parameter '{param_key}'. "
351
+ f"'{EMBED_QUERY_PARAM}' and '{EMBED_OPTIONS_QUERY_PARAM}' are "
352
+ f"used internally for Streamlit's embed functionality."
353
+ )
354
+
355
+ # Clean up old binding if a different widget was bound to this param
356
+ old_binding = self._bindings_by_param.get(param_key)
357
+ if old_binding and old_binding.widget_id != widget_id:
358
+ self._bindings_by_widget.pop(old_binding.widget_id, None)
359
+
360
+ binding = WidgetBinding(
361
+ widget_id=widget_id,
362
+ param_key=param_key,
363
+ value_type=value_type,
364
+ script_hash=script_hash,
365
+ )
366
+ self._bindings_by_param[param_key] = binding
367
+ self._bindings_by_widget[widget_id] = binding
368
+
369
+ def unbind_widget(self, widget_id: str) -> None:
370
+ """Remove a widget binding.
371
+
372
+ Parameters
373
+ ----------
374
+ widget_id : str
375
+ The unique widget ID.
376
+ """
377
+ binding = self._bindings_by_widget.pop(widget_id, None)
378
+ if binding:
379
+ self._bindings_by_param.pop(binding.param_key, None)
380
+
381
+ def is_bound(self, param_key: str) -> bool:
382
+ """Check if a query parameter is bound to a widget.
383
+
384
+ Note: This check is case-sensitive, meaning "Foo" and "foo" are treated
385
+ as different parameters. This is intentional because Python keys are
386
+ case-sensitive and users explicitly choose their parameter names via
387
+ the widget's `key` argument. This differs from embed parameter checks
388
+ which are case-insensitive for URL compatibility.
389
+
390
+ Parameters
391
+ ----------
392
+ param_key : str
393
+ The query parameter key (case-sensitive).
394
+
395
+ Returns
396
+ -------
397
+ bool
398
+ True if the parameter is bound to a widget.
399
+ """
400
+ return param_key in self._bindings_by_param
401
+
402
+ def get_binding_for_param(self, param_key: str) -> WidgetBinding | None:
403
+ """Get the binding for a query parameter.
404
+
405
+ Parameters
406
+ ----------
407
+ param_key : str
408
+ The query parameter key.
409
+
410
+ Returns
411
+ -------
412
+ WidgetBinding | None
413
+ The binding if found, None otherwise.
414
+ """
415
+ return self._bindings_by_param.get(param_key)
416
+
417
+ def get_binding_for_widget(self, widget_id: str) -> WidgetBinding | None:
418
+ """Get the binding for a widget.
419
+
420
+ Parameters
421
+ ----------
422
+ widget_id : str
423
+ The unique widget ID.
424
+
425
+ Returns
426
+ -------
427
+ WidgetBinding | None
428
+ The binding if found, None otherwise.
429
+ """
430
+ return self._bindings_by_widget.get(widget_id)
431
+
432
+ def remove_param(self, param_key: str) -> bool:
433
+ """Remove a query parameter without protection checks.
434
+
435
+ This is an internal method for use by SessionState when clearing
436
+ invalid URL-seeded values. It bypasses the bound param protection
437
+ since the binding system itself needs to clear these values.
438
+
439
+ Parameters
440
+ ----------
441
+ param_key : str
442
+ The query parameter key to remove.
443
+
444
+ Returns
445
+ -------
446
+ bool
447
+ True if the param was removed, False if it didn't exist.
448
+ """
449
+ if param_key in self._query_params:
450
+ del self._query_params[param_key]
451
+ self._send_query_param_msg()
452
+ return True
453
+ return False
454
+
455
+ def set_initial_query_params(self, query_string: str) -> None:
456
+ """Store the initial query params from the URL for session state seeding.
457
+
458
+ Parameters
459
+ ----------
460
+ query_string : str
461
+ The URL query string (without the leading '?').
462
+ """
463
+ parsed = parse.parse_qs(query_string, keep_blank_values=True)
464
+ self._initial_query_params = parsed
465
+
466
+ def set_initial_query_params_from_current(self) -> None:
467
+ """Set _initial_query_params from the current filtered _query_params.
468
+
469
+ This is called after MPA page transitions where populate_from_query_string()
470
+ has filtered out params bound to widgets on other pages. Using this ensures
471
+ widget seeding only uses params that are valid for the current page, preventing
472
+ stale values from previous pages from leaking through.
473
+ """
474
+ # Convert _query_params to the list format used by _initial_query_params
475
+ # (parse_qs returns dict[str, list[str]])
476
+ self._initial_query_params = {
477
+ k: v if isinstance(v, list) else [v] for k, v in self._query_params.items()
478
+ }
479
+
480
+ def get_initial_value(self, param_key: str) -> str | list[str] | None:
481
+ """Get the initial URL value for a query parameter.
482
+
483
+ This is used for seeding session state on initial page load.
484
+
485
+ Parameters
486
+ ----------
487
+ param_key : str
488
+ The query parameter key.
489
+
490
+ Returns
491
+ -------
492
+ str | list[str] | None
493
+ The initial value(s) if present, None otherwise.
494
+ """
495
+ values = self._initial_query_params.get(param_key)
496
+ if values is None:
497
+ return None
498
+ if len(values) == 1:
499
+ return values[0]
500
+ return values
501
+
502
+ def _set_corrected_value(self, param_key: str, value: Any, value_type: str) -> None:
503
+ """Set a corrected value for a query parameter.
504
+
505
+ This is called when URL auto-correction is needed (e.g., after clamping
506
+ a value to min/max bounds). It updates both the internal query params
507
+ and sends a forward message to update the frontend URL.
508
+
509
+ Parameters
510
+ ----------
511
+ param_key : str
512
+ The query parameter key.
513
+ value : Any
514
+ The corrected value to set.
515
+ value_type : str
516
+ The WidgetState value type (e.g., "double_value", "int_value").
517
+ """
518
+
519
+ def format_number(v: Any) -> str:
520
+ """Format a number, using integer format if value is a whole number.
521
+
522
+ Examples: 5.0 -> "5", 5.5 -> "5.5", 5 -> "5"
523
+ Handles special float values (NaN, Inf) by returning them as-is.
524
+ """
525
+ # math.isfinite returns False for NaN, inf, -inf
526
+ # which would raise ValueError/OverflowError when converting to int
527
+ if isinstance(v, float) and math.isfinite(v) and v == int(v):
528
+ return str(int(v))
529
+ return str(v)
530
+
531
+ # Convert the value to a string representation for the URL
532
+ # All array types use repeated params: ?foo=a&foo=b
533
+ if value_type in {
534
+ "string_array_value",
535
+ "int_array_value",
536
+ "double_array_value",
537
+ }:
538
+ if isinstance(value, (list, tuple)):
539
+ # Store as list for repeated params
540
+ self._query_params[param_key] = [
541
+ format_number(v) if value_type == "double_array_value" else str(v)
542
+ for v in value
543
+ ]
544
+ self._send_query_param_msg()
545
+ return
546
+ str_value = (
547
+ format_number(value)
548
+ if value_type == "double_array_value"
549
+ else str(value)
550
+ )
551
+ else:
552
+ str_value = str(value)
553
+
554
+ self._query_params[param_key] = str_value
555
+ self._send_query_param_msg()
556
+
557
+ def populate_from_query_string(
558
+ self,
559
+ query_string: str,
560
+ valid_script_hashes: set[str] | None = None,
561
+ ) -> None:
562
+ """Populate query params from a URL query string.
563
+
564
+ Clears current params and repopulates from the URL. When valid_script_hashes
565
+ is provided (for MPA page transitions), filters out params bound to other pages.
566
+
567
+ Parameters
568
+ ----------
569
+ query_string : str
570
+ The raw query string from the URL (e.g., "foo=bar&baz=qux").
571
+ valid_script_hashes : set[str] | None
572
+ If provided, only keep params that are:
573
+ - Unbound (no widget binding)
574
+ - Bound to a widget with script_hash in this set
575
+ Params bound to other pages are filtered out.
576
+ If None, all params are kept (no filtering).
577
+ """
578
+ parsed_query_params = parse.parse_qs(query_string, keep_blank_values=True)
579
+
580
+ self.clear_with_no_forward_msg()
581
+ stale_widget_ids: list[str] = []
582
+
583
+ for key, val in parsed_query_params.items():
584
+ binding = self._bindings_by_param.get(key)
585
+ should_keep = True
586
+
587
+ # If filtering is enabled, check if this param should be filtered out
588
+ if (
589
+ valid_script_hashes is not None
590
+ and binding is not None
591
+ and binding.script_hash not in valid_script_hashes
592
+ ):
593
+ # Binding from a different page - filter it out
594
+ stale_widget_ids.append(binding.widget_id)
595
+ should_keep = False
596
+
597
+ if should_keep:
598
+ if len(val) == 0:
599
+ self.set_with_no_forward_msg(key, val="")
600
+ elif len(val) == 1:
601
+ self.set_with_no_forward_msg(key, val=val[-1])
602
+ else:
603
+ self.set_with_no_forward_msg(key, val)
604
+
605
+ # Clean up bindings for widgets from other pages
606
+ for widget_id in stale_widget_ids:
607
+ self.unbind_widget(widget_id)
608
+
609
+ # Update frontend URL if we filtered out any params
610
+ if stale_widget_ids:
611
+ self._send_query_param_msg()
612
+
613
+ def remove_stale_bindings(
614
+ self,
615
+ active_widget_ids: set[str],
616
+ fragment_ids_this_run: list[str] | None = None,
617
+ widget_metadata: dict[str, Any] | None = None,
618
+ ) -> None:
619
+ """Remove bindings and URL params for widgets that are no longer active.
620
+
621
+ This cleans up query params for conditional widgets that have been unmounted.
622
+ For fragment runs, widgets outside the running fragment(s) are preserved.
623
+
624
+ Note: Page-based cleanup for MPA navigation is handled separately via
625
+ populate_from_query_string() which is called before the script runs.
626
+
627
+ Parameters
628
+ ----------
629
+ active_widget_ids : set[str]
630
+ Set of widget IDs that are currently active/rendered.
631
+ fragment_ids_this_run : list[str] | None
632
+ List of fragment IDs being run, or None for full script runs.
633
+ widget_metadata : dict[str, Any] | None
634
+ Widget metadata dict to check fragment IDs.
635
+ """
636
+ stale_widget_ids = []
637
+ for widget_id in self._bindings_by_widget:
638
+ if widget_id in active_widget_ids:
639
+ # Widget is active in this run - keep it
640
+ continue
641
+
642
+ # For fragment runs, preserve widgets that aren't part of the running fragments
643
+ if fragment_ids_this_run and widget_metadata:
644
+ metadata = widget_metadata.get(widget_id)
645
+ if metadata and metadata.fragment_id not in fragment_ids_this_run:
646
+ # Widget belongs to a different fragment or main script - keep it
647
+ continue
648
+
649
+ stale_widget_ids.append(widget_id)
650
+
651
+ params_removed = False
652
+ for widget_id in stale_widget_ids:
653
+ binding = self._bindings_by_widget.get(widget_id)
654
+ if binding:
655
+ param_key = binding.param_key
656
+ # Remove the query param from the URL
657
+ if param_key in self._query_params:
658
+ del self._query_params[param_key]
659
+ params_removed = True
660
+ self.unbind_widget(widget_id)
661
+
662
+ # Send forward message to update frontend URL if we removed any params
663
+ if params_removed:
664
+ self._send_query_param_msg()
665
+
178
666
 
179
667
  def missing_key_error_message(key: str) -> str:
180
668
  return f'st.query_params has no key "{key}".'