diff --git a/playwright/_impl/_impl_to_api_mapping.py b/playwright/_impl/_impl_to_api_mapping.py index 8b3cbd756..3aa01514a 100644 --- a/playwright/_impl/_impl_to_api_mapping.py +++ b/playwright/_impl/_impl_to_api_mapping.py @@ -17,6 +17,7 @@ from playwright._impl._errors import Error from playwright._impl._map import Map +from playwright._impl._signature import signature API_ATTR = "_pw_api_instance_" IMPL_ATTR = "_pw_impl_instance_" @@ -119,7 +120,7 @@ def to_impl( def wrap_handler(self, handler: Callable[..., Any]) -> Callable[..., None]: def wrapper_func(*args: Any) -> Any: - parameters = inspect.signature(handler).parameters + parameters = signature(handler).parameters has_varargs = any( parameter.kind == inspect.Parameter.VAR_POSITIONAL for parameter in parameters.values() diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 21ae85645..d75454e0d 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -101,6 +101,7 @@ serialize_headers, ) from playwright._impl._screencast import Screencast +from playwright._impl._signature import signature from playwright._impl._video import Video from playwright._impl._waiter import Waiter from playwright._impl._web_storage import WebStorage @@ -125,7 +126,7 @@ def __init__( self.times = times def __call__(self) -> Any: - arg_count = len(inspect.signature(self._handler).parameters) + arg_count = len(signature(self._handler).parameters) if arg_count == 0: return self._handler() return self._handler(self.locator) diff --git a/playwright/_impl/_signature.py b/playwright/_impl/_signature.py new file mode 100644 index 000000000..adba677f2 --- /dev/null +++ b/playwright/_impl/_signature.py @@ -0,0 +1,32 @@ +# 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 inspect +import sys +from typing import Any, Callable + +if sys.version_info < (3, 14): + + def signature(fn: Callable[..., Any]) -> inspect.Signature: + return inspect.signature(fn) + +else: + # PEP 649 made annotations lazily evaluated, and inspect.signature() + # evaluates them eagerly by default. Handlers annotated with names that + # only exist under `if TYPE_CHECKING:` would raise NameError while all we + # need is the parameter list, so ask for unresolved forward references. + from annotationlib import Format + + def signature(fn: Callable[..., Any]) -> inspect.Signature: + return inspect.signature(fn, annotation_format=Format.FORWARDREF) diff --git a/tests/async/test_console.py b/tests/async/test_console.py index 737144412..c46fbe1bd 100644 --- a/tests/async/test_console.py +++ b/tests/async/test_console.py @@ -12,13 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List +import sys +from typing import TYPE_CHECKING, List import pytest from playwright.async_api import ConsoleMessage, Page from tests.server import Server +if TYPE_CHECKING: + + class Unresolvable: + """Annotation-only type, deliberately undefined at runtime.""" + async def test_console_should_work(page: Page, browser_name: str) -> None: messages: List[ConsoleMessage] = [] @@ -153,3 +159,22 @@ async def test_console_should_not_throw_when_there_are_console_messages_in_detac popup = await page_info.value # 4. Connect to the popup and make sure it doesn't throw. assert await popup.evaluate("1 + 1") == 2 + + +@pytest.mark.skipif( + sys.version_info < (3, 14), + reason="PEP 649 lazy annotations require Python 3.14+", +) +async def test_console_should_support_annotations_with_runtime_unresolved_types( + page: Page, +) -> None: + # Since PEP 649, annotations are lazily evaluated; the signature + # inspection done for event listeners must not resolve them eagerly. + messages: List[str] = [] + + def on_console(message: Unresolvable) -> None: + messages.append(message.text) + + page.on("console", on_console) + await page.evaluate('() => console.log("hello")') + assert messages == ["hello"] diff --git a/tests/async/test_page_add_locator_handler.py b/tests/async/test_page_add_locator_handler.py index ba798692e..852a286de 100644 --- a/tests/async/test_page_add_locator_handler.py +++ b/tests/async/test_page_add_locator_handler.py @@ -13,6 +13,8 @@ # limitations under the License. import asyncio +import sys +from typing import TYPE_CHECKING import pytest @@ -20,6 +22,11 @@ from tests.server import Server from tests.utils import TARGET_CLOSED_ERROR_MESSAGE +if TYPE_CHECKING: + + class Unresolvable: + """Annotation-only type, deliberately undefined at runtime.""" + async def test_should_work(page: Page, server: Server) -> None: await page.goto(server.PREFIX + "/input/handle-locator.html") @@ -437,3 +444,31 @@ async def _handler(locator: Locator) -> None: assert await page.evaluate("window.clicked") == 0 await expect(page.locator("#interstitial")).to_be_visible() assert "Timeout 3000ms exceeded" in error.value.message + + +@pytest.mark.skipif( + sys.version_info < (3, 14), + reason="PEP 649 lazy annotations require Python 3.14+", +) +async def test_should_support_annotations_with_runtime_unresolved_types( + page: Page, server: Server +) -> None: + # Since PEP 649, annotations are lazily evaluated; the signature + # inspection done for locator handlers must not resolve them eagerly. + await page.goto(server.PREFIX + "/input/handle-locator.html") + + original_locator = page.get_by_text("This interstitial covers the button") + called = 0 + + async def handler(locator: Unresolvable) -> None: + nonlocal called + called += 1 + assert locator == original_locator + await page.locator("#close").click() + + await page.add_locator_handler(original_locator, handler) + await page.locator("#aside").hover() + await page.evaluate('() => window.setupAnnoyingInterstitial("mouseover", 1)') + await page.locator("#target").click() + assert called == 1 + assert await page.evaluate("window.clicked") == 1 diff --git a/tests/sync/test_console.py b/tests/sync/test_console.py index 8a1f3a8fb..1f2ad09b6 100644 --- a/tests/sync/test_console.py +++ b/tests/sync/test_console.py @@ -12,13 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List +import sys +from typing import TYPE_CHECKING, List import pytest from playwright.sync_api import ConsoleMessage, Page from tests.server import Server +if TYPE_CHECKING: + + class Unresolvable: + """Annotation-only type, deliberately undefined at runtime.""" + def test_console_should_work(page: Page, browser_name: str) -> None: messages: List[ConsoleMessage] = [] @@ -157,3 +163,22 @@ def test_console_should_not_throw_when_there_are_console_messages_in_detached_if ) # 4. Connect to the popup and make sure it doesn't throw. assert popup.value.evaluate("1 + 1") == 2 + + +@pytest.mark.skipif( + sys.version_info < (3, 14), + reason="PEP 649 lazy annotations require Python 3.14+", +) +def test_console_should_support_annotations_with_runtime_unresolved_types( + page: Page, +) -> None: + # Since PEP 649, annotations are lazily evaluated; the signature + # inspection done for event listeners must not resolve them eagerly. + messages: List[str] = [] + + def on_console(message: Unresolvable) -> None: + messages.append(message.text) + + page.on("console", on_console) + page.evaluate('() => console.log("hello")') + assert messages == ["hello"] diff --git a/tests/sync/test_page_add_locator_handler.py b/tests/sync/test_page_add_locator_handler.py index 047b4a775..9d1479ae0 100644 --- a/tests/sync/test_page_add_locator_handler.py +++ b/tests/sync/test_page_add_locator_handler.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys +from typing import TYPE_CHECKING import pytest @@ -19,6 +21,11 @@ from tests.server import Server from tests.utils import TARGET_CLOSED_ERROR_MESSAGE +if TYPE_CHECKING: + + class Unresolvable: + """Annotation-only type, deliberately undefined at runtime.""" + def test_should_work(page: Page, server: Server) -> None: page.goto(server.PREFIX + "/input/handle-locator.html") @@ -433,3 +440,31 @@ def _handler(locator: Locator) -> None: assert page.evaluate("window.clicked") == 0 expect(page.locator("#interstitial")).to_be_visible() assert "Timeout 3000ms exceeded" in error.value.message + + +@pytest.mark.skipif( + sys.version_info < (3, 14), + reason="PEP 649 lazy annotations require Python 3.14+", +) +def test_should_support_annotations_with_runtime_unresolved_types( + page: Page, server: Server +) -> None: + # Since PEP 649, annotations are lazily evaluated; the signature + # inspection done for locator handlers must not resolve them eagerly. + page.goto(server.PREFIX + "/input/handle-locator.html") + + original_locator = page.get_by_text("This interstitial covers the button") + called = 0 + + def handler(locator: Unresolvable) -> None: + nonlocal called + called += 1 + assert locator == original_locator + page.locator("#close").click() + + page.add_locator_handler(original_locator, handler) + page.locator("#aside").hover() + page.evaluate('() => window.setupAnnoyingInterstitial("mouseover", 1)') + page.locator("#target").click() + assert called == 1 + assert page.evaluate("window.clicked") == 1