import copy import os from collections.abc import Callable from collections.abc import Mapping from pathlib import Path from typing import Any from typing import TypeVar from typing import overload T = TypeVar("T") def str_to_bool(value: str) -> bool: """ Converts a string representation of truth to a boolean value. Recognizes 'true', '1', 't', 'y', 'yes' as True, and 'false', '0', 'f', 'n', 'no' as False. Case-insensitive. Args: value: The string to convert. Returns: The boolean representation of the string. Raises: ValueError: If the string is not a recognized boolean value. """ val_lower = value.strip().lower() if val_lower in ("true", "1", "t", "y", "yes"): return True elif val_lower in ("false", "0", "f", "n", "no"): return False raise ValueError(f"Cannot convert '{value}' to a boolean.") @overload def get_int_from_env(key: str) -> int | None: ... @overload def get_int_from_env(key: str, default: None) -> int | None: ... @overload def get_int_from_env(key: str, default: int) -> int: ... def get_int_from_env(key: str, default: int | None = None) -> int | None: """ Return an integer value based on the environment variable. If default is provided, returns that value when key is missing. If default is None, returns None when key is missing. """ if key not in os.environ: return default return int(os.environ[key]) def parse_dict_from_str( env_str: str | None, defaults: dict[str, Any] | None = None, type_map: Mapping[str, Callable[[str], Any]] | None = None, separator: str = ",", ) -> dict[str, Any]: """ Parses a key-value string into a dictionary, applying defaults and casting types. Supports nested keys via dot-notation, e.g.: "database.host=localhost,database.port=5432" Args: env_str: The string from the environment variable (e.g., "port=9090,debug=true"). defaults: A dictionary of default values (can contain nested dicts). type_map: A dictionary mapping keys (dot-notation allowed) to a type or a parsing function (e.g., {'port': int, 'debug': bool, 'database.port': int}). The special `bool` type triggers custom boolean parsing. separator: The character used to separate key-value pairs. Defaults to ','. Returns: A dictionary with the parsed and correctly-typed settings. Raises: ValueError: If a value cannot be cast to its specified type. """ def _set_nested(d: dict, keys: list[str], value: Any) -> None: """Set a nested value, creating intermediate dicts as needed.""" cur = d for k in keys[:-1]: if k not in cur or not isinstance(cur[k], dict): cur[k] = {} cur = cur[k] cur[keys[-1]] = value def _get_nested(d: dict, keys: list[str]) -> Any: """Get nested value or raise KeyError if not present.""" cur = d for k in keys: if not isinstance(cur, dict) or k not in cur: raise KeyError cur = cur[k] return cur def _has_nested(d: dict, keys: list[str]) -> bool: try: _get_nested(d, keys) return True except KeyError: return False settings: dict[str, Any] = copy.deepcopy(defaults) if defaults else {} _type_map = type_map if type_map else {} if not env_str: return settings # Parse the environment string using the specified separator pairs = [p.strip() for p in env_str.split(separator) if p.strip()] for pair in pairs: if "=" not in pair: # ignore malformed pairs continue key, val = pair.split("=", 1) key = key.strip() val = val.strip() if not key: continue parts = key.split(".") _set_nested(settings, parts, val) # Apply type casting to the updated settings (supports nested keys in type_map) for key, caster in _type_map.items(): key_parts = key.split(".") if _has_nested(settings, key_parts): raw_val = _get_nested(settings, key_parts) # Only cast if it's a string (i.e. from env parsing). If defaults already provided # a different type we leave it as-is. if isinstance(raw_val, str): try: if caster is bool: parsed = str_to_bool(raw_val) elif caster is Path: parsed = Path(raw_val).resolve() else: parsed = caster(raw_val) except (ValueError, TypeError) as e: caster_name = getattr(caster, "__name__", repr(caster)) raise ValueError( f"Error casting key '{key}' with value '{raw_val}' " f"to type '{caster_name}'", ) from e _set_nested(settings, key_parts, parsed) return settings def get_bool_from_env(key: str, default: str = "NO") -> bool: """ Return a boolean value based on whatever the user has supplied in the environment based on whether the value "looks like" it's True or not. """ return str_to_bool(os.getenv(key, default)) @overload def get_float_from_env(key: str) -> float | None: ... @overload def get_float_from_env(key: str, default: None) -> float | None: ... @overload def get_float_from_env(key: str, default: float) -> float: ... def get_float_from_env(key: str, default: float | None = None) -> float | None: """ Return a float value based on the environment variable. If default is provided, returns that value when key is missing. If default is None, returns None when key is missing. """ if key not in os.environ: return default return float(os.environ[key]) @overload def get_path_from_env(key: str) -> Path | None: ... @overload def get_path_from_env(key: str, default: None) -> Path | None: ... @overload def get_path_from_env(key: str, default: Path | str) -> Path: ... def get_path_from_env(key: str, default: Path | str | None = None) -> Path | None: """ Return a Path object based on the environment variable. If default is provided, returns that value when key is missing. If default is None, returns None when key is missing. """ if key not in os.environ: return default if default is None else Path(default).resolve() return Path(os.environ[key]).resolve() def get_list_from_env( key: str, separator: str = ",", default: list[T] | None = None, *, strip_whitespace: bool = True, remove_empty: bool = True, required: bool = False, ) -> list[str] | list[T]: """ Get and parse a list from an environment variable or return a default. Args: key: Environment variable name separator: Character(s) to split on (default: ',') default: Default value to return if env var is not set or empty strip_whitespace: Whether to strip whitespace from each element remove_empty: Whether to remove empty strings from the result required: If True, raise an error when the env var is missing and no default provided Returns: List of strings or list of type-cast values, or default if env var is empty/None Raises: ValueError: If required=True and env var is missing and there is no default """ # Get the environment variable value env_value = os.environ.get(key) # Handle required environment variables if required and env_value is None and default is None: raise ValueError(f"Required environment variable '{key}' is not set") if env_value: items = env_value.split(separator) if strip_whitespace: items = [item.strip() for item in items] if remove_empty: items = [item for item in items if item] return items elif default is not None: return default else: return [] @overload def get_choice_from_env( env_key: str, choices: set[str] | frozenset[str], ) -> str | None: ... @overload def get_choice_from_env( env_key: str, choices: set[str] | frozenset[str], default: None, ) -> str | None: ... @overload def get_choice_from_env( env_key: str, choices: set[str] | frozenset[str], default: str, ) -> str: ... def get_choice_from_env( env_key: str, choices: set[str] | frozenset[str], default: str | None = None, ) -> str | None: """ Gets and validates an environment variable against a set of allowed choices. Args: env_key: The environment variable key to validate choices: Set of valid choices for the environment variable default: Default value if environment variable is not set; None means optional Returns: The validated environment variable value, or None if not set and no default Raises: ValueError: If the environment variable value is not in choices """ value = os.environ.get(env_key, default) if value is None: return None if value not in choices: raise ValueError( f"Environment variable '{env_key}' has invalid value '{value}'. " f"Valid choices are: {', '.join(sorted(choices))}", ) return value