diff --git a/src/widgetastic/browser.py b/src/widgetastic/browser.py index d3931ba6..965ffc39 100644 --- a/src/widgetastic/browser.py +++ b/src/widgetastic/browser.py @@ -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 ( @@ -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 {} @@ -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): @@ -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 @@ -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))) @@ -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)] @@ -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). @@ -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() @@ -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: @@ -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( @@ -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. @@ -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. @@ -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 = '' @@ -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( @@ -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. @@ -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) @@ -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): @@ -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: @@ -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 @@ -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