"""
implementation of page objects, element(s) and sections
"""
import contextlib
import inspect
import threading
import types
import weakref
from collections import OrderedDict
from collections.abc import Sequence
from functools import wraps
import selenium.webdriver.common.by
from selenium.common.exceptions import (
NoSuchElementException,
NoSuchFrameException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait
from holmium.core.conditions import BaseCondition
from .enhancers import get_enhancers
from .facets import CopyOnCreateFacetCollectionMeta, ElementFacet, Faceted
from .logger import log
# pylint: disable=unnecessary-lambda,too-few-public-methods,too-many-arguments
[docs]class Locators(selenium.webdriver.common.by.By):
"""
proxy class to access locator types
"""
pass
[docs]class ElementList(list):
"""
proxy to a standard list which would be stored in
a :class:`holmium.core.Page`.
"""
def __init__(self, instance, *args, **kwargs):
self.instance = weakref.ref(instance)
list.__init__(self, *args, **kwargs)
def __getitem__(self, index):
return list.__getitem__(self, index).__get__(
self.instance(), self.instance().__class__
)
def __iter__(self):
for idx in range(len(self)):
yield self[idx]
[docs]class ElementDict(dict):
"""
proxy to a standard dict which would be stored in
a :class:`holmium.core.Page`.
"""
def __init__(self, instance, *args, **kwargs):
self.instance = weakref.ref(instance)
dict.__init__(self, *args, **kwargs)
def __getitem__(self, key):
return dict.__getitem__(self, key).__get__(
self.instance(), self.instance().__class__
)
[docs] def values(self):
return [self[key] for key in self]
[docs] def items(self):
return [(key, self[key]) for key in self.keys()]
class Registry(CopyOnCreateFacetCollectionMeta):
"""
simple meta class to keep track of all page objects registered
"""
pages = {}
def __new__(mcs, *args, **kwargs):
page = super().__new__(mcs, *args, **kwargs)
Registry.pages[args[0]] = page
return page
[docs]class Page(Faceted, metaclass=Registry):
"""
Base class for all page objects to extend from.
void Instance methods implemented by subclasses are provisioned
with fluent wrappers to facilitate with writing code such as::
class Google(Page):
def query(self):
....
def submit(self):
....
def get_results(self):
....
assert len(Google().query("page objects").submit().get_results()) > 0
"""
local = threading.local()
def __init__(self, driver, url=None, iframe=None):
# pylint: disable=too-many-branches
super().__init__()
self.driver = driver
self.touched = False
self.initialized = False
if url:
self.home = url
elif driver.current_url:
self.home = driver.current_url
else:
self.home = None
self.iframe = iframe
def update_element(element, name):
"""
check if the element is a facet
and register it.
"""
if issubclass(element.__class__, ElementGetter):
element.iframe = self.iframe
if element.is_facet:
facet = ElementFacet(element, name, debug=element.is_debug_facet)
facet.register(self)
return True
return False
for element in inspect.getmembers(self.__class__):
if issubclass(element[1].__class__, list):
hit = True
for item in element[1]:
hit &= update_element(item, element[0])
if hit:
self.__setattr__(element[0], ElementList(self, element[1]))
elif issubclass(element[1].__class__, dict):
hit = True
for item in element[1].values():
hit &= update_element(item, element[0])
if hit:
self.__setattr__(element[0], ElementDict(self, element[1]))
else:
update_element(element[1], element[0])
if url:
self.driver.get(url)
self.initialized = True
@contextlib.contextmanager
def scope(self):
"""
context manager to manage the current webdriver
in use.
"""
Page.local.driver = object.__getattribute__(self, "driver")
yield
@classmethod
def get_driver(cls):
"""
returns the thread local driver
"""
return cls.local.driver
def go_home(self):
"""
returns the page object to the url it was initialized with
"""
self.driver.get(self.home)
def __getattribute__(self, key):
"""
to enable fluent access to page objects, instance methods that
don't return a value, instead return the page object instance.
"""
def attr_getter(key):
return object.__getattribute__(self, key)
def attr_setter(key, value):
return object.__setattr__(self, key, value)
with attr_getter("scope")():
if not attr_getter("touched") and attr_getter("initialized"):
attr_getter("evaluate")()
attr_setter("touched", True)
attr = attr_getter(key)
# check if home url is set, else update.
if not attr_getter("home"):
log.debug("home url not set, attempting to update.")
attr_setter("home", attr_getter("driver").current_url)
if isinstance(attr, types.MethodType):
@wraps(attr)
def wrap(*args, **kwargs):
"""
fluent wrapper
"""
resp = attr(*args, **kwargs)
if resp is None:
resp = self
return resp
return wrap
return attr
[docs]class ElementGetter:
"""
internal class to encapsulate the logic used by
:class:`holmium.core.Element`
& :class:`holmium.core.Elements`
"""
# pylint: disable=too-many-instance-attributes
def __init__(
self,
locator_type,
query_string,
base_element=None,
timeout=0,
value=lambda el: el,
only_if=lambda el: el is not None,
facet=False,
filter_by=lambda el: el is not None,
):
"""
:param holmium.core.Locators locator_type: selenium locator to use when
locating the element
:param str query_string: the value to pass to the locator
:param holmium.core.Element base_element: a reference to another i
element under which to locate this element.
:param int timeout: time to implicitely wait for the element
:param lambda value: transform function for the value of the element.
The located :class:`selenium.webdriver.remote.webelement.WebElement`
instance is passed as the only argument to the function.
:param function only_if: extra validation function that is called
repeatedly until :attr:`timeout` has elapsed. If not provided the
default function used checks that the element is present.
The located :class:`selenium.webdriver.remote.webelement.WebElement`
instance is passed as the only argument to the function.
:param bool facet: flag to treat this element as a facet.
:param function filter_by: condition function that determines if the
located :class:`selenium.webdriver.remote.webelement.WebElement`, the
only argument passed to the function, should be returned. If not
provided, the default function used checks that the element is
present.
"""
self.query_string = query_string
self.locator_type = locator_type
self.timeout = timeout
self.driver = None
self.iframe = None
self.base_element = base_element
self.value_mapper = value
self.root_fn = lambda: Page.get_driver()
self.only_if = only_if
log.debug(
"locator:%s, query_string:%s, timeout:%d"
% (locator_type, query_string, timeout)
)
self.is_facet = facet
self.is_debug_facet = False
self.filter_by = filter_by
@property
def root(self):
"returns the root webelement"
return self.root_fn()
@root.setter
def root(self, root_fn):
"sets the root element extraction function"
self.root_fn = root_fn
def _get_element(self, method=None):
"""
extracts the webelement(s)
:param function method: the method used to query the webdriver
"""
if self.base_element:
if isinstance(self.base_element, types.LambdaType):
_ = self.base_element()
_meth = getattr(_, method.__name__)
elif isinstance(self.base_element, Element):
_meth = getattr(
self.base_element.__get__(self, self.__class__), method.__name__
)
elif isinstance(self.base_element, WebElement):
_meth = getattr(self.base_element, "find_element")
else:
raise TypeError(
"invalid base_element type (%s) used" % (type(self.base_element))
)
else:
_meth = method
log.debug(
"looking up locator:%s, query_string:%s, timeout:%d"
% (self.locator_type, self.query_string, self.timeout)
)
if self.iframe:
Page.local.driver.switch_to.default_content()
Page.local.driver.switch_to.frame(self.iframe)
if self.timeout:
try:
def callback(_):
"""
timeout & only_if explicit wait.
"""
return _meth(self.locator_type, self.query_string) and (
BaseCondition.get_current() or self.only_if
)(_meth(self.locator_type, self.query_string))
WebDriverWait(
self.root,
self.timeout,
ignored_exceptions=[
StaleElementReferenceException,
],
).until(callback)
except TimeoutException:
log.debug(
"unable to find element %s after waiting for %d seconds"
% (self.query_string, self.timeout)
)
raise
retval = _meth(self.locator_type, self.query_string)
if isinstance(retval, list):
return [el for el in retval if self.filter_by(el)]
elif self.filter_by(retval):
return retval
else:
return None
[docs] @classmethod
def enhance(cls, element):
"""
incase a higher level abstraction for a WebElement is available
we will use that in Pages. (e.g. a select element is converted into
:class:`selenium.webdriver.support.ui.Select`)
"""
if element is None:
return None
for enhancer in get_enhancers():
if enhancer.matches(element):
return enhancer(element)
return element
[docs]class Element(ElementGetter):
"""
Utility to get a :class:`selenium.webdriver.remote.webelement.WebElement`
by querying via one of :class:`holmium.core.Locators`
:param holmium.core.Locators locator_type: selenium locator to use when
locating the element
:param str query_string: the value to pass to the locator
:param holmium.core.Element base_element: a reference to another element
under which to locate this element.
:param int timeout: time to implicitely wait for the element
:param lambda value: transform function for the value of the element.
The located :class:`selenium.webdriver.remote.webelement.WebElement`
instance is passed as the only argument to the function.
:param function only_if: extra validation function that is called
repeatedly until :attr:`timeout` elapses. If not provided the default
function used checks that the element is present. The located
:class:`selenium.webdriver.remote.webelement.WebElement` instance is
passed as the only argument to the function.
:param bool facet: flag to treat this element as a facet.
:param function filter_by: condition function that determines if the
located :class:`selenium.webdriver.remote.webelement.WebElement`, the
only argument passed to the function, should be returned. If not
provided, the default function used checks that the element is present.
"""
def __get__(self, instance, owner):
if not instance:
return self
try:
return (
self.value_mapper(
self.enhance(self._get_element(self.root.find_element))
)
if self.root
else None
)
except (
NoSuchElementException,
TimeoutException,
StaleElementReferenceException,
):
return None
[docs]class Elements(ElementGetter):
"""
Utility to get a collection of
:class:`selenium.webdriver.remote.webelement.WebElement` objects by
querying via one of :class:`holmium.core.Locators`
:param holmium.core.Locators locator_type: selenium locator to use when
locating the element
:param str query_string: the value to pass to the locator
:param holmium.core.Element base_element: a reference to another element
under which to locate this element.
:param int timeout: time to implicitely wait for the element
:param lambda value: transform function for each element in the collection.
The located :class:`selenium.webdriver.remote.webelement.WebElement`
instance is passed as the only argument to the function.
:param function only_if: extra validation function that is called
repeatedly until :attr:`timeout` elapses. If not provided the default
function used checks that the element collection is not empty. The list
of located :class:`selenium.webdriver.remote.webelement.WebElement`
instances is passed as the only argument to the function.
:param bool facet: flag to treat this element as a facet.
:param function filter_by: condition function determines which elements are
included in the collection. If not provided the default function used
includes all elements identified by :attr:`query_string`.
A :class:`selenium.webdriver.remote.webelement.WebElement` instance is
passed as the only argument to the function.
"""
# pylint: disable=incomplete-protocol,line-too-long
def __init__(
self,
locator_type,
query_string=None,
base_element=None,
timeout=0,
value=lambda el: el,
only_if=lambda els: len(els) > 0,
facet=False,
filter_by=lambda el: el is not None,
):
super().__init__(
locator_type,
query_string,
base_element=base_element,
timeout=timeout,
facet=facet,
value=value,
only_if=only_if,
filter_by=filter_by,
)
def __getitem__(self, idx):
return lambda: self.__get__(self, self.__class__)[idx]
def __get__(self, instance, owner):
if not instance:
return self
try:
return (
[
self.value_mapper(self.enhance(el))
for el in self._get_element(self.root.find_elements)
]
if self.root
else []
)
except (
NoSuchElementException,
TimeoutException,
StaleElementReferenceException,
):
return []
[docs]class ElementMap(Elements):
"""
Used to create dynamic dictionaries based on an element locator specified
by one of :class:`holmium.core.Locators`.
The wrapped dictionary is an :class:`collections.OrderedDict` instance.
:param holmium.core.Locators locator_type: selenium locator to use when
locating the element
:param str query_string: the value to pass to the locator
:param holmium.core.Element base_element: a reference to another element
under which to locate this element.
:param int timeout: time to implicitely wait for the element
:param bool facet: flag to treat this element as a facet.
:param lambda key: transform function for mapping a key to a WebElement in
the collection. The located
:class:`selenium.webdriver.remote.webelement.WebElement` instance is
passed as the only argument to the function.
:param lambda value: transform function for the value when accessed via the
key. The located :class:`selenium.webdriver.remote.webelement.WebElement`
instance is passed as the only argument to the function.
:param function only_if: extra validation function that is called
repeatedly until :attr:`timeout` elapses. If not provided the default
function used checks that the element collection is not empty. The list
of located :class:`selenium.webdriver.remote.webelement.WebElement`
instances is passed as the only argument to the function.
:param function filter_by: condition function determines which elements are
included in the collection. If not provided the default function used
includes all elements identified by :attr:`query_string`.
A :class:`selenium.webdriver.remote.webelement.WebElement` instance is
passed as the only argument to the function.
"""
# pylint: disable=incomplete-protocol,line-too-long
def __init__(
self,
locator_type,
query_string=None,
base_element=None,
timeout=0,
key=lambda el: el.text,
value=lambda el: el,
only_if=lambda els: len(els) > 0,
facet=False,
filter_by=lambda el: el is not None,
):
super().__init__(
locator_type,
query_string,
base_element,
timeout,
facet=facet,
only_if=only_if,
filter_by=filter_by,
)
self.key_mapper = key
self.value_mapper = value
def __get__(self, instance, owner):
if not instance:
return self
try:
return (
OrderedDict(
(self.key_mapper(el), self.value_mapper(self.enhance(el)))
for el in self._get_element(self.root.find_elements)
)
if self.root
else {}
)
except (
NoSuchElementException,
TimeoutException,
StaleElementReferenceException,
):
return {}
def __getitem__(self, key):
return lambda: self.__get__(self, self.__class__)[key]
[docs]class Section(Faceted):
"""
Base class to encapsulate reusable page sections::
class MySection(Section):
things = Elements( .... )
class MyPage(Page):
section_1 = MySection(Locators.CLASS_NAME, "section")
section_2 = MySection(Locators.ID, "unique_section")
"""
def __init__(self, locator_type, query_string, iframe=None, timeout=0):
super().__init__()
self.touched = False
self.locator_type = locator_type
self.query_string = query_string
self.iframe = iframe
self.timeout = timeout
self.__root_val = None
self.element_members = {}
for element in inspect.getmembers(self.__class__):
if issubclass(element[1].__class__, ElementGetter):
self.element_members[element[0]] = element[1]
if element[1].is_facet:
facet = ElementFacet(
element[1], element[0], debug=element[1].is_debug_facet
)
facet.register(self)
def __get__(self, instance, owner):
for element in self.element_members.values():
element.root = lambda: self.root
return self
def __getattribute__(self, item):
def attr_getter(key):
return super(Section, self).__getattribute__(key)
def attr_setter(key, value):
return super(Section, self).__setattr__(key, value)
members = attr_getter("element_members")
touched = attr_getter("touched")
if not touched and item in members:
attr_getter("evaluate")()
attr_setter("touched", True)
return attr_getter(item)
@property
def root(self):
"""
returns the element the section is rooted at
"""
if self.iframe:
try:
Page.get_driver().switch_to.default_content()
Page.get_driver().switch_to.frame(self.iframe)
except NoSuchFrameException:
log.error("unable to switch to iframe %s" % self.iframe)
try:
if not self.__root_val:
WebDriverWait(Page.get_driver(), self.timeout).until(
lambda _: Page.get_driver().find_element(
self.locator_type, self.query_string
)
)
return self.__root_val or Page.get_driver().find_element(
self.locator_type, self.query_string
)
except (
NoSuchElementException,
TimeoutException,
StaleElementReferenceException,
):
return None
@root.setter
def root(self, val):
"""
sets the root of the section
"""
self.__root_val = val
[docs]class Sections(Section, Sequence):
"""
Base class for an Iterable view of a collection of
:class:`holmium.core.Section` objects.
"""
def __init__(self, locator_type, query_string, iframe=None, timeout=0):
super().__init__(locator_type, query_string, iframe, timeout)
def __getelements__(self):
if self.timeout:
try:
WebDriverWait(Page.get_driver(), self.timeout).until(
lambda _: Page.get_driver().find_elements(
self.locator_type, self.query_string
)
)
except TimeoutException:
log.debug(
"unable to find element %s after waiting for %d seconds"
% (self.query_string, self.timeout)
)
return Page.get_driver().find_elements(self.locator_type, self.query_string)
def __iter__(self):
for element in self.__getelements__():
self.root = element
yield self
def __len__(self):
return len(self.__getelements__())
def __getitem__(self, idx):
if idx < 0:
idx = len(self) + idx
if idx < 0 or idx >= len(self):
raise IndexError("Sections index (%d) out of range" % idx)
for i, _ in enumerate(self):
if i == idx:
break
return self