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