# retoor from __future__ import annotations import logging from pathlib import Path from typing import Any, Callable import httpx from devplacepy import stealth 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 = "", key_resolver: Callable[[], str] | None = None, ) -> None: headers = { "User-Agent": "devii/0.1", "Accept": "application/json", "X-Devii-Agent": "1", } if api_key: headers["Authorization"] = f"Bearer {api_key}" headers["X-API-Key"] = api_key self._key_resolver = key_resolver self._client = stealth.stealth_async_client( 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 self._auth_failed(path, response) and self._refresh_key(): 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 @staticmethod def _auth_failed(path: str, response: httpx.Response) -> bool: if response.status_code == 401: return True return path != LOGIN_PATH and LOGIN_PATH in str(response.url) def _refresh_key(self) -> bool: if self._key_resolver is None: return False try: key = self._key_resolver() except Exception: # noqa: BLE001 - refresh is best-effort; the original failure still surfaces logger.exception("Credential refresh failed after auth failure") return False if not key or key == self._client.headers.get("X-API-Key"): return False self._client.headers["Authorization"] = f"Bearer {key}" self._client.headers["X-API-Key"] = key self.authenticated = True self.username = self.username or "api-key" logger.info("Refreshed platform credentials after auth failure") return True 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