diff --git a/README.md b/README.md index 5d2815c45..e09a4318e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 90.0.4430.0 | ✅ | ✅ | ✅ | +| Chromium 91.0.4455.0 | ✅ | ✅ | ✅ | | WebKit 14.2 | ✅ | ✅ | ✅ | | Firefox 87.0b10 | ✅ | ✅ | ✅ | diff --git a/playwright/_impl/_artifact.py b/playwright/_impl/_artifact.py new file mode 100644 index 000000000..9c3afc3b5 --- /dev/null +++ b/playwright/_impl/_artifact.py @@ -0,0 +1,48 @@ +# 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 pathlib +from pathlib import Path +from typing import 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 +from playwright._impl._stream import Stream + + +class Artifact(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + self._is_remote = False + self.absolute_path = initializer["absolutePath"] + + async def path_after_finished(self) -> Optional[pathlib.Path]: + if self._is_remote: + raise Error( + "Path is not available when using browser_type.connect(). Use save_as() to save a local copy." + ) + return pathlib.Path(await self._channel.send("pathAfterFinished")) + + async def save_as(self, path: Union[str, Path]) -> None: + stream = cast(Stream, from_channel(await self._channel.send("saveAsStream"))) + make_dirs_for_file(path) + await stream.save_as(path) + + async def failure(self) -> Optional[str]: + return patch_error_message(await self._channel.send("failure")) + + async def delete(self) -> None: + await self._channel.send("delete") diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 4f6ebe639..d5d512063 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -47,6 +47,7 @@ def __init__( self._browser_type = parent self._is_connected = True self._is_closed_or_closing = False + self._is_remote = False self._contexts: List[BrowserContext] = [] self._channel.on("close", lambda _: self._on_close()) diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 3cade6e15..b2c232327 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -79,6 +79,7 @@ def __repr__(self) -> str: return f"" def _on_page(self, page: Page) -> None: + print("ON PAGE ARRIVED") page._set_browser_context(self) self._pages.append(page) self.emit(BrowserContext.Events.Page, page) diff --git a/playwright/_impl/_download.py b/playwright/_impl/_download.py index 0400f005a..11cdd3e7e 100644 --- a/playwright/_impl/_download.py +++ b/playwright/_impl/_download.py @@ -14,38 +14,43 @@ import pathlib from pathlib import Path -from typing import Dict, Optional, Union +from typing import TYPE_CHECKING, Optional, Union -from playwright._impl._connection import ChannelOwner -from playwright._impl._helper import patch_error_message +from playwright._impl._artifact import Artifact +if TYPE_CHECKING: # pragma: no cover + from playwright._impl._page import Page -class Download(ChannelOwner): + +class Download: def __init__( - self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + self, page: "Page", url: str, suggested_filename: str, artifact: Artifact ) -> None: - super().__init__(parent, type, guid, initializer) + self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber + self._url = url + self._suggested_filename = suggested_filename + self._artifact = artifact def __repr__(self) -> str: return f"" @property def url(self) -> str: - return self._initializer["url"] + return self._url @property def suggested_filename(self) -> str: - return self._initializer["suggestedFilename"] + return self._suggested_filename async def delete(self) -> None: - await self._channel.send("delete") + await self._artifact.delete() async def failure(self) -> Optional[str]: - return patch_error_message(await self._channel.send("failure")) + return await self._artifact.failure() async def path(self) -> Optional[pathlib.Path]: - return pathlib.Path(await self._channel.send("path")) + return await self._artifact.path_after_finished() async def save_as(self, path: Union[str, Path]) -> None: - path = str(Path(path)) - return await self._channel.send("saveAs", dict(path=path)) + await self._artifact.save_as(path) diff --git a/playwright/_impl/_element_handle.py b/playwright/_impl/_element_handle.py index af0861f44..84fed008b 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -20,7 +20,12 @@ from playwright._impl._api_structures import FilePayload, FloatRect, Position from playwright._impl._connection import ChannelOwner, from_nullable_channel from playwright._impl._file_chooser import normalize_file_payloads -from playwright._impl._helper import KeyboardModifier, MouseButton, locals_to_params +from playwright._impl._helper import ( + KeyboardModifier, + MouseButton, + locals_to_params, + make_dirs_for_file, +) from playwright._impl._js_handle import ( JSHandle, Serializable, @@ -221,6 +226,7 @@ async def screenshot( encoded_binary = await self._channel.send("screenshot", params) decoded_binary = base64.b64decode(encoded_binary) if path: + make_dirs_for_file(path) with open(path, "wb") as fd: fd.write(decoded_binary) return decoded_binary diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 7f961df19..01be264ed 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -14,10 +14,12 @@ import fnmatch import math +import os import re import sys import time import traceback +from pathlib import Path from types import TracebackType from typing import ( TYPE_CHECKING, @@ -232,3 +234,9 @@ def not_installed_error(message: str) -> Exception: def to_snake_case(name: str) -> str: return to_snake_case_regex.sub(r"_\1", name).lower() + + +def make_dirs_for_file(path: Union[Path, str]) -> None: + if not os.path.isabs(path): + path = Path.cwd() / path + os.makedirs(os.path.dirname(path), exist_ok=True) diff --git a/playwright/_impl/_object_factory.py b/playwright/_impl/_object_factory.py index aa9c0d988..1679c5332 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -14,6 +14,7 @@ from typing import Any, Dict, cast +from playwright._impl._artifact import Artifact from playwright._impl._browser import Browser from playwright._impl._browser_context import BrowserContext from playwright._impl._browser_type import BrowserType @@ -22,7 +23,6 @@ from playwright._impl._connection import ChannelOwner from playwright._impl._console_message import ConsoleMessage from playwright._impl._dialog import Dialog -from playwright._impl._download import Download from playwright._impl._element_handle import ElementHandle from playwright._impl._frame import Frame from playwright._impl._js_handle import JSHandle @@ -30,6 +30,7 @@ from playwright._impl._page import BindingCall, Page, Worker from playwright._impl._playwright import Playwright from playwright._impl._selectors import Selectors +from playwright._impl._stream import Stream class DummyObject(ChannelOwner): @@ -42,6 +43,8 @@ def __init__( def create_remote_object( parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> Any: + if type == "Artifact": + return Artifact(parent, type, guid, initializer) if type == "BindingCall": return BindingCall(parent, type, guid, initializer) if type == "Browser": @@ -63,8 +66,6 @@ def create_remote_object( return ConsoleMessage(parent, type, guid, initializer) if type == "Dialog": return Dialog(parent, type, guid, initializer) - if type == "Download": - return Download(parent, type, guid, initializer) if type == "ElementHandle": return ElementHandle(parent, type, guid, initializer) if type == "Frame": @@ -81,6 +82,8 @@ def create_remote_object( return Response(parent, type, guid, initializer) if type == "Route": return Route(parent, type, guid, initializer) + if type == "Stream": + return Stream(parent, type, guid, initializer) if type == "WebSocket": return WebSocket(parent, type, guid, initializer) if type == "Worker": diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 1ba1573d6..5e1b56270 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -54,6 +54,7 @@ URLMatchResponse, is_safe_close_error, locals_to_params, + make_dirs_for_file, parse_error, serialize_error, ) @@ -142,12 +143,7 @@ def __init__( self._channel.on( "domcontentloaded", lambda _: self.emit(Page.Events.DOMContentLoaded) ) - self._channel.on( - "download", - lambda params: self.emit( - Page.Events.Download, from_channel(params["download"]) - ), - ) + self._channel.on("download", lambda params: self._on_download(params)) self._channel.on( "fileChooser", lambda params: self.emit( @@ -208,12 +204,7 @@ def __init__( from_channel(params["route"]), from_channel(params["request"]) ), ) - self._channel.on( - "video", - lambda params: cast(Video, self.video)._set_relative_path( - params["relativePath"] - ), - ) + self._channel.on("video", lambda params: self._on_video(params)) self._channel.on( "webSocket", lambda params: self.emit( @@ -294,6 +285,18 @@ def _on_dialog(self, params: Any) -> None: else: asyncio.create_task(dialog.dismiss()) + def _on_download(self, params: Any) -> None: + url = params["url"] + suggested_filename = params["suggestedFilename"] + artifact = from_channel(params["artifact"]) + self.emit( + Page.Events.Download, Download(self, url, suggested_filename, artifact) + ) + + def _on_video(self, params: Any) -> None: + artifact = from_channel(params["artifact"]) + cast(Video, self.video)._artifact_ready(artifact) + def _add_event_handler(self, event: str, k: Any, v: Any) -> None: if event == Page.Events.FileChooser and len(self.listeners(event)) == 0: self._channel.send_no_reply( @@ -575,6 +578,7 @@ async def screenshot( encoded_binary = await self._channel.send("screenshot", params) decoded_binary = base64.b64decode(encoded_binary) if path: + make_dirs_for_file(path) with open(path, "wb") as fd: fd.write(decoded_binary) return decoded_binary @@ -765,6 +769,7 @@ async def pdf( encoded_binary = await self._channel.send("pdf", params) decoded_binary = base64.b64decode(encoded_binary) if path: + make_dirs_for_file(path) with open(path, "wb") as fd: fd.write(decoded_binary) return decoded_binary @@ -773,13 +778,10 @@ async def pdf( def video( self, ) -> Optional[Video]: - context_options = self._browser_context._options - if "recordVideo" not in context_options: + if "recordVideo" not in self._browser_context._options: return None if not self._video: self._video = Video(self) - if "videoRelativePath" in self._initializer: - self._video._set_relative_path(self._initializer["videoRelativePath"]) return self._video def expect_event( diff --git a/playwright/_impl/_stream.py b/playwright/_impl/_stream.py new file mode 100644 index 000000000..a346bf7e5 --- /dev/null +++ b/playwright/_impl/_stream.py @@ -0,0 +1,34 @@ +# 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 base64 +from pathlib import Path +from typing import Dict + +from playwright._impl._connection import ChannelOwner + + +class Stream(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + + async def save_as(self, path: Path) -> None: + with open(path, mode="wb") as file: + while True: + binary = await self._channel.send("read") + if not binary: + break + file.write(base64.b64decode(binary)) diff --git a/playwright/_impl/_video.py b/playwright/_impl/_video.py index da3c72a3a..171852506 100644 --- a/playwright/_impl/_video.py +++ b/playwright/_impl/_video.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os import pathlib -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Union + +from playwright._impl._artifact import Artifact +from playwright._impl._helper import Error if TYPE_CHECKING: # pragma: no cover from playwright._impl._page import Page @@ -25,22 +27,37 @@ def __init__(self, page: "Page") -> None: self._loop = page._loop self._dispatcher_fiber = page._dispatcher_fiber self._page = page - self._path_future = page._loop.create_future() - - async def path(self) -> pathlib.Path: - return await self._path_future - - def _set_relative_path(self, relative_path: str) -> None: - self._path_future.set_result( - pathlib.Path( - os.path.join( - cast( - str, self._page._browser_context._options["recordVideo"]["dir"] - ), - relative_path, - ) - ) - ) + self._artifact_future = page._loop.create_future() + if page.is_closed(): + self._page_closed() + else: + page.on("close", lambda: self._page_closed()) def __repr__(self) -> str: return f"