8 Commits

8 changed files with 129 additions and 104 deletions

View File

@@ -27,7 +27,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
pip install $RUNNER
# Set working directory for dependency installation
WORKDIR /tmp
WORKDIR /app
# Copy dependency files
COPY pyproject.toml poetry.lock* uv.lock* Makefile ./
@@ -43,8 +43,10 @@ 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
@@ -62,8 +64,6 @@ RUN --mount=type=cache,target=/root/.cache/pip \
uv pip install --system -e .[dev]; \
fi
COPY alfred/ ./alfred
COPY scripts ./scripts
COPY tests/ ./tests
# ===========================================
@@ -120,4 +120,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"]

View File

@@ -41,10 +41,10 @@ def _get_secret_factory(rule: str):
class Settings(BaseSettings):
"""
Application settings.
Application settings management.
Settings are loaded from .env and validated using the schema
defined in pyproject.toml.
Initializes configuration with internal defaults from the settings module,
then overrides them with environment variables loaded from a .env file.
"""
model_config = SettingsConfigDict(

View File

@@ -185,39 +185,26 @@ def load_schema(base_dir: Path | None = None) -> SettingsSchema:
if base_dir is None:
base_dir = BASE_DIR
# Try settings.toml first (cleaner, dedicated file)
# Load from settings.toml (required)
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():
if not settings_toml_path.exists():
raise FileNotFoundError(
f"Neither settings.toml nor pyproject.toml found in {base_dir}"
f"settings.toml not found at {settings_toml_path}. "
f"This file is required and must be present in the application root."
)
with open(toml_path, "rb") as f:
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 pyproject.toml"
f"Missing [tool.alfred.settings_schema] section in settings.toml at {settings_toml_path}"
) from e
return SettingsSchema(schema_dict)
def validate_value(definition: SettingDefinition, value: Any) -> bool:
"""

View File

@@ -17,6 +17,7 @@ services:
# --- MAIN APPLICATION ---
alfred:
container_name: alfred-core
image: alfred_media_organizer:latest
build:
context: .
args:
@@ -34,7 +35,7 @@ services:
- ./data:/data
- ./logs:/logs
# TODO: Hot reload (comment out in production)
- ./alfred:/home/appuser/alfred
#- ./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:
@@ -169,7 +170,7 @@ services:
- ./data/vectordb:/var/lib/postgresql/data
profiles: ["rag", "full"]
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-alfred} -d $${POSTGRES_DB_NAME:-alfred}" ]
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB_NAME}" ]
interval: 5s
timeout: 5s
retries: 5

12
poetry.lock generated
View File

@@ -74,13 +74,13 @@ wcmatch = ">=8.5.1"
[[package]]
name = "certifi"
version = "2025.11.12"
version = "2026.1.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
{file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
]
[[package]]
@@ -394,13 +394,13 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand
[[package]]
name = "filelock"
version = "3.20.1"
version = "3.20.2"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
files = [
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
{file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"},
{file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"},
]
[[package]]

View File

@@ -12,8 +12,8 @@ from alfred.settings_bootstrap import (
@pytest.fixture
def test_toml_content():
"""Test TOML content with schema."""
def test_pyproject_content():
"""Test pyproject.toml content with poetry metadata."""
return """
[tool.poetry]
name = "test"
@@ -25,7 +25,13 @@ 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"
@@ -72,11 +78,15 @@ def config_source(tmp_path):
@pytest.fixture
def create_test_env(tmp_path, test_toml_content):
def create_test_env(tmp_path, test_pyproject_content, test_settings_content):
"""Create a complete test environment."""
# Create pyproject.toml
toml_path = tmp_path / "pyproject.toml"
toml_path.write_text(test_toml_content)
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)
# Create .env.example
env_example = tmp_path / ".env.example"
@@ -330,11 +340,17 @@ 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_toml_content):
def test_validation_error(
self, tmp_path, test_pyproject_content, test_settings_content
):
"""Test validation error is raised."""
# Add a setting with validation
toml_with_validation = (
test_toml_content
# 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
+ """
[tool.alfred.settings_schema.TEST_VALIDATED]
type = "integer"
@@ -345,8 +361,8 @@ description = "Validated setting"
category = "test"
"""
)
toml_path = tmp_path / "pyproject.toml"
toml_path.write_text(toml_with_validation)
settings_path = tmp_path / "settings.toml"
settings_path.write_text(settings_with_validation)
env_example = tmp_path / ".env.example"
env_example.write_text("TEST_VALIDATED=\n")
@@ -364,9 +380,10 @@ 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
toml_content = create_test_env.toml_path.read_text()
toml_content += """
# 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 += """
[tool.alfred.settings_schema.EXPORTED_VAR]
type = "string"
source = "env"
@@ -374,7 +391,7 @@ default = "exported"
export_to_env_make = true
category = "build"
"""
create_test_env.toml_path.write_text(toml_content)
settings_path.write_text(settings_content)
# Recreate schema
from alfred.settings_schema import load_schema

View File

@@ -8,14 +8,19 @@ from alfred.settings_bootstrap import ConfigSource, SettingsBootstrap
@pytest.fixture
def test_toml_with_all_types(tmp_path):
"""Create test TOML with all setting types."""
toml_content = """
# 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"
@@ -59,14 +64,9 @@ compute_template = "{STRING_VAR}_{INT_VAR}"
description = "Computed variable"
category = "test"
"""
toml_path = tmp_path / "pyproject.toml"
toml_path.write_text(toml_content)
return tmp_path
(tmp_path / "settings.toml").write_text(settings_content)
@pytest.fixture
def test_env_example(tmp_path):
"""Create .env.example template."""
# Create .env.example
env_example_content = """# Test configuration
STRING_VAR=
INT_VAR=
@@ -81,18 +81,18 @@ SECRET_VAR=
# Computed values
COMPUTED_VAR=
# Custom section
# Custom variable (not in schema)
CUSTOM_VAR=custom_value
"""
env_example_path = tmp_path / ".env.example"
env_example_path.write_text(env_example_content)
return env_example_path
(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_env_example):
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
@@ -110,7 +110,7 @@ class TestTemplatePreservation:
assert "# Security" in env_content
assert "# Computed values" in env_content
def test_preserves_empty_lines(self, test_toml_with_all_types, test_env_example):
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
@@ -126,7 +126,7 @@ class TestTemplatePreservation:
# Check there are empty lines (structure preserved)
assert "" in lines
def test_preserves_variable_order(self, test_toml_with_all_types, test_env_example):
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
@@ -150,9 +150,7 @@ class TestTemplatePreservation:
class TestSecretPreservation:
"""Test that secrets are never overwritten."""
def test_preserves_existing_secrets(
self, test_toml_with_all_types, test_env_example
):
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
@@ -186,11 +184,14 @@ class TestSecretPreservation:
def test_multiple_secrets_preserved(self, tmp_path):
"""Test that multiple secrets are all preserved."""
toml_content = """
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"
@@ -209,7 +210,7 @@ source = "generated"
secret_rule = "8:hex"
category = "security"
"""
(tmp_path / "pyproject.toml").write_text(toml_content)
(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
@@ -236,9 +237,7 @@ category = "security"
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_env_example
):
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
@@ -264,9 +263,7 @@ class TestCustomVariables:
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_env_example
):
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
@@ -285,9 +282,7 @@ class TestCustomVariables:
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_env_example
):
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
@@ -306,9 +301,7 @@ class TestCustomVariables:
class TestBooleanHandling:
"""Test that booleans are handled correctly."""
def test_booleans_written_as_lowercase(
self, test_toml_with_all_types, test_env_example
):
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
@@ -327,18 +320,21 @@ class TestBooleanHandling:
def test_false_boolean_written_as_lowercase(self, tmp_path):
"""Test that False is written as 'false'."""
toml_content = """
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 / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("BOOL_FALSE=\n")
from alfred.settings_schema import load_schema
@@ -356,18 +352,21 @@ category = "test"
def test_boolean_parsing_from_env(self, tmp_path):
"""Test that various boolean formats are parsed correctly."""
toml_content = """
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 / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("BOOL_VAR=\n")
from alfred.settings_schema import load_schema
@@ -402,9 +401,7 @@ category = "test"
class TestComputedVariables:
"""Test that computed variables are calculated correctly."""
def test_computed_variables_written_to_env(
self, test_toml_with_all_types, test_env_example
):
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
@@ -421,11 +418,14 @@ class TestComputedVariables:
def test_computed_uri_example(self, tmp_path):
"""Test computed URI (like MONGO_URI) is written correctly."""
toml_content = """
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"
@@ -457,7 +457,7 @@ 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 / "pyproject.toml").write_text(toml_content)
(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"
)
@@ -491,18 +491,21 @@ class TestEdgeCases:
def test_missing_env_example(self, tmp_path):
"""Test that missing .env.example raises error."""
toml_content = """
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 / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
from alfred.settings_schema import load_schema
@@ -516,18 +519,21 @@ category = "test"
def test_empty_env_example(self, tmp_path):
"""Test that empty .env.example works."""
toml_content = """
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 / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("")
from alfred.settings_schema import load_schema
@@ -543,18 +549,21 @@ category = "test"
def test_variable_with_equals_in_value(self, tmp_path):
"""Test that variables with '=' in their value are handled correctly."""
toml_content = """
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 / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("URL_VAR=\n")
from alfred.settings_schema import load_schema
@@ -571,11 +580,14 @@ category = "test"
def test_preserves_existing_values_on_update(self, tmp_path):
"""Test that existing values are preserved when updating."""
toml_content = """
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"
@@ -588,7 +600,7 @@ source = "env"
default = "default2"
category = "test"
"""
(tmp_path / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
(tmp_path / ".env.example").write_text("VAR1=\nVAR2=\n")
from alfred.settings_schema import load_schema
@@ -619,14 +631,17 @@ class TestIntegration:
def test_full_workflow_like_alfred(self, tmp_path):
"""Test a full workflow similar to Alfred's actual usage."""
toml_content = """
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"
@@ -677,7 +692,7 @@ source = "env"
default = false
category = "app"
"""
(tmp_path / "pyproject.toml").write_text(toml_content)
(tmp_path / "settings.toml").write_text(settings_content)
env_example_content = """# Application settings
HOST=0.0.0.0
@@ -715,7 +730,12 @@ ALFRED_VERSION=
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([l for l in env_content_1.split("\n") if "JWT_SECRET=" in l][0]) > 20
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

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 / "pyproject.toml"
toml_path = tmp_path / "settings.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 / "pyproject.toml"
toml_path = tmp_path / "settings.toml"
toml_path.write_text("""
[tool.poetry]
name = "test"