Skip to content

Configuration Management

Type-safe configuration with Pydantic and environment variables

Sillo provides type-safe configuration management using Pydantic, with automatic .env file loading, validation, and environment-specific settings.

The configuration system provides:

  • Type Safety - Full type hints, IDE autocomplete, mypy support
  • Validation - Pydantic validates all values on load
  • Environment Variables - Load from .env files
  • Required vs Optional - Clear which settings are mandatory
  • Secret Masking - Automatically hides sensitive values
  • Environment-Specific - Different configs per environment (dev/staging/prod)
  • Defaults - Sensible defaults for optional settings

Configuration support is built-in. Just create a config class and .env file:

Terminal window
# No additional packages needed
# Uses Pydantic (already a dependency)
from sillo.config import Config, Field
from typing import Literal
class AppConfig(Config):
"""Application configuration from .env"""
# Required fields
database_url: str
jwt_secret: str
# Optional fields with defaults
debug: bool = False
log_level: Literal['debug', 'info', 'warning', 'error'] = 'info'
port: int = 8000
class Config:
env_file = '.env'
case_sensitive = False
# Load configuration
config = AppConfig()
.env
DATABASE_URL=postgresql://localhost/mydb
JWT_SECRET=your-secret-key
DEBUG=true
LOG_LEVEL=debug
PORT=8000
from sillo import silloApp
app = silloApp(
debug=config.debug,
title="My API"
)
@app.on_startup
async def startup():
print(f"Starting in {config.environment}")
await database.connect(config.database_url)
from sillo.config import Config, Field
from typing import Literal, Optional
class AppConfig(Config):
"""Define all application settings."""
# Required string
database_url: str
# Required with description
api_key: str = Field(..., description="Third-party API key")
# Optional with default
debug: bool = False
# Enum-like with type
environment: Literal['dev', 'staging', 'prod'] = 'dev'
# Integer with default
port: int = 8000
# Optional (can be None)
cache_url: Optional[str] = None
class Config:
env_file = '.env'
env_file_encoding = 'utf-8'
case_sensitive = False

Pydantic supports many types automatically:

from sillo.config import Config
from typing import Literal, Optional
from pathlib import Path
class Config(Config):
# Basic types
name: str
port: int
timeout: float
enabled: bool
# Enums
environment: Literal['dev', 'staging', 'prod']
log_level: Literal['debug', 'info', 'warning', 'error']
# Optional
optional_key: Optional[str] = None
# Collections
allowed_origins: list[str] = []
cors_headers: dict[str, str] = {}
# Paths
log_file: Path = Path('app.log')

Pydantic validates types on load:

port: int = 8000 # Must be integer or convertible to int
debug: bool = False # Must be bool or 'true'/'false' string
timeout: float = 30.0 # Must be float
# These work:
PORT=8000 # Loaded as int
DEBUG=true # Converted to bool
TIMEOUT=30.5 # Loaded as float
# These fail:
PORT=eight # ValidationError
DEBUG=yes # ValidationError (only true/false works)
Terminal window
# Simple key=value
DATABASE_URL=postgresql://localhost/db
DEBUG=true
PORT=8000
# Multiline not directly supported, use single line
LONG_VALUE=this_is_a_long_value_on_a_single_line
# Comments with #
# This is a comment
SECRET_KEY=... # Inline comment
# Empty lines are OK
# No spaces around = (recommended)
KEY=value # OK
KEY = value # Also OK
project/
├── .env # Main config (git ignored)
├── .env.example # Template (commit this)
├── .env.development # Development overrides
├── .env.production # Production config (git ignored)
└── .env.test # Test config
import os
environment = os.getenv('ENVIRONMENT', 'development')
env_file = f'.env.{environment}'
class AppConfig(Config):
class Config:
env_file = env_file # Load .env.development or .env.production
# Load config
config = AppConfig()
# Access values (type-safe)
db_url: str = config.database_url
port: int = config.port
debug: bool = config.debug
# All have proper types and IDE autocomplete
from sillo import silloApp
config = AppConfig()
app = silloApp(
debug=config.debug,
title="My API",
)
@app.on_startup
async def startup():
print(f"Starting {app.title}")
print(f"Environment: {config.environment}")
print(f"Database: {config.database_url}")
config = AppConfig()
# JWT_SECRET, API_KEY, PASSWORD fields are automatically masked
print(config)
# <AppConfig {..., 'jwt_secret': '***', 'api_key': '***', ...}>
# Secrets not masked in direct access
secret = config.jwt_secret # Full value available in code
class AppConfig(Config):
environment: Literal['development', 'production']
debug: bool = False
config = AppConfig()
# Change behavior based on config
if config.debug:
logger.setLevel('DEBUG')
else:
logger.setLevel('INFO')
if config.environment == 'production':
use_https()
else:
allow_http()
class AppConfig(Config):
enable_payments: bool = False
enable_notifications: bool = False
enable_analytics: bool = False
config = AppConfig()
# Enable/disable features via .env
if config.enable_payments:
setup_stripe(config.stripe_api_key)
if config.enable_notifications:
setup_email_service()
class AppConfig(Config):
# Option 1: Full URL
database_url: str
# Option 2: Components
database_host: str = 'localhost'
database_port: int = 5432
database_user: str
database_password: str
database_name: str
@property
def db_connection_string(self) -> str:
"""Build connection string from components."""
return (
f"postgresql://"
f"{self.database_user}:{self.database_password}"
f"@{self.database_host}:{self.database_port}"
f"/{self.database_name}"
)
class AppConfig(Config):
jwt_secret: str # Never commit!
jwt_algorithm: str = 'HS256'
jwt_expiration_hours: int = 24
cors_origins: str = '*' # Restrict in production
enable_swagger: bool = False # Disable in production
debug: bool = False # Always False in production
# Production .env
DEBUG=false
ENABLE_SWAGGER=false
CORS_ORIGINS=https://app.example.com,https://www.example.com
from typing import Literal
class EmailConfig(Config):
email_provider: Literal['smtp', 'sendgrid', 'mailgun'] = 'smtp'
# SMTP settings
smtp_host: str = 'localhost'
smtp_port: int = 587
smtp_user: str = ''
smtp_password: str = ''
# API key for SendGrid/Mailgun
api_key: str = ''
email_from: str = 'noreply@example.com'
# Use in app
config = EmailConfig()
if config.email_provider == 'smtp':
send_via_smtp(config)
elif config.email_provider == 'sendgrid':
send_via_sendgrid(config)
from typing import Literal
class LoggingConfig(Config):
log_level: Literal['debug', 'info', 'warning', 'error'] = 'info'
log_format: Literal['json', 'text', 'pretty'] = 'json'
log_file: str = 'app.log'
log_max_size_mb: int = 10
log_backup_count: int = 5
class AppConfig(Config):
# These MUST be in .env or will raise ValidationError
database_url: str
jwt_secret: str
api_key: str

If missing:

ValidationError: 3 validation errors for AppConfig
database_url
Field required (type=missing)
jwt_secret
Field required (type=missing)
api_key
Field required (type=missing)
class Config(Config):
port: int = 8000
timeout: float = 30.0
debug: bool = False
# .env
PORT=8000 # OK: Valid integer
TIMEOUT=30.5 # OK: Valid float
DEBUG=true # OK: Valid boolean
PORT=not_a_number # ValidationError: Invalid integer
TIMEOUT=abc # ValidationError: Invalid float
DEBUG=yes # ValidationError: Invalid boolean (only true/false)
.env
class Config(Config):
environment: Literal['dev', 'staging', 'prod']
ENVIRONMENT=dev # OK
ENVIRONMENT=prod # OK
ENVIRONMENT=test # ValidationError: Invalid literal
# Error:
ValidationError: 1 validation error for Config
environment
Input should be 'dev', 'staging' or 'prod'
.env.development
DEBUG=true
LOG_LEVEL=debug
DATABASE_URL=sqlite:///dev.db
JWT_SECRET=dev-secret-not-secure
ENABLE_SWAGGER=true
CORS_ORIGINS=*
.env.staging
DEBUG=false
LOG_LEVEL=info
DATABASE_URL=postgresql://staging.db.local/app
JWT_SECRET=staging-secret-key
ENABLE_SWAGGER=true
CORS_ORIGINS=https://staging.example.com
Terminal window
# .env.production (never commit!)
DEBUG=false
LOG_LEVEL=warning
DATABASE_URL=postgresql://prod.db.aws.com/app
JWT_SECRET=<generate-strong-key>
ENABLE_SWAGGER=false
CORS_ORIGINS=https://app.example.com,https://www.example.com
Terminal window
# Create and commit template
cp .env.example .env
# Add to .gitignore
echo ".env" >> .gitignore
echo ".env.production" >> .gitignore
# Developers copy template
cp .env.example .env
# Then edit with their local values
config.py
from sillo.config import Config
class AppConfig(Config):
database_url: str
jwt_secret: str
debug: bool = False
config = AppConfig()
# Use throughout app
# from config import config
main.py
try:
config = AppConfig()
except ValidationError as e:
print(f"Invalid configuration: {e}")
sys.exit(1)
# Proceed only if config is valid
app = silloApp(debug=config.debug)
class AppConfig(Config):
"""Application configuration.
Load from .env file. See .env.example for template.
"""
database_url: str = Field(
...,
description="PostgreSQL connection string"
)
jwt_secret: str = Field(
...,
description="Secret key for JWT signing"
)
debug: bool = Field(
default=False,
description="Enable debug mode (never in production)"
)
# Good
db_url: str = config.database_url
port: int = config.port
# Avoid
db_url = config.database_url # Type not clear
.gitignore
.env
.env.production
.env.*.local
# OK to commit
.env.example

Problem: Required field missing from .env

Solution: Add the field to .env file

config.py
class AppConfig(Config):
database_url: str # Required
# .env file must have:
DATABASE_URL=postgresql://localhost/db

ValidationError: Input should be a valid integer

Section titled “ValidationError: Input should be a valid integer”

Problem: Non-integer value for integer field

PORT=eight # Wrong
# Fix:
PORT=8000 # Right

Problem: File not found or wrong path

class Config(Config):
class Config:
env_file = '.env' # Must be in current directory
# OR with full path
from pathlib import Path
class Config(Config):
class Config:
env_file = str(Path(__file__).parent / '.env')

Problem: Sensitive values logged

Solution: Config automatically masks common secret fields

# These are automatically masked:
jwt_secret: str # Appears as ***
api_key: str # Appears as ***
password: str # Appears as ***
access_token: str # Appears as ***