From be0970b0ae4a2e00d4e09a9af191dea0dc6b0b81 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 00:09:25 +0800 Subject: [PATCH 01/11] fix 'I/O operation on closed file' and 'Form data has been processed already' upon redirect --- aiohttp/client.py | 4 ++ aiohttp/client_reqrep.py | 18 +++++ aiohttp/formdata.py | 7 +- aiohttp/payload.py | 80 +++++++++++++++------ tests/test_client_functional.py | 10 +-- tests/test_formdata.py | 113 ++++++++++++++++++++++++----- tests/test_multipart.py | 27 +++++++ tests/test_payload.py | 121 +++++++++++++++++++++++++++++++- 8 files changed, 330 insertions(+), 50 deletions(-) diff --git a/aiohttp/client.py b/aiohttp/client.py index 9c2fd8073a..a6dabdce6c 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -75,6 +75,7 @@ ClientResponse, Fingerprint, RequestInfo, + process_data_to_payload, ) from .client_ws import ( DEFAULT_WS_CLIENT_TIMEOUT, @@ -521,6 +522,9 @@ async def _request( for trace in traces: await trace.send_request_start(method, url.update_query(params), headers) + # preprocess the data so we can reuse the Payload object when redirect is needed + data = process_data_to_payload(data) + timer = tm.timer() try: with timer: diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index b15fe9ebbf..169885ad5d 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -163,6 +163,24 @@ class ConnectionKey: proxy_headers_hash: Optional[int] # hash(CIMultiDict) +def process_data_to_payload(body): + # this function is used to convert data to payload before looping into redirects, + # so payload with io objects can be keep alive and use the stored data for the next request + if body is None: + return None + + # FormData + if isinstance(body, FormData): + body = body() + + try: + body = payload.PAYLOAD_REGISTRY.get(body, disposition=None) + except payload.LookupError: + pass # keep for ClientRequest to handle + + return body + + class ClientRequest: GET_METHODS = { hdrs.METH_GET, diff --git a/aiohttp/formdata.py b/aiohttp/formdata.py index 6e005a78ba..f49c33e1b4 100644 --- a/aiohttp/formdata.py +++ b/aiohttp/formdata.py @@ -28,7 +28,6 @@ def __init__( self._writer = multipart.MultipartWriter("form-data", boundary=self._boundary) self._fields: List[Any] = [] self._is_multipart = False - self._is_processed = False self._quote_fields = quote_fields self._charset = charset @@ -117,8 +116,8 @@ def _gen_form_urlencoded(self) -> payload.BytesPayload: def _gen_form_data(self) -> multipart.MultipartWriter: """Encode a list of fields using the multipart/form-data MIME format""" - if self._is_processed: - raise RuntimeError("Form data has been processed already") + if not self._fields: + return self._writer for dispparams, headers, value in self._fields: try: if hdrs.CONTENT_TYPE in headers: @@ -149,7 +148,7 @@ def _gen_form_data(self) -> multipart.MultipartWriter: self._writer.append_payload(part) - self._is_processed = True + self._fields.clear() return self._writer def __call__(self) -> Payload: diff --git a/aiohttp/payload.py b/aiohttp/payload.py index ea50b6a38c..cab60878ba 100644 --- a/aiohttp/payload.py +++ b/aiohttp/payload.py @@ -307,17 +307,39 @@ def __init__( if hdrs.CONTENT_DISPOSITION not in self.headers: self.set_content_disposition(disposition, filename=self._filename) + self._writable = True + self._seekable = True + try: + # It is weird but some IO object dont have `seekable()` method as IOBase object, + # it seems better for us to direct try if the `seek()` and `tell()` is available + # e.g. tarfile.TarFile._Stream + self._value.seek(self._value.tell()) + except (AttributeError, OSError): + self._seekable = False + + if self._seekable: + self._stream_pos = self._value.tell() + else: + self._stream_pos = 0 + async def write(self, writer: AbstractStreamWriter) -> None: loop = asyncio.get_event_loop() - try: + if self._seekable: + await loop.run_in_executor(None, self._value.seek, self._stream_pos) + elif not self._writable: + raise RuntimeError( + f'Non-seekable IO payload "{self._value}" is already consumed (possibly due to redirect, consider storing in a seekable IO buffer instead)' + ) + chunk = await loop.run_in_executor(None, self._value.read, 2**16) + while chunk: + await writer.write(chunk) chunk = await loop.run_in_executor(None, self._value.read, 2**16) - while chunk: - await writer.write(chunk) - chunk = await loop.run_in_executor(None, self._value.read, 2**16) - finally: - await loop.run_in_executor(None, self._value.close) + if not self._seekable: + self._writable = False # Non-seekable IO `_value` can only be consumed once def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: + if self._seekable: + self._value.seek(self._stream_pos) return "".join(r.decode(encoding, errors) for r in self._value.readlines()) @@ -354,27 +376,34 @@ def __init__( @property def size(self) -> Optional[int]: try: - return os.fstat(self._value.fileno()).st_size - self._value.tell() + return os.fstat(self._value.fileno()).st_size - self._stream_pos except OSError: return None def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: + if self._seekable: + self._value.seek(self._stream_pos) return self._value.read() async def write(self, writer: AbstractStreamWriter) -> None: loop = asyncio.get_event_loop() - try: + if self._seekable: + await loop.run_in_executor(None, self._value.seek, self._stream_pos) + elif not self._writable: + raise RuntimeError( + f'Non-seekable IO payload "{self._value}" is already consumed (possibly due to redirect, consider storing in a seekable IO buffer instead)' + ) + chunk = await loop.run_in_executor(None, self._value.read, 2**16) + while chunk: + data = ( + chunk.encode(encoding=self._encoding) + if self._encoding + else chunk.encode() + ) + await writer.write(data) chunk = await loop.run_in_executor(None, self._value.read, 2**16) - while chunk: - data = ( - chunk.encode(encoding=self._encoding) - if self._encoding - else chunk.encode() - ) - await writer.write(data) - chunk = await loop.run_in_executor(None, self._value.read, 2**16) - finally: - await loop.run_in_executor(None, self._value.close) + if not self._seekable: + self._writable = False # Non-seekable IO `_value` can only be consumed once class BytesIOPayload(IOBasePayload): @@ -382,12 +411,15 @@ class BytesIOPayload(IOBasePayload): @property def size(self) -> int: - position = self._value.tell() - end = self._value.seek(0, os.SEEK_END) - self._value.seek(position) - return end - position + if self._seekable: + end = self._value.seek(0, os.SEEK_END) + self._value.seek(self._stream_pos) + return end - self._stream_pos + return None def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: + if self._seekable: + self._value.seek(self._stream_pos) return self._value.read().decode(encoding, errors) @@ -397,7 +429,7 @@ class BufferedReaderPayload(IOBasePayload): @property def size(self) -> Optional[int]: try: - return os.fstat(self._value.fileno()).st_size - self._value.tell() + return os.fstat(self._value.fileno()).st_size - self._stream_pos except (OSError, AttributeError): # data.fileno() is not supported, e.g. # io.BufferedReader(io.BytesIO(b'data')) @@ -406,6 +438,8 @@ def size(self) -> Optional[int]: return None def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: + if self._seekable: + self._value.seek(self._stream_pos) return self._value.read().decode(encoding, errors) diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 8713f3682f..bff815c12f 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -1544,6 +1544,8 @@ async def test_GET_DEFLATE( aiohttp_client: AiohttpClient, data: Optional[bytes] ) -> None: async def handler(request: web.Request) -> web.Response: + recv_data = await request.read() + assert recv_data == b"" # both cases should receive empty bytes return web.json_response({"ok": True}) write_mock = None @@ -1553,10 +1555,10 @@ async def write_bytes( self: ClientRequest, writer: StreamWriter, conn: Connection ) -> None: nonlocal write_mock - original_write = writer._write + original_write = writer.write with mock.patch.object( - writer, "_write", autospec=True, spec_set=True, side_effect=original_write + writer, "write", autospec=True, spec_set=True, side_effect=original_write ) as write_mock: await original_write_bytes(self, writer, conn) @@ -1571,8 +1573,8 @@ async def write_bytes( assert content == {"ok": True} assert write_mock is not None - # No chunks should have been sent for an empty body. - write_mock.assert_not_called() + # Empty b"" should have been sent for an empty body. + write_mock.assert_called_once_with(b"") async def test_POST_DATA_DEFLATE(aiohttp_client: AiohttpClient) -> None: diff --git a/tests/test_formdata.py b/tests/test_formdata.py index 7ddd53038c..99a92a7748 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -1,9 +1,12 @@ import io +import pathlib +import tarfile from unittest import mock import pytest from aiohttp import FormData, web +from aiohttp.client_exceptions import ClientConnectionError from aiohttp.http_writer import StreamWriter from aiohttp.pytest_plugin import AiohttpClient @@ -95,28 +98,104 @@ async def test_formdata_field_name_is_not_quoted( assert b'name="email 1"' in buf -async def test_mark_formdata_as_processed(aiohttp_client: AiohttpClient) -> None: - async def handler(request: web.Request) -> web.Response: - return web.Response() +async def test_formdata_boundary_param() -> None: + boundary = "some_boundary" + form = FormData(boundary=boundary) + assert form._writer.boundary == boundary - app = web.Application() - app.add_routes([web.post("/", handler)]) - client = await aiohttp_client(app) +async def test_formdata_on_redirect(aiohttp_client: AiohttpClient) -> None: + with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open("rb") as fobj: + content = fobj.read() + fobj.seek(0) - data = FormData() - data.add_field("test", "test_value", content_type="application/json") + async def handler_0(request: web.Request): + raise web.HTTPPermanentRedirect("/1") - resp = await client.post("/", data=data) - assert len(data._writer._parts) == 1 + async def handler_1(request: web.Request) -> web.Response: + req_data = await request.post() + assert req_data["sample.txt"].file.read() == content + return web.Response() - with pytest.raises(RuntimeError): - await client.post("/", data=data) + app = web.Application() + app.router.add_post("/0", handler_0) + app.router.add_post("/1", handler_1) - resp.release() + client = await aiohttp_client(app) + data = FormData() + data._gen_form_data = mock.Mock(wraps=data._gen_form_data) + data.add_field("sample.txt", fobj) -async def test_formdata_boundary_param() -> None: - boundary = "some_boundary" - form = FormData(boundary=boundary) - assert form._writer.boundary == boundary + resp = await client.post("/0", data=data) + assert len(data._writer._parts) == 1 + assert resp.status == 200 + + resp.release() + + +async def test_formdata_on_redirect_after_recv(aiohttp_client: AiohttpClient) -> None: + with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open("rb") as fobj: + content = fobj.read() + fobj.seek(0) + + async def handler_0(request: web.Request): + req_data = await request.post() + assert req_data["sample.txt"].file.read() == content + raise web.HTTPPermanentRedirect("/1") + + async def handler_1(request: web.Request) -> web.Response: + req_data = await request.post() + assert req_data["sample.txt"].file.read() == content + return web.Response() + + app = web.Application() + app.router.add_post("/0", handler_0) + app.router.add_post("/1", handler_1) + + client = await aiohttp_client(app) + + data = FormData() + data._gen_form_data = mock.Mock(wraps=data._gen_form_data) + data.add_field("sample.txt", fobj) + + resp = await client.post("/0", data=data) + assert len(data._writer._parts) == 1 + assert resp.status == 200 + + resp.release() + + +async def test_streaming_tarfile_on_redirect(aiohttp_client: AiohttpClient) -> None: + data = b"This is a tar file payload text file." + + async def handler_0(request: web.Request): + await request.read() + raise web.HTTPPermanentRedirect("/1") + + async def handler_1(request: web.Request) -> web.Response: + await request.read() + return web.Response() + + app = web.Application() + app.router.add_post("/0", handler_0) + app.router.add_post("/1", handler_1) + + client = await aiohttp_client(app) + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + ti = tarfile.TarInfo(name="payload1.txt") + ti.size = len(data) + tf.addfile(tarinfo=ti, fileobj=io.BytesIO(data)) + + # Streaming tarfile. + buf.seek(0) + tf = tarfile.open(fileobj=buf, mode="r|") + for entry in tf: + with pytest.raises(ClientConnectionError) as exc_info: + await client.post("/0", data=tf.extractfile(entry)) + cause_exc = exc_info._excinfo[1].__cause__ + assert isinstance(cause_exc, RuntimeError) + assert len(cause_exc.args) == 1 + assert cause_exc.args[0].startswith("Non-seekable IO payload") diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 55befdbb60..94baa4f350 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -1422,6 +1422,33 @@ async def test_reset_content_disposition_header( b' attachments; filename="bug.py"' ) + async def test_multiple_write_on_io_payload(self, buf: bytearray, stream: Stream): + with aiohttp.MultipartWriter("form-data", boundary=":") as writer: + with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open( + "rb" + ) as fobj: + content = fobj.read() + fobj.seek(0) + + target_buf = ( + b'--:\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename="sample.txt"\r\n\r\n' + + content + + b"\r\n--:--\r\n" + ) + + writer.append(fobj) + assert len(writer._parts) == 1 + assert isinstance(writer._parts[0][0], payload.BufferedReaderPayload) + + await writer.write(stream) + assert bytes(buf) == target_buf + + buf.clear() + assert bytes(buf) == b"" + + await writer.write(stream) + assert bytes(buf) == target_buf + async def test_async_for_reader() -> None: data: Tuple[Dict[str, str], int, bytes, bytes, bytes] = ( diff --git a/tests/test_payload.py b/tests/test_payload.py index 8c04c5cba5..4f494c54f3 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -1,6 +1,8 @@ import array -from io import StringIO +import io +import pathlib from typing import Any, AsyncIterator, Iterator +from unittest import mock import pytest @@ -92,13 +94,42 @@ def test_string_payload() -> None: def test_string_io_payload() -> None: - s = StringIO("ű" * 5000) + s = io.StringIO("ű" * 5000) p = payload.StringIOPayload(s) assert p.encoding == "utf-8" assert p.content_type == "text/plain; charset=utf-8" assert p.size == 10000 +def test_text_io_payload() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + filesize = filepath.stat().st_size + with filepath.open("r") as f: + p = payload.TextIOPayload(f) + assert p.encoding == "utf-8" + assert p.content_type == "text/plain; charset=utf-8" + assert p.size == filesize + assert not f.closed + + +def test_bytes_io_payload() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + filesize = filepath.stat().st_size + with filepath.open("rb") as f: + p = payload.BytesIOPayload(f) + assert p.size == filesize + assert not f.closed + + +def test_buffered_reader_payload() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + filesize = filepath.stat().st_size + with filepath.open("rb") as f: + p = payload.BufferedReaderPayload(f) + assert p.size == filesize + assert not f.closed + + def test_async_iterable_payload_default_content_type() -> None: async def gen() -> AsyncIterator[bytes]: return @@ -120,3 +151,89 @@ async def gen() -> AsyncIterator[bytes]: def test_async_iterable_payload_not_async_iterable() -> None: with pytest.raises(TypeError): payload.AsyncIterablePayload(object()) # type: ignore[arg-type] + + +async def write_mock(*args, **kwargs): + pass + + +async def test_string_io_payload_write() -> None: + content = "ű" * 5000 + + s = io.StringIO(content) + p = payload.StringIOPayload(s) + + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: + instance = mock_obj.return_value + instance.write = mock.Mock(write_mock) + + await p.write(instance) + instance.write.assert_called_once_with(content.encode("utf-8")) + + instance.write.reset_mock() + + await p.write(instance) + instance.write.assert_called_once_with(content.encode("utf-8")) + + +async def test_text_io_payload_write() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + with filepath.open("r") as f: + content = f.read() + f.seek(0) + + p = payload.TextIOPayload(f) + + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: + instance = mock_obj.return_value + instance.write = mock.Mock(write_mock) + + await p.write(instance) + instance.write.assert_called_once_with(content.encode("utf-8")) # 1 chunk + + instance.write.reset_mock() + + await p.write(instance) + instance.write.assert_called_once_with(content.encode("utf-8")) # 1 chunk + + +async def test_bytes_io_payload_write() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + with filepath.open("rb") as f: + content = f.read() + with io.BytesIO(content) as bf: + + p = payload.BytesIOPayload(bf) + + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: + instance = mock_obj.return_value + instance.write = mock.Mock(write_mock) + + await p.write(instance) + instance.write.assert_called_once_with(content) # 1 chunk + + instance.write.reset_mock() + + await p.write(instance) + instance.write.assert_called_once_with(content) # 1 chunk + + +async def test_buffered_reader_payload_write() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + with filepath.open("rb") as f: + content = f.read() + f.seek(0) + + p = payload.BufferedReaderPayload(f) + + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: + instance = mock_obj.return_value + instance.write = mock.Mock(write_mock) + + await p.write(instance) + instance.write.assert_called_once_with(content) # 1 chunk + + instance.write.reset_mock() + + await p.write(instance) + instance.write.assert_called_once_with(content) # 1 chunk From 542d4bcc9015c2944de78b5eef935c222a08f9db Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 00:23:20 +0800 Subject: [PATCH 02/11] fix linter items --- aiohttp/payload.py | 2 +- tests/test_formdata.py | 20 +++++++++++--------- tests/test_multipart.py | 4 +++- tests/test_payload.py | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/aiohttp/payload.py b/aiohttp/payload.py index cab60878ba..e353666f32 100644 --- a/aiohttp/payload.py +++ b/aiohttp/payload.py @@ -410,7 +410,7 @@ class BytesIOPayload(IOBasePayload): _value: io.BytesIO @property - def size(self) -> int: + def size(self) -> Optional[int]: if self._seekable: end = self._value.seek(0, os.SEEK_END) self._value.seek(self._stream_pos) diff --git a/tests/test_formdata.py b/tests/test_formdata.py index 99a92a7748..c29e7e2e14 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -109,12 +109,13 @@ async def test_formdata_on_redirect(aiohttp_client: AiohttpClient) -> None: content = fobj.read() fobj.seek(0) - async def handler_0(request: web.Request): + async def handler_0(request: web.Request) -> web.Response: raise web.HTTPPermanentRedirect("/1") async def handler_1(request: web.Request) -> web.Response: req_data = await request.post() - assert req_data["sample.txt"].file.read() == content + target_file: web.FileField = req_data["sample.txt"] + assert target_file.file.read() == content return web.Response() app = web.Application() @@ -124,7 +125,6 @@ async def handler_1(request: web.Request) -> web.Response: client = await aiohttp_client(app) data = FormData() - data._gen_form_data = mock.Mock(wraps=data._gen_form_data) data.add_field("sample.txt", fobj) resp = await client.post("/0", data=data) @@ -139,14 +139,16 @@ async def test_formdata_on_redirect_after_recv(aiohttp_client: AiohttpClient) -> content = fobj.read() fobj.seek(0) - async def handler_0(request: web.Request): + async def handler_0(request: web.Request) -> web.Response: req_data = await request.post() - assert req_data["sample.txt"].file.read() == content + target_file: web.FileField = req_data["sample.txt"] + assert target_file.file.read() == content raise web.HTTPPermanentRedirect("/1") async def handler_1(request: web.Request) -> web.Response: req_data = await request.post() - assert req_data["sample.txt"].file.read() == content + target_file: web.FileField = req_data["sample.txt"] + assert target_file.file.read() == content return web.Response() app = web.Application() @@ -156,7 +158,6 @@ async def handler_1(request: web.Request) -> web.Response: client = await aiohttp_client(app) data = FormData() - data._gen_form_data = mock.Mock(wraps=data._gen_form_data) data.add_field("sample.txt", fobj) resp = await client.post("/0", data=data) @@ -169,7 +170,7 @@ async def handler_1(request: web.Request) -> web.Response: async def test_streaming_tarfile_on_redirect(aiohttp_client: AiohttpClient) -> None: data = b"This is a tar file payload text file." - async def handler_0(request: web.Request): + async def handler_0(request: web.Request) -> web.Response: await request.read() raise web.HTTPPermanentRedirect("/1") @@ -195,7 +196,8 @@ async def handler_1(request: web.Request) -> web.Response: for entry in tf: with pytest.raises(ClientConnectionError) as exc_info: await client.post("/0", data=tf.extractfile(entry)) - cause_exc = exc_info._excinfo[1].__cause__ + raw_exc_info: tuple = exc_info._excinfo + cause_exc = raw_exc_info[1].__cause__ assert isinstance(cause_exc, RuntimeError) assert len(cause_exc.args) == 1 assert cause_exc.args[0].startswith("Non-seekable IO payload") diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 94baa4f350..756f8cffc9 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -1422,7 +1422,9 @@ async def test_reset_content_disposition_header( b' attachments; filename="bug.py"' ) - async def test_multiple_write_on_io_payload(self, buf: bytearray, stream: Stream): + async def test_multiple_write_on_io_payload( + self, buf: bytearray, stream: Stream + ) -> None: with aiohttp.MultipartWriter("form-data", boundary=":") as writer: with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open( "rb" diff --git a/tests/test_payload.py b/tests/test_payload.py index 4f494c54f3..26f3dc0c98 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -153,7 +153,7 @@ def test_async_iterable_payload_not_async_iterable() -> None: payload.AsyncIterablePayload(object()) # type: ignore[arg-type] -async def write_mock(*args, **kwargs): +async def write_mock(*args, **kwargs) -> None: pass From dd34bcb5cf6c8df933f00024607c2fcf8f54c9c8 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 00:32:25 +0800 Subject: [PATCH 03/11] try fix linter items --- aiohttp/client_reqrep.py | 2 +- tests/test_formdata.py | 12 ++++++++---- tests/test_payload.py | 12 ++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 169885ad5d..35f8035dc2 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -163,7 +163,7 @@ class ConnectionKey: proxy_headers_hash: Optional[int] # hash(CIMultiDict) -def process_data_to_payload(body): +def process_data_to_payload(body: Any) -> Optional[payload.Payload]: # this function is used to convert data to payload before looping into redirects, # so payload with io objects can be keep alive and use the stored data for the next request if body is None: diff --git a/tests/test_formdata.py b/tests/test_formdata.py index c29e7e2e14..d7917e6641 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -114,7 +114,8 @@ async def handler_0(request: web.Request) -> web.Response: async def handler_1(request: web.Request) -> web.Response: req_data = await request.post() - target_file: web.FileField = req_data["sample.txt"] + target_file = req_data["sample.txt"] + assert isinstance(tarfile, web.FileField) assert target_file.file.read() == content return web.Response() @@ -141,13 +142,15 @@ async def test_formdata_on_redirect_after_recv(aiohttp_client: AiohttpClient) -> async def handler_0(request: web.Request) -> web.Response: req_data = await request.post() - target_file: web.FileField = req_data["sample.txt"] + target_file = req_data["sample.txt"] + assert isinstance(tarfile, web.FileField) assert target_file.file.read() == content raise web.HTTPPermanentRedirect("/1") async def handler_1(request: web.Request) -> web.Response: req_data = await request.post() - target_file: web.FileField = req_data["sample.txt"] + target_file = req_data["sample.txt"] + assert isinstance(tarfile, web.FileField) assert target_file.file.read() == content return web.Response() @@ -196,7 +199,8 @@ async def handler_1(request: web.Request) -> web.Response: for entry in tf: with pytest.raises(ClientConnectionError) as exc_info: await client.post("/0", data=tf.extractfile(entry)) - raw_exc_info: tuple = exc_info._excinfo + raw_exc_info = exc_info._excinfo + assert isinstance(raw_exc_info, tuple) cause_exc = raw_exc_info[1].__cause__ assert isinstance(cause_exc, RuntimeError) assert len(cause_exc.args) == 1 diff --git a/tests/test_payload.py b/tests/test_payload.py index 26f3dc0c98..c92f6b7fb7 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -153,10 +153,6 @@ def test_async_iterable_payload_not_async_iterable() -> None: payload.AsyncIterablePayload(object()) # type: ignore[arg-type] -async def write_mock(*args, **kwargs) -> None: - pass - - async def test_string_io_payload_write() -> None: content = "ű" * 5000 @@ -165,7 +161,7 @@ async def test_string_io_payload_write() -> None: with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value - instance.write = mock.Mock(write_mock) + instance.write = mock.AsyncMock() await p.write(instance) instance.write.assert_called_once_with(content.encode("utf-8")) @@ -186,7 +182,7 @@ async def test_text_io_payload_write() -> None: with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value - instance.write = mock.Mock(write_mock) + instance.write = mock.AsyncMock() await p.write(instance) instance.write.assert_called_once_with(content.encode("utf-8")) # 1 chunk @@ -207,7 +203,7 @@ async def test_bytes_io_payload_write() -> None: with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value - instance.write = mock.Mock(write_mock) + instance.write = mock.AsyncMock() await p.write(instance) instance.write.assert_called_once_with(content) # 1 chunk @@ -228,7 +224,7 @@ async def test_buffered_reader_payload_write() -> None: with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value - instance.write = mock.Mock(write_mock) + instance.write = mock.AsyncMock() await p.write(instance) instance.write.assert_called_once_with(content) # 1 chunk From fb2d3b822c40dff8a18af4c831015677fb50fca7 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 00:39:53 +0800 Subject: [PATCH 04/11] try fix linter items --- aiohttp/client_reqrep.py | 2 +- tests/test_formdata.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 35f8035dc2..f8a5346dee 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -163,7 +163,7 @@ class ConnectionKey: proxy_headers_hash: Optional[int] # hash(CIMultiDict) -def process_data_to_payload(body: Any) -> Optional[payload.Payload]: +def process_data_to_payload(body: Any) -> Any: # this function is used to convert data to payload before looping into redirects, # so payload with io objects can be keep alive and use the stored data for the next request if body is None: diff --git a/tests/test_formdata.py b/tests/test_formdata.py index d7917e6641..b15e493573 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -114,9 +114,10 @@ async def handler_0(request: web.Request) -> web.Response: async def handler_1(request: web.Request) -> web.Response: req_data = await request.post() - target_file = req_data["sample.txt"] - assert isinstance(tarfile, web.FileField) - assert target_file.file.read() == content + assert ["sample.txt"] == list(req_data.keys()) + file_field = req_data["sample.txt"] + assert isinstance(file_field, web.FileField) + assert content == file_field.file.read() return web.Response() app = web.Application() @@ -142,16 +143,18 @@ async def test_formdata_on_redirect_after_recv(aiohttp_client: AiohttpClient) -> async def handler_0(request: web.Request) -> web.Response: req_data = await request.post() - target_file = req_data["sample.txt"] - assert isinstance(tarfile, web.FileField) - assert target_file.file.read() == content + assert ["sample.txt"] == list(req_data.keys()) + file_field = req_data["sample.txt"] + assert isinstance(file_field, web.FileField) + assert content == file_field.file.read() raise web.HTTPPermanentRedirect("/1") async def handler_1(request: web.Request) -> web.Response: req_data = await request.post() - target_file = req_data["sample.txt"] - assert isinstance(tarfile, web.FileField) - assert target_file.file.read() == content + assert ["sample.txt"] == list(req_data.keys()) + file_field = req_data["sample.txt"] + assert isinstance(file_field, web.FileField) + assert content == file_field.file.read() return web.Response() app = web.Application() From 4020c14ec0f74aa139e59cd9e48e94077cbce897 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 21:28:01 +0800 Subject: [PATCH 05/11] simplify Formdata._gen_form_data; update test coverage --- aiohttp/formdata.py | 2 -- tests/test_formdata.py | 44 ++++++++++++++++++++++++++++++++++++------ tests/test_payload.py | 12 ++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/aiohttp/formdata.py b/aiohttp/formdata.py index f49c33e1b4..5d1a421d0e 100644 --- a/aiohttp/formdata.py +++ b/aiohttp/formdata.py @@ -116,8 +116,6 @@ def _gen_form_urlencoded(self) -> payload.BytesPayload: def _gen_form_data(self) -> multipart.MultipartWriter: """Encode a list of fields using the multipart/form-data MIME format""" - if not self._fields: - return self._writer for dispparams, headers, value in self._fields: try: if hdrs.CONTENT_TYPE in headers: diff --git a/tests/test_formdata.py b/tests/test_formdata.py index b15e493573..4e3388b1cd 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -1,6 +1,8 @@ import io import pathlib import tarfile +import tempfile +from typing import NoReturn from unittest import mock import pytest @@ -173,23 +175,21 @@ async def handler_1(request: web.Request) -> web.Response: resp.release() -async def test_streaming_tarfile_on_redirect(aiohttp_client: AiohttpClient) -> None: - data = b"This is a tar file payload text file." - +async def test_nonseekable_io_on_redirect(aiohttp_client: AiohttpClient) -> None: async def handler_0(request: web.Request) -> web.Response: await request.read() raise web.HTTPPermanentRedirect("/1") - async def handler_1(request: web.Request) -> web.Response: + async def handler_1(request: web.Request) -> NoReturn: await request.read() - return web.Response() + assert False app = web.Application() app.router.add_post("/0", handler_0) app.router.add_post("/1", handler_1) - client = await aiohttp_client(app) + data = b"Test io data." buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tf: ti = tarfile.TarInfo(name="payload1.txt") @@ -208,3 +208,35 @@ async def handler_1(request: web.Request) -> web.Response: assert isinstance(cause_exc, RuntimeError) assert len(cause_exc.args) == 1 assert cause_exc.args[0].startswith("Non-seekable IO payload") + + +async def test_nonseekable_text_io_on_redirect(aiohttp_client: AiohttpClient) -> None: + async def handler_0(request: web.Request) -> web.Response: + await request.read() + raise web.HTTPPermanentRedirect("/1") + + async def handler_1(request: web.Request) -> NoReturn: + await request.read() + assert False + + app = web.Application() + app.router.add_post("/0", handler_0) + app.router.add_post("/1", handler_1) + client = await aiohttp_client(app) + + data = "Test io data." + with tempfile.SpooledTemporaryFile(mode="w+") as temp_file: + temp_file.write(data) + temp_file.seek(0) + + f = getattr(temp_file, "_file") + assert isinstance(f, io.TextIOBase) + with mock.patch.object(f, "seek", side_effect=OSError): + with pytest.raises(ClientConnectionError) as exc_info: + await client.post("/0", data=f) + raw_exc_info = exc_info._excinfo + assert isinstance(raw_exc_info, tuple) + cause_exc = raw_exc_info[1].__cause__ + assert isinstance(cause_exc, RuntimeError) + assert len(cause_exc.args) == 1 + assert cause_exc.args[0].startswith("Non-seekable IO payload") diff --git a/tests/test_payload.py b/tests/test_payload.py index c92f6b7fb7..b522d8df10 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -118,6 +118,10 @@ def test_bytes_io_payload() -> None: with filepath.open("rb") as f: p = payload.BytesIOPayload(f) assert p.size == filesize + + p._seekable = False + assert p.size is None + assert not f.closed @@ -159,6 +163,8 @@ async def test_string_io_payload_write() -> None: s = io.StringIO(content) p = payload.StringIOPayload(s) + assert p.decode() == content + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() @@ -180,6 +186,8 @@ async def test_text_io_payload_write() -> None: p = payload.TextIOPayload(f) + assert p.decode() == content + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() @@ -201,6 +209,8 @@ async def test_bytes_io_payload_write() -> None: p = payload.BytesIOPayload(bf) + assert p.decode() == content.decode() + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() @@ -222,6 +232,8 @@ async def test_buffered_reader_payload_write() -> None: p = payload.BufferedReaderPayload(f) + assert p.decode() == content.decode() + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() From 32e127deccd46a61fbe08ebec6f377bb4bae0749 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 21:32:09 +0800 Subject: [PATCH 06/11] update history fragment --- CHANGES/9201.bugfix.rst | 1 + CONTRIBUTORS.txt | 1 + 2 files changed, 2 insertions(+) create mode 100644 CHANGES/9201.bugfix.rst diff --git a/CHANGES/9201.bugfix.rst b/CHANGES/9201.bugfix.rst new file mode 100644 index 0000000000..f93695de6b --- /dev/null +++ b/CHANGES/9201.bugfix.rst @@ -0,0 +1 @@ +Fix `I/O operation on closed file` and `Form data has been processed already` upon redirect on multipart data -- by :user:`GLGDLY`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 9ab093ae2a..8e7f97d9b9 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -134,6 +134,7 @@ Franek Magiera Frederik Gladhorn Frederik Peter Aalund Gabriel Tremblay +Gary Leung Gary Wilson Jr. Gennady Andreyev Georges Dubus From f3e62c31a85e28dc08d02e2cb42f6a6b56cb9b55 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 22:18:48 +0800 Subject: [PATCH 07/11] update test cov --- tests/test_payload.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_payload.py b/tests/test_payload.py index b522d8df10..d1382105e0 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -178,6 +178,29 @@ async def test_string_io_payload_write() -> None: instance.write.assert_called_once_with(content.encode("utf-8")) +async def test_io_base_payload_write() -> None: + filepath = pathlib.Path(__file__).parent / "sample.txt" + with filepath.open("rb") as f: + content = f.read() + with io.BytesIO(content) as bf: + + p = payload.IOBasePayload(bf) + + assert p.decode() == content.decode() + + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: + instance = mock_obj.return_value + instance.write = mock.AsyncMock() + + await p.write(instance) + instance.write.assert_called_once_with(content) # 1 chunk + + instance.write.reset_mock() + + await p.write(instance) + instance.write.assert_called_once_with(content) # 1 chunk + + async def test_text_io_payload_write() -> None: filepath = pathlib.Path(__file__).parent / "sample.txt" with filepath.open("r") as f: From 85fec6cfa6af20ee62c389d6ffe6874b4681cb27 Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 22:34:33 +0800 Subject: [PATCH 08/11] update test cov --- tests/test_payload.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_payload.py b/tests/test_payload.py index d1382105e0..4ee2e3b739 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -188,6 +188,10 @@ async def test_io_base_payload_write() -> None: assert p.decode() == content.decode() + p._seekable = False + assert p.decode() == "" + p._seekable = True + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() @@ -211,6 +215,10 @@ async def test_text_io_payload_write() -> None: assert p.decode() == content + p._seekable = False + assert p.decode() == "" + p._seekable = True + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() @@ -234,6 +242,10 @@ async def test_bytes_io_payload_write() -> None: assert p.decode() == content.decode() + p._seekable = False + assert p.decode() == "" + p._seekable = True + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() @@ -257,6 +269,10 @@ async def test_buffered_reader_payload_write() -> None: assert p.decode() == content.decode() + p._seekable = False + assert p.decode() == "" + p._seekable = True + with mock.patch("aiohttp.http_writer.StreamWriter") as mock_obj: instance = mock_obj.return_value instance.write = mock.AsyncMock() From b75761c28a113f429c18dfbb96e8459d87b4de8f Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Fri, 20 Sep 2024 23:14:10 +0800 Subject: [PATCH 09/11] add TODO note for IOBasePayload._seekable --- aiohttp/payload.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/aiohttp/payload.py b/aiohttp/payload.py index e353666f32..f0940062ea 100644 --- a/aiohttp/payload.py +++ b/aiohttp/payload.py @@ -308,11 +308,20 @@ def __init__( self.set_content_disposition(disposition, filename=self._filename) self._writable = True + + # Below try-except code segment is a temporary workaround for the issue now: + # It is weird but some IO objects don't have `seekable()` method as io.IOBase, + # the workaround here is to directly check if `seek()` and `tell()` methods are available + # e.g. tarfile.TarFile._Stream + # + # TODO: version check should be added after those libs are updated to prevent the issue + # seealso: https://github.com/aio-libs/aiohttp/pull/9201 + + # if sys.version_info >= (3, xx) + # self._seekable = self._value.seekable() + # else: self._seekable = True try: - # It is weird but some IO object dont have `seekable()` method as IOBase object, - # it seems better for us to direct try if the `seek()` and `tell()` is available - # e.g. tarfile.TarFile._Stream self._value.seek(self._value.tell()) except (AttributeError, OSError): self._seekable = False From 320e5085eba35053947132b70bc636ef6db1845a Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Sat, 21 Sep 2024 00:13:53 +0800 Subject: [PATCH 10/11] new general sol for finding _seekable in IOBasePayload --- aiohttp/payload.py | 14 +------------- tests/test_formdata.py | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/aiohttp/payload.py b/aiohttp/payload.py index f0940062ea..b68b757339 100644 --- a/aiohttp/payload.py +++ b/aiohttp/payload.py @@ -309,20 +309,8 @@ def __init__( self._writable = True - # Below try-except code segment is a temporary workaround for the issue now: - # It is weird but some IO objects don't have `seekable()` method as io.IOBase, - # the workaround here is to directly check if `seek()` and `tell()` methods are available - # e.g. tarfile.TarFile._Stream - # - # TODO: version check should be added after those libs are updated to prevent the issue - # seealso: https://github.com/aio-libs/aiohttp/pull/9201 - - # if sys.version_info >= (3, xx) - # self._seekable = self._value.seekable() - # else: - self._seekable = True try: - self._value.seek(self._value.tell()) + self._seekable = self._value.seekable() except (AttributeError, OSError): self._seekable = False diff --git a/tests/test_formdata.py b/tests/test_formdata.py index 4e3388b1cd..e4a1091183 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -231,7 +231,7 @@ async def handler_1(request: web.Request) -> NoReturn: f = getattr(temp_file, "_file") assert isinstance(f, io.TextIOBase) - with mock.patch.object(f, "seek", side_effect=OSError): + with mock.patch.object(f, "seekable", return_value=False): with pytest.raises(ClientConnectionError) as exc_info: await client.post("/0", data=f) raw_exc_info = exc_info._excinfo From c4da788151bbe12de58e9daf6777471ca24c5dbd Mon Sep 17 00:00:00 2001 From: GLGDLY Date: Sat, 21 Sep 2024 15:05:30 +0800 Subject: [PATCH 11/11] new general sol for finding _seekable in IOBasePayload --- aiohttp/client_reqrep.py | 5 +---- aiohttp/payload.py | 2 +- tests/test_formdata.py | 6 +++--- tests/test_multipart.py | 4 +--- tests/test_payload.py | 16 ++++++++-------- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index f8a5346dee..68713ab237 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -169,14 +169,11 @@ def process_data_to_payload(body: Any) -> Any: if body is None: return None - # FormData if isinstance(body, FormData): body = body() - try: + with contextlib.suppress(payload.LookupError): body = payload.PAYLOAD_REGISTRY.get(body, disposition=None) - except payload.LookupError: - pass # keep for ClientRequest to handle return body diff --git a/aiohttp/payload.py b/aiohttp/payload.py index b68b757339..395c44f6ea 100644 --- a/aiohttp/payload.py +++ b/aiohttp/payload.py @@ -311,7 +311,7 @@ def __init__( try: self._seekable = self._value.seekable() - except (AttributeError, OSError): + except AttributeError: # https://github.com/python/cpython/issues/124293 self._seekable = False if self._seekable: diff --git a/tests/test_formdata.py b/tests/test_formdata.py index e4a1091183..2cf1f3177d 100644 --- a/tests/test_formdata.py +++ b/tests/test_formdata.py @@ -1,7 +1,7 @@ import io -import pathlib import tarfile import tempfile +from pathlib import Path from typing import NoReturn from unittest import mock @@ -107,7 +107,7 @@ async def test_formdata_boundary_param() -> None: async def test_formdata_on_redirect(aiohttp_client: AiohttpClient) -> None: - with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open("rb") as fobj: + with Path(__file__).with_name("sample.txt").open("rb") as fobj: content = fobj.read() fobj.seek(0) @@ -139,7 +139,7 @@ async def handler_1(request: web.Request) -> web.Response: async def test_formdata_on_redirect_after_recv(aiohttp_client: AiohttpClient) -> None: - with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open("rb") as fobj: + with Path(__file__).with_name("sample.txt").open("rb") as fobj: content = fobj.read() fobj.seek(0) diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 756f8cffc9..74a715369e 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -1426,9 +1426,7 @@ async def test_multiple_write_on_io_payload( self, buf: bytearray, stream: Stream ) -> None: with aiohttp.MultipartWriter("form-data", boundary=":") as writer: - with pathlib.Path(pathlib.Path(__file__).parent / "sample.txt").open( - "rb" - ) as fobj: + with pathlib.Path(__file__).with_name("sample.txt").open("rb") as fobj: content = fobj.read() fobj.seek(0) diff --git a/tests/test_payload.py b/tests/test_payload.py index 4ee2e3b739..c04533a817 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -1,6 +1,6 @@ import array import io -import pathlib +from pathlib import Path from typing import Any, AsyncIterator, Iterator from unittest import mock @@ -102,7 +102,7 @@ def test_string_io_payload() -> None: def test_text_io_payload() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") filesize = filepath.stat().st_size with filepath.open("r") as f: p = payload.TextIOPayload(f) @@ -113,7 +113,7 @@ def test_text_io_payload() -> None: def test_bytes_io_payload() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") filesize = filepath.stat().st_size with filepath.open("rb") as f: p = payload.BytesIOPayload(f) @@ -126,7 +126,7 @@ def test_bytes_io_payload() -> None: def test_buffered_reader_payload() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") filesize = filepath.stat().st_size with filepath.open("rb") as f: p = payload.BufferedReaderPayload(f) @@ -179,7 +179,7 @@ async def test_string_io_payload_write() -> None: async def test_io_base_payload_write() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") with filepath.open("rb") as f: content = f.read() with io.BytesIO(content) as bf: @@ -206,7 +206,7 @@ async def test_io_base_payload_write() -> None: async def test_text_io_payload_write() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") with filepath.open("r") as f: content = f.read() f.seek(0) @@ -233,7 +233,7 @@ async def test_text_io_payload_write() -> None: async def test_bytes_io_payload_write() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") with filepath.open("rb") as f: content = f.read() with io.BytesIO(content) as bf: @@ -260,7 +260,7 @@ async def test_bytes_io_payload_write() -> None: async def test_buffered_reader_payload_write() -> None: - filepath = pathlib.Path(__file__).parent / "sample.txt" + filepath = Path(__file__).with_name("sample.txt") with filepath.open("rb") as f: content = f.read() f.seek(0)