From 6cb5c7871ae446f881ef24231c6297afd89ae5bf Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Wed, 23 Sep 2026 00:59:10 +0600 Subject: [PATCH] fix(handlers): do not evaluate handler annotations in inspect.signature on Python 3.14 Since PEP 649 (Python 3.14), annotations are lazily evaluated and inspect.signature() resolves them eagerly by default. Event listeners and locator handlers annotated with names that only exist under `if TYPE_CHECKING:` therefore raised NameError when the signature was inspected, even though only the parameter list is needed. Ask for unresolved forward references instead. Fixes: https://github.com/microsoft/playwright/issues/42857 --- playwright/_impl/_impl_to_api_mapping.py | 3 +- playwright/_impl/_page.py | 3 +- playwright/_impl/_signature.py | 32 ++++++++++++++++++ tests/async/test_console.py | 27 ++++++++++++++- tests/async/test_page_add_locator_handler.py | 35 ++++++++++++++++++++ tests/sync/test_console.py | 27 ++++++++++++++- tests/sync/test_page_add_locator_handler.py | 35 ++++++++++++++++++++ 7 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 playwright/_impl/_signature.py 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