The Zen of Python says so. Your secrets should follow the same philosophy.
import os
# "Just use os.environ.get with a default"
database_url = os.environ.get(
'DATABASE_URL',
'sqlite:///local.db'
)
# Runs fine locally
# Runs fine in CI
# Runs fine in production with...
# wait, why is it using SQLite?
import os
import sys
# Fail fast. Fail loud.
database_url = os.environ.get('DATABASE_URL')
if not database_url:
print("DATABASE_URL required", file=sys.stderr)
print("Run: envv run -- python app.py")
sys.exit(1)
# Now you know it's there
Missing secrets aren't silent errors. They're landmines.
The micro-framework that trusts you to handle config. So handle it.
from flask import Flask
import os
import sys
def require_env(key: str) -> str:
"""Get an environment variable or die trying."""
value = os.environ.get(key)
if not value:
print(f"Missing required: {key}", file=sys.stderr)
sys.exit(1)
return value
app = Flask(__name__)
app.secret_key = require_env('SECRET_KEY')
DATABASE_URL = require_env('DATABASE_URL')
STRIPE_KEY = require_env('STRIPE_API_KEY')
@app.route('/health')
def health():
return {'status': 'ok', 'config': 'valid'}
# If you got here, everything exists
Pydantic for request validation. Why not config validation?
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
stripe_api_key: str
secret_key: str
debug: bool = False
class Config:
# envv injects these into the environment
env_file = None # We don't need .env files
# Validates on import. Crashes if anything missing.
settings = Settings()
# Type-safe access everywhere:
# settings.database_url # str, guaranteed
# settings.debug # bool, with default
from fastapi import FastAPI
from config import settings
app = FastAPI()
@app.get("/")
def root():
return {
"status": "ok",
"debug": settings.debug,
# Mask the actual value
"database": settings.database_url.split('@')[-1]
}
Not everything is a web app. One-off scripts need secrets too.
#!/usr/bin/env python3
"""Database migration script."""
import os
import sys
DATABASE_URL = os.environ.get('DATABASE_URL')
if not DATABASE_URL:
print("Usage: envv run -- python migrate.py")
sys.exit(1)
def main():
print(f"Migrating: {DATABASE_URL.split('@')[-1]}")
# ... migration logic
if __name__ == '__main__':
main()
from dotenv import load_dotenv
load_dotenv() # Reads .env into os.environ
# Now you have a .env file
# That needs to be distributed
# And is probably in git history
# And is definitely plaintext
# No load_dotenv. No .env file.
# envv injects secrets before Python runs
envv run -- python app.py
# os.environ already populated
# .env.encrypted is ciphertext
# Safe to commit
# Late-night debugging
print(f"Config: {os.environ}")
# Oops, that went to the logs
# Which went to CloudWatch
# Which your whole team can read
# Including the Stripe key
def mask(s: str) -> str:
if len(s) <= 8:
return "***"
return f"{s[:4]}...{s[-4:]}"
print(f"API Key: {mask(api_key)}")
# Output: API Key: sk_l...xxxx
One command. One pattern. All your Python projects.
curl -fsSL https://getenvv.com/envv | sh