| """Central configuration accessors. |
| |
| Every accessor reads ``os.environ`` (or an explicit mapping) **live** on each call, |
| so lazily-built runtimes and tests that monkeypatch the environment always observe |
| the current value — there is no import-time snapshot. Stdlib-only, so this module is |
| safe to import from any runtime, embedding subprocess, or Modal container. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| from collections.abc import Mapping |
|
|
| TRUE_VALUES = {"1", "true", "yes", "on"} |
| FALSE_VALUES = {"0", "false", "no", "off"} |
|
|
|
|
| class ConfigError(RuntimeError, ValueError): |
| """Invalid configuration value. |
| |
| Subclasses both ``RuntimeError`` and ``ValueError`` so existing |
| ``except RuntimeError`` handlers and ``pytest.raises`` checks keep working |
| regardless of which base they expect. |
| """ |
|
|
|
|
| def _source(env: Mapping[str, str] | None) -> Mapping[str, str]: |
| return os.environ if env is None else env |
|
|
|
|
| def str_env(name: str, default: str = "", *, env: Mapping[str, str] | None = None) -> str: |
| """Raw environment string, or ``default`` when unset.""" |
| return _source(env).get(name, default) |
|
|
|
|
| def bool_env(name: str, default: bool = False, *, env: Mapping[str, str] | None = None) -> bool: |
| """Boolean flag. Empty or unrecognised values fall back to ``default``.""" |
| raw = _source(env).get(name, "").strip().lower() |
| if not raw: |
| return default |
| if raw in TRUE_VALUES: |
| return True |
| if raw in FALSE_VALUES: |
| return False |
| return default |
|
|
|
|
| def tri_state_env(name: str, *, env: Mapping[str, str] | None = None) -> bool | None: |
| """``True``/``False`` for recognised boolean strings, ``None`` when unset/unrecognised.""" |
| raw = _source(env).get(name, "").strip().lower() |
| if raw in TRUE_VALUES: |
| return True |
| if raw in FALSE_VALUES: |
| return False |
| return None |
|
|
|
|
| def int_env( |
| name: str, |
| default: int, |
| *, |
| minimum: int | None = None, |
| maximum: int | None = None, |
| env: Mapping[str, str] | None = None, |
| ) -> int: |
| """Integer with optional bounds. Empty falls back to ``default``; out-of-range raises ConfigError.""" |
| raw = _source(env).get(name, "").strip() |
| if not raw: |
| return default |
| value = int(raw) |
| if minimum is not None and value < minimum: |
| raise ConfigError(f"{name} {_below_message(minimum)}") |
| if maximum is not None and value > maximum: |
| raise ConfigError(f"{name} must be at most {maximum}.") |
| return value |
|
|
|
|
| def optional_int_env( |
| name: str, |
| *, |
| minimum: int = 1, |
| env: Mapping[str, str] | None = None, |
| ) -> int | None: |
| """Integer or ``None`` when unset. Values below ``minimum`` raise ConfigError.""" |
| raw = _source(env).get(name, "").strip() |
| if not raw: |
| return None |
| value = int(raw) |
| if value < minimum: |
| raise ConfigError(f"{name} {_below_message(minimum)}") |
| return value |
|
|
|
|
| def first_nonempty_env(*names: str, default: str = "", env: Mapping[str, str] | None = None) -> str: |
| """First non-empty (stripped) value among ``names``, else ``default``.""" |
| source = _source(env) |
| for name in names: |
| value = source.get(name, "").strip() |
| if value: |
| return value |
| return default |
|
|
|
|
| def _below_message(minimum: int) -> str: |
| if minimum == 1: |
| return "must be a positive integer." |
| if minimum == 0: |
| return "must be a non-negative integer." |
| return f"must be at least {minimum}." |
|
|