selenium-query 0.1.0__tar.gz

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.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.3
2
+ Name: selenium-query
3
+ Version: 0.1.0
4
+ Summary: Python equivalent of jQuery, to write selenium tests.
5
+ License: GPL-3.0-or-later
6
+ Author: Frédéric Zinelli
7
+ Author-email: frederic.zinelli@gmail.com
8
+ Requires-Python: >=3.9, <4.0
9
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: selenium (>=4.36)
17
+ Description-Content-Type: text/markdown
18
+
19
+
File without changes
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "selenium-query"
3
+ version = "0.1.0"
4
+ description = "Python equivalent of jQuery, to write selenium tests."
5
+ license = { text = "GPL-3.0-or-later" }
6
+ authors = [
7
+ {name = "Frédéric Zinelli",email = "frederic.zinelli@gmail.com"}
8
+ ]
9
+ readme = "README.md"
10
+ requires-python = ">=3.9, <4.0"
11
+ dependencies = [
12
+ "selenium (>=4.36)"
13
+ ]
14
+
15
+
16
+ [tool.setuptools]
17
+ packages = ["selenium_query"]
18
+
19
+ [tool.poetry.group.dev.dependencies]
20
+ pytest = "<9.0"
21
+
22
+ [build-system]
23
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
24
+ build-backend = "poetry.core.masonry.api"
@@ -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
@@ -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
+