streamlit-nightly 1.53.1.dev20260120__py3-none-any.whl → 1.53.2.dev20260122__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 (110) hide show
  1. streamlit/connections/snowflake_connection.py +5 -2
  2. streamlit/elements/lib/options_selector_utils.py +63 -19
  3. streamlit/elements/widgets/multiselect.py +31 -7
  4. streamlit/elements/widgets/radio.py +91 -31
  5. streamlit/elements/widgets/selectbox.py +38 -16
  6. streamlit/proto/Radio_pb2.py +4 -2
  7. streamlit/proto/Radio_pb2.pyi +20 -3
  8. streamlit/static/index.html +1 -1
  9. streamlit/static/manifest.json +308 -308
  10. streamlit/static/static/js/{ErrorOutline.esm.CqVEQ-uJ.js → ErrorOutline.esm.CIFYUdwC.js} +1 -1
  11. streamlit/static/static/js/{FileDownload.esm.G-HJG6nt.js → FileDownload.esm.DWVTnTHm.js} +1 -1
  12. streamlit/static/static/js/{FileHelper.Bd4bPp0Y.js → FileHelper.BPYQIPd1.js} +1 -1
  13. streamlit/static/static/js/{FormClearHelper.B74VElyu.js → FormClearHelper.CypmvhYZ.js} +1 -1
  14. streamlit/static/static/js/{InputInstructions.ClBLOwfm.js → InputInstructions.Bi62hDTQ.js} +1 -1
  15. streamlit/static/static/js/{Particles.CyYskark.js → Particles.yebG0VuV.js} +1 -1
  16. streamlit/static/static/js/{ProgressBar.vWX1Igzt.js → ProgressBar.Dy9CI6w4.js} +1 -1
  17. streamlit/static/static/js/{StreamlitSyntaxHighlighter.BNhyeEtl.js → StreamlitSyntaxHighlighter.Btk92CPv.js} +1 -1
  18. streamlit/static/static/js/{TableChart.esm.Mk-Hvy1t.js → TableChart.esm.DBeVaFNt.js} +1 -1
  19. streamlit/static/static/js/{Toolbar.NhlhlTzK.js → Toolbar.DC2Tp-qb.js} +1 -1
  20. streamlit/static/static/js/{WidgetLabelHelpIconInline.Ch9VDnrM.js → WidgetLabelHelpIconInline.3DnEd9BK.js} +1 -1
  21. streamlit/static/static/js/{base-input.DSh9H2uv.js → base-input.7Sj6pVk0.js} +1 -1
  22. streamlit/static/static/js/{checkbox.DCFAQrRB.js → checkbox.CcUx3XuQ.js} +1 -1
  23. streamlit/static/static/js/{createDownloadLinkElement.D5zpcd24.js → createDownloadLinkElement.DZuwkCqy.js} +1 -1
  24. streamlit/static/static/js/{data-grid-overlay-editor.GAqbIurd.js → data-grid-overlay-editor.Dw-AewlN.js} +1 -1
  25. streamlit/static/static/js/{downloader.C1MBZFo2.js → downloader.Bsx5M2Du.js} +1 -1
  26. streamlit/static/static/js/{embed.CZG6oOsc.js → embed.C7by6AoE.js} +1 -1
  27. streamlit/static/static/js/{es6.toHnBzIb.js → es6.BpAqZaR_.js} +2 -2
  28. streamlit/static/static/js/{formatNumber.BsU-5o3V.js → formatNumber.DjehVPVS.js} +1 -1
  29. streamlit/static/static/js/{iconPosition.OKOYrH6A.js → iconPosition.D02OPE-d.js} +1 -1
  30. streamlit/static/static/js/{iframeResizer.contentWindow.D6yxU4DL.js → iframeResizer.contentWindow.xtstqPd7.js} +1 -1
  31. streamlit/static/static/js/{index.CV4niwLB.js → index.-faJDV20.js} +1 -1
  32. streamlit/static/static/js/{index.D7Hgmilz.js → index.5H98WqjT.js} +1 -1
  33. streamlit/static/static/js/{index.0iCXW13-.js → index.8FPw0_gD.js} +1 -1
  34. streamlit/static/static/js/{index.D94k70Hg.js → index.B5tD5YeV.js} +1 -1
  35. streamlit/static/static/js/{index.YRiVxrFw.js → index.B8-HOwf1.js} +1 -1
  36. streamlit/static/static/js/{index.BW0F_Pbs.js → index.B9gbSNsw.js} +1 -1
  37. streamlit/static/static/js/{index.qjO5OK90.js → index.BB_iwaVr.js} +6 -6
  38. streamlit/static/static/js/{index.BXqtIpgi.js → index.BDlI2pRp.js} +1 -1
  39. streamlit/static/static/js/{index.EOvPT8N-.js → index.BGTMh3Uu.js} +1 -1
  40. streamlit/static/static/js/{index.Bhr3IHkD.js → index.BIcJe97b.js} +1 -1
  41. streamlit/static/static/js/{index.E4x2UBkh.js → index.BK9S5qug.js} +1 -1
  42. streamlit/static/static/js/{index._t3w-7R0.js → index.BOkpEbJS.js} +1 -1
  43. streamlit/static/static/js/{index.SBwNLdli.js → index.BV6XgCij.js} +1 -1
  44. streamlit/static/static/js/{index.CqywI2eW.js → index.BVhVdVeE.js} +1 -1
  45. streamlit/static/static/js/{index.CXLtvxrQ.js → index.Bhy8EBYI.js} +1 -1
  46. streamlit/static/static/js/{index.P0_E3iNi.js → index.Bo1ztye0.js} +1 -1
  47. streamlit/static/static/js/{index.C6LOxzFC.js → index.BqfJJr3c.js} +1 -1
  48. streamlit/static/static/js/{index.CJCEiqv2.js → index.Bri1T2TS.js} +1 -1
  49. streamlit/static/static/js/{index.MZ7ugsN-.js → index.BvZbnSMC.js} +1 -1
  50. streamlit/static/static/js/{index.CGt5hnLi.js → index.C5ehUqNt.js} +1 -1
  51. streamlit/static/static/js/{index.Bh0_7PvD.js → index.C9v49R-a.js} +1 -1
  52. streamlit/static/static/js/{index.Ch4X_YdF.js → index.CA0RmxJF.js} +1 -1
  53. streamlit/static/static/js/{index.B8mzpc1l.js → index.CEwnDCn9.js} +1 -1
  54. streamlit/static/static/js/{index.DhGqPx_f.js → index.CGbvkEtg.js} +1 -1
  55. streamlit/static/static/js/{index.ia5Ub9p7.js → index.CKUBdVQ9.js} +1 -1
  56. streamlit/static/static/js/{index.DGrQJk9x.js → index.CdRwiHPm.js} +1 -1
  57. streamlit/static/static/js/{index.dGk9EWLh.js → index.CgARjn28.js} +1 -1
  58. streamlit/static/static/js/{index.Dq0ZbGYZ.js → index.CyDHwK5P.js} +1 -1
  59. streamlit/static/static/js/{index.Blo8DAcU.js → index.D6J2UPzF.js} +1 -1
  60. streamlit/static/static/js/{index.DR8Ty3j4.js → index.D6Z9hKJY.js} +1 -1
  61. streamlit/static/static/js/{index.BVbT0yWJ.js → index.D9RL5sRp.js} +1 -1
  62. streamlit/static/static/js/{index.DwgEkDLk.js → index.DDu_qTm0.js} +1 -1
  63. streamlit/static/static/js/{index.5CwvMIZq.js → index.DJjSqPAx.js} +1 -1
  64. streamlit/static/static/js/{index.C_w1nWis.js → index.DO2T-QzF.js} +1 -1
  65. streamlit/static/static/js/{index.D0h2EJ_T.js → index.DSSapl3Q.js} +1 -1
  66. streamlit/static/static/js/{index.BkYhb--A.js → index.DZv5AoR1.js} +1 -1
  67. streamlit/static/static/js/{index.Bs4E_0D7.js → index.D_TIyPF4.js} +1 -1
  68. streamlit/static/static/js/{index.Dl2MB5Tx.js → index.DdxofXV8.js} +1 -1
  69. streamlit/static/static/js/{index.DJ8OTQbk.js → index.DgLRJfs3.js} +1 -1
  70. streamlit/static/static/js/{index.AkleAg_w.js → index.JL0uGAeJ.js} +1 -1
  71. streamlit/static/static/js/{index.BU8aUWlo.js → index.S-mjkUeF.js} +1 -1
  72. streamlit/static/static/js/{index.XgBfXgN1.js → index.XFMDBL5n.js} +1 -1
  73. streamlit/static/static/js/{index.-pgbbiGJ.js → index.ZIA43eTF.js} +1 -1
  74. streamlit/static/static/js/index.iF5zYERg.js +3 -0
  75. streamlit/static/static/js/{index.COcj-WpN.js → index.iXzAofuY.js} +1 -1
  76. streamlit/static/static/js/index.m3dn5Bai.js +188 -0
  77. streamlit/static/static/js/{index.BHWCIpbw.js → index.m4WkwGMu.js} +1 -1
  78. streamlit/static/static/js/{index.DbCS2N3T.js → index.x1B588Xu.js} +1 -1
  79. streamlit/static/static/js/{input.Bavj6HHJ.js → input.VYKyGuhi.js} +1 -1
  80. streamlit/static/static/js/{main.dnUTEH_j.js → main.u5Bb3MY7.js} +1 -1
  81. streamlit/static/static/js/{memory.Czf1Sxzc.js → memory.BOMt4yAV.js} +1 -1
  82. streamlit/static/static/js/{number-overlay-editor.CRQIke3a.js → number-overlay-editor.CihlAHgl.js} +1 -1
  83. streamlit/static/static/js/{pandasStylerUtils.agmr-LQ2.js → pandasStylerUtils.BuqSgXpk.js} +1 -1
  84. streamlit/static/static/js/{sandbox.BlCiIomw.js → sandbox.COGR4pqz.js} +1 -1
  85. streamlit/static/static/js/{styled-components.Bz3KSbhj.js → styled-components.BEf3c4IJ.js} +1 -1
  86. streamlit/static/static/js/{throttle.B1o314FW.js → throttle.Bl-XsA9N.js} +1 -1
  87. streamlit/static/static/js/{timepicker.3x4ndo9E.js → timepicker.B-HgBYlK.js} +1 -1
  88. streamlit/static/static/js/{toConsumableArray.BeHbBK8g.js → toConsumableArray.BrQebwtE.js} +1 -1
  89. streamlit/static/static/js/uniqueId.8R4hbkYl.js +1 -0
  90. streamlit/static/static/js/{useBasicWidgetState.CFP4_PTk.js → useBasicWidgetState.8WwISl9r.js} +1 -1
  91. streamlit/static/static/js/{useIntlLocale.BJubkaPQ.js → useIntlLocale.D37LWdCR.js} +1 -1
  92. streamlit/static/static/js/{useTextInputAutoExpand.DBGwhM4R.js → useTextInputAutoExpand.Bb_KqJvq.js} +1 -1
  93. streamlit/static/static/js/{useUpdateUiValue.CyufNQfR.js → useUpdateUiValue.D1BLS5t7.js} +1 -1
  94. streamlit/static/static/js/{useWaveformController.Dj5h0D8Y.js → useWaveformController.Ce0-qTws.js} +1 -1
  95. streamlit/static/static/js/{withCalculatedWidth.D0IRb-7w.js → withCalculatedWidth.BX2K3UVv.js} +1 -1
  96. streamlit/static/static/js/{withFullScreenWrapper.CHBnw0Co.js → withFullScreenWrapper.CqfGs8T2.js} +1 -1
  97. streamlit/testing/v1/element_tree.py +2 -2
  98. streamlit/watcher/event_based_path_watcher.py +37 -7
  99. streamlit/watcher/path_watcher.py +60 -1
  100. streamlit/watcher/util.py +26 -10
  101. streamlit/web/bootstrap.py +11 -2
  102. {streamlit_nightly-1.53.1.dev20260120.dist-info → streamlit_nightly-1.53.2.dev20260122.dist-info}/METADATA +1 -1
  103. {streamlit_nightly-1.53.1.dev20260120.dist-info → streamlit_nightly-1.53.2.dev20260122.dist-info}/RECORD +107 -107
  104. {streamlit_nightly-1.53.1.dev20260120.dist-info → streamlit_nightly-1.53.2.dev20260122.dist-info}/WHEEL +1 -1
  105. streamlit/static/static/js/index.1sBZt_1I.js +0 -188
  106. streamlit/static/static/js/index.DRGDE5oo.js +0 -3
  107. streamlit/static/static/js/uniqueId.Zn1vZBLX.js +0 -1
  108. {streamlit_nightly-1.53.1.dev20260120.data → streamlit_nightly-1.53.2.dev20260122.data}/scripts/streamlit.cmd +0 -0
  109. {streamlit_nightly-1.53.1.dev20260120.dist-info → streamlit_nightly-1.53.2.dev20260122.dist-info}/entry_points.txt +0 -0
  110. {streamlit_nightly-1.53.1.dev20260120.dist-info → streamlit_nightly-1.53.2.dev20260122.dist-info}/top_level.txt +0 -0
@@ -135,7 +135,9 @@ class BaseSnowflakeConnection(BaseConnection["InternalSnowflakeConnection"]):
135
135
  ),
136
136
  wait=wait_fixed(1),
137
137
  )
138
- def _query(sql: str) -> DataFrame:
138
+ # `params` must be an explicit parameter (not captured from closure) so that
139
+ # `@st.cache_data` includes it in the cache key.
140
+ def _query(sql: str, params: Any = None) -> DataFrame:
139
141
  cur = self._instance.cursor()
140
142
  cur.execute(sql, params=params, **kwargs)
141
143
  return cur.fetch_pandas_all() # type: ignore
@@ -153,7 +155,7 @@ class BaseSnowflakeConnection(BaseConnection["InternalSnowflakeConnection"]):
153
155
  ttl=ttl,
154
156
  )(_query)
155
157
 
156
- return _query(sql)
158
+ return _query(sql, params)
157
159
 
158
160
  def write_pandas(
159
161
  self,
@@ -349,6 +351,7 @@ class BaseSnowflakeConnection(BaseConnection["InternalSnowflakeConnection"]):
349
351
  """Closes the underlying Snowflake connection."""
350
352
  if self._raw_instance is not None:
351
353
  self._raw_instance.close()
354
+ self._raw_instance = None
352
355
 
353
356
 
354
357
  class SnowflakeConnection(BaseSnowflakeConnection):
@@ -290,6 +290,7 @@ def validate_and_sync_value_with_options(
290
290
  opt: Sequence[T],
291
291
  default_index: int | None,
292
292
  key: str | int | None,
293
+ format_func: Callable[[Any], str] = str,
293
294
  ) -> tuple[T | None, bool]:
294
295
  """Validate current value against options, resetting session state if invalid.
295
296
 
@@ -306,6 +307,11 @@ def validate_and_sync_value_with_options(
306
307
  The default index to reset to if value is invalid.
307
308
  key
308
309
  The widget key for session state updates.
310
+ format_func
311
+ Function to format options for comparison. Used to compare values by their
312
+ string representation instead of using == directly. This is necessary because
313
+ widget values are deepcopied, and for custom classes without __eq__, the
314
+ deepcopied instances would fail identity comparison.
309
315
 
310
316
  Returns
311
317
  -------
@@ -315,30 +321,50 @@ def validate_and_sync_value_with_options(
315
321
  if current_value is None:
316
322
  return current_value, False
317
323
 
318
- # Check if current value is still in the new options
319
- try:
320
- index_(opt, current_value)
321
- return current_value, False
322
- except ValueError:
323
- # Value not in options - reset to default
324
- if default_index is not None and len(opt) > 0:
325
- new_value: T | None = opt[default_index]
326
- else:
327
- new_value = None
324
+ # For Enum values, use the original index_() approach which uses == comparison.
325
+ # This correctly handles enum class identity - enums from different classes
326
+ # (e.g., after script rerun) should NOT be considered equal, which is important
327
+ # for enum coercion to work correctly when coercion is disabled.
328
+ if isinstance(current_value, Enum):
329
+ try:
330
+ index_(opt, current_value)
331
+ return current_value, False
332
+ except ValueError:
333
+ pass # Fall through to reset logic below
334
+ else:
335
+ # For non-Enum values, use format_func comparison. This handles custom objects
336
+ # without __eq__ where widget values are deepcopied and the deepcopied instances
337
+ # would fail identity comparison with ==.
338
+ try:
339
+ formatted_value = format_func(current_value)
340
+ except Exception:
341
+ # format_func failed - value is invalid
342
+ formatted_value = None
343
+
344
+ formatted_options_set = {format_func(o) for o in opt}
345
+ if formatted_value is not None and formatted_value in formatted_options_set:
346
+ return current_value, False
347
+
348
+ # Value not in options - reset to default
349
+ if default_index is not None and len(opt) > 0:
350
+ new_value: T | None = opt[default_index]
351
+ else:
352
+ new_value = None
328
353
 
329
- if key is not None:
330
- # Update session_state so subsequent accesses in this run
331
- # return the corrected value. Use reset_state_value to avoid
332
- # the "cannot be modified after widget instantiated" error.
333
- get_session_state().reset_state_value(str(key), new_value)
354
+ if key is not None:
355
+ # Update session_state so subsequent accesses in this run
356
+ # return the corrected value. Use reset_state_value to avoid
357
+ # the "cannot be modified after widget instantiated" error.
358
+ get_session_state().reset_state_value(str(key), new_value)
334
359
 
335
- return new_value, True
360
+ return new_value, True
336
361
 
337
362
 
338
363
  def validate_and_sync_multiselect_value_with_options(
339
364
  current_values: list[T] | list[T | str],
340
365
  opt: Sequence[T],
341
366
  key: str | int | None,
367
+ format_func: Callable[[Any], str] = str,
342
368
  ) -> tuple[list[T] | list[T | str], bool]:
343
369
  """Validate multiselect values against options, syncing session state if needed.
344
370
 
@@ -356,6 +382,11 @@ def validate_and_sync_multiselect_value_with_options(
356
382
  The sequence of valid options.
357
383
  key
358
384
  The widget key for session state updates.
385
+ format_func
386
+ Function to format options for comparison. Used to compare values by their
387
+ string representation instead of using == directly. This is necessary because
388
+ widget values are deepcopied, and for custom classes without __eq__, the
389
+ deepcopied instances would fail identity comparison.
359
390
 
360
391
  Returns
361
392
  -------
@@ -365,13 +396,26 @@ def validate_and_sync_multiselect_value_with_options(
365
396
  if not current_values:
366
397
  return current_values, False
367
398
 
399
+ # Create a set of formatted options for O(1) lookup.
400
+ # We use format_func to compare values by their string representation
401
+ # instead of using == directly. This is necessary because widget values
402
+ # are deepcopied, and for custom classes without __eq__, the deepcopied
403
+ # instances would fail identity comparison.
404
+ formatted_options_set = {format_func(o) for o in opt}
405
+
368
406
  valid_values: list[T | str] = []
369
407
  for value in current_values:
370
408
  try:
371
- index_(opt, value)
409
+ formatted_value = format_func(value)
410
+ except Exception: # noqa: S112
411
+ # format_func failed on this value (e.g., a string value from a previous
412
+ # session when format_func expects an object with specific attributes).
413
+ # In this case, the value is definitely not valid since the current options
414
+ # can be formatted successfully.
415
+ continue
416
+
417
+ if formatted_value in formatted_options_set:
372
418
  valid_values.append(value)
373
- except ValueError: # noqa: PERF203
374
- pass
375
419
 
376
420
  if len(valid_values) == len(current_values):
377
421
  return current_values, False
@@ -82,6 +82,7 @@ class MultiSelectSerde(Generic[T]):
82
82
  formatted_options: list[str]
83
83
  formatted_option_to_option_index: dict[str, int]
84
84
  default_options_indices: list[int]
85
+ format_func: Callable[[Any], str]
85
86
 
86
87
  def __init__(
87
88
  self,
@@ -90,6 +91,7 @@ class MultiSelectSerde(Generic[T]):
90
91
  formatted_options: list[str],
91
92
  formatted_option_to_option_index: dict[str, int],
92
93
  default_options_indices: list[int] | None = None,
94
+ format_func: Callable[[Any], str] = str,
93
95
  ) -> None:
94
96
  """Initialize the MultiSelectSerde.
95
97
 
@@ -111,24 +113,45 @@ class MultiSelectSerde(Generic[T]):
111
113
  default_option_index : int or None, optional
112
114
  The index of the default option to use when no selection is made.
113
115
  If None, no default option is selected.
116
+ format_func : Callable[[Any], str], optional
117
+ Function to format options for comparison. Used to compare values by their
118
+ string representation instead of using == directly. This is necessary because
119
+ widget values are deepcopied, and for custom classes without __eq__, the
120
+ deepcopied instances would fail identity comparison.
114
121
  """
115
122
 
116
123
  self.options = options
117
124
  self.formatted_options = formatted_options
118
125
  self.formatted_option_to_option_index = formatted_option_to_option_index
119
126
  self.default_options_indices = default_options_indices or []
127
+ self.format_func = format_func
120
128
 
121
129
  def serialize(self, value: list[T | str] | list[T]) -> list[str]:
122
130
  converted_value = convert_anything_to_list(value)
123
131
  values: list[str] = []
124
132
  for v in converted_value:
133
+ # Use format_func to find the formatted option instead of using
134
+ # self.options.index(v) which relies on == comparison. This is necessary
135
+ # because widget values are deepcopied, and for custom classes without
136
+ # __eq__, the deepcopied instances would fail identity comparison.
125
137
  try:
126
- option_index = self.options.index(v)
127
- values.append(self.formatted_options[option_index])
128
- except ValueError: # noqa: PERF203
129
- # at this point we know that v is a string, otherwise
130
- # it would have been found in the options
131
- values.append(cast("str", v))
138
+ formatted_value = self.format_func(v)
139
+ except Exception:
140
+ # format_func failed (e.g., v is a string but format_func expects
141
+ # an object with specific attributes). Use str(v) to ensure we append
142
+ # a proper string, not the original object. This handles both cases:
143
+ # - v is already a string -> str(v) returns it unchanged
144
+ # - v is a custom object -> str(v) gives its string representation
145
+ values.append(str(v))
146
+ continue
147
+
148
+ if formatted_value in self.formatted_option_to_option_index:
149
+ values.append(formatted_value)
150
+ else:
151
+ # Value not found in options - it's likely a user-entered string
152
+ # (when accept_new_options=True) or an invalid value. Use the
153
+ # formatted string (not the original object) for type consistency.
154
+ values.append(formatted_value)
132
155
  return values
133
156
 
134
157
  def deserialize(self, ui_value: list[str] | None) -> list[T | str] | list[T]:
@@ -530,6 +553,7 @@ class MultiSelectMixin:
530
553
  formatted_options=formatted_options,
531
554
  formatted_option_to_option_index=formatted_option_to_option_index,
532
555
  default_options_indices=default_values,
556
+ format_func=format_func,
533
557
  )
534
558
 
535
559
  widget_state = register_widget(
@@ -560,7 +584,7 @@ class MultiSelectMixin:
560
584
  # previously selected values are no longer available.
561
585
  current_values, value_needs_reset = (
562
586
  validate_and_sync_multiselect_value_with_options(
563
- widget_state.value, indexable_options, key
587
+ widget_state.value, indexable_options, key, format_func
564
588
  )
565
589
  )
566
590
 
@@ -14,7 +14,6 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from dataclasses import dataclass
18
17
  from textwrap import dedent
19
18
  from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload
20
19
 
@@ -27,7 +26,11 @@ from streamlit.elements.lib.layout_utils import (
27
26
  Width,
28
27
  validate_width,
29
28
  )
30
- from streamlit.elements.lib.options_selector_utils import index_, maybe_coerce_enum
29
+ from streamlit.elements.lib.options_selector_utils import (
30
+ create_mappings,
31
+ maybe_coerce_enum,
32
+ validate_and_sync_value_with_options,
33
+ )
31
34
  from streamlit.elements.lib.policies import (
32
35
  check_widget_policies,
33
36
  maybe_raise_label_warnings,
@@ -63,27 +66,70 @@ if TYPE_CHECKING:
63
66
  T = TypeVar("T")
64
67
 
65
68
 
66
- @dataclass
67
69
  class RadioSerde(Generic[T]):
70
+ """Serializer/deserializer for Radio widget values.
71
+
72
+ Uses string-based values (formatted option strings) for robust handling
73
+ of dynamic option changes, similar to SelectboxSerde.
74
+ """
75
+
68
76
  options: Sequence[T]
69
- index: int | None
77
+ formatted_options: list[str]
78
+ formatted_option_to_option_index: dict[str, int]
79
+ default_option_index: int | None
80
+ format_func: Callable[[Any], str]
70
81
 
71
- def serialize(self, v: object) -> int | None:
82
+ def __init__(
83
+ self,
84
+ options: Sequence[T],
85
+ *,
86
+ formatted_options: list[str],
87
+ formatted_option_to_option_index: dict[str, int],
88
+ default_option_index: int | None = None,
89
+ format_func: Callable[[Any], str] = str,
90
+ ) -> None:
91
+ self.options = options
92
+ self.formatted_options = formatted_options
93
+ self.formatted_option_to_option_index = formatted_option_to_option_index
94
+ self.default_option_index = default_option_index
95
+ self.format_func = format_func
96
+
97
+ def serialize(self, v: T | str | None) -> str | None:
72
98
  if v is None:
73
99
  return None
100
+ if len(self.options) == 0:
101
+ return None
74
102
 
75
- return 0 if len(self.options) == 0 else index_(self.options, v)
103
+ # Use format_func to find the formatted option instead of using
104
+ # index_(self.options, v) which relies on == comparison. This is necessary
105
+ # because widget values are deepcopied, and for custom classes without
106
+ # __eq__, the deepcopied instances would fail identity comparison.
107
+ try:
108
+ formatted_value = self.format_func(v)
109
+ except Exception:
110
+ # format_func failed (e.g., v is a string but format_func expects
111
+ # an object with specific attributes). Treat v as a raw string.
112
+ return cast("str", v)
113
+
114
+ if formatted_value in self.formatted_option_to_option_index:
115
+ return formatted_value
116
+ # Value not found in options - return as raw string
117
+ return cast("str", v)
118
+
119
+ def deserialize(self, ui_value: str | None) -> T | str | None:
120
+ # If no options, there's no valid value - return None
121
+ if len(self.options) == 0:
122
+ return None
76
123
 
77
- def deserialize(self, ui_value: int | None) -> T | None:
78
- idx = ui_value if ui_value is not None else self.index
124
+ if ui_value is None:
125
+ return (
126
+ self.options[self.default_option_index]
127
+ if self.default_option_index is not None
128
+ else None
129
+ )
79
130
 
80
- return (
81
- self.options[idx]
82
- if idx is not None
83
- and len(self.options) > 0
84
- and self.options[idx] is not None
85
- else None
86
- )
131
+ option_index = self.formatted_option_to_option_index.get(ui_value)
132
+ return self.options[option_index] if option_index is not None else ui_value
87
133
 
88
134
 
89
135
  class RadioMixin:
@@ -368,18 +414,17 @@ class RadioMixin:
368
414
  opt = convert_anything_to_list(options)
369
415
  check_python_comparable(opt)
370
416
 
417
+ formatted_options, formatted_option_to_option_index = create_mappings(
418
+ opt, format_func
419
+ )
420
+
371
421
  element_id = compute_and_register_element_id(
372
422
  "radio",
373
423
  user_key=key,
374
- # Treat provided key as the main widget identity. Only include the
375
- # following parameters in the identity computation since they can
376
- # invalidate the current selection mapping.
377
- # Changes to format_func also invalidate the current selection,
378
- # but this is already handled via the `options` parameter below:
379
- key_as_main_identity={"options"},
424
+ key_as_main_identity=True,
380
425
  dg=self.dg,
381
426
  label=label,
382
- options=[str(format_func(option)) for option in opt],
427
+ options=formatted_options,
383
428
  index=index,
384
429
  help=help,
385
430
  horizontal=horizontal,
@@ -415,7 +460,7 @@ class RadioMixin:
415
460
  radio_proto.label = label
416
461
  if index is not None:
417
462
  radio_proto.default = index
418
- radio_proto.options[:] = [str(format_func(option)) for option in opt]
463
+ radio_proto.options[:] = formatted_options
419
464
  radio_proto.form_id = current_form_id(self.dg)
420
465
  radio_proto.horizontal = horizontal
421
466
  radio_proto.disabled = disabled
@@ -429,7 +474,13 @@ class RadioMixin:
429
474
  if help is not None:
430
475
  radio_proto.help = dedent(help)
431
476
 
432
- serde = RadioSerde(opt, index)
477
+ serde = RadioSerde(
478
+ opt,
479
+ formatted_options=formatted_options,
480
+ formatted_option_to_option_index=formatted_option_to_option_index,
481
+ default_option_index=index,
482
+ format_func=format_func,
483
+ )
433
484
 
434
485
  widget_state = register_widget(
435
486
  radio_proto.id,
@@ -439,21 +490,30 @@ class RadioMixin:
439
490
  deserializer=serde.deserialize,
440
491
  serializer=serde.serialize,
441
492
  ctx=ctx,
442
- value_type="int_value",
493
+ value_type="string_value",
443
494
  )
444
495
  widget_state = maybe_coerce_enum(widget_state, options, opt)
445
496
 
446
- if widget_state.value_changed:
447
- if widget_state.value is not None:
448
- serialized_value = serde.serialize(widget_state.value)
449
- if serialized_value is not None:
450
- radio_proto.value = serialized_value
497
+ # Validate the current value against the new options.
498
+ # If the value is no longer valid (not in options), reset to default.
499
+ # This handles the case where options change dynamically and the
500
+ # previously selected value is no longer available.
501
+ # Cast to T | None since radio doesn't support accept_new_options,
502
+ # so string values that aren't in options will be reset to default.
503
+ current_value, value_needs_reset = validate_and_sync_value_with_options(
504
+ cast("T | None", widget_state.value), opt, index, key
505
+ )
506
+
507
+ if value_needs_reset or widget_state.value_changed:
508
+ serialized_value = serde.serialize(current_value)
509
+ if serialized_value is not None:
510
+ radio_proto.raw_value = serialized_value
451
511
  radio_proto.set_value = True
452
512
 
453
513
  if ctx:
454
514
  save_for_app_testing(ctx, element_id, format_func)
455
515
  self.dg._enqueue("radio", radio_proto, layout_config=layout_config)
456
- return widget_state.value
516
+ return current_value
457
517
 
458
518
  @property
459
519
  def dg(self) -> DeltaGenerator:
@@ -35,7 +35,6 @@ from streamlit.elements.lib.layout_utils import (
35
35
  )
36
36
  from streamlit.elements.lib.options_selector_utils import (
37
37
  create_mappings,
38
- index_,
39
38
  maybe_coerce_enum,
40
39
  validate_and_sync_value_with_options,
41
40
  )
@@ -79,6 +78,7 @@ class SelectboxSerde(Generic[T]):
79
78
  formatted_options: list[str]
80
79
  formatted_option_to_option_index: dict[str, int]
81
80
  default_option_index: int | None
81
+ format_func: Callable[[Any], str]
82
82
 
83
83
  def __init__(
84
84
  self,
@@ -87,6 +87,7 @@ class SelectboxSerde(Generic[T]):
87
87
  formatted_options: list[str],
88
88
  formatted_option_to_option_index: dict[str, int],
89
89
  default_option_index: int | None = None,
90
+ format_func: Callable[[Any], str] = str,
90
91
  ) -> None:
91
92
  """Initialize the SelectboxSerde.
92
93
 
@@ -108,33 +109,53 @@ class SelectboxSerde(Generic[T]):
108
109
  default_option_index : int or None, optional
109
110
  The index of the default option to use when no selection is made.
110
111
  If None, no default option is selected.
112
+ format_func : Callable[[Any], str], optional
113
+ Function to format options for comparison. Used to compare values by their
114
+ string representation instead of using == directly. This is necessary because
115
+ widget values are deepcopied, and for custom classes without __eq__, the
116
+ deepcopied instances would fail identity comparison.
111
117
  """
112
118
 
113
119
  self.options = options
114
120
  self.formatted_options = formatted_options
115
121
  self.formatted_option_to_option_index = formatted_option_to_option_index
116
122
  self.default_option_index = default_option_index
123
+ self.format_func = format_func
117
124
 
118
125
  def serialize(self, v: T | str | None) -> str | None:
119
126
  if v is None:
120
127
  return None
121
- if len(self.options) == 0:
122
- return ""
123
-
124
- # we don't check for isinstance(v, str) because this could lead to wrong
125
- # results if v is a string that is part of the options itself as it would
126
- # skip formatting in that case
128
+ # Note: We don't short-circuit for empty options here because
129
+ # accept_new_options=True allows user-entered values even with no options.
130
+ # The normal flow below handles this correctly.
131
+
132
+ # Use format_func to find the formatted option instead of using
133
+ # index_(self.options, v) which relies on == comparison. This is necessary
134
+ # because widget values are deepcopied, and for custom classes without
135
+ # __eq__, the deepcopied instances would fail identity comparison.
127
136
  try:
128
- option_index = index_(self.options, v)
129
- return self.formatted_options[option_index]
130
- except ValueError:
131
- # we know that v is a string, otherwise it would have been found in the
132
- # options
133
- return cast("str", v)
137
+ formatted_value = self.format_func(v)
138
+ except Exception:
139
+ # format_func failed (e.g., v is a string but format_func expects
140
+ # an object with specific attributes). Use str(v) to ensure we return
141
+ # a proper string, not the original object. This handles both cases:
142
+ # - v is already a string -> str(v) returns it unchanged
143
+ # - v is a custom object -> str(v) gives its string representation
144
+ return str(v)
145
+
146
+ if formatted_value in self.formatted_option_to_option_index:
147
+ return formatted_value
148
+ # Value not found in options - return the formatted string (not the original
149
+ # object) to maintain type consistency since serialize() must return str|None
150
+ return formatted_value
134
151
 
135
152
  def deserialize(self, ui_value: str | None) -> T | str | None:
136
- # check if the option is pointing to a generic option type T,
137
- # otherwise return the option itself
153
+ # Note: We don't short-circuit for empty options here because
154
+ # accept_new_options=True allows user-entered values even with no options.
155
+ # The normal flow below handles this: ui_value not in options -> return ui_value.
156
+
157
+ # Check if the option is pointing to a generic option type T,
158
+ # otherwise return the option itself.
138
159
  if ui_value is None:
139
160
  return (
140
161
  self.options[self.default_option_index]
@@ -583,6 +604,7 @@ class SelectboxMixin:
583
604
  formatted_options=formatted_options,
584
605
  formatted_option_to_option_index=formatted_option_to_option_index,
585
606
  default_option_index=index,
607
+ format_func=format_func,
586
608
  )
587
609
  widget_state = register_widget(
588
610
  selectbox_proto.id,
@@ -605,7 +627,7 @@ class SelectboxMixin:
605
627
  # This handles the case where options change dynamically and the
606
628
  # previously selected value is no longer available.
607
629
  current_value, value_needs_reset = validate_and_sync_value_with_options(
608
- widget_state.value, opt, index, key
630
+ widget_state.value, opt, index, key, format_func
609
631
  )
610
632
 
611
633
  if value_needs_reset or widget_state.value_changed:
@@ -15,7 +15,7 @@ _sym_db = _symbol_database.Default()
15
15
  from streamlit.proto import LabelVisibilityMessage_pb2 as streamlit_dot_proto_dot_LabelVisibilityMessage__pb2
16
16
 
17
17
 
18
- DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bstreamlit/proto/Radio.proto\x1a,streamlit/proto/LabelVisibilityMessage.proto\"\x90\x02\n\x05Radio\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x14\n\x07\x64\x65\x66\x61ult\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x0f\n\x07options\x18\x04 \x03(\t\x12\x0c\n\x04help\x18\x05 \x01(\t\x12\x0f\n\x07\x66orm_id\x18\x06 \x01(\t\x12\x12\n\x05value\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\tset_value\x18\x08 \x01(\x08\x12\x10\n\x08\x64isabled\x18\t \x01(\x08\x12\x12\n\nhorizontal\x18\n \x01(\x08\x12\x31\n\x10label_visibility\x18\x0b \x01(\x0b\x32\x17.LabelVisibilityMessage\x12\x10\n\x08\x63\x61ptions\x18\x0c \x03(\tB\n\n\x08_defaultB\x08\n\x06_valueB*\n\x1c\x63om.snowflake.apps.streamlitB\nRadioProtob\x06proto3')
18
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bstreamlit/proto/Radio.proto\x1a,streamlit/proto/LabelVisibilityMessage.proto\"\xba\x02\n\x05Radio\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x14\n\x07\x64\x65\x66\x61ult\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x0f\n\x07options\x18\x04 \x03(\t\x12\x0c\n\x04help\x18\x05 \x01(\t\x12\x0f\n\x07\x66orm_id\x18\x06 \x01(\t\x12\x16\n\x05value\x18\x07 \x01(\x05\x42\x02\x18\x01H\x01\x88\x01\x01\x12\x16\n\traw_value\x18\r \x01(\tH\x02\x88\x01\x01\x12\x11\n\tset_value\x18\x08 \x01(\x08\x12\x10\n\x08\x64isabled\x18\t \x01(\x08\x12\x12\n\nhorizontal\x18\n \x01(\x08\x12\x31\n\x10label_visibility\x18\x0b \x01(\x0b\x32\x17.LabelVisibilityMessage\x12\x10\n\x08\x63\x61ptions\x18\x0c \x03(\tB\n\n\x08_defaultB\x08\n\x06_valueB\x0c\n\n_raw_valueB*\n\x1c\x63om.snowflake.apps.streamlitB\nRadioProtob\x06proto3')
19
19
 
20
20
  _globals = globals()
21
21
  _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -23,6 +23,8 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'streamlit.proto.Radio_pb2',
23
23
  if not _descriptor._USE_C_DESCRIPTORS:
24
24
  _globals['DESCRIPTOR']._loaded_options = None
25
25
  _globals['DESCRIPTOR']._serialized_options = b'\n\034com.snowflake.apps.streamlitB\nRadioProto'
26
+ _globals['_RADIO'].fields_by_name['value']._loaded_options = None
27
+ _globals['_RADIO'].fields_by_name['value']._serialized_options = b'\030\001'
26
28
  _globals['_RADIO']._serialized_start=78
27
- _globals['_RADIO']._serialized_end=350
29
+ _globals['_RADIO']._serialized_end=392
28
30
  # @@protoc_insertion_point(module_scope)
@@ -31,6 +31,11 @@ if sys.version_info >= (3, 10):
31
31
  else:
32
32
  from typing_extensions import TypeAlias as _TypeAlias
33
33
 
34
+ if sys.version_info >= (3, 13):
35
+ from warnings import deprecated as _deprecated
36
+ else:
37
+ from typing_extensions import deprecated as _deprecated
38
+
34
39
  DESCRIPTOR: _descriptor.FileDescriptor
35
40
 
36
41
  @_typing.final
@@ -44,6 +49,7 @@ class Radio(_message.Message):
44
49
  HELP_FIELD_NUMBER: _builtins.int
45
50
  FORM_ID_FIELD_NUMBER: _builtins.int
46
51
  VALUE_FIELD_NUMBER: _builtins.int
52
+ RAW_VALUE_FIELD_NUMBER: _builtins.int
47
53
  SET_VALUE_FIELD_NUMBER: _builtins.int
48
54
  DISABLED_FIELD_NUMBER: _builtins.int
49
55
  HORIZONTAL_FIELD_NUMBER: _builtins.int
@@ -54,7 +60,13 @@ class Radio(_message.Message):
54
60
  default: _builtins.int
55
61
  help: _builtins.str
56
62
  form_id: _builtins.str
57
- value: _builtins.int
63
+ @_builtins.property
64
+ @_deprecated("""This field has been marked as deprecated using proto field options.""")
65
+ def value(self) -> _builtins.int: ...
66
+ @value.setter
67
+ @_deprecated("""This field has been marked as deprecated using proto field options.""")
68
+ def value(self, value: _builtins.int) -> None: ...
69
+ raw_value: _builtins.str
58
70
  set_value: _builtins.bool
59
71
  disabled: _builtins.bool
60
72
  horizontal: _builtins.bool
@@ -74,23 +86,28 @@ class Radio(_message.Message):
74
86
  help: _builtins.str = ...,
75
87
  form_id: _builtins.str = ...,
76
88
  value: _builtins.int | None = ...,
89
+ raw_value: _builtins.str | None = ...,
77
90
  set_value: _builtins.bool = ...,
78
91
  disabled: _builtins.bool = ...,
79
92
  horizontal: _builtins.bool = ...,
80
93
  label_visibility: _LabelVisibilityMessage_pb2.LabelVisibilityMessage | None = ...,
81
94
  captions: _abc.Iterable[_builtins.str] | None = ...,
82
95
  ) -> None: ...
83
- _HasFieldArgType: _TypeAlias = _typing.Literal["_default", b"_default", "_value", b"_value", "default", b"default", "label_visibility", b"label_visibility", "value", b"value"] # noqa: Y015
96
+ _HasFieldArgType: _TypeAlias = _typing.Literal["_default", b"_default", "_raw_value", b"_raw_value", "_value", b"_value", "default", b"default", "label_visibility", b"label_visibility", "raw_value", b"raw_value", "value", b"value"] # noqa: Y015
84
97
  def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ...
85
- _ClearFieldArgType: _TypeAlias = _typing.Literal["_default", b"_default", "_value", b"_value", "captions", b"captions", "default", b"default", "disabled", b"disabled", "form_id", b"form_id", "help", b"help", "horizontal", b"horizontal", "id", b"id", "label", b"label", "label_visibility", b"label_visibility", "options", b"options", "set_value", b"set_value", "value", b"value"] # noqa: Y015
98
+ _ClearFieldArgType: _TypeAlias = _typing.Literal["_default", b"_default", "_raw_value", b"_raw_value", "_value", b"_value", "captions", b"captions", "default", b"default", "disabled", b"disabled", "form_id", b"form_id", "help", b"help", "horizontal", b"horizontal", "id", b"id", "label", b"label", "label_visibility", b"label_visibility", "options", b"options", "raw_value", b"raw_value", "set_value", b"set_value", "value", b"value"] # noqa: Y015
86
99
  def ClearField(self, field_name: _ClearFieldArgType) -> None: ...
87
100
  _WhichOneofReturnType__default: _TypeAlias = _typing.Literal["default"] # noqa: Y015
88
101
  _WhichOneofArgType__default: _TypeAlias = _typing.Literal["_default", b"_default"] # noqa: Y015
102
+ _WhichOneofReturnType__raw_value: _TypeAlias = _typing.Literal["raw_value"] # noqa: Y015
103
+ _WhichOneofArgType__raw_value: _TypeAlias = _typing.Literal["_raw_value", b"_raw_value"] # noqa: Y015
89
104
  _WhichOneofReturnType__value: _TypeAlias = _typing.Literal["value"] # noqa: Y015
90
105
  _WhichOneofArgType__value: _TypeAlias = _typing.Literal["_value", b"_value"] # noqa: Y015
91
106
  @_typing.overload
92
107
  def WhichOneof(self, oneof_group: _WhichOneofArgType__default) -> _WhichOneofReturnType__default | None: ...
93
108
  @_typing.overload
109
+ def WhichOneof(self, oneof_group: _WhichOneofArgType__raw_value) -> _WhichOneofReturnType__raw_value | None: ...
110
+ @_typing.overload
94
111
  def WhichOneof(self, oneof_group: _WhichOneofArgType__value) -> _WhichOneofReturnType__value | None: ...
95
112
 
96
113
  Global___Radio: _TypeAlias = Radio # noqa: Y015
@@ -37,7 +37,7 @@
37
37
  <script>
38
38
  window.prerenderReady = false
39
39
  </script>
40
- <script type="module" crossorigin src="./static/js/index.qjO5OK90.js"></script>
40
+ <script type="module" crossorigin src="./static/js/index.BB_iwaVr.js"></script>
41
41
  <link rel="stylesheet" crossorigin href="./static/css/index.BUP6fTcR.css">
42
42
  </head>
43
43
  <body>