# 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)