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
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-16 16:01 +0000
1"""Sync and async hush-hush clients."""
3from __future__ import annotations
5import base64
6import os
7from datetime import datetime
8from typing import Literal
10import httpx
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
32API_KEY_ENV_VAR = "HUSH_HUSH_API_KEY"
33"""Environment variable `Client`/`AsyncClient` fall back to when `api_key`
34isn't passed explicitly."""
36DEFAULT_TIMEOUT = 30.0
37"""Default per-request timeout, in seconds, used when `timeout` isn't
38passed explicitly."""
40__all__ = [
41 "API_KEY_ENV_VAR",
42 "APIError",
43 "AsyncClient",
44 "AuditLogEntry",
45 "Client",
46 "Health",
47 "ObjectMetadata",
48 "UsedBy",
49]
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)
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)
65class Client:
66 """Synchronous hush-hush client.
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.
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 """
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 )
108 def health(self) -> Health:
109 """Checks whether hush-hush is up. Needs no credential.
111 Returns:
112 The server's health status.
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
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`).
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.
145 Returns:
146 The created object's metadata.
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
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.
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.
172 Returns:
173 The object's raw sealed ciphertext.
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
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.
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.
198 Returns:
199 The updated object's metadata.
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
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.
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.
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)
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.
235 Args:
236 slug: The object's slug.
238 Returns:
239 The object's recorded consumers.
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
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.
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.
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.
276 Returns:
277 Matching audit log entries, in the requested order.
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
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 """
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 )
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
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
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
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
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)
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
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