# -*- coding: utf-8 -*- """ HTTP/HTTPS Client mit Offline-Cache-Unterstuetzung. Bietet async HTTP GET/POST/PUT/DELETE und Download-Funktionen. Optionaler Cache fuer Offline-Betrieb. Unterstuetzt HTTP und HTTPS (via aiohttp). """ from __future__ import annotations import asyncio from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any from trixy_core.utils.debug import pinfo, pdebug, perror if TYPE_CHECKING: from trixy_core.online.cache import ResponseCache class RequestMethod(Enum): """HTTP-Methoden.""" GET = "GET" POST = "POST" PUT = "PUT" DELETE = "DELETE" PATCH = "PATCH" HEAD = "HEAD" @dataclass class HttpResponse: """HTTP-Response Wrapper.""" status_code: int = 0 headers: dict[str, str] = field(default_factory=dict) body: str = "" body_bytes: bytes = b"" url: str = "" from_cache: bool = False error: str = "" @property def ok(self) -> bool: """True bei Status 2xx.""" return 200 <= self.status_code < 300 @property def is_error(self) -> bool: """True bei Fehler (kein Status oder 4xx/5xx).""" return self.status_code == 0 or self.status_code >= 400 def json(self) -> Any: """Parst den Body als JSON.""" import json return json.loads(self.body) def __repr__(self) -> str: cached = " (cache)" if self.from_cache else "" return f"HttpResponse(status={self.status_code}{cached}, url={self.url!r})" class HttpClient: """ Async HTTP-Client mit optionalem Offline-Cache. Beispiel: ```python client = HttpClient(cache=response_cache) # Normaler Request resp = await client.get("https://api.example.com/data") # Mit Cache (wird offline zurueckgegeben) resp = await client.get( "https://api.weather.com/forecast", cache_ttl=3600, # 1 Stunde Cache ) # Download await client.download_to_file( "https://example.com/model.zip", "/tmp/model.zip", ) ``` """ def __init__( self, cache: "ResponseCache | None" = None, is_online_fn: "callable | None" = None, timeout: float = 30.0, ) -> None: """ Args: cache: ResponseCache fuer Offline-Betrieb (optional) is_online_fn: Funktion die True/False zurueckgibt (optional) timeout: Standard-Timeout in Sekunden """ self._cache = cache self._is_online_fn = is_online_fn self._timeout = timeout self._session = None @property def is_online(self) -> bool: """Prueft ob Online (ueber is_online_fn oder Fallback True).""" if self._is_online_fn: return self._is_online_fn() return True async def _get_session(self): """Erstellt oder gibt bestehende aiohttp-Session zurueck.""" if self._session is None or self._session.closed: import aiohttp self._session = aiohttp.ClientSession() return self._session async def close(self) -> None: """Schliesst die HTTP-Session.""" if self._session and not self._session.closed: await self._session.close() self._session = None # === HTTP-Methoden === async def get( self, url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float | None = None, cache_ttl: float = 0, ) -> HttpResponse: """HTTP GET Request.""" return await self.request( RequestMethod.GET, url, headers=headers, params=params, timeout=timeout, cache_ttl=cache_ttl, ) async def post( self, url: str, body: str | bytes | dict | None = None, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float | None = None, cache_ttl: float = 0, ) -> HttpResponse: """HTTP POST Request.""" return await self.request( RequestMethod.POST, url, body=body, headers=headers, params=params, timeout=timeout, cache_ttl=cache_ttl, ) async def put( self, url: str, body: str | bytes | dict | None = None, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> HttpResponse: """HTTP PUT Request.""" return await self.request( RequestMethod.PUT, url, body=body, headers=headers, timeout=timeout, ) async def delete( self, url: str, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> HttpResponse: """HTTP DELETE Request.""" return await self.request( RequestMethod.DELETE, url, headers=headers, timeout=timeout, ) async def head( self, url: str, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> HttpResponse: """HTTP HEAD Request (nur Header, kein Body).""" return await self.request( RequestMethod.HEAD, url, headers=headers, timeout=timeout, ) # === Kern-Request === async def request( self, method: RequestMethod, url: str, body: str | bytes | dict | None = None, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float | None = None, cache_ttl: float = 0, ) -> HttpResponse: """ Fuehrt einen HTTP-Request aus. Bei Offline-Zustand und aktivem Cache wird der Cache zurueckgegeben. Args: method: HTTP-Methode url: Ziel-URL body: Request-Body (str, bytes oder dict fuer JSON) headers: Request-Headers params: URL-Query-Parameter timeout: Timeout in Sekunden cache_ttl: Cache-TTL in Sekunden (0 = kein Cache) Returns: HttpResponse """ use_cache = cache_ttl > 0 and self._cache is not None method_str = method.value # Offline? → Cache pruefen if not self.is_online and use_cache: cached = self._cache.get(url, method_str, params=params, ignore_ttl=True) if cached: pdebug(f"Offline-Cache: {method_str} {url}") return HttpResponse( status_code=cached.status_code, headers=cached.headers, body=cached.body if isinstance(cached.body, str) else "", body_bytes=cached.body if isinstance(cached.body, bytes) else b"", url=url, from_cache=True, ) if not self.is_online: return HttpResponse( url=url, error="Offline — kein Cache verfuegbar", ) # Online → Request ausfuehren try: import aiohttp session = await self._get_session() req_timeout = aiohttp.ClientTimeout(total=timeout or self._timeout) # Body vorbereiten kwargs: dict[str, Any] = { "headers": headers, "params": params, "timeout": req_timeout, } if body is not None: if isinstance(body, dict): kwargs["json"] = body elif isinstance(body, bytes): kwargs["data"] = body else: kwargs["data"] = body async with session.request(method_str, url, **kwargs) as resp: resp_headers = dict(resp.headers) content_type = resp_headers.get("Content-Type", "") is_binary = "application/octet-stream" in content_type or "image/" in content_type if is_binary: resp_body_bytes = await resp.read() resp_body_str = "" else: resp_body_str = await resp.text() resp_body_bytes = b"" response = HttpResponse( status_code=resp.status, headers=resp_headers, body=resp_body_str, body_bytes=resp_body_bytes, url=str(resp.url), ) # Cache speichern if use_cache and response.ok: cache_body = resp_body_bytes if is_binary else resp_body_str self._cache.put( url, method_str, response.status_code, response.headers, cache_body, params=params, ttl_seconds=cache_ttl, ) return response except asyncio.TimeoutError: return HttpResponse(url=url, error=f"Timeout ({timeout or self._timeout}s)") except ImportError: return HttpResponse(url=url, error="aiohttp nicht installiert") except Exception as e: # Request fehlgeschlagen — Cache als Fallback if use_cache and self._cache: cached = self._cache.get(url, method_str, params=params, ignore_ttl=True) if cached: pdebug(f"Fehler-Fallback aus Cache: {method_str} {url} ({e})") return HttpResponse( status_code=cached.status_code, headers=cached.headers, body=cached.body if isinstance(cached.body, str) else "", body_bytes=cached.body if isinstance(cached.body, bytes) else b"", url=url, from_cache=True, ) return HttpResponse(url=url, error=str(e)) # === Download-Funktionen === async def download_to_string( self, url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float | None = None, cache_ttl: float = 0, ) -> str: """ Laedt eine URL und gibt den Inhalt als String zurueck. Returns: Response-Body als String, oder leerer String bei Fehler. """ resp = await self.get(url, headers=headers, params=params, timeout=timeout, cache_ttl=cache_ttl) if resp.ok: return resp.body if resp.error: perror(f"Download fehlgeschlagen: {url} — {resp.error}") return "" async def download_to_file( self, url: str, target_path: str, headers: dict[str, str] | None = None, timeout: float | None = None, chunk_size: int = 65536, progress_callback: "callable | None" = None, ) -> bool: """ Laedt eine Datei herunter und speichert sie auf der Festplatte. Args: url: Download-URL target_path: Ziel-Dateipfad headers: Request-Headers timeout: Timeout in Sekunden (None = kein Timeout) chunk_size: Chunk-Groesse fuer Streaming progress_callback: callback(downloaded_bytes, total_bytes) Returns: True bei Erfolg """ if not self.is_online: perror(f"Download nicht moeglich — offline: {url}") return False try: import aiohttp session = await self._get_session() req_timeout = aiohttp.ClientTimeout( total=timeout, sock_read=timeout or 300, ) async with session.get(url, headers=headers, timeout=req_timeout) as resp: if resp.status != 200: perror(f"Download fehlgeschlagen: {url} (HTTP {resp.status})") return False total = resp.content_length or 0 downloaded = 0 # Zielverzeichnis erstellen Path(target_path).parent.mkdir(parents=True, exist_ok=True) with open(target_path, "wb") as f: async for chunk in resp.content.iter_chunked(chunk_size): f.write(chunk) downloaded += len(chunk) if progress_callback: progress_callback(downloaded, total) pdebug(f"Download abgeschlossen: {url} -> {target_path} ({downloaded} Bytes)") return True except asyncio.TimeoutError: perror(f"Download Timeout: {url}") return False except Exception as e: perror(f"Download Fehler: {url} — {e}") return False