Skip to content

Commit

Permalink
[#530] NEW: config._match_placeholder_to_match_elements
Browse files Browse the repository at this point in the history
  • Loading branch information
yashaka committed Jul 20, 2024
1 parent 71dd4c9 commit abab8a4
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 34 deletions.
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,15 @@ check vscode pylance, mypy, jetbrains qodana...

### TODO: consider removing experimantal mark from `ConditionMismatch._to_raise_if_not`

### TODO: consider regex support via .pattern prop (similar to .ignore_case) (#537)

## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024)

### TODO: consider regex support via .pattern prop (similar to .ignore_case) (#537)
### TODO: in addition to browser – _page for pure web and _device for pure mobile?

### TODO: Location strategy?

### TODO: customize ... vs (...,) as one_or_more, etc. – via config option
### TODO: basic Element descriptors?

### Deprecated conditions

Expand Down Expand Up @@ -270,7 +274,7 @@ List of collection conditions added (still marked as experimental with `_` prefi

Where:

- default list glob placeholders are:
- default list globbing placeholders are:
- `[{...}]` matches **zero or one** item of any text in the list
- `{...}` matches **exactly one** item of any text in the list
- `...` matches one **or more** items of any text in the list
Expand Down
8 changes: 8 additions & 0 deletions selene/core/_browser.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import abc
import itertools
import typing
from abc import ABC, abstractmethod
from types import MappingProxyType

from typing_extensions import Literal, Dict, cast

from selenium.webdriver.common.options import BaseOptions
from selenium.webdriver.common.service import Service
Expand Down Expand Up @@ -104,6 +107,11 @@ class Browser(WaitingEntity['Browser']):
_match_only_visible_elements_texts: bool = True,
_match_only_visible_elements_size: bool = False,
_match_ignoring_case: bool = False,
_placeholders_to_match_elements: Dict[
Literal['exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'], Any
] = cast( # noqa
dict, MappingProxyType({})
),
# Etc.
_build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ...,
) -> Browser: ...
Expand Down
52 changes: 51 additions & 1 deletion selene/core/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
import os
import time
import warnings
from types import MappingProxyType

import typing_extensions as typing
from selenium.common import WebDriverException
from typing_extensions import Callable, Optional, Any, TypeVar
from typing_extensions import Callable, Optional, Any, TypeVar, Dict, Literal, cast

from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.common.options import BaseOptions
Expand Down Expand Up @@ -1165,6 +1167,54 @@ def _executor(self):
# but it will not :(
# todo: consider documenting the major list of conditions that are affected by this option
_match_ignoring_case: bool = False
# TODO: find the best name...
# compare:
# > _placeholders_to_match_elements
# > _placeholders_to_list_globs
# > _placeholders_for_list_globs
# > _placeholders_for_list_globs_to_match
_placeholders_to_match_elements: Dict[
Literal['zero_or_one', 'exactly_one', 'one_or_more', 'zero_or_more'], Any
] = cast(dict, MappingProxyType({}))
"""A dict of default placeholders to be used among values passed to Selene
collection conditions like `have._texts_like(*values)`. Such values then can
be considered as a list globbing pattern, where a defined placeholder will
match the corresponding to placeholder type number of ANY elements.
The default list globbing placeholders are:
- `[{...}]` matches **zero or one** item of any text in the list
- `{...}` matches **exactly one** item of any text in the list
- `...` matches one **or more** items of any text in the list
- `[...]` matches **zero** or more items of any text in the list
Thus, using this option you can redefine them. Assuming, you don't like, that
`...` matches "one or more" and want it to match "exactly one" instead, then
you can set the following defaults:
```python
from selene import browser
...
# GIVEN default placeholders
browser.all('.number0-9').should(have._texts_like([{...}], 0, {...}, 2, ...))
# WHEN
browser.config._placeholders_to_match_elements = {
'zero_or_one': '[...]',
'exactly_one': '...',
'one_or_more': '(...,)',
'zero_or_more': '[(...,)]',
}
# THEN
browser.all('.number0-9').should(have._texts_like([...], 0, ..., 2, (...,)))
```
All globbing placeholders can be mixed in the same list of expected item values
in any order.
* """

# TODO: better name? now technically it's not a decorator but decorator_builder...
# or decorator_factory...
Expand Down
17 changes: 16 additions & 1 deletion selene/core/configuration.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import itertools
from typing import Callable, Optional, Any, Union
from types import MappingProxyType

from typing_extensions import Callable, Optional, Any, Union, Dict, Literal, cast

from selenium.webdriver.common.options import BaseOptions
from selenium.webdriver.common.service import Service
Expand Down Expand Up @@ -101,6 +103,9 @@ class Config:
_match_only_visible_elements_texts: bool = True
_match_only_visible_elements_size: bool = False
_match_ignoring_case: bool = False
_placeholders_to_match_elements: Dict[
Literal['exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'], Any
] = cast(dict, MappingProxyType({}))
# Etc.
_build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ...
_executor: _DriverStrategiesExecutor = ...
Expand Down Expand Up @@ -158,6 +163,11 @@ class Config:
_match_only_visible_elements_texts: bool = True,
_match_only_visible_elements_size: bool = False,
_match_ignoring_case: bool = False,
_placeholders_to_match_elements: Dict[
Literal['exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'], Any
] = cast( # noqa
dict, MappingProxyType({})
),
# Etc.
_build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ...,
): ...
Expand Down Expand Up @@ -214,6 +224,11 @@ class Config:
_match_only_visible_elements_texts: bool = True,
_match_only_visible_elements_size: bool = False,
_match_ignoring_case: bool = False,
_placeholders_to_match_elements: Dict[
Literal['exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'], Any
] = cast( # noqa
dict, MappingProxyType({})
),
# Etc.
_build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ...,
): ...
25 changes: 24 additions & 1 deletion selene/core/entity.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import abc
import itertools
import typing
from abc import ABC, abstractmethod
from types import MappingProxyType

from selenium.webdriver.common.options import BaseOptions
from selenium.webdriver.common.service import Service
Expand All @@ -22,7 +23,19 @@ from selene.support.webdriver import WebHelper as WebHelper
from selenium.webdriver.remote.switch_to import SwitchTo as SwitchTo
from selenium.webdriver.remote.webdriver import WebDriver as WebDriver
from selenium.webdriver.remote.webelement import WebElement
from typing import Callable, Iterable, Optional, Tuple, TypeVar, Union, Any, Generic
from typing_extensions import (
Callable,
Iterable,
Optional,
Tuple,
TypeVar,
Union,
Any,
Generic,
Dict,
Literal,
cast,
)

E = TypeVar('E', bound='Assertable')
R = TypeVar('R')
Expand Down Expand Up @@ -88,6 +101,11 @@ class Element(WaitingEntity['Element']):
_match_only_visible_elements_texts: bool = True,
_match_only_visible_elements_size: bool = False,
_match_ignoring_case: bool = False,
_placeholders_to_match_elements: Dict[
Literal['exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'], Any
] = cast( # noqa
dict, MappingProxyType({})
),
# Etc.
_build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ...,
) -> Element: ...
Expand Down Expand Up @@ -153,6 +171,11 @@ class Collection(WaitingEntity['Collection'], Iterable[Element]):
_match_only_visible_elements_texts: bool = True,
_match_only_visible_elements_size: bool = False,
_match_ignoring_case: bool = False,
_placeholders_to_match_elements: Dict[
Literal['exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'], Any
] = cast( # noqa
dict, MappingProxyType({})
),
# Etc.
_build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ...,
) -> Collection: ...
Expand Down
47 changes: 21 additions & 26 deletions selene/core/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,42 +961,31 @@ class _exact_texts_like(Condition[Collection]):
"""
_MATCHING_EMPTY_STRING_MARKER = '‹EMTPY_STRING›'
_RENDERING_SEPARATOR = ', '
# todo: consider customizing via config
_RENDERING_TRANSLATIONS = (
({...}, '{...}'),
([{...}], '[{...}]'),
(..., '...'),
([...], '[...])'),
)
# initially designed version
__X_RENDERING_TRANSLATIONS = (
(..., '...'),
([...], '[...]'),
# was added to support a bit more placeholder renderings
((...,), '(...,)'),
([(...,)], '[(...,)])'),
)

_PredefinedPatternType = Literal[
'exactly_one', 'zero_or_one', 'one_or_more', 'zero_or_more'
'zero_or_one', 'exactly_one', 'one_or_more', 'zero_or_more'
]

# TODO: consider to redefine on self (or other options),
# to get fresh version of _MATCHING_SEPARATOR if it was patched
# todo: consider to redefine on self (or other options),
# to get fresh version of _MATCHING_SEPARATOR if it was patched
# todo: consider customizing via config
_PredefinedGlobPatterns: Dict[_PredefinedPatternType, str] = dict(
# TODO: ensure correctness of patterns
exactly_one=r'[^' + _MATCHING_SEPARATOR + r']+',
zero_or_one=r'[^' + _MATCHING_SEPARATOR + r']*',
one_or_more=r'.+?',
zero_or_more=r'.*?',
)

# todo: initial default globs version
__X_DEFAULT_GLOBS: Tuple[Tuple[Any, str], ...] = (
(..., _PredefinedGlobPatterns['exactly_one']),
([...], _PredefinedGlobPatterns['zero_or_one']),
((...,), _PredefinedGlobPatterns['one_or_more']),
([(...,)], _PredefinedGlobPatterns['zero_or_more']),
)

_DEFAULT_GLOBS: Tuple[Tuple[Any, str], ...] = (
({...}, _PredefinedGlobPatterns['exactly_one']),
([{...}], _PredefinedGlobPatterns['zero_or_one']),
Expand All @@ -1018,7 +1007,7 @@ def __init__(
super().__init__(lambda _: self.__str__(), self.__call__)
self._expected = expected
self._inverted = _inverted
self._globs = _globs if _globs else _exact_texts_like._DEFAULT_GLOBS
self._globs = _globs
self._name_prefix = _name_prefix
self._name = _name
self._flags = _flags
Expand Down Expand Up @@ -1088,11 +1077,19 @@ def where(
_flags=self._flags,
)

@property
def _glob_markers(self):
return [glob_marker for glob_marker, _ in self._globs]
def __globs_from(
self, *, placeholders: Dict[_exact_texts_like._PredefinedPatternType, Any]
) -> Tuple[Tuple[Any, str], ...]:
return tuple(
(glob_marker, self._PredefinedGlobPatterns[glob_pattern_type])
for glob_pattern_type, glob_marker in placeholders.items()
)

def __call__(self, entity: Collection):
entity_globs = self.__globs_from(
placeholders=entity.config._placeholders_to_match_elements
)
globs = self._globs or entity_globs or _exact_texts_like._DEFAULT_GLOBS

actual_texts = (
[
Expand All @@ -1107,7 +1104,7 @@ def __call__(self, entity: Collection):
# TODO: consider moving to self
zero_like = lambda item_marker: item_marker in [
marker
for marker, pattern in self._globs
for marker, pattern in globs
if pattern
in (
_exact_texts_like._PredefinedGlobPatterns['zero_or_one'],
Expand Down Expand Up @@ -1149,9 +1146,7 @@ def __call__(self, entity: Collection):
actual_to_render = _exact_texts_like._RENDERING_SEPARATOR.join(actual_texts)

glob_pattern_by = lambda marker: next( # noqa
glob_pattern
for glob_marker, glob_pattern in self._globs
if glob_marker == marker
glob_pattern for glob_marker, glob_pattern in globs if glob_marker == marker
)
with_added_empty_string_marker = lambda item: (
str(item) if item != '' else _exact_texts_like._MATCHING_EMPTY_STRING_MARKER
Expand All @@ -1176,7 +1171,7 @@ def __call__(self, entity: Collection):
+ (
self._process_patterns(with_added_empty_string_marker(item))
+ MATCHING_SEPARATOR
if item not in self._glob_markers
if item not in [glob_marker for glob_marker, _ in globs]
else (
glob_pattern_by(item)
+ MATCHING_SEPARATOR
Expand Down
Loading

0 comments on commit abab8a4

Please sign in to comment.