diff --git a/python/rustwright/_async_generated.py b/python/rustwright/_async_generated.py index 6e2670f..2c54a69 100644 --- a/python/rustwright/_async_generated.py +++ b/python/rustwright/_async_generated.py @@ -1,5 +1,5 @@ # This file is generated by tools/generate_async_api.py. Do not edit. -# sync_api.py sha256: 49b3f768b7d4abd70196e6caf57ef90a50b1d2ad57e0a826b0029a707e70dc90 +# sync_api.py sha256: 5e7f9fadf3222f7a52167a45f72b610fce8702619dff3d56e745f911b5b99b4c from __future__ import annotations from pathlib import Path @@ -14,6 +14,12 @@ async def _generated_run_sync_call(func: Any, /, *args: Any, **kwargs: Any) -> A return await _run_sync_call(func, *args, **kwargs) +async def _generated_run_sync_action(sync_owner: Any, func: Any, /, *args: Any, **kwargs: Any) -> Any: + from .async_api import _run_sync_action + + return await _run_sync_action(sync_owner, func, *args, **kwargs) + + async def _generated_run_sync_wait_sliced(sync_owner: Any, func: Any, /, *args: Any, **kwargs: Any) -> Any: from .async_api import _run_sync_wait_sliced @@ -440,40 +446,40 @@ async def wait_for_function(self, expression: str, *, arg: Any=None, timeout: Op return _generated_call_async_api('_wrap_async_js_handle', await _generated_run_sync_wait_sliced(self._sync, self._sync.wait_for_function, expression, arg=_generated_unwrap_async_arg(arg), timeout=timeout, polling=polling)) async def dblclick(self, selector: str, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.dblclick, selector, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.dblclick, selector, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def type(self, selector: str, text: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.type, selector, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after, strict=strict) + await _generated_run_sync_action(self._sync, self._sync.type, selector, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after, strict=strict) async def press(self, selector: str, key: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.press, selector, key, delay=delay, timeout=timeout, no_wait_after=no_wait_after, strict=strict) + await _generated_run_sync_action(self._sync, self._sync.press, selector, key, delay=delay, timeout=timeout, no_wait_after=no_wait_after, strict=strict) async def hover(self, selector: str, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.hover, selector, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.hover, selector, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, strict=strict, trial=trial) async def tap(self, selector: str, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.tap, selector, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.tap, selector, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def drag_and_drop(self, source: str, target: str, *, source_position: Any=None, target_position: Any=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, timeout: Optional[float]=None, strict: Optional[bool]=None, trial: Optional[bool]=None, steps: Optional[int]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.drag_and_drop, source, target, source_position=source_position, target_position=target_position, force=force, no_wait_after=no_wait_after, timeout=timeout, strict=strict, trial=trial, steps=steps) + await _generated_run_sync_action(self._sync, self._sync.drag_and_drop, source, target, source_position=source_position, target_position=target_position, force=force, no_wait_after=no_wait_after, timeout=timeout, strict=strict, trial=trial, steps=steps) async def focus(self, selector: str, *, strict: Optional[bool]=None, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.focus, selector, strict=strict, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.focus, selector, strict=strict, timeout=timeout) async def check(self, selector: str, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.check, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.check, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def uncheck(self, selector: str, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.uncheck, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.uncheck, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def select_option(self, selector: str, value: Any=None, *, index: Any=None, label: Any=None, element: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None, strict: Optional[bool]=None) -> Any: - return await _generated_run_sync_wait_sliced(self._sync, self._sync.select_option, selector, value, index=index, label=label, element=element, timeout=timeout, no_wait_after=no_wait_after, force=force, strict=strict) + return await _generated_run_sync_action(self._sync, self._sync.select_option, selector, value, index=index, label=label, element=element, timeout=timeout, no_wait_after=no_wait_after, force=force, strict=strict) async def set_input_files(self, selector: str, files: Any, *, timeout: Optional[float]=None, strict: Optional[bool]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_input_files, selector, files, timeout=timeout, strict=strict, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.set_input_files, selector, files, timeout=timeout, strict=strict, no_wait_after=no_wait_after) async def set_checked(self, selector: str, checked: bool, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_checked, selector, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.set_checked, selector, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def opener(self) -> Any: return _generated_call_async_api('_wrap_async_page', await _generated_run_sync_call(self._sync.opener)) @@ -590,46 +596,46 @@ async def wait_for_function(self, expression: str, *, arg: Any=None, timeout: Op return _generated_call_async_api('_wrap_async_js_handle', await _generated_run_sync_wait_sliced(self._sync, self._sync.wait_for_function, expression, arg=_generated_unwrap_async_arg(arg), timeout=timeout, polling=polling)) async def click(self, selector: str, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, click_count: Optional[int]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.click, selector, modifiers=modifiers, position=position, delay=delay, button=button, click_count=click_count, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.click, selector, modifiers=modifiers, position=position, delay=delay, button=button, click_count=click_count, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def dblclick(self, selector: str, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.dblclick, selector, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.dblclick, selector, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def fill(self, selector: str, value: str, *, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, force: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.fill, selector, value, timeout=timeout, no_wait_after=no_wait_after, strict=strict, force=force) + await _generated_run_sync_action(self._sync, self._sync.fill, selector, value, timeout=timeout, no_wait_after=no_wait_after, strict=strict, force=force) async def type(self, selector: str, text: str, *, delay: Optional[float]=None, strict: Optional[bool]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.type, selector, text, delay=delay, strict=strict, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.type, selector, text, delay=delay, strict=strict, timeout=timeout, no_wait_after=no_wait_after) async def press(self, selector: str, key: str, *, delay: Optional[float]=None, strict: Optional[bool]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.press, selector, key, delay=delay, strict=strict, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.press, selector, key, delay=delay, strict=strict, timeout=timeout, no_wait_after=no_wait_after) async def hover(self, selector: str, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.hover, selector, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.hover, selector, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, strict=strict, trial=trial) async def tap(self, selector: str, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.tap, selector, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.tap, selector, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def focus(self, selector: str, *, strict: Optional[bool]=None, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.focus, selector, strict=strict, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.focus, selector, strict=strict, timeout=timeout) async def check(self, selector: str, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.check, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.check, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def uncheck(self, selector: str, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.uncheck, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.uncheck, selector, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def set_checked(self, selector: str, checked: bool, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_checked, selector, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.set_checked, selector, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, strict=strict, trial=trial) async def select_option(self, selector: str, value: Any=None, *, index: Any=None, label: Any=None, element: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, force: Optional[bool]=None) -> Any: - return await _generated_run_sync_wait_sliced(self._sync, self._sync.select_option, selector, value, index=index, label=label, element=_generated_unwrap_async_wrapper_sequence(element), timeout=timeout, no_wait_after=no_wait_after, strict=strict, force=force) + return await _generated_run_sync_action(self._sync, self._sync.select_option, selector, value, index=index, label=label, element=_generated_unwrap_async_wrapper_sequence(element), timeout=timeout, no_wait_after=no_wait_after, strict=strict, force=force) async def set_input_files(self, selector: str, files: Any, *, strict: Optional[bool]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_input_files, selector, files, strict=strict, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.set_input_files, selector, files, strict=strict, timeout=timeout, no_wait_after=no_wait_after) async def drag_and_drop(self, source: str, target: str, *, source_position: Any=None, target_position: Any=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, strict: Optional[bool]=None, timeout: Optional[float]=None, trial: Optional[bool]=None, steps: Optional[int]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.drag_and_drop, source, target, source_position=source_position, target_position=target_position, force=force, no_wait_after=no_wait_after, strict=strict, timeout=timeout, trial=trial, steps=steps) + await _generated_run_sync_action(self._sync, self._sync.drag_and_drop, source, target, source_position=source_position, target_position=target_position, force=force, no_wait_after=no_wait_after, strict=strict, timeout=timeout, trial=trial, steps=steps) class _AsyncLocatorGeneratedMixin: @@ -711,58 +717,58 @@ async def normalize(self) -> 'AsyncLocator': return _generated_call_async_api('AsyncLocator', await _generated_run_sync_call(self._sync.normalize)) async def click(self, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, click_count: Optional[int]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None, steps: Optional[int]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.click, modifiers=modifiers, position=position, delay=delay, button=button, click_count=click_count, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) + await _generated_run_sync_action(self._sync, self._sync.click, modifiers=modifiers, position=position, delay=delay, button=button, click_count=click_count, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) async def dblclick(self, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None, steps: Optional[int]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.dblclick, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) + await _generated_run_sync_action(self._sync, self._sync.dblclick, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) async def fill(self, value: str, *, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.fill, value, timeout=timeout, no_wait_after=no_wait_after, force=force) + await _generated_run_sync_action(self._sync, self._sync.fill, value, timeout=timeout, no_wait_after=no_wait_after, force=force) async def type(self, text: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.type, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.type, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after) async def press(self, key: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.press, key, delay=delay, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.press, key, delay=delay, timeout=timeout, no_wait_after=no_wait_after) async def hover(self, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.hover, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.hover, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, trial=trial) async def tap(self, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.tap, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.tap, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def focus(self, *, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.focus, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.focus, timeout=timeout) async def blur(self, *, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.blur, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.blur, timeout=timeout) async def clear(self, *, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.clear, timeout=timeout, no_wait_after=no_wait_after, force=force) + await _generated_run_sync_action(self._sync, self._sync.clear, timeout=timeout, no_wait_after=no_wait_after, force=force) async def check(self, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.check, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.check, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def uncheck(self, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.uncheck, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.uncheck, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def set_checked(self, checked: bool, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_checked, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.set_checked, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def select_option(self, value: Any=None, *, index: Any=None, label: Any=None, element: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None) -> Any: - return await _generated_run_sync_wait_sliced(self._sync, self._sync.select_option, value, index=index, label=label, element=_generated_unwrap_async_wrapper_sequence(element), timeout=timeout, no_wait_after=no_wait_after, force=force) + return await _generated_run_sync_action(self._sync, self._sync.select_option, value, index=index, label=label, element=_generated_unwrap_async_wrapper_sequence(element), timeout=timeout, no_wait_after=no_wait_after, force=force) async def set_input_files(self, files: Any, *, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_input_files, files, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.set_input_files, files, timeout=timeout, no_wait_after=no_wait_after) async def scroll_into_view_if_needed(self, *, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.scroll_into_view_if_needed, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.scroll_into_view_if_needed, timeout=timeout) async def select_text(self, *, force: Optional[bool]=None, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.select_text, force=force, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.select_text, force=force, timeout=timeout) async def press_sequentially(self, text: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.press_sequentially, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.press_sequentially, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after) async def screenshot(self, *, timeout: Optional[float]=None, type: Optional[str]=None, path: Optional[str]=None, quality: Optional[int]=None, omit_background: Optional[bool]=None, animations: Optional[str]=None, caret: Optional[str]=None, scale: Optional[str]=None, mask: Any=None, mask_color: Optional[str]=None, style: Optional[str]=None) -> bytes: return await _generated_run_sync_call(self._sync.screenshot, timeout=timeout, type=type, path=path, quality=quality, omit_background=omit_background, animations=animations, caret=caret, scale=scale, mask=_generated_unwrap_async_wrapper_sequence(mask), mask_color=mask_color, style=style) @@ -853,49 +859,49 @@ async def dispose(self) -> None: await _generated_run_sync_call(self._sync.dispose) async def click(self, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, click_count: Optional[int]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None, steps: Optional[int]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.click, modifiers=modifiers, position=position, delay=delay, button=button, click_count=click_count, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) + await _generated_run_sync_action(self._sync, self._sync.click, modifiers=modifiers, position=position, delay=delay, button=button, click_count=click_count, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) async def dblclick(self, *, modifiers: Any=None, position: Any=None, delay: Optional[float]=None, button: Optional[str]=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None, steps: Optional[int]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.dblclick, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) + await _generated_run_sync_action(self._sync, self._sync.dblclick, modifiers=modifiers, position=position, delay=delay, button=button, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial, steps=steps) async def fill(self, value: str, *, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.fill, value, timeout=timeout, no_wait_after=no_wait_after, force=force) + await _generated_run_sync_action(self._sync, self._sync.fill, value, timeout=timeout, no_wait_after=no_wait_after, force=force) async def type(self, text: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.type, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.type, text, delay=delay, timeout=timeout, no_wait_after=no_wait_after) async def press(self, key: str, *, delay: Optional[float]=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.press, key, delay=delay, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.press, key, delay=delay, timeout=timeout, no_wait_after=no_wait_after) async def hover(self, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None, force: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.hover, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.hover, modifiers=modifiers, position=position, timeout=timeout, no_wait_after=no_wait_after, force=force, trial=trial) async def tap(self, *, modifiers: Any=None, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.tap, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.tap, modifiers=modifiers, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def wait_for_selector(self, selector: str, *, state: Optional[str]=None, timeout: Optional[float]=None, strict: Optional[bool]=None) -> Optional['AsyncElementHandle']: return _generated_call_async_api('_wrap_async_element_handle', await _generated_run_sync_wait_sliced(self._sync, self._sync.wait_for_selector, selector, state=state, timeout=timeout, strict=strict)) async def check(self, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.check, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.check, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def uncheck(self, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.uncheck, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.uncheck, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def set_checked(self, checked: bool, *, position: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None, trial: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_checked, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) + await _generated_run_sync_action(self._sync, self._sync.set_checked, checked, position=position, timeout=timeout, force=force, no_wait_after=no_wait_after, trial=trial) async def select_option(self, value: Any=None, *, index: Any=None, label: Any=None, element: Any=None, timeout: Optional[float]=None, force: Optional[bool]=None, no_wait_after: Optional[bool]=None) -> Any: - return await _generated_run_sync_wait_sliced(self._sync, self._sync.select_option, value, index=index, label=label, element=_generated_unwrap_async_wrapper_sequence(element), timeout=timeout, force=force, no_wait_after=no_wait_after) + return await _generated_run_sync_action(self._sync, self._sync.select_option, value, index=index, label=label, element=_generated_unwrap_async_wrapper_sequence(element), timeout=timeout, force=force, no_wait_after=no_wait_after) async def set_input_files(self, files: Any, *, timeout: Optional[float]=None, no_wait_after: Optional[bool]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.set_input_files, files, timeout=timeout, no_wait_after=no_wait_after) + await _generated_run_sync_action(self._sync, self._sync.set_input_files, files, timeout=timeout, no_wait_after=no_wait_after) async def scroll_into_view_if_needed(self, *, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.scroll_into_view_if_needed, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.scroll_into_view_if_needed, timeout=timeout) async def select_text(self, *, force: Optional[bool]=None, timeout: Optional[float]=None) -> None: - await _generated_run_sync_wait_sliced(self._sync, self._sync.select_text, force=force, timeout=timeout) + await _generated_run_sync_action(self._sync, self._sync.select_text, force=force, timeout=timeout) async def wait_for_element_state(self, state: str, *, timeout: Optional[float]=None) -> None: await _generated_run_sync_wait_sliced(self._sync, self._sync.wait_for_element_state, state, timeout=timeout) diff --git a/python/rustwright/async_api.py b/python/rustwright/async_api.py index 355b280..6657ec4 100644 --- a/python/rustwright/async_api.py +++ b/python/rustwright/async_api.py @@ -15,6 +15,7 @@ from . import _rustwright from .sync_api import ( + _ACTION_CANCELLATION, APIRequest as SyncAPIRequest, APIRequestContext as SyncAPIRequestContext, APIResponse as SyncAPIResponse, @@ -708,6 +709,32 @@ def _rewrite_sliced_wait_timeout_message(message: str, timeout: Any) -> str: return re.sub(r"timed out after [0-9]+(?:\.[0-9]+)? ms", f"Timeout {timeout_label}ms exceeded.", message, count=1) +async def _run_sync_action( + sync_owner: Any, + action_func: Callable[..., Any], + *args: Any, + timeout: Optional[float] = None, + **kwargs: Any, +) -> Any: + # Context is copied into the executor by _run_sync_call. Sync polls and + # native dispatch use the same token without changing public signatures. + cancellation = _rustwright._RustCancelToken() + context = _ACTION_CANCELLATION.set(cancellation) + try: + return await _run_sync_call(action_func, *args, timeout=timeout, **kwargs) + except asyncio.CancelledError: + cancellation.cancel() + raise + except TimeoutError as exc: + raw_timeout = _async_wait_default_timeout(sync_owner) if timeout is None else timeout + message = _rewrite_sliced_wait_timeout_message(str(exc), raw_timeout) + if message == str(exc): + raise + raise _copy_wire_error_metadata(exc, TimeoutError(message)) from None + finally: + _ACTION_CANCELLATION.reset(context) + + async def _run_sync_wait_sliced( sync_owner: Any, wait_func: Callable[..., Any], @@ -3126,7 +3153,7 @@ async def click( for value in (modifiers, position, delay, button, click_count, force, no_wait_after, trial) ) ): - await _run_sync_wait_sliced( + await _run_sync_action( self._sync, self._sync.click, selector, @@ -3926,7 +3953,7 @@ async def drag_to( ) -> None: if not isinstance(target, _AsyncWrapper): getattr(target, "_impl_obj") - await _run_sync_wait_sliced( + await _run_sync_action( self._sync, self._sync.drag_to, target._sync, diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index af4df7a..155627b 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -47,6 +47,9 @@ _DOWNLOADS_NOT_ACCEPTED = "Pass 'accept_downloads=True' when you are creating your browser context." _MISSING = object() _UNSET = object() +_ACTION_CANCELLATION: contextvars.ContextVar[Any] = contextvars.ContextVar( + "rustwright_action_cancellation", default=None, +) _SYNC_CLOSE_OPEN = "open" _SYNC_CLOSE_CLOSING = "closing" _SYNC_CLOSE_CLOSED = "closed" @@ -1747,16 +1750,42 @@ def _translate_error(exc: Exception) -> Error: return Error(message) +def _check_action_cancelled() -> None: + cancellation = _ACTION_CANCELLATION.get() + if cancellation is not None and cancellation.is_cancelled(): + raise Error("Action was cancelled") + + +def _action_cancel_kwargs() -> dict[str, Any]: + cancellation = _ACTION_CANCELLATION.get() + return {} if cancellation is None else {"cancel": cancellation} + + +def _call_action(fn, *args, **kwargs): + """Commit a Python-managed dispatch and let its required cleanup finish.""" + cancellation = _ACTION_CANCELLATION.get() + if cancellation is None: + return fn(*args, **kwargs) + # The Rust gate arbitrates cancellation atomically. Nested calls and cleanup + # must not observe cancellation after that gate has admitted the dispatch. + context = _ACTION_CANCELLATION.set(None) + try: + return _call(cancellation.run_committed, fn, args, kwargs) + finally: + _ACTION_CANCELLATION.reset(context) + + def _call(fn, *args, **kwargs): + _check_action_cancelled() try: return fn(*args, **kwargs) except (RuntimeError, ValueError) as exc: raise _translate_error(exc) from None -def _call_native_key(method: str, key: str, fn, *args): +def _call_native_key(method: str, key: str, fn, *args, **kwargs): try: - return _call_with_method_prefix(method, fn, *args) + return _call_with_method_prefix(method, fn, *args, **kwargs) except Error as exc: message = str(exc).removeprefix(f"{method}: ") unsupported = re.fullmatch(r"unsupported key(?: modifier)?: (.*)", message) @@ -5224,9 +5253,11 @@ def _event_wait_step(remaining: float, owner: Any = None) -> float: def _sleep_until_next_poll(deadline: float, interval: float = 0.02) -> None: + _check_action_cancelled() remaining = deadline - time.monotonic() if remaining > 0: time.sleep(min(interval, remaining)) + _check_action_cancelled() def _actionability_probe_timeout(remaining_ms: float, timeout_disabled: bool = False) -> float: @@ -24437,11 +24468,13 @@ def _target_state( self._index, _json(options), self._page._default_timeout if timeout is None else timeout, + **_action_cancel_kwargs(), ) return _decode_json_result_json(result) def _fill_apply(self, value: str, *, strict: bool, forced: bool, timeout: float) -> dict[str, Any]: - result = _call( + result = _call_action( + _call, self._page._core.locator_fill_apply, _json(self._spec), self._index, @@ -24461,7 +24494,8 @@ def _select_apply( timeout: float, method: str, ) -> dict[str, Any]: - result = _call_with_method_prefix( + result = _call_action( + _call_with_method_prefix, method, self._page._core.locator_select_apply, _json(self._spec), @@ -24520,6 +24554,7 @@ def _wait_for_single( stable_position_required: bool = True, action_position: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: + _check_action_cancelled() self._raise_if_frame_locator_in_composite(f"Locator.{action}") method = _locator_method_for_action(action) _raise_if_owner_unavailable(self._page, method=method) @@ -24530,6 +24565,7 @@ def _wait_for_single( requires_stable = require_stable or (check_stable and state in {"actionable", "stable", "scrollable"}) while True: + _check_action_cancelled() _raise_if_owner_unavailable(self._page, method=method) remaining_ms = max((deadline - time.monotonic()) * 1000, 1.0) command_timeout = _actionability_probe_timeout(remaining_ms, timeout_disabled) @@ -24557,6 +24593,7 @@ def _wait_for_single( break _sleep_until_next_poll(deadline) continue + _check_action_cancelled() if self._strict and not self._explicit_index: self._raise_frame_strict_violation(last_info.get("frame_strict_violation")) count = int(last_info.get("count") or 0) @@ -25159,6 +25196,21 @@ def _frame_viewport_offset(self, *, timeout: Optional[float]) -> tuple[float, fl return offset_x, offset_y def _with_mouse_modifiers(self, modifiers: Optional[Iterable[str]], action: Callable[[], None]) -> None: + pending = [] + for modifier in modifiers or []: + normalized = self._page.keyboard._normalize_modifier(str(modifier)) + if normalized is None: + raise Error(f"unsupported mouse modifier: {modifier}") + if normalized not in self._page.keyboard._modifiers: + pending.append(normalized) + if not pending: + action() + return + # Modifier-down is the first input in this sequence. Keep modifier-up + # and the pointer sequence inside the same cancellation boundary. + _call_action(self._dispatch_with_mouse_modifiers, pending, action) + + def _dispatch_with_mouse_modifiers(self, modifiers: Iterable[str], action: Callable[[], None]) -> None: pressed: list[str] = [] existing = set(self._page.keyboard._modifiers) try: @@ -25295,6 +25347,7 @@ def _dispatch_locator_pointer_action( self._index, json.dumps(action), self._page._default_timeout if timeout is None else timeout, + **_action_cancel_kwargs(), ) def _mouse_dblclick( @@ -25527,6 +25580,7 @@ def _fill(self, value: str, *, action: str, timeout: Optional[float] = None, for action, timeout_ms, on_poll, + **_action_cancel_kwargs(), ) self._page._slow_mo() @@ -25574,6 +25628,7 @@ def type( text, delay_value, remaining_ms, + **_action_cancel_kwargs(), ) self._page._slow_mo() @@ -25608,6 +25663,7 @@ def press( key, delay_value, remaining_ms, + **_action_cancel_kwargs(), ) self._page._slow_mo() @@ -25853,6 +25909,9 @@ def _native_drag_to( def focus(self, *, timeout: Optional[float] = None) -> None: self._wait_for_single("focus", state="attached", timeout=timeout) + _call_action(self._focus, timeout=timeout) + + def _focus(self, *, timeout: Optional[float]) -> None: self._eval( """ if (!el) throw new Error('No element matches locator'); @@ -25864,7 +25923,8 @@ def focus(self, *, timeout: Optional[float] = None) -> None: self._page._slow_mo() def _focus_for_keyboard_action(self, *, timeout: Optional[float] = None) -> bool: - self.focus(timeout=timeout) + # The caller waits before committing the focus/type/cleanup sequence. + self._focus(timeout=timeout) return bool( self._eval( """ @@ -25905,7 +25965,8 @@ def _cleanup_keyboard_fallback_focus(self) -> None: def blur(self, *, timeout: Optional[float] = None) -> None: self._wait_for_single("blur", state="attached", timeout=timeout) - self._eval( + _call_action( + self._eval, """ if (!el) throw new Error('No element matches locator'); if (typeof el.blur === 'function') el.blur(); @@ -26239,7 +26300,8 @@ def _set_input_files_impl( ) -> None: self._wait_for_single("set input files", state="attached", timeout=timeout) payloads = _file_payloads(files) - self._eval( + _call_action( + self._eval, f""" if (!el) throw new Error('No element matches locator'); if (!(el instanceof HTMLInputElement) || el.type !== 'file') throw new Error('Element is not a file input'); @@ -26574,7 +26636,8 @@ def scroll_into_view_if_needed(self, *, timeout: Optional[float] = None) -> None def select_text(self, *, force: Optional[bool] = None, timeout: Optional[float] = None) -> None: self._wait_for_single("select text", state="attached" if force else "visible", timeout=timeout) - self._eval( + _call_action( + self._eval, """ if (!el) throw new Error('No element matches locator'); if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && typeof el.select === 'function') { @@ -26613,16 +26676,20 @@ def press_sequentially( name="delay", ) self._wait_for_single("press sequentially", state="attached", timeout=timeout) - fallback_focused = self._focus_for_keyboard_action(timeout=timeout) - try: - if fallback_focused: - self._page.keyboard._type_without_text(text, delay=delay_value) - else: - self._page.keyboard.type(text, delay=delay_value) - self._page._slow_mo() - finally: - if fallback_focused: - self._cleanup_keyboard_fallback_focus() + + def dispatch() -> None: + fallback_focused = self._focus_for_keyboard_action(timeout=timeout) + try: + if fallback_focused: + self._page.keyboard._type_without_text(text, delay=delay_value) + else: + self._page.keyboard.type(text, delay=delay_value) + self._page._slow_mo() + finally: + if fallback_focused: + self._cleanup_keyboard_fallback_focus() + + _call_action(dispatch) def screenshot( self, diff --git a/src/lib.rs b/src/lib.rs index 45c26b6..90432bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,7 @@ use pyo3::exceptions::{PyRuntimeError, PyValueError}; #[cfg(feature = "python")] use pyo3::prelude::*; #[cfg(feature = "python")] -use pyo3::types::{PyAny, PyBytes, PyDict, PyList, PyModule}; +use pyo3::types::{PyAny, PyBytes, PyDict, PyList, PyModule, PyTuple}; #[cfg(feature = "python")] use pyo3::IntoPyObjectExt; use serde::de::{MapAccess, SeqAccess, Visitor}; @@ -2732,6 +2732,22 @@ impl PyRustCancelToken { fn cancel(&self) { self.token.cancel(); } + + fn is_cancelled(&self) -> bool { + self.token.is_cancelled() + } + + // Python dispatch and its cleanup share the existing atomic commit boundary. + // Cancellation can win before this callback, but cannot interrupt it halfway. + fn run_committed( + &self, + callback: &Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: &Bound<'_, PyDict>, + ) -> PyResult> { + let _commit = self.token.begin_physical_action().map_err(py_err)?; + callback.call(args, Some(kwargs)).map(Bound::unbind) + } } #[cfg(feature = "python")] @@ -40778,7 +40794,7 @@ impl PyPage { .map_err(py_err) } - #[pyo3(signature = (locator_json, locator_index, action_json, timeout_ms=None))] + #[pyo3(signature = (locator_json, locator_index, action_json, timeout_ms=None, cancel=None))] fn dispatch_locator_pointer_action( &self, py: Python<'_>, @@ -40786,6 +40802,7 @@ impl PyPage { locator_index: usize, action_json: &str, timeout_ms: Option, + cancel: Option>, ) -> PyResult<()> { let action = serde_json::from_str::(action_json) .map_err(|error| PyValueError::new_err(error.to_string()))?; @@ -40794,6 +40811,7 @@ impl PyPage { let client = Arc::clone(&browser.client); let locator_json = locator_json.to_string(); let timeout = BrowserInner::command_timeout(timeout_ms); + let cancel = cancel.map(|cancel| cancel.token.clone()); py.detach(move || { browser.block_on(async move { @@ -40811,7 +40829,7 @@ impl PyPage { action.get(name).and_then(Value::as_i64).unwrap_or(default) }; let position = action.get("position").and_then(Value::as_object); - let resolved = resolve_locator_point( + let resolved = cancelable(cancel.clone(), resolve_locator_point( Arc::clone(&page), &locator_json, locator_index, @@ -40820,7 +40838,7 @@ impl PyPage { None, false, deadline, - ) + )) .await?; let top_x = number("targetX")?; let top_y = number("targetY")?; @@ -40833,6 +40851,7 @@ impl PyPage { match kind { "click" => { + let _commit = cancel.as_ref().map(CancelToken::begin_physical_action).transpose()?; let button = action .get("button") .and_then(Value::as_str) @@ -40856,6 +40875,7 @@ impl PyPage { .await?; } "hover" => { + let _commit = cancel.as_ref().map(CancelToken::begin_physical_action).transpose()?; dispatch_mouse_move_sequence_in_session( &client, &resolved.session_id, @@ -40871,6 +40891,7 @@ impl PyPage { .await?; } "tap" => { + let _commit = cancel.as_ref().map(CancelToken::begin_physical_action).transpose()?; client .send( "Input.dispatchTouchEvent", @@ -40909,7 +40930,7 @@ impl PyPage { .and_then(Value::as_str) .ok_or_else(|| RwError::Message("drag action is missing target locator".to_string()))?; let target_position = action.get("targetPosition").and_then(Value::as_object); - let target = resolve_locator_point( + let target = cancelable(cancel.clone(), resolve_locator_point( Arc::clone(&page), target_locator_json, integer("targetIndex", 0).max(0) as usize, @@ -40918,13 +40939,14 @@ impl PyPage { None, false, deadline, - ) + )) .await?; if target.session_id != resolved.session_id { return Err(RwError::Message( "drag source and target must belong to the same CDP target".to_string(), )); } + let _commit = cancel.as_ref().map(CancelToken::begin_physical_action).transpose()?; dispatch_mouse_move_sequence_in_session( &client, &resolved.session_id, @@ -42376,7 +42398,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .map_err(py_err) } - #[pyo3(signature = (locator_json, index, options_json, timeout_ms=None))] + #[pyo3(signature = (locator_json, index, options_json, timeout_ms=None, cancel=None))] fn locator_probe_state( &self, py: Python<'_>, @@ -42384,16 +42406,18 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; index: usize, options_json: &str, timeout_ms: Option, + cancel: Option>, ) -> PyResult { let body = locator_probe_state_body(options_json).map_err(py_err)?; let page = Arc::clone(&self.inner); let locator_json = locator_json.to_string(); let timeout = BrowserInner::command_timeout(timeout_ms); + let cancel = cancel.map(|cancel| cancel.token.clone()); py.detach(move || { let browser = Arc::clone(&page.browser); - browser.block_on(async move { + browser.block_on(cancelable(cancel, async move { evaluate_locator_action_for_page(page, locator_json, index, body, timeout).await - }) + })) }) .map_err(py_err) } @@ -42590,7 +42614,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; text: &str, timeout_ms: Option, ) -> PyResult<()> { - self.type_text_native(locator_json, index, text, None, timeout_ms) + self.type_text_native(locator_json, index, text, None, timeout_ms, None) } fn keyboard_primary_modifier(&self) -> &'static str { @@ -42699,7 +42723,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .to_string()) } - #[pyo3(signature = (locator_json, index, text, delay_ms=None, timeout_ms=None))] + #[pyo3(signature = (locator_json, index, text, delay_ms=None, timeout_ms=None, cancel=None))] fn type_text_native( &self, locator_json: &str, @@ -42707,6 +42731,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; text: &str, delay_ms: Option, timeout_ms: Option, + cancel: Option>, ) -> PyResult<()> { let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); @@ -42714,6 +42739,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; let text = text.to_string(); let deadline = OperationDeadline::new(native_input_timeout(timeout_ms)); let delay = native_input_delay(delay_ms); + let cancel = cancel.map(|cancel| cancel.token.clone()); browser .block_on(type_locator_for_native_input( &page, @@ -42723,12 +42749,12 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; delay, deadline, InputDispatchPolicy::ActionDeadline, - None, + cancel.as_ref(), )) .map_err(py_err) } - #[pyo3(signature = (locator_json, index, key, delay_ms=None, timeout_ms=None))] + #[pyo3(signature = (locator_json, index, key, delay_ms=None, timeout_ms=None, cancel=None))] fn press_key_native( &self, locator_json: &str, @@ -42736,6 +42762,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; key: &str, delay_ms: Option, timeout_ms: Option, + cancel: Option>, ) -> PyResult<()> { let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); @@ -42743,6 +42770,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; let key = key.to_string(); let deadline = OperationDeadline::new(native_input_timeout(timeout_ms)); let delay = native_input_delay(delay_ms); + let cancel = cancel.map(|cancel| cancel.token.clone()); browser .block_on(press_locator_for_native_input( &page, @@ -42752,7 +42780,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; delay, deadline, InputDispatchPolicy::ActionDeadline, - None, + cancel.as_ref(), )) .map_err(py_err) } diff --git a/tests/test_async_generated_fresh.py b/tests/test_async_generated_fresh.py index 987b7c1..5a4a4e0 100644 --- a/tests/test_async_generated_fresh.py +++ b/tests/test_async_generated_fresh.py @@ -1,13 +1,21 @@ from __future__ import annotations import ast +import asyncio +import threading +import time from collections import Counter from pathlib import Path +from types import SimpleNamespace + +import pytest from tools.generate_async_api import ( GENERATED_METHODS, HAND_ASYNC_HELPERS, HAND_METHODS, + SINGLE_DISPATCH_METHODS, + SLICED_WAIT_METHODS, generate_async_api, ) @@ -56,6 +64,156 @@ def test_every_class_async_method_has_one_owner() -> None: assert len(actual_hand | expected_generated) == 391 +def test_only_observational_waits_use_sliced_runner() -> None: + assert SLICED_WAIT_METHODS == { + "AsyncPage": ("wait_for_url", "wait_for_function"), + "AsyncFrame": ("wait_for_selector", "wait_for_url", "wait_for_function"), + "AsyncLocator": ("wait_for",), + "AsyncElementHandle": ("wait_for_selector", "wait_for_element_state"), + } + wait_names = {name for names in SLICED_WAIT_METHODS.values() for name in names} + wait_names.add("wait_for_load_state") + for class_name, methods in SINGLE_DISPATCH_METHODS.items(): + assert set(methods).isdisjoint(wait_names) + assert set(methods) <= set(GENERATED_METHODS[class_name]) + + # Include handwritten fallbacks, such as Page.click and Locator.drag_to. + for source in (ASYNC_API, GENERATED_API): + tree = ast.parse(source.read_text(encoding="utf-8")) + for class_node in tree.body: + if not isinstance(class_node, ast.ClassDef): + continue + for method in class_node.body: + if not isinstance(method, ast.AsyncFunctionDef): + continue + runners = { + node.func.id + for node in ast.walk(method) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + if runners & {"_run_sync_wait_sliced", "_generated_run_sync_wait_sliced"}: + assert method.name in wait_names, (class_node.name, method.name) + + +@pytest.mark.parametrize("timeout", [2000, None, 0]) +@pytest.mark.parametrize("message", [ + "Locator.press: timed out after 50 ms", + "Locator.press: Timeout 50ms exceeded.\nCall log: waiting for target", + "timed out waiting for locator to be actionable while trying to click; no element matched", +]) +def test_async_mutation_timeout_is_not_retried(timeout, message) -> None: + from rustwright.async_api import AsyncLocator, TimeoutError + + calls = [] + error = TimeoutError(message) + error._rustwright_error_kind = "action_timeout" + error._rustwright_error_payload = {"phase": "dispatch", "retryable": False} + + class SyncLocator: + _page = SimpleNamespace(_default_timeout=1234) + + def press(self, key, **kwargs): + calls.append((key, kwargs["timeout"])) + raise error + + async def run(): + with pytest.raises(TimeoutError) as caught: + await AsyncLocator(SyncLocator()).press("Enter", timeout=timeout) + label = 1234 if timeout is None else timeout + expected = message.replace("timed out after 50 ms", f"Timeout {label}ms exceeded.") + expected = expected.replace("Timeout 50ms exceeded", f"Timeout {label}ms exceeded") + assert str(caught.value) == expected + assert caught.value._rustwright_error_kind == "action_timeout" + assert caught.value._rustwright_error_payload == error._rustwright_error_payload + + asyncio.run(run()) + assert calls == [("Enter", timeout)] + + +@pytest.mark.parametrize("cancel_at", [None, "wait", "dispatch"]) +def test_async_mutation_yields_and_cancellation_does_not_retry(cancel_at, monkeypatch) -> None: + from rustwright import async_api, sync_api + + tokens = [] + original_run_sync_call = async_api._run_sync_call + + async def record_executor_call(func, *args, **kwargs): + tokens.append(sync_api._ACTION_CANCELLATION.get()) + return await original_run_sync_call(func, *args, **kwargs) + + monkeypatch.setattr(async_api, "_run_sync_call", record_executor_call) + + async def run(): + loop = asyncio.get_running_loop() + entered = asyncio.Event() + committed = asyncio.Event() + release = threading.Event() + finished = threading.Event() + stopped_by_cancellation = threading.Event() + calls = [] + events = [] + + def dispatch(): + loop.call_soon_threadsafe(committed.set) + try: + assert release.wait(2), "dispatch must leave the event loop free" + events.append("input") + finally: + # Even a native call in cleanup must survive late cancellation. + sync_api._call(events.append, "cleanup") + + class SyncLocator: + def click(self, **kwargs): + calls.append((threading.get_ident(), kwargs["timeout"])) + loop.call_soon_threadsafe(entered.set) + try: + if cancel_at != "dispatch": + deadline = time.monotonic() + 30 + while not release.wait(0.01): + sync_api._check_action_cancelled() + assert time.monotonic() < deadline, "worker did not observe cancellation" + sync_api._check_action_cancelled() + sync_api._call_action(dispatch) + except sync_api.Error: + stopped_by_cancellation.set() + raise + finally: + finished.set() + + task = asyncio.create_task(async_api.AsyncLocator(SyncLocator()).click(timeout=30000)) + try: + await asyncio.wait_for(entered.wait(), timeout=1) + assert not task.done() + assert len(calls) == 1 + assert calls[0][1] == 30000 + assert calls[0][0] != threading.get_ident() + assert len(tokens) == 1 and tokens[0] is not None + if cancel_at: + if cancel_at == "dispatch": + await asyncio.wait_for(committed.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert tokens[0].is_cancelled() + if cancel_at == "wait": + # No manual release: signalling the token must stop the worker. + assert await asyncio.to_thread(finished.wait, 2) + assert stopped_by_cancellation.is_set() + with pytest.raises(RuntimeError): + tokens[0].run_committed(events.append, ("late input",), {}) + assert events == [] + finally: + release.set() + if cancel_at is None: + assert await task is None + assert await asyncio.to_thread(finished.wait, 2) + assert len(calls) == 1 + assert events == ([] if cancel_at == "wait" else ["input", "cleanup"]) + assert sync_api._ACTION_CANCELLATION.get() is None + + asyncio.run(run()) + + def test_non_class_async_helpers_stay_hand_written() -> None: tree = ast.parse(ASYNC_API.read_text(encoding="utf-8")) parents: dict[ast.AST, ast.AST] = {} diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index a2f04b1..f9f21bc 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -42958,7 +42958,7 @@ async def record_sync_dispatch(_owner, func, *args, **kwargs): monkeypatch.setattr(async_api, "_native_page_hot_path_supported", lambda _page: True) monkeypatch.setattr(async_api, "_native_selector_locator", lambda *args, **kwargs: FakeLocator()) - monkeypatch.setattr(async_api, "_run_sync_wait_sliced", record_sync_dispatch) + monkeypatch.setattr(async_api, "_run_sync_action", record_sync_dispatch) async def run() -> None: await page.click("#target") @@ -43218,6 +43218,155 @@ async def run() -> None: asyncio.run(run()) +@pytest.mark.parametrize("wrapper", ["locator", "page", "page_fallback", "frame", "element_handle"]) +def test_async_mutations_dispatch_once_with_slow_handlers(wrapper): + async def run() -> None: + from rustwright.async_api import async_playwright + + content = """ + + + """ + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + try: + page = await browser.new_page() + if wrapper == "frame": + await page.set_content(f'') + owner = page.frame(name="child") + assert owner is not None + else: + await page.set_content(content) + owner = page + + for method, selector, args, counter in ( + ("click", "#toggle", (), "__clicks"), + ("fill", "#field", ("value",), "__inputs"), + ("press", "#field", ("Enter",), "__presses"), + ): + options = {"timeout": 2000} + if wrapper == "locator": + target = owner.locator(selector) + elif wrapper == "element_handle": + target = await owner.query_selector(selector) + assert target is not None + else: + target = owner + args = (selector, *args) + if wrapper == "page_fallback" and method in {"click", "fill"}: + options["force"] = False + # Each handler exceeds the old 50 ms slice. Retrying the + # mutation delivered repeated trusted events and timed out. + await getattr(target, method)(*args, **options) + assert await owner.evaluate(f"window.{counter}") == 1 + assert await owner.locator("#field").input_value() == "value" + finally: + await browser.close() + + asyncio.run(run()) + + +@pytest.mark.parametrize("wrapper", ["locator", "frame"]) +def test_async_cancel_pending_mutation_prevents_late_dispatch(wrapper, monkeypatch): + async def run() -> None: + from rustwright import async_api + + loop = asyncio.get_running_loop() + entered = asyncio.Event() + finished = asyncio.Event() + started_at = None + finished_at = None + run_sync_call = async_api._run_sync_call + + async def observe_action_start(func, *args, **kwargs): + if func.__name__ in {"click", "fill", "press"} and kwargs.get("timeout") == 30000: + def action(): + nonlocal started_at, finished_at + started_at = time.monotonic() + loop.call_soon_threadsafe(entered.set) + try: + return func(*args, **kwargs) + finally: + finished_at = time.monotonic() + loop.call_soon_threadsafe(finished.set) + + return await run_sync_call(action) + return await run_sync_call(func, *args, **kwargs) + + monkeypatch.setattr(async_api, "_run_sync_call", observe_action_start) + async with async_api.async_playwright() as p: + browser = await p.chromium.launch(headless=True) + try: + page = await browser.new_page() + for method, args in (("click", ()), ("fill", ("value",)), ("press", ("Enter",))): + await page.set_content('' if wrapper == "frame" else "") + owner = page.frame(name="child") if wrapper == "frame" else page + assert owner is not None + await owner.evaluate("""() => { + window.__events = {click: 0, input: 0, keydown: 0}; + for (const type of ['click', 'input', 'keydown']) { + document.addEventListener(type, event => { + if (event.isTrusted) window.__events[type]++; + }); + } + }""") + target = owner if wrapper == "frame" else owner.locator("#late") + call_args = ("#late", *args) if wrapper == "frame" else args + entered.clear() + finished.clear() + finished_at = None + pending = asyncio.create_task(getattr(target, method)(*call_args, timeout=30000)) + # Prove the executor has started, so cancellation cannot pass + # merely by removing a job that is still queued. + await asyncio.wait_for(entered.wait(), timeout=5) + assert not pending.done() + assert not finished.is_set() + cancelled_at = time.monotonic() + assert started_at is not None and cancelled_at - started_at < 5 + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + # The 30 s action cannot time out or find a target here. + # Require actual worker exit, even if loop scheduling is late. + await asyncio.wait_for(finished.wait(), timeout=10) + assert finished_at is not None and finished_at - cancelled_at < 10 + await owner.evaluate("""() => { + const input = document.createElement('input'); + input.id = 'late'; + document.body.appendChild(input); + }""") + # Longer than both the old 50 ms slice and the 20 ms poll. + await asyncio.sleep(0.25) + expected = {"click": 0, "input": 0, "keydown": 0} + assert await owner.evaluate("window.__events") == expected + assert await owner.locator("#late").input_value() == "" + # Positive control: these same listeners must observe input. + await getattr(target, method)(*call_args, timeout=30000) + expected[{"click": "click", "fill": "input", "press": "keydown"}[method]] = 1 + assert await owner.evaluate("window.__events") == expected + finally: + await browser.close() + + asyncio.run(run()) + + def test_async_actions_yield_event_loop_while_waiting_for_targets(): async def run() -> None: from playwright.async_api import async_playwright diff --git a/tools/generate_async_api.py b/tools/generate_async_api.py index 16911c0..dad99cf 100644 --- a/tools/generate_async_api.py +++ b/tools/generate_async_api.py @@ -120,6 +120,7 @@ # _install_async_expectation_negated_aliases.make_alias. HAND_ASYNC_HELPERS = ( "_run_sync_call", + "_run_sync_action", "_await_native", "_await_native_method", "_await_native_action", @@ -201,17 +202,26 @@ } -# These methods intentionally slice blocking sync waits so cancellation and -# unrelated event-loop work can make progress. The method bodies are otherwise -# ordinary delegation, so the runner choice stays explicit generator data. +# Only observational waits may retry after a slice times out. Mutations must +# receive their full timeout once, because a timeout can follow physical input. SLICED_WAIT_METHODS = { + "AsyncPage": ("wait_for_url", "wait_for_function"), + "AsyncFrame": ("wait_for_selector", "wait_for_url", "wait_for_function"), + "AsyncLocator": ("wait_for",), + "AsyncElementHandle": ("wait_for_selector", "wait_for_element_state"), +} + + +# These actions retain async timeout-message formatting without retrying the +# sync operation. The executor keeps the event loop free while the action waits. +SINGLE_DISPATCH_METHODS = { "AsyncPage": ( - "wait_for_url", "wait_for_function", "dblclick", "type", "press", + "dblclick", "type", "press", "hover", "tap", "drag_and_drop", "focus", "check", "uncheck", "select_option", "set_input_files", "set_checked", ), "AsyncFrame": ( - "wait_for_selector", "wait_for_url", "wait_for_function", "click", + "click", "dblclick", "fill", "type", "press", "hover", "tap", "focus", "check", "uncheck", "set_checked", "select_option", "set_input_files", "drag_and_drop", @@ -220,13 +230,13 @@ "click", "dblclick", "fill", "type", "press", "hover", "tap", "focus", "blur", "clear", "check", "uncheck", "set_checked", "select_option", "set_input_files", "scroll_into_view_if_needed", - "select_text", "press_sequentially", "wait_for", + "select_text", "press_sequentially", ), "AsyncElementHandle": ( "click", "dblclick", "fill", "type", "press", "hover", "tap", - "wait_for_selector", "check", "uncheck", "set_checked", + "check", "uncheck", "set_checked", "select_option", "set_input_files", "scroll_into_view_if_needed", - "select_text", "wait_for_element_state", + "select_text", ), } @@ -485,6 +495,9 @@ def _delegating_call( runner_args: list[ast.expr] = [sync_method, *positional] if method.name in SLICED_WAIT_METHODS.get(async_class, ()): runner = "_generated_run_sync_wait_sliced" + elif method.name in SINGLE_DISPATCH_METHODS.get(async_class, ()): + runner = "_generated_run_sync_action" + if runner != "_generated_run_sync_call": runner_args = [ ast.Attribute( value=ast.Name(id="self", ctx=ast.Load()), @@ -694,6 +707,12 @@ async def _generated_run_sync_call(func: Any, /, *args: Any, **kwargs: Any) -> A return await _run_sync_call(func, *args, **kwargs) +async def _generated_run_sync_action(sync_owner: Any, func: Any, /, *args: Any, **kwargs: Any) -> Any: + from .async_api import _run_sync_action + + return await _run_sync_action(sync_owner, func, *args, **kwargs) + + async def _generated_run_sync_wait_sliced(sync_owner: Any, func: Any, /, *args: Any, **kwargs: Any) -> Any: from .async_api import _run_sync_wait_sliced