Skip to content

Commit

Permalink
added experimental element_by_its and filtered_by_their (yet made it …
Browse files Browse the repository at this point in the history
…possible to pass Callables to element_by and filtered_by)
  • Loading branch information
yashaka committed Mar 20, 2020
1 parent 67e4b21 commit 283c849
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 16 deletions.
51 changes: 38 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- what about soft assertions in selene?
- improve stacktraces
- consider using something like `__tracebackhide__ = True`
- consider adding more readable alias to by tuple, like in:
`css_or_xpath_or_by: Union[str, tuple]`

## 2.0.0b1 (to be released on *.01.2020)
- remove all deprecated things and stay calm:)
Expand Down Expand Up @@ -43,7 +45,15 @@
- use __all__ in selene api imports, etc
- The variable __all__ is a list of public objects of that module, as interpreted by import *. ... In other words, __all__ is a list of strings defining what symbols in a module will be exported when from <module> import * is used on the module

- todo: consider adding `element_by_its` and `filtered_by_their` as in example below:

## 2.0.0a22 (to be released on ?.03.2020)

- fixed `have.texts` when actual collection has bigger size than actual

- added (yet marked with "experimental" warning)
- `element_by_its`
- `filtered_by_their`
- ... see code examples below:

```
# Given
Expand All @@ -53,30 +63,45 @@
# .result-url
# .result-snippet
# in addition to
results = browser.all('.result')
results.element_by(lambda result:
have.text('browser tests in Python')(result.element('.result-title')))
results.element_by(lambda result: have.text('browser tests in Python')(
result.element('.result-title')))\
.element('.result-url').click()
# or maybe
# you can now write:
rusults.element_by_its('.result-title', have.text('browser tests in Python'))
.element('.result-url').click()
# rusults.filtered_by_their('.result-title', have.text('Python'))
.should(have.size(...))
# or even...
rusults.element_by(lambda it: Result(it).title, have.text('browser tests in Python'))
.element('.result-url').click()
# or even
class Result:
def __init__(self, element):
self.element = element
self.title = self.element.element('.result-title')
self.url = self.element.element('.result-url')
Result(rusults.element_by_its(lambda it: Result(it).title, have.text('browser tests in Python')))\
.url.click()
# or probably better
rusults.element_by_its(lambda it: Result(it).title, have.text('browser tests in Python'))
# it's yet marked as experimental because probably it would be enough
# to make it possible to accept Callable[[Element], bool] in element_by to allow:
rusults.element_by(
lambda it: it.element('.result-title').matching(have.text('browser tests in Python')))
.element('.result-url').click()
```

## 2.0.0a22 (to be released on ?.03.2020)
- fixed `have.texts` when actual collection has bigger size than actual
# moreover... if failed, the error becomes weird if using lambdas:
# Timed out after 4s, while waiting for:
# browser.all(('css selector', '.result')).element_by(<function Collection.element_by_its.<locals>.<lambda> at 0x10df67f28>).element(('css selector', '.result-url')).click
# Reason: AssertionError: Cannot find element by condition «<function Collection.element_by_its.<locals>.<lambda> at 0x10df67f28>» from webelements collection:
```
-
## 2.0.0a21 (released on 22.01.2020)
- fixed hooks for entities created via entity.with_(Config(...))

Expand Down
174 changes: 171 additions & 3 deletions selene/core/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,13 +676,110 @@ def from_(self, start: int) -> Collection:
def to(self, stop: int) -> Collection:
return self[:stop]

def filtered_by(self, condition: Condition[Element]) -> Collection:
def filtered_by(self,
condition: Union[
Condition[Element],
Callable[[E], None]]) -> Collection:
condition = condition if isinstance(condition, Condition) \
else Condition(str(condition), condition)

return Collection(
Locator(f'{self}.filtered_by({condition})',
lambda: [element() for element in self.cached if element.matching(condition)]),
self.config)

def element_by(self, condition: Condition[Element]) -> Element:
def filtered_by_their(
self,
selector_or_callable: Union[str,
tuple,
Callable[[Element], Element]],
condition: Condition[Element]) -> Collection:
"""
:param selector_or_callable:
- selector may be a str with css/xpath selector or tuple with by.* locator
- callable should be a function on element that returns element
:param condition: a condition to
:return: collection subset with inner/relative element matching condition
GIVEN html elements somewhere in DOM::
.result
.result-title
.result-url
.result-snippet
THEN::
browser.all('.result')\
.filtered_by_their('.result-title', have.text('Selene'))\
.should(have.size(3))
... is a shortcut for::
browser.all('.result')\
.filtered_by_their(lambda it: have.text(text)(it.element('.result-title')))\
.should(have.size(3))
OR with PageObject:
THEN::
results.element_by_its(lambda it: Result(it).title, have.text(text))\
.should(have.size(3))
Shortcut for::
results.element_by(lambda it: have.text(text)(Result(it).title))\
.should(have.size(3))
WHERE::
results = browser.all('.result')
class Result:
def __init__(self, element):
self.element = element
self.title = self.element.element('.result-title')
self.url = self.element.element('.result-url')
# ...
"""
warnings.warn(
'filtered_by_their is experimental; might be renamed or removed in future',
FutureWarning)

def find_in(parent: Element):
if callable(selector_or_callable):
return selector_or_callable(parent)
else:
return parent.element(selector_or_callable)

return self.filtered_by(lambda it: condition(find_in(it)))

def element_by(self,
condition: Union[
Condition[Element],
Callable[[E], None]]) -> Element:
# todo: In the implementation below...
# We use condition in context of "matching", i.e. as a predicate...
# why then not accept Callable[[E], bool] also?
# (as you remember, Condition is Callable[[E], None] throwing Error)
# This will allow the following code be possible
# results.element_by(lambda it:
# Result(it).title.matching(have.text(text)))
# instead of:
# results.element_by(lambda it: have.text(text)(
# Result(it).title))
# in addition to:
# results.element_by_its(lambda it:
# Result(it).title, have.text(text))
# Open Points:
# - do we need element_by_its, if we allow Callable[[E], bool] ?
# - if we add elements_by_its, do we need then to accept Callable[[E], bool] ?
# - probably... Callable[[E], bool] will lead to worse error messages,
# in such case we ignore thrown error's message
# - hm... ut seems like we nevertheless ignore it...
# we use element.matching(condition) below
condition = condition if isinstance(condition, Condition) \
else Condition(str(condition), condition)

def find() -> WebElement:
cached = self.cached

Expand All @@ -699,6 +796,77 @@ def find() -> WebElement:

return Element(Locator(f'{self}.element_by({condition})', find), self.config)

def element_by_its(
self,
selector_or_callable: Union[str,
tuple,
Callable[[Element], Element]],
condition: Condition[Element]) -> Element:
"""
:param selector_or_callable:
- selector may be a str with css/xpath selector or tuple with by.* locator
- callable should be a function on element that returns element
:param condition: a condition to
:return: element from collection that has inner/relative element matching condition
GIVEN html elements somewhere in DOM::
.result
.result-title
.result-url
.result-snippet
THEN::
browser.all('.result')\
.element_by_its('.result-title', have.text(text))\
.element('.result-url').click()
... is a shortcut for::
browser.all('.result')\
.element_by(lambda it: have.text(text)(it.element('.result-title')))\
.element('.result-url').click()
OR with PageObject:
THEN::
Result(results.element_by_its(lambda it: Result(it).title, have.text(text)))\
.url.click()
Shortcut for::
Result(results.element_by(lambda it: have.text(text)(Result(it).title)))\
.url.click()
WHERE::
results = browser.all('.result')
class Result:
def __init__(self, element):
self.element = element
self.title = self.element.element('.result-title')
self.url = self.element.element('.result-url')
# ...
"""
# todo: main questions to answer before removing warning:
# - isn't it enough to allow Callable[[Element], bool] as condition?
# browser.all('.result').element_by(
# lambda it: it.element('.result-title').matching(have.text('browser tests in Python')))
# .element('.result-url').click()
# - how to improve error messages in case we pass lambda (not a fun with good name/str repr)?
warnings.warn(
'element_by_its is experimental; might be renamed or removed in future',
FutureWarning)

def find_in(parent: Element):
if callable(selector_or_callable):
return selector_or_callable(parent)
else:
return parent.element(selector_or_callable)

return self.element_by(lambda it: condition(find_in(it)))

# todo: consider adding ss alias
def all(self, css_or_xpath_or_by: Union[str, tuple]) -> Collection:
warnings.warn('might be renamed or deprecated in future; '
Expand Down Expand Up @@ -754,7 +922,7 @@ def collected(self, finder: Callable[[Element], Union[Element, Collection]]) ->
# because they are not supposed to be used in entity.get(*) context defined for other query.* fns

return Collection(
Locator(f'{self}.collected({finder})', # todo: consider skipping None while flattening
Locator(f'{self}.collected({finder})', # todo: consider skipping None while flattening
lambda: flatten([finder(element)() for element in self.cached])),
self.config)

Expand Down
34 changes: 34 additions & 0 deletions tests/acceptance/shared_browser/test_ecosia.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# MIT License
#
# Copyright (c) 2015-2019 Iakiv Kramarenko
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from selene.support.shared import browser
from selene import by, have


def test_search():
browser.open('https://www.ecosia.org/')
browser.element(by.name('q')).type('github yashaka selene').press_enter()

browser.all('.result')\
.element_by_its('.result-title', have.text('yashaka/selene'))\
.element('.result-url').click()

browser.should(have.title_containing('yashaka/selene'))

0 comments on commit 283c849

Please sign in to comment.