Skip to content

Commit ffad57a

Browse files
author
Milan Falešník
committed
Speedup by checking the page only when necessary; Logging update.
1 parent 42015e8 commit ffad57a

4 files changed

Lines changed: 131 additions & 37 deletions

File tree

src/widgetastic/browser.py

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from .exceptions import (
2222
NoSuchElementException, UnexpectedAlertPresentException, MoveTargetOutOfBoundsException,
2323
StaleElementReferenceException, NoAlertPresentException, LocatorNotImplemented)
24-
from .log import create_widget_logger, null_logger
24+
from .log import create_widget_logger, logged, null_logger
25+
from .utils import repeat_once_on_exceptions
2526
from .xpath import normalize_space
2627

2728

@@ -256,6 +257,7 @@ def elements(
256257

257258
return result
258259

260+
@repeat_once_on_exceptions(NoSuchElementException, check_safe=True)
259261
def element(self, locator, *args, **kwargs):
260262
"""Returns one :py:class:`selenium.webdriver.remote.webelement.WebElement`
261263
@@ -267,6 +269,8 @@ def element(self, locator, *args, **kwargs):
267269
Raises:
268270
:py:class:`selenium.common.exceptions.NoSuchElementException`
269271
"""
272+
if 'check_safe' not in kwargs:
273+
kwargs['check_safe'] = False
270274
try:
271275
vcheck = self._locator_force_visibility_check(locator)
272276
if vcheck is not None:
@@ -281,18 +285,18 @@ def element(self, locator, *args, **kwargs):
281285
else:
282286
return elements[0]
283287
except IndexError:
284-
raise NoSuchElementException('Could not find an element {}'.format(repr(locator)))
288+
raise NoSuchElementException('Could not find an element {!r}'.format(locator))
285289

286290
def perform_click(self):
287291
"""Clicks the left mouse button at the current mouse position."""
288292
ActionChains(self.selenium).click().perform()
289293

294+
@logged(log_args=True, only_after=True, debug_only=True, log_full_exception=False)
290295
def click(self, locator, *args, **kwargs):
291296
"""Clicks at a specific element using two separate events (mouse move, mouse click).
292297
293298
Args: See :py:meth:`elements`
294299
"""
295-
self.logger.debug('click: %r', locator)
296300
ignore_ajax = kwargs.pop('ignore_ajax', False)
297301
el = self.move_to_element(locator, *args, **kwargs)
298302
self.plugin.before_click(el)
@@ -308,12 +312,12 @@ def click(self, locator, *args, **kwargs):
308312
except (StaleElementReferenceException, UnexpectedAlertPresentException):
309313
pass
310314

315+
@logged(log_args=True, only_after=True, debug_only=True, log_full_exception=False)
311316
def raw_click(self, locator, *args, **kwargs):
312317
"""Clicks at a specific element using the direct event.
313318
314319
Args: See :py:meth:`elements`
315320
"""
316-
self.logger.debug('raw_click: %r', locator)
317321
ignore_ajax = kwargs.pop('ignore_ajax', False)
318322
el = self.element(locator, *args, **kwargs)
319323
self.plugin.before_click(el)
@@ -356,6 +360,10 @@ def is_displayed(self, locator, *args, **kwargs):
356360
# Just in case
357361
return False
358362

363+
@logged(log_args=True, only_after=True, debug_only=True, log_full_exception=False)
364+
@repeat_once_on_exceptions(
365+
StaleElementReferenceException, MoveTargetOutOfBoundsException,
366+
check_safe=True)
359367
def move_to_element(self, locator, *args, **kwargs):
360368
"""Moves the mouse cursor to the middle of the element represented by the locator.
361369
@@ -364,23 +372,31 @@ def move_to_element(self, locator, *args, **kwargs):
364372
365373
Args: See :py:meth:`elements`
366374
375+
Keywords:
376+
workaround: Default True, tells whether it can or can not perform the JS workaround.
377+
367378
Returns:
368379
:py:class:`selenium.webdriver.remote.webelement.WebElement`
369380
"""
370-
self.logger.debug('move_to_element: %r', locator)
381+
if 'check_safe' not in kwargs:
382+
kwargs['check_safe'] = False
383+
workaround = kwargs.pop('workaround', True)
371384
el = self.element(locator, *args, **kwargs)
372385
if el.tag_name == "option":
373386
# Instead of option, let's move on its parent <select> if possible
374-
parent = self.element("..", parent=el)
387+
parent = self.element("..", parent=el, check_safe=False)
375388
if parent.tag_name == "select":
376-
self.move_to_element(parent)
389+
self.move_to_element(parent, workaround=workaround)
377390
return el
378391
move_to = ActionChains(self.selenium).move_to_element(el)
379392
try:
380393
move_to.perform()
381394
except MoveTargetOutOfBoundsException:
395+
if not workaround:
396+
# No workaround, reraise
397+
raise
382398
# ff workaround
383-
self.execute_script("arguments[0].scrollIntoView();", el)
399+
self.execute_script("arguments[0].scrollIntoView();", el, silent=True)
384400
try:
385401
move_to.perform()
386402
except MoveTargetOutOfBoundsException: # This has become desperate now.
@@ -399,20 +415,24 @@ def drag_and_drop(self, source, target):
399415
self.logger.debug('drag_and_drop %r to %r', source, target)
400416
ActionChains(self.selenium).drag_and_drop(self.element(source), self.element(target))
401417

418+
@logged(log_args=True, only_after=True, debug_only=True, log_full_exception=False)
402419
def move_by_offset(self, x, y):
403-
self.logger.debug('move_by_offset X:%r Y:%r', x, y)
404-
ActionChains(self.selenium).move_by_offset(x, y)
420+
"""Moves mouse pointer by given values."""
421+
ActionChains(self.selenium).move_by_offset(x, y).perform()
422+
self.plugin.ensure_page_safe()
405423

406424
def execute_script(self, script, *args, **kwargs):
407425
"""Executes a script."""
408426
if not kwargs.pop('silent', False):
409-
self.logger.debug('execute_script: %r', script)
427+
self.logger.debug('execute_script(%r)', script)
410428
return self.selenium.execute_script(dedent(script), *args, **kwargs)
411429

412430
def refresh(self):
413431
"""Triggers a page refresh."""
414432
return self.selenium.refresh()
415433

434+
@logged(
435+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
416436
def classes(self, locator, *args, **kwargs):
417437
"""Return a list of classes attached to the element.
418438
@@ -421,11 +441,11 @@ def classes(self, locator, *args, **kwargs):
421441
Returns:
422442
A :py:class:`set` of strings with classes.
423443
"""
424-
result = set(self.execute_script(
444+
return set(self.execute_script(
425445
"return arguments[0].classList;", self.element(locator, *args, **kwargs), silent=True))
426-
self.logger.debug('css classes for %r => %r', locator, result)
427-
return result
428446

447+
@logged(
448+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
429449
def tag(self, *args, **kwargs):
430450
"""Returns the tag name of the element represented by the locator passed.
431451
@@ -436,7 +456,9 @@ def tag(self, *args, **kwargs):
436456
"""
437457
return self.element(*args, **kwargs).tag_name
438458

439-
def text(self, *args, **kwargs):
459+
@logged(
460+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
461+
def text(self, locator, *args, **kwargs):
440462
"""Returns the text inside the element represented by the locator passed.
441463
442464
The returned text is normalized with :py:func:`widgetastic.xpath.normalize_space` as defined
@@ -448,45 +470,56 @@ def text(self, *args, **kwargs):
448470
:py:class:`str` with the text
449471
"""
450472
try:
451-
text = self.element(*args, **kwargs).text
473+
text = self.move_to_element(locator, *args, **dict(kwargs, workaround=False)).text
452474
except MoveTargetOutOfBoundsException:
453475
text = ''
454476

455477
if not text:
456478
# It is probably invisible
457479
text = self.execute_script(
458480
'return arguments[0].textContent || arguments[0].innerText;',
459-
self.element(*args, **kwargs))
481+
self.element(locator, *args, **kwargs),
482+
silent=True)
460483
if text is None:
461484
text = ''
462485

463486
return normalize_space(text)
464487

465-
def get_attribute(self, attr, *args, **kwargs):
466-
return self.element(*args, **kwargs).get_attribute(attr)
488+
@logged(
489+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
490+
def get_attribute(self, attr, locator, *args, **kwargs):
491+
"""Get attribute value from an element."""
492+
return self.element(locator, *args, **kwargs).get_attribute(attr)
467493

468-
def set_attribute(self, attr, value, *args, **kwargs):
494+
@logged(log_args=True, only_after=True, debug_only=True, log_full_exception=False)
495+
def set_attribute(self, attr, value, locator, *args, **kwargs):
469496
return self.execute_script(
470497
"arguments[0].setAttribute(arguments[1], arguments[2]);",
471-
self.element(*args, **kwargs), attr, value)
498+
self.element(locator, *args, **kwargs), attr, value)
472499

473-
def size_of(self, *args, **kwargs):
500+
@logged(
501+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
502+
def size_of(self, locator, *args, **kwargs):
474503
"""Returns element's size as a tuple of width/height."""
475-
size = self.element(*args, **kwargs).size
504+
size = self.element(locator, *args, **kwargs).size
476505
return Size(size['width'], size['height'])
477506

507+
@logged(log_args=True, only_after=True, debug_only=True, log_full_exception=False)
478508
def clear(self, locator, *args, **kwargs):
479509
"""Clears a text input with given locator."""
480-
self.logger.debug('clear: %r', locator)
481510
el = self.element(locator, *args, **kwargs)
482511
self.plugin.before_keyboard_input(el, None)
483512
result = el.clear()
513+
self.plugin.ensure_page_safe()
484514
self.plugin.after_keyboard_input(el, None)
485515
return result
486516

487-
def is_selected(self, *args, **kwargs):
488-
return self.element(*args, **kwargs).is_selected()
517+
@logged(
518+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
519+
def is_selected(self, locator, *args, **kwargs):
520+
return self.element(locator, *args, **kwargs).is_selected()
489521

522+
@logged(log_args=True, debug_only=True, log_full_exception=False)
490523
def send_keys(self, text, locator, *args, **kwargs):
491524
"""Sends keys to the element. Detects the file inputs automatically.
492525
@@ -508,8 +541,9 @@ def send_keys(self, text, locator, *args, **kwargs):
508541
self.selenium.file_detector = LocalFileDetector()
509542
el = self.move_to_element(locator, *args, **kwargs)
510543
self.plugin.before_keyboard_input(el, text)
511-
self.logger.debug('send_keys %r to %r', text, locator)
512544
result = el.send_keys(text)
545+
# Ensure the page input was safe
546+
self.plugin.ensure_page_safe()
513547
if Keys.ENTER not in text:
514548
try:
515549
self.plugin.after_keyboard_input(el, text)
@@ -537,6 +571,8 @@ def get_alert(self):
537571
return self.selenium.switch_to_alert()
538572

539573
@property
574+
@logged(
575+
log_args=True, log_result=True, only_after=True, debug_only=True, log_full_exception=False)
540576
def alert_present(self):
541577
"""Checks whether there is any alert present.
542578
@@ -551,6 +587,7 @@ def alert_present(self):
551587
else:
552588
return True
553589

590+
@logged(log_args=True, log_full_exception=False)
554591
def dismiss_any_alerts(self):
555592
"""Loops until there are no further alerts present to dismiss.
556593
@@ -564,6 +601,7 @@ def dismiss_any_alerts(self):
564601
except NoAlertPresentException: # Just in case. alert_present should be reliable
565602
pass
566603

604+
@logged(log_args=True, log_result=True, log_full_exception=False)
567605
def handle_alert(self, cancel=False, wait=30.0, squash=False, prompt=None, check_present=False):
568606
"""Handles an alert popup.
569607

src/widgetastic/log.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ def create_item_logger(parent_logger, item):
9292
return _create_logger_appender(parent_logger, '[{!r}]'.format(item))
9393

9494

95-
def logged(log_args=False, log_result=False):
95+
def logged(
96+
log_args=False, log_result=False, only_after=False, debug_only=False,
97+
log_full_exception=True):
9698
"""Decorator that logs entry and exit to a method and also times the execution.
9799
98100
It assumes that the object where you decorate the methods on has a ``.logger`` attribute.
@@ -105,34 +107,46 @@ def logged(log_args=False, log_result=False):
105107
Args:
106108
log_args: Whether to log args passed to the method
107109
log_result: Whether to log the result value returned from the method.
110+
only_after: Whether to log only after the method finished.
111+
debug_only: Use only debug log level at max.
112+
log_full_exception: Whether to log the full exceptions.
108113
"""
109114
def g(f):
110115
@wraps(f)
111116
def wrapped(self, *args, **kwargs):
117+
if debug_only:
118+
info_logger = self.logger.debug
119+
else:
120+
info_logger = self.logger.info
112121
start_time = time.time()
113122
signature = f.__name__ + (call_sig(args, kwargs) if log_args else '')
114-
self.logger.debug('%s started', signature)
123+
if not only_after:
124+
self.logger.debug('%s started', signature)
115125
try:
116126
result = f(self, *args, **kwargs)
117127
except DoNotReadThisWidget:
118128
elapsed_time = (time.time() - start_time) * 1000.0
119-
self.logger.info(
129+
info_logger(
120130
'%s not read on widget\'s request (elapsed %.0f ms)',
121131
signature, elapsed_time)
122132
raise
123133
except Exception as e:
124134
elapsed_time = (time.time() - start_time) * 1000.0
125-
self.logger.error(
126-
'An exception happened during %s call (elapsed %.0f ms)',
127-
signature, elapsed_time)
128-
self.logger.exception(e)
135+
if log_full_exception:
136+
self.logger.exception(
137+
'An exception happened during %s call (elapsed %.0f ms)',
138+
signature, elapsed_time)
139+
else:
140+
self.logger.error(
141+
'An exception %s happened during %s call (elapsed %.0f ms)',
142+
str(e), signature, elapsed_time)
129143
raise
130144
else:
131145
elapsed_time = (time.time() - start_time) * 1000.0
132146
if log_result:
133-
self.logger.info('%s -> %r (elapsed %.0f ms)', signature, result, elapsed_time)
147+
info_logger('%s -> %r (elapsed %.0f ms)', signature, result, elapsed_time)
134148
else:
135-
self.logger.info('%s (elapsed %.0f ms)', signature, elapsed_time)
149+
info_logger('%s (elapsed %.0f ms)', signature, elapsed_time)
136150
return result
137151

138152
wrapped.original_function = f

src/widgetastic/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import six
77
import string
88
from cached_property import cached_property
9+
from functools import wraps
910
from smartloc import Locator
1011
from threading import Lock
1112

@@ -477,3 +478,34 @@ def nested_getattr(o, steps):
477478
for step in steps:
478479
result = getattr(result, step)
479480
return result
481+
482+
483+
def repeat_once_on_exceptions(*exception_classes, **set_key_to_what):
484+
"""This decorator serves as DRY repeater of method or function execution.
485+
486+
Works the way that when any of the exception classes is caught, the kwargs of the method call
487+
is updated with ``set_key_to_what`` and invoked once more.
488+
489+
Args:
490+
*exception_classes: Exception classes on which this decorator should react
491+
**set_key_to_what: kwargs override used when the exception is hit and call retried.
492+
493+
Returns:
494+
Whatever the wrapped function/method returns.
495+
496+
"""
497+
if not set_key_to_what:
498+
raise TypeError('set_key_to_what is not set!')
499+
500+
def g(f):
501+
@wraps(f)
502+
def wrapped(*args, **kwargs):
503+
try:
504+
return f(*args, **kwargs)
505+
except exception_classes:
506+
kwargs.update(set_key_to_what)
507+
return f(*args, **kwargs)
508+
509+
return wrapped
510+
511+
return g

0 commit comments

Comments
 (0)