KCH / src /chapters /ch02-configuration-management.qmd
bsamadi's picture
Update to pixi env
c032460
# Configuration and Settings Management
## Learning Objectives
- Read and write configuration files (YAML, TOML, JSON)
- Manage environment variables securely
- Implement user preferences and defaults
- Validate configuration with Pydantic
- Handle configuration across different environments
## Why Configuration Management?
Good configuration management allows users to:
- Customize tool behavior without changing code
- Store API keys and secrets securely
- Maintain different settings for dev/prod environments
- Share configuration across team members
## Configuration File Formats
### YAML
Human-readable, great for complex configurations:
```yaml
# config.yaml
app:
name: "My CLI Tool"
version: "1.0.0"
api:
endpoint: "https://api.example.com"
timeout: 30
features:
ai_enabled: true
max_retries: 3
```
### TOML
Python-native, used by pyproject.toml:
```toml
# config.toml
[app]
name = "My CLI Tool"
version = "1.0.0"
[api]
endpoint = "https://api.example.com"
timeout = 30
[features]
ai_enabled = true
max_retries = 3
```
### JSON
Universal, machine-readable:
```json
{
"app": {
"name": "My CLI Tool",
"version": "1.0.0"
},
"api": {
"endpoint": "https://api.example.com",
"timeout": 30
}
}
```
## Reading Configuration Files
Install dependencies:
```bash
pixi add pydantic pyyaml python-dotenv tomli
```
### YAML Configuration
```python
# src/my_cli/config.py
import yaml
from pathlib import Path
from typing import Optional
def load_yaml_config(config_path: Path) -> dict:
"""Load configuration from YAML file."""
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with open(config_path) as f:
return yaml.safe_load(f)
def save_yaml_config(config: dict, config_path: Path):
"""Save configuration to YAML file."""
with open(config_path, 'w') as f:
yaml.dump(config, f, default_flow_style=False)
```
### TOML Configuration
```python
import tomli
import tomli_w
def load_toml_config(config_path: Path) -> dict:
"""Load configuration from TOML file."""
with open(config_path, 'rb') as f:
return tomli.load(f)
def save_toml_config(config: dict, config_path: Path):
"""Save configuration to TOML file."""
with open(config_path, 'wb') as f:
tomli_w.dump(config, f)
```
## Environment Variables
### Using python-dotenv
Create `.env` file:
```bash
# .env
API_KEY=your-secret-key-here
API_ENDPOINT=https://api.example.com
DEBUG=true
MAX_RETRIES=3
```
Load in your application:
```python
import os
from dotenv import load_dotenv
# Load .env file
load_dotenv()
# Access variables
api_key = os.getenv("API_KEY")
api_endpoint = os.getenv("API_ENDPOINT", "https://default.api.com")
debug = os.getenv("DEBUG", "false").lower() == "true"
max_retries = int(os.getenv("MAX_RETRIES", "3"))
```
### Security Best Practices
```python
# .gitignore
.env
.env.local
*.secret
# .env.example (commit this)
API_KEY=your-key-here
API_ENDPOINT=https://api.example.com
DEBUG=false
```
## Configuration with Pydantic
Pydantic provides type-safe configuration with validation:
```python
from pydantic import BaseModel, Field, validator
from typing import Optional
from pathlib import Path
class APIConfig(BaseModel):
"""API configuration."""
endpoint: str = Field(..., description="API endpoint URL")
key: str = Field(..., description="API key")
timeout: int = Field(30, ge=1, le=300, description="Request timeout in seconds")
max_retries: int = Field(3, ge=0, le=10)
@validator('endpoint')
def validate_endpoint(cls, v):
if not v.startswith(('http://', 'https://')):
raise ValueError('Endpoint must start with http:// or https://')
return v
class AppConfig(BaseModel):
"""Application configuration."""
name: str = "My CLI Tool"
version: str = "1.0.0"
debug: bool = False
data_dir: Path = Field(default_factory=lambda: Path.home() / ".my-cli")
api: APIConfig
class Config:
env_prefix = "MYCLI_" # Environment variables start with MYCLI_
# Usage
config = AppConfig(
api=APIConfig(
endpoint="https://api.example.com",
key=os.getenv("API_KEY")
)
)
# Validation happens automatically
print(config.api.timeout) # 30
print(config.data_dir) # /home/user/.my-cli
```
## Configuration Manager
Complete configuration management system:
```python
# src/my_cli/config.py
from pathlib import Path
from typing import Optional
import os
import yaml
from pydantic import BaseModel, Field
from dotenv import load_dotenv
class Config(BaseModel):
"""Application configuration."""
# App settings
app_name: str = "my-cli"
debug: bool = False
# Paths
config_dir: Path = Field(default_factory=lambda: Path.home() / ".my-cli")
data_dir: Path = Field(default_factory=lambda: Path.home() / ".my-cli" / "data")
# API settings
api_endpoint: str = "https://api.example.com"
api_key: Optional[str] = None
api_timeout: int = 30
# Feature flags
ai_enabled: bool = True
max_retries: int = 3
class Config:
env_prefix = "MYCLI_"
class ConfigManager:
"""Manage application configuration."""
def __init__(self, config_path: Optional[Path] = None):
self.config_path = config_path or Path.home() / ".my-cli" / "config.yaml"
self.config: Optional[Config] = None
def load(self) -> Config:
"""Load configuration from file and environment."""
# Load .env file if it exists
load_dotenv()
# Start with defaults
config_data = {}
# Load from file if exists
if self.config_path.exists():
with open(self.config_path) as f:
config_data = yaml.safe_load(f) or {}
# Override with environment variables
env_overrides = {
'debug': os.getenv('MYCLI_DEBUG', '').lower() == 'true',
'api_key': os.getenv('MYCLI_API_KEY'),
'api_endpoint': os.getenv('MYCLI_API_ENDPOINT'),
}
# Remove None values
env_overrides = {k: v for k, v in env_overrides.items() if v is not None}
# Merge configurations (env takes precedence)
config_data.update(env_overrides)
# Create and validate config
self.config = Config(**config_data)
# Ensure directories exist
self.config.config_dir.mkdir(parents=True, exist_ok=True)
self.config.data_dir.mkdir(parents=True, exist_ok=True)
return self.config
def save(self):
"""Save current configuration to file."""
if not self.config:
raise ValueError("No configuration loaded")
self.config_path.parent.mkdir(parents=True, exist_ok=True)
# Convert to dict, excluding sensitive data
config_dict = self.config.dict(exclude={'api_key'})
with open(self.config_path, 'w') as f:
yaml.dump(config_dict, f, default_flow_style=False)
def get(self, key: str, default=None):
"""Get configuration value."""
if not self.config:
self.load()
return getattr(self.config, key, default)
def set(self, key: str, value):
"""Set configuration value."""
if not self.config:
self.load()
setattr(self.config, key, value)
self.save()
# Global config instance
_config_manager = None
def get_config() -> Config:
"""Get global configuration instance."""
global _config_manager
if _config_manager is None:
_config_manager = ConfigManager()
_config_manager.load()
return _config_manager.config
```
## Using Configuration in CLI
```python
# src/my_cli/cli.py
import typer
from rich.console import Console
from .config import get_config, ConfigManager
app = typer.Typer()
console = Console()
@app.command()
def show_config():
"""Show current configuration."""
config = get_config()
console.print("[bold cyan]Current Configuration:[/bold cyan]\n")
console.print(f"App Name: {config.app_name}")
console.print(f"Debug: {config.debug}")
console.print(f"Config Dir: {config.config_dir}")
console.print(f"API Endpoint: {config.api_endpoint}")
console.print(f"AI Enabled: {config.ai_enabled}")
@app.command()
def set_config(key: str, value: str):
"""Set a configuration value."""
manager = ConfigManager()
manager.load()
# Type conversion
if value.lower() in ('true', 'false'):
value = value.lower() == 'true'
elif value.isdigit():
value = int(value)
manager.set(key, value)
console.print(f"[green]✓ Set {key} = {value}[/green]")
@app.command()
def init_config():
"""Initialize configuration with defaults."""
manager = ConfigManager()
manager.load()
manager.save()
console.print(f"[green]✓ Configuration initialized at {manager.config_path}[/green]")
```
## Multi-Environment Configuration
Support different configurations for dev/staging/prod:
```python
from enum import Enum
class Environment(str, Enum):
DEV = "development"
STAGING = "staging"
PROD = "production"
class EnvironmentConfig(BaseModel):
"""Environment-specific configuration."""
environment: Environment = Environment.DEV
@property
def is_production(self) -> bool:
return self.environment == Environment.PROD
@property
def is_development(self) -> bool:
return self.environment == Environment.DEV
# Load based on environment
env = os.getenv("MYCLI_ENV", "development")
config_file = f"config.{env}.yaml"
```
## Configuration Validation
```python
from pydantic import validator, root_validator
class Config(BaseModel):
api_key: Optional[str] = None
ai_enabled: bool = False
@validator('api_key')
def validate_api_key(cls, v, values):
if values.get('ai_enabled') and not v:
raise ValueError('API key required when AI is enabled')
return v
@root_validator
def validate_config(cls, values):
"""Validate entire configuration."""
if values.get('debug') and values.get('environment') == 'production':
raise ValueError('Debug mode not allowed in production')
return values
```
## Best Practices
1. **Use environment variables for secrets**: Never commit API keys or passwords
2. **Provide defaults**: Make configuration optional with sensible defaults
3. **Validate early**: Use Pydantic to catch configuration errors at startup
4. **Document configuration**: Provide example config files
5. **Support multiple formats**: Allow YAML, TOML, or JSON based on user preference
## Using Copilot
Ask Copilot to help:
- "Create a Pydantic model for API configuration with validation"
- "Generate a function to load YAML config with error handling"
- "Add environment variable support to configuration loader"
- "Create a CLI command to show and edit configuration"
## Next Steps
With configuration management in place, you're ready to integrate AI capabilities into your CLI tool in Chapter 3.
## Resources
- [Pydantic Documentation](https://docs.pydantic.dev/)
- [python-dotenv](https://github.com/theskumar/python-dotenv)
- [PyYAML Documentation](https://pyyaml.org/wiki/PyYAMLDocumentation)