client.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. # -*- coding: utf-8 -*-
  2. """
  3. HTTP/HTTPS Client mit Offline-Cache-Unterstuetzung.
  4. Bietet async HTTP GET/POST/PUT/DELETE und Download-Funktionen.
  5. Optionaler Cache fuer Offline-Betrieb.
  6. Unterstuetzt HTTP und HTTPS (via aiohttp).
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. from dataclasses import dataclass, field
  11. from enum import Enum
  12. from pathlib import Path
  13. from typing import TYPE_CHECKING, Any
  14. from trixy_core.utils.debug import pinfo, pdebug, perror
  15. if TYPE_CHECKING:
  16. from trixy_core.online.cache import ResponseCache
  17. class RequestMethod(Enum):
  18. """HTTP-Methoden."""
  19. GET = "GET"
  20. POST = "POST"
  21. PUT = "PUT"
  22. DELETE = "DELETE"
  23. PATCH = "PATCH"
  24. HEAD = "HEAD"
  25. @dataclass
  26. class HttpResponse:
  27. """HTTP-Response Wrapper."""
  28. status_code: int = 0
  29. headers: dict[str, str] = field(default_factory=dict)
  30. body: str = ""
  31. body_bytes: bytes = b""
  32. url: str = ""
  33. from_cache: bool = False
  34. error: str = ""
  35. @property
  36. def ok(self) -> bool:
  37. """True bei Status 2xx."""
  38. return 200 <= self.status_code < 300
  39. @property
  40. def is_error(self) -> bool:
  41. """True bei Fehler (kein Status oder 4xx/5xx)."""
  42. return self.status_code == 0 or self.status_code >= 400
  43. def json(self) -> Any:
  44. """Parst den Body als JSON."""
  45. import json
  46. return json.loads(self.body)
  47. def __repr__(self) -> str:
  48. cached = " (cache)" if self.from_cache else ""
  49. return f"HttpResponse(status={self.status_code}{cached}, url={self.url!r})"
  50. class HttpClient:
  51. """
  52. Async HTTP-Client mit optionalem Offline-Cache.
  53. Beispiel:
  54. ```python
  55. client = HttpClient(cache=response_cache)
  56. # Normaler Request
  57. resp = await client.get("https://api.example.com/data")
  58. # Mit Cache (wird offline zurueckgegeben)
  59. resp = await client.get(
  60. "https://api.weather.com/forecast",
  61. cache_ttl=3600, # 1 Stunde Cache
  62. )
  63. # Download
  64. await client.download_to_file(
  65. "https://example.com/model.zip",
  66. "/tmp/model.zip",
  67. )
  68. ```
  69. """
  70. def __init__(
  71. self,
  72. cache: "ResponseCache | None" = None,
  73. is_online_fn: "callable | None" = None,
  74. timeout: float = 30.0,
  75. ) -> None:
  76. """
  77. Args:
  78. cache: ResponseCache fuer Offline-Betrieb (optional)
  79. is_online_fn: Funktion die True/False zurueckgibt (optional)
  80. timeout: Standard-Timeout in Sekunden
  81. """
  82. self._cache = cache
  83. self._is_online_fn = is_online_fn
  84. self._timeout = timeout
  85. self._session = None
  86. @property
  87. def is_online(self) -> bool:
  88. """Prueft ob Online (ueber is_online_fn oder Fallback True)."""
  89. if self._is_online_fn:
  90. return self._is_online_fn()
  91. return True
  92. async def _get_session(self):
  93. """Erstellt oder gibt bestehende aiohttp-Session zurueck."""
  94. if self._session is None or self._session.closed:
  95. import aiohttp
  96. self._session = aiohttp.ClientSession()
  97. return self._session
  98. async def close(self) -> None:
  99. """Schliesst die HTTP-Session."""
  100. if self._session and not self._session.closed:
  101. await self._session.close()
  102. self._session = None
  103. # === HTTP-Methoden ===
  104. async def get(
  105. self,
  106. url: str,
  107. headers: dict[str, str] | None = None,
  108. params: dict[str, str] | None = None,
  109. timeout: float | None = None,
  110. cache_ttl: float = 0,
  111. ) -> HttpResponse:
  112. """HTTP GET Request."""
  113. return await self.request(
  114. RequestMethod.GET, url,
  115. headers=headers, params=params,
  116. timeout=timeout, cache_ttl=cache_ttl,
  117. )
  118. async def post(
  119. self,
  120. url: str,
  121. body: str | bytes | dict | None = None,
  122. headers: dict[str, str] | None = None,
  123. params: dict[str, str] | None = None,
  124. timeout: float | None = None,
  125. cache_ttl: float = 0,
  126. ) -> HttpResponse:
  127. """HTTP POST Request."""
  128. return await self.request(
  129. RequestMethod.POST, url,
  130. body=body, headers=headers, params=params,
  131. timeout=timeout, cache_ttl=cache_ttl,
  132. )
  133. async def put(
  134. self,
  135. url: str,
  136. body: str | bytes | dict | None = None,
  137. headers: dict[str, str] | None = None,
  138. timeout: float | None = None,
  139. ) -> HttpResponse:
  140. """HTTP PUT Request."""
  141. return await self.request(
  142. RequestMethod.PUT, url,
  143. body=body, headers=headers, timeout=timeout,
  144. )
  145. async def delete(
  146. self,
  147. url: str,
  148. headers: dict[str, str] | None = None,
  149. timeout: float | None = None,
  150. ) -> HttpResponse:
  151. """HTTP DELETE Request."""
  152. return await self.request(
  153. RequestMethod.DELETE, url,
  154. headers=headers, timeout=timeout,
  155. )
  156. async def head(
  157. self,
  158. url: str,
  159. headers: dict[str, str] | None = None,
  160. timeout: float | None = None,
  161. ) -> HttpResponse:
  162. """HTTP HEAD Request (nur Header, kein Body)."""
  163. return await self.request(
  164. RequestMethod.HEAD, url,
  165. headers=headers, timeout=timeout,
  166. )
  167. # === Kern-Request ===
  168. async def request(
  169. self,
  170. method: RequestMethod,
  171. url: str,
  172. body: str | bytes | dict | None = None,
  173. headers: dict[str, str] | None = None,
  174. params: dict[str, str] | None = None,
  175. timeout: float | None = None,
  176. cache_ttl: float = 0,
  177. ) -> HttpResponse:
  178. """
  179. Fuehrt einen HTTP-Request aus.
  180. Bei Offline-Zustand und aktivem Cache wird der Cache zurueckgegeben.
  181. Args:
  182. method: HTTP-Methode
  183. url: Ziel-URL
  184. body: Request-Body (str, bytes oder dict fuer JSON)
  185. headers: Request-Headers
  186. params: URL-Query-Parameter
  187. timeout: Timeout in Sekunden
  188. cache_ttl: Cache-TTL in Sekunden (0 = kein Cache)
  189. Returns:
  190. HttpResponse
  191. """
  192. use_cache = cache_ttl > 0 and self._cache is not None
  193. method_str = method.value
  194. # Offline? → Cache pruefen
  195. if not self.is_online and use_cache:
  196. cached = self._cache.get(url, method_str, params=params, ignore_ttl=True)
  197. if cached:
  198. pdebug(f"Offline-Cache: {method_str} {url}")
  199. return HttpResponse(
  200. status_code=cached.status_code,
  201. headers=cached.headers,
  202. body=cached.body if isinstance(cached.body, str) else "",
  203. body_bytes=cached.body if isinstance(cached.body, bytes) else b"",
  204. url=url,
  205. from_cache=True,
  206. )
  207. if not self.is_online:
  208. return HttpResponse(
  209. url=url,
  210. error="Offline — kein Cache verfuegbar",
  211. )
  212. # Online → Request ausfuehren
  213. try:
  214. import aiohttp
  215. session = await self._get_session()
  216. req_timeout = aiohttp.ClientTimeout(total=timeout or self._timeout)
  217. # Body vorbereiten
  218. kwargs: dict[str, Any] = {
  219. "headers": headers,
  220. "params": params,
  221. "timeout": req_timeout,
  222. }
  223. if body is not None:
  224. if isinstance(body, dict):
  225. kwargs["json"] = body
  226. elif isinstance(body, bytes):
  227. kwargs["data"] = body
  228. else:
  229. kwargs["data"] = body
  230. async with session.request(method_str, url, **kwargs) as resp:
  231. resp_headers = dict(resp.headers)
  232. content_type = resp_headers.get("Content-Type", "")
  233. is_binary = "application/octet-stream" in content_type or "image/" in content_type
  234. if is_binary:
  235. resp_body_bytes = await resp.read()
  236. resp_body_str = ""
  237. else:
  238. resp_body_str = await resp.text()
  239. resp_body_bytes = b""
  240. response = HttpResponse(
  241. status_code=resp.status,
  242. headers=resp_headers,
  243. body=resp_body_str,
  244. body_bytes=resp_body_bytes,
  245. url=str(resp.url),
  246. )
  247. # Cache speichern
  248. if use_cache and response.ok:
  249. cache_body = resp_body_bytes if is_binary else resp_body_str
  250. self._cache.put(
  251. url, method_str, response.status_code,
  252. response.headers, cache_body,
  253. params=params, ttl_seconds=cache_ttl,
  254. )
  255. return response
  256. except asyncio.TimeoutError:
  257. return HttpResponse(url=url, error=f"Timeout ({timeout or self._timeout}s)")
  258. except ImportError:
  259. return HttpResponse(url=url, error="aiohttp nicht installiert")
  260. except Exception as e:
  261. # Request fehlgeschlagen — Cache als Fallback
  262. if use_cache and self._cache:
  263. cached = self._cache.get(url, method_str, params=params, ignore_ttl=True)
  264. if cached:
  265. pdebug(f"Fehler-Fallback aus Cache: {method_str} {url} ({e})")
  266. return HttpResponse(
  267. status_code=cached.status_code,
  268. headers=cached.headers,
  269. body=cached.body if isinstance(cached.body, str) else "",
  270. body_bytes=cached.body if isinstance(cached.body, bytes) else b"",
  271. url=url,
  272. from_cache=True,
  273. )
  274. return HttpResponse(url=url, error=str(e))
  275. # === Download-Funktionen ===
  276. async def download_to_string(
  277. self,
  278. url: str,
  279. headers: dict[str, str] | None = None,
  280. params: dict[str, str] | None = None,
  281. timeout: float | None = None,
  282. cache_ttl: float = 0,
  283. ) -> str:
  284. """
  285. Laedt eine URL und gibt den Inhalt als String zurueck.
  286. Returns:
  287. Response-Body als String, oder leerer String bei Fehler.
  288. """
  289. resp = await self.get(url, headers=headers, params=params,
  290. timeout=timeout, cache_ttl=cache_ttl)
  291. if resp.ok:
  292. return resp.body
  293. if resp.error:
  294. perror(f"Download fehlgeschlagen: {url} — {resp.error}")
  295. return ""
  296. async def download_to_file(
  297. self,
  298. url: str,
  299. target_path: str,
  300. headers: dict[str, str] | None = None,
  301. timeout: float | None = None,
  302. chunk_size: int = 65536,
  303. progress_callback: "callable | None" = None,
  304. ) -> bool:
  305. """
  306. Laedt eine Datei herunter und speichert sie auf der Festplatte.
  307. Args:
  308. url: Download-URL
  309. target_path: Ziel-Dateipfad
  310. headers: Request-Headers
  311. timeout: Timeout in Sekunden (None = kein Timeout)
  312. chunk_size: Chunk-Groesse fuer Streaming
  313. progress_callback: callback(downloaded_bytes, total_bytes)
  314. Returns:
  315. True bei Erfolg
  316. """
  317. if not self.is_online:
  318. perror(f"Download nicht moeglich — offline: {url}")
  319. return False
  320. try:
  321. import aiohttp
  322. session = await self._get_session()
  323. req_timeout = aiohttp.ClientTimeout(
  324. total=timeout, sock_read=timeout or 300,
  325. )
  326. async with session.get(url, headers=headers, timeout=req_timeout) as resp:
  327. if resp.status != 200:
  328. perror(f"Download fehlgeschlagen: {url} (HTTP {resp.status})")
  329. return False
  330. total = resp.content_length or 0
  331. downloaded = 0
  332. # Zielverzeichnis erstellen
  333. Path(target_path).parent.mkdir(parents=True, exist_ok=True)
  334. with open(target_path, "wb") as f:
  335. async for chunk in resp.content.iter_chunked(chunk_size):
  336. f.write(chunk)
  337. downloaded += len(chunk)
  338. if progress_callback:
  339. progress_callback(downloaded, total)
  340. pdebug(f"Download abgeschlossen: {url} -> {target_path} ({downloaded} Bytes)")
  341. return True
  342. except asyncio.TimeoutError:
  343. perror(f"Download Timeout: {url}")
  344. return False
  345. except Exception as e:
  346. perror(f"Download Fehler: {url} — {e}")
  347. return False