Skip to content

Commit 842e827

Browse files
committed
OOP changes for easier customization
1 parent 837fe1a commit 842e827

File tree

7 files changed

+232
-6
lines changed

7 files changed

+232
-6
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,40 @@ if link:
112112
print(f"Navigated to: {current_tab.url}")
113113
```
114114

115+
### Customization via Inheritance
116+
117+
pypecdp is designed to be easily extended through OOP inheritance:
118+
119+
```python
120+
from pypecdp import Browser, Tab, Elem
121+
122+
class MyElem(Elem):
123+
async def click_and_wait(self, timeout=10.0):
124+
"""Click and wait for page load."""
125+
tab = await self.click()
126+
if tab:
127+
await tab.wait_for_event(cdp.page.LoadEventFired, timeout=timeout)
128+
return tab
129+
130+
class MyTab(Tab):
131+
elem_class = MyElem # Use custom Elem class
132+
133+
async def search(self, query):
134+
"""Custom search method."""
135+
search_box = await self.wait_for_elem("#search")
136+
await search_box.type(query)
137+
138+
class MyBrowser(Browser):
139+
tab_class = MyTab # Use custom Tab class
140+
141+
# Use your custom classes
142+
browser = await MyBrowser.start()
143+
tab = await browser.navigate("https://example.com")
144+
# tab is now MyTab instance, elements are MyElem instances
145+
```
146+
147+
See `example/customize_pypecdp.py` for working example.
148+
115149
### Event Handlers
116150

117151
```python

example/customize_pypecdp.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Example: Customize pypecdp using OOP inheritance.
2+
3+
Demonstrates how to extend pypecdp classes with custom functionality.
4+
"""
5+
6+
import asyncio
7+
import os
8+
9+
from pypecdp import Browser, Elem, Tab, cdp
10+
11+
12+
# Custom Elem with additional methods
13+
class MyElem(Elem):
14+
"""Custom element with click_and_wait method."""
15+
16+
async def click_and_wait(self, timeout: float = 10.0) -> None:
17+
"""Click element and wait for navigation."""
18+
tab = await self.click()
19+
if tab:
20+
await tab.wait_for_event(cdp.page.LoadEventFired, timeout=timeout)
21+
print(f"Navigated to: {tab.url}")
22+
23+
24+
# Custom Tab using custom Elem
25+
class MyTab(Tab):
26+
"""Custom tab with helper methods."""
27+
28+
elem_class = MyElem # Use our custom Elem class
29+
30+
async def get_title(self) -> str:
31+
"""Get page title via JavaScript."""
32+
result = await self.eval("document.title")
33+
return result.value if result.value else ""
34+
35+
36+
# Custom Browser using custom Tab
37+
class MyBrowser(Browser):
38+
"""Custom browser with helper methods."""
39+
40+
tab_class = MyTab # Use our custom Tab class
41+
42+
async def navigate_and_log(self, url: str) -> Tab:
43+
"""Navigate with logging."""
44+
print(f"Navigating to: {url}")
45+
tab = await self.navigate(url)
46+
print(f"Loaded: {url}")
47+
return tab
48+
49+
50+
async def main() -> None:
51+
"""Demonstrate custom classes."""
52+
# Use custom browser
53+
browser = await MyBrowser.start(
54+
chrome_path=os.environ.get("PYPECDP_CHROME_PATH", "chromium"),
55+
headless=True,
56+
)
57+
58+
# All tabs will be MyTab instances
59+
tab = await browser.navigate_and_log("https://example.com")
60+
print(f"Title: {await tab.get_title()}")
61+
62+
# All elements will be MyElem instances
63+
link = await tab.wait_for_elem('a[href*="iana"]')
64+
if link:
65+
# Use custom click_and_wait method
66+
await link.click_and_wait()
67+
68+
await browser.close()
69+
70+
71+
if __name__ == "__main__":
72+
asyncio.run(main())

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pypecdp"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "Async Chrome DevTools Protocol over POSIX pipes."
99
readme = "README.md"
1010
requires-python = ">=3.12"

src/pypecdp/browser.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,15 @@ class Browser:
3030
reader: Stream reader for CDP pipe communication.
3131
writer: Stream writer for CDP pipe communication.
3232
targets: Mapping of target IDs to Tab instances.
33+
34+
Class Attributes:
35+
tab_class: Class to use for creating Tab instances. Override this
36+
in subclasses to use custom Tab implementations.
3337
"""
3438

39+
# Class attribute for customization - subclasses can override
40+
tab_class: type[Tab] = Tab
41+
3542
def __init__(
3643
self,
3744
config: Config | None = None,
@@ -360,7 +367,7 @@ async def _handle_browser_event(
360367
"shared_worker",
361368
"service_worker",
362369
}:
363-
self.targets.setdefault(tid, Tab(self, tid, info))
370+
self.targets.setdefault(tid, self.tab_class(self, tid, info))
364371
if self._auto_attach:
365372
asyncio.ensure_future(
366373
self.send(
@@ -389,7 +396,9 @@ async def _handle_browser_event(
389396
info = event.target_info
390397
tid = info.target_id
391398
if sid and tid:
392-
tab = self.targets.setdefault(tid, Tab(self, tid, info))
399+
tab = self.targets.setdefault(
400+
tid, self.tab_class(self, tid, info)
401+
)
393402
tab.session_id = sid
394403
self._session_to_tab[sid] = tab
395404
if tab.type in {"page", "iframe"}:
@@ -450,7 +459,9 @@ async def create_tab(
450459
new_window=False,
451460
)
452461
)
453-
tab = self.targets.setdefault(target_id, Tab(self, target_id, None))
462+
tab = self.targets.setdefault(
463+
target_id, self.tab_class(self, target_id, None)
464+
)
454465
await asyncio.sleep(0.1)
455466
return tab
456467

src/pypecdp/tab.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,15 @@ class Tab:
2525
target_id: CDP target identifier.
2626
target_info: Optional target metadata.
2727
session_id: CDP session ID for this tab.
28+
29+
Class Attributes:
30+
elem_class: Class to use for creating Elem instances. Override this
31+
in subclasses to use custom Elem implementations.
2832
"""
2933

34+
# Class attribute for customization - subclasses can override
35+
elem_class: type[Elem] = Elem
36+
3037
def __init__(
3138
self,
3239
browser: Browser,
@@ -421,13 +428,13 @@ def _filter(
421428
root: cdp.dom.Node,
422429
) -> Elem | None:
423430
if root.node_id == nid:
424-
return Elem(tab=self, node=root)
431+
return self.elem_class(tab=self, node=root)
425432
node_children = root.children or []
426433
shadow_roots = root.shadow_roots or []
427434
children = node_children + shadow_roots
428435
for child in children:
429436
if child.node_id == nid:
430-
return Elem(tab=self, node=child)
437+
return self.elem_class(tab=self, node=child)
431438
if child.content_document:
432439
elem = _filter(nid, child.content_document)
433440
else:

tests/test_browser.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,52 @@ async def test_context_manager_exit(self) -> None:
270270
await browser.__aexit__(None, None, None)
271271

272272
browser.close.assert_awaited_once()
273+
274+
275+
class TestBrowserCustomization:
276+
"""Test suite for Browser customization via inheritance."""
277+
278+
def test_browser_tab_class_attribute(self) -> None:
279+
"""Test Browser has tab_class attribute."""
280+
from pypecdp.tab import Tab
281+
282+
assert hasattr(Browser, "tab_class")
283+
assert Browser.tab_class == Tab
284+
285+
def test_custom_browser_can_override_tab_class(self) -> None:
286+
"""Test custom Browser can override tab_class."""
287+
from pypecdp.tab import Tab
288+
289+
class CustomTab(Tab):
290+
def custom_method(self) -> str:
291+
return "custom"
292+
293+
class CustomBrowser(Browser):
294+
tab_class = CustomTab
295+
296+
assert CustomBrowser.tab_class == CustomTab
297+
assert CustomBrowser.tab_class != Browser.tab_class
298+
299+
def test_custom_browser_uses_custom_tab_for_targets(self) -> None:
300+
"""Test custom browser creates custom tab instances."""
301+
from pypecdp.tab import Tab
302+
303+
class CustomTab(Tab):
304+
pass
305+
306+
class CustomBrowser(Browser):
307+
tab_class = CustomTab
308+
309+
browser = CustomBrowser()
310+
target_id = cdp.target.TargetID("test-123")
311+
target_info = Mock()
312+
target_info.target_id = target_id
313+
target_info.type_ = "page"
314+
315+
# Manually create tab using the pattern Browser uses
316+
tab = browser.targets.setdefault(
317+
target_id, browser.tab_class(browser, target_id, target_info)
318+
)
319+
320+
assert isinstance(tab, CustomTab)
321+
assert tab.browser == browser

tests/test_tab.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,3 +548,56 @@ def test_parent_property_returns_none_when_parent_not_found(
548548
parent = tab.parent
549549

550550
assert parent is None
551+
552+
553+
class TestTabCustomization:
554+
"""Test suite for Tab customization via inheritance."""
555+
556+
@pytest.fixture
557+
def mock_browser(self) -> Mock:
558+
"""Create a mock Browser."""
559+
return Mock()
560+
561+
def test_tab_elem_class_attribute(self) -> None:
562+
"""Test Tab has elem_class attribute."""
563+
assert hasattr(Tab, "elem_class")
564+
assert Tab.elem_class == Elem
565+
566+
def test_custom_tab_can_override_elem_class(self) -> None:
567+
"""Test custom Tab can override elem_class."""
568+
569+
class CustomElem(Elem):
570+
def custom_method(self) -> str:
571+
return "custom"
572+
573+
class CustomTab(Tab):
574+
elem_class = CustomElem
575+
576+
assert CustomTab.elem_class == CustomElem
577+
assert CustomTab.elem_class != Tab.elem_class
578+
579+
def test_custom_tab_creates_custom_elem(self, mock_browser: Mock) -> None:
580+
"""Test custom tab creates custom elem instances."""
581+
582+
class CustomElem(Elem):
583+
pass
584+
585+
class CustomTab(Tab):
586+
elem_class = CustomElem
587+
588+
target_id = cdp.target.TargetID("test-123")
589+
tab = CustomTab(mock_browser, target_id)
590+
591+
# Setup doc
592+
root_node = Mock()
593+
root_node.node_id = 1
594+
root_node.backend_node_id = 10
595+
root_node.children = []
596+
root_node.shadow_roots = []
597+
tab.doc = root_node
598+
599+
# elem() should create CustomElem instance
600+
elem = tab.elem(cdp.dom.NodeId(1))
601+
602+
assert isinstance(elem, CustomElem)
603+
assert elem.tab == tab

0 commit comments

Comments
 (0)