Skip to content

Commit ba101c8

Browse files
cursoragentP4X-ng
andcommitted
Add event waiting helper and clean stale files
Co-authored-by: P4x-ng <P4X-ng@users.noreply.github.com>
1 parent 2140462 commit ba101c8

File tree

7 files changed

+194
-67
lines changed

7 files changed

+194
-67
lines changed

.github/copilot-instructions.md~

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

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- CODE_OF_CONDUCT.md following Contributor Covenant
1313
- SECURITY.md with security policy
1414
- This CHANGELOG.md file
15+
- `CDPConnection.wait_for_event(...)` helper for awaiting typed events with optional predicates
16+
17+
### Changed
18+
- Expanded I/O mode documentation with event-waiting examples
19+
- Removed duplicate sections in README to keep docs concise
1520

1621
## [0.5.0] - 2023
1722

README.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,23 @@ asyncio.run(main())
7171
- **JSON-RPC Framing**: Automatic message ID assignment and request/response matching
7272
- **Command Multiplexing**: Execute multiple commands concurrently with proper tracking
7373
- **Event Handling**: Async iterator for receiving browser events
74+
- **Event Waiting Helper**: `wait_for_event(...)` to await specific events with optional filtering
7475
- **Error Handling**: Comprehensive error handling with typed exceptions
7576

7677
See the [examples directory](examples/) for more usage patterns.
7778

79+
### Waiting for a Specific Event
80+
81+
```python
82+
from cdp import page
83+
84+
async with CDPConnection(url) as conn:
85+
await conn.execute(page.enable())
86+
await conn.execute(page.navigate(url="https://example.com"))
87+
load_event = await conn.wait_for_event(page.LoadEventFired, timeout=5.0)
88+
print(load_event.timestamp)
89+
```
90+
7891
## Sans-I/O Mode (Original)
7992

8093
For users who prefer to manage their own I/O:
@@ -142,18 +155,4 @@ All CDP types, commands, and events are fully typed with Python type hints, prov
142155
- Clear API contracts
143156
- Inline documentation
144157

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-
159158
<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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,77 @@ async def listen(self) -> typing.AsyncIterator[typing.Any]:
318318
if self._closed:
319319
break
320320
continue
321+
322+
@staticmethod
323+
def _format_event_type(
324+
event_type: typing.Union[type, typing.Tuple[type, ...]]
325+
) -> str:
326+
"""Format event type(s) for error messages."""
327+
if isinstance(event_type, tuple):
328+
return ', '.join(evt.__name__ for evt in event_type)
329+
return event_type.__name__
330+
331+
async def wait_for_event(
332+
self,
333+
event_type: typing.Union[type, typing.Tuple[type, ...]],
334+
timeout: typing.Optional[float] = None,
335+
predicate: typing.Optional[typing.Callable[[typing.Any], bool]] = None,
336+
) -> typing.Any:
337+
"""
338+
Wait for the first event matching a type and optional predicate.
339+
340+
Note:
341+
This consumes events from the shared event queue. Events that do not
342+
match are discarded.
343+
344+
Args:
345+
event_type: Event class (or tuple of event classes) to match.
346+
timeout: Optional timeout in seconds.
347+
predicate: Optional callable to further filter matching events.
348+
349+
Returns:
350+
The first matching event instance.
351+
352+
Raises:
353+
CDPConnectionError: If the connection is not open.
354+
asyncio.TimeoutError: If timeout elapses before a matching event.
355+
"""
356+
if self._ws is None:
357+
raise CDPConnectionError("Not connected")
358+
359+
if self._closed and self._event_queue.empty():
360+
raise CDPConnectionError("Connection closed")
361+
362+
loop = asyncio.get_running_loop()
363+
deadline = loop.time() + timeout if timeout is not None else None
364+
365+
while True:
366+
event_type_name = self._format_event_type(event_type)
367+
368+
try:
369+
if deadline is None:
370+
event = await self._event_queue.get()
371+
else:
372+
remaining = deadline - loop.time()
373+
if remaining <= 0:
374+
raise asyncio.TimeoutError(
375+
f"Timed out waiting for event {event_type_name}"
376+
)
377+
event = await asyncio.wait_for(self._event_queue.get(), timeout=remaining)
378+
except asyncio.TimeoutError:
379+
raise asyncio.TimeoutError(
380+
f"Timed out waiting for event {event_type_name}"
381+
)
382+
383+
if isinstance(event, event_type) and (
384+
predicate is None or predicate(event)
385+
):
386+
return event
387+
388+
if self._closed and self._event_queue.empty():
389+
raise CDPConnectionError(
390+
"Connection closed before matching event was received"
391+
)
321392

322393
def get_event_nowait(self) -> typing.Optional[typing.Any]:
323394
"""

docs/connection.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ async with CDPConnection(url) as conn:
112112
print(f"Navigated to {event.frame.url}")
113113
```
114114

115+
Wait for a single event directly:
116+
117+
```python
118+
async with CDPConnection(url) as conn:
119+
await conn.execute(page.enable())
120+
await conn.execute(page.navigate(url="https://example.com"))
121+
122+
# Wait for the next load event
123+
load_event = await conn.wait_for_event(page.LoadEventFired, timeout=5.0)
124+
print(f"Loaded at {load_event.timestamp}")
125+
```
126+
127+
You can also apply additional filtering logic:
128+
129+
```python
130+
load_event = await conn.wait_for_event(
131+
page.LoadEventFired,
132+
timeout=5.0,
133+
predicate=lambda evt: evt.timestamp > 0,
134+
)
135+
```
136+
115137
You can also get events without blocking:
116138

117139
```python
@@ -161,6 +183,7 @@ class CDPConnection:
161183
async def close(self) -> None
162184
async def execute(self, cmd, timeout: Optional[float] = None) -> Any
163185
async def listen(self) -> AsyncIterator[Any]
186+
async def wait_for_event(self, event_type, timeout: Optional[float] = None, predicate=None) -> Any
164187
def get_event_nowait(self) -> Optional[Any]
165188

166189
@property

pf_test_results.txt

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

test/test_connection.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,88 @@ async def test_get_event_nowait():
287287
assert isinstance(received_event, page.LoadEventFired)
288288

289289

290+
@pytest.mark.asyncio
291+
async def test_wait_for_event():
292+
"""Test waiting for a specific event type."""
293+
mock_ws = MockWebSocket()
294+
mock_ws.queue_message({
295+
"method": "Page.loadEventFired",
296+
"params": {
297+
"timestamp": 123456.789
298+
}
299+
})
300+
301+
with patch('cdp.connection.websockets.connect', new_callable=AsyncMock) as mock_connect:
302+
mock_connect.return_value = mock_ws
303+
304+
async with CDPConnection("ws://localhost:9222/test") as conn:
305+
received_event = await conn.wait_for_event(page.LoadEventFired, timeout=1.0)
306+
assert isinstance(received_event, page.LoadEventFired)
307+
assert received_event.timestamp == 123456.789
308+
309+
310+
@pytest.mark.asyncio
311+
async def test_wait_for_event_predicate():
312+
"""Test waiting for an event matching a predicate."""
313+
mock_ws = MockWebSocket()
314+
mock_ws.queue_message({
315+
"method": "Page.loadEventFired",
316+
"params": {
317+
"timestamp": 1.0
318+
}
319+
})
320+
mock_ws.queue_message({
321+
"method": "Page.loadEventFired",
322+
"params": {
323+
"timestamp": 2.0
324+
}
325+
})
326+
327+
with patch('cdp.connection.websockets.connect', new_callable=AsyncMock) as mock_connect:
328+
mock_connect.return_value = mock_ws
329+
330+
async with CDPConnection("ws://localhost:9222/test") as conn:
331+
received_event = await conn.wait_for_event(
332+
page.LoadEventFired,
333+
timeout=1.0,
334+
predicate=lambda evt: evt.timestamp > 1.5,
335+
)
336+
assert isinstance(received_event, page.LoadEventFired)
337+
assert received_event.timestamp == 2.0
338+
339+
340+
@pytest.mark.asyncio
341+
async def test_wait_for_event_timeout():
342+
"""Test waiting for an event times out when none match."""
343+
mock_ws = MockWebSocket()
344+
# Queue an event that fails the predicate.
345+
mock_ws.queue_message({
346+
"method": "Page.loadEventFired",
347+
"params": {
348+
"timestamp": 1.0
349+
}
350+
})
351+
352+
with patch('cdp.connection.websockets.connect', new_callable=AsyncMock) as mock_connect:
353+
mock_connect.return_value = mock_ws
354+
355+
async with CDPConnection("ws://localhost:9222/test") as conn:
356+
with pytest.raises(asyncio.TimeoutError):
357+
await conn.wait_for_event(
358+
page.LoadEventFired,
359+
timeout=0.1,
360+
predicate=lambda evt: evt.timestamp > 2.0,
361+
)
362+
363+
364+
@pytest.mark.asyncio
365+
async def test_wait_for_event_not_connected():
366+
"""Test waiting for an event without a connection."""
367+
conn = CDPConnection("ws://localhost:9222/test")
368+
with pytest.raises(CDPConnectionError, match="Not connected"):
369+
await conn.wait_for_event(page.LoadEventFired)
370+
371+
290372
@pytest.mark.asyncio
291373
async def test_pending_command_count():
292374
"""Test tracking pending command count."""

0 commit comments

Comments
 (0)