From 014fbe68b4d24408a04118763d46ffd51189d96c Mon Sep 17 00:00:00 2001 From: Marco Schmid Date: Tue, 12 May 2026 17:36:36 +0200 Subject: [PATCH] Task_13: Storage Factory --- TASK.md | 89 ++++++++++++++++++++++------------------ src/overpass/config.yaml | 5 ++- src/overpass/main.py | 25 ++++++++--- src/overpass/storage.py | 36 ++++++++++++++++ 4 files changed, 107 insertions(+), 48 deletions(-) create mode 100644 src/overpass/storage.py diff --git a/TASK.md b/TASK.md index 3e75086..2d4967c 100644 --- a/TASK.md +++ b/TASK.md @@ -1,59 +1,66 @@ -# Task 12 — Storage-Abstraktion mit ABC +# Task 13 — Storage-Factory und StorageType -## Rückblick Task 11: Externe Konfiguration +## Rückblick Task 12: Storage-Abstraktion -Ihr habt alle Konfigurationswerte in eine `config.yaml` ausgelagert. Die wichtigsten Punkte: +Ihr habt `storage.py` mit einer abstrakten Basisklasse `Storage` und einer +konkreten `JsonStorage` eingeführt. Die wichtigsten Punkte: -- **Config vs. Umgebungsvariablen:** In die YAML-Datei gehören Werte, die das - Verhalten der Applikation steuern (Bboxen, Timeouts, aktive Queries). In - Umgebungsvariablen (`.env`) gehören Secrets und Deployment-spezifische Werte - (Passwörter, API-Keys, Datenbankpfade) — also alles, was nicht ins Git-Repository - soll. -- **Ungültiger `PoiType`-Wert:** `PoiType("gondelbahn")` wirft einen `ValueError`, - weil der Wert nicht im Enum existiert. Das ist eigentlich gut — fail fast. In - `main.py` könnte man diesen Fehler abfangen und eine sprechende Fehlermeldung - ausgeben. -- **`Path(__file__).parent / "config.yaml"`:** Gleiche Logik wie bei den Query-Dateien — - der Pfad wird immer relativ zur `main.py` aufgelöst, nicht zum Arbeitsverzeichnis. +- **ABC und `@abstractmethod`:** Eine Klasse, die von `ABC` erbt und eine + `@abstractmethod` definiert, kann nicht direkt instanziiert werden — + Python wirft einen `TypeError`. Eine Unterklasse muss *alle* abstrakten + Methoden implementieren, sonst gilt sie selbst als abstrakt. +- **Warum `store()` einen `str` zurückgibt:** Der Aufrufer bekommt einen + Identifier zurück (Dateipfad, Tabellenname, URL), ohne zu wissen, welches + Backend dahintersteckt. Das ist nützlich fürs Logging und für Tests. +- **`StorageError`:** Eine eigene Exception macht den Code robuster — + `main.py` muss nur `StorageError` kennen, nicht alle möglichen + `OSError`-, `psycopg2`- oder sonstigen Backend-Fehler. ## Aufgabe -Aktuell landen die gefetchten POIs nirgends — sie werden nur geloggt und dann -verworfen. Ziel ist es, die POIs in eine JSON-Datei zu speichern. +In `main.py` steht aktuell: -Wir wollen das aber so umsetzen, dass das Storage-Backend später leicht -ausgetauscht werden kann (z.B. gegen eine Datenbank) — ohne `main.py` oder -die Fetch-Logik anzufassen. +```python +storage = JsonStorage(output_dir=config["storage"]["output_dir"]) +``` + +Das bedeutet: Will man später ein anderes Backend verwenden, muss `main.py` +angefasst werden. Ausserdem muss `main.py` wissen, welche Klasse (`JsonStorage`, +`PostgresStorage`, ...) zu welchem Config-Wert gehört. + +Ziel ist eine **Factory-Funktion** `build_storage(cfg)`, die diese Entscheidung +übernimmt — `main.py` übergibt nur die Config und bekommt ein fertiges +`Storage`-Objekt zurück. **Konkret:** -1. Lege eine neue Datei `storage.py` an. -2. Definiere darin eine **abstrakte Basisklasse** `Storage` (erbt von `ABC`) mit - einer abstrakten Methode: +1. Füge in `storage.py` eine `StorageType`-Enum hinzu: ```python - @abstractmethod - def store(self, pois: list[POI]) -> str: - ... + class StorageType(StrEnum): + JSON = "json" + POSTGRES = "postgres" ``` - Die Methode soll die POIs speichern und einen Identifier zurückgeben - (z.B. den Dateipfad oder Tabellennamen). -3. Implementiere eine konkrete Klasse `JsonStorage(Storage)`: - - Nimmt einen `output_dir: str | Path` im Konstruktor entgegen. - - `store()` schreibt alle POIs als JSON-Datei in dieses Verzeichnis, - benannt nach dem `poi_type` (z.B. `bergbahn.json`). -4. Instanziiere `JsonStorage` in `main.py` und rufe nach dem Fetchen - `storage.store(pois)` auf. -5. Ergänze in `config.yaml` einen `storage`-Abschnitt: +2. Schreibe eine Factory-Funktion `build_storage(cfg: dict) -> Storage` + in `storage.py`, die anhand von `cfg["type"]` den richtigen Storage + instanziiert. Verwende dafür ein `match`-Statement. +3. Passe `config.yaml` an — der `storage`-Abschnitt bekommt ein `type`-Feld: ```yaml storage: - output_dir: ./data/results + type: json + params: + output_dir: ./data/results +``` +4. Ersetze in `main.py` die direkte Instanziierung durch: +```python + storage = build_storage(config["storage"]) ``` **Fragen zum Nachdenken:** -- Was ist eine abstrakte Basisklasse (ABC) — und was passiert, wenn man - `Storage()` direkt instanziiert oder eine Unterklasse schreibt, die - `store()` nicht implementiert? -- Warum gibt `store()` einen `str` zurück (den Identifier) statt nichts (`None`)? -- Was wäre der Nachteil, wenn `main.py` direkt `JsonStorage` instanziieren - und überall verwenden würde — ohne das `Storage`-Interface? \ No newline at end of file +- Was ist der Vorteil eines `match`-Statements gegenüber einer + `if/elif`-Kette — und ab wann lohnt sich das? +- Was passiert, wenn jemand in `config.yaml` einen ungültigen + `type`-Wert einträgt (z.B. `"mongodb"`)? Wie sollte + `build_storage()` damit umgehen? +- Warum übergeben wir `params` als `**params` an den Konstruktor — + und was ist der Vorteil gegenüber einzelnen Parametern? \ No newline at end of file diff --git a/src/overpass/config.yaml b/src/overpass/config.yaml index 6c2fc30..c7b62f8 100644 --- a/src/overpass/config.yaml +++ b/src/overpass/config.yaml @@ -7,4 +7,7 @@ bboxen: schweiz: [45.8, 5.9, 47.8, 10.5] active_queries: - - bergbahn \ No newline at end of file + - bergbahn + +storage: + output_dir: data/results \ No newline at end of file diff --git a/src/overpass/main.py b/src/overpass/main.py index f546b63..6e02bd9 100644 --- a/src/overpass/main.py +++ b/src/overpass/main.py @@ -4,6 +4,7 @@ from pathlib import Path from .fetcher import load_query, load_pois, OverpassApiError from .models import POI, PoiType +from .storage import JsonStorage, StorageError logging.basicConfig( level=logging.INFO, @@ -12,24 +13,36 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +ROOT = Path(__file__).parent.parent.parent + def main() -> None: - config = yaml.safe_load((Path(__file__).parent / "config.yaml").read_text()) - timeout = config["overpass"]["timeout"] - maxsize = config["overpass"]["maxsize"] - bboxen = config["bboxen"] + config = yaml.safe_load((Path(__file__).parent / "config.yaml").read_text()) + timeout = config["overpass"]["timeout"] + maxsize = config["overpass"]["maxsize"] + bboxen = config["bboxen"] poi_types = [PoiType(pt) for pt in config["active_queries"]] + storage = JsonStorage(output_dir=ROOT / config["storage"]["output_dir"]) for poi_type in poi_types: + collected_pois = [] for name, bbox in bboxen.items(): try: query = load_query(poi_type, bbox, timeout, maxsize) pois: list[POI] = load_pois(query=query, poi_type=poi_type) + collected_pois.extend(pois) except OverpassApiError as exc: logger.error(f"[{poi_type}] Fehler bei '{name}': {exc}") continue logger.info(f"[{poi_type}] {name}: {len(pois)} POIs gefunden") - for poi in pois: - logger.debug(f" {poi.id}: ({poi.lat}, {poi.lon})") + + if collected_pois: + try: + location = storage.store(collected_pois) + logger.info(f"[{poi_type}] {len(collected_pois)} POIs gespeichert: {location}") + except StorageError as exc: + logger.error(f"[{poi_type}] Fehler beim Speichern: {exc}") + else: + logger.warning(f"[{poi_type}] Nichts zu speichern") if __name__ == "__main__": main() \ No newline at end of file diff --git a/src/overpass/storage.py b/src/overpass/storage.py new file mode 100644 index 0000000..2b10a34 --- /dev/null +++ b/src/overpass/storage.py @@ -0,0 +1,36 @@ +import logging +import json +from abc import ABC, abstractmethod +from dataclasses import asdict +from pathlib import Path +from .models import POI + +logger = logging.getLogger(__name__) + + +class StorageError(Exception): + pass + + +class Storage(ABC): + """Abstrakte Basisklasse für POI-Storage-Backends.""" + + @abstractmethod + def store(self, pois: list[POI]) -> str: + raise NotImplementedError + + +class JsonStorage(Storage): + def __init__(self, output_dir: str | Path): + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + def store(self, pois: list[POI]) -> str: + poi_type = pois[0].poi_type + output_path = self.output_dir / f"{poi_type}.json" + try: + with output_path.open("w", encoding="utf-8") as f: + json.dump([asdict(poi) for poi in pois], f, indent=2, ensure_ascii=False) + except OSError as exc: + raise StorageError("Fehler beim Speichern der POIs") from exc + return str(output_path.resolve()) \ No newline at end of file