Spaces:
Paused
Paused
| # /// zerodep | |
| # version = "0.3.0" | |
| # deps = ["dotenv", "yaml", "jsonc"] | |
| # tier = "subsystem" | |
| # category = "config" | |
| # note = "Install/update via `zerodep add config`" | |
| # /// | |
| """Unified configuration loader β zero dependencies, stdlib only, Python 3.10+. | |
| Part of zerodep: https://github.com/Oaklight/zerodep | |
| Copyright (c) 2026 Peng Ding. MIT License. | |
| Drop-in replacement for python-decouple / dynaconf core functionality. | |
| Loads settings from environment variables, .env files, and config files | |
| (JSON, JSONC, YAML, TOML, INI) with type coercion and prefix support. | |
| Example:: | |
| from config import config, setup | |
| # Auto-discovers .env, reads env vars | |
| setup(prefix="MYAPP_") | |
| debug = config("DEBUG", default=False, cast=bool) | |
| port = config("PORT", default=8000, cast=int) | |
| hosts = config("ALLOWED_HOSTS", cast=Csv()) | |
| # Or use Config directly | |
| cfg = Config(config_path="settings.yaml", prefix="MYAPP_") | |
| db_host = cfg("DATABASE__HOST", default="localhost") | |
| """ | |
| from __future__ import annotations | |
| import configparser | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| from typing import Any, Callable, Sequence | |
| __all__ = [ | |
| # Sentinels | |
| "MISSING", | |
| # Exceptions | |
| "ConfigError", | |
| "UndefinedValueError", | |
| # Type coercion helpers | |
| "Csv", | |
| "Choices", | |
| # Main class | |
| "Config", | |
| # Module-level convenience | |
| "setup", | |
| "config", | |
| ] | |
| def _ensure_sibling_path(name: str) -> str: | |
| """Return the sibling module directory and prepend it to ``sys.path``.""" | |
| sibling_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", name) | |
| if sibling_dir not in sys.path: | |
| sys.path.insert(0, sibling_dir) | |
| return sibling_dir | |
| # ββ Sibling module loaders (lazy) βββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_dotenv_helpers() -> tuple[Callable[..., Any], Callable[..., Any]]: | |
| """Load sibling ``dotenv`` helpers on demand.""" | |
| _ensure_sibling_path("dotenv") | |
| sys.modules.pop("dotenv", None) | |
| try: | |
| from dotenv import dotenv_values, find_dotenv | |
| except ImportError as exc: | |
| raise ImportError( | |
| ".env loading requires the sibling dotenv module. " | |
| "Copy dotenv/dotenv.py into your project, " | |
| "or set dotenv_path=None to disable." | |
| ) from exc | |
| return dotenv_values, find_dotenv | |
| def _load_yaml_loader() -> Callable[[str], Any]: | |
| """Load the sibling ``yaml`` module's ``load`` function on demand.""" | |
| _ensure_sibling_path("yaml") | |
| sys.modules.pop("yaml", None) | |
| try: | |
| from yaml import load as yaml_load | |
| except ImportError as exc: | |
| raise ImportError( | |
| "YAML config files require the sibling yaml module. " | |
| "Copy yaml/yaml.py into your project." | |
| ) from exc | |
| return yaml_load | |
| def _load_jsonc_loader() -> Callable[[str], Any] | None: | |
| """Load the sibling ``jsonc`` module's ``loads`` function if available.""" | |
| _ensure_sibling_path("jsonc") | |
| sys.modules.pop("jsonc", None) | |
| try: | |
| from jsonc import loads as jsonc_loads | |
| except ImportError: | |
| return None | |
| return jsonc_loads | |
| # ββ Stdlib tomllib (Python 3.11+) βββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| import tomllib # type: ignore[import-not-found] | |
| _HAS_TOML = True | |
| except ModuleNotFoundError: | |
| _HAS_TOML = False | |
| # ββ Sentinels βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class _Missing: | |
| """Sentinel for missing configuration values.""" | |
| _instance: _Missing | None = None | |
| def __new__(cls) -> _Missing: | |
| if cls._instance is None: | |
| cls._instance = super().__new__(cls) | |
| return cls._instance | |
| def __repr__(self) -> str: | |
| return "MISSING" | |
| def __bool__(self) -> bool: | |
| return False | |
| MISSING = _Missing() | |
| class _Auto: | |
| """Sentinel indicating auto-discovery of .env files.""" | |
| _instance: _Auto | None = None | |
| def __new__(cls) -> _Auto: | |
| if cls._instance is None: | |
| cls._instance = super().__new__(cls) | |
| return cls._instance | |
| def __repr__(self) -> str: | |
| return "AUTO" | |
| _AUTO = _Auto() | |
| # ββ Sentinel for injection parameters ββββββββββββββββββββββββββββββββββββββ | |
| class _Unset: | |
| """Sentinel indicating 'use default sibling auto-discovery'.""" | |
| _instance: _Unset | None = None | |
| def __new__(cls) -> _Unset: | |
| if cls._instance is None: | |
| cls._instance = super().__new__(cls) | |
| return cls._instance | |
| def __repr__(self) -> str: | |
| return "UNSET" | |
| _UNSET = _Unset() | |
| # ββ Exceptions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ConfigError(Exception): | |
| """Base exception for all config operations.""" | |
| class UndefinedValueError(ConfigError): | |
| """Raised when a required configuration value is missing.""" | |
| # ββ Bool constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _TRUTHY = frozenset({"1", "true", "yes", "on", "t", "y"}) | |
| _FALSY = frozenset({"0", "false", "no", "off", "f", "n", ""}) | |
| # ββ Type coercion helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Csv: | |
| """Parse comma-separated values with optional per-item casting. | |
| Example:: | |
| Csv()("a, b, c") # ["a", "b", "c"] | |
| Csv(cast=int)("1,2,3") # [1, 2, 3] | |
| Csv(delimiter=";")("a;b") # ["a", "b"] | |
| Args: | |
| cast: Callable applied to each item after splitting. | |
| delimiter: String to split on. | |
| strip: Characters to strip from each item. Use ``" %s"`` to strip | |
| whitespace around the format-string placeholder (default). | |
| post_process: Callable applied to the final list (e.g. ``tuple``). | |
| """ | |
| def __init__( | |
| self, | |
| cast: Callable[[str], Any] = str, | |
| delimiter: str = ",", | |
| strip: str = " %s", | |
| post_process: Callable[[list[Any]], Any] = list, | |
| ) -> None: | |
| self.cast = cast | |
| self.delimiter = delimiter | |
| self.strip = strip | |
| self.post_process = post_process | |
| def __call__(self, value: str) -> Any: | |
| if isinstance(value, (list, tuple)): | |
| return self.post_process([self.cast(item) for item in value]) | |
| parts = str(value).split(self.delimiter) | |
| result = [] | |
| for part in parts: | |
| item = part.strip(self.strip.replace("%s", "")) if self.strip else part | |
| if item or not self.strip: | |
| result.append(self.cast(item)) | |
| return self.post_process(result) | |
| class Choices: | |
| """Validate that a value belongs to a fixed set. | |
| Example:: | |
| Choices(["dev", "staging", "prod"])("dev") # "dev" | |
| Choices([1, 2, 3], cast=int)("2") # 2 | |
| Args: | |
| choices: Allowed values (after casting). | |
| cast: Callable applied before validation. | |
| """ | |
| def __init__( | |
| self, | |
| choices: Sequence[Any], | |
| cast: Callable[[str], Any] = str, | |
| ) -> None: | |
| self.choices = choices | |
| self.cast = cast | |
| def __call__(self, value: str) -> Any: | |
| casted = self.cast(value) | |
| if casted not in self.choices: | |
| raise ValueError( | |
| f"{casted!r} is not a valid choice. Allowed: {list(self.choices)}" | |
| ) | |
| return casted | |
| # ββ File loaders ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_json_file(path: str | os.PathLike[str]) -> dict[str, Any]: | |
| """Load a JSON file and return a dict.""" | |
| with open(path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| if not isinstance(data, dict): | |
| raise ValueError(f"JSON config must be an object, got {type(data).__name__}") | |
| return data | |
| def _load_jsonc_file(path: str | os.PathLike[str]) -> dict[str, Any]: | |
| """Load a JSONC file, falling back to plain JSON if jsonc module is unavailable.""" | |
| with open(path, encoding="utf-8") as f: | |
| text = f.read() | |
| jsonc_loads = _load_jsonc_loader() | |
| if jsonc_loads is not None: | |
| data = jsonc_loads(text) | |
| else: | |
| data = json.loads(text) | |
| if not isinstance(data, dict): | |
| raise ValueError(f"JSONC config must be an object, got {type(data).__name__}") | |
| return data | |
| def _load_yaml_file(path: str | os.PathLike[str]) -> dict[str, Any]: | |
| """Load a YAML file using the sibling yaml module.""" | |
| yaml_load = _load_yaml_loader() | |
| with open(path, encoding="utf-8") as f: | |
| text = f.read() | |
| data = yaml_load(text) | |
| if data is None: | |
| return {} | |
| if not isinstance(data, dict): | |
| raise ValueError(f"YAML config must be a mapping, got {type(data).__name__}") | |
| return data | |
| def _load_toml_file(path: str | os.PathLike[str]) -> dict[str, Any]: | |
| """Load a TOML file using stdlib tomllib (Python 3.11+).""" | |
| if not _HAS_TOML: | |
| raise ImportError("TOML config files require Python 3.11+ (tomllib).") | |
| with open(path, "rb") as f: | |
| return tomllib.load(f) | |
| def _load_ini_file( | |
| path: str | os.PathLike[str], separator: str = "__" | |
| ) -> dict[str, Any]: | |
| """Load an INI/CFG file and flatten sections into separator-joined keys. | |
| Example: ``[database]`` section with ``host = localhost`` becomes | |
| ``database__host = localhost`` (using the default separator). | |
| """ | |
| parser = configparser.ConfigParser() | |
| parser.read(path, encoding="utf-8") | |
| data: dict[str, Any] = {} | |
| for section in parser.sections(): | |
| for key, value in parser.items(section): | |
| flat_key = f"{section}{separator}{key}" | |
| data[flat_key] = value | |
| if parser.defaults(): | |
| for key, value in parser.defaults().items(): | |
| data[key] = value | |
| return data | |
| _LOADERS: dict[str, Callable[..., dict[str, Any]]] = { | |
| ".json": _load_json_file, | |
| ".jsonc": _load_jsonc_file, | |
| ".yaml": _load_yaml_file, | |
| ".yml": _load_yaml_file, | |
| ".toml": _load_toml_file, | |
| ".ini": _load_ini_file, | |
| ".cfg": _load_ini_file, | |
| } | |
| # ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _cast_bool(value: Any) -> bool: | |
| """Convert a value to bool using common truthy/falsy strings.""" | |
| if isinstance(value, bool): | |
| return value | |
| s = str(value).lower().strip() | |
| if s in _TRUTHY: | |
| return True | |
| if s in _FALSY: | |
| return False | |
| raise ValueError( | |
| f"Cannot convert {value!r} to bool. Use one of: {sorted(_TRUTHY | _FALSY)}" | |
| ) | |
| def _cast_list(value: Any) -> list[Any]: | |
| """Convert a value to list: try JSON array first, then comma-split.""" | |
| if isinstance(value, list): | |
| return value | |
| if isinstance(value, tuple): | |
| return list(value) | |
| s = str(value).strip() | |
| if s.startswith("["): | |
| try: | |
| result = json.loads(s) | |
| if isinstance(result, list): | |
| return result | |
| except (json.JSONDecodeError, ValueError): | |
| pass | |
| return [item.strip() for item in s.split(",") if item.strip()] | |
| def _cast_tuple(value: Any) -> tuple[Any, ...]: | |
| """Convert a value to tuple via _cast_list.""" | |
| return tuple(_cast_list(value)) | |
| def _apply_cast(value: Any, cast: type | Callable[..., Any] | None) -> Any: | |
| """Apply a type cast to a value.""" | |
| if cast is None: | |
| return value | |
| if cast is bool: | |
| return _cast_bool(value) | |
| if cast is list: | |
| return _cast_list(value) | |
| if cast is tuple: | |
| return _cast_tuple(value) | |
| return cast(value) | |
| def _deep_get(data: dict[str, Any], parts: list[str]) -> Any: | |
| """Retrieve a nested value from a dict using a list of key parts. | |
| Returns *MISSING* if any intermediate key is missing or not a dict. | |
| """ | |
| current: Any = data | |
| for part in parts: | |
| if not isinstance(current, dict): | |
| return MISSING | |
| # Try exact key first, then case-insensitive | |
| if part in current: | |
| current = current[part] | |
| else: | |
| lower = part.lower() | |
| found = False | |
| for k in current: | |
| if k.lower() == lower: | |
| current = current[k] | |
| found = True | |
| break | |
| if not found: | |
| return MISSING | |
| return current | |
| def _flatten_dict( | |
| data: dict[str, Any], separator: str = "__", prefix: str = "" | |
| ) -> dict[str, Any]: | |
| """Flatten a nested dict into separator-joined keys.""" | |
| result: dict[str, Any] = {} | |
| for key, value in data.items(): | |
| full_key = f"{prefix}{separator}{key}" if prefix else key | |
| if isinstance(value, dict): | |
| result.update(_flatten_dict(value, separator, full_key)) | |
| else: | |
| result[full_key] = value | |
| return result | |
| # ββ Main Config class βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Config: | |
| """Unified configuration loader with multi-source support. | |
| Sources are checked in priority order (highest first): | |
| 1. Environment variables (``os.environ``), optionally prefixed | |
| 2. ``.env`` file values (via sibling dotenv module) | |
| 3. Config file values (JSON, JSONC, YAML, TOML, INI) | |
| 4. Default value passed to ``get()``/``__call__()`` | |
| Args: | |
| dotenv_path: Path to ``.env`` file. Use ``None`` to disable, | |
| or omit (default ``_AUTO``) to auto-discover by searching | |
| upward from the current directory. | |
| config_path: Path to a config file. Format is detected from the | |
| file extension. | |
| prefix: Prefix for environment variable lookups. For example, | |
| ``prefix="MYAPP_"`` means looking up ``"PORT"`` checks | |
| ``os.environ["MYAPP_PORT"]``. | |
| separator: Separator for nested key access. Default ``"__"`` | |
| means ``"DATABASE__HOST"`` resolves to ``data["DATABASE"]["HOST"]`` | |
| or ``data["database"]["host"]`` in config files. | |
| loaders: Override the file-format loader registry. Defaults to | |
| ``_UNSET`` (use built-in loaders with sibling auto-discovery | |
| for yaml/jsonc). Pass a ``dict`` mapping extensions (e.g. | |
| ``".yaml"``) to loader callables to replace the defaults. | |
| Stdlib loaders (json, toml, ini) are always available unless | |
| explicitly overridden. | |
| dotenv_loader: Override the dotenv loading mechanism. Defaults to | |
| ``_UNSET`` (auto-discover sibling ``dotenv`` module). Pass | |
| ``None`` to disable .env loading entirely, or a callable that | |
| returns ``(dotenv_values_fn, find_dotenv_fn)`` to inject a | |
| custom implementation. | |
| Example:: | |
| cfg = Config(config_path="settings.yaml", prefix="MYAPP_") | |
| debug = cfg("DEBUG", default=False, cast=bool) | |
| db_host = cfg("DATABASE__HOST", default="localhost") | |
| """ | |
| def __init__( | |
| self, | |
| *, | |
| dotenv_path: str | os.PathLike[str] | None | _Auto = _AUTO, | |
| config_path: str | os.PathLike[str] | None = None, | |
| prefix: str = "", | |
| separator: str = "__", | |
| loaders: dict[str, Callable[..., dict[str, Any]]] | None | _Unset = _UNSET, | |
| dotenv_loader: Callable[[], tuple[Callable[..., Any], Callable[..., Any]]] | |
| | None | |
| | _Unset = _UNSET, | |
| ) -> None: | |
| self._prefix = prefix | |
| self._separator = separator | |
| self._dotenv_data: dict[str, str | None] = {} | |
| self._config_data: dict[str, Any] = {} | |
| self._loaders: dict[str, Callable[..., dict[str, Any]]] | |
| # Resolve loader registry | |
| if isinstance(loaders, _Unset): | |
| self._loaders = _LOADERS | |
| elif loaders is None: | |
| self._loaders = {} | |
| else: | |
| self._loaders = loaders | |
| # Load .env file | |
| if dotenv_loader is None: | |
| pass # .env explicitly disabled | |
| elif isinstance(dotenv_path, _Auto): | |
| try: | |
| if isinstance(dotenv_loader, _Unset): | |
| dotenv_values, find_dotenv = _load_dotenv_helpers() | |
| else: | |
| dotenv_values, find_dotenv = dotenv_loader() | |
| except ImportError: | |
| pass | |
| else: | |
| found = find_dotenv(usecwd=True) | |
| if found: | |
| self._dotenv_data = dotenv_values(found) | |
| elif dotenv_path is not None: | |
| if isinstance(dotenv_loader, _Unset): | |
| dotenv_values, _find_dotenv = _load_dotenv_helpers() | |
| else: | |
| dotenv_values, _find_dotenv = dotenv_loader() | |
| self._dotenv_data = dotenv_values(str(dotenv_path)) | |
| # Load config file | |
| if config_path is not None: | |
| self._load_config_file(config_path) | |
| def _load_config_file(self, path: str | os.PathLike[str]) -> None: | |
| """Load a config file based on its extension.""" | |
| p = Path(path) | |
| ext = p.suffix.lower() | |
| loader = self._loaders.get(ext) | |
| if loader is None: | |
| raise ValueError( | |
| f"Unsupported config file format: {ext!r}. " | |
| f"Supported: {', '.join(sorted(self._loaders))}" | |
| ) | |
| if loader in (_load_ini_file,): | |
| self._config_data = loader(p, separator=self._separator) | |
| else: | |
| self._config_data = loader(p) | |
| def _lookup(self, key: str) -> Any: | |
| """Look up a key across all sources in priority order. | |
| Returns the value if found, or *MISSING* if not found anywhere. | |
| """ | |
| # 1. Environment variables (with prefix) | |
| env_key = f"{self._prefix}{key}" if self._prefix else key | |
| env_val = os.environ.get(env_key) | |
| if env_val is not None: | |
| return env_val | |
| # 2. .env file values (with prefix) | |
| dotenv_val = self._dotenv_data.get(env_key) | |
| if dotenv_val is not None: | |
| return dotenv_val | |
| # 3. Config file values (exact key first, then nested lookup) | |
| if self._config_data: | |
| # Exact key match (e.g. INI-flattened keys) | |
| if key in self._config_data: | |
| return self._config_data[key] | |
| # Nested lookup via separator splitting | |
| if self._separator in key: | |
| parts = key.split(self._separator) | |
| result = _deep_get(self._config_data, parts) | |
| if result is not MISSING: | |
| return result | |
| return MISSING | |
| def get( | |
| self, | |
| key: str, | |
| *, | |
| default: Any = MISSING, | |
| cast: type | Callable[..., Any] | None = None, | |
| ) -> Any: | |
| """Retrieve a configuration value. | |
| Args: | |
| key: Configuration key to look up. | |
| default: Default value if key is not found. If not provided and | |
| the key is missing, ``UndefinedValueError`` is raised. | |
| cast: Type or callable to apply to the value. Built-in support | |
| for ``bool``, ``int``, ``float``, ``list``, ``tuple``. | |
| Also accepts ``Csv(...)`` and ``Choices(...)`` instances. | |
| Returns: | |
| The resolved and optionally cast configuration value. | |
| Raises: | |
| UndefinedValueError: If key is missing and no default is given. | |
| """ | |
| value = self._lookup(key) | |
| if value is MISSING: | |
| if default is MISSING: | |
| raise UndefinedValueError( | |
| f"{key!r} is not set and has no default value." | |
| ) | |
| value = default | |
| return _apply_cast(value, cast) | |
| def __call__( | |
| self, | |
| key: str, | |
| *, | |
| default: Any = MISSING, | |
| cast: type | Callable[..., Any] | None = None, | |
| ) -> Any: | |
| """Shorthand for ``get()``. See :meth:`get` for details.""" | |
| return self.get(key, default=default, cast=cast) | |
| def has(self, key: str) -> bool: | |
| """Check whether a key exists in any source.""" | |
| return self._lookup(key) is not MISSING | |
| def as_dict(self) -> dict[str, Any]: | |
| """Return a merged view of all config sources (flattened). | |
| Lower-priority sources are merged first, so higher-priority | |
| sources overwrite them. | |
| """ | |
| merged: dict[str, Any] = {} | |
| # 3. Config file (lowest priority) | |
| if self._config_data: | |
| merged.update(_flatten_dict(self._config_data, self._separator)) | |
| # 2. .env file | |
| for k, v in self._dotenv_data.items(): | |
| if v is not None: | |
| merged[k] = v | |
| # 1. Environment variables (only those matching prefix) | |
| if self._prefix: | |
| plen = len(self._prefix) | |
| for k, v in os.environ.items(): | |
| if k.startswith(self._prefix): | |
| merged[k[plen:]] = v | |
| else: | |
| merged.update(os.environ) | |
| return merged | |
| # ββ Module-level convenience ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _default_config: Config | None = None | |
| def setup( | |
| *, | |
| dotenv_path: str | os.PathLike[str] | None | _Auto = _AUTO, | |
| config_path: str | os.PathLike[str] | None = None, | |
| prefix: str = "", | |
| separator: str = "__", | |
| ) -> Config: | |
| """Initialize the module-level :class:`Config` instance. | |
| Call this once at application startup to configure sources and prefix. | |
| Subsequent calls to :func:`config` will use this instance. | |
| Args: | |
| dotenv_path: Path to ``.env`` file, ``None`` to disable, or | |
| ``_AUTO`` (default) to auto-discover. | |
| config_path: Path to a config file (JSON/YAML/TOML/INI). | |
| prefix: Prefix for environment variable lookups. | |
| separator: Separator for nested key access. | |
| Returns: | |
| The newly created :class:`Config` instance. | |
| """ | |
| global _default_config | |
| _default_config = Config( | |
| dotenv_path=dotenv_path, | |
| config_path=config_path, | |
| prefix=prefix, | |
| separator=separator, | |
| ) | |
| return _default_config | |
| def config( | |
| key: str, | |
| *, | |
| default: Any = MISSING, | |
| cast: type | Callable[..., Any] | None = None, | |
| ) -> Any: | |
| """Look up a configuration value using the module-level instance. | |
| If :func:`setup` has not been called, a default :class:`Config` is | |
| created automatically (auto-discovers ``.env``, no config file, | |
| no prefix). | |
| Args: | |
| key: Configuration key. | |
| default: Fallback value if missing. | |
| cast: Type or callable to coerce the value. | |
| Returns: | |
| The resolved configuration value. | |
| Raises: | |
| UndefinedValueError: If key is missing and no default. | |
| """ | |
| global _default_config | |
| if _default_config is None: | |
| _default_config = Config() | |
| return _default_config(key, default=default, cast=cast) | |