Coverage for src/hush_hush/client.py: 70%

119 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-16 16:01 +0000

1"""Sync and async hush-hush clients.""" 

2 

3from __future__ import annotations 

4 

5import base64 

6import os 

7from datetime import datetime 

8from typing import Literal 

9 

10import httpx 

11 

12from ._generated.api.audit_log import query_audit_log as _query_audit_log 

13from ._generated.api.health import health as _health 

14from ._generated.api.objects import create_object as _create_object 

15from ._generated.api.objects import delete_object as _delete_object 

16from ._generated.api.objects import get_object as _get_object 

17from ._generated.api.objects import get_object_used_by as _get_object_used_by 

18from ._generated.api.objects import update_object as _update_object 

19from ._generated.client import AuthenticatedClient as _AuthenticatedClient 

20from ._generated.client import Client as _GeneratedClient 

21from ._generated.models.audit_log_entry import AuditLogEntry 

22from ._generated.models.create_object_request import CreateObjectRequest as _CreateObjectRequestBody 

23from ._generated.models.health import Health 

24from ._generated.models.object_metadata import ObjectMetadata 

25from ._generated.models.query_audit_log_order import QueryAuditLogOrder 

26from ._generated.models.update_object_request import UpdateObjectRequest as _UpdateObjectRequestBody 

27from ._generated.models.used_by import UsedBy 

28from ._generated.types import UNSET 

29from .errors import APIError 

30from .retry import AsyncRetryTransport, RetryTransport 

31 

32API_KEY_ENV_VAR = "HUSH_HUSH_API_KEY" 

33"""Environment variable `Client`/`AsyncClient` fall back to when `api_key` 

34isn't passed explicitly.""" 

35 

36DEFAULT_TIMEOUT = 30.0 

37"""Default per-request timeout, in seconds, used when `timeout` isn't 

38passed explicitly.""" 

39 

40__all__ = [ 

41 "API_KEY_ENV_VAR", 

42 "APIError", 

43 "AsyncClient", 

44 "AuditLogEntry", 

45 "Client", 

46 "Health", 

47 "ObjectMetadata", 

48 "UsedBy", 

49] 

50 

51 

52def _resolve_api_key(api_key: str | None) -> str | None: 

53 return api_key if api_key is not None else os.environ.get(API_KEY_ENV_VAR) 

54 

55 

56def _raise_for_status(response: httpx.Response, ok_status: int) -> None: 

57 """Raises APIError if response's status isn't the one success status 

58 this operation documents. hush-hush's read paths only ever document one 

59 success status each, so this doesn't need to handle a set of them. 

60 """ 

61 if response.status_code != ok_status: 

62 raise APIError(response) 

63 

64 

65class Client: 

66 """Synchronous hush-hush client. 

67 

68 A single instance handles both read and write calls. The credential is 

69 only actually required by hush-hush on write operations 

70 (create/update/delete); reads (get, used-by, audit-log query) succeed 

71 without one, since hush-hush's confidentiality boundary is "who holds a 

72 matching private key," not who's calling the endpoint. 

73 

74 Args: 

75 base_url: Base URL of the hush-hush instance to call, e.g. 

76 `"https://hush-hush.example.com"`. 

77 api_key: Bearer credential for write operations. Falls back to the 

78 `HUSH_HUSH_API_KEY` environment variable when not given. 

79 timeout: Per-request timeout, in seconds. 

80 max_retries: How many times a request is retried after a network 

81 failure or a 5xx/429 response before the error is raised to the 

82 caller. Any other 4xx is never retried. 

83 """ 

84 

85 def __init__( 

86 self, 

87 base_url: str, 

88 *, 

89 api_key: str | None = None, 

90 timeout: float = DEFAULT_TIMEOUT, 

91 max_retries: int = 3, 

92 ) -> None: 

93 resolved_key = _resolve_api_key(api_key) 

94 transport = RetryTransport(httpx.HTTPTransport(), max_retries=max_retries) 

95 httpx_args = {"transport": transport} 

96 if resolved_key: 

97 self._client = _AuthenticatedClient( 

98 base_url=base_url, 

99 token=resolved_key, 

100 timeout=httpx.Timeout(timeout), 

101 httpx_args=httpx_args, 

102 ) 

103 else: 

104 self._client = _GeneratedClient( 

105 base_url=base_url, timeout=httpx.Timeout(timeout), httpx_args=httpx_args 

106 ) 

107 

108 def health(self) -> Health: 

109 """Checks whether hush-hush is up. Needs no credential. 

110 

111 Returns: 

112 The server's health status. 

113 

114 Raises: 

115 APIError: If the server responds with anything other than 200. 

116 """ 

117 response = _health.sync_detailed(client=self._client) 

118 _raise_for_status(response, 200) 

119 assert isinstance(response.parsed, Health) 

120 return response.parsed 

121 

122 def create_object( 

123 self, 

124 slug: str, 

125 value: bytes, 

126 *, 

127 used_by: list[str] | None = None, 

128 caller: str | None = None, 

129 ) -> ObjectMetadata: 

130 """Stores an already-sealed value under a new, caller-chosen slug. 

131 Requires a credential (see `api_key`/`HUSH_HUSH_API_KEY`). 

132 

133 Args: 

134 slug: The new object's slug. Must match hush-hush's slug pattern 

135 (lowercase alphanumeric, `-`/`_`). 

136 value: The already-sealed (encrypted) value. This SDK never 

137 encrypts or decrypts anything — hush-hush stores whatever 

138 bytes it's given as opaque ciphertext. 

139 used_by: Consumers (repos or hosts) recorded as depending on 

140 this object. Set once, at creation; unaffected by later 

141 value updates. 

142 caller: Recorded in the audit log as the calling program's 

143 self-reported identity. Not verified by the server. 

144 

145 Returns: 

146 The created object's metadata. 

147 

148 Raises: 

149 APIError: If the server responds with anything other than 201 

150 (for example, 401 for a missing/invalid credential, or 409 

151 if an object already exists under that slug). 

152 """ 

153 body = _CreateObjectRequestBody( 

154 slug=slug, value=base64.b64encode(value).decode("ascii"), used_by=used_by or UNSET 

155 ) 

156 response = _create_object.sync_detailed( 

157 client=self._client, body=body, x_caller=caller or UNSET 

158 ) 

159 _raise_for_status(response, 201) 

160 assert isinstance(response.parsed, ObjectMetadata) 

161 return response.parsed 

162 

163 def get_object(self, slug: str, *, caller: str | None = None) -> bytes: 

164 """Fetches an object's sealed ciphertext exactly as stored — this 

165 SDK never decrypts it, the same as the server. Needs no credential. 

166 

167 Args: 

168 slug: The object's slug. 

169 caller: Recorded in the audit log as the calling program's 

170 self-reported identity. Not verified by the server. 

171 

172 Returns: 

173 The object's raw sealed ciphertext. 

174 

175 Raises: 

176 APIError: If the server responds with anything other than 200 

177 (for example, 404 if no object exists under that slug). 

178 """ 

179 response = _get_object.sync_detailed( 

180 slug=slug, client=self._client, x_caller=caller or UNSET 

181 ) 

182 _raise_for_status(response, 200) 

183 return response.content 

184 

185 def update_object( 

186 self, slug: str, value: bytes, *, caller: str | None = None 

187 ) -> ObjectMetadata: 

188 """Replaces the stored ciphertext for an existing object. The 

189 object's slug and used-by metadata are unchanged. Requires a 

190 credential. 

191 

192 Args: 

193 slug: The existing object's slug. 

194 value: The new already-sealed (encrypted) value. 

195 caller: Recorded in the audit log as the calling program's 

196 self-reported identity. Not verified by the server. 

197 

198 Returns: 

199 The updated object's metadata. 

200 

201 Raises: 

202 APIError: If the server responds with anything other than 200 

203 (for example, 401 or 404). 

204 """ 

205 body = _UpdateObjectRequestBody(value=base64.b64encode(value).decode("ascii")) 

206 response = _update_object.sync_detailed( 

207 slug=slug, client=self._client, body=body, x_caller=caller or UNSET 

208 ) 

209 _raise_for_status(response, 200) 

210 assert isinstance(response.parsed, ObjectMetadata) 

211 return response.parsed 

212 

213 def delete_object(self, slug: str, *, caller: str | None = None) -> None: 

214 """Permanently removes an object. A subsequent get by this slug 

215 returns a 404 APIError. Requires a credential. 

216 

217 Args: 

218 slug: The object's slug. 

219 caller: Recorded in the audit log as the calling program's 

220 self-reported identity. Not verified by the server. 

221 

222 Raises: 

223 APIError: If the server responds with anything other than 204 

224 (for example, 401 or 404). 

225 """ 

226 response = _delete_object.sync_detailed( 

227 slug=slug, client=self._client, x_caller=caller or UNSET 

228 ) 

229 _raise_for_status(response, 204) 

230 

231 def get_object_used_by(self, slug: str) -> UsedBy: 

232 """Returns the recorded list of consumers for an object — the "what 

233 depends on this" mapping set at creation. Needs no credential. 

234 

235 Args: 

236 slug: The object's slug. 

237 

238 Returns: 

239 The object's recorded consumers. 

240 

241 Raises: 

242 APIError: If the server responds with anything other than 200 

243 (for example, 404 if no object exists under that slug). 

244 """ 

245 response = _get_object_used_by.sync_detailed(slug=slug, client=self._client) 

246 _raise_for_status(response, 200) 

247 assert isinstance(response.parsed, UsedBy) 

248 return response.parsed 

249 

250 def query_audit_log( 

251 self, 

252 *, 

253 object_id: str | None = None, 

254 caller: str | None = None, 

255 from_: datetime | None = None, 

256 to: datetime | None = None, 

257 order: Literal["asc", "desc"] = "asc", 

258 ) -> list[AuditLogEntry]: 

259 """Queries the audit log — every create, read, update, and delete 

260 call is recorded here. Needs no credential. Filters combine with 

261 AND when more than one is given. 

262 

263 hush-hush's `/audit-log` endpoint has no pagination parameters, so 

264 this always returns the full matching result set as a single list, 

265 never a page plus a cursor. 

266 

267 Args: 

268 object_id: Restrict to entries for this object id. 

269 caller: Restrict to entries recorded with this caller identity. 

270 from_: Restrict to entries at or after this time. 

271 to: Restrict to entries at or before this time. 

272 order: "asc" (the default) returns oldest entries first, "desc" 

273 newest first. Only "asc" and "desc" are accepted, and a type 

274 checker rejects anything else. 

275 

276 Returns: 

277 Matching audit log entries, in the requested order. 

278 

279 Raises: 

280 APIError: If the server responds with anything other than 200. 

281 """ 

282 response = _query_audit_log.sync_detailed( 

283 client=self._client, 

284 object_id=object_id or UNSET, 

285 caller=caller or UNSET, 

286 from_=from_ or UNSET, 

287 to=to or UNSET, 

288 order=QueryAuditLogOrder(order), 

289 ) 

290 _raise_for_status(response, 200) 

291 assert isinstance(response.parsed, list) 

292 return response.parsed 

293 

294 

295class AsyncClient: 

296 """Asynchronous hush-hush client — see [Client][hush_hush.Client] for 

297 the shared behavior every method here has an `await`-able equivalent of. 

298 """ 

299 

300 def __init__( 

301 self, 

302 base_url: str, 

303 *, 

304 api_key: str | None = None, 

305 timeout: float = DEFAULT_TIMEOUT, 

306 max_retries: int = 3, 

307 ) -> None: 

308 resolved_key = _resolve_api_key(api_key) 

309 transport = AsyncRetryTransport(httpx.AsyncHTTPTransport(), max_retries=max_retries) 

310 httpx_args = {"transport": transport} 

311 if resolved_key: 

312 self._client = _AuthenticatedClient( 

313 base_url=base_url, 

314 token=resolved_key, 

315 timeout=httpx.Timeout(timeout), 

316 httpx_args=httpx_args, 

317 ) 

318 else: 

319 self._client = _GeneratedClient( 

320 base_url=base_url, timeout=httpx.Timeout(timeout), httpx_args=httpx_args 

321 ) 

322 

323 async def health(self) -> Health: 

324 """See [Client.health][hush_hush.Client.health].""" 

325 response = await _health.asyncio_detailed(client=self._client) 

326 _raise_for_status(response, 200) 

327 assert isinstance(response.parsed, Health) 

328 return response.parsed 

329 

330 async def create_object( 

331 self, 

332 slug: str, 

333 value: bytes, 

334 *, 

335 used_by: list[str] | None = None, 

336 caller: str | None = None, 

337 ) -> ObjectMetadata: 

338 """See [Client.create_object][hush_hush.Client.create_object].""" 

339 body = _CreateObjectRequestBody( 

340 slug=slug, value=base64.b64encode(value).decode("ascii"), used_by=used_by or UNSET 

341 ) 

342 response = await _create_object.asyncio_detailed( 

343 client=self._client, body=body, x_caller=caller or UNSET 

344 ) 

345 _raise_for_status(response, 201) 

346 assert isinstance(response.parsed, ObjectMetadata) 

347 return response.parsed 

348 

349 async def get_object(self, slug: str, *, caller: str | None = None) -> bytes: 

350 """See [Client.get_object][hush_hush.Client.get_object].""" 

351 response = await _get_object.asyncio_detailed( 

352 slug=slug, client=self._client, x_caller=caller or UNSET 

353 ) 

354 _raise_for_status(response, 200) 

355 return response.content 

356 

357 async def update_object( 

358 self, slug: str, value: bytes, *, caller: str | None = None 

359 ) -> ObjectMetadata: 

360 """See [Client.update_object][hush_hush.Client.update_object].""" 

361 body = _UpdateObjectRequestBody(value=base64.b64encode(value).decode("ascii")) 

362 response = await _update_object.asyncio_detailed( 

363 slug=slug, client=self._client, body=body, x_caller=caller or UNSET 

364 ) 

365 _raise_for_status(response, 200) 

366 assert isinstance(response.parsed, ObjectMetadata) 

367 return response.parsed 

368 

369 async def delete_object(self, slug: str, *, caller: str | None = None) -> None: 

370 """See [Client.delete_object][hush_hush.Client.delete_object].""" 

371 response = await _delete_object.asyncio_detailed( 

372 slug=slug, client=self._client, x_caller=caller or UNSET 

373 ) 

374 _raise_for_status(response, 204) 

375 

376 async def get_object_used_by(self, slug: str) -> UsedBy: 

377 """See [Client.get_object_used_by][hush_hush.Client.get_object_used_by].""" 

378 response = await _get_object_used_by.asyncio_detailed(slug=slug, client=self._client) 

379 _raise_for_status(response, 200) 

380 assert isinstance(response.parsed, UsedBy) 

381 return response.parsed 

382 

383 async def query_audit_log( 

384 self, 

385 *, 

386 object_id: str | None = None, 

387 caller: str | None = None, 

388 from_: datetime | None = None, 

389 to: datetime | None = None, 

390 order: Literal["asc", "desc"] = "asc", 

391 ) -> list[AuditLogEntry]: 

392 """See [Client.query_audit_log][hush_hush.Client.query_audit_log].""" 

393 response = await _query_audit_log.asyncio_detailed( 

394 client=self._client, 

395 object_id=object_id or UNSET, 

396 caller=caller or UNSET, 

397 from_=from_ or UNSET, 

398 to=to or UNSET, 

399 order=QueryAuditLogOrder(order), 

400 ) 

401 _raise_for_status(response, 200) 

402 assert isinstance(response.parsed, list) 

403 return response.parsed