From faaf1aafa7cf759de06da65ed37ffb3c423f5d14 Mon Sep 17 00:00:00 2001 From: Francwa Date: Sat, 3 Jan 2026 10:06:37 +0100 Subject: [PATCH] feat: implemented declarative schema-based settings system --- CONTRIBUTE.md | 261 ++++++++++++++++++++ Makefile | 22 +- alfred/settings.py | 281 +++++++++++++-------- alfred/settings_bootstrap.py | 381 ++++++++++++++++++++++++++++ alfred/settings_schema.py | 291 ++++++++++++++++++++++ pyproject.toml | 17 -- scripts/bootstrap.py | 262 +++----------------- scripts/generate_build_vars.py | 22 -- scripts/validate_settings.py | 66 +++++ settings.toml | 304 +++++++++++++++++++++++ tests/test_settings_bootstrap.py | 410 +++++++++++++++++++++++++++++++ tests/test_settings_schema.py | 332 +++++++++++++++++++++++++ 12 files changed, 2277 insertions(+), 372 deletions(-) create mode 100644 CONTRIBUTE.md create mode 100644 alfred/settings_bootstrap.py create mode 100644 alfred/settings_schema.py delete mode 100644 scripts/generate_build_vars.py create mode 100644 scripts/validate_settings.py create mode 100644 settings.toml create mode 100644 tests/test_settings_bootstrap.py create mode 100644 tests/test_settings_schema.py diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..ed48018 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,261 @@ +# Contributing to Alfred + +## Settings Management System + +Alfred uses a **declarative, schema-based configuration system** that ensures type safety, validation, and maintainability. + +### Architecture Overview + +``` +settings.toml # Schema definitions (single source of truth) + ↓ +settings_schema.py # Parser & validation + ↓ +settings_bootstrap.py # Generation & resolution + ↓ +.env # Runtime configuration +.env.make # Build variables for Makefile + ↓ +settings.py # Pydantic Settings (runtime validation) +``` + +### Key Files + +- **`settings.toml`** — Declarative schema for all settings +- **`alfred/settings_schema.py`** — Schema parser and validation logic +- **`alfred/settings_bootstrap.py`** — Bootstrap logic (generates `.env` and `.env.make`) +- **`alfred/settings.py`** — Pydantic Settings class (runtime) +- **`.env`** — Generated configuration file (gitignored) +- **`.env.make`** — Build variables for Makefile (gitignored) + +### Setting Sources + +Settings can come from different sources: + +| Source | Description | Example | +|--------|-------------|---------| +| `toml` | From `pyproject.toml` | Version numbers, build config | +| `env` | From `.env` file | User-provided values, API keys | +| `generated` | Auto-generated secrets | JWT secrets, passwords | +| `computed` | Calculated from other settings | Database URIs | + +### How to Add a New Setting + +#### 1. Define in `settings.toml` + +```toml +[tool.alfred.settings_schema.MY_NEW_SETTING] +type = "string" # string, integer, float, boolean, secret, computed +source = "env" # env, toml, generated, computed +default = "default_value" # Optional: default value +description = "Description here" # Required: clear description +category = "app" # app, api, database, security, build +required = true # Optional: default is true +validator = "range:1:100" # Optional: validation rule +export_to_env_make = false # Optional: export to .env.make for Makefile +``` + +#### 2. Regenerate Configuration + +```bash +make bootstrap +``` + +This will: +- Read the schema from `settings.toml` +- Generate/update `.env` with the new setting +- Generate/update `.env.make` if `export_to_env_make = true` +- Preserve existing secrets + +#### 3. Validate + +```bash +make validate +``` + +This ensures all settings are valid according to the schema. + +#### 4. Use in Code + +The setting is automatically available in `settings.py`: + +```python +from alfred.settings import settings + +print(settings.my_new_setting) +``` + +### Setting Types + +#### String Setting + +```toml +[tool.alfred.settings_schema.API_URL] +type = "string" +source = "env" +default = "https://api.example.com" +description = "API base URL" +category = "api" +``` + +#### Integer Setting with Validation + +```toml +[tool.alfred.settings_schema.MAX_RETRIES] +type = "integer" +source = "env" +default = 3 +description = "Maximum retry attempts" +category = "app" +validator = "range:1:10" +``` + +#### Secret (Auto-generated) + +```toml +[tool.alfred.settings_schema.API_SECRET] +type = "secret" +source = "generated" +secret_rule = "32:b64" # 32 bytes, base64 encoded +description = "API secret key" +category = "security" +``` + +Secret rules: +- `"32:b64"` — 32 bytes, URL-safe base64 +- `"16:hex"` — 16 bytes, hexadecimal + +#### Computed Setting + +```toml +[tool.alfred.settings_schema.DATABASE_URL] +type = "computed" +source = "computed" +compute_from = ["DB_HOST", "DB_PORT", "DB_NAME"] +compute_template = "postgresql://{DB_HOST}:{DB_PORT}/{DB_NAME}" +description = "Database connection URL" +category = "database" +``` + +#### From TOML (Build Variables) + +```toml +[tool.alfred.settings_schema.APP_VERSION] +type = "string" +source = "toml" +toml_path = "tool.poetry.version" +description = "Application version" +category = "build" +export_to_env_make = true # Available in Makefile +``` + +### Validators + +Available validators: + +- **`range:min:max`** — Numeric range validation + ```toml + validator = "range:0.0:2.0" # For floats + validator = "range:1:100" # For integers + ``` + +### Categories + +Organize settings by category: + +- **`app`** — Application settings +- **`api`** — API keys and external services +- **`database`** — Database configuration +- **`security`** — Secrets and security keys +- **`build`** — Build-time configuration + +### Best Practices + +1. **Always add a description** — Make it clear what the setting does +2. **Use appropriate types** — Don't use strings for numbers +3. **Add validation** — Use validators for numeric ranges +4. **Categorize properly** — Helps with organization +5. **Use computed settings** — For values derived from others (e.g., URIs) +6. **Mark secrets as generated** — Let the system handle secret generation +7. **Export build vars** — Set `export_to_env_make = true` for Makefile variables + +### Workflow Example + +```bash +# 1. Edit settings.toml +vim settings.toml + +# 2. Regenerate configuration +make bootstrap + +# 3. Validate +make validate + +# 4. Test +python -c "from alfred.settings import settings; print(settings.my_new_setting)" + +# 5. Commit (settings.toml only, not .env) +git add settings.toml +git commit -m "Add MY_NEW_SETTING" +``` + +### Commands + +```bash +make bootstrap # Generate .env and .env.make from schema +make validate # Validate all settings against schema +make help # Show all available commands +``` + +### Troubleshooting + +**Setting not found in schema:** +``` +KeyError: Missing [tool.alfred.settings_schema] section +``` +→ Check that `settings.toml` exists and has the correct structure + +**Validation error:** +``` +ValueError: MY_SETTING must be between 1 and 100, got 150 +``` +→ Check the validator in `settings.toml` and adjust the value in `.env` + +**Secret not preserved:** +→ Secrets are automatically preserved during `make bootstrap`. If lost, they were never in `.env` (check `.env` exists before running bootstrap) + +### Testing + +When adding a new setting, consider adding tests: + +```python +# tests/test_settings_schema.py +def test_my_new_setting(self, create_schema_file): + """Test MY_NEW_SETTING definition.""" + schema_toml = """ +[tool.alfred.settings_schema.MY_NEW_SETTING] +type = "string" +source = "env" +default = "test" +""" + base_dir = create_schema_file(schema_toml) + schema = load_schema(base_dir) + + definition = schema.get("MY_NEW_SETTING") + assert definition.default == "test" +``` + +### Migration from Old System + +If you're migrating from the old system: + +1. Settings are now in `settings.toml` instead of scattered across files +2. No more `.env.example` — schema is the source of truth +3. Secrets are auto-generated and preserved +4. Validation happens at bootstrap time, not just runtime + +--- + +## Questions? + +Open an issue or check the existing settings in `settings.toml` for examples. diff --git a/Makefile b/Makefile index d28a8a2..31af7bc 100644 --- a/Makefile +++ b/Makefile @@ -16,17 +16,26 @@ DOCKER_BUILD := docker build --no-cache \ --build-arg RUNNER=$(RUNNER) # --- Phony --- -.PHONY: .env bootstrap up down restart logs ps shell build build-test install \ +.PHONY: .env bootstrap validate up down restart logs ps shell build build-test install \ update install-hooks test coverage lint format clean major minor patch help # --- Setup --- -.env .env.make: +.env: @echo "Initializing environment..." @python scripts/bootstrap.py \ && echo "✓ Environment ready" \ || (echo "✗ Environment setup failed" && exit 1) -bootstrap: .env .env.make +# .env.make is automatically generated by bootstrap.py when .env is created +.env.make: .env + +bootstrap: .env + +validate: + @echo "Validating settings..." + @python scripts/validate_settings.py \ + && echo "✓ Settings valid" \ + || (echo "✗ Settings validation failed" && exit 1) # --- Docker --- up: .env @@ -161,8 +170,12 @@ help: @echo "" @echo "Usage: make [target] [p=profile1,profile2]" @echo "" + @echo "Setup:" + @echo " bootstrap Generate .env and .env.make from schema" + @echo " validate Validate settings against schema" + @echo "" @echo "Docker:" - @echo " up Start containers (default profile: core)" + @echo " up Start containers (default profile: full)" @echo " Example: make up p=rag,meili" @echo " down Stop all containers" @echo " restart Restart containers (supports p=...)" @@ -172,7 +185,6 @@ help: @echo " build Build the production Docker image" @echo "" @echo "Dev & Quality:" - @echo " setup Bootstrap .env and security keys" @echo " install Install dependencies via $(RUNNER)" @echo " test Run pytest suite" @echo " coverage Run tests and generate HTML report" diff --git a/alfred/settings.py b/alfred/settings.py index 50ef520..e1ec3ad 100644 --- a/alfred/settings.py +++ b/alfred/settings.py @@ -1,14 +1,19 @@ -import secrets -from pathlib import Path -from typing import NamedTuple +""" +Application settings using Pydantic Settings. + +Settings are loaded from .env file and validated against the schema +defined in pyproject.toml [tool.alfred.settings_schema]. +""" + +from pathlib import Path -import tomllib from pydantic import Field, computed_field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from .settings_schema import SCHEMA + BASE_DIR = Path(__file__).resolve().parent.parent ENV_FILE_PATH = BASE_DIR / ".env" -toml_path = BASE_DIR / "pyproject.toml" class ConfigurationError(Exception): @@ -17,154 +22,224 @@ class ConfigurationError(Exception): pass -class ProjectVersions(NamedTuple): - """ - Immutable structure for project versions. - Forces explicit naming and prevents accidental swaps. - """ - - librechat: str - rag: str - alfred: str +def _get_default_from_schema(setting_name: str): + """Get default value from schema for a setting.""" + definition = SCHEMA.get(setting_name.upper()) + return definition.default if definition else None -def get_versions_from_toml() -> ProjectVersions: - """ - Reads versioning information from pyproject.toml. - Returns the default value if the file or key is missing. - """ +def _get_secret_factory(rule: str): + """Create a factory function for generating secrets.""" - if not toml_path.exists(): - raise FileNotFoundError(f"pyproject.toml not found: {toml_path}") + def factory(): + from .settings_bootstrap import generate_secret # noqa: PLC0415 - with open(toml_path, "rb") as f: - data = tomllib.load(f) - try: - return ProjectVersions( - librechat=data["tool"]["alfred"]["settings"]["librechat_version"], - rag=data["tool"]["alfred"]["settings"]["rag_version"], - alfred=data["tool"]["poetry"]["version"], - ) - except KeyError as e: - raise KeyError(f"Error: Missing key {e} in pyproject.toml") from e + return generate_secret(rule) - -# Load versions once -VERSIONS: ProjectVersions = get_versions_from_toml() + return factory class Settings(BaseSettings): + """ + Application settings. + + Settings are loaded from .env and validated using the schema + defined in pyproject.toml. + """ + model_config = SettingsConfigDict( env_file=ENV_FILE_PATH, env_file_encoding="utf-8", extra="ignore", case_sensitive=False, ) - # --- GENERAL SETTINGS --- - host: str = "0.0.0.0" - port: int = 3080 + + # --- BUILD (from TOML) --- + alfred_version: str = Field( + default=_get_default_from_schema("ALFRED_VERSION"), description="Alfred version" + ) + python_version: str = Field( + default=_get_default_from_schema("PYTHON_VERSION"), description="Python version" + ) + python_version_short: str = Field( + default=_get_default_from_schema("PYTHON_VERSION_SHORT"), + description="Python version (short)", + ) + runner: str = Field( + default=_get_default_from_schema("RUNNER"), description="Dependency manager" + ) + image_name: str = Field( + default=_get_default_from_schema("IMAGE_NAME"), description="Docker image name" + ) + service_name: str = Field( + default=_get_default_from_schema("SERVICE_NAME"), + description="Docker service name", + ) + librechat_version: str = Field( + default=_get_default_from_schema("LIBRECHAT_VERSION"), + description="LibreChat version", + ) + rag_version: str = Field( + default=_get_default_from_schema("RAG_VERSION"), description="RAG version" + ) + + # --- APP SETTINGS --- + host: str = Field( + default=_get_default_from_schema("HOST"), description="Server host" + ) + port: int = Field( + default=_get_default_from_schema("PORT"), description="Server port" + ) + max_history_messages: int = Field( + default=_get_default_from_schema("MAX_HISTORY_MESSAGES"), + description="Maximum conversation history", + ) + max_tool_iterations: int = Field( + default=_get_default_from_schema("MAX_TOOL_ITERATIONS"), + description="Maximum tool iterations", + ) + request_timeout: int = Field( + default=_get_default_from_schema("REQUEST_TIMEOUT"), + description="Request timeout in seconds", + ) + llm_temperature: float = Field( + default=_get_default_from_schema("LLM_TEMPERATURE"), + description="LLM temperature", + ) + data_storage_dir: str = Field( + default=_get_default_from_schema("DATA_STORAGE_DIR"), + description="Data storage directory", + alias="DATA_STORAGE_DIR", + ) + + # Legacy aliases debug_logging: bool = False debug_console: bool = False - data_storage: str = "data" - librechat_version: str = Field(VERSIONS.librechat, description="Librechat version") - rag_version: str = Field(VERSIONS.rag, description="RAG engine version") - alfred_version: str = Field(VERSIONS.alfred, description="Alfred version") - - # --- CONTEXT SETTINGS --- - max_history_messages: int = 10 - max_tool_iterations: int = 10 - request_timeout: int = 30 - - # TODO: Finish - deepseek_base_url: str = "https://api.deepseek.com" - deepseek_model: str = "deepseek-chat" + data_storage: str = Field(default="data", exclude=True) # Deprecated # --- API KEYS --- - anthropic_api_key: str | None = Field(None, description="Claude API key") - deepseek_api_key: str | None = Field(None, description="Deepseek API key") - google_api_key: str | None = Field(None, description="Gemini API key") + tmdb_api_key: str | None = Field(None, description="TMDB API key") + deepseek_api_key: str | None = Field(None, description="DeepSeek API key") + openai_api_key: str | None = Field(None, description="OpenAI API key") + anthropic_api_key: str | None = Field(None, description="Anthropic API key") + google_api_key: str | None = Field(None, description="Google API key") kimi_api_key: str | None = Field(None, description="Kimi API key") - openai_api_key: str | None = Field(None, description="ChatGPT API key") - # --- SECURITY KEYS --- - # Generated automatically if not in .env to ensure "Secure by Default" - jwt_secret: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) - jwt_refresh_secret: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) - - # We keep these for encryption of keys in MongoDB (AES-256 Hex format) - creds_key: str = Field(default_factory=lambda: secrets.token_hex(32)) - creds_iv: str = Field(default_factory=lambda: secrets.token_hex(16)) - - # --- SERVICES --- - qbittorrent_url: str = "http://qbittorrent:16140" - qbittorrent_username: str = "admin" - qbittorrent_password: str = Field(default_factory=lambda: secrets.token_urlsafe(16)) - - mongo_host: str = "mongodb" - mongo_user: str = "alfred" - mongo_password: str = Field( - default_factory=lambda: secrets.token_urlsafe(24), repr=False, exclude=True + # --- SECURITY SECRETS --- + jwt_secret: str = Field( + default_factory=_get_secret_factory("32:b64"), description="JWT signing secret" + ) + jwt_refresh_secret: str = Field( + default_factory=_get_secret_factory("32:b64"), description="JWT refresh secret" + ) + creds_key: str = Field( + default_factory=_get_secret_factory("32:hex"), + description="Credentials encryption key", + ) + creds_iv: str = Field( + default_factory=_get_secret_factory("16:hex"), + description="Credentials encryption IV", + ) + meili_master_key: str = Field( + default_factory=_get_secret_factory("32:b64"), + description="Meilisearch master key", + repr=False, + ) + + # --- DATABASE --- + mongo_host: str = Field( + default=_get_default_from_schema("MONGO_HOST"), description="MongoDB host" + ) + mongo_port: int = Field( + default=_get_default_from_schema("MONGO_PORT"), description="MongoDB port" + ) + mongo_user: str = Field( + default=_get_default_from_schema("MONGO_USER"), description="MongoDB user" + ) + mongo_password: str = Field( + default_factory=_get_secret_factory("16:hex"), + description="MongoDB password", + repr=False, + exclude=True, + ) + mongo_db_name: str = Field( + default=_get_default_from_schema("MONGO_DB_NAME"), + description="MongoDB database name", ) - mongo_port: int = 27017 - mongo_db_name: str = "alfred" @computed_field(repr=False) @property def mongo_uri(self) -> str: + """MongoDB connection URI.""" return ( f"mongodb://{self.mongo_user}:{self.mongo_password}" f"@{self.mongo_host}:{self.mongo_port}/{self.mongo_db_name}" f"?authSource=admin" ) - postgres_host: str = "vectordb" - postgres_user: str = "alfred" - postgres_password: str = Field( - default_factory=lambda: secrets.token_urlsafe(24), repr=False, exclude=True + postgres_host: str = Field( + default=_get_default_from_schema("POSTGRES_HOST"), description="PostgreSQL host" + ) + postgres_port: int = Field( + default=_get_default_from_schema("POSTGRES_PORT"), description="PostgreSQL port" + ) + postgres_user: str = Field( + default=_get_default_from_schema("POSTGRES_USER"), description="PostgreSQL user" + ) + postgres_password: str = Field( + default_factory=_get_secret_factory("16:hex"), + description="PostgreSQL password", + repr=False, + exclude=True, + ) + postgres_db_name: str = Field( + default=_get_default_from_schema("POSTGRES_DB_NAME"), + description="PostgreSQL database name", ) - postgres_port: int = 5432 - postgres_db_name: str = "alfred" @computed_field(repr=False) @property def postgres_uri(self) -> str: + """PostgreSQL connection URI.""" return ( f"postgresql://{self.postgres_user}:{self.postgres_password}" f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db_name}" ) - tmdb_api_key: str | None = Field(None, description="The Movie Database API key") + # --- EXTERNAL SERVICES --- tmdb_base_url: str = "https://api.themoviedb.org/3" - # --- LLM PICKER & CONFIG --- - # Providers: 'local', 'deepseek', ... + qbittorrent_url: str = "http://qbittorrent:16140" + qbittorrent_username: str = "admin" + qbittorrent_password: str = Field( + default_factory=_get_secret_factory("16:hex"), + description="qBittorrent password", + ) + + # --- LLM CONFIG --- default_llm_provider: str = "local" ollama_base_url: str = "http://ollama:11434" - # Models: ... ollama_model: str = "llama3.3:latest" - llm_temperature: float = 0.2 + + deepseek_base_url: str = "https://api.deepseek.com" + deepseek_model: str = "deepseek-chat" # --- RAG ENGINE --- - rag_enabled: bool = True # TODO: Handle False + rag_enabled: bool = True rag_api_url: str = "http://rag_api:8000" embeddings_provider: str = "ollama" - # Models: ... embeddings_model: str = "nomic-embed-text" # --- MEILISEARCH --- - meili_enabled: bool = Field(True, description="Enable meili") + meili_enabled: bool = True meili_no_analytics: bool = True meili_host: str = "http://meilisearch:7700" - meili_master_key: str = Field( - default_factory=lambda: secrets.token_urlsafe(32), - description="Master key for Meilisearch", - repr=False, - ) - # --- VALIDATORS --- + # --- VALIDATORS (from schema) --- @field_validator("llm_temperature") @classmethod def validate_temperature(cls, v: float) -> float: + """Validate LLM temperature is in valid range.""" if not 0.0 <= v <= 2.0: raise ConfigurationError( f"Temperature must be between 0.0 and 2.0, got {v}" @@ -174,15 +249,17 @@ class Settings(BaseSettings): @field_validator("max_tool_iterations") @classmethod def validate_max_iterations(cls, v: int) -> int: + """Validate max tool iterations is in valid range.""" if not 1 <= v <= 20: raise ConfigurationError( - f"max_tool_iterations must be between 1 and 50, got {v}" + f"max_tool_iterations must be between 1 and 20, got {v}" ) return v @field_validator("request_timeout") @classmethod def validate_timeout(cls, v: int) -> int: + """Validate request timeout is in valid range.""" if not 1 <= v <= 300: raise ConfigurationError( f"request_timeout must be between 1 and 300 seconds, got {v}" @@ -192,18 +269,24 @@ class Settings(BaseSettings): @field_validator("deepseek_base_url", "tmdb_base_url") @classmethod def validate_url(cls, v: str, info) -> str: + """Validate URLs start with http:// or https://.""" if not v.startswith(("http://", "https://")): - raise ConfigurationError(f"Invalid {info.field_name}") + raise ConfigurationError(f"Invalid {info.field_name}: must be a valid URL") return v - def is_tmdb_configured(self): + # --- HELPER METHODS --- + def is_tmdb_configured(self) -> bool: + """Check if TMDB API key is configured.""" return bool(self.tmdb_api_key) - def is_deepseek_configured(self): + def is_deepseek_configured(self) -> bool: + """Check if DeepSeek API key is configured.""" return bool(self.deepseek_api_key) - def dump_safe(self): + def dump_safe(self) -> dict: + """Dump settings excluding sensitive fields.""" return self.model_dump(exclude_none=False) +# Global settings instance settings = Settings() diff --git a/alfred/settings_bootstrap.py b/alfred/settings_bootstrap.py new file mode 100644 index 0000000..6dfbe16 --- /dev/null +++ b/alfred/settings_bootstrap.py @@ -0,0 +1,381 @@ +""" +Settings bootstrap - Generate and validate configuration files. + +This module uses the settings schema to generate .env and .env.make files +with proper validation and secret generation. +""" + +import re +import secrets +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import tomllib + +from .settings_schema import ( + SCHEMA, + SettingDefinition, + SettingSource, + SettingsSchema, + SettingType, + validate_value, +) + + +@dataclass +class ConfigSource: + """Configuration source paths.""" + + base_dir: Path + toml_path: Path + env_path: Path + env_example_path: Path + + @classmethod + def from_base_dir(cls, base_dir: Path | None = None) -> "ConfigSource": + """Create ConfigSource from base directory.""" + if base_dir is None: + # Don't import settings.py to avoid Pydantic dependency in pre-commit + base_dir = Path(__file__).resolve().parent.parent + + return cls( + base_dir=base_dir, + toml_path=base_dir / "pyproject.toml", + env_path=base_dir / ".env", + env_example_path=base_dir / ".env.example", + ) + + +def extract_python_version(version_string: str) -> tuple[str, str]: + """ + Extract Python version from poetry dependency string. + + Examples: + "==3.14.2" -> ("3.14.2", "3.14") + "^3.14.2" -> ("3.14.2", "3.14") + """ + clean_version = re.sub(r"^[=^~><]+", "", version_string.strip()) + parts = clean_version.split(".") + + if len(parts) >= 2: + full_version = clean_version + short_version = f"{parts[0]}.{parts[1]}" + return full_version, short_version + else: + raise ValueError(f"Invalid Python version format: {version_string}") + + +def generate_secret(rule: str) -> str: + """ + Generate a cryptographically secure secret. + + Args: + rule: Format "size:tech" (e.g., "32:b64", "16:hex") + """ + parts = rule.split(":") + if len(parts) != 2: + raise ValueError(f"Invalid security rule format: {rule}") + + size_str, tech = parts + size = int(size_str) + + match tech: + case "b64": + return secrets.token_urlsafe(size) + case "hex": + return secrets.token_hex(size) + case _: + raise ValueError(f"Invalid security format: {tech}") + + +def get_nested_value(data: dict, path: str) -> Any: + """ + Get nested value from dict using dot notation. + + Example: + get_nested_value({"a": {"b": {"c": 1}}}, "a.b.c") -> 1 + """ + keys = path.split(".") + value = data + for key in keys: + if not isinstance(value, dict): + raise KeyError(f"Cannot access {key} in non-dict value") + value = value[key] + return value + + +class SettingsBootstrap: + """ + Bootstrap settings from schema. + + This class orchestrates the entire bootstrap process: + 1. Load schema + 2. Load sources (TOML, existing .env) + 3. Resolve all settings + 4. Validate + 5. Write .env and .env.make + """ + + def __init__(self, source: ConfigSource, schema: SettingsSchema | None = None): + """ + Initialize bootstrap. + + Args: + source: Configuration source paths + schema: Settings schema (uses global SCHEMA if None) + """ + self.source = source + self.schema = schema or SCHEMA + self.toml_data: dict | None = None + self.existing_env: dict[str, str] = {} + self.resolved_settings: dict[str, Any] = {} + + def bootstrap(self) -> None: + """ + Run complete bootstrap process. + + This is the main entry point that orchestrates everything. + """ + print("���� Starting settings bootstrap...") + + # 1. Load sources + self._load_sources() + + # 2. Resolve all settings + self._resolve_settings() + + # 3. Validate + self._validate_settings() + + # 4. Write files + self._write_env() + self._write_env_make() + + print("✅ Bootstrap complete!") + print("\n⚠️ Reminder: Add your API keys to .env if needed") + + def _load_sources(self) -> None: + """Load TOML and existing .env.""" + # Load TOML + if not self.source.toml_path.exists(): + raise FileNotFoundError( + f"pyproject.toml not found: {self.source.toml_path}" + ) + + with open(self.source.toml_path, "rb") as f: + self.toml_data = tomllib.load(f) + + # Load existing .env + if self.source.env_path.exists(): + print("🔄 Reading existing .env...") + with open(self.source.env_path) as f: + for line in f: + if "=" in line and not line.strip().startswith("#"): + key, value = line.split("=", 1) + self.existing_env[key.strip()] = value.strip() + print(f" Found {len(self.existing_env)} existing keys") + else: + print("🔧 Creating new .env file...") + + def _resolve_settings(self) -> None: + """Resolve all settings from their sources.""" + print("📋 Resolving settings...") + + # First pass: resolve non-computed settings + for definition in self.schema: + if definition.source != SettingSource.COMPUTED: + self.resolved_settings[definition.name] = self._resolve_setting( + definition + ) + + # Second pass: resolve computed settings (they may depend on others) + for definition in self.schema: + if definition.source == SettingSource.COMPUTED: + self.resolved_settings[definition.name] = self._resolve_setting( + definition + ) + + def _resolve_setting(self, definition: SettingDefinition) -> Any: + """Resolve a single setting value.""" + match definition.source: + case SettingSource.TOML: + return self._resolve_from_toml(definition) + case SettingSource.ENV: + return self._resolve_from_env(definition) + case SettingSource.GENERATED: + return self._resolve_generated(definition) + case SettingSource.COMPUTED: + return self._resolve_computed(definition) + + def _resolve_from_toml(self, definition: SettingDefinition) -> Any: + """Resolve setting from TOML.""" + if not definition.toml_path: + raise ValueError( + f"{definition.name}: toml_path is required for TOML source" + ) + + value = get_nested_value(self.toml_data, definition.toml_path) + + # Apply transform if specified + if definition.transform: + match definition.transform: + case "extract_python_version_full": + value, _ = extract_python_version(value) + case "extract_python_version_short": + _, value = extract_python_version(value) + case _: + raise ValueError(f"Unknown transform: {definition.transform}") + + return value + + def _resolve_from_env(self, definition: SettingDefinition) -> Any: + """Resolve setting from .env.""" + # Check existing .env first + if definition.name in self.existing_env: + value = self.existing_env[definition.name] + elif definition.default is not None: + value = definition.default + elif not definition.required: + return None + else: + raise ValueError(f"{definition.name} is required but not found in .env") + + # Convert type + match definition.type: + case SettingType.INTEGER: + return int(value) + case SettingType.FLOAT: + return float(value) + case SettingType.BOOLEAN: + return value.lower() in ("true", "1", "yes") + case _: + return value + + def _resolve_generated(self, definition: SettingDefinition) -> str: + """Resolve generated secret.""" + # Preserve existing secret + if definition.name in self.existing_env: + print(f" ↻ Kept existing {definition.name}") + return self.existing_env[definition.name] + + # Generate new secret + if not definition.secret_rule: + raise ValueError( + f"{definition.name}: secret_rule is required for GENERATED source" + ) + + secret = generate_secret(definition.secret_rule) + print(f" + Generated {definition.name} ({definition.secret_rule})") + return secret + + def _resolve_computed(self, definition: SettingDefinition) -> str: + """Resolve computed setting.""" + if not definition.compute_template: + raise ValueError( + f"{definition.name}: compute_template is required for COMPUTED source" + ) + + # Build context from dependencies + context = {} + if definition.compute_from: + for dep in definition.compute_from: + if dep not in self.resolved_settings: + raise ValueError( + f"{definition.name}: dependency {dep} not resolved yet" + ) + context[dep] = self.resolved_settings[dep] + + # Format template + return definition.compute_template.format(**context) + + def _validate_settings(self) -> None: + """Validate all resolved settings.""" + print("✓ Validating settings...") + + errors = [] + for definition in self.schema: + value = self.resolved_settings.get(definition.name) + try: + validate_value(definition, value) + except ValueError as e: + errors.append(str(e)) + + if errors: + raise ValueError( + "Validation errors:\n" + "\n".join(f" - {e}" for e in errors) + ) + + def _write_env(self) -> None: + """Write .env file.""" + print("📝 Writing .env...") + + lines = [] + lines.append("# Auto-generated by bootstrap - DO NOT EDIT MANUALLY\n") + lines.append("# Edit pyproject.toml [tool.alfred.settings_schema] instead\n") + lines.append("\n") + + # Group by category + categories = {} + for definition in self.schema: + if definition.category not in categories: + categories[definition.category] = [] + categories[definition.category].append(definition) + + # Write by category + for category, definitions in sorted(categories.items()): + lines.append(f"# --- {category.upper()} ---\n") + for definition in definitions: + # Skip computed settings (they're not in .env) + if definition.source == SettingSource.COMPUTED: + continue + + value = self.resolved_settings.get(definition.name, "") + if definition.description: + lines.append(f"# {definition.description}\n") + lines.append(f"{definition.name}={value}\n") + lines.append("\n") + + # Write computed settings at the end + computed_defs = [d for d in self.schema if d.source == SettingSource.COMPUTED] + if computed_defs: + lines.append("# --- COMPUTED (auto-generated) ---\n") + for definition in computed_defs: + value = self.resolved_settings.get(definition.name, "") + if definition.description: + lines.append(f"# {definition.description}\n") + lines.append(f"{definition.name}={value}\n") + + with open(self.source.env_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + print(f"✅ {self.source.env_path.name} written") + + def _write_env_make(self) -> None: + """Write .env.make for Makefile.""" + print("📝 Writing .env.make...") + + lines = ["# Auto-generated from pyproject.toml\n"] + + for definition in self.schema.get_for_env_make(): + value = self.resolved_settings.get(definition.name, "") + lines.append(f"export {definition.name}={value}\n") + + env_make_path = self.source.base_dir / ".env.make" + with open(env_make_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + print("✅ .env.make written") + + +def bootstrap_env(source: ConfigSource) -> None: # noqa: PLC0415 + """ + Bootstrap environment configuration. + + This is the main entry point for bootstrapping. + + Args: + source: Configuration source paths + """ + bootstrapper = SettingsBootstrap(source) + bootstrapper.bootstrap() diff --git a/alfred/settings_schema.py b/alfred/settings_schema.py new file mode 100644 index 0000000..7a23117 --- /dev/null +++ b/alfred/settings_schema.py @@ -0,0 +1,291 @@ +""" +Settings schema parser and definitions. + +This module loads the settings schema from pyproject.toml and provides +type-safe access to setting definitions. +""" + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any + +import tomllib + +BASE_DIR = Path(__file__).resolve().parent.parent + + +class SettingType(Enum): + """Type of setting value.""" + + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + BOOLEAN = "boolean" + SECRET = "secret" + COMPUTED = "computed" + + +class SettingSource(Enum): + """Source of setting value.""" + + ENV = "env" # From .env file + TOML = "toml" # From pyproject.toml + GENERATED = "generated" # Auto-generated (secrets) + COMPUTED = "computed" # Computed from other settings + + +@dataclass +class SettingDefinition: + """ + Complete definition of a setting. + + This is the parsed representation of a setting from pyproject.toml. + """ + + name: str + type: SettingType + source: SettingSource + description: str = "" + category: str = "general" + required: bool = True + default: str | int | float | bool | None = None + + # For TOML source + toml_path: str | None = None + transform: str | None = None # Transform function name + + # For SECRET source + secret_rule: str | None = None # e.g., "32:b64", "16:hex" + + # For COMPUTED source + compute_from: list[str] | None = None # Dependencies + compute_template: str | None = None # Template string + + # For validation + validator: str | None = None # e.g., "range:0.0:2.0" + + # For export + export_to_env_make: bool = False + + +class SettingsSchema: + """ + Settings schema loaded from pyproject.toml. + + Provides access to all setting definitions and utilities for + working with the schema. + """ + + def __init__(self, schema_dict: dict[str, dict[str, Any]]): + """ + Initialize schema from parsed TOML. + + Args: + schema_dict: Dictionary from [tool.alfred.settings_schema] + """ + self.definitions: dict[str, SettingDefinition] = {} + self._parse_schema(schema_dict) + + def _parse_schema(self, schema_dict: dict[str, dict[str, Any]]) -> None: + """Parse schema dictionary into SettingDefinition objects.""" + for name, config in schema_dict.items(): + # Skip non-setting entries + if not isinstance(config, dict): + continue + + # Parse type + type_str = config.get("type", "string") + setting_type = SettingType(type_str) + + # Parse source + source_str = config.get("source", "env") + source = SettingSource(source_str) + + # Parse default value based on type + default = config.get("default") + if default is not None: + match setting_type: + case SettingType.INTEGER: + default = int(default) + case SettingType.FLOAT: + default = float(default) + case SettingType.BOOLEAN: + default = bool(default) + case _: + default = str(default) if default else None + + # Create definition + definition = SettingDefinition( + name=name, + type=setting_type, + source=source, + description=config.get("description", ""), + category=config.get("category", "general"), + required=config.get("required", True), + default=default, + toml_path=config.get("toml_path"), + transform=config.get("transform"), + secret_rule=config.get("secret_rule"), + compute_from=config.get("compute_from"), + compute_template=config.get("compute_template"), + validator=config.get("validator"), + export_to_env_make=config.get("export_to_env_make", False), + ) + + self.definitions[name] = definition + + def get(self, name: str) -> SettingDefinition | None: + """Get setting definition by name.""" + return self.definitions.get(name) + + def get_by_category(self, category: str) -> list[SettingDefinition]: + """Get all settings in a category.""" + return [d for d in self.definitions.values() if d.category == category] + + def get_by_source(self, source: SettingSource) -> list[SettingDefinition]: + """Get all settings from a specific source.""" + return [d for d in self.definitions.values() if d.source == source] + + def get_required(self) -> list[SettingDefinition]: + """Get all required settings.""" + return [d for d in self.definitions.values() if d.required] + + def get_for_env_make(self) -> list[SettingDefinition]: + """Get all settings that should be exported to .env.make.""" + return [d for d in self.definitions.values() if d.export_to_env_make] + + def __iter__(self): + """Iterate over all setting definitions.""" + return iter(self.definitions.values()) + + def __len__(self): + """Number of settings in schema.""" + return len(self.definitions) + + +def load_schema(base_dir: Path | None = None) -> SettingsSchema: + """ + Load settings schema from settings.toml or pyproject.toml. + + Priority: + 1. settings.toml (if exists) + 2. pyproject.toml [tool.alfred.settings_schema] + + Args: + base_dir: Base directory containing config files + + Returns: + SettingsSchema instance + + Raises: + FileNotFoundError: If neither file exists + KeyError: If settings_schema section is missing + """ + if base_dir is None: + base_dir = BASE_DIR + + # Try settings.toml first (cleaner, dedicated file) + settings_toml_path = base_dir / "settings.toml" + if settings_toml_path.exists(): + with open(settings_toml_path, "rb") as f: + data = tomllib.load(f) + + try: + schema_dict = data["tool"]["alfred"]["settings_schema"] + return SettingsSchema(schema_dict) + except KeyError as e: + raise KeyError( + "Missing [tool.alfred.settings_schema] section in settings.toml" + ) from e + + # Fallback to pyproject.toml + toml_path = base_dir / "pyproject.toml" + if not toml_path.exists(): + raise FileNotFoundError( + f"Neither settings.toml nor pyproject.toml found in {base_dir}" + ) + + with open(toml_path, "rb") as f: + data = tomllib.load(f) + + try: + schema_dict = data["tool"]["alfred"]["settings_schema"] + except KeyError as e: + raise KeyError( + "Missing [tool.alfred.settings_schema] section in pyproject.toml" + ) from e + + return SettingsSchema(schema_dict) + + +def validate_value(definition: SettingDefinition, value: Any) -> bool: + """ + Validate a value against a setting definition. + + Args: + definition: Setting definition with validation rules + value: Value to validate + + Returns: + True if valid + + Raises: + ValueError: If validation fails + """ + if value is None: + if definition.required: + raise ValueError(f"{definition.name} is required but got None") + return True + + # Type validation + match definition.type: + case SettingType.INTEGER: + if not isinstance(value, int): + raise ValueError( + f"{definition.name} must be integer, got {type(value).__name__}" + ) + case SettingType.FLOAT: + if not isinstance(value, (int, float)): + raise ValueError( + f"{definition.name} must be float, got {type(value).__name__}" + ) + case SettingType.BOOLEAN: + if not isinstance(value, bool): + raise ValueError( + f"{definition.name} must be boolean, got {type(value).__name__}" + ) + case SettingType.STRING | SettingType.SECRET: + if not isinstance(value, str): + raise ValueError( + f"{definition.name} must be string, got {type(value).__name__}" + ) + + # Custom validator + if definition.validator: + _apply_validator(definition.name, definition.validator, value) + + return True + + +def _apply_validator(name: str, validator: str, value: Any) -> None: + """Apply custom validator to value.""" + if validator.startswith("range:"): + # Parse range validator: "range:min:max" + parts = validator.split(":") + if len(parts) != 3: + raise ValueError(f"Invalid range validator format: {validator}") + + min_val = float(parts[1]) + max_val = float(parts[2]) + + if not (min_val <= value <= max_val): + raise ValueError( + f"{name} must be between {min_val} and {max_val}, got {value}" + ) + else: + raise ValueError(f"Unknown validator: {validator}") + + +# Load schema once at module import +SCHEMA = load_schema() diff --git a/pyproject.toml b/pyproject.toml index ae376e8..0f21801 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,23 +6,6 @@ authors = ["Francwa "] readme = "README.md" package-mode = false -[tool.alfred.settings] -image_name = "alfred_media_organizer" -librechat_version = "v0.8.1" -rag_version = "v0.7.0" -runner = "poetry" -service_name = "alfred" - -[tool.alfred.security] -jwt_secret = "32:b64" -jwt_refresh_secret = "32:b64" -creds_key = "32:b64" -creds_iv = "16:b64" -meili_master_key = "32:b64" -mongo_password = "16:hex" -postgres_password = "16:hex" -qbittorrent_password = "16:hex" - [tool.poetry.dependencies] python = "==3.14.2" python-dotenv = "^1.0.0" diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 5b54fe8..3cb6b01 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -1,239 +1,43 @@ -import secrets +#!/usr/bin/env python3 +"""Bootstrap script - generates .env and .env.make from pyproject.toml schema.""" + +import sys from pathlib import Path -import tomllib -from config_loader import load_build_config, write_env_make +# Add parent directory to path to import from alfred package +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from alfred.settings_bootstrap import ConfigSource, bootstrap_env -def generate_secret(rule: str) -> str: +def main(): """ - Generates a cryptographically secure secret based on a spec string. - Example specs: '32:b64', '16:hex'. + Initialize .env file from settings schema in pyproject.toml. + + - Reads schema from [tool.alfred.settings_schema] + - Generates secrets automatically + - Preserves existing secrets + - Validates all settings + - Writes .env and .env.make """ - chunks: list[str] = rule.split(":") - size: int = int(chunks[0]) - tech: str = chunks[1] + try: + base_dir = Path(__file__).resolve().parent.parent + config_source = ConfigSource.from_base_dir(base_dir) + bootstrap_env(config_source) + except FileNotFoundError as e: + print(f"❌ {e}") + return 1 + except ValueError as e: + print(f"❌ Validation error: {e}") + return 1 + except Exception as e: + print(f"❌ Bootstrap failed: {e}") + import traceback # noqa: PLC0415 - if tech == "b64": - return secrets.token_urlsafe(size) - elif tech == "hex": - return secrets.token_hex(size) - else: - raise ValueError(f"Invalid security format: {tech}") - - -def extract_python_version(version_string: str) -> tuple[str, str]: - """ - Extract Python version from poetry dependency string. - Examples: - "==3.14.2" -> ("3.14.2", "3.14") - "^3.14.2" -> ("3.14.2", "3.14") - "~3.14.2" -> ("3.14.2", "3.14") - "3.14.2" -> ("3.14.2", "3.14") - """ - import re # noqa: PLC0415 - - # Remove poetry version operators (==, ^, ~, >=, etc.) - clean_version = re.sub(r"^[=^~><]+", "", version_string.strip()) - - # Extract version parts - parts = clean_version.split(".") - - if len(parts) >= 2: - full_version = clean_version - short_version = f"{parts[0]}.{parts[1]}" - return full_version, short_version - else: - raise ValueError(f"Invalid Python version format: {version_string}") - - -# TODO: Refactor -def bootstrap(): # noqa: PLR0912, PLR0915 - """ - Initializes the .env file by merging .env.example with generated secrets - and build variables from pyproject.toml. - Also generates .env.make for Makefile. - - ALWAYS preserves existing secrets! - """ - base_dir = Path(__file__).resolve().parent.parent - env_path = base_dir / ".env" - - example_path = base_dir / ".env.example" - if not example_path.exists(): - print(f"❌ {example_path.name} not found.") - return - - toml_path = base_dir / "pyproject.toml" - if not toml_path.exists(): - print(f"❌ {toml_path.name} not found.") - return - - # ALWAYS load existing .env if it exists - existing_env = {} - if env_path.exists(): - print("🔄 Reading existing .env...") - with open(env_path) as f: - for line in f: - if "=" in line and not line.strip().startswith("#"): - key, value = line.split("=", 1) - existing_env[key.strip()] = value.strip() - print(f" Found {len(existing_env)} existing keys") - print("🔧 Updating .env file (keeping secrets)...") - else: - print("🔧 Initializing: Creating secure .env file...") - - # Load data from pyproject.toml - with open(toml_path, "rb") as f: - data = tomllib.load(f) - security_keys = data["tool"]["alfred"]["security"] - settings_keys = data["tool"]["alfred"]["settings"] - dependencies = data["tool"]["poetry"]["dependencies"] - alfred_version = data["tool"]["poetry"]["version"] - - # Normalize TOML keys to UPPER_CASE for .env format (done once) - security_keys_upper = {k.upper(): v for k, v in security_keys.items()} - settings_keys_upper = {k.upper(): v for k, v in settings_keys.items()} - - # Extract Python version - python_version_full, python_version_short = extract_python_version( - dependencies["python"] - ) - - # Read .env.example - with open(example_path) as f: - example_lines = f.readlines() - - new_lines = [] - # Process each line from .env.example - for raw_line in example_lines: - line = raw_line.strip() - - if line and not line.startswith("#") and "=" in line: - key, value = line.split("=", 1) - key = key.strip() - - # Check if key exists in current .env (update mode) - if key in existing_env: - # Keep existing value for secrets - if key in security_keys_upper: - new_lines.append(f"{key}={existing_env[key]}\n") - print(f" ↻ Kept existing {key}") - # Update build vars from pyproject.toml - elif key in settings_keys_upper: - new_value = settings_keys_upper[key] - if existing_env[key] != new_value: - new_lines.append(f"{key}={new_value}\n") - print(f" ↻ Updated {key}: {existing_env[key]} → {new_value}") - else: - new_lines.append(f"{key}={existing_env[key]}\n") - print(f" ↻ Kept {key}={existing_env[key]}") - # Update Python versions - elif key == "PYTHON_VERSION": - if existing_env[key] != python_version_full: - new_lines.append(f"{key}={python_version_full}\n") - print( - f" ↻ Updated Python: {existing_env[key]} → {python_version_full}" - ) - else: - new_lines.append(f"{key}={existing_env[key]}\n") - print(f" ↻ Kept Python: {existing_env[key]}") - elif key == "PYTHON_VERSION_SHORT": - if existing_env[key] != python_version_short: - new_lines.append(f"{key}={python_version_short}\n") - print( - f" ↻ Updated Python (short): {existing_env[key]} → {python_version_short}" - ) - else: - new_lines.append(f"{key}={existing_env[key]}\n") - print(f" ↻ Kept Python (short): {existing_env[key]}") - elif key == "ALFRED_VERSION": - if existing_env.get(key) != alfred_version: - new_lines.append(f"{key}={alfred_version}\n") - print( - f" ↻ Updated Alfred version: {existing_env.get(key, 'N/A')} → {alfred_version}" - ) - else: - new_lines.append(f"{key}={alfred_version}\n") - print(f" ↻ Kept Alfred version: {alfred_version}") - # Keep other existing values - else: - new_lines.append(f"{key}={existing_env[key]}\n") - # Key doesn't exist, generate/add it - elif key in security_keys_upper: - rule = security_keys_upper[key] - secret = generate_secret(rule) - new_lines.append(f"{key}={secret}\n") - print(f" + Secret generated for {key} ({rule})") - elif key in settings_keys_upper: - value = settings_keys_upper[key] - new_lines.append(f"{key}={value}\n") - print(f" + Setting added: {key}={value}") - elif key == "PYTHON_VERSION": - new_lines.append(f"{key}={python_version_full}\n") - print(f" + Python version: {python_version_full}") - elif key == "PYTHON_VERSION_SHORT": - new_lines.append(f"{key}={python_version_short}\n") - print(f" + Python version (short): {python_version_short}") - elif key == "ALFRED_VERSION": - new_lines.append(f"{key}={alfred_version}\n") - print(f" + Alfred version: {alfred_version}") - else: - new_lines.append(raw_line) - else: - # Keep comments and empty lines - new_lines.append(raw_line) - - # Compute database URIs from the generated values - final_env = {} - for line in new_lines: - if "=" in line and not line.strip().startswith("#"): - key, value = line.split("=", 1) - final_env[key.strip()] = value.strip() - - # Compute MONGO_URI - if "MONGO_USER" in final_env and "MONGO_PASSWORD" in final_env: - mongo_uri = ( - f"mongodb://{final_env.get('MONGO_USER', 'alfred')}:" - f"{final_env.get('MONGO_PASSWORD', '')}@" - f"{final_env.get('MONGO_HOST', 'mongodb')}:" - f"{final_env.get('MONGO_PORT', '27017')}/" - f"{final_env.get('MONGO_DB_NAME', 'alfred')}?authSource=admin" - ) - # Update MONGO_URI in new_lines - for i, line in enumerate(new_lines): - if line.startswith("MONGO_URI="): - new_lines[i] = f"MONGO_URI={mongo_uri}\n" - print(" ✓ Computed MONGO_URI") - break - - # Compute POSTGRES_URI - if "POSTGRES_USER" in final_env and "POSTGRES_PASSWORD" in final_env: - postgres_uri = ( - f"postgresql://{final_env.get('POSTGRES_USER', 'alfred')}:" - f"{final_env.get('POSTGRES_PASSWORD', '')}@" - f"{final_env.get('POSTGRES_HOST', 'vectordb')}:" - f"{final_env.get('POSTGRES_PORT', '5432')}/" - f"{final_env.get('POSTGRES_DB_NAME', 'alfred')}" - ) - # Update POSTGRES_URI in new_lines - for i, line in enumerate(new_lines): - if line.startswith("POSTGRES_URI="): - new_lines[i] = f"POSTGRES_URI={postgres_uri}\n" - print(" ✓ Computed POSTGRES_URI") - break - - # Write .env file - with open(env_path, "w", encoding="utf-8") as f: - f.writelines(new_lines) - print(f"\n✅ {env_path.name} generated successfully.") - - # Generate .env.make for Makefile using shared config loader - config = load_build_config(base_dir) - write_env_make(config, base_dir) - print("✅ .env.make generated for Makefile.") - print("\n⚠️ Reminder: Please manually add your API keys to the .env file.") + traceback.print_exc() + return 1 + return 0 if __name__ == "__main__": - bootstrap() + sys.exit(main()) diff --git a/scripts/generate_build_vars.py b/scripts/generate_build_vars.py deleted file mode 100644 index 8a9ebbf..0000000 --- a/scripts/generate_build_vars.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -"""Generate .env.make for CI/CD without generating secrets.""" - -import sys - -from config_loader import load_build_config, write_env_make - - -def main(): - """Generate .env.make from pyproject.toml.""" - try: - config = load_build_config() - write_env_make(config) - print("✅ .env.make generated successfully.") - return 0 - except Exception as e: - print(f"❌ Failed to generate .env.make: {e}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/validate_settings.py b/scripts/validate_settings.py new file mode 100644 index 0000000..0b86bc9 --- /dev/null +++ b/scripts/validate_settings.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Validate settings against schema.""" + +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from alfred.settings_bootstrap import ConfigSource, SettingsBootstrap +from alfred.settings_schema import SCHEMA + + +def main(): + """ + Validate settings from .env against schema. + + Returns: + 0 if valid, 1 if invalid + """ + print("🔍 Validating settings...") + + try: + base_dir = Path(__file__).resolve().parent.parent + source = ConfigSource.from_base_dir(base_dir) + + # Check if .env exists + if not source.env_path.exists(): + print(f"❌ {source.env_path} not found") + print(" Run 'make bootstrap' to generate it") + return 1 + + # Create bootstrap instance (loads and validates) + bootstrapper = SettingsBootstrap(source) + bootstrapper._load_sources() + bootstrapper._resolve_settings() + bootstrapper._validate_settings() + + print(f"✅ All {len(SCHEMA)} settings are valid!") + + # Show summary by category + print("\n📊 Settings summary:") + categories = {} + for definition in SCHEMA: + if definition.category not in categories: + categories[definition.category] = 0 + categories[definition.category] += 1 + + for category, count in sorted(categories.items()): + print(f" {category}: {count} settings") + + return 0 + + except ValueError as e: + print(f"❌ Validation failed: {e}") + return 1 + except Exception as e: + print(f"❌ Error: {e}") + import traceback # noqa: PLC0415 + + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/settings.toml b/settings.toml new file mode 100644 index 0000000..b92d2e4 --- /dev/null +++ b/settings.toml @@ -0,0 +1,304 @@ +[tool.alfred.settings_schema] + +# Build variables (from pyproject.toml) +[tool.alfred.settings_schema.ALFRED_VERSION] +type = "string" +source = "toml" +toml_path = "tool.poetry.version" +description = "Alfred version" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.PYTHON_VERSION] +type = "string" +source = "toml" +toml_path = "tool.poetry.dependencies.python" +transform = "extract_python_version_full" +description = "Python version (full)" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.PYTHON_VERSION_SHORT] +type = "string" +source = "toml" +toml_path = "tool.poetry.dependencies.python" +transform = "extract_python_version_short" +description = "Python version (major.minor)" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.RUNNER] +type = "string" +source = "env" +default = "poetry" +description = "Dependency manager (poetry/uv)" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.IMAGE_NAME] +type = "string" +source = "env" +default = "alfred_media_organizer" +description = "Docker image name" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.SERVICE_NAME] +type = "string" +source = "env" +default = "alfred" +description = "Docker service name" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.LIBRECHAT_VERSION] +type = "string" +source = "env" +default = "v0.8.1" +description = "LibreChat version" +category = "build" +export_to_env_make = true + +[tool.alfred.settings_schema.RAG_VERSION] +type = "string" +source = "env" +default = "v0.7.0" +description = "RAG API version" +category = "build" +export_to_env_make = true + +# Security secrets (generated) +[tool.alfred.settings_schema.JWT_SECRET] +type = "secret" +source = "generated" +secret_rule = "32:b64" +description = "JWT signing secret" +category = "security" +required = true + +[tool.alfred.settings_schema.JWT_REFRESH_SECRET] +type = "secret" +source = "generated" +secret_rule = "32:b64" +description = "JWT refresh token secret" +category = "security" +required = true + +[tool.alfred.settings_schema.CREDS_KEY] +type = "secret" +source = "generated" +secret_rule = "32:hex" +description = "Credentials encryption key (AES-256)" +category = "security" +required = true + +[tool.alfred.settings_schema.CREDS_IV] +type = "secret" +source = "generated" +secret_rule = "16:hex" +description = "Credentials encryption IV" +category = "security" +required = true + +[tool.alfred.settings_schema.MEILI_MASTER_KEY] +type = "secret" +source = "generated" +secret_rule = "32:b64" +description = "Meilisearch master key" +category = "security" +required = true + +[tool.alfred.settings_schema.MONGO_PASSWORD] +type = "secret" +source = "generated" +secret_rule = "16:hex" +description = "MongoDB password" +category = "security" +required = true + +[tool.alfred.settings_schema.POSTGRES_PASSWORD] +type = "secret" +source = "generated" +secret_rule = "16:hex" +description = "PostgreSQL password" +category = "security" +required = true + +[tool.alfred.settings_schema.QBITTORRENT_PASSWORD] +type = "secret" +source = "generated" +secret_rule = "16:hex" +description = "qBittorrent password" +category = "security" +required = true + +# Database configuration +[tool.alfred.settings_schema.MONGO_HOST] +type = "string" +source = "env" +default = "mongodb" +description = "MongoDB host" +category = "database" + +[tool.alfred.settings_schema.MONGO_PORT] +type = "integer" +source = "env" +default = 27017 +description = "MongoDB port" +category = "database" + +[tool.alfred.settings_schema.MONGO_USER] +type = "string" +source = "env" +default = "alfred" +description = "MongoDB user" +category = "database" + +[tool.alfred.settings_schema.MONGO_DB_NAME] +type = "string" +source = "env" +default = "alfred" +description = "MongoDB database name" +category = "database" + +[tool.alfred.settings_schema.MONGO_URI] +type = "computed" +source = "computed" +compute_from = ["MONGO_USER", "MONGO_PASSWORD", "MONGO_HOST", "MONGO_PORT", "MONGO_DB_NAME"] +compute_template = "mongodb://{MONGO_USER}:{MONGO_PASSWORD}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB_NAME}?authSource=admin" +description = "MongoDB connection URI" +category = "database" + +[tool.alfred.settings_schema.POSTGRES_HOST] +type = "string" +source = "env" +default = "vectordb" +description = "PostgreSQL host" +category = "database" + +[tool.alfred.settings_schema.POSTGRES_PORT] +type = "integer" +source = "env" +default = 5432 +description = "PostgreSQL port" +category = "database" + +[tool.alfred.settings_schema.POSTGRES_USER] +type = "string" +source = "env" +default = "alfred" +description = "PostgreSQL user" +category = "database" + +[tool.alfred.settings_schema.POSTGRES_DB_NAME] +type = "string" +source = "env" +default = "alfred" +description = "PostgreSQL database name" +category = "database" + +[tool.alfred.settings_schema.POSTGRES_URI] +type = "computed" +source = "computed" +compute_from = ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_HOST", "POSTGRES_PORT", "POSTGRES_DB_NAME"] +compute_template = "postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB_NAME}" +description = "PostgreSQL connection URI" +category = "database" + +# API Keys (optional, from .env) +[tool.alfred.settings_schema.TMDB_API_KEY] +type = "string" +source = "env" +required = false +description = "The Movie Database API key" +category = "api" + +[tool.alfred.settings_schema.DEEPSEEK_API_KEY] +type = "string" +source = "env" +required = false +description = "DeepSeek API key" +category = "api" + +[tool.alfred.settings_schema.OPENAI_API_KEY] +type = "string" +source = "env" +required = false +description = "OpenAI API key" +category = "api" + +[tool.alfred.settings_schema.ANTHROPIC_API_KEY] +type = "string" +source = "env" +required = false +description = "Anthropic (Claude) API key" +category = "api" + +[tool.alfred.settings_schema.GOOGLE_API_KEY] +type = "string" +source = "env" +required = false +description = "Google (Gemini) API key" +category = "api" + +[tool.alfred.settings_schema.KIMI_API_KEY] +type = "string" +source = "env" +required = false +description = "Kimi API key" +category = "api" + +# Application settings +[tool.alfred.settings_schema.HOST] +type = "string" +source = "env" +default = "0.0.0.0" +description = "Server host" +category = "app" + +[tool.alfred.settings_schema.PORT] +type = "integer" +source = "env" +default = 3080 +description = "Server port" +category = "app" + +[tool.alfred.settings_schema.MAX_HISTORY_MESSAGES] +type = "integer" +source = "env" +default = 10 +description = "Maximum conversation history messages" +category = "app" +validator = "range:1:100" + + +[tool.alfred.settings_schema.MAX_TOOL_ITERATIONS] +type = "integer" +source = "env" +default = 10 +description = "Maximum tool iterations per request" +category = "app" +validator = "range:1:20" + +[tool.alfred.settings_schema.REQUEST_TIMEOUT] +type = "integer" +source = "env" +default = 30 +description = "Request timeout in seconds" +category = "app" +validator = "range:1:300" + +[tool.alfred.settings_schema.LLM_TEMPERATURE] +type = "float" +source = "env" +default = 0.2 +description = "LLM temperature" +category = "app" +validator = "range:0.0:2.0" + +[tool.alfred.settings_schema.DATA_STORAGE_DIR] +type = "string" +source = "env" +default = "data" +description = "Data storage directory" +category = "app" diff --git a/tests/test_settings_bootstrap.py b/tests/test_settings_bootstrap.py new file mode 100644 index 0000000..1af39e1 --- /dev/null +++ b/tests/test_settings_bootstrap.py @@ -0,0 +1,410 @@ +"""Tests for settings bootstrap.""" + +import pytest + +from alfred.settings_bootstrap import ( + ConfigSource, + SettingsBootstrap, + extract_python_version, + generate_secret, + get_nested_value, +) + + +@pytest.fixture +def test_toml_content(): + """Test TOML content with schema.""" + return """ +[tool.poetry] +name = "test" +version = "1.0.0" + +[tool.poetry.dependencies] +python = "==3.14.2" + +[tool.alfred.settings] +runner = "poetry" +image_name = "test_image" + +[tool.alfred.settings_schema.TEST_FROM_TOML] +type = "string" +source = "toml" +toml_path = "tool.poetry.version" +description = "Version from TOML" +category = "test" + +[tool.alfred.settings_schema.TEST_FROM_ENV] +type = "string" +source = "env" +default = "default_value" +description = "Value from env" +category = "test" + +[tool.alfred.settings_schema.TEST_SECRET] +type = "secret" +source = "generated" +secret_rule = "16:hex" +description = "Generated secret" +category = "security" + +[tool.alfred.settings_schema.TEST_COMPUTED] +type = "computed" +source = "computed" +compute_from = ["TEST_FROM_TOML", "TEST_FROM_ENV"] +compute_template = "{TEST_FROM_TOML}-{TEST_FROM_ENV}" +description = "Computed value" +category = "test" + +[tool.alfred.settings_schema.PYTHON_VERSION] +type = "string" +source = "toml" +toml_path = "tool.poetry.dependencies.python" +transform = "extract_python_version_full" +description = "Python version" +category = "build" +""" + + +@pytest.fixture +def config_source(tmp_path): + """Create a ConfigSource for testing.""" + return ConfigSource.from_base_dir(tmp_path) + + +@pytest.fixture +def create_test_env(tmp_path, test_toml_content): + """Create a complete test environment.""" + # Create pyproject.toml + toml_path = tmp_path / "pyproject.toml" + toml_path.write_text(test_toml_content) + + # Create .env.example + env_example = tmp_path / ".env.example" + env_example.write_text(""" +TEST_FROM_TOML= +TEST_FROM_ENV= +TEST_SECRET= +TEST_COMPUTED= +PYTHON_VERSION= +""") + + return ConfigSource.from_base_dir(tmp_path) + + +class TestExtractPythonVersion: + """Test Python version extraction.""" + + def test_exact_version(self): + """Test exact version format.""" + full, short = extract_python_version("==3.14.2") + assert full == "3.14.2" + assert short == "3.14" + + def test_caret_version(self): + """Test caret version format.""" + full, short = extract_python_version("^3.14.2") + assert full == "3.14.2" + assert short == "3.14" + + def test_invalid_version(self): + """Test invalid version raises error.""" + with pytest.raises(ValueError, match="Invalid Python version"): + extract_python_version("3") + + +class TestGenerateSecret: + """Test secret generation.""" + + def test_generate_b64(self): + """Test base64 secret generation.""" + secret = generate_secret("32:b64") + assert isinstance(secret, str) + assert len(secret) > 0 + + def test_generate_hex(self): + """Test hex secret generation.""" + secret = generate_secret("16:hex") + assert isinstance(secret, str) + assert len(secret) == 32 # 16 bytes = 32 hex chars + assert all(c in "0123456789abcdef" for c in secret) + + def test_invalid_format(self): + """Test invalid format raises error.""" + with pytest.raises(ValueError, match="Invalid security format"): + generate_secret("32:invalid") + + def test_invalid_rule(self): + """Test invalid rule format raises error.""" + with pytest.raises(ValueError, match="Invalid security rule format"): + generate_secret("32") + + +class TestGetNestedValue: + """Test nested value extraction.""" + + def test_simple_path(self): + """Test simple path.""" + data = {"key": "value"} + assert get_nested_value(data, "key") == "value" + + def test_nested_path(self): + """Test nested path.""" + data = {"a": {"b": {"c": "value"}}} + assert get_nested_value(data, "a.b.c") == "value" + + def test_missing_key(self): + """Test missing key raises error.""" + data = {"a": {"b": "value"}} + with pytest.raises(KeyError): + get_nested_value(data, "a.c") + + def test_non_dict_value(self): + """Test accessing non-dict raises error.""" + data = {"a": "not a dict"} + with pytest.raises(KeyError, match="non-dict"): + get_nested_value(data, "a.b") + + +class TestSettingsBootstrap: + """Test settings bootstrap.""" + + def test_load_sources(self, create_test_env): + """Test loading TOML and env sources.""" + bootstrapper = SettingsBootstrap(create_test_env) + bootstrapper._load_sources() + + assert bootstrapper.toml_data is not None + assert "tool" in bootstrapper.toml_data + assert isinstance(bootstrapper.existing_env, dict) + + def test_resolve_from_toml(self, create_test_env): + """Test resolving setting from TOML.""" + # Load schema from test env + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + # Get definition for TEST_FROM_TOML + definition = bootstrapper.schema.get("TEST_FROM_TOML") + value = bootstrapper._resolve_from_toml(definition) + + assert value == "1.0.0" # From tool.poetry.version + + def test_resolve_from_env_with_default(self, create_test_env): + """Test resolving from env with default.""" + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + definition = bootstrapper.schema.get("TEST_FROM_ENV") + value = bootstrapper._resolve_from_env(definition) + + assert value == "default_value" + + def test_resolve_from_env_existing(self, create_test_env): + """Test resolving from existing env.""" + # Create existing .env + create_test_env.env_path.write_text("TEST_FROM_ENV=existing_value\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + definition = bootstrapper.schema.get("TEST_FROM_ENV") + value = bootstrapper._resolve_from_env(definition) + + assert value == "existing_value" + + def test_resolve_generated_new(self, create_test_env): + """Test generating new secret.""" + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + definition = bootstrapper.schema.get("TEST_SECRET") + value = bootstrapper._resolve_generated(definition) + + assert isinstance(value, str) + assert len(value) == 32 # 16 hex = 32 chars + + def test_resolve_generated_preserve_existing(self, create_test_env): + """Test preserving existing secret.""" + # Create existing .env with secret + create_test_env.env_path.write_text("TEST_SECRET=existing_secret\n") + + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + definition = bootstrapper.schema.get("TEST_SECRET") + value = bootstrapper._resolve_generated(definition) + + assert value == "existing_secret" + + def test_resolve_computed(self, create_test_env): + """Test resolving computed setting.""" + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + # Resolve dependencies first + bootstrapper.resolved_settings["TEST_FROM_TOML"] = "1.0.0" + bootstrapper.resolved_settings["TEST_FROM_ENV"] = "test" + + definition = bootstrapper.schema.get("TEST_COMPUTED") + value = bootstrapper._resolve_computed(definition) + + assert value == "1.0.0-test" + + def test_resolve_with_transform(self, create_test_env): + """Test resolving with transform function.""" + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper._load_sources() + + definition = bootstrapper.schema.get("PYTHON_VERSION") + value = bootstrapper._resolve_from_toml(definition) + + assert value == "3.14.2" # Transformed from "==3.14.2" + + def test_full_bootstrap(self, create_test_env): + """Test complete bootstrap process.""" + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper.bootstrap() + + # Check .env was created + assert create_test_env.env_path.exists() + + # Check .env.make was created + env_make_path = create_test_env.base_dir / ".env.make" + assert env_make_path.exists() + + # Check content + env_content = create_test_env.env_path.read_text() + assert "TEST_FROM_TOML=1.0.0" in env_content + assert "TEST_FROM_ENV=default_value" in env_content + assert "TEST_SECRET=" in env_content + assert "TEST_COMPUTED=1.0.0-default_value" in env_content + + def test_bootstrap_preserves_secrets(self, create_test_env): + """Test that bootstrap preserves existing secrets.""" + # Create existing .env with secret + create_test_env.env_path.write_text(""" +TEST_FROM_ENV=old_value +TEST_SECRET=my_secret_123 +""") + + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper.bootstrap() + + # Check secret was preserved + env_content = create_test_env.env_path.read_text() + assert "TEST_SECRET=my_secret_123" in env_content + + def test_validation_error(self, tmp_path, test_toml_content): + """Test validation error is raised.""" + # Add a setting with validation + toml_with_validation = ( + test_toml_content + + """ +[tool.alfred.settings_schema.TEST_VALIDATED] +type = "integer" +source = "env" +default = 150 +validator = "range:1:100" +description = "Validated setting" +category = "test" +""" + ) + toml_path = tmp_path / "pyproject.toml" + toml_path.write_text(toml_with_validation) + + env_example = tmp_path / ".env.example" + env_example.write_text("TEST_VALIDATED=\n") + + source = ConfigSource.from_base_dir(tmp_path) + + from alfred.settings_schema import load_schema + + schema = load_schema(tmp_path) + + bootstrapper = SettingsBootstrap(source, schema) + + with pytest.raises(ValueError, match="Validation errors"): + bootstrapper.bootstrap() + + def test_write_env_make_only_exports(self, create_test_env): + """Test that .env.make only contains export_to_env_make settings.""" + # Add a setting with export_to_env_make + toml_content = create_test_env.toml_path.read_text() + toml_content += """ +[tool.alfred.settings_schema.EXPORTED_VAR] +type = "string" +source = "env" +default = "exported" +export_to_env_make = true +category = "build" +""" + create_test_env.toml_path.write_text(toml_content) + + # Recreate schema + from alfred.settings_schema import load_schema + + schema = load_schema(create_test_env.base_dir) + + bootstrapper = SettingsBootstrap(create_test_env, schema) + bootstrapper.bootstrap() + + env_make_content = (create_test_env.base_dir / ".env.make").read_text() + assert "EXPORTED_VAR=exported" in env_make_content + # Non-exported vars should not be in .env.make + assert "TEST_FROM_ENV" not in env_make_content + + +class TestConfigSource: + """Test ConfigSource dataclass.""" + + def test_from_base_dir(self, tmp_path): + """Test creating ConfigSource from base dir.""" + source = ConfigSource.from_base_dir(tmp_path) + + assert source.base_dir == tmp_path + assert source.toml_path == tmp_path / "pyproject.toml" + assert source.env_path == tmp_path / ".env" + assert source.env_example_path == tmp_path / ".env.example" + + def test_from_base_dir_default(self): + """Test creating ConfigSource with default base dir.""" + source = ConfigSource.from_base_dir() + + assert source.base_dir.exists() + assert source.toml_path.name == "pyproject.toml" diff --git a/tests/test_settings_schema.py b/tests/test_settings_schema.py new file mode 100644 index 0000000..3436de9 --- /dev/null +++ b/tests/test_settings_schema.py @@ -0,0 +1,332 @@ +"""Tests for settings schema parser.""" + +import pytest + +from alfred.settings_schema import ( + SettingDefinition, + SettingSource, + SettingType, + load_schema, + validate_value, +) + + +@pytest.fixture +def minimal_schema_toml(): + """Minimal valid schema TOML.""" + return """ +[tool.alfred.settings_schema.TEST_STRING] +type = "string" +source = "env" +default = "test_value" +description = "Test string setting" +category = "test" + +[tool.alfred.settings_schema.TEST_INTEGER] +type = "integer" +source = "env" +default = 42 +description = "Test integer setting" +category = "test" +validator = "range:1:100" + +[tool.alfred.settings_schema.TEST_SECRET] +type = "secret" +source = "generated" +secret_rule = "32:b64" +description = "Test secret" +category = "security" +required = true + +[tool.alfred.settings_schema.TEST_COMPUTED] +type = "computed" +source = "computed" +compute_from = ["TEST_STRING", "TEST_INTEGER"] +compute_template = "{TEST_STRING}_{TEST_INTEGER}" +description = "Test computed" +category = "test" + +[tool.alfred.settings_schema.TEST_OPTIONAL] +type = "string" +source = "env" +required = false +description = "Optional setting" +category = "test" +""" + + +@pytest.fixture +def create_schema_file(tmp_path): + """Factory to create pyproject.toml with schema.""" + + def _create(content: str): + toml_path = tmp_path / "pyproject.toml" + full_content = f""" +[tool.poetry] +name = "test" +version = "1.0.0" + +{content} +""" + toml_path.write_text(full_content) + return tmp_path + + return _create + + +class TestSettingDefinition: + """Test SettingDefinition dataclass.""" + + def test_create_definition(self): + """Test creating a setting definition.""" + definition = SettingDefinition( + name="TEST_SETTING", + type=SettingType.STRING, + source=SettingSource.ENV, + description="Test setting", + category="test", + default="default_value", + ) + + assert definition.name == "TEST_SETTING" + assert definition.type == SettingType.STRING + assert definition.source == SettingSource.ENV + assert definition.default == "default_value" + assert definition.required is True # Default + + +class TestSettingsSchema: + """Test SettingsSchema parser.""" + + def test_parse_schema(self, create_schema_file, minimal_schema_toml): + """Test parsing schema from TOML.""" + base_dir = create_schema_file(minimal_schema_toml) + schema = load_schema(base_dir) + + assert len(schema) == 5 + assert "TEST_STRING" in schema.definitions + assert "TEST_INTEGER" in schema.definitions + assert "TEST_SECRET" in schema.definitions + assert "TEST_COMPUTED" in schema.definitions + assert "TEST_OPTIONAL" in schema.definitions + + def test_get_definition(self, create_schema_file, minimal_schema_toml): + """Test getting a definition by name.""" + base_dir = create_schema_file(minimal_schema_toml) + schema = load_schema(base_dir) + + definition = schema.get("TEST_STRING") + assert definition is not None + assert definition.name == "TEST_STRING" + assert definition.type == SettingType.STRING + assert definition.default == "test_value" + + def test_get_by_category(self, create_schema_file, minimal_schema_toml): + """Test getting definitions by category.""" + base_dir = create_schema_file(minimal_schema_toml) + schema = load_schema(base_dir) + + test_settings = schema.get_by_category("test") + assert len(test_settings) == 4 + + security_settings = schema.get_by_category("security") + assert len(security_settings) == 1 + + def test_get_by_source(self, create_schema_file, minimal_schema_toml): + """Test getting definitions by source.""" + base_dir = create_schema_file(minimal_schema_toml) + schema = load_schema(base_dir) + + env_settings = schema.get_by_source(SettingSource.ENV) + assert len(env_settings) == 3 + + generated_settings = schema.get_by_source(SettingSource.GENERATED) + assert len(generated_settings) == 1 + + computed_settings = schema.get_by_source(SettingSource.COMPUTED) + assert len(computed_settings) == 1 + + def test_get_required(self, create_schema_file, minimal_schema_toml): + """Test getting required settings.""" + base_dir = create_schema_file(minimal_schema_toml) + schema = load_schema(base_dir) + + required = schema.get_required() + # TEST_OPTIONAL is not required + assert len(required) == 4 + + def test_parse_types(self, create_schema_file): + """Test parsing different setting types.""" + schema_toml = """ +[tool.alfred.settings_schema.STR_SETTING] +type = "string" +source = "env" +default = "text" + +[tool.alfred.settings_schema.INT_SETTING] +type = "integer" +source = "env" +default = 42 + +[tool.alfred.settings_schema.FLOAT_SETTING] +type = "float" +source = "env" +default = 3.14 + +[tool.alfred.settings_schema.BOOL_SETTING] +type = "boolean" +source = "env" +default = true +""" + base_dir = create_schema_file(schema_toml) + schema = load_schema(base_dir) + + assert schema.get("STR_SETTING").default == "text" + assert schema.get("INT_SETTING").default == 42 + assert schema.get("FLOAT_SETTING").default == 3.14 + assert schema.get("BOOL_SETTING").default is True + + def test_missing_schema_section(self, tmp_path): + """Test error when schema section is missing.""" + toml_path = tmp_path / "pyproject.toml" + toml_path.write_text(""" +[tool.poetry] +name = "test" +version = "1.0.0" +""") + + with pytest.raises(KeyError, match="settings_schema"): + load_schema(tmp_path) + + +class TestValidateValue: + """Test value validation.""" + + def test_validate_string(self): + """Test validating string values.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.STRING, + source=SettingSource.ENV, + ) + + assert validate_value(definition, "test") is True + + with pytest.raises(ValueError, match="must be string"): + validate_value(definition, 123) + + def test_validate_integer(self): + """Test validating integer values.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.INTEGER, + source=SettingSource.ENV, + ) + + assert validate_value(definition, 42) is True + + with pytest.raises(ValueError, match="must be integer"): + validate_value(definition, "not an int") + + def test_validate_float(self): + """Test validating float values.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.FLOAT, + source=SettingSource.ENV, + ) + + assert validate_value(definition, 3.14) is True + assert validate_value(definition, 42) is True # int is ok for float + + with pytest.raises(ValueError, match="must be float"): + validate_value(definition, "not a float") + + def test_validate_required(self): + """Test validating required settings.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.STRING, + source=SettingSource.ENV, + required=True, + ) + + with pytest.raises(ValueError, match="is required"): + validate_value(definition, None) + + def test_validate_optional(self): + """Test validating optional settings.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.STRING, + source=SettingSource.ENV, + required=False, + ) + + assert validate_value(definition, None) is True + + def test_validate_range(self): + """Test range validator.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.INTEGER, + source=SettingSource.ENV, + validator="range:1:100", + ) + + assert validate_value(definition, 50) is True + assert validate_value(definition, 1) is True + assert validate_value(definition, 100) is True + + with pytest.raises(ValueError, match=r"must be between .* and .*, got"): + validate_value(definition, 0) + + with pytest.raises(ValueError, match=r"must be between .* and .*, got"): + validate_value(definition, 101) + + def test_validate_float_range(self): + """Test range validator with floats.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.FLOAT, + source=SettingSource.ENV, + validator="range:0.0:2.0", + ) + + assert validate_value(definition, 1.5) is True + assert validate_value(definition, 0.0) is True + assert validate_value(definition, 2.0) is True + + with pytest.raises(ValueError, match="must be between 0.0 and 2.0"): + validate_value(definition, -0.1) + + with pytest.raises(ValueError, match="must be between 0.0 and 2.0"): + validate_value(definition, 2.1) + + def test_invalid_validator(self): + """Test unknown validator raises error.""" + definition = SettingDefinition( + name="TEST", + type=SettingType.STRING, + source=SettingSource.ENV, + validator="unknown:validator", + ) + + with pytest.raises(ValueError, match="Unknown validator"): + validate_value(definition, "test") + + +class TestSchemaIteration: + """Test schema iteration.""" + + def test_iterate_schema(self, create_schema_file, minimal_schema_toml): + """Test iterating over schema definitions.""" + base_dir = create_schema_file(minimal_schema_toml) + schema = load_schema(base_dir) + + definitions = list(schema) + assert len(definitions) == 5 + + names = [d.name for d in definitions] + assert "TEST_STRING" in names + assert "TEST_INTEGER" in names