"""Module to simplify asynchronous HTTP calls.""" from __future__ import annotations import asyncio import json import logging from typing import Any, ParamSpec from aiohttp import BasicAuth, ClientSession, ClientTimeout from .parsers import decode_plist logging.getLogger(__name__) class HttpResponse: """Response of a request made by `HttpSession`.""" def __init__(self, status_code: int, content: bytes) -> None: """Initialize the response.""" self._status_code = status_code self._content = content @property def status_code(self) -> int: """HTTP status code of the response.""" return self._status_code @property def ok(self) -> bool: """Whether the status code is "OK" (2xx).""" return str(self._status_code).startswith("2") def text(self) -> str: """Response content as a UTF-8 encoded string.""" return self._content.decode("utf-8") def json(self) -> dict[Any, Any]: """Response content as a dict, obtained by JSON-decoding the response content.""" return json.loads(self.text()) def plist(self) -> dict[Any, Any]: """Response content as a dict, obtained by Plist-decoding the response content.""" data = decode_plist(self._content) if not isinstance(data, dict): msg = f"Unknown Plist-encoded data type: {data}. This is a bug, please report it." raise TypeError(msg) return data P = ParamSpec("P") class HttpSession: """Asynchronous HTTP session manager. For internal use only.""" def __init__(self) -> None: # noqa: D107 self._session: ClientSession | None = None async def _ensure_session(self) -> None: if self._session is None: logging.debug("Creating aiohttp session") self._session = ClientSession(timeout=ClientTimeout(total=5)) async def close(self) -> None: """Close the underlying session. Should be called when session will no longer be used.""" if self._session is not None: logging.debug("Closing aiohttp session") await self._session.close() self._session = None def __del__(self) -> None: """ Attempt to gracefully close the session. Ideally this should be done by manually calling close(). """ if self._session is None: return try: loop = asyncio.get_running_loop() loop.call_soon_threadsafe(loop.create_task, self.close()) except RuntimeError: # cannot await closure pass async def request( self, method: str, url: str, auth: tuple[str] | None = None, **kwargs: P.kwargs, ) -> HttpResponse: """ Make an HTTP request. Keyword arguments will directly be passed to `aiohttp.ClientSession.request`. """ await self._ensure_session() basic_auth = None if auth is not None: basic_auth = BasicAuth(auth[0], auth[1]) async with await self._session.request( method, url, auth=basic_auth, ssl=False, **kwargs, ) as r: return HttpResponse(r.status, await r.content.read()) async def get(self, url: str, **kwargs: P.kwargs) -> HttpResponse: """Alias for `HttpSession.request("GET", ...)`.""" return await self.request("GET", url, **kwargs) async def post(self, url: str, **kwargs: P.kwargs) -> HttpResponse: """Alias for `HttpSession.request("POST", ...)`.""" return await self.request("POST", url, **kwargs)