From 3bed5e790a2068d7e173b69b911ebf1bb287b4f8 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 11 Jun 2026 21:07:58 +0200 Subject: [PATCH 1/3] unify production app configuration and ML routes --- app/api/v1/entities.py | 26 +++------------ app/config.py | 2 ++ app/main.py | 34 +++++++++----------- backend/app.py | 8 ++--- backend/routes/ml.py | 63 ++++++++++++++++++++++--------------- tests/api/test_entities.py | 2 -- tests/api/test_ml_routes.py | 50 +++++++++++++++++++++++++++++ tests/test_config.py | 16 ++++++++++ 8 files changed, 127 insertions(+), 74 deletions(-) create mode 100644 tests/api/test_ml_routes.py create mode 100644 tests/test_config.py diff --git a/app/api/v1/entities.py b/app/api/v1/entities.py index e05ad73..2c08dac 100644 --- a/app/api/v1/entities.py +++ b/app/api/v1/entities.py @@ -2,38 +2,20 @@ from __future__ import annotations from typing import List -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends +from app.dependencies import get_ha_reader from app.ha.models import HaEntitySummary from app.ha.reader import HaReader -from app.rules.recommender import Recommender router = APIRouter(prefix="/v1", tags=["entities"]) -def _state_ha_reader(request: Request) -> HaReader: - try: - return request.app.state.ha_reader - except AttributeError as exc: - raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc - - -def _state_recommender(request: Request) -> Recommender: - try: - return request.app.state.recommender - except AttributeError as exc: - raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc - - @router.get( "/entities", summary="Home-Assistant-Entities auflisten", description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.", response_model=List[HaEntitySummary], ) -def list_entities(request: Request) -> List[HaEntitySummary]: - ha_reader = _state_ha_reader(request) - recommender = _state_recommender(request) - entities = ha_reader.read_entities() - recommender.run(entities) - return entities \ No newline at end of file +def list_entities(ha_reader: HaReader = Depends(get_ha_reader)) -> List[HaEntitySummary]: + return list(ha_reader.read_entities()) diff --git a/app/config.py b/app/config.py index fd9d619..24de378 100644 --- a/app/config.py +++ b/app/config.py @@ -8,6 +8,7 @@ from dataclasses import dataclass class Settings: ha_url: str | None = None ha_token: str | None = None + model_store: str = ".model_store" @property def ha_configured(self) -> bool: @@ -18,4 +19,5 @@ def load_settings() -> Settings: return Settings( ha_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"), ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"), + model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"), ) diff --git a/app/main.py b/app/main.py index 4d8c33c..592c78a 100644 --- a/app/main.py +++ b/app/main.py @@ -3,43 +3,39 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from app.api.v1.entities import router as entities_router +from app.config import load_settings from app.core.exception_handlers import register_exception_handlers from app.ha.client import HaClient, HaClientSettings from app.ha.reader import HaReader -from app.rules.recommender import Recommender -from app.rules.heating import HeatingRule +from backend.routes.ml import init_ml_routes @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] settings = app.state.settings - ha_url = getattr(settings, "ha_url", None) - ha_token = getattr(settings, "ha_token", None) - client = HaClient( - settings=HaClientSettings( - url=ha_url or "", - token=ha_token or "", + if hasattr(app.state, "ha_reader"): + del app.state.ha_reader + if settings.ha_configured: + client = HaClient( + settings=HaClientSettings( + url=settings.ha_url, + token=settings.ha_token, + ) ) - ) - app.state.ha_reader = HaReader(client=client) - app.state.recommender = Recommender(rules=[HeatingRule()]) + app.state.ha_reader = HaReader(client=client) yield -class Settings: - ha_url: str = "http://localhost:8123" - ha_token: str = "" - - app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", version="0.1.0", lifespan=lifespan, ) -app.state.settings = Settings() +app.state.settings = load_settings() register_exception_handlers(app) app.include_router(entities_router) +init_ml_routes(app, model_store=app.state.settings.model_store) @app.get("/health") @@ -49,4 +45,4 @@ def health() -> dict[str, str]: @app.get("/") def root() -> dict[str, str]: - return {"service": "sillyhome-next", "docs": "/docs"} \ No newline at end of file + return {"service": "sillyhome-next", "docs": "/docs"} diff --git a/backend/app.py b/backend/app.py index af0d9a3..e61d761 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,14 +9,10 @@ from app.ml.feature_store import FeatureStore, FeatureVector def create_app() -> FastAPI: application = FastAPI(title="SillyHome Next ML") init_ml_routes(application) - _seed_default_model(application.state if hasattr(application, "state") else application) + _seed_default_model(application.state) return application -def _app_state(): # noqa: ANN001 - return app.state - - def _seed_default_model(state) -> None: # noqa: ANN001 registry = getattr(state, "registry", None) if registry is None: @@ -34,4 +30,4 @@ def _seed_default_model(state) -> None: # noqa: ANN001 registry.register(artifact) -app = create_app() \ No newline at end of file +app = create_app() diff --git a/backend/routes/ml.py b/backend/routes/ml.py index 7044724..700c452 100644 --- a/backend/routes/ml.py +++ b/backend/routes/ml.py @@ -4,13 +4,12 @@ import logging from datetime import datetime, timezone from typing import List, Sequence -from fastapi import APIRouter +from fastapi import APIRouter, FastAPI, HTTPException, Request, status from pydantic import BaseModel, Field from app.ml.feature_store import FeatureVector from app.ml.predictor import Predictor from app.ml.registry.model_registry import ModelRegistry -from app.ml.training import TrainedArtifact logger = logging.getLogger(__name__) @@ -52,53 +51,67 @@ def health() -> HealthResponse: @router.get("/models", response_model=ModelsResponse, status_code=200) -def list_models() -> ModelsResponse: - registry = _require_registry() +def list_models(request: Request) -> ModelsResponse: + registry = _require_registry(request) models = [artifact.artifact_id for artifact in registry.list_models()] return ModelsResponse(models=models) @router.post("/predict", response_model=PredictResponse, status_code=200) -def predict(request: PredictRequest) -> PredictResponse: - registry = _require_registry() +def predict(payload: PredictRequest, request: Request) -> PredictResponse: + registry = _require_registry(request) predictor = Predictor(registry=registry) - vector = FeatureVector(sensor_id=request.sensor_id, values=request.values) + vector = FeatureVector(sensor_id=payload.sensor_id, values=payload.values) try: - prediction = predictor.predict(request.model_id, vector) - except KeyError as exc: # unknown artifact - raise _not_found_error(str(exc)) from exc - return PredictResponse(model_id=request.model_id, sensor_id=request.sensor_id, prediction=prediction) + prediction = predictor.predict(payload.model_id, vector) + except KeyError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return PredictResponse( + model_id=payload.model_id, + sensor_id=payload.sensor_id, + prediction=prediction, + ) @router.post("/batch", response_model=BatchResponse, status_code=200) -def predict_batch(request: BatchRequest) -> BatchResponse: - registry = _require_registry() +def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse: + registry = _require_registry(request) predictor = Predictor(registry=registry) responses: List[PredictResponse] = [] - for item in request.requests: + for item in payload.requests: vector = FeatureVector(sensor_id=item.sensor_id, values=item.values) try: prediction = predictor.predict(item.model_id, vector) except KeyError as exc: - raise _not_found_error(str(exc)) from exc + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc responses.append( PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction) ) return BatchResponse(predictions=responses) -def _require_registry() -> ModelRegistry: - from backend.app import _app_state - - state_obj = _app_state() - registry = getattr(state_obj, "registry", None) - if registry is None: - raise RuntimeError("ML registry nicht initialisiert.") +def _require_registry(request: Request) -> ModelRegistry: + registry = getattr(request.app.state, "registry", None) + if not isinstance(registry, ModelRegistry): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="ML registry nicht initialisiert.", + ) return registry -def init_ml_routes(app) -> None: # noqa: ANN001 - registry = ModelRegistry(".model_store") +def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None: + registry = ModelRegistry(model_store) app.state.registry = registry app.include_router(router) - logger.info("ML routes registered") \ No newline at end of file + logger.info("ML routes registered") diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index 6df2771..1c57112 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -49,8 +49,6 @@ def test_entities_returns_reader_data() -> None: def test_entities_returns_503_without_home_assistant_config() -> None: with TestClient(app) as client: - if hasattr(app.state, "ha_reader"): - delattr(app.state, "ha_reader") response = client.get("/v1/entities") assert response.status_code == 503 diff --git a/tests/api/test_ml_routes.py b/tests/api/test_ml_routes.py new file mode 100644 index 0000000..3358edb --- /dev/null +++ b/tests/api/test_ml_routes.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_ml_routes_are_exposed_by_production_app() -> None: + with TestClient(app) as client: + health = client.get("/ml/health") + models = client.get("/ml/models") + + assert health.status_code == 200 + assert models.status_code == 200 + assert models.json() == {"models": []} + + +def test_unknown_model_returns_404() -> None: + with TestClient(app) as client: + response = client.post( + "/ml/predict", + json={ + "modelId": "missing", + "sensor_id": "sensor.kitchen", + "values": {"temperature": 21.0}, + }, + ) + + assert response.status_code == 404 + + +def test_unsupported_sensor_returns_422(tmp_path) -> None: + from app.ml.registry.model_registry import ModelRegistry + from app.ml.training import TrainedArtifact + + registry = ModelRegistry(tmp_path) + registry.register(TrainedArtifact("default", ("sensor.kitchen",))) + + with TestClient(app) as client: + app.state.registry = registry + response = client.post( + "/ml/predict", + json={ + "modelId": "default", + "sensor_id": "sensor.unknown", + "values": {"temperature": 21.0}, + }, + ) + + assert response.status_code == 422 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d81ee63 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from app.config import load_settings + + +def test_load_settings_reads_documented_environment(monkeypatch) -> None: + monkeypatch.setenv("SILLYHOME_HA_URL", "http://ha.local:8123") + monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") + monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models") + + settings = load_settings() + + assert settings.ha_url == "http://ha.local:8123" + assert settings.ha_token == "secret" + assert settings.model_store == "/tmp/models" + assert settings.ha_configured From 471146761eb0053d1ff1886ae9933a0d9e00dc24 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 11 Jun 2026 21:06:39 +0200 Subject: [PATCH 2/3] harden model registry persistence and evaluation --- app/ml/evaluation.py | 31 +++++++++++------- app/ml/registry/model_registry.py | 53 ++++++++++++++++++++++++++++--- tests/ml/test_evaluation.py | 26 +++++++++++++-- tests/ml/test_model_registry.py | 38 ++++++++++++++++++++++ 4 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 tests/ml/test_model_registry.py diff --git a/app/ml/evaluation.py b/app/ml/evaluation.py index f0bcb2a..e163ebf 100644 --- a/app/ml/evaluation.py +++ b/app/ml/evaluation.py @@ -1,11 +1,10 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field -from typing import Sequence +from collections.abc import Sequence +from dataclasses import dataclass -from app.ml.feature_store import FeatureVector -from app.ml.training import TrainingPipeline, TrainedArtifact +from app.ml.training import TrainingPipeline logger = logging.getLogger(__name__) @@ -29,15 +28,16 @@ class Evaluator: self._pipeline = pipeline def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport: - artifacts = list(self._pipeline._artifacts) - if not artifacts: - raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") + try: + supported_sensors = set(self._pipeline.export(artifact_id).supported_sensors) + except KeyError as exc: + raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc - supported_sensors = self._pipeline.export(artifact_id).supported_sensors - unknown_hits = sum(1 for prediction in predictions if ":" not in prediction) - supported_references = sum(1 for sensor in supported_sensors for prediction in predictions if sensor in prediction) + parsed_sensors = [_prediction_sensor(prediction) for prediction in predictions] + supported_hits = sum(sensor in supported_sensors for sensor in parsed_sensors) + unknown_hits = sum(sensor not in supported_sensors for sensor in parsed_sensors) sample_size = len(predictions) - coverage = supported_references / sample_size if sample_size else 0.0 + coverage = supported_hits / sample_size if sample_size else 0.0 unknown_rate = unknown_hits / sample_size if sample_size else 0.0 coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8) @@ -54,4 +54,11 @@ class Evaluator: coverage, unknown_rate, ) - return report \ No newline at end of file + return report + + +def _prediction_sensor(prediction: str) -> str | None: + parts = prediction.split(":", 2) + if len(parts) != 3 or not parts[0] or not parts[1]: + return None + return parts[1] diff --git a/app/ml/registry/model_registry.py b/app/ml/registry/model_registry.py index a236d24..94d2eac 100644 --- a/app/ml/registry/model_registry.py +++ b/app/ml/registry/model_registry.py @@ -1,26 +1,34 @@ from __future__ import annotations +import json import logging +import os from pathlib import Path -from typing import Iterable +import re +from collections.abc import Iterable from app.ml.training import TrainedArtifact logger = logging.getLogger(__name__) +_ARTIFACT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + class ModelRegistry: def __init__(self, root: str | Path) -> None: - self._root = Path(root) + self._root = Path(root).resolve() self._root.mkdir(parents=True, exist_ok=True) self._artifacts: dict[str, TrainedArtifact] = {} + self._load_existing() def register(self, artifact: TrainedArtifact) -> TrainedArtifact: + self._validate_artifact_id(artifact.artifact_id) self._artifacts[artifact.artifact_id] = artifact self._persist(artifact) return artifact def load_artifact(self, artifact_id: str) -> TrainedArtifact: + self._validate_artifact_id(artifact_id) if artifact_id not in self._artifacts: raise KeyError(f"Artifact '{artifact_id}' nicht registriert.") return self._artifacts[artifact_id] @@ -28,10 +36,45 @@ class ModelRegistry: def list_models(self) -> Iterable[TrainedArtifact]: return list(self._artifacts.values()) + def _load_existing(self) -> None: + for source in sorted(self._root.glob("*.json")): + try: + raw = json.loads(source.read_text(encoding="utf-8")) + artifact_id = raw["artifact_id"] + supported_sensors = raw["supported_sensors"] + if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list): + raise ValueError("invalid artifact structure") + self._validate_artifact_id(artifact_id) + if source.name != f"{artifact_id}.json": + raise ValueError("artifact id does not match filename") + if not all(isinstance(sensor, str) for sensor in supported_sensors): + raise ValueError("supported_sensors must contain strings") + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc + + self._artifacts[artifact_id] = TrainedArtifact( + artifact_id=artifact_id, + supported_sensors=tuple(supported_sensors), + ) + def _persist(self, artifact: TrainedArtifact) -> None: target = self._root / f"{artifact.artifact_id}.json" - target.write_text( - f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n", + temporary = target.with_suffix(".json.tmp") + payload = { + "artifact_id": artifact.artifact_id, + "supported_sensors": list(artifact.supported_sensors), + } + temporary.write_text( + json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8", ) - logger.info("Modell gespeichert: %s", target) \ No newline at end of file + os.replace(temporary, target) + logger.info("Modell gespeichert: %s", target) + + @staticmethod + def _validate_artifact_id(artifact_id: str) -> None: + if not _ARTIFACT_ID_PATTERN.fullmatch(artifact_id) or ".." in artifact_id: + raise ValueError( + "artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich " + "und Bindestrich enthalten." + ) diff --git a/tests/ml/test_evaluation.py b/tests/ml/test_evaluation.py index fac9894..d54182a 100644 --- a/tests/ml/test_evaluation.py +++ b/tests/ml/test_evaluation.py @@ -21,13 +21,35 @@ def evaluator_factory() -> Evaluator: def test_evaluate_returns_report_with_metrics() -> None: evaluator = evaluator_factory() - report = evaluator.evaluate("artifact_v1", ["artifact_v1:sensor.kitchen:{'temperature': 21.0}", "artifact_v1:sensor.bedroom:{'temperature': 18.5}"]) + report = evaluator.evaluate( + "artifact_v1", + [ + "artifact_v1:sensor.kitchen:{'temperature': 21.0}", + "artifact_v1:sensor.bedroom:{'temperature': 18.5}", + ], + ) assert report.artifact_id == "artifact_v1" assert report.sample_size == 2 assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"} + assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0 def test_evaluate_without_training_raises_value_error() -> None: evaluator = Evaluator(TrainingPipeline(FeatureStore())) with pytest.raises(ValueError): - evaluator.evaluate("artifact_v1", []) \ No newline at end of file + evaluator.evaluate("artifact_v1", []) + + +def test_coverage_is_bounded_and_requires_exact_sensor_match() -> None: + evaluator = evaluator_factory() + report = evaluator.evaluate( + "artifact_v1", + [ + "artifact_v1:sensor.kitchen:{'note': 'sensor.bedroom'}", + "artifact_v1:sensor.kitchen_extra:{}", + "malformed", + ], + ) + + metrics = {metric.name: metric.value for metric in report.metrics} + assert metrics == {"coverage": pytest.approx(1 / 3), "unknown_rate": pytest.approx(2 / 3)} diff --git a/tests/ml/test_model_registry.py b/tests/ml/test_model_registry.py new file mode 100644 index 0000000..3558ae8 --- /dev/null +++ b/tests/ml/test_model_registry.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json + +import pytest + +from app.ml.registry.model_registry import ModelRegistry +from app.ml.training import TrainedArtifact + + +def test_registry_loads_persisted_artifacts_after_restart(tmp_path) -> None: + registry = ModelRegistry(tmp_path) + artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom")) + registry.register(artifact) + + restarted = ModelRegistry(tmp_path) + + assert restarted.load_artifact("model-v1") == artifact + + +@pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""]) +def test_registry_rejects_unsafe_artifact_ids(tmp_path, artifact_id: str) -> None: + registry = ModelRegistry(tmp_path) + + with pytest.raises(ValueError): + registry.register(TrainedArtifact(artifact_id, ("sensor.kitchen",))) + + assert list(tmp_path.parent.glob("escape.json")) == [] + + +def test_registry_rejects_corrupt_persisted_artifact(tmp_path) -> None: + (tmp_path / "broken.json").write_text( + json.dumps({"artifact_id": "../broken", "supported_sensors": []}), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="broken.json"): + ModelRegistry(tmp_path) From aaf319ff1471a0eaeaa013bc63042713e00e19c7 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 11 Jun 2026 21:14:07 +0200 Subject: [PATCH 3/3] harden delivery pipeline and production runtime --- .dockerignore | 3 +-- .env.example | 3 ++- .gitea/workflows/quality.yml | 24 ++++++++++++++++++ CHANGELOG.md | 5 ++++ Dockerfile | 24 +++++++++++++----- README.md | 42 +++++++++++++++++++++++-------- app/ha/client.py | 7 ++++-- app/ha/reader.py | 5 ++-- app/main.py | 17 ++++++++++--- app/ml/__init__.py | 2 +- app/ml/feature_store.py | 6 ++--- app/ml/registry/model_registry.py | 2 +- app/ml/training.py | 5 ++-- backend/__init__.py | 1 + backend/app.py | 18 ++++++++++--- backend/routes/__init__.py | 1 + backend/routes/ml.py | 11 ++++---- docker-compose.yml | 21 +++++++++++++--- docs/ml_api.md | 22 ++++++++++------ docs/ml_training.md | 13 +++++++--- pyproject.toml | 8 ++++++ tests/api/test_ml_routes.py | 6 +++-- tests/ml/test_feature_store.py | 14 +++++++---- tests/ml/test_model_registry.py | 7 +++--- tests/ml/test_training.py | 4 +-- tests/test_config.py | 4 ++- 26 files changed, 202 insertions(+), 73 deletions(-) create mode 100644 .gitea/workflows/quality.yml create mode 100644 backend/__init__.py create mode 100644 backend/routes/__init__.py diff --git a/.dockerignore b/.dockerignore index 12a23fc..6926101 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,5 @@ node_modules .vscode .git .gitignore -README.md .dockerignore -docker-compose*.yml \ No newline at end of file +docker-compose*.yml diff --git a/.env.example b/.env.example index 8969042..22758df 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ SILLYHOME_HA_URL=http://homeassistant.local:8123 -SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN \ No newline at end of file +SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN +SILLYHOME_MODEL_STORE=.model_store diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml new file mode 100644 index 0000000..8bac343 --- /dev/null +++ b/.gitea/workflows/quality.yml @@ -0,0 +1,24 @@ +name: quality + +on: + push: + branches: ["main", "otto/**", "feature/**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: python -m pytest + - run: ruff check . + - run: mypy diff --git a/CHANGELOG.md b/CHANGELOG.md index f39fa92..bfea698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,8 @@ ## Unreleased - Projektinitiierung - Architektur, ADRs und Roadmap +- Einheitliche produktive FastAPI-App für HA- und ML-Routen +- Funktionierende ENV-Konfiguration und sauberer HA-503-Zustand +- Persistente, validierte und gegen Path Traversal gehärtete Model Registry +- Reproduzierbares Packaging, CI-Gates und gehärteter non-root Container +- Definierte API-Fehler und korrigierte Evaluationsmetriken diff --git a/Dockerfile b/Dockerfile index fc7cc17..7194abe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,27 @@ FROM python:3.13-slim -ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + SILLYHOME_MODEL_STORE=/app/data/models WORKDIR /app -COPY pyproject.toml ./ -RUN python -m pip install --upgrade pip && \ - pip install --no-cache-dir -e ".[dev]" +RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome -COPY . . +COPY pyproject.toml README.md ./ +COPY app ./app +COPY backend ./backend +RUN python -m pip install --upgrade pip && \ + python -m pip install . && \ + mkdir -p /app/data/models && \ + chown -R sillyhome:sillyhome /app/data EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +USER sillyhome + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 13da62c..b88623c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # SillyHome Next -Modern, lokal-first und datenschutzfreundliches Smart-Home-Intelligenzsystem für Home Assistant. +Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant. + +## Reifegrad + +Version `0.1.0` stellt eine gehärtete technische Basis bereit: Home-Assistant-Entities +lesen, regelbasierte Bausteine und eine persistente Modell-Artefakt-Registry. Die +aktuelle Trainings- und Vorhersagelogik ist noch eine deterministische +Schnittstellen-Implementierung und **kein produktives Machine-Learning-Modell**. ## Motivation TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit. @@ -13,14 +20,12 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens - Lokal-first ohne Cloudpflicht - Erweiterbar, testbar, dokumentiert -## APPENDIX - -### Quickstart +## Quickstart 1. Python-Venv anlegen und Abhängigkeiten installieren: ```bash python -m venv .venv source .venv/bin/activate -pip install -e . +pip install -e ".[dev]" ``` 2. Konfiguration aus `.env.example` übernehmen und anpassen: @@ -37,17 +42,32 @@ uvicorn app.main:app --reload - `http://127.0.0.1:8000/health` - Health-Check - `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation - `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities -- `http://127.0.0.1:8000/ml/health` - ML-Serving Health (ab ML-005) +- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health + +Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`. + +### Docker Compose + +```bash +cp .env.example .env +docker compose up --build -d +curl --fail http://127.0.0.1:8000/health +``` + +Compose veröffentlicht die API standardmäßig nur auf `127.0.0.1`. Für Zugriff aus +dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden. ### ENV-Konfiguration (`.env.example`) - `SILLYHOME_HA_URL` – Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`) -- `SILLYHOME_HA_TOKEN` – Long-Lived Access Token aus Home Assistant (nur lesen) +- `SILLYHOME_HA_TOKEN` – Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten +- `SILLYHOME_MODEL_STORE` – Verzeichnis für persistierte Modell-Metadaten -Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme. +Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins +Versionskontrollsystem. ### Tests ```bash -pytest -q +pytest ruff check . -mypy app tests -``` \ No newline at end of file +mypy +``` diff --git a/app/ha/client.py b/app/ha/client.py index 69dd1f2..a3d526d 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -31,10 +31,13 @@ class HaClient: "Content-Type": "application/json", }) + def close(self) -> None: + self._session.close() + def list_entities(self) -> list[dict[str, object]]: try: response = self._session.get( - f"{self._settings.url}/api/states", + f"{self._settings.url.rstrip('/')}/api/states", timeout=self._settings.timeout_seconds, ) except requests.Timeout as exc: @@ -71,4 +74,4 @@ class HaClient: "Antwort von Home Assistant hat unerwartetes Format." ) - return payload \ No newline at end of file + return payload diff --git a/app/ha/reader.py b/app/ha/reader.py index 828d28b..b4695d3 100644 --- a/app/ha/reader.py +++ b/app/ha/reader.py @@ -15,9 +15,10 @@ class HaReader: entities = self._client.list_entities() summaries: list[HaEntitySummary] = [] for item in entities: - entity_id = item.get("entity_id", "") - if "." not in entity_id: + raw_entity_id = item.get("entity_id") + if not isinstance(raw_entity_id, str) or "." not in raw_entity_id: continue + entity_id = raw_entity_id domain = entity_id.split(".", 1)[0] raw_attributes = item.get("attributes") or {} attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {} diff --git a/app/main.py b/app/main.py index 592c78a..49d55e8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from typing import cast from fastapi import FastAPI @@ -7,23 +9,30 @@ from app.config import load_settings from app.core.exception_handlers import register_exception_handlers from app.ha.client import HaClient, HaClientSettings from app.ha.reader import HaReader +from app.ml.registry.model_registry import ModelRegistry from backend.routes.ml import init_ml_routes @asynccontextmanager -async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] +async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = app.state.settings + client: HaClient | None = None + app.state.registry = ModelRegistry(settings.model_store) if hasattr(app.state, "ha_reader"): del app.state.ha_reader if settings.ha_configured: client = HaClient( settings=HaClientSettings( - url=settings.ha_url, - token=settings.ha_token, + url=cast(str, settings.ha_url), + token=cast(str, settings.ha_token), ) ) app.state.ha_reader = HaReader(client=client) - yield + try: + yield + finally: + if client is not None: + client.close() app = FastAPI( diff --git a/app/ml/__init__.py b/app/ml/__init__.py index c9e9f5e..c5c5510 100644 --- a/app/ml/__init__.py +++ b/app/ml/__init__.py @@ -1,5 +1,5 @@ """Machine-Learning-Grundbausteine für SillyHome Next.""" -__all__ = ["FeatureStore", "FeatureVector"] +__all__ = ["FeatureStore", "FeatureVector", "TrainedArtifact", "TrainingPipeline"] from app.ml.feature_store import FeatureStore, FeatureVector from app.ml.training import TrainedArtifact, TrainingPipeline diff --git a/app/ml/feature_store.py b/app/ml/feature_store.py index 9e2540d..f567c46 100644 --- a/app/ml/feature_store.py +++ b/app/ml/feature_store.py @@ -1,8 +1,8 @@ from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass, field -from typing import Iterable +from collections.abc import Iterable +from dataclasses import dataclass @dataclass(frozen=True) @@ -28,4 +28,4 @@ class FeatureStore: return series[-1] if series else None def all(self) -> list[FeatureVector]: - return [vector for vectors in self._vectors.values() for vector in vectors] \ No newline at end of file + return [vector for vectors in self._vectors.values() for vector in vectors] diff --git a/app/ml/registry/model_registry.py b/app/ml/registry/model_registry.py index 94d2eac..693e72f 100644 --- a/app/ml/registry/model_registry.py +++ b/app/ml/registry/model_registry.py @@ -23,8 +23,8 @@ class ModelRegistry: def register(self, artifact: TrainedArtifact) -> TrainedArtifact: self._validate_artifact_id(artifact.artifact_id) - self._artifacts[artifact.artifact_id] = artifact self._persist(artifact) + self._artifacts[artifact.artifact_id] = artifact return artifact def load_artifact(self, artifact_id: str) -> TrainedArtifact: diff --git a/app/ml/training.py b/app/ml/training.py index a31f5bd..c6d8bdf 100644 --- a/app/ml/training.py +++ b/app/ml/training.py @@ -2,9 +2,8 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Sequence -from app.ml.feature_store import FeatureVector, FeatureStore +from app.ml.feature_store import FeatureStore logger = logging.getLogger(__name__) @@ -34,4 +33,4 @@ class TrainingPipeline: def export(self, artifact_id: str) -> TrainedArtifact: if artifact_id not in self._artifacts: raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.") - return self._artifacts[artifact_id] \ No newline at end of file + return self._artifacts[artifact_id] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..54bab79 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Secondary application entry points for SillyHome Next.""" diff --git a/backend/app.py b/backend/app.py index e61d761..f27c18a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,4 +1,8 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI +from starlette.datastructures import State from backend.routes.ml import init_ml_routes from app.ml.registry.model_registry import ModelRegistry @@ -6,14 +10,20 @@ from app.ml.training import TrainingPipeline from app.ml.feature_store import FeatureStore, FeatureVector -def create_app() -> FastAPI: - application = FastAPI(title="SillyHome Next ML") - init_ml_routes(application) +@asynccontextmanager +async def lifespan(application: FastAPI) -> AsyncIterator[None]: + application.state.registry = ModelRegistry(application.state.model_store) _seed_default_model(application.state) + yield + + +def create_app() -> FastAPI: + application = FastAPI(title="SillyHome Next ML", lifespan=lifespan) + init_ml_routes(application) return application -def _seed_default_model(state) -> None: # noqa: ANN001 +def _seed_default_model(state: State) -> None: registry = getattr(state, "registry", None) if registry is None: registry = ModelRegistry(".model_store") diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/backend/routes/ml.py b/backend/routes/ml.py index 700c452..640f1f9 100644 --- a/backend/routes/ml.py +++ b/backend/routes/ml.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from datetime import datetime, timezone -from typing import List, Sequence +from collections.abc import Sequence from fastapi import APIRouter, FastAPI, HTTPException, Request, status from pydantic import BaseModel, Field @@ -24,7 +24,7 @@ class HealthResponse(BaseModel): class PredictRequest(BaseModel): model_id: str = Field(..., alias="modelId") sensor_id: str - values: dict + values: dict[str, float] class PredictResponse(BaseModel): @@ -42,7 +42,7 @@ class BatchResponse(BaseModel): class ModelsResponse(BaseModel): - models: List[str] + models: list[str] @router.get("/health", response_model=HealthResponse, status_code=200) @@ -82,7 +82,7 @@ def predict(payload: PredictRequest, request: Request) -> PredictResponse: def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse: registry = _require_registry(request) predictor = Predictor(registry=registry) - responses: List[PredictResponse] = [] + responses: list[PredictResponse] = [] for item in payload.requests: vector = FeatureVector(sensor_id=item.sensor_id, values=item.values) try: @@ -111,7 +111,6 @@ def _require_registry(request: Request) -> ModelRegistry: def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None: - registry = ModelRegistry(model_store) - app.state.registry = registry + app.state.model_store = model_store app.include_router(router) logger.info("ML routes registered") diff --git a/docker-compose.yml b/docker-compose.yml index 9c0385a..7a12712 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,22 @@ services: api: build: . ports: - - "8000:8000" + - "127.0.0.1:8000:8000" env_file: - - .env - restart: unless-stopped \ No newline at end of file + - path: .env + required: false + environment: + SILLYHOME_MODEL_STORE: /app/data/models + volumes: + - model-data:/app/data/models + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + +volumes: + model-data: diff --git a/docs/ml_api.md b/docs/ml_api.md index afced19..55ce0f3 100644 --- a/docs/ml_api.md +++ b/docs/ml_api.md @@ -1,6 +1,11 @@ # ML-Serving-API -Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHome Next. +Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen +Modell-Artefakt- und Vorhersage-Schnittstelle. + +> Hinweis: Version 0.1.0 enthält noch kein statistisch trainiertes ML-Modell. +> Die Vorhersage ist eine deterministische Referenzimplementierung für den +> späteren Modellvertrag. ## Basis-URL @@ -10,7 +15,8 @@ Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHo - Einzelvorhersage: `/predict` - Batchvorhersage: `/batch` -Der Standard-Start erfolgt über `uvicorn backend.app:app --reload`, danach steht die API unter `/ml` bereit. +Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und +ML-Routen in derselben Anwendung bereit. ## Endpoints @@ -101,16 +107,18 @@ Batch-Vorhersage für mehrere Sensorwerte. ## Fehlerfälle -- `400 Bad Request`: Fehlende oder ungültige Felder. -- `404 Not Found`: Modell oder Sensor nicht registriert. -- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler. +- `404 Not Found`: Modell nicht registriert. +- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig. +- `503 Service Unavailable`: Registry ist nicht initialisiert. ## Betrieb -Beim Start wird automatisch ein Default-Artefakt erstellt, falls noch kein Modell registriert ist. Neue Modelle müssen zusätzlich über `ModelRegistry.register(...)` eingetragen werden. +Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte +werden derzeit intern über `ModelRegistry.register(...)` registriert. Die +Registry speichert validiertes JSON atomisch und lädt es beim Neustart. ## Verweise - `app/ml/predictor.py` - `app/ml/registry/model_registry.py` -- `backend/routes/ml.py` \ No newline at end of file +- `backend/routes/ml.py` diff --git a/docs/ml_training.md b/docs/ml_training.md index ea6a94a..6957c4a 100644 --- a/docs/ml_training.md +++ b/docs/ml_training.md @@ -1,12 +1,14 @@ # ML Training- und Evaluations-Workflow -Dieser Workflow beschreibt, wie Modelle trainiert, evaluiert und an der Serving-Layer registriert werden. +Dieser Workflow beschreibt den aktuellen Platzhalter für Modell-Metadaten, +Evaluation und Serving. Er trainiert in Version 0.1.0 noch kein statistisches +Modell. ## 1. Daten sammeln Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen. -## 2. Modell trainieren +## 2. Artefakt-Metadaten erzeugen ```python store = FeatureStore() @@ -16,7 +18,9 @@ artifact = pipeline.run("my_artifact") pipeline.export("my_artifact") ``` -`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten Sensor-IDs. +`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten +Sensor-IDs. Gewichte, Parameter oder ein echtes Modell werden noch nicht +berechnet. ## 3. Modell evaluieren @@ -36,4 +40,5 @@ Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifac ## Hinweise - Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet. -- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus. \ No newline at end of file +- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus. +- `coverage` zählt nur exakte Sensor-Referenzen und bleibt im Bereich 0 bis 1. diff --git a/pyproject.toml b/pyproject.toml index cc8d904..3e662ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + [project] name = "sillyhome-next" version = "0.1.0" @@ -24,6 +28,10 @@ addopts = "-q" [tool.mypy] strict = true +files = ["app", "backend", "tests"] + +[tool.setuptools.packages.find] +include = ["app*", "backend*"] [tool.ruff] line-length = 100 diff --git a/tests/api/test_ml_routes.py b/tests/api/test_ml_routes.py index 3358edb..d7431ad 100644 --- a/tests/api/test_ml_routes.py +++ b/tests/api/test_ml_routes.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from fastapi.testclient import TestClient from app.main import app @@ -12,7 +14,7 @@ def test_ml_routes_are_exposed_by_production_app() -> None: assert health.status_code == 200 assert models.status_code == 200 - assert models.json() == {"models": []} + assert isinstance(models.json()["models"], list) def test_unknown_model_returns_404() -> None: @@ -29,7 +31,7 @@ def test_unknown_model_returns_404() -> None: assert response.status_code == 404 -def test_unsupported_sensor_returns_422(tmp_path) -> None: +def test_unsupported_sensor_returns_422(tmp_path: Path) -> None: from app.ml.registry.model_registry import ModelRegistry from app.ml.training import TrainedArtifact diff --git a/tests/ml/test_feature_store.py b/tests/ml/test_feature_store.py index 2d881ea..bb59b29 100644 --- a/tests/ml/test_feature_store.py +++ b/tests/ml/test_feature_store.py @@ -1,7 +1,5 @@ from __future__ import annotations -import pytest - from app.ml.feature_store import FeatureStore, FeatureVector @@ -31,12 +29,18 @@ def test_add_batch_appends_all_vectors() -> None: ] store.add_batch(vectors) assert len(store.all()) == 3 - assert store.latest("sensor.kitchen").values["temperature"] == 20.0 + latest = store.latest("sensor.kitchen") + assert latest is not None + assert latest.values["temperature"] == 20.0 def test_different_sensors_are_stored_independently() -> None: store = FeatureStore() store.add(_vector("sensor.living_room", 21.0)) store.add(_vector("sensor.bedroom", 18.5)) - assert store.latest("sensor.living_room").values["temperature"] == 21.0 - assert store.latest("sensor.bedroom").values["temperature"] == 18.5 \ No newline at end of file + living_room = store.latest("sensor.living_room") + bedroom = store.latest("sensor.bedroom") + assert living_room is not None + assert bedroom is not None + assert living_room.values["temperature"] == 21.0 + assert bedroom.values["temperature"] == 18.5 diff --git a/tests/ml/test_model_registry.py b/tests/ml/test_model_registry.py index 3558ae8..6f5d761 100644 --- a/tests/ml/test_model_registry.py +++ b/tests/ml/test_model_registry.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from pathlib import Path import pytest @@ -8,7 +9,7 @@ from app.ml.registry.model_registry import ModelRegistry from app.ml.training import TrainedArtifact -def test_registry_loads_persisted_artifacts_after_restart(tmp_path) -> None: +def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> None: registry = ModelRegistry(tmp_path) artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom")) registry.register(artifact) @@ -19,7 +20,7 @@ def test_registry_loads_persisted_artifacts_after_restart(tmp_path) -> None: @pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""]) -def test_registry_rejects_unsafe_artifact_ids(tmp_path, artifact_id: str) -> None: +def test_registry_rejects_unsafe_artifact_ids(tmp_path: Path, artifact_id: str) -> None: registry = ModelRegistry(tmp_path) with pytest.raises(ValueError): @@ -28,7 +29,7 @@ def test_registry_rejects_unsafe_artifact_ids(tmp_path, artifact_id: str) -> Non assert list(tmp_path.parent.glob("escape.json")) == [] -def test_registry_rejects_corrupt_persisted_artifact(tmp_path) -> None: +def test_registry_rejects_corrupt_persisted_artifact(tmp_path: Path) -> None: (tmp_path / "broken.json").write_text( json.dumps({"artifact_id": "../broken", "supported_sensors": []}), encoding="utf-8", diff --git a/tests/ml/test_training.py b/tests/ml/test_training.py index 7a9256c..df926da 100644 --- a/tests/ml/test_training.py +++ b/tests/ml/test_training.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest from app.ml.feature_store import FeatureStore, FeatureVector -from app.ml.training import TrainingPipeline, TrainedArtifact +from app.ml.training import TrainingPipeline def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector: @@ -45,4 +45,4 @@ def test_export_returns_registered_artifact() -> None: def test_export_missing_artifact_raises_key_error() -> None: pipeline = store_with_data() with pytest.raises(KeyError): - pipeline.export("artifact_v1") \ No newline at end of file + pipeline.export("artifact_v1") diff --git a/tests/test_config.py b/tests/test_config.py index d81ee63..e5a9525 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,11 @@ from __future__ import annotations +from pytest import MonkeyPatch + from app.config import load_settings -def test_load_settings_reads_documented_environment(monkeypatch) -> None: +def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) -> None: monkeypatch.setenv("SILLYHOME_HA_URL", "http://ha.local:8123") monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")