- name
- playwright-roll
- description
- Roll Playwright Python to a new driver version. Walks the upstream `docs/src/api/` commit range, regenerates the public API from the docs, ports each change into `_impl/`, records justified differences in `scripts/codegen/python_rules.js`, and adds tests.
# Rolling Playwright Python
The goal of a roll is to move the driver pin in `DRIVER_VERSION` to a new release, port every public API change introduced upstream during that interval, and suppress the rest, so that `./scripts/update_api.sh` and `mypy playwright` run clean and the test suite still passes.
The previous human-facing summary lives in `../../../ROLLING.md`. This skill is the operational playbook — read it end to end before starting.
## Mental model
The Python port is hand-written code in `playwright/_impl/`, plus a generator (`scripts/codegen/generate_api.js`) that:
1. parses the upstream docs (`$PW_SRC_DIR/docs/src/api`) with the upstream parser and filters them for Python,
2. applies the Python facts that the docs do not contain, from `scripts/codegen/python_rules.js`, and
3. emits the whole public API into `playwright/{async,sync}_api/_generated.py`: signatures, types, docstrings, event overloads, and the call into `_impl/` for every member.
The generator never reads `_impl/`. A new member or option in the docs therefore shows up in `_generated.py` right away, as a call into `_impl/` that does not exist yet. Each generated class types its `_impl_obj`, so **`mypy playwright` lists every such gap** as an error in `_generated.py`. Three resolutions:
- **PORT** — the new API is intended for Python (no `langs.only` filter, or `langs.only` includes `"python"`). Implement it in `_impl/` until mypy is clean.
- **MISMATCH** — the shipped Python API differs from the docs (a callback signature uses unions, a kwarg uses a legacy name, etc.). The shipped implementation wins. Add an entry to `scripts/codegen/python_rules.js` with a comment explaining *why*, and propose the upstream docs change that would make the entry unnecessary.
- **N/A** — the commit only touches docs, has `* langs: js` (or any other filter that excludes Python), is server-side, Electron-only, or was reverted later in the same release. No action; the generator already leaves it out.
The upstream documentation source of truth is `docs/src/api/*.md` in the playwright repo. Every `## method:` / `## property:` / `## event:` / `### option:` / `### param:` block has an optional `* langs: js` (or `js, python`, etc.) filter. The Python doclint resolves these into `langs` fields on each member of `api.json`. **An empty `langs: {}` means "all languages including Python" — *implement it*, don't suppress it.**
> **The mistake the 1.59 roll made twice over:** classifying things as "internal tooling, N/A for Python" based on the *name* of the API (Screencast, Debugger, pickLocator, clearConsoleMessages, artifactsDir, …). Almost all of those had empty `langs: {}` in `api.json` and were real Python APIs. Sounding tooling-y is not a `langs` filter. **The `langs` field on the member in `api.json` is the only authoritative signal.** When in doubt, dump it (see "Verifying classifications" below).
## Process
### 1. Set up the env
`CONTRIBUTING.md` covers this. Notes from past rolls:
- The repo requires Python 3.10+. If `python3.10` isn't available, use `python3` (3.12 is fine).
- If `python3-venv` is missing system-wide, use `uv venv env` instead, then `uv pip install --python env/bin/python --upgrade pip`. Don't try to `apt install` — sudo is denied in the harness.
- Always activate the venv before any `pip`, `pytest`, `mypy`, or `pre-commit` invocation.
### 2. Bump the driver pin, download it, and generate api.json
The docs walk and codegen read the upstream docs from a `microsoft/playwright`
checkout at `$PW_SRC_DIR`. **Use a separate checkout that exists only for
rolls, on the exact release branch you roll to**: `release-<new>`, the
`major.minor` of the new `DRIVER_VERSION` (the branch has the release's docs
plus any cherry-picks). Never point `PW_SRC_DIR` at a checkout that someone
works in: on `main` or a feature branch the docs differ, and the generator
silently emits that API instead of the release's.
```sh
export PW_SRC_DIR=../playwright-for-python # the roll checkout, nothing else
# First roll on this machine: clone it (no npm install needed).
git clone https://github.com/microsoft/playwright.git "$PW_SRC_DIR"
# Every roll: move it to the release branch.
git -C "$PW_SRC_DIR" fetch origin
git -C "$PW_SRC_DIR" checkout release-<new> # e.g. release-1.62
git -C "$PW_SRC_DIR" merge --ff-only origin/release-<new>
```
Check the checkout before you generate, and again before you commit:
```sh
git -C "$PW_SRC_DIR" rev-parse --abbrev-ref HEAD # must print release-<new>
git -C "$PW_SRC_DIR" status --porcelain # must print nothing
git -C "$PW_SRC_DIR" rev-parse HEAD origin/release-<new> # both lines must be equal
```
Then bump the pins and assemble the driver:
```sh
# Edit DRIVER_VERSION (repo root): the playwright-core npm version for the new
# release, no "v" prefix, e.g. 1.62.0
python scripts/update_node_version.py # refresh NODE_VERSION to the current LTS
source env/bin/activate
python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js, assembles the driver
playwright install chromium # NOT --with-deps; sudo is denied
# The walk below inspects `langs` from api.json. Codegen does not need it.
node "$PW_SRC_DIR/utils/doclint/generateApiJson.js" > /tmp/api.json
```
The wheel build just downloads the `playwright-core` npm package at
`DRIVER_VERSION` and the matching Node.js binary (no source build, no Node/npm/git
toolchain), and unpacks the driver under `playwright/driver/`. The docs are the
one piece not shipped in the bundle — `./scripts/update_api.sh` reads them from
`$PW_SRC_DIR`. `/tmp/api.json` is only a convenient dump of the same docs for
the walk.
### 3. Identify the commit range
Use the roll checkout at `$PW_SRC_DIR` (from step 2).
Bring it up to date and ensure release branches/tags are present before walking
the range:
```sh
git -C "$PW_SRC_DIR" fetch --tags
git -C "$PW_SRC_DIR" fetch origin 'release-*:release-*'
```
There is sometimes no `vX.Y.0` tag for the latest release (the bots cut release branches first and tag later). Anchor on commits, not tags.
The diff range is "every commit on the new release branch since the previous release was cut". Anchor commits:
- **Previous release end**: the `chore: bump version to vX.Y.0-next` commit on `main`. That commit is the first commit *after* the previous release (X.Y-1) was cut. Use its parent (`<sha>~1`) as the lower bound.
```sh
git -C "$PW_SRC_DIR" log --all --grep="bump version to v" --oneline | head
```
- **New release end**: the tip of `release-<new>` (or the matching tag if it exists).
Save the commit list, oldest first, scoped to `docs/src/api/`:
```sh
git -C "$PW_SRC_DIR" log <prev-anchor>~1..release-<new> --oneline --reverse -- docs/src/api > /tmp/roll-<new>-commits.md
```
A normal roll yields 50–100 commits. If you see 0 or thousands, the range is wrong.
Format the file as a markdown checklist and add the standard preamble (status legend, where to look up `api.json` etc.) — see the file from the 1.58→1.59 roll for the template.
### 4. Walk the commit list
For each commit, in chronological order:
```sh
git -C "$PW_SRC_DIR" show <sha> -- docs/src/api/
```
Look for:
- `## (async )?method:` / `## property:` / `## event:` additions or removals;
- `* langs: ...` lines on those blocks;
- `### param:` / `### option:` additions or removals;
- new `class-X.md` files (whole new classes — usually `langs: js`);
- type changes in `- returns:` lines.
Classify and act.
#### Verifying classifications (do this before suppressing anything)
Before tagging anything as MISMATCH or N/A based on appearance, dump the actual `langs` from `api.json`:
```python
import json
data = json.load(open("/tmp/api.json"))
classes = {c["name"]: c for c in data}
for cls_name in ["Page", "BrowserContext", "Screencast", "Debugger"]:
cls = classes.get(cls_name)
if not cls:
continue
print(f"\n{cls_name}: cls_langs={cls.get('langs', {})}")
for m in cls["members"]:
print(f" {m['name']} kind={m.get('kind')} langs={m.get('langs', {})}")
```
For options/params nested inside an `Object`-typed arg, walk one level deeper:
```python
for a in member.get("args", []):
if a["name"] == "options":
for prop in a.get("type", {}).get("properties", []):
print(prop["name"], prop.get("langs", {}))
```
A few rules of thumb that catch most "actually a PORT" cases:
- If the *containing class* has empty `langs: {}` and the *member* has empty `langs: {}`, it's for Python — implement it.
- If the member is empty but a single *option* has `langs: js`, the method is for Python and you only skip that option (e.g. `Screencast.start.size` is `langs: js` while `Screencast.start` itself isn't).
- If you're about to add three or more `Method not implemented:` entries for the same class, stop — you almost certainly need to implement the class.
#### PORT
Implement the change in `playwright/_impl/<module>.py`. Use the upstream JS implementation as a reference: `$PW_SRC_DIR/packages/playwright-core/src/client/<module>.ts`. Translate idioms:
| Upstream JS | Python |
|---|---|
| `async foo(): Promise<X>` | `async def foo(self) -> X:` |
| `foo(): X` (sync getter, no args, no body) | `@property def foo(self) -> X:` (the generator makes argument-less sync members properties — see `isPythonProperty` in `scripts/codegen/generate_api.js` — and reads `self._impl_obj.foo` without calling it. mypy does not catch a plain method here; the test does.) |
| `await this._channel.foo({ a, b })` | `await self._channel.send("foo", None, locals_to_params(locals()))` |
| `(await this._channel.foo()).value` | `await self._channel.send("foo", None)` (Python's `send()` auto-unwraps single-key responses; only call `send_return_as_dict` when the protocol returns multiple keys.) |
| `(await this._channel.foo()).artifact` (multi-key, may be empty) | `result = await self._channel.send_return_as_dict("foo", None); (result or {}).get("artifact")` — `send_return_as_dict` returns **`None`** (not `{}`) when the protocol response carries no fields. |
| `try { ... } catch (e) { if (isTargetClosedError(e)) return; throw e; }` | `try: ...; except Exception as e: if is_target_closed_error(e): return; raise` (import from `playwright._impl._errors`) |
| Inline `[Object]` return like `{endpoint: string}` | A `TypedDict` in `playwright/_impl/_api_structures.py`, which the generator writes. It names the struct from the docs: `` "Name" <[Object]> ``, or an `* alias-python:` line under it. See `RemoteAddr`, `BrowserBindResult`, `DebuggerPausedDetails`. |
| `binary` event/return field | The Python channel layer hands you a base64 string. Decode with `base64.b64decode(value)` before exposing as `bytes`. See `Screencast._dispatch_frame`. |
When implementing a new ChannelOwner subclass (one constructed by the protocol with `(parent, type, guid, initializer)`):
1. Register it in `playwright/_impl/_object_factory.py:create_remote_object` — otherwise the guid resolves to `DummyObject` and downstream code breaks mysteriously.
2. Add it to `playwright/_impl/_api_classes.py` (the import and `__all__`). The generator emits every Python class in the docs, and the generated API imports each implementation from that file. If the class is missing there, `_generated.py` fails to import.
When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`):
- Add it to `playwright/_impl/_api_classes.py`, as for a ChannelOwner subclass.
- Set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber` in `__init__`. The generated `AsyncBase`/`SyncBase` wrappers read these; missing them gives `AttributeError: 'X' object has no attribute '_loop'` at first use.
When the docs add a new struct (an inline `[Object]` with named fields):
- The generator writes its TypedDict into `_api_structures.py`. Never edit that file by hand; internal types go in `_internal_structures.py`.
- It needs a Python name in the docs: a name before the type (`` - `size` "ScreenSize" <[Object]> ``), or an `* alias-python:` line under the type. The generator fails with "No Python name for the struct at …" until one exists. Nested structs need names too.
- Re-export it from both `playwright/async_api/__init__.py` and `playwright/sync_api/__init__.py`: assignment line plus an entry in `__all__`. Same pattern as `ViewportSize`, `RemoteAddr`.
If the new API was previously recorded in `python_rules.js` (for example in `extraMembers`), **remove that entry** when implementing it. The generator fails on entries that no longer match anything, so stale entries cannot pile up.
If a doc rename involves a *positional* parameter (no default, before any `*`), users almost certainly call it positionally — you can rename freely. The 1.59 `BrowserType.connect.wsEndpoint` → `endpoint` is the canonical example. Don't suppress this kind of rename; just rename in `_impl/`. **Important corollary:** when docs rename a param, the wire-protocol field usually also changed in `packages/protocol/spec/*.yml` and the server-side dispatcher in `packages/playwright-core/src/server/dispatchers/*Dispatcher.ts`. If so, you must also update the channel-send dict key (e.g. `{"wsEndpoint": …}` → `{"endpoint": …}`). Keeping the old name in Python for a renamed param hides a wire-protocol bug.
#### MISMATCH
A MISMATCH is a divergence between the docs and the shipped Python surface. The shipped implementation wins. Use it sparingly — most apparent mismatches turn out to be PORTs you skipped. Fix a type, a name or a position in the upstream docs: declare the param or option for Python only, with `* langs: python`, and limit the shared declaration to the other languages. Example from `class-page.md`, where Python accepts a callback with one or two arguments:
```md
### param: Page.route.handler
* since: v1.8
Ver en GitHub