Task_18: Testing

This commit is contained in:
Marco Schmid 2026-05-13 09:44:20 +02:00
parent d154b15280
commit d5962bfe35
4 changed files with 129 additions and 73 deletions

106
TASK.md
View File

@ -1,76 +1,52 @@
# Task 17 — PostgresStorage
# Task 18 — Testing
## Rückblick Task 16: Lock und @timer-Decorator
Ihr habt `CONCURRENT_LOCKED` und den `@timer`-Decorator eingeführt.
Die wichtigsten Punkte:
- **Warum Lock, obwohl der GIL schützt?** Der GIL verhindert echte
Parallelität auf CPU-Ebene, aber er gibt keine Garantien über die
Reihenfolge von Operationen oder die Konsistenz komplexerer
Datenstrukturen. Ein expliziter `Lock` macht die Absicht klar,
ist portabel (auch ohne GIL, z.B. in PyPy oder zukünftigen
Python-Versionen) und schützt bei read-modify-write-Operationen
zuverlässig.
- **`time.perf_counter()`** ist präziser als `time.time()` für
Laufzeitmessungen, weil er eine hochauflösende Systemuhr
verwendet und nicht durch Systemzeit-Anpassungen beeinflusst wird.
## Rückblick Task 17: PostgresStorage
Falls alles geklappt hat, habt ihr nun eure POI's in einer lokalen PostGreSQL-Datenbank abgespeichert. Erreicht haben wir
diese in einer Implementation von `PostgresStorage(Storage)` ohne bestehende Businesslogik in `main.py` etc. anzufassen.
Das garantiert maximale Flexibilität und Wiederverwendbarkeit des Codes und ist deutlich wartungsfreundlicher und damit
auch übersichtlicher.
## Aufgabe
Bisher speichern wir POIs in JSON-Dateien. Für grössere Datenmengen
und spätere Abfragen ist eine Datenbank besser geeignet. Ziel ist es,
`PostgresStorage` als zweites konkretes Backend zu implementieren —
ohne `main.py`, `pipeline.py` oder `fetcher.py` anzufassen.
Bisher haben wir keinen einzigen Test für unser Miniprojekt geschrieben. Wir werden das hier auch nicht im grossen Stil
und vollumfänglich machen, sondern nur exemplarisch.
**Vorbereitung:**
- Stelle sicher, dass eine PostgreSQL-Datenbank läuft und erreichbar ist.
- Erstelle in der PostgreSQL-Datenbank eine neue Datenbank. Das kannst Du z.B. in PGAdmin oder aus dem Terminal erreichen.
Gebt der neuen Datenbank einen Namen (z.B. overpass) und ordnet ihr einen Nutzer (z.B. postgres) inkl. Passwort zu.
- Installiere `psycopg2`: `pip install psycopg2-binary`
- Ergänze den Connection-String in `config.yaml` ("postgresql://user:password@localhost:5432/dbname").
UND: Wir haben noch eine 'sicherheitsrelevante' Einschränkung im Code. Im jetzigen Stand werden persönliche/private
Informationen wie der `username` und vor allem das `password` im `connection_string` in `config.yaml` geschrieben. Das
ist nicht ideal, weil die config ins `git` geschrieben wird und somit für alle (berechtigten) einsehbar ist. Das wollen
und müssen wir ändern!
**Konkret:**
1. Ergänze in `models.py` zwei Methoden in der `POI`-Dataclass:
```python
def to_row(self) -> tuple:
"""POI-Objekt → DB-Zeile (Schreiben)"""
...
@classmethod
def from_row(cls, row: dict) -> POI:
"""DB-Zeile → POI-Objekt (Lesen)"""
...
```
Die `tags` in POI sollen als JSONB gespeichert werden —
verwende dafür `psycopg2.extras.Json`.
2. Implementiere `PostgresStorage(Storage)` in `storage.py`:
- Nimmt `connection_string: str` und `table: str = "pois"` entgegen.
- `_ensure_table()`: Erstellt die Tabelle, falls sie nicht existiert
(Primary Key: `(id, poi_type)`).
- `store()`: Schreibt alle POIs per UPSERT in die Tabelle
(`ON CONFLICT DO UPDATE`). Verwende `execute_values` aus
`psycopg2.extras` für effizientes Bulk-Insert.
- Verwende `with conn:` als Context-Manager für Transaktionen
(automatisches commit/rollback).
- Für die Bildung des SQL-Statements könnt ihr gerne in den Unterrichtsunterlagen (Folien) 'spicken'.
3. Ergänze `build_storage()` — der `POSTGRES`-Case soll nun
`PostgresStorage(**params)` zurückgeben statt `NotImplementedError`.
4. Passe `config.yaml` an:
```yaml
storage:
type: postgres
params:
connection_string: "postgresql://user:password@localhost:5432/dbname"
```
1. Speichert den privaten `connection_string` in einer neue angelegten `.env` und verwendet diesen in euerem Code unter
Nutzung von `load_dotenv` aus (aus dem dotenv-Modul). Schaut zudem, dass euer `.env` in `.gitignore` aufgeführt ist
und somit nicht von git 'getracked' wird!
2. Erstellt einen neuen Ordner `tests` in eurem root-Projektverzeichnis (also neben `src`)
3. Erstellt darin ein neues Modul `tests_overpass.py`
4. Wie erwähnt wollen wir nur exemplarisch wenige Tests machen:
* in der POI-Klasse haben wir eine Klassenmethode `from_row`: Schreibt einen ganz einfachen Unittest, welcher überprüft,
ob diese tatsächlich ein `POI`-Objekt zurück gibt.
```python
@classmethod
def from_row(cls, row: dict) -> POI:
"""DB-Zeile → POI-Objekt (Lesen)"""
return cls(
id = row["id"],
type = row["type"],
poi_type = row["poi_type"],
lat = row["lat"],
lon = row["lon"],
tags = row["tags"] or {},
)
```
* im Unterricht wurden kurz Inhalte aus dem Testing mit `mocks`, `stub` und `fake` angesprochen. Lest das nochmals und
versucht zu verstehen wie und wo man diese in unserem Projekt gebrauchen könnte und warum. Wir werden gemeinsam je ein
Beispiel aus jeder dieser 3 Kategorien zusammen anschauen.
**Fragen zum Nachdenken:**
- Warum `ON CONFLICT DO UPDATE` (UPSERT) statt einem einfachen
`INSERT` — was passiert ohne es beim zweiten Ausführen?
- Was macht `with conn:` als Context-Manager — was passiert bei
einem Fehler innerhalb des Blocks?
- Zum Projektabschluss: Bitte überlegt euch Stärken aber sicher auch Einschränkungen/Schwächen unseres Mini-Projekts.
Was könnte man besser/einfacher/anders machen?

View File

@ -19,6 +19,7 @@ active_queries:
- bergbahn
storage:
type: json
type: postgres # json | postgres
params:
output_dir: data/results
# output_dir: data/results
connection_string: "postgresql://postgres:marco1234@localhost:5432/overpass"

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from psycopg2.extras import Json
@dataclass
@ -9,13 +11,23 @@ class POI:
poi_type: str
lat: float
lon: float
tags: dict = field(default_factory=dict) # weil mutable defaults in Dataclasses eine bekannte Python-Falle sind
# (alle Instanzen würden dasselbe Dict teilen...)
tags: dict = field(default_factory=dict)
@classmethod
def from_row(cls, row: dict) -> POI:
"""DB-Zeile → POI-Objekt (Lesen)"""
return cls(
id = row["id"],
type = row["type"],
poi_type = row["poi_type"],
lat = row["lat"],
lon = row["lon"],
tags = row["tags"] or {},
)
# REMARK:
# Wann eine eigene Dataclass für tags?
# Nur wenn die tags strukturiert und vorhersehbar sind, was bei OSM-Daten nicht der Fall ist...
def to_row(self) -> tuple:
"""POI-Objekt → DB-Zeile (Schreiben)"""
return self.id, self.poi_type, self.type, self.lat, self.lon, Json(self.tags)
class PoiType(StrEnum):

View File

@ -4,6 +4,11 @@ from abc import ABC, abstractmethod
from dataclasses import asdict
from enum import StrEnum
from pathlib import Path
import psycopg2
import psycopg2.extras
from psycopg2.extras import execute_values
from .models import POI
logger = logging.getLogger(__name__)
@ -42,6 +47,68 @@ class JsonStorage(Storage):
return str(output_path.resolve())
class PostgresStorage(Storage):
def __init__(self, connection_string: str, table: str = "pois"):
self.connection_string = connection_string
self.table = table
self._ensure_table()
def _connect(self) -> psycopg2.extensions.connection:
try:
return psycopg2.connect(self.connection_string)
except psycopg2.OperationalError as exc:
raise StorageError("Datenbankverbindung fehlgeschlagen") from exc
def _ensure_table(self) -> None:
sql = f"""
CREATE TABLE IF NOT EXISTS {self.table} (
id TEXT NOT NULL,
poi_type TEXT NOT NULL,
type TEXT,
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
tags JSONB NOT NULL DEFAULT '{{}}',
PRIMARY KEY (id, poi_type)
);
"""
conn = self._connect()
try:
with conn:
conn.cursor().execute(sql)
except psycopg2.Error as exc:
raise StorageError("Fehler beim Erstellen der Tabelle") from exc
finally:
conn.close()
def store(self, pois: list[POI]) -> str:
if not pois:
logger.warning("store() mit leerer Liste — nichts zu tun.")
return self.table
rows = [p.to_row() for p in pois]
sql = f"""
INSERT INTO {self.table} (id, poi_type, type, lat, lon, tags)
VALUES %s
ON CONFLICT (id, poi_type) DO UPDATE SET
type = EXCLUDED.type,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
tags = EXCLUDED.tags;
"""
conn = self._connect()
try:
with conn:
execute_values(conn.cursor(), sql, rows)
logger.info(f"{len(pois)} POIs in '{self.table}' gespeichert (UPSERT).")
except psycopg2.Error as exc:
raise StorageError("Fehler beim Speichern in PostgreSQL") from exc
finally:
conn.close()
return self.table
def build_storage(cfg: dict, root: Path) -> Storage:
"""Factory: liest config-Dict und gibt fertigen Storage zurück."""
storage_type = StorageType(cfg["type"])
@ -51,6 +118,6 @@ def build_storage(cfg: dict, root: Path) -> Storage:
case StorageType.JSON:
return JsonStorage(output_dir=root / params["output_dir"])
case StorageType.POSTGRES:
raise NotImplementedError("PostgresStorage folgt später")
return PostgresStorage(**params)
case _:
raise ValueError(f"Unbekannter Storage-Typ: '{storage_type}'")