chore: initial import
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user