harden delivery pipeline and production runtime
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled

This commit is contained in:
2026-06-11 21:14:07 +02:00
parent 471146761e
commit aaf319ff14
26 changed files with 202 additions and 73 deletions

View File

@@ -12,6 +12,5 @@ node_modules
.vscode .vscode
.git .git
.gitignore .gitignore
README.md
.dockerignore .dockerignore
docker-compose*.yml docker-compose*.yml

View File

@@ -1,2 +1,3 @@
SILLYHOME_HA_URL=http://homeassistant.local:8123 SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
SILLYHOME_MODEL_STORE=.model_store

View File

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

View File

@@ -3,3 +3,8 @@
## Unreleased ## Unreleased
- Projektinitiierung - Projektinitiierung
- Architektur, ADRs und Roadmap - 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

View File

@@ -1,15 +1,27 @@
FROM python:3.13-slim 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 WORKDIR /app
COPY pyproject.toml ./ RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -e ".[dev]"
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 EXPOSE 8000
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"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,6 +1,13 @@
# SillyHome Next # 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 ## 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. 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 - Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert - Erweiterbar, testbar, dokumentiert
## APPENDIX ## Quickstart
### Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren: 1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash ```bash
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -e . pip install -e ".[dev]"
``` ```
2. Konfiguration aus `.env.example` übernehmen und anpassen: 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/health` - Health-Check
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation - `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/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`) ### ENV-Konfiguration (`.env.example`)
- `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`) - `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 ### Tests
```bash ```bash
pytest -q pytest
ruff check . ruff check .
mypy app tests mypy
``` ```

View File

@@ -31,10 +31,13 @@ class HaClient:
"Content-Type": "application/json", "Content-Type": "application/json",
}) })
def close(self) -> None:
self._session.close()
def list_entities(self) -> list[dict[str, object]]: def list_entities(self) -> list[dict[str, object]]:
try: try:
response = self._session.get( response = self._session.get(
f"{self._settings.url}/api/states", f"{self._settings.url.rstrip('/')}/api/states",
timeout=self._settings.timeout_seconds, timeout=self._settings.timeout_seconds,
) )
except requests.Timeout as exc: except requests.Timeout as exc:

View File

@@ -15,9 +15,10 @@ class HaReader:
entities = self._client.list_entities() entities = self._client.list_entities()
summaries: list[HaEntitySummary] = [] summaries: list[HaEntitySummary] = []
for item in entities: for item in entities:
entity_id = item.get("entity_id", "") raw_entity_id = item.get("entity_id")
if "." not in entity_id: if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
continue continue
entity_id = raw_entity_id
domain = entity_id.split(".", 1)[0] domain = entity_id.split(".", 1)[0]
raw_attributes = item.get("attributes") or {} raw_attributes = item.get("attributes") or {}
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {} attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}

View File

@@ -1,4 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from typing import cast
from fastapi import FastAPI 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.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes from backend.routes.ml import init_ml_routes
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None
app.state.registry = ModelRegistry(settings.model_store)
if hasattr(app.state, "ha_reader"): if hasattr(app.state, "ha_reader"):
del app.state.ha_reader del app.state.ha_reader
if settings.ha_configured: if settings.ha_configured:
client = HaClient( client = HaClient(
settings=HaClientSettings( settings=HaClientSettings(
url=settings.ha_url, url=cast(str, settings.ha_url),
token=settings.ha_token, token=cast(str, settings.ha_token),
) )
) )
app.state.ha_reader = HaReader(client=client) app.state.ha_reader = HaReader(client=client)
yield try:
yield
finally:
if client is not None:
client.close()
app = FastAPI( app = FastAPI(

View File

@@ -1,5 +1,5 @@
"""Machine-Learning-Grundbausteine für SillyHome Next.""" """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.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainedArtifact, TrainingPipeline from app.ml.training import TrainedArtifact, TrainingPipeline

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field from collections.abc import Iterable
from typing import Iterable from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -23,8 +23,8 @@ class ModelRegistry:
def register(self, artifact: TrainedArtifact) -> TrainedArtifact: def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
self._validate_artifact_id(artifact.artifact_id) self._validate_artifact_id(artifact.artifact_id)
self._artifacts[artifact.artifact_id] = artifact
self._persist(artifact) self._persist(artifact)
self._artifacts[artifact.artifact_id] = artifact
return artifact return artifact
def load_artifact(self, artifact_id: str) -> TrainedArtifact: def load_artifact(self, artifact_id: str) -> TrainedArtifact:

View File

@@ -2,9 +2,8 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass 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__) logger = logging.getLogger(__name__)

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Secondary application entry points for SillyHome Next."""

View File

@@ -1,4 +1,8 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from starlette.datastructures import State
from backend.routes.ml import init_ml_routes from backend.routes.ml import init_ml_routes
from app.ml.registry.model_registry import ModelRegistry 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 from app.ml.feature_store import FeatureStore, FeatureVector
def create_app() -> FastAPI: @asynccontextmanager
application = FastAPI(title="SillyHome Next ML") async def lifespan(application: FastAPI) -> AsyncIterator[None]:
init_ml_routes(application) application.state.registry = ModelRegistry(application.state.model_store)
_seed_default_model(application.state) _seed_default_model(application.state)
yield
def create_app() -> FastAPI:
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
init_ml_routes(application)
return application return application
def _seed_default_model(state) -> None: # noqa: ANN001 def _seed_default_model(state: State) -> None:
registry = getattr(state, "registry", None) registry = getattr(state, "registry", None)
if registry is None: if registry is None:
registry = ModelRegistry(".model_store") registry = ModelRegistry(".model_store")

View File

@@ -0,0 +1 @@
"""API route modules."""

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Sequence from collections.abc import Sequence
from fastapi import APIRouter, FastAPI, HTTPException, Request, status from fastapi import APIRouter, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -24,7 +24,7 @@ class HealthResponse(BaseModel):
class PredictRequest(BaseModel): class PredictRequest(BaseModel):
model_id: str = Field(..., alias="modelId") model_id: str = Field(..., alias="modelId")
sensor_id: str sensor_id: str
values: dict values: dict[str, float]
class PredictResponse(BaseModel): class PredictResponse(BaseModel):
@@ -42,7 +42,7 @@ class BatchResponse(BaseModel):
class ModelsResponse(BaseModel): class ModelsResponse(BaseModel):
models: List[str] models: list[str]
@router.get("/health", response_model=HealthResponse, status_code=200) @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: def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
registry = _require_registry(request) registry = _require_registry(request)
predictor = Predictor(registry=registry) predictor = Predictor(registry=registry)
responses: List[PredictResponse] = [] responses: list[PredictResponse] = []
for item in payload.requests: for item in payload.requests:
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values) vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
try: try:
@@ -111,7 +111,6 @@ def _require_registry(request: Request) -> ModelRegistry:
def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None: def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None:
registry = ModelRegistry(model_store) app.state.model_store = model_store
app.state.registry = registry
app.include_router(router) app.include_router(router)
logger.info("ML routes registered") logger.info("ML routes registered")

View File

@@ -2,7 +2,22 @@ services:
api: api:
build: . build: .
ports: ports:
- "8000:8000" - "127.0.0.1:8000:8000"
env_file: env_file:
- .env - 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 restart: unless-stopped
volumes:
model-data:

View File

@@ -1,6 +1,11 @@
# ML-Serving-API # 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 ## Basis-URL
@@ -10,7 +15,8 @@ Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHo
- Einzelvorhersage: `/predict` - Einzelvorhersage: `/predict`
- Batchvorhersage: `/batch` - 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 ## Endpoints
@@ -101,13 +107,15 @@ Batch-Vorhersage für mehrere Sensorwerte.
## Fehlerfälle ## Fehlerfälle
- `400 Bad Request`: Fehlende oder ungültige Felder. - `404 Not Found`: Modell nicht registriert.
- `404 Not Found`: Modell oder Sensor nicht registriert. - `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler. - `503 Service Unavailable`: Registry ist nicht initialisiert.
## Betrieb ## 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 ## Verweise

View File

@@ -1,12 +1,14 @@
# ML Training- und Evaluations-Workflow # 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 ## 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. 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 ```python
store = FeatureStore() store = FeatureStore()
@@ -16,7 +18,9 @@ artifact = pipeline.run("my_artifact")
pipeline.export("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 ## 3. Modell evaluieren
@@ -37,3 +41,4 @@ Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifac
## Hinweise ## Hinweise
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet. - 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. - 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.

View File

@@ -1,3 +1,7 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.1.0" version = "0.1.0"
@@ -24,6 +28,10 @@ addopts = "-q"
[tool.mypy] [tool.mypy]
strict = true strict = true
files = ["app", "backend", "tests"]
[tool.setuptools.packages.find]
include = ["app*", "backend*"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app 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 health.status_code == 200
assert models.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: def test_unknown_model_returns_404() -> None:
@@ -29,7 +31,7 @@ def test_unknown_model_returns_404() -> None:
assert response.status_code == 404 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.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact from app.ml.training import TrainedArtifact

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector from app.ml.feature_store import FeatureStore, FeatureVector
@@ -31,12 +29,18 @@ def test_add_batch_appends_all_vectors() -> None:
] ]
store.add_batch(vectors) store.add_batch(vectors)
assert len(store.all()) == 3 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: def test_different_sensors_are_stored_independently() -> None:
store = FeatureStore() store = FeatureStore()
store.add(_vector("sensor.living_room", 21.0)) store.add(_vector("sensor.living_room", 21.0))
store.add(_vector("sensor.bedroom", 18.5)) store.add(_vector("sensor.bedroom", 18.5))
assert store.latest("sensor.living_room").values["temperature"] == 21.0 living_room = store.latest("sensor.living_room")
assert store.latest("sensor.bedroom").values["temperature"] == 18.5 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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
import pytest import pytest
@@ -8,7 +9,7 @@ from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact 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) registry = ModelRegistry(tmp_path)
artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom")) artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom"))
registry.register(artifact) 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", "..", ""]) @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) registry = ModelRegistry(tmp_path)
with pytest.raises(ValueError): 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")) == [] 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( (tmp_path / "broken.json").write_text(
json.dumps({"artifact_id": "../broken", "supported_sensors": []}), json.dumps({"artifact_id": "../broken", "supported_sensors": []}),
encoding="utf-8", encoding="utf-8",

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import pytest import pytest
from app.ml.feature_store import FeatureStore, FeatureVector 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: def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:

View File

@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pytest import MonkeyPatch
from app.config import load_settings 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_URL", "http://ha.local:8123")
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models") monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")