feat: implemented declarative schema-based settings system

This commit is contained in:
2026-01-03 10:06:37 +01:00
parent aa89a3fb00
commit faaf1aafa7
12 changed files with 2277 additions and 372 deletions

261
CONTRIBUTE.md Normal file
View File

@@ -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.

View File

@@ -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"

View File

@@ -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()

View File

@@ -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("<EFBFBD><EFBFBD><EFBFBD><EFBFBD> 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()

291
alfred/settings_schema.py Normal file
View File

@@ -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()

View File

@@ -6,23 +6,6 @@ authors = ["Francwa <francois.hodiaumont@gmail.com>"]
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"

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

304
settings.toml Normal file
View File

@@ -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"

View File

@@ -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"

View File

@@ -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