Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H

| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->141.0.7390.37<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->143.0.7499.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.0<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->142.0.1<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->144.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |

## Documentation

Expand Down
69 changes: 0 additions & 69 deletions playwright/_impl/_accessibility.py

This file was deleted.

5 changes: 4 additions & 1 deletion playwright/_impl/_browser_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,10 +688,13 @@ def _on_request_finished(

def _on_console_message(self, event: Dict) -> None:
message = ConsoleMessage(event, self._loop, self._dispatcher_fiber)
self.emit(BrowserContext.Events.Console, message)
worker = message.worker
if worker:
worker.emit(Worker.Events.Console, message)
page = message.page
if page:
page.emit(Page.Events.Console, message)
self.emit(BrowserContext.Events.Console, message)

def _on_dialog(self, dialog: Dialog) -> None:
has_listeners = self.emit(BrowserContext.Events.Dialog, dialog)
Expand Down
6 changes: 6 additions & 0 deletions playwright/_impl/_console_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

if TYPE_CHECKING: # pragma: no cover
from playwright._impl._page import Page
from playwright._impl._worker import Worker


class ConsoleMessage:
Expand All @@ -31,6 +32,7 @@ def __init__(
self._loop = loop
self._dispatcher_fiber = dispatcher_fiber
self._page: Optional["Page"] = from_nullable_channel(event.get("page"))
self._worker: Optional["Worker"] = from_nullable_channel(event.get("worker"))

def __repr__(self) -> str:
return f"<ConsoleMessage type={self.type} text={self.text}>"
Expand Down Expand Up @@ -76,3 +78,7 @@ def location(self) -> SourceLocation:
@property
def page(self) -> Optional["Page"]:
return self._page

@property
def worker(self) -> Optional["Worker"]:
return self._worker
2 changes: 2 additions & 0 deletions playwright/_impl/_element_handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async def click(
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send(
"click", self._frame._timeout, locals_to_params(locals())
Expand All @@ -153,6 +154,7 @@ async def dblclick(
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send(
"dblclick", self._frame._timeout, locals_to_params(locals())
Expand Down
18 changes: 18 additions & 0 deletions playwright/_impl/_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,23 @@ async def click(
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
await self._click(**locals_to_params(locals()))

async def _click(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unfortunate!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true that! but without this separation, we'd have a frame.click(steps=) param

self,
selector: str,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
delay: float = None,
button: MouseButton = None,
clickCount: int = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send("click", self._timeout, locals_to_params(locals()))

Expand Down Expand Up @@ -734,6 +751,7 @@ async def drag_and_drop(
strict: bool = None,
timeout: float = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send(
"dragAndDrop", self._timeout, locals_to_params(locals())
Expand Down
11 changes: 10 additions & 1 deletion playwright/_impl/_glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,21 @@ def glob_to_regex_pattern(glob: str) -> str:
tokens.append("\\" + char if char in escaped_chars else char)
i += 1
elif c == "*":
char_before = glob[i - 1] if i > 0 else None
star_count = 1
while i + 1 < len(glob) and glob[i + 1] == "*":
star_count += 1
i += 1
if star_count > 1:
tokens.append("(.*)")
char_after = glob[i + 1] if i + 1 < len(glob) else None
if char_after == "/":
if char_before == "/":
tokens.append("((.+/)|)")
else:
tokens.append("(.*/)")
i += 1
else:
tokens.append("(.*)")
else:
tokens.append("([^/]*)")
else:
Expand Down
68 changes: 33 additions & 35 deletions playwright/_impl/_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
Union,
cast,
)
from urllib.parse import urljoin, urlparse
from urllib.parse import ParseResult, urljoin, urlparse, urlunparse

from playwright._impl._api_structures import NameValue
from playwright._impl._errors import (
Expand Down Expand Up @@ -210,8 +210,12 @@ def map_token(original: str, replacement: str) -> str:
# Handle special case of http*://, note that the new schema has to be
# a web schema so that slashes are properly inserted after domain.
if index == 0 and token.endswith(":"):
# Using a simple replacement for the scheme part
processed_parts.append(map_token(token, "http:"))
# Replace any pattern with http:
if "*" in token or "{" in token:
processed_parts.append(map_token(token, "http:"))
else:
# Preserve explicit schema as is as it may affect trailing slashes after domain.
processed_parts.append(token)
continue
question_index = token.find("?")
if question_index == -1:
Expand All @@ -222,55 +226,49 @@ def map_token(original: str, replacement: str) -> str:
processed_parts.append(new_prefix + new_suffix)

relative_path = "/".join(processed_parts)
resolved_url, case_insensitive_part = resolve_base_url(base_url, relative_path)
resolved, case_insensitive_part = resolve_base_url(base_url, relative_path)

for replacement, original in token_map.items():
normalize = case_insensitive_part and replacement in case_insensitive_part
resolved_url = resolved_url.replace(
replacement, original.lower() if normalize else original, 1
for token, original in token_map.items():
normalize = case_insensitive_part and token in case_insensitive_part
resolved = resolved.replace(
token, original.lower() if normalize else original, 1
)

return ensure_trailing_slash(resolved_url)
return resolved


def resolve_base_url(
base_url: Optional[str], given_url: str
) -> Tuple[str, Optional[str]]:
try:
resolved = urljoin(base_url if base_url is not None else "", given_url)
parsed = urlparse(resolved)
url = nodelike_urlparse(
urljoin(base_url if base_url is not None else "", given_url)
)
resolved = urlunparse(url)
# Schema and domain are case-insensitive.
hostname_port = (
parsed.hostname or ""
url.hostname or ""
) # can't use parsed.netloc because it includes userinfo (username:password)
if parsed.port:
hostname_port += f":{parsed.port}"
case_insensitive_prefix = f"{parsed.scheme}://{hostname_port}"
if url.port:
hostname_port += f":{url.port}"
case_insensitive_prefix = f"{url.scheme}://{hostname_port}"
return resolved, case_insensitive_prefix
except Exception:
return given_url, None


# In Node.js, new URL('http://localhost') returns 'http://localhost/'.
# To ensure the same url matching behavior, do the same.
def ensure_trailing_slash(url: str) -> str:
split = url.split("://", maxsplit=1)
if len(split) == 2:
# URL parser doesn't like strange/unknown schemes, so we replace it for parsing, then put it back
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this comment incorrect? Could it be python-version-specific?

Copy link
Member Author

@Skn0tt Skn0tt Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it was incorrect. Certainly not version specific, urlparse didn't change much in the past years: https://github.com/python/cpython/blob/3.14/Lib/urllib/parse.py

The implementation definitely wasn't correct for the my.custom.protocol test cases. And I figured that instead of trying to understand its behaviour, I just implement the WHATWG spec.

parsable_url = "http://" + split[1]
else:
# Given current rules, this should never happen _and_ still be a valid matcher. We require the protocol to be part of the match,
# so either the user is using a glob that starts with "*" (and none of this code is running), or the user actually has `something://` in `match`
parsable_url = url
parsed = urlparse(parsable_url, allow_fragments=True)
if len(split) == 2:
# Replace the scheme that we removed earlier
parsed = parsed._replace(scheme=split[0])
if parsed.path == "":
parsed = parsed._replace(path="/")
url = parsed.geturl()

return url
def nodelike_urlparse(url: str) -> ParseResult:
parsed = urlparse(url, allow_fragments=True)

# https://url.spec.whatwg.org/#special-scheme
is_special_url = parsed.scheme in ["http", "https", "ws", "wss", "ftp", "file"]
if is_special_url:
# special urls have a list path, list paths are serialized as follows: https://url.spec.whatwg.org/#url-path-serializer
# urllib diverges, so we patch it here
if parsed.path == "":
parsed = parsed._replace(path="/")

return parsed


class HarLookupResult(TypedDict, total=False):
Expand Down
20 changes: 19 additions & 1 deletion playwright/_impl/_locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import json
import pathlib
import re
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -155,9 +156,10 @@ async def click(
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.click(self._selector, strict=True, **params)
return await self._frame._click(self._selector, strict=True, **params)

async def dblclick(
self,
Expand All @@ -169,6 +171,7 @@ async def dblclick(
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.dblclick(self._selector, strict=True, **params)
Expand Down Expand Up @@ -343,6 +346,20 @@ def describe(self, description: str) -> "Locator":
f"{self._selector} >> internal:describe={json.dumps(description)}",
)

@property
def description(self) -> Optional[str]:
try:
match = re.search(
r' >> internal:describe=("(?:[^"\\]|\\.)*")$', self._selector
)
if match:
description = json.loads(match.group(1))
if isinstance(description, str):
return description
except (json.JSONDecodeError, ValueError):
pass
return None

def filter(
self,
hasText: Union[str, Pattern[str]] = None,
Expand Down Expand Up @@ -414,6 +431,7 @@ async def drag_to(
trial: bool = None,
sourcePosition: Position = None,
targetPosition: Position = None,
steps: int = None,
) -> None:
params = locals_to_params(locals())
del params["target"]
Expand Down
Loading
Loading