Skip to main content

playwright-roll

Roll Playwright Python to a new driver version. Walks the upstream `docs/src/api/` commit range, ports each public-API change, suppresses the rest in `expected_api_mismatch.txt`, regenerates the typed surface, and adds tests.

Source facts

Repository
microsoft/playwright-python
Last source activity
June 24, 2026 at 07:25
Detected SKILL.md language
English
Stars
15,030
Forks
1,244

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
playwright-roll
description
Roll Playwright Python to a new driver version. Walks the upstream `docs/src/api/` commit range, ports each public-API change, suppresses the rest in `expected_api_mismatch.txt`, regenerates the typed surface, 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` runs 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/generate_*.py`, `scripts/documentation_provider.py`) that: 1. introspects the Python `_impl` classes via `inspect`, 2. emits typed wrapper classes into `playwright/{async,sync}_api/_generated.py`, and 3. diffs the introspected surface against the Playwright `api.json` (generated from the upstream docs — see step 2). Anything in `api.json` that is missing or differently typed in `_impl/` causes generation to fail. Three resolutions: - **PORT** — the new API is intended for Python (no `langs.only` filter, or `langs.only` includes `"python"`). Implement it in `_impl/`. - **MISMATCH** — the API genuinely exists for Python but is shaped differently (a callback signature uses unions, a kwarg uses a legacy name, etc.) and there's a justified reason to keep the divergence. Add a precise line to `scripts/expected_api_mismatch.txt` with a comment explaining *why*. - **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 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 You need a nearby `microsoft/playwright` checkout for the docs walk and for `api.json` generation. Point `PW_SRC_DIR` at it and check out the new tag there: ```sh export PW_SRC_DIR=../playwright git -C "$PW_SRC_DIR" fetch --tags origin git -C "$PW_SRC_DIR" checkout v<new> # e.g. v1.62.0 ``` 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 # api.json isn't in the bundle, and the walk below inspects `langs` from it. # Generate a copy to inspect (update_api.sh generates its own temp copy in step 6): API_JSON_MODE=1 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/`. `api.json` is the one piece not shipped in the bundle — it's generated from `$PW_SRC_DIR` on demand (here to `/tmp/api.json` for the walk, and into a temp file passed via `PW_API_JSON` by `./scripts/update_api.sh` during codegen). ### 3. Identify the commit range Use the nearby `microsoft/playwright` 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 doc generator treats argument-less sync getters as properties — see `documentation_provider.py:133`. If you make it a method instead, you'll get a "Method vs property mismatch" error.) | | `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` — *not* `Dict[str, str]`. The doc generator serializes TypedDicts as `{field: type, ...}` via `get_type_hints` and that matches the inline-object form exactly. 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. Import it and add it to `generated_types` in `scripts/generate_api.py`, plus add a `XxxImpl` import in the `header` string. When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`): - 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 adding a new TypedDict in `_api_structures.py`: - Add it to the `from playwright._impl._api_structures import …` line in `scripts/generate_api.py` so the generator can resolve it as a forward reference in type hints. - 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 suppressed in `expected_api_mismatch.txt`, **remove that line** when implementing it. 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/src/protocol.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": …}`). A "Parameter not documented" suppression for a renamed param is a code smell hiding a wire-protocol bug. #### MISMATCH A MISMATCH is a *justified, durable* divergence between the docs and the Python surface. Use it sparingly — most apparent mismatches turn out to be PORTs you skipped. Legitimate examples in the current `expected_api_mismatch.txt`: - Hidden internal kwargs (`Browser.new_context(default_browser_type=)`). - Callback signatures where Python explicitly unions one-arg and two-arg variants but the docs document only the canonical form (`Page.route(handler=)`, `WebSocketRoute.on_close(handler=)`). Add a precise line to `scripts/expected_api_mismatch.txt` with a `# comment` group header explaining *why* the divergence is intentional. The exact wording comes from the generator's error message. Examples: ``` # One vs two arguments in the callback, Python explicitly unions. Parameter type mismatch in Page.route(handler=): documented as Callable[[Route, Request], Union[Any, Any]], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any]] ``` The generator removes lines from `expected_api_mismatch.txt` that no longer match an error. If you see "No longer there: …" in the script's stderr, delete that line. **Do not suppress** these — they're PORTs in disguise: - "Internal tooling" classes/methods whose `langs` field is empty (`Screencast.*`, `Debugger.*`, `Page.pick_locator`, `BrowserContext.debugger`, `Browser.bind/unbind`, `Page.{clear,console}_*`). The 1.59 roll suppressed all of these initially, then had to walk every one back. Verify `langs` first. - A renamed positional parameter (`Parameter not documented: X.y(old_name=)` + `Parameter not implemented: X.y(new_name=)`). Just rename in `_impl/` and update the channel-send dict key. - A `Parameter type mismatch in X.y(return=): documented as {field: T}, code has Dict[str, T]`. Use a TypedDict. #### N/A
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub