v0.142.0
 1"""Thin HTTP client for the Plain Cloud API.
 2
 3Wraps httpx with the bearer token from credentials and surfaces non-2xx
 4responses as `APIError` with the server's message when available.
 5"""
 6
 7from __future__ import annotations
 8
 9from typing import Any
10
11import click
12import httpx
13
14from .credentials import Credentials
15
16
17class APIError(click.ClickException):
18    """Non-2xx response from the API. Click prints the message in red and exits 1."""
19
20    exit_code = 1
21
22    def __init__(self, status_code: int, message: str) -> None:
23        super().__init__(message)
24        self.status_code = status_code
25
26    def show(self, file: Any = None) -> None:
27        click.secho(self.message, fg="red", err=True)
28
29
30class Client:
31    def __init__(
32        self,
33        creds: Credentials,
34        *,
35        transport: httpx.BaseTransport | None = None,  # test seam for MockTransport
36    ) -> None:
37        self._client = httpx.Client(
38            base_url=creds.api_url.rstrip("/") + "/api",
39            headers={
40                "Authorization": f"Bearer {creds.token}",
41                "Accept": "application/json",
42            },
43            timeout=httpx.Timeout(30.0),
44            transport=transport,
45        )
46
47    def __enter__(self) -> Client:
48        return self
49
50    def __exit__(self, *exc: Any) -> None:
51        self._client.close()
52
53    def get(self, path: str, **kwargs: Any) -> Any:
54        return self._request("GET", path, **kwargs)
55
56    def raw_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
57        """Send a request and return the raw response without raising on
58        non-2xx. Used by the `api` escape-hatch subcommand so the user
59        sees the actual response body and status code from the server.
60        """
61        if not path.startswith("/"):
62            path = "/" + path
63        return self._client.request(method, path, **kwargs)
64
65    def _request(self, method: str, path: str, **kwargs: Any) -> Any:
66        response = self.raw_request(method, path, **kwargs)
67        if response.status_code >= 400:
68            try:
69                payload = response.json()
70                message = (
71                    payload.get("error") or payload.get("message") or response.text
72                )
73            except ValueError:
74                message = response.text or response.reason_phrase
75            raise APIError(response.status_code, message)
76        if not response.content:
77            return None
78        return response.json()