Files
2026-07-24 21:37:03 +02:00

238 lines
9.3 KiB
Python

# -*- coding: utf-8 -*-
"""
Gemeinsamer Tandoor-Zugang für die Plugins der Suite.
Die drei mitgebrachten Original-Tools bringen ihren eigenen Client mit und
bleiben unangetastet. Alles, was danach in dieser Suite entsteht, benutzt
diesen hier — damit Paginierung, Wiederholversuche und Fehlermeldungen nur an
einer Stelle gepflegt werden müssen.
Belegt am Tandoor-Quelltext (cookbook/serializer.py, cookbook/urls.py):
Food id, name, plural_name, description, url, fdc_id,
properties, properties_food_amount, properties_food_unit,
supermarket_category, numrecipe (nur lesbar), full_name
Property id, property_amount, property_type
PropertyType id, name, unit, description, order, open_data_slug, fdc_id
Endpunkte /api/food/ /api/unit/ /api/keyword/ /api/property-type/
/api/property/ /api/recipe/
Zusammenführen PUT /api/food/{id}/merge/{target}/
"""
from __future__ import annotations
import os
import time
from typing import Any, Iterator
import requests
class TandoorError(RuntimeError):
"""Fehler beim Reden mit Tandoor — mit lesbarer Meldung."""
class TandoorClient:
def __init__(
self,
base_url: str,
token: str,
auth_scheme: str = "Bearer",
timeout: float = 45.0,
verify: bool = True,
retries: int = 2,
) -> None:
if not base_url or not token:
raise TandoorError("Tandoor-URL oder Token fehlt.")
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retries = max(0, retries)
self.session = requests.Session()
self.session.verify = verify
self.session.headers.update({
"Authorization": f"{auth_scheme} {token}".strip(),
"Accept": "application/json",
"User-Agent": "boehmitools/1.0",
})
self._cache: dict[str, list[dict[str, Any]]] = {}
# ------------------------------------------------------------------ HTTP
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self.base_url}/{path.lstrip('/')}"
last: Exception | None = None
for attempt in range(self.retries + 1):
try:
response = self.session.request(
method, url, timeout=self.timeout, **kwargs
)
except requests.RequestException as exc:
last = exc
if attempt < self.retries:
time.sleep(0.6 * (attempt + 1))
continue
raise TandoorError(f"{method} {url} nicht erreichbar: {exc}") from exc
# 5xx sind oft vorübergehend, 4xx nicht.
if response.status_code >= 500 and attempt < self.retries:
time.sleep(0.6 * (attempt + 1))
continue
if response.status_code >= 400:
raise TandoorError(
f"{method} {url} → HTTP {response.status_code}: "
f"{response.text[:400]}"
)
return response
raise TandoorError(f"{method} {url} fehlgeschlagen: {last}")
def get_json(self, path: str, **kwargs: Any) -> Any:
return self.request("GET", path, **kwargs).json()
def patch_json(self, path: str, payload: dict[str, Any]) -> Any:
return self.request("PATCH", path, json=payload).json()
def put_json(self, path: str, payload: dict[str, Any] | None = None) -> Any:
response = self.request("PUT", path, json=payload or {})
if not response.content:
return None
try:
return response.json()
except ValueError:
return None
def post_json(self, path: str, payload: dict[str, Any]) -> Any:
return self.request("POST", path, json=payload).json()
def delete(self, path: str) -> None:
self.request("DELETE", path)
# ----------------------------------------------------------- Sammlungen
def iter_objects(self, endpoint: str, params: dict[str, Any] | None = None) -> Iterator[dict[str, Any]]:
"""Läuft eine paginierte Liste vollständig ab."""
query = {"page_size": 200}
query.update(params or {})
path: str | None = f"api/{endpoint.strip('/')}/"
first = True
while path:
payload = self.get_json(path, params=query if first else None)
first = False
if isinstance(payload, list):
yield from (item for item in payload if isinstance(item, dict))
return
if not isinstance(payload, dict):
raise TandoorError(f"Unerwartete Antwort von {endpoint}.")
yield from (
item for item in payload.get("results") or [] if isinstance(item, dict)
)
nxt = payload.get("next")
if not nxt:
return
# "next" ist eine absolute URL; für die nächste Runde relativ machen.
path = nxt[len(self.base_url):] if nxt.startswith(self.base_url) else nxt
def list_objects(self, endpoint: str, refresh: bool = False) -> list[dict[str, Any]]:
"""Wie iter_objects, aber gepuffert — ein Lauf fragt jede Liste einmal ab."""
if refresh or endpoint not in self._cache:
self._cache[endpoint] = list(self.iter_objects(endpoint))
return self._cache[endpoint]
def forget(self, endpoint: str | None = None) -> None:
if endpoint is None:
self._cache.clear()
else:
self._cache.pop(endpoint, None)
# -------------------------------------------------------------- Bequem
def ping(self) -> dict[str, Any]:
"""Kurzer Verbindungstest; wirft TandoorError, wenn etwas klemmt."""
payload = self.get_json("api/food/", params={"page_size": 1})
count = payload.get("count") if isinstance(payload, dict) else None
return {"ok": True, "foods": count, "base_url": self.base_url}
def merge(self, endpoint: str, source_id: int, target_id: int) -> None:
"""
PUT /api/{endpoint}/{source}/merge/{target}/
Achtung: Tandoor hängt alle Verweise der Quelle auf das Ziel um und
löscht die Quelle. Die *Properties der Quelle* gehen dabei verloren
(so gewollt im Tandoor-Code, um Dubletten zu vermeiden) — es muss also
immer der schlechtere Eintrag auf den besseren zeigen.
"""
if source_id == target_id:
raise TandoorError("Quelle und Ziel sind identisch.")
self.put_json(f"api/{endpoint}/{source_id}/merge/{target_id}/")
@classmethod
def from_env(cls, **overrides: Any) -> "TandoorClient":
def flag(name: str, default: str) -> bool:
return os.environ.get(name, default).strip().casefold() not in {
"0", "false", "no", "nein", "off",
}
settings: dict[str, Any] = {
"base_url": os.environ.get("TANDOOR_URL", "").strip(),
"token": os.environ.get("TANDOOR_TOKEN", "").strip(),
"auth_scheme": os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer").strip() or "Bearer",
"timeout": float(os.environ.get("TANDOOR_TIMEOUT", "45") or 45),
"verify": flag("TANDOOR_VERIFY_TLS", "true"),
}
settings.update({k: v for k, v in overrides.items() if v is not None})
return cls(**settings)
# --------------------------------------------------------------- Properties
def food_property_map(food: dict[str, Any]) -> dict[int, float | None]:
"""{property_type_id: Wert} für ein Food-Objekt."""
result: dict[int, float | None] = {}
for prop in food.get("properties") or []:
if not isinstance(prop, dict):
continue
ptype = prop.get("property_type") or {}
type_id = ptype.get("id") if isinstance(ptype, dict) else None
if isinstance(type_id, int):
amount = prop.get("property_amount")
result[type_id] = None if amount is None else float(amount)
return result
def merged_properties_payload(
food: dict[str, Any],
updates: dict[int, float],
) -> list[dict[str, Any]]:
"""
Baut die vollständige properties-Liste für einen PATCH.
Tandoor ersetzt die Liste komplett (drf-writable-nested). Wer nur den neuen
Wert schickt, verliert die vorhandenen. Deshalb wird hier immer der Bestand
übernommen und nur ergänzt oder überschrieben.
"""
payload: list[dict[str, Any]] = []
seen: set[int] = set()
for prop in food.get("properties") or []:
if not isinstance(prop, dict):
continue
ptype = prop.get("property_type") or {}
type_id = ptype.get("id")
if not isinstance(type_id, int):
continue
seen.add(type_id)
amount = updates.get(type_id, prop.get("property_amount"))
entry: dict[str, Any] = {
"property_amount": None if amount is None else float(amount),
"property_type": {"id": type_id},
}
if isinstance(prop.get("id"), int):
entry["id"] = prop["id"]
payload.append(entry)
for type_id, amount in updates.items():
if type_id not in seen:
payload.append({
"property_amount": float(amount),
"property_type": {"id": type_id},
})
return payload