Skip to content

Commit 884d386

Browse files
cursoragentP4X-ng
andcommitted
Add wait_for_event helper to CDPConnection
Co-authored-by: P4x-ng <P4X-ng@users.noreply.github.com>
1 parent 2140462 commit 884d386

File tree

5 files changed

+247
-42
lines changed

5 files changed

+247
-42
lines changed

README.md

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ async def main():
5757
# Connect to a Chrome DevTools Protocol endpoint
5858
async with CDPConnection("ws://localhost:9222/devtools/page/YOUR_PAGE_ID") as conn:
5959
# Navigate to a URL
60-
frame_id, loader_id, error = await conn.execute(
60+
frame_id, loader_id, error_text, is_download = await conn.execute(
6161
page.navigate(url="https://example.com")
6262
)
63-
print(f"Navigated to example.com, frame_id: {frame_id}")
63+
print(f"Navigated to example.com, frame_id: {frame_id}, is_download={is_download}")
64+
65+
# Wait for a specific event (new helper API)
66+
load_event = await conn.wait_for_event(page.LoadEventFired, timeout=10.0)
67+
print(f"Load event timestamp: {load_event.timestamp}")
6468

6569
asyncio.run(main())
6670
```
@@ -71,6 +75,7 @@ asyncio.run(main())
7175
- **JSON-RPC Framing**: Automatic message ID assignment and request/response matching
7276
- **Command Multiplexing**: Execute multiple commands concurrently with proper tracking
7377
- **Event Handling**: Async iterator for receiving browser events
78+
- **Event Waiting**: Wait for a specific event type (optionally with predicates)
7479
- **Error Handling**: Comprehensive error handling with typed exceptions
7580

7681
See the [examples directory](examples/) for more usage patterns.
@@ -94,6 +99,23 @@ For detailed API documentation, see:
9499
- **[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)** - Official CDP specification
95100
- **[Examples](examples/)** - Code examples demonstrating usage patterns
96101

102+
### Event waiting helper
103+
104+
`CDPConnection.wait_for_event()` can simplify flows where you need to wait for one
105+
specific event:
106+
107+
```python
108+
await conn.execute(page.enable())
109+
await conn.execute(page.navigate(url="https://example.com"))
110+
111+
load_event = await conn.wait_for_event(
112+
page.LoadEventFired,
113+
predicate=lambda evt: evt.timestamp > 0,
114+
timeout=10.0,
115+
)
116+
print(load_event.timestamp)
117+
```
118+
97119
### Key Modules
98120

99121
- `cdp.connection` - WebSocket I/O and connection management (I/O mode)
@@ -119,41 +141,4 @@ For information about reporting security vulnerabilities, please see our [Securi
119141

120142
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
121143

122-
## API Reference
123-
124-
The library provides Python wrappers for all Chrome DevTools Protocol domains:
125-
126-
- **Page**: Page control (navigation, screenshots, etc.)
127-
- **DOM**: DOM inspection and manipulation
128-
- **Network**: Network monitoring and interception
129-
- **Runtime**: JavaScript execution and evaluation
130-
- **Debugger**: JavaScript debugging
131-
- **Performance**: Performance metrics and profiling
132-
- **Security**: Security-related information
133-
- And many more...
134-
135-
For complete API documentation, visit [py-cdp.readthedocs.io](https://py-cdp.readthedocs.io).
136-
137-
### Type System
138-
139-
All CDP types, commands, and events are fully typed with Python type hints, providing:
140-
- IDE autocomplete support
141-
- Static type checking with mypy
142-
- Clear API contracts
143-
- Inline documentation
144-
145-
## Contributing
146-
147-
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
148-
- How to report bugs and request features
149-
- Development setup and workflow
150-
- Coding standards and testing requirements
151-
- Pull request process
152-
153-
For questions or discussions, feel free to open an issue on GitHub.
154-
155-
## License
156-
157-
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
158-
159144
<a href="https://www.hyperiongray.com/?pk_campaign=github&pk_kwd=pycdp"><img alt="define hyperion gray" width="500px" src="https://hyperiongray.s3.amazonaws.com/define-hg.svg"></a>

cdp/connection.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import logging
1313
import typing
14-
from dataclasses import dataclass, field
14+
from dataclasses import dataclass
1515

1616
try:
1717
import websockets
@@ -55,6 +55,22 @@ class PendingCommand:
5555
params: T_JSON_DICT
5656

5757

58+
@dataclass
59+
class EventWaiter:
60+
"""Represents a consumer waiting for a matching event."""
61+
future: asyncio.Future
62+
event_type: typing.Optional[typing.Union[type, typing.Tuple[type, ...]]] = None
63+
predicate: typing.Optional[typing.Callable[[typing.Any], bool]] = None
64+
65+
def matches(self, event: typing.Any) -> bool:
66+
"""Return True if this waiter should receive the event."""
67+
if self.event_type is not None and not isinstance(event, self.event_type):
68+
return False
69+
if self.predicate is not None and not self.predicate(event):
70+
return False
71+
return True
72+
73+
5874
class CDPConnection:
5975
"""
6076
Manages a WebSocket connection to Chrome DevTools Protocol.
@@ -96,6 +112,7 @@ def __init__(self, url: str, timeout: float = 30.0):
96112
self._next_command_id = 1
97113
self._pending_commands: typing.Dict[int, PendingCommand] = {}
98114
self._event_queue: asyncio.Queue = asyncio.Queue()
115+
self._event_waiters: typing.List[EventWaiter] = []
99116
self._recv_task: typing.Optional[asyncio.Task] = None
100117
self._closed = False
101118

@@ -131,6 +148,12 @@ async def close(self) -> None:
131148
if not pending.future.done():
132149
pending.future.cancel()
133150
self._pending_commands.clear()
151+
152+
# Cancel all event waiters
153+
for waiter in self._event_waiters:
154+
if not waiter.future.done():
155+
waiter.future.set_exception(CDPConnectionError("Connection closed"))
156+
self._event_waiters.clear()
134157

135158
# Close the WebSocket
136159
if self._ws:
@@ -213,9 +236,28 @@ async def _handle_event(self, data: T_JSON_DICT) -> None:
213236
"""Handle an event notification."""
214237
try:
215238
event = parse_json_event(data)
239+
self._notify_event_waiters(event)
216240
await self._event_queue.put(event)
217241
except Exception as e:
218242
logger.error(f"Failed to parse event: {e}")
243+
244+
def _notify_event_waiters(self, event: typing.Any) -> None:
245+
"""Resolve any pending waiters that match the event."""
246+
if not self._event_waiters:
247+
return
248+
249+
remaining_waiters: typing.List[EventWaiter] = []
250+
for waiter in self._event_waiters:
251+
if waiter.future.done():
252+
continue
253+
try:
254+
if waiter.matches(event):
255+
waiter.future.set_result(event)
256+
else:
257+
remaining_waiters.append(waiter)
258+
except Exception as e:
259+
waiter.future.set_exception(e)
260+
self._event_waiters = remaining_waiters
219261

220262
async def execute(
221263
self,
@@ -330,6 +372,48 @@ def get_event_nowait(self) -> typing.Optional[typing.Any]:
330372
return self._event_queue.get_nowait()
331373
except asyncio.QueueEmpty:
332374
return None
375+
376+
async def wait_for_event(
377+
self,
378+
event_type: typing.Optional[typing.Union[type, typing.Tuple[type, ...]]] = None,
379+
predicate: typing.Optional[typing.Callable[[typing.Any], bool]] = None,
380+
timeout: typing.Optional[float] = None,
381+
) -> typing.Any:
382+
"""
383+
Wait for the next event matching the provided filters.
384+
385+
Args:
386+
event_type: Optional event class (or tuple of classes) to match.
387+
predicate: Optional callable that must return True for a match.
388+
timeout: Optional timeout override in seconds.
389+
390+
Returns:
391+
The matching CDP event object.
392+
393+
Raises:
394+
CDPConnectionError: If the connection is closed.
395+
asyncio.TimeoutError: If no matching event arrives in time.
396+
"""
397+
if self._closed:
398+
raise CDPConnectionError("Connection closed")
399+
if self._ws is None:
400+
raise CDPConnectionError("Not connected")
401+
402+
future: asyncio.Future = asyncio.Future()
403+
waiter = EventWaiter(future=future, event_type=event_type, predicate=predicate)
404+
self._event_waiters.append(waiter)
405+
406+
timeout_val = timeout if timeout is not None else self.timeout
407+
try:
408+
return await asyncio.wait_for(future, timeout=timeout_val)
409+
except asyncio.TimeoutError:
410+
if waiter in self._event_waiters:
411+
self._event_waiters.remove(waiter)
412+
raise asyncio.TimeoutError("Timed out waiting for matching CDP event")
413+
except Exception:
414+
if waiter in self._event_waiters:
415+
self._event_waiters.remove(waiter)
416+
raise
333417

334418
@property
335419
def is_connected(self) -> bool:

docs/connection.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,21 @@ async def main():
2121
# Connect using async context manager
2222
async with CDPConnection("ws://localhost:9222/devtools/page/YOUR_PAGE_ID") as conn:
2323
# Execute a command
24-
frame_id, loader_id, error = await conn.execute(
24+
frame_id, loader_id, error_text, is_download = await conn.execute(
2525
page.navigate(url="https://example.com")
2626
)
27+
print(f"Navigated: frame_id={frame_id} is_download={is_download}")
2728

2829
# Evaluate JavaScript
2930
result, exception = await conn.execute(
3031
runtime.evaluate(expression="document.title")
3132
)
3233
print(f"Page title: {result.value}")
3334

35+
# Wait for a specific event
36+
event = await conn.wait_for_event(page.LoadEventFired, timeout=10.0)
37+
print(f"Page loaded at {event.timestamp}")
38+
3439
asyncio.run(main())
3540
```
3641

@@ -120,6 +125,20 @@ if event:
120125
print(f"Got event: {event}")
121126
```
122127

128+
Wait for a single matching event without manually iterating:
129+
130+
```python
131+
# Wait for the next LoadEventFired
132+
event = await conn.wait_for_event(page.LoadEventFired, timeout=10.0)
133+
134+
# Wait using a custom predicate
135+
event = await conn.wait_for_event(
136+
page.LoadEventFired,
137+
predicate=lambda evt: evt.timestamp > 0,
138+
timeout=10.0,
139+
)
140+
```
141+
123142
### Error Handling
124143

125144
The connection module provides typed exceptions:
@@ -162,6 +181,12 @@ class CDPConnection:
162181
async def execute(self, cmd, timeout: Optional[float] = None) -> Any
163182
async def listen(self) -> AsyncIterator[Any]
164183
def get_event_nowait(self) -> Optional[Any]
184+
async def wait_for_event(
185+
self,
186+
event_type: Optional[Union[type, Tuple[type, ...]]] = None,
187+
predicate: Optional[Callable[[Any], bool]] = None,
188+
timeout: Optional[float] = None,
189+
) -> Any
165190

166191
@property
167192
def is_connected(self) -> bool

examples/connection_example.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
1. Connect to a Chrome DevTools Protocol endpoint
77
2. Execute commands
88
3. Handle events
9-
4. Multiplex multiple commands concurrently
9+
4. Wait for a specific event
10+
5. Multiplex multiple commands concurrently
1011
"""
1112

1213
import asyncio
@@ -76,6 +77,25 @@ async def event_handling_example():
7677
print("Navigation complete!")
7778

7879

80+
async def wait_for_event_example():
81+
"""Example showing wait_for_event helper usage."""
82+
print("\n=== wait_for_event Example ===")
83+
84+
url = "ws://localhost:9222/devtools/page/YOUR_PAGE_ID"
85+
86+
async with CDPConnection(url) as conn:
87+
await conn.execute(page.enable())
88+
await conn.execute(page.navigate(url="https://example.com"))
89+
90+
# Wait for a matching load event instead of manually iterating.
91+
load_event = await conn.wait_for_event(
92+
page.LoadEventFired,
93+
predicate=lambda evt: evt.timestamp > 0,
94+
timeout=10.0,
95+
)
96+
print(f"Page loaded at timestamp: {load_event.timestamp}")
97+
98+
7999
async def multiplexing_example():
80100
"""Example showing concurrent command execution (multiplexing)."""
81101
print("\n=== Multiplexing Example ===")
@@ -138,6 +158,7 @@ async def main():
138158
# Uncomment the examples you want to run:
139159
# await basic_example()
140160
# await event_handling_example()
161+
# await wait_for_event_example()
141162
# await multiplexing_example()
142163
# await error_handling_example()
143164

0 commit comments

Comments
 (0)