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

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""" 

9 

10from __future__ import annotations 

11 

12import os 

13 

14import httpx 

15 

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 

23 

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 

28 

29 

30class ForgeDashboardClient: 

31 """A forge-dashboard API client. 

32 

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. 

41 

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 """ 

48 

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 

64 

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 

77 

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) 

87 

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) 

93 

94 @property 

95 def raw(self) -> _GeneratedClient: 

96 """The underlying generated ``Client``.""" 

97 return self._raw 

98 

99 def health(self) -> Health: 

100 """Liveness. Needs no session -- a good first call to prove the 

101 client reaches the server at all. 

102 

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 

115 

116 async def ahealth(self) -> Health: 

117 """Async counterpart of :meth:`health`. 

118 

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 

128 

129 def get_version(self) -> Version: 

130 """The running server's build version. Needs no session. 

131 

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 

141 

142 async def aget_version(self) -> Version: 

143 """Async counterpart of :meth:`get_version`. 

144 

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 

154 

155 def close(self) -> None: 

156 self._raw.get_httpx_client().close() 

157 

158 async def aclose(self) -> None: 

159 await self._raw.get_async_httpx_client().aclose() 

160 

161 def __enter__(self) -> ForgeDashboardClient: 

162 return self 

163 

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() 

169 

170 async def __aenter__(self) -> ForgeDashboardClient: 

171 return self 

172 

173 async def __aexit__(self, *exc_info: object) -> None: 

174 await self.aclose()