Skip to content

Commit b16d837

Browse files
authored
Merge pull request #201 from autoscrape-labs/feat/get-parent-element
feat: add method to retrieve parent element and its attributes
2 parents c8e86cf + dc7e20c commit b16d837

File tree

4 files changed

+167
-12
lines changed

4 files changed

+167
-12
lines changed

pydoll/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ class Scripts:
119119
}
120120
"""
121121

122+
GET_PARENT_NODE = """
123+
function() {
124+
return this.parentElement;
125+
}
126+
"""
127+
122128

123129
class Key(tuple[str, int], Enum):
124130
BACKSPACE = ('Backspace', 8)

pydoll/elements/mixins/find_elements_mixin.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,13 @@ async def _find_element(
205205
EvaluateResponse, CallFunctionOnResponse
206206
] = await self._execute_command(command)
207207

208-
if not response_for_command.get('result', {}).get('result', {}).get('objectId'):
208+
if not self._has_object_id_key(response_for_command):
209209
if raise_exc:
210210
raise ElementNotFound()
211211
return None
212212

213213
object_id = response_for_command['result']['result']['objectId']
214-
node_description = await self._describe_node(object_id=object_id)
215-
attributes = node_description.get('attributes', [])
216-
217-
tag_name = node_description.get('nodeName', '').lower()
218-
attributes.extend(['tag_name', tag_name])
219-
214+
attributes = await self._get_object_attributes(object_id=object_id)
220215
return create_web_element(object_id, self._connection_handler, by, value, attributes) # type: ignore
221216

222217
async def _find_elements(
@@ -280,6 +275,16 @@ async def _find_elements(
280275
)
281276
return elements
282277

278+
async def _get_object_attributes(self, object_id: str) -> list[str]:
279+
"""
280+
Get attributes of a DOM node.
281+
"""
282+
node_description = await self._describe_node(object_id=object_id)
283+
attributes = node_description.get('attributes', [])
284+
tag_name = node_description.get('nodeName', '').lower()
285+
attributes.extend(['tag_name', tag_name])
286+
return attributes
287+
283288
def _get_by_and_value( # noqa: PLR0913, PLR0917
284289
self,
285290
by_map: dict[str, By],
@@ -491,3 +496,10 @@ def _ensure_relative_xpath(xpath: str) -> str:
491496
Converts absolute XPath to relative for context-based searches.
492497
"""
493498
return f'.{xpath}' if not xpath.startswith('.') else xpath
499+
500+
@staticmethod
501+
def _has_object_id_key(response: Union[EvaluateResponse, CallFunctionOnResponse]) -> bool:
502+
"""
503+
Check if response has objectId key.
504+
"""
505+
return bool(response.get('result', {}).get('result', {}).get('objectId'))

pydoll/elements/web_element.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pydoll.elements.mixins import FindElementsMixin
2424
from pydoll.exceptions import (
2525
ElementNotAFileInput,
26+
ElementNotFound,
2627
ElementNotInteractable,
2728
ElementNotVisible,
2829
)
@@ -130,6 +131,16 @@ async def get_bounds_using_js(self) -> dict[str, int]:
130131
response = await self._execute_script(Scripts.BOUNDS, return_by_value=True)
131132
return json.loads(response['result']['result']['value'])
132133

134+
async def get_parent_element(self) -> 'WebElement':
135+
"""Element's parent element."""
136+
result = await self._execute_script(Scripts.GET_PARENT_NODE)
137+
if not self._has_object_id_key(result):
138+
raise ElementNotFound(f'Parent element not found for element: {self}')
139+
140+
object_id = result['result']['result']['objectId']
141+
attributes = await self._get_object_attributes(object_id=object_id)
142+
return WebElement(object_id, self._connection_handler, attributes_list=attributes)
143+
133144
async def take_screenshot(self, path: str, quality: int = 100):
134145
"""
135146
Capture screenshot of this element only.
@@ -345,11 +356,13 @@ async def press_keyboard_key(
345356

346357
async def _click_option_tag(self):
347358
"""Specialized method for clicking <option> elements in dropdowns."""
348-
await self._execute_command(RuntimeCommands.call_function_on(
349-
object_id=self._object_id,
350-
function_declaration=Scripts.CLICK_OPTION_TAG,
351-
return_by_value=True,
352-
))
359+
await self._execute_command(
360+
RuntimeCommands.call_function_on(
361+
object_id=self._object_id,
362+
function_declaration=Scripts.CLICK_OPTION_TAG,
363+
return_by_value=True,
364+
)
365+
)
353366

354367
async def _is_element_visible(self):
355368
"""Check if element is visible using comprehensive JavaScript visibility test."""

tests/test_web_element.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,130 @@ async def test_type_text_default_interval(self, input_element):
314314
mock_sleep.assert_called_with(0.1) # Default interval
315315
assert input_element.click.call_count == 1
316316

317+
@pytest.mark.asyncio
318+
async def test_get_parent_element_success(self, web_element):
319+
"""Test successful parent element retrieval."""
320+
script_response = {
321+
'result': {
322+
'result': {
323+
'objectId': 'parent-object-id'
324+
}
325+
}
326+
}
327+
describe_response = {
328+
'result': {
329+
'node': {
330+
'nodeName': 'DIV',
331+
'attributes': ['id', 'parent-container', 'class', 'container']
332+
}
333+
}
334+
}
335+
web_element._connection_handler.execute_command.side_effect = [
336+
script_response, # Script execution
337+
describe_response, # Describe node
338+
]
339+
340+
parent_element = await web_element.get_parent_element()
341+
342+
assert isinstance(parent_element, WebElement)
343+
assert parent_element._object_id == 'parent-object-id'
344+
assert parent_element._attributes == {
345+
'id': 'parent-container',
346+
'class_name': 'container',
347+
'tag_name': 'div'
348+
}
349+
web_element._connection_handler.execute_command.assert_called()
350+
351+
@pytest.mark.asyncio
352+
async def test_get_parent_element_not_found(self, web_element):
353+
"""Test parent element not found raises ElementNotFound."""
354+
script_response = {
355+
'result': {
356+
'result': {} # No objectId
357+
}
358+
}
359+
360+
web_element._connection_handler.execute_command.return_value = script_response
361+
362+
with pytest.raises(ElementNotFound, match='Parent element not found for element:'):
363+
await web_element.get_parent_element()
364+
365+
@pytest.mark.asyncio
366+
async def test_get_parent_element_with_complex_attributes(self, web_element):
367+
"""Test parent element with complex attribute list."""
368+
script_response = {
369+
'result': {
370+
'result': {
371+
'objectId': 'complex-parent-id'
372+
}
373+
}
374+
}
375+
376+
describe_response = {
377+
'result': {
378+
'node': {
379+
'nodeName': 'SECTION',
380+
'attributes': [
381+
'id', 'main-section',
382+
'class', 'content-wrapper',
383+
'data-testid', 'parent-element',
384+
'aria-label', 'Main content area'
385+
]
386+
}
387+
}
388+
}
389+
390+
web_element._connection_handler.execute_command.side_effect = [
391+
script_response,
392+
describe_response,
393+
]
394+
395+
parent_element = await web_element.get_parent_element()
396+
397+
assert isinstance(parent_element, WebElement)
398+
assert parent_element._object_id == 'complex-parent-id'
399+
assert parent_element._attributes == {
400+
'id': 'main-section',
401+
'class_name': 'content-wrapper',
402+
'data-testid': 'parent-element',
403+
'aria-label': 'Main content area',
404+
'tag_name': 'section'
405+
}
406+
407+
@pytest.mark.asyncio
408+
async def test_get_parent_element_root_element(self, web_element):
409+
"""Test getting parent of root element (should return document body)."""
410+
script_response = {
411+
'result': {
412+
'result': {
413+
'objectId': 'body-object-id'
414+
}
415+
}
416+
}
417+
418+
describe_response = {
419+
'result': {
420+
'node': {
421+
'nodeName': 'BODY',
422+
'attributes': ['class', 'page-body']
423+
}
424+
}
425+
}
426+
427+
web_element._connection_handler.execute_command.side_effect = [
428+
script_response,
429+
describe_response,
430+
]
431+
432+
parent_element = await web_element.get_parent_element()
433+
434+
assert isinstance(parent_element, WebElement)
435+
assert parent_element._object_id == 'body-object-id'
436+
assert parent_element._attributes == {
437+
'class_name': 'page-body',
438+
'tag_name': 'body'
439+
}
440+
317441

318442
class TestWebElementKeyboardInteraction:
319443
"""Test keyboard interaction methods."""

0 commit comments

Comments
 (0)