CSE Roadmap

Core tier · about 5 hours · 7 parts

Part 3 of 7 · 3 min read

Typed settings in code

Reading every setting once, in one place, checked and typed, so that a missing key stops the program on the first second.

Do not scatter calls to os.getenv around the code. Read everything once, in one place, check it, and pass the settings object around. The example project uses a library called pydantic-settings, which builds on the Pydantic you met in module 1:

$ pip install pydantic-settings
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")

    llm_api_key: SecretStr          # required: start-up fails if missing
    llm_name: str = "small-1"
    request_timeout_s: float = 30.0
    log_level: str = "INFO"

Read it: the class lists the settings and their types. env_prefix="APP_" means the variable for llm_name is called APP_LLM_NAME, so your settings do not clash with other programs' variables. env_file=".env" means "also read a file called .env in the folder you run the program from". llm_api_key has no default value, so it is required. SecretStr is a type for secrets that we will come back to.

What you get, shown with real output:

Fail fast. If APP_LLM_API_KEY is not set, the program stops at start-up, not an hour into production:

$ python -c "from settings import load_settings; load_settings()"
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
llm_api_key
  Field required [type=missing, input_value={}, input_type=dict]

The top of the output is a long Python traceback. The last lines are the message that matters: the setting llm_api_key is required and missing.

Types and secrets. With a .env file containing the key, a timeout of 5 and a model name, the settings load like this:

>>> s = load_settings()
>>> s
Settings(llm_api_key=SecretStr('**********'), llm_name='from-file', request_timeout_s=5.0, log_level='INFO')
>>> type(s.request_timeout_s)
<class 'float'>

APP_REQUEST_TIMEOUT_S=5 in the file was text, and became the number 5.0. And the key prints as **********. That is SecretStr: printing the settings, or logging them by accident, shows stars instead of the key. You read the real value only at the moment you need it, with .get_secret_value().

Fail fast

Detecting a problem as early as possible, at the moment it first could be known, instead of letting the program run on until the problem causes confusing damage far away.

Which value wins?

If the same setting is given in several places, one has to win. Here is the order, which I confirmed by running the example with APP_LLM_NAME set in three places:

Source llm_name it gave Rank
An environment variable set in the shell (from-shell) from-shell 1st: wins
The .env file (from-file) from-file 2nd
The default in the class (small-1) small-1 3rd: only if nothing else is set

The order is: real environment variable, then .env file, then the default. That is what you want. A host can override a developer's file without anyone editing it.

The names in the settings class and in .env.example must match. The committed example file shows only names:

APP_LLM_API_KEY=
APP_LLM_NAME=small-1
APP_REQUEST_TIMEOUT_S=30
APP_LOG_LEVEL=INFO

A newcomer copies it to .env and fills in the key. Your tests can set variables with monkeypatch.setenv (module 5).

Check yourself

  • What does SecretStr do when you print the settings?
  • A setting is given in the shell, in .env and as a default. Which one wins?
  • Why is it better to stop at start-up than to fail later?