Python Configuration Management
Configuration Management with Python Settings
Every service class built so far in this unit takes a model parameter with a hardcoded default like "gpt-5.6-terra", and every real client reads OPENAI_API_KEY implicitly from the environment. This works for a single script. It breaks down once an application needs different settings for local development, automated tests, staging, and production — different API keys, different default models, different timeout values, different feature flags. Configuration management is the discipline of collecting these values in one well-defined place instead of scattering os.environ.get(...) calls and hardcoded literals throughout the codebase.
Why Scattered Configuration Is a Problem
Consider code without centralized configuration:
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
DEFAULT_MODEL = "gpt-5.6-terra"
REQUEST_TIMEOUT = 30
If these three lines are duplicated — even slightly differently — across five files, changing the timeout means finding and editing five places, and it is easy to miss one. Worse, os.environ["OPENAI_API_KEY"] raises a raw KeyError with no helpful message if the variable is missing, and there is no single place that documents every configuration value the application actually needs.
A Simple Settings Object
The most basic fix is a single class or dataclass that gathers every configuration value in one place, with typed fields and validation:
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
openai_api_key: str
default_model: str = "gpt-5.6-terra"
request_timeout_seconds: float = 30.0
@classmethod
def from_env(cls) -> "Settings":
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError(
"OPENAI_API_KEY is not set. Set it in your environment or .env file."
)
return cls(
openai_api_key=api_key,
default_model=os.environ.get("DEFAULT_MODEL", "gpt-5.6-terra"),
request_timeout_seconds=float(os.environ.get("REQUEST_TIMEOUT_SECONDS", "30")),
)
Now, application startup calls Settings.from_env() exactly once, and every part of the application that needs configuration receives a Settings instance (via dependency injection, as covered in Lesson 2) instead of reading environment variables directly:
def build_client(settings: Settings) -> OpenAI:
return OpenAI(api_key=settings.openai_api_key, timeout=settings.request_timeout_seconds)
This is already a large improvement: there is one clear error message if the API key is missing, one place listing every configuration value the application uses, and no repeated os.environ calls scattered through business logic.
Why Use pydantic-settings Instead of Hand-Rolled Parsing
The hand-written Settings.from_env() above works, but it re-implements, by hand, several things a dedicated library already does well: type coercion (turning the string "30" into the float 30.0), validation error messages, support for .env files, and nested configuration. The pydantic-settings package (a companion to Pydantic) provides all of this:
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
openai_api_key: str = Field(alias="OPENAI_API_KEY")
default_model: str = "gpt-5.6-terra"
request_timeout_seconds: float = 30.0
settings = Settings()
Note:
pydantic-settingsis a separate package from core Pydantic (pip install pydantic-settings), and its configuration API (SettingsConfigDict, field aliasing,.envloading behavior) has changed between major versions — check the installed version's documentation for exact field names and defaults.
Here, Settings() automatically reads matching environment variables (and, if present, a .env file) and coerces each one to its declared type, raising a clear pydantic.ValidationError listing every missing or invalid field if construction fails — rather than failing on the first KeyError encountered, which might hide a second, unrelated missing variable.
The .env File Pattern
For local development, environment variables are conventionally stored in a .env file at the project root rather than being exported manually in a shell session:
OPENAI_API_KEY=sk-your-development-key-here
DEFAULT_MODEL=gpt-5.6-terra
REQUEST_TIMEOUT_SECONDS=45
pydantic-settings (via the env_file option shown above) or the standalone python-dotenv package can load this file automatically at startup. The .env file itself must never be committed to version control — a .gitignore entry for .env is essential, since these files typically hold real API keys.
# .gitignore
.env
A companion file, .env.example, is committed instead, listing every variable name the application needs with placeholder or empty values, so a new developer knows exactly what to fill in without ever seeing a real secret:
OPENAI_API_KEY=
DEFAULT_MODEL=gpt-5.6-terra
REQUEST_TIMEOUT_SECONDS=30
Environment-Specific Configuration
Different environments (local development, CI test runs, staging, production) often need different defaults — a cheaper or faster model for local iteration, stricter timeouts in production. One common pattern is an environment field that selects among preset defaults:
from enum import Enum
from pydantic_settings import BaseSettings
class Environment(str, Enum):
DEVELOPMENT = "development"
TESTING = "testing"
PRODUCTION = "production"
class Settings(BaseSettings):
environment: Environment = Environment.DEVELOPMENT
openai_api_key: str = ""
default_model: str = "gpt-5.6-terra"
@property
def request_timeout_seconds(self) -> float:
return 10.0 if self.environment == Environment.TESTING else 30.0
This keeps environment-dependent logic declarative and in one place, rather than as if os.environ.get("ENV") == "production": checks spread across the codebase.
Testing Code That Depends on Settings
Because a well-designed application receives a Settings object via dependency injection rather than reading the environment directly inside business logic, tests can construct a Settings instance with fixed values, with no environment variables or .env file involved at all:
def test_summarizer_uses_configured_default_model() -> None:
settings = Settings(openai_api_key="fake-key-for-test", default_model="gpt-5.6-terra")
assert settings.default_model == "gpt-5.6-terra"
assert settings.openai_api_key == "fake-key-for-test"
print("PASS: Settings exposes the configured model and key without reading the environment")
test_summarizer_uses_configured_default_model()
This test demonstrates the same principle from Lesson 2 applied to configuration: because Settings is just a plain object constructed with explicit values, no test needs a real .env file or real environment variables to verify behavior that depends on configuration.
Common Mistakes
Reading os.environ directly inside business logic. Scattering os.environ.get("DEFAULT_MODEL") throughout service classes makes it impossible to know, from one place, every configuration value the application depends on, and makes those classes harder to test without manipulating real environment variables.
Committing .env to version control. This leaks API keys and other secrets into git history, which is difficult to fully remove even after deleting the file in a later commit. Always .gitignore it and commit only .env.example.
Failing silently on missing configuration. A default like api_key: str = "" that is never validated lets the application start successfully and fail confusingly later, on the first real API call, instead of failing immediately and clearly at startup.
Best Practices
Centralize all configuration in one Settings object, constructed once at startup. Every other class receives configuration values (or the whole Settings object) via constructor injection, never by reading the environment itself.
Fail fast and clearly on missing required configuration. Validate required fields (like an API key) at construction time, with an error message that says exactly which variable is missing and how to set it.
Keep secrets out of defaults and out of source code. Required secrets should have no default value in code — they must come from the environment, a .env file (development only), or a secrets manager (production) — never a hardcoded fallback string.
Document every configuration variable in .env.example. This file acts as living documentation of what the application needs to run, and should be updated in the same commit that introduces a new setting.