From 6cb5c7871ae446f881ef24231c6297afd89ae5bf Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Wed, 23 Sep 2026 00:59:10 +0600 Subject: [PATCH 1/2] 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 From 6e6d2c2a20046376730e8f69265cf881c985d044 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Thu, 24 Sep 2026 00:23:40 +0600 Subject: [PATCH 2/2] test(handlers): use TYPE_CHECKING-only aliases for unresolved annotations Replace the Unresolvable placeholder with the real handler types imported under `if TYPE_CHECKING:` and aliased, e.g. ConsoleMessage as TypeCheckingOnlyConsoleMessage. The alias stays undefined at runtime (still exercising PEP 649 lazy annotations) while mypy and pyright accept the handler signatures. --- tests/async/test_console.py | 7 +++---- tests/async/test_page_add_locator_handler.py | 7 +++---- tests/sync/test_console.py | 7 +++---- tests/sync/test_page_add_locator_handler.py | 7 +++---- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/async/test_console.py b/tests/async/test_console.py index c46fbe1bd..2bc0a8a7e 100644 --- a/tests/async/test_console.py +++ b/tests/async/test_console.py @@ -21,9 +21,8 @@ from tests.server import Server if TYPE_CHECKING: - - class Unresolvable: - """Annotation-only type, deliberately undefined at runtime.""" + # Real type for static checkers, undefined at runtime (PEP 649 case). + from playwright.async_api import ConsoleMessage as TypeCheckingOnlyConsoleMessage async def test_console_should_work(page: Page, browser_name: str) -> None: @@ -172,7 +171,7 @@ async def test_console_should_support_annotations_with_runtime_unresolved_types( # inspection done for event listeners must not resolve them eagerly. messages: List[str] = [] - def on_console(message: Unresolvable) -> None: + def on_console(message: TypeCheckingOnlyConsoleMessage) -> None: messages.append(message.text) page.on("console", on_console) diff --git a/tests/async/test_page_add_locator_handler.py b/tests/async/test_page_add_locator_handler.py index 852a286de..6f1ba3945 100644 --- a/tests/async/test_page_add_locator_handler.py +++ b/tests/async/test_page_add_locator_handler.py @@ -23,9 +23,8 @@ from tests.utils import TARGET_CLOSED_ERROR_MESSAGE if TYPE_CHECKING: - - class Unresolvable: - """Annotation-only type, deliberately undefined at runtime.""" + # Real type for static checkers, undefined at runtime (PEP 649 case). + from playwright.async_api import Locator as TypeCheckingOnlyLocator async def test_should_work(page: Page, server: Server) -> None: @@ -460,7 +459,7 @@ async def test_should_support_annotations_with_runtime_unresolved_types( original_locator = page.get_by_text("This interstitial covers the button") called = 0 - async def handler(locator: Unresolvable) -> None: + async def handler(locator: TypeCheckingOnlyLocator) -> None: nonlocal called called += 1 assert locator == original_locator diff --git a/tests/sync/test_console.py b/tests/sync/test_console.py index 1f2ad09b6..60680a46b 100644 --- a/tests/sync/test_console.py +++ b/tests/sync/test_console.py @@ -21,9 +21,8 @@ from tests.server import Server if TYPE_CHECKING: - - class Unresolvable: - """Annotation-only type, deliberately undefined at runtime.""" + # Real type for static checkers, undefined at runtime (PEP 649 case). + from playwright.sync_api import ConsoleMessage as TypeCheckingOnlyConsoleMessage def test_console_should_work(page: Page, browser_name: str) -> None: @@ -176,7 +175,7 @@ def test_console_should_support_annotations_with_runtime_unresolved_types( # inspection done for event listeners must not resolve them eagerly. messages: List[str] = [] - def on_console(message: Unresolvable) -> None: + def on_console(message: TypeCheckingOnlyConsoleMessage) -> None: messages.append(message.text) page.on("console", on_console) diff --git a/tests/sync/test_page_add_locator_handler.py b/tests/sync/test_page_add_locator_handler.py index 9d1479ae0..b16aca9f4 100644 --- a/tests/sync/test_page_add_locator_handler.py +++ b/tests/sync/test_page_add_locator_handler.py @@ -22,9 +22,8 @@ from tests.utils import TARGET_CLOSED_ERROR_MESSAGE if TYPE_CHECKING: - - class Unresolvable: - """Annotation-only type, deliberately undefined at runtime.""" + # Real type for static checkers, undefined at runtime (PEP 649 case). + from playwright.sync_api import Locator as TypeCheckingOnlyLocator def test_should_work(page: Page, server: Server) -> None: @@ -456,7 +455,7 @@ def test_should_support_annotations_with_runtime_unresolved_types( original_locator = page.get_by_text("This interstitial covers the button") called = 0 - def handler(locator: Unresolvable) -> None: + def handler(locator: TypeCheckingOnlyLocator) -> None: nonlocal called called += 1 assert locator == original_locator