|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .errors import AuthRequiredError, NetworkError, ToolInputError, UpstreamError
|
|
|
|
logger = logging.getLogger("devii.http")
|
|
|
|
LOGIN_FAILURE_MARKERS = ("Invalid email or password", "name=\"password\"")
|
|
LOGIN_PATH = "/auth/login"
|
|
|
|
|
|
class PlatformClient:
|
|
def __init__(self, base_url: str, timeout_seconds: float, api_key: str = "") -> None:
|
|
headers = {"User-Agent": "devii/0.1", "Accept": "application/json"}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
headers["X-API-Key"] = api_key
|
|
self._client = httpx.AsyncClient(
|
|
base_url=base_url,
|
|
timeout=timeout_seconds,
|
|
follow_redirects=True,
|
|
headers=headers,
|
|
)
|
|
self.authenticated: bool = bool(api_key)
|
|
self.username: str | None = "api-key" if api_key else None
|
|
|
|
async def aclose(self) -> None:
|
|
await self._client.aclose()
|
|
|
|
def session_cookie(self) -> str:
|
|
return self._client.cookies.get("session") or ""
|
|
|
|
async def login(self, email: str, password: str, remember_me: bool = True) -> dict[str, Any]:
|
|
form = {
|
|
"email": email,
|
|
"password": password,
|
|
"remember_me": "on" if remember_me else "",
|
|
"next": "/feed",
|
|
}
|
|
response = await self._send("POST", LOGIN_PATH, data=form)
|
|
has_session = len(self._client.cookies) > 0
|
|
failed = response.status_code >= 400 or not has_session
|
|
try:
|
|
data = response.json()
|
|
if isinstance(data, dict) and (
|
|
data.get("ok") is False or data.get("error") or data.get("detail")
|
|
):
|
|
failed = True
|
|
except ValueError:
|
|
if any(marker in response.text for marker in LOGIN_FAILURE_MARKERS):
|
|
failed = True
|
|
|
|
if failed:
|
|
self.authenticated = False
|
|
self.username = None
|
|
logger.info("Login failed for %s", email)
|
|
raise AuthRequiredError(
|
|
"Login failed: invalid email or password.",
|
|
email=email,
|
|
)
|
|
|
|
self.authenticated = True
|
|
self.username = email
|
|
logger.info("Login succeeded for %s", email)
|
|
return {"status": "logged_in", "user": email}
|
|
|
|
async def logout(self) -> dict[str, Any]:
|
|
await self._send("GET", "/auth/logout")
|
|
self._client.cookies.clear()
|
|
self.authenticated = False
|
|
previous = self.username
|
|
self.username = None
|
|
logger.info("Logged out %s", previous)
|
|
return {"status": "logged_out", "previous_user": previous}
|
|
|
|
async def call(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
params: dict[str, Any] | None = None,
|
|
data: dict[str, Any] | None = None,
|
|
file_field: tuple[str, str] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> httpx.Response:
|
|
files = None
|
|
if file_field is not None:
|
|
field_name, raw_path = file_field
|
|
files = {field_name: self._open_upload(raw_path)}
|
|
|
|
response = await self._send(
|
|
method, path, params=params, data=data, files=files, headers=headers
|
|
)
|
|
|
|
if path != LOGIN_PATH and LOGIN_PATH in str(response.url):
|
|
self.authenticated = False
|
|
self.username = None
|
|
logger.info("Session expired during %s %s", method, path)
|
|
raise AuthRequiredError(
|
|
"Session expired or not authenticated. Use the login tool again.",
|
|
path=path,
|
|
)
|
|
|
|
if response.status_code == 401:
|
|
self.authenticated = False
|
|
logger.info("Unauthorized (401) during %s %s", method, path)
|
|
raise AuthRequiredError(
|
|
"Credentials are missing or invalid (401). Log in again or check the API key.",
|
|
path=path,
|
|
)
|
|
|
|
if response.status_code >= 500:
|
|
raise UpstreamError(
|
|
f"Server returned {response.status_code}.",
|
|
status=response.status_code,
|
|
path=path,
|
|
)
|
|
return response
|
|
|
|
def _open_upload(self, raw_path: str) -> tuple[str, bytes]:
|
|
candidate = Path(raw_path).expanduser()
|
|
if not candidate.is_file():
|
|
raise ToolInputError(f"File not found: {raw_path}", path=raw_path)
|
|
return candidate.name, candidate.read_bytes()
|
|
|
|
async def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
try:
|
|
logger.debug("Request %s %s kwargs=%s", method, path, list(kwargs))
|
|
response = await self._client.request(method, path, **kwargs)
|
|
logger.debug("Response %s %s -> %s", method, path, response.status_code)
|
|
return response
|
|
except httpx.TimeoutException as exc:
|
|
raise NetworkError(f"Request timed out: {method} {path}", path=path) from exc
|
|
except httpx.ConnectError as exc:
|
|
raise NetworkError(f"Could not connect: {method} {path}", path=path) from exc
|
|
except httpx.HTTPError as exc:
|
|
raise NetworkError(f"HTTP error: {exc}", path=path) from exc
|