selenium-query 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- selenium_query/__init__.py +10 -0
- selenium_query/_any_all_getters.py +17 -0
- selenium_query/_asserter_getter.py +90 -0
- selenium_query/_basics/__init__.py +0 -0
- selenium_query/_basics/action_getter.py +362 -0
- selenium_query/_basics/base_getter.py +177 -0
- selenium_query/_basics/boolean_getter.py +36 -0
- selenium_query/_basics/generic_value_getter.py +119 -0
- selenium_query/_filter_getter.py +53 -0
- selenium_query/_mapper_getter.py +226 -0
- selenium_query/_negation_transmitter.py +40 -0
- selenium_query/_order_getter.py +70 -0
- selenium_query/_types_and_tools.py +93 -0
- selenium_query/_values_getter.py +78 -0
- selenium_query/getter.py +61 -0
- selenium_query-0.1.0.dist-info/METADATA +19 -0
- selenium_query-0.1.0.dist-info/RECORD +18 -0
- selenium_query-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
from ._types_and_tools import center_of
|
|
3
|
+
from .getter import Getter
|
|
4
|
+
from ._any_all_getters import BoolAnyGetter, BoolAllGetter
|
|
5
|
+
from ._values_getter import ValuesGetter
|
|
6
|
+
from ._asserter_getter import AsserterGetter
|
|
7
|
+
from ._filter_getter import FilterGetter
|
|
8
|
+
from ._order_getter import OrderGetter
|
|
9
|
+
from ._mapper_getter import MapperGetter
|
|
10
|
+
from ._negation_transmitter import NotTransmitter
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Callable, ClassVar
|
|
3
|
+
|
|
4
|
+
from .getter import Getter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Explicite redeclaration, to register properly the accessor:
|
|
9
|
+
class BoolAllGetter(Getter, accessor="all"):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BoolAnyGetter(BoolAllGetter, accessor="any"):
|
|
15
|
+
|
|
16
|
+
COMBINER: ClassVar[Callable] = any
|
|
17
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from textwrap import dedent
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
5
|
+
|
|
6
|
+
from ._basics.generic_value_getter import should_be
|
|
7
|
+
from .getter import Getter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsserterGetter(Getter, accessor="check"):
|
|
15
|
+
"""
|
|
16
|
+
Asserter version of the Getter (css, data, props, id, has_class, ...), raising
|
|
17
|
+
AssertionError if ever something doesn't match.
|
|
18
|
+
* Assertions are chainable.
|
|
19
|
+
* An extra assertion message (head of default message) can be set using `getter.check(msg)`.
|
|
20
|
+
|
|
21
|
+
The expected values can be given as a unique value all the elements must match, or as a list.
|
|
22
|
+
In the latter,
|
|
23
|
+
|
|
24
|
+
Accessible through `Getter.check`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_msg:str = None
|
|
28
|
+
|
|
29
|
+
def __call__(self, msg:str):
|
|
30
|
+
self._msg = dedent(msg).rstrip().lstrip('\n')
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def _with_msg(self, msg:str):
|
|
34
|
+
if not self._msg:
|
|
35
|
+
return msg
|
|
36
|
+
return f"\n\n{ self._msg or '' }\n\n{ msg }"
|
|
37
|
+
|
|
38
|
+
def _check_elements(
|
|
39
|
+
self,
|
|
40
|
+
ref_items,
|
|
41
|
+
value_getter: Callable[[WebElement,str],Any],
|
|
42
|
+
*,
|
|
43
|
+
is_ok: Callable[[Any,Any],bool] = None,
|
|
44
|
+
feedback: Callable[[Any,Any],bool] = None,
|
|
45
|
+
) -> 'AsserterGetter' :
|
|
46
|
+
|
|
47
|
+
is_ok = self._default_is_ok(is_ok)
|
|
48
|
+
feedback = feedback or should_be
|
|
49
|
+
|
|
50
|
+
bads = [] if self.elements else ('no such element',)
|
|
51
|
+
bads.extend(
|
|
52
|
+
f"Wrong number of expected values for {prop!r}: {len(exp)} should be {len(self.elements)}"
|
|
53
|
+
for prop, exp in ref_items
|
|
54
|
+
if isinstance(exp, (list,tuple)) and len(self.elements) != len(exp)
|
|
55
|
+
)
|
|
56
|
+
for i_elt,element in enumerate(self.elements):
|
|
57
|
+
bads.extend(
|
|
58
|
+
f"{prop}: { feedback(expected, actual, self._negate) }"
|
|
59
|
+
for prop, exp in ref_items
|
|
60
|
+
if not is_ok(
|
|
61
|
+
(expected := exp[i_elt] if isinstance(exp, (tuple, list)) else exp),
|
|
62
|
+
(actual := value_getter(element, prop))
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if bads:
|
|
67
|
+
data = ',\n '.join(bads)
|
|
68
|
+
msg = self._with_msg(f" { data }\n\nOn: { self }")
|
|
69
|
+
assert False, msg
|
|
70
|
+
|
|
71
|
+
self._msg = None
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def exists(self, exp=True):
|
|
76
|
+
""" Usable for non existing list of elements """
|
|
77
|
+
actual = super().exists() # Already handle the negation!
|
|
78
|
+
msg = self._with_msg(f" {actual} should { ' not'*self._negate }be {exp}\n\nOn: {self}")
|
|
79
|
+
assert actual == exp, msg
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def count(self, n:int):
|
|
84
|
+
""" Usable for non existing list of elements """
|
|
85
|
+
actual = super().count() # Already handle the negation!
|
|
86
|
+
msg = self._with_msg(
|
|
87
|
+
f" {actual} should { ' not'*self._negate }be {n}\n\nWrong number of children on: {self}"
|
|
88
|
+
)
|
|
89
|
+
assert actual == n, msg
|
|
90
|
+
return self
|
|
File without changes
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
|
|
2
|
+
from contextlib import contextmanager, nullcontext
|
|
3
|
+
from typing import Any, ClassVar, List, Optional, Union, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from selenium.webdriver import Keys
|
|
6
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
7
|
+
|
|
8
|
+
from .._types_and_tools import GetterOrCss, WaitingContext
|
|
9
|
+
from .base_getter import BaseGetter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..getter import Getter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ActionGetter(BaseGetter):
|
|
18
|
+
"""
|
|
19
|
+
Relay class, applying automatically the action which are normally `on_element` (or equivalent)
|
|
20
|
+
to all the self.elements. The chain of actions must then be triggered by accessing the `go`
|
|
21
|
+
property (triggers Action.perform() on all elements).
|
|
22
|
+
|
|
23
|
+
Methods which take another target as argument may take a css selector string instead
|
|
24
|
+
of a WebElement: the original method will be called with the equivalent element.
|
|
25
|
+
Note that the cardinality of the elements in the current object and those in the
|
|
26
|
+
targeted ones must be identical.
|
|
27
|
+
|
|
28
|
+
(see: https://github.com/SeleniumHQ/selenium/blob/trunk/py/selenium/webdriver/common/action_chains.py)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_context_action: ClassVar[Optional[ActionChains]] = None
|
|
32
|
+
"""
|
|
33
|
+
Global ActionChains instance used when the `ActionGetter.context_chain_and_go` context
|
|
34
|
+
manager is used.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
DURATION: ClassVar[int] = 40
|
|
39
|
+
"""
|
|
40
|
+
Base duration (in milliseconds value for selenium actions (mouse move, in between clicks, ...)
|
|
41
|
+
Warning about consecutive clicks/actions: short values might end up in different behaviors.
|
|
42
|
+
Selenium default is 250 ms.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
@contextmanager
|
|
48
|
+
def swap_duration(duration:int):
|
|
49
|
+
"""
|
|
50
|
+
Temporarily change the default duration for all actions. Restore it at the end of the tests.
|
|
51
|
+
"""
|
|
52
|
+
old = ActionGetter.DURATION
|
|
53
|
+
ActionGetter.DURATION = duration
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
ActionGetter.DURATION = old
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def context_chain_and_go(self, duration:int=None):
|
|
62
|
+
"""
|
|
63
|
+
With this context manager, the very same ActionChain object is used for every single
|
|
64
|
+
element/Getter object. When resuming executions, the ActionChain is automatically
|
|
65
|
+
triggered.
|
|
66
|
+
|
|
67
|
+
WARNING:
|
|
68
|
+
- `scroll_to` is not usable this way: scroll to an element must be done before, and all
|
|
69
|
+
subsequent moves should be done through other methods.
|
|
70
|
+
- there are no specific checks about other actions performed when several elements exist
|
|
71
|
+
on the same Getter object, so everything will be applied in order, even if it actually
|
|
72
|
+
does not make sense at `perform` time.
|
|
73
|
+
"""
|
|
74
|
+
ActionGetter._context_action = ActionChains(
|
|
75
|
+
self.driver,
|
|
76
|
+
duration=self.DURATION if duration is None else duration
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
yield None
|
|
80
|
+
ActionGetter._context_action.perform()
|
|
81
|
+
finally:
|
|
82
|
+
ActionGetter._context_action = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def __getitem__(self, idx:int):
|
|
86
|
+
"""
|
|
87
|
+
Returns a new instance of the current class, with only the element at index @idx.
|
|
88
|
+
If the current instance has actions, the corresponding action object is shared with the
|
|
89
|
+
returned object.
|
|
90
|
+
"""
|
|
91
|
+
sub_getter = super().__getitem__(idx)
|
|
92
|
+
if self._actions:
|
|
93
|
+
sub_getter._actions = [self._actions[idx]]
|
|
94
|
+
return sub_getter
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def go(self):
|
|
98
|
+
""" Alias for `perform()`. """
|
|
99
|
+
return self.perform()
|
|
100
|
+
|
|
101
|
+
def perform(self) -> 'Getter':
|
|
102
|
+
"""
|
|
103
|
+
Apply all the registered actions, then return a fresh Getter holding the very same
|
|
104
|
+
elements, but with a find method usable and no actions registered.
|
|
105
|
+
"""
|
|
106
|
+
for action in self._actions:
|
|
107
|
+
action.perform()
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
#------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _this_or_that(self, other:Optional[GetterOrCss], current:bool=False):
|
|
114
|
+
"""
|
|
115
|
+
By default apply the actions on the elements of the current instance.
|
|
116
|
+
If `@other` is provided, use the corresponding elements instead.
|
|
117
|
+
If @current=True, this means the current mouse position will be used to perform the
|
|
118
|
+
action, instead of moving to any element before.
|
|
119
|
+
"""
|
|
120
|
+
if current and other is not None:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Cannot use the `current` mouse location when another element is targeted ({other=!r})."
|
|
123
|
+
)
|
|
124
|
+
return (
|
|
125
|
+
(None,) * len(self.elements) if current else
|
|
126
|
+
self.elements if not other else
|
|
127
|
+
self._getter_or_css(other).elements
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _getter_or_css(self, thing:Optional[GetterOrCss]) -> 'Getter' :
|
|
131
|
+
return thing and (self.find(thing, in_page=True) if isinstance(thing, str) else thing)
|
|
132
|
+
|
|
133
|
+
def _duplicate_value(self, value:Any):
|
|
134
|
+
return (value,) * len(self.elements)
|
|
135
|
+
|
|
136
|
+
def _apply(self, name:str, *args_values:List[Any]):
|
|
137
|
+
"""
|
|
138
|
+
@args_values: list of all values to use for each element.
|
|
139
|
+
The lists are provided in the order matching the original method arguments.
|
|
140
|
+
Inside the lists, the values must match the self.elements order.
|
|
141
|
+
|
|
142
|
+
@returns a new ActionGetter instance, holding the new actions.
|
|
143
|
+
THIS INSTANCE MUST BE KEPT/STORED/USED to actually trigger all the registered actions.
|
|
144
|
+
"""
|
|
145
|
+
if not self.elements:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Cannot initiate actions on a Getter without known elements.\n{ self }"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
sizes = [*map(len,args_values)]
|
|
151
|
+
if len(set(sizes)) != 1:
|
|
152
|
+
raise ValueError(f"Mismatched arguments data lengths for {name}: {sizes}.\n{ self }")
|
|
153
|
+
|
|
154
|
+
zipped_args = tuple(zip(*args_values))
|
|
155
|
+
if len(zipped_args) != len(self.elements):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Mismatched arguments lengths for {name}: {len(self.elements)} elements, but found "
|
|
158
|
+
f"{len(zipped_args)} sets of arguments.\n{ self }"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if ActionGetter._context_action is not None:
|
|
162
|
+
for args in zipped_args:
|
|
163
|
+
ActionGetter._context_action = getattr(ActionGetter._context_action, name)(*args)
|
|
164
|
+
return self # No need to return a new instance, since no instance states are mutated, here.
|
|
165
|
+
|
|
166
|
+
else:
|
|
167
|
+
actions = self._actions or [
|
|
168
|
+
ActionChains(self.driver, duration=self.DURATION) for _ in self.elements
|
|
169
|
+
]
|
|
170
|
+
built_actions = [
|
|
171
|
+
getattr(action, name)(*args) for args,action in zip(zipped_args, actions)
|
|
172
|
+
]
|
|
173
|
+
out = ActionGetter.from_(self)
|
|
174
|
+
out._actions = built_actions
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
#-----------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def reset_actions(self):
|
|
182
|
+
return self._apply("reset_actions")
|
|
183
|
+
|
|
184
|
+
def click(self, other:Optional[GetterOrCss]=None, *, current=False):
|
|
185
|
+
return self._apply("click", self._this_or_that(other, current))
|
|
186
|
+
|
|
187
|
+
def click_and_hold(self, other:Optional[GetterOrCss]=None, *, current=False):
|
|
188
|
+
return self._apply("click_and_hold", self._this_or_that(other, current))
|
|
189
|
+
|
|
190
|
+
def context_click(self, other:Optional[GetterOrCss]=None, *, current=False):
|
|
191
|
+
return self._apply("context_click", self._this_or_that(other, current))
|
|
192
|
+
|
|
193
|
+
def double_click(self, other:Optional[GetterOrCss]=None, *, current=False):
|
|
194
|
+
return self._apply("double_click", self._this_or_that(other, current))
|
|
195
|
+
|
|
196
|
+
def drag_and_drop(
|
|
197
|
+
self,
|
|
198
|
+
target: GetterOrCss,
|
|
199
|
+
get_target: bool=False,
|
|
200
|
+
other_src: Optional[GetterOrCss]=None,
|
|
201
|
+
*,
|
|
202
|
+
current=False,
|
|
203
|
+
):
|
|
204
|
+
"""
|
|
205
|
+
If @get_target is True, returns the Getter of the targeted elements instead of the current
|
|
206
|
+
instance.
|
|
207
|
+
The actions are transferred to the target object (shared object => CAREFUL WITH THAT!!!!)
|
|
208
|
+
"""
|
|
209
|
+
targets = self._getter_or_css(target)
|
|
210
|
+
self._apply("drag_and_drop", self._this_or_that(other_src, current), targets.elements)
|
|
211
|
+
if get_target:
|
|
212
|
+
targets._actions = self._actions
|
|
213
|
+
return targets
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def drag_and_drop_by_offset(self, xoffset: int, yoffset: int, other_src:Optional[GetterOrCss]=None):
|
|
217
|
+
return self._apply(
|
|
218
|
+
"drag_and_drop_by_offset",
|
|
219
|
+
self._this_or_that(other_src),
|
|
220
|
+
self._duplicate_value(xoffset),
|
|
221
|
+
self._duplicate_value(yoffset),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def key_down(self, value: str, other:Optional[GetterOrCss]=None, *, current=False):
|
|
225
|
+
return self._apply("key_down", self._duplicate_value(value), self._this_or_that(other, current))
|
|
226
|
+
|
|
227
|
+
def key_up(self, value: str, other:Optional[GetterOrCss]=None, *, current=False):
|
|
228
|
+
return self._apply("key_up", self._duplicate_value(value), self._this_or_that(other, current))
|
|
229
|
+
|
|
230
|
+
def move_by_offset(self, xoffset: int, yoffset: int, release=False):
|
|
231
|
+
out = self._apply(
|
|
232
|
+
"move_by_offset",
|
|
233
|
+
self._duplicate_value(xoffset), self._duplicate_value(yoffset)
|
|
234
|
+
)
|
|
235
|
+
if release:
|
|
236
|
+
self.release(current=True)
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
def move_to_element(self, other:Optional[GetterOrCss]=None):
|
|
240
|
+
return self._apply("move_to_element", self._this_or_that(other))
|
|
241
|
+
|
|
242
|
+
move_to = move_to_element
|
|
243
|
+
""" Alias for move_to_element """
|
|
244
|
+
|
|
245
|
+
def move_to_element_with_offset(self, xoffset: int, yoffset: int, other:Optional[GetterOrCss]=None):
|
|
246
|
+
return self._apply(
|
|
247
|
+
"move_to_element_with_offset", self._this_or_that(other),
|
|
248
|
+
self._duplicate_value(xoffset), self._duplicate_value(yoffset)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def pause(self, seconds: Union[float, int, None]=None):
|
|
252
|
+
return self._apply("pause", self._duplicate_value(seconds or self.DURATION))
|
|
253
|
+
|
|
254
|
+
def release(self, other:Optional[GetterOrCss]=None, *, current=False):
|
|
255
|
+
return self._apply("release", self._this_or_that(other, current))
|
|
256
|
+
|
|
257
|
+
def send_keys_to_element(self, *keys_to_send: str, other:Optional[GetterOrCss]=None, current=False):
|
|
258
|
+
return self._apply(
|
|
259
|
+
"send_keys_to_element", self._this_or_that(other, current),
|
|
260
|
+
*map(self._duplicate_value, keys_to_send)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
send_keys = send_keys_to_element
|
|
264
|
+
"""
|
|
265
|
+
Alias for send_keys_to_element (because the Getter setup generalize the thing...)
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# /!\ Selenium's base Scroll stuff just DOES NOT WORK!???
|
|
270
|
+
#
|
|
271
|
+
# def scroll_to_element(self, other:Optional[GetterOrCss]=None):
|
|
272
|
+
# return self._apply("scroll_to_element", self._this_or_that(other))
|
|
273
|
+
|
|
274
|
+
# def scroll_by_amount(self, delta_x: int, delta_y: int):
|
|
275
|
+
# return self._apply(
|
|
276
|
+
# "scroll_by_amount",
|
|
277
|
+
# self._duplicate_value(delta_x), self._duplicate_value(delta_y)
|
|
278
|
+
# )
|
|
279
|
+
|
|
280
|
+
# def scroll_from_origin(self, scroll_origin: ScrollOrigin, delta_x: int, delta_y: int):
|
|
281
|
+
# raise NotImplementedError("Getting lazy...")
|
|
282
|
+
|
|
283
|
+
def scroll_to(self, other:Optional[GetterOrCss]=None, *, shift:float=0, target_location:str='3/4'):
|
|
284
|
+
"""
|
|
285
|
+
Scroll the current/given element into view. @shift may be used to tweak the final scroll
|
|
286
|
+
position, to avoid some targets to go out of the viewport.
|
|
287
|
+
|
|
288
|
+
@shift: Apply a vertical shift to the point considered as the point to center in the page.
|
|
289
|
+
@target_location: Ratio of innerHeight where to place the "center+shift" of the target.
|
|
290
|
+
|
|
291
|
+
WARNING: this action CANNOT be chained with others, because it is applied directly with JS.
|
|
292
|
+
"""
|
|
293
|
+
elt = self if other is None else self._getter_or_css(other)
|
|
294
|
+
# Cannot scroll to different elements, considering how the actions are handled, so forbid it:
|
|
295
|
+
elt.check("Getters.scroll_to usage requires instances with exactly one element").count(1)
|
|
296
|
+
|
|
297
|
+
assert not self._actions and not ActionGetter._context_action, (
|
|
298
|
+
"scroll_to(...) cannot be chained with other actions, because it interacts directly with "
|
|
299
|
+
"the page, through the JS executor."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
rect = elt.get.rect
|
|
303
|
+
center_y = rect['y'] + rect['height']/2 + shift
|
|
304
|
+
|
|
305
|
+
self.run_js(f"""
|
|
306
|
+
var centerY={ center_y }, H=innerHeight, Y=scrollY
|
|
307
|
+
var inBound = Y + H/6 <= centerY && centerY <= Y + H * 5/6
|
|
308
|
+
if(!inBound){{
|
|
309
|
+
scrollTo(0, centerY - H * { target_location } );
|
|
310
|
+
}}
|
|
311
|
+
""")
|
|
312
|
+
return elt
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def send_shortcut(
|
|
316
|
+
self,
|
|
317
|
+
keys:str,
|
|
318
|
+
*,
|
|
319
|
+
waiting_context:WaitingContext=None,
|
|
320
|
+
):
|
|
321
|
+
"""
|
|
322
|
+
Takes a string representing a combination of keys "+" separated (spaces are ignored), like:
|
|
323
|
+
"Ctrl+S"
|
|
324
|
+
"Ctrl + Alt + Shift + Enter"
|
|
325
|
+
...
|
|
326
|
+
The string is split, converted to the related selenium Keys and the combination is
|
|
327
|
+
automatically applied to the element.
|
|
328
|
+
|
|
329
|
+
@waiting_context: If given, must be a context manager function (ALREADY CALLED) to control
|
|
330
|
+
any delay/logic that should ba applied around the shortcut application.
|
|
331
|
+
"""
|
|
332
|
+
*modifiers,key = map(to_keys, keys.replace(' ','').split("+"))
|
|
333
|
+
self.scroll_to()
|
|
334
|
+
with waiting_context or nullcontext():
|
|
335
|
+
with self.context_chain_and_go():
|
|
336
|
+
for mod in modifiers:
|
|
337
|
+
self.key_down(mod)
|
|
338
|
+
self.send_keys(key)
|
|
339
|
+
for mod in modifiers:
|
|
340
|
+
self.key_up(mod, current=True)
|
|
341
|
+
# `current` because the shortcut could cause a move in the page.
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def to_keys(name:str) -> Keys:
|
|
347
|
+
"""
|
|
348
|
+
Automatically convert a `Keys` name string to the corresponding value, or return the
|
|
349
|
+
original string.
|
|
350
|
+
|
|
351
|
+
* The input is case insensitive.
|
|
352
|
+
* 'CTRL' is automatically converted to 'CONTROL'
|
|
353
|
+
* 'PLUS' is automatically converted to 'Keys.ADD' (-> numpad +)
|
|
354
|
+
"""
|
|
355
|
+
upper = name.upper()
|
|
356
|
+
if name.upper() == 'PLUS':
|
|
357
|
+
return Keys.ADD
|
|
358
|
+
if upper == 'CTRL':
|
|
359
|
+
upper = 'CONTROL'
|
|
360
|
+
return getattr(Keys, upper, name)
|
|
361
|
+
|
|
362
|
+
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kind of jQuery like usage, but for Selenium logistic/elements...
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Callable, ClassVar, List, TYPE_CHECKING, Tuple
|
|
8
|
+
|
|
9
|
+
from selenium.webdriver.common.by import By
|
|
10
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
11
|
+
from selenium.webdriver.support.wait import WebDriverWait
|
|
12
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
13
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..getter import Getter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def negate_output(method):
|
|
21
|
+
@wraps(method)
|
|
22
|
+
def wrapper(self:'Getter', *a, **kw):
|
|
23
|
+
out = method(self, *a, **kw)
|
|
24
|
+
return self.NEGATE_OUTCOME[self._negate](out)
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
def forbid_negation(method):
|
|
28
|
+
@wraps(method)
|
|
29
|
+
def wrapper(self:'Getter', *a, **kw):
|
|
30
|
+
if self._negate:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"The `not_` modifier/property should not be used with the `get` one."
|
|
33
|
+
)
|
|
34
|
+
return method(self, *a, **kw)
|
|
35
|
+
return wrapper
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BaseGetter:
|
|
40
|
+
|
|
41
|
+
driver: WebDriver = None
|
|
42
|
+
waiter: WebDriverWait = None
|
|
43
|
+
elements: List[WebElement] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
_css_selector: str = ""
|
|
46
|
+
_full_css: str = ""
|
|
47
|
+
|
|
48
|
+
_negate: bool = False
|
|
49
|
+
|
|
50
|
+
_actions: List[ActionChains] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
NEGATE_OUTCOME: ClassVar[Tuple[Callable,Callable]] = (lambda _:_), (lambda v: not v)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __repr__(self):
|
|
57
|
+
return f"{ self.__class__.__name__ }({self._full_css!r}, n_elements={ len(self.elements) })"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@negate_output
|
|
61
|
+
def exists(self):
|
|
62
|
+
return bool(self.elements)
|
|
63
|
+
# __bool__ = exists # Dropped (mostly causing a mess: not possible to check instance definition against None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@negate_output
|
|
67
|
+
def count(self):
|
|
68
|
+
return len(self.elements)
|
|
69
|
+
__len__ = count
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __iter__(self):
|
|
73
|
+
return ( self.__class__.from_(self, [elt]) for elt in self.elements )
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def __getitem__(self, idx:int):
|
|
77
|
+
return self.__class__.from_(self, [self.elements[idx]])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find(self, css_selector:str, *, in_page=False) -> 'Getter':
|
|
81
|
+
"""
|
|
82
|
+
Find all children element matching the given css selector.
|
|
83
|
+
If @in_page is true, starts searching from the top of the page instead of starting
|
|
84
|
+
with the current element/Getter.
|
|
85
|
+
@key: ordering key function, if given.
|
|
86
|
+
"""
|
|
87
|
+
if not in_page and not self.elements:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Inconsistent `find` request: the current selection is empty.\n{ self }"
|
|
90
|
+
)
|
|
91
|
+
if in_page:
|
|
92
|
+
elements = self.driver.find_elements(By.CSS_SELECTOR, css_selector)
|
|
93
|
+
else:
|
|
94
|
+
elements = []
|
|
95
|
+
for elt in self.elements:
|
|
96
|
+
children = elt.find_elements(By.CSS_SELECTOR, css_selector)
|
|
97
|
+
elements.extend(children)
|
|
98
|
+
|
|
99
|
+
full_css_path = f'{self._full_css} -> "{css_selector}"' if self._full_css else f'"{css_selector}"'
|
|
100
|
+
|
|
101
|
+
return self.__class__.from_(self, elements, css_selector, full_css_path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_js(self, code:str, *args, async_=False, with_promise=True):
|
|
105
|
+
"""
|
|
106
|
+
Use driver.execute_script on the given code.
|
|
107
|
+
|
|
108
|
+
* `@args` are arguments that will be passed in the JS environment (available through the
|
|
109
|
+
`arguments` array).
|
|
110
|
+
* `@async_=False`: if True, JS script will run async?. An error is raised if `with_promise`
|
|
111
|
+
is False and `done` is not present in the code (a call to it is needed to resume execution).
|
|
112
|
+
* `@with_promise=True`: if True and the script is supposed to be run async, `code` is
|
|
113
|
+
expected to be a Promise (aka, no await in the source!) and `'.then(done, console.error)'`
|
|
114
|
+
is automatically added at the end of `code` to consume the promise and resume executions.
|
|
115
|
+
In that case, the `done` call is not needed in the source code.
|
|
116
|
+
"""
|
|
117
|
+
method = 'execute_script'
|
|
118
|
+
if async_:
|
|
119
|
+
method = 'execute_async_script'
|
|
120
|
+
if not with_promise and 'done' not in code:
|
|
121
|
+
raise ValueError("Invalid async script: a call to the `done` callback is needed")
|
|
122
|
+
code = f"""
|
|
123
|
+
var done = arguments[arguments.length - 1];
|
|
124
|
+
{ code }{ '.then(done, console.error)' * with_promise }
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
return getattr(self.driver, method)(code, *args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def wait_for_element(self, css_selector):
|
|
131
|
+
if self._actions:
|
|
132
|
+
raise ValueError(f"Cannot use `wait_until(...)` on objects with defined ActionChains.")
|
|
133
|
+
|
|
134
|
+
def finder(_):
|
|
135
|
+
elt = self.find(css_selector, in_page=True)
|
|
136
|
+
return elt.exists() and elt
|
|
137
|
+
|
|
138
|
+
return self.waiter.until(finder)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def wait_until(self, bool_provider):
|
|
142
|
+
if self._actions:
|
|
143
|
+
raise ValueError(f"Cannot use `wait_until(...)` on objects with defined ActionChains.")
|
|
144
|
+
return self.waiter.until(bool_provider)
|
|
145
|
+
|
|
146
|
+
def wait_prompt_or_alert(self):
|
|
147
|
+
def _wait(*_):
|
|
148
|
+
try:
|
|
149
|
+
return self.driver.switch_to.alert
|
|
150
|
+
except:
|
|
151
|
+
return None
|
|
152
|
+
return self.waiter.until(_wait)
|
|
153
|
+
|
|
154
|
+
def clear(self):
|
|
155
|
+
"""
|
|
156
|
+
Clear the content of editable and resettable elements (no validation -> raise at runtime).
|
|
157
|
+
"""
|
|
158
|
+
for elt in self.elements:
|
|
159
|
+
elt.clear()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
#-----------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_(cls, getter:'Getter', elements=None, _css_selector=None, _full_css=None):
|
|
167
|
+
"""
|
|
168
|
+
Build a new Getter instance (from the given class/subclass) based on the current
|
|
169
|
+
object, updating various values on the fly.
|
|
170
|
+
"""
|
|
171
|
+
return cls(
|
|
172
|
+
getter.driver,
|
|
173
|
+
getter.waiter,
|
|
174
|
+
getter.elements if elements is None else elements, # no nee dto copy the list: never mutated
|
|
175
|
+
getter._css_selector if _css_selector is None else _css_selector,
|
|
176
|
+
getter._full_css if _full_css is None else _full_css,
|
|
177
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, ClassVar
|
|
4
|
+
from operator import eq, ne
|
|
5
|
+
|
|
6
|
+
from .base_getter import negate_output
|
|
7
|
+
from .generic_value_getter import ComparerGetter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BooleanGetter(ComparerGetter):
|
|
12
|
+
"""
|
|
13
|
+
Common/default behavior, equivalent to `Getter.all...`
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
COMBINER: ClassVar[Callable] = all
|
|
17
|
+
|
|
18
|
+
_negate_outside = False
|
|
19
|
+
""" If True, apply a negation inside the COMBINER function (aka, for each element). """
|
|
20
|
+
|
|
21
|
+
def _default_is_ok(self, is_ok: Callable[[Any,Any],bool]):
|
|
22
|
+
if self._negate:
|
|
23
|
+
return (lambda a,b: not is_ok(a,b)) if is_ok else ne
|
|
24
|
+
return is_ok or eq
|
|
25
|
+
|
|
26
|
+
@negate_output
|
|
27
|
+
def _check_elements(
|
|
28
|
+
self, ref_items, value_getter, *, is_ok = None, **_,
|
|
29
|
+
):
|
|
30
|
+
is_ok = self._default_is_ok(is_ok)
|
|
31
|
+
out = self.COMBINER(
|
|
32
|
+
is_ok(exp, value_getter(element, prop))
|
|
33
|
+
for element in self.elements
|
|
34
|
+
for prop, exp in ref_items
|
|
35
|
+
)
|
|
36
|
+
return not out if self._negate_outside else out
|