Skip to content

Commit 1b5b25f

Browse files
authored
Merge pull request #427 from KrAsH-CoD3/fix/resource-leaks-and-deprecations
Fix: resource leaks, incorrect behavior, and deprecation warnings
2 parents 17bdbc2 + e8f2201 commit 1b5b25f

16 files changed

Lines changed: 291 additions & 66 deletions

File tree

examples/cloudflare_bypass.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ async def example_with_context_manager():
1111
"""
1212
browser = Chrome()
1313
await browser.start()
14-
page = await browser.get_page()
14+
page = await browser.new_tab()
1515

1616
print('Using context manager approach...')
1717
async with page.expect_and_bypass_cloudflare_captcha():
@@ -32,7 +32,7 @@ async def example_with_enable_disable():
3232
"""
3333
browser = Chrome()
3434
await browser.start()
35-
page = await browser.get_page()
35+
page = await browser.new_tab()
3636

3737
print('Using enable/disable approach...')
3838

pydoll/browser/chromium/base.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,11 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
136136
if self._backup_preferences_dir:
137137
logger.debug(f'Restoring backup preferences directory: {self._backup_preferences_dir}')
138138
user_data_dir = self._get_user_data_dir()
139-
shutil.copy2(
140-
self._backup_preferences_dir,
141-
os.path.join(user_data_dir, 'Default', 'Preferences'),
142-
)
139+
if user_data_dir:
140+
shutil.copy2(
141+
self._backup_preferences_dir,
142+
os.path.join(user_data_dir, 'Default', 'Preferences'),
143+
)
143144
if await self._is_browser_running(timeout=2):
144145
await self.stop()
145146

@@ -165,6 +166,8 @@ async def connect(self, ws_address: str) -> Tab:
165166
await self._setup_ws_address(ws_address)
166167
tabs = await self.get_opened_tabs()
167168
logger.info(f'Connected. Tabs available: {len(tabs)}')
169+
if not tabs:
170+
raise NoValidTabFound('No tabs available on remote browser')
168171
return tabs[0]
169172

170173
async def start(self, headless: bool = False) -> Tab:
@@ -358,11 +361,12 @@ async def get_opened_tabs(self) -> list[Tab]:
358361
for target in targets
359362
if target['type'] == 'page' and 'extension' not in target['url']
360363
]
361-
all_target_ids = [target['targetId'] for target in valid_tab_targets]
364+
all_target_ids = {target['targetId'] for target in valid_tab_targets}
365+
# Prune stale entries: close tabs whose IDs no longer appear in targets.
366+
for stale_id in set(self._tabs_opened.keys()) - all_target_ids:
367+
del self._tabs_opened[stale_id]
362368
existing_target_ids = list(self._tabs_opened.keys())
363-
remaining_target_ids = [
364-
target_id for target_id in all_target_ids if target_id not in existing_target_ids
365-
]
369+
remaining_target_ids = list(all_target_ids - set(existing_target_ids))
366370
existing_tabs = [self._tabs_opened[target_id] for target_id in existing_target_ids]
367371
new_tabs = []
368372
for target_id in reversed(remaining_target_ids):

pydoll/browser/tab.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -624,13 +624,13 @@ async def find_shadow_roots(self, deep: bool = False, timeout: float = 0) -> lis
624624
if not timeout:
625625
return await self._collect_all_shadow_roots(deep)
626626

627-
start_time = asyncio.get_event_loop().time()
627+
start_time = asyncio.get_running_loop().time()
628628
while True:
629629
shadow_roots = await self._collect_all_shadow_roots(deep)
630630
if shadow_roots:
631631
return shadow_roots
632632

633-
if asyncio.get_event_loop().time() - start_time > timeout:
633+
if asyncio.get_running_loop().time() - start_time > timeout:
634634
raise WaitElementTimeout(
635635
f'Timed out after {timeout}s waiting for shadow roots in page'
636636
)
@@ -701,24 +701,27 @@ async def _resolve_shadow_host(self, host_backend_id: int | None) -> WebElement
701701
async def _collect_oopif_shadow_roots(self) -> list[ShadowRoot]:
702702
"""Discover shadow roots inside cross-origin iframes (OOPIFs)."""
703703
browser_handler = ConnectionHandler(connection_port=self._connection_port)
704-
targets_response: GetTargetsResponse = await browser_handler.execute_command(
705-
TargetCommands.get_targets()
706-
)
704+
try:
705+
targets_response: GetTargetsResponse = await browser_handler.execute_command(
706+
TargetCommands.get_targets()
707+
)
707708

708-
target_infos = targets_response.get('result', {}).get('targetInfos', [])
709-
iframe_targets = [t for t in target_infos if t.get('type') == 'iframe']
709+
target_infos = targets_response.get('result', {}).get('targetInfos', [])
710+
iframe_targets = [t for t in target_infos if t.get('type') == 'iframe']
710711

711-
if not iframe_targets:
712-
logger.debug('No OOPIF targets found')
713-
return []
712+
if not iframe_targets:
713+
logger.debug('No OOPIF targets found')
714+
return []
714715

715-
shadow_roots: list[ShadowRoot] = []
716-
for target in iframe_targets:
717-
roots = await self._collect_shadow_roots_from_oopif_target(target, browser_handler)
718-
shadow_roots.extend(roots)
716+
shadow_roots: list[ShadowRoot] = []
717+
for target in iframe_targets:
718+
roots = await self._collect_shadow_roots_from_oopif_target(target, browser_handler)
719+
shadow_roots.extend(roots)
719720

720-
logger.debug(f'Found {len(shadow_roots)} shadow roots in OOPIFs')
721-
return shadow_roots
721+
logger.debug(f'Found {len(shadow_roots)} shadow roots in OOPIFs')
722+
return shadow_roots
723+
finally:
724+
await browser_handler.close()
722725

723726
async def _collect_shadow_roots_from_oopif_target(
724727
self,
@@ -1681,7 +1684,7 @@ async def expect_download(
16811684
_page_events_was_enabled = False
16821685
await self.enable_page_events()
16831686

1684-
loop = asyncio.get_event_loop()
1687+
loop = asyncio.get_running_loop()
16851688
will_begin: asyncio.Future[bool] = loop.create_future()
16861689
done: asyncio.Future[bool] = loop.create_future()
16871690
state: dict[str, Any] = {

pydoll/connection/connection_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,10 @@ async def execute_command(
117117
f'Sending command: id={command.get("id")}, method={command.get("method")}, '
118118
f'timeout={timeout}s'
119119
)
120-
start = asyncio.get_event_loop().time()
120+
start = asyncio.get_running_loop().time()
121121
await ws.send(command_str)
122122
response: str = await asyncio.wait_for(future, timeout)
123-
elapsed = asyncio.get_event_loop().time() - start
123+
elapsed = asyncio.get_running_loop().time() - start
124124
logger.debug(f'Command completed: id={command.get("id")} in {elapsed:.3f}s')
125125
return json.loads(response)
126126
except asyncio.TimeoutError:

pydoll/constants.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,12 +408,34 @@ class Scripts:
408408
409409
// Standard input/textarea
410410
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
411-
const start = el.selectionStart || el.value.length;
412-
const end = el.selectionEnd || el.value.length;
411+
el.focus();
412+
let start, end;
413+
try {
414+
start = el.selectionStart;
415+
end = el.selectionEnd;
416+
} catch (e) {
417+
// Unsupported input type (number, email, range, etc.)
418+
el.value = text ? el.value + text : '';
419+
el.dispatchEvent(new Event('input', { bubbles: true }));
420+
el.dispatchEvent(new Event('change', { bubbles: true }));
421+
return true;
422+
}
423+
const hasSelection = start !== end;
424+
// When inserting empty text with no selection, select all first
425+
// so the field is cleared (matches user expectation for insertText('')).
426+
if (!hasSelection && text === '') {
427+
el.select();
428+
start = 0;
429+
end = el.value.length;
430+
}
431+
start = start ?? el.value.length;
432+
end = end ?? el.value.length;
413433
const before = el.value.substring(0, start);
414434
const after = el.value.substring(end);
415435
el.value = before + text + after;
416-
el.selectionStart = el.selectionEnd = start + text.length;
436+
try {
437+
el.selectionStart = el.selectionEnd = start + text.length;
438+
} catch (e) {}
417439
el.dispatchEvent(new Event('input', { bubbles: true }));
418440
el.dispatchEvent(new Event('change', { bubbles: true }));
419441
return true;

pydoll/decorators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def retry(
8888
exponential_backoff (bool): If True, increase the delay exponentially
8989
9090
Usage:
91-
@retry_on_exception(
91+
@retry(
9292
max_retries=3,
9393
exceptions=[ValueError, TypeError],
9494
delay=1

pydoll/elements/mixins/find_elements_mixin.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ async def find_or_wait_element(
334334
return await self._find_across_iframes(segments, timeout, find_all, raise_exc)
335335

336336
find_method = self._find_element if not find_all else self._find_elements
337-
start_time = asyncio.get_event_loop().time()
337+
start_time = asyncio.get_running_loop().time()
338338

339339
if not timeout:
340340
logger.debug('No timeout specified; performing single attempt')
@@ -349,7 +349,7 @@ async def find_or_wait_element(
349349
logger.debug('Found 1 element within timeout window')
350350
return element
351351

352-
if asyncio.get_event_loop().time() - start_time > timeout:
352+
if asyncio.get_running_loop().time() - start_time > timeout:
353353
if raise_exc:
354354
logger.error('Timeout while waiting for elements')
355355
raise WaitElementTimeout(
@@ -387,7 +387,7 @@ async def _find_across_iframes(
387387
ElementNotFound: If ``timeout=0``, nothing found, and ``raise_exc=True``.
388388
WaitElementTimeout: If timeout expires and ``raise_exc=True``.
389389
"""
390-
start_time = asyncio.get_event_loop().time()
390+
start_time = asyncio.get_running_loop().time()
391391
selector_repr = ' -> '.join(seg for _, seg in segments)
392392

393393
while True:
@@ -400,7 +400,7 @@ async def _find_across_iframes(
400400
raise ElementNotFound(f'Element not found across iframes: {selector_repr}')
401401
return [] if find_all else None
402402

403-
if asyncio.get_event_loop().time() - start_time > timeout:
403+
if asyncio.get_running_loop().time() - start_time > timeout:
404404
if raise_exc:
405405
raise WaitElementTimeout(
406406
f'Timed out after {timeout}s waiting for element '

pydoll/elements/web_element.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pydoll.elements.mixins import FindElementsMixin
2424
from pydoll.elements.shadow_root import ShadowRoot
2525
from pydoll.exceptions import (
26+
CommandExecutionTimeout,
2627
ElementNotAFileInput,
2728
ElementNotFound,
2829
ElementNotInteractable,
@@ -32,6 +33,7 @@
3233
MissingScreenshotPath,
3334
ShadowRootNotFound,
3435
WaitElementTimeout,
36+
WebSocketConnectionClosed,
3537
)
3638
from pydoll.interactions.iframe import IFrameContext, IFrameContextResolver
3739
from pydoll.interactions.keyboard import Keyboard
@@ -238,7 +240,10 @@ async def iframe_context(self) -> Optional[IFrameContext]:
238240
return None
239241

240242
resolver = self._get_iframe_resolver()
243+
old_context = self._iframe_context
241244
self._iframe_context = await resolver.resolve()
245+
if old_context is not None and old_context is not self._iframe_context:
246+
await old_context.close()
242247
self._apply_routing_from_context()
243248
return self._iframe_context
244249

@@ -299,14 +304,14 @@ async def get_shadow_root(self, timeout: float = 0) -> ShadowRoot:
299304
if not timeout:
300305
return await self._get_shadow_root()
301306

302-
start_time = asyncio.get_event_loop().time()
307+
start_time = asyncio.get_running_loop().time()
303308
while True:
304309
try:
305310
return await self._get_shadow_root()
306311
except ShadowRootNotFound:
307312
pass
308313

309-
if asyncio.get_event_loop().time() - start_time > timeout:
314+
if asyncio.get_running_loop().time() - start_time > timeout:
310315
raise WaitElementTimeout(
311316
f'Timed out after {timeout}s waiting for shadow root on element'
312317
)
@@ -512,7 +517,7 @@ async def wait_until(
512517
f'Waiting for element: visible={is_visible}, '
513518
f'interactable={is_interactable}, timeout={timeout}s'
514519
)
515-
loop = asyncio.get_event_loop()
520+
loop = asyncio.get_running_loop()
516521
start_time = loop.time()
517522
while True:
518523
results = await asyncio.gather(*(check() for check in checks))
@@ -683,9 +688,22 @@ async def insert_text(self, text: str):
683688
# Keep cached attributes coherent for common cases (e.g., input value)
684689
# This avoids forcing a DOM round-trip for simple assertions.
685690
if self._attributes.get('tag_name', '').lower() in {'input', 'textarea'}:
686-
# When inserting into an empty field, resulting value equals inserted text.
687-
# For complex cases (non-empty with caret), tests usually check non-empty.
688-
self._attributes['value'] = text
691+
# Re-read the actual DOM value to keep cache consistent.
692+
# insertText appends at cursor, so the result may differ from
693+
# the inserted text when the field already had content.
694+
try:
695+
live_result = await self.execute_script('return this.value', return_by_value=True)
696+
self._attributes['value'] = (
697+
live_result.get('result', {}).get('result', {}).get('value', text)
698+
)
699+
except (
700+
KeyError,
701+
TypeError,
702+
AttributeError,
703+
CommandExecutionTimeout,
704+
WebSocketConnectionClosed,
705+
):
706+
self._attributes['value'] = text
689707

690708
async def set_input_files(self, files: str | Path | list[str | Path]):
691709
"""

pydoll/interactions/iframe.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ class IFrameContext:
3131
session_handler: Optional[ConnectionHandler] = None
3232
session_id: Optional[str] = None
3333

34+
async def close(self) -> None:
35+
"""Close the session handler if one was created for this context."""
36+
if self.session_handler is not None:
37+
await self.session_handler.close()
38+
self.session_handler = None
39+
3440

3541
class IFrameContextResolver:
3642
"""Resolves iframe context for WebElement."""
@@ -244,6 +250,9 @@ async def _resolve_oopif_if_needed(
244250
resolved_url or current_document_url,
245251
)
246252

253+
if session_handler:
254+
await session_handler.close()
255+
247256
return (
248257
None,
249258
None,
@@ -274,6 +283,27 @@ async def _resolve_oopif_by_parent(
274283
browser_handler = ConnectionHandler(
275284
connection_port=self._element._connection_handler._connection_port
276285
)
286+
try:
287+
return await self._try_resolve_oopif(
288+
browser_handler,
289+
content_frame_id,
290+
backend_node_id,
291+
base_handler,
292+
base_session_id,
293+
)
294+
except Exception:
295+
await browser_handler.close()
296+
raise
297+
298+
async def _try_resolve_oopif(
299+
self,
300+
browser_handler: ConnectionHandler,
301+
content_frame_id: str,
302+
backend_node_id: Optional[int],
303+
base_handler: Optional[ConnectionHandler],
304+
base_session_id: Optional[str],
305+
) -> tuple[Optional[ConnectionHandler], Optional[str], Optional[str], Optional[str]]:
306+
"""Core logic for OOPIF resolution. Caller owns the handler lifecycle."""
277307
targets_response: GetTargetsResponse = await browser_handler.execute_command(
278308
TargetCommands.get_targets()
279309
)
@@ -370,6 +400,7 @@ async def _resolve_oopif_by_parent(
370400
if child_frame_id:
371401
return browser_handler, attached_session_id, child_frame_id, None
372402

403+
await browser_handler.close()
373404
return None, None, None, None
374405

375406
@staticmethod

pydoll/utils/bundle.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,27 @@ def rewrite_html_urls(
166166
html: str,
167167
asset_map: dict[str, tuple[str, bytes, str, ResourceType]],
168168
) -> str:
169-
"""Rewrite asset URLs in HTML to point to local assets/ directory."""
170-
for url, (filename, data, mime, rtype) in asset_map.items():
169+
"""Rewrite asset URLs in HTML to point to local assets/ directory.
170+
171+
Handles both absolute URLs (https://...) and root-relative paths (/style.css)
172+
by deriving the path component from each absolute URL and replacing it too.
173+
"""
174+
replacements: dict[str, str] = {}
175+
for url, (filename, data, mime, rtype) in list(asset_map.items()):
171176
if rtype == ResourceType.STYLESHEET:
172177
css_text = data.decode('utf-8', errors='replace')
173178
rewritten_css = rewrite_css_urls(css_text, url, asset_map)
174179
asset_map[url] = (filename, rewritten_css.encode('utf-8'), mime, rtype)
175-
html = html.replace(url, f'assets/{filename}')
180+
181+
replacements[url] = f'assets/{filename}'
182+
parsed = urlparse(url)
183+
if parsed.path and parsed.path != '/':
184+
replacements[parsed.path] = f'assets/{filename}'
185+
186+
# Apply longest-first to prevent shorter paths from corrupting longer ones.
187+
for old_str in sorted(replacements, key=len, reverse=True):
188+
html = html.replace(old_str, replacements[old_str])
189+
176190
return html
177191

178192

0 commit comments

Comments
 (0)