From 7de5de2e76deb92327b4f1ff24fcce0cad3a1dcc Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 29 Apr 2026 10:23:04 +0200 Subject: [PATCH] feat: add AsyncContext/Disposable support Adds support for the Disposable return type that 12 driver methods now return as of 1.59.1, so users can use them as context managers: async with await page.route("**/*", handler): ... # handler is automatically unrouted on exit Affected methods (Page & BrowserContext): add_init_script, expose_binding, expose_function, route. Plus Tracing.group and Screencast.{start,show_overlay,show_actions}. Two flavors: - Disposable(ChannelOwner) wraps a driver-side Disposable channel (used where the protocol returns one, e.g. addInitScript). - DisposableStub is a Python-side wrapper that calls a dispose lambda on exit (used for client-side pairings like route/unroute and tracing.group/group_end). Codegen rewrites both classes to AsyncContextManager/SyncContextManager in the generated public API so consumers see standard Python types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playwright/_impl/_browser_context.py | 22 +++-- playwright/_impl/_disposable.py | 93 +++++++++++++++++++++ playwright/_impl/_object_factory.py | 3 + playwright/_impl/_page.py | 26 +++--- playwright/_impl/_screencast.py | 18 +++- playwright/_impl/_tracing.py | 6 +- playwright/async_api/_generated.py | 120 +++++++++++++++++++++------ playwright/sync_api/_generated.py | 120 +++++++++++++++++++++------ scripts/documentation_provider.py | 20 ++++- scripts/expected_api_mismatch.txt | 3 + scripts/generate_api.py | 3 + scripts/generate_async_api.py | 5 +- scripts/generate_sync_api.py | 5 +- tests/async/test_add_init_script.py | 10 +++ tests/async/test_page_route.py | 15 ++++ tests/sync/test_add_init_script.py | 8 ++ tests/sync/test_unroute_behavior.py | 17 +++- 17 files changed, 418 insertions(+), 76 deletions(-) create mode 100644 playwright/_impl/_disposable.py diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 4b1c19c40..6839d7c7f 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -48,6 +48,7 @@ from playwright._impl._console_message import ConsoleMessage from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable, DisposableStub from playwright._impl._errors import Error, TargetClosedError from playwright._impl._event_context_manager import EventContextManagerImpl from playwright._impl._fetch import APIRequestContext @@ -394,16 +395,18 @@ async def set_offline(self, offline: bool) -> None: async def add_init_script( self, script: str = None, path: Union[str, Path] = None - ) -> None: + ) -> Disposable: if path: script = (await async_readfile(path)).decode() if not isinstance(script, str): raise Error("Either path or script parameter must be specified") - await self._channel.send("addInitScript", None, dict(source=script)) + return from_channel( + await self._channel.send("addInitScript", None, dict(source=script)) + ) async def expose_binding( self, name: str, callback: Callable, handle: bool = None - ) -> None: + ) -> Disposable: for page in self._pages: if name in page._bindings: raise Error( @@ -412,16 +415,18 @@ async def expose_binding( if name in self._bindings: raise Error(f'Function "{name}" has been already registered') self._bindings[name] = callback - await self._channel.send( - "exposeBinding", None, dict(name=name, needsHandle=handle or False) + return from_channel( + await self._channel.send( + "exposeBinding", None, dict(name=name, needsHandle=handle or False) + ) ) - async def expose_function(self, name: str, callback: Callable) -> None: - await self.expose_binding(name, lambda source, *args: callback(*args)) + async def expose_function(self, name: str, callback: Callable) -> Disposable: + return await self.expose_binding(name, lambda source, *args: callback(*args)) async def route( self, url: URLMatch, handler: RouteHandlerCallback, times: int = None - ) -> None: + ) -> DisposableStub: self._routes.insert( 0, RouteHandler( @@ -433,6 +438,7 @@ async def route( ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler), self) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None diff --git a/playwright/_impl/_disposable.py b/playwright/_impl/_disposable.py new file mode 100644 index 000000000..c0b7e85a1 --- /dev/null +++ b/playwright/_impl/_disposable.py @@ -0,0 +1,93 @@ +# 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 inspect +import traceback +from typing import Awaitable, Callable, Dict + +import greenlet + +from playwright._impl._connection import ChannelOwner +from playwright._impl._errors import Error, is_target_closed_error + + +class Disposable(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + + async def dispose(self) -> None: + try: + await self._channel.send( + "dispose", + None, + ) + except Exception as e: + if not is_target_closed_error(e): + raise e + + async def close(self) -> None: + await self.dispose() + + def __repr__(self) -> str: + return "" + + +class DisposableStub: + def __init__( + self, + dispose_fn: Callable[[], Awaitable[None]], + parent: ChannelOwner, + ) -> None: + self._dispose_fn = dispose_fn + self._loop = parent._loop + self._dispatcher_fiber = parent._dispatcher_fiber + + async def dispose(self) -> None: + await self._dispose_fn() + + async def __aenter__(self) -> "DisposableStub": + return self + + async def __aexit__(self, *args: object) -> None: + await self.dispose() + + def __enter__(self) -> "DisposableStub": + return self + + def __exit__(self, *args: object) -> None: + self._sync(self.dispose()) + + def _sync(self, coro: object) -> object: + __tracebackhide__ = True + if self._loop.is_closed(): + coro.close() # type: ignore + raise Error("Event loop is closed! Is Playwright already stopped?") + g_self = greenlet.getcurrent() + task = self._loop.create_task(coro) # type: ignore + setattr(task, "__pw_stack__", inspect.stack(0)) + setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)) + task.add_done_callback(lambda _: g_self.switch()) + while not task.done(): + self._dispatcher_fiber.switch() # type: ignore + asyncio._set_running_loop(self._loop) + return task.result() + + async def close(self) -> None: + await self.dispose() + + def __repr__(self) -> str: + return "" diff --git a/playwright/_impl/_object_factory.py b/playwright/_impl/_object_factory.py index 7abfb4b33..7911ddb30 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -22,6 +22,7 @@ from playwright._impl._connection import ChannelOwner from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable from playwright._impl._element_handle import ElementHandle from playwright._impl._fetch import APIRequestContext from playwright._impl._frame import Frame @@ -69,6 +70,8 @@ def create_remote_object( return Debugger(parent, type, guid, initializer) if type == "Dialog": return Dialog(parent, type, guid, initializer) + if type == "Disposable": + return Disposable(parent, type, guid, initializer) if type == "ElementHandle": return ElementHandle(parent, type, guid, initializer) if type == "Frame": diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 4cecbd64c..020058acf 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -49,6 +49,7 @@ from_nullable_channel, ) from playwright._impl._console_message import ConsoleMessage +from playwright._impl._disposable import Disposable, DisposableStub from playwright._impl._download import Download from playwright._impl._element_handle import ElementHandle, determine_screenshot_type from playwright._impl._errors import Error, TargetClosedError, is_target_closed_error @@ -501,12 +502,12 @@ async def add_style_tag( ) -> ElementHandle: return await self._main_frame.add_style_tag(**locals_to_params(locals())) - async def expose_function(self, name: str, callback: Callable) -> None: - await self.expose_binding(name, lambda source, *args: callback(*args)) + async def expose_function(self, name: str, callback: Callable) -> Disposable: + return await self.expose_binding(name, lambda source, *args: callback(*args)) async def expose_binding( self, name: str, callback: Callable, handle: bool = None - ) -> None: + ) -> Disposable: if name in self._bindings: raise Error(f'Function "{name}" has been already registered') if name in self._browser_context._bindings: @@ -514,10 +515,12 @@ async def expose_binding( f'Function "{name}" has been already registered in the browser context' ) self._bindings[name] = callback - await self._channel.send( - "exposeBinding", - None, - dict(name=name, needsHandle=handle or False), + return from_channel( + await self._channel.send( + "exposeBinding", + None, + dict(name=name, needsHandle=handle or False), + ) ) async def set_extra_http_headers(self, headers: Dict[str, str]) -> None: @@ -661,18 +664,20 @@ async def bring_to_front(self) -> None: async def add_init_script( self, script: str = None, path: Union[str, Path] = None - ) -> None: + ) -> Disposable: if path: script = add_source_url_to_script( (await async_readfile(path)).decode(), path ) if not isinstance(script, str): raise Error("Either path or script parameter must be specified") - await self._channel.send("addInitScript", None, dict(source=script)) + return from_channel( + await self._channel.send("addInitScript", None, dict(source=script)) + ) async def route( self, url: URLMatch, handler: RouteHandlerCallback, times: int = None - ) -> None: + ) -> DisposableStub: self._routes.insert( 0, RouteHandler( @@ -684,6 +689,7 @@ async def route( ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler), self) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py index 1f19c7220..1f1da3c4f 100644 --- a/playwright/_impl/_screencast.py +++ b/playwright/_impl/_screencast.py @@ -19,6 +19,7 @@ from playwright._impl._api_structures import ScreencastFrame from playwright._impl._artifact import Artifact from playwright._impl._connection import from_nullable_channel +from playwright._impl._disposable import DisposableStub from playwright._impl._errors import Error from playwright._impl._helper import locals_to_params @@ -63,7 +64,7 @@ async def start( onFrame: ScreencastFrameCallback = None, path: Union[str, Path] = None, quality: int = None, - ) -> None: + ) -> DisposableStub: if self._started: raise Error("Screencast is already started") self._started = True @@ -81,6 +82,7 @@ async def start( if artifact_channel: self._artifact = from_nullable_channel(artifact_channel) self._save_path = path + return DisposableStub(lambda: self.stop(), self._page) async def stop(self) -> None: self._started = False @@ -96,18 +98,26 @@ async def show_actions( duration: float = None, position: ScreencastPosition = None, fontSize: int = None, - ) -> None: + ) -> DisposableStub: await self._page._channel.send( "screencastShowActions", None, locals_to_params(locals()) ) + return DisposableStub(lambda: self.hide_actions(), self._page) async def hide_actions(self) -> None: await self._page._channel.send("screencastHideActions", None) - async def show_overlay(self, html: str, duration: float = None) -> None: - await self._page._channel.send( + async def show_overlay(self, html: str, duration: float = None) -> DisposableStub: + result = await self._page._channel.send_return_as_dict( "screencastShowOverlay", None, locals_to_params(locals()) ) + overlay_id = (result or {}).get("id") + return DisposableStub( + lambda: self._page._channel.send( + "screencastRemoveOverlay", None, {"id": overlay_id} + ), + self._page, + ) async def show_chapter( self, diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index 8dda75994..2798b89d9 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -18,6 +18,7 @@ from playwright._impl._api_structures import TracingGroupLocation from playwright._impl._artifact import Artifact from playwright._impl._connection import ChannelOwner, from_nullable_channel +from playwright._impl._disposable import DisposableStub from playwright._impl._helper import locals_to_params @@ -148,8 +149,11 @@ def _reset_stack_counter(self) -> None: self._is_tracing = False self._connection.set_is_tracing(False) - async def group(self, name: str, location: TracingGroupLocation = None) -> None: + async def group( + self, name: str, location: TracingGroupLocation = None + ) -> DisposableStub: await self._channel.send("tracingGroup", None, locals_to_params(locals())) + return DisposableStub(lambda: self.group_end(), self) async def group_end(self) -> None: await self._channel.send( diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 7119b9d84..5ca533ef2 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -62,6 +62,7 @@ from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._debugger import Debugger as DebuggerImpl from playwright._impl._dialog import Dialog as DialogImpl +from playwright._impl._disposable import Disposable as DisposableImpl from playwright._impl._download import Download as DownloadImpl from playwright._impl._element_handle import ElementHandle as ElementHandleImpl from playwright._impl._errors import Error @@ -7469,7 +7470,7 @@ async def start( ] = None, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, quality: typing.Optional[int] = None, - ) -> None: + ) -> "AsyncContextManager": """Screencast.start Starts the screencast. When `path` is provided, it saves video recording to the specified file. When `onFrame` is @@ -7485,9 +7486,13 @@ async def start( Path where the video should be saved when the screencast is stopped. When provided, video recording is started. quality : Union[int, None] The quality of the image, between 0-100. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.start( onFrame=self._wrap_handler(on_frame), path=path, quality=quality ) @@ -7512,7 +7517,7 @@ async def show_actions( ] ] = None, font_size: typing.Optional[int] = None, - ) -> None: + ) -> "AsyncContextManager": """Screencast.show_actions Enables visual annotations on interacted elements. Returns a disposable that stops showing actions when disposed. @@ -7525,9 +7530,13 @@ async def show_actions( Position of the action title overlay. Defaults to `"top-right"`. font_size : Union[int, None] Font size of the action title in pixels. Defaults to `24`. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.show_actions( duration=duration, position=position, fontSize=font_size ) @@ -7543,7 +7552,7 @@ async def hide_actions(self) -> None: async def show_overlay( self, html: str, *, duration: typing.Optional[float] = None - ) -> None: + ) -> "AsyncContextManager": """Screencast.show_overlay Adds an overlay with the given HTML content. The overlay is displayed on top of the page until removed. Returns a @@ -7556,9 +7565,13 @@ async def show_overlay( duration : Union[float, None] Duration in milliseconds after which the overlay is automatically removed. Overlay stays until dismissed if not provided. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.show_overlay(html=html, duration=duration) ) @@ -9100,7 +9113,9 @@ async def add_style_tag( await self._impl_obj.add_style_tag(url=url, path=path, content=content) ) - async def expose_function(self, name: str, callback: typing.Callable) -> None: + async def expose_function( + self, name: str, callback: typing.Callable + ) -> "AsyncContextManager": """Page.expose_function The method adds a function called `name` on the `window` object of every frame in the page. When called, the @@ -9154,9 +9169,13 @@ async def main(): Name of the function on the window object callback : Callable Callback function which will be called in Playwright's context. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.expose_function( name=name, callback=self._wrap_handler(callback) ) @@ -9168,7 +9187,7 @@ async def expose_binding( callback: typing.Callable, *, handle: typing.Optional[bool] = None, - ) -> None: + ) -> "AsyncContextManager": """Page.expose_binding The method adds a function called `name` on the `window` object of every frame in this page. When called, the @@ -9223,9 +9242,13 @@ async def main(): Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is supported. When passing by value, multiple arguments are supported. Deprecated: This option will be removed in the future. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.expose_binding( name=name, callback=self._wrap_handler(callback), handle=handle ) @@ -9760,7 +9783,7 @@ async def add_init_script( script: typing.Optional[str] = None, *, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, - ) -> None: + ) -> "AsyncContextManager": """Page.add_init_script Adds a script which would be evaluated in one of the following scenarios: @@ -9790,9 +9813,13 @@ async def add_init_script( path : Union[pathlib.Path, str, None] Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.add_init_script(script=script, path=path) ) @@ -9805,7 +9832,7 @@ async def route( ], *, times: typing.Optional[int] = None, - ) -> None: + ) -> "AsyncContextManager": """Page.route Routing provides the capability to modify network requests that are made by a page. @@ -9873,9 +9900,13 @@ async def handle_route(route: Route): handler function to route the request. times : Union[int, None] How often a route should be used. By default it will be used every time. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.route( url=self._wrap_handler(url), handler=self._wrap_handler(handler), @@ -13512,7 +13543,7 @@ async def add_init_script( script: typing.Optional[str] = None, *, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, - ) -> None: + ) -> "AsyncContextManager": """BrowserContext.add_init_script Adds a script which would be evaluated in one of the following scenarios: @@ -13542,9 +13573,13 @@ async def add_init_script( path : Union[pathlib.Path, str, None] Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.add_init_script(script=script, path=path) ) @@ -13554,7 +13589,7 @@ async def expose_binding( callback: typing.Callable, *, handle: typing.Optional[bool] = None, - ) -> None: + ) -> "AsyncContextManager": """BrowserContext.expose_binding The method adds a function called `name` on the `window` object of every frame in every page in the context. When @@ -13607,15 +13642,21 @@ async def main(): Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is supported. When passing by value, multiple arguments are supported. Deprecated: This option will be removed in the future. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.expose_binding( name=name, callback=self._wrap_handler(callback), handle=handle ) ) - async def expose_function(self, name: str, callback: typing.Callable) -> None: + async def expose_function( + self, name: str, callback: typing.Callable + ) -> "AsyncContextManager": """BrowserContext.expose_function The method adds a function called `name` on the `window` object of every frame in every page in the context. When @@ -13668,9 +13709,13 @@ async def main(): Name of the function on the window object. callback : Callable Callback function that will be called in the Playwright's context. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.expose_function( name=name, callback=self._wrap_handler(callback) ) @@ -13685,7 +13730,7 @@ async def route( ], *, times: typing.Optional[int] = None, - ) -> None: + ) -> "AsyncContextManager": """BrowserContext.route Routing provides the capability to modify network requests that are made by any page in the browser context. Once @@ -13747,9 +13792,13 @@ async def handle_route(route: Route): handler function to route the request. times : Union[int, None] How often a route should be used. By default it will be used every time. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.route( url=self._wrap_handler(url), handler=self._wrap_handler(handler), @@ -15882,7 +15931,7 @@ async def stop( async def group( self, name: str, *, location: typing.Optional[TracingGroupLocation] = None - ) -> None: + ) -> "AsyncContextManager": """Tracing.group **NOTE** Use `test.step` instead when available. @@ -15908,9 +15957,13 @@ async def group( location : Union[{file: str, line: Union[int, None], column: Union[int, None]}, None] Specifies a custom location for the group to be shown in the trace viewer. Defaults to the location of the `tracing.group()` call. + + Returns + ------- + AsyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( await self._impl_obj.group(name=name, location=location) ) @@ -21643,3 +21696,22 @@ async def not_to_be_ok(self) -> None: mapping.register(APIResponseAssertionsImpl, APIResponseAssertions) + + +class Disposable(AsyncContextManager): + + async def dispose(self) -> None: + """Disposable.dispose + + Removes the associated resource. For example, removes the init script installed via `page.add_init_script()` + or `browser_context.add_init_script()`. + """ + + return mapping.from_maybe_impl(await self._impl_obj.dispose()) + + async def close(self) -> None: + + return mapping.from_maybe_impl(await self._impl_obj.close()) + + +mapping.register(DisposableImpl, Disposable) diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index d7809f507..f7a8a2c24 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -56,6 +56,7 @@ from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._debugger import Debugger as DebuggerImpl from playwright._impl._dialog import Dialog as DialogImpl +from playwright._impl._disposable import Disposable as DisposableImpl from playwright._impl._download import Download as DownloadImpl from playwright._impl._element_handle import ElementHandle as ElementHandleImpl from playwright._impl._errors import Error @@ -7541,7 +7542,7 @@ def start( ] = None, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, quality: typing.Optional[int] = None, - ) -> None: + ) -> "SyncContextManager": """Screencast.start Starts the screencast. When `path` is provided, it saves video recording to the specified file. When `onFrame` is @@ -7557,9 +7558,13 @@ def start( Path where the video should be saved when the screencast is stopped. When provided, video recording is started. quality : Union[int, None] The quality of the image, between 0-100. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.start( onFrame=self._wrap_handler(on_frame), path=path, quality=quality @@ -7586,7 +7591,7 @@ def show_actions( ] ] = None, font_size: typing.Optional[int] = None, - ) -> None: + ) -> "SyncContextManager": """Screencast.show_actions Enables visual annotations on interacted elements. Returns a disposable that stops showing actions when disposed. @@ -7599,9 +7604,13 @@ def show_actions( Position of the action title overlay. Defaults to `"top-right"`. font_size : Union[int, None] Font size of the action title in pixels. Defaults to `24`. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.show_actions( duration=duration, position=position, fontSize=font_size @@ -7619,7 +7628,7 @@ def hide_actions(self) -> None: def show_overlay( self, html: str, *, duration: typing.Optional[float] = None - ) -> None: + ) -> "SyncContextManager": """Screencast.show_overlay Adds an overlay with the given HTML content. The overlay is displayed on top of the page until removed. Returns a @@ -7632,9 +7641,13 @@ def show_overlay( duration : Union[float, None] Duration in milliseconds after which the overlay is automatically removed. Overlay stays until dismissed if not provided. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync(self._impl_obj.show_overlay(html=html, duration=duration)) ) @@ -9097,7 +9110,9 @@ def add_style_tag( ) ) - def expose_function(self, name: str, callback: typing.Callable) -> None: + def expose_function( + self, name: str, callback: typing.Callable + ) -> "SyncContextManager": """Page.expose_function The method adds a function called `name` on the `window` object of every frame in the page. When called, the @@ -9148,9 +9163,13 @@ def run(playwright: Playwright): Name of the function on the window object callback : Callable Callback function which will be called in Playwright's context. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.expose_function( name=name, callback=self._wrap_handler(callback) @@ -9164,7 +9183,7 @@ def expose_binding( callback: typing.Callable, *, handle: typing.Optional[bool] = None, - ) -> None: + ) -> "SyncContextManager": """Page.expose_binding The method adds a function called `name` on the `window` object of every frame in this page. When called, the @@ -9216,9 +9235,13 @@ def run(playwright: Playwright): Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is supported. When passing by value, multiple arguments are supported. Deprecated: This option will be removed in the future. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.expose_binding( name=name, callback=self._wrap_handler(callback), handle=handle @@ -9767,7 +9790,7 @@ def add_init_script( script: typing.Optional[str] = None, *, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, - ) -> None: + ) -> "SyncContextManager": """Page.add_init_script Adds a script which would be evaluated in one of the following scenarios: @@ -9797,9 +9820,13 @@ def add_init_script( path : Union[pathlib.Path, str, None] Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync(self._impl_obj.add_init_script(script=script, path=path)) ) @@ -9812,7 +9839,7 @@ def route( ], *, times: typing.Optional[int] = None, - ) -> None: + ) -> "SyncContextManager": """Page.route Routing provides the capability to modify network requests that are made by a page. @@ -9880,9 +9907,13 @@ def handle_route(route: Route): handler function to route the request. times : Union[int, None] How often a route should be used. By default it will be used every time. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.route( url=self._wrap_handler(url), @@ -13512,7 +13543,7 @@ def add_init_script( script: typing.Optional[str] = None, *, path: typing.Optional[typing.Union[pathlib.Path, str]] = None, - ) -> None: + ) -> "SyncContextManager": """BrowserContext.add_init_script Adds a script which would be evaluated in one of the following scenarios: @@ -13542,9 +13573,13 @@ def add_init_script( path : Union[pathlib.Path, str, None] Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync(self._impl_obj.add_init_script(script=script, path=path)) ) @@ -13554,7 +13589,7 @@ def expose_binding( callback: typing.Callable, *, handle: typing.Optional[bool] = None, - ) -> None: + ) -> "SyncContextManager": """BrowserContext.expose_binding The method adds a function called `name` on the `window` object of every frame in every page in the context. When @@ -13604,9 +13639,13 @@ def run(playwright: Playwright): Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is supported. When passing by value, multiple arguments are supported. Deprecated: This option will be removed in the future. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.expose_binding( name=name, callback=self._wrap_handler(callback), handle=handle @@ -13614,7 +13653,9 @@ def run(playwright: Playwright): ) ) - def expose_function(self, name: str, callback: typing.Callable) -> None: + def expose_function( + self, name: str, callback: typing.Callable + ) -> "SyncContextManager": """BrowserContext.expose_function The method adds a function called `name` on the `window` object of every frame in every page in the context. When @@ -13664,9 +13705,13 @@ def run(playwright: Playwright): Name of the function on the window object. callback : Callable Callback function that will be called in the Playwright's context. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.expose_function( name=name, callback=self._wrap_handler(callback) @@ -13683,7 +13728,7 @@ def route( ], *, times: typing.Optional[int] = None, - ) -> None: + ) -> "SyncContextManager": """BrowserContext.route Routing provides the capability to modify network requests that are made by any page in the browser context. Once @@ -13746,9 +13791,13 @@ def handle_route(route: Route): handler function to route the request. times : Union[int, None] How often a route should be used. By default it will be used every time. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync( self._impl_obj.route( url=self._wrap_handler(url), @@ -15864,7 +15913,7 @@ def stop( def group( self, name: str, *, location: typing.Optional[TracingGroupLocation] = None - ) -> None: + ) -> "SyncContextManager": """Tracing.group **NOTE** Use `test.step` instead when available. @@ -15890,9 +15939,13 @@ def group( location : Union[{file: str, line: Union[int, None], column: Union[int, None]}, None] Specifies a custom location for the group to be shown in the trace viewer. Defaults to the location of the `tracing.group()` call. + + Returns + ------- + SyncContextManager """ - return mapping.from_maybe_impl( + return mapping.from_impl( self._sync(self._impl_obj.group(name=name, location=location)) ) @@ -21771,3 +21824,22 @@ def not_to_be_ok(self) -> None: mapping.register(APIResponseAssertionsImpl, APIResponseAssertions) + + +class Disposable(SyncContextManager): + + def dispose(self) -> None: + """Disposable.dispose + + Removes the associated resource. For example, removes the init script installed via `page.add_init_script()` + or `browser_context.add_init_script()`. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.dispose())) + + def close(self) -> None: + + return mapping.from_maybe_impl(self._sync(self._impl_obj.close())) + + +mapping.register(DisposableImpl, Disposable) diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index 2e2264b06..8016a601d 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -39,12 +39,18 @@ def __init__(self, is_async: bool) -> None: ) self.api = json.loads(process_output.stdout) self.errors: Set[str] = set() + self.class_aliases: Dict[str, str] = { + "Disposable": "AsyncContextManager" if is_async else "SyncContextManager", + "DisposableStub": ( + "AsyncContextManager" if is_async else "SyncContextManager" + ), + } self._patch_case() def _patch_case(self) -> None: self.classes = {} for clazz in self.api: - if not works_for_python(clazz): + if not works_for_python(clazz) and clazz["name"] not in self.class_aliases: continue members = {} self.classes[clazz["name"]] = clazz @@ -343,6 +349,11 @@ def compare_types( doc_type = self.make_optional(doc_type) if doc_type != code_type: + if ( + code_type in self.class_aliases + and doc_type == self.class_aliases[code_type] + ): + return self.errors.add( f"Parameter type mismatch in {fqname}: documented as {doc_type}, code has {code_type}" ) @@ -372,7 +383,10 @@ def serialize_python_type(self, value: Any, direction: str) -> str: if match and "_api_structures" not in str_value and "_errors" not in str_value: if match.group(1) == "EventContextManagerImpl": return "EventContextManager" - return match.group(1) + class_name = match.group(1) + if class_name in self.class_aliases: + return self.class_aliases[class_name] + return class_name match = re.match(r"^typing\.(\w+)$", str_value) if match: @@ -512,6 +526,8 @@ def inner_serialize_doc_type(self, type: Any, direction: str) -> str: return "None" if type_name == "EvaluationArgument": return "Dict" + if type_name in self.class_aliases: + return self.class_aliases[type_name] return type["name"] def print_remainder(self) -> None: diff --git a/scripts/expected_api_mismatch.txt b/scripts/expected_api_mismatch.txt index f47493440..cddb87f15 100644 --- a/scripts/expected_api_mismatch.txt +++ b/scripts/expected_api_mismatch.txt @@ -4,6 +4,9 @@ Parameter not documented: Browser.new_context(default_browser_type=) Parameter not documented: Browser.new_page(default_browser_type=) +# python-specific adapter for context manager support +Method not documented: Disposable.close + # One vs two arguments in the callback, Python explicitly unions. Parameter type mismatch in BrowserContext.route(handler=): documented as Callable[[Route, Request], Union[Any, Any]], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any]] Parameter type mismatch in BrowserContext.unroute(handler=): documented as Union[Callable[[Route, Request], Union[Any, Any]], None], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any], None] diff --git a/scripts/generate_api.py b/scripts/generate_api.py index eea3be7bd..9d217b7c5 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -31,6 +31,7 @@ from playwright._impl._console_message import ConsoleMessage from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable from playwright._impl._download import Download from playwright._impl._element_handle import ElementHandle from playwright._impl._fetch import APIRequest, APIRequestContext, APIResponse @@ -235,6 +236,7 @@ def return_value(value: Any) -> List[str]: from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._debugger import Debugger as DebuggerImpl from playwright._impl._dialog import Dialog as DialogImpl +from playwright._impl._disposable import Disposable as DisposableImpl from playwright._impl._download import Download as DownloadImpl from playwright._impl._element_handle import ElementHandle as ElementHandleImpl from playwright._impl._file_chooser import FileChooser as FileChooserImpl @@ -294,6 +296,7 @@ def return_value(value: Any) -> List[str]: PageAssertions, LocatorAssertions, APIResponseAssertions, + Disposable, ] all_types = generated_types + [ diff --git a/scripts/generate_async_api.py b/scripts/generate_async_api.py index ca52ef1a3..4c0b4b655 100755 --- a/scripts/generate_async_api.py +++ b/scripts/generate_async_api.py @@ -39,7 +39,7 @@ def generate(t: Any) -> None: print("") class_name = short_name(t) base_class = t.__bases__[0].__name__ - if class_name in ["Page", "BrowserContext", "Browser"]: + if class_name in ["Page", "BrowserContext", "Browser", "Disposable"]: base_sync_class = "AsyncContextManager" elif base_class in ["ChannelOwner", "object", "AssertionsBase"]: base_sync_class = "AsyncBase" @@ -88,6 +88,9 @@ def generate(t: Any) -> None: return_type_value = return_type_value.replace( "EventContextManager", "AsyncEventContextManager" ) + return_type_value = return_type_value.replace( + '"Disposable"', '"AsyncContextManager"' + ).replace('"DisposableStub"', '"AsyncContextManager"') print("") async_prefix = "async " if is_async else "" print( diff --git a/scripts/generate_sync_api.py b/scripts/generate_sync_api.py index 01d6cb02b..5ccf3b672 100755 --- a/scripts/generate_sync_api.py +++ b/scripts/generate_sync_api.py @@ -40,7 +40,7 @@ def generate(t: Any) -> None: print("") class_name = short_name(t) base_class = t.__bases__[0].__name__ - if class_name in ["Page", "BrowserContext", "Browser"]: + if class_name in ["Page", "BrowserContext", "Browser", "Disposable"]: base_sync_class = "SyncContextManager" elif base_class in ["ChannelOwner", "object", "AssertionsBase"]: base_sync_class = "SyncBase" @@ -86,6 +86,9 @@ def generate(t: Any) -> None: is_async = inspect.iscoroutinefunction(value) return_type_value = return_type(value) return_type_value = re.sub(r"\"([^\"]+)Impl\"", r"\1", return_type_value) + return_type_value = return_type_value.replace( + '"Disposable"', '"SyncContextManager"' + ).replace('"DisposableStub"', '"SyncContextManager"') print("") print( f" def {name}({signature(value, len(name) + 9)}) -> {return_type_value}:" diff --git a/tests/async/test_add_init_script.py b/tests/async/test_add_init_script.py index 33853780a..7dbae8aba 100644 --- a/tests/async/test_add_init_script.py +++ b/tests/async/test_add_init_script.py @@ -89,3 +89,13 @@ async def test_should_work_with_trailing_comments(page: Page) -> None: await page.add_init_script("window.secret = 42;") await page.goto("data:text/html,") assert await page.evaluate("secret") == 42 + + +async def test_add_init_script_returns_disposable(page: Page) -> None: + async with await page.add_init_script("window.injected = 123"): + await page.goto( + "data:text/html," + ) + assert await page.evaluate("window.result") == 123 + await page.goto("data:text/html,") + assert await page.evaluate("window.result") is None diff --git a/tests/async/test_page_route.py b/tests/async/test_page_route.py index 848a95045..ecfac7f73 100644 --- a/tests/async/test_page_route.py +++ b/tests/async/test_page_route.py @@ -1246,3 +1246,18 @@ async def handle_question_mark(route: Route) -> None: await page.goto(server.PREFIX + "/index123hello") await expect(page.locator("body")).to_have_text("index123hello") + + +async def test_page_route_returns_disposable(page: Page, server: Server) -> None: + intercepted: List[str] = [] + + async def handler(route: Route) -> None: + intercepted.append(route.request.url) + await route.continue_() + + async with await page.route("**/*", handler): + await page.goto(server.EMPTY_PAGE) + assert any(server.EMPTY_PAGE in url for url in intercepted) + intercepted.clear() + await page.goto(server.EMPTY_PAGE) + assert intercepted == [] diff --git a/tests/sync/test_add_init_script.py b/tests/sync/test_add_init_script.py index e17fc5e8b..cd508cf4d 100644 --- a/tests/sync/test_add_init_script.py +++ b/tests/sync/test_add_init_script.py @@ -85,3 +85,11 @@ def test_should_work_with_trailing_comments(page: Page) -> None: page.add_init_script("window.secret = 42;") page.goto("data:text/html,") assert page.evaluate("secret") == 42 + + +def test_add_init_script_returns_disposable(page: Page) -> None: + with page.add_init_script("window.injected = 123"): + page.goto("data:text/html,") + assert page.evaluate("window.result") == 123 + page.goto("data:text/html,") + assert page.evaluate("window.result") is None diff --git a/tests/sync/test_unroute_behavior.py b/tests/sync/test_unroute_behavior.py index 12ae9e22d..f37146582 100644 --- a/tests/sync/test_unroute_behavior.py +++ b/tests/sync/test_unroute_behavior.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from playwright.sync_api import BrowserContext, Page +from playwright.sync_api import BrowserContext, Page, Route from tests.server import Server from tests.utils import must @@ -44,3 +44,18 @@ def test_page_unroute_all_removes_all_routes(page: Page, server: Server) -> None page.unroute_all() response = must(page.goto(server.EMPTY_PAGE)) assert response.ok + + +def test_page_route_returns_disposable(page: Page, server: Server) -> None: + intercepted: list = [] + + def handler(route: Route) -> None: + intercepted.append(route.request.url) + route.continue_() + + with page.route("**/*", handler): + page.goto(server.EMPTY_PAGE) + assert any(server.EMPTY_PAGE in url for url in intercepted) + intercepted.clear() + page.goto(server.EMPTY_PAGE) + assert intercepted == []