Files
hockey_new/hockey_data/client.py

111 lines
4.3 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from .config import HockeySettingsStore
class Stat2TVNotFoundError(RuntimeError):
def __init__(self, url: str, status_code: int = 404) -> None:
self.url = url
self.status_code = status_code
super().__init__(f"Stat2TV resource not found: HTTP {status_code}")
@dataclass(slots=True)
class Stat2TVResponse:
content: bytes
status_code: int
content_type: str
auth_mode: str
url: str
class Stat2TVClient:
def __init__(self, settings: HockeySettingsStore) -> None:
self.settings_store = settings
self._working_auth_mode: str | None = None
self._clients: dict[bool, httpx.AsyncClient] = {}
self._auth_signature: tuple[str, str, str] | None = None
def _client(self, verify_ssl: bool) -> httpx.AsyncClient:
# A bounded pool is shared by independent matches. Credentials remain
# request-local; changing settings never leaks one request's auth.
client = self._clients.get(verify_ssl)
if client is None or client.is_closed:
client = httpx.AsyncClient(
verify=verify_ssl, follow_redirects=True,
limits=httpx.Limits(max_connections=32, max_keepalive_connections=16),
headers={"Accept": "application/xml,text/xml,*/*",
"User-Agent": "HockeyControlPanel/20.2"},
)
self._clients[verify_ssl] = client
return client
async def aclose(self) -> None:
clients, self._clients = list(self._clients.values()), {}
for client in clients:
await client.aclose()
async def fetch_xml(self, endpoint: str) -> Stat2TVResponse:
settings = self.settings_store.public()
username, password = self.settings_store.credentials()
if not username or not password:
raise RuntimeError(
"Логин и пароль Stat2TV не настроены. "
"Откройте настройки хоккейного модуля."
)
url = settings["base_url"].rstrip("/") + "/" + endpoint.lstrip("/")
signature = (settings["base_url"], username, password)
if self._auth_signature != signature:
self._working_auth_mode = None
self._auth_signature = signature
requested_mode = settings["auth_mode"]
if requested_mode in {"basic", "digest"}:
modes = [requested_mode]
elif self._working_auth_mode in {"basic", "digest"}:
other = "digest" if self._working_auth_mode == "basic" else "basic"
modes = [self._working_auth_mode, other]
else:
modes = ["basic", "digest"]
last_error: Exception | None = None
for mode in modes:
auth: Any = (
httpx.BasicAuth(username, password)
if mode == "basic"
else httpx.DigestAuth(username, password)
)
try:
client = self._client(bool(settings["verify_ssl"]))
response = await client.get(url, auth=auth,
timeout=float(settings["request_timeout_seconds"]))
if response.status_code in {401, 403} and requested_mode == "auto":
last_error = RuntimeError(
f"Авторизация {mode} отклонена: HTTP {response.status_code}"
)
continue
if response.status_code in {404, 410}:
raise Stat2TVNotFoundError(str(response.url), response.status_code)
response.raise_for_status()
self._working_auth_mode = mode
return Stat2TVResponse(
content=response.content,
status_code=response.status_code,
content_type=response.headers.get("content-type", ""),
auth_mode=mode,
url=str(response.url),
)
except Stat2TVNotFoundError:
raise
except Exception as error:
last_error = error
if requested_mode != "auto":
break
raise RuntimeError(f"Stat2TV недоступен: {last_error}")