Skip to content

Commit 478282f

Browse files
authored
chore(codegen): generate the public TypedDicts from the docs (#3247)
1 parent b8b7155 commit 478282f

19 files changed

Lines changed: 410 additions & 195 deletions

‎.claude/skills/playwright-roll/SKILL.md‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Implement the change in `playwright/_impl/<module>.py`. Use the upstream JS impl
162162
| `(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.) |
163163
| `(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. |
164164
| `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`) |
165-
| Inline `[Object]` return like `{endpoint: string}` | A `TypedDict` in `playwright/_impl/_api_structures.py`. The generator names the struct from its docs `* alias:` (or a `structRenames` / `structNamesByPlace` entry in `python_rules.js`) and imports that name from `_api_structures.py`. See `RemoteAddr`, `BrowserBindResult`, `DebuggerPausedDetails`. |
165+
| Inline `[Object]` return like `{endpoint: string}` | A `TypedDict` in `playwright/_impl/_api_structures.py`, which the generator writes. It names the struct from its docs `* alias:` (or a `structRenames` / `structNamesByPlace` entry in `python_rules.js`). See `RemoteAddr`, `BrowserBindResult`, `DebuggerPausedDetails`. |
166166
| `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`. |
167167

168168
When implementing a new ChannelOwner subclass (one constructed by the protocol with `(parent, type, guid, initializer)`):
@@ -172,8 +172,9 @@ When implementing a new ChannelOwner subclass (one constructed by the protocol w
172172
When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`):
173173
- 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.
174174

175-
When adding a new TypedDict in `_api_structures.py`:
176-
- Its name must match what the generator emits: the struct's `* alias:` in the docs, or a `structRenames` / `structNamesByPlace` entry in `python_rules.js`. The generator fails with "No Python name for the struct at …" until one exists.
175+
When the docs add a new struct (an inline `[Object]` with named fields):
176+
- The generator writes its TypedDict into `_api_structures.py`. Never edit that file by hand; internal types go in `_internal_structures.py`.
177+
- It needs a Python name: the struct's `* alias:` in the docs, or a `structRenames` / `structNamesByPlace` entry in `python_rules.js`. The generator fails with "No Python name for the struct at …" until one exists. Nested structs need names too.
177178
- 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`.
178179

179180
If the new API was previously recorded in `python_rules.js` (for example in `skipArgs`), **remove that entry** when implementing it. The generator fails on entries that no longer match anything, so stale entries cannot pile up.
@@ -203,7 +204,7 @@ The generator fails with "the entry … no longer matches anything; remove it" w
203204

204205
- "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.
205206
- A renamed positional parameter (mypy: unexpected keyword argument `newName` for the impl method). Just rename in `_impl/` and update the channel-send dict key.
206-
- A struct return the generator cannot name ("No Python name for the struct at …"). Add a TypedDict and name it — don't type it `Dict`.
207+
- A struct return the generator cannot name ("No Python name for the struct at …"). Name it, so that the generator writes a TypedDict — don't type it `Dict`.
207208

208209
#### N/A
209210

@@ -247,11 +248,12 @@ Failure modes and fixes:
247248
| mypy: `"XImpl" has no attribute "y"` in `_generated.py` | The docs have a new member `X.y`; `_impl/` does not. | PORT it. |
248249
| mypy: `Unexpected keyword argument "z" for "y" of "XImpl"` | New parameter or option on an existing method. | Add the kwarg (docs camelCase name) in `_impl/`. If Python must not have it, `skipArgs`. |
249250
| mypy: incompatible argument type in `_generated.py` | The docs type changed, or `_impl/` declares a narrower type. | Match the type in `_impl/`, or record a `typeOverrides` entry for a justified divergence. |
250-
| generator: `No Python name for the struct at X.y.z` | A new inline `[Object]` with no `* alias:` in the docs. | Add the TypedDict and name it: `alias-python` upstream, or `structNamesByPlace` / `structRenames`. |
251+
| generator: `No Python name for the struct at X.y.z` | A new inline `[Object]` with no `* alias:` in the docs. | Name it: `alias-python` upstream, or `structNamesByPlace` / `structRenames`. |
252+
| generator: `The struct X has different fields at A and B` | Two places in the docs share one struct name, but their fields differ. | Give one place its own name in `structNamesByPlace`, or fix the docs. |
251253
| generator: `Unknown docs type "T" at X.y` | A docs type the type table in `generate_api.js` (`toPy`) does not know. | Add it to `toPy`. |
252254
| generator: `required parameter z follows an optional one` | The docs order params so that Python would need a default before a non-default. | Add a `positional` entry with the Python order. |
253255
| generator: `the entry "…" no longer matches anything; remove it` | An upstream change made a rules entry unnecessary (or renamed what it points at). | Delete it, or re-key it to the new docs name. |
254-
| generator: `legacy_order.json: … has no …; remove it` | A member or parameter in the temporary legacy order was removed or renamed upstream. | Delete that name from `scripts/codegen/legacy_order.json`. New members need no entry; they go last. |
256+
| generator: `legacy_order.json: … has no …; remove it` | A member, parameter or TypedDict in the temporary legacy order was removed or renamed upstream. | Delete that name from `scripts/codegen/legacy_order.json`. New members need no entry; they go last. |
255257
| `pyright … reportInconsistentOverload` (single-event class) | A class gained its first event, so there is one `Literal[…]` overload + impl, which pyright wants ≥2 of. | The generator already handles this — `emitEvents` adds a second `@typing.overload` with `event: str` when a class has exactly one event. |
256258
| `pre-commit` keeps reformatting `_generated.py` on each run | First run after regen always reformats once; rerun until idle. | `pre-commit run --all-files` to settle. |
257259
| A kwarg in `_impl/` that the docs don't mention (e.g. legacy name from a doc rename) | Nothing fails: the generator simply never passes it. | If the param is positional with no default, just rename it in `_impl/`. Check `packages/protocol/spec/*.yml` and the server dispatcher — if the wire field renamed too, also update the channel-send dict key. Only use `extraOptions` for genuinely hidden internal kwargs (`default_browser_type`). |
@@ -303,6 +305,7 @@ All keys use docs names: `Class.member` or `Class.member.arg`, with the upstream
303305
| `positional` | Methods whose positional parameters differ from "params positional, options keyword-only". |
304306
| `positionalOptions` | Option names that are positional everywhere (`predicate`). |
305307
| `structRenames`, `structNamesByPlace` | TypedDict names. |
308+
| `structTotal` | `total=` of a TypedDict, where it differs from "total=False if any field is optional". |
306309
| `implModules` | `_impl/` module of classes not in `_<snake_case name>.py`. |
307310

308311
## Tips & gotchas
@@ -313,7 +316,7 @@ All keys use docs names: `Class.member` or `Class.member.arg`, with the upstream
313316
- **Watch for revert pairs in the same range.** 1.59 added and reverted `Browser.isRemote` (#39613 / #39620) inside the same release. Walking chronologically lets you skip the add when you see the revert later.
314317
- **Watch for rename-revert pairs.** 1.59 had `Locator.normalize` → `Locator.toCode` (#39648) → `Locator.normalize` (#39754). Final state wins; only port the last.
315318
- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C "$PW_SRC_DIR" show <sha> -- packages/protocol/spec/` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores.
316-
- **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields, define a `TypedDict` in `_api_structures.py`, name it for the generator, and re-export it from both public `__init__.py` files. Zero runtime cost (it's still a `dict`), and the docstrings expand its fields from the docs.
319+
- **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields, name the struct for the generator, which writes its `TypedDict` into `_api_structures.py`, and re-export it from both public `__init__.py` files. Zero runtime cost (it's still a `dict`), and the docstrings expand its fields from the docs.
317320
- **Positional renames are free.** A param with no default before any `*` separator is positional-or-keyword in Python, but realistic call sites pass it positionally. Renaming such a param doesn't break callers.
318321
- **The "Backport changes" GitHub issue can be misleading.** In the 1.59 roll its checkboxes were all marked `[x]` with annotations like "✅ IMPLEMENTED", but several of those features had not actually been merged into the Python port. Trust the `docs/src/api/` walk over the issue.
319322
- **The docs parser has quirks.** In a type expression, everything after a function's `:` is its return type, so `[function]([A]):[any]|[B]` is one function returning `any|B`. Put a function with a return type last in a union. The parser also drops the second argument of a function type when the first is a union (see the `WebSocketRoute.onClose.handler` override, which builds the type by hand). And when a `### param:` header name differs from the backticked name on its type line, the header name is the docs name and the type-line name becomes the alias (see `argNames`).

‎CLAUDE.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk
88

99
## Layout
1010

11-
- `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.
12-
- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.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/`.
13-
- `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, and the calls into `_impl/`. It does not read `_impl/`.
14-
- `scripts/codegen/python_rules.js` — the Python facts that the docs do not contain (positional parameters, struct names, type overrides, hidden members). 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.
15-
- `scripts/codegen/legacy_order.json` — **temporary**. The old generator's order of classes, members, keyword-only parameters and union members. The generator applies it, and more old habits marked `TEMPORARY` in the generator, so that the switch to the docs generator has a small diff. New members go last. If the generator reports an entry that no longer matches, remove the entry. A follow-up change deletes the file and the `TEMPORARY` code.
11+
- `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/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/`.
13+
- `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/`.
14+
- `scripts/codegen/python_rules.js` — the Python facts that the docs do not contain (positional parameters, struct names and `total=`, type overrides, hidden members). 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.
15+
- `scripts/codegen/legacy_order.json` — **temporary**. The old generator's order of classes, members, keyword-only parameters and union members, and the hand-written order of the TypedDicts. The generator applies it, and more old habits marked `TEMPORARY` in the generator, so that the switch to the docs generator has a small diff. New members go last. If the generator reports an entry that no longer matches, remove the entry. A follow-up change deletes the file and the `TEMPORARY` code.
1616
- `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.
1717
- `tests/async/`, `tests/sync/` — pytest suites. Most new tests are added to the async file with a sync mirror.
1818
- `DRIVER_VERSION` — the single source of truth for which Playwright release the driver is assembled from (one line, the `playwright-core` npm version, e.g. `1.61.0`, no `v` prefix). Read by `setup.py`, `scripts/build_driver.py`, and CI. 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.

‎CONTRIBUTING.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ For more details look at the [CI configuration](./.github/workflows/ci.yml).
7575

7676
### Regenerating APIs
7777

78-
`update_api.sh` generates `playwright/{async,sync}_api/_generated.py` from the
79-
API docs in a nearby `microsoft/playwright` checkout (at the release branch
80-
matching `DRIVER_VERSION`); point `PW_SRC_DIR` at it. The generator is
78+
`update_api.sh` generates `playwright/{async,sync}_api/_generated.py` and the
79+
public TypedDicts in `playwright/_impl/_api_structures.py` from the API docs in
80+
a nearby `microsoft/playwright` checkout (at the release branch matching
81+
`DRIVER_VERSION`); point `PW_SRC_DIR` at it. The generator is
8182
`scripts/codegen/generate_api.js`; Python facts that the docs do not contain
8283
live in `scripts/codegen/python_rules.js`.
8384

‎playwright/_impl/_api_structures.py‎

Lines changed: 3 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
# Generated from the docs by scripts/codegen/generate_api.js. Do not edit.
16+
1517
from pathlib import Path
1618
from typing import Any, Dict, List, Literal, Optional, Sequence, TypedDict, Union
1719

18-
# These are the structures that we like keeping in a JSON form for their potential
19-
# reuse between SDKs / services. They are public and are a part of the
20-
# stable API.
21-
22-
# Explicitly mark optional params as such for the documentation
23-
# If there is at least one optional param, set total=False for better mypy handling.
24-
2520

2621
class Cookie(TypedDict, total=False):
2722
name: str
@@ -46,7 +41,6 @@ class StorageStateCookie(TypedDict, total=False):
4641
sameSite: Literal["Lax", "None", "Strict"]
4742

4843

49-
# TODO: We are waiting for PEP705 so SetCookieParam can be readonly and matches Cookie.
5044
class SetCookieParam(TypedDict, total=False):
5145
name: str
5246
value: str
@@ -171,7 +165,7 @@ class FilePayload(TypedDict):
171165

172166
class DropPayload(TypedDict, total=False):
173167
files: Optional[
174-
Union[str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]]
168+
Union[str, Path, Sequence[Union[str, Path]], FilePayload, Sequence[FilePayload]]
175169
]
176170
data: Optional[Dict[str, str]]
177171

@@ -198,136 +192,6 @@ class NameValue(TypedDict):
198192
value: str
199193

200194

201-
HeadersArray = List[NameValue]
202-
Headers = Dict[str, str]
203-
204-
205-
class ServerFilePayload(TypedDict):
206-
name: str
207-
mimeType: str
208-
buffer: str
209-
210-
211-
class FormField(TypedDict, total=False):
212-
name: str
213-
value: Optional[str]
214-
file: Optional[ServerFilePayload]
215-
216-
217-
class ExpectedTextValue(TypedDict, total=False):
218-
string: str
219-
regexSource: str
220-
regexFlags: str
221-
matchSubstring: bool
222-
normalizeWhiteSpace: bool
223-
ignoreCase: Optional[bool]
224-
225-
226-
class FrameExpectOptions(TypedDict, total=False):
227-
expressionArg: Any
228-
expectedText: Optional[Sequence[ExpectedTextValue]]
229-
expectedNumber: Optional[float]
230-
expectedValue: Optional[Any]
231-
useInnerText: Optional[bool]
232-
isNot: bool
233-
timeout: Optional[float]
234-
pseudo: Optional[str]
235-
236-
237-
class FrameExpectResult(TypedDict, total=False):
238-
matches: bool
239-
received: Any
240-
log: Optional[List[str]]
241-
errorMessage: Optional[str]
242-
timedOut: Optional[bool]
243-
244-
245-
AriaRole = Literal[
246-
"alert",
247-
"alertdialog",
248-
"application",
249-
"article",
250-
"banner",
251-
"blockquote",
252-
"button",
253-
"caption",
254-
"cell",
255-
"checkbox",
256-
"code",
257-
"columnheader",
258-
"combobox",
259-
"complementary",
260-
"contentinfo",
261-
"definition",
262-
"deletion",
263-
"dialog",
264-
"directory",
265-
"document",
266-
"emphasis",
267-
"feed",
268-
"figure",
269-
"form",
270-
"generic",
271-
"grid",
272-
"gridcell",
273-
"group",
274-
"heading",
275-
"img",
276-
"insertion",
277-
"link",
278-
"list",
279-
"listbox",
280-
"listitem",
281-
"log",
282-
"main",
283-
"marquee",
284-
"math",
285-
"menu",
286-
"menubar",
287-
"menuitem",
288-
"menuitemcheckbox",
289-
"menuitemradio",
290-
"meter",
291-
"navigation",
292-
"none",
293-
"note",
294-
"option",
295-
"paragraph",
296-
"presentation",
297-
"progressbar",
298-
"radio",
299-
"radiogroup",
300-
"region",
301-
"row",
302-
"rowgroup",
303-
"rowheader",
304-
"scrollbar",
305-
"search",
306-
"searchbox",
307-
"separator",
308-
"slider",
309-
"spinbutton",
310-
"status",
311-
"strong",
312-
"subscript",
313-
"superscript",
314-
"switch",
315-
"tab",
316-
"table",
317-
"tablist",
318-
"tabpanel",
319-
"term",
320-
"textbox",
321-
"time",
322-
"timer",
323-
"toolbar",
324-
"tooltip",
325-
"tree",
326-
"treegrid",
327-
"treeitem",
328-
]
329-
330-
331195
class TracingGroupLocation(TypedDict):
332196
file: str
333197
line: Optional[int]

0 commit comments

Comments
 (0)