# 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