# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import copy import os import re import urllib.error import urllib.request from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from collections.abc import Iterator from streamlit import cli_util, url_util from streamlit.config_option import ConfigOption from streamlit.elements.lib.color_util import is_css_color_like from streamlit.errors import ( StreamlitInvalidThemeError, StreamlitInvalidThemeOptionError, StreamlitInvalidThemeSectionError, ) # Maximum size for theme files (1MB). Theme files should be small configuration # files containing only theme options, not large data files. _MAX_THEME_FILE_SIZE_BYTES = 1024 * 1024 # 1MB def _get_logger() -> Any: """Get logger for this module. Separate function to avoid circular imports.""" from streamlit.logger import get_logger return get_logger(__name__) def server_option_changed( old_options: dict[str, ConfigOption], new_options: dict[str, ConfigOption] ) -> bool: """Return True if and only if an option in the server section differs between old_options and new_options. """ for opt_name, opt_val in old_options.items(): if not opt_name.startswith("server"): continue old_val = opt_val.value new_val = new_options[opt_name].value if old_val != new_val: return True return False def show_config( section_descriptions: dict[str, str], config_options: dict[str, ConfigOption], ) -> None: """Print the given config sections/options to the terminal.""" out = [] out.append( _clean( """ # Below are all the sections and options you can have in ~/.streamlit/config.toml. """ ) ) def append_desc(text: str) -> None: out.append("# " + cli_util.style_for_cli(text, bold=True)) def append_comment(text: str) -> None: out.append("# " + cli_util.style_for_cli(text)) def append_section(text: str) -> None: out.append(cli_util.style_for_cli(text, bold=True, fg="green")) def append_setting(text: str) -> None: out.append(cli_util.style_for_cli(text, fg="green")) for section in section_descriptions: # We inject a fake config section used for unit tests that we exclude here as # its options are often missing required properties, which confuses the code # below. if section == "_test": continue section_options = { k: v for k, v in config_options.items() if v.section == section and v.visibility == "visible" and not v.is_expired() } # Only show config header if section is non-empty. if len(section_options) == 0: continue out.append("") append_section(f"[{section}]") out.append("") for option in section_options.values(): key = option.key.split(".")[-1] description_paragraphs = _clean_paragraphs(option.description or "") last_paragraph_idx = len(description_paragraphs) - 1 for i, paragraph in enumerate(description_paragraphs): # Split paragraph into lines lines = paragraph.rstrip().split( "\n" ) # Remove trailing newline characters # If the first line is empty, remove it if lines and not lines[0].strip(): lines = lines[1:] # Choose function based on whether it's the first paragraph or not append_func = append_desc if i == 0 else append_comment # Add comment character to each line and add to out for line in lines: append_func(line.lstrip()) # # Add a line break after a paragraph only if it's not the last paragraph if i != last_paragraph_idx: append_comment("") if option.deprecated: if out[-1] != "#": append_comment("") append_comment( cli_util.style_for_cli("THIS IS DEPRECATED.", fg="yellow") ) append_comment("") for line in _clean_paragraphs(option.deprecation_text): append_comment(line) append_comment("") append_comment( f"This option will be removed on or after {option.expiration_date}." ) import toml toml_default = toml.dumps({"default": option.default_val}) toml_default = toml_default[10:].strip() if len(toml_default) > 0: # Ensure a line break before appending "Default" comment, if not already there if out[-1] != "#": append_comment("") append_comment(f"Default: {toml_default}") else: # Don't say "Default: (unset)" here because this branch applies # to complex config settings too. pass option_is_manually_set = ( option.where_defined != ConfigOption.DEFAULT_DEFINITION ) if option_is_manually_set: if out[-1] != "# ": append_comment("") append_comment(f"The value below was set in {option.where_defined}") toml_setting = toml.dumps({key: option.value}) if len(toml_setting) == 0: toml_setting = f"# {key} =\n" elif not option_is_manually_set: toml_setting = f"# {toml_setting}" append_setting(toml_setting) cli_util.print_to_cli("\n".join(out)) def _clean(txt: str) -> str: """Replace sequences of multiple spaces with a single space, excluding newlines. Preserves leading and trailing spaces, and does not modify spaces in between lines. """ return re.sub(" +", " ", txt) def _clean_paragraphs(txt: str) -> list[str]: """Split the text into paragraphs, preserve newlines within the paragraphs.""" # Strip both leading and trailing newlines. txt = txt.strip("\n") paragraphs = txt.split("\n\n") return [ "\n".join(_clean(line) for line in paragraph.split("\n")) for paragraph in paragraphs ] # Theme configuration - theme.base support functions def _check_color_value(value: Any, option_name: str) -> None: """Validate theme color configuration option values. Validates that the value is a string (or list of strings, in the case of chartCategoricalColors and chartSequentialColors) and is not empty. Handles both single color strings (like primaryColor, backgroundColor) and arrays of color strings (like chartCategoricalColors, chartSequentialColors). Parameters ---------- value : Any The color value to validate. Can be a string or list of strings. option_name : str The name of the theme option being validated (e.g., "theme.primaryColor"). Raises ------ StreamlitInvalidThemeOptionError If the value is not a string/list of strings, is empty, or contains empty values in the case of arrays. Notes ----- Logs warnings for potentially invalid colors, since more comprehensive validation happens on the frontend. """ logger = _get_logger() # Handle array color options (chartCategoricalColors, chartSequentialColors) if isinstance(value, list): if not value: raise StreamlitInvalidThemeOptionError( f"Theme option '{option_name}' cannot be an empty array" ) for i, color in enumerate(value): if not isinstance(color, str): raise StreamlitInvalidThemeOptionError( f"Theme option '{option_name}[{i}]' must be a string, got {type(color).__name__}: {color}" ) color_str = color.strip() if not color_str: raise StreamlitInvalidThemeOptionError( f"Theme option '{option_name}[{i}]' cannot be empty" ) # Lightweight color validation with warning if not is_css_color_like(color_str): logger.warning( "Theme option '%s[%s]' may be an invalid color: %s. " "Expected formats: hex, rgb, and rgba colors", option_name, i, color_str, ) return # All colors in array have been checked # Handle single color options (primaryColor, backgroundColor, etc.) if not isinstance(value, str): raise StreamlitInvalidThemeOptionError( f"Theme option '{option_name}' must be a string or array of strings, got {type(value).__name__}: {value}" ) value_str: str = value.strip() if not value_str: raise StreamlitInvalidThemeOptionError( f"Theme option '{option_name}' cannot be empty" ) # Lightweight color validation with warning if not is_css_color_like(value_str): logger.warning( "Theme option '%s' may be an invalid color: %s. " "Expected formats: hex, rgb, and rgba colors", option_name, value_str, ) def _iterate_theme_config_options( config_options: dict[str, ConfigOption], ) -> Iterator[tuple[str, Any]]: """ Iterate through theme config options, yielding (option_path, value) pairs. Returns: theme.primaryColor, #ff0000, ... Leveraged by _extract_current_theme_config() to retrieve main config.toml theme options. """ for opt_name, opt_val in config_options.items(): if opt_name.startswith("theme.") and opt_val.value is not None: yield opt_name, opt_val.value def _extract_current_theme_config( config_options: dict[str, ConfigOption], ) -> dict[str, Any]: """ Extract current theme configuration from config options. Returns a dictionary with the current theme options in nested format. """ current_theme_options = {} for opt_name, opt_value in _iterate_theme_config_options(config_options): parts = opt_name.split(".") if len(parts) == 2: # theme.option _, option = parts if option != "base": # Don't include the base option itself current_theme_options[option] = opt_value elif len(parts) == 3: # theme.sidebar.option or theme.light.option _, section, option = parts if section not in current_theme_options: current_theme_options[section] = {} current_theme_options[section][option] = opt_value elif len(parts) == 4: # theme.light.sidebar.option or theme.dark.sidebar.option _, section, subsection, option = parts if section not in current_theme_options: current_theme_options[section] = {} if subsection not in current_theme_options[section]: current_theme_options[section][subsection] = {} current_theme_options[section][subsection][option] = opt_value return current_theme_options def _get_valid_theme_options( config_options_template: dict[str, ConfigOption], ) -> tuple[set[str], set[str]]: """Get valid theme configuration options for main theme and theme sections. Extracts valid theme options from the config options template to ensure they stay in sync with the actual theme options defined via _create_theme_options() calls. Parameters ---------- config_options_template : dict[str, ConfigOption] Template of all available configuration options. Returns ------- tuple[set[str], set[str]] A tuple (main_theme_options, section_theme_options) where: - main_theme_options: Valid theme options for the main theme (without "theme." prefix) - section_theme_options: Valid theme options for sections/subsections (sidebar, light, dark, light.sidebar, dark.sidebar) Notes ----- All non-main theme sections have the same valid options, so we only need to extract them once. """ # Extract options dynamically from the config template main_theme_options = set() section_theme_options = set() # Extract theme options from the config template for option_key in config_options_template: if option_key.startswith("theme."): parts = option_key.split(".") # Direct theme options like "theme.primaryColor" if parts[0] == "theme" and len(parts) == 2: _, option_name = parts main_theme_options.add(option_name) # Subsection options like "theme.sidebar.primaryColor" elif parts[0] == "theme" and parts[1] == "sidebar" and len(parts) == 3: # All subsections (sidebar, light, dark, light.sidebar, dark.sidebar) # get the same options as theme.sidebar (which excludes main-only options) _, _, option_name = parts section_theme_options.add(option_name) return main_theme_options, section_theme_options def _invalid_theme_option_warning( option_name: str, file_path_or_url: str, valid_options: set[str], section_name: str = "theme", ) -> None: """Helper function to log a warning for an invalid theme option.""" if section_name == "theme": full_option_name = f"{section_name}.{option_name}" else: # Handle sections like "sidebar" -> "theme.sidebar.{option_name}" # or subsections like "light.sidebar" -> "theme.light.sidebar.{option_name}" full_option_name = f"theme.{section_name}.{option_name}" valid_options_list = "\n".join(f" • {opt}" for opt in sorted(valid_options)) _get_logger().warning( "Theme file %s contains invalid theme option: '%s'.\n\n" "Valid '%s' options are:\n%s", file_path_or_url, full_option_name, section_name, valid_options_list, ) def _validate_theme_section_recursive( section_configs: dict[str, Any], section_path: str, file_path_or_url: str, section_options: set[str], filtered_parent: dict[str, Any], allow_sidebar_subsection: bool = False, ) -> None: """Recursively validate a theme section and its subsection/options. Parameters ---------- section_configs : dict[str, Any] The section configs to validate. section_path : str Path like 'sidebar', 'light', 'light.sidebar'. file_path_or_url : str Theme file path for error messages. section_options : set[str] Valid options for this section. filtered_parent : dict[str, Any] Parent section to populate/filter out invalid options. allow_sidebar_subsection : bool, optional Allow sidebar subsection (only "light" and "dark" sections), by default False. Raises ------ StreamlitInvalidThemeSectionError If an invalid subsection is found. """ for option_name, option_value in section_configs.items(): if isinstance(option_value, dict): # This is a subsection if not allow_sidebar_subsection or option_name != "sidebar": raise StreamlitInvalidThemeSectionError( f"theme.{section_path}.{option_name}", file_path_or_url, ) # Create and validate the subsection's options if option_name not in filtered_parent: filtered_parent[option_name] = {} _validate_theme_section_recursive( option_value, f"{section_path}.{option_name}", file_path_or_url, section_options, filtered_parent[option_name], False, # sidebar subsection can't have further subsections ) elif option_name not in section_options: # This is an invalid section option _invalid_theme_option_warning( option_name, file_path_or_url, section_options, section_path, ) # Remove the invalid option from the filtered theme filtered_parent.pop(option_name, None) else: # Valid option - add to filtered theme and check color values filtered_parent[option_name] = option_value full_option_name = f"theme.{section_path}.{option_name}" if "color" in full_option_name.lower(): _check_color_value(option_value, full_option_name) def _validate_theme_file_content( theme_content: dict[str, Any], file_path_or_url: str, config_options_template: dict[str, ConfigOption], ) -> dict[str, Any]: """ Validate that a theme file contains only valid theme sections and config options. If invalid sections are found in the theme file, a StreamlitInvalidThemeSectionError is raised. If invalid config options are found in the theme file, a warning is logged with the valid options for the given section. Returns ------- A filtered copy of the theme content with invalid options removed. """ # Get valid options for each type of section valid_main_options, valid_section_options = _get_valid_theme_options( config_options_template ) # Valid theme sections valid_sections = {"sidebar", "light", "dark"} theme_section = theme_content.get("theme", {}) # Create a filtered copy of the theme content filtered_theme = copy.deepcopy(theme_content) filtered_theme_section = filtered_theme.get("theme", {}) # Validate theme options for option_name, option_value in theme_section.items(): # This is a section like theme.sidebar, theme.light, theme.dark if isinstance(option_value, dict): # Invalid section: raise error if option_name not in valid_sections: raise StreamlitInvalidThemeSectionError( option_name, file_path_or_url, ) # Create the section in our filtered theme and validate it if option_name not in filtered_theme_section: filtered_theme_section[option_name] = {} # Subsection can only be sidebar from within light and dark sections allow_sidebar_subsection = option_name in {"light", "dark"} _validate_theme_section_recursive( option_value, option_name, file_path_or_url, valid_section_options, filtered_theme_section[option_name], allow_sidebar_subsection, ) elif option_name not in valid_main_options: # Invalid main theme option _invalid_theme_option_warning( option_name, file_path_or_url, valid_main_options, ) # Remove the invalid option from the filtered theme filtered_theme_section.pop(option_name, None) else: # Valid main theme option - if color config, check color value full_option_name = f"theme.{option_name}" if "color" in full_option_name.lower(): _check_color_value(option_value, full_option_name) return filtered_theme def _load_theme_file( file_path_or_url: str, config_options_template: dict[str, ConfigOption] ) -> dict[str, Any]: """ Load and parse a theme TOML file from a local path or URL. Handles raising errors when a file cannot be found, read, parsed, or contains invalid theme options. Otherwise returns the parsed TOML content as a dictionary. """ def _raise_missing_toml() -> None: raise StreamlitInvalidThemeError( "The 'toml' package is required to load theme files. " "Please install it with 'pip install toml'." ) def _raise_file_not_found() -> None: raise FileNotFoundError(f"Theme file not found: {file_path_or_url}") def _raise_missing_theme_section() -> None: raise StreamlitInvalidThemeSectionError( f"Theme file {file_path_or_url} must contain a [theme] section" ) def _raise_file_too_large() -> None: content_size = len(content.encode("utf-8")) raise StreamlitInvalidThemeError( f"Theme file {file_path_or_url} is too large ({content_size:,} bytes). " f"Maximum allowed size is {_MAX_THEME_FILE_SIZE_BYTES:,} bytes (1MB). " f"Theme files should contain only configuration options, not large data." ) try: import toml except ImportError: _raise_missing_toml() # Check if it's a URL using the url_util helper (only allow http/https schemes by default) is_valid_url = url_util.is_url(file_path_or_url) try: if is_valid_url: # Load from URL - noqa: S310 suppressed since url_util.is_url() restricts to only # http/https schemes by default, preventing file:// or other dangerous schemes # 30-second timeout prevents hanging in poor network conditions (same as cli.py) with urllib.request.urlopen(file_path_or_url, timeout=30) as response: # noqa: S310 content = response.read().decode("utf-8") else: # Load from local file path # Resolve relative paths from the current working directory if not os.path.isabs(file_path_or_url): file_path_or_url = os.path.join(os.getcwd(), file_path_or_url) if not os.path.exists(file_path_or_url): _raise_file_not_found() with open(file_path_or_url, encoding="utf-8") as f: content = f.read() # Check file size limit - theme files should be small configuration files content_size = len(content.encode("utf-8")) if content_size > _MAX_THEME_FILE_SIZE_BYTES: _raise_file_too_large() # Parse the TOML content parsed_theme = toml.loads(content) # Validate that the theme file has a theme section if "theme" not in parsed_theme: _raise_missing_theme_section() # Validate that the theme file contains only valid theme options, filtering out invalid ones filtered_theme = _validate_theme_file_content( parsed_theme, file_path_or_url, config_options_template ) return filtered_theme except ( StreamlitInvalidThemeError, StreamlitInvalidThemeOptionError, StreamlitInvalidThemeSectionError, FileNotFoundError, ): # Re-raise these specific exceptions raise except urllib.error.URLError as e: raise StreamlitInvalidThemeError( f"Could not load theme file from URL {file_path_or_url}: {e}" ) from e except Exception as e: raise StreamlitInvalidThemeError( f"Error loading theme file {file_path_or_url}: {e}" ) from e def _deep_merge_theme_dicts( base_dict: dict[str, Any], override_dict: dict[str, Any] ) -> dict[str, Any]: """ Recursively merge two dictionaries, with override_dict values taking precedence. Handles arbitrary levels of nesting for theme configurations. """ merged = copy.deepcopy(base_dict) for key, value in override_dict.items(): if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): # Both base and override have dict values for this key, merge recursively merged[key] = _deep_merge_theme_dicts(merged[key], value) else: # Override value takes precedence (either new key or non-dict value) merged[key] = copy.deepcopy(value) return merged def _apply_theme_inheritance( base_theme: dict[str, Any], override_theme: dict[str, Any] ) -> dict[str, Any]: """ Apply theme inheritance where theme config values from config.toml take precedence over the theme configs defined in theme.base toml file. Returns a dictionary with the merged theme configuration. """ return _deep_merge_theme_dicts(base_theme, override_theme) def _set_theme_options_recursive( options_dict: dict[str, Any], prefix: str, set_option_func: Any, source: str ) -> None: """ Recursively set theme options from nested dictionary in process_theme_inheritance(). This utility function traverses nested theme configuration sections/subsection and sets each option using the provided set_option_func. """ for option_name, option_value in options_dict.items(): if option_name == "base" and prefix == "theme": # Base is handled separately in theme inheritance continue current_key = f"{prefix}.{option_name}" if prefix else option_name if isinstance(option_value, dict): # Recursively handle nested sections _set_theme_options_recursive( option_value, current_key, set_option_func, source ) else: # Set the actual config option set_option_func(current_key, option_value, source) # Theme configuration - handles theme.base def process_theme_inheritance( config_options: dict[str, ConfigOption] | None, config_options_template: dict[str, ConfigOption], set_option_func: Any, ) -> None: """ Process theme inheritance if theme.base points to a theme file. This function checks if theme.base is set to a file path or URL, loads the theme file, and applies inheritance logic where the current config.toml values override the theme.base file values. Sets the merged theme options to the config. """ # Get the current theme.base value if config_options is None: return base_option = config_options.get("theme.base") if not base_option or base_option.value is None: return base_value = base_option.value # Check if it's a file path or URL (not just "light" or "dark") if base_value in ("light", "dark"): return def _raise_invalid_nested_base() -> None: raise StreamlitInvalidThemeError( f"Theme file {base_value} cannot reference another theme file in its base property. " f"Only 'light' and 'dark' are allowed in referenced theme files." ) try: # Load the theme file config options theme_file_content = _load_theme_file(base_value, config_options_template) # Validate that theme.base of the referenced theme file doesn't reference another file theme_base = theme_file_content.get("theme", {}).get("base") if theme_base and theme_base not in ("light", "dark"): _raise_invalid_nested_base() # Get current theme options from main config.toml current_theme_options = ( _extract_current_theme_config(config_options) if config_options else {} ) # Apply inheritance: referenced theme file as base, override with theme options specified in config.toml merged_theme = _apply_theme_inheritance( theme_file_content, {"theme": current_theme_options} ) # Preserve theme options set by env vars and command line flags (higher precedence) high_precedence_theme_options = {} if config_options is not None: for opt_name, opt_config in config_options.items(): if ( opt_name.startswith("theme.") and opt_name != "theme.base" and opt_config.where_defined in ( "environment variable", "command-line argument or environment variable", ) ): high_precedence_theme_options[opt_name] = { "value": opt_config.value, "where_defined": opt_config.where_defined, } # Clear existing theme options (except base) to prepare for inheritance theme_options_to_remove = [ opt_name for opt_name in config_options if opt_name.startswith("theme.") and opt_name != "theme.base" ] for opt_name in theme_options_to_remove: set_option_func(opt_name, None, "reset for theme inheritance") # Handle theme.base - always set it to a valid value ("light" or "dark", not a path/URL) theme_file_base = theme_file_content.get("theme", {}).get("base") if theme_file_base: set_option_func("theme.base", theme_file_base, f"theme file: {base_value}") else: # Theme file doesn't specify a base, default to "light" set_option_func( "theme.base", "light", f"theme file: {base_value} (default)" ) # Set the merged theme options using recursive helper theme_section = merged_theme.get("theme", {}) _set_theme_options_recursive( theme_section, "theme", set_option_func, f"theme file: {base_value}" ) # Finally, restore theme options set by env vars and command line flags (highest precedence) for opt_name, opt_data in high_precedence_theme_options.items(): set_option_func(opt_name, opt_data["value"], opt_data["where_defined"]) except ( StreamlitInvalidThemeError, StreamlitInvalidThemeOptionError, StreamlitInvalidThemeSectionError, FileNotFoundError, ): # Re-raise expected user errors as-is to preserve specific error messages raise except Exception as e: _get_logger().exception("Error processing theme inheritance") # Only wrap unexpected errors (not our specific validation errors) raise StreamlitInvalidThemeError( f"Failed to process theme inheritance from {base_value}: {e}" ) from e