Task_13: Storage Factory

This commit is contained in:
Marco Schmid 2026-05-12 17:36:36 +02:00
parent 04e1ed8097
commit 014fbe68b4
4 changed files with 107 additions and 48 deletions

89
TASK.md
View File

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

View File

@ -7,4 +7,7 @@ bboxen:
schweiz: [45.8, 5.9, 47.8, 10.5]
active_queries:
- bergbahn
- bergbahn
storage:
output_dir: data/results

View File

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

36
src/overpass/storage.py Normal file
View File

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