7 Commits

8 changed files with 104 additions and 129 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 /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
# ===========================================
@@ -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 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(

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

View File

@@ -17,7 +17,6 @@ services:
# --- MAIN APPLICATION ---
alfred:
container_name: alfred-core
image: alfred_media_organizer:latest
build:
context: .
args:
@@ -35,7 +34,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:
@@ -170,7 +169,7 @@ 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

12
poetry.lock generated
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]]

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

View File

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