diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51608cebf..41e01d49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,8 +127,8 @@ jobs: build-v2: # playwright/_impl_v2, the line-by-line port of the JS client, behind PW_IMPL=v2. - # It has only the async API, and runs all of tests/async: the older tests, the - # ports of the upstream tests and the Python-only tests of v2. + # It runs all of tests/sync and tests/async: the older tests, the ports of the + # upstream tests and the Python-only tests of v2. name: Build (v2) timeout-minutes: 45 strategy: @@ -152,6 +152,8 @@ jobs: pip install -e . python -m build --wheel python -m playwright install --with-deps ${{ matrix.browser }} + - name: Test Sync API + run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto - name: Test Async API run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto diff --git a/AGENTS.md b/AGENTS.md index 16337e998..63f5108b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk ## 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. Internal types are in `_internal_structures.py`. -- `playwright/_impl_v2/` — the second implementation, a line-by-line port of upstream's JS client (`packages/playwright-core/src/client/*.ts`), one module per client file in snake_case (`browserContext.ts` is `browser_context.py`), members in the JS order. It has only the async API; `PW_IMPL=v2` switches the async API to it (`playwright/_impl/_api_classes.py`). Each difference from the JS code has a `# Python:` comment; see its `__init__.py` for the rules. `isomorphic/`, `protocol/` and `utils/` hold its own ports of the parts of `packages/isomorphic`, `packages/protocol` and `packages/utils` that it uses; from `playwright/_impl` it uses only the public error classes, `FormData`, the generated API types, the driver location and the protocol check. `protocol_channels.py`, `protocol_types.py` and `protocol/validator.py` are generated by `scripts/codegen/generate_protocol.py`. +- `playwright/_impl_v2/` — the second implementation, a line-by-line port of upstream's JS client (`packages/playwright-core/src/client/*.ts`), one module per client file in snake_case (`browserContext.ts` is `browser_context.py`), members in the JS order. `PW_IMPL=v2` switches the async and the sync API to it (`playwright/_impl/_api_classes.py`, `playwright/{async,sync}_api/_context_manager.py`); the sync API is the greenlet mechanism of v1, shared (`playwright/_impl/_sync_base.py`, `playwright/_impl/_greenlets.py`). Each difference from the JS code has a `# Python:` comment; see its `__init__.py` for the rules. `isomorphic/`, `protocol/` and `utils/` hold its own ports of the parts of `packages/isomorphic`, `packages/protocol` and `packages/utils` that it uses; from `playwright/_impl` it uses only the public error classes, `FormData`, the generated API types, the driver location, the protocol check and the greenlets of the sync API. `protocol_channels.py`, `protocol_types.py` and `protocol/validator.py` are generated by `scripts/codegen/generate_protocol.py`. - `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py`, and the public TypedDicts in `playwright/_impl/_api_structures.py` — **auto-generated** from the upstream API docs. Never edit by hand; rerun `./scripts/update_api.sh` after a roll or after changing `scripts/codegen/`. - `scripts/codegen/generate_api.js` — the generator. A Node.js script that loads the upstream doc parser (`utils/doclint/api_parser.js`) from `$PW_SRC_DIR`, filters the docs for Python, and writes the whole public API: signatures, types, docstrings, event overloads, the calls into `_impl/`, and the TypedDicts. It does not read `_impl/`. - `scripts/codegen/python_rules.js` — the Python facts that the docs do not contain (positional options, struct `total=`, hidden members and options). Policy: the shipped Python API wins; each entry is a candidate for an upstream docs fix, and the generator fails on entries that no longer match anything. Every entry needs a one-line rationale comment above it. @@ -18,7 +18,7 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk - `_protocol_types.py` has the TypedDicts for initializers, results, events and nested types, named as upstream's `utils/generate_channels.js` names them (`PageInitializer`, `FrameGotoResult`, `PageRouteEvent`, …). - `_protocol.py` has the parameters of every command. With `PW_CHECK_PROTOCOL=1`, `playwright/_impl/_protocol_check.py` checks each message that the client sends against it. The test suite sets this, and fails a test that sends an unknown command, an unknown or missing parameter, or a value of the wrong type. - `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, ported test for test from the upstream tests, which `tests/upstream_registry.md` lists by identifier. See [Tests](#tests). `playwright/_impl` (v1, the default) runs the older tests in `tests/{async,sync}/legacy/` and the other tests outside of `tests/async` and `tests/sync`; `PW_IMPL=v2` runs `playwright/_impl_v2` on all of `tests/async` (`tests/conftest.py`). CI runs v2 on Ubuntu 22.04 with Chromium, Firefox and WebKit. An older test that checks a behavior where v2 follows upstream and v1 does not is marked `@only_v1("")` (`tests/utils.py`), naming the upstream port that covers v2, or the Python-only test of v2 in `tests/async/python/`, which v1 does not run. +- `tests/async/`, `tests/sync/` — pytest suites, ported test for test from the upstream tests, which `tests/upstream_registry.md` lists by identifier. See [Tests](#tests). `playwright/_impl` (v1, the default) runs the older tests in `tests/{async,sync}/legacy/` and the other tests outside of `tests/async` and `tests/sync`; `PW_IMPL=v2` runs `playwright/_impl_v2` on all of `tests/async` and `tests/sync` (`tests/conftest.py`). CI runs v2 on Ubuntu 22.04 with Chromium, Firefox and WebKit. An older test that checks a behavior where v2 follows upstream and v1 does not is marked `@only_v1("")` (`tests/utils.py`), naming the upstream port that covers v2, or the Python-only test of v2 in `tests/async/python/`, which v1 does not run. - `DRIVER_VERSION` — the single source of truth for which Playwright build the driver is assembled from. Two lines: `npm=`, the `playwright-core` npm version (e.g. `npm=1.61.0`, no `v` prefix), and `rev=`, the `microsoft/playwright` commit that the package is built from (`npm view playwright-core@ gitHead`). Read by `setup.py`, `scripts/build_driver.py`, `scripts/update_api.sh`, and CI (through `setup.py`). The wheel build downloads `playwright-core` at this version from npm plus the matching Node.js binary and assembles the per-platform bundles — no source build. The version is baked into the staged bundle filenames (`driver/playwright--.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). @@ -35,7 +35,6 @@ pip install --upgrade pip pip install -r local-requirements.txt pip install -e . python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js and assembles the driver -pre-commit install ``` If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (then `uv pip install --python env/bin/python --upgrade pip`). @@ -43,9 +42,9 @@ If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (t ## Common commands - Regenerate `_generated.py`: `./scripts/update_api.sh` (runs codegen + pre-commit on the generated files). -- Lint everything: `pre-commit run --all-files`. +- Lint everything: `pre-commit run --all-files` (what the Lint job of CI runs; the checks are not a git hook, do not `pre-commit install`). - Type-check: `mypy playwright`. -- Run tests: `pytest --browser chromium [-k name]` (v1), `PW_IMPL=v2 pytest tests/async --browser chromium` (v2), or `./test.sh [browser]` for both. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo). +- Run tests: `pytest --browser chromium [-k name]` (v1), `PW_IMPL=v2 pytest tests/sync tests/async --browser chromium` (v2; `./test-v2.sh [browser]` runs both suites), or `./test.sh [browser]` for both implementations. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo). 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`. @@ -91,7 +90,7 @@ It documents the full process: the upstream commit-range diff over `docs/src/api ## Commit Convention -Before committing, run `mypy playwright` and fix errors. +Before committing, run `pre-commit run --all-files` (the Lint job of CI) and fix errors. Semantic commit messages: `label(scope): description` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be7a55d9d..4c975f494 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,10 +64,9 @@ Checking for typing errors mypy playwright ``` -Format the code +Lint and format the code (what the Lint job of CI runs; the checks are not a git hook) ```sh -pre-commit install pre-commit run --all-files ``` diff --git a/ROLLING.md b/ROLLING.md index d5cab2631..e544f0ac7 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -8,7 +8,6 @@ ``` python -m pip install --upgrade pip pip install -r local-requirements.txt -pre-commit install pip install -e . ``` * change the driver pin in `DRIVER_VERSION`: `npm=` the `playwright-core` npm version (e.g. `1.61.0`) and `rev=` the commit it is built from (`npm view playwright-core@ gitHead`); refresh `NODE_VERSION`: `python scripts/update_node_version.py` diff --git a/playwright/_impl/_greenlets.py b/playwright/_impl/_greenlets.py index 609c3d787..b62a4f1f2 100644 --- a/playwright/_impl/_greenlets.py +++ b/playwright/_impl/_greenlets.py @@ -13,7 +13,7 @@ # limitations under the License. import asyncio import os -from typing import Tuple +from typing import Any, Callable, Tuple, Type import greenlet @@ -52,10 +52,41 @@ def __str__(self) -> str: return "" +class BindingGreenlet(greenlet.greenlet): + def __str__(self) -> str: + return "" + + +class WebSocketRouteGreenlet(greenlet.greenlet): + def __str__(self) -> str: + return "" + + def connection_closed_error() -> TargetClosedError: return TargetClosedError("Playwright connection closed") +def run_in_greenlet( + greenlet_class: Type[greenlet.greenlet], func: Callable[[], Any] +) -> "asyncio.Future[Any]": + """Runs a callback of the sync API in its own greenlet. + + The callback may block in a sync call. In its own greenlet it switches to the + dispatcher fiber then, and the event loop goes on. The future settles with the + result of the callback when it returns, or with its error. + """ + future: "asyncio.Future[Any]" = asyncio.get_running_loop().create_future() + + def body() -> None: + try: + future.set_result(func()) + except Exception as e: + future.set_exception(e) + + greenlet_class(body).switch() + return future + + def wait_for_future( loop: asyncio.AbstractEventLoop, dispatcher_fiber: greenlet.greenlet, diff --git a/playwright/_impl/_sync_base.py b/playwright/_impl/_sync_base.py index 17726f71d..ef73c054f 100644 --- a/playwright/_impl/_sync_base.py +++ b/playwright/_impl/_sync_base.py @@ -13,6 +13,8 @@ # limitations under the License. import asyncio +import os +import sys import traceback from contextlib import AbstractContextManager from types import TracebackType @@ -36,6 +38,9 @@ mapping = ImplToApiMapping() +# PW_IMPL=v2: playwright/_impl_v2 reads the stack of a sync call from its frames. +_IS_V2 = os.environ.get("PW_IMPL") == "v2" + T = TypeVar("T") Self = TypeVar("Self", bound="SyncContextManager") @@ -104,8 +109,14 @@ def _sync( raise connection_closed_error() task: asyncio.tasks.Task[Any] = self._loop.create_task(coro) - setattr(task, "__pw_stack__", _capture_stack_trace()) - setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)) + # The task runs in the dispatcher fiber, where the stack of this call is not + # visible. The implementation reads it from the task: v2 walks the frames of + # the caller (capture_raw_stack()), v1 takes the parsed stack. + if _IS_V2: + setattr(task, "__pw_caller_frame__", sys._getframe(1)) + else: + setattr(task, "__pw_stack__", _capture_stack_trace()) + setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)) wait_for_future(self._loop, self._dispatcher_fiber, task) return task.result() diff --git a/playwright/_impl_v2/__init__.py b/playwright/_impl_v2/__init__.py index b01770f42..9f0c02def 100644 --- a/playwright/_impl_v2/__init__.py +++ b/playwright/_impl_v2/__init__.py @@ -37,5 +37,9 @@ runs the prefix and returns the awaitable of the rest (the wait_for_*() methods). - Python has no AbortSignal: the `signal` options are not ported. Cancelling the task that waits for a call aborts the call, as an aborted signal does. -- There is no sync code here: no greenlet, no sync waits. +- The sync API is the one of playwright/_impl (playwright/sync_api/_context_manager.py, + playwright/_impl/_sync_base.py, playwright/_impl/_greenlets.py): a greenlet runs the + event loop, and a sync call switches to it. Each object carries `_loop` and + `_dispatcher_fiber` for the generated API; a user callback that may block runs in a + greenlet of its own (transform.md, section 13). """ diff --git a/playwright/_impl_v2/api_classes.py b/playwright/_impl_v2/api_classes.py index b221a9576..9f530e63a 100644 --- a/playwright/_impl_v2/api_classes.py +++ b/playwright/_impl_v2/api_classes.py @@ -15,7 +15,7 @@ """Python only: the v2 class of each class in the generated API. playwright/_impl/_api_classes.py takes these when PW_IMPL=v2 is set. The expect() -matchers are not part of the client; they stay in playwright/_impl/_assertions.py. +matchers are not part of the JS client; the Python ones are in assertions.py. """ from playwright._impl_v2.assertions import ( diff --git a/playwright/_impl_v2/assertions.py b/playwright/_impl_v2/assertions.py index df77ad6ee..d140eeead 100644 --- a/playwright/_impl_v2/assertions.py +++ b/playwright/_impl_v2/assertions.py @@ -62,9 +62,10 @@ def __init__( is_soft: bool = False, ) -> None: self._actual_locator = locator + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = locator._loop - # The sync API reads it; playwright/_impl_v2 (async only) has none. - self._dispatcher_fiber = getattr(locator, "_dispatcher_fiber", None) + self._dispatcher_fiber = locator._dispatcher_fiber self._timeout = timeout self._is_not = is_not self._custom_message = message @@ -1057,9 +1058,10 @@ def __init__( message: Optional[str] = None, is_soft: bool = False, ) -> None: + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = response._loop - # The sync API reads it; playwright/_impl_v2 (async only) has none. - self._dispatcher_fiber = getattr(response, "_dispatcher_fiber", None) + self._dispatcher_fiber = response._dispatcher_fiber self._timeout = timeout self._is_not = is_not self._actual = response diff --git a/playwright/_impl_v2/browser_context.py b/playwright/_impl_v2/browser_context.py index f2f61098e..76d6632ca 100644 --- a/playwright/_impl_v2/browser_context.py +++ b/playwright/_impl_v2/browser_context.py @@ -187,7 +187,7 @@ def on_service_worker( def on_console(event: "channels.BrowserContextConsoleEvent") -> None: worker = Worker.from_nullable(event["worker"]) page = Page.from_nullable(event["page"]) - console_message = ConsoleMessage(event, page, worker) + console_message = ConsoleMessage(event, page, worker, self) if worker: worker.emit(Events.Worker.Console, console_message) if page: @@ -208,7 +208,7 @@ def on_page_error(event: "channels.BrowserContextPageErrorEvent") -> None: parsed_error = parse_error(event["error"]) self.emit( Events.BrowserContext.WebError, - WebError(page_object, parsed_error, event["location"]), + WebError(page_object, parsed_error, event["location"], self), ) if page_object: page_object.emit(Events.Page.PageError, parsed_error) @@ -590,7 +590,7 @@ async def route( ), ) await self._update_interception_patterns() - return DisposableStub(lambda: self.unroute(url=url, handler=handler)) + return DisposableStub(lambda: self.unroute(url=url, handler=handler), self) @api_call(title="Route WebSockets") async def route_web_socket( diff --git a/playwright/_impl_v2/channel_owner.py b/playwright/_impl_v2/channel_owner.py index 55b9ae85a..9e2dc1690 100644 --- a/playwright/_impl_v2/channel_owner.py +++ b/playwright/_impl_v2/channel_owner.py @@ -151,7 +151,9 @@ def func(api_zone: ApiZone) -> Awaitable[Any]: ) # Python: a channel call never raises at the call; the awaitable raises, as a - # rejected promise does. + # rejected promise does. A validation error is raised as it is, without the + # API name in front of it (upstream adds it): the message names the Python + # option, which is enough. try: result = owner._wrap_api_call(func, ApiCallOptions(internal=is_internal)) except Exception as e: @@ -195,8 +197,11 @@ def __init__( super().__init__() self.set_max_listeners(0) self._connection = connection - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object: the greenlet that runs the loop under the sync API, None under + # the async API (playwright/sync_api/_context_manager.py). self._loop: asyncio.AbstractEventLoop = connection._loop + self._dispatcher_fiber: Any = connection._dispatcher_fiber self._type = type self._guid = guid self._parent: Optional[ChannelOwner] = ( @@ -334,6 +339,9 @@ def _wrap_api_call( internal=options.internal if options else False, ) zone = current_zone().with_("apiZone", api_zone) + # Python: func runs now, up to its first await. An error that it raises here + # is raised as it is, without the API name and the logging below; upstream + # has it as a rejected promise, which the catch below decorates. awaitable = zone.run(lambda: func(api_zone)) async def finish() -> R: diff --git a/playwright/_impl_v2/client_instrumentation.py b/playwright/_impl_v2/client_instrumentation.py index 10ed24ddf..6d02ee5c2 100644 --- a/playwright/_impl_v2/client_instrumentation.py +++ b/playwright/_impl_v2/client_instrumentation.py @@ -53,13 +53,13 @@ def remove_all_listeners(self) -> None: self._listeners.clear() def _on(self, name: str, *params: Any) -> None: - for listener in list(self._listeners): + for listener in self._listeners: method = getattr(listener, name, None) if method: method(*params) async def _run(self, name: str, *params: Any) -> None: - for listener in list(self._listeners): + for listener in self._listeners: method = getattr(listener, name, None) if method: await method(*params) diff --git a/playwright/_impl_v2/clock.py b/playwright/_impl_v2/clock.py index 6fcfefee9..0065c9be4 100644 --- a/playwright/_impl_v2/clock.py +++ b/playwright/_impl_v2/clock.py @@ -31,8 +31,10 @@ class Clock: def __init__(self, browser_context: "BrowserContext") -> None: self._browser_context = browser_context - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = browser_context._loop + self._dispatcher_fiber = browser_context._dispatcher_fiber async def install( self, time: Optional[Union[float, str, datetime.datetime]] diff --git a/playwright/_impl_v2/connect.py b/playwright/_impl_v2/connect.py index d1b9205cd..e4b94c2db 100644 --- a/playwright/_impl_v2/connect.py +++ b/playwright/_impl_v2/connect.py @@ -128,12 +128,14 @@ async def connect_to_endpoint( assert local_utils transport: Transport = JsonPipeTransport(local_utils) connect_headers = await transport.connect(params, timeout) - # Python: the connection runs in the event loop of its parent. + # Python: the connection runs in the event loop, and under the sync API in the + # dispatcher fiber, of its parent. connection = Connection( parent_connection._loop, local_utils, parent_connection._instrumentation, cast(List[Dict[str, str]], connect_headers), + parent_connection._dispatcher_fiber, ) connection.mark_as_remote() connection.on("close", lambda: transport.close()) diff --git a/playwright/_impl_v2/connection.py b/playwright/_impl_v2/connection.py index 2175f80f9..0841d1730 100644 --- a/playwright/_impl_v2/connection.py +++ b/playwright/_impl_v2/connection.py @@ -21,8 +21,7 @@ import asyncio import json -import sys -import traceback +import logging from typing import ( TYPE_CHECKING, Any, @@ -35,6 +34,7 @@ cast, ) +from playwright._impl._greenlets import EventGreenlet from playwright._impl._protocol_check import check_message from playwright._impl._protocol_check import is_enabled as is_protocol_check_enabled from playwright._impl_v2.channel_owner import ChannelOwner @@ -116,6 +116,9 @@ def reject(self, error: BaseException) -> None: self.future.set_exception(error) +_logger = logging.getLogger("playwright") + + class Connection(EventEmitter): def __init__( self, @@ -123,10 +126,14 @@ def __init__( local_utils: Optional["LocalUtils"] = None, instrumentation: Optional[ClientInstrumentation] = None, headers: Optional[List[Dict[str, str]]] = None, + dispatcher_fiber: Any = None, ) -> None: super().__init__() - # Python: the event loop that this connection and its objects run in. + # Python: the event loop that this connection and its objects run in, and + # under the sync API the greenlet that runs the loop (None under the async + # API), see playwright/sync_api/_context_manager.py. self._loop = loop + self._dispatcher_fiber = dispatcher_fiber self._objects: Dict[str, ChannelOwner] = {} self.onmessage: Callable[[Dict[str, Any]], None] = lambda message: None self._next_call_id = create_call_id_generator() @@ -272,11 +279,11 @@ def on_done(f: "asyncio.Future[Any]") -> None: return future def _on_event_listener_error(self, error: BaseException) -> None: - # Python: see send_message_to_server(). - print("Error occurred in event listener", file=sys.stderr) - traceback.print_exception( - type(error), error, error.__traceback__, file=sys.stderr - ) + # Python: see send_message_to_server(). The error is logged, not printed: + # a script without logging configuration still gets it on stderr, from the + # last-resort handler of logging, and a test runner keeps it in the log of + # the test. + _logger.error("Error occurred in event listener", exc_info=error) self._error = error def _type_of_guid(self, guid: str) -> Optional[str]: @@ -353,9 +360,17 @@ def dispatch(self, message: Dict[str, Any]) -> None: return validator = find_validator(object._type, method or "", "Event") - object._channel.emit( - method, validator(params, "", self._validator_from_wire_context()) - ) + params = validator(params, "", self._validator_from_wire_context()) + if not self._dispatcher_fiber: + object._channel.emit(method, params) + return + # Python: under the sync API, a listener may block in a sync call. Each + # listener runs in its own greenlet, so that it can switch to the dispatcher + # fiber then, and the loop goes on with the next listener. The greenlet + # resumes when the sync call completes. + channel = object._channel + for listener in channel.raw_listeners(method): + EventGreenlet(channel._call_handler).switch(method, listener, (params,)) def close(self, cause: Optional[str] = None) -> None: if self._closed_error: diff --git a/playwright/_impl_v2/console_message.py b/playwright/_impl_v2/console_message.py index da4466227..19c3283f6 100644 --- a/playwright/_impl_v2/console_message.py +++ b/playwright/_impl_v2/console_message.py @@ -20,7 +20,6 @@ of each object. """ -import asyncio from typing import TYPE_CHECKING, List, Literal, Optional, Union, cast from playwright._impl._api_structures import SourceLocation @@ -28,6 +27,7 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl_v2 import protocol_types as channels + from playwright._impl_v2.channel_owner import ChannelOwner from playwright._impl_v2.page import Page from playwright._impl_v2.worker import Worker @@ -69,14 +69,17 @@ def __init__( event: ConsoleMessageEvent, page: Optional["Page"], worker: Optional["Worker"], + parent: "ChannelOwner", ) -> None: self._page = page self._worker = worker self._event = event # Not ported: the util.inspect.custom hook. Python: __str__() below. - # Python: the generated API reads the event loop of each object. A console - # message is created in an event or in an API call, so the loop is running. - self._loop = asyncio.get_running_loop() + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. The parent is the page, worker or context that creates the + # message; the page and the worker may be None. + self._loop = parent._loop + self._dispatcher_fiber = parent._dispatcher_fiber @property def worker(self) -> Optional["Worker"]: diff --git a/playwright/_impl_v2/credentials.py b/playwright/_impl_v2/credentials.py index 6f744a968..55d2c52d3 100644 --- a/playwright/_impl_v2/credentials.py +++ b/playwright/_impl_v2/credentials.py @@ -31,8 +31,10 @@ class Credentials: def __init__(self, browser_context: "BrowserContext") -> None: self._browser_context = browser_context - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = browser_context._loop + self._dispatcher_fiber = browser_context._dispatcher_fiber async def install(self) -> None: await self._browser_context._channel.credentialsInstall(timeout=NO_TIMEOUT) diff --git a/playwright/_impl_v2/disposable.py b/playwright/_impl_v2/disposable.py index 8a576ef66..cf1c67421 100644 --- a/playwright/_impl_v2/disposable.py +++ b/playwright/_impl_v2/disposable.py @@ -68,11 +68,12 @@ async def dispose(self) -> None: class DisposableStub(Disposable): - def __init__(self, dispose: Callable[[], Awaitable[Any]]) -> None: + def __init__(self, dispose: Callable[[], Awaitable[Any]], parent: Any) -> None: self._dispose: Optional[Callable[[], Awaitable[Any]]] = dispose - # Python: the generated API reads the event loop of each object. A stub is - # created in an API call, so the loop is running. - self._loop = asyncio.get_running_loop() + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. The parent is the object that creates the stub. + self._loop = parent._loop + self._dispatcher_fiber = parent._dispatcher_fiber # Not ported: [Symbol.asyncDispose](). Python: Disposable.close(). diff --git a/playwright/_impl_v2/download.py b/playwright/_impl_v2/download.py index 45b3d9515..49adca256 100644 --- a/playwright/_impl_v2/download.py +++ b/playwright/_impl_v2/download.py @@ -36,8 +36,10 @@ def __init__( self._url = url self._suggested_filename = suggested_filename self._artifact = artifact - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber @property def page(self) -> "Page": diff --git a/playwright/_impl_v2/event_emitter.py b/playwright/_impl_v2/event_emitter.py index c81707735..42f84f538 100644 --- a/playwright/_impl_v2/event_emitter.py +++ b/playwright/_impl_v2/event_emitter.py @@ -247,8 +247,12 @@ def remove_all_listeners( errors: List[BaseException] = [] self._rejection_handler = lambda error: errors.append(error) + # Python: the pending handlers are taken now, as _waitFor() takes them + # before its first await. + pending = self._wait_for(type) + async def wait() -> None: - await self._wait_for(type) + await pending if errors: raise errors[0] diff --git a/playwright/_impl_v2/fetch.py b/playwright/_impl_v2/fetch.py index 441a2d528..0d0fbbe52 100644 --- a/playwright/_impl_v2/fetch.py +++ b/playwright/_impl_v2/fetch.py @@ -101,8 +101,10 @@ class APIRequest: def __init__(self, playwright: "Playwright") -> None: self._playwright = playwright self._contexts: Set["APIRequestContext"] = set() - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = playwright._loop + self._dispatcher_fiber = playwright._dispatcher_fiber async def new_context( self, @@ -660,8 +662,10 @@ def __init__( self._initializer = initializer self._headers = RawHeaders(self._initializer["headers"]) self._api_name = "APIResponse" - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = context._loop + self._dispatcher_fiber = context._dispatcher_fiber # Not ported: the inspect.custom hook of Node's util.inspect(). diff --git a/playwright/_impl_v2/file_chooser.py b/playwright/_impl_v2/file_chooser.py index 931329824..94df70f36 100644 --- a/playwright/_impl_v2/file_chooser.py +++ b/playwright/_impl_v2/file_chooser.py @@ -36,8 +36,10 @@ def __init__( self._page = page self._element_handle = element_handle self._is_multiple = is_multiple - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber @property def element(self) -> "ElementHandle": diff --git a/playwright/_impl_v2/input.py b/playwright/_impl_v2/input.py index faa1ae52d..aa076e1d3 100644 --- a/playwright/_impl_v2/input.py +++ b/playwright/_impl_v2/input.py @@ -37,8 +37,10 @@ class Keyboard: def __init__(self, page: "Page") -> None: self._page = page - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber async def down(self, key: str) -> None: await self._page._channel.keyboardDown(key=key, timeout=NO_TIMEOUT) @@ -63,8 +65,10 @@ async def press(self, key: str, delay: Optional[float]) -> None: class Mouse: def __init__(self, page: "Page") -> None: self._page = page - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber def _wrap_api_call( self, @@ -128,8 +132,10 @@ async def wheel(self, delta_x: float, delta_y: float) -> None: class Touchscreen: def __init__(self, page: "Page") -> None: self._page = page - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber async def tap(self, x: float, y: float) -> None: await self._page._channel.touchscreenTap(x=x, y=y, timeout=NO_TIMEOUT) diff --git a/playwright/_impl_v2/locator.py b/playwright/_impl_v2/locator.py index f01e32eb4..eeb52bce7 100644 --- a/playwright/_impl_v2/locator.py +++ b/playwright/_impl_v2/locator.py @@ -101,8 +101,10 @@ def __init__( self._frame = frame self._selector = selector self._api_name = "Locator" - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = frame._loop + self._dispatcher_fiber = frame._dispatcher_fiber # Python: the Python code of this locator, known only for a normalized # locator. See normalize(). self._locator_code: Optional[str] = None @@ -373,7 +375,7 @@ async def highlight(self, style: Optional[str]) -> DisposableStub: # Not ported: the object form of style, and cssObjectToString(), are # JavaScript only. The Python API takes a string. await self._frame._highlight(self._selector, style=style) - return DisposableStub(lambda: self.hide_highlight()) + return DisposableStub(lambda: self.hide_highlight(), self) async def hide_highlight(self) -> None: await self._frame._hide_highlight(self._selector) @@ -991,8 +993,10 @@ class FrameLocator: def __init__(self, frame: "Frame", selector: str) -> None: self._frame = frame self._frame_selector = selector - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = frame._loop + self._dispatcher_fiber = frame._dispatcher_fiber def _child_selector(self, selector: str) -> str: if self._frame_selector == ANY_FRAME_SELECTOR: diff --git a/playwright/_impl_v2/network.py b/playwright/_impl_v2/network.py index 6439e21d0..f564191e8 100644 --- a/playwright/_impl_v2/network.py +++ b/playwright/_impl_v2/network.py @@ -53,6 +53,11 @@ SecurityDetails, SetCookieParam, ) +from playwright._impl._greenlets import ( + RouteGreenlet, + WebSocketRouteGreenlet, + run_in_greenlet, +) from playwright._impl_v2.channel_owner import ( ChannelOwner, api_call, @@ -843,8 +848,10 @@ class _WebSocketRouteServer: def __init__(self, route: WebSocketRoute) -> None: self._route = route - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = route._loop + self._dispatcher_fiber = route._dispatcher_fiber @property def _api_impl_class(self) -> type: @@ -933,9 +940,15 @@ def matches(self, ws_url: str) -> bool: async def handle(self, web_socket_route: WebSocketRoute) -> None: handler = self.handler - # Python: the handler may return an awaitable, as a JS handler returns a - # promise. - result = handler(web_socket_route) + # Python: under the sync API the handler may block in a sync call, so it + # runs in its own greenlet, as an event listener does. The handler may + # return an awaitable, as a JS handler returns a promise. + if web_socket_route._dispatcher_fiber: + result = await run_in_greenlet( + WebSocketRouteGreenlet, lambda: handler(web_socket_route) + ) + else: + result = handler(web_socket_route) if inspect.isawaitable(result): await result await web_socket_route._after_handle() @@ -1271,10 +1284,12 @@ async def _handle_impl(self, route: Route) -> bool: if is_target_closed_error(e): # We are failing in the handler because the target close closed. # Give user a hint! - # Python: the hint names the Python method and option. + # Python: the hint names the Python method and option, without + # `await` under the sync API. + await_prefix = "" if route._dispatcher_fiber else "await " e = rewrite_error_message( e, - f"\"{cast(Error, e).message}\" while running route callback.\nConsider awaiting `await page.unroute_all(behavior='ignoreErrors')`\nbefore the end of the test to ignore remaining routes in flight.", + f"\"{cast(Error, e).message}\" while running route callback.\nConsider awaiting `{await_prefix}page.unroute_all(behavior='ignoreErrors')`\nbefore the end of the test to ignore remaining routes in flight.", ) raise e finally: @@ -1307,9 +1322,16 @@ async def _handle_internal(self, route: Route) -> bool: handled_promise = route._start_handling() # Extract handler into a variable to avoid [RouteHandler.handler] in the stack. handler = self.handler - # Python: the handler may return an awaitable, as a JS handler returns a - # promise; Promise.all() takes a value too. - result = handler(route, route.request) + # Python: under the sync API the handler may block in a sync call, so it + # runs in its own greenlet, as an event listener does. The handler may + # return an awaitable, as a JS handler returns a promise (an internal + # handler does, the HAR router); Promise.all() takes a value too. + if route._dispatcher_fiber: + result = await run_in_greenlet( + RouteGreenlet, lambda: handler(route, route.request) + ) + else: + result = handler(route, route.request) if inspect.isawaitable(result): handled, _ = await asyncio.gather(handled_promise, result) else: diff --git a/playwright/_impl_v2/out_of_process.py b/playwright/_impl_v2/out_of_process.py index b1ae8f28e..93475725c 100644 --- a/playwright/_impl_v2/out_of_process.py +++ b/playwright/_impl_v2/out_of_process.py @@ -35,10 +35,12 @@ async def start() -> Tuple[Playwright, "PlaywrightClient"]: class PlaywrightClient: - def __init__(self) -> None: + def __init__(self, dispatcher_fiber: Any = None) -> None: + # Python: the sync API passes the greenlet that runs the loop, see + # playwright/sync_api/_context_manager.py. loop = asyncio.get_running_loop() self._close_promise: "asyncio.Future[None]" = loop.create_future() - connection = Connection(loop) + connection = Connection(loop, dispatcher_fiber=dispatcher_fiber) self._transport = transport = PipeTransport(loop) self._connection = connection connection.onmessage = lambda message: transport.send(message) @@ -55,7 +57,12 @@ def on_message(message: Any) -> None: self._playwright = asyncio.ensure_future(self._run(connection)) async def _run(self, connection: Connection) -> Playwright: - await self._transport.connect() + try: + await self._transport.connect() + except Exception: + # Python: the driver did not start, so stop() has nothing to wait for. + self._close_promise.set_result(None) + raise reader = asyncio.ensure_future(self._transport.run()) def on_close(reader: "asyncio.Future[None]") -> None: @@ -80,5 +87,6 @@ def on_close(reader: "asyncio.Future[None]") -> None: return next(iter(done)).result() async def stop(self) -> None: - self._transport.request_stop() + if not self._close_promise.done(): + self._transport.request_stop() await self._close_promise diff --git a/playwright/_impl_v2/page.py b/playwright/_impl_v2/page.py index b8a54b730..e08ace5af 100644 --- a/playwright/_impl_v2/page.py +++ b/playwright/_impl_v2/page.py @@ -59,6 +59,11 @@ Position, ViewportSize, ) +from playwright._impl._greenlets import ( + BindingGreenlet, + LocatorHandlerGreenlet, + run_in_greenlet, +) from playwright._impl_v2.artifact import Artifact from playwright._impl_v2.channel_owner import ( ChannelOwner, @@ -223,6 +228,7 @@ def __init__( self._video = Video( self._connection, Artifact.from_(initializer["video"]) if initializer.get("video") else None, + self, ) self.screencast = Screencast(self) @@ -643,10 +649,18 @@ async def _on_locator_handler_triggered(self, uid: int) -> None: if handler and handler["times"] != 0: if handler["times"] is not None: handler["times"] -= 1 - # Python: the handler may return an awaitable, as a JS handler - # returns a promise. The generated API passes it the arguments that - # it takes, so a handler without arguments works too. - result = handler["handler"](handler["locator"]) + # Python: under the sync API the handler may block in a sync call, + # so it runs in its own greenlet, as an event listener does. The + # handler may return an awaitable, as a JS handler returns a + # promise. The generated API passes it the arguments that it takes, + # so a handler without arguments works too. + if self._dispatcher_fiber: + result = await run_in_greenlet( + LocatorHandlerGreenlet, + lambda: handler["handler"](handler["locator"]), + ) + else: + result = handler["handler"](handler["locator"]) if inspect.isawaitable(result): await result remove = handler is not None and handler["times"] == 0 @@ -707,19 +721,20 @@ def wait_for_request( ) -> Awaitable[Request]: # Python: a plain def, so that the listener is registered at the call; see # _wait_for_event(). - async def predicate(request: Request) -> bool: + # Python: the predicate is a plain def too: the JS async function calls the + # user predicate before its first await, in the listener. Under the sync + # API the listener runs in the greenlet of the event, where the user + # predicate may block; a task would spin (transform.md, 13). The result may + # be an awaitable, as a JS predicate returns a promise; the listener awaits + # it then. + def predicate(request: Request) -> Any: if isinstance(url_or_predicate, (str, re.Pattern)): return url_matches( self._browser_context._options.get("baseURL"), request.url, url_or_predicate, ) - # Python: the predicate may return an awaitable, as a JS predicate - # returns a promise. - result = url_or_predicate(request) - if inspect.isawaitable(result): - result = await result - return result + return url_or_predicate(request) trimmed_url = trim_url(url_or_predicate) log_line = f"waiting for request {trimmed_url}" if trimmed_url else None @@ -733,19 +748,20 @@ def wait_for_response( ) -> Awaitable[Response]: # Python: a plain def, so that the listener is registered at the call; see # _wait_for_event(). - async def predicate(response: Response) -> bool: + # Python: the predicate is a plain def too: the JS async function calls the + # user predicate before its first await, in the listener. Under the sync + # API the listener runs in the greenlet of the event, where the user + # predicate may block; a task would spin (transform.md, 13). The result may + # be an awaitable, as a JS predicate returns a promise; the listener awaits + # it then. + def predicate(response: Response) -> Any: if isinstance(url_or_predicate, (str, re.Pattern)): return url_matches( self._browser_context._options.get("baseURL"), response.url, url_or_predicate, ) - # Python: the predicate may return an awaitable, as a JS predicate - # returns a promise. - result = url_or_predicate(response) - if inspect.isawaitable(result): - result = await result - return result + return url_or_predicate(response) trimmed_url = trim_url(url_or_predicate) log_line = f"waiting for response {trimmed_url}" if trimmed_url else None @@ -906,7 +922,7 @@ async def route( ), ) await self._update_interception_patterns() - return DisposableStub(lambda: self.unroute(url=url, handler=handler)) + return DisposableStub(lambda: self.unroute(url=url, handler=handler), self) async def route_from_har( self, @@ -1227,7 +1243,7 @@ async def console_messages( messages = ( await self._channel.consoleMessages(filter=filter, timeout=NO_TIMEOUT) )["messages"] - return [ConsoleMessage(message, self, None) for message in messages] + return [ConsoleMessage(message, self, None, self) for message in messages] async def clear_page_errors(self) -> None: await self._channel.clearPageErrors(timeout=NO_TIMEOUT) @@ -1847,11 +1863,16 @@ async def call(self, func: Callable[..., Any]) -> None: "page": page, "frame": frame, } - # Python: the callback may return an awaitable, as a JS callback returns - # a promise. - result = func( - source, *[parse_result(arg) for arg in self._initializer["args"]] - ) + args = [parse_result(arg) for arg in self._initializer["args"]] + # Python: under the sync API the callback may block in a sync call, so + # it runs in its own greenlet, as an event listener does. The callback + # may return an awaitable, as a JS callback returns a promise. + if self._dispatcher_fiber: + result = await run_in_greenlet( + BindingGreenlet, lambda: func(source, *args) + ) + else: + result = func(source, *args) if inspect.isawaitable(result): result = await result # Python: _to_impl() turns the API objects of the result into impl objects. diff --git a/playwright/_impl_v2/playwright.py b/playwright/_impl_v2/playwright.py index 18f562ed9..6a0614882 100644 --- a/playwright/_impl_v2/playwright.py +++ b/playwright/_impl_v2/playwright.py @@ -54,7 +54,7 @@ def __init__( self.webkit._playwright = self local_utils = self._connection.local_utils() self.devices: Dict[str, Any] = local_utils.devices if local_utils else {} - self.selectors = Selectors() + self.selectors = Selectors(self) # Instrumentation. self._default_launch_options: Optional[Dict[str, Any]] = None diff --git a/playwright/_impl_v2/protocol/validator_primitives.py b/playwright/_impl_v2/protocol/validator_primitives.py index 22dadcf91..c1828079c 100644 --- a/playwright/_impl_v2/protocol/validator_primitives.py +++ b/playwright/_impl_v2/protocol/validator_primitives.py @@ -149,7 +149,10 @@ def t_binary(arg: Any, path: str, context: ValidatorContext) -> Any: raise ValidationError(f"{path}: expected Buffer, got {_typeof(arg)}") return base64.b64encode(arg).decode() if context.binary == "buffer": - if not isinstance(arg, (bytes, bytearray)) and not isinstance(arg, object): + # Python: `arg instanceof Object` is false for a primitive. + if not isinstance(arg, (bytes, bytearray)) and isinstance( + arg, (str, int, float, type(None)) + ): raise ValidationError(f"{path}: expected Buffer, got {_typeof(arg)}") return arg raise ValidationError(f'Unsupported binary behavior "{context.binary}"') diff --git a/playwright/_impl_v2/screencast.py b/playwright/_impl_v2/screencast.py index 4c1d5e4ac..a5b6da739 100644 --- a/playwright/_impl_v2/screencast.py +++ b/playwright/_impl_v2/screencast.py @@ -61,8 +61,10 @@ def __init__(self, page: "Page") -> None: self._save_path: Optional[Union[str, Path]] = None self._on_frame: Optional[Callable[[ScreencastFrame], Any]] = None self._artifact: Optional[Artifact] = None - # Python: the generated API reads the event loop of each object. + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber # Python: a sync listener that calls on_frame at once, as the JS async # listener does before its first await; the rest runs in a task. @@ -138,7 +140,7 @@ async def start( if result["artifact"]: self._artifact = Artifact.from_(result["artifact"]) self._save_path = path - return DisposableStub(lambda: self.stop()) + return DisposableStub(lambda: self.stop(), self) @api_call() async def stop(self) -> None: @@ -172,7 +174,8 @@ async def show_actions( timeout=NO_TIMEOUT, ) return DisposableStub( - lambda: self._page._channel.screencastHideActions(timeout=NO_TIMEOUT) + lambda: self._page._channel.screencastHideActions(timeout=NO_TIMEOUT), + self, ) async def hide_actions(self) -> None: @@ -189,7 +192,8 @@ async def show_overlay( return DisposableStub( lambda: self._page._channel.screencastRemoveOverlay( id=id, timeout=NO_TIMEOUT - ) + ), + self, ) async def show_chapter( diff --git a/playwright/_impl_v2/selectors.py b/playwright/_impl_v2/selectors.py index 2a6740881..d9f11bca5 100644 --- a/playwright/_impl_v2/selectors.py +++ b/playwright/_impl_v2/selectors.py @@ -20,7 +20,6 @@ JS Set; nothing depends on that order. """ -import asyncio from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Union @@ -33,16 +32,18 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl_v2 import protocol_types as channels from playwright._impl_v2.browser_context import BrowserContext + from playwright._impl_v2.playwright import Playwright class Selectors: - def __init__(self) -> None: + def __init__(self, playwright: "Playwright") -> None: self._selector_engines: List["channels.SelectorEngine"] = [] self._test_id_attribute_name: Optional[str] = None self._contexts_for_selectors: Set["BrowserContext"] = set() - # Python: the generated API reads the event loop of each object. Playwright - # creates Selectors in the loop of the connection. - self._loop = asyncio.get_event_loop() + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. Playwright creates the selectors. + self._loop = playwright._loop + self._dispatcher_fiber = playwright._dispatcher_fiber async def register( self, diff --git a/playwright/_impl_v2/tracing.py b/playwright/_impl_v2/tracing.py index 3a031caab..fcd034aec 100644 --- a/playwright/_impl_v2/tracing.py +++ b/playwright/_impl_v2/tracing.py @@ -121,7 +121,7 @@ async def start( ) )["traceName"] await self._start_collecting_stacks(trace_name, self._is_live) - return DisposableStub(lambda: self.stop(path=None)) + return DisposableStub(lambda: self.stop(path=None), self) @api_call() async def start_chunk( @@ -133,7 +133,7 @@ async def start_chunk( ) )["traceName"] await self._start_collecting_stacks(trace_name, self._is_live) - return DisposableStub(lambda: self.stop_chunk(path=None)) + return DisposableStub(lambda: self.stop_chunk(path=None), self) async def group(self, name: str, location: Optional[Location]) -> DisposableStub: if location: @@ -145,7 +145,7 @@ async def group(self, name: str, location: Optional[Location]) -> DisposableStub ), timeout=NO_TIMEOUT, ) - return DisposableStub(lambda: self.group_end()) + return DisposableStub(lambda: self.group_end(), self) async def group_end(self) -> None: await self._channel.tracingGroupEnd(timeout=NO_TIMEOUT) @@ -201,7 +201,7 @@ async def start_har( update_content=content if content is not None else default_content, update_mode=mode if mode is not None else "full", ) - return DisposableStub(lambda: self.stop_har()) + return DisposableStub(lambda: self.stop_har(), self) @api_call() async def stop_har(self) -> None: diff --git a/playwright/_impl_v2/utils/debug_logger.py b/playwright/_impl_v2/utils/debug_logger.py index a5bfec911..674ce25cf 100644 --- a/playwright/_impl_v2/utils/debug_logger.py +++ b/playwright/_impl_v2/utils/debug_logger.py @@ -19,28 +19,44 @@ """ import os +import re import sys -from typing import Set +from typing import List, Pattern LogName = str class DebugLogger: def __init__(self) -> None: - self._enabled: Set[str] = set() - for name in (os.environ.get("DEBUG") or "").split(","): - name = name.strip() - if name == "pw:*": - self._enabled.update({"api", "channel"}) - elif name.startswith("pw:"): - self._enabled.add(name[3:]) + # Python: the names of the `debug` npm package, which debugLogger.ts uses: + # DEBUG is a list of names, split on commas and spaces; `*` in a name + # matches anything; a name that starts with `-` turns its matches off. + self._names: List[Pattern[str]] = [] + self._skips: List[Pattern[str]] = [] + for name in re.split(r"[\s,]+", (os.environ.get("DEBUG") or "").strip()): + if not name: + continue + if name.startswith("-"): + self._skips.append(_template(name[1:])) + else: + self._names.append(_template(name)) def log(self, name: LogName, message: object) -> None: if self.is_enabled(name): print(f"pw:{name} {message}", file=sys.stderr) def is_enabled(self, name: LogName) -> bool: - return name in self._enabled + full_name = "pw:" + name + if any(skip.match(full_name) for skip in self._skips): + return False + return any(enabled.match(full_name) for enabled in self._names) + + +def _template(name: str) -> Pattern[str]: + # matchesTemplate() of the `debug` package: `*` matches any text. + return re.compile( + "^" + ".*".join(re.escape(part) for part in name.split("*")) + "$" + ) debug_logger = DebugLogger() diff --git a/playwright/_impl_v2/utils/stack_trace.py b/playwright/_impl_v2/utils/stack_trace.py index c6b547e3c..2384e5b9a 100644 --- a/playwright/_impl_v2/utils/stack_trace.py +++ b/playwright/_impl_v2/utils/stack_trace.py @@ -53,12 +53,24 @@ def capture_raw_stack() -> RawStack: frame: Optional[FrameType] = sys._getframe(1) while frame and len(stack) < 50: if frame.f_code is _EVENT_LOOP_CALLBACK_CODE: - break + # Python: under the sync API the call runs in a task of the dispatcher + # fiber, and the frames of the user code are in the greenlet of the + # sync call, suspended; the task points to them (_sync_base.py). + frame = _sync_caller_frame() + continue stack.append(frame) frame = frame.f_back return stack +def _sync_caller_frame() -> Optional[FrameType]: + try: + task = asyncio.current_task() + except RuntimeError: + return None + return getattr(task, "__pw_caller_frame__", None) + + def rewrite_error_message(e: Exception, new_message: str) -> Exception: """Port of rewriteErrorMessage(). diff --git a/playwright/_impl_v2/video.py b/playwright/_impl_v2/video.py index d71934912..bc4040479 100644 --- a/playwright/_impl_v2/video.py +++ b/playwright/_impl_v2/video.py @@ -15,8 +15,8 @@ """Port of packages/playwright-core/src/client/video.ts. Python: path() returns a pathlib.Path, and the message of the remote path error -names save_as(). The check of the sync API in save_as() (the page must be closed -first) is not ported: there is no sync code here. +names save_as(). Under the sync API, save_as() checks that the page is closed: the +video is complete only then, and a sync call can not wait for a close. """ import pathlib @@ -29,17 +29,26 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl_v2.artifact import Artifact from playwright._impl_v2.connection import Connection + from playwright._impl_v2.page import Page class Video(EventEmitter): def __init__( - self, connection: "Connection", artifact: Optional["Artifact"] + self, connection: "Connection", artifact: Optional["Artifact"], page: "Page" ) -> None: super().__init__() self._is_remote = connection.is_remote() self._artifact = artifact - # Python: the generated API reads the event loop of each object. + # Python: the page, for the check of the sync API in save_as(). + self._page = page + # Python: the generated API reads the event loop and the dispatcher fiber of + # each object. self._loop = connection._loop + self._dispatcher_fiber = connection._dispatcher_fiber + + # Python only: __repr__(). + def __repr__(self) -> str: + return f"