Commandline arguments are great and that, but remember the last time
when you wanted to use environment variables? Here we provide the EnvVar
class to help you define default values via environment flags.
params-proto' EnvVar automatically handles type conversion and template expansion.
Enjoy!
Quick Start
python
from params_proto import proto, EnvVar@proto.clidef train( # Read from environment variable with fallback batch_size: int = EnvVar @ "BATCH_SIZE" | 128, # Read from environment variable (no fallback) api_key: str = EnvVar @ "API_KEY", # Template expansion with multiple variables data_dir: str = EnvVar @ "$HOME/data/$PROJECT",): """Train model with environment configuration.""" print(batch_size, api_key, data_dir)
Usage:
bash
# Set environment variablesexport BATCH_SIZE=256export API_KEY=secret-key-123export HOME=/home/aliceexport PROJECT=ml-project# Run with env varspython train.py# CLI args can still override env varspython train.py --batch-size 512
Usage Patterns
1. Matmul Operator (@)
The cleanest syntax uses the @ operator:
python
@proto.clidef config( # Environment variable only (no fallback) port: int = EnvVar @ "PORT", # Environment variable with fallback using | operator host: str = EnvVar @ "HOST" | "localhost", # Template expansion log_file: str = EnvVar @ "$HOME/logs/app.log",): """Configuration from environment.""" pass
CLI usage:
bash
# Without env vars - uses fallbackspython config.py# port=None, host="localhost", log_file expands $HOME# With env vars setexport PORT=8080export HOST=api.example.comexport HOME=/home/userpython config.py# port=8080, host="api.example.com", log_file="/home/user/logs/app.log"
2. Function Call Syntax
Use function call syntax for explicit keyword arguments:
python
@proto.clidef connect( db_url: str = EnvVar("DATABASE_URL", default="sqlite:///local.db"), timeout: int = EnvVar("DB_TIMEOUT", default=30), pool_size: int = EnvVar("DB_POOL_SIZE", default=10),): """Connect to database with environment configuration.""" print(f"Connecting to: {db_url}")
CLI usage:
bash
# Uses all defaultspython connect.py# db_url="sqlite:///local.db", timeout=30, pool_size=10# With environment variablesexport DATABASE_URL=postgres://prod-db:5432/myappexport DB_TIMEOUT=60python connect.py# db_url="postgres://prod-db:5432/myapp", timeout=60, pool_size=10
3. Pipe Operator for Defaults
The pipe operator (|) provides a clean syntax for fallback values:
python
@proto.clidef train( # Pipe operator chains env var name with fallback lr: float = EnvVar @ "LEARNING_RATE" | 0.001, epochs: int = EnvVar @ "EPOCHS" | 100, seed: int = EnvVar @ "RANDOM_SEED" | 42,): """Train with environment or defaults.""" pass
Behavior:
If environment variable exists: uses its value (with type conversion)
If environment variable missing: uses the fallback value
Template Expansion
EnvVar supports template strings with environment variable substitution.
Basic Template Syntax
Two syntaxes are supported:
python
@proto.clidef setup( # Dollar prefix: $VAR_NAME home_dir: str = EnvVar @ "$HOME", # Braces syntax: ${VAR_NAME} config_file: str = EnvVar("${HOME}/.config/app.conf", default="~/.config/app.conf"), # Both syntaxes work together log_path: str = EnvVar @ "$HOME/logs/${APP_NAME}.log",): """Setup with path templates.""" pass
When a variable in a template is not set, it's replaced with an empty string:
python
@proto.clidef get_path( # SUBDIR might not be set data_path: str = EnvVar("$BASE_DIR/$SUBDIR/output", default="/tmp/output"),): """Get path with potentially missing vars.""" pass
Example:
bash
export BASE_DIR=/data# SUBDIR is NOT setpython get_path.py# data_path="/data//output" (SUBDIR becomes empty string)export SUBDIR=trainingpython get_path.py# data_path="/data/training/output"
Type Conversion
EnvVar automatically converts string values from environment variables to the annotated type:
python
@proto.clidef config( # String to int port: int = EnvVar @ "PORT" | 8080, # String to float threshold: float = EnvVar @ "THRESHOLD" | 0.75, # String to bool debug: bool = EnvVar @ "DEBUG" | False, # Remains string api_key: str = EnvVar @ "API_KEY" | "dev-key",): """Configuration with type conversion.""" pass
Resolve from environment variables at decoration time
Convert to the annotated type (str, int, bool, float)
Use fallback defaults when the env var is not set
Usage:
bash
# Shared config applies to both servicesexport HOST=10.0.0.1export PORT=3000export DEBUG=true# Service-specific configexport API_TIMEOUT=60export API_KEY=secret-keyexport WORKER_CONCURRENCY=8python api_server.py # Uses APIConfig with inherited HOST, PORT, DEBUGpython worker.py # Uses WorkerConfig with same inherited fields
Security Considerations
1. Never Log Secrets
Be careful not to log environment variables that contain secrets:
python
# ✗ BAD: Logs the secret@proto.clidef connect(api_key: str = EnvVar @ "API_KEY"): print(f"Using API key: {api_key}") # ✗ Exposes secret in logs# ✓ GOOD: Masks the secret@proto.clidef connect(api_key: str = EnvVar @ "API_KEY"): masked = api_key[:4] + "..." if api_key else "None" print(f"Using API key: {masked}") # ✓ Safe logging
2. Require Secrets in Production
Use assertions to ensure required secrets are set:
python
@proto.prefixclass Config: env: str = EnvVar @ "APP_ENV" | "development" secret_key: str = EnvVar @ "SECRET_KEY" | "dev-secret-key"@proto.clidef main(): """Run application.""" if Config.env == "production": assert Config.secret_key != "dev-secret-key", ( "SECRET_KEY environment variable must be set in production" )
3. Validate Environment Variables
Add validation for critical configuration:
python
@proto.clidef server( port: int = EnvVar @ "PORT" | 8000, workers: int = EnvVar @ "WORKERS" | 4,): """Start server with validation.""" assert 1024 <= port <= 65535, f"Port must be in range 1024-65535, got {port}" assert workers > 0, f"Workers must be positive, got {workers}" print(f"Starting server on port {port} with {workers} workers")
Testing with Environment Variables
When testing code that uses EnvVar, manage environment variables in test fixtures:
python
import osimport pytestfrom params_proto import proto, EnvVardef test_envvar_configuration(): """Test configuration from environment variables.""" # Set up test environment os.environ["TEST_PORT"] = "9000" os.environ["TEST_HOST"] = "testhost" try: @proto def config( port: int = EnvVar @ "TEST_PORT" | 8000, host: str = EnvVar @ "TEST_HOST" | "localhost", ): return port, host # Test with env vars result = config() assert result == (9000, "testhost") finally: # Clean up del os.environ["TEST_PORT"] del os.environ["TEST_HOST"]
Better approach with pytest fixtures:
python
@pytest.fixturedef test_env(): """Set up test environment variables.""" original = os.environ.copy() # Set test vars os.environ["TEST_PORT"] = "9000" os.environ["TEST_HOST"] = "testhost" yield # Restore original environment os.environ.clear() os.environ.update(original)def test_config(test_env): """Test with environment fixture.""" @proto def config( port: int = EnvVar @ "TEST_PORT", host: str = EnvVar @ "TEST_HOST", ): return port, host assert config() == (9000, "testhost")