Skip to content

Commit cbbe329

Browse files
lesnik512claude
andcommitted
feat: add pure _accumulate_capped core with property test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c3726c commit cbbe329

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

src/httpware/client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ def _parse_content_length(raw: str | None) -> int | None:
4343
return value if value >= 0 else None
4444

4545

46+
class _CapExceeded(Exception): # noqa: N818 — internal control-flow signal, not a user-facing error
47+
"""Internal signal: decoded bytes crossed the cap mid-read. Carries bytes read so far."""
48+
49+
def __init__(self, *, read: int) -> None:
50+
self.read = read
51+
super().__init__(f"decoded body exceeded cap after {read} bytes")
52+
53+
54+
def _accumulate_capped(chunks: typing.Iterable[bytes], cap: int) -> bytes:
55+
"""Concatenate `chunks`, raising `_CapExceeded` the moment the running total exceeds `cap`.
56+
57+
Counts decoded bytes (the in-memory footprint). Grown in a single bytearray
58+
so there is no transient list-plus-join double allocation.
59+
"""
60+
buf = bytearray()
61+
for chunk in chunks:
62+
buf += chunk
63+
if len(buf) > cap:
64+
raise _CapExceeded(read=len(buf))
65+
return bytes(buf)
66+
67+
4668
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
4769
"""Construct the default decoder tuple based on installed extras.
4870

tests/test_capped_read_props.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Hypothesis property tests for the pure _accumulate_capped core.
2+
3+
The one subtle invariant of the response-body cap is chunk-boundary
4+
independence: the accumulator must behave identically no matter how the decoded
5+
body is split into chunks. It must raise _CapExceeded iff the total decoded
6+
length exceeds the cap, and otherwise return the body byte-for-byte.
7+
"""
8+
9+
import pytest
10+
from hypothesis import given
11+
from hypothesis import strategies as st
12+
13+
from httpware.client import _accumulate_capped, _CapExceeded
14+
15+
16+
def _partition(body: bytes, sizes: list[int]) -> list[bytes]:
17+
"""Split `body` into chunks following `sizes` (remainder becomes a final chunk)."""
18+
chunks: list[bytes] = []
19+
pos = 0
20+
for size in sizes:
21+
if pos >= len(body):
22+
break
23+
chunks.append(body[pos : pos + size])
24+
pos += size
25+
if pos < len(body):
26+
chunks.append(body[pos:])
27+
return chunks
28+
29+
30+
@given(
31+
body=st.binary(max_size=2048),
32+
sizes=st.lists(st.integers(min_value=1, max_value=64), max_size=64),
33+
cap=st.integers(min_value=1, max_value=4096),
34+
)
35+
def test_accumulate_capped_chunk_boundary_independence(body: bytes, sizes: list[int], cap: int) -> None:
36+
chunks = _partition(body, sizes)
37+
if len(body) > cap:
38+
with pytest.raises(_CapExceeded) as caught:
39+
_accumulate_capped(chunks, cap)
40+
assert caught.value.read > cap
41+
else:
42+
assert _accumulate_capped(chunks, cap) == body
43+
44+
45+
@given(body=st.binary(min_size=2, max_size=512))
46+
def test_accumulate_capped_trips_at_one_below_length(body: bytes) -> None:
47+
cap = len(body) - 1
48+
with pytest.raises(_CapExceeded):
49+
_accumulate_capped([body], cap)
50+
51+
52+
@given(body=st.binary(max_size=512))
53+
def test_accumulate_capped_passes_at_exact_length(body: bytes) -> None:
54+
cap = max(1, len(body))
55+
assert _accumulate_capped([body], cap) == body

0 commit comments

Comments
 (0)