From ba9dac4f1e1a14716640b92781bfbb4a6e26fbcd Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 12 Oct 2026 09:32:07 -0700 Subject: [PATCH] chore(codegen): generate the public API from the upstream docs - scripts/codegen/generate_api.js builds async_api/_generated.py and sync_api/_generated.py from docs/src/api with the upstream doc parser, instead of introspecting playwright/_impl. - scripts/codegen/python_rules.js records the Python facts that the docs do not contain. - scripts/codegen/legacy_order.json and the code marked TEMPORARY keep the old order and formatting, so that the generated diff shows only real changes. Follow-up changes remove them. - Removes the old generator scripts. --- .claude/skills/playwright-roll/SKILL.md | 150 +-- CLAUDE.md | 16 +- CONTRIBUTING.md | 7 +- ROLLING.md | 9 +- playwright/async_api/_generated.py | 379 +++---- playwright/sync_api/_generated.py | 375 ++++--- scripts/codegen/compare_api.py | 459 +++++++++ scripts/codegen/generate_api.js | 1252 +++++++++++++++++++++++ scripts/codegen/legacy_order.json | 293 ++++++ scripts/codegen/python_rules.js | 242 +++++ scripts/documentation_provider.py | 645 ------------ scripts/expected_api_mismatch.txt | 28 - scripts/generate_api.py | 373 ------- scripts/generate_async_api.py | 145 --- scripts/generate_sync_api.py | 149 --- scripts/update_api.sh | 50 +- 16 files changed, 2739 insertions(+), 1833 deletions(-) create mode 100644 scripts/codegen/compare_api.py create mode 100644 scripts/codegen/generate_api.js create mode 100644 scripts/codegen/legacy_order.json create mode 100644 scripts/codegen/python_rules.js delete mode 100644 scripts/documentation_provider.py delete mode 100644 scripts/expected_api_mismatch.txt delete mode 100644 scripts/generate_api.py delete mode 100755 scripts/generate_async_api.py delete mode 100755 scripts/generate_sync_api.py diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index d075aa20c..112cb8e66 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -1,27 +1,27 @@ --- 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. +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` runs clean and the test suite still passes. +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/generate_*.py`, `scripts/documentation_provider.py`) that: +The Python port is hand-written code in `playwright/_impl/`, plus a generator (`scripts/codegen/generate_api.js`) 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). +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. -Anything in `api.json` that is missing or differently typed in `_impl/` causes generation to fail. Three resolutions: +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`. (Temporary: while `scripts/codegen/legacy_order.json` exists, `_impl_obj` is not typed and mypy reports nothing. Find the gaps in the docs diff and with the tests instead.) 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. +- **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.** @@ -40,12 +40,13 @@ The upstream documentation source of truth is `docs/src/api/*.md` in the playwri ### 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: +codegen. Point `PW_SRC_DIR` at it and check out the release branch for the new +version there (the branch has the release's docs plus any cherry-picks): ```sh export PW_SRC_DIR=../playwright -git -C "$PW_SRC_DIR" fetch --tags origin -git -C "$PW_SRC_DIR" checkout v # e.g. v1.62.0 +git -C "$PW_SRC_DIR" fetch origin 'release-*:release-*' +git -C "$PW_SRC_DIR" checkout release- # e.g. release-1.62 ``` Then bump the pins and assemble the driver: @@ -59,17 +60,16 @@ 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 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/`. `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). +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 @@ -157,50 +157,53 @@ Implement the change in `playwright/_impl/.py`. Use the upstream JS impl | Upstream JS | Python | |---|---| | `async foo(): Promise` | `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.) | +| `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` — *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`. | +| 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`. | | `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. +2. Nothing to do in codegen: the generator emits every Python class in the docs. If the class does not live in `_impl/_.py`, add it to `implModules` in `scripts/codegen/python_rules.js`. 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. +- 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. - 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 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. -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. +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": …}`). An `argNames` entry 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`: +A MISMATCH is a divergence between the docs and the shipped Python surface. The shipped implementation wins, but each entry is a debt: the real fix is an upstream docs change (a `* langs: python` declaration, an `alias-python:`, a Python type override), after which the entry goes away. Use it sparingly — most apparent mismatches turn out to be PORTs you skipped. Legitimate examples in the current `python_rules.js`: -- 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=)`). +- Hidden internal kwargs (`extraOptions`: `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 (`typeOverrides`: `Page.route.handler`, `WebSocketRoute.onClose.handler`). +- Positional parameters that the docs declare as options (`positional`: `select_option(value, ...)`). -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: +Add an entry to the right table in `scripts/codegen/python_rules.js`, with a one-line `//` comment above it explaining *why*. Keys use docs names (`Class.member.arg`, camelCase). Example: -``` -# 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]] +```js + typeOverrides: { + // One vs two arguments in the callback, Python explicitly unions. + 'Page.route.handler': '[function]([Route])|[function]([Route], [Request])', + }, ``` -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. +The generator fails with "the entry … no longer matches anything; remove it" when an upstream change makes an entry unnecessary. Delete that entry. **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. +- A renamed positional parameter (mypy: unexpected keyword argument `newName` for the impl method). Just rename in `_impl/` and update the channel-send dict key. +- 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`. #### N/A @@ -220,11 +223,18 @@ Tick the box in `/tmp/roll--commits.md` with one line: `[x] PW_SRC_DIR=../playwright ./scripts/update_api.sh # PW_SRC_DIR already exported in step 2 ``` -The script does, in order: -1. generates `api.json` from `$PW_SRC_DIR` into a temp file and exports `PW_API_JSON`, -2. `git checkout HEAD -- playwright/{async,sync}_api/_generated.py` (resets to last committed), -3. runs `scripts/generate_{sync,async}_api.py` (they read `api.json` via `PW_API_JSON`), dumping to `.x` then renaming into place, -4. invokes `pre-commit run --files` on the generated files. +The script, for `sync` and then `async`: +1. runs `node scripts/codegen/generate_api.js --flavor ` (reads the docs from `$PW_SRC_DIR`) straight into `playwright/_api/_generated.py`, +2. invokes `pre-commit run --files` on the generated file. + +Then run `mypy playwright` and work through the errors in `_generated.py` — each one is a call into `_impl/` that the implementation does not support yet. + +To see exactly what a regeneration changed in the public API, compare against the committed file: + +```sh +git show HEAD:playwright/async_api/_generated.py > /tmp/old.py +python scripts/codegen/compare_api.py /tmp/old.py playwright/async_api/_generated.py --show all +``` **CI no longer verifies that `_generated.py` is in sync** (the Lint job dropped the "Verify generated API is up to date" step so it needn't check out upstream). So @@ -234,16 +244,17 @@ Failure modes and fixes: | Symptom | Cause | Fix | |---|---|---| -| `Method not implemented: X.y` | `api.json` documents `X.y`, no Python impl exists. | PORT it, or add a MISMATCH line. | -| `Parameter not implemented: X.y(z=)` | New parameter on existing method. | Add the kwarg in `_impl/`, or MISMATCH. | -| `Method vs property mismatch: X.y` | You implemented as a method but the doc treats it as a property (sync, no args, has return type). | Add `@property` in `_impl/`. | -| `Method not documented: X.y` | Python has it but `api.json` doesn't. | The upstream removed the API; remove from `_impl/` and from `_generated.py` callers. | -| `Parameter type mismatch in X.y(z=): documented as ..., code has ...` | Type signature doesn't line up. | Match the type in `_impl/`, or MISMATCH it for known historical divergences. | -| `pyright … reportInconsistentOverload` (single-event class) | A class gained its first event in `api.json`, so the generator emits one `Literal[…]` overload + impl, which pyright wants ≥2 of. | The generator already handles this — `documentation_provider.print_events` emits a second `@typing.overload` with `event: str` plus a generic impl. If you regress this, see how 1.59 handled `CDPSession` getting a `close` event. | +| mypy: `"XImpl" has no attribute "y"` in `_generated.py` | The docs have a new member `X.y`; `_impl/` does not. | PORT it. | +| 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`. | +| 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. | +| 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`. | +| 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`. | +| 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. | +| 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. | +| 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. | +| `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. | | `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. | -| `Parameter not documented: X.y(z=)` | Python has a kwarg the docs don't mention (e.g. legacy name from a doc rename). | If the param is positional with no default, just rename it in `_impl/`. Check `protocol.yml` and the server dispatcher — if the wire field renamed too, also update the channel-send dict key. Only MISMATCH for genuinely hidden internal kwargs (`default_browser_type`). | -| `KeyError: 'templates'` deep in `inner_serialize_doc_type` | A `Promise\|X` union upstream collapsed to a bare `Promise` with no templates in `api.json`. | `documentation_provider.inner_serialize_doc_type` should treat that as `Any` (`if "templates" not in type: return "Any"`). | -| `"void" is not defined (reportUndefinedVariable)` in generated event handlers | `api.json` has a `void`-typed event payload that the serializer left as the literal string `"void"`. | `documentation_provider.inner_serialize_doc_type` should map `"void"` to `"None"` alongside `"null"`. | +| 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`). | | `AttributeError: 'X' object has no attribute '_loop'` (or `_dispatcher_fiber`) at first use of a new wrapper class | The non-ChannelOwner wrapper isn't initializing the fields the generated `AsyncBase`/`SyncBase` reads. | In the wrapper's `__init__`, set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber`. | | `'NoneType' object has no attribute 'get'` after `send_return_as_dict` | Method's protocol response carries no fields and `send_return_as_dict` returned `None`. | `(result or {}).get(...)`. | | Frame/buffer payload arrives as a `str` instead of `bytes` | Protocol `binary` fields cross the wire as base64. | `base64.b64decode(value)` in the impl before exposing. | @@ -276,32 +287,35 @@ pytest --browser chromium tests/async/ tests/sync/. -Parameter not implemented: .(=) -Parameter not documented: .(=) -Method vs property mismatch: . -Method not documented: . -Parameter type mismatch in .(=): documented as , code has -``` - -Class names use the upstream PascalCase (`BrowserContext`, `BrowserType`); method/param names are converted to `snake_case` matching the Python surface. +| Table | Use | +|---|---| +| `extraClasses` | A class the docs mark for other languages only, but Python has (`Disposable`). | +| `handWrittenClasses` | Classes in the Python docs that are written by hand (`Error`, `TimeoutError`, `FormData`). | +| `contextManagerClasses`, `tracebackHideClasses` | Base-class and `__tracebackhide__` choices. | +| `extraMembers`, `extraOptions` | Python-only members and hidden options; generated without docstrings. | +| `skipArgs` | Docs arguments Python does not have. | +| `argNames` | Docs arguments whose Python name differs. | +| `typeOverrides` | Python types that differ from the docs (docs type syntax; a function type with a return type must be last in a union). A member place overrides the return type. | +| `positional` | Methods whose positional parameters differ from "params positional, options keyword-only". | +| `positionalOptions` | Option names that are positional everywhere (`predicate`). | +| `structRenames`, `structNamesByPlace` | TypedDict names. | +| `implModules` | `_impl/` module of classes not in `_.py`. | ## Tips & gotchas - **`langs.only` is your filter — and the only filter.** Don't classify by name (`Screencast`, `Debugger`, `pickLocator`) or by intuition ("looks like internal tooling"). Always check `langs` in `api.json`. The 1.59 roll cost two extra audit passes by trusting names over `langs`. -- **Audit your own classifications a second time.** After the first walk through the commit range, before opening the PR, re-read every line in `expected_api_mismatch.txt` you added during this roll and ask "is this divergence justified, or did I skip a port?" Run the `langs`-dump snippet on each suspicious entry. The 1.59 roll's first PR had ~20 wrong suppressions; the second pass cut them to 0. -- **A cluster of suppressions on the same class is a smell.** If you're about to add five `Method not implemented: Foo.*` lines, you're almost certainly looking at a class that needs to be implemented. Implement the whole thing once and the suppressions disappear. +- **Audit your own classifications a second time.** After the first walk through the commit range, before opening the PR, re-read every entry in `python_rules.js` you added during this roll and ask "is this divergence justified, or did I skip a port?" Run the `langs`-dump snippet on each suspicious entry. The 1.59 roll's first PR had ~20 wrong suppressions; the second pass cut them to 0. +- **A cluster of rules on the same class is a smell.** If you're about to add five entries for `Foo.*`, you're almost certainly looking at a class that needs to be implemented. Implement the whole thing once and the entries disappear. - **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. - **Watch for rename-revert pairs.** 1.59 had `Locator.normalize` → `Locator.toCode` (#39648) → `Locator.normalize` (#39754). Final state wins; only port the last. -- **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 -- packages/protocol/src/protocol.yml` 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. -- **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields (or even `[Object=Foo]`), define a `TypedDict` in `_api_structures.py`, re-export from both public `__init__.py` files, and use it. Zero runtime cost (it's still a `dict`), and the doc generator's type comparator matches by structure via `get_type_hints`. +- **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 -- 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. +- **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. - **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. - **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. -- **`api.json` may carry doclint quirks.** 1.59 hit two: `Promise|X` collapsed to a bare `Promise` with no `templates`, and `void`-typed events serialized as the literal string `"void"`. Both are upstream artifacts; patch `inner_serialize_doc_type` to handle them rather than fighting the api.json side. -- **Don't edit `_generated.py` to fix lint or typing.** Fix `_impl/`, `documentation_provider.py`, or `expected_api_mismatch.txt` instead. Hand-editing the generated file is reverted on the next regen. +- **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`). +- **Don't edit `_generated.py` to fix lint or typing.** Fix `_impl/`, `scripts/codegen/generate_api.js`, or `python_rules.js` instead. Hand-editing the generated file is reverted on the next regen. - **`/tmp/roll--commits.md` is a working artifact, not a deliverable.** Don't commit it. The commit message and PR description are where the audit summary belongs. diff --git a/CLAUDE.md b/CLAUDE.md index 81a1adc4e..779e6d18a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,19 +4,21 @@ Guidance for Claude when working in this repository. ## What this is -Python bindings for [Playwright](https://playwright.dev). The Python client talks JSON over a pipe to the Node-based driver bundled in `playwright/driver/`. The pipe protocol is defined upstream in `packages/protocol/src/protocol.yml`. +Python bindings for [Playwright](https://playwright.dev). The Python client talks JSON over a pipe to the Node-based driver bundled in `playwright/driver/`. The pipe protocol is defined upstream in `packages/protocol/spec/*.yml`. ## Layout - `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. -- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py` — **auto-generated**. Never edit by hand; rerun `./scripts/update_api.sh` after changing `_impl/` or the driver. -- `scripts/generate_api.py`, `scripts/generate_async_api.py`, `scripts/generate_sync_api.py`, `scripts/documentation_provider.py` — codegen and validation. They diff the Python implementation against Playwright's `api.json` (provided via the `PW_API_JSON` env var; see `scripts/update_api.sh`) and abort if either side is out of sync. -- `scripts/expected_api_mismatch.txt` — explicit allowlist of "documented in JS, not in Python" or "named differently in Python" gaps. Lines that no longer apply must be removed. +- `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/`. +- `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/`. +- `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. +- `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. While the file exists, `_impl_obj` is not typed, so the mypy check described below is not active yet: a docs member that `_impl/` lacks fails only at run time. A follow-up change deletes the file and the `TEMPORARY` code. +- `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. - `tests/async/`, `tests/sync/` — pytest suites. Most new tests are added to the async file with a sync mirror. - `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--.zip`), so it doubles as the build cache key. - `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`). - `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). -- `api.json` is **not** shipped in the bundle and is never written into the driver — `scripts/update_api.sh` generates it from a nearby `microsoft/playwright` checkout (`$PW_SRC_DIR`) into a temp file and passes it to codegen via `PW_API_JSON` (read by `scripts/documentation_provider.py`). Needed only when regenerating the API, never at runtime. +- The API docs are **not** shipped in the bundle. `scripts/update_api.sh` reads them from a nearby `microsoft/playwright` checkout (`$PW_SRC_DIR`, at the release branch for `DRIVER_VERSION`). Needed only when regenerating the API, never at runtime. - `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs. ## Setup @@ -41,7 +43,7 @@ If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (t - Type-check: `mypy playwright`. - Run tests: `pytest --browser chromium [-k name]`. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo). -When changing public API, edit `_impl/`, then run `./scripts/update_api.sh`. The script regenerates `_generated.py` and validates against Playwright's `api.json` (which it generates from `$PW_SRC_DIR`). If validation fails, fix the mismatch in `_impl/`, in `expected_api_mismatch.txt`, or in `documentation_provider.py` — not by hand-editing `_generated.py`. +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`. ## Rolling Playwright to a new version @@ -60,7 +62,7 @@ It documents the full process: the upstream commit-range diff over `docs/src/api - Don't hand-edit generated files. - Don't add `# type: ignore` or modify `_generated.py` to silence pyright; fix the source of the mismatch. - New public methods on impl classes need a sync test mirror under `tests/sync/`. -- Keep `expected_api_mismatch.txt` minimal — every entry needs a one-line rationale comment above it. +- Keep `scripts/codegen/python_rules.js` minimal — every entry needs a one-line rationale comment above it. Prefer fixing the upstream docs. - Prefer `locals_to_params(locals())` for forwarding optional kwargs to channel sends, matching the rest of the codebase. ## Commit Convention diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ef51a8f4..dda1526f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,8 +75,11 @@ For more details look at the [CI configuration](./.github/workflows/ci.yml). ### Regenerating APIs -`update_api.sh` generates `api.json` from a nearby `microsoft/playwright` -checkout (at the tag matching `DRIVER_VERSION`); point `PW_SRC_DIR` at it. +`update_api.sh` generates `playwright/{async,sync}_api/_generated.py` from the +API docs in a nearby `microsoft/playwright` checkout (at the release branch +matching `DRIVER_VERSION`); point `PW_SRC_DIR` at it. The generator is +`scripts/codegen/generate_api.js`; Python facts that the docs do not contain +live in `scripts/codegen/python_rules.js`. ```bash PW_SRC_DIR=../playwright ./scripts/update_api.sh diff --git a/ROLLING.md b/ROLLING.md index 63222c38c..567748612 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -13,12 +13,7 @@ pip install -e . ``` * change the driver pin in `DRIVER_VERSION` (the `playwright-core` npm version, e.g. `1.61.0`) and refresh `NODE_VERSION`: `python scripts/update_node_version.py` * download the new driver: `python -m build --wheel` (fetches `playwright-core` via `npm pack` + the matching Node.js binary and assembles the bundle; no source build). -* generate API (needs a nearby `microsoft/playwright` checkout at `v`): `PW_SRC_DIR=../playwright ./scripts/update_api.sh` +* generate API from the docs (needs a nearby `microsoft/playwright` checkout at the release branch for the new version, e.g. `release-1.64`): `PW_SRC_DIR=../playwright ./scripts/update_api.sh` +* run `mypy playwright`: every error in `_generated.py` points at an implementation method in `playwright/_impl/` that lacks a new member or option. Implement it there, or record the difference in `scripts/codegen/python_rules.js`. (Temporary: while `scripts/codegen/legacy_order.json` exists, mypy does not check this yet; rely on the tests.) * commit changes & send PR * wait for bots to pass & merge the PR - - -## Fix typing issues with Playwright ToT - -1. `API_JSON_MODE=1 node ../playwright/utils/doclint/generateApiJson.js > /tmp/api.json` -1. `PW_API_JSON=/tmp/api.json ./scripts/update_api.sh` diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index a3e44d694..64327aad7 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -141,7 +141,7 @@ def service_worker(self) -> typing.Optional["Worker"]: This method is Chromium only. It's safe to call when using other browsers, but it will always be `null`. - Requests originated in a Service Worker do not have a `request.frame()` available. + Requests originated in a Service Worker do not have a `request.frame` available. Returns ------- @@ -282,7 +282,7 @@ def redirected_to(self) -> typing.Optional["Request"]: **Usage** - This method is the opposite of `request.redirected_from()`: + This method is the opposite of `request.redirected_from`: ```py assert request.redirected_from.redirected_to == request @@ -381,7 +381,7 @@ def is_navigation_request(self) -> bool: Whether this request is driving frame's navigation. Some navigation requests are issued before the corresponding frame is created, and therefore do not have - `request.frame()` available. + `request.frame` available. Returns ------- @@ -987,7 +987,7 @@ async def handle(route, request): **Details** The `headers` option applies to both the routed request and any redirects it initiates. However, `url`, `method`, - and `postData` only apply to the original request and are not carried over to redirected requests. + and `post_data` only apply to the original request and are not carried over to redirected requests. `route.continue_()` will immediately send the request to the network, other matching handlers won't be invoked. Use `route.fallback()` If you want next matching handler in the chain to be invoked. @@ -1798,7 +1798,7 @@ async def tap(self, x: float, y: float) -> None: Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). - **NOTE** `touchscreen.tap()` will throw if the `hasTouch` option of the browser context is false. + **NOTE** `touchscreen.tap()` will throw if the `has_touch` option of the browser context is false. Parameters ---------- @@ -1972,18 +1972,6 @@ async def json_value(self) -> typing.Any: class ElementHandle(JSHandle): - def as_element(self) -> typing.Optional["ElementHandle"]: - """ElementHandle.as_element - - Returns either `null` or the object handle itself, if the object handle is an instance of `ElementHandle`. - - Returns - ------- - Union[ElementHandle, None] - """ - - return mapping.from_impl_nullable(self._impl_obj.as_element()) - async def owner_frame(self) -> typing.Optional["Frame"]: """ElementHandle.owner_frame @@ -2148,10 +2136,10 @@ async def dispatch_event( await element_handle.dispatch_event(\"click\") ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -2301,7 +2289,7 @@ async def click( 1. Wait for [actionability](https://playwright.dev/python/docs/actionability) checks on the element, unless `force` option is set. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. If the element is detached from the DOM at any moment during the action, this method throws. @@ -3102,7 +3090,7 @@ async def screenshot( Defaults to `"device"`. mask : Union[Sequence[Locator], None] Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. The mask is also applied to + box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box. The mask is also applied to invisible elements, see [Matching only visible elements](../locators.md#matching-only-visible-elements) to disable that. mask_color : Union[str, None] @@ -3552,7 +3540,7 @@ async def goto( The method will not throw an error when any valid HTTP status code is returned by the remote server, including 404 \"Not Found\" and 500 \"Internal Server Error\". The status code for such responses can be retrieved by calling - `response.status()`. + `response.status`. **NOTE** The method either throws an error or returns a main resource response. The only exceptions are navigation to `about:blank` or navigation to the same URL with a different hash, which would succeed and return `null`. @@ -4232,10 +4220,10 @@ async def dispatch_event( await frame.dispatch_event(\"button#submit\", \"click\") ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -4545,7 +4533,7 @@ async def click( the element is detached during the checks, the whole action is retried. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. @@ -6361,7 +6349,7 @@ def owner(self) -> "Locator": Useful when you have a `FrameLocator` object obtained somewhere, and later on would like to interact with the `iframe` element. - For a reverse operation, use `locator.content_frame()`. + For a reverse operation, use `locator.content_frame`. **Usage** @@ -6428,7 +6416,7 @@ def locator( return mapping.from_impl( self._impl_obj.locator( - selectorOrLocator=selector_or_locator, + selectorOrLocator=mapping.to_impl(selector_or_locator), hasText=has_text, hasNotText=has_not_text, has=has._impl_obj if has else None, @@ -7456,13 +7444,13 @@ async def create( Seeds a virtual WebAuthn credential and returns it. - With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential + With only `rp_id`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey flows. The returned object carries the private and public keys, so it can be persisted to disk and re-seeded in a later test. - To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. - Pass `signCount` as well to continue from the signature counter the relying party has already seen. + To **import a known credential**, supply all four of `id`, `user_handle`, `private_key` and `public_key` together. + Pass `sign_count` as well to continue from the signature counter the relying party has already seen. Call `credentials.install()` before navigating to a page that uses WebAuthn. @@ -7519,7 +7507,7 @@ async def get( ) -> typing.List[VirtualCredential]: """Credentials.get - Returns every credential currently held by the authenticator, optionally filtered by `rpId` or `id`. This includes + Returns every credential currently held by the authenticator, optionally filtered by `rp_id` or `id`. This includes both credentials seeded with `credentials.create()` and credentials the page registered itself by calling `navigator.credentials.create()`. @@ -7550,26 +7538,26 @@ class ConsoleMessage(AsyncBase): @property def type( self, - ) -> typing.Union[ - Literal["assert"], - Literal["clear"], - Literal["count"], - Literal["debug"], - Literal["dir"], - Literal["dirxml"], - Literal["endGroup"], - Literal["error"], - Literal["info"], - Literal["log"], - Literal["profile"], - Literal["profileEnd"], - Literal["startGroup"], - Literal["startGroupCollapsed"], - Literal["table"], - Literal["time"], - Literal["timeEnd"], - Literal["trace"], - Literal["warning"], + ) -> Literal[ + "assert", + "clear", + "count", + "debug", + "dir", + "dirxml", + "endGroup", + "error", + "info", + "log", + "profile", + "profileEnd", + "startGroup", + "startGroupCollapsed", + "table", + "time", + "timeEnd", + "trace", + "warning", ]: """ConsoleMessage.type @@ -7642,7 +7630,7 @@ def worker(self) -> typing.Optional["Worker"]: """ConsoleMessage.worker The web worker or service worker that produced this console message, if any. Note that console messages from web - workers also have non-null `console_message.page()`. + workers also have non-null `console_message.page`. Returns ------- @@ -7907,7 +7895,7 @@ async def path(self) -> pathlib.Path: Returns path to the downloaded file for a successful download, or throws for a failed/canceled download. The method will wait for the download to finish if necessary. The method throws when connected remotely. - Note that the download's file name is a random GUID, use `download.suggested_filename()` to get suggested + Note that the download's file name is a random GUID, use `download.suggested_filename` to get suggested file name. Returns @@ -7965,7 +7953,7 @@ async def start( ) -> "AsyncContextManager": """Screencast.start - Starts the screencast. When `path` is provided, it saves video recording to the specified file. When `onFrame` is + Starts the screencast. When `path` is provided, it saves video recording to the specified file. When `on_frame` is provided, delivers JPEG-encoded frames to the callback. Both can be used together. **Usage** @@ -8048,17 +8036,6 @@ async def show_actions( **Usage** - ```js - await page.screencast.showActions({ - style: { - point: 'width: 20px; height: 20px; border-radius: 50%; background: red', - highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)', - title: 'font-size: 16px', - }, - }); - ``` - - Returns ------- AsyncContextManager @@ -8498,7 +8475,7 @@ def on( ``` **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `page.on('request_finished')` event and not with `page.on('request_failed')`. A request will + will complete with `page.on('requestfinished')` event and not with `page.on('requestfailed')`. A request will only be considered failed when the client cannot get an HTTP response from the server, e.g. due to network error net::ERR_FAILED.""" @@ -8778,7 +8755,7 @@ def once( ``` **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `page.on('request_finished')` event and not with `page.on('request_failed')`. A request will + will complete with `page.on('requestfinished')` event and not with `page.on('requestfailed')`. A request will only be considered failed when the client cannot get an HTTP response from the server, e.g. due to network error net::ERR_FAILED.""" @@ -8987,7 +8964,7 @@ def webmcp(self) -> "WebMCP": """Page.webmcp Tools that the main frame registers through the experimental `WebMCP` browser API. Shortcut for - `frame.webmcp` of `page.main_frame()`, see `WebMCP` for details. + `frame.webmcp` of `page.main_frame`, see `WebMCP` for details. **Usage** @@ -9051,6 +9028,10 @@ def frame( frame = page.frame(name=\"frame-name\") ``` + ```py + frame = page.frame(url=r\".*domain.*\") + ``` + Parameters ---------- name : Union[str, None] @@ -9464,10 +9445,10 @@ async def dispatch_event( await page.dispatch_event(\"button#submit\", \"click\") ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -10020,7 +10001,7 @@ async def goto( The method will not throw an error when any valid HTTP status code is returned by the remote server, including 404 \"Not Found\" and 500 \"Internal Server Error\". The status code for such responses can be retrieved by calling - `response.status()`. + `response.status`. **NOTE** The method either throws an error or returns a main resource response. The only exceptions are navigation to `about:blank` or navigation to the same URL with a different hash, which would succeed and return `null`. @@ -10031,7 +10012,7 @@ async def goto( Parameters ---------- url : str - URL to navigate page to. The url should include scheme, e.g. `https://`. When a `baseURL` via the context options + URL to navigate page to. The url should include scheme, e.g. `https://`. When a `base_url` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. timeout : Union[float, None] @@ -10740,7 +10721,7 @@ async def route( **NOTE** `page.route()` will not intercept requests intercepted by Service Worker. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. **NOTE** `page.route()` will not intercept the first request of a popup page. Use `browser_context.route()` instead. @@ -10789,7 +10770,7 @@ async def handle_route(route: Route): Parameters ---------- url : Union[Callable[[str], bool], Pattern[str], str] - A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `baseURL` is set in + A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `base_url` is set in the context options and the provided URL is a string that does not start with `*`, it is resolved using the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. handler : Union[Callable[[Route, Request], Any], Callable[[Route], Any]] @@ -10871,7 +10852,7 @@ def handler(ws: WebSocketRoute): ---------- url : Union[Callable[[str], bool], Pattern[str], str] Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the - `baseURL` context option. + `base_url` context option. handler : Callable[[WebSocketRoute], Any] Handler function to route the WebSocket. """ @@ -10923,7 +10904,7 @@ async def route_from_har( Playwright will not serve requests intercepted by Service Worker from the HAR file. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. Parameters ---------- @@ -11022,7 +11003,7 @@ async def screenshot( Defaults to `"device"`. mask : Union[Sequence[Locator], None] Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. The mask is also applied to + box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box. The mask is also applied to invisible elements, see [Matching only visible elements](../locators.md#matching-only-visible-elements) to disable that. mask_color : Union[str, None] @@ -11115,12 +11096,12 @@ async def close( ) -> None: """Page.close - If `runBeforeUnload` is `false`, does not run any unload handlers and waits for the page to be closed. If - `runBeforeUnload` is `true` the method will run unload handlers, but will **not** wait for the page to close. + If `run_before_unload` is `false`, does not run any unload handlers and waits for the page to be closed. If + `run_before_unload` is `true` the method will run unload handlers, but will **not** wait for the page to close. By default, `page.close()` **does not** run `beforeunload` handlers. - **NOTE** if `runBeforeUnload` is passed as true, a `beforeunload` dialog might be summoned and should be handled + **NOTE** if `run_before_unload` is passed as true, a `beforeunload` dialog might be summoned and should be handled manually via `page.on('dialog')` event. Parameters @@ -11174,7 +11155,7 @@ async def click( the element is detached during the checks, the whole action is retried. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. @@ -11352,7 +11333,7 @@ async def tap( When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. - **NOTE** `page.tap()` will throw if the `hasTouch` option of the browser context is false. + **NOTE** `page.tap()` will throw if the `has_touch` option of the browser context is false. Parameters ---------- @@ -13010,7 +12991,7 @@ async def pdf( - `A5`: 5.83in x 8.27in - `A6`: 4.13in x 5.83in - **NOTE** `headerTemplate` and `footerTemplate` markup have the following limitations: > 1. Script tags inside + **NOTE** `header_template` and `footer_template` markup have the following limitations: > 1. Script tags inside templates are not evaluated. > 2. Page styles are not visible inside templates. Parameters @@ -13028,7 +13009,7 @@ async def pdf( - `'pageNumber'` current page number - `'totalPages'` total pages in the document footer_template : Union[str, None] - HTML template for the print footer. Should use the same format as the `headerTemplate`. + HTML template for the print footer. Should use the same format as the `header_template`. print_background : Union[bool, None] Print background graphics. Defaults to `false`. landscape : Union[bool, None] @@ -13536,7 +13517,7 @@ def expect_request( Parameters ---------- url_or_predicate : Union[Callable[[Request], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] - Request URL string, regex or predicate receiving `Request` object. When a `baseURL` via the context options was + Request URL string, regex or predicate receiving `Request` object. When a `base_url` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. timeout : Union[float, None] @@ -13565,7 +13546,7 @@ def expect_request_finished( Performs action and waits for a `Request` to finish loading. If predicate is provided, it passes `Request` value into the `predicate` function and waits for `predicate(request)` to return a truthy value. Will throw an error if - the page is closed before the `page.on('request_finished')` event is fired. + the page is closed before the `page.on('requestfinished')` event is fired. Parameters ---------- @@ -13620,7 +13601,7 @@ def expect_response( Parameters ---------- url_or_predicate : Union[Callable[[Response], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] - Request URL string, regex or predicate receiving `Response` object. When a `baseURL` via the context options was + Request URL string, regex or predicate receiving `Response` object. When a `base_url` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. timeout : Union[float, None] @@ -13804,7 +13785,7 @@ async def add_locator_handler( handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't perform any actions, the handler will not be triggered. - After executing the handler, Playwright will ensure that overlay that triggered the handler is not visible - anymore. You can opt-out of this behavior with `noWaitAfter`. + anymore. You can opt-out of this behavior with `no_wait_after`. - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. If your handler takes too long, it might cause timeouts. - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the @@ -13829,50 +13810,50 @@ async def add_locator_handler( ```py # Setup the handler. - def handler(): - page.get_by_role(\"button\", name=\"No thanks\").click() - page.add_locator_handler(page.get_by_text(\"Sign up to the newsletter\"), handler) + async def handler(): + await page.get_by_role(\"button\", name=\"No thanks\").click() + await page.add_locator_handler(page.get_by_text(\"Sign up to the newsletter\"), handler) # Write the test as usual. - page.goto(\"https://example.com\") - page.get_by_role(\"button\", name=\"Start here\").click() + await page.goto(\"https://example.com\") + await page.get_by_role(\"button\", name=\"Start here\").click() ``` An example that skips the \"Confirm your security details\" page when it is shown: ```py # Setup the handler. - def handler(): - page.get_by_role(\"button\", name=\"Remind me later\").click() - page.add_locator_handler(page.get_by_text(\"Confirm your security details\"), handler) + async def handler(): + await page.get_by_role(\"button\", name=\"Remind me later\").click() + await page.add_locator_handler(page.get_by_text(\"Confirm your security details\"), handler) # Write the test as usual. - page.goto(\"https://example.com\") - page.get_by_role(\"button\", name=\"Start here\").click() + await page.goto(\"https://example.com\") + await page.get_by_role(\"button\", name=\"Start here\").click() ``` An example with a custom callback on every actionability check. It uses a `` locator that is always visible, - so the handler is called before every actionability check. It is important to specify `noWaitAfter`, because the + so the handler is called before every actionability check. It is important to specify `no_wait_after`, because the handler does not hide the `` element. ```py # Setup the handler. - def handler(): - page.evaluate(\"window.removeObstructionsForTestIfNeeded()\") - page.add_locator_handler(page.locator(\"body\"), handler, no_wait_after=True) + async def handler(): + await page.evaluate(\"window.removeObstructionsForTestIfNeeded()\") + await page.add_locator_handler(page.locator(\"body\"), handler, no_wait_after=True) # Write the test as usual. - page.goto(\"https://example.com\") - page.get_by_role(\"button\", name=\"Start here\").click() + await page.goto(\"https://example.com\") + await page.get_by_role(\"button\", name=\"Start here\").click() ``` Handler takes the original locator as an argument. You can also automatically remove the handler after a number of invocations by setting `times`: ```py - def handler(locator): - locator.click() - page.add_locator_handler(page.get_by_label(\"Close\"), handler, times=1) + async def handler(locator): + await locator.click() + await page.add_locator_handler(page.get_by_label(\"Close\"), handler, times=1) ``` Parameters @@ -13959,7 +13940,7 @@ async def page_errors( ) -> typing.List["Error"]: """Page.page_errors - Returns up to (currently) 200 last page errors from this page. See `page.on('page_error')` for more details. + Returns up to (currently) 200 last page errors from this page. See `page.on('pageerror')` for more details. Parameters ---------- @@ -14238,7 +14219,7 @@ def on( f: typing.Callable[["Frame"], "typing.Union[typing.Awaitable[None], None]"], ) -> None: """ - Emitted when a frame is attached in any page belonging to this context. See also `page.on('frame_attached')` to + Emitted when a frame is attached in any page belonging to this context. See also `page.on('frameattached')` to receive events about a specific page.""" @typing.overload @@ -14248,7 +14229,7 @@ def on( f: typing.Callable[["Frame"], "typing.Union[typing.Awaitable[None], None]"], ) -> None: """ - Emitted when a frame is detached in any page belonging to this context. See also `page.on('frame_detached')` to + Emitted when a frame is detached in any page belonging to this context. See also `page.on('framedetached')` to receive events about a specific page.""" @typing.overload @@ -14259,7 +14240,7 @@ def on( ) -> None: """ Emitted when a frame is navigated to a new url in any page belonging to this context. See also - `page.on('frame_navigated')` to receive events about navigations in a specific page. + `page.on('framenavigated')` to receive events about navigations in a specific page. """ @typing.overload @@ -14318,7 +14299,7 @@ def on( ) -> None: """ Emitted when exception is unhandled in any of the pages in this context. To listen for errors from a particular - page, use `page.on('page_error')` instead.""" + page, use `page.on('pageerror')` instead.""" @typing.overload def on( @@ -14341,11 +14322,11 @@ def on( ) -> None: """ Emitted when a request fails, for example by timing out. To only listen for failed requests from a particular page, - use `page.on('request_failed')`. + use `page.on('requestfailed')`. **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `browser_context.on('request_finished')` event and not with - `browser_context.on('request_failed')`.""" + will complete with `browser_context.on('requestfinished')` event and not with + `browser_context.on('requestfailed')`.""" @typing.overload def on( @@ -14356,7 +14337,7 @@ def on( """ Emitted when a request finishes successfully after downloading the response body. For a successful response, the sequence of events is `request`, `response` and `requestfinished`. To listen for successful requests from a - particular page, use `page.on('request_finished')`.""" + particular page, use `page.on('requestfinished')`.""" @typing.overload def on( @@ -14486,7 +14467,7 @@ def once( f: typing.Callable[["Frame"], "typing.Union[typing.Awaitable[None], None]"], ) -> None: """ - Emitted when a frame is attached in any page belonging to this context. See also `page.on('frame_attached')` to + Emitted when a frame is attached in any page belonging to this context. See also `page.on('frameattached')` to receive events about a specific page.""" @typing.overload @@ -14496,7 +14477,7 @@ def once( f: typing.Callable[["Frame"], "typing.Union[typing.Awaitable[None], None]"], ) -> None: """ - Emitted when a frame is detached in any page belonging to this context. See also `page.on('frame_detached')` to + Emitted when a frame is detached in any page belonging to this context. See also `page.on('framedetached')` to receive events about a specific page.""" @typing.overload @@ -14507,7 +14488,7 @@ def once( ) -> None: """ Emitted when a frame is navigated to a new url in any page belonging to this context. See also - `page.on('frame_navigated')` to receive events about navigations in a specific page. + `page.on('framenavigated')` to receive events about navigations in a specific page. """ @typing.overload @@ -14566,7 +14547,7 @@ def once( ) -> None: """ Emitted when exception is unhandled in any of the pages in this context. To listen for errors from a particular - page, use `page.on('page_error')` instead.""" + page, use `page.on('pageerror')` instead.""" @typing.overload def once( @@ -14589,11 +14570,11 @@ def once( ) -> None: """ Emitted when a request fails, for example by timing out. To only listen for failed requests from a particular page, - use `page.on('request_failed')`. + use `page.on('requestfailed')`. **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `browser_context.on('request_finished')` event and not with - `browser_context.on('request_failed')`.""" + will complete with `browser_context.on('requestfinished')` event and not with + `browser_context.on('requestfailed')`.""" @typing.overload def once( @@ -14604,7 +14585,7 @@ def once( """ Emitted when a request finishes successfully after downloading the response body. For a successful response, the sequence of events is `request`, `response` and `requestfinished`. To listen for successful requests from a - particular page, use `page.on('request_finished')`.""" + particular page, use `page.on('requestfinished')`.""" @typing.overload def once( @@ -15204,7 +15185,7 @@ async def route( **NOTE** `browser_context.route()` will not intercept requests intercepted by Service Worker. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. **Usage** @@ -15251,7 +15232,7 @@ async def handle_route(route: Route): Parameters ---------- url : Union[Callable[[str], bool], Pattern[str], str] - A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `baseURL` is set in + A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `base_url` is set in the context options and the provided URL is a string that does not start with `*`, it is resolved using the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. handler : Union[Callable[[Route, Request], Any], Callable[[Route], Any]] @@ -15336,7 +15317,7 @@ async def handler(ws: WebSocketRoute): ---------- url : Union[Callable[[str], bool], Pattern[str], str] Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the - `baseURL` context option. + `base_url` context option. handler : Callable[[WebSocketRoute], Any] Handler function to route the WebSocket. """ @@ -15388,7 +15369,7 @@ async def route_from_har( Playwright will not serve requests intercepted by Service Worker from the HAR file. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. Parameters ---------- @@ -15699,7 +15680,7 @@ async def storage_state( credentials : Union[bool, None] Set to `true` to include the context's virtual WebAuthn `browser_context.credentials` (passkeys) in the storage state snapshot. The captured credentials carry their private keys and signature counters, so they can be - re-seeded into a later context via the `storageState` option or `browser_context.set_storage_state()`. Note + re-seeded into a later context via the `storage_state` option or `browser_context.set_storage_state()`. Note that restoring the storage state that contains credentials will automatically install the virtual WebAuthn authenticator (see `credentials.install()`), and prevent all real authenticators from working in this context. @@ -16039,7 +16020,9 @@ async def new_cdp_session( CDPSession """ - return mapping.from_impl(await self._impl_obj.new_cdp_session(page=page)) + return mapping.from_impl( + await self._impl_obj.new_cdp_session(page=mapping.to_impl(page)) + ) mapping.register(BrowserContextImpl, BrowserContext) @@ -16300,7 +16283,7 @@ async def new_context( ] = None, record_har_mode: typing.Optional[Literal["full", "minimal"]] = None, record_har_content: typing.Optional[Literal["attach", "embed", "omit"]] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, record_video_fps: typing.Optional[int] = None, ) -> "BrowserContext": """Browser.new_context @@ -16508,7 +16491,7 @@ async def new_context( recordHarUrlFilter=record_har_url_filter, recordHarMode=record_har_mode, recordHarContent=record_har_content, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), recordVideoFps=record_video_fps, ) ) @@ -16561,7 +16544,7 @@ async def new_page( ] = None, record_har_mode: typing.Optional[Literal["full", "minimal"]] = None, record_har_content: typing.Optional[Literal["attach", "embed", "omit"]] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, record_video_fps: typing.Optional[int] = None, ) -> "Page": """Browser.new_page @@ -16753,7 +16736,7 @@ async def new_page( recordHarUrlFilter=record_har_url_filter, recordHarMode=record_har_mode, recordHarContent=record_har_content, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), recordVideoFps=record_video_fps, ) ) @@ -16963,7 +16946,7 @@ async def launch( **Usage** - You can use `ignoreDefaultArgs` to filter out `--mute-audio` from default arguments: + You can use `ignore_default_args` to filter out `--mute-audio` from default arguments: ```py browser = await playwright.chromium.launch( # or \"firefox\" or \"webkit\". @@ -16973,7 +16956,7 @@ async def launch( > **Chromium-only** Playwright can also be used to control the Google Chrome or Microsoft Edge browsers, but it works best with the version of Chromium it is bundled with. There is no guarantee it will work with any other - version. Use `executablePath` option with extreme caution. + version. Use `executable_path` option with extreme caution. > > If Google Chrome (rather than Chromium) is preferred, a [Chrome Canary](https://www.google.com/chrome/browser/canary.html) or @@ -16989,7 +16972,7 @@ async def launch( Parameters ---------- executable_path : Union[pathlib.Path, str, None] - Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is + Path to a browser executable to run instead of the bundled one. If `executable_path` is a relative path, then it is resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. channel : Union[str, None] @@ -17138,14 +17121,14 @@ async def launch_persistent_context( ] = None, record_har_mode: typing.Optional[Literal["full", "minimal"]] = None, record_har_content: typing.Optional[Literal["attach", "embed", "omit"]] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, record_video_fps: typing.Optional[int] = None, ) -> "BrowserContext": """BrowserType.launch_persistent_context Returns the persistent browser context instance. - Launches browser that uses persistent storage located at `userDataDir` and returns the only context. Closing this + Launches browser that uses persistent storage located at `user_data_dir` and returns the only context. Closing this context will automatically close the browser. Parameters @@ -17175,7 +17158,7 @@ async def launch_persistent_context( Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or "msedge-canary" to use branded [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). executable_path : Union[pathlib.Path, str, None] - Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is + Path to a browser executable to run instead of the bundled one. If `executable_path` is a relative path, then it is resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. args : Union[Sequence[str], None] @@ -17411,7 +17394,7 @@ async def launch_persistent_context( recordHarUrlFilter=record_har_url_filter, recordHarMode=record_har_mode, recordHarContent=record_har_content, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), recordVideoFps=record_video_fps, ) ) @@ -17431,7 +17414,7 @@ async def connect_over_cdp( This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol. - The default browser context is accessible via `browser.contexts()`. + The default browser context is accessible via `browser.contexts`. **NOTE** Connecting over the Chrome DevTools Protocol is only supported for Chromium-based browsers. @@ -17469,9 +17452,9 @@ async def connect_over_cdp( upon the file system being the same between Playwright and the Browser. no_defaults : Union[bool, None] When true, Playwright will not apply its default overrides to the existing default browser context. Specifically, - `acceptDownloads` is left at the browser's setting, focus emulation is not enabled, and media emulation options - (such as `colorScheme`, `reducedMotion`, `forcedColors`, and `contrast`) are not applied. Useful when attaching to - a user's daily-driver browser where these overrides would interfere with existing browser state. New contexts + `accept_downloads` is left at the browser's setting, focus emulation is not enabled, and media emulation options + (such as `color_scheme`, `reduced_motion`, `forced_colors`, and `contrast`) are not applied. Useful when attaching + to a user's daily-driver browser where these overrides would interfere with existing browser state. New contexts created via `browser.new_context()` are not affected. Defaults to `false`. artifacts_dir : Union[pathlib.Path, str, None] If specified, browser artifacts (such as traces and downloads) are saved into this directory. @@ -17651,7 +17634,7 @@ def request(self) -> "APIRequest": def __getitem__(self, value: str) -> "BrowserType": - return mapping.from_impl(self._impl_obj.__getitem__(value=value)) + return mapping.from_impl(self._impl_obj.__getitem__(value)) async def stop(self) -> None: """Playwright.stop @@ -17721,7 +17704,7 @@ async def start( ---------- name : Union[str, None] If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the - `tracesDir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you + `traces_dir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you need to pass `path` option to `tracing.stop()` instead. title : Union[str, None] Trace name to be shown in the Trace Viewer. @@ -17792,7 +17775,7 @@ async def start_chunk( Trace name to be shown in the Trace Viewer. name : Union[str, None] If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the - `tracesDir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you + `traces_dir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you need to pass `path` option to `tracing.stop_chunk()` instead. Returns @@ -17849,10 +17832,10 @@ async def group( ```py # All actions between group and group_end # will be shown in the trace viewer as a group. - page.context.tracing.group(\"Open Playwright.dev > API\") - page.goto(\"https://playwright.dev/\") - page.get_by_role(\"link\", name=\"API\").click() - page.context.tracing.group_end() + await page.context.tracing.group(\"Open Playwright.dev > API\") + await page.goto(\"https://playwright.dev/\") + await page.get_by_role(\"link\", name=\"API\").click() + await page.context.tracing.group_end() ``` Parameters @@ -17996,7 +17979,7 @@ def content_frame(self) -> "FrameLocator": Useful when you have a `Locator` object obtained somewhere, and later on would like to interact with the content inside the frame. - For a reverse operation, use `frame_locator.owner()`. + For a reverse operation, use `frame_locator.owner`. **Usage** @@ -18044,7 +18027,7 @@ def visible(self) -> "Locator": This is the recommended way to distinguish elements by visibility, as opposed to the `:visible` CSS pseudo-class. Note that visibility is checked every time the locator is used, and not at the moment of the - `locator.visible()` call. + `locator.visible` call. **Usage** @@ -18210,7 +18193,7 @@ async def click( 1. Wait for [actionability](https://playwright.dev/python/docs/actionability) checks on the element, unless `force` option is set. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. If the element is detached from the DOM at any moment during the action, this method throws. @@ -18397,10 +18380,10 @@ async def dispatch_event( `click` is dispatched. This is equivalent to calling [element.click()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click). - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -18731,7 +18714,7 @@ def locator( return mapping.from_impl( self._impl_obj.locator( - selectorOrLocator=selector_or_locator, + selectorOrLocator=mapping.to_impl(selector_or_locator), hasText=has_text, hasNotText=has_not_text, has=has._impl_obj if has else None, @@ -19366,7 +19349,7 @@ def filter( Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. visible : Union[bool, None] - Only matches visible or invisible elements. Prefer the `locator.visible()` shortcut when matching only + Only matches visible or invisible elements. Prefer the `locator.visible` shortcut when matching only visible elements. Returns @@ -19399,7 +19382,7 @@ def or_(self, locator: "Locator") -> "Locator": **NOTE** If both \"New email\" button and security dialog appear on screen, the \"or\" locator will match both of them, possibly throwing the [\"strict mode violation\" error](https://playwright.dev/python/docs/locators#strictness). In this case, you can use - `locator.first()` to only match one of them. + `locator.first` to only match one of them. ```py new_email = page.get_by_role(\"button\", name=\"New\") @@ -19452,7 +19435,7 @@ def within(self, locator: "Locator") -> "Locator": Returns a locator that matches this locator's elements inside each element matched by `locator`. - Note that relative locators, such as `locator.nth()` or `locator.first()`, are resolved separately + Note that relative locators, such as `locator.nth()` or `locator.first`, are resolved separately inside each matched parent. In the example below, `page.getByRole('cell').nth(2)` picks the third cell of every row, not the third cell in the whole table. @@ -19834,7 +19817,7 @@ async def inner_text( Returns the [`element.innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText). **NOTE** If you need to assert text on the page, prefer `locator_assertions.to_have_text()` with - `useInnerText` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. + `use_inner_text` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. Parameters ---------- @@ -20233,7 +20216,7 @@ async def screenshot( Defaults to `"device"`. mask : Union[Sequence[Locator], None] Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. The mask is also applied to + box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box. The mask is also applied to invisible elements, see [Matching only visible elements](../locators.md#matching-only-visible-elements) to disable that. mask_color : Union[str, None] @@ -20858,7 +20841,7 @@ async def all_inner_texts(self) -> typing.List[str]: Returns an array of `node.innerText` values for all matching nodes. **NOTE** If you need to assert text on the page, prefer `locator_assertions.to_have_text()` with - `useInnerText` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. + `use_inner_text` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. **Usage** @@ -21262,8 +21245,8 @@ def tracing(self) -> "Tracing": async def dispose(self, *, reason: typing.Optional[str] = None) -> None: """APIRequestContext.dispose - All responses returned by `a_pi_request_context.get()` and similar methods are stored in the memory, so that - you can later call `a_pi_response.body()`.This method discards all its resources, calling any method on + All responses returned by `api_request_context.get()` and similar methods are stored in the memory, so that + you can later call `api_response.body()`.This method discards all its resources, calling any method on disposed `APIRequestContext` will throw an exception. Parameters @@ -21854,6 +21837,14 @@ async def post( `application/x-www-form-urlencoded` encoding (see below how to use `multipart/form-data` form encoding to send files): + ```python + formData = { + \"title\": \"Book Title\", + \"body\": \"John Doe\", + } + api_request_context.post(\"https://example.com/api/findBook\", form=formData) + ``` + The common way to send file(s) in the body of a request is to upload them as form fields with `multipart/form-data` encoding. Use `FormData` to construct request body and pass it to the request as `multipart` parameter: @@ -21972,6 +21963,18 @@ async def fetch( The common way to send file(s) in the body of a request is to upload them as form fields with `multipart/form-data` encoding, by specifiying the `multipart` parameter: + ```python + api_request_context.fetch( + \"https://example.com/api/uploadScript\", method=\"post\", + multipart={ + \"fileField\": { + \"name\": \"f.js\", + \"mimeType\": \"text/javascript\", + \"buffer\": b\"console.log(2022);\", + }, + }) + ``` + Parameters ---------- url_or_request : Union[Request, str] @@ -22019,7 +22022,7 @@ async def fetch( return mapping.from_impl( await self._impl_obj.fetch( - urlOrRequest=url_or_request, + urlOrRequest=mapping.to_impl(url_or_request), params=mapping.to_impl(params), method=method, headers=mapping.to_impl(headers), @@ -22088,7 +22091,7 @@ async def new_context( storage_state: typing.Optional[ typing.Union[StorageState, str, pathlib.Path] ] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, fail_on_status_code: typing.Optional[bool] = None, max_redirects: typing.Optional[int] = None, ) -> "APIRequestContext": @@ -22099,7 +22102,7 @@ async def new_context( Parameters ---------- base_url : Union[str, None] - Methods like `a_pi_request_context.get()` take the base URL into consideration by using the + Methods like `api_request_context.get()` take the base URL into consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. Examples: - baseURL: `http://localhost:3000` and sending request to `/bar.html` results in `http://localhost:3000/bar.html` @@ -22126,9 +22129,9 @@ async def new_context( timeout. storage_state : Union[pathlib.Path, str, {cookies: Sequence[{name: str, value: str, domain: str, path: str, expires: float, httpOnly: bool, secure: bool, sameSite: Union["Lax", "None", "Strict"]}], origins: Sequence[{origin: str, localStorage: Sequence[{name: str, value: str}]}]}, None] Populates context with given storage state. This option can be used to initialize context with logged-in - information obtained via `browser_context.storage_state()` or `a_pi_request_context.storage_state()`. + information obtained via `browser_context.storage_state()` or `api_request_context.storage_state()`. Either a path to the file with saved storage, or the value returned by one of - `browser_context.storage_state()` or `a_pi_request_context.storage_state()` methods. + `browser_context.storage_state()` or `api_request_context.storage_state()` methods. client_certificates : Union[Sequence[{origin: str, certPath: Union[pathlib.Path, str, None], cert: Union[bytes, None], keyPath: Union[pathlib.Path, str, None], key: Union[bytes, None], pfxPath: Union[pathlib.Path, str, None], pfx: Union[bytes, None], passphrase: Union[str, None]}], None] TLS Client Authentication allows the server to request a client certificate and verify it. @@ -22169,7 +22172,7 @@ async def new_context( userAgent=user_agent, timeout=to_milliseconds(timeout), storageState=storage_state, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), failOnStatusCode=fail_on_status_code, maxRedirects=max_redirects, ) @@ -22269,7 +22272,7 @@ async def to_have_url( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression parameter if specified. A provided predicate ignores this flag. """ __tracebackhide__ = True @@ -22300,7 +22303,7 @@ async def not_to_have_url( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -22455,7 +22458,7 @@ async def to_contain_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -22496,7 +22499,7 @@ async def not_to_contain_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -22540,7 +22543,7 @@ async def to_have_attribute( value : Union[Pattern[str], str, None] Expected attribute value. If not specified, the assertion verifies that the attribute is present. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -22575,7 +22578,7 @@ async def not_to_have_attribute( value : Union[Pattern[str], str, None] Expected attribute value. If not specified, the assertion verifies that the attribute is absent. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -23235,7 +23238,7 @@ async def to_have_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -23276,7 +23279,7 @@ async def not_to_have_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -23682,7 +23685,7 @@ async def to_be_visible( Ensures that `Locator` points to an attached and [visible](https://playwright.dev/python/docs/actionability#visible) DOM node. - To check that at least one element from the list is visible, use `locator.first()`. + To check that at least one element from the list is visible, use `locator.first`. **Usage** @@ -23877,7 +23880,7 @@ async def to_have_accessible_description( description : Union[Pattern[str], str] Expected accessible description. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -23908,7 +23911,7 @@ async def not_to_have_accessible_description( name : Union[Pattern[str], str] Expected accessible description. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -23945,7 +23948,7 @@ async def to_have_accessible_name( name : Union[Pattern[str], str] Expected accessible name. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -23974,7 +23977,7 @@ async def not_to_have_accessible_name( name : Union[Pattern[str], str] Expected accessible name. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24129,7 +24132,7 @@ async def to_have_accessible_error_message( error_message : Union[Pattern[str], str] Expected accessible error message. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24160,7 +24163,7 @@ async def not_to_have_accessible_error_message( error_message : Union[Pattern[str], str] Expected accessible error message. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24368,7 +24371,7 @@ async def to_be_ok(self) -> None: async def not_to_be_ok(self) -> None: """APIResponseAssertions.not_to_be_ok - The opposite of `a_pi_response_assertions.to_be_ok()`. + The opposite of `api_response_assertions.to_be_ok()`. """ __tracebackhide__ = True diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 3df68aba3..006b69b30 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -141,7 +141,7 @@ def service_worker(self) -> typing.Optional["Worker"]: This method is Chromium only. It's safe to call when using other browsers, but it will always be `null`. - Requests originated in a Service Worker do not have a `request.frame()` available. + Requests originated in a Service Worker do not have a `request.frame` available. Returns ------- @@ -282,7 +282,7 @@ def redirected_to(self) -> typing.Optional["Request"]: **Usage** - This method is the opposite of `request.redirected_from()`: + This method is the opposite of `request.redirected_from`: ```py assert request.redirected_from.redirected_to == request @@ -381,7 +381,7 @@ def is_navigation_request(self) -> bool: Whether this request is driving frame's navigation. Some navigation requests are issued before the corresponding frame is created, and therefore do not have - `request.frame()` available. + `request.frame` available. Returns ------- @@ -1001,7 +1001,7 @@ def handle(route, request): **Details** The `headers` option applies to both the routed request and any redirects it initiates. However, `url`, `method`, - and `postData` only apply to the original request and are not carried over to redirected requests. + and `post_data` only apply to the original request and are not carried over to redirected requests. `route.continue_()` will immediately send the request to the network, other matching handlers won't be invoked. Use `route.fallback()` If you want next matching handler in the chain to be invoked. @@ -1798,7 +1798,7 @@ def tap(self, x: float, y: float) -> None: Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). - **NOTE** `touchscreen.tap()` will throw if the `hasTouch` option of the browser context is false. + **NOTE** `touchscreen.tap()` will throw if the `has_touch` option of the browser context is false. Parameters ---------- @@ -1974,18 +1974,6 @@ def json_value(self) -> typing.Any: class ElementHandle(JSHandle): - def as_element(self) -> typing.Optional["ElementHandle"]: - """ElementHandle.as_element - - Returns either `null` or the object handle itself, if the object handle is an instance of `ElementHandle`. - - Returns - ------- - Union[ElementHandle, None] - """ - - return mapping.from_impl_nullable(self._impl_obj.as_element()) - def owner_frame(self) -> typing.Optional["Frame"]: """ElementHandle.owner_frame @@ -2152,10 +2140,10 @@ def dispatch_event( element_handle.dispatch_event(\"click\") ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -2311,7 +2299,7 @@ def click( 1. Wait for [actionability](https://playwright.dev/python/docs/actionability) checks on the element, unless `force` option is set. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. If the element is detached from the DOM at any moment during the action, this method throws. @@ -3136,7 +3124,7 @@ def screenshot( Defaults to `"device"`. mask : Union[Sequence[Locator], None] Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. The mask is also applied to + box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box. The mask is also applied to invisible elements, see [Matching only visible elements](../locators.md#matching-only-visible-elements) to disable that. mask_color : Union[str, None] @@ -3598,7 +3586,7 @@ def goto( The method will not throw an error when any valid HTTP status code is returned by the remote server, including 404 \"Not Found\" and 500 \"Internal Server Error\". The status code for such responses can be retrieved by calling - `response.status()`. + `response.status`. **NOTE** The method either throws an error or returns a main resource response. The only exceptions are navigation to `about:blank` or navigation to the same URL with a different hash, which would succeed and return `null`. @@ -4291,10 +4279,10 @@ def dispatch_event( frame.dispatch_event(\"button#submit\", \"click\") ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -4616,7 +4604,7 @@ def click( the element is detached during the checks, the whole action is retried. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. @@ -6471,7 +6459,7 @@ def owner(self) -> "Locator": Useful when you have a `FrameLocator` object obtained somewhere, and later on would like to interact with the `iframe` element. - For a reverse operation, use `locator.content_frame()`. + For a reverse operation, use `locator.content_frame`. **Usage** @@ -6538,7 +6526,7 @@ def locator( return mapping.from_impl( self._impl_obj.locator( - selectorOrLocator=selector_or_locator, + selectorOrLocator=mapping.to_impl(selector_or_locator), hasText=has_text, hasNotText=has_not_text, has=has._impl_obj if has else None, @@ -7550,13 +7538,13 @@ def create( Seeds a virtual WebAuthn credential and returns it. - With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential + With only `rp_id`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey flows. The returned object carries the private and public keys, so it can be persisted to disk and re-seeded in a later test. - To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. - Pass `signCount` as well to continue from the signature counter the relying party has already seen. + To **import a known credential**, supply all four of `id`, `user_handle`, `private_key` and `public_key` together. + Pass `sign_count` as well to continue from the signature counter the relying party has already seen. Call `credentials.install()` before navigating to a page that uses WebAuthn. @@ -7615,7 +7603,7 @@ def get( ) -> typing.List[VirtualCredential]: """Credentials.get - Returns every credential currently held by the authenticator, optionally filtered by `rpId` or `id`. This includes + Returns every credential currently held by the authenticator, optionally filtered by `rp_id` or `id`. This includes both credentials seeded with `credentials.create()` and credentials the page registered itself by calling `navigator.credentials.create()`. @@ -7646,26 +7634,26 @@ class ConsoleMessage(SyncBase): @property def type( self, - ) -> typing.Union[ - Literal["assert"], - Literal["clear"], - Literal["count"], - Literal["debug"], - Literal["dir"], - Literal["dirxml"], - Literal["endGroup"], - Literal["error"], - Literal["info"], - Literal["log"], - Literal["profile"], - Literal["profileEnd"], - Literal["startGroup"], - Literal["startGroupCollapsed"], - Literal["table"], - Literal["time"], - Literal["timeEnd"], - Literal["trace"], - Literal["warning"], + ) -> Literal[ + "assert", + "clear", + "count", + "debug", + "dir", + "dirxml", + "endGroup", + "error", + "info", + "log", + "profile", + "profileEnd", + "startGroup", + "startGroupCollapsed", + "table", + "time", + "timeEnd", + "trace", + "warning", ]: """ConsoleMessage.type @@ -7738,7 +7726,7 @@ def worker(self) -> typing.Optional["Worker"]: """ConsoleMessage.worker The web worker or service worker that produced this console message, if any. Note that console messages from web - workers also have non-null `console_message.page()`. + workers also have non-null `console_message.page`. Returns ------- @@ -7985,7 +7973,7 @@ def path(self) -> pathlib.Path: Returns path to the downloaded file for a successful download, or throws for a failed/canceled download. The method will wait for the download to finish if necessary. The method throws when connected remotely. - Note that the download's file name is a random GUID, use `download.suggested_filename()` to get suggested + Note that the download's file name is a random GUID, use `download.suggested_filename` to get suggested file name. Returns @@ -8043,7 +8031,7 @@ def start( ) -> "SyncContextManager": """Screencast.start - Starts the screencast. When `path` is provided, it saves video recording to the specified file. When `onFrame` is + Starts the screencast. When `path` is provided, it saves video recording to the specified file. When `on_frame` is provided, delivers JPEG-encoded frames to the callback. Both can be used together. **Usage** @@ -8128,17 +8116,6 @@ def show_actions( **Usage** - ```js - await page.screencast.showActions({ - style: { - point: 'width: 20px; height: 20px; border-radius: 50%; background: red', - highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)', - title: 'font-size: 16px', - }, - }); - ``` - - Returns ------- SyncContextManager @@ -8538,7 +8515,7 @@ def on( ``` **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `page.on('request_finished')` event and not with `page.on('request_failed')`. A request will + will complete with `page.on('requestfinished')` event and not with `page.on('requestfailed')`. A request will only be considered failed when the client cannot get an HTTP response from the server, e.g. due to network error net::ERR_FAILED.""" @@ -8768,7 +8745,7 @@ def once( ``` **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `page.on('request_finished')` event and not with `page.on('request_failed')`. A request will + will complete with `page.on('requestfinished')` event and not with `page.on('requestfailed')`. A request will only be considered failed when the client cannot get an HTTP response from the server, e.g. due to network error net::ERR_FAILED.""" @@ -8965,7 +8942,7 @@ def webmcp(self) -> "WebMCP": """Page.webmcp Tools that the main frame registers through the experimental `WebMCP` browser API. Shortcut for - `frame.webmcp` of `page.main_frame()`, see `WebMCP` for details. + `frame.webmcp` of `page.main_frame`, see `WebMCP` for details. **Usage** @@ -9025,6 +9002,10 @@ def frame( **Usage** + ```py + frame = page.frame(name=\"frame-name\") + ``` + ```py frame = page.frame(url=r\".*domain.*\") ``` @@ -9453,10 +9434,10 @@ def dispatch_event( page.dispatch_event(\"button#submit\", \"click\") ``` - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -10021,7 +10002,7 @@ def goto( The method will not throw an error when any valid HTTP status code is returned by the remote server, including 404 \"Not Found\" and 500 \"Internal Server Error\". The status code for such responses can be retrieved by calling - `response.status()`. + `response.status`. **NOTE** The method either throws an error or returns a main resource response. The only exceptions are navigation to `about:blank` or navigation to the same URL with a different hash, which would succeed and return `null`. @@ -10032,7 +10013,7 @@ def goto( Parameters ---------- url : str - URL to navigate page to. The url should include scheme, e.g. `https://`. When a `baseURL` via the context options + URL to navigate page to. The url should include scheme, e.g. `https://`. When a `base_url` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. timeout : Union[float, None] @@ -10757,7 +10738,7 @@ def route( **NOTE** `page.route()` will not intercept requests intercepted by Service Worker. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. **NOTE** `page.route()` will not intercept the first request of a popup page. Use `browser_context.route()` instead. @@ -10806,7 +10787,7 @@ def handle_route(route: Route): Parameters ---------- url : Union[Callable[[str], bool], Pattern[str], str] - A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `baseURL` is set in + A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `base_url` is set in the context options and the provided URL is a string that does not start with `*`, it is resolved using the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. handler : Union[Callable[[Route, Request], Any], Callable[[Route], Any]] @@ -10892,7 +10873,7 @@ def handler(ws: WebSocketRoute): ---------- url : Union[Callable[[str], bool], Pattern[str], str] Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the - `baseURL` context option. + `base_url` context option. handler : Callable[[WebSocketRoute], Any] Handler function to route the WebSocket. """ @@ -10946,7 +10927,7 @@ def route_from_har( Playwright will not serve requests intercepted by Service Worker from the HAR file. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. Parameters ---------- @@ -11047,7 +11028,7 @@ def screenshot( Defaults to `"device"`. mask : Union[Sequence[Locator], None] Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. The mask is also applied to + box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box. The mask is also applied to invisible elements, see [Matching only visible elements](../locators.md#matching-only-visible-elements) to disable that. mask_color : Union[str, None] @@ -11147,12 +11128,12 @@ def close( ) -> None: """Page.close - If `runBeforeUnload` is `false`, does not run any unload handlers and waits for the page to be closed. If - `runBeforeUnload` is `true` the method will run unload handlers, but will **not** wait for the page to close. + If `run_before_unload` is `false`, does not run any unload handlers and waits for the page to be closed. If + `run_before_unload` is `true` the method will run unload handlers, but will **not** wait for the page to close. By default, `page.close()` **does not** run `beforeunload` handlers. - **NOTE** if `runBeforeUnload` is passed as true, a `beforeunload` dialog might be summoned and should be handled + **NOTE** if `run_before_unload` is passed as true, a `beforeunload` dialog might be summoned and should be handled manually via `page.on('dialog')` event. Parameters @@ -11208,7 +11189,7 @@ def click( the element is detached during the checks, the whole action is retried. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. @@ -11390,7 +11371,7 @@ def tap( When all steps combined have not finished during the specified `timeout`, this method throws a `TimeoutError`. Passing zero timeout disables this. - **NOTE** `page.tap()` will throw if the `hasTouch` option of the browser context is false. + **NOTE** `page.tap()` will throw if the `has_touch` option of the browser context is false. Parameters ---------- @@ -13081,7 +13062,7 @@ def pdf( - `A5`: 5.83in x 8.27in - `A6`: 4.13in x 5.83in - **NOTE** `headerTemplate` and `footerTemplate` markup have the following limitations: > 1. Script tags inside + **NOTE** `header_template` and `footer_template` markup have the following limitations: > 1. Script tags inside templates are not evaluated. > 2. Page styles are not visible inside templates. Parameters @@ -13099,7 +13080,7 @@ def pdf( - `'pageNumber'` current page number - `'totalPages'` total pages in the document footer_template : Union[str, None] - HTML template for the print footer. Should use the same format as the `headerTemplate`. + HTML template for the print footer. Should use the same format as the `header_template`. print_background : Union[bool, None] Print background graphics. Defaults to `false`. landscape : Union[bool, None] @@ -13607,7 +13588,7 @@ def expect_request( Parameters ---------- url_or_predicate : Union[Callable[[Request], bool], Pattern[str], str] - Request URL string, regex or predicate receiving `Request` object. When a `baseURL` via the context options was + Request URL string, regex or predicate receiving `Request` object. When a `base_url` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. timeout : Union[float, None] @@ -13636,7 +13617,7 @@ def expect_request_finished( Performs action and waits for a `Request` to finish loading. If predicate is provided, it passes `Request` value into the `predicate` function and waits for `predicate(request)` to return a truthy value. Will throw an error if - the page is closed before the `page.on('request_finished')` event is fired. + the page is closed before the `page.on('requestfinished')` event is fired. Parameters ---------- @@ -13689,7 +13670,7 @@ def expect_response( Parameters ---------- url_or_predicate : Union[Callable[[Response], bool], Pattern[str], str] - Request URL string, regex or predicate receiving `Response` object. When a `baseURL` via the context options was + Request URL string, regex or predicate receiving `Response` object. When a `base_url` via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. timeout : Union[float, None] @@ -13875,7 +13856,7 @@ def add_locator_handler( handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't perform any actions, the handler will not be triggered. - After executing the handler, Playwright will ensure that overlay that triggered the handler is not visible - anymore. You can opt-out of this behavior with `noWaitAfter`. + anymore. You can opt-out of this behavior with `no_wait_after`. - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. If your handler takes too long, it might cause timeouts. - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the @@ -13900,50 +13881,50 @@ def add_locator_handler( ```py # Setup the handler. - async def handler(): - await page.get_by_role(\"button\", name=\"No thanks\").click() - await page.add_locator_handler(page.get_by_text(\"Sign up to the newsletter\"), handler) + def handler(): + page.get_by_role(\"button\", name=\"No thanks\").click() + page.add_locator_handler(page.get_by_text(\"Sign up to the newsletter\"), handler) # Write the test as usual. - await page.goto(\"https://example.com\") - await page.get_by_role(\"button\", name=\"Start here\").click() + page.goto(\"https://example.com\") + page.get_by_role(\"button\", name=\"Start here\").click() ``` An example that skips the \"Confirm your security details\" page when it is shown: ```py # Setup the handler. - async def handler(): - await page.get_by_role(\"button\", name=\"Remind me later\").click() - await page.add_locator_handler(page.get_by_text(\"Confirm your security details\"), handler) + def handler(): + page.get_by_role(\"button\", name=\"Remind me later\").click() + page.add_locator_handler(page.get_by_text(\"Confirm your security details\"), handler) # Write the test as usual. - await page.goto(\"https://example.com\") - await page.get_by_role(\"button\", name=\"Start here\").click() + page.goto(\"https://example.com\") + page.get_by_role(\"button\", name=\"Start here\").click() ``` An example with a custom callback on every actionability check. It uses a `` locator that is always visible, - so the handler is called before every actionability check. It is important to specify `noWaitAfter`, because the + so the handler is called before every actionability check. It is important to specify `no_wait_after`, because the handler does not hide the `` element. ```py # Setup the handler. - async def handler(): - await page.evaluate(\"window.removeObstructionsForTestIfNeeded()\") - await page.add_locator_handler(page.locator(\"body\"), handler, no_wait_after=True) + def handler(): + page.evaluate(\"window.removeObstructionsForTestIfNeeded()\") + page.add_locator_handler(page.locator(\"body\"), handler, no_wait_after=True) # Write the test as usual. - await page.goto(\"https://example.com\") - await page.get_by_role(\"button\", name=\"Start here\").click() + page.goto(\"https://example.com\") + page.get_by_role(\"button\", name=\"Start here\").click() ``` Handler takes the original locator as an argument. You can also automatically remove the handler after a number of invocations by setting `times`: ```py - async def handler(locator): - await locator.click() - await page.add_locator_handler(page.get_by_label(\"Close\"), handler, times=1) + def handler(locator): + locator.click() + page.add_locator_handler(page.get_by_label(\"Close\"), handler, times=1) ``` Parameters @@ -14032,7 +14013,7 @@ def page_errors( ) -> typing.List["Error"]: """Page.page_errors - Returns up to (currently) 200 last page errors from this page. See `page.on('page_error')` for more details. + Returns up to (currently) 200 last page errors from this page. See `page.on('pageerror')` for more details. Parameters ---------- @@ -14297,7 +14278,7 @@ def on( self, event: Literal["frameattached"], f: typing.Callable[["Frame"], "None"] ) -> None: """ - Emitted when a frame is attached in any page belonging to this context. See also `page.on('frame_attached')` to + Emitted when a frame is attached in any page belonging to this context. See also `page.on('frameattached')` to receive events about a specific page.""" @typing.overload @@ -14305,7 +14286,7 @@ def on( self, event: Literal["framedetached"], f: typing.Callable[["Frame"], "None"] ) -> None: """ - Emitted when a frame is detached in any page belonging to this context. See also `page.on('frame_detached')` to + Emitted when a frame is detached in any page belonging to this context. See also `page.on('framedetached')` to receive events about a specific page.""" @typing.overload @@ -14314,7 +14295,7 @@ def on( ) -> None: """ Emitted when a frame is navigated to a new url in any page belonging to this context. See also - `page.on('frame_navigated')` to receive events about navigations in a specific page. + `page.on('framenavigated')` to receive events about navigations in a specific page. """ @typing.overload @@ -14363,7 +14344,7 @@ def on( ) -> None: """ Emitted when exception is unhandled in any of the pages in this context. To listen for errors from a particular - page, use `page.on('page_error')` instead.""" + page, use `page.on('pageerror')` instead.""" @typing.overload def on( @@ -14382,11 +14363,11 @@ def on( ) -> None: """ Emitted when a request fails, for example by timing out. To only listen for failed requests from a particular page, - use `page.on('request_failed')`. + use `page.on('requestfailed')`. **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `browser_context.on('request_finished')` event and not with - `browser_context.on('request_failed')`.""" + will complete with `browser_context.on('requestfinished')` event and not with + `browser_context.on('requestfailed')`.""" @typing.overload def on( @@ -14395,7 +14376,7 @@ def on( """ Emitted when a request finishes successfully after downloading the response body. For a successful response, the sequence of events is `request`, `response` and `requestfinished`. To listen for successful requests from a - particular page, use `page.on('request_finished')`.""" + particular page, use `page.on('requestfinished')`.""" @typing.overload def on( @@ -14497,7 +14478,7 @@ def once( self, event: Literal["frameattached"], f: typing.Callable[["Frame"], "None"] ) -> None: """ - Emitted when a frame is attached in any page belonging to this context. See also `page.on('frame_attached')` to + Emitted when a frame is attached in any page belonging to this context. See also `page.on('frameattached')` to receive events about a specific page.""" @typing.overload @@ -14505,7 +14486,7 @@ def once( self, event: Literal["framedetached"], f: typing.Callable[["Frame"], "None"] ) -> None: """ - Emitted when a frame is detached in any page belonging to this context. See also `page.on('frame_detached')` to + Emitted when a frame is detached in any page belonging to this context. See also `page.on('framedetached')` to receive events about a specific page.""" @typing.overload @@ -14514,7 +14495,7 @@ def once( ) -> None: """ Emitted when a frame is navigated to a new url in any page belonging to this context. See also - `page.on('frame_navigated')` to receive events about navigations in a specific page. + `page.on('framenavigated')` to receive events about navigations in a specific page. """ @typing.overload @@ -14565,7 +14546,7 @@ def once( ) -> None: """ Emitted when exception is unhandled in any of the pages in this context. To listen for errors from a particular - page, use `page.on('page_error')` instead.""" + page, use `page.on('pageerror')` instead.""" @typing.overload def once( @@ -14584,11 +14565,11 @@ def once( ) -> None: """ Emitted when a request fails, for example by timing out. To only listen for failed requests from a particular page, - use `page.on('request_failed')`. + use `page.on('requestfailed')`. **NOTE** HTTP Error responses, such as 404 or 503, are still successful responses from HTTP standpoint, so request - will complete with `browser_context.on('request_finished')` event and not with - `browser_context.on('request_failed')`.""" + will complete with `browser_context.on('requestfinished')` event and not with + `browser_context.on('requestfailed')`.""" @typing.overload def once( @@ -14597,7 +14578,7 @@ def once( """ Emitted when a request finishes successfully after downloading the response body. For a successful response, the sequence of events is `request`, `response` and `requestfinished`. To listen for successful requests from a - particular page, use `page.on('request_finished')`.""" + particular page, use `page.on('requestfinished')`.""" @typing.overload def once( @@ -15189,7 +15170,7 @@ def route( **NOTE** `browser_context.route()` will not intercept requests intercepted by Service Worker. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. **Usage** @@ -15237,7 +15218,7 @@ def handle_route(route: Route): Parameters ---------- url : Union[Callable[[str], bool], Pattern[str], str] - A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `baseURL` is set in + A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If `base_url` is set in the context options and the provided URL is a string that does not start with `*`, it is resolved using the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. handler : Union[Callable[[Route, Request], Any], Callable[[Route], Any]] @@ -15326,7 +15307,7 @@ def handler(ws: WebSocketRoute): ---------- url : Union[Callable[[str], bool], Pattern[str], str] Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the - `baseURL` context option. + `base_url` context option. handler : Callable[[WebSocketRoute], Any] Handler function to route the WebSocket. """ @@ -15380,7 +15361,7 @@ def route_from_har( Playwright will not serve requests intercepted by Service Worker from the HAR file. See [this](https://github.com/microsoft/playwright/issues/1090) issue. We recommend disabling Service Workers when - using request interception by setting `serviceWorkers` to `'block'`. + using request interception by setting `service_workers` to `'block'`. Parameters ---------- @@ -15693,7 +15674,7 @@ def storage_state( credentials : Union[bool, None] Set to `true` to include the context's virtual WebAuthn `browser_context.credentials` (passkeys) in the storage state snapshot. The captured credentials carry their private keys and signature counters, so they can be - re-seeded into a later context via the `storageState` option or `browser_context.set_storage_state()`. Note + re-seeded into a later context via the `storage_state` option or `browser_context.set_storage_state()`. Note that restoring the storage state that contains credentials will automatically install the virtual WebAuthn authenticator (see `credentials.install()`), and prevent all real authenticators from working in this context. @@ -16035,7 +16016,9 @@ def new_cdp_session(self, page: typing.Union["Page", "Frame"]) -> "CDPSession": CDPSession """ - return mapping.from_impl(self._sync(self._impl_obj.new_cdp_session(page=page))) + return mapping.from_impl( + self._sync(self._impl_obj.new_cdp_session(page=mapping.to_impl(page))) + ) mapping.register(BrowserContextImpl, BrowserContext) @@ -16254,7 +16237,7 @@ def new_context( ] = None, record_har_mode: typing.Optional[Literal["full", "minimal"]] = None, record_har_content: typing.Optional[Literal["attach", "embed", "omit"]] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, record_video_fps: typing.Optional[int] = None, ) -> "BrowserContext": """Browser.new_context @@ -16463,7 +16446,7 @@ def new_context( recordHarUrlFilter=record_har_url_filter, recordHarMode=record_har_mode, recordHarContent=record_har_content, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), recordVideoFps=record_video_fps, ) ) @@ -16517,7 +16500,7 @@ def new_page( ] = None, record_har_mode: typing.Optional[Literal["full", "minimal"]] = None, record_har_content: typing.Optional[Literal["attach", "embed", "omit"]] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, record_video_fps: typing.Optional[int] = None, ) -> "Page": """Browser.new_page @@ -16710,7 +16693,7 @@ def new_page( recordHarUrlFilter=record_har_url_filter, recordHarMode=record_har_mode, recordHarContent=record_har_content, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), recordVideoFps=record_video_fps, ) ) @@ -16925,7 +16908,7 @@ def launch( **Usage** - You can use `ignoreDefaultArgs` to filter out `--mute-audio` from default arguments: + You can use `ignore_default_args` to filter out `--mute-audio` from default arguments: ```py browser = playwright.chromium.launch( # or \"firefox\" or \"webkit\". @@ -16935,7 +16918,7 @@ def launch( > **Chromium-only** Playwright can also be used to control the Google Chrome or Microsoft Edge browsers, but it works best with the version of Chromium it is bundled with. There is no guarantee it will work with any other - version. Use `executablePath` option with extreme caution. + version. Use `executable_path` option with extreme caution. > > If Google Chrome (rather than Chromium) is preferred, a [Chrome Canary](https://www.google.com/chrome/browser/canary.html) or @@ -16951,7 +16934,7 @@ def launch( Parameters ---------- executable_path : Union[pathlib.Path, str, None] - Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is + Path to a browser executable to run instead of the bundled one. If `executable_path` is a relative path, then it is resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. channel : Union[str, None] @@ -17102,14 +17085,14 @@ def launch_persistent_context( ] = None, record_har_mode: typing.Optional[Literal["full", "minimal"]] = None, record_har_content: typing.Optional[Literal["attach", "embed", "omit"]] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, record_video_fps: typing.Optional[int] = None, ) -> "BrowserContext": """BrowserType.launch_persistent_context Returns the persistent browser context instance. - Launches browser that uses persistent storage located at `userDataDir` and returns the only context. Closing this + Launches browser that uses persistent storage located at `user_data_dir` and returns the only context. Closing this context will automatically close the browser. Parameters @@ -17139,7 +17122,7 @@ def launch_persistent_context( Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or "msedge-canary" to use branded [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). executable_path : Union[pathlib.Path, str, None] - Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is + Path to a browser executable to run instead of the bundled one. If `executable_path` is a relative path, then it is resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. args : Union[Sequence[str], None] @@ -17376,7 +17359,7 @@ def launch_persistent_context( recordHarUrlFilter=record_har_url_filter, recordHarMode=record_har_mode, recordHarContent=record_har_content, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), recordVideoFps=record_video_fps, ) ) @@ -17397,7 +17380,7 @@ def connect_over_cdp( This method attaches Playwright to an existing browser instance using the Chrome DevTools Protocol. - The default browser context is accessible via `browser.contexts()`. + The default browser context is accessible via `browser.contexts`. **NOTE** Connecting over the Chrome DevTools Protocol is only supported for Chromium-based browsers. @@ -17435,9 +17418,9 @@ def connect_over_cdp( upon the file system being the same between Playwright and the Browser. no_defaults : Union[bool, None] When true, Playwright will not apply its default overrides to the existing default browser context. Specifically, - `acceptDownloads` is left at the browser's setting, focus emulation is not enabled, and media emulation options - (such as `colorScheme`, `reducedMotion`, `forcedColors`, and `contrast`) are not applied. Useful when attaching to - a user's daily-driver browser where these overrides would interfere with existing browser state. New contexts + `accept_downloads` is left at the browser's setting, focus emulation is not enabled, and media emulation options + (such as `color_scheme`, `reduced_motion`, `forced_colors`, and `contrast`) are not applied. Useful when attaching + to a user's daily-driver browser where these overrides would interfere with existing browser state. New contexts created via `browser.new_context()` are not affected. Defaults to `false`. artifacts_dir : Union[pathlib.Path, str, None] If specified, browser artifacts (such as traces and downloads) are saved into this directory. @@ -17618,7 +17601,7 @@ def request(self) -> "APIRequest": def __getitem__(self, value: str) -> "BrowserType": - return mapping.from_impl(self._impl_obj.__getitem__(value=value)) + return mapping.from_impl(self._impl_obj.__getitem__(value)) def stop(self) -> None: """Playwright.stop @@ -17688,7 +17671,7 @@ def start( ---------- name : Union[str, None] If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the - `tracesDir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you + `traces_dir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you need to pass `path` option to `tracing.stop()` instead. title : Union[str, None] Trace name to be shown in the Trace Viewer. @@ -17761,7 +17744,7 @@ def start_chunk( Trace name to be shown in the Trace Viewer. name : Union[str, None] If specified, intermediate trace files are going to be saved into the files with the given name prefix inside the - `tracesDir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you + `traces_dir` directory specified in `browser_type.launch()`. To specify the final trace zip file name, you need to pass `path` option to `tracing.stop_chunk()` instead. Returns @@ -17818,10 +17801,10 @@ def group( ```py # All actions between group and group_end # will be shown in the trace viewer as a group. - await page.context.tracing.group(\"Open Playwright.dev > API\") - await page.goto(\"https://playwright.dev/\") - await page.get_by_role(\"link\", name=\"API\").click() - await page.context.tracing.group_end() + page.context.tracing.group(\"Open Playwright.dev > API\") + page.goto(\"https://playwright.dev/\") + page.get_by_role(\"link\", name=\"API\").click() + page.context.tracing.group_end() ``` Parameters @@ -17967,7 +17950,7 @@ def content_frame(self) -> "FrameLocator": Useful when you have a `Locator` object obtained somewhere, and later on would like to interact with the content inside the frame. - For a reverse operation, use `frame_locator.owner()`. + For a reverse operation, use `frame_locator.owner`. **Usage** @@ -18015,7 +17998,7 @@ def visible(self) -> "Locator": This is the recommended way to distinguish elements by visibility, as opposed to the `:visible` CSS pseudo-class. Note that visibility is checked every time the locator is used, and not at the moment of the - `locator.visible()` call. + `locator.visible` call. **Usage** @@ -18183,7 +18166,7 @@ def click( 1. Wait for [actionability](https://playwright.dev/python/docs/actionability) checks on the element, unless `force` option is set. 1. Scroll the element into view if needed. 1. Use `page.mouse` to click in the center of the element, or the specified `position`. - 1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + 1. Wait for initiated navigations to either succeed or fail, unless `no_wait_after` option is set. If the element is detached from the DOM at any moment during the action, this method throws. @@ -18374,10 +18357,10 @@ def dispatch_event( `click` is dispatched. This is equivalent to calling [element.click()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click). - Under the hood, it creates an instance of an event based on the given `type`, initializes it with `eventInit` + Under the hood, it creates an instance of an event based on the given `type`, initializes it with `event_init` properties and dispatches it on the element. Events are `composed`, `cancelable` and bubble by default. - Since `eventInit` is event-specific, please refer to the events documentation for the lists of initial properties: + Since `event_init` is event-specific, please refer to the events documentation for the lists of initial properties: - [DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/DeviceMotionEvent) - [DeviceOrientationEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/DeviceOrientationEvent) - [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent/DragEvent) @@ -18722,7 +18705,7 @@ def locator( return mapping.from_impl( self._impl_obj.locator( - selectorOrLocator=selector_or_locator, + selectorOrLocator=mapping.to_impl(selector_or_locator), hasText=has_text, hasNotText=has_not_text, has=has._impl_obj if has else None, @@ -19356,7 +19339,7 @@ def filter( Note that outer and inner locators must belong to the same frame. Inner locator must not contain `FrameLocator`s. visible : Union[bool, None] - Only matches visible or invisible elements. Prefer the `locator.visible()` shortcut when matching only + Only matches visible or invisible elements. Prefer the `locator.visible` shortcut when matching only visible elements. Returns @@ -19389,7 +19372,7 @@ def or_(self, locator: "Locator") -> "Locator": **NOTE** If both \"New email\" button and security dialog appear on screen, the \"or\" locator will match both of them, possibly throwing the [\"strict mode violation\" error](https://playwright.dev/python/docs/locators#strictness). In this case, you can use - `locator.first()` to only match one of them. + `locator.first` to only match one of them. ```py new_email = page.get_by_role(\"button\", name=\"New\") @@ -19442,7 +19425,7 @@ def within(self, locator: "Locator") -> "Locator": Returns a locator that matches this locator's elements inside each element matched by `locator`. - Note that relative locators, such as `locator.nth()` or `locator.first()`, are resolved separately + Note that relative locators, such as `locator.nth()` or `locator.first`, are resolved separately inside each matched parent. In the example below, `page.getByRole('cell').nth(2)` picks the third cell of every row, not the third cell in the whole table. @@ -19832,7 +19815,7 @@ def inner_text( Returns the [`element.innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText). **NOTE** If you need to assert text on the page, prefer `locator_assertions.to_have_text()` with - `useInnerText` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. + `use_inner_text` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. Parameters ---------- @@ -20233,7 +20216,7 @@ def screenshot( Defaults to `"device"`. mask : Union[Sequence[Locator], None] Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. The mask is also applied to + box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box. The mask is also applied to invisible elements, see [Matching only visible elements](../locators.md#matching-only-visible-elements) to disable that. mask_color : Union[str, None] @@ -20881,7 +20864,7 @@ def all_inner_texts(self) -> typing.List[str]: Returns an array of `node.innerText` values for all matching nodes. **NOTE** If you need to assert text on the page, prefer `locator_assertions.to_have_text()` with - `useInnerText` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. + `use_inner_text` option to avoid flakiness. See [assertions guide](https://playwright.dev/python/docs/test-assertions) for more details. **Usage** @@ -21293,8 +21276,8 @@ def tracing(self) -> "Tracing": def dispose(self, *, reason: typing.Optional[str] = None) -> None: """APIRequestContext.dispose - All responses returned by `a_pi_request_context.get()` and similar methods are stored in the memory, so that - you can later call `a_pi_response.body()`.This method discards all its resources, calling any method on + All responses returned by `api_request_context.get()` and similar methods are stored in the memory, so that + you can later call `api_response.body()`.This method discards all its resources, calling any method on disposed `APIRequestContext` will throw an exception. Parameters @@ -21887,6 +21870,14 @@ def post( JSON objects can be passed directly to the request: + ```python + data = { + \"title\": \"Book Title\", + \"body\": \"John Doe\", + } + api_request_context.post(\"https://example.com/api/createBook\", data=data) + ``` + To send form data to the server use `form` option. Its value will be encoded into the request body with `application/x-www-form-urlencoded` encoding (see below how to use `multipart/form-data` form encoding to send files): @@ -22008,6 +21999,14 @@ def fetch( JSON objects can be passed directly to the request: + ```python + data = { + \"title\": \"Book Title\", + \"body\": \"John Doe\", + } + api_request_context.fetch(\"https://example.com/api/createBook\", method=\"post\", data=data) + ``` + The common way to send file(s) in the body of a request is to upload them as form fields with `multipart/form-data` encoding, by specifiying the `multipart` parameter: @@ -22071,7 +22070,7 @@ def fetch( return mapping.from_impl( self._sync( self._impl_obj.fetch( - urlOrRequest=url_or_request, + urlOrRequest=mapping.to_impl(url_or_request), params=mapping.to_impl(params), method=method, headers=mapping.to_impl(headers), @@ -22141,7 +22140,7 @@ def new_context( storage_state: typing.Optional[ typing.Union[StorageState, str, pathlib.Path] ] = None, - client_certificates: typing.Optional[typing.List[ClientCertificate]] = None, + client_certificates: typing.Optional[typing.Sequence[ClientCertificate]] = None, fail_on_status_code: typing.Optional[bool] = None, max_redirects: typing.Optional[int] = None, ) -> "APIRequestContext": @@ -22152,7 +22151,7 @@ def new_context( Parameters ---------- base_url : Union[str, None] - Methods like `a_pi_request_context.get()` take the base URL into consideration by using the + Methods like `api_request_context.get()` take the base URL into consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. Examples: - baseURL: `http://localhost:3000` and sending request to `/bar.html` results in `http://localhost:3000/bar.html` @@ -22179,9 +22178,9 @@ def new_context( timeout. storage_state : Union[pathlib.Path, str, {cookies: Sequence[{name: str, value: str, domain: str, path: str, expires: float, httpOnly: bool, secure: bool, sameSite: Union["Lax", "None", "Strict"]}], origins: Sequence[{origin: str, localStorage: Sequence[{name: str, value: str}]}]}, None] Populates context with given storage state. This option can be used to initialize context with logged-in - information obtained via `browser_context.storage_state()` or `a_pi_request_context.storage_state()`. + information obtained via `browser_context.storage_state()` or `api_request_context.storage_state()`. Either a path to the file with saved storage, or the value returned by one of - `browser_context.storage_state()` or `a_pi_request_context.storage_state()` methods. + `browser_context.storage_state()` or `api_request_context.storage_state()` methods. client_certificates : Union[Sequence[{origin: str, certPath: Union[pathlib.Path, str, None], cert: Union[bytes, None], keyPath: Union[pathlib.Path, str, None], key: Union[bytes, None], pfxPath: Union[pathlib.Path, str, None], pfx: Union[bytes, None], passphrase: Union[str, None]}], None] TLS Client Authentication allows the server to request a client certificate and verify it. @@ -22223,7 +22222,7 @@ def new_context( userAgent=user_agent, timeout=to_milliseconds(timeout), storageState=storage_state, - clientCertificates=client_certificates, + clientCertificates=mapping.to_impl(client_certificates), failOnStatusCode=fail_on_status_code, maxRedirects=max_redirects, ) @@ -22328,7 +22327,7 @@ def to_have_url( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression parameter if specified. A provided predicate ignores this flag. """ __tracebackhide__ = True @@ -22361,7 +22360,7 @@ def not_to_have_url( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -22522,7 +22521,7 @@ def to_contain_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -22565,7 +22564,7 @@ def not_to_contain_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -22611,7 +22610,7 @@ def to_have_attribute( value : Union[Pattern[str], str, None] Expected attribute value. If not specified, the assertion verifies that the attribute is present. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -22648,7 +22647,7 @@ def not_to_have_attribute( value : Union[Pattern[str], str, None] Expected attribute value. If not specified, the assertion verifies that the attribute is absent. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -23345,7 +23344,7 @@ def to_have_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -23388,7 +23387,7 @@ def not_to_have_text( timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. """ __tracebackhide__ = True @@ -23816,7 +23815,7 @@ def to_be_visible( Ensures that `Locator` points to an attached and [visible](https://playwright.dev/python/docs/actionability#visible) DOM node. - To check that at least one element from the list is visible, use `locator.first()`. + To check that at least one element from the list is visible, use `locator.first`. **Usage** @@ -24021,7 +24020,7 @@ def to_have_accessible_description( description : Union[Pattern[str], str] Expected accessible description. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24054,7 +24053,7 @@ def not_to_have_accessible_description( name : Union[Pattern[str], str] Expected accessible description. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24093,7 +24092,7 @@ def to_have_accessible_name( name : Union[Pattern[str], str] Expected accessible name. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24124,7 +24123,7 @@ def not_to_have_accessible_name( name : Union[Pattern[str], str] Expected accessible name. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24281,7 +24280,7 @@ def to_have_accessible_error_message( error_message : Union[Pattern[str], str] Expected accessible error message. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24314,7 +24313,7 @@ def not_to_have_accessible_error_message( error_message : Union[Pattern[str], str] Expected accessible error message. ignore_case : Union[bool, None] - Whether to perform case-insensitive match. `ignoreCase` option takes precedence over the corresponding regular + Whether to perform case-insensitive match. `ignore_case` option takes precedence over the corresponding regular expression flag if specified. timeout : Union[float, None] Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -24531,7 +24530,7 @@ def to_be_ok(self) -> None: def not_to_be_ok(self) -> None: """APIResponseAssertions.not_to_be_ok - The opposite of `a_pi_response_assertions.to_be_ok()`. + The opposite of `api_response_assertions.to_be_ok()`. """ __tracebackhide__ = True diff --git a/scripts/codegen/compare_api.py b/scripts/codegen/compare_api.py new file mode 100644 index 000000000..ea818bd71 --- /dev/null +++ b/scripts/codegen/compare_api.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compare two generated API modules, for example the committed +`playwright/async_api/_generated.py` and a freshly generated one. + +The files are parsed, not imported. For every class and member the tool +compares what users can see and what the generated code does: + +- base classes, member names, member kind (property, def, async def); +- positional parameters (names, order, defaults); +- keyword-only parameters (names and defaults; the order is reported + separately, because it is allowed to differ); +- every annotation and the return annotation, after normalization + (unions are flattened and sorted, Optional[X] is Union[X, None], + quotes are removed, Literal values are sorted and merged); +- the body: the call into the implementation, with keyword arguments sorted; +- docstrings (reported separately; they are allowed to differ). + +Exit code is 1 if any difference outside the allowed kinds is found. +""" + +import argparse +import ast +import difflib +import re +import sys +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set, Tuple + +ALLOWED_KINDS = {"kwonly-order", "docstring", "member-inherited"} + + +@dataclass +class Param: + name: str + annotation: str + default: Optional[str] + + +@dataclass +class Func: + name: str + is_async: bool + decorators: List[str] + positional: List[Param] + kwonly: List[Param] + returns: str + docstring: Optional[str] + body: str + + +@dataclass +class Klass: + name: str + bases: List[str] + members: Dict[str, Func] = field(default_factory=dict) + + +def _strip_typing(name: str) -> str: + return re.sub(r"^typing\.", "", name) + + +def _union_members(node: Optional[ast.AST]) -> List[Optional[ast.AST]]: + if node is None: + return [None] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + try: + return _union_members(ast.parse(node.value, mode="eval").body) + except SyntaxError: + return [node] + if isinstance(node, ast.Constant) and node.value is None: + return [None] + if isinstance(node, ast.Subscript): + base = _strip_typing(ast.unparse(node.value)) + args = ( + list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice] + ) + if base == "Union": + result: List[Optional[ast.AST]] = [] + for arg in args: + result.extend(_union_members(arg)) + return result + if base == "Optional": + return _union_members(args[0]) + [None] + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _union_members(node.left) + _union_members(node.right) + return [node] + + +def canon(node: Optional[ast.AST]) -> str: + """Normalized text of an annotation.""" + if node is None: + return "None" + if isinstance(node, ast.Constant): + if isinstance(node.value, str): + try: + return canon(ast.parse(node.value, mode="eval").body) + except SyntaxError: + return repr(node.value) + return "None" if node.value is None else repr(node.value) + if isinstance(node, (ast.Name, ast.Attribute)): + return _strip_typing(ast.unparse(node)) + if isinstance(node, ast.Subscript): + base = _strip_typing(ast.unparse(node.value)) + args = ( + list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice] + ) + if base == "Literal": + values = sorted( + repr(a.value) if isinstance(a, ast.Constant) else ast.unparse(a) + for a in args + ) + return f"Literal[{', '.join(values)}]" + if base in ("Union", "Optional"): + return _canon_union(_union_members(node)) + return f"{base}[{', '.join(canon(a) for a in args)}]" + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _canon_union(_union_members(node)) + if isinstance(node, ast.List): + return f"[{', '.join(canon(a) for a in node.elts)}]" + return ast.unparse(node) + + +def _canon_union(members: List[Optional[ast.AST]]) -> str: + items: Set[str] = set() + literals: Set[str] = set() + for member in members: + text = canon(member) + match = re.match(r"^Literal\[(.*)\]$", text) + if match: + literals.update(v.strip() for v in match.group(1).split(", ")) + else: + items.add(text) + if literals: + items.add(f"Literal[{', '.join(sorted(literals))}]") + ordered = sorted(items, key=lambda i: "~" if i == "None" else i) + if len(ordered) == 1: + return ordered[0] + return f"Union[{', '.join(ordered)}]" + + +class _SortKeywords(ast.NodeTransformer): + def visit_Call(self, node: ast.Call) -> ast.AST: + self.generic_visit(node) + node.keywords.sort(key=lambda k: k.arg or "") + return node + + +def _body_text(func: ast.AST) -> str: + assert isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)) + statements = list(func.body) + if ( + statements + and isinstance(statements[0], ast.Expr) + and isinstance(statements[0].value, ast.Constant) + and isinstance(statements[0].value.value, str) + ): + statements = statements[1:] + returns_none = func.returns is not None and ast.unparse(func.returns) == "None" + text = [] + for statement in statements: + # In a function that returns None, `return mapping.from_maybe_impl(X)` + # and a plain `X` do the same thing. + if ( + returns_none + and isinstance(statement, ast.Return) + and isinstance(statement.value, ast.Call) + and ast.unparse(statement.value.func) == "mapping.from_maybe_impl" + and len(statement.value.args) == 1 + ): + statement = ast.Expr(statement.value.args[0]) + statement = _SortKeywords().visit(ast.parse(ast.unparse(statement))) + text.append(ast.unparse(statement)) + return "\n".join(text) + + +def _params(args: ast.arguments) -> Tuple[List[Param], List[Param]]: + positional_nodes = [*args.posonlyargs, *args.args] + defaults: List[Optional[ast.expr]] = [None] * ( + len(positional_nodes) - len(args.defaults) + ) + list(args.defaults) + positional = [] + for arg, default in zip(positional_nodes, defaults): + if arg.arg == "self": + continue + positional.append( + Param( + arg.arg, + canon(arg.annotation) if arg.annotation else "", + ast.unparse(default) if default is not None else None, + ) + ) + kwonly = [] + for arg, kw_default in zip(args.kwonlyargs, args.kw_defaults): + kwonly.append( + Param( + arg.arg, + canon(arg.annotation) if arg.annotation else "", + ast.unparse(kw_default) if kw_default is not None else None, + ) + ) + return positional, kwonly + + +def _member_key(func: ast.AST, decorators: List[str]) -> str: + assert isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)) + if "typing.overload" not in decorators: + return func.name + # Overloads are keyed by the annotation of their first parameter, which is + # the event name literal in all overloads we generate. + args = [a for a in func.args.args if a.arg != "self"] + first = canon(args[0].annotation) if args and args[0].annotation else "" + return f"{func.name}@overload({first})" + + +def load(path: str) -> Dict[str, Klass]: + with open(path, encoding="utf-8") as f: + module = ast.parse(f.read()) + classes: Dict[str, Klass] = {} + for node in module.body: + if not isinstance(node, ast.ClassDef): + continue + klass = Klass(node.name, [ast.unparse(b) for b in node.bases]) + for item in node.body: + if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + decorators = [ast.unparse(d) for d in item.decorator_list] + positional, kwonly = _params(item.args) + func = Func( + name=item.name, + is_async=isinstance(item, ast.AsyncFunctionDef), + decorators=decorators, + positional=positional, + kwonly=kwonly, + returns=canon(item.returns) if item.returns else "", + docstring=ast.get_docstring(item, clean=True), + body=_body_text(item), + ) + klass.members[_member_key(item, decorators)] = func + classes[node.name] = klass + return classes + + +@dataclass +class Difference: + kind: str + where: str + old: str + new: str + + +def _signature_text(params: List[Param]) -> str: + return ", ".join( + f"{p.name}: {p.annotation}" + (f" = {p.default}" if p.default else "") + for p in params + ) + + +def compare_funcs(where: str, old: Func, new: Func, out: List[Difference]) -> None: + if old.is_async != new.is_async: + out.append( + Difference( + "async", + where, + "async" if old.is_async else "def", + "async" if new.is_async else "def", + ) + ) + if sorted(old.decorators) != sorted(new.decorators): + out.append( + Difference("decorators", where, str(old.decorators), str(new.decorators)) + ) + old_pos = [(p.name, p.annotation, p.default) for p in old.positional] + new_pos = [(p.name, p.annotation, p.default) for p in new.positional] + if [p[0] for p in old_pos] != [p[0] for p in new_pos]: + out.append( + Difference( + "positional", + where, + _signature_text(old.positional), + _signature_text(new.positional), + ) + ) + else: + for o, n in zip(old.positional, new.positional): + if o.annotation != n.annotation: + out.append( + Difference( + "annotation", f"{where}({o.name}=)", o.annotation, n.annotation + ) + ) + if o.default != n.default: + out.append( + Difference( + "default", f"{where}({o.name}=)", str(o.default), str(n.default) + ) + ) + old_kw = {p.name: p for p in old.kwonly} + new_kw = {p.name: p for p in new.kwonly} + if set(old_kw) != set(new_kw): + out.append( + Difference( + "kwonly", + where, + ", ".join(sorted(set(old_kw) - set(new_kw))) or "-", + ", ".join(sorted(set(new_kw) - set(old_kw))) or "-", + ) + ) + elif [p.name for p in old.kwonly] != [p.name for p in new.kwonly]: + out.append( + Difference( + "kwonly-order", + where, + ", ".join(p.name for p in old.kwonly), + ", ".join(p.name for p in new.kwonly), + ) + ) + for name in sorted(set(old_kw) & set(new_kw)): + o, n = old_kw[name], new_kw[name] + if o.annotation != n.annotation: + out.append( + Difference( + "annotation", f"{where}({name}=)", o.annotation, n.annotation + ) + ) + if o.default != n.default: + out.append( + Difference( + "default", f"{where}({name}=)", str(o.default), str(n.default) + ) + ) + if old.returns != new.returns: + out.append(Difference("return", where, old.returns, new.returns)) + if old.body != new.body: + out.append(Difference("body", where, old.body, new.body)) + if (old.docstring or "") != (new.docstring or ""): + out.append( + Difference("docstring", where, old.docstring or "", new.docstring or "") + ) + + +def _inherited(classes: Dict[str, Klass], klass: Klass, member: str) -> Optional[Func]: + for base in klass.bases: + if base not in classes: + continue + if member in classes[base].members: + return classes[base].members[member] + found = _inherited(classes, classes[base], member) + if found: + return found + return None + + +def compare(old: Dict[str, Klass], new: Dict[str, Klass]) -> List[Difference]: + out: List[Difference] = [] + for name in sorted(set(old) - set(new)): + out.append(Difference("class-missing", name, name, "-")) + for name in sorted(set(new) - set(old)): + out.append(Difference("class-extra", name, "-", name)) + for name in sorted(set(old) & set(new)): + o, n = old[name], new[name] + if o.bases != n.bases: + out.append(Difference("bases", name, str(o.bases), str(n.bases))) + for member in sorted(set(o.members) - set(n.members)): + inherited = _inherited(new, n, member) + if inherited: + details: List[Difference] = [] + compare_funcs(name, o.members[member], inherited, details) + if all(d.kind == "docstring" for d in details): + out.append( + Difference("member-inherited", f"{name}.{member}", member, "-") + ) + continue + out.append(Difference("member-missing", f"{name}.{member}", member, "-")) + for member in sorted(set(n.members) - set(o.members)): + out.append(Difference("member-extra", f"{name}.{member}", "-", member)) + for member in sorted(set(o.members) & set(n.members)): + compare_funcs(f"{name}.{member}", o.members[member], n.members[member], out) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0]) + parser.add_argument("old", help="reference file, for example the committed one") + parser.add_argument("new", help="file to check") + parser.add_argument( + "--show", + action="append", + default=[], + help="print the details of this kind of difference (repeatable; 'all' for every kind)", + ) + parser.add_argument( + "--allow", + action="append", + default=[], + metavar="KIND:WHERE", + help="accept one reviewed difference, for example 'body:Browser.new_context' (repeatable)", + ) + args = parser.parse_args() + + differences = compare(load(args.old), load(args.new)) + allowed = set(args.allow) + unused_allows = set(allowed) + by_kind: Dict[str, List[Difference]] = {} + for d in differences: + key = f"{d.kind}:{d.where}" + if key in allowed: + unused_allows.discard(key) + by_kind.setdefault("accepted", []).append(d) + continue + by_kind.setdefault(d.kind, []).append(d) + for key in sorted(unused_allows): + print(f"--allow {key} matched nothing") + ALLOWED_KINDS.add("accepted") + + show_all = "all" in args.show + for kind in sorted(by_kind): + items = by_kind[kind] + note = " (allowed)" if kind in ALLOWED_KINDS else "" + print(f"{kind}: {len(items)}{note}") + if not show_all and kind not in args.show: + continue + for d in items: + if kind == "docstring": + print(f" {d.where}:") + for line in difflib.unified_diff( + d.old.splitlines(), d.new.splitlines(), lineterm="", n=0 + ): + if line.startswith(("---", "+++", "@@")): + continue + print(f" {line}") + else: + print(f" {d.where}") + print(f" old: {d.old}") + print(f" new: {d.new}") + + failing = [k for k in by_kind if k not in ALLOWED_KINDS] + if failing: + print(f"FAIL: unexpected differences: {', '.join(sorted(failing))}") + return 1 + print("OK: no unexpected differences") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/codegen/generate_api.js b/scripts/codegen/generate_api.js new file mode 100644 index 000000000..29bdf887e --- /dev/null +++ b/scripts/codegen/generate_api.js @@ -0,0 +1,1252 @@ +#!/usr/bin/env node +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +// Generates the public Python API (playwright/{async,sync}_api/_generated.py) +// from the upstream Playwright docs (docs/src/api in microsoft/playwright). +// +// Usage: +// PW_SRC_DIR= node scripts/codegen/generate_api.js --flavor async|sync [--out ] +// +// The docs are parsed with the upstream parser (utils/doclint/api_parser.js) and +// filtered for Python. Facts that the docs do not contain come from +// python_rules.js. The output is not formatted; run isort and black on it. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const rules = require('./python_rules'); +// TEMPORARY: see "TEMPORARY: the legacy order" below. Replace the require() +// with null to see the output without it. +const legacyOrder = require('./legacy_order.json'); + +/** + * @typedef {{ + * kind: 'name' | 'class' | 'struct' | 'union' | 'list' | 'dict' | 'callable' | 'literal' | 'pattern' | 'awaitable' | 'ecm' | 'cm', + * name?: string, + * wrapper?: boolean, + * members?: PyType[], + * item?: PyType, + * key?: PyType, + * value?: PyType, + * args?: PyType[] | null, + * ret?: PyType, + * values?: string[], + * inner?: PyType | null, + * docType?: any, + * place?: string, + * dir?: 'in' | 'out', + * }} PyType + */ + +/** + * @typedef {{ + * name: string, + * implName: string, + * type: PyType, + * optional: boolean, + * comment: string, + * deprecated: string | undefined, + * documented: boolean, + * }} PyParam + */ + +/** + * @typedef {{ + * kind: 'property' | 'method', + * name: string, + * isAsync: boolean, + * positional: PyParam[], + * keywordOnly: PyParam[], + * returnType: PyType | null, + * comment: string | null, + * documented: boolean, + * }} PyMember + */ + +/** + * @typedef {{ + * name: string, + * base: string, + * events: any[], + * members: PyMember[], + * }} PyClass + */ + +// --------------------------------------------------------------------------- +// Command line and upstream modules. + +function parseArgs(argv) { + const result = { flavor: 'async', out: '' }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--flavor') + result.flavor = argv[++i]; + else if (argv[i] === '--out') + result.out = argv[++i]; + else + fail(`Unknown argument: ${argv[i]}`); + } + if (!['async', 'sync'].includes(result.flavor)) + fail(`--flavor must be "async" or "sync", got "${result.flavor}"`); + return result; +} + +/** @param {string} message */ +function fail(message) { + console.error(message); + process.exit(1); +} + +const options = parseArgs(process.argv.slice(2)); +const isAsync = options.flavor === 'async'; + +const PW_SRC_DIR = process.env.PW_SRC_DIR; +if (!PW_SRC_DIR) + fail('Set PW_SRC_DIR to a microsoft/playwright checkout at the release branch in DRIVER_VERSION.'); +const { parseApi } = require(path.join(PW_SRC_DIR, 'utils', 'doclint', 'api_parser')); +const { Type, parseCodeLang } = require(path.join(PW_SRC_DIR, 'utils', 'doclint', 'documentation')); +const md = require(path.join(PW_SRC_DIR, 'utils', 'markdown')); + +const BASE_CLASS = isAsync ? 'AsyncBase' : 'SyncBase'; +const CONTEXT_MANAGER_CLASS = isAsync ? 'AsyncContextManager' : 'SyncContextManager'; +const EVENT_CONTEXT_MANAGER_CLASS = isAsync ? 'AsyncEventContextManager' : 'EventContextManager'; + +/** @type {string[]} */ +const errors = []; +/** @type {Set} */ +const usedRules = new Set(); +/** @type {Set} */ +const usedStructs = new Set(); + +// --------------------------------------------------------------------------- +// Names. + +/** @param {string} name */ +function toSnakeCase(name) { + // Same as playwright._impl._helper.to_snake_case. + return name.replace(/((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))/g, '_$1').toLowerCase(); +} + +/** + * Looks up a rule and records that it was used. + * @param {string} table + * @param {string} key + */ +function lookupRule(table, key) { + const entries = /** @type {any} */ (rules)[table]; + if (Object.prototype.hasOwnProperty.call(entries, key)) { + usedRules.add(`${table}:${key}`); + return entries[key]; + } + for (const pattern of Object.keys(entries)) { + if (!pattern.includes('*')) + continue; + const regex = new RegExp('^' + pattern.split('*').map(escapeRegExp).join('[^.]+') + '$'); + if (regex.test(key)) { + usedRules.add(`${table}:${pattern}`); + return entries[pattern]; + } + } + return undefined; +} + +/** @param {string} text */ +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// --------------------------------------------------------------------------- +// Docs. + +function loadDocumentation() { + const documentation = parseApi(path.join(PW_SRC_DIR, 'docs', 'src', 'api')); + for (const name of rules.extraClasses) { + const clazz = documentation.classes.get(name); + if (!clazz) { + errors.push(`extraClasses: the docs have no class ${name}`); + continue; + } + if (!clazz.langs.only || clazz.langs.only.includes('python')) { + errors.push(`extraClasses: ${name} is already in the Python docs; remove the entry`); + continue; + } + clazz.langs.only = [...clazz.langs.only, 'python']; + } + documentation.filterForLanguage('python'); + return documentation; +} + +const documentation = loadDocumentation(); + +/** @type {Set} */ +const generatedClassNames = new Set(documentation.classesArray + .map(c => c.name) + .filter(name => !rules.handWrittenClasses.includes(name))); + +/** @param {any} member */ +function isPythonProperty(member) { + if (member.kind === 'property') + return true; + if (member.kind !== 'method' || member.async || member.argsArray.length) + return false; + const name = toSnakeCase(member.alias); + return !name.startsWith('is_') && !name.startsWith('as_') && name !== 'connect_to_server'; +} + +/** @param {any} member */ +function memberLink(member) { + const variable = toSnakeCase(member.clazz.varName); + if (member.kind === 'event') + return '`' + `${variable}.on('${member.alias.toLowerCase()}')` + '`'; + if (isPythonProperty(member)) + return '`' + `${variable}.${toSnakeCase(member.alias)}` + '`'; + return '`' + `${variable}.${toSnakeCase(member.alias)}()` + '`'; +} + +documentation.setLinkRenderer(({ clazz, member, param, option }) => { + if (param) + return '`' + toSnakeCase(param.alias) + '`'; + if (option) + return '`' + option.alias.split('.').map(toSnakeCase).join('.') + '`'; + // TEMPORARY: the legacy order renders member links after wrapping, in + // renderLegacyMemberLinks(). + if (member) + return legacyOrder ? undefined : memberLink(member); + if (clazz) + return '`' + clazz.name + '`'; + return undefined; +}); + +// --------------------------------------------------------------------------- +// Docstrings. + +const KEPT_CODE_LANGUAGES = new Set(['html', 'yml', 'sh', 'py', 'python']); + +/** @param {any[]} nodes */ +function filterCodeBlocks(nodes) { + const result = []; + for (const node of nodes) { + if (node.type === 'code' && node.codeLang) { + if (node.codeLang === 'python async' || node.codeLang === 'python sync') { + if (node.codeLang !== `python ${options.flavor}`) + continue; + } else if (!KEPT_CODE_LANGUAGES.has(parseCodeLang(node.codeLang).highlighter)) { + continue; + } + } + if (node.children) + node.children = filterCodeBlocks(node.children); + result.push(node); + } + return result; +} + +/** + * Renders a member or argument description as plain text. Same as the + * upstream generateSourceCodeComment(), but keeps only the code examples for + * Python and for this flavor. + * @param {any[] | undefined} spec + */ +function renderSpec(spec) { + const nodes = filterCodeBlocks((spec || []) + .filter(n => !n.type.startsWith('h') && (n.type !== 'li' || n.liType !== 'default')) + .map(n => md.clone(n))); + md.visitAll(nodes, node => { + if (node.type === 'li' && node.liType === 'bullet') + node.liType = 'default'; + if (node.type === 'code' && node.codeLang) + node.codeLang = parseCodeLang(node.codeLang).highlighter; + }); + // TEMPORARY: renderLegacyMemberLinks() is the legacy order. + return renderLegacyMemberLinks(md.render(nodes, { maxColumns: 120 - 5, omitLastCR: true, flattenText: true, noteMode: 'compact' })); +} + +/** @param {string} text */ +function escapeDocstring(text) { + return escapeBackslashes(text).replace(/"/g, '\\"'); +} + +/** + * Parameter descriptions keep their quotes: they never end the docstring. + * @param {string} text + */ +function escapeBackslashes(text) { + return text.replace(/\\/g, '\\\\'); +} + +/** + * @param {string} text + * @param {string} indent + */ +function indentParagraph(text, indent) { + const lines = text.split('\n'); + return [lines[0], ...lines.slice(1).map(line => indent + line)].join('\n'); +} + +/** @param {string} text */ +function resolveRelativeLinks(text) { + return text.replace(/\[([^\]]+)\]\((\.[^)]+)\)/g, (match, linkText, href) => { + const resolved = new URL(href.replace(/\.md/g, ''), 'https://playwright.dev/python/docs/api/').href; + return `[${linkText}](${resolved})`; + }); +} + +/** + * Member description: one blank line between paragraphs, indented. + * @param {string} text + * @param {string} indent + */ +function formatComment(text, indent) { + const result = []; + let lastWasBlank = true; + for (const line of escapeDocstring(text).split('\n')) { + if (!line.trim()) { + lastWasBlank = true; + continue; + } + if (lastWasBlank) { + lastWasBlank = false; + result.push(''); + } + result.push(line); + } + return resolveRelativeLinks(indentParagraph(result.join('\n'), indent)); +} + +// --------------------------------------------------------------------------- +// Types. + +/** @param {string} name */ +const NAME = name => /** @type {PyType} */ ({ kind: 'name', name }); +const ANY = NAME('Any'); +const NONE = NAME('None'); + +/** @param {PyType} t */ +const isNone = t => t.kind === 'name' && t.name === 'None'; + +/** @param {string} name */ +function isStringLiteral(name) { + return name.startsWith('"') && name.endsWith('"'); +} + +/** + * @param {PyType[]} members + * @returns {PyType} + */ +function makeUnion(members) { + /** @type {PyType[]} */ + const flat = []; + for (const member of members) { + if (member.kind === 'union') + flat.push(...(member.members || [])); + else + flat.push(member); + } + /** @type {PyType[]} */ + const result = []; + /** @type {PyType | null} */ + let literal = null; + const seen = new Set(); + for (const member of flat) { + if (member.kind === 'literal') { + if (!literal) { + literal = { kind: 'literal', values: [] }; + result.push(literal); + } + for (const value of member.values || []) { + if (!(literal.values || []).includes(value)) + literal.values?.push(value); + } + continue; + } + const key = annotation(member, false); + if (seen.has(key)) + continue; + seen.add(key); + result.push(member); + } + if (literal) + literal.values?.sort(); + return result.length === 1 ? result[0] : { kind: 'union', members: result }; +} + +/** + * @param {any} type docs Type + * @param {{ dir: 'in' | 'out', place: string, fnReturn?: boolean }} ctx + * @returns {PyType} + */ +function toPy(type, ctx) { + if (type.union) + return makeUnion(type.union.map(t => toPy(t, ctx))); + const name = type.name; + if (isStringLiteral(name)) + return { kind: 'literal', values: [name.substring(1, name.length - 1)] }; + switch (name) { + case 'string': + case 'URL': + return NAME('str'); + case 'boolean': + return NAME('bool'); + case 'int': + case 'long': + return NAME('int'); + case 'float': + return NAME('float'); + case 'null': + case 'void': + case 'undefined': + return NONE; + case 'any': + case 'unknown': + case 'Serializable': + case 'EvaluationArgument': + return ANY; + case 'Buffer': + case 'ReadStream': + return NAME('bytes'); + case 'Date': + return NAME('datetime.datetime'); + case 'RegExp': + return { kind: 'pattern' }; + case 'path': + return ctx.dir === 'in' ? makeUnion([NAME('str'), NAME('pathlib.Path')]) : NAME('pathlib.Path'); + case 'function': { + if (!type.args) + return { kind: 'callable', args: null, ret: ANY }; + const args = type.args.map(t => toPy(t, { ...ctx, fnReturn: false })); + const ret = type.returnType ? toPy(type.returnType, { ...ctx, fnReturn: true }) : ANY; + return { kind: 'callable', args, ret }; + } + case 'Promise': { + const inner = type.templates ? toPy(type.templates[0], { ...ctx, fnReturn: false }) : ANY; + // A callback may return a promise: in Python, the async API accepts an + // awaitable there; the sync API does not. + if (ctx.fnReturn && isAsync && !(inner.kind === 'name' && inner.name === 'Any')) + return { kind: 'awaitable', inner }; + return inner; + } + case 'Array': + return { kind: 'list', item: toPy(type.templates[0], ctx), dir: ctx.dir }; + case 'Object': + case 'Map': + if (type.templates) + return { kind: 'dict', key: toPy(type.templates[0], ctx), value: toPy(type.templates[1], ctx) }; + if (type.properties && type.properties.length) + return { kind: 'struct', docType: type, place: ctx.place, dir: ctx.dir }; + return { kind: 'dict', key: undefined, value: undefined }; + case 'EventContextManager': + return { kind: 'ecm', inner: type.templates ? toPy(type.templates[0], ctx) : null }; + case 'Disposable': + return { kind: 'cm' }; + case 'Error': + case 'FormData': + return { kind: 'class', name, wrapper: false }; + } + if (generatedClassNames.has(name)) + return { kind: 'class', name, wrapper: true }; + throw new Error(`Unknown docs type "${name}" at ${ctx.place}`); +} + +/** @param {PyType} t */ +function structName(t) { + const place = /** @type {string} */ (t.place); + let name = lookupRule('structNamesByPlace', place); + if (!name) { + const alias = t.docType.langAliases?.python || t.docType.langAliases?.default; + if (alias) + name = lookupRule('structRenames', alias) || alias; + } + if (!name) { + errors.push(`No Python name for the struct at ${place}: {${t.docType.properties.map(p => p.name).join(', ')}}`); + return 'typing.Dict'; + } + usedStructs.add(name); + return name; +} + +/** + * Python annotation text. + * @param {PyType} t + * @param {boolean=} record whether to record used struct names + * @returns {string} + */ +function annotation(t, record = true) { + switch (t.kind) { + case 'name': + return t.name === 'Any' ? 'typing.Any' : /** @type {string} */ (t.name); + case 'class': + return `"${t.name}"`; + case 'struct': + return record ? structName(t) : `struct:${t.place}`; + case 'union': { + const members = t.members || []; + const others = members.filter(m => !isNone(m)).map(m => annotation(m, record)); + const inner = others.length === 1 ? others[0] : `typing.Union[${others.join(', ')}]`; + return members.some(isNone) ? `typing.Optional[${inner}]` : inner; + } + case 'list': + return `typing.${t.dir === 'in' ? 'Sequence' : 'List'}[${annotation(/** @type {PyType} */ (t.item), record)}]`; + case 'dict': + if (!t.key) + return 'typing.Dict'; + return `typing.Dict[${annotation(t.key, record)}, ${annotation(/** @type {PyType} */ (t.value), record)}]`; + case 'callable': + if (!t.args) + return 'typing.Callable'; + return `typing.Callable[[${t.args.map(a => annotation(a, record)).join(', ')}], ${annotation(/** @type {PyType} */ (t.ret), record)}]`; + case 'literal': + return `Literal[${(t.values || []).map(v => JSON.stringify(v)).join(', ')}]`; + case 'pattern': + return 'typing.Pattern[str]'; + case 'awaitable': + return `typing.Awaitable[${annotation(/** @type {PyType} */ (t.inner), record)}]`; + case 'ecm': + return t.inner ? `${EVENT_CONTEXT_MANAGER_CLASS}[${annotation(t.inner, record)}]` : EVENT_CONTEXT_MANAGER_CLASS; + case 'cm': + return `"${CONTEXT_MANAGER_CLASS}"`; + } + throw new Error(`Unknown type kind ${t.kind}`); +} + +/** @param {string} text */ +function makeOptional(text) { + if (text.startsWith('Union[')) { + if (text.endsWith('None]')) + return text; + return text.substring(0, text.length - 1) + ', None]'; + } + return `Union[${text}, None]`; +} + +/** @param {string[]} items */ +function sortDocUnion(items) { + const key = item => item === 'None' ? '}' : item; + return [...items].sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0); +} + +/** + * Type text for docstrings. Same format as the old generator: structs are + * expanded, unions are sorted with None last. + * @param {PyType} t + * @returns {string} + */ +function docType(t) { + switch (t.kind) { + case 'name': + return /** @type {string} */ (t.name); + case 'class': + return /** @type {string} */ (t.name); + case 'struct': { + const fields = t.docType.properties.map(p => { + const field = docType(toPy(p.type, { dir: t.dir || 'out', place: `${t.place}.${p.name}` })); + return `${p.name}: ${p.required ? field : makeOptional(field)}`; + }); + return `{${fields.join(', ')}}`; + } + case 'union': { + const members = t.members || []; + if (members.length === 2 && members.some(isNone)) + return makeOptional(docType(/** @type {PyType} */ (members.find(m => !isNone(m))))); + const items = []; + for (const member of members) { + if (member.kind === 'literal') + items.push(...(member.values || []).map(v => `"${v}"`)); + else + items.push(docType(member)); + } + return `Union[${sortDocUnion(items).join(', ')}]`; + } + case 'list': + return `${t.dir === 'in' ? 'Sequence' : 'List'}[${docType(/** @type {PyType} */ (t.item))}]`; + case 'dict': + if (!t.key) + return 'Dict'; + return `Dict[${docType(t.key)}, ${docType(/** @type {PyType} */ (t.value))}]`; + case 'callable': + if (!t.args) + return 'Callable'; + return `Callable[[${t.args.map(docType).join(', ')}], ${docType(/** @type {PyType} */ (t.ret))}]`; + case 'literal': { + const values = (t.values || []).map(v => `"${v}"`); + return values.length === 1 ? values[0] : `Union[${values.join(', ')}]`; + } + case 'pattern': + return 'Pattern[str]'; + case 'awaitable': + return `typing.Awaitable[${docType(/** @type {PyType} */ (t.inner))}]`; + case 'ecm': + return t.inner ? `EventContextManager[${docType(t.inner)}]` : 'EventContextManager'; + case 'cm': + return CONTEXT_MANAGER_CLASS; + } + throw new Error(`Unknown type kind ${t.kind}`); +} + +/** + * Visits the type, but not the fields of structs: a struct crosses into the + * implementation as a plain dict, whatever its fields are. + * @param {PyType} t + * @param {(t: PyType) => boolean} predicate + * @returns {boolean} + */ +function someType(t, predicate) { + if (predicate(t)) + return true; + const children = [ + ...(t.members || []), + ...(t.args || []), + t.item, t.key, t.value, t.ret, t.inner, + ].filter(Boolean); + return children.some(c => someType(/** @type {PyType} */ (c), predicate)); +} + +/** + * The Python expression that passes a parameter to the implementation. + * @param {PyParam} param + */ +function forwardExpression(param) { + const t = param.type; + const name = param.name; + if (someType(t, c => c.kind === 'callable')) + return `self._wrap_handler(${name})`; + if (param.implName === 'timeout' && someType(t, c => c.kind === 'name' && c.name === 'float')) + return `to_milliseconds(${name})`; + if (someType(t, c => + (c.kind === 'name' && c.name === 'Any') || + c.kind === 'dict' || + (c.kind === 'list' && c.dir === 'in') || + (c.kind === 'class' && !!c.name && c.name.includes('Handle')))) + return `mapping.to_impl(${name})`; + if (t.kind === 'class' && t.wrapper) + return param.optional ? `${name}._impl_obj if ${name} else None` : `${name}._impl_obj`; + if (t.kind === 'union' && t.members?.length === 2 && t.members.some(isNone)) { + const other = /** @type {PyType} */ (t.members.find(m => !isNone(m))); + if (other.kind === 'class' && other.wrapper) + return `${name}._impl_obj if ${name} else None`; + } + // A union such as `str | Locator`: unwrap whichever value was passed. + if (someType(t, c => c.kind === 'class' && !!c.wrapper)) + return `mapping.to_impl(${name})`; + return name; +} + +/** + * The function that converts a return value from the implementation. + * @param {PyType | null} t + */ +function returnWrapper(t) { + if (!t || !someType(t, c => c.kind === 'class' || c.kind === 'struct' || c.kind === 'cm')) + return 'mapping.from_maybe_impl'; + if (t.kind === 'union' && t.members?.length === 2 && t.members.some(isNone)) + return 'mapping.from_impl_nullable'; + if (t.kind === 'list') + return 'mapping.from_impl_list'; + if (t.kind === 'dict') + return 'mapping.from_impl_dict'; + return 'mapping.from_impl'; +} + +// --------------------------------------------------------------------------- +// Python model. + +/** + * @param {string} place + * @param {any} docsType + */ +function typeAt(place, docsType) { + const override = lookupRule('typeOverrides', place); + if (override === undefined) + return docsType; + if (typeof override === 'function') + return override({ parse: expression => Type.parse(expression) }); + return Type.parse(override); +} + +/** + * @param {any} clazz + * @param {any} member + * @returns {PyMember} + */ +function buildMember(clazz, member) { + const memberPlace = `${clazz.name}.${member.name}`; + const returnDocsType = typeAt(memberPlace, member.type); + const returnType = returnDocsType.name === 'void' && !returnDocsType.union ? null : toPy(returnDocsType, { dir: 'out', place: memberPlace }); + const isEventContextManager = returnType?.kind === 'ecm'; + + /** @type {{ arg: any, isOption: boolean }[]} */ + const args = []; + for (const arg of member.argsArray) { + if (arg.name === 'options') { + for (const option of arg.type.properties) + args.push({ arg: option, isOption: true }); + } else { + args.push({ arg, isOption: false }); + } + } + + for (let i = args.length - 1; i >= 0; i--) { + if (lookupRule('skipArgs', `${memberPlace}.${args[i].arg.name}`)) + args.splice(i, 1); + } + + /** @param {{ arg: any, isOption: boolean }} entry */ + const toParam = ({ arg, isOption }) => { + const place = `${memberPlace}.${arg.name}`; + const type = toPy(typeAt(place, arg.type), { dir: 'in', place }); + const nullable = type.kind === 'union' && !!type.members?.some(isNone); + const renamed = lookupRule('argNames', place); + return /** @type {PyParam} */ ({ + name: renamed || toSnakeCase(arg.alias), + implName: renamed || arg.name, + type, + optional: isOption || !arg.required || nullable, + comment: renderSpec(arg.spec), + // TEMPORARY: renderLegacyMemberLinks() is the legacy order. + deprecated: arg.deprecated && renderLegacyMemberLinks(arg.deprecated), + documented: true, + }); + }; + + const positionalRule = lookupRule('positional', memberPlace); + /** @type {PyParam[]} */ + let positional; + /** @type {PyParam[]} */ + let keywordOnly; + if (positionalRule) { + for (const name of positionalRule) { + if (!args.some(a => a.arg.name === name)) + errors.push(`positional: ${memberPlace} has no argument ${name}`); + } + positional = positionalRule.map(name => args.find(a => a.arg.name === name)).filter(Boolean).map(toParam); + keywordOnly = args.filter(a => !positionalRule.includes(a.arg.name)).map(toParam); + } else { + const isPositional = a => !a.isOption || rules.positionalOptions.includes(a.arg.name); + positional = args.filter(isPositional).map(toParam); + keywordOnly = args.filter(a => !isPositional(a)).map(toParam); + } + for (const extra of lookupRule('extraOptions', memberPlace) || []) { + keywordOnly.push({ + name: toSnakeCase(extra.name), + implName: extra.name, + type: toPy(Type.parse(extra.type), { dir: 'in', place: `${memberPlace}.${extra.name}` }), + optional: true, + comment: '', + deprecated: undefined, + documented: false, + }); + } + let sawOptional = false; + for (const param of positional) { + if (sawOptional && !param.optional) + errors.push(`${memberPlace}: required parameter ${param.name} follows an optional one; add a "positional" rule`); + sawOptional = sawOptional || param.optional; + } + + const kind = isPythonProperty(member) ? 'property' : 'method'; + return { + kind, + name: toSnakeCase(member.alias), + isAsync: !!member.async && !isEventContextManager, + positional, + keywordOnly, + returnType, + comment: renderSpec(member.spec), + documented: true, + }; +} + +/** + * @param {string} className + * @param {any} extra + * @returns {PyMember} + */ +function buildExtraMember(className, extra) { + const place = `${className}.${extra.name}`; + const returnDocsType = Type.parse(extra.returns); + return { + kind: extra.kind, + name: extra.name, + isAsync: extra.async, + positional: extra.args.map(arg => ({ + name: toSnakeCase(arg.name), + implName: arg.name, + type: toPy(Type.parse(arg.type), { dir: 'in', place: `${place}.${arg.name}` }), + optional: false, + comment: '', + deprecated: undefined, + documented: false, + })), + keywordOnly: [], + returnType: returnDocsType.name === 'void' ? null : toPy(returnDocsType, { dir: 'out', place }), + comment: null, + documented: false, + }; +} + +/** + * @param {any} clazz + * @returns {PyClass} + */ +function buildClass(clazz) { + let base = BASE_CLASS; + if (rules.contextManagerClasses.includes(clazz.name)) + base = CONTEXT_MANAGER_CLASS; + else if (clazz.extends && generatedClassNames.has(clazz.extends)) + base = clazz.extends; + const members = []; + for (const member of clazz.membersArray) { + if (member.kind === 'event') + continue; + members.push(buildMember(clazz, member)); + } + for (const extra of lookupRule('extraMembers', clazz.name) || []) + members.push(buildExtraMember(clazz.name, extra)); + return { name: clazz.name, base, events: clazz.eventsArray, members }; +} + +/** Classes in docs order, with base classes first. */ +function orderedClasses() { + const classes = documentation.classesArray.filter(c => generatedClassNames.has(c.name)); + const result = []; + const done = new Set(); + const visit = clazz => { + if (done.has(clazz.name)) + return; + if (clazz.extends && generatedClassNames.has(clazz.extends)) + visit(documentation.classes.get(clazz.extends)); + done.add(clazz.name); + result.push(clazz); + }; + classes.forEach(visit); + return result; +} + +// --------------------------------------------------------------------------- +// TEMPORARY: the legacy order. +// +// legacy_order.json holds the order of classes, members and keyword-only +// parameters in the _generated.py files that the old generator wrote. That +// generator read playwright/_impl, so its order followed the implementation. +// The file lists only the places where that order differs from the docs order. +// It keeps the diff of the switch to this generator small enough to review. +// +// The section also reproduces more habits of the old generator: +// - the order of the members of a union, from legacyOrder.unions; +// - docstring lines wrapped before the member links were replaced with their +// Python form, so that the wrapped lines can be too long or too short; +// - `return mapping.from_maybe_impl(...)` around calls that return nothing; +// - no type on `_impl_obj`. So mypy does not check the generated calls +// against playwright/_impl yet. The type needs two annotation fixes in +// _impl/ (`_wrap_handler`, `to_milliseconds`); they land with its removal. +// +// To remove it: delete legacy_order.json, this section, the calls to its +// functions, and the `legacyOrder ? ... : ...` choices. Every place is marked +// TEMPORARY. + +/** + * Sorts items by the position of their name in `order`. Items that are not in + * `order` go last, in their current order. + * @template T + * @param {T[]} items + * @param {string[] | undefined} order + * @param {(item: T) => string} nameOf + * @param {string} where + * @returns {T[]} + */ +function sortByLegacyOrder(items, order, nameOf, where) { + if (!order) + return items; + const names = new Set(items.map(nameOf)); + for (const name of order) { + if (!names.has(name)) + errors.push(`legacy_order.json: ${where} has no ${name}; remove it`); + } + /** @param {T} item */ + const position = item => { + const index = order.indexOf(nameOf(item)); + return index === -1 ? order.length : index; + }; + return items.map((item, index) => ({ item, index })) + .sort((a, b) => position(a.item) - position(b.item) || a.index - b.index) + .map(entry => entry.item); +} + +/** @type {Map} */ +const membersByLink = new Map(); +for (const clazz of documentation.classesArray) { + for (const member of clazz.membersArray) + membersByLink.set(`${member.kind}: ${clazz.name}.${member.name}`, member); +} + +/** + * Replaces the member links that the link renderer left in the text. + * @param {string} text + */ +function renderLegacyMemberLinks(text) { + return text.replace(/\[`(event|method|property): ([^\]]+)`\](?:\(([^)]*?)\))?/g, (match, kind, name) => { + const member = membersByLink.get(`${kind}: ${name}`); + if (!member) + throw new Error(`Unknown member link ${match}`); + return memberLink(member); + }); +} + +/** @type {Set} */ +const usedLegacyUnions = new Set(); + +/** + * Sorts the members of every union in `type` in the order of + * legacyOrder.unions[key]. + * @param {PyType} type + * @param {string} key `Class.member.param`, in Python names. + * @returns {PyType} + */ +function applyLegacyUnionOrder(type, key) { + const order = legacyOrder?.unions[key]; + if (!order) + return type; + usedLegacyUnions.add(key); + /** @param {string} text */ + const normalize = text => text.replace(/["'\s]/g, ''); + const normalizedOrder = order.map(normalize); + /** @param {PyType} t */ + const position = t => { + const index = normalizedOrder.indexOf(normalize(annotation(t))); + return index === -1 ? normalizedOrder.length : index; + }; + /** + * @param {PyType} t + * @returns {PyType} + */ + const sort = t => { + if (t.kind === 'union') { + const members = (t.members || []).map(sort).map((member, index) => ({ member, index })); + members.sort((a, b) => position(a.member) - position(b.member) || a.index - b.index); + return { ...t, members: members.map(entry => entry.member) }; + } + if (t.kind === 'list') + return { ...t, item: sort(/** @type {PyType} */ (t.item)) }; + if (t.kind === 'dict' && t.value) + return { ...t, value: sort(t.value) }; + return t; + }; + return sort(type); +} + +/** + * @param {PyClass[]} classes + * @returns {PyClass[]} + */ +function applyLegacyOrder(classes) { + if (!legacyOrder) + return classes; + const result = sortByLegacyOrder(classes, legacyOrder.classes, clazz => clazz.name, 'the API'); + const memberKeys = new Set(); + for (const clazz of result) { + clazz.members = sortByLegacyOrder(clazz.members, legacyOrder.members[clazz.name], member => member.name, clazz.name); + for (const member of clazz.members) { + const key = `${clazz.name}.${member.name}`; + memberKeys.add(key); + member.keywordOnly = sortByLegacyOrder(member.keywordOnly, legacyOrder.keywordOnly[key], param => param.name, key); + } + } + for (const name of Object.keys(legacyOrder.members)) { + if (!classes.some(clazz => clazz.name === name)) + errors.push(`legacy_order.json: the API has no class ${name}; remove it`); + } + for (const key of Object.keys(legacyOrder.keywordOnly)) { + if (!memberKeys.has(key)) + errors.push(`legacy_order.json: the API has no member ${key}; remove it`); + } + return result; +} + +function checkLegacyUnions() { + if (!legacyOrder) + return; + for (const key of Object.keys(legacyOrder.unions)) { + if (!usedLegacyUnions.has(key)) + errors.push(`legacy_order.json: the API has no parameter ${key}; remove it`); + } +} + +// --------------------------------------------------------------------------- +// Output. + +const LICENSE = `# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +`; + +/** @param {string} className */ +function implModule(className) { + return lookupRule('implModules', className) || `_${toSnakeCase(className)}`; +} + +/** @param {any} event */ +function eventPayload(clazz, event) { + let payload = docType(toPy(event.type, { dir: 'out', place: `${clazz.name}.${event.name}` })); + if (payload.startsWith('{')) + payload = 'typing.Dict'; + return payload.replace(/Union\[/g, 'typing.Union['); +} + +/** + * @param {PyClass} clazz + * @param {string[]} out + */ +function emitEvents(clazz, out) { + const events = clazz.events; + if (!events.length) + return; + const handlerReturn = isAsync ? 'typing.Union[typing.Awaitable[None], None]' : 'None'; + for (const method of ['on', 'once']) { + for (const event of events) { + out.push(' @typing.overload'); + out.push(` def ${method}(self, event: Literal['${event.name.toLowerCase()}'], f: typing.Callable[['${eventPayload(clazz, event)}'], '${handlerReturn}']) -> None:`); + out.push(` """${formatComment(renderSpec(event.spec), ' '.repeat(8))}"""`); + } + if (events.length === 1) { + out.push(' @typing.overload'); + out.push(` def ${method}(self, event: str, f: typing.Callable[...,${handlerReturn}]) -> None: ...`); + out.push(''); + } + out.push(` def ${method}(self, event: str, f: typing.Callable[...,${handlerReturn}]) -> None:`); + out.push(` return super().${method}(event=event,f=f)`); + } +} + +/** + * Typed overloads of expect_event() / wait_for_event(), keyed on the event name. + * @param {PyClass} clazz + * @param {string} methodName + * @param {string[]} out + */ +function emitEventOverloads(clazz, methodName, out) { + if (!clazz.events.length) + return; + const isExpect = methodName === 'expect_event'; + const asyncPrefix = !isExpect && isAsync ? 'async ' : ''; + for (const event of clazz.events) { + const payload = eventPayload(clazz, event); + const returnType = isExpect ? `${EVENT_CONTEXT_MANAGER_CLASS}["${payload}"]` : `"${payload}"`; + out.push(' @typing.overload'); + out.push(` ${asyncPrefix}def ${methodName}(`); + out.push(' self,'); + out.push(` event: typing.Literal["${event.name.toLowerCase()}"],`); + out.push(` predicate: typing.Optional[typing.Callable[["${payload}"], bool]] = None,`); + out.push(' *,'); + out.push(' timeout: typing.Optional[float] = None,'); + out.push(` ) -> ${returnType}: ...`); + out.push(''); + } + const catchAllReturn = isExpect ? `${EVENT_CONTEXT_MANAGER_CLASS}[typing.Any]` : 'typing.Any'; + out.push(' @typing.overload'); + out.push(` ${asyncPrefix}def ${methodName}(`); + out.push(' self,'); + out.push(' event: str,'); + out.push(' predicate: typing.Optional[typing.Callable[..., bool]] = None,'); + out.push(' *,'); + out.push(' timeout: typing.Optional[float] = None,'); + out.push(` ) -> ${catchAllReturn}: ...`); + out.push(''); +} + +/** + * @param {PyParam} param + * @param {string} key `Class.member.param`, in Python names. + */ +function paramAnnotation(param, key) { + // TEMPORARY: applyLegacyUnionOrder() is the legacy order. + let type = applyLegacyUnionOrder(param.type, key); + if (param.implName === 'timeout' && type.kind === 'name' && type.name === 'float') + type = makeUnion([NAME('float'), NAME('datetime.timedelta')]); + if (param.optional && !(type.kind === 'union' && type.members?.some(isNone))) + type = { kind: 'union', members: [type, NONE] }; + return annotation(type) + (param.optional ? ' = None' : ''); +} + +/** + * @param {PyClass} clazz + * @param {PyMember} member + */ +function emitDocstring(clazz, member, out) { + const indent = ' '.repeat(8); + out.push(`${indent}"""${clazz.name}.${member.name}`); + if (member.comment) + out.push(`${indent}${formatComment(member.comment, indent)}`); + const params = [...member.positional, ...member.keywordOnly].filter(p => p.documented); + if (params.length) { + out.push(''); + out.push(`${indent}Parameters`); + out.push(`${indent}----------`); + for (const param of params) { + const type = param.optional ? makeOptional(docType(param.type)) : docType(param.type); + out.push(`${indent}${param.name} : ${type}`); + if (param.comment) + out.push(`${indent} ${indentParagraph(escapeBackslashes(param.comment), indent + ' ')}`); + if (param.deprecated) + out.push(`${indent} Deprecated: ${escapeBackslashes(param.deprecated)}`); + } + } + if (member.returnType) { + out.push(''); + out.push(`${indent}Returns`); + out.push(`${indent}-------`); + out.push(`${indent}${docType(member.returnType)}`); + } + out.push(`${indent}"""`); +} + +/** + * @param {PyClass} clazz + * @param {PyMember} member + * @param {string[]} out + */ +function emitMember(clazz, member, out) { + const returnAnnotation = member.returnType ? annotation(member.returnType) : 'None'; + const wrapper = returnWrapper(member.returnType); + if (member.kind === 'property') { + out.push(' @property'); + out.push(` def ${member.name}(self) -> ${returnAnnotation}:`); + if (member.documented) + emitDocstring(clazz, member, out); + out.push(` return ${wrapper}(self._impl_obj.${member.name})`); + return; + } + + /** @param {PyParam} p */ + const declaration = p => `${p.name}: ${paramAnnotation(p, `${clazz.name}.${member.name}.${p.name}`)}`; + const params = ['self', ...member.positional.map(declaration)]; + if (member.keywordOnly.length) + params.push('*', ...member.keywordOnly.map(declaration)); + out.push(` ${member.isAsync && isAsync ? 'async ' : ''}def ${member.name}(${params.join(', ')}) -> ${returnAnnotation}:`); + if (member.documented) + emitDocstring(clazz, member, out); + if (rules.tracebackHideClasses.includes(clazz.name)) + out.push(' __tracebackhide__ = True'); + + // Dunder methods take positional arguments only. + const isDunder = member.name.startsWith('__'); + const callArgs = [...member.positional, ...member.keywordOnly].map(p => isDunder ? forwardExpression(p) : `${p.implName}=${forwardExpression(p)}`).join(', '); + const call = `self._impl_obj.${member.name}(${callArgs})`; + if (member.returnType?.kind === 'ecm') { + if (isAsync) { + out.push(''); + out.push(` return AsyncEventContextManager(${call}.future)`); + } else { + out.push(` return EventContextManager(self, ${call}.future)`); + } + return; + } + out.push(''); + let expression = call; + if (member.isAsync) + expression = isAsync ? `await ${call}` : `self._sync(${call})`; + if (member.returnType) + out.push(` return ${wrapper}(${expression})`); + else if (legacyOrder) // TEMPORARY: the legacy order. + out.push(` return mapping.from_maybe_impl(${expression})`); + else + out.push(` ${expression}`); +} + +/** + * @param {PyClass} clazz + * @param {string[]} out + */ +function emitClass(clazz, out) { + out.push(''); + out.push(`class ${clazz.name}(${clazz.base}):`); + // Typed, so that mypy checks every call below against the implementation. + if (!legacyOrder) // TEMPORARY: the legacy order. + out.push(` _impl_obj: ${clazz.name}Impl`); + out.push(''); + emitEvents(clazz, out); + for (const member of clazz.members) { + out.push(''); + if (member.name === 'expect_event' || member.name === 'wait_for_event') + emitEventOverloads(clazz, member.name, out); + emitMember(clazz, member, out); + } + out.push(''); + out.push(`mapping.register(${clazz.name}Impl, ${clazz.name})`); +} + +function checkRules() { + for (const table of ['extraMembers', 'extraOptions', 'skipArgs', 'argNames', 'typeOverrides', 'positional', 'structRenames', 'structNamesByPlace', 'implModules']) { + for (const key of Object.keys(/** @type {any} */ (rules)[table])) { + if (!usedRules.has(`${table}:${key}`)) + errors.push(`${table}: the entry "${key}" no longer matches anything; remove it`); + } + } + for (const table of ['handWrittenClasses', 'contextManagerClasses', 'tracebackHideClasses']) { + for (const name of /** @type {any} */ (rules)[table]) { + if (!documentation.classes.has(name)) + errors.push(`${table}: the docs have no class ${name}`); + } + } +} + +function main() { + // TEMPORARY: applyLegacyOrder() is the legacy order. + const classes = applyLegacyOrder(orderedClasses().map(buildClass)); + const body = []; + for (const clazz of classes) + emitClass(clazz, body); + + const header = [LICENSE, '', 'import typing', 'import pathlib', 'import datetime', '', 'from typing import Literal', '']; + if (usedStructs.size) + header.push(`from playwright._impl._api_structures import ${[...usedStructs].sort().join(', ')}`); + for (const clazz of classes) + header.push(`from playwright._impl.${implModule(clazz.name)} import ${clazz.name} as ${clazz.name}Impl`); + header.push('from playwright._impl._errors import Error'); + header.push('from playwright._impl._form_data import FormData'); + header.push('from playwright._impl._helper import to_milliseconds'); + if (isAsync) + header.push('from playwright._impl._async_base import AsyncEventContextManager, AsyncBase, AsyncContextManager, mapping'); + else + header.push('from playwright._impl._sync_base import EventContextManager, SyncBase, SyncContextManager, mapping'); + + checkRules(); + checkLegacyUnions(); // TEMPORARY: the legacy order. + if (errors.length) { + for (const error of errors) + console.error(error); + process.exit(1); + } + + const text = [...header, ...body, ''].join('\n'); + if (options.out) + fs.writeFileSync(options.out, text); + else + process.stdout.write(text); +} + +main(); diff --git a/scripts/codegen/legacy_order.json b/scripts/codegen/legacy_order.json new file mode 100644 index 000000000..8dc9f1861 --- /dev/null +++ b/scripts/codegen/legacy_order.json @@ -0,0 +1,293 @@ +{ + "classes": [ + "Request", + "Response", + "Route", + "WebSocket", + "WebSocketRoute", + "Keyboard", + "Mouse", + "Touchscreen", + "JSHandle", + "ElementHandle", + "FileChooser", + "Frame", + "FrameLocator", + "Worker", + "Selectors", + "Clock", + "Credentials", + "ConsoleMessage", + "Debugger", + "Dialog", + "Download", + "Screencast", + "Video", + "WebMCP", + "Page", + "WebError", + "WebStorage", + "BrowserContext", + "CDPSession", + "Browser", + "BrowserType", + "Playwright", + "Tracing", + "Locator", + "APIResponse", + "APIRequestContext", + "APIRequest", + "PageAssertions", + "LocatorAssertions", + "APIResponseAssertions", + "Disposable" + ], + "members": { + "Request": ["url", "resource_type", "service_worker", "method", "post_data", "post_data_json", "post_data_buffer", "existing_response", "frame", "redirected_from", "redirected_to", "failure", "timing", "headers", "sizes", "response", "is_navigation_request", "all_headers", "headers_array", "header_value"], + "Response": ["url", "ok", "status", "status_text", "headers", "from_service_worker", "request", "frame", "all_headers", "headers_array", "header_value", "header_values", "server_addr", "security_details", "http_version", "finished", "body", "text", "json"], + "Route": ["request", "abort", "fulfill", "fetch", "fallback", "continue_"], + "WebSocket": ["url", "expect_event", "wait_for_event", "is_closed"], + "WebSocketRoute": ["url", "protocols", "close", "connect_to_server", "send", "on_message", "on_close"], + "Keyboard": ["down", "up", "insert_text", "type", "press"], + "Mouse": ["move", "down", "up", "click", "dblclick", "wheel"], + "JSHandle": ["evaluate", "evaluate_handle", "get_property", "get_properties", "as_element", "dispose", "json_value"], + "ElementHandle": ["owner_frame", "content_frame", "get_attribute", "text_content", "inner_text", "inner_html", "is_checked", "is_disabled", "is_editable", "is_enabled", "is_hidden", "is_visible", "dispatch_event", "scroll_into_view_if_needed", "hover", "click", "dblclick", "select_option", "tap", "fill", "select_text", "input_value", "set_input_files", "focus", "type", "press", "set_checked", "check", "uncheck", "bounding_box", "screenshot", "query_selector", "query_selector_all", "eval_on_selector", "eval_on_selector_all", "wait_for_element_state", "wait_for_selector"], + "FileChooser": ["page", "element", "is_multiple", "set_files"], + "Frame": ["page", "name", "url", "webmcp", "parent_frame", "child_frames", "goto", "expect_navigation", "wait_for_url", "wait_for_load_state", "frame_element", "evaluate", "evaluate_handle", "query_selector", "query_selector_all", "wait_for_selector", "is_checked", "is_disabled", "is_editable", "is_enabled", "is_hidden", "is_visible", "dispatch_event", "eval_on_selector", "eval_on_selector_all", "content", "set_content", "is_detached", "add_script_tag", "add_style_tag", "click", "dblclick", "tap", "fill", "locator", "get_by_alt_text", "get_by_label", "get_by_placeholder", "get_by_role", "get_by_test_id", "get_by_text", "get_by_title", "frame_locator", "focus", "text_content", "inner_text", "inner_html", "get_attribute", "hover", "drag_and_drop", "select_option", "input_value", "set_input_files", "type", "press", "check", "uncheck", "wait_for_timeout", "wait_for_function", "title", "set_checked"], + "FrameLocator": ["first", "last", "owner", "locator", "get_by_alt_text", "get_by_label", "get_by_placeholder", "get_by_role", "get_by_test_id", "get_by_text", "get_by_title", "frame_locator", "nth"], + "Worker": ["url", "evaluate", "evaluate_handle", "expect_event"], + "Clock": ["install", "fast_forward", "pause_at", "resume", "run_for", "set_fixed_time", "set_system_time"], + "ConsoleMessage": ["type", "text", "args", "location", "timestamp", "page", "worker"], + "Dialog": ["type", "message", "default_value", "page", "accept", "dismiss"], + "Download": ["page", "url", "suggested_filename", "delete", "failure", "path", "save_as", "cancel"], + "Screencast": ["start", "stop", "show_actions", "hide_actions", "show_overlay", "show_chapter", "show_overlays", "hide_overlays"], + "Video": ["path", "save_as", "delete"], + "WebMCP": ["tools", "call_tool"], + "Page": ["keyboard", "mouse", "touchscreen", "context", "clock", "main_frame", "frames", "url", "viewport_size", "workers", "request", "video", "screencast", "webmcp", "local_storage", "session_storage", "opener", "frame", "set_default_navigation_timeout", "set_default_timeout", "query_selector", "query_selector_all", "wait_for_selector", "is_checked", "is_disabled", "is_editable", "is_enabled", "is_hidden", "is_visible", "dispatch_event", "evaluate", "evaluate_handle", "eval_on_selector", "eval_on_selector_all", "add_script_tag", "add_style_tag", "expose_function", "expose_binding", "set_extra_http_headers", "content", "set_content", "goto", "reload", "wait_for_load_state", "wait_for_url", "wait_for_event", "go_back", "go_forward", "request_gc", "emulate_media", "set_viewport_size", "bring_to_front", "hide_highlight", "add_init_script", "route", "unroute", "route_web_socket", "unroute_all", "route_from_har", "screenshot", "title", "aria_snapshot", "close", "is_closed", "click", "dblclick", "tap", "fill", "locator", "get_by_alt_text", "get_by_label", "get_by_placeholder", "get_by_ref", "get_by_role", "get_by_test_id", "get_by_text", "get_by_title", "frame_locator", "focus", "text_content", "inner_text", "inner_html", "get_attribute", "hover", "drag_and_drop", "select_option", "input_value", "set_input_files", "type", "press", "check", "uncheck", "wait_for_timeout", "wait_for_function", "pause", "pdf", "expect_event", "expect_console_message", "expect_download", "expect_file_chooser", "expect_navigation", "expect_popup", "expect_request", "expect_request_finished", "expect_response", "expect_websocket", "expect_worker", "set_checked", "add_locator_handler", "remove_locator_handler", "requests", "console_messages", "page_errors", "clear_console_messages", "clear_page_errors", "pick_locator", "cancel_pick_locator"], + "BrowserContext": ["pages", "browser", "background_pages", "service_workers", "tracing", "debugger", "request", "clock", "credentials", "set_default_navigation_timeout", "set_default_timeout", "new_page", "cookies", "add_cookies", "clear_cookies", "grant_permissions", "clear_permissions", "set_geolocation", "set_extra_http_headers", "set_offline", "add_init_script", "expose_binding", "expose_function", "route", "unroute", "route_web_socket", "unroute_all", "route_from_har", "expect_event", "is_closed", "close", "storage_state", "set_storage_state", "wait_for_event", "expect_console_message", "expect_page", "new_cdp_session"], + "CDPSession": ["send", "detach"], + "Browser": ["contexts", "browser_type", "version", "is_connected", "new_context", "new_page", "close", "new_browser_cdp_session", "bind", "unbind", "start_tracing", "stop_tracing"], + "BrowserType": ["name", "executable_path", "launch", "launch_persistent_context", "connect_over_cdp", "connect"], + "Playwright": ["devices", "selectors", "chromium", "firefox", "webkit", "request", "__getitem__", "stop"], + "Tracing": ["start", "start_chunk", "stop_chunk", "stop", "group", "group_end", "start_har", "stop_har"], + "Locator": ["page", "first", "last", "content_frame", "description", "visible", "bounding_box", "check", "click", "dblclick", "dispatch_event", "evaluate", "evaluate_all", "evaluate_handle", "fill", "clear", "locator", "get_by_alt_text", "get_by_label", "get_by_placeholder", "get_by_role", "get_by_test_id", "get_by_text", "get_by_title", "frame_locator", "element_handle", "element_handles", "nth", "describe", "filter", "or_", "and_", "within", "focus", "blur", "all", "count", "drag_to", "drop", "get_attribute", "hover", "inner_html", "inner_text", "input_value", "is_checked", "is_disabled", "is_editable", "is_enabled", "is_hidden", "is_visible", "press", "screenshot", "aria_snapshot", "normalize", "scroll_into_view_if_needed", "select_option", "select_text", "set_input_files", "tap", "text_content", "type", "press_sequentially", "uncheck", "all_inner_texts", "all_text_contents", "wait_for", "wait_for_function", "set_checked", "highlight", "hide_highlight"], + "APIResponse": ["ok", "url", "status", "status_text", "headers", "headers_array", "timing", "body", "text", "json", "security_details", "server_addr", "dispose"], + "APIRequestContext": ["tracing", "dispose", "cookies", "add_cookies", "clear_cookies", "delete", "head", "get", "patch", "put", "post", "fetch", "storage_state"], + "PageAssertions": ["to_have_title", "not_to_have_title", "to_have_url", "not_to_have_url", "to_match_aria_snapshot", "not_to_match_aria_snapshot"], + "LocatorAssertions": ["to_contain_text", "not_to_contain_text", "to_have_attribute", "not_to_have_attribute", "to_have_class", "not_to_have_class", "to_contain_class", "not_to_contain_class", "to_have_count", "not_to_have_count", "to_have_css", "not_to_have_css", "to_have_id", "not_to_have_id", "to_have_js_property", "not_to_have_js_property", "to_have_value", "not_to_have_value", "to_have_values", "not_to_have_values", "to_have_text", "not_to_have_text", "to_be_attached", "to_be_checked", "not_to_be_attached", "not_to_be_checked", "to_be_disabled", "not_to_be_disabled", "to_be_editable", "not_to_be_editable", "to_be_empty", "not_to_be_empty", "to_be_enabled", "not_to_be_enabled", "to_be_hidden", "not_to_be_hidden", "to_be_visible", "not_to_be_visible", "to_be_focused", "not_to_be_focused", "to_be_in_viewport", "not_to_be_in_viewport", "to_have_accessible_description", "not_to_have_accessible_description", "to_have_accessible_name", "not_to_have_accessible_name", "to_have_role", "to_have_accessible_error_message", "not_to_have_accessible_error_message", "not_to_have_role", "to_match_aria_snapshot", "not_to_match_aria_snapshot"], + "APIResponseAssertions": ["to_be_ok", "not_to_be_ok"] + }, + "keywordOnly": { + "Route.fulfill": ["status", "headers", "body", "json", "path", "content_type", "response"], + "Route.fetch": ["url", "method", "headers", "post_data", "max_redirects", "max_retries", "timeout"], + "Route.fallback": ["url", "method", "headers", "post_data"], + "Route.continue_": ["url", "method", "headers", "post_data"], + "Mouse.click": ["delay", "button", "click_count"], + "Mouse.dblclick": ["delay", "button"], + "ElementHandle.hover": ["modifiers", "position", "timeout", "no_wait_after", "force", "trial", "scroll"], + "ElementHandle.click": ["modifiers", "position", "delay", "button", "click_count", "timeout", "force", "no_wait_after", "trial", "steps", "scroll"], + "ElementHandle.dblclick": ["modifiers", "position", "delay", "button", "timeout", "force", "no_wait_after", "trial", "steps", "scroll"], + "ElementHandle.select_option": ["index", "label", "element", "timeout", "force", "no_wait_after"], + "ElementHandle.tap": ["modifiers", "position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "ElementHandle.fill": ["timeout", "no_wait_after", "force"], + "ElementHandle.set_input_files": ["timeout", "no_wait_after"], + "ElementHandle.type": ["delay", "timeout", "no_wait_after"], + "ElementHandle.press": ["delay", "timeout", "no_wait_after"], + "ElementHandle.set_checked": ["position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "ElementHandle.check": ["position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "ElementHandle.uncheck": ["position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "ElementHandle.screenshot": ["timeout", "type", "path", "quality", "omit_background", "animations", "caret", "scale", "mask", "mask_color", "style"], + "ElementHandle.wait_for_selector": ["state", "timeout", "strict"], + "FileChooser.set_files": ["timeout", "no_wait_after"], + "Frame.goto": ["timeout", "wait_until", "referer"], + "Frame.expect_navigation": ["url", "wait_until", "timeout"], + "Frame.wait_for_url": ["wait_until", "timeout"], + "Frame.wait_for_selector": ["strict", "timeout", "state"], + "Frame.add_script_tag": ["url", "path", "content", "type"], + "Frame.add_style_tag": ["url", "path", "content"], + "Frame.click": ["modifiers", "position", "delay", "button", "click_count", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Frame.dblclick": ["modifiers", "position", "delay", "button", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Frame.tap": ["modifiers", "position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Frame.fill": ["timeout", "no_wait_after", "strict", "force"], + "Frame.locator": ["has_text", "has_not_text", "has", "has_not"], + "Frame.get_by_role": ["checked", "disabled", "expanded", "include_hidden", "level", "name", "pressed", "selected", "exact", "description"], + "Frame.hover": ["modifiers", "position", "timeout", "no_wait_after", "force", "strict", "trial", "scroll"], + "Frame.drag_and_drop": ["source_position", "target_position", "force", "no_wait_after", "strict", "timeout", "trial", "steps", "scroll"], + "Frame.select_option": ["index", "label", "element", "timeout", "no_wait_after", "strict", "force"], + "Frame.set_input_files": ["strict", "timeout", "no_wait_after"], + "Frame.type": ["delay", "strict", "timeout", "no_wait_after"], + "Frame.press": ["delay", "strict", "timeout", "no_wait_after"], + "Frame.check": ["position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Frame.uncheck": ["position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Frame.wait_for_function": ["arg", "timeout", "polling"], + "Frame.set_checked": ["position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "FrameLocator.locator": ["has_text", "has_not_text", "has", "has_not"], + "FrameLocator.get_by_role": ["checked", "disabled", "expanded", "include_hidden", "level", "name", "pressed", "selected", "exact", "description"], + "Selectors.register": ["path", "content_script"], + "Credentials.create": ["id", "user_handle", "private_key", "public_key", "sign_count"], + "Credentials.get": ["rp_id", "id"], + "Screencast.start": ["on_frame", "path", "quality", "size", "fps"], + "Screencast.show_actions": ["duration", "position", "font_size", "cursor", "style"], + "Page.wait_for_selector": ["timeout", "state", "strict"], + "Page.dispatch_event": ["timeout", "strict"], + "Page.add_script_tag": ["url", "path", "content", "type"], + "Page.add_style_tag": ["url", "path", "content"], + "Page.goto": ["timeout", "wait_until", "referer"], + "Page.wait_for_url": ["wait_until", "timeout"], + "Page.emulate_media": ["media", "color_scheme", "reduced_motion", "forced_colors", "contrast"], + "Page.route_from_har": ["url", "not_found", "update", "update_content", "update_mode"], + "Page.screenshot": ["timeout", "type", "path", "quality", "omit_background", "full_page", "clip", "animations", "caret", "scale", "mask", "mask_color", "style"], + "Page.aria_snapshot": ["timeout", "depth", "mode", "boxes"], + "Page.close": ["run_before_unload", "reason"], + "Page.click": ["modifiers", "position", "delay", "button", "click_count", "timeout", "force", "no_wait_after", "trial", "strict", "scroll"], + "Page.dblclick": ["modifiers", "position", "delay", "button", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Page.tap": ["modifiers", "position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Page.fill": ["timeout", "no_wait_after", "strict", "force"], + "Page.locator": ["has_text", "has_not_text", "has", "has_not"], + "Page.get_by_role": ["checked", "disabled", "expanded", "include_hidden", "level", "name", "pressed", "selected", "exact", "description"], + "Page.hover": ["modifiers", "position", "timeout", "no_wait_after", "force", "strict", "trial", "scroll"], + "Page.drag_and_drop": ["source_position", "target_position", "force", "no_wait_after", "timeout", "strict", "trial", "steps", "scroll"], + "Page.select_option": ["index", "label", "element", "timeout", "no_wait_after", "force", "strict"], + "Page.set_input_files": ["timeout", "strict", "no_wait_after"], + "Page.type": ["delay", "timeout", "no_wait_after", "strict"], + "Page.press": ["delay", "timeout", "no_wait_after", "strict"], + "Page.check": ["position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Page.uncheck": ["position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "Page.wait_for_function": ["arg", "timeout", "polling"], + "Page.pdf": ["scale", "display_header_footer", "header_template", "footer_template", "print_background", "landscape", "page_ranges", "format", "width", "height", "prefer_css_page_size", "margin", "path", "outline", "tagged"], + "Page.expect_navigation": ["url", "wait_until", "timeout"], + "Page.set_checked": ["position", "timeout", "force", "no_wait_after", "strict", "trial", "scroll"], + "BrowserContext.clear_cookies": ["name", "domain", "path"], + "BrowserContext.route_from_har": ["url", "not_found", "update", "update_content", "update_mode"], + "BrowserContext.storage_state": ["path", "indexed_db", "opfs", "credentials"], + "Browser.new_context": ["viewport", "screen", "no_viewport", "ignore_https_errors", "java_script_enabled", "bypass_csp", "user_agent", "locale", "timezone_id", "geolocation", "permissions", "extra_http_headers", "offline", "http_credentials", "device_scale_factor", "is_mobile", "has_touch", "color_scheme", "reduced_motion", "forced_colors", "contrast", "accept_downloads", "default_browser_type", "proxy", "record_har_path", "record_har_omit_content", "record_video_dir", "record_video_size", "storage_state", "base_url", "strict_selectors", "service_workers", "record_har_url_filter", "record_har_mode", "record_har_content", "client_certificates", "record_video_fps"], + "Browser.new_page": ["viewport", "screen", "no_viewport", "ignore_https_errors", "java_script_enabled", "bypass_csp", "user_agent", "locale", "timezone_id", "geolocation", "permissions", "extra_http_headers", "offline", "http_credentials", "device_scale_factor", "is_mobile", "has_touch", "color_scheme", "forced_colors", "contrast", "reduced_motion", "accept_downloads", "default_browser_type", "proxy", "record_har_path", "record_har_omit_content", "record_video_dir", "record_video_size", "storage_state", "base_url", "strict_selectors", "service_workers", "record_har_url_filter", "record_har_mode", "record_har_content", "client_certificates", "record_video_fps"], + "Browser.bind": ["workspace_dir", "host", "port"], + "Browser.start_tracing": ["page", "path", "screenshots", "categories"], + "BrowserType.launch": ["executable_path", "channel", "args", "ignore_default_args", "handle_sigint", "handle_sigterm", "handle_sighup", "timeout", "env", "headless", "proxy", "downloads_path", "slow_mo", "traces_dir", "artifacts_dir", "chromium_sandbox", "firefox_user_prefs"], + "BrowserType.launch_persistent_context": ["channel", "executable_path", "args", "ignore_default_args", "handle_sigint", "handle_sigterm", "handle_sighup", "timeout", "env", "headless", "proxy", "downloads_path", "slow_mo", "viewport", "screen", "no_viewport", "ignore_https_errors", "java_script_enabled", "bypass_csp", "user_agent", "locale", "timezone_id", "geolocation", "permissions", "extra_http_headers", "offline", "http_credentials", "device_scale_factor", "is_mobile", "has_touch", "color_scheme", "reduced_motion", "forced_colors", "contrast", "accept_downloads", "traces_dir", "artifacts_dir", "chromium_sandbox", "firefox_user_prefs", "record_har_path", "record_har_omit_content", "record_video_dir", "record_video_size", "base_url", "strict_selectors", "service_workers", "record_har_url_filter", "record_har_mode", "record_har_content", "client_certificates", "record_video_fps"], + "BrowserType.connect_over_cdp": ["timeout", "slow_mo", "headers", "is_local", "no_defaults", "artifacts_dir"], + "BrowserType.connect": ["timeout", "slow_mo", "headers", "expose_network"], + "Tracing.start": ["name", "title", "snapshots", "aria_snapshots", "screen_snapshots", "screenshots", "sources", "live"], + "Tracing.start_chunk": ["title", "name"], + "Locator.check": ["position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "Locator.click": ["modifiers", "position", "delay", "button", "click_count", "timeout", "force", "no_wait_after", "trial", "steps", "scroll"], + "Locator.dblclick": ["modifiers", "position", "delay", "button", "timeout", "force", "no_wait_after", "trial", "steps", "scroll"], + "Locator.fill": ["timeout", "no_wait_after", "force"], + "Locator.clear": ["timeout", "no_wait_after", "force"], + "Locator.locator": ["has_text", "has_not_text", "has", "has_not"], + "Locator.get_by_role": ["checked", "disabled", "expanded", "include_hidden", "level", "name", "pressed", "selected", "exact", "description"], + "Locator.filter": ["has_text", "has_not_text", "has", "has_not", "visible"], + "Locator.drag_to": ["force", "no_wait_after", "timeout", "trial", "source_position", "target_position", "steps", "scroll"], + "Locator.hover": ["modifiers", "position", "timeout", "no_wait_after", "force", "trial", "scroll"], + "Locator.press": ["delay", "timeout", "no_wait_after"], + "Locator.screenshot": ["timeout", "type", "path", "quality", "omit_background", "animations", "caret", "scale", "mask", "mask_color", "style"], + "Locator.aria_snapshot": ["timeout", "depth", "mode", "boxes"], + "Locator.select_option": ["index", "label", "element", "timeout", "no_wait_after", "force"], + "Locator.set_input_files": ["timeout", "no_wait_after"], + "Locator.tap": ["modifiers", "position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "Locator.type": ["delay", "timeout", "no_wait_after"], + "Locator.press_sequentially": ["delay", "timeout", "no_wait_after"], + "Locator.uncheck": ["position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "Locator.wait_for": ["timeout", "state"], + "Locator.set_checked": ["position", "timeout", "force", "no_wait_after", "trial", "scroll"], + "APIRequestContext.clear_cookies": ["name", "domain", "path"], + "APIRequestContext.delete": ["params", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.head": ["params", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.get": ["params", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.patch": ["params", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.put": ["params", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.post": ["params", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.fetch": ["params", "method", "headers", "data", "form", "multipart", "timeout", "fail_on_status_code", "ignore_https_errors", "max_redirects", "max_retries"], + "APIRequestContext.storage_state": ["path", "indexed_db", "opfs"], + "APIRequest.new_context": ["base_url", "extra_http_headers", "http_credentials", "ignore_https_errors", "proxy", "user_agent", "timeout", "storage_state", "client_certificates", "fail_on_status_code", "max_redirects"], + "PageAssertions.to_have_url": ["timeout", "ignore_case"], + "PageAssertions.not_to_have_url": ["timeout", "ignore_case"], + "LocatorAssertions.to_contain_text": ["use_inner_text", "timeout", "ignore_case"], + "LocatorAssertions.not_to_contain_text": ["use_inner_text", "timeout", "ignore_case"], + "LocatorAssertions.to_have_css": ["timeout", "pseudo"], + "LocatorAssertions.to_have_text": ["use_inner_text", "timeout", "ignore_case"], + "LocatorAssertions.not_to_have_text": ["use_inner_text", "timeout", "ignore_case"], + "LocatorAssertions.to_be_checked": ["timeout", "checked", "indeterminate"], + "LocatorAssertions.to_be_visible": ["visible", "timeout"], + "LocatorAssertions.not_to_be_visible": ["visible", "timeout"] + }, + "unions": { + "Route.fulfill.path": ["pathlib.Path", "str"], + "Route.fetch.post_data": ["typing.Any", "str", "bytes"], + "Route.fallback.post_data": ["typing.Any", "str", "bytes"], + "Route.continue_.post_data": ["typing.Any", "str", "bytes"], + "ElementHandle.set_input_files.files": ["str", "pathlib.Path", "FilePayload", "typing.Sequence[typing.Union[str, pathlib.Path]]", "typing.Sequence[FilePayload]"], + "ElementHandle.screenshot.path": ["pathlib.Path", "str"], + "FileChooser.set_files.files": ["str", "pathlib.Path", "FilePayload", "typing.Sequence[typing.Union[str, pathlib.Path]]", "typing.Sequence[FilePayload]"], + "Frame.add_script_tag.path": ["pathlib.Path", "str"], + "Frame.add_style_tag.path": ["pathlib.Path", "str"], + "Frame.set_input_files.files": ["str", "pathlib.Path", "FilePayload", "typing.Sequence[typing.Union[str, pathlib.Path]]", "typing.Sequence[FilePayload]"], + "FrameLocator.locator.selector_or_locator": ["'Locator'", "str"], + "Selectors.register.path": ["pathlib.Path", "str"], + "Screencast.start.path": ["pathlib.Path", "str"], + "Page.add_script_tag.path": ["pathlib.Path", "str"], + "Page.add_style_tag.path": ["pathlib.Path", "str"], + "Page.add_init_script.path": ["pathlib.Path", "str"], + "Page.route_from_har.har": ["pathlib.Path", "str"], + "Page.screenshot.path": ["pathlib.Path", "str"], + "Page.set_input_files.files": ["str", "pathlib.Path", "FilePayload", "typing.Sequence[typing.Union[str, pathlib.Path]]", "typing.Sequence[FilePayload]"], + "Page.pdf.path": ["pathlib.Path", "str"], + "BrowserContext.add_init_script.path": ["pathlib.Path", "str"], + "BrowserContext.route_from_har.har": ["pathlib.Path", "str"], + "BrowserContext.storage_state.path": ["pathlib.Path", "str"], + "BrowserContext.set_storage_state.storage_state": ["StorageState", "str", "pathlib.Path"], + "Browser.new_context.record_har_path": ["pathlib.Path", "str"], + "Browser.new_context.record_video_dir": ["pathlib.Path", "str"], + "Browser.new_context.storage_state": ["StorageState", "str", "pathlib.Path"], + "Browser.new_page.record_har_path": ["pathlib.Path", "str"], + "Browser.new_page.record_video_dir": ["pathlib.Path", "str"], + "Browser.new_page.storage_state": ["StorageState", "str", "pathlib.Path"], + "Browser.start_tracing.path": ["pathlib.Path", "str"], + "BrowserType.launch.executable_path": ["pathlib.Path", "str"], + "BrowserType.launch.downloads_path": ["pathlib.Path", "str"], + "BrowserType.launch.traces_dir": ["pathlib.Path", "str"], + "BrowserType.launch.artifacts_dir": ["pathlib.Path", "str"], + "BrowserType.launch_persistent_context.executable_path": ["pathlib.Path", "str"], + "BrowserType.launch_persistent_context.downloads_path": ["pathlib.Path", "str"], + "BrowserType.launch_persistent_context.traces_dir": ["pathlib.Path", "str"], + "BrowserType.launch_persistent_context.artifacts_dir": ["pathlib.Path", "str"], + "BrowserType.launch_persistent_context.record_har_path": ["pathlib.Path", "str"], + "BrowserType.launch_persistent_context.record_video_dir": ["pathlib.Path", "str"], + "BrowserType.connect_over_cdp.artifacts_dir": ["pathlib.Path", "str"], + "Tracing.stop_chunk.path": ["pathlib.Path", "str"], + "Tracing.stop.path": ["pathlib.Path", "str"], + "Tracing.start_har.path": ["pathlib.Path", "str"], + "Locator.screenshot.path": ["pathlib.Path", "str"], + "Locator.set_input_files.files": ["str", "pathlib.Path", "FilePayload", "typing.Sequence[typing.Union[str, pathlib.Path]]", "typing.Sequence[FilePayload]"], + "APIRequestContext.delete.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.delete.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.head.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.head.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.get.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.get.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.patch.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.patch.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.put.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.put.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.post.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.post.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.fetch.data": ["typing.Any", "bytes", "str"], + "APIRequestContext.fetch.multipart": ["typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]]", "'FormData'", "bytes", "bool", "float", "str", "FilePayload"], + "APIRequestContext.storage_state.path": ["pathlib.Path", "str"], + "APIRequest.new_context.storage_state": ["StorageState", "str", "pathlib.Path"], + "PageAssertions.to_have_title.title_or_reg_exp": ["typing.Pattern[str]", "str"], + "PageAssertions.not_to_have_title.title_or_reg_exp": ["typing.Pattern[str]", "str"], + "PageAssertions.not_to_have_url.url_or_reg_exp": ["typing.Pattern[str]", "str"], + "LocatorAssertions.to_contain_text.expected": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.not_to_contain_text.expected": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.to_have_class.expected": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.not_to_have_class.expected": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.to_contain_class.expected": ["typing.Sequence[str]", "str"], + "LocatorAssertions.not_to_contain_class.expected": ["typing.Sequence[str]", "str"], + "LocatorAssertions.to_have_values.values": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.not_to_have_values.values": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.to_have_text.expected": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"], + "LocatorAssertions.not_to_have_text.expected": ["typing.Sequence[str]", "typing.Sequence[typing.Pattern[str]]", "typing.Sequence[typing.Union[typing.Pattern[str], str]]", "typing.Pattern[str]", "str"] + } +} diff --git a/scripts/codegen/python_rules.js b/scripts/codegen/python_rules.js new file mode 100644 index 000000000..d0c5fd54d --- /dev/null +++ b/scripts/codegen/python_rules.js @@ -0,0 +1,242 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +// Facts about the Python API that the upstream docs (docs/src/api) do not +// contain. The generator applies these on top of the docs. +// +// Policy: the shipped Python API is the reference. Each entry records one place +// where Python differs from the docs. The preferred fix is a docs change +// upstream, after which the generator reports the entry as unused and it must +// be removed. Every entry needs a one-line reason above it. +// +// Keys use docs names: `Class.member` or `Class.member.arg`, where `member` +// and `arg` are the names in the docs (camelCase), not the Python aliases. + +module.exports = { + // Classes that the docs mark for other languages only, but that Python has. + extraClasses: [ + // Python returns Disposable objects from route(), add_init_script() and others. + 'Disposable', + ], + + // Classes in the Python view of the docs that are written by hand. + handWrittenClasses: [ + // playwright/_impl/_errors.py + 'Error', + 'TimeoutError', + // playwright/_impl/_form_data.py + 'FormData', + ], + + // Classes that support `async with` / `with` and call close() on exit. + contextManagerClasses: ['Page', 'BrowserContext', 'Browser', 'Disposable'], + + // Classes whose methods hide themselves from pytest tracebacks. + tracebackHideClasses: ['PageAssertions', 'LocatorAssertions', 'APIResponseAssertions'], + + // Members that Python has but the docs do not describe. They get no docstring. + extraMembers: { + // Python-specific adapter for context manager support. + 'Disposable': [ + { kind: 'method', name: 'close', async: true, args: [], returns: 'void' }, + ], + // playwright["chromium"] picks a browser type by name. + 'Playwright': [ + { kind: 'method', name: '__getitem__', async: false, args: [{ name: 'value', type: 'string' }], returns: 'BrowserType' }, + ], + }, + + // Options that Python has but the docs do not describe. They get no docstring. + extraOptions: { + // Hidden: lets new_context(**playwright.devices["..."]) accept the device's browser type. + 'Browser.newContext': [{ name: 'defaultBrowserType', type: 'string' }], + // Hidden: lets new_page(**playwright.devices["..."]) accept the device's browser type. + 'Browser.newPage': [{ name: 'defaultBrowserType', type: 'string' }], + }, + + // Arguments that the docs describe but Python does not have. + skipArgs: { + // Deprecated and ignored; Python never added it to Frame (Page and Locator have it). + 'Frame.isHidden.timeout': true, + // Deprecated and ignored; Python never added it to Frame (Page and Locator have it). + 'Frame.isVisible.timeout': true, + }, + + // Arguments whose Python name differs from the docs. + argNames: { + // The docs header says `name`, the type line says `description`; Python shipped `name`. + 'LocatorAssertions.NotToHaveAccessibleDescription.name': 'name', + }, + + // Python types that differ from the docs. Keys are argument places + // (`Class.member.arg`) or member places (`Class.member`, for the return + // type). Values are docs type expressions, or functions that build a docs + // Type when the expression parser cannot. In an expression, a function type + // with a return type must come last in a union: the parser reads everything + // after `:` as the return type. + typeOverrides: { + // One vs two arguments in the callback, Python explicitly unions. + 'BrowserContext.route.handler': '[function]([Route])|[function]([Route], [Request])', + // One vs two arguments in the callback, Python explicitly unions. + 'BrowserContext.unroute.handler': '[function]([Route])|[function]([Route], [Request])', + // One vs two arguments in the callback, Python explicitly unions. + 'Page.route.handler': '[function]([Route])|[function]([Route], [Request])', + // One vs two arguments in the callback, Python explicitly unions. + 'Page.unroute.handler': '[function]([Route])|[function]([Route], [Request])', + // Zero or one argument in the callback, Python explicitly unions. + 'Page.addLocatorHandler.handler': '[function]([Locator])|[function]():[any]', + // Python passes a plain dict; the docs type is EvaluationArgument. + '*.dispatchEvent.eventInit': '[Object]', + // Python returns None; the docs say it returns a Disposable. + 'Locator.highlight': 'void', + // Python returns None; the docs say `null|string` but also "returns always `null`". + 'Response.finished': 'void', + // Binary messages arrive as bytes. + 'WebSocketRoute.onMessage.handler': '[function]([string]|[Buffer]):[any]', + // Both close() arguments may be missing. The docs parser drops the second + // argument of a function type when the first one is a union, so the type is + // built by hand. + 'WebSocketRoute.onClose.handler': ({ parse }) => { + const type = parse('[function]([int]|[null]):[any]'); + type.args.push(parse('[string]|[null]')); + return type; + }, + // The async API also accepts an `async def` predicate. + 'Page.waitForRequest.urlOrPredicate': '[string]|[RegExp]|[function]([Request]):[boolean]|[Promise]<[boolean]>', + // The async API also accepts an `async def` predicate. + 'Page.waitForResponse.urlOrPredicate': '[string]|[RegExp]|[function]([Response]):[boolean]|[Promise]<[boolean]>', + }, + + // Methods whose positional parameters differ from the docs. By default every + // `### param` is positional (in docs order) and every `### option` is + // keyword-only. An entry lists the positional parameters, in order; every + // other parameter becomes keyword-only. Options named `predicate` are always + // positional (see `positionalOptions` below). + positional: { + // select_option(value, *, index, label, element). + 'ElementHandle.selectOption': ['value'], + // select_option(selector, value, *, index, label, element). + 'Frame.selectOption': ['selector', 'value'], + // select_option(value, *, index, label, element). + 'Locator.selectOption': ['value'], + // select_option(selector, value, *, index, label, element). + 'Page.selectOption': ['selector', 'value'], + // add_init_script(script, *, path). + 'BrowserContext.addInitScript': ['script'], + // add_init_script(script, *, path). + 'Page.addInitScript': ['script'], + // wait_for_function(expression, *, arg). + 'Frame.waitForFunction': ['expression'], + // wait_for_function(expression, *, arg). + 'Locator.waitForFunction': ['expression'], + // wait_for_function(expression, *, arg). + 'Page.waitForFunction': ['expression'], + // start_tracing(*, page). + 'Browser.startTracing': [], + // frame(name, *, url). + 'Page.frame': ['name'], + }, + + // Options that are positional parameters in Python, in every method. + positionalOptions: [ + // expect_*(predicate) and wait_for_event(event, predicate). + 'predicate', + ], + + // Struct (TypedDict) names. The docs name a struct with `* alias:`; these + // entries map a docs alias to the Python name, when they differ. + structRenames: { + // Python name. + BindResult: 'BrowserBindResult', + // Python name. + BoundingBox: 'FloatRect', + // Python name. + HttpHeader: 'NameValue', + // Python name. + Location: 'DebuggerLocation', + // Python name. + Proxy: 'ProxySettings', + // Python name. + ScreenSize: 'ViewportSize', + // Python name. + WebStorageItem: 'NameValue', + }, + + // Struct names for places where the docs give no alias, or where one docs + // alias needs two Python names. Keyed by place: `Class.member.arg[.prop]` + // for parameters and options, `Class.member` for return values and + // properties. `*` matches one name segment. + structNamesByPlace: { + // No docs alias. + 'Page.screenshot.clip': 'FloatRect', + // No docs alias. + 'ConsoleMessage.location': 'SourceLocation', + // No docs alias. + '*.storageState': 'StorageState', + // No docs alias. + '*.*.storageState': 'StorageState', + // No docs alias. + '*.addCookies.cookies': 'SetCookieParam', + // No docs alias. + '*.timing': 'ResourceTiming', + // No docs alias. + '*.serverAddr': 'RemoteAddr', + // No docs alias. + 'Request.sizes': 'RequestSizes', + // No docs alias. + '*.*.sourcePosition': 'Position', + // No docs alias. + '*.*.targetPosition': 'Position', + // No docs alias. + '*.*.viewport': 'ViewportSize', + // No docs alias. + '*.*.recordVideoSize': 'ViewportSize', + // No docs alias. + 'Page.setViewportSize.viewportSize': 'ViewportSize', + // No docs alias. + 'Screencast.start.size': 'ScreencastSize', + // No docs alias. + 'APIRequestContext.*.multipart': 'FilePayload', + // No docs alias. + 'Page.pdf.margin': 'PdfMargins', + // The docs alias is `Location`, which maps to DebuggerLocation elsewhere. + 'Tracing.group.location': 'TracingGroupLocation', + }, + + // Old implementation: the module in playwright/_impl/ that holds each class. + // Classes not listed are in `_.py`. + implModules: { + APIRequest: '_fetch', + APIRequestContext: '_fetch', + APIResponse: '_fetch', + APIResponseAssertions: '_assertions', + FrameLocator: '_locator', + Keyboard: '_input', + LocatorAssertions: '_assertions', + Mouse: '_input', + PageAssertions: '_assertions', + Request: '_network', + Response: '_network', + Route: '_network', + Touchscreen: '_input', + WebMCP: '_webmcp', + WebSocket: '_network', + WebSocketRoute: '_network', + Worker: '_page', + }, +}; diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py deleted file mode 100644 index 32c43e5d2..000000000 --- a/scripts/documentation_provider.py +++ /dev/null @@ -1,645 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import os -import pathlib -import re -from sys import stderr -from typing import Any, Dict, List, Set, Union, get_args, get_origin, get_type_hints -from urllib.parse import urljoin - -from playwright._impl._helper import to_snake_case - -enum_regex = r"^\"[^\"]+\"(?:\|\"[^\"]+\")+$" -union_regex = r"^[^\|]+(?:\|[^\|]+)+$" - - -class DocumentationProvider: - def __init__(self, is_async: bool) -> None: - self.is_async = is_async - self.api: Any = {} - self.links: Dict[str, str] = {} - self.printed_entries: List[str] = [] - api_json_path = os.environ.get("PW_API_JSON") - if not api_json_path: - raise RuntimeError( - "PW_API_JSON must point to a Playwright api.json file. Run codegen " - "via ./scripts/update_api.sh, which generates api.json from a " - "microsoft/playwright checkout (PW_SRC_DIR)." - ) - self.api = json.loads(pathlib.Path(api_json_path).read_text(encoding="utf-8")) - self.errors: Set[str] = set() - self.class_aliases: Dict[str, str] = { - "Disposable": "AsyncContextManager" if is_async else "SyncContextManager", - "DisposableStub": ( - "AsyncContextManager" if is_async else "SyncContextManager" - ), - } - self._patch_case() - - def _patch_case(self) -> None: - self.classes = {} - for clazz in self.api: - if not works_for_python(clazz) and clazz["name"] not in self.class_aliases: - continue - members = {} - self.classes[clazz["name"]] = clazz - events = [] - for member in clazz["members"]: - if not works_for_python(member): - continue - member_name = member["name"] - new_name = name_or_alias(member) - self._add_link(member["kind"], clazz["name"], member_name, new_name) - - if member["kind"] == "event": - events.append(member) - else: - new_name = to_snake_case(new_name) - member["name"] = new_name - members[new_name] = member - apply_type_or_override(member) - - if "args" in member: - args = {} - for arg in member["args"]: - if not works_for_python(arg): - continue - if arg["name"] == "options": - for option in arg["type"]["properties"]: - if not works_for_python(option): - continue - option = self_or_override(option) - option_name = to_snake_case(name_or_alias(option)) - option["name"] = option_name - option["required"] = False - args[option_name] = option - else: - arg = self_or_override(arg) - arg_name = to_snake_case(name_or_alias(arg)) - arg["name"] = arg_name - args[arg_name] = arg - - member["args"] = args - - clazz["members"] = members - clazz["events"] = events - - def _add_link(self, kind: str, clazz: str, member: str, alias: str) -> None: - match = re.match(r"(JS|CDP|[A-Z])([^.]+)", clazz) - if not match: - raise Exception("Invalid class " + clazz) - var_name = to_snake_case(f"{match.group(1).lower()}{match.group(2)}") - new_name = to_snake_case(alias) - if kind == "event": - new_name = new_name.lower() - self.links[f"[`event: {clazz}.{member}`]"] = ( - f"`{var_name}.on('{new_name}')`" - ) - elif kind == "property": - self.links[f"[`property: {clazz}.{member}`]"] = f"`{var_name}.{new_name}`" - else: - self.links[f"[`method: {clazz}.{member}`]"] = f"`{var_name}.{new_name}()`" - - def print_entry( - self, - class_name: str, - method_name: str, - signature: Dict[str, Any] = None, - is_property: bool = False, - ) -> None: - if class_name in ["BindingCall"] or method_name in [ - "pid", - ]: - return - original_method_name = method_name - self.printed_entries.append(f"{class_name}.{method_name}") - clazz = self.classes[class_name] - method = clazz["members"].get(method_name) - if not method and "extends" in clazz: - superclass = self.classes.get(clazz["extends"]) - if superclass: - method = superclass["members"].get(method_name) - fqname = f"{class_name}.{method_name}" - - if not method: - self.errors.add(f"Method not documented: {fqname}") - return - - doc_is_property = ( - not method.get("async") and not len(method["args"]) and "type" in method - ) - if ( - method["name"].startswith("is_") - or method["name"].startswith("as_") - or method["name"] == "connect_to_server" - ): - doc_is_property = False - if doc_is_property != is_property: - self.errors.add(f"Method vs property mismatch: {fqname}") - return - - indent = " " * 8 - print(f'{indent}"""{class_name}.{to_snake_case(original_method_name)}') - if method.get("comment"): - print(f"{indent}{self.beautify_method_comment(method['comment'], indent)}") - signature_no_return = {**signature} if signature else None - if signature_no_return and "return" in signature_no_return: - del signature_no_return["return"] - - # Collect a list of all names, flatten options. - args = method["args"] - if signature and signature_no_return: - print("") - print(" Parameters") - print(" ----------") - for [name, value] in signature.items(): - name = to_snake_case(name) - if name == "return": - continue - original_name = name - doc_value = args.get(name) - if name in args: - del args[name] - if not doc_value: - self.errors.add(f"Parameter not documented: {fqname}({name}=)") - else: - code_type = self.serialize_python_type(value, "in") - - print(f"{indent}{to_snake_case(original_name)} : {code_type}") - if doc_value.get("comment"): - print( - f"{indent} {self.indent_paragraph(self.render_links(doc_value['comment']), f'{indent} ')}" - ) - if doc_value.get("deprecated"): - print( - f"{indent} Deprecated: {self.render_links(doc_value['deprecated'])}" - ) - self.compare_types(code_type, doc_value, f"{fqname}({name}=)", "in") - if ( - signature - and "return" in signature - and str(signature["return"]) != "" - ): - value = signature["return"] - doc_value = method - self.compare_types(value, doc_value, f"{fqname}(return=)", "out") - print("") - print(" Returns") - print(" -------") - print(f" {self.serialize_python_type(value, 'out')}") - print(f'{indent}"""') - - for name in args: - if args[name].get("deprecated"): - continue - self.errors.add( - f"Parameter not implemented: {class_name}.{method_name}({name}=)" - ) - - def print_events(self, class_name: str) -> None: - clazz = self.classes[class_name] - events = clazz["events"] - if events: - doc = [] - for event_type in ["on", "once"]: - return_type = ( - "typing.Union[typing.Awaitable[None], None]" - if self.is_async - else "None" - ) - for event in events: - func_arg = self.serialize_doc_type(event["type"], "") - if func_arg.startswith("{"): - func_arg = "typing.Dict" - if "Union[" in func_arg: - func_arg = func_arg.replace("Union[", "typing.Union[") - doc.append(" @typing.overload") - doc.append( - f" def {event_type}(self, event: Literal['{event['name'].lower()}'], f: typing.Callable[['{func_arg}'], '{return_type}']) -> None:" - ) - doc.append( - f' """{self.beautify_method_comment(event["comment"], " " * 8)}"""' - ) - if len(events) == 1: - doc.append(" @typing.overload") - doc.append( - f" def {event_type}(self, event: str, f: typing.Callable[...,{return_type}]) -> None: ..." - ) - doc.append("") - doc.append( - f" def {event_type}(self, event: str, f: typing.Callable[...,{return_type}]) -> None:" - ) - doc.append(f" return super().{event_type}(event=event,f=f)") - print("\n".join(doc)) - - def print_event_overloads(self, class_name: str, method_name: str) -> None: - """Emit ``@typing.overload`` stubs for ``expect_event`` / ``wait_for_event`` - keyed on ``Literal`` event names with their payload types from api.json, - so pyright/mypy can narrow the return type at call sites. - Must be called right before the implementation signature is emitted. - """ - if class_name not in self.classes: - return - events = self.classes[class_name].get("events") or [] - if not events: - return - is_expect = method_name == "expect_event" - async_prefix = "async " if not is_expect and self.is_async else "" - if is_expect: - ctx_mgr = ( - "AsyncEventContextManager" if self.is_async else "EventContextManager" - ) - for event in events: - payload = self.serialize_doc_type(event["type"], "") - if payload.startswith("{"): - payload = "typing.Dict" - if "Union[" in payload: - payload = payload.replace("Union[", "typing.Union[") - return_type = f'{ctx_mgr}["{payload}"]' if is_expect else f'"{payload}"' - event_literal = event["name"].lower() - print(" @typing.overload") - print(f" {async_prefix}def {method_name}(") - print(" self,") - print(f' event: typing.Literal["{event_literal}"],') - print( - f' predicate: typing.Optional[typing.Callable[["{payload}"], bool]] = None,' - ) - print(" *,") - print(" timeout: typing.Optional[float] = None,") - print(f" ) -> {return_type}: ...") - print("") - # Catch-all overload for non-literal event names — keeps pyright happy - # with `event: str` callers without falling through to `Unknown`. - catchall_return = f"{ctx_mgr}[typing.Any]" if is_expect else "typing.Any" - print(" @typing.overload") - print(f" {async_prefix}def {method_name}(") - print(" self,") - print(" event: str,") - print(" predicate: typing.Optional[typing.Callable[..., bool]] = None,") - print(" *,") - print(" timeout: typing.Optional[float] = None,") - print(f" ) -> {catchall_return}: ...") - print("") - - def indent_paragraph(self, p: str, indent: str) -> str: - lines = p.split("\n") - result = [lines[0]] - for line in lines[1:]: - result.append(indent + line) - return "\n".join(result) - - def beautify_method_comment(self, comment: str, indent: str) -> str: - comment = self.filter_out_redundant_python_code_snippets(comment) - comment = comment.replace("\\", "\\\\") - comment = comment.replace('"', '\\"') - lines = comment.split("\n") - result = [] - skip_example = False - last_was_blank = True - for line in lines: - if not line.strip(): - last_was_blank = True - continue - match = re.match(r"\s*```(.+)", line) - if match: - lang = match[1] - if lang in ["html", "yml", "sh", "py", "python"]: - skip_example = False - elif lang == "python " + ("async" if self.is_async else "sync"): - skip_example = False - line = "```py" - else: - skip_example = True - if not skip_example: - if last_was_blank: - last_was_blank = False - result.append("") - result.append(self.render_links(line)) - if skip_example and line.strip() == "```": - skip_example = False - comment = self.indent_paragraph("\n".join(result), indent) - return self.resolve_playwright_dev_links(comment) - - def filter_out_redundant_python_code_snippets(self, comment: str) -> str: - groups = [] - current_group = [] - lines = comment.split("\n") - start_pos = None - for i in range(len(lines)): - line = lines[i].strip() - if line.startswith("```py"): - start_pos = i - elif line == "```" and start_pos is not None: - current_group.append((start_pos, i)) - start_pos = None - elif ( - (line.startswith("```") or i == len(lines) - 1) - and start_pos is None - and len(current_group) == 2 - ): - groups.append(current_group) - current_group = [] - groups.reverse() - for first_pos, second_pos in groups: - # flake8: noqa: E203 - second_snippet_is_async = "await" in lines[second_pos[0] : second_pos[1]] - if second_snippet_is_async == self.is_async: - # flake8: noqa: E203 - del lines[first_pos[0] : first_pos[1] + 1] - else: - # flake8: noqa: E203 - del lines[second_pos[0] : second_pos[1] + 1] - return "\n".join(lines) - - def resolve_playwright_dev_links(self, comment: str) -> str: - def replace_callback(m: re.Match) -> str: - link_text = m.group(1) - link_href = m.group(2) - resolved = urljoin( - "https://playwright.dev/python/docs/api/", link_href.replace(".md", "") - ) - return f"[{link_text}]({resolved})" - - # matches against internal markdown links which start with '.'/'..' - # e.g. [Playwright](./class-foobar.md) - return re.sub(r"\[([^\]]+)\]\((\.[^\)]+)\)", replace_callback, comment) - - def render_links(self, comment: str) -> str: - for [old, new] in self.links.items(): - comment = comment.replace(old, new) - return comment - - def make_optional(self, text: str) -> str: - if text.startswith("Union["): - if text.endswith("None]"): - return text - return text[:-1] + ", None]" - return f"Union[{text}, None]" - - def compare_types( - self, value: Any, doc_value: Any, fqname: str, direction: str - ) -> None: - if "(arg=)" in fqname or "(pageFunction=)" in fqname: - return - code_type = self.serialize_python_type(value, direction) - doc_type = self.serialize_doc_type(doc_value["type"], direction) - if not doc_value["required"]: - doc_type = self.make_optional(doc_type) - - if doc_type != code_type: - if ( - code_type in self.class_aliases - and doc_type == self.class_aliases[code_type] - ): - return - self.errors.add( - f"Parameter type mismatch in {fqname}: documented as {doc_type}, code has {code_type}" - ) - - def serialize_python_type(self, value: Any, direction: str) -> str: - str_value = str(value) - if isinstance(value, list): - return f"[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), value)))}]" - if str_value == "": - return "Error" - if str_value == "": - return "None" - if str_value == "": - return "datetime.datetime" - match = re.match(r"^$", str_value) - if match: - return match.group(1) - if str_value == str(pathlib.Path): - return "pathlib.Path" - match = re.match( - r"playwright._impl._event_context_manager.EventContextManagerImpl\[playwright._impl.[^.]+.(.*)\]", - str_value, - ) - if match: - return "EventContextManager[" + match.group(1) + "]" - match = re.match(r"^$", str_value) - if match and "_api_structures" not in str_value and "_errors" not in str_value: - if match.group(1) == "EventContextManagerImpl": - return "EventContextManager" - class_name = match.group(1) - if class_name in self.class_aliases: - return self.class_aliases[class_name] - return class_name - - match = re.match(r"^typing\.(\w+)$", str_value) - if match: - return match.group(1) - - origin = get_origin(value) - args = get_args(value) - hints = None - try: - hints = get_type_hints(value) - except Exception: - pass - if hints: - signature: List[str] = [] - for [name, value] in hints.items(): - signature.append( - f"{name}: {self.serialize_python_type(value, direction)}" - ) - return f"{{{', '.join(signature)}}}" - if origin == Union: - args = get_args(value) - if not self.is_async: - # Sync API doesn't accept awaitable callbacks; drop the - # Awaitable arm so docstring types match the sync signature. - args = tuple( - a - for a in args - if str(get_origin(a)) != "" - ) - if len(args) == 1: - return self.serialize_python_type(args[0], direction) - if len(args) == 2 and str(args[1]) == "": - return self.make_optional( - self.serialize_python_type(args[0], direction) - ) - ll = list(map(lambda a: self.serialize_python_type(a, direction), args)) - ll.sort(key=lambda item: "}" if item == "None" else item) - return f"Union[{', '.join(ll)}]" - if str(origin) == "": - args = get_args(value) - return f"Dict[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" - if str(origin) == "": - args = get_args(value) - return f"Sequence[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" - if str(origin) == "": - args = get_args(value) - list_type = "Sequence" if direction == "in" else "List" - return f"{list_type}[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" - if str(origin) == "": - args = get_args(value) - return f"Callable[{', '.join(list(map(lambda a: self.serialize_python_type(a, direction), args)))}]" - if str(origin) == "": - return "Pattern[str]" - if str(origin) == "typing.Literal": - args = get_args(value) - if len(args) == 1: - return '"' + self.serialize_python_type(args[0], direction) + '"' - body = ", ".join( - list( - map( - lambda a: '"' + self.serialize_python_type(a, direction) + '"', - args, - ) - ) - ) - return f"Union[{body}]" - return str_value - - def serialize_doc_type(self, type: Any, direction: str) -> str: - result = self.inner_serialize_doc_type(type, direction) - return result - - def inner_serialize_doc_type(self, type: Any, direction: str) -> str: - if type["name"] == "Promise": - if "templates" not in type: - return "Any" - type = type["templates"][0] - - if "union" in type: - ll = [self.serialize_doc_type(t, direction) for t in type["union"]] - ll.sort(key=lambda item: "}" if item == "None" else item) - for i in range(len(ll)): - if ll[i].startswith("Union["): - ll[i] = ll[i][6:-1] - return f"Union[{', '.join(ll)}]" - - type_name = type["name"] - if type_name == "path": - if direction == "in": - return "Union[pathlib.Path, str]" - else: - return "pathlib.Path" - - if type_name == "function" and "args" not in type: - return "Callable" - - if type_name == "function": - return_type = "Any" - if type.get("returnType"): - return_type = self.serialize_doc_type(type["returnType"], direction) - return f"Callable[[{', '.join(self.serialize_doc_type(t, direction) for t in type['args'])}], {return_type}]" - - if "templates" in type: - base = type_name - if type_name == "Array": - base = "Sequence" if direction == "in" else "List" - if type_name == "Object" or type_name == "Map": - base = "Dict" - return f"{base}[{', '.join(self.serialize_doc_type(t, direction) for t in type['templates'])}]" - - if type_name == "Object" and "properties" in type: - items = [] - for p in type["properties"]: - items.append( - (p["name"]) - + ": " - + ( - self.serialize_doc_type(p["type"], direction) - if p["required"] - else self.make_optional( - self.serialize_doc_type(p["type"], direction) - ) - ) - ) - return f"{{{', '.join(items)}}}" - if type_name == "boolean": - return "bool" - if type_name == "long": - return "int" - if type_name.lower() == "string": - return "str" - if type_name == "any" or type_name == "unknown" or type_name == "Serializable": - return "Any" - if type_name == "Object": - return "Dict" - if type_name == "Function": - return "Callable" - if type_name == "Buffer" or type_name == "ReadStream": - return "bytes" - if type_name == "Date": - return "datetime.datetime" - if type_name == "URL": - return "str" - if type_name == "RegExp": - return "Pattern[str]" - if type_name == "null" or type_name == "void": - return "None" - if type_name == "EvaluationArgument": - return "Dict" - if type_name in self.class_aliases: - return self.class_aliases[type_name] - return type["name"] - - def print_remainder(self) -> None: - for [class_name, clazz] in self.classes.items(): - for [member_name, member] in clazz["members"].items(): - if member.get("deprecated"): - continue - if class_name in ["Error", "FormData"]: - continue - entry = f"{class_name}.{member_name}" - if entry not in self.printed_entries: - self.errors.add(f"Method not implemented: {entry}") - - with open("scripts/expected_api_mismatch.txt") as f: - for line in f.readlines(): - sline = line.strip() - if not len(sline) or sline.startswith("#"): - continue - if sline in self.errors: - self.errors.remove(sline) - else: - print("No longer there: " + sline, file=stderr) - - if len(self.errors) > 0: - for error in self.errors: - print(error, file=stderr) - exit(1) - - -def works_for_python(item: Any) -> bool: - return not item["langs"].get("only") or "python" in item["langs"]["only"] - - -def name_or_alias(item: Any) -> str: - alias = ( - item["langs"].get("aliases").get("python") - if item["langs"].get("aliases") - else None - ) - return alias or item["name"] - - -def self_or_override(item: Any) -> Any: - override = ( - item["langs"].get("overrides").get("python") - if item["langs"].get("overrides") - else None - ) - return override or item - - -def apply_type_or_override(member: Any) -> Any: - if member["langs"].get("types") and member["langs"]["types"].get("python"): - member["type"] = member["langs"]["types"]["python"] diff --git a/scripts/expected_api_mismatch.txt b/scripts/expected_api_mismatch.txt deleted file mode 100644 index 685f81f8b..000000000 --- a/scripts/expected_api_mismatch.txt +++ /dev/null @@ -1,28 +0,0 @@ -# Playwright Python API - -# Hidden property -Parameter not documented: Browser.new_context(default_browser_type=) -Parameter not documented: Browser.new_page(default_browser_type=) - -# python-specific adapter for context manager support -Method not documented: Disposable.close - -# One vs two arguments in the callback, Python explicitly unions. -Parameter type mismatch in BrowserContext.route(handler=): documented as Callable[[Route, Request], Union[Any, Any]], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any]] -Parameter type mismatch in BrowserContext.unroute(handler=): documented as Union[Callable[[Route, Request], Union[Any, Any]], None], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any], None] -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]] -Parameter type mismatch in Page.unroute(handler=): documented as Union[Callable[[Route, Request], Union[Any, Any]], None], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any], None] - -# One vs two arguments in the callback, Python explicitly unions. -Parameter type mismatch in Page.add_locator_handler(handler=): documented as Callable[[Locator], Any], code has Union[Callable[[Locator], Any], Callable[[], Any]] - -Parameter type mismatch in BrowserContext.route_web_socket(handler=): documented as Callable[[WebSocketRoute], Union[Any, Any]], code has Callable[[WebSocketRoute], Any] -Parameter type mismatch in Page.route_web_socket(handler=): documented as Callable[[WebSocketRoute], Union[Any, Any]], code has Callable[[WebSocketRoute], Any] -Parameter type mismatch in WebSocketRoute.on_close(handler=): documented as Callable[[Union[int, undefined]], Union[Any, Any]], code has Callable[[Union[int, None], Union[str, None]], Any] -Parameter type mismatch in WebSocketRoute.on_message(handler=): documented as Callable[[str], Union[Any, Any]], code has Callable[[Union[bytes, str]], Any] - -# Async API additionally accepts an `async def` predicate. (Sync collapses the -# Awaitable union, so the sync generation pass reports these as "No longer -# there" — that is expected; async generation still needs them.) -Parameter type mismatch in Page.expect_request(url_or_predicate=): documented as Union[Callable[[Request], bool], Pattern[str], str], code has Union[Callable[[Request], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] -Parameter type mismatch in Page.expect_response(url_or_predicate=): documented as Union[Callable[[Response], bool], Pattern[str], str], code has Union[Callable[[Response], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] diff --git a/scripts/generate_api.py b/scripts/generate_api.py deleted file mode 100644 index 3ad125dc5..000000000 --- a/scripts/generate_api.py +++ /dev/null @@ -1,373 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import re -import sys -from types import FunctionType -from typing import Any, Dict, List, Match, Optional, Union, cast, get_args, get_origin -from typing import get_type_hints as typing_get_type_hints - -from playwright._impl._assertions import ( - APIResponseAssertions, - LocatorAssertions, - PageAssertions, -) -from playwright._impl._browser import Browser -from playwright._impl._browser_context import BrowserContext -from playwright._impl._browser_type import BrowserType -from playwright._impl._cdp_session import CDPSession -from playwright._impl._clock import Clock -from playwright._impl._console_message import ConsoleMessage -from playwright._impl._credentials import Credentials -from playwright._impl._debugger import Debugger -from playwright._impl._dialog import Dialog -from playwright._impl._disposable import Disposable -from playwright._impl._download import Download -from playwright._impl._element_handle import ElementHandle -from playwright._impl._fetch import APIRequest, APIRequestContext, APIResponse -from playwright._impl._file_chooser import FileChooser -from playwright._impl._frame import Frame -from playwright._impl._helper import Error, to_snake_case -from playwright._impl._input import Keyboard, Mouse, Touchscreen -from playwright._impl._js_handle import JSHandle, Serializable -from playwright._impl._locator import FrameLocator, Locator -from playwright._impl._network import ( - Request, - Response, - Route, - WebSocket, - WebSocketRoute, -) -from playwright._impl._page import Page, Worker -from playwright._impl._playwright import Playwright -from playwright._impl._screencast import Screencast -from playwright._impl._selectors import Selectors -from playwright._impl._tracing import Tracing -from playwright._impl._video import Video -from playwright._impl._web_error import WebError -from playwright._impl._web_storage import WebStorage -from playwright._impl._webmcp import WebMCP - -SYNC_API = False - - -def process_type(value: Any, param: bool = False) -> str: - value = str(value) - value = re.sub("pathlib._local.Path", "pathlib.Path", value) - value = re.sub(r"", r"\1", value) - value = re.sub(r"NoneType", "None", value) - value = re.sub(r"playwright\._impl\._api_structures.([\w]+)", r"\1", value) - value = re.sub(r"playwright\._impl\.[\w]+\.([\w]+)", r'"\1"', value) - value = re.sub(r"typing.Literal", "Literal", value) - if SYNC_API: - # Sync API does not accept awaitable callbacks; collapse - # Union[X, Awaitable[X]] (used for predicates the async API also - # accepts as `async def`) down to just X. - value = re.sub( - r"typing\.Union\[([^\[\],]+),\s*typing\.Awaitable\[\1\]\]", - r"\1", - value, - ) - if param: - value = re.sub(r"^typing.Union\[([^,]+), None\]$", r"\1 = None", value) - value = re.sub( - r"typing.Union\[(Literal\[[^\]]+\]), None\]", r"\1 = None", value - ) - value = re.sub( - r"^typing.Union\[(.+), None\]$", r"typing.Union[\1] = None", value - ) - value = re.sub( - r"^typing.Optional\[(.+)\]$", r"typing.Optional[\1] = None", value - ) - if not re.match(r"typing.Optional\[.*\] = None", value): - value = re.sub(r"(.*) = None", r"typing.Optional[\1] = None", value) - return value - - -positional_exceptions = [ - r"abort\.errorCode", - r"accept\.promptText", - r"add_init_script\.script", - r"call_tool\.input", - r"cookies\.urls", - r"dispatch_event\.eventInit", - r"eval.*\.arg", - r"expect_.*\.predicate", - r"evaluate_handle\.arg", - r"frame.*\.name", - r"frame_locator\.selector", - r"not_to_have_attribute\.value", - r"register\.script", - r"select_option\.value", - r"send\.params", - r"set_geolocation\.geolocation", - r"to_have_attribute\.value", - r"wait_for_.*\.predicate", - r"wait_for_load_state\.state", - r"unroute\.handler", -] - - -def is_positional_exception(key: str) -> bool: - for pattern in positional_exceptions: - if re.match(pattern, key): - return True - return False - - -def signature(func: FunctionType, indent: int) -> str: - hints = get_type_hints(func, globals()) - tokens = ["self"] - split = ",\n" + " " * indent - - saw_optional = False - for [name, value] in hints.items(): - if name == "return": - continue - positional_exception = is_positional_exception(f"{func.__name__}.{name}") - if saw_optional and positional_exception: - raise Exception( - "Positional exception is not first in the list " - + f"{func.__name__}.{name}" - ) - processed = process_type(value, True) - if name == "timeout": - processed = re.sub( - r"\bfloat\b", - "typing.Union[float, datetime.timedelta]", - processed, - ) - if ( - not positional_exception - and not saw_optional - and processed.startswith("typing.Optional") - ): - saw_optional = True - tokens.append("*") - tokens.append(f"{to_snake_case(name)}: {processed}") - return split.join(tokens) - - -def arguments(func: FunctionType, indent: int) -> str: - hints = get_type_hints(func, globals()) - tokens = [] - split = ",\n" + " " * indent - for [name, value] in hints.items(): - value_str = str(value) - if name == "return": - continue - assert ( - "_" not in name - ), f"Underscore in impl classes is not allowed, use camel case, func={func}, name={name}" - if "Callable" in value_str: - tokens.append(f"{name}=self._wrap_handler({to_snake_case(name)})") - elif name == "timeout" and "float" in value_str: - tokens.append(f"{name}=to_milliseconds({to_snake_case(name)})") - elif ( - "typing.Any" in value_str - or "typing.Dict" in value_str - or "typing.Sequence" in value_str - or "Handle" in value_str - ): - tokens.append(f"{name}=mapping.to_impl({to_snake_case(name)})") - elif ( - re.match(r" str: - value = get_type_hints(func, globals())["return"] - return process_type(value) - - -def short_name(t: Any) -> str: - match = cast( - Match[str], re.compile(r"playwright\._impl\.[^.]+\.([^']+)").search(str(t)) - ) - return match.group(1) - - -def return_value(value: Any) -> List[str]: - value_str = str(value) - if "playwright" not in value_str: - return ["mapping.from_maybe_impl(", ")"] - if ( - get_origin(value) == Union - and len(get_args(value)) == 2 - and str(get_args(value)[1]) == "" - ): - return ["mapping.from_impl_nullable(", ")"] - if str(get_origin(value)) in [ - "", - "", - ]: - return ["mapping.from_impl_list(", ")"] - if str(get_origin(value)) == "": - return ["mapping.from_impl_dict(", ")"] - return ["mapping.from_impl(", ")"] - - -header = """ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import typing -import pathlib -import datetime - -from typing import Literal - - -from playwright._impl._api_structures import Cookie, SetCookieParam, FloatRect, FilePayload, Geolocation, HttpCredentials, PdfMargins, Position, ProxySettings, ResourceTiming, SourceLocation, StorageState, ClientCertificate, ViewportSize, RemoteAddr, SecurityDetails, RequestSizes, NameValue, TracingGroupLocation, DebuggerLocation, DebuggerPausedDetails, ScreencastActionStyle, ScreencastFrame, ScreencastSize, BrowserBindResult, WebErrorLocation, DropPayload, VirtualCredential, WebMCPTool -from playwright._impl._browser import Browser as BrowserImpl -from playwright._impl._browser_context import BrowserContext as BrowserContextImpl -from playwright._impl._browser_type import BrowserType as BrowserTypeImpl -from playwright._impl._clock import Clock as ClockImpl -from playwright._impl._credentials import Credentials as CredentialsImpl -from playwright._impl._cdp_session import CDPSession as CDPSessionImpl -from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl -from playwright._impl._debugger import Debugger as DebuggerImpl -from playwright._impl._dialog import Dialog as DialogImpl -from playwright._impl._disposable import Disposable as DisposableImpl -from playwright._impl._download import Download as DownloadImpl -from playwright._impl._element_handle import ElementHandle as ElementHandleImpl -from playwright._impl._file_chooser import FileChooser as FileChooserImpl -from playwright._impl._frame import Frame as FrameImpl -from playwright._impl._input import Keyboard as KeyboardImpl, Mouse as MouseImpl, Touchscreen as TouchscreenImpl -from playwright._impl._js_handle import JSHandle as JSHandleImpl -from playwright._impl._network import Request as RequestImpl, Response as ResponseImpl, Route as RouteImpl, WebSocket as WebSocketImpl, WebSocketRoute as WebSocketRouteImpl -from playwright._impl._page import Page as PageImpl, Worker as WorkerImpl -from playwright._impl._web_error import WebError as WebErrorImpl -from playwright._impl._playwright import Playwright as PlaywrightImpl -from playwright._impl._selectors import Selectors as SelectorsImpl -from playwright._impl._screencast import Screencast as ScreencastImpl -from playwright._impl._video import Video as VideoImpl -from playwright._impl._web_storage import WebStorage as WebStorageImpl -from playwright._impl._webmcp import WebMCP as WebMCPImpl -from playwright._impl._tracing import Tracing as TracingImpl -from playwright._impl._locator import Locator as LocatorImpl, FrameLocator as FrameLocatorImpl -from playwright._impl._errors import Error -from playwright._impl._form_data import FormData -from playwright._impl._helper import to_milliseconds -from playwright._impl._fetch import APIRequest as APIRequestImpl, APIResponse as APIResponseImpl, APIRequestContext as APIRequestContextImpl -from playwright._impl._assertions import PageAssertions as PageAssertionsImpl, LocatorAssertions as LocatorAssertionsImpl, APIResponseAssertions as APIResponseAssertionsImpl -""" - - -generated_types = [ - Request, - Response, - Route, - WebSocket, - WebSocketRoute, - Keyboard, - Mouse, - Touchscreen, - JSHandle, - ElementHandle, - FileChooser, - Frame, - FrameLocator, - Worker, - Selectors, - Clock, - Credentials, - ConsoleMessage, - Debugger, - Dialog, - Download, - Screencast, - Video, - WebMCP, - Page, - WebError, - WebStorage, - BrowserContext, - CDPSession, - Browser, - BrowserType, - Playwright, - Tracing, - Locator, - APIResponse, - APIRequestContext, - APIRequest, - PageAssertions, - LocatorAssertions, - APIResponseAssertions, - Disposable, -] - -all_types = generated_types + [ - Error, -] - -api_globals = globals() -assert Serializable - - -# Python 3.11+ does not treat default args with None as Optional anymore, this wrapper will still wrap them. -# https://github.com/python/cpython/issues/90353 -def get_type_hints(func: Any, globalns: Any) -> Dict[str, Any]: - original_value = typing_get_type_hints(func, globalns) - if sys.version_info < (3, 11): - return original_value - for key, value in _get_defaults(func).items(): - if value is None and original_value[key] is not Optional: - original_value[key] = Optional[original_value[key]] - return original_value - - -def _get_defaults(func: Any) -> Dict[str, Any]: - """Internal helper to extract the default arguments, by name.""" - try: - code = func.__code__ - except AttributeError: - # Some built-in functions don't have __code__, __defaults__, etc. - return {} - pos_count = code.co_argcount - arg_names = code.co_varnames - arg_names = arg_names[:pos_count] - defaults = func.__defaults__ or () - kwdefaults = func.__kwdefaults__ - res = dict(kwdefaults) if kwdefaults else {} - pos_offset = pos_count - len(defaults) - for name, value in zip(arg_names[pos_offset:], defaults): - assert name not in res - res[name] = value - return res diff --git a/scripts/generate_async_api.py b/scripts/generate_async_api.py deleted file mode 100755 index e844bc400..000000000 --- a/scripts/generate_async_api.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import inspect -import re -from types import FunctionType -from typing import Any - -from documentation_provider import DocumentationProvider -from generate_api import ( - api_globals, - arguments, - generated_types, - get_type_hints, - header, - process_type, - return_type, - return_value, - short_name, - signature, -) - -documentation_provider = DocumentationProvider(True) - - -def generate(t: Any) -> None: - print("") - class_name = short_name(t) - base_class = t.__bases__[0].__name__ - if class_name in ["Page", "BrowserContext", "Browser", "Disposable"]: - base_sync_class = "AsyncContextManager" - elif base_class in ["ChannelOwner", "object", "AssertionsBase"]: - base_sync_class = "AsyncBase" - else: - base_sync_class = base_class - print(f"class {class_name}({base_sync_class}):") - print("") - documentation_provider.print_events(class_name) - for [name, type] in get_type_hints(t, api_globals).items(): - print("") - print(" @property") - print(f" def {name}(self) -> {process_type(type)}:") - documentation_provider.print_entry(class_name, name, {"return": type}, True) - [prefix, suffix] = return_value(type) - prefix = " return " + prefix + f"self._impl_obj.{name}" - print(f"{prefix}{suffix}") - for [name, value] in t.__dict__.items(): - if name.startswith("_"): - continue - if str(value).startswith(" {return_type(value)}:" - ) - documentation_provider.print_entry( - class_name, name, get_type_hints(value, api_globals), True - ) - [prefix, suffix] = return_value( - get_type_hints(value, api_globals)["return"] - ) - prefix = " return " + prefix + f"self._impl_obj.{name}" - print(f"{prefix}{arguments(value, len(prefix))}{suffix}") - for [name, value] in t.__dict__.items(): - if isinstance(value, FunctionType) and "remove_listener" != name: - # List of dunder methods to allow without docs - allow_without_docs_methods = [ - "__getitem__", - ] - if name.startswith("_") and name not in allow_without_docs_methods: - continue - is_async = inspect.iscoroutinefunction(value) - return_type_value = return_type(value) - return_type_value = re.sub(r"\"([^\"]+)Impl\"", r"\1", return_type_value) - return_type_value = return_type_value.replace( - "EventContextManager", "AsyncEventContextManager" - ) - return_type_value = return_type_value.replace( - '"Disposable"', '"AsyncContextManager"' - ).replace('"DisposableStub"', '"AsyncContextManager"') - print("") - if name in ("expect_event", "wait_for_event"): - documentation_provider.print_event_overloads(class_name, name) - async_prefix = "async " if is_async else "" - print( - f" {async_prefix}def {name}({signature(value, len(name) + 9)}) -> {return_type_value}:" - ) - # Allow dunder methods without docs - if name not in allow_without_docs_methods: - documentation_provider.print_entry( - class_name, name, get_type_hints(value, api_globals) - ) - if class_name in [ - "LocatorAssertions", - "PageAssertions", - "APIResponseAssertions", - ]: - print(" __tracebackhide__ = True") - if "expect_" in name: - print("") - print( - f" return AsyncEventContextManager(self._impl_obj.{name}({arguments(value, 12)}).future)" - ) - else: - [prefix, suffix] = return_value( - get_type_hints(value, api_globals)["return"] - ) - if is_async: - prefix += "await " - prefix = prefix + f"self._impl_obj.{name}(" - suffix = ")" + suffix - print( - f""" - return {prefix}{arguments(value, len(prefix))}{suffix}""" - ) - print("") - print(f"mapping.register({class_name}Impl, {class_name})") - - -def main() -> None: - print(header) - print( - "from playwright._impl._async_base import AsyncEventContextManager, AsyncBase, AsyncContextManager, mapping" - ) - - for t in generated_types: - generate(t) - documentation_provider.print_remainder() - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/scripts/generate_sync_api.py b/scripts/generate_sync_api.py deleted file mode 100755 index fa6ac8702..000000000 --- a/scripts/generate_sync_api.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import inspect -import re -import sys -from types import FunctionType -from typing import Any - -import generate_api -from documentation_provider import DocumentationProvider -from generate_api import ( - api_globals, - arguments, - generated_types, - get_type_hints, - header, - process_type, - return_type, - return_value, - short_name, - signature, -) - -generate_api.SYNC_API = True - -documentation_provider = DocumentationProvider(False) - - -def generate(t: Any) -> None: - print("") - class_name = short_name(t) - base_class = t.__bases__[0].__name__ - if class_name in ["Page", "BrowserContext", "Browser", "Disposable"]: - base_sync_class = "SyncContextManager" - elif base_class in ["ChannelOwner", "object", "AssertionsBase"]: - base_sync_class = "SyncBase" - else: - base_sync_class = base_class - print(f"class {class_name}({base_sync_class}):") - print("") - documentation_provider.print_events(class_name) - for [name, type] in get_type_hints(t, api_globals).items(): - print("") - print(" @property") - print(f" def {name}(self) -> {process_type(type)}:") - documentation_provider.print_entry(class_name, name, {"return": type}, True) - [prefix, suffix] = return_value(type) - prefix = " return " + prefix + f"self._impl_obj.{name}" - print(f"{prefix}{suffix}") - for [name, value] in t.__dict__.items(): - if name.startswith("_"): - continue - if str(value).startswith(" {return_type(value)}:" - ) - documentation_provider.print_entry( - class_name, name, get_type_hints(value, api_globals), True - ) - [prefix, suffix] = return_value( - get_type_hints(value, api_globals)["return"] - ) - prefix = " return " + prefix + f"self._impl_obj.{name}" - print(f"{prefix}{arguments(value, len(prefix))}{suffix}") - for [name, value] in t.__dict__.items(): - if isinstance(value, FunctionType) and "remove_listener" != name: - # List of dunder methods to allow without docs - allow_without_docs_methods = [ - "__getitem__", - ] - if name.startswith("_") and name not in allow_without_docs_methods: - continue - is_async = inspect.iscoroutinefunction(value) - return_type_value = return_type(value) - return_type_value = re.sub(r"\"([^\"]+)Impl\"", r"\1", return_type_value) - return_type_value = return_type_value.replace( - '"Disposable"', '"SyncContextManager"' - ).replace('"DisposableStub"', '"SyncContextManager"') - print("") - if name in ("expect_event", "wait_for_event"): - documentation_provider.print_event_overloads(class_name, name) - print( - f" def {name}({signature(value, len(name) + 9)}) -> {return_type_value}:" - ) - # Allow dunder methods without docs - if name not in allow_without_docs_methods: - documentation_provider.print_entry( - class_name, name, get_type_hints(value, api_globals) - ) - if class_name in [ - "LocatorAssertions", - "PageAssertions", - "APIResponseAssertions", - ]: - print(" __tracebackhide__ = True") - if "expect_" in name: - print( - f" return EventContextManager(self, self._impl_obj.{name}({arguments(value, 12)}).future)" - ) - else: - [prefix, suffix] = return_value( - get_type_hints(value, api_globals)["return"] - ) - if is_async: - prefix += f"self._sync(self._impl_obj.{name}(" - suffix = "))" + suffix - else: - prefix += f"self._impl_obj.{name}(" - suffix = ")" + suffix - - print( - f""" - return {prefix}{arguments(value, len(prefix))}{suffix}""" - ) - print("") - print(f"mapping.register({class_name}Impl, {class_name})") - - -def main() -> None: - assert sys.version_info >= (3, 9) - - print(header) - print( - "from playwright._impl._sync_base import EventContextManager, SyncBase, SyncContextManager, mapping" - ) - - for t in generated_types: - generate(t) - documentation_provider.print_remainder() - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/scripts/update_api.sh b/scripts/update_api.sh index c2eae493a..3451c05c0 100755 --- a/scripts/update_api.sh +++ b/scripts/update_api.sh @@ -1,40 +1,24 @@ #!/bin/bash -# Codegen needs Playwright's api.json (the documented API surface), which the -# assembled driver bundle does not ship — it is generated from the upstream docs -# and is only needed here, when regenerating the API. Generate it into a temp file -# and hand it to the generators via PW_API_JSON (documentation_provider.py reads it -# from there); nothing is written into the driver. If PW_API_JSON is already set, -# use that pre-generated api.json as-is; otherwise generate it from a nearby -# microsoft/playwright checkout pointed to by PW_SRC_DIR (at the tag in -# DRIVER_VERSION). +# Generates the public API (playwright/{async,sync}_api/_generated.py) from the +# Playwright API docs (docs/src/api) in a microsoft/playwright checkout, pointed +# to by PW_SRC_DIR. Use the release branch for the version in DRIVER_VERSION +# (for example release-1.64). The generator is scripts/codegen/generate_api.js; +# Python facts that the docs do not contain are in scripts/codegen/python_rules.js. driver_version="$(cat DRIVER_VERSION)" -if [[ -z "$PW_API_JSON" ]]; then - if [[ -z "$PW_SRC_DIR" ]]; then - echo "Set PW_SRC_DIR to a microsoft/playwright checkout at v${driver_version}" >&2 - echo "(or PW_API_JSON to a pre-generated api.json), e.g.:" >&2 - echo " PW_SRC_DIR=../playwright ./scripts/update_api.sh" >&2 - exit 1 - fi - PW_API_JSON="$(mktemp "${TMPDIR:-/tmp}/playwright-api-json.XXXXXX")" - trap 'rm -f "$PW_API_JSON"' EXIT - echo "Generating api.json from $PW_SRC_DIR" - if ! API_JSON_MODE=1 node "$PW_SRC_DIR/utils/doclint/generateApiJson.js" > "$PW_API_JSON"; then - echo "Failed to generate api.json from $PW_SRC_DIR (a microsoft/playwright checkout at v${driver_version}?)" >&2 - exit 1 - fi +if [[ -z "$PW_SRC_DIR" ]]; then + echo "Set PW_SRC_DIR to a microsoft/playwright checkout at the release branch for v${driver_version}, e.g.:" >&2 + echo " PW_SRC_DIR=../playwright ./scripts/update_api.sh" >&2 + exit 1 fi -export PW_API_JSON +export PW_SRC_DIR function update_api { - echo "Generating $1" - file_name="$1" - generate_script="$2" - git checkout HEAD -- "$file_name" - - if PYTHONIOENCODING=utf-8 python "$generate_script" > .x; then - mv .x "$file_name" - pre-commit run --files $file_name + flavor="$1" + file_name="playwright/${flavor}_api/_generated.py" + echo "Generating $file_name" + if node scripts/codegen/generate_api.js --flavor "$flavor" --out "$file_name"; then + pre-commit run --files "$file_name" echo "Regenerated APIs" else echo "Exited due to errors" @@ -42,8 +26,8 @@ function update_api { fi } -update_api "playwright/sync_api/_generated.py" "scripts/generate_sync_api.py" -update_api "playwright/async_api/_generated.py" "scripts/generate_async_api.py" +update_api sync +update_api async playwright install