Params / Config Pattern¶
This guide explains the two-layer settings architecture used throughout this project and shows how to apply it correctly when adding new configuration.
Overview¶
Settings are split into two layers:
| Layer | Location | Responsibility |
|---|---|---|
| Config | src/lang_tools/config/ |
Defines the shape of settings as Pydantic models |
| Params | src/lang_tools/params/ |
Loads actual values and produces a Config via to_config() |
Config models are pure data containers. They define types, defaults, and validation. They never read environment variables, open files, or perform any I/O.
Params classes are plain Python classes. They read environment
variables, inspect EnvType, and hard-code environment-specific literals.
They expose the assembled settings as a typed Config model.
The reference implementations are:
SampleConfig- config sideSampleParams- params side
Config layer¶
Rules¶
- Extend
BaseModelKwargs, not plainBaseModel. - Use
SecretStrfor every sensitive field (API keys, passwords, tokens). Pydantic masks the value automatically inreprand log output. - Add an optional
kwargs: dict = Field(default_factory=dict)when the config will be forwarded to a third-party constructor viato_kw(). - Never import
os, callload_dotenv, or read any runtime state.
Example¶
from pydantic import Field, SecretStr
from lang_tools.data_models.basemodel_kwargs import BaseModelKwargs
class NestedModel(BaseModelKwargs):
some_str: str
class SampleConfig(BaseModelKwargs):
some_int: int
nested_model: NestedModel
secret_api_key: SecretStr
kwargs: dict = Field(default_factory=dict)
Accessing secrets¶
Call .get_secret_value() only at the point where the raw value is
genuinely needed (e.g., when constructing an external client):
Params layer¶
Rules¶
-
Single constructor argument:
env_type: EnvType | None = None. Pass it explicitly from the caller (e.g.,LangToolsParams). WhenNone, fall back toEnvType.from_env_var(). Keep the argument present even if the class does not yet use it - consistency matters more than brevity here. -
Thin
__init__: storeenv_typeand call_load_params(). Nothing else. -
_load_params()orchestration: - Call
_load_common_params()first. - Dispatch on
env_type.stage(DEV / PROD) viamatch. -
Each stage method dispatches further on
env_type.location(LOCAL / RENDER) for fine-grained overrides. -
Literals, not
os.getenv(): write non-secret values as Python literals in the appropriate loading method. Use the stage / location dispatch machinery to express the differences - do not duplicate values across branches. -
Secrets via
os.environ: load secrets withos.environ[VAR](raisesKeyErrornaturally - no custom exception needed) and wrap inSecretStr. Centralise this in a module-level helper. -
to_config(): assembles and returns the Pydantic config model. -
__str__: render every secret field as[REDACTED].
Full example¶
import os
from pydantic import SecretStr
from lang_tools.params.env_type import (
EnvLocationType, EnvStageType, EnvType,
UnknownEnvLocationError, UnknownEnvStageError,
)
from lang_tools.config.sample_config import NestedModel, SampleConfig
def _load_secret(var_name: str) -> SecretStr:
return SecretStr(os.environ[var_name])
class SampleParams:
def __init__(self, env_type: EnvType | None = None) -> None:
self.env_type = env_type or EnvType.from_env_var()
self._load_params()
def _load_params(self) -> None:
self._load_common_params()
match self.env_type.stage:
case EnvStageType.DEV:
self._load_dev_params()
case EnvStageType.PROD:
self._load_prod_params()
case _:
raise UnknownEnvStageError(self.env_type.stage)
def _load_common_params(self) -> None:
# Non-secret: literal, same in every env
self.nested_model_some_str: str = "Hello, Params!"
self.custom_kwargs: dict = {"key1": "value1", "key2": "value2"}
# Secret: KeyError raised naturally if var is missing
self.secret_api_key: SecretStr = _load_secret("SAMPLE_API_KEY")
def _load_dev_params(self) -> None:
self.some_int: int = 7 # smaller value in dev
match self.env_type.location:
case EnvLocationType.LOCAL:
self._load_dev_local_params()
case EnvLocationType.RENDER:
self._load_dev_render_params()
case _:
raise UnknownEnvLocationError(self.env_type.location)
def _load_dev_local_params(self) -> None:
pass # no local-specific overrides needed
def _load_dev_render_params(self) -> None:
self.nested_model_some_str = "Hello from Render (dev)!"
def _load_prod_params(self) -> None:
self.some_int = 42
match self.env_type.location:
case EnvLocationType.LOCAL:
self._load_prod_local_params()
case EnvLocationType.RENDER:
self._load_prod_render_params()
case _:
raise UnknownEnvLocationError(self.env_type.location)
def _load_prod_local_params(self) -> None:
pass
def _load_prod_render_params(self) -> None:
self.nested_model_some_str = "Hello from Render (prod)!"
def to_config(self) -> SampleConfig:
return SampleConfig(
some_int=self.some_int,
nested_model=NestedModel(some_str=self.nested_model_some_str),
secret_api_key=self.secret_api_key,
kwargs=self.custom_kwargs,
)
def __str__(self) -> str:
s = "SampleParams:"
s += f"\n env_type: {self.env_type}"
s += f"\n some_int: {self.some_int}"
s += f"\n nested_model_some_str: {self.nested_model_some_str}"
s += "\n secret_api_key: [REDACTED]"
return s
def __repr__(self) -> str:
return str(self)
Common mistakes¶
Reading env vars for non-secret values¶
# Wrong - do not read non-secrets from os.getenv()
self.host: str = os.getenv("WEBAPP_HOST", "0.0.0.0")
# Right - write the literal; use stage/location dispatch for differences
def _load_common_params(self) -> None:
self.host: str = "0.0.0.0"
def _load_prod_render_params(self) -> None:
self.host = "0.0.0.0" # same here; only override what actually changes
Custom exception for a missing env var¶
# Wrong - unnecessary custom exception
token = os.getenv("BOT_TOKEN")
if not token:
raise MissingTokenError("BOT_TOKEN not set")
# Right - os.environ raises KeyError with the variable name
self._token = _load_secret("BOT_TOKEN")
Calling __init__ with logic beyond delegation¶
# Wrong - logic in __init__
def __init__(self, env_type: EnvType) -> None:
self.env_type = env_type
self.host = "0.0.0.0" # should be in _load_common_params
if env_type.stage == EnvStageType.PROD:
self.host = "server.example.com"
# Right - __init__ only delegates
def __init__(self, env_type: EnvType | None = None) -> None:
self.env_type = env_type or EnvType.from_env_var()
self._load_params()
How it fits into ParamsParams¶
LangToolsParams (the top-level singleton) owns all Params instances
and passes the resolved EnvType to each one:
class LangToolsParams(metaclass=Singleton):
def load_config(self) -> None:
self.paths = LangToolsPaths(env_type=self.env_type)
self.sample = SampleParams(env_type=self.env_type)
...
This ensures that the whole application agrees on a single EnvType
determined once at startup.
Testing¶
In tests, always pass an explicit env_type to avoid relying on
environment variables:
from lang_tools.params.env_type import EnvLocationType, EnvStageType, EnvType
from lang_tools.params.sample_params import SampleParams
DEV_LOCAL = EnvType(stage=EnvStageType.DEV, location=EnvLocationType.LOCAL)
def test_dev_local_defaults() -> None:
params = SampleParams(env_type=DEV_LOCAL)
assert params.some_int == 7
Secrets required by _load_common_params should be set before the
first SampleParams instantiation. The root tests/conftest.py does
this with os.environ.setdefault(...):