Skip to content

Commit 09e4384

Browse files
committed
fix: prepare_body stream detection
1 parent 0b401c7 commit 09e4384

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

src/requests/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,9 @@ def prepare_body(
596596
if not isinstance(body, bytes):
597597
body = body.encode("utf-8")
598598

599-
if isinstance(data, Iterable) and not isinstance(
599+
# Also check hasattr("read") for wrappers that proxy file
600+
# methods via __getattr__ (e.g. tqdm.wrapattr)
601+
if (isinstance(data, Iterable) or hasattr(data, "read")) and not isinstance(
600602
data, (str, bytes, list, tuple, Mapping)
601603
):
602604
try:

tests/test_requests.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@
8181
HAS_PYOPENSSL = False
8282

8383

84+
class AttrProxyFileWrapper:
85+
"""File wrapper that proxies all attribute access via __getattr__.
86+
87+
Mimics tqdm.utils.CallbackIOWrapper/ObjectWrapper. Has read/tell/seek
88+
via the underlying file, but does NOT define __iter__ in its own class,
89+
so isinstance(..., Iterable) returns False while hasattr("read") is True.
90+
"""
91+
92+
def __init__(self, fileobj):
93+
self._fileobj = fileobj
94+
95+
def __getattr__(self, name):
96+
return getattr(self._fileobj, name)
97+
98+
8499
class TestRequests:
85100
digest_auth_algo = ("MD5", "SHA-256", "SHA-512")
86101

@@ -2073,6 +2088,82 @@ def __iter__(self):
20732088

20742089
assert "Unable to rewind request body" in str(e)
20752090

2091+
def test_attr_proxy_body_position_is_recorded(self):
2092+
data = AttrProxyFileWrapper(io.BytesIO(b"the data"))
2093+
prep = requests.Request("GET", "http://example.com", data=data).prepare()
2094+
assert prep._body_position == 0
2095+
assert prep.body is data
2096+
2097+
def test_attr_proxy_content_length_is_set(self):
2098+
data = AttrProxyFileWrapper(io.BytesIO(b"the data"))
2099+
prep = requests.Request("GET", "http://example.com", data=data).prepare()
2100+
assert prep.headers["Content-Length"] == "8"
2101+
2102+
def test_rewind_attr_proxy_body(self):
2103+
data = AttrProxyFileWrapper(io.BytesIO(b"the data"))
2104+
prep = requests.Request("GET", "http://example.com", data=data).prepare()
2105+
assert prep._body_position == 0
2106+
2107+
assert prep.body.read() == b"the data"
2108+
assert prep.body.read() == b""
2109+
2110+
requests.utils.rewind_body(prep)
2111+
assert prep.body.read() == b"the data"
2112+
2113+
def test_rewind_partially_read_attr_proxy_body(self):
2114+
raw = io.BytesIO(b"the data")
2115+
raw.read(4)
2116+
data = AttrProxyFileWrapper(raw)
2117+
prep = requests.Request("GET", "http://example.com", data=data).prepare()
2118+
assert prep._body_position == 4
2119+
2120+
assert prep.body.read() == b"data"
2121+
2122+
requests.utils.rewind_body(prep)
2123+
assert prep.body.read() == b"data"
2124+
2125+
def test_rewind_attr_proxy_body_no_seek(self):
2126+
class NoSeekWrapper:
2127+
def __init__(self):
2128+
pass
2129+
2130+
def tell(self):
2131+
return 0
2132+
2133+
def __iter__(self):
2134+
return iter([])
2135+
2136+
data = NoSeekWrapper()
2137+
prep = requests.Request("GET", "http://example.com", data=data).prepare()
2138+
assert prep._body_position == 0
2139+
2140+
with pytest.raises(UnrewindableBodyError):
2141+
requests.utils.rewind_body(prep)
2142+
2143+
def test_read_only_object_detected_as_stream(self):
2144+
"""An object with read() but no __iter__ should still be a stream."""
2145+
2146+
class ReadOnlyFile:
2147+
def __init__(self, data):
2148+
self._buf = io.BytesIO(data)
2149+
2150+
def read(self, n=-1):
2151+
return self._buf.read(n)
2152+
2153+
def tell(self):
2154+
return self._buf.tell()
2155+
2156+
def seek(self, pos):
2157+
return self._buf.seek(pos)
2158+
2159+
def __len__(self):
2160+
return self._buf.getbuffer().nbytes
2161+
2162+
data = ReadOnlyFile(b"the data")
2163+
prep = requests.Request("GET", "http://example.com", data=data).prepare()
2164+
assert prep._body_position == 0
2165+
assert prep.body is data
2166+
20762167
def _patch_adapter_gzipped_redirect(self, session, url):
20772168
adapter = session.get_adapter(url=url)
20782169
org_build_response = adapter.build_response

0 commit comments

Comments
 (0)