Input & UX

Handling Configuration Files & Env Vars

Reliable CLI tooling requires a deterministic configuration hierarchy. This guide establishes a production-ready pattern for merging environment variables, dotfiles, and structured configs while maintaining strict type safety. As part of the broader Advanced Input Parsing & User Experience framework, we focus exclusively on the data ingestion layer before arguments reach your command handlers.

Handling Configuration Files & Env Vars

Reliable CLI tooling requires a deterministic configuration hierarchy. This guide establishes a production-ready pattern for merging environment variables, dotfiles, and structured configs while maintaining strict type safety. As part of the broader Advanced Input Parsing & User Experience framework, we focus exclusively on the data ingestion layer before arguments reach your command handlers.

  • Define explicit precedence: CLI flags override environment variables, which override config files, which override defaults.
  • Use pydantic-settings for unified, type-validated configuration models.
  • Isolate secrets from version control using .env and OS-level injection.

Establishing Environment Variable Precedence

Environment variables serve as the primary bridge between deployment pipelines and local development. Modern Python CLIs should leverage pydantic-settings to parse os.environ directly into structured models. This approach eliminates manual string casting and reduces boilerplate.

When combined with Advanced Argument Validation Strategies, this ensures malformed environment inputs fail fast during initialization. Silent runtime errors are replaced with explicit validation traces.

Implementation relies on BaseSettings with a custom env_prefix. Use python-dotenv strictly for local development workflows. Production environments must rely on orchestrator injection or native platform secret managers.

# src/config.py
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class AppConfig(BaseSettings):
 model_config = SettingsConfigDict(
 env_prefix="MYTOOL_",
 env_file=".env",
 env_file_encoding="utf-8",
 extra="ignore",
 )

 api_key: str = Field(..., description="Required authentication token")
 timeout: int = Field(30, description="Request timeout in seconds")
 log_level: str = Field("INFO", description="Logging verbosity")
  • Use BaseSettings with custom env_prefix to namespace tool variables.
  • Validate required versus optional vars using Pydantic field defaults and Field(...).
  • Restrict .env parsing to local execution; never commit secrets to VCS.

Structured File Parsing & Safe Merging

Configuration files (TOML, YAML, JSON) provide version-controlled defaults for complex toolchains. The loading strategy must prevent arbitrary code execution and handle missing keys gracefully. For YAML-specific implementations, refer to Loading YAML configs safely in CLI apps.

We recommend pydantic-settings SettingsConfigDict to layer file paths dynamically. This ensures a single unified config object regardless of the source format. Prefer TOML for modern Python tooling due to native ecosystem support and strict typing rules.

Implement strict schema validation on file load to reject deprecated keys immediately. Use pathlib for cross-platform config resolution in ~/.config/ and /etc/.

# pyproject.toml
[project]
name = "mytool"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = [
 "pydantic>=2.6",
 "pydantic-settings>=2.2",
 "typer>=0.12",
 "python-dotenv>=1.0",
]
# src/config_loader.py
import os
from pathlib import Path
from config import AppConfig

def resolve_config_path() -> Path:
 """Cross-platform resolution for user and system configs."""
 if os.name == "nt":
 return Path(os.getenv("LOCALAPPDATA", ".")) / "mytool" / "config.toml"
 return Path.home() / ".config" / "mytool" / "config.toml"

def load_config() -> AppConfig:
 config_path = resolve_config_path()
 return AppConfig(_env_file=None, toml_file=config_path)
  • Prefer TOML for modern Python tooling due to native ecosystem support.
  • Implement strict schema validation on file load to reject deprecated keys.
  • Use pathlib for cross-platform config resolution in ~/.config/ and /etc/.

Testing Configuration Workflows in CI/CD

Configuration logic is notoriously difficult to test due to global state pollution from os.environ. Isolate tests using the pytest monkeypatch context manager or the pytest-env plugin. When running under uv or Poetry, ensure your test matrix explicitly overrides config paths.

This prevents local developer settings from leaking into CI pipelines. Properly isolated config testing also enables dynamic theming and prompt generation. These patterns can be extended into Interactive Terminal UI with Rich for context-aware user experiences.

Wrap config loading in a factory function to enable dependency injection in tests. Use pytest.mark.parametrize to assert precedence rules across multiple input combinations. Snapshot config outputs to detect unintended schema drift across dependency updates.

# tests/test_config.py
import os
import pytest
from config import AppConfig

@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
 for key in list(os.environ.keys()):
 if key.startswith("MYTOOL_"):
 monkeypatch.delenv(key)
 yield monkeypatch

def test_env_precedence(clean_env):
 clean_env.setenv("MYTOOL_TIMEOUT", "60")
 cfg = AppConfig()
 assert cfg.timeout == 60

@pytest.mark.parametrize("input_val, expected", [("10", 10), ("0", 0)])
def test_type_coercion(clean_env, input_val, expected):
 clean_env.setenv("MYTOOL_TIMEOUT", input_val)
 assert AppConfig().timeout == expected

Run the isolated test suite using modern package managers:

# Initialize environment and run tests
uv venv
uv pip install -e ".[dev]"
uv run pytest tests/test_config.py -v --tb=short
  • Wrap config loading in a factory function to enable dependency injection in tests.
  • Use pytest.mark.parametrize to assert precedence rules across multiple input combinations.
  • Snapshot config outputs to detect unintended schema drift across dependency updates.