diff --git a/README.md b/README.md index 3987e8b..d4d7429 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Pallets Projects Website +## What's different in this fork + + + + This is the code and content for https://palletsprojects.com. Static content from the `content` folder is loaded when the server is started then served quickly from memory or cache. This allows editing content as plain files like a diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..b806141 --- /dev/null +++ b/src/config.py @@ -0,0 +1,103 @@ +import tomllib +from pathlib import Path + +from pydantic import BaseModel +from pydantic import Field +from pydantic import ValidationError + + +class ConfigError(Exception): + """Custom exception for configuration-related errors.""" + + pass + + +class Settings(BaseModel): + """ + Defines the application's configuration structure using Pydantic. + This ensures that all configuration values are of the correct type. + """ + + api_key: str = Field(..., description="The API key for the primary service.") + timeout: int = Field( + default=30, gt=0, description="Default timeout for API requests in seconds." + ) + log_level: str = Field( + default="INFO", + pattern=r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", + description="Logging level (e.g., DEBUG, INFO, WARNING).", + ) + database_url: str | None = Field( + default=None, description="Optional database connection URL." + ) + + +def load_config(config_path: Path = Path("config.toml")) -> Settings: + """ + Loads and validates configuration from a TOML file. + + This function is designed to provide clear, actionable error messages + to help users fix configuration issues quickly. + + Args: + config_path: The path to the configuration file. + + Returns: + A validated Settings object. + + Raises: + ConfigError: If the file is not found, cannot be parsed, or fails validation. + """ + # Pain Point 1: The configuration file doesn't exist. + # The error should be explicit about what's missing and where we looked. + if not config_path.is_file(): + raise ConfigError( + f"Configuration file not found at '{config_path}'.\n" + "Hint: Check that the file exists at this path. You may need to create it " + "from a template (e.g., 'config.example.toml') or specify the correct " + "path via a command-line argument or environment variable." + ) + + # Pain Point 2: The file exists but is malformed (e.g., invalid TOML). + # We'll catch the specific parsing error and provide context. + try: + with open(config_path, "rb") as f: + config_data = tomllib.load(f) + except tomllib.TOMLDecodeError as e: + raise ConfigError( + f"Error parsing '{config_path}':\n" + f" {e}\n\n" + "Hint: This looks like a TOML syntax error. Common issues include " + "unclosed quotes for strings or misplaced brackets. Please double-check " + "the line mentioned in the error above." + ) + except OSError as e: + # Also handle cases where the file can't be read due to permissions. + raise ConfigError( + f"Could not read configuration file '{config_path}': {e}\n" + "Hint: Please check the file's permissions and ensure the application " + "has read access." + ) + + # Pain Points 3 & 4: Missing required keys or values of the wrong type. + # Pydantic handles this validation, but we can format its error for clarity. + try: + return Settings(**config_data) + except ValidationError as e: + # Pydantic's default error is good, but we can make it more user-friendly + # by formatting it as a clear, readable list. + error_messages = [] + for error in e.errors(): + field = " -> ".join(map(str, error["loc"])) + message = error["msg"] + error_messages.append(f" - Field '{field}': {message}") + + formatted_errors = "\n".join(error_messages) + raise ConfigError( + f"Configuration in '{config_path}' is invalid:\n" + f"{formatted_errors}\n\n" + "Hint: Please review the errors above and correct your configuration file. For example:\n" + " - 'api_key' must be a non-empty string.\n" + " - 'timeout' must be a whole number greater than 0.\n" + " - 'log_level' must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL." + ) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..82383fe --- /dev/null +++ b/src/main.py @@ -0,0 +1,126 @@ +import sys +from typing import Any + + +class ConfigError(Exception): + """A custom exception for user-facing configuration errors.""" + + pass + + +def validate_config(config: dict[str, Any]) -> dict[str, Any]: + """ + Validates a configuration dictionary, raising ConfigError with actionable + advice if any checks fail. + """ + _check_for_required_keys(config) + _check_api_key(config) + _check_timeout(config) + _check_mode(config) + + return config + + +def _check_for_required_keys(config: dict[str, Any]): + """Ensure all mandatory keys are present.""" + required_keys = ["api_key", "timeout", "mode"] + missing_keys = [key for key in required_keys if key not in config] + + if missing_keys: + key_str = ", ".join(f"'{key}'" for key in missing_keys) + raise ConfigError( + f"Configuration error: Missing required key(s): {key_str}.\n" + f" > Please add the missing key(s) to your configuration file." + ) + + +def _check_api_key(config: dict[str, Any]): + """Validate the 'api_key' is a non-empty string.""" + api_key = config.get("api_key") + if not isinstance(api_key, str) or not api_key: + # A common mistake is an empty string or forgetting quotes in YAML. + # This message helps diagnose both. + raise ConfigError( + "Configuration error: The 'api_key' must be a non-empty string.\n" + f" > We found a value of type '{type(api_key).__name__}'.\n" + ' > Please ensure your config looks like: api_key: "your_secret_key"' + ) + + +def _check_timeout(config: dict[str, Any]): + """Validate the 'timeout' is a positive integer.""" + timeout = config.get("timeout") + if not isinstance(timeout, int) or timeout <= 0: + # Guide the user on the expected type and a valid range. + raise ConfigError( + "Configuration error: The 'timeout' must be a positive integer.\n" + f" > We found '{timeout}', which is a '{type(timeout).__name__}'.\n" + " > Please use a whole number greater than zero, like: timeout: 30" + ) + + +def _check_mode(config: dict[str, Any]): + """Validate the 'mode' is one of the allowed values.""" + mode = config.get("mode") + valid_modes = ["fast", "balanced", "high_quality"] + if mode not in valid_modes: + # Show the user the exact value they provided and list the valid options. + # This prevents typos and guesswork. + raise ConfigError( + f"Configuration error: Invalid value for 'mode'.\n" + f" > We received '{mode}', but the only allowed values are: {valid_modes}.\n" + f" > Please update 'mode' in your configuration file." + ) + + +def run_with_config(config_name: str, config_data: dict[str, Any]): + """A helper to simulate running the app with a given configuration.""" + print(f"--- Attempting to load '{config_name}' ---") + try: + validate_config(config_data) + print("✅ Configuration is valid and loaded successfully.\n") + except ConfigError as e: + # This is where the user sees our improved, actionable error message. + # Printing to stderr is standard practice for errors. + print(f"❌ {e}\n", file=sys.stderr) + + +def main(): + """ + Demonstrates configuration validation with user-friendly error messages + by running through several common invalid configuration scenarios. + """ + # A valid configuration to show the success case. + valid_config = {"api_key": "sk-12345abcde", "timeout": 30, "mode": "balanced"} + + # --- Example Error Cases --- + + # 1. Missing a required key ('api_key') + config_missing_key = {"timeout": 60, "mode": "fast"} + + # 2. Incorrect data type for 'timeout' (string instead of int) + config_wrong_type = { + "api_key": "sk-12345abcde", + "timeout": "30", # Should be an integer + "mode": "fast", + } + + # 3. Invalid value for 'mode' + config_invalid_value = { + "api_key": "sk-12345abcde", + "timeout": 15, + "mode": "quick", # Not a valid mode + } + + # 4. Empty string for 'api_key' + config_empty_key = {"api_key": "", "timeout": 15, "mode": "high_quality"} + + run_with_config("Valid Config", valid_config) + run_with_config("Config Missing Key", config_missing_key) + run_with_config("Config Wrong Type", config_wrong_type) + run_with_config("Config Invalid Value", config_invalid_value) + run_with_config("Config Empty Key", config_empty_key) + + +if __name__ == "__main__": + main()