1 Commits

Author SHA1 Message Date
francwa faaf1aafa7 feat: implemented declarative schema-based settings system 2026-01-04 17:28:48 +01:00
12 changed files with 97 additions and 1062 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ MONGO_HOST=mongodb
MONGO_PORT=27017
MONGO_USER=alfred
MONGO_PASSWORD=
MONGO_DB_NAME=LibreChat
MONGO_DB_NAME=alfred
# PostgreSQL (Vector Database / RAG)
POSTGRES_URI=
+4 -5
View File
@@ -27,7 +27,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
pip install $RUNNER
# Set working directory for dependency installation
WORKDIR /app
WORKDIR /tmp
# Copy dependency files
COPY pyproject.toml poetry.lock* uv.lock* Makefile ./
@@ -43,10 +43,8 @@ RUN --mount=type=cache,target=/root/.cache/pip \
uv pip install --system -r pyproject.toml; \
fi
COPY alfred/ ./alfred/
COPY scripts/ ./scripts/
COPY .env.example ./
COPY settings.toml ./
# ===========================================
# Stage 2: Testing
@@ -64,6 +62,8 @@ RUN --mount=type=cache,target=/root/.cache/pip \
uv pip install --system -e .[dev]; \
fi
COPY alfred/ ./alfred
COPY scripts ./scripts
COPY tests/ ./tests
# ===========================================
@@ -108,7 +108,6 @@ COPY --chown=appuser:appuser alfred/ ./alfred
COPY --chown=appuser:appuser scripts/ ./scripts
COPY --chown=appuser:appuser .env.example ./
COPY --chown=appuser:appuser pyproject.toml ./
COPY --chown=appuser:appuser settings.toml ./
# Create volumes for persistent data
VOLUME ["/data", "/logs"]
@@ -120,4 +119,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5).raise_for_status()" || exit 1
CMD ["python", "-m", "uvicorn", "alfred.app:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["python", "-m", "uvicorn", "alfred.app:app", "--host", "0.0.0.0", "--port", "8000"]
+3 -3
View File
@@ -41,10 +41,10 @@ def _get_secret_factory(rule: str):
class Settings(BaseSettings):
"""
Application settings management.
Application settings.
Initializes configuration with internal defaults from the settings module,
then overrides them with environment variables loaded from a .env file.
Settings are loaded from .env and validated using the schema
defined in pyproject.toml.
"""
model_config = SettingsConfigDict(
+39 -75
View File
@@ -241,18 +241,16 @@ class SettingsBootstrap:
else:
raise ValueError(f"{definition.name} is required but not found in .env")
# Convert type (only if value is a string from .env)
# Convert type
match definition.type:
case SettingType.INTEGER:
return int(value) if not isinstance(value, int) else value
return int(value)
case SettingType.FLOAT:
return float(value) if not isinstance(value, float) else value
return float(value)
case SettingType.BOOLEAN:
if isinstance(value, bool):
return value
return str(value).lower() in ("true", "1", "yes")
return value.lower() in ("true", "1", "yes")
case _:
return str(value) if not isinstance(value, str) else value
return value
def _resolve_generated(self, definition: SettingDefinition) -> str:
"""Resolve generated secret."""
@@ -309,83 +307,49 @@ class SettingsBootstrap:
)
def _write_env(self) -> None:
"""
Write .env file using .env.example as template.
This preserves the structure, comments, and formatting of .env.example
while updating only the values of variables defined in the schema.
Custom variables from existing .env are appended at the end.
"""
"""Write .env file."""
print("📝 Writing .env...")
# Check if .env.example exists
if not self.source.env_example_path.exists():
raise FileNotFoundError(
f".env.example not found: {self.source.env_example_path}"
)
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")
# Read .env.example as template
with open(self.source.env_example_path, encoding="utf-8") as f:
template_lines = f.readlines()
# Group by category
categories = {}
for definition in self.schema:
if definition.category not in categories:
categories[definition.category] = []
categories[definition.category].append(definition)
# Track which keys we've processed from .env.example
processed_keys = set()
# 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
# Process template line by line
output_lines = []
for line in template_lines:
stripped = line.strip()
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")
# Keep comments and empty lines as-is
if not stripped or stripped.startswith("#"):
output_lines.append(line)
continue
# 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")
# Check if line contains a variable assignment
if "=" in line:
key, _ = line.split("=", 1)
key = key.strip()
processed_keys.add(key)
# Check if this variable is in our schema
definition = self.schema.get(key)
if definition:
# Update with resolved value (including computed settings)
value = self.resolved_settings.get(key, "")
# Convert Python booleans to lowercase for .env compatibility
if isinstance(value, bool):
value = "true" if value else "false"
output_lines.append(f"{key}={value}\n")
# Variable not in schema
# If it exists in current .env, use that value, otherwise keep template
elif key in self.existing_env:
output_lines.append(f"{key}={self.existing_env[key]}\n")
else:
output_lines.append(line)
else:
# Keep any other lines as-is
output_lines.append(line)
# Append custom variables from existing .env that aren't in .env.example
custom_vars = {
k: v for k, v in self.existing_env.items() if k not in processed_keys
}
if custom_vars:
output_lines.append("\n# --- CUSTOM VARIABLES ---\n")
output_lines.append("# Variables added manually (not in .env.example)\n")
for key, value in sorted(custom_vars.items()):
output_lines.append(f"{key}={value}\n")
# Write updated .env
with open(self.source.env_path, "w", encoding="utf-8") as f:
f.writelines(output_lines)
f.writelines(lines)
print(f"{self.source.env_path.name} written (preserving template structure)")
if custom_vars:
print(f" ️ Preserved {len(custom_vars)} custom variable(s)")
print(f"{self.source.env_path.name} written")
def _write_env_make(self) -> None:
"""Write .env.make for Makefile."""
+20 -7
View File
@@ -185,26 +185,39 @@ def load_schema(base_dir: Path | None = None) -> SettingsSchema:
if base_dir is None:
base_dir = BASE_DIR
# Load from settings.toml (required)
# 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)
if not settings_toml_path.exists():
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"settings.toml not found at {settings_toml_path}. "
f"This file is required and must be present in the application root."
f"Neither settings.toml nor pyproject.toml found in {base_dir}"
)
with open(settings_toml_path, "rb") as f:
with open(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(
f"Missing [tool.alfred.settings_schema] section in settings.toml at {settings_toml_path}"
"Missing [tool.alfred.settings_schema] section in pyproject.toml"
) from e
return SettingsSchema(schema_dict)
def validate_value(definition: SettingDefinition, value: Any) -> bool:
"""
+7 -11
View File
@@ -17,7 +17,6 @@ services:
# --- MAIN APPLICATION ---
alfred:
container_name: alfred-core
image: alfred_media_organizer:latest
build:
context: .
args:
@@ -36,8 +35,6 @@ services:
- ./logs:/logs
# TODO: Hot reload (comment out in production)
#- ./alfred:/home/appuser/alfred
command: >
sh -c "python -u -m uvicorn alfred.app:app --host 0.0.0.0 --port 8000 2>&1 | tee -a /logs/alfred.log"
networks:
- alfred-net
@@ -87,11 +84,12 @@ services:
ports:
- "${MONGO_PORT}:${MONGO_PORT}"
volumes:
- ./data/mongodb:/data/db
- ./mongod.conf:/etc/mongod.conf:ro
command: ["mongod", "--config", "/etc/mongod.conf"]
- ./data/mongo:/data/db
command: mongod --quiet --setParameter logComponentVerbosity='{"network":{"verbosity":0}}'
healthcheck:
test: mongosh --quiet -u "${MONGO_USER}" -p "${MONGO_PASSWORD}" --authenticationDatabase admin --eval "db.adminCommand('ping')"
test: |
mongosh --quiet --eval "db.adminCommand('ping')" || \
mongosh --quiet -u "${MONGO_USER}" -p "${MONGO_PASSWORD}" --authenticationDatabase admin --eval "db.adminCommand('ping')"
interval: 10s
timeout: 5s
retries: 5
@@ -170,14 +168,12 @@ services:
- ./data/vectordb:/var/lib/postgresql/data
profiles: ["rag", "full"]
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB_NAME}" ]
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-alfred} -d ${POSTGRES_DB_NAME:-alfred}" ]
interval: 5s
timeout: 5s
retries: 5
networks:
alfred-net:
aliases:
- db
- alfred-net
# --- QBITTORENT (Optional) ---
qbittorrent:
-49
View File
@@ -1,49 +0,0 @@
# MongoDB Configuration File
# Network settings
net:
port: 27017
bindIp: 0.0.0.0
# Storage settings
storage:
dbPath: /data/db
# Security settings
security:
authorization: enabled
# System log settings
systemLog:
destination: file
path: /dev/stdout
logAppend: false
verbosity: 0
quiet: true
component:
accessControl:
verbosity: -1
command:
verbosity: 0
control:
verbosity: 0
ftdc:
verbosity: 0
geo:
verbosity: 0
index:
verbosity: 0
network:
verbosity: 0
query:
verbosity: 0
replication:
verbosity: 0
sharding:
verbosity: 0
storage:
verbosity: 0
write:
verbosity: 0
transaction:
verbosity: 0
Generated
+6 -6
View File
@@ -74,13 +74,13 @@ wcmatch = ">=8.5.1"
[[package]]
name = "certifi"
version = "2026.1.4"
version = "2025.11.12"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
]
[[package]]
@@ -394,13 +394,13 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand
[[package]]
name = "filelock"
version = "3.20.2"
version = "3.20.1"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
files = [
{file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"},
{file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"},
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
]
[[package]]
-110
View File
@@ -302,113 +302,3 @@ source = "env"
default = "data"
description = "Data storage directory"
category = "app"
# TMDB Configuration
[tool.alfred.settings_schema.TMDB_BASE_URL]
type = "string"
source = "env"
default = "https://api.themoviedb.org/3"
description = "TMDB API base URL"
category = "external_services"
# qBittorrent Configuration
[tool.alfred.settings_schema.QBITTORRENT_URL]
type = "string"
source = "env"
default = "http://qbittorrent:16140"
description = "qBittorrent web UI URL"
category = "external_services"
[tool.alfred.settings_schema.QBITTORRENT_USERNAME]
type = "string"
source = "env"
default = "admin"
description = "qBittorrent username"
category = "external_services"
[tool.alfred.settings_schema.QBITTORRENT_PORT]
type = "integer"
source = "env"
default = 16140
description = "qBittorrent port"
category = "external_services"
# Meilisearch Configuration
[tool.alfred.settings_schema.MEILI_ENABLED]
type = "boolean"
source = "env"
default = false
description = "Enable Meilisearch"
category = "external_services"
[tool.alfred.settings_schema.MEILI_NO_ANALYTICS]
type = "boolean"
source = "env"
default = true
description = "Disable Meilisearch analytics"
category = "external_services"
[tool.alfred.settings_schema.MEILI_HOST]
type = "string"
source = "env"
default = "http://meilisearch:7700"
description = "Meilisearch host URL"
category = "external_services"
# LLM Configuration
[tool.alfred.settings_schema.DEFAULT_LLM_PROVIDER]
type = "string"
source = "env"
default = "local"
description = "Default LLM provider (local/openai/anthropic/deepseek/google/kimi)"
category = "llm"
[tool.alfred.settings_schema.OLLAMA_BASE_URL]
type = "string"
source = "env"
default = "http://ollama:11434"
description = "Ollama API base URL"
category = "llm"
[tool.alfred.settings_schema.OLLAMA_MODEL]
type = "string"
source = "env"
default = "llama3.3:latest"
description = "Ollama model name"
category = "llm"
# RAG Configuration
[tool.alfred.settings_schema.RAG_ENABLED]
type = "boolean"
source = "env"
default = true
description = "Enable RAG system"
category = "rag"
[tool.alfred.settings_schema.RAG_API_URL]
type = "string"
source = "env"
default = "http://rag_api:8000"
description = "RAG API URL"
category = "rag"
[tool.alfred.settings_schema.RAG_API_PORT]
type = "integer"
source = "env"
default = 8000
description = "RAG API port"
category = "rag"
[tool.alfred.settings_schema.EMBEDDINGS_PROVIDER]
type = "string"
source = "env"
default = "ollama"
description = "Embeddings provider"
category = "rag"
[tool.alfred.settings_schema.EMBEDDINGS_MODEL]
type = "string"
source = "env"
default = "nomic-embed-text"
description = "Embeddings model name"
category = "rag"
+15 -32
View File
@@ -12,8 +12,8 @@ from alfred.settings_bootstrap import (
@pytest.fixture
def test_pyproject_content():
"""Test pyproject.toml content with poetry metadata."""
def test_toml_content():
"""Test TOML content with schema."""
return """
[tool.poetry]
name = "test"
@@ -25,13 +25,7 @@ python = "==3.14.2"
[tool.alfred.settings]
runner = "poetry"
image_name = "test_image"
"""
@pytest.fixture
def test_settings_content():
"""Test settings.toml content with schema."""
return """
[tool.alfred.settings_schema.TEST_FROM_TOML]
type = "string"
source = "toml"
@@ -78,15 +72,11 @@ def config_source(tmp_path):
@pytest.fixture
def create_test_env(tmp_path, test_pyproject_content, test_settings_content):
def create_test_env(tmp_path, test_toml_content):
"""Create a complete test environment."""
# Create pyproject.toml
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(test_pyproject_content)
# Create settings.toml
settings_path = tmp_path / "settings.toml"
settings_path.write_text(test_settings_content)
toml_path = tmp_path / "pyproject.toml"
toml_path.write_text(test_toml_content)
# Create .env.example
env_example = tmp_path / ".env.example"
@@ -340,17 +330,11 @@ TEST_SECRET=my_secret_123
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_pyproject_content, test_settings_content
):
def test_validation_error(self, tmp_path, test_toml_content):
"""Test validation error is raised."""
# Create pyproject.toml
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(test_pyproject_content)
# Add a setting with validation to settings.toml
settings_with_validation = (
test_settings_content
# Add a setting with validation
toml_with_validation = (
test_toml_content
+ """
[tool.alfred.settings_schema.TEST_VALIDATED]
type = "integer"
@@ -361,8 +345,8 @@ description = "Validated setting"
category = "test"
"""
)
settings_path = tmp_path / "settings.toml"
settings_path.write_text(settings_with_validation)
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")
@@ -380,10 +364,9 @@ category = "test"
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 to settings.toml
settings_path = create_test_env.base_dir / "settings.toml"
settings_content = settings_path.read_text()
settings_content += """
# 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"
@@ -391,7 +374,7 @@ default = "exported"
export_to_env_make = true
category = "build"
"""
settings_path.write_text(settings_content)
create_test_env.toml_path.write_text(toml_content)
# Recreate schema
from alfred.settings_schema import load_schema
-761
View File
@@ -1,761 +0,0 @@
"""Advanced tests for settings bootstrap - template preservation and edge cases."""
import pytest
from alfred.settings_bootstrap import ConfigSource, SettingsBootstrap
@pytest.fixture
def test_toml_with_all_types(tmp_path):
"""Create test TOML with all setting types."""
# Create pyproject.toml for poetry metadata
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
[tool.poetry.dependencies]
python = "^3.14"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
# Create settings.toml with schema
settings_content = """
[tool.alfred.settings_schema.STRING_VAR]
type = "string"
source = "env"
default = "default_string"
description = "String variable"
category = "test"
[tool.alfred.settings_schema.INT_VAR]
type = "integer"
source = "env"
default = 42
description = "Integer variable"
category = "test"
[tool.alfred.settings_schema.FLOAT_VAR]
type = "float"
source = "env"
default = 3.14
description = "Float variable"
category = "test"
[tool.alfred.settings_schema.BOOL_VAR]
type = "boolean"
source = "env"
default = true
description = "Boolean variable"
category = "test"
[tool.alfred.settings_schema.SECRET_VAR]
type = "secret"
source = "generated"
secret_rule = "16:hex"
description = "Secret variable"
category = "security"
[tool.alfred.settings_schema.COMPUTED_VAR]
type = "computed"
source = "computed"
compute_from = ["STRING_VAR", "INT_VAR"]
compute_template = "{STRING_VAR}_{INT_VAR}"
description = "Computed variable"
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
# Create .env.example
env_example_content = """# Test configuration
STRING_VAR=
INT_VAR=
FLOAT_VAR=
# Boolean settings
BOOL_VAR=
# Security
SECRET_VAR=
# Computed values
COMPUTED_VAR=
# Custom variable (not in schema)
CUSTOM_VAR=custom_value
"""
(tmp_path / ".env.example").write_text(env_example_content)
return tmp_path
class TestTemplatePreservation:
"""Test that .env.example template structure is preserved."""
def test_preserves_comments(self, test_toml_with_all_types):
"""Test that comments from .env.example are preserved."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# Check comments are preserved
assert "# Test configuration" in env_content
assert "# Boolean settings" in env_content
assert "# Security" in env_content
assert "# Computed values" in env_content
def test_preserves_empty_lines(self, test_toml_with_all_types):
"""Test that empty lines from .env.example are preserved."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
lines = env_content.split("\n")
# Check there are empty lines (structure preserved)
assert "" in lines
def test_preserves_variable_order(self, test_toml_with_all_types):
"""Test that variable order from .env.example is preserved."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# Check order is preserved
string_pos = env_content.find("STRING_VAR=")
int_pos = env_content.find("INT_VAR=")
float_pos = env_content.find("FLOAT_VAR=")
bool_pos = env_content.find("BOOL_VAR=")
assert string_pos < int_pos < float_pos < bool_pos
class TestSecretPreservation:
"""Test that secrets are never overwritten."""
def test_preserves_existing_secrets(self, test_toml_with_all_types):
"""Test that existing secrets are preserved across multiple bootstraps."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
# First bootstrap - generates secret
bootstrapper1 = SettingsBootstrap(source, schema)
bootstrapper1.bootstrap()
env_content_1 = source.env_path.read_text()
secret_1 = [
line.split("=")[1]
for line in env_content_1.split("\n")
if line.startswith("SECRET_VAR=")
][0]
# Second bootstrap - should preserve secret
bootstrapper2 = SettingsBootstrap(source, schema)
bootstrapper2.bootstrap()
env_content_2 = source.env_path.read_text()
secret_2 = [
line.split("=")[1]
for line in env_content_2.split("\n")
if line.startswith("SECRET_VAR=")
][0]
assert secret_1 == secret_2
assert len(secret_1) == 32 # 16 hex bytes
def test_multiple_secrets_preserved(self, tmp_path):
"""Test that multiple secrets are all preserved."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.SECRET_1]
type = "secret"
source = "generated"
secret_rule = "16:hex"
category = "security"
[tool.alfred.settings_schema.SECRET_2]
type = "secret"
source = "generated"
secret_rule = "32:b64"
category = "security"
[tool.alfred.settings_schema.SECRET_3]
type = "secret"
source = "generated"
secret_rule = "8:hex"
category = "security"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("SECRET_1=\nSECRET_2=\nSECRET_3=\n")
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
# First bootstrap
bootstrapper1 = SettingsBootstrap(source, schema)
bootstrapper1.bootstrap()
env_content_1 = source.env_path.read_text()
# Second bootstrap
bootstrapper2 = SettingsBootstrap(source, schema)
bootstrapper2.bootstrap()
env_content_2 = source.env_path.read_text()
# All secrets should be identical
assert env_content_1 == env_content_2
class TestCustomVariables:
"""Test that custom variables (not in schema) are preserved."""
def test_preserves_custom_variables_from_env(self, test_toml_with_all_types):
"""Test that custom variables added to .env are preserved."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
# First bootstrap
bootstrapper1 = SettingsBootstrap(source, schema)
bootstrapper1.bootstrap()
# Add custom variables to .env
with open(source.env_path, "a") as f:
f.write("\nMY_CUSTOM_VAR=custom_value\n")
f.write("ANOTHER_CUSTOM=another_value\n")
# Second bootstrap
bootstrapper2 = SettingsBootstrap(source, schema)
bootstrapper2.bootstrap()
env_content = source.env_path.read_text()
# Custom variables should be preserved
assert "MY_CUSTOM_VAR=custom_value" in env_content
assert "ANOTHER_CUSTOM=another_value" in env_content
def test_custom_variables_in_dedicated_section(self, test_toml_with_all_types):
"""Test that custom variables are placed in a dedicated section."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
# Create .env with custom variable
source.env_path.write_text("MY_CUSTOM_VAR=test\n")
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# Check custom section exists
assert "# --- CUSTOM VARIABLES ---" in env_content
assert "MY_CUSTOM_VAR=test" in env_content
def test_preserves_custom_from_env_example(self, test_toml_with_all_types):
"""Test that custom variables in .env.example are preserved."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# CUSTOM_VAR is in .env.example but not in schema
assert "CUSTOM_VAR=custom_value" in env_content
class TestBooleanHandling:
"""Test that booleans are handled correctly."""
def test_booleans_written_as_lowercase(self, test_toml_with_all_types):
"""Test that Python booleans are written as lowercase strings."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# Boolean should be lowercase
assert "BOOL_VAR=true" in env_content
assert "BOOL_VAR=True" not in env_content
assert "BOOL_VAR=TRUE" not in env_content
def test_false_boolean_written_as_lowercase(self, tmp_path):
"""Test that False is written as 'false'."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.BOOL_FALSE]
type = "boolean"
source = "env"
default = false
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("BOOL_FALSE=\n")
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
assert "BOOL_FALSE=false" in env_content
assert "BOOL_FALSE=False" not in env_content
def test_boolean_parsing_from_env(self, tmp_path):
"""Test that various boolean formats are parsed correctly."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.BOOL_VAR]
type = "boolean"
source = "env"
default = false
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("BOOL_VAR=\n")
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
# Test different boolean formats
test_cases = [
("true", True),
("TRUE", True),
("True", True),
("1", True),
("yes", True),
("false", False),
("FALSE", False),
("False", False),
("0", False),
("no", False),
]
for input_val, expected in test_cases:
source.env_path.write_text(f"BOOL_VAR={input_val}\n")
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper._load_sources()
bootstrapper._resolve_settings()
assert bootstrapper.resolved_settings["BOOL_VAR"] == expected
class TestComputedVariables:
"""Test that computed variables are calculated correctly."""
def test_computed_variables_written_to_env(self, test_toml_with_all_types):
"""Test that computed variables are written with their computed values."""
from alfred.settings_schema import load_schema
schema = load_schema(test_toml_with_all_types)
source = ConfigSource.from_base_dir(test_toml_with_all_types)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# Computed variable should have its computed value
assert "COMPUTED_VAR=default_string_42" in env_content
def test_computed_uri_example(self, tmp_path):
"""Test computed URI (like MONGO_URI) is written correctly."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.DB_HOST]
type = "string"
source = "env"
default = "localhost"
category = "database"
[tool.alfred.settings_schema.DB_PORT]
type = "integer"
source = "env"
default = 5432
category = "database"
[tool.alfred.settings_schema.DB_USER]
type = "string"
source = "env"
default = "user"
category = "database"
[tool.alfred.settings_schema.DB_PASSWORD]
type = "secret"
source = "generated"
secret_rule = "16:hex"
category = "security"
[tool.alfred.settings_schema.DB_URI]
type = "computed"
source = "computed"
compute_from = ["DB_USER", "DB_PASSWORD", "DB_HOST", "DB_PORT"]
compute_template = "postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/db"
category = "database"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text(
"DB_HOST=\nDB_PORT=\nDB_USER=\nDB_PASSWORD=\nDB_URI=\n"
)
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
# Check URI is computed and written
assert "DB_URI=postgresql://user:" in env_content
assert "@localhost:5432/db" in env_content
# Extract password from URI to verify it's the same as DB_PASSWORD
import re
uri_match = re.search(r"DB_URI=postgresql://user:([^@]+)@", env_content)
password_match = re.search(r"DB_PASSWORD=([^\n]+)", env_content)
assert uri_match and password_match
assert uri_match.group(1) == password_match.group(1)
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_missing_env_example(self, tmp_path):
"""Test that missing .env.example raises error."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.TEST_VAR]
type = "string"
source = "env"
default = "test"
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
bootstrapper = SettingsBootstrap(source, schema)
with pytest.raises(FileNotFoundError, match=".env.example not found"):
bootstrapper.bootstrap()
def test_empty_env_example(self, tmp_path):
"""Test that empty .env.example works."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.TEST_VAR]
type = "string"
source = "env"
default = "test"
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("")
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
# Should create .env even if .env.example is empty
assert source.env_path.exists()
def test_variable_with_equals_in_value(self, tmp_path):
"""Test that variables with '=' in their value are handled correctly."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.URL_VAR]
type = "string"
source = "env"
default = "http://example.com?key=value"
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("URL_VAR=\n")
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
bootstrapper = SettingsBootstrap(source, schema)
bootstrapper.bootstrap()
env_content = source.env_path.read_text()
assert "URL_VAR=http://example.com?key=value" in env_content
def test_preserves_existing_values_on_update(self, tmp_path):
"""Test that existing values are preserved when updating."""
pyproject_content = """
[tool.poetry]
name = "test"
version = "1.0.0"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.VAR1]
type = "string"
source = "env"
default = "default1"
category = "test"
[tool.alfred.settings_schema.VAR2]
type = "string"
source = "env"
default = "default2"
category = "test"
"""
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("VAR1=\nVAR2=\n")
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
# First bootstrap
bootstrapper1 = SettingsBootstrap(source, schema)
bootstrapper1.bootstrap()
# Modify values
source.env_path.write_text("VAR1=custom1\nVAR2=custom2\n")
# Second bootstrap
bootstrapper2 = SettingsBootstrap(source, schema)
bootstrapper2.bootstrap()
env_content = source.env_path.read_text()
# Custom values should be preserved
assert "VAR1=custom1" in env_content
assert "VAR2=custom2" in env_content
class TestIntegration:
"""Integration tests with realistic scenarios."""
def test_full_workflow_like_alfred(self, tmp_path):
"""Test a full workflow similar to Alfred's actual usage."""
pyproject_content = """
[tool.poetry]
name = "alfred"
version = "0.1.7"
[tool.poetry.dependencies]
python = "^3.14"
"""
(tmp_path / "pyproject.toml").write_text(pyproject_content)
settings_content = """
[tool.alfred.settings_schema.ALFRED_VERSION]
type = "string"
source = "toml"
toml_path = "tool.poetry.version"
category = "build"
export_to_env_make = true
[tool.alfred.settings_schema.HOST]
type = "string"
source = "env"
default = "0.0.0.0"
category = "app"
[tool.alfred.settings_schema.PORT]
type = "integer"
source = "env"
default = 3080
category = "app"
[tool.alfred.settings_schema.JWT_SECRET]
type = "secret"
source = "generated"
secret_rule = "32:hex"
category = "security"
[tool.alfred.settings_schema.MONGO_HOST]
type = "string"
source = "env"
default = "mongodb"
category = "database"
[tool.alfred.settings_schema.MONGO_PASSWORD]
type = "secret"
source = "generated"
secret_rule = "16:hex"
category = "security"
[tool.alfred.settings_schema.MONGO_URI]
type = "computed"
source = "computed"
compute_from = ["MONGO_HOST", "MONGO_PASSWORD"]
compute_template = "mongodb://user:{MONGO_PASSWORD}@{MONGO_HOST}:27017/db"
category = "database"
[tool.alfred.settings_schema.DEBUG_MODE]
type = "boolean"
source = "env"
default = false
category = "app"
"""
(tmp_path / "settings.toml").write_text(settings_content)
env_example_content = """# Application settings
HOST=0.0.0.0
PORT=3080
DEBUG_MODE=false
# Security
JWT_SECRET=
# Database
MONGO_HOST=mongodb
MONGO_PASSWORD=
MONGO_URI=
# Build info
ALFRED_VERSION=
"""
(tmp_path / ".env.example").write_text(env_example_content)
from alfred.settings_schema import load_schema
schema = load_schema(tmp_path)
source = ConfigSource.from_base_dir(tmp_path)
# First bootstrap
bootstrapper1 = SettingsBootstrap(source, schema)
bootstrapper1.bootstrap()
env_content_1 = source.env_path.read_text()
# Verify structure
assert "# Application settings" in env_content_1
assert "HOST=0.0.0.0" in env_content_1
assert "PORT=3080" in env_content_1
assert "DEBUG_MODE=false" in env_content_1 # lowercase!
assert "ALFRED_VERSION=0.1.7" in env_content_1
assert "JWT_SECRET=" in env_content_1
assert (
len(
[line for line in env_content_1.split("\n") if "JWT_SECRET=" in line][0]
)
> 20
)
assert "MONGO_URI=mongodb://user:" in env_content_1
# Second bootstrap - should preserve everything
bootstrapper2 = SettingsBootstrap(source, schema)
bootstrapper2.bootstrap()
env_content_2 = source.env_path.read_text()
# Everything should be identical
assert env_content_1 == env_content_2
# Add custom variable
with open(source.env_path, "a") as f:
f.write("\nMY_CUSTOM_SETTING=test123\n")
# Third bootstrap - should preserve custom
bootstrapper3 = SettingsBootstrap(source, schema)
bootstrapper3.bootstrap()
env_content_3 = source.env_path.read_text()
assert "MY_CUSTOM_SETTING=test123" in env_content_3
assert "# --- CUSTOM VARIABLES ---" in env_content_3
+2 -2
View File
@@ -60,7 +60,7 @@ def create_schema_file(tmp_path):
"""Factory to create pyproject.toml with schema."""
def _create(content: str):
toml_path = tmp_path / "settings.toml"
toml_path = tmp_path / "pyproject.toml"
full_content = f"""
[tool.poetry]
name = "test"
@@ -188,7 +188,7 @@ default = true
def test_missing_schema_section(self, tmp_path):
"""Test error when schema section is missing."""
toml_path = tmp_path / "settings.toml"
toml_path = tmp_path / "pyproject.toml"
toml_path.write_text("""
[tool.poetry]
name = "test"