Task_18: Testing
This commit is contained in:
parent
d154b15280
commit
d5962bfe35
106
TASK.md
106
TASK.md
@ -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?
|
||||
@ -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"
|
||||
@ -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):
|
||||
|
||||
@ -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}'")
|
||||
Loading…
x
Reference in New Issue
Block a user