| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- # -*- 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
|