Skip to content

Commit 19329ac

Browse files
cursoragentP4X-ng
andcommitted
Add wait_for_event API and clean connection docs
Co-authored-by: P4x-ng <P4X-ng@users.noreply.github.com>
1 parent 2140462 commit 19329ac

File tree

6 files changed

+198
-94
lines changed

6 files changed

+198
-94
lines changed

.github/copilot-instructions.md~

Lines changed: 0 additions & 32 deletions
This file was deleted.

README.md

Lines changed: 28 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ 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+
64+
# Wait for a specific event type
65+
await conn.wait_for_event(page.LoadEventFired, timeout=5.0)
6366
print(f"Navigated to example.com, frame_id: {frame_id}")
6467

6568
asyncio.run(main())
@@ -70,7 +73,7 @@ asyncio.run(main())
7073
- **WebSocket Management**: Automatic connection lifecycle management with async context managers
7174
- **JSON-RPC Framing**: Automatic message ID assignment and request/response matching
7275
- **Command Multiplexing**: Execute multiple commands concurrently with proper tracking
73-
- **Event Handling**: Async iterator for receiving browser events
76+
- **Event Handling**: Async iterators plus typed waiting via `wait_for_event(...)`
7477
- **Error Handling**: Comprehensive error handling with typed exceptions
7578

7679
See the [examples directory](examples/) for more usage patterns.
@@ -88,39 +91,6 @@ assert repr(frame_id) == "FrameId('my id')"
8891

8992
## API Documentation
9093

91-
For detailed API documentation, see:
92-
93-
- **[Complete Documentation](https://py-cdp.readthedocs.io)** - Full API reference on Read the Docs
94-
- **[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)** - Official CDP specification
95-
- **[Examples](examples/)** - Code examples demonstrating usage patterns
96-
97-
### Key Modules
98-
99-
- `cdp.connection` - WebSocket I/O and connection management (I/O mode)
100-
- `cdp.<domain>` - Type wrappers for each CDP domain (e.g., `cdp.page`, `cdp.network`, `cdp.runtime`)
101-
- Each domain module provides types, commands, and events for that CDP domain
102-
103-
## Contributing
104-
105-
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
106-
107-
- Setting up your development environment
108-
- Running tests and type checking
109-
- Submitting pull requests
110-
- Reporting issues
111-
112-
Please also read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
113-
114-
## Security
115-
116-
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
117-
118-
## License
119-
120-
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
121-
122-
## API Reference
123-
12494
The library provides Python wrappers for all Chrome DevTools Protocol domains:
12595

12696
- **Page**: Page control (navigation, screenshots, etc.)
@@ -132,7 +102,18 @@ The library provides Python wrappers for all Chrome DevTools Protocol domains:
132102
- **Security**: Security-related information
133103
- And many more...
134104

135-
For complete API documentation, visit [py-cdp.readthedocs.io](https://py-cdp.readthedocs.io).
105+
For detailed API documentation, see:
106+
107+
- **[Complete Documentation](https://py-cdp.readthedocs.io)** - Full API reference on Read the Docs
108+
- **[Connection Guide](docs/connection.md)** - I/O mode, multiplexing, and event waiting helpers
109+
- **[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)** - Official CDP specification
110+
- **[Examples](examples/)** - Usage patterns and runnable snippets
111+
112+
### Key Modules
113+
114+
- `cdp.connection` - WebSocket I/O and connection management (I/O mode)
115+
- `cdp.<domain>` - Type wrappers for each CDP domain (e.g., `cdp.page`, `cdp.network`, `cdp.runtime`)
116+
- Each domain module provides types, commands, and events for that CDP domain
136117

137118
### Type System
138119

@@ -144,13 +125,18 @@ All CDP types, commands, and events are fully typed with Python type hints, prov
144125

145126
## Contributing
146127

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
128+
We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for:
129+
130+
- development setup
131+
- test and type-check commands
132+
- pull request workflow
133+
- bug and feature reporting
134+
135+
Please also read our [Code of Conduct](CODE_OF_CONDUCT.md).
136+
137+
## Security
152138

153-
For questions or discussions, feel free to open an issue on GitHub.
139+
For vulnerability reporting, see [SECURITY.md](SECURITY.md).
154140

155141
## License
156142

cdp/connection.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010
import asyncio
11+
from collections import deque
1112
import json
1213
import logging
1314
import typing
@@ -25,6 +26,7 @@
2526

2627

2728
logger = logging.getLogger(__name__)
29+
T_Event = typing.TypeVar('T_Event')
2830

2931

3032
class CDPError(Exception):
@@ -96,6 +98,7 @@ def __init__(self, url: str, timeout: float = 30.0):
9698
self._next_command_id = 1
9799
self._pending_commands: typing.Dict[int, PendingCommand] = {}
98100
self._event_queue: asyncio.Queue = asyncio.Queue()
101+
self._event_buffer: typing.Deque[typing.Any] = deque()
99102
self._recv_task: typing.Optional[asyncio.Task] = None
100103
self._closed = False
101104

@@ -131,6 +134,7 @@ async def close(self) -> None:
131134
if not pending.future.done():
132135
pending.future.cancel()
133136
self._pending_commands.clear()
137+
self._event_buffer.clear()
134138

135139
# Close the WebSocket
136140
if self._ws:
@@ -311,13 +315,91 @@ async def listen(self) -> typing.AsyncIterator[typing.Any]:
311315
"""
312316
while not self._closed:
313317
try:
318+
if self._event_buffer:
319+
yield self._event_buffer.popleft()
320+
continue
314321
event = await asyncio.wait_for(self._event_queue.get(), timeout=1.0)
315322
yield event
316323
except asyncio.TimeoutError:
317324
# Check if connection is still alive
318325
if self._closed:
319326
break
320327
continue
328+
329+
async def wait_for_event(
330+
self,
331+
event_type: typing.Union[typing.Type[T_Event], typing.Tuple[typing.Type[T_Event], ...]],
332+
predicate: typing.Optional[typing.Callable[[T_Event], bool]] = None,
333+
timeout: typing.Optional[float] = None
334+
) -> T_Event:
335+
"""
336+
Wait for the next event that matches the given type and optional predicate.
337+
338+
Any non-matching events are retained internally and remain available via
339+
``listen()`` or ``get_event_nowait()``.
340+
341+
Args:
342+
event_type: Expected event class (or tuple of classes).
343+
predicate: Optional additional filter for matching events.
344+
timeout: Optional timeout in seconds.
345+
346+
Returns:
347+
The first matching event.
348+
349+
Raises:
350+
asyncio.TimeoutError: If no matching event is received in time.
351+
CDPConnectionError: If the connection is closed while waiting.
352+
"""
353+
if timeout is not None and timeout < 0:
354+
raise ValueError('timeout must be >= 0')
355+
356+
# First inspect buffered events before waiting for new ones.
357+
buffered: typing.Deque[typing.Any] = deque()
358+
while self._event_buffer:
359+
event = self._event_buffer.popleft()
360+
if isinstance(event, event_type) and (predicate is None or predicate(event)):
361+
self._event_buffer.extendleft(reversed(buffered))
362+
return event
363+
buffered.append(event)
364+
self._event_buffer.extendleft(reversed(buffered))
365+
366+
loop = asyncio.get_running_loop()
367+
deadline = None if timeout is None else (loop.time() + timeout)
368+
369+
while True:
370+
if self._closed:
371+
raise CDPConnectionError('Connection closed while waiting for event')
372+
373+
remaining: typing.Optional[float] = None
374+
if deadline is not None:
375+
remaining = deadline - loop.time()
376+
if remaining <= 0:
377+
break
378+
379+
try:
380+
event = await asyncio.wait_for(self._event_queue.get(), timeout=remaining)
381+
except asyncio.TimeoutError as exc:
382+
raise asyncio.TimeoutError(
383+
f'No event of type {self._event_type_name(event_type)} before timeout'
384+
) from exc
385+
386+
if isinstance(event, event_type) and (predicate is None or predicate(event)):
387+
return event
388+
389+
self._event_buffer.append(event)
390+
391+
raise asyncio.TimeoutError(
392+
f'No event of type {self._event_type_name(event_type)} before timeout'
393+
)
394+
395+
@staticmethod
396+
def _event_type_name(
397+
event_type: typing.Union[typing.Type[typing.Any], typing.Tuple[typing.Type[typing.Any], ...]]
398+
) -> str:
399+
"""Return a display name for event type annotations."""
400+
if isinstance(event_type, tuple):
401+
return ', '.join(t.__name__ for t in event_type)
402+
return event_type.__name__
321403

322404
def get_event_nowait(self) -> typing.Optional[typing.Any]:
323405
"""
@@ -326,6 +408,8 @@ def get_event_nowait(self) -> typing.Optional[typing.Any]:
326408
Returns:
327409
A CDP event object, or None if no events are available
328410
"""
411+
if self._event_buffer:
412+
return self._event_buffer.popleft()
329413
try:
330414
return self._event_queue.get_nowait()
331415
except asyncio.QueueEmpty:

docs/connection.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ 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
)
2727

@@ -120,6 +120,19 @@ if event:
120120
print(f"Got event: {event}")
121121
```
122122

123+
Wait for a specific event type (with optional filtering):
124+
125+
```python
126+
load_event = await conn.wait_for_event(page.LoadEventFired, timeout=5.0)
127+
print(f"Loaded at timestamp: {load_event.timestamp}")
128+
129+
# Wait for a matching event
130+
late_event = await conn.wait_for_event(
131+
page.LoadEventFired,
132+
predicate=lambda evt: evt.timestamp > load_event.timestamp
133+
)
134+
```
135+
123136
### Error Handling
124137

125138
The connection module provides typed exceptions:
@@ -160,6 +173,7 @@ class CDPConnection:
160173
async def connect(self) -> None
161174
async def close(self) -> None
162175
async def execute(self, cmd, timeout: Optional[float] = None) -> Any
176+
async def wait_for_event(self, event_type, predicate=None, timeout=None) -> Any
163177
async def listen(self) -> AsyncIterator[Any]
164178
def get_event_nowait(self) -> Optional[Any]
165179

examples/connection_example.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,26 +54,17 @@ async def event_handling_example():
5454
async with CDPConnection(url) as conn:
5555
# Enable page domain to receive events
5656
await conn.execute(page.enable())
57-
58-
# Start navigation
57+
58+
# Start navigation and wait for a specific event.
5959
print("Starting navigation...")
60-
nav_task = asyncio.create_task(
61-
conn.execute(page.navigate(url="https://example.com"))
62-
)
63-
64-
# Listen for events while navigation is in progress
65-
event_count = 0
66-
async for event in conn.listen():
67-
print(f"Received event: {type(event).__name__}")
68-
event_count += 1
69-
70-
# Stop after receiving a few events
71-
if event_count >= 3:
72-
break
73-
74-
# Wait for navigation to complete
75-
await nav_task
76-
print("Navigation complete!")
60+
await conn.execute(page.navigate(url="https://example.com"))
61+
load_event = await conn.wait_for_event(page.LoadEventFired, timeout=5.0)
62+
print(f"Page loaded at timestamp: {load_event.timestamp}")
63+
64+
# You can still iterate over queued events.
65+
event = conn.get_event_nowait()
66+
if event:
67+
print(f"Next queued event: {type(event).__name__}")
7768

7869

7970
async def multiplexing_example():

0 commit comments

Comments
 (0)