Skip to content

Commit 4e1ce7a

Browse files
authored
feat(v2): add the sync API on top of playwright/_impl_v2 (#3277)
1 parent d0aaf3c commit 4e1ce7a

57 files changed

Lines changed: 531 additions & 168 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ jobs:
127127

128128
build-v2:
129129
# playwright/_impl_v2, the line-by-line port of the JS client, behind PW_IMPL=v2.
130-
# It has only the async API, and runs all of tests/async: the older tests, the
131-
# ports of the upstream tests and the Python-only tests of v2.
130+
# It runs all of tests/sync and tests/async: the older tests, the ports of the
131+
# upstream tests and the Python-only tests of v2.
132132
name: Build (v2)
133133
timeout-minutes: 45
134134
strategy:
@@ -152,6 +152,8 @@ jobs:
152152
pip install -e .
153153
python -m build --wheel
154154
python -m playwright install --with-deps ${{ matrix.browser }}
155+
- name: Test Sync API
156+
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
155157
- name: Test Async API
156158
run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
157159

‎AGENTS.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk
99
## Layout
1010

1111
- `playwright/_impl/` — hand-written client implementation (one module per object: `_browser.py`, `_page.py`, `_locator.py`, `_network.py`, etc.). Edit these to add or change behavior. Internal types are in `_internal_structures.py`.
12-
- `playwright/_impl_v2/` — the second implementation, a line-by-line port of upstream's JS client (`packages/playwright-core/src/client/*.ts`), one module per client file in snake_case (`browserContext.ts` is `browser_context.py`), members in the JS order. It has only the async API; `PW_IMPL=v2` switches the async API to it (`playwright/_impl/_api_classes.py`). Each difference from the JS code has a `# Python:` comment; see its `__init__.py` for the rules. `isomorphic/`, `protocol/` and `utils/` hold its own ports of the parts of `packages/isomorphic`, `packages/protocol` and `packages/utils` that it uses; from `playwright/_impl` it uses only the public error classes, `FormData`, the generated API types, the driver location and the protocol check. `protocol_channels.py`, `protocol_types.py` and `protocol/validator.py` are generated by `scripts/codegen/generate_protocol.py`.
12+
- `playwright/_impl_v2/` — the second implementation, a line-by-line port of upstream's JS client (`packages/playwright-core/src/client/*.ts`), one module per client file in snake_case (`browserContext.ts` is `browser_context.py`), members in the JS order. `PW_IMPL=v2` switches the async and the sync API to it (`playwright/_impl/_api_classes.py`, `playwright/{async,sync}_api/_context_manager.py`); the sync API is the greenlet mechanism of v1, shared (`playwright/_impl/_sync_base.py`, `playwright/_impl/_greenlets.py`). Each difference from the JS code has a `# Python:` comment; see its `__init__.py` for the rules. `isomorphic/`, `protocol/` and `utils/` hold its own ports of the parts of `packages/isomorphic`, `packages/protocol` and `packages/utils` that it uses; from `playwright/_impl` it uses only the public error classes, `FormData`, the generated API types, the driver location, the protocol check and the greenlets of the sync API. `protocol_channels.py`, `protocol_types.py` and `protocol/validator.py` are generated by `scripts/codegen/generate_protocol.py`.
1313
- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py`, and the public TypedDicts in `playwright/_impl/_api_structures.py` — **auto-generated** from the upstream API docs. Never edit by hand; rerun `./scripts/update_api.sh` after a roll or after changing `scripts/codegen/`.
1414
- `scripts/codegen/generate_api.js` — the generator. A Node.js script that loads the upstream doc parser (`utils/doclint/api_parser.js`) from `$PW_SRC_DIR`, filters the docs for Python, and writes the whole public API: signatures, types, docstrings, event overloads, the calls into `_impl/`, and the TypedDicts. It does not read `_impl/`.
1515
- `scripts/codegen/python_rules.js` — the Python facts that the docs do not contain (positional options, struct `total=`, hidden members and options). Policy: the shipped Python API wins; each entry is a candidate for an upstream docs fix, and the generator fails on entries that no longer match anything. Every entry needs a one-line rationale comment above it.
@@ -18,7 +18,7 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk
1818
- `_protocol_types.py` has the TypedDicts for initializers, results, events and nested types, named as upstream's `utils/generate_channels.js` names them (`PageInitializer`, `FrameGotoResult`, `PageRouteEvent`, …).
1919
- `_protocol.py` has the parameters of every command. With `PW_CHECK_PROTOCOL=1`, `playwright/_impl/_protocol_check.py` checks each message that the client sends against it. The test suite sets this, and fails a test that sends an unknown command, an unknown or missing parameter, or a value of the wrong type.
2020
- `scripts/codegen/compare_api.py` — compares two generated files (signatures, annotations, call bodies, docstrings) and lists every difference. Use it to check that a generator change does not change the API.
21-
- `tests/async/`, `tests/sync/` — pytest suites, ported test for test from the upstream tests, which `tests/upstream_registry.md` lists by identifier. See [Tests](#tests). `playwright/_impl` (v1, the default) runs the older tests in `tests/{async,sync}/legacy/` and the other tests outside of `tests/async` and `tests/sync`; `PW_IMPL=v2` runs `playwright/_impl_v2` on all of `tests/async` (`tests/conftest.py`). CI runs v2 on Ubuntu 22.04 with Chromium, Firefox and WebKit. An older test that checks a behavior where v2 follows upstream and v1 does not is marked `@only_v1("<tests of v2>")` (`tests/utils.py`), naming the upstream port that covers v2, or the Python-only test of v2 in `tests/async/python/`, which v1 does not run.
21+
- `tests/async/`, `tests/sync/` — pytest suites, ported test for test from the upstream tests, which `tests/upstream_registry.md` lists by identifier. See [Tests](#tests). `playwright/_impl` (v1, the default) runs the older tests in `tests/{async,sync}/legacy/` and the other tests outside of `tests/async` and `tests/sync`; `PW_IMPL=v2` runs `playwright/_impl_v2` on all of `tests/async` and `tests/sync` (`tests/conftest.py`). CI runs v2 on Ubuntu 22.04 with Chromium, Firefox and WebKit. An older test that checks a behavior where v2 follows upstream and v1 does not is marked `@only_v1("<tests of v2>")` (`tests/utils.py`), naming the upstream port that covers v2, or the Python-only test of v2 in `tests/async/python/`, which v1 does not run.
2222
- `DRIVER_VERSION` — the single source of truth for which Playwright build the driver is assembled from. Two lines: `npm=<version>`, the `playwright-core` npm version (e.g. `npm=1.61.0`, no `v` prefix), and `rev=<sha>`, the `microsoft/playwright` commit that the package is built from (`npm view playwright-core@<version> gitHead`). Read by `setup.py`, `scripts/build_driver.py`, `scripts/update_api.sh`, and CI (through `setup.py`). The wheel build downloads `playwright-core` at this version from npm plus the matching Node.js binary and assembles the per-platform bundles — no source build. The version is baked into the staged bundle filenames (`driver/playwright-<version>-<suffix>.zip`), so it doubles as the build cache key.
2323
- `NODE_VERSION` — the Node.js version bundled with the driver (one line, e.g. `24.16.0`). Maintained at roll time by `scripts/update_node_version.py` (latest LTS, mirroring upstream's `utils/build/update-playwright-node.mjs`).
2424
- `scripts/build_driver.py` — assembles the per-platform driver bundles into `driver/` by downloading the `playwright-core` npm package (`DRIVER_VERSION`) and the official Node.js binaries (`NODE_VERSION`). Fetches `playwright-core` with `npm pack` (needs Node.js/npm on PATH; honours a root `.npmrc`) and the Node.js binaries over plain HTTP; invoked from `setup.py`'s `bdist_wheel` with the target platform's suffix (no arg builds all six).
@@ -35,17 +35,16 @@ pip install --upgrade pip
3535
pip install -r local-requirements.txt
3636
pip install -e .
3737
python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js and assembles the driver
38-
pre-commit install
3938
```
4039

4140
If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (then `uv pip install --python env/bin/python --upgrade pip`).
4241

4342
## Common commands
4443

4544
- Regenerate `_generated.py`: `./scripts/update_api.sh` (runs codegen + pre-commit on the generated files).
46-
- Lint everything: `pre-commit run --all-files`.
45+
- Lint everything: `pre-commit run --all-files` (what the Lint job of CI runs; the checks are not a git hook, do not `pre-commit install`).
4746
- Type-check: `mypy playwright`.
48-
- Run tests: `pytest --browser chromium [-k name]` (v1), `PW_IMPL=v2 pytest tests/async --browser chromium` (v2), or `./test.sh [browser]` for both. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo).
47+
- Run tests: `pytest --browser chromium [-k name]` (v1), `PW_IMPL=v2 pytest tests/sync tests/async --browser chromium` (v2; `./test-v2.sh [browser]` runs both suites), or `./test.sh [browser]` for both implementations. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo).
4948

5049
The public API comes from the upstream docs. After `./scripts/update_api.sh`, run `mypy playwright`: the generated classes type their `_impl_obj`, so every new member or option that `_impl/` does not implement yet is a mypy error in `_generated.py`. Fix it in `_impl/`, or record a justified difference in `scripts/codegen/python_rules.js` — not by hand-editing `_generated.py`.
5150

@@ -91,7 +90,7 @@ It documents the full process: the upstream commit-range diff over `docs/src/api
9190

9291
## Commit Convention
9392

94-
Before committing, run `mypy playwright` and fix errors.
93+
Before committing, run `pre-commit run --all-files` (the Lint job of CI) and fix errors.
9594

9695
Semantic commit messages: `label(scope): description`
9796

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,9 @@ Checking for typing errors
6464
mypy playwright
6565
```
6666

67-
Format the code
67+
Lint and format the code (what the Lint job of CI runs; the checks are not a git hook)
6868

6969
```sh
70-
pre-commit install
7170
pre-commit run --all-files
7271
```
7372

‎ROLLING.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
```
99
python -m pip install --upgrade pip
1010
pip install -r local-requirements.txt
11-
pre-commit install
1211
pip install -e .
1312
```
1413
* change the driver pin in `DRIVER_VERSION`: `npm=` the `playwright-core` npm version (e.g. `1.61.0`) and `rev=` the commit it is built from (`npm view playwright-core@<version> gitHead`); refresh `NODE_VERSION`: `python scripts/update_node_version.py`

‎playwright/_impl/_greenlets.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414
import asyncio
1515
import os
16-
from typing import Tuple
16+
from typing import Any, Callable, Tuple, Type
1717

1818
import greenlet
1919

@@ -52,10 +52,41 @@ def __str__(self) -> str:
5252
return "<EventGreenlet>"
5353

5454

55+
class BindingGreenlet(greenlet.greenlet):
56+
def __str__(self) -> str:
57+
return "<BindingGreenlet>"
58+
59+
60+
class WebSocketRouteGreenlet(greenlet.greenlet):
61+
def __str__(self) -> str:
62+
return "<WebSocketRouteGreenlet>"
63+
64+
5565
def connection_closed_error() -> TargetClosedError:
5666
return TargetClosedError("Playwright connection closed")
5767

5868

69+
def run_in_greenlet(
70+
greenlet_class: Type[greenlet.greenlet], func: Callable[[], Any]
71+
) -> "asyncio.Future[Any]":
72+
"""Runs a callback of the sync API in its own greenlet.
73+
74+
The callback may block in a sync call. In its own greenlet it switches to the
75+
dispatcher fiber then, and the event loop goes on. The future settles with the
76+
result of the callback when it returns, or with its error.
77+
"""
78+
future: "asyncio.Future[Any]" = asyncio.get_running_loop().create_future()
79+
80+
def body() -> None:
81+
try:
82+
future.set_result(func())
83+
except Exception as e:
84+
future.set_exception(e)
85+
86+
greenlet_class(body).switch()
87+
return future
88+
89+
5990
def wait_for_future(
6091
loop: asyncio.AbstractEventLoop,
6192
dispatcher_fiber: greenlet.greenlet,

‎playwright/_impl/_sync_base.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# limitations under the License.
1414

1515
import asyncio
16+
import os
17+
import sys
1618
import traceback
1719
from contextlib import AbstractContextManager
1820
from types import TracebackType
@@ -36,6 +38,9 @@
3638

3739
mapping = ImplToApiMapping()
3840

41+
# PW_IMPL=v2: playwright/_impl_v2 reads the stack of a sync call from its frames.
42+
_IS_V2 = os.environ.get("PW_IMPL") == "v2"
43+
3944

4045
T = TypeVar("T")
4146
Self = TypeVar("Self", bound="SyncContextManager")
@@ -104,8 +109,14 @@ def _sync(
104109
raise connection_closed_error()
105110

106111
task: asyncio.tasks.Task[Any] = self._loop.create_task(coro)
107-
setattr(task, "__pw_stack__", _capture_stack_trace())
108-
setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10))
112+
# The task runs in the dispatcher fiber, where the stack of this call is not
113+
# visible. The implementation reads it from the task: v2 walks the frames of
114+
# the caller (capture_raw_stack()), v1 takes the parsed stack.
115+
if _IS_V2:
116+
setattr(task, "__pw_caller_frame__", sys._getframe(1))
117+
else:
118+
setattr(task, "__pw_stack__", _capture_stack_trace())
119+
setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10))
109120

110121
wait_for_future(self._loop, self._dispatcher_fiber, task)
111122
return task.result()

‎playwright/_impl_v2/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,9 @@
3737
runs the prefix and returns the awaitable of the rest (the wait_for_*() methods).
3838
- Python has no AbortSignal: the `signal` options are not ported. Cancelling the task
3939
that waits for a call aborts the call, as an aborted signal does.
40-
- There is no sync code here: no greenlet, no sync waits.
40+
- The sync API is the one of playwright/_impl (playwright/sync_api/_context_manager.py,
41+
playwright/_impl/_sync_base.py, playwright/_impl/_greenlets.py): a greenlet runs the
42+
event loop, and a sync call switches to it. Each object carries `_loop` and
43+
`_dispatcher_fiber` for the generated API; a user callback that may block runs in a
44+
greenlet of its own (transform.md, section 13).
4145
"""

‎playwright/_impl_v2/api_classes.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""Python only: the v2 class of each class in the generated API.
1616
1717
playwright/_impl/_api_classes.py takes these when PW_IMPL=v2 is set. The expect()
18-
matchers are not part of the client; they stay in playwright/_impl/_assertions.py.
18+
matchers are not part of the JS client; the Python ones are in assertions.py.
1919
"""
2020

2121
from playwright._impl_v2.assertions import (

‎playwright/_impl_v2/assertions.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ def __init__(
6262
is_soft: bool = False,
6363
) -> None:
6464
self._actual_locator = locator
65+
# Python: the generated API reads the event loop and the dispatcher fiber of
66+
# each object.
6567
self._loop = locator._loop
66-
# The sync API reads it; playwright/_impl_v2 (async only) has none.
67-
self._dispatcher_fiber = getattr(locator, "_dispatcher_fiber", None)
68+
self._dispatcher_fiber = locator._dispatcher_fiber
6869
self._timeout = timeout
6970
self._is_not = is_not
7071
self._custom_message = message
@@ -1057,9 +1058,10 @@ def __init__(
10571058
message: Optional[str] = None,
10581059
is_soft: bool = False,
10591060
) -> None:
1061+
# Python: the generated API reads the event loop and the dispatcher fiber of
1062+
# each object.
10601063
self._loop = response._loop
1061-
# The sync API reads it; playwright/_impl_v2 (async only) has none.
1062-
self._dispatcher_fiber = getattr(response, "_dispatcher_fiber", None)
1064+
self._dispatcher_fiber = response._dispatcher_fiber
10631065
self._timeout = timeout
10641066
self._is_not = is_not
10651067
self._actual = response

‎playwright/_impl_v2/browser_context.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def on_service_worker(
187187
def on_console(event: "channels.BrowserContextConsoleEvent") -> None:
188188
worker = Worker.from_nullable(event["worker"])
189189
page = Page.from_nullable(event["page"])
190-
console_message = ConsoleMessage(event, page, worker)
190+
console_message = ConsoleMessage(event, page, worker, self)
191191
if worker:
192192
worker.emit(Events.Worker.Console, console_message)
193193
if page:
@@ -208,7 +208,7 @@ def on_page_error(event: "channels.BrowserContextPageErrorEvent") -> None:
208208
parsed_error = parse_error(event["error"])
209209
self.emit(
210210
Events.BrowserContext.WebError,
211-
WebError(page_object, parsed_error, event["location"]),
211+
WebError(page_object, parsed_error, event["location"], self),
212212
)
213213
if page_object:
214214
page_object.emit(Events.Page.PageError, parsed_error)
@@ -590,7 +590,7 @@ async def route(
590590
),
591591
)
592592
await self._update_interception_patterns()
593-
return DisposableStub(lambda: self.unroute(url=url, handler=handler))
593+
return DisposableStub(lambda: self.unroute(url=url, handler=handler), self)
594594

595595
@api_call(title="Route WebSockets")
596596
async def route_web_socket(

0 commit comments

Comments
 (0)