-
Notifications
You must be signed in to change notification settings - Fork 42
/
widget.py
2422 lines (2010 loc) · 92.8 KB
/
widget.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- 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 time import sleep
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 = {}
self._cache_highlight = None
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 _do_highlight(self, border):
self.browser.execute_script(
"arguments[0].style.border='{}'".format(border), self.__element__()
)
def highlight(self, timeout=5, colour='red'):
cache_border = self.browser.execute_script("arguments[0].style.border", self.__element__())
self._do_highlight('3px solid {}'.format(colour))
sleep(timeout)
self._do_highlight(cache_border)
def highlight_on(self, colour='red'):
self._cache_highlight = self.browser.execute_script("arguments[0].style.border", self.__element__())
self._do_highlight('3px solid {}'.format(colour))
def highlight_off(self):
self._do_highlight(self._cache_highlight)
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