From ddf749f968b626f242a3c975cde41f22e92154a3 Mon Sep 17 00:00:00 2001 From: Marco Schmid Date: Wed, 13 May 2026 10:25:23 +0200 Subject: [PATCH] Finalizing mini-project --- .env.example | 1 + TASK.md | 70 +++++-------- pyproject.toml | 37 +++++++ requirements.txt | 3 +- src/overpass/config.yaml | 2 +- src/overpass/main.py | 5 + tests/test_overpass.py | 221 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 293 insertions(+), 46 deletions(-) create mode 100644 .env.example create mode 100644 pyproject.toml create mode 100644 tests/test_overpass.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb788df --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DB_CONNECTION_STRING="postgresql://user:password@localhost:5432/DB_name" \ No newline at end of file diff --git a/TASK.md b/TASK.md index 2b42246..c8700e4 100644 --- a/TASK.md +++ b/TASK.md @@ -1,52 +1,34 @@ -# Task 18 — Testing +# Finalizing -## Rückblick Task 17: PostgresStorage +## Rückblick Task 18: Testing -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. +In der Musterlösung wurde zusätzlich ein `pyproject.toml`-File eingefügt. Dieses erlaubt es euch u.a. die erstellten Tests +direkt aus der Konsole im Root-Verzeichnis mit `pytest tests` auszuführen. -## Aufgabe +Zusätzlich findet ihr eine `.env.example`-Datei. Diese ist selbst nicht funktional und soll dem User anzeigen, was die +funktionale `.env` für eine Struktur hat. So weiss in unserem Falle jeder, er/sie muss eine `.env` mit `DB_CONNECTION_STRING` +anlegen. -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. +Im Testing (`test_overpass.py`) wurde lediglich ein einziger Unittest und Demonstration für `stub`, `mock` und `fake` gemacht. +In der Realität wäre das zu wenig, für Demonstrationszwecke sollte es genügen ... -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:** +**Stärken** +* Schichtenarchitektur ist (hoffentlich) klar: fetcher → pipeline → storage, keine zirkulären Abhängigkeiten +* Fehlerbehandlung konsistent mit eigenen Exception-Typen +* Konfiguration sauber von Code getrennt +* Threading von API-Requests (concurrent) -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. +**Schwächen/Entwicklungspotenzial** +* Keine Retry-Logik für Overpass: Die API ist öffentlich und manchmal überlastet. Eine einfache Retry-Logik mit tenacity wäre produktionsreif. +* ROOT-Konstante ist fragil. Drei .parent-Aufrufe funktionieren nur bei genau dieser Verzeichnistiefe. Wir haben nun am Schluss noch ein + `pyproject.toml` gebildet und könnten deshalb das ROOT daraus extrahieren. -**Fragen zum Nachdenken:** -- 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? \ No newline at end of file + ```python + def find_root() -> Path: + for parent in Path(__file__).parents: + if (parent / "pyproject.toml").exists(): + return parent + raise RuntimeError("Projektroot nicht gefunden") + ``` +* ... \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6266898 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=72", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "overpass" +version = "0.1.0" +description = "Overpass API POI Fetcher – CDS Unterrichtsprojekt" +requires-python = ">=3.12" + +dependencies = [ + "requests", + "pyyaml", + "psycopg2-binary", + "dotenv" +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", +] + +[project.scripts] +overpass = "overpass.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +addopts = "-v --tb=short" + +[tool.coverage.run] +source = ["src/overpass"] +omit = ["*/__init__.py"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 663bd1f..7a61382 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -requests \ No newline at end of file +requests +dotenv \ No newline at end of file diff --git a/src/overpass/config.yaml b/src/overpass/config.yaml index 4d963c2..68e44ed 100644 --- a/src/overpass/config.yaml +++ b/src/overpass/config.yaml @@ -22,4 +22,4 @@ storage: type: postgres # json | postgres params: # output_dir: data/results - connection_string: "postgresql://postgres:marco1234@localhost:5432/overpass" \ No newline at end of file + connection_string: Null \ No newline at end of file diff --git a/src/overpass/main.py b/src/overpass/main.py index f7e86c9..2c1d144 100644 --- a/src/overpass/main.py +++ b/src/overpass/main.py @@ -1,6 +1,8 @@ +import os import yaml import logging from pathlib import Path +from dotenv import load_dotenv from .models import PoiType from .pipeline import fetch_and_store, FetchMode @@ -14,11 +16,14 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +load_dotenv() ROOT = Path(__file__).parent.parent.parent @timer def main() -> None: config = yaml.safe_load((Path(__file__).parent / "config.yaml").read_text()) + config["storage"]["params"]["connection_string"] = os.environ["DB_CONNECTION_STRING"] # Credentials direkt aus .env + timeout = config["overpass"]["timeout"] maxsize = config["overpass"]["maxsize"] bboxen = config["bboxen"] diff --git a/tests/test_overpass.py b/tests/test_overpass.py new file mode 100644 index 0000000..a919178 --- /dev/null +++ b/tests/test_overpass.py @@ -0,0 +1,221 @@ +from unittest.mock import patch, MagicMock +from overpass.models import POI, PoiType + +# --------------------------------------------------------------------------- +# Hilfsfunktion – vermeidet Wiederholung in jedem Test +# --------------------------------------------------------------------------- + +def make_poi(**kwargs) -> POI: + defaults = dict(id="123", poi_type="bergbahn", type="node", + lat=47.1, lon=8.5, tags={"name": "Rössli"}) + return POI(**{**defaults, **kwargs}) + + +# --------------------------------------------------------------------------- +# Einfacher UnitTest +# Eine Test-Klasse ohne Mocks, keine DB, keine HTTP -> Unittest +# --------------------------------------------------------------------------- + +class TestPoiFromRow: + """POI.from_row: DB-Zeile wird korrekt in ein POI-Objekt umgewandelt.""" + + def test_is_a_poi_instance(self): + row = { + "id": "123", + "type": "node", + "poi_type": "bergbahn", + "lat": 46.72, + "lon": 9.70, + "tags": {"name": "Davos"}, + } + poi = POI.from_row(row) + assert isinstance(poi, POI) + + def test_all_attributes_set(self): + row = { + "id": "123", + "type": "node", + "poi_type": "bergbahn", + "lat": 46.72, + "lon": 9.70, + "tags": {"name": "Davos"}, + } + poi = POI.from_row(row) + + assert poi.id == "123" + assert poi.type == "node" + assert poi.poi_type == "bergbahn" + assert poi.lat == 46.72 + assert poi.lon == 9.70 + assert poi.tags == {"name": "Davos"} + + def test_none_tag_will_become_empty_dict(self): + row = {"id": "1", "type": "node", "poi_type": "bergbahn", + "lat": 0.0, "lon": 0.0, "tags": None} + poi = POI.from_row(row) + assert poi.tags == {} + +# --------------------------------------------------------------------------- +# Unser Projekt hat aber einige externe Abhängigkeiten, wie post-requests auf OverpassAPI oder das Schreiben in eine +# Postgres-Datenbank. Um diese Problematiken im Testing in den Griff zu kriegen, gibt es mehrere Möglichkeiten, die wir +# hier exemplarisch Anschauen wollen. +# --------------------------------------------------------------------------- + + +# STUB → "Was kommt rein?" → gibt immer dieselbe Antwort zurück +# FAKE → "Funktioniert es?" → echte Logik, keine Infrastruktur +# MOCK → "Wurde es benutzt?" → prüft Aufruf, Argumente, Häufigkeit + + +# ────────────────────────────────────────────── +# STUB – liefert vorbereitete Antworten zurück +# Kein echtes Verhalten, nur feste Rückgabewerte +# ────────────────────────────────────────────── + +from overpass.fetcher import load_pois + +def get_stub_response() -> MagicMock: + stub_response = MagicMock() + stub_response.json.return_value = { + "elements": [ + {"id": 1, "type": "node", "lat": 47.1, "lon": 8.5, + "tags": {"name": "Davos"}} + ] + } + stub_response.raise_for_status = MagicMock() # tut nichts + return stub_response + + +def test_stub_load_pois(): + """Stub ersetzt nur requests.post – der Rest von requests bleibt echt.""" + + stub_response = get_stub_response() + + with patch("overpass.fetcher.requests.post", return_value=stub_response): + pois = load_pois(query="[out:json];", poi_type=PoiType.BERGBAHN) + + assert len(pois) == 1 + assert pois[0].tags["name"] == "Davos" + + +# Code-Fluss: +# +# test_stub_load_pois() +# │ +# ├── load_pois(query, poi_type) ← echter Code +# │ │ +# │ ├── _fetch_overpass(query) ← echter Code +# │ │ │ +# │ │ ├── requests.post(...) ← ❌ ERSETZT durch Stub +# │ │ │ └── gibt stub_response zurück +# │ │ │ +# │ │ ├── response.raise_for_status() ← läuft (tut nichts) +# │ │ └── response.json() ← gibt {"elements": [...]} zurück +# │ │ +# │ ├── _parse_pois(data, poi_type) ← echter Code +# │ │ └── _parse_poi(element) ← echter Code +# │ │ └── POI(id=1, ...) ← echter Code +# │ │ +# │ └── gibt [POI(...)] zurück +# │ +# └── assert len(pois) == 1 ✅ + +# Vorteil: +# - externe API wird nicht gebraucht! +# - Antwort ist vorhersagbar! + + +# ────────────────────────────────────────────── +# FAKE – vereinfachte, aber funktionierende Implementierung +# Hat echte Logik – nur ohne Datenbank/Datei +# ────────────────────────────────────────────── + +# ZIEL: Funktion 'fetch_and_store' soll getestet werden, aber diese hat sowohl einen HTTP-Request als auch das Speichern +# in die PostGreSQL-DB drin... + +# Wir 'stuben' den HTTP-Request (bereits erledigt) und 'faken' den Datenbankzugriff + +from overpass.storage import Storage +from overpass.pipeline import fetch_and_store, FetchMode + +class FakeStorage(Storage): + """Speichert POIs im Arbeitsspeicher statt in DB oder Datei.""" + def __init__(self): + self.saved: list[POI] = [] + + def store(self, pois: list[POI]) -> str: + self.saved.extend(pois) # echte Logik, aber kein I/O + return f"memory ({len(self.saved)} POIs)" + + +def test_fetch_and_store_speichert_alle_bboxen(): + """ + Prüft: Sammelt fetch_and_store POIs aus ALLEN Bboxen + und übergibt sie gesammelt an storage.store() + """ + storage = FakeStorage() # kein JSON, kein Postgres, kein I/O + + bboxen = { + "davos": [46.72, 9.70, 46.92, 10.00], + "zuerich": [47.30, 8.40, 47.50, 8.70], + } + + stub_response = get_stub_response() + + with patch("overpass.fetcher.requests.post", return_value=stub_response): + fetch_and_store( + poi_type = PoiType.BERGBAHN, + bboxen = bboxen, + timeout = 25, + maxsize = 500000, + storage = storage, # FakeStorage statt JsonStorage/PostgresStorage + fetch_mode = FetchMode.SERIAL, + ) + + # Wurden POIs aus beiden Bboxen gesammelt? + assert len(storage.saved) > 0 + assert len(storage.saved) == 2 + + +# Was wird getestet? +# Nicht FakeStorage selbst – die ist trivial. +# Getestet wird der Code der Storage verwendet, also 'fetch_and_store' in pipeline.py: + + +# ────────────────────────────────────────────── +# MOCK – prüft wie ein Objekt verwendet wurde +# Interessiert sich nicht für den Rückgabewert, +# sondern ob store() mit den richtigen Argumenten aufgerufen wurde +# ────────────────────────────────────────────── + +def test_mock_fetch_and_store_calls_store_exactly_once(): + """ + fetch_and_store soll store() genau einmal aufrufen – egal wie viele Bboxen es gibt. + """ + storage = MagicMock(spec=Storage) # spec=Storage bedeutet: nur Methoden die in der echten Storage-Klasse + # existieren sind erlaubt – storage.speichern() würde einen Fehler werfen, + # storage.store() nicht + stub_response = get_stub_response() + + bboxen = { + "davos": [46.72, 9.70, 46.92, 10.00], + "zuerich": [47.30, 8.40, 47.50, 8.70], + "bern": [46.90, 7.30, 47.10, 7.60], + } + + + with patch("overpass.fetcher.requests.post", return_value=stub_response): + fetch_and_store( + poi_type = PoiType.BERGBAHN, + bboxen = bboxen, + timeout = 25, + maxsize = 500000, + storage = storage, + fetch_mode = FetchMode.SERIAL, + ) + + # 3 Bboxen – aber store() darf nur EINMAL aufgerufen werden + storage.store.assert_called_once() + +# Der Test prüft nicht ob POIs korrekt gespeichert werden – er prüft ob store() +# überhaupt und mit den richtigen Argumenten aufgerufen wurde. \ No newline at end of file