From c4f35a2bf447b30207e36b71248fdc1201fd7cb8 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:29:23 -0700 Subject: [PATCH] fix: preserve memoryview bytes in sandbox streaming uploads --- .../async_manager/sandboxes/sandbox_files.py | 3 +- .../sync_manager/sandboxes/sandbox_files.py | 3 +- tests/test_sandbox_upload_buffers.py | 78 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/test_sandbox_upload_buffers.py diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py index d01a62ba..42f02750 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py @@ -64,7 +64,7 @@ async def _aiter_stream_content(stream, chunk_size: int = DEFAULT_TRANSFER_CHUNK if isinstance(stream, str): yield stream.encode("utf-8") return - if isinstance(stream, (bytes, bytearray)): + if isinstance(stream, (bytes, bytearray, memoryview)): yield bytes(stream) return if hasattr(stream, "__aiter__"): @@ -492,6 +492,7 @@ async def upload_stream( content_length: Optional[int] = None, chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE, ): + """Upload a bytes-like buffer, readable file, or iterable of byte chunks.""" headers = {} if content_length is not None: headers["content-length"] = str(content_length) diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py index c3464329..9a9ef521 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py @@ -57,7 +57,7 @@ def _iter_stream_content(stream, chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE): if isinstance(stream, str): yield stream.encode("utf-8") return - if isinstance(stream, (bytes, bytearray)): + if isinstance(stream, (bytes, bytearray, memoryview)): yield bytes(stream) return if hasattr(stream, "read"): @@ -461,6 +461,7 @@ def upload_stream( content_length: Optional[int] = None, chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE, ): + """Upload a bytes-like buffer, readable file, or iterable of byte chunks.""" headers = {} if content_length is not None: headers["content-length"] = str(content_length) diff --git a/tests/test_sandbox_upload_buffers.py b/tests/test_sandbox_upload_buffers.py new file mode 100644 index 00000000..27fe0225 --- /dev/null +++ b/tests/test_sandbox_upload_buffers.py @@ -0,0 +1,78 @@ +"""Exercise buffer inputs through the public sandbox file upload methods.""" + +import io +import struct +from array import array + +import pytest + +from hyperbrowser.client.managers.async_manager.sandboxes.sandbox_files import ( + SandboxFilesApi as AsyncSandboxFilesApi, +) +from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_files import ( + SandboxFilesApi, +) + + +CASES = [ + (lambda: memoryview(b"\x00\xffab"), b"\x00\xffab"), + (lambda: memoryview(b"prefixpayloadsuffix")[6:13], b"payload"), + (lambda: memoryview(b"abcdef")[::2], b"ace"), + ( + lambda: memoryview(array("H", [0x0102, 0x0304])), + struct.pack("=HH", 0x0102, 0x0304), + ), + (lambda: memoryview(b""), b""), + (lambda: b"abc", b"abc"), + (lambda: bytearray(b"abc"), b"abc"), + (lambda: io.BytesIO(b"abcdef"), b"abcdef"), + (lambda: iter([b"ab", b"cd", b"ef"]), b"abcdef"), +] + + +class RecordingTransport: + def request_json(self, path, *, content, params, method, headers): + self.body = b"".join(content) + self.path = path + self.params = params + self.method = method + self.headers = headers + return {"path": params["path"], "bytesWritten": len(self.body)} + + +class AsyncRecordingTransport(RecordingTransport): + async def request_json(self, path, *, content, params, method, headers): + chunks = [chunk async for chunk in content] + return super().request_json( + path, + content=chunks, + params=params, + method=method, + headers=headers, + ) + + +@pytest.mark.parametrize("make_stream,expected", CASES) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.anyio +async def test_upload_stream_preserves_buffer_bytes( + make_stream, expected, asynchronous +): + stream = make_stream() + if asynchronous: + transport = AsyncRecordingTransport() + files = AsyncSandboxFilesApi(transport, lambda: None) + result = await files.upload_stream("/tmp/buffer.bin", stream, chunk_size=2) + else: + transport = RecordingTransport() + files = SandboxFilesApi(transport, lambda: None) + result = files.upload_stream("/tmp/buffer.bin", stream, chunk_size=2) + + assert transport.body == expected + assert result.bytes_written == len(expected) + assert result.path == "/tmp/buffer.bin" + assert transport.path == "/sandbox/files/upload" + assert transport.method == "PUT" + assert transport.params == {"path": "/tmp/buffer.bin"} + if isinstance(stream, memoryview): + assert stream.tobytes() == expected # The caller's view remains usable.