Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 64 additions & 26 deletions src/widgetastic/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from selenium.webdriver.support.wait import WebDriverWait
from smartloc import Locator
from textwrap import dedent
from threading import RLock
from wait_for import wait_for

from .exceptions import (
Expand Down Expand Up @@ -133,17 +134,26 @@ class Browser(object):
something like ``/tmp/someawfulhashadmin`` for obvious reasons that however might not be obvious
to the ordinary users of Selenium.

This wrapper uses a reentrant lock to ensure the thread safety. All methods and properties
called on an instance of this class should be thread-safe. If you want to dig into selenium and
call something directly from it, use the ``selenium_access`` attribute as a context manager.

.. code-block:: python

with wtbrowser.selenium_access:
wtbrowser.selenium.do_something_nasty()

Args:
selenium: Any :py:class:`selenium.webdriver.remote.webdriver.WebDriver` descendant
plugin_class: If you want to alter the behaviour of some aspects, you can specify your own
class as plugin.
logger: a logger, if not specified, default is used.
logger: a logger, if not specified, ``null_logger`` is used.
extra_objects: If the testing system needs to know more about the environment, you can pass
a dictionary in this parameter, where you can store all these additional objects.
"""
def __init__(self, selenium, plugin_class=None, logger=None, extra_objects=None):
def __init__(self, selenium, plugin_class=DefaultPlugin, logger=None, extra_objects=None):
self.selenium_access = RLock()
self.selenium = selenium
plugin_class = plugin_class or DefaultPlugin
self.plugin = plugin_class(self)
self.logger = logger or null_logger
self.extra_objects = extra_objects or {}
Expand All @@ -152,7 +162,8 @@ def __init__(self, selenium, plugin_class=None, logger=None, extra_objects=None)
def handles_alerts(self):
"""Important for unit testing as PhantomJS does not handle alerts. This makes the alert
handling functions do nothing."""
return self.selenium.capabilities.get('handlesAlerts', True)
with self.selenium_access:
return self.selenium.capabilities.get('handlesAlerts', True)

@property
def browser(self):
Expand All @@ -168,8 +179,7 @@ def product_version(self):
"""
raise NotImplementedError('You have to implement product_version')

@staticmethod
def _process_locator(locator):
def _process_locator(self, locator):
"""Processes the locator so the :py:meth:`elements` gets exactly what it needs."""
if isinstance(locator, WebElement):
return locator
Expand All @@ -180,6 +190,8 @@ def _process_locator(locator):
# Deal with the case when __locator__ returns a webelement.
loc = locator.__locator__()
if isinstance(loc, WebElement):
# But warn that it is not good
self.logger.warning('%r has __locator__() that returns a webelement!', locator)
return loc
raise LocatorNotImplemented(
'You have to implement __locator__ on {!r}'.format(type(locator)))
Expand Down Expand Up @@ -233,7 +245,8 @@ def elements(
root_element = self.selenium
else:
root_element = self.selenium
result = root_element.find_elements(*locator)
with self.selenium_access:
result = root_element.find_elements(*locator)

if check_visibility:
result = [e for e in result if self.is_displayed(e)]
Expand Down Expand Up @@ -269,7 +282,8 @@ def element(self, locator, *args, **kwargs):

def perform_click(self):
"""Clicks the left mouse button at the current mouse position."""
ActionChains(self.selenium).click().perform()
with self.selenium_access:
ActionChains(self.selenium).click().perform()

def click(self, locator, *args, **kwargs):
"""Clicks at a specific element using two separate events (mouse move, mouse click).
Expand Down Expand Up @@ -301,7 +315,8 @@ def raw_click(self, locator, *args, **kwargs):
ignore_ajax = kwargs.pop('ignore_ajax', False)
el = self.element(locator, *args, **kwargs)
self.plugin.before_click(el)
el.click()
with self.selenium_access:
el.click()
if not ignore_ajax:
try:
self.plugin.ensure_page_safe()
Expand All @@ -326,7 +341,9 @@ def is_displayed(self, locator, *args, **kwargs):
while retry:
retry = False
try:
return self.move_to_element(locator, *args, **kwargs).is_displayed()
element = self.move_to_element(locator, *args, **kwargs)
with self.selenium_access:
return element.is_displayed()
except (NoSuchElementException, MoveTargetOutOfBoundsException):
return False
except StaleElementReferenceException:
Expand Down Expand Up @@ -361,12 +378,14 @@ def move_to_element(self, locator, *args, **kwargs):
return el
move_to = ActionChains(self.selenium).move_to_element(el)
try:
move_to.perform()
with self.selenium_access:
move_to.perform()
except MoveTargetOutOfBoundsException:
# ff workaround
self.execute_script("arguments[0].scrollIntoView();", el)
try:
move_to.perform()
with self.selenium_access:
move_to.perform()
except MoveTargetOutOfBoundsException: # This has become desperate now.
raise MoveTargetOutOfBoundsException(
"Despite all the workarounds, scrolling to `{}` was unsuccessful.".format(
Expand All @@ -375,13 +394,15 @@ def move_to_element(self, locator, *args, **kwargs):

def move_by_offset(self, x, y):
self.logger.debug('move_by_offset X:%r Y:%r', x, y)
ActionChains(self.selenium).move_by_offset(x, y)
with self.selenium_access:
ActionChains(self.selenium).move_by_offset(x, y).perform()

def execute_script(self, script, *args, **kwargs):
"""Executes a script."""
if not kwargs.pop('silent', False):
self.logger.debug('execute_script: %r', script)
return self.selenium.execute_script(dedent(script), *args, **kwargs)
with self.selenium_access:
return self.selenium.execute_script(dedent(script), *args, **kwargs)

def classes(self, locator, *args, **kwargs):
"""Return a list of classes attached to the element.
Expand All @@ -404,7 +425,9 @@ def tag(self, *args, **kwargs):
Returns:
:py:class:`str` with the tag name
"""
return self.element(*args, **kwargs).tag_name
e = self.element(*args, **kwargs)
with self.selenium_access:
return e.tag_name

def text(self, *args, **kwargs):
"""Returns the text inside the element represented by the locator passed.
Expand All @@ -418,7 +441,9 @@ def text(self, *args, **kwargs):
:py:class:`str` with the text
"""
try:
text = self.element(*args, **kwargs).text
e = self.element(*args, **kwargs)
with self.selenium_access:
text = e.text
except MoveTargetOutOfBoundsException:
text = ''

Expand All @@ -433,7 +458,9 @@ def text(self, *args, **kwargs):
return normalize_space(text)

def get_attribute(self, attr, *args, **kwargs):
return self.element(*args, **kwargs).get_attribute(attr)
e = self.element(*args, **kwargs)
with self.selenium_access:
return e.get_attribute(attr)

def set_attribute(self, attr, value, *args, **kwargs):
return self.execute_script(
Expand All @@ -445,12 +472,15 @@ def clear(self, locator, *args, **kwargs):
self.logger.debug('clear: %r', locator)
el = self.element(locator, *args, **kwargs)
self.plugin.before_keyboard_input(el, None)
result = el.clear()
with self.selenium_access:
result = el.clear()
self.plugin.after_keyboard_input(el, None)
return result

def is_selected(self, *args, **kwargs):
return self.element(*args, **kwargs).is_selected()
e = self.element(*args, **kwargs)
with self.selenium_access:
return e.is_selected()

def send_keys(self, text, locator, *args, **kwargs):
"""Sends keys to the element. Detects the file inputs automatically.
Expand All @@ -474,7 +504,8 @@ def send_keys(self, text, locator, *args, **kwargs):
el = self.move_to_element(locator, *args, **kwargs)
self.plugin.before_keyboard_input(el, text)
self.logger.debug('send_keys %r to %r', text, locator)
result = el.send_keys(text)
with self.selenium_access:
result = el.send_keys(text)
if Keys.ENTER not in text:
try:
self.plugin.after_keyboard_input(el, text)
Expand All @@ -499,7 +530,8 @@ def get_alert(self):
"""
if not self.handles_alerts:
return None
return self.selenium.switch_to_alert()
with self.selenium_access:
return self.selenium.switch_to_alert()

@property
def alert_present(self):
Expand All @@ -510,7 +542,9 @@ def alert_present(self):
if not self.handles_alerts:
return False
try:
self.get_alert().text
alert = self.get_alert()
with self.selenium_access:
alert.text
except NoAlertPresentException:
return False
else:
Expand All @@ -525,7 +559,8 @@ def dismiss_any_alerts(self):
while self.alert_present:
alert = self.get_alert()
self.logger.info('dismissing alert: %r', alert.text)
alert.dismiss()
with self.selenium_access:
alert.dismiss()
except NoAlertPresentException: # Just in case. alert_present should be reliable
pass

Expand Down Expand Up @@ -564,13 +599,16 @@ def handle_alert(self, cancel=False, wait=30.0, squash=False, prompt=None, check
self.logger.info('handling alert: %r', popup.text)
if prompt is not None:
self.logger.info(' answering prompt: %r', prompt)
popup.send_keys(prompt)
with self.selenium_access:
popup.send_keys(prompt)
if cancel:
self.logger.info(' dismissing')
popup.dismiss()
with self.selenium_access:
popup.dismiss()
else:
self.logger.info(' accepting')
popup.accept()
with self.selenium_access:
popup.accept()
# Should any problematic "double" alerts appear here, we don't care, just blow'em away.
self.dismiss_any_alerts()
return True
Expand Down