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