streamlit 1.49.1__py3-none-any.whl → 1.51.0__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 (259) hide show
  1. streamlit/__init__.py +4 -1
  2. streamlit/column_config.py +2 -0
  3. streamlit/commands/navigation.py +7 -7
  4. streamlit/commands/page_config.py +4 -6
  5. streamlit/components/v1/custom_component.py +17 -42
  6. streamlit/components/v2/__init__.py +458 -0
  7. streamlit/components/v2/bidi_component/__init__.py +20 -0
  8. streamlit/components/v2/bidi_component/constants.py +29 -0
  9. streamlit/components/v2/bidi_component/main.py +386 -0
  10. streamlit/components/v2/bidi_component/serialization.py +265 -0
  11. streamlit/components/v2/bidi_component/state.py +92 -0
  12. streamlit/components/v2/component_definition_resolver.py +143 -0
  13. streamlit/components/v2/component_file_watcher.py +403 -0
  14. streamlit/components/v2/component_manager.py +431 -0
  15. streamlit/components/v2/component_manifest_handler.py +122 -0
  16. streamlit/components/v2/component_path_utils.py +245 -0
  17. streamlit/components/v2/component_registry.py +409 -0
  18. streamlit/components/v2/get_bidi_component_manager.py +51 -0
  19. streamlit/components/v2/manifest_scanner.py +615 -0
  20. streamlit/components/v2/presentation.py +198 -0
  21. streamlit/components/v2/types.py +324 -0
  22. streamlit/config.py +741 -32
  23. streamlit/config_option.py +4 -1
  24. streamlit/config_util.py +650 -1
  25. streamlit/connections/base_connection.py +4 -2
  26. streamlit/dataframe_util.py +18 -10
  27. streamlit/delta_generator.py +8 -7
  28. streamlit/delta_generator_singletons.py +3 -1
  29. streamlit/deprecation_util.py +17 -6
  30. streamlit/elements/arrow.py +90 -42
  31. streamlit/elements/deck_gl_json_chart.py +98 -39
  32. streamlit/elements/dialog_decorator.py +2 -1
  33. streamlit/elements/exception.py +3 -1
  34. streamlit/elements/form.py +6 -6
  35. streamlit/elements/graphviz_chart.py +24 -9
  36. streamlit/elements/heading.py +3 -5
  37. streamlit/elements/iframe.py +0 -2
  38. streamlit/elements/image.py +12 -13
  39. streamlit/elements/layouts.py +89 -22
  40. streamlit/elements/lib/built_in_chart_utils.py +95 -31
  41. streamlit/elements/lib/color_util.py +8 -18
  42. streamlit/elements/lib/column_config_utils.py +9 -8
  43. streamlit/elements/lib/column_types.py +595 -148
  44. streamlit/elements/lib/dialog.py +3 -2
  45. streamlit/elements/lib/image_utils.py +3 -5
  46. streamlit/elements/lib/layout_utils.py +50 -13
  47. streamlit/elements/lib/mutable_status_container.py +2 -2
  48. streamlit/elements/lib/options_selector_utils.py +2 -2
  49. streamlit/elements/lib/pandas_styler_utils.py +30 -14
  50. streamlit/elements/lib/utils.py +21 -9
  51. streamlit/elements/map.py +81 -40
  52. streamlit/elements/media.py +7 -7
  53. streamlit/elements/metric.py +11 -35
  54. streamlit/elements/pdf.py +2 -4
  55. streamlit/elements/plotly_chart.py +142 -26
  56. streamlit/elements/progress.py +2 -4
  57. streamlit/elements/pyplot.py +6 -6
  58. streamlit/elements/space.py +113 -0
  59. streamlit/elements/vega_charts.py +400 -143
  60. streamlit/elements/widgets/audio_input.py +52 -4
  61. streamlit/elements/widgets/button.py +29 -29
  62. streamlit/elements/widgets/button_group.py +33 -6
  63. streamlit/elements/widgets/camera_input.py +3 -4
  64. streamlit/elements/widgets/chat.py +7 -0
  65. streamlit/elements/widgets/checkbox.py +1 -0
  66. streamlit/elements/widgets/color_picker.py +1 -0
  67. streamlit/elements/widgets/data_editor.py +34 -29
  68. streamlit/elements/widgets/file_uploader.py +6 -10
  69. streamlit/elements/widgets/multiselect.py +14 -3
  70. streamlit/elements/widgets/number_input.py +5 -4
  71. streamlit/elements/widgets/radio.py +10 -2
  72. streamlit/elements/widgets/select_slider.py +8 -4
  73. streamlit/elements/widgets/selectbox.py +9 -2
  74. streamlit/elements/widgets/slider.py +38 -41
  75. streamlit/elements/widgets/text_widgets.py +6 -0
  76. streamlit/elements/widgets/time_widgets.py +15 -12
  77. streamlit/elements/write.py +28 -23
  78. streamlit/emojis.py +1 -1
  79. streamlit/errors.py +115 -0
  80. streamlit/git_util.py +65 -43
  81. streamlit/hello/hello.py +8 -0
  82. streamlit/hello/utils.py +2 -1
  83. streamlit/material_icon_names.py +1 -1
  84. streamlit/navigation/page.py +4 -1
  85. streamlit/proto/ArrowData_pb2.py +27 -0
  86. streamlit/proto/ArrowData_pb2.pyi +46 -0
  87. streamlit/proto/Arrow_pb2.py +10 -8
  88. streamlit/proto/Arrow_pb2.pyi +31 -2
  89. streamlit/proto/AudioInput_pb2.py +2 -2
  90. streamlit/proto/AudioInput_pb2.pyi +6 -2
  91. streamlit/proto/BidiComponent_pb2.py +34 -0
  92. streamlit/proto/BidiComponent_pb2.pyi +153 -0
  93. streamlit/proto/Block_pb2.py +11 -11
  94. streamlit/proto/Block_pb2.pyi +9 -1
  95. streamlit/proto/DeckGlJsonChart_pb2.py +10 -4
  96. streamlit/proto/DeckGlJsonChart_pb2.pyi +9 -3
  97. streamlit/proto/Element_pb2.py +5 -3
  98. streamlit/proto/Element_pb2.pyi +14 -4
  99. streamlit/proto/HeightConfig_pb2.py +2 -2
  100. streamlit/proto/HeightConfig_pb2.pyi +6 -3
  101. streamlit/proto/NewSession_pb2.py +18 -16
  102. streamlit/proto/NewSession_pb2.pyi +158 -6
  103. streamlit/proto/PlotlyChart_pb2.py +8 -6
  104. streamlit/proto/PlotlyChart_pb2.pyi +3 -1
  105. streamlit/proto/Space_pb2.py +27 -0
  106. streamlit/proto/Space_pb2.pyi +42 -0
  107. streamlit/proto/WidgetStates_pb2.py +2 -2
  108. streamlit/proto/WidgetStates_pb2.pyi +13 -3
  109. streamlit/proto/WidthConfig_pb2.py +2 -2
  110. streamlit/proto/WidthConfig_pb2.pyi +6 -3
  111. streamlit/runtime/app_session.py +45 -6
  112. streamlit/runtime/caching/cache_data_api.py +4 -4
  113. streamlit/runtime/caching/cache_errors.py +4 -1
  114. streamlit/runtime/caching/cache_resource_api.py +3 -2
  115. streamlit/runtime/caching/cache_utils.py +2 -1
  116. streamlit/runtime/caching/cached_message_replay.py +3 -3
  117. streamlit/runtime/caching/hashing.py +3 -4
  118. streamlit/runtime/caching/legacy_cache_api.py +2 -1
  119. streamlit/runtime/connection_factory.py +1 -3
  120. streamlit/runtime/forward_msg_queue.py +4 -1
  121. streamlit/runtime/fragment.py +2 -1
  122. streamlit/runtime/memory_media_file_storage.py +1 -1
  123. streamlit/runtime/metrics_util.py +6 -2
  124. streamlit/runtime/runtime.py +14 -0
  125. streamlit/runtime/scriptrunner/exec_code.py +2 -1
  126. streamlit/runtime/scriptrunner/script_runner.py +2 -2
  127. streamlit/runtime/scriptrunner_utils/script_run_context.py +3 -6
  128. streamlit/runtime/secrets.py +2 -4
  129. streamlit/runtime/session_manager.py +3 -1
  130. streamlit/runtime/state/common.py +30 -5
  131. streamlit/runtime/state/presentation.py +85 -0
  132. streamlit/runtime/state/safe_session_state.py +2 -2
  133. streamlit/runtime/state/session_state.py +220 -16
  134. streamlit/runtime/state/widgets.py +19 -3
  135. streamlit/runtime/theme_util.py +148 -0
  136. streamlit/runtime/websocket_session_manager.py +3 -1
  137. streamlit/source_util.py +2 -2
  138. streamlit/static/index.html +2 -2
  139. streamlit/static/manifest.json +244 -227
  140. streamlit/static/static/css/{index.C8X8rNzw.css → index.BpABIXK9.css} +1 -1
  141. streamlit/static/static/css/index.DgR7E2CV.css +1 -0
  142. streamlit/static/static/js/{ErrorOutline.esm.DcGrhbBP.js → ErrorOutline.esm.YoJdlW1p.js} +1 -1
  143. streamlit/static/static/js/{FileDownload.esm.DgBvV6Pq.js → FileDownload.esm.Ddx8VEYy.js} +1 -1
  144. streamlit/static/static/js/{FileHelper.M6AAaeuA.js → FileHelper.90EtOmj9.js} +1 -1
  145. streamlit/static/static/js/{FormClearHelper.DHh1GFzm.js → FormClearHelper.BB1Km6eP.js} +1 -1
  146. streamlit/static/static/js/InputInstructions.jhH15PqV.js +1 -0
  147. streamlit/static/static/js/{Particles.DDVT-6Qc.js → Particles.DUsputn1.js} +1 -1
  148. streamlit/static/static/js/{ProgressBar.BEY0cXXV.js → ProgressBar.DLY8H6nE.js} +2 -2
  149. streamlit/static/static/js/Toolbar.D8nHCkuz.js +1 -0
  150. streamlit/static/static/js/{base-input.CK3UVGp1.js → base-input.CJGiNqed.js} +3 -3
  151. streamlit/static/static/js/{checkbox.D8W881TL.js → checkbox.Cpdd482O.js} +1 -1
  152. streamlit/static/static/js/{createSuper.B6W-Dh9S.js → createSuper.CuQIogbW.js} +1 -1
  153. streamlit/static/static/js/data-grid-overlay-editor.2Ufgxc6y.js +1 -0
  154. streamlit/static/static/js/{downloader.DiKpuU_S.js → downloader.CN0K7xlu.js} +1 -1
  155. streamlit/static/static/js/{es6.B8zRNPZ-.js → es6.BJcsVXQ0.js} +2 -2
  156. streamlit/static/static/js/{iframeResizer.contentWindow.DIewJmmh.js → iframeResizer.contentWindow.XzUvQqcZ.js} +1 -1
  157. streamlit/static/static/js/index.B1ZQh4P1.js +1 -0
  158. streamlit/static/static/js/index.BKstZk0M.js +27 -0
  159. streamlit/static/static/js/{index.Bte_9Lyq.js → index.BMcFsUee.js} +1 -1
  160. streamlit/static/static/js/{index.qhs54UAB.js → index.BR-IdcTb.js} +1 -1
  161. streamlit/static/static/js/{index.CejBxbg1.js → index.B_dWA3vd.js} +1 -1
  162. streamlit/static/static/js/{index.D5naqx-J.js → index.BgnZEMVh.js} +1 -1
  163. streamlit/static/static/js/{index.C7fRKRs4.js → index.BohqXifI.js} +1 -1
  164. streamlit/static/static/js/{index.cnnXF7xQ.js → index.Br5nxKNj.js} +1 -1
  165. streamlit/static/static/js/index.BrIKVbNc.js +3 -0
  166. streamlit/static/static/js/index.BtWUPzle.js +1 -0
  167. streamlit/static/static/js/index.C0RLraek.js +1 -0
  168. streamlit/static/static/js/{index.CP5TD2z1.js → index.CAIjskgG.js} +1 -1
  169. streamlit/static/static/js/{index.CD8HuT3N.js → index.CAj-7vWz.js} +135 -162
  170. streamlit/static/static/js/{index.DtYN2x4k.js → index.CMtEit2O.js} +1 -1
  171. streamlit/static/static/js/index.CkRlykEE.js +12 -0
  172. streamlit/static/static/js/{index.Ts_0SdB9.js → index.CmN3FXfI.js} +2 -2
  173. streamlit/static/static/js/{index.BnEpvLEz.js → index.CwbFI1_-.js} +1 -1
  174. streamlit/static/static/js/{index.CcJf6BCU.js → index.CxIUUfab.js} +27 -27
  175. streamlit/static/static/js/index.D2KPNy7e.js +1 -0
  176. streamlit/static/static/js/{index.Ch7MBCx0.js → index.D3GPA5k4.js} +47 -47
  177. streamlit/static/static/js/{index.ho6NIXGl.js → index.DGAh7DMq.js} +1 -1
  178. streamlit/static/static/js/index.DKb_NvmG.js +197 -0
  179. streamlit/static/static/js/{index.CvYYtxD_.js → index.DMqgUYKq.js} +1 -1
  180. streamlit/static/static/js/{index.zecpGxtj.js → index.DOFlg3dS.js} +1 -1
  181. streamlit/static/static/js/{index.B9mjBcgE.js → index.DPUXkcQL.js} +1 -1
  182. streamlit/static/static/js/index.DX1xY89g.js +1 -0
  183. streamlit/static/static/js/index.DYATBCsq.js +2 -0
  184. streamlit/static/static/js/{index.D2-atlaQ.js → index.DaSmGJ76.js} +3 -3
  185. streamlit/static/static/js/index.Dd7bMeLP.js +1 -0
  186. streamlit/static/static/js/{index.4eF4NxG2.js → index.DjmmgI5U.js} +1 -1
  187. streamlit/static/static/js/index.Dq56CyM2.js +1 -0
  188. streamlit/static/static/js/index.DuiXaS5_.js +7 -0
  189. streamlit/static/static/js/index.DvFidMLe.js +2 -0
  190. streamlit/static/static/js/{index.452cqrrL.js → index.DwkhC5Pc.js} +1 -1
  191. streamlit/static/static/js/{index.Dk4C7X3i.js → index.Q-3sFn1v.js} +1 -1
  192. streamlit/static/static/js/{index.CjXWwH-y.js → index.QJ5QO9sJ.js} +1 -1
  193. streamlit/static/static/js/{index.B6U8LQo3.js → index.VwTaeety.js} +1 -1
  194. streamlit/static/static/js/index.YOqQbeX8.js +1 -0
  195. streamlit/static/static/js/{input.nzVJphXi.js → input.D4MN_FzN.js} +1 -1
  196. streamlit/static/static/js/{memory.CjCgTQz3.js → memory.DrZjtdGT.js} +1 -1
  197. streamlit/static/static/js/{number-overlay-editor.DaRFzZEO.js → number-overlay-editor.DRwAw1In.js} +1 -1
  198. streamlit/static/static/js/{possibleConstructorReturn.DgiPnZ9N.js → possibleConstructorReturn.exeeJQEP.js} +1 -1
  199. streamlit/static/static/js/record.B-tDciZb.js +1 -0
  200. streamlit/static/static/js/{sandbox.mithfq7Z.js → sandbox.ClO3IuUr.js} +1 -1
  201. streamlit/static/static/js/{timepicker.Dbl5KFh6.js → timepicker.DAhu-vcF.js} +4 -4
  202. streamlit/static/static/js/{toConsumableArray.D-Dx88BQ.js → toConsumableArray.DNbljYEC.js} +1 -1
  203. streamlit/static/static/js/{uniqueId.Bh26R_3S.js → uniqueId.oG4Gvj1v.js} +1 -1
  204. streamlit/static/static/js/{useBasicWidgetState.DeK-QJpD.js → useBasicWidgetState.D6sOH6oI.js} +1 -1
  205. streamlit/static/static/js/{useTextInputAutoExpand.4iAdLWD-.js → useTextInputAutoExpand.4u3_GcuN.js} +2 -2
  206. streamlit/static/static/js/{useUpdateUiValue.CmT7_nJN.js → useUpdateUiValue.F2R3eTeR.js} +1 -1
  207. streamlit/static/static/js/wavesurfer.esm.vI8Eid4k.js +73 -0
  208. streamlit/static/static/js/withFullScreenWrapper.zothJIsI.js +1 -0
  209. streamlit/static/static/media/MaterialSymbols-Rounded.C7IFxh57.woff2 +0 -0
  210. streamlit/string_util.py +56 -1
  211. streamlit/testing/v1/app_test.py +2 -2
  212. streamlit/testing/v1/element_tree.py +23 -9
  213. streamlit/testing/v1/util.py +2 -2
  214. streamlit/type_util.py +3 -4
  215. streamlit/url_util.py +1 -3
  216. streamlit/user_info.py +1 -2
  217. streamlit/util.py +3 -1
  218. streamlit/watcher/event_based_path_watcher.py +23 -12
  219. streamlit/watcher/local_sources_watcher.py +11 -1
  220. streamlit/watcher/path_watcher.py +9 -6
  221. streamlit/watcher/polling_path_watcher.py +4 -1
  222. streamlit/watcher/util.py +2 -2
  223. streamlit/web/bootstrap.py +0 -31
  224. streamlit/web/cli.py +51 -22
  225. streamlit/web/server/bidi_component_request_handler.py +193 -0
  226. streamlit/web/server/component_file_utils.py +97 -0
  227. streamlit/web/server/component_request_handler.py +8 -21
  228. streamlit/web/server/oidc_mixin.py +3 -1
  229. streamlit/web/server/routes.py +18 -5
  230. streamlit/web/server/server.py +10 -0
  231. streamlit/web/server/server_util.py +3 -1
  232. streamlit/web/server/upload_file_request_handler.py +3 -1
  233. {streamlit-1.49.1.dist-info → streamlit-1.51.0.dist-info}/METADATA +4 -5
  234. {streamlit-1.49.1.dist-info → streamlit-1.51.0.dist-info}/RECORD +238 -209
  235. streamlit/static/static/css/index.COe1010n.css +0 -1
  236. streamlit/static/static/js/Hooks.DGu1od_L.js +0 -1
  237. streamlit/static/static/js/InputInstructions.z6sVgyYt.js +0 -1
  238. streamlit/static/static/js/Toolbar.DSnK1fUh.js +0 -1
  239. streamlit/static/static/js/data-grid-overlay-editor.DRTHOydk.js +0 -1
  240. streamlit/static/static/js/index.BXYmrqnf.js +0 -1
  241. streamlit/static/static/js/index.B_8AnktO.js +0 -1
  242. streamlit/static/static/js/index.Bl7zGQSh.js +0 -7
  243. streamlit/static/static/js/index.BnJIOYn9.js +0 -73
  244. streamlit/static/static/js/index.C1HcTl5K.js +0 -1
  245. streamlit/static/static/js/index.C7lSmSOP.js +0 -1
  246. streamlit/static/static/js/index.C_tmcx4B.js +0 -1
  247. streamlit/static/static/js/index.D3K5nOu9.js +0 -197
  248. streamlit/static/static/js/index.DkKT3LUI.js +0 -1
  249. streamlit/static/static/js/index.MTPPBDHk.js +0 -2
  250. streamlit/static/static/js/index.pqW9AMJD.js +0 -3
  251. streamlit/static/static/js/index.urHgTgMQ.js +0 -12
  252. streamlit/static/static/js/index.wzkv_11M.js +0 -1
  253. streamlit/static/static/js/index.yF5AncHY.js +0 -1
  254. streamlit/static/static/js/withFullScreenWrapper.DLp1ENGm.js +0 -1
  255. streamlit/static/static/media/MaterialSymbols-Rounded.CBxVaFdk.woff2 +0 -0
  256. {streamlit-1.49.1.data → streamlit-1.51.0.data}/scripts/streamlit.cmd +0 -0
  257. {streamlit-1.49.1.dist-info → streamlit-1.51.0.dist-info}/WHEEL +0 -0
  258. {streamlit-1.49.1.dist-info → streamlit-1.51.0.dist-info}/entry_points.txt +0 -0
  259. {streamlit-1.49.1.dist-info → streamlit-1.51.0.dist-info}/top_level.txt +0 -0
@@ -19,11 +19,14 @@ from __future__ import annotations
19
19
  import datetime
20
20
  import re
21
21
  import textwrap
22
- from typing import Any, Callable
22
+ from typing import TYPE_CHECKING, Any
23
23
 
24
24
  from streamlit.string_util import to_snake_case
25
25
  from streamlit.util import repr_
26
26
 
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Callable
29
+
27
30
 
28
31
  class ConfigOption:
29
32
  '''Stores a Streamlit configuration option.
streamlit/config_util.py CHANGED
@@ -14,10 +14,35 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import copy
18
+ import os
17
19
  import re
20
+ import urllib.error
21
+ import urllib.request
22
+ from typing import TYPE_CHECKING, Any
18
23
 
19
- from streamlit import cli_util
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Iterator
26
+
27
+ from streamlit import cli_util, url_util
20
28
  from streamlit.config_option import ConfigOption
29
+ from streamlit.elements.lib.color_util import is_css_color_like
30
+ from streamlit.errors import (
31
+ StreamlitInvalidThemeError,
32
+ StreamlitInvalidThemeOptionError,
33
+ StreamlitInvalidThemeSectionError,
34
+ )
35
+
36
+ # Maximum size for theme files (1MB). Theme files should be small configuration
37
+ # files containing only theme options, not large data files.
38
+ _MAX_THEME_FILE_SIZE_BYTES = 1024 * 1024 # 1MB
39
+
40
+
41
+ def _get_logger() -> Any:
42
+ """Get logger for this module. Separate function to avoid circular imports."""
43
+ from streamlit.logger import get_logger
44
+
45
+ return get_logger(__name__)
21
46
 
22
47
 
23
48
  def server_option_changed(
@@ -181,3 +206,627 @@ def _clean_paragraphs(txt: str) -> list[str]:
181
206
  "\n".join(_clean(line) for line in paragraph.split("\n"))
182
207
  for paragraph in paragraphs
183
208
  ]
209
+
210
+
211
+ # Theme configuration - theme.base support functions
212
+
213
+
214
+ def _check_color_value(value: Any, option_name: str) -> None:
215
+ """Validate theme color configuration option values.
216
+
217
+ Validates that the value is a string (or list of strings, in the case of
218
+ chartCategoricalColors and chartSequentialColors) and is not empty.
219
+
220
+ Handles both single color strings (like primaryColor, backgroundColor)
221
+ and arrays of color strings (like chartCategoricalColors, chartSequentialColors).
222
+
223
+ Parameters
224
+ ----------
225
+ value : Any
226
+ The color value to validate. Can be a string or list of strings.
227
+ option_name : str
228
+ The name of the theme option being validated (e.g., "theme.primaryColor").
229
+
230
+ Raises
231
+ ------
232
+ StreamlitInvalidThemeOptionError
233
+ If the value is not a string/list of strings, is empty, or contains
234
+ empty values in the case of arrays.
235
+
236
+ Notes
237
+ -----
238
+ Logs warnings for potentially invalid colors, since more comprehensive
239
+ validation happens on the frontend.
240
+ """
241
+ logger = _get_logger()
242
+
243
+ # Handle array color options (chartCategoricalColors, chartSequentialColors)
244
+ if isinstance(value, list):
245
+ if not value:
246
+ raise StreamlitInvalidThemeOptionError(
247
+ f"Theme option '{option_name}' cannot be an empty array"
248
+ )
249
+
250
+ for i, color in enumerate(value):
251
+ if not isinstance(color, str):
252
+ raise StreamlitInvalidThemeOptionError(
253
+ f"Theme option '{option_name}[{i}]' must be a string, got {type(color).__name__}: {color}"
254
+ )
255
+
256
+ color_str = color.strip()
257
+ if not color_str:
258
+ raise StreamlitInvalidThemeOptionError(
259
+ f"Theme option '{option_name}[{i}]' cannot be empty"
260
+ )
261
+
262
+ # Lightweight color validation with warning
263
+ if not is_css_color_like(color_str):
264
+ logger.warning(
265
+ "Theme option '%s[%s]' may be an invalid color: %s. "
266
+ "Expected formats: hex, rgb, and rgba colors",
267
+ option_name,
268
+ i,
269
+ color_str,
270
+ )
271
+
272
+ return # All colors in array have been checked
273
+
274
+ # Handle single color options (primaryColor, backgroundColor, etc.)
275
+ if not isinstance(value, str):
276
+ raise StreamlitInvalidThemeOptionError(
277
+ f"Theme option '{option_name}' must be a string or array of strings, got {type(value).__name__}: {value}"
278
+ )
279
+
280
+ value_str: str = value.strip()
281
+
282
+ if not value_str:
283
+ raise StreamlitInvalidThemeOptionError(
284
+ f"Theme option '{option_name}' cannot be empty"
285
+ )
286
+
287
+ # Lightweight color validation with warning
288
+ if not is_css_color_like(value_str):
289
+ logger.warning(
290
+ "Theme option '%s' may be an invalid color: %s. "
291
+ "Expected formats: hex, rgb, and rgba colors",
292
+ option_name,
293
+ value_str,
294
+ )
295
+
296
+
297
+ def _iterate_theme_config_options(
298
+ config_options: dict[str, ConfigOption],
299
+ ) -> Iterator[tuple[str, Any]]:
300
+ """
301
+ Iterate through theme config options, yielding (option_path, value) pairs.
302
+ Returns: theme.primaryColor, #ff0000, ...
303
+
304
+ Leveraged by _extract_current_theme_config() to retrieve main config.toml theme options.
305
+ """
306
+ for opt_name, opt_val in config_options.items():
307
+ if opt_name.startswith("theme.") and opt_val.value is not None:
308
+ yield opt_name, opt_val.value
309
+
310
+
311
+ def _extract_current_theme_config(
312
+ config_options: dict[str, ConfigOption],
313
+ ) -> dict[str, Any]:
314
+ """
315
+ Extract current theme configuration from config options.
316
+ Returns a dictionary with the current theme options in nested format.
317
+ """
318
+ current_theme_options = {}
319
+
320
+ for opt_name, opt_value in _iterate_theme_config_options(config_options):
321
+ parts = opt_name.split(".")
322
+ if len(parts) == 2: # theme.option
323
+ _, option = parts
324
+ if option != "base": # Don't include the base option itself
325
+ current_theme_options[option] = opt_value
326
+ elif len(parts) == 3: # theme.sidebar.option or theme.light.option
327
+ _, section, option = parts
328
+ if section not in current_theme_options:
329
+ current_theme_options[section] = {}
330
+ current_theme_options[section][option] = opt_value
331
+ elif len(parts) == 4: # theme.light.sidebar.option or theme.dark.sidebar.option
332
+ _, section, subsection, option = parts
333
+ if section not in current_theme_options:
334
+ current_theme_options[section] = {}
335
+ if subsection not in current_theme_options[section]:
336
+ current_theme_options[section][subsection] = {}
337
+ current_theme_options[section][subsection][option] = opt_value
338
+
339
+ return current_theme_options
340
+
341
+
342
+ def _get_valid_theme_options(
343
+ config_options_template: dict[str, ConfigOption],
344
+ ) -> tuple[set[str], set[str]]:
345
+ """Get valid theme configuration options for main theme and theme sections.
346
+
347
+ Extracts valid theme options from the config options template to ensure they
348
+ stay in sync with the actual theme options defined via _create_theme_options() calls.
349
+
350
+ Parameters
351
+ ----------
352
+ config_options_template : dict[str, ConfigOption]
353
+ Template of all available configuration options.
354
+
355
+ Returns
356
+ -------
357
+ tuple[set[str], set[str]]
358
+ A tuple (main_theme_options, section_theme_options) where:
359
+ - main_theme_options: Valid theme options for the main theme (without "theme." prefix)
360
+ - section_theme_options: Valid theme options for sections/subsections
361
+ (sidebar, light, dark, light.sidebar, dark.sidebar)
362
+
363
+ Notes
364
+ -----
365
+ All non-main theme sections have the same valid options, so we only need to
366
+ extract them once.
367
+ """
368
+ # Extract options dynamically from the config template
369
+ main_theme_options = set()
370
+ section_theme_options = set()
371
+
372
+ # Extract theme options from the config template
373
+ for option_key in config_options_template:
374
+ if option_key.startswith("theme."):
375
+ parts = option_key.split(".")
376
+ # Direct theme options like "theme.primaryColor"
377
+ if parts[0] == "theme" and len(parts) == 2:
378
+ _, option_name = parts
379
+ main_theme_options.add(option_name)
380
+ # Subsection options like "theme.sidebar.primaryColor"
381
+ elif parts[0] == "theme" and parts[1] == "sidebar" and len(parts) == 3:
382
+ # All subsections (sidebar, light, dark, light.sidebar, dark.sidebar)
383
+ # get the same options as theme.sidebar (which excludes main-only options)
384
+ _, _, option_name = parts
385
+ section_theme_options.add(option_name)
386
+
387
+ return main_theme_options, section_theme_options
388
+
389
+
390
+ def _invalid_theme_option_warning(
391
+ option_name: str,
392
+ file_path_or_url: str,
393
+ valid_options: set[str],
394
+ section_name: str = "theme",
395
+ ) -> None:
396
+ """Helper function to log a warning for an invalid theme option."""
397
+
398
+ if section_name == "theme":
399
+ full_option_name = f"{section_name}.{option_name}"
400
+ else:
401
+ # Handle sections like "sidebar" -> "theme.sidebar.{option_name}"
402
+ # or subsections like "light.sidebar" -> "theme.light.sidebar.{option_name}"
403
+ full_option_name = f"theme.{section_name}.{option_name}"
404
+
405
+ valid_options_list = "\n".join(f" • {opt}" for opt in sorted(valid_options))
406
+ _get_logger().warning(
407
+ "Theme file %s contains invalid theme option: '%s'.\n\n"
408
+ "Valid '%s' options are:\n%s",
409
+ file_path_or_url,
410
+ full_option_name,
411
+ section_name,
412
+ valid_options_list,
413
+ )
414
+
415
+
416
+ def _validate_theme_section_recursive(
417
+ section_configs: dict[str, Any],
418
+ section_path: str,
419
+ file_path_or_url: str,
420
+ section_options: set[str],
421
+ filtered_parent: dict[str, Any],
422
+ allow_sidebar_subsection: bool = False,
423
+ ) -> None:
424
+ """Recursively validate a theme section and its subsection/options.
425
+
426
+ Parameters
427
+ ----------
428
+ section_configs : dict[str, Any]
429
+ The section configs to validate.
430
+ section_path : str
431
+ Path like 'sidebar', 'light', 'light.sidebar'.
432
+ file_path_or_url : str
433
+ Theme file path for error messages.
434
+ section_options : set[str]
435
+ Valid options for this section.
436
+ filtered_parent : dict[str, Any]
437
+ Parent section to populate/filter out invalid options.
438
+ allow_sidebar_subsection : bool, optional
439
+ Allow sidebar subsection (only "light" and "dark" sections), by default False.
440
+
441
+ Raises
442
+ ------
443
+ StreamlitInvalidThemeSectionError
444
+ If an invalid subsection is found.
445
+ """
446
+ for option_name, option_value in section_configs.items():
447
+ if isinstance(option_value, dict):
448
+ # This is a subsection
449
+ if not allow_sidebar_subsection or option_name != "sidebar":
450
+ raise StreamlitInvalidThemeSectionError(
451
+ f"theme.{section_path}.{option_name}",
452
+ file_path_or_url,
453
+ )
454
+
455
+ # Create and validate the subsection's options
456
+ if option_name not in filtered_parent:
457
+ filtered_parent[option_name] = {}
458
+
459
+ _validate_theme_section_recursive(
460
+ option_value,
461
+ f"{section_path}.{option_name}",
462
+ file_path_or_url,
463
+ section_options,
464
+ filtered_parent[option_name],
465
+ False, # sidebar subsection can't have further subsections
466
+ )
467
+ elif option_name not in section_options:
468
+ # This is an invalid section option
469
+ _invalid_theme_option_warning(
470
+ option_name,
471
+ file_path_or_url,
472
+ section_options,
473
+ section_path,
474
+ )
475
+ # Remove the invalid option from the filtered theme
476
+ filtered_parent.pop(option_name, None)
477
+ else:
478
+ # Valid option - add to filtered theme and check color values
479
+ filtered_parent[option_name] = option_value
480
+ full_option_name = f"theme.{section_path}.{option_name}"
481
+ if "color" in full_option_name.lower():
482
+ _check_color_value(option_value, full_option_name)
483
+
484
+
485
+ def _validate_theme_file_content(
486
+ theme_content: dict[str, Any],
487
+ file_path_or_url: str,
488
+ config_options_template: dict[str, ConfigOption],
489
+ ) -> dict[str, Any]:
490
+ """
491
+ Validate that a theme file contains only valid theme sections and config options.
492
+
493
+ If invalid sections are found in the theme file, a StreamlitInvalidThemeSectionError is raised.
494
+
495
+ If invalid config options are found in the theme file, a warning is logged with the valid
496
+ options for the given section.
497
+
498
+ Returns
499
+ -------
500
+ A filtered copy of the theme content with invalid options removed.
501
+ """
502
+ # Get valid options for each type of section
503
+ valid_main_options, valid_section_options = _get_valid_theme_options(
504
+ config_options_template
505
+ )
506
+ # Valid theme sections
507
+ valid_sections = {"sidebar", "light", "dark"}
508
+
509
+ theme_section = theme_content.get("theme", {})
510
+
511
+ # Create a filtered copy of the theme content
512
+ filtered_theme = copy.deepcopy(theme_content)
513
+ filtered_theme_section = filtered_theme.get("theme", {})
514
+
515
+ # Validate theme options
516
+ for option_name, option_value in theme_section.items():
517
+ # This is a section like theme.sidebar, theme.light, theme.dark
518
+ if isinstance(option_value, dict):
519
+ # Invalid section: raise error
520
+ if option_name not in valid_sections:
521
+ raise StreamlitInvalidThemeSectionError(
522
+ option_name,
523
+ file_path_or_url,
524
+ )
525
+
526
+ # Create the section in our filtered theme and validate it
527
+ if option_name not in filtered_theme_section:
528
+ filtered_theme_section[option_name] = {}
529
+
530
+ # Subsection can only be sidebar from within light and dark sections
531
+ allow_sidebar_subsection = option_name in {"light", "dark"}
532
+
533
+ _validate_theme_section_recursive(
534
+ option_value,
535
+ option_name,
536
+ file_path_or_url,
537
+ valid_section_options,
538
+ filtered_theme_section[option_name],
539
+ allow_sidebar_subsection,
540
+ )
541
+
542
+ elif option_name not in valid_main_options:
543
+ # Invalid main theme option
544
+ _invalid_theme_option_warning(
545
+ option_name,
546
+ file_path_or_url,
547
+ valid_main_options,
548
+ )
549
+ # Remove the invalid option from the filtered theme
550
+ filtered_theme_section.pop(option_name, None)
551
+
552
+ else:
553
+ # Valid main theme option - if color config, check color value
554
+ full_option_name = f"theme.{option_name}"
555
+ if "color" in full_option_name.lower():
556
+ _check_color_value(option_value, full_option_name)
557
+
558
+ return filtered_theme
559
+
560
+
561
+ def _load_theme_file(
562
+ file_path_or_url: str, config_options_template: dict[str, ConfigOption]
563
+ ) -> dict[str, Any]:
564
+ """
565
+ Load and parse a theme TOML file from a local path or URL.
566
+
567
+ Handles raising errors when a file cannot be found, read, parsed,
568
+ or contains invalid theme options.
569
+
570
+ Otherwise returns the parsed TOML content as a dictionary.
571
+ """
572
+
573
+ def _raise_missing_toml() -> None:
574
+ raise StreamlitInvalidThemeError(
575
+ "The 'toml' package is required to load theme files. "
576
+ "Please install it with 'pip install toml'."
577
+ )
578
+
579
+ def _raise_file_not_found() -> None:
580
+ raise FileNotFoundError(f"Theme file not found: {file_path_or_url}")
581
+
582
+ def _raise_missing_theme_section() -> None:
583
+ raise StreamlitInvalidThemeSectionError(
584
+ f"Theme file {file_path_or_url} must contain a [theme] section"
585
+ )
586
+
587
+ def _raise_file_too_large() -> None:
588
+ content_size = len(content.encode("utf-8"))
589
+ raise StreamlitInvalidThemeError(
590
+ f"Theme file {file_path_or_url} is too large ({content_size:,} bytes). "
591
+ f"Maximum allowed size is {_MAX_THEME_FILE_SIZE_BYTES:,} bytes (1MB). "
592
+ f"Theme files should contain only configuration options, not large data."
593
+ )
594
+
595
+ try:
596
+ import toml
597
+ except ImportError:
598
+ _raise_missing_toml()
599
+
600
+ # Check if it's a URL using the url_util helper (only allow http/https schemes by default)
601
+ is_valid_url = url_util.is_url(file_path_or_url)
602
+
603
+ try:
604
+ if is_valid_url:
605
+ # Load from URL - noqa: S310 suppressed since url_util.is_url() restricts to only
606
+ # http/https schemes by default, preventing file:// or other dangerous schemes
607
+ # 30-second timeout prevents hanging in poor network conditions (same as cli.py)
608
+ with urllib.request.urlopen(file_path_or_url, timeout=30) as response: # noqa: S310
609
+ content = response.read().decode("utf-8")
610
+ else:
611
+ # Load from local file path
612
+ # Resolve relative paths from the current working directory
613
+ if not os.path.isabs(file_path_or_url):
614
+ file_path_or_url = os.path.join(os.getcwd(), file_path_or_url)
615
+
616
+ if not os.path.exists(file_path_or_url):
617
+ _raise_file_not_found()
618
+
619
+ with open(file_path_or_url, encoding="utf-8") as f:
620
+ content = f.read()
621
+
622
+ # Check file size limit - theme files should be small configuration files
623
+ content_size = len(content.encode("utf-8"))
624
+ if content_size > _MAX_THEME_FILE_SIZE_BYTES:
625
+ _raise_file_too_large()
626
+
627
+ # Parse the TOML content
628
+ parsed_theme = toml.loads(content)
629
+
630
+ # Validate that the theme file has a theme section
631
+ if "theme" not in parsed_theme:
632
+ _raise_missing_theme_section()
633
+
634
+ # Validate that the theme file contains only valid theme options, filtering out invalid ones
635
+ filtered_theme = _validate_theme_file_content(
636
+ parsed_theme, file_path_or_url, config_options_template
637
+ )
638
+
639
+ return filtered_theme
640
+
641
+ except (
642
+ StreamlitInvalidThemeError,
643
+ StreamlitInvalidThemeOptionError,
644
+ StreamlitInvalidThemeSectionError,
645
+ FileNotFoundError,
646
+ ):
647
+ # Re-raise these specific exceptions
648
+ raise
649
+ except urllib.error.URLError as e:
650
+ raise StreamlitInvalidThemeError(
651
+ f"Could not load theme file from URL {file_path_or_url}: {e}"
652
+ ) from e
653
+ except Exception as e:
654
+ raise StreamlitInvalidThemeError(
655
+ f"Error loading theme file {file_path_or_url}: {e}"
656
+ ) from e
657
+
658
+
659
+ def _deep_merge_theme_dicts(
660
+ base_dict: dict[str, Any], override_dict: dict[str, Any]
661
+ ) -> dict[str, Any]:
662
+ """
663
+ Recursively merge two dictionaries, with override_dict values taking precedence.
664
+ Handles arbitrary levels of nesting for theme configurations.
665
+ """
666
+ merged = copy.deepcopy(base_dict)
667
+
668
+ for key, value in override_dict.items():
669
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
670
+ # Both base and override have dict values for this key, merge recursively
671
+ merged[key] = _deep_merge_theme_dicts(merged[key], value)
672
+ else:
673
+ # Override value takes precedence (either new key or non-dict value)
674
+ merged[key] = copy.deepcopy(value)
675
+
676
+ return merged
677
+
678
+
679
+ def _apply_theme_inheritance(
680
+ base_theme: dict[str, Any], override_theme: dict[str, Any]
681
+ ) -> dict[str, Any]:
682
+ """
683
+ Apply theme inheritance where theme config values from config.toml
684
+ take precedence over the theme configs defined in theme.base toml file.
685
+
686
+ Returns a dictionary with the merged theme configuration.
687
+ """
688
+ return _deep_merge_theme_dicts(base_theme, override_theme)
689
+
690
+
691
+ def _set_theme_options_recursive(
692
+ options_dict: dict[str, Any], prefix: str, set_option_func: Any, source: str
693
+ ) -> None:
694
+ """
695
+ Recursively set theme options from nested dictionary in process_theme_inheritance().
696
+ This utility function traverses nested theme configuration sections/subsection
697
+ and sets each option using the provided set_option_func.
698
+ """
699
+ for option_name, option_value in options_dict.items():
700
+ if option_name == "base" and prefix == "theme":
701
+ # Base is handled separately in theme inheritance
702
+ continue
703
+
704
+ current_key = f"{prefix}.{option_name}" if prefix else option_name
705
+
706
+ if isinstance(option_value, dict):
707
+ # Recursively handle nested sections
708
+ _set_theme_options_recursive(
709
+ option_value, current_key, set_option_func, source
710
+ )
711
+ else:
712
+ # Set the actual config option
713
+ set_option_func(current_key, option_value, source)
714
+
715
+
716
+ # Theme configuration - handles theme.base
717
+
718
+
719
+ def process_theme_inheritance(
720
+ config_options: dict[str, ConfigOption] | None,
721
+ config_options_template: dict[str, ConfigOption],
722
+ set_option_func: Any,
723
+ ) -> None:
724
+ """
725
+ Process theme inheritance if theme.base points to a theme file.
726
+
727
+ This function checks if theme.base is set to a file path or URL,
728
+ loads the theme file, and applies inheritance logic where the
729
+ current config.toml values override the theme.base file values.
730
+
731
+ Sets the merged theme options to the config.
732
+ """
733
+ # Get the current theme.base value
734
+ if config_options is None:
735
+ return
736
+
737
+ base_option = config_options.get("theme.base")
738
+ if not base_option or base_option.value is None:
739
+ return
740
+
741
+ base_value = base_option.value
742
+
743
+ # Check if it's a file path or URL (not just "light" or "dark")
744
+ if base_value in ("light", "dark"):
745
+ return
746
+
747
+ def _raise_invalid_nested_base() -> None:
748
+ raise StreamlitInvalidThemeError(
749
+ f"Theme file {base_value} cannot reference another theme file in its base property. "
750
+ f"Only 'light' and 'dark' are allowed in referenced theme files."
751
+ )
752
+
753
+ try:
754
+ # Load the theme file config options
755
+ theme_file_content = _load_theme_file(base_value, config_options_template)
756
+
757
+ # Validate that theme.base of the referenced theme file doesn't reference another file
758
+ theme_base = theme_file_content.get("theme", {}).get("base")
759
+ if theme_base and theme_base not in ("light", "dark"):
760
+ _raise_invalid_nested_base()
761
+
762
+ # Get current theme options from main config.toml
763
+ current_theme_options = (
764
+ _extract_current_theme_config(config_options) if config_options else {}
765
+ )
766
+
767
+ # Apply inheritance: referenced theme file as base, override with theme options specified in config.toml
768
+ merged_theme = _apply_theme_inheritance(
769
+ theme_file_content, {"theme": current_theme_options}
770
+ )
771
+
772
+ # Preserve theme options set by env vars and command line flags (higher precedence)
773
+ high_precedence_theme_options = {}
774
+ if config_options is not None:
775
+ for opt_name, opt_config in config_options.items():
776
+ if (
777
+ opt_name.startswith("theme.")
778
+ and opt_name != "theme.base"
779
+ and opt_config.where_defined
780
+ in (
781
+ "environment variable",
782
+ "command-line argument or environment variable",
783
+ )
784
+ ):
785
+ high_precedence_theme_options[opt_name] = {
786
+ "value": opt_config.value,
787
+ "where_defined": opt_config.where_defined,
788
+ }
789
+
790
+ # Clear existing theme options (except base) to prepare for inheritance
791
+ theme_options_to_remove = [
792
+ opt_name
793
+ for opt_name in config_options
794
+ if opt_name.startswith("theme.") and opt_name != "theme.base"
795
+ ]
796
+ for opt_name in theme_options_to_remove:
797
+ set_option_func(opt_name, None, "reset for theme inheritance")
798
+
799
+ # Handle theme.base - always set it to a valid value ("light" or "dark", not a path/URL)
800
+ theme_file_base = theme_file_content.get("theme", {}).get("base")
801
+ if theme_file_base:
802
+ set_option_func("theme.base", theme_file_base, f"theme file: {base_value}")
803
+ else:
804
+ # Theme file doesn't specify a base, default to "light"
805
+ set_option_func(
806
+ "theme.base", "light", f"theme file: {base_value} (default)"
807
+ )
808
+
809
+ # Set the merged theme options using recursive helper
810
+ theme_section = merged_theme.get("theme", {})
811
+ _set_theme_options_recursive(
812
+ theme_section, "theme", set_option_func, f"theme file: {base_value}"
813
+ )
814
+
815
+ # Finally, restore theme options set by env vars and command line flags (highest precedence)
816
+ for opt_name, opt_data in high_precedence_theme_options.items():
817
+ set_option_func(opt_name, opt_data["value"], opt_data["where_defined"])
818
+
819
+ except (
820
+ StreamlitInvalidThemeError,
821
+ StreamlitInvalidThemeOptionError,
822
+ StreamlitInvalidThemeSectionError,
823
+ FileNotFoundError,
824
+ ):
825
+ # Re-raise expected user errors as-is to preserve specific error messages
826
+ raise
827
+ except Exception as e:
828
+ _get_logger().exception("Error processing theme inheritance")
829
+ # Only wrap unexpected errors (not our specific validation errors)
830
+ raise StreamlitInvalidThemeError(
831
+ f"Failed to process theme inheritance from {base_value}: {e}"
832
+ ) from e
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  from abc import ABC, abstractmethod
19
- from typing import Any, Generic, TypeVar
19
+ from typing import Any, Generic, TypeVar, cast
20
20
 
21
21
  from streamlit.runtime.secrets import AttrDict, secrets_singleton
22
22
  from streamlit.util import calc_md5
@@ -116,7 +116,9 @@ class BaseConnection(ABC, Generic[RawConnectionT]):
116
116
  if connections_section is None or type(connections_section) is not AttrDict:
117
117
  return AttrDict({})
118
118
 
119
- return connections_section.get(self._connection_name, AttrDict({}))
119
+ return cast(
120
+ "AttrDict", connections_section.get(self._connection_name, AttrDict({}))
121
+ )
120
122
 
121
123
  def reset(self) -> None:
122
124
  """Reset this connection so that it gets reinitialized the next time it's used.