Skip to content

Commit fa150d4

Browse files
authored
Merge pull request #133 from kernel/hypeship/audit-log-download-helper
feat: add complete audit log download helper
2 parents 0a54788 + 3e2fcf9 commit fa150d4

4 files changed

Lines changed: 765 additions & 1 deletion

File tree

src/kernel/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
app_registry,
5252
export_registry,
5353
)
54+
from .lib.audit_log_download import (
55+
AuditLogDownloadError,
56+
AuditLogDownloadResult,
57+
AuditLogDownloadProgress,
58+
)
5459

5560
__all__ = [
5661
"types",
@@ -105,6 +110,9 @@
105110
"App",
106111
"app_registry",
107112
"export_registry",
113+
"AuditLogDownloadError",
114+
"AuditLogDownloadProgress",
115+
"AuditLogDownloadResult",
108116
]
109117

110118
if not _t.TYPE_CHECKING:
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
from __future__ import annotations
2+
3+
import time
4+
import hashlib
5+
import inspect
6+
from typing import BinaryIO, Callable, Optional, Protocol, Awaitable, ContextManager, AsyncContextManager
7+
from dataclasses import dataclass
8+
9+
import anyio
10+
import httpx
11+
from anyio import to_thread
12+
13+
from .._exceptions import KernelError
14+
15+
_DEFAULT_MAX_TRANSFER_RETRIES = 6
16+
_MAX_CHUNK_ROWS = 50_000
17+
_MAX_RETRY_DELAY = 8.0
18+
19+
20+
class AuditLogDownloadError(KernelError):
21+
pass
22+
23+
24+
@dataclass(frozen=True)
25+
class AuditLogDownloadResult:
26+
bytes_written: int
27+
chunks: int
28+
rows: int
29+
30+
31+
@dataclass(frozen=True)
32+
class AuditLogDownloadProgress(AuditLogDownloadResult):
33+
chunk_rows: int
34+
35+
36+
class _SyncChunkResponse(Protocol):
37+
@property
38+
def headers(self) -> httpx.Headers: ...
39+
40+
def read(self) -> bytes: ...
41+
42+
def close(self) -> None: ...
43+
44+
45+
class _AsyncChunkResponse(Protocol):
46+
@property
47+
def headers(self) -> httpx.Headers: ...
48+
49+
async def read(self) -> bytes: ...
50+
51+
async def close(self) -> None: ...
52+
53+
54+
SyncFetchChunk = Callable[[Optional[str]], ContextManager[_SyncChunkResponse]]
55+
AsyncFetchChunk = Callable[[Optional[str]], AsyncContextManager[_AsyncChunkResponse]]
56+
ProgressCallback = Callable[[AuditLogDownloadProgress], None]
57+
AsyncProgressCallback = Callable[[AuditLogDownloadProgress], Optional[Awaitable[None]]]
58+
59+
60+
def download_audit_logs(
61+
fetch_chunk: SyncFetchChunk,
62+
destination: BinaryIO,
63+
*,
64+
on_progress: ProgressCallback | None = None,
65+
max_transfer_retries: int = _DEFAULT_MAX_TRANSFER_RETRIES,
66+
) -> AuditLogDownloadResult:
67+
if not callable(getattr(destination, "write", None)):
68+
raise TypeError("audit log download destination must provide write()")
69+
_validate_max_transfer_retries(max_transfer_retries)
70+
71+
cursor: str | None = None
72+
result = AuditLogDownloadResult(bytes_written=0, chunks=0, rows=0)
73+
seen_cursors: set[str] = set()
74+
while True:
75+
body, headers = _fetch_verified_chunk(fetch_chunk, cursor, max_transfer_retries)
76+
chunk_rows, next_cursor, has_more = _parse_chunk_headers(headers, cursor)
77+
if has_more and next_cursor is not None:
78+
if next_cursor in seen_cursors:
79+
raise AuditLogDownloadError("response repeated X-Next-Cursor header")
80+
seen_cursors.add(next_cursor)
81+
_write_chunk(destination, body)
82+
83+
cursor = next_cursor
84+
result = AuditLogDownloadResult(
85+
bytes_written=result.bytes_written + len(body),
86+
chunks=result.chunks + 1,
87+
rows=result.rows + chunk_rows,
88+
)
89+
if on_progress is not None:
90+
on_progress(
91+
AuditLogDownloadProgress(
92+
bytes_written=result.bytes_written,
93+
chunks=result.chunks,
94+
rows=result.rows,
95+
chunk_rows=chunk_rows,
96+
)
97+
)
98+
if not has_more:
99+
return result
100+
101+
102+
async def async_download_audit_logs(
103+
fetch_chunk: AsyncFetchChunk,
104+
destination: BinaryIO,
105+
*,
106+
on_progress: AsyncProgressCallback | None = None,
107+
max_transfer_retries: int = _DEFAULT_MAX_TRANSFER_RETRIES,
108+
) -> AuditLogDownloadResult:
109+
if not callable(getattr(destination, "write", None)):
110+
raise TypeError("audit log download destination must provide write()")
111+
_validate_max_transfer_retries(max_transfer_retries)
112+
113+
cursor: str | None = None
114+
result = AuditLogDownloadResult(bytes_written=0, chunks=0, rows=0)
115+
seen_cursors: set[str] = set()
116+
while True:
117+
body, headers = await _async_fetch_verified_chunk(fetch_chunk, cursor, max_transfer_retries)
118+
chunk_rows, next_cursor, has_more = _parse_chunk_headers(headers, cursor)
119+
if has_more and next_cursor is not None:
120+
if next_cursor in seen_cursors:
121+
raise AuditLogDownloadError("response repeated X-Next-Cursor header")
122+
seen_cursors.add(next_cursor)
123+
await to_thread.run_sync(_write_chunk, destination, body)
124+
125+
cursor = next_cursor
126+
result = AuditLogDownloadResult(
127+
bytes_written=result.bytes_written + len(body),
128+
chunks=result.chunks + 1,
129+
rows=result.rows + chunk_rows,
130+
)
131+
if on_progress is not None:
132+
callback_result = on_progress(
133+
AuditLogDownloadProgress(
134+
bytes_written=result.bytes_written,
135+
chunks=result.chunks,
136+
rows=result.rows,
137+
chunk_rows=chunk_rows,
138+
)
139+
)
140+
if inspect.isawaitable(callback_result):
141+
await callback_result
142+
if not has_more:
143+
return result
144+
145+
146+
def _fetch_verified_chunk(
147+
fetch_chunk: SyncFetchChunk, cursor: str | None, max_transfer_retries: int
148+
) -> tuple[bytes, httpx.Headers]:
149+
for retries in range(max_transfer_retries + 1):
150+
transfer_error: Exception | None = None
151+
with fetch_chunk(cursor) as response:
152+
try:
153+
body = response.read()
154+
headers = httpx.Headers(response.headers)
155+
_verify_checksum(body, headers)
156+
except Exception as error:
157+
transfer_error = error
158+
else:
159+
return body, headers
160+
assert transfer_error is not None
161+
if retries == max_transfer_retries:
162+
raise transfer_error
163+
time.sleep(min(2**retries, _MAX_RETRY_DELAY))
164+
raise AssertionError("unreachable")
165+
166+
167+
async def _async_fetch_verified_chunk(
168+
fetch_chunk: AsyncFetchChunk, cursor: str | None, max_transfer_retries: int
169+
) -> tuple[bytes, httpx.Headers]:
170+
for retries in range(max_transfer_retries + 1):
171+
transfer_error: Exception | None = None
172+
async with fetch_chunk(cursor) as response:
173+
try:
174+
body = await response.read()
175+
headers = httpx.Headers(response.headers)
176+
await to_thread.run_sync(_verify_checksum, body, headers)
177+
except Exception as error:
178+
transfer_error = error
179+
else:
180+
return body, headers
181+
assert transfer_error is not None
182+
if retries == max_transfer_retries:
183+
raise transfer_error
184+
await anyio.sleep(min(2**retries, _MAX_RETRY_DELAY))
185+
raise AssertionError("unreachable")
186+
187+
188+
def _verify_checksum(body: bytes, headers: httpx.Headers) -> None:
189+
expected = headers.get("x-content-sha256")
190+
if not expected:
191+
raise AuditLogDownloadError("response missing X-Content-Sha256 header")
192+
actual = hashlib.sha256(body).hexdigest()
193+
if actual != expected:
194+
raise AuditLogDownloadError(f"audit log chunk checksum mismatch (got {actual}, want {expected})")
195+
196+
197+
def _parse_chunk_headers(headers: httpx.Headers, current_cursor: str | None) -> tuple[int, str | None, bool]:
198+
has_more_value = headers.get("x-has-more")
199+
if has_more_value not in {"true", "false"}:
200+
raise AuditLogDownloadError("response missing or invalid X-Has-More header")
201+
has_more = has_more_value == "true"
202+
203+
row_count = headers.get("x-row-count")
204+
if row_count is None or not row_count.isascii() or not row_count.isdecimal():
205+
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
206+
normalized_row_count = row_count.lstrip("0") or "0"
207+
if len(normalized_row_count) > len(str(_MAX_CHUNK_ROWS)):
208+
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
209+
rows = int(normalized_row_count)
210+
if rows > _MAX_CHUNK_ROWS:
211+
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
212+
213+
next_cursor = headers.get("x-next-cursor") or None
214+
if has_more and (not next_cursor or next_cursor == current_cursor):
215+
raise AuditLogDownloadError("response has invalid X-Next-Cursor header")
216+
if not has_more and next_cursor:
217+
raise AuditLogDownloadError("response returned a cursor after the final chunk")
218+
return rows, next_cursor, has_more
219+
220+
221+
def _write_chunk(destination: BinaryIO, body: bytes) -> None:
222+
remaining = memoryview(body)
223+
while remaining:
224+
written = destination.write(remaining)
225+
if type(written) is not int or written <= 0 or written > len(remaining):
226+
raise AuditLogDownloadError("audit log download destination performed a short write")
227+
remaining = remaining[written:]
228+
229+
230+
def _validate_max_transfer_retries(max_transfer_retries: int) -> None:
231+
if type(max_transfer_retries) is not int or max_transfer_retries < 0:
232+
raise ValueError("max_transfer_retries must be a non-negative integer")

src/kernel/resources/audit_logs.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Union
5+
from typing import Union, BinaryIO, ContextManager, AsyncContextManager
66
from datetime import datetime
77
from typing_extensions import Literal
88

@@ -30,6 +30,13 @@
3030
from ..pagination import SyncPageTokenPagination, AsyncPageTokenPagination
3131
from .._base_client import AsyncPaginator, make_request_options
3232
from ..types.audit_log_entry import AuditLogEntry
33+
from ..lib.audit_log_download import (
34+
ProgressCallback,
35+
AsyncProgressCallback,
36+
AuditLogDownloadResult,
37+
download_audit_logs,
38+
async_download_audit_logs,
39+
)
3340

3441
__all__ = ["AuditLogsResource", "AsyncAuditLogsResource"]
3542

@@ -222,6 +229,60 @@ def export_chunk(
222229
cast_to=BinaryAPIResponse,
223230
)
224231

232+
def download(
233+
self,
234+
*,
235+
to: BinaryIO,
236+
end: Union[str, datetime],
237+
start: Union[str, datetime],
238+
auth_strategy: str | Omit = omit,
239+
exclude_method: SequenceNotStr[str] | Omit = omit,
240+
limit: int | Omit = omit,
241+
method: str | Omit = omit,
242+
search: str | Omit = omit,
243+
search_user_id: SequenceNotStr[str] | Omit = omit,
244+
service: str | Omit = omit,
245+
on_progress: ProgressCallback | None = None,
246+
max_transfer_retries: int = 6,
247+
extra_headers: Headers | None = None,
248+
extra_query: Query | None = None,
249+
extra_body: Body | None = None,
250+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
251+
) -> AuditLogDownloadResult:
252+
"""Download a complete gzip-compressed JSON Lines audit log export.
253+
254+
The SDK writes the export to a writable binary destination, verifies
255+
every chunk, and retries transient transfer failures. It does not close
256+
the destination. If the download fails, the destination may contain a
257+
partial export; use a temporary file and atomic rename when the completed
258+
export must be published atomically.
259+
"""
260+
261+
def fetch_chunk(cursor: str | None) -> ContextManager[StreamedBinaryAPIResponse]:
262+
return self.with_streaming_response.export_chunk(
263+
end=end,
264+
start=start,
265+
auth_strategy=auth_strategy,
266+
cursor=cursor if cursor is not None else omit,
267+
exclude_method=exclude_method,
268+
limit=limit,
269+
method=method,
270+
search=search,
271+
search_user_id=search_user_id,
272+
service=service,
273+
extra_headers=extra_headers,
274+
extra_query=extra_query,
275+
extra_body=extra_body,
276+
timeout=timeout,
277+
)
278+
279+
return download_audit_logs(
280+
fetch_chunk,
281+
to,
282+
on_progress=on_progress,
283+
max_transfer_retries=max_transfer_retries,
284+
)
285+
225286

226287
class AsyncAuditLogsResource(AsyncAPIResource):
227288
"""Read audit log records for the authenticated organization."""
@@ -411,6 +472,60 @@ async def export_chunk(
411472
cast_to=AsyncBinaryAPIResponse,
412473
)
413474

475+
async def download(
476+
self,
477+
*,
478+
to: BinaryIO,
479+
end: Union[str, datetime],
480+
start: Union[str, datetime],
481+
auth_strategy: str | Omit = omit,
482+
exclude_method: SequenceNotStr[str] | Omit = omit,
483+
limit: int | Omit = omit,
484+
method: str | Omit = omit,
485+
search: str | Omit = omit,
486+
search_user_id: SequenceNotStr[str] | Omit = omit,
487+
service: str | Omit = omit,
488+
on_progress: AsyncProgressCallback | None = None,
489+
max_transfer_retries: int = 6,
490+
extra_headers: Headers | None = None,
491+
extra_query: Query | None = None,
492+
extra_body: Body | None = None,
493+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
494+
) -> AuditLogDownloadResult:
495+
"""Download a complete gzip-compressed JSON Lines audit log export.
496+
497+
The SDK writes the export to a writable binary destination, verifies
498+
every chunk, and retries transient transfer failures. It does not close
499+
the destination. If the download fails, the destination may contain a
500+
partial export; use a temporary file and atomic rename when the completed
501+
export must be published atomically.
502+
"""
503+
504+
def fetch_chunk(cursor: str | None) -> AsyncContextManager[AsyncStreamedBinaryAPIResponse]:
505+
return self.with_streaming_response.export_chunk(
506+
end=end,
507+
start=start,
508+
auth_strategy=auth_strategy,
509+
cursor=cursor if cursor is not None else omit,
510+
exclude_method=exclude_method,
511+
limit=limit,
512+
method=method,
513+
search=search,
514+
search_user_id=search_user_id,
515+
service=service,
516+
extra_headers=extra_headers,
517+
extra_query=extra_query,
518+
extra_body=extra_body,
519+
timeout=timeout,
520+
)
521+
522+
return await async_download_audit_logs(
523+
fetch_chunk,
524+
to,
525+
on_progress=on_progress,
526+
max_transfer_retries=max_transfer_retries,
527+
)
528+
414529

415530
class AuditLogsResourceWithRawResponse:
416531
def __init__(self, audit_logs: AuditLogsResource) -> None:

0 commit comments

Comments
 (0)