# -*- coding: utf-8 -*-
from __future__ import unicode_literals
"""This module contains the base classes that are used to implement the more specific behaviour."""
import inspect
import re
import six
import types
from six.moves import html_parser
from cached_property import cached_property
from collections import defaultdict, namedtuple
from copy import copy
from jsmin import jsmin
from selenium.webdriver.remote.file_detector import LocalFileDetector
from selenium.webdriver.remote.webelement import WebElement
from smartloc import Locator
from wait_for import wait_for
from .browser import Browser, BrowserParentWrapper
from .exceptions import (
NoSuchElementException, LocatorNotImplemented, WidgetOperationFailed, DoNotReadThisWidget,
RowNotFound)
from .log import (
PrependParentsAdapter, create_widget_logger, logged, call_sig, create_child_logger,
create_item_logger)
from .utils import (
Widgetable, Fillable, ParametrizedLocator, ParametrizedString, ConstructorResolvable,
attributize_string, normalize_space, nested_getattr, deflatten_dict)
from .xpath import quote
def do_not_read_this_widget():
"""Call inside widget's read method in case you don't want it to appear in the data."""
raise DoNotReadThisWidget('Do not read this widget.')
def wrap_fill_method(method):
"""Generates a method that automatically coerces the first argument as Fillable."""
@six.wraps(method)
def wrapped(self, value, *args, **kwargs):
return method(self, Fillable.coerce(value), *args, **kwargs)
return wrapped
def resolve_verpicks_in_method(method):
"""Generates a method that automatically resolves VersionPick attributes"""
@six.wraps(method)
def wrapped(self, *args, **kwargs):
def resolve_arg(parent, arg):
if (isinstance(arg, ConstructorResolvable) and not (
method.__name__ == '__new__' or
isinstance(arg, ParametrizedString) or
hasattr(method, 'skip_resolve'))):
# 1. ParametrizedString is also ConstructorResolvable but
# it should be resolved later in other place
# 2. if method has skip_resolve attr, its arguments won't be automatically resolved
# 3. __new__ doesn't require resolve
return arg.resolve(parent)
else:
return arg
if method.__name__ == '__init__':
# __init__ doesn't have initialized self.parent.
# So, we need to look for parent/browser in arguments
if args and isinstance(args[0], (Widget, Browser)):
parent = args[0]
elif 'parent' in kwargs and isinstance(kwargs['parent'], (Widget, Browser)):
parent = kwargs['parent']
else:
raise ValueError("parent isn't passed to init")
else:
parent = self
new_args = [resolve_arg(parent, arg) for arg in args]
new_kwargs = {key: resolve_arg(parent, value) for key, value in kwargs.items()}
return method(self, *new_args, **new_kwargs)
return wrapped
def process_parameters(parent_obj, args, kwargs):
"""Processes the widget input parameters - checks if args or kwarg values are parametrized."""
new_args = []
for arg in args:
if isinstance(arg, ConstructorResolvable):
new_args.append(arg.resolve(parent_obj))
else:
new_args.append(arg)
new_kwargs = {}
for k, v in kwargs.items():
if isinstance(v, ConstructorResolvable):
new_kwargs[k] = v.resolve(parent_obj)
else:
new_kwargs[k] = v
return new_args, new_kwargs
class WidgetDescriptor(Widgetable):
"""This class handles instantiating and caching of the widgets on view.
It stores the class and the parameters it should be instantiated with. Once it is accessed from
the instance of the class where it was defined on, it passes the instance to the widget class
followed by args and then kwargs.
It also acts as a counter, so you can then order the widgets by their "creation" stamp.
"""
def __init__(self, klass, *args, **kwargs):
self.klass = klass
self.args = args
self.kwargs = kwargs
# TODO: WE can bring it back, popping out just for compatibility sake
self.kwargs.pop('log_on_fill_unspecified', None)
def __get__(self, obj, type=None):
if obj is None: # class access
return self
# Cache on WidgetDescriptor
if self not in obj._widget_cache:
kwargs = copy(self.kwargs)
try:
kwargs['logger'] = create_child_logger(obj.logger, obj._desc_name_mapping[self])
except KeyError:
kwargs['logger'] = obj.logger
except AttributeError:
pass
args, kwargs = process_parameters(obj, self.args, kwargs)
if issubclass(self.klass, ParametrizedView):
# Shortcut, don't cache as the ParametrizedViewRequest is not the widget yet
return ParametrizedViewRequest(obj, self.klass, *args, **kwargs)
else:
o = self.klass(obj, *args, **kwargs)
o.parent_descriptor = self
obj._widget_cache[self] = o
widget = obj._widget_cache[self]
obj.child_widget_accessed(widget)
return widget
def __repr__(self):
return '{}{}'.format(self.klass.__name__, call_sig(self.args, self.kwargs))
class ExtraData(object):
"""This class implements a simple access to the extra data passed through
:py:class:`widgetastic.browser.Browser` object.
.. code-block:: python
widget.extra.foo
# is equivalent to
widget.browser.extra_objects['foo']
"""
# TODO: Possibly replace it with a descriptor of some sort?
def __init__(self, widget):
self._widget = widget
@property
def _extra_objects_list(self):
return list(six.iterkeys(self._widget.browser.extra_objects))
def __dir__(self):
return self._extra_objects_list
def __getattr__(self, attr):
try:
return self._widget.browser.extra_objects[attr]
except KeyError:
raise AttributeError('Extra object {!r} was not found ({} are available)'.format(
attr, ', '.join(self._extra_objects_list)))
class WidgetIncluder(Widgetable):
"""Includes widgets from another widget. Useful for sharing pieces of code."""
def __init__(self, widget_class, use_parent=False):
self.widget_class = widget_class
self.use_parent = use_parent
def __repr__(self):
return '{}({})'.format(type(self).__name__, self.widget_class.__name__)
class IncludedWidget(object):
def __init__(self, included_id, widget_name, use_parent):
self.included_id = included_id
self.widget_name = widget_name
self.use_parent = use_parent
def __get__(self, o, t=None):
if o is None:
return self
return o._get_included_widget(self.included_id, self.widget_name, self.use_parent)
def __repr__(self):
return '{}({}, {!r})'.format(type(self).__name__, self.included_id, self.widget_name)
class WidgetMetaclass(type):
"""Metaclass that ensures that ``fill`` and ``read`` methods are logged and coerce Fillable
properly.
For ``fill`` methods placed in :py:class:`Widget` descendants it first wraps them using
:py:func:`wrap_fill_method` that ensures that :py:class:`widgetastic.utils.Fillable` can be
passed and then it wraps them in the :py:func:`widgetastic.log.logged`.
The same happens for ``read`` except the ``wrap_fill_method`` which is only useful for ``fill``.
Therefore, you shall not wrap any ``read`` or ``fill`` methods in
:py:func:`widgetastic.log.logged`.
"""
def __new__(cls, name, bases, attrs):
new_attrs = {}
desc_name_mapping = {}
included_widgets = []
for base in bases:
for key, value in six.iteritems(getattr(base, '_desc_name_mapping', {})):
desc_name_mapping[key] = value
for widget_includer in getattr(base, '_included_widgets', ()):
included_widgets.append(widget_includer)
for widget_name in widget_includer.widget_class.cls_widget_names():
new_attrs[widget_name] = IncludedWidget(widget_includer._seq_id, widget_name,
widget_includer.use_parent)
for key, value in six.iteritems(attrs):
if inspect.isclass(value) and issubclass(value, View):
new_attrs[key] = WidgetDescriptor(value)
desc_name_mapping[new_attrs[key]] = key
elif isinstance(value, WidgetIncluder):
included_widgets.append(value)
# Now generate accessors for each included widget
for widget_name in value.widget_class.cls_widget_names():
new_attrs[widget_name] = IncludedWidget(value._seq_id, widget_name,
value.use_parent)
elif isinstance(value, Widgetable):
new_attrs[key] = value
desc_name_mapping[value] = key
for widget in value.child_items:
if not isinstance(widget, (Widgetable, Widget)):
continue
desc_name_mapping[widget] = key
elif key == 'fill':
# handle fill() specifics
new_attrs[key] = logged(log_args=True, log_result=True)(wrap_fill_method(value))
elif key == 'read':
# handle read() specifics
new_attrs[key] = logged(log_result=True)(value)
elif isinstance(value, types.FunctionType):
# VP resolution wrapper, allows to resolve VersionPicks in all widget methods
new_attrs[key] = resolve_verpicks_in_method(value)
else:
# Do nothing
new_attrs[key] = value
if 'ROOT' in new_attrs and '__locator__' not in new_attrs:
# For handling the root locator of the View
root = new_attrs['ROOT']
if isinstance(root, ParametrizedLocator):
new_attrs['__locator__'] = _gen_locator_root()
else:
new_attrs['__locator__'] = _gen_locator_meth(Locator(root))
new_attrs['_included_widgets'] = tuple(sorted(included_widgets, key=lambda w: w._seq_id))
new_attrs['_desc_name_mapping'] = desc_name_mapping
return super(WidgetMetaclass, cls).__new__(cls, name, bases, new_attrs)
class Widget(six.with_metaclass(WidgetMetaclass, object)):
"""Base class for all UI objects.
Does couple of things:
* Ensures it gets instantiated with a browser or another widget as parent. If you create an
instance in a class, it then creates a :py:class:`WidgetDescriptor` which is then invoked
on the instance and instantiates the widget with underlying browser.
* Implements some basic interface for all widgets.
If you are inheriting from this class, you **MUST ALWAYS** ensure that the inherited class
has an init that always takes the ``parent`` as the first argument. You can do that on your
own, setting the parent as ``self.parent`` or you can do something like this:
.. code-block:: python
def __init__(self, parent, arg1, arg2, logger=None):
super(MyClass, self).__init__(parent, logger=logger)
# or if you have somehow complex inheritance ...
Widget.__init__(self, parent, logger=logger)
"""
#: Default value for parent_descriptor
parent_descriptor = None
# Helper methods
@staticmethod
def include(*args, **kwargs):
"""Include another widget with exposing the given widget's widgets in this widget."""
return WidgetIncluder(*args, **kwargs)
def __new__(cls, *args, **kwargs):
"""Implement some typing saving magic.
Unless you are passing a :py:class:`Widget` or :py:class:`widgetastic.browser.Browser`
as a first argument which implies the instantiation of an actual widget, it will return
:py:class:`WidgetDescriptor` instead which will resolve automatically inside of
:py:class:`View` instance.
This allows you a sort of Django-ish access to the defined widgets then.
"""
if (args and isinstance(args[0], (Widget, Browser))) \
or ('parent' in kwargs and isinstance(kwargs['parent'], (Widget, Browser))):
return super(Widget, cls).__new__(cls)
else:
return WidgetDescriptor(cls, *args, **kwargs)
def __init__(self, parent, logger=None):
self.parent = parent
if logger is None:
self.logger = create_child_logger(parent.logger, type(self).__name__)
elif isinstance(logger, PrependParentsAdapter):
# The logger is already prepared
self.logger = logger
else:
# We need a PrependParentsAdapter here.
self.logger = create_widget_logger(type(self).__name__, logger)
self.extra = ExtraData(self)
self._widget_cache = {}
self._initialized_included_widgets = {}
def __element__(self):
"""Implement the logic of querying
:py:class:`selenium.webdriver.remote.webelement.WebElement` from Selenium.
It uses :py:meth:`__locator__` to retrieve the locator and then it looks up the WebElement
on the parent's browser.
If hte ``__locator__`` isbadly implemented and returns a ``WebElement`` instance, it returns
it directly.
You usually want this method to be intact.
"""
try:
locator = self.__locator__()
except AttributeError:
raise AttributeError(
'__locator__() is not defined on {} class'.format(type(self).__name__))
else:
if isinstance(locator, WebElement):
self.logger.warning(
'__locator__ of %s class returns a WebElement!', type(self).__name__)
return locator
else:
return self.parent_browser.element(locator)
def _get_included_widget(self, includer_id, widget_name, use_parent):
if includer_id not in self._initialized_included_widgets:
for widget_includer in self._included_widgets:
if widget_includer._seq_id == includer_id:
parent = self if use_parent else self.parent
self._initialized_included_widgets[widget_includer._seq_id] =\
widget_includer.widget_class(parent, self.logger)
break
else:
raise ValueError('Could not find includer #{}'.format(includer_id))
return getattr(self._initialized_included_widgets[includer_id], widget_name)
def flush_widget_cache(self):
"""Flush the widget cache recursively for the whole :py:class:`Widget` tree structure.
Do not use this unless you see glitches and ``StaleElementReferenceException``. Well written
widgets should not need flushing.
"""
for widget in self._widget_cache.values():
try:
widget.flush_widget_cache()
except AttributeError:
# ParametrizedViewRequest does this, we can safely ignore that
pass
self._widget_cache.clear()
for widget in self._initialized_included_widgets.values():
try:
widget.flush_widget_cache()
except AttributeError:
# ParametrizedViewRequest does this, we can safely ignore that
pass
self._initialized_included_widgets.clear()
@classmethod
def cls_widget_names(cls):
"""Returns a list of widget names in the order they were defined on the class.
Returns:
A :py:class:`list` of :py:class:`Widget` instances.
"""
result = []
for key in dir(cls):
value = getattr(cls, key)
if isinstance(value, Widgetable):
# check the values of a VersionPick object are widgetable themselves
if all([isinstance(item, Widgetable) for item in value.child_items]):
result.append((key, value))
for includer in cls._included_widgets:
result.append((None, includer))
presorted_widgets = sorted(result, key=lambda pair: pair[1]._seq_id)
result = []
for name, widget in presorted_widgets:
if isinstance(widget, WidgetIncluder):
result.extend(widget.widget_class.cls_widget_names())
else:
result.append(name)
return tuple(result)
@property
def widget_names(self):
"""Returns a list of widget names in the order they were defined on the class.
Returns:
A :py:class:`list` of :py:class:`Widget` instances.
"""
return self.cls_widget_names()
@property
def hierarchy(self):
"""Returns a list of widgets from the top level to this one."""
if not isinstance(self.parent, Widget):
return [self]
else:
return self.parent.hierarchy + [self]
@property
def locatable_parent(self):
"""If the widget has a parent that is locatable, returns it. Otherwise returns None"""
for locatable in list(reversed(self.hierarchy))[1:]:
if hasattr(locatable, '__locator__') and not getattr(locatable, 'INDIRECT', False):
return locatable
else:
return None
@property
def root_browser(self):
return self.parent.root_browser
@property
def parent_browser(self):
try:
return self.locatable_parent.browser
except AttributeError:
# locatable_parent == None
return self.root_browser
@property
def browser(self):
"""Returns the instance of parent browser.
If the view defines ``__locator__`` or ``ROOT`` then a new wrapper is created that injects
the ``parent=``
Returns:
:py:class:`widgetastic.browser.Browser` instance
Raises:
:py:class:`ValueError` when the browser is not defined, which is an error.
"""
try:
if hasattr(self, '__locator__'):
# Wrap it so we have automatic parent injection
return BrowserParentWrapper(self, self.root_browser)
else:
# This view has no locator, therefore just use the parent browser
return self.root_browser
except AttributeError:
raise ValueError('Unknown value {!r} specified as parent.'.format(self.parent))
@property
def parent_view(self):
"""Returns a parent view, if the widget lives inside one.
Returns:
:py:class:`View` instance if the widget is defined in one, otherwise ``None``.
"""
if isinstance(self.parent, View):
return self.parent
else:
return None
@property
def is_displayed(self):
"""Shortcut allowing you to detect if the widget is displayed.
If the logic behind is_displayed is more complex, you can always override this.
Returns:
:py:class:`bool`
"""
return self.browser.is_displayed(self)
@logged()
def wait_displayed(self, timeout='10s'):
"""Wait for the element to be displayed. Uses the :py:meth:`is_displayed`
Args:
timout: If you want, you can override the default timeout here
"""
wait_for(lambda: self.is_displayed, timeout=timeout, delay=0.2)
@logged()
def move_to(self):
"""Moves the mouse to the Selenium WebElement that is resolved by this widget.
Returns:
:py:class:`selenium.webdriver.remote.webelement.WebElement` instance
"""
return self.browser.move_to_element(self)
def child_widget_accessed(self, widget):
"""Called when a child widget of this widget gets accessed.
Useful when eg. the containing widget needs to open for the child widget to become visible.
Args:
widget: The widget being accessed.
"""
pass
def fill(self, *args, **kwargs):
"""Interactive objects like inputs, selects, checkboxes, et cetera should implement fill.
When you implement this method, it *MUST ALWAYS* return a boolean whether the value
*was changed*. Otherwise it can break.
For actual filling, please use :py:meth:`fill_with`. It offers richer interface for filling.
Returns:
A boolean whether it changed the value or not.
"""
raise NotImplementedError(
'Widget {} does not implement fill()!'.format(type(self).__name__))
def read(self, *args, **kwargs):
"""Each object should implement read so it is easy to get the value of such object.
When you implement this method, the exact return value is up to you but it *MUST* be
consistent with what :py:meth:`fill` takes.
"""
raise NotImplementedError(
'Widget {} does not implement read()!'.format(type(self).__name__))
def _process_fill_handler(self, handler):
"""Processes a given handler in the way that it is usable as a callable + its representation
Handlers can come in variety of ways. Simplest thing is to pass a callable, it will get
executed. The handler can also work with classes that mix in :py:class:`ClickableMixin`
where they use the :py:meth:`CallableMixin.click` as the handler action. If you pass a
string, it will first get resolved by getting it as an attribute of the instance. Then all
abovementioned steps are tried.
Args:
handler: The handler. More explanation in the description of this method.
Returns:
A 2-tuple consisting of ``(action_callable, obj_for_repr)``. The ``obj_for_repr`` is an
object that can be passed to a logger that uses ``%r``.
"""
if isinstance(handler, six.string_types):
try:
handler = getattr(self, handler)
except AttributeError:
raise TypeError('{} does not exist on {!r}'.format(handler, self))
if isinstance(handler, ClickableMixin):
return (handler.click, handler)
elif callable(handler):
return (handler, handler)
else:
raise TypeError('Fill handler must be callable or clickable.')
def fill_with(self, value, on_change=None, no_change=None):
"""Method to fill the widget, especially usable when filling in forms.
Args:
value: Value to fill - gets passed to :py:meth:`fill`
on_change: Optional handler to be executed when there was a change. See
:py:meth`_process_fill_handler` for details
no_change: Optional handler to be executed when there was no change. See
:py:meth`_process_fill_handler` for details
Returns:
Whether there was any change. Same as :py:meth:`fill`.
"""
changed = self.fill(value)
if changed:
if on_change is not None:
action, rep = self._process_fill_handler(on_change)
self.logger.info('invoking after fill on_change=%r', rep)
action()
else:
if no_change is not None:
action, rep = self._process_fill_handler(no_change)
self.logger.info('invoking after fill no_change=%r', rep)
action()
return changed
@property
def sub_widgets(self):
"""Returns all sub-widgets of this widget.
Returns:
A :py:class:`list` of :py:class:`Widget`
"""
return [getattr(self, widget_name) for widget_name in self.widget_names]
@property
def cached_sub_widgets(self):
"""Returns all cached sub-widgets of this widgets.
Returns:
A :py:class:`list` of :py:class:`Widget`
"""
return [
getattr(self, widget_name)
for widget_name in self.widget_names
# Grab the descriptor
if getattr(type(self), widget_name) in self._widget_cache]
@property
def width(self):
return self.browser.size_of(self, parent=self.parent)[0]
@property
def height(self):
return self.browser.size_of(self, parent=self.parent)[1]
def __iter__(self):
"""Allows iterating over the widgets on the view."""
for widget_attr in self.widget_names:
yield getattr(self, widget_attr)
def _gen_locator_meth(loc):
def __locator__(self): # noqa
return loc
return __locator__
def _gen_locator_root():
def __locator__(self): # noqa
return self.ROOT
return __locator__
class View(Widget):
"""View is a kind of abstract widget that can hold another widgets. Remembers the order,
so therefore it can function like a form with defined filling order.
It looks like this:
.. code-block:: python
class Login(View):
user = SomeInputWidget('user')
password = SomeInputWidget('pass')
login = SomeButtonWidget('Log In')
def a_method(self):
do_something()
The view is usually instantiated with an instance of
:py:class:`widgetastic.browser.Browser`, which will then enable resolving of all of the
widgets defined.
Args:
parent: A parent :py:class:`View` or :py:class:`widgetastic.browser.Browser`
additional_context: If the view needs some context, for example - you want to check that
you are on the page of user XYZ but you can also be on the page for user FOO, then
you shall use the ``additional_context`` to pass in required variables that will allow
you to detect this.
"""
#: Skip this view in the element lookup hierarchy
INDIRECT = False
def __init__(self, parent, logger=None, **kwargs):
Widget.__init__(self, parent, logger=logger)
self.context = kwargs.pop('additional_context', {})
self.last_fill_data = None
@staticmethod
def nested(view_class):
"""Shortcut for :py:class:`WidgetDescriptor`
Usage:
.. code-block:: python
class SomeView(View):
some_widget = Widget()
@View.nested
class another_view(View):
pass
Why? The problem is counting things. When you are placing widgets themselves on a view, they
handle counting themselves and just work. But when you are creating a nested view, that is a
bit of a problem. The widgets are instantiated, whereas the views are placed in a class and
wait for the :py:class:`ViewMetaclass` to pick them up, but that happens after all other
widgets have been instantiated into the :py:class:`WidgetDescriptor`s, which has the
consequence of things being out of order. By wrapping the class into the descriptor we do
the job of :py:meth:`Widget.__new__` which creates the :py:class:`WidgetDescriptor` if not
called with a :py:class:`widgetastic.browser.Browser` or :py:class:`Widget` instance as the
first argument.
Args:
view_class: A subclass of :py:class:`View`
"""
return WidgetDescriptor(view_class)
@property
def is_displayed(self):
"""Overrides the :py:meth:`Widget.is_displayed`. The difference is that if the view does
not have the root locator, it assumes it is displayed.
Returns:
:py:class:`bool`
"""
try:
return super(View, self).is_displayed
except (LocatorNotImplemented, AttributeError):
return True
def move_to(self):
"""Overrides the :py:meth:`Widget.move_to`. The difference is that if the view does
not have the root locator, it returns None.
Returns:
:py:class:`selenium.webdriver.remote.webelement.WebElement` instance or ``None``.
"""
try:
return super(View, self).move_to()
except LocatorNotImplemented:
return None
def fill(self, values):
"""Implementation of form filling.
This method goes through all widgets defined on this view one by one and calls their
``fill`` methods appropriately.
``None`` values will be ignored.
It will log any skipped fill items.
It will log a warning if you pass any extra values for filling.
It will store the fill value in :py:attr:`last_fill_data`. The data will be "deflattened" to
ensure uniformity.
Args:
values: A dictionary of ``widget_name: value_to_fill``.
Returns:
:py:class:`bool` if the fill changed any value.
"""
values = deflatten_dict(values)
self.last_fill_data = values
was_change = False
b_fill = self.before_fill(values)
if b_fill is True:
# Only on True, nothing else
was_change = True
extra_keys = set(values.keys()) - set(self.widget_names)
if extra_keys:
self.logger.warning(
'Extra values that have no corresponding fill fields passed: %s',
', '.join(extra_keys))
for name in self.widget_names:
widget = getattr(self, name)
if name not in values or values[name] is None:
if name not in values:
self.logger.debug(
'Skipping fill of %r because value was not specified', name)
else:
self.logger.debug(
'Skipping fill of %r because value was None', name)
continue
try:
value = values[name]
if widget.fill(value):
was_change = True
except NotImplementedError:
continue
a_fill = self.after_fill(was_change)
if isinstance(a_fill, bool):
return a_fill
else:
return was_change
def read(self):
"""Reads the contents of the view and presents them as a dictionary.
Returns:
A :py:class:`dict` of ``widget_name: widget_read_value`` where the values are retrieved
using the :py:meth:`Widget.read`.
"""
result = {}
for widget_name in self.widget_names:
widget = getattr(self, widget_name)
try:
value = widget.read()
except (NotImplementedError, NoSuchElementException, DoNotReadThisWidget):
continue
result[widget_name] = value
return result
def before_fill(self, values):
"""A hook invoked before the loop of filling is invoked.
If it returns None, the ``was_changed`` in :py:meth:`fill` does not change. If it returns a
boolean, then on ``True`` it modifies the ``was_changed`` to True as well.
Args:
values: The same values that are passed to :py:meth:`fill`
"""
pass
def after_fill(self, was_change):
"""A hook invoked after all the widgets were filled.
If it returns None, the ``was_changed`` in :py:meth:`fill` does not change. If it returns a
boolean, that boolean will be returned as ``was_changed``.
Args:
was_change: :py:class:`bool` signalizing whether the :py:meth:`fill` changed anything,
"""
pass
class ParametrizedView(View):
"""View that needs parameters to be run.
In order to use this class, you need to specify parameters in the :py:attribute:`PARAMETERS`
attribute.
Then a parametrized view could be defined like this:
.. code-block:: python
class AView(View):
# some widgets, .... etc
class thing(ParametrizedView):
PARAMETERS = ('thing_name', )
ROOT = ParametrizedLocator('.//div[./h2[normalize-space(.)={thing_name|quote}]]')
a_widget = SomeWidget()
view = AView(browser)
This will not work:
.. code-block:: python
view.thing.a_widget # Throws an error
You now need to pass the required parameter as an argument to the view. Just like a method:
.. code-block:: python
view.a_thing(thing_name='snafu').a_widget
# Or alternatively positionally
view.a_thing('snafu').a_widget
This is enough to support the ``fill` interface. If you want to ``read`` the parametrized view
as well, you need to implement some logic which tells it what aprameters are available. That
is achieved by implementing an ``all`` classmethod on the parametrized view:
.. code-block:: python
# inside class thing(ParametrizedView):
@classmethod
def all(cls, browser):
elements = browser.elements('some locator')
# You then need to scavenge the values for PARAMETERS
# ParametrizedView expects such format of parameters:
return [ # List of all occurences of the parametrized group
('foo', ) # Tuple of all parameters that are necessary to look up the given group
('snafu', )
]
There can be any number of parameters, but bear mind that all of them are required and ``all``
must always return the same number of parameters for each group.
When filling, remember the keys of the fill dictionary are the parameters. If there is only one
parameter, it can just be the parameter itself. If there are multiple parameters for the groups,
then a tuple is expected as a key, containing all the parameter values. The values of the
dictionary are then passed into each parametrized group to be filled as an ordinary view.
"""
#: Tuple of parameter names that this view takes.
PARAMETERS = ()
@classmethod
def all(cls, browser):
"""Method that returns tuples of parameters that correspond to PARAMETERS attribute.
It is required for proper functionality of :py:meth:`read` so it knows the exact instances
of the view.
Returns:
An iterable that contains tuples. Values in the tuples must map exactly to the keys in
the PARAMETERS class attribute.
"""
raise NotImplementedError('You need to implement the all() classmethod')
class ParametrizedViewRequest(object):
"""An intermediate object handling the argument retrieval and subsequent correct view
instantiation.
See :py:class:`ParametrizedView` for more info.
"""
def __init__(self, parent_object, view_class, *args, **kwargs):
self.parent_object = parent_object
self.view_class = view_class
self.args = args
self.kwargs = kwargs
def __call__(self, *args, **kwargs):
if len(args) > len(self.view_class.PARAMETERS):
raise TypeError(
'You passed more parameters than {} accepts'.format(self.view_class.__name__))
param_dict = {}
for passed_arg, required_arg in zip(args, self.view_class.PARAMETERS):
param_dict[required_arg] = passed_arg
for key, value in kwargs.items():
if key not in self.view_class.PARAMETERS:
raise TypeError('Unknown view parameter {}'.format(key))
param_dict[key] = value
for param in self.view_class.PARAMETERS:
if param not in param_dict:
raise TypeError(
'You did not pass the required parameter {} into {}'.format(
param, self.view_class.__name__))
new_kwargs = copy(self.kwargs)
if 'additional_context' not in self.kwargs:
new_kwargs['additional_context'] = {}
new_kwargs['additional_context'].update(param_dict)
# And finally, set up a nice logger
parent_logger = self.parent_object.logger
current_name = self.view_class.__name__
# Now add the params to the name so it is class_name(args)
current_name += call_sig((), param_dict) # no args because we process everything into dict
new_kwargs['logger'] = create_child_logger(parent_logger, current_name)
result = self.view_class(self.parent_object, *self.args, **new_kwargs)
self.parent_object.child_widget_accessed(result)
return result
def __getitem__(self, int_or_slice):
"""Emulates list-like behaviour.
Maps into the dict-like structure by utilizing all() to get the list of all items and then
it picks the one selected by the list-like accessor. Supports both integers and slices.
"""
all_items = self.view_class.all(self.parent_object.browser)
items = all_items[int_or_slice]
single = isinstance(int_or_slice, int)
if single:
items = [items]
views = []
for args in items:
views.append(self(*args))
if single:
return views[0]
else:
return views
def __iter__(self):
for args in self.view_class.all(self.parent_object.browser):
yield self(*args)
def __len__(self):
return len(self.view_class.all(self.parent_object.browser))
def __getattr__(self, attr):
raise AttributeError(
'This is not an instance of {}. You need to call this object and pass the required '
'parameters of the view.'.format(self.view_class.__name__))
def read(self):
# Special handling of the parametrized views
all_presences = self.view_class.all(self.parent_object.browser)
value = {}
for param_tuple in all_presences:
# For each presence store it in a dictionary
args = param_tuple
if len(param_tuple) < 2:
# Single value - no tuple
param_tuple = param_tuple[0]
value[param_tuple] = self(*args).read()
return value
def fill(self, value):
was_change = False
if not isinstance(value, dict):
raise ValueError('When filling parametrized view a dict is required')
for param_tuple, fill_value in value.items():
if not isinstance(param_tuple, tuple):
param_tuple = (param_tuple, )
if self(*param_tuple).fill(fill_value):
was_change = True
return was_change
class ClickableMixin(object):
@logged()
def click(self, handle_alert=None):
"""Click this widget
Args:
handle_alert: Special alert handling. None - no handling, True - accept, False - dismiss
"""
self.browser.click(self, ignore_ajax=(handle_alert is not None))
if handle_alert is not None:
self.browser.handle_alert(cancel=not handle_alert, wait=2.0, squash=True)
# ignore_ajax will not execute the ensure_page_safe plugin with True
self.browser.plugin.ensure_page_safe()
class GenericLocatorWidget(Widget, ClickableMixin):
"""A base class for any widgets with a locator.
Clickable.
Args:
locator: Locator of the object ob the page.
"""
ROOT = ParametrizedLocator('{@locator}')
def __init__(self, parent, locator, logger=None):
Widget.__init__(self, parent, logger=logger)
self.locator = locator
def __repr__(self):
return '{}({!r})'.format(type(self).__name__, self.locator)
class Text(GenericLocatorWidget):
"""A widget that can represent anything that can be read from the webpage as a text content of
a tag.
Args:
locator: Locator of the object on the page.
"""
@property
def text(self):
return self.browser.text(self, parent=self.parent)
def read(self):
return self.text
class Image(GenericLocatorWidget):
"""A widget that represents an image.
Args:
locator: Locator of the object on the page.
"""
@property
def src(self):
return self.browser.get_attribute('src', self, parent=self.parent)
@property
def alt(self):
return self.browser.get_attribute('alt', self, parent=self.parent)
@property
def title(self):
return self.browser.get_attribute('title', self, parent=self.parent)
class BaseInput(Widget):
"""This represents the bare minimum to interact with bogo-standard form inputs.
Args:
name: If you want to look the input up by name, use this parameter, pass the name.
id: If you want to look the input up by id, use this parameter, pass the id.
locator: If you have specific locator, use it here.
"""
def __init__(self, parent, name=None, id=None, locator=None, logger=None):
if (locator and (name or id)) or (name and (id or locator)) or (id and (name or locator)):
raise TypeError('You can only pass one of name, id or locator!')
Widget.__init__(self, parent, logger=logger)
self.name = None
self.id = None
if name or id:
if name is not None:
id_attr = '@name={}'.format(quote(name))
self.name = name
elif id is not None:
id_attr = '@id={}'.format(quote(id))
self.id = id
self.locator = './/*[(self::input or self::textarea) and {}]'.format(id_attr)
else:
self.locator = locator
def __repr__(self):
return '{}(locator={!r})'.format(type(self).__name__, self.locator)
def __locator__(self):
return self.locator
class TextInput(BaseInput):
"""This represents the bare minimum to interact with bogo-standard text form inputs.
Args:
name: If you want to look the input up by name, use this parameter, pass the name.
id: If you want to look the input up by id, use this parameter, pass the id.
locator: If you have specific locator, use it here.
"""
@property
def value(self):
return self.browser.get_attribute('value', self)
def read(self):
return self.value
def fill(self, value):
current_value = self.value
if value == current_value:
return False
# Clear and type everything
self.browser.click(self)
self.browser.clear(self)
self.browser.send_keys(value, self)
return True
class FileInput(BaseInput):
"""This represents the file input.
Args:
name: If you want to look the input up by name, use this parameter, pass the name.
id: If you want to look the input up by id, use this parameter, pass the id.
locator: If you have specific locator, use it here.
"""
def read(self):
raise DoNotReadThisWidget()
def fill(self, value):
with self.browser.selenium.file_detector_context(LocalFileDetector):
self.browser.send_keys(value, self)
return True
class ColourInput(BaseInput):
"""Represents the input for inputting colour values.
Args:
name: If you want to look the input up by name, use this parameter, pass the name.
id: If you want to look the input up by id, use this parameter, pass the id.
locator: If you have specific locator, use it here.
"""
@property
def colour(self):
return self.browser.execute_script('return arguments[0].value;', self)
@colour.setter
def colour(self, value):
self.browser.execute_script(jsmin('''
arguments[0].value = arguments[1];
if(arguments[0].onchange !== null) {
arguments[0].onchange();
}
'''), self, value)
def read(self):
return self.colour
def fill(self, value):
if self.colour == value:
return False
self.colour = value
return True
class Checkbox(BaseInput, ClickableMixin):
"""This widget represents the bogo-standard form checkbox.
Args:
name: If you want to look the input up by name, use this parameter, pass the name.
id: If you want to look the input up by id, use this parameter, pass the id.
locator: If you have specific locator, use it here.
"""
@property
def selected(self):
return self.browser.is_selected(self)
def read(self):
return self.selected
def fill(self, value):
value = bool(value)
current_value = self.selected
if value == current_value:
return False
else:
self.click()
if self.selected != value:
# TODO: More verbose here
raise WidgetOperationFailed('Failed to set the checkbox to requested value.')
return True
class TableColumn(Widget, ClickableMixin):
"""Represents a cell in the row."""
def __init__(self, parent, position, logger=None):
Widget.__init__(self, parent, logger=logger)
self.position = position
def __locator__(self):
return self.browser.element('./td[{}]'.format(self.position + 1), parent=self.parent)
def __repr__(self):
return '{}({!r}, {!r})'.format(type(self).__name__, self.parent, self.position)
@property
def column_name(self):
"""If there is a name associated with this column, return it. Otherwise returns None."""
try:
return self.row.position_to_column_name(self.position)
except KeyError:
return None
@cached_property
def widget(self):
"""Returns the associated widget if defined. If there is none defined, returns None."""
args = ()
kwargs = {}
if self.column_name is None:
if self.position not in self.table.column_widgets:
return None
wcls = self.table.column_widgets[self.position]
else:
if self.column_name not in self.table.column_widgets:
return None
wcls = self.table.column_widgets[self.column_name]
# Verpick, ...
if isinstance(wcls, ConstructorResolvable):
return wcls.resolve(self)
# We cannot use WidgetDescriptor's facility for instantiation as it does caching and all
# that stuff
if isinstance(wcls, WidgetDescriptor):
args = wcls.args
kwargs = wcls.kwargs
wcls = wcls.klass
kwargs = copy(kwargs)
if 'logger' not in kwargs:
kwargs['logger'] = create_child_logger(self.logger, wcls.__name__)
return wcls(self, *args, **kwargs)
@property
def text(self):
return self.browser.text(self)
@property
def row(self):
return self.parent
@property
def table(self):
return self.row.table
def read(self):
"""Reads the content of the cell. If widget is present and visible, it is read, otherwise
the text of the cell is returned.
"""
if self.widget is not None and self.widget.is_displayed:
return self.widget.read()
else:
return self.text
def fill(self, value):
"""Fills the cell with the value if the widget is present. If not, raises a TypeError."""
if self.widget is not None:
return self.widget.fill(value)
else:
if self.text == str(value):
self.logger.debug(
'Not filling %d because it already has value %r even though there is no widget',
self.column_name or self.position,
value)
return False
else:
raise TypeError(
(
'Cannot fill column {}, no widget and the value differs '
'(wanted to fill {!r} but there is {!r}').format(
self.column_name or self.position, value, self.text))
class TableRow(Widget, ClickableMixin):
"""Represents a row in the table.
If subclassing and also changing the Column class, do not forget to set the Column to the new
class.
Args:
index: Position of the row in the table.
"""
Column = TableColumn
def __init__(self, parent, index, logger=None):
Widget.__init__(self, parent, logger=logger)
self.index = index
@property
def table(self):
return self.parent
def __repr__(self):
return '{}({!r}, {!r})'.format(type(self).__name__, self.parent, self.index)
def __locator__(self):
loc = self.parent.ROW_AT_INDEX.format(self.index + 1)
return self.browser.element(loc, parent=self.parent)
def position_to_column_name(self, position):
"""Maps the position index into the column name (pretty)"""
return self.table.index_header_mapping[position]
def __getitem__(self, item):
if isinstance(item, int):
return self.Column(self, item, logger=create_item_logger(self.logger, item))
elif isinstance(item, six.string_types):
index = self.table.header_index_mapping[self.table.ensure_normal(item)]
return self.Column(self, index, logger=create_item_logger(self.logger, item))
else:
raise TypeError('row[] accepts only integers and strings')
def __getattr__(self, attr):
try:
return self[self.table.ensure_normal(attr)]
except KeyError:
raise AttributeError('Cannot find column {} in the table'.format(attr))
def __dir__(self):
result = super(TableRow, self).__dir__()
result.extend(self.table.attributized_headers.keys())
return sorted(result)
def __iter__(self):
for i, header in enumerate(self.table.headers):
yield header, self[i]
def read(self):
"""Read the row - the result is a dictionary"""
result = {}
for i, (header, cell) in enumerate(self):
if header is None:
header = i
result[header] = cell.read()
return result
def fill(self, value):
"""Row filling.
Accepts either a dictionary or an iterable that can be zipped with headers to create a dict.
"""
if isinstance(value, (list, tuple)):
# make it a dict
value = dict(zip(self.table.headers, value))
elif not isinstance(value, dict):
if self.table.assoc_column_position is None:
raise ValueError(
'For filling rows with single value you need to specify assoc_column')
value = {self.table.assoc_column_position: value}
changed = False
for key, value in value.items():
if value is None:
self.logger.info('Skipping fill of %r because the value is None', key)
continue
else:
self.logger.info('Filling column %r', key)
# if the row widgets aren't visible the row needs to be clicked to edit
if hasattr(self.parent, 'action_row') and getattr(self[key], 'widget', False):
if not self[key].widget.is_displayed:
self.click()
if self[key].fill(value):
changed = True
return changed
class Table(Widget):
"""Basic table-handling class.
Usage is as follows assuming the table is instantiated as ``view.table``:
.. code-block:: python
# List the headers
view.table.headers # => (None, 'something', ...)
# Access rows by their position
view.table[0] # => gives you the first row
# Or you can iterate through rows simply
for row in view.table:
do_something()
# You can filter rows
# The column names are "attributized"
view.table.rows(column_name='asdf') # All rows where asdf is in "Column Name"
# And with Django fashion:
view.table.rows(column_name__contains='asdf')
view.table.rows(column_name__startswith='asdf')
view.table.rows(column_name__endswith='asdf')
# You can put multiple filters together.
# And you can of course query a songle row
row = view.table.row(column_name='asdf')
# You can also look the rows up by their indices
rows = view.table.rows((0, 'asdf')) # First column has asdf exactly
rows = view.table.rows((1, 'contains', 'asdf')) # Second column contains asdf
# The partial search methods are the same like for keywords.
# You can add multiple tuple queries and also combine them with keyword search
# You are also able to filter based on some row-based filters
# Yield only those rows who have data-foo=bar in their tr:
view.table.rows(_row__attr=('data-foo', 'bar'))
# You can do it similarly for the other operations
view.table.rows(_row__attr_startswith=('data-foo', 'bar'))
view.table.rows(_row__attr_endswith=('data-foo', 'bar'))
view.table.rows(_row__attr_contains=('data-foo', 'bar'))
# First item in the tuple is the attribute name, second the operand of the operation.
# It is perfectly possibly to combine these queries with other kinds
# When you have a row, you can do these things.
row[0] # => gives you the first column cell in the row
row['Column Name'] # => Gives you the column that is named "Column Name". Non-attributized
row.column_name # => Gives you the column whose attributized name is "column_name"
# Basic row column can give you text
assert row.column_name.text == 'some text'
# Or you can click at it
assert row.column_name.click()
# Table cells can contain widgets or whole groups of widgets:
Table(locator, column_widgets={column_name_or_index: widget_class_or_definition, ...})
# The on TableColumn instances you can access .widget
# This is also taken into account with reading or filling
# For filling such table, fill takes a list, one entry per row, goes from start
table.fill([{'Column1': 'value1'}, ...])
# You can also designate one column as "special" associative column using assoc_column
# You can specify it with column name
Table(locator, column_widgets={...}, assoc_column='Display Name')
# Or by the column index
Table(locator, column_widgets={...}, assoc_column=0)
# When you use assoc_column, you can use dictionary instead of the list, which means that
# you can pick the rows to fill by the value in given column.
# The same example as previous article
table.fill({'foo': {'Column1': 'value1'}}) # Given that the assoc_column column has 'foo'
# on that line
If you subclass :py:class:`Table`, :py:class:`TableRow`, or :py:class:`TableColumn`, do not
forget to update the :py:attr:`Table.Row` and :py:attr:`TableRow.Column` in order for the
classes to use the correct class.
Args:
locator: A locator to the table ``<table>`` tag.
column_widgets: A mapping to widgets that are present in cells. Keys signify column name,
value is the widget definition.
assoc_column: Index or name of the column used for associative filling.
rows_ignore_top: Number of rows to ignore from top when reading/filling.
rows_ignore_bottom: Number of rows to ignore from bottom when reading/filling.
top_ignore_fill: Whether to also strip these top rows for fill.
bottom_ignore_fill: Whether to also strip these top rows for fill.
"""
ROWS = './tbody/tr[./td]|./tr[not(./th) and ./td]'
HEADER_IN_ROWS = './tbody/tr[1]/th'
HEADERS = './thead/tr/th|./tr/th' + '|' + HEADER_IN_ROWS
ROW_AT_INDEX = './tbody/tr[{0}]|./tr[not(./th)][{0}]'
ROOT = ParametrizedLocator('{@locator}')
Row = TableRow
def __init__(
self, parent, locator, column_widgets=None, assoc_column=None,
rows_ignore_top=None, rows_ignore_bottom=None, top_ignore_fill=False,
bottom_ignore_fill=False, logger=None):
Widget.__init__(self, parent, logger=logger)
self.locator = locator
self.column_widgets = column_widgets or {}
self.assoc_column = assoc_column
self.rows_ignore_top = rows_ignore_top
self.rows_ignore_bottom = rows_ignore_bottom
self.top_ignore_fill = top_ignore_fill
self.bottom_ignore_fill = bottom_ignore_fill
def __repr__(self):
return (
'{}({!r}, column_widgets={!r}, assoc_column={!r}, rows_ignore_top={!r}, '
'rows_ignore_bottom={!r})').format(
type(self).__name__, self.locator, self.column_widgets, self.assoc_column,
self.rows_ignore_top, self.rows_ignore_bottom)
def _process_negative_index(self, nindex):
"""The semantics is pretty much the same like for ordinary list."""
rc = self.row_count
if (- nindex) > rc:
raise ValueError(
'Negative index {} wanted but we only have {} rows'.format(nindex, rc))
return rc + nindex
def clear_cache(self):
"""Clear all cached properties."""
for item in [
'headers', 'attributized_headers', 'header_index_mapping', 'index_header_mapping',
'assoc_column_position']:
try:
delattr(self, item)
except AttributeError:
pass
@cached_property
def headers(self):
result = []
for header in self.browser.elements(self.HEADERS, parent=self):
result.append(self.browser.text(header).strip() or None)
without_none = [x for x in result if x is not None]
if len(without_none) != len(set(without_none)):
self.logger.warning(
'Detected duplicate headers in %r. Correct functionality is not guaranteed',
without_none)
return tuple(result)
def ensure_normal(self, name):
"""When you pass string in, it ensures it comes out as non-attributized string."""
if name in self.attributized_headers:
return self.attributized_headers[name]
else:
return name
@cached_property
def attributized_headers(self):
"""Contains mapping between attributized headers and pretty headers"""
return {attributize_string(h): h for h in self.headers if h is not None}
@cached_property
def header_index_mapping(self):
"""Contains mapping between header name (pretty) and position index."""
return {h: i for i, h in enumerate(self.headers) if h is not None}
@cached_property
def index_header_mapping(self):
"""Contains mapping between hposition index and header name (pretty)."""
return {i: h for h, i in self.header_index_mapping.items()}
@cached_property
def assoc_column_position(self):
"""Returns the position of the column specified as associative. If not specified, None
returned.
"""
if self.assoc_column is None:
return None
elif isinstance(self.assoc_column, int):
return self.assoc_column
elif isinstance(self.assoc_column, six.string_types):
if self.assoc_column in self.attributized_headers:
header = self.attributized_headers[self.assoc_column]
elif self.assoc_column in self.headers:
header = self.assoc_column
else:
raise ValueError(
'Could not find the assoc_value={!r} in headers'.format(self.assoc_column))
return self.header_index_mapping[header]
else:
raise TypeError(
'Wrong type passed for assoc_column= : {}'.format(type(self.assoc_column).__name__))
def __getitem__(self, item):
if isinstance(item, six.string_types):
if self.assoc_column is None:
raise TypeError('You cannot use string indices when no assoc_column specified!')
try:
row = self.row((self.assoc_column, item))
except RowNotFound:
raise KeyError(
'Row {!r} not found in table by associative column {!r}'.format(
item, self.assoc_column))
at_index = row.index
elif isinstance(item, int):
at_index = item
else:
raise TypeError('Table [] accepts only strings or integers.')
if at_index < 0:
# To mimic the list handling
at_index = self._process_negative_index(at_index)
return self.Row(self, at_index, logger=create_item_logger(self.logger, item))
def row(self, *extra_filters, **filters):
try:
return six.next(self.rows(*extra_filters, **filters))
except StopIteration:
raise RowNotFound(
'Row not found when using filters {!r}/{!r}'.format(extra_filters, filters))
def __iter__(self):
return self.rows()
def _get_number_preceeding_rows(self, row_el):
"""This is a sort of trick that helps us remove stale element errors.
We know that correct tables only have ``<tr>`` elements next to each other. We do not want
to pass around webelements because they can get stale. Therefore this trick will give us the
number of elements that precede this element, effectively giving us the index of the row.
How simple.
"""
return self.browser.execute_script(
jsmin("""\
var p = []; var e = arguments[0];
while (e.previousElementSibling)
p.push(e = e.previousElementSibling);
return p.length;
"""), row_el, silent=True)
def map_column(self, column):
"""Return column position. Can accept int, normal name, attributized name."""
if isinstance(column, int):
return column
else:
try:
return self.header_index_mapping[self.attributized_headers[column]]
except KeyError:
try:
return self.header_index_mapping[column]
except KeyError:
raise NameError('Could not find column {!r} in the table'.format(column))
@cached_property
def _is_header_in_body(self):
"""Checks whether the header is erroneously specified in the body of table."""
return len(self.browser.elements(self.HEADER_IN_ROWS, parent=self)) > 0
def rows(self, *extra_filters, **filters):
if not (filters or extra_filters):
return self._all_rows()
else:
return self._filtered_rows(*extra_filters, **filters)
def _all_rows(self):
for row_pos in range(len(self.browser.elements(self.ROWS, parent=self))):
row_pos = row_pos if not self._is_header_in_body else row_pos + 1
yield self.Row(self, row_pos, logger=create_item_logger(self.logger, row_pos))
def _filtered_rows(self, *extra_filters, **filters):
# Pre-process the filters
processed_filters = defaultdict(list)
regexp_filters = []
row_filters = []
for filter_column, filter_value in six.iteritems(filters):
if filter_column.startswith('_row__'):
row_filters.append((filter_column.split('__', 1)[-1], filter_value))
continue
if '__' in filter_column:
column, method = filter_column.rsplit('__', 1)
else:
column = filter_column
method = None
if isinstance(filter_value, re._pattern_type):
regexp_filters.append((self.map_column(column), filter_value))
continue
processed_filters[self.map_column(column)].append((method, filter_value))
for argfilter in extra_filters:
if not isinstance(argfilter, (tuple, list)):
raise TypeError('Wrong type passed into tuplefilters (expected tuple or list)')
if len(argfilter) == 2:
# Column / string match
column, value = argfilter
method = None
if isinstance(value, re._pattern_type):
regexp_filters.append((self.map_column(column), value))
continue
elif len(argfilter) == 3:
# Column / method / string match
column, method, value = argfilter
else:
raise ValueError(
'tuple filters can only be (column, string) or (column, method, string)')
processed_filters[self.map_column(column)].append((method, value))
# Build the query
query_parts = []
for column_index, matchers in six.iteritems(processed_filters):
col_query_parts = []
for method, value in matchers:
if method is None:
# equals
q = 'normalize-space(.)=normalize-space({})'.format(quote(value))
elif method == 'contains':
# in
q = 'contains(normalize-space(.), normalize-space({}))'.format(quote(value))
elif method == 'startswith':
# starts with
q = 'starts-with(normalize-space(.), normalize-space({}))'.format(quote(value))
elif method == 'endswith':
# ends with
# This needs to be faked since selenium does not support this feature.
q = (
'substring(normalize-space(.), '
'string-length(normalize-space(.)) - string-length({0}) + 1)={0}').format(
'normalize-space({})'.format(quote(value)))
else:
raise ValueError('Unknown method {}'.format(method))
col_query_parts.append(q)
query_parts.append(
'./td[{}][{}]'.format(column_index + 1, ' and '.join(col_query_parts)))
# Row query
row_parts = []
for row_action, row_value in row_filters:
row_action = row_action.lower()
if row_action.startswith('attr'):
try:
attr_name, attr_value = row_value
except ValueError:
raise ValueError(
'When passing _row__{}= into the row filter, you must pass it a 2-tuple'
.format(row_action))
if row_action == 'attr_startswith':
row_parts.append('starts-with(@{}, {})'.format(attr_name, quote(attr_value)))
elif row_action == 'attr':
row_parts.append('@{}={}'.format(attr_name, quote(attr_value)))
elif row_action == 'attr_endswith':
row_parts.append(
('substring(@{attr}, '
'string-length(@{attr}) - string-length({value}) + 1)={value}').format(
attr=attr_name,
value='normalize-space({value})'.format(value=quote(attr_value))))
elif row_action == 'attr_contains':
row_parts.append('contains(@{}, {})'.format(attr_name, quote(attr_value)))
else:
raise ValueError('Unsupported action {}'.format(row_action))
else:
raise ValueError('Unsupported action {}'.format(row_action))
if query_parts and row_parts:
query = './/tr[{}][{}]'.format(' and '.join(row_parts), ' and '.join(query_parts))
elif query_parts:
query = './/tr[{}]'.format(' and '.join(query_parts))
elif row_parts:
query = './/tr[{}]'.format(' and '.join(row_parts))
else:
# When using ONLY regexps, we might see no query_parts, therefore default query
query = self.ROWS
# Preload the rows to prevent stale element exceptions
rows = []
for row_element in self.browser.elements(query, parent=self):
row_pos = self._get_number_preceeding_rows(row_element)
row_pos = row_pos if not self._is_header_in_body else row_pos + 1
rows.append(self.Row(self, row_pos, logger=create_item_logger(self.logger, row_pos)))
for row in rows:
if regexp_filters:
for regexp_column, regexp_filter in regexp_filters:
if regexp_filter.search(row[regexp_column].text) is None:
break
else:
yield row
else:
yield row
def row_by_cell_or_widget_value(self, column, key):
"""Row queries do not work with embedded widgets. Therefore you can use this method.
Args:
column: Position or name fo the column where you are looking the value for.
key: The value looked for
Returns:
:py:class:`TableRow` instance
Raises:
:py:class:`RowNotFound`
"""
try:
return self.row((column, key))
except RowNotFound:
for row in self.rows():
if row[column].widget is None:
continue
if not row[column].widget.is_displayed:
continue
if row[column].widget.read() == key:
return row
else:
raise RowNotFound('Row not found by {!r}/{!r}'.format(column, key))
def read(self):
"""Reads the table. Returns a list, every item in the list is contents read from the row."""
rows = list(self)
# Cut the unwanted rows if necessary
if self.rows_ignore_top is not None:
rows = rows[self.rows_ignore_top:]
if self.rows_ignore_bottom is not None and self.rows_ignore_bottom > 0:
rows = rows[:-self.rows_ignore_bottom]
if self.assoc_column_position is None:
return [row.read() for row in rows]
else:
result = {}
for row in rows:
row_read = row.read()
try:
key = row_read.pop(self.header_index_mapping[self.assoc_column_position])
except KeyError:
try:
key = row_read.pop(self.assoc_column_position)
except KeyError:
try:
key = row_read.pop(self.assoc_column)
except KeyError:
raise ValueError(
'The assoc_column={!r} could not be retrieved'.format(
self.assoc_column))
if key in result:
raise ValueError('Duplicate value for {}={!r}'.format(key, result[key]))
result[key] = row_read
return result
def fill(self, value):
"""Fills the table, accepts list which is dispatched to respective rows."""
if isinstance(value, dict):
if self.assoc_column_position is None:
raise TypeError('In order to support dict you need to specify assoc_column')
changed = False
for key, fill_value in six.iteritems(value):
try:
row = self.row_by_cell_or_widget_value(self.assoc_column_position, key)
except RowNotFound:
row = self[self.row_add()]
fill_value = copy(fill_value)
fill_value[self.assoc_column_position] = key
if row.fill(fill_value):
self.row_save(row=row.index)
changed = True
return changed
else:
if not isinstance(value, (list, tuple)):
value = [value]
total_values = len(value)
rows = list(self)
# Adapt the behaviour similar to read
if self.top_ignore_fill and self.rows_ignore_top is not None:
rows = rows[self.rows_ignore_top:]
if (
self.bottom_ignore_fill and
self.rows_ignore_bottom is not None and
self.rows_ignore_bottom > 0):
rows = rows[:-self.rows_ignore_bottom]
row_count = len(rows)
present_row_values = value[:row_count]
if total_values > row_count:
extra_row_values = value[row_count:]
else:
extra_row_values = []
changed = any(row.fill(value) for row, value in zip(rows, present_row_values))
for extra_value in extra_row_values:
if self[self.row_add()].fill(extra_value):
changed = True
return changed
@property
def row_count(self):
"""Returns how many rows are currently in the table."""
return len(self.browser.elements(self.ROWS, parent=self))
def row_add(self):
"""To be implemented if the table has dynamic rows.
This method is called when adding a new row is necessary.
Default implementation shouts :py:class:`NotImplementedError`.
Returns:
An index (position) of the new row. ``None`` in case of error.
"""
raise NotImplementedError(
'You need to implement the row_add in order to use dynamic adding')
def row_save(self, row=None):
"""To be implemented if the table has dynamic rows.
Used when the table needs confirming saving of each row.
Default implementation just writes a debug message that it is not used.
"""
self.logger.debug('Row saving not used.')
class Select(Widget):
"""Representation of the bogo-standard ``<select>`` tag.
Check documentation for each method. The API is based on the selenium select, but modified so
we don't bother with WebElements.
Fill and read work as follows:
.. code-block:: python
view.select.fill('foo')
view.select.fill(['foo'])
# Are equivalent
This implies that you can fill either single value or multiple values. If you need to fill
the select using the value and not the text, you can pass a tuple instead of the string like
this:
.. code-block:: python
view.select.fill(('by_value', 'some_value'))
# Or if you have multiple items
view.select.fill([('by_value', 'some_value'), 'something by text', ...])
The :py:meth:`read` returns a :py:class:`list` in case the select is multiselect, otherwise it
returns the value directly.
Arguments are exclusive, so only one at time can be used.
Args:
locator: If you have a full locator to locate this select.
id: If you want to locate the select by the ID
name: If you want to locate the select by name.
Raises:
:py:class:`TypeError` - if you pass more than one of the abovementioned args.
"""
Option = namedtuple("Option", ["text", "value"])
ALL_OPTIONS = jsmin('''\
var result_arr = [];
var opt_elements = arguments[0].options;
for(var i = 0; i < opt_elements.length; i++){
var option = opt_elements[i];
result_arr.push([option.innerHTML, option.getAttribute("value")]);
}
return result_arr;
''')
SELECTED_OPTIONS = jsmin('return arguments[0].selectedOptions;')
SELECTED_OPTIONS_TEXT = jsmin('''\
var result_arr = [];
var opt_elements = arguments[0].selectedOptions;
for(var i = 0; i < opt_elements.length; i++){
result_arr.push(opt_elements[i].innerHTML);
}
return result_arr;
''')
SELECTED_OPTIONS_VALUE = jsmin('''\
var result_arr = [];
var opt_elements = arguments[0].selectedOptions;
for(var i = 0; i < opt_elements.length; i++){
result_arr.push(opt_elements[i].getAttribute("value"));
}
return result_arr;
''')
def __init__(self, parent, locator=None, id=None, name=None, logger=None):
Widget.__init__(self, parent, logger=logger)
if (locator and id) or (id and name) or (locator and name):
raise TypeError('You can only pass one of the params locator, id, name into Select')
if locator is not None:
self.locator = locator
elif id is not None:
self.locator = './/select[@id={}]'.format(quote(id))
else: # name
self.locator = './/select[@name={}]'.format(quote(name))
def __locator__(self):
return self.locator
def __repr__(self):
return '{}(locator={!r})'.format(type(self).__name__, self.locator)
@cached_property
def is_multiple(self):
"""Detects and returns whether this ``<select>`` is multiple"""
return self.browser.get_attribute('multiple', self) is not None
@property
def classes(self):
"""Returns the classes associated with the select."""
return self.browser.classes(self)
@property
def all_options(self):
"""Returns a list of tuples of all the options in the Select.
Text first, value follows.
Returns:
A :py:class:`list` of :py:class:`Option`
"""
# More reliable using javascript
options = self.browser.execute_script(self.ALL_OPTIONS, self.browser.element(self))
parser = html_parser.HTMLParser()
return [
self.Option(normalize_space(parser.unescape(option[0])), option[1])
for option in options]
@property
def all_selected_options(self):
"""Returns a list of all selected options as their displayed texts."""
parser = html_parser.HTMLParser()
return [
normalize_space(parser.unescape(option))
for option
in self.browser.execute_script(self.SELECTED_OPTIONS_TEXT, self.browser.element(self))]
@property
def all_selected_values(self):
"""Returns a list of all selected options as their values.
If the value is not present, it is ignored.
"""
values = self.browser.execute_script(
self.SELECTED_OPTIONS_VALUE,
self.browser.element(self))
return [value for value in values if value is not None]
@property
def first_selected_option(self):
"""Returns the first selected option (or the only selected option)
Raises:
:py:class:`ValueError` - in case there is not item selected.
"""
try:
return self.all_selected_options[0]
except IndexError:
raise ValueError("No options are selected")
def deselect_all(self):
"""Deselect all items. Only works for multiselect.
Raises:
:py:class:`NotImplementedError` - If you call this on non-multiselect.
"""
if not self.is_multiple:
raise NotImplementedError("You may only deselect all options of a multi-select")
for opt in self.browser.execute_script(self.SELECTED_OPTIONS, self.browser.element(self)):
self.browser.raw_click(opt)
def get_value_by_text(self, text):
"""Given the visible text, retrieve the underlying value."""
opt = self.browser.element(
".//option[normalize-space(.)={}]".format(quote(normalize_space(text))),
parent=self)
return self.browser.get_attribute("value", opt)
def select_by_value(self, *items):
"""Selects item(s) by their respective values in the select.
Args:
*items: Items' values to be selected.
Raises:
:py:class:`ValueError` - if you pass multiple values and the select is not multiple.
:py:class:`ValueError` - if the value was not found.
"""
if len(items) > 1 and not self.is_multiple:
raise ValueError(
'The Select {!r} does not allow multiple selections'.format(self))
for value in items:
matched = False
for opt in self.browser.elements(
'.//option[@value={}]'.format(quote(value)),
parent=self):
if not opt.is_selected():
opt.click()
if not self.is_multiple:
return
matched = True
if not matched:
raise ValueError("Cannot locate option with value: {!r}".format(value))
def select_by_visible_text(self, *items):
"""Selects item(s) by their respective displayed text in the select.
Args:
*items: Items' visible texts to be selected.
Raises:
:py:class:`ValueError` - if you pass multiple values and the select is not multiple.
:py:class:`ValueError` - if the text was not found.
"""
if len(items) > 1 and not self.is_multiple:
raise ValueError(
'The Select {!r} does not allow multiple selections'.format(self))
for text in items:
matched = False
for opt in self.browser.elements(
'.//option[normalize-space(.)={}]'.format(quote(normalize_space(text))),
parent=self):
if not opt.is_selected():
opt.click()
if not self.is_multiple:
return
matched = True
if not matched:
available = ", ".join(repr(opt.text) for opt in self.all_options)
raise ValueError(
"Cannot locate option with visible text: {!r}. Available options: {}".format(
text, available))
def read(self):
items = self.all_selected_options
if self.is_multiple:
return items
else:
try:
return items[0]
except IndexError:
return None
def fill(self, item_or_items):
if item_or_items is None:
items = []
elif isinstance(item_or_items, list):
items = item_or_items
else:
items = [item_or_items]
selected_values = self.all_selected_values
selected_options = self.all_selected_options
options_to_select = []
values_to_select = []
deselect = True
for item in items:
if isinstance(item, tuple):
try:
mod, value = item
if not isinstance(mod, six.string_types):
raise ValueError('The select modifier must be a string')
mod = mod.lower()
except ValueError:
raise ValueError('If passing tuples into the S.fill(), they must be 2-tuples')
else:
mod = 'by_text'
value = item
if mod == 'by_text':
value = normalize_space(value)
if value in selected_options:
deselect = False
continue
options_to_select.append(value)
elif mod == 'by_value':
if value in selected_values:
deselect = False
continue
values_to_select.append(value)
else:
raise ValueError('Unknown select modifier {}'.format(mod))
if deselect:
try:
self.deselect_all()
deselected = bool(selected_options or selected_values)
except NotImplementedError:
deselected = False
else:
deselected = False
if options_to_select:
self.select_by_visible_text(*options_to_select)
if values_to_select:
self.select_by_value(*values_to_select)
return bool(options_to_select or values_to_select or deselected)
class ConditionalSwitchableView(Widgetable):
"""Conditional switchable view implementation.
This widget proxy is useful when you have a form whose parts displayed depend on certain
conditions. Eg. when you select certain value from a dropdown, one form is displayed next,
when other value is selected, a different form is displayed next. This widget proxy is designed
to register those multiple views and then upon accessing decide which view to use based on the
registration conditions.
The resulting widget proxy acts similarly like a nested view (if you use view of course).
Example:
.. code-block:: python
class SomeForm(View):
foo = Input('...')
action_type = Select(name='action_type')
action_form = ConditionalSwitchableView(reference='action_type')
# Simple value matching. If Action type 1 is selected in the select, use this view.
# And if the action_type value does not get matched, use this view as default
@action_form.register('Action type 1', default=True)
class ActionType1Form(View):
widget = Widget()
# You can use a callable to declare the widget values to compare
@action_form.register(lambda action_type: action_type == 'Action type 2')
class ActionType2Form(View):
widget = Widget()
# With callable, you can use values from multiple widgets
@action_form.register(
lambda action_type, foo: action_type == 'Action type 2' and foo == 2)
class ActionType2Form(View):
widget = Widget()
You can see it gives you the flexibility of decision based on the values in the view.
Args:
reference: For using non-callable conditions, this must be specified. Specifies the name of
the widget whose value will be used for comparing non-callable conditions. Supports
going across objects using ``.``.
ignore_bad_reference: If this is enabled, then when the widget representing the reference
is not displayed or otherwise broken, it will then use the default view.
"""
def __init__(self, reference=None, ignore_bad_reference=False):
self.reference = reference
self.registered_views = []
self.default_view = None
self.ignore_bad_reference = ignore_bad_reference
@property
def child_items(self):
return [
descriptor
for _, descriptor
in self.registered_views
if isinstance(descriptor, WidgetDescriptor)]
def register(self, condition, default=False, widget=None):
"""Register a view class against given condition.
Args:
condition: Condition check for switching to appropriate view. Can be callable or
non-callable. If callable, then callable parameters are resolved as values from
widgets resolved by the argument name, then the callable is invoked with the params.
If the invocation result is truthy, that view class is used. If it is a non-callable
then it is compared with the value read from the widget specified as ``reference``.
default: If no other condition matches any registered view, use this one. Can only be
specified for one registration.
widget: In case you do not want to use this as a decorator, you can pass the widget
class or instantiated widget as this parameter.
"""
def view_process(cls_or_descriptor):
if not (
isinstance(cls_or_descriptor, WidgetDescriptor) or
(inspect.isclass(cls_or_descriptor) and issubclass(cls_or_descriptor, Widget))):
raise TypeError(
'Unsupported object registered into the selector (!r})'.format(
cls_or_descriptor))
self.registered_views.append((condition, cls_or_descriptor))
if default:
if self.default_view is not None:
raise TypeError('Multiple default views specified')
self.default_view = cls_or_descriptor
# We explicitly return None
return None
if widget is None:
return view_process
else:
return view_process(widget)
def __get__(self, o, t):
if o is None:
return self
condition_arg_cache = {}
for condition, cls_or_descriptor in self.registered_views:
if not callable(condition):
# Compare it to a known value (if present)
if self.reference is None:
# No reference to check against
raise TypeError(
'reference= not set so you cannot use non-callables as conditions')
else:
if self.reference not in condition_arg_cache:
try:
ref_o = nested_getattr(o, self.reference)
if isinstance(ref_o, Widget):
ref_value = ref_o.read()
else:
ref_value = ref_o
condition_arg_cache[self.reference] = ref_value
except AttributeError:
raise TypeError(
'Wrong widget name specified as reference=: {}'.format(
self.reference))
except NoSuchElementException:
if self.ignore_bad_reference:
# reference is not displayed? We are probably aware of this so skip.
continue
else:
raise
if condition == condition_arg_cache[self.reference]:
view_object = cls_or_descriptor
break
else:
# Parse the callable's args and inject the correct args
c_args, c_varargs, c_keywords, c_defaults = inspect.getargspec(condition)
if c_varargs or c_keywords or c_defaults:
raise TypeError('You can only use simple arguments in lambda conditions')
arg_values = []
for arg in c_args:
if arg not in condition_arg_cache:
try:
condition_arg_cache[arg] = getattr(o, arg).read()
except AttributeError:
raise TypeError(
'Wrong widget name specified as parameter {}'.format(arg))
arg_values.append(condition_arg_cache[arg])
if condition(*arg_values):
view_object = cls_or_descriptor
break
else:
if self.default_view is not None:
view_object = self.default_view
else:
raise ValueError('Could not find a corresponding registered view.')
if inspect.isclass(view_object):
view_class = view_object
else:
view_class = type(view_object)
o.logger.info('Picked %s', view_class.__name__)
if isinstance(view_object, Widgetable):
# We init the widget descriptor here
return view_object.__get__(o, t)
else:
return view_object(o, additional_context=o.context)
class WTMixin(six.with_metaclass(WidgetMetaclass, object)):
"""Base class for mixins for views.
Lightweight class that only has the bare minimum of what is required for widgetastic operation.
Use this if you want to create mixins for views.
"""