diff --git a/playwright/async_api.py b/playwright/async_api.py index b4ec806f5..c09e8e5b1 100644 --- a/playwright/async_api.py +++ b/playwright/async_api.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import pathlib import sys import typing @@ -65,6 +64,8 @@ from playwright.page import Worker as WorkerImpl from playwright.playwright import Playwright as PlaywrightImpl from playwright.selectors import Selectors as SelectorsImpl +from playwright.stream import Stream as StreamImpl +from playwright.stream import StreamIO as StreamIOImpl NoneType = type(None) @@ -2761,6 +2762,7 @@ async def register( Name that is used in selectors as a prefix, e.g. `{name: 'foo'}` enables `foo=myselectorbody` selectors. May only contain `[a-zA-Z0-9_]` characters. source : Optional[str] Script that evaluates to a selector engine instance. + path : Optional[str] contentScript : Optional[bool] Whether to run this selector engine in isolated JavaScript environment. This environment has access to the same DOM, but not any JavaScript objects from the frame's scripts. Defaults to `false`. Note that running as a content script is not guaranteed when this engine is used together with other registered engines. """ @@ -2882,6 +2884,49 @@ async def dismiss(self) -> NoneType: mapping.register(DialogImpl, Dialog) +class StreamIO(AsyncBase): + def __init__(self, obj: StreamIOImpl): + super().__init__(obj) + + async def read(self, size: int = None) -> typing.Union[bytes, NoneType]: + """StreamIO.read + + Reads from the download bytes + + Parameters + ---------- + size : Optional[int] + Size in bytes how much should be read. + + Returns + ------- + Optional[bytes] + """ + return mapping.from_maybe_impl(await self._impl_obj.read(size=size)) + + +mapping.register(StreamIOImpl, StreamIO) + + +class Stream(AsyncBase): + def __init__(self, obj: StreamImpl): + super().__init__(obj) + + async def stream(self) -> "StreamIO": + """Stream.stream + + Returns the file stream + + Returns + ------- + StreamIO + """ + return mapping.from_impl(await self._impl_obj.stream()) + + +mapping.register(StreamImpl, Stream) + + class Download(AsyncBase): def __init__(self, obj: DownloadImpl): super().__init__(obj) @@ -2951,6 +2996,17 @@ async def saveAs(self, path: typing.Union[pathlib.Path, str]) -> NoneType: """ return mapping.from_maybe_impl(await self._impl_obj.saveAs(path=path)) + async def createReadStream(self) -> typing.Union["StreamIO", NoneType]: + """Download.createReadStream + + Returns readable stream for current download or `null` if download failed. + + Returns + ------- + Optional[StreamIO] + """ + return mapping.from_impl_nullable(await self._impl_obj.createReadStream()) + mapping.register(DownloadImpl, Download) @@ -3708,6 +3764,7 @@ async def waitForRequest( ---------- url : Union[str, Pattern, typing.Callable[[str], bool], NoneType] Request URL string, regex or predicate receiving Request object. + predicate : Optional[typing.Callable[[playwright.network.Request], bool]] timeout : Optional[int] Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be changed by using the page.setDefaultTimeout(timeout) method. @@ -3737,6 +3794,7 @@ async def waitForResponse( ---------- url : Union[str, Pattern, typing.Callable[[str], bool], NoneType] Request URL string, regex or predicate receiving Response object. + predicate : Optional[typing.Callable[[playwright.network.Response], bool]] timeout : Optional[int] Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be changed by using the browserContext.setDefaultTimeout(timeout) or page.setDefaultTimeout(timeout) methods. @@ -3768,6 +3826,8 @@ async def waitForEvent( ---------- event : str Event name, same one would pass into `page.on(event)`. + predicate : Optional[typing.Callable[[typing.Any], bool]] + timeout : Optional[int] Returns ------- @@ -3907,6 +3967,7 @@ async def addInitScript(self, source: str = None, path: str = None) -> NoneType: ---------- source : Optional[str] Script to be evaluated in the page. + path : Optional[str] """ return mapping.from_maybe_impl( await self._impl_obj.addInitScript(source=source, path=path) @@ -5033,6 +5094,7 @@ async def addInitScript(self, source: str = None, path: str = None) -> NoneType: ---------- source : Optional[str] Script to be evaluated in all pages in the browser context. + path : Optional[str] """ return mapping.from_maybe_impl( await self._impl_obj.addInitScript(source=source, path=path) @@ -5149,6 +5211,8 @@ async def waitForEvent( ---------- event : str Event name, same one would pass into `browserContext.on(event)`. + predicate : Optional[typing.Callable[[typing.Any], bool]] + timeout : Optional[int] Returns ------- diff --git a/playwright/browser_context.py b/playwright/browser_context.py index be2490441..5f77139ba 100644 --- a/playwright/browser_context.py +++ b/playwright/browser_context.py @@ -189,7 +189,7 @@ async def waitForEvent( timeout = self._timeout_settings.timeout() wait_helper = WaitHelper(self._loop) wait_helper.reject_on_timeout( - timeout, f'Timeout while waiting for event "${event}"' + timeout, f'Timeout while waiting for event "{event}"' ) if event != BrowserContext.Events.Close: wait_helper.reject_on_event( diff --git a/playwright/download.py b/playwright/download.py index 1c183fd86..b22ca8952 100644 --- a/playwright/download.py +++ b/playwright/download.py @@ -12,10 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import io from pathlib import Path from typing import Dict, Optional, Union -from playwright.connection import ChannelOwner +from playwright.connection import ChannelOwner, from_channel +from playwright.stream import StreamIO class Download(ChannelOwner): @@ -44,3 +46,9 @@ async def path(self) -> Optional[str]: async def saveAs(self, path: Union[Path, str]) -> None: path = str(Path(path)) return await self._channel.send("saveAs", dict(path=path)) + + async def createReadStream(self) -> Optional[StreamIO]: + stream = await self._channel.send("stream") + if not stream: + return None + return await from_channel(stream).stream() diff --git a/playwright/object_factory.py b/playwright/object_factory.py index d4cd118ce..b7d9e1518 100644 --- a/playwright/object_factory.py +++ b/playwright/object_factory.py @@ -31,6 +31,7 @@ from playwright.page import BindingCall, Page, Worker from playwright.playwright import Playwright from playwright.selectors import Selectors +from playwright.stream import Stream class DummyObject(ChannelOwner): @@ -88,4 +89,6 @@ def create_remote_object( return Worker(parent, type, guid, initializer) if type == "Selectors": return Selectors(parent, type, guid, initializer) + if type == "Stream": + return Stream(parent, type, guid, initializer) return DummyObject(parent, type, guid, initializer) diff --git a/playwright/page.py b/playwright/page.py index 7bf024cc2..230022901 100644 --- a/playwright/page.py +++ b/playwright/page.py @@ -461,7 +461,7 @@ async def waitForEvent( timeout = self._timeout_settings.timeout() wait_helper = WaitHelper(self._loop) wait_helper.reject_on_timeout( - timeout, f'Timeout while waiting for event "${event}"' + timeout, f'Timeout while waiting for event "{event}"' ) if event != Page.Events.Crash: wait_helper.reject_on_event(self, Page.Events.Crash, Error("Page crashed")) diff --git a/playwright/stream.py b/playwright/stream.py new file mode 100644 index 000000000..4085590ff --- /dev/null +++ b/playwright/stream.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import base64 +import io +from typing import Dict, Optional + +from playwright.connection import ChannelOwner + + +class StreamIO(io.RawIOBase): + def __init__(self, channel: ChannelOwner, loop) -> None: + self._channel = channel + self._loop = loop + + + async def read(self, size: int = None) -> Optional[bytes]: # type: ignore + if not size: + size = 16384 + result = await self._channel.send("read", dict(size=size)) + return base64.b64decode(result) + + +class Stream(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + self._loop = parent._loop + + async def stream(self,) -> StreamIO: + return StreamIO(self._channel, self._loop) diff --git a/playwright/sync_api.py b/playwright/sync_api.py index b8fc47675..f2a786c62 100644 --- a/playwright/sync_api.py +++ b/playwright/sync_api.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import pathlib import sys import typing @@ -64,6 +63,8 @@ from playwright.page import Worker as WorkerImpl from playwright.playwright import Playwright as PlaywrightImpl from playwright.selectors import Selectors as SelectorsImpl +from playwright.stream import Stream as StreamImpl +from playwright.stream import StreamIO as StreamIOImpl from playwright.sync_base import EventContextManager, SyncBase, mapping NoneType = type(None) @@ -2885,6 +2886,7 @@ def register( Name that is used in selectors as a prefix, e.g. `{name: 'foo'}` enables `foo=myselectorbody` selectors. May only contain `[a-zA-Z0-9_]` characters. source : Optional[str] Script that evaluates to a selector engine instance. + path : Optional[str] contentScript : Optional[bool] Whether to run this selector engine in isolated JavaScript environment. This environment has access to the same DOM, but not any JavaScript objects from the frame's scripts. Defaults to `false`. Note that running as a content script is not guaranteed when this engine is used together with other registered engines. """ @@ -3008,6 +3010,49 @@ def dismiss(self) -> NoneType: mapping.register(DialogImpl, Dialog) +class StreamIO(SyncBase): + def __init__(self, obj: StreamIOImpl): + super().__init__(obj) + + def read(self, size: int = None) -> typing.Union[bytes, NoneType]: + """StreamIO.read + + Reads from the download bytes + + Parameters + ---------- + size : Optional[int] + Size in bytes how much should be read. + + Returns + ------- + Optional[bytes] + """ + return mapping.from_maybe_impl(self._sync(self._impl_obj.read(size=size))) + + +mapping.register(StreamIOImpl, StreamIO) + + +class Stream(SyncBase): + def __init__(self, obj: StreamImpl): + super().__init__(obj) + + def stream(self) -> "StreamIO": + """Stream.stream + + Returns the file stream + + Returns + ------- + StreamIO + """ + return mapping.from_impl(self._sync(self._impl_obj.stream())) + + +mapping.register(StreamImpl, Stream) + + class Download(SyncBase): def __init__(self, obj: DownloadImpl): super().__init__(obj) @@ -3077,6 +3122,17 @@ def saveAs(self, path: typing.Union[pathlib.Path, str]) -> NoneType: """ return mapping.from_maybe_impl(self._sync(self._impl_obj.saveAs(path=path))) + def createReadStream(self) -> typing.Union["StreamIO", NoneType]: + """Download.createReadStream + + Returns readable stream for current download or `null` if download failed. + + Returns + ------- + Optional[StreamIO] + """ + return mapping.from_impl_nullable(self._sync(self._impl_obj.createReadStream())) + mapping.register(DownloadImpl, Download) @@ -3862,6 +3918,7 @@ def waitForRequest( ---------- url : Union[str, Pattern, typing.Callable[[str], bool], NoneType] Request URL string, regex or predicate receiving Request object. + predicate : Optional[typing.Callable[[playwright.network.Request], bool]] timeout : Optional[int] Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be changed by using the page.setDefaultTimeout(timeout) method. @@ -3893,6 +3950,7 @@ def waitForResponse( ---------- url : Union[str, Pattern, typing.Callable[[str], bool], NoneType] Request URL string, regex or predicate receiving Response object. + predicate : Optional[typing.Callable[[playwright.network.Response], bool]] timeout : Optional[int] Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be changed by using the browserContext.setDefaultTimeout(timeout) or page.setDefaultTimeout(timeout) methods. @@ -3926,6 +3984,8 @@ def waitForEvent( ---------- event : str Event name, same one would pass into `page.on(event)`. + predicate : Optional[typing.Callable[[typing.Any], bool]] + timeout : Optional[int] Returns ------- @@ -4071,6 +4131,7 @@ def addInitScript(self, source: str = None, path: str = None) -> NoneType: ---------- source : Optional[str] Script to be evaluated in the page. + path : Optional[str] """ return mapping.from_maybe_impl( self._sync(self._impl_obj.addInitScript(source=source, path=path)) @@ -5247,6 +5308,7 @@ def addInitScript(self, source: str = None, path: str = None) -> NoneType: ---------- source : Optional[str] Script to be evaluated in all pages in the browser context. + path : Optional[str] """ return mapping.from_maybe_impl( self._sync(self._impl_obj.addInitScript(source=source, path=path)) @@ -5371,6 +5433,8 @@ def waitForEvent( ---------- event : str Event name, same one would pass into `browserContext.on(event)`. + predicate : Optional[typing.Callable[[typing.Any], bool]] + timeout : Optional[int] Returns ------- diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index 0cb259c34..b2e22dc73 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -50,6 +50,46 @@ def __init__(self) -> None: self.printed_entries: List[str] = [] with open("api.json") as json_file: self.api = json.load(json_file) + self.api["StreamIO"] = { + "name": "StreamIO", + "members": { + "read": { + "kind": "method", + "name": "read", + "type": {"name": "Promise", "properties": {}}, + "comment": "Reads from the download bytes", + "returnComment": "", + "required": True, + "templates": [], + "args": { + "size": { + "kind": "property", + "name": "size", + "type": {"name": "int", "properties": {}}, + "comment": "Size in bytes how much should be read.", + "returnComment": "", + "required": False, + "templates": [], + } + }, + } + }, + } + self.api["Stream"] = { + "name": "Stream", + "members": { + "stream": { + "kind": "method", + "name": "stream", + "type": {"name": "Promise", "properties": {}}, + "comment": "Returns the file stream", + "returnComment": "", + "required": True, + "templates": [], + "args": {}, + } + }, + } method_name_rewrites: Dict[str, str] = { "continue_": "continue", @@ -108,15 +148,14 @@ def print_entry( elif not doc_value and fqname == "Page.setViewportSize": args = args["viewportSize"]["type"]["properties"] doc_value = args.get(name) + code_type = self.serialize_python_type(value) + print(f"{indent}{original_name} : {code_type}") if not doc_value: print( f"Missing parameter documentation: {fqname}({name}=)", file=stderr, ) else: - code_type = self.serialize_python_type(value) - - print(f"{indent}{original_name} : {code_type}") if doc_value["comment"]: print( f"{indent} {self.indent_paragraph(doc_value['comment'], f'{indent} ')}" @@ -210,6 +249,8 @@ def compare_types(self, value: Any, doc_value: Any, fqname: str) -> None: def serialize_python_type(self, value: Any) -> str: str_value = str(value) + if str_value == "": + return "io.BytesIO" if str_value == "": return "Error" match = re.match(r"^$", str_value) diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 30e1f25a0..02d797fee 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -44,10 +44,12 @@ from playwright.page import BindingCall, Page, Worker from playwright.playwright import Playwright from playwright.selectors import Selectors +from playwright.stream import Stream, StreamIO def process_type(value: Any, param: bool = False) -> str: value = str(value) + value = value.replace("_io.BytesIO", "io.BytesIO") value = re.sub(r"", r"\1", value) if "playwright.helper" in value: value = re.sub(r"playwright\.helper\.", "", value) @@ -141,7 +143,6 @@ def return_value(value: Any) -> List[str]: # See the License for the specific language governing permissions and # limitations under the License. - import typing import sys import pathlib @@ -171,6 +172,7 @@ def return_value(value: Any) -> List[str]: from playwright.page import BindingCall as BindingCallImpl, Page as PageImpl, Worker as WorkerImpl from playwright.playwright import Playwright as PlaywrightImpl from playwright.selectors import Selectors as SelectorsImpl +from playwright.stream import Stream as StreamImpl, StreamIO as StreamIOImpl """ all_types = [ @@ -188,6 +190,8 @@ def return_value(value: Any) -> List[str]: Selectors, ConsoleMessage, Dialog, + StreamIO, + Stream, Download, BindingCall, Page, @@ -203,3 +207,7 @@ def return_value(value: Any) -> List[str]: api_globals = globals() assert ValuesToSelect assert Serializable + + +def check_inheritance(base_class): + return base_class in ["ChannelOwner", "object", "RawIOBase"] diff --git a/scripts/generate_async_api.py b/scripts/generate_async_api.py index a7835c936..b05548b5f 100644 --- a/scripts/generate_async_api.py +++ b/scripts/generate_async_api.py @@ -21,7 +21,7 @@ from scripts.generate_api import ( all_types, api_globals, - arguments, + arguments, check_inheritance, header, process_type, return_type, @@ -39,7 +39,7 @@ def generate(t: Any) -> None: base_class = t.__bases__[0].__name__ base_sync_class = ( "AsyncBase" - if base_class == "ChannelOwner" or base_class == "object" + if check_inheritance(base_class) else base_class ) print(f"class {class_name}({base_sync_class}):") diff --git a/scripts/generate_sync_api.py b/scripts/generate_sync_api.py index 7b2717d1f..02809e7d7 100644 --- a/scripts/generate_sync_api.py +++ b/scripts/generate_sync_api.py @@ -21,7 +21,7 @@ from scripts.generate_api import ( all_types, api_globals, - arguments, + arguments, check_inheritance, header, process_type, return_type, @@ -39,7 +39,7 @@ def generate(t: Any) -> None: base_class = t.__bases__[0].__name__ base_sync_class = ( "SyncBase" - if base_class == "ChannelOwner" or base_class == "object" + if check_inheritance(base_class) else base_class ) print(f"class {class_name}({base_sync_class}):") diff --git a/scripts/update_api.sh b/scripts/update_api.sh index a05afd6b6..795563695 100755 --- a/scripts/update_api.sh +++ b/scripts/update_api.sh @@ -4,7 +4,6 @@ function update_api { echo "Generating $1" file_name="$1" generate_script="$2" - git checkout HEAD -- "$file_name" python "$generate_script" > .x @@ -12,7 +11,10 @@ function update_api { pre-commit run --files $file_name } -update_api "playwright/sync_api.py" "scripts/generate_sync_api.py" +git checkout HEAD -- "playwright/async_api.py" +git checkout HEAD -- "playwright/sync_api.py" + update_api "playwright/async_api.py" "scripts/generate_async_api.py" +update_api "playwright/sync_api.py" "scripts/generate_sync_api.py" echo "Regenerated APIs" diff --git a/tests/async/test_download.py b/tests/async/test_download.py index 70c317610..493ff5928 100644 --- a/tests/async/test_download.py +++ b/tests/async/test_download.py @@ -286,6 +286,15 @@ async def test_should_delete_file(browser, server): await page.close() +async def test_should_expose_stream_new(browser, server): + page = await browser.newPage(acceptDownloads=True) + await page.setContent(f'download') + [download, _] = await asyncio.gather(page.waitForEvent("download"), page.click("a")) + stream = await download.createReadStream() + assert (await stream.read()).decode() == "Hello world" + await page.close() + + async def test_should_delete_downloads_on_context_destruction(browser, server): page = await browser.newPage(acceptDownloads=True) await page.setContent(f'download') diff --git a/tests/sync/test_sync.py b/tests/sync/test_sync.py index 926ef4896..25b24e2b8 100644 --- a/tests/sync/test_sync.py +++ b/tests/sync/test_sync.py @@ -207,3 +207,19 @@ def test_expect_response_should_work(page: Page, server): assert resp.value.status == 200 assert resp.value.ok assert resp.value.request + +def test_should_expose_stream_new(browser:Browser, server): + def handle_download(request): + request.setHeader("Content-Type", "application/octet-stream") + request.write(b"Hello world") + request.finish() + + server.set_route("/download", handle_download) + page = browser.newPage(acceptDownloads=True) + page.setContent(f'download') + with page.expect_download() as download: + page.click("a") + download = download.value + stream = download.createReadStream() + assert stream.read().decode() == "Hello world" + page.close() \ No newline at end of file