Coverage for src/forge_dashboard/client.py: 97%
70 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-11 22:25 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-11 22:25 +0000
1"""The hand-written client wrapper. Everything under
2``forge_dashboard._generated`` is produced by openapi-python-client from
3``openapi/openapi.yaml`` -- never edit it by hand, regenerate it instead
4(see ``hack/fetch-spec.sh`` and ``CONTRIBUTING.md``). This module,
5``errors.py`` and ``retry.py`` are the hand-written layer on top of it:
6auth injection, retry and typed errors (rules/sdk-generation.md's
7"Generated vs hand-written").
8"""
10from __future__ import annotations
12import os
14import httpx
16from ._generated.api.health import get_version as _get_version
17from ._generated.api.health import health as _get_health
18from ._generated.client import Client as _GeneratedClient
19from ._generated.models.health import Health
20from ._generated.models.version import Version
21from .errors import decode_error
22from .retry import DEFAULT_RETRY_CONFIG, AsyncRetryTransport, RetryConfig, RetryTransport
24#: The environment variable :class:`ForgeDashboardClient` falls back to
25#: when no token is passed explicitly via ``api_token``. Not a hardcoded
26#: credential -- it's the name of the env var to read one from.
27API_TOKEN_ENV_VAR = "FORGE_DASHBOARD_API_TOKEN" # noqa: S105 # nosec B105
30class ForgeDashboardClient:
31 """A forge-dashboard API client.
33 Every operation except :meth:`health`, :meth:`get_version` and the two
34 webhook receivers requires an authenticated session. forge-dashboard
35 accepts either a browser's passkey session cookie or a personal API
36 token (``Authorization: Bearer <token>``, minted via ``POST
37 /api/tokens``) -- see the spec's ``auth`` tag. This wrapper only speaks
38 the bearer-token half, the headless-friendly one: pass a token as
39 *api_token* or set ``FORGE_DASHBOARD_API_TOKEN`` in the environment --
40 the constructor argument wins when both are given.
42 The underlying generated client is available as :attr:`raw` for any
43 operation this wrapper doesn't have a dedicated method for; every
44 generated ``forge_dashboard._generated.api.<tag>.<operation>`` module's
45 ``sync_detailed``/``asyncio_detailed`` functions take it as their
46 ``client`` argument.
47 """
49 def __init__(
50 self,
51 base_url: str,
52 *,
53 api_token: str | None = None,
54 retry: RetryConfig = DEFAULT_RETRY_CONFIG,
55 httpx_client: httpx.Client | None = None,
56 httpx_async_client: httpx.AsyncClient | None = None,
57 raise_on_unexpected_status: bool = False,
58 ) -> None:
59 token = api_token if api_token is not None else os.environ.get(API_TOKEN_ENV_VAR)
60 # HTTP header names are case-insensitive (RFC 7230) and httpx
61 # normalizes lookups, so a casing mutant on "Authorization" below
62 # is behaviorally equivalent -- pragma'd rather than chasing it.
63 headers = {"Authorization": f"Bearer {token}"} if token else {} # pragma: no mutate
65 # headers isn't passed here, and base_url is never read back off
66 # self._raw afterward either: this Client's own lazy httpx-client
67 # construction is never reached -- set_httpx_client/
68 # set_async_httpx_client below always override it -- so the base
69 # URL and the auth header both have to be applied to whichever
70 # client actually sends requests instead (see below). base_url is
71 # on its own statement, pragma'd, so mutating that dead value
72 # doesn't mask a real mutation of raise_on_unexpected_status on the
73 # same line -- that one *is* read, by every generated operation's
74 # _parse_response.
75 self._raw = _GeneratedClient(base_url=base_url) # pragma: no mutate - never read back, see comment above
76 self._raw.raise_on_unexpected_status = raise_on_unexpected_status
78 sync_client = httpx_client
79 if sync_client is None:
80 sync_client = httpx.Client(base_url=base_url, transport=RetryTransport(retry=retry))
81 # Apply the bearer token directly to whichever client will actually
82 # send requests -- a caller-supplied httpx_client bypasses the
83 # generated Client's own lazy header construction, so the header
84 # has to be set here rather than relying on that path.
85 sync_client.headers.update(headers)
86 self._raw.set_httpx_client(sync_client)
88 async_client = httpx_async_client
89 if async_client is None:
90 async_client = httpx.AsyncClient(base_url=base_url, transport=AsyncRetryTransport(retry=retry))
91 async_client.headers.update(headers)
92 self._raw.set_async_httpx_client(async_client)
94 @property
95 def raw(self) -> _GeneratedClient:
96 """The underlying generated ``Client``."""
97 return self._raw
99 def health(self) -> Health:
100 """Liveness. Needs no session -- a good first call to prove the
101 client reaches the server at all.
103 Returns:
104 The server's health status.
105 """
106 response = _get_health.sync_detailed(client=self._raw)
107 if error := decode_error(response):
108 raise error
109 if response.parsed is None:
110 # Not an assert -- holds under -O and doesn't trip bandit's
111 # B101. health's only documented response is 200 Health, so
112 # this only fires if the spec and server ever disagree.
113 raise TypeError(f"unexpected health response body: {response.parsed!r}")
114 return response.parsed
116 async def ahealth(self) -> Health:
117 """Async counterpart of :meth:`health`.
119 Returns:
120 The server's health status.
121 """
122 response = await _get_health.asyncio_detailed(client=self._raw)
123 if error := decode_error(response):
124 raise error
125 if response.parsed is None:
126 raise TypeError(f"unexpected health response body: {response.parsed!r}")
127 return response.parsed
129 def get_version(self) -> Version:
130 """The running server's build version. Needs no session.
132 Returns:
133 The running server's build version.
134 """
135 response = _get_version.sync_detailed(client=self._raw)
136 if error := decode_error(response):
137 raise error
138 if response.parsed is None:
139 raise TypeError(f"unexpected getVersion response body: {response.parsed!r}")
140 return response.parsed
142 async def aget_version(self) -> Version:
143 """Async counterpart of :meth:`get_version`.
145 Returns:
146 The running server's build version.
147 """
148 response = await _get_version.asyncio_detailed(client=self._raw)
149 if error := decode_error(response):
150 raise error
151 if response.parsed is None:
152 raise TypeError(f"unexpected getVersion response body: {response.parsed!r}")
153 return response.parsed
155 def close(self) -> None:
156 self._raw.get_httpx_client().close()
158 async def aclose(self) -> None:
159 await self._raw.get_async_httpx_client().aclose()
161 def __enter__(self) -> ForgeDashboardClient:
162 return self
164 def __exit__(self, *exc_info: object) -> None:
165 # httpx.BaseTransport's own __exit__ ignores exc_info and just
166 # closes -- our RetryTransport doesn't override that -- so there's
167 # nothing this needs from *exc_info beyond letting `with` call it.
168 self.close()
170 async def __aenter__(self) -> ForgeDashboardClient:
171 return self
173 async def __aexit__(self, *exc_info: object) -> None:
174 await self.aclose()