diff --git a/playwright/_impl/_artifact.py b/playwright/_impl/_artifact.py index 63833fe04..f61aca489 100644 --- a/playwright/_impl/_artifact.py +++ b/playwright/_impl/_artifact.py @@ -14,7 +14,7 @@ import pathlib from pathlib import Path -from typing import Dict, Optional, Union, cast +from typing import AsyncIterator, Dict, Optional, Union, cast from playwright._impl._connection import ChannelOwner, from_channel from playwright._impl._helper import Error, make_dirs_for_file, patch_error_message @@ -41,6 +41,11 @@ async def save_as(self, path: Union[str, Path]) -> None: make_dirs_for_file(path) await stream.save_as(path) + async def read_stream(self) -> AsyncIterator[bytes]: + stream = cast(Stream, from_channel(await self._channel.send("stream"))) + async for chunk in stream.read_stream(): + yield chunk + async def failure(self) -> Optional[str]: return patch_error_message(await self._channel.send("failure")) diff --git a/playwright/_impl/_download.py b/playwright/_impl/_download.py index ffaf5cacd..64ffb56a0 100644 --- a/playwright/_impl/_download.py +++ b/playwright/_impl/_download.py @@ -14,7 +14,7 @@ import pathlib from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, AsyncIterator, Optional, Union from playwright._impl._artifact import Artifact @@ -60,5 +60,9 @@ async def path(self) -> pathlib.Path: async def save_as(self, path: Union[str, Path]) -> None: await self._artifact.save_as(path) + async def read_stream(self) -> AsyncIterator[bytes]: + async for chunk in self._artifact.read_stream(): + yield chunk + async def cancel(self) -> None: return await self._artifact.cancel() diff --git a/playwright/_impl/_stream.py b/playwright/_impl/_stream.py index d27427589..c34a520ab 100644 --- a/playwright/_impl/_stream.py +++ b/playwright/_impl/_stream.py @@ -14,7 +14,7 @@ import base64 from pathlib import Path -from typing import Dict, Union +from typing import AsyncIterator, Dict, Union from playwright._impl._connection import ChannelOwner @@ -36,6 +36,13 @@ async def save_as(self, path: Union[str, Path]) -> None: ) await self._loop.run_in_executor(None, lambda: file.close()) + async def read_stream(self) -> AsyncIterator[bytes]: + while True: + binary = await self._channel.send("read", {"size": 1024 * 1024}) + if not binary: + break + yield base64.b64decode(binary) + async def read_all(self) -> bytes: binary = b"" while True: diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index e484baa09..d8e6e0b09 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -15,7 +15,7 @@ import pathlib import typing -from typing import Literal +from typing import AsyncIterator, Literal from playwright._impl._accessibility import Accessibility as AccessibilityImpl from playwright._impl._api_structures import ( @@ -6852,6 +6852,15 @@ async def save_as(self, path: typing.Union[str, pathlib.Path]) -> None: return mapping.from_maybe_impl(await self._impl_obj.save_as(path=path)) + async def read_stream(self) -> AsyncIterator[bytes]: + """Download.read_stream + + Yields a readable stream chunks for a successful download, or throws for a failed/canceled download. + """ + + async for chunk in mapping.from_maybe_impl(self._impl_obj.read_stream()): + yield chunk + async def cancel(self) -> None: """Download.cancel diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index a861367be..51e7d3d09 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -15,7 +15,7 @@ import pathlib import typing -from typing import Literal +from typing import Iterable, Literal from playwright._impl._accessibility import Accessibility as AccessibilityImpl from playwright._impl._api_structures import ( @@ -6962,6 +6962,15 @@ def save_as(self, path: typing.Union[str, pathlib.Path]) -> None: return mapping.from_maybe_impl(self._sync(self._impl_obj.save_as(path=path))) + def read_stream(self) -> Iterable[bytes]: + """Download.read_stream + + Yields a readable stream chunks for a successful download, or throws for a failed/canceled download. + """ + + for chunk in mapping.from_maybe_impl(self._sync(self._impl_obj.read_stream())): + yield chunk + def cancel(self) -> None: """Download.cancel diff --git a/tests/async/test_download.py b/tests/async/test_download.py index 96d06820e..8faaf8fe7 100644 --- a/tests/async/test_download.py +++ b/tests/async/test_download.py @@ -43,8 +43,16 @@ def handle_download_with_file_name(request: TestServerRequest) -> None: request.write(b"Hello world") request.finish() + def handle_download_big_file(request: TestServerRequest) -> None: + request.setHeader("Content-Type", "application/octet-stream") + request.setHeader("Content-Disposition", "attachment") + request.write(b"A" * 1024 * 1024) + request.write(b"B") + request.finish() + server.set_route("/download", handle_download) server.set_route("/downloadWithFilename", handle_download_with_file_name) + server.set_route("/downloadBigFile", handle_download_big_file) yield @@ -381,3 +389,25 @@ def handle_download(request: TestServerRequest) -> None: await download.cancel() assert await download.failure() == "canceled" await page.close() + + +async def test_stream_reading(browser: Browser, server: Server) -> None: + page = await browser.new_page(accept_downloads=True) + await page.set_content(f'download') + async with page.expect_download() as download_info: + await page.click("a") + download = await download_info.value + data = b"".join([chunk async for chunk in download.read_stream()]) + assert data == b"Hello world" + await page.close() + + +async def test_stream_reading_multiple_chunks(browser: Browser, server: Server) -> None: + page = await browser.new_page(accept_downloads=True) + await page.set_content(f'download') + async with page.expect_download() as download_info: + await page.click("a") + download = await download_info.value + data = b"".join([chunk async for chunk in download.read_stream()]) + assert data == b"A" * 1024 * 1024 + b"B" + await page.close()