From c4db91be8286a5948032f2cbdf76b4c798f649eb Mon Sep 17 00:00:00 2001 From: yashaka Date: Tue, 5 Mar 2024 12:52:25 +0200 Subject: [PATCH] [#518] REFACTOR: apply protocols to commands - try 01 --- selene/common/__init__.py | 2 ++ selene/common/_protocol.py | 53 ++++++++++++++++++++++++++++++++++++++ selene/core/command.py | 27 ++++++++++--------- selene/core/entity.py | 33 +++++++++++++++++++++++- selene/core/entity.pyi | 4 ++- selene/core/wait.py | 6 ++++- 6 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 selene/common/_protocol.py diff --git a/selene/common/__init__.py b/selene/common/__init__.py index 493b443c..872dacee 100644 --- a/selene/common/__init__.py +++ b/selene/common/__init__.py @@ -1 +1,3 @@ # TODO: consider renaming this package to _common + +from . import _protocol # type: ignore diff --git a/selene/common/_protocol.py b/selene/common/_protocol.py new file mode 100644 index 00000000..41e1e21b --- /dev/null +++ b/selene/common/_protocol.py @@ -0,0 +1,53 @@ +# MIT License +# +# Copyright (c) 2015-2022 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 __future__ import annotations +from typing import Generic, Callable, TypeVar, Optional, Protocol + +T = TypeVar('T') + +R = TypeVar('R', covariant=True) # TODO: consider renaming to R_co as pylint suggests +E = TypeVar( + 'E', contravariant=True +) # TODO: consider renaming to E_contra as pylint suggests + + +# TODO: should we rename it to ObjectQuery? +# class EntityQuery(Protocol[E, R]): +# def __call__(self, entity: E) -> R: ... +# +# def __str__(self) -> str: ... + +# what if we simplify to just callable? +EntityQuery = Callable[[E], R] + +EntityCommand = EntityQuery[E, None] +EntityPredicate = EntityQuery[E, bool] + + +class EntityParametrizedQuery(Protocol[E, R]): + def __call__(self, entity: E, *args, **kwargs) -> R: ... + + def __str__(self) -> str: ... + + +EntityParametrizedCommand = EntityParametrizedQuery[E, None] +EntityParametrizedPredicate = EntityParametrizedQuery[E, bool] diff --git a/selene/core/command.py b/selene/core/command.py index 1b34e8be..3bc3f390 100644 --- a/selene/core/command.py +++ b/selene/core/command.py @@ -230,11 +230,17 @@ def func(element: Element): lambda element: element.execute_script('element.scrollIntoView(true)'), ) + @staticmethod + @overload + def _click(element: Element | None) -> None: ... + + @staticmethod + @overload + def _click(*, xoffset=0, yoffset=0) -> Command[Element]: ... + # TODO: should we process collections too? i.e. click through all elements? @staticmethod - def __click( - entity: Element | None = None, /, *, xoffset=0, yoffset=0 - ) -> Command[Element]: + def _click(element: Element | None = None, *, xoffset=0, yoffset=0): def func(element: Element): element.execute_script( ''' @@ -268,7 +274,12 @@ def func(element: Element): yoffset, ) - command: Command[Element] = Command( + if isinstance(element, Element): + # somebody passed command as `.perform(command.js.click)` + # not as `.perform(command.js.click())` + func(element) + + return Command( ( 'click' if (not xoffset and not yoffset) @@ -277,14 +288,6 @@ def func(element: Element): func, ) - if isinstance(entity, Element): - # somebody passed command as `.perform(command.js.click)` - # not as `.perform(command.js.click())` - element = entity - command.__call__(element) - - return command - class __ClickWithOffset(Command[Element]): def __init__(self): self._description = 'click' diff --git a/selene/core/entity.py b/selene/core/entity.py index 3de5836f..5717bddc 100644 --- a/selene/core/entity.py +++ b/selene/core/entity.py @@ -26,12 +26,14 @@ from abc import abstractmethod, ABC from typing import TypeVar, Union, Callable, Tuple, Iterable, Optional +from typing_extensions import override from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver import ActionChains from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webelement import WebElement +from selene import common from selene.common.fp import pipe from selene.core.configuration import Config @@ -87,7 +89,7 @@ def __init__(self, config: Config): def wait(self) -> Wait[E]: return self.config.wait(typing.cast(E, self)) # type: ignore - def perform(self, command: Command[E]) -> E: + def perform(self, command: common._protocol.EntityCommand[E]) -> E: """Useful to call external commands. Commands might be predefined in Selene: @@ -193,6 +195,35 @@ def __call__(self) -> WebElement: # --- WaitingEntity --- # + # @override + # def perform(self, command: common._protocol.EntityCommand[Element]) -> Element: + # """Useful to call external commands. + # + # Commands might be predefined in Selene: + # element.perform(command.js.scroll_into_view) + # or some custom defined by selene user: + # element.perform(my_action.triple_click) + # + # You might think that it will be useful + # to use these methods also in Selene internally + # in order to define built in commands e.g. in Element class, like: + # + # def click(self): + # return self.perform(Command('click', lambda element: element().click())) + # + # instead of: + # + # def click(self): + # self.wait.for_(Command('click', lambda element: element().click())) + # return self + # + # But so far, we use the latter version - though, less concise, but more explicit, + # making it more obvious that waiting is built in;) + # + # """ + # self.wait.for_(command) + # return self + @property def wait(self) -> Wait[Element]: # TODO: will not it break code like browser.with_(timeout=...)? diff --git a/selene/core/entity.pyi b/selene/core/entity.pyi index 19f5c3fe..96d6e3f9 100644 --- a/selene/core/entity.pyi +++ b/selene/core/entity.pyi @@ -6,6 +6,7 @@ from abc import ABC, abstractmethod from selenium.webdriver.common.options import BaseOptions from selenium.webdriver.common.service import Service +from selene import common from selene.common.fp import pipe as pipe, F from selene.common.helpers import ( flatten as flatten, @@ -47,7 +48,7 @@ class WaitingEntity(Matchable[E], Configured, metaclass=abc.ABCMeta): def __init__(self, config: Config) -> None: ... @property def wait(self) -> Wait[E]: ... - def perform(self, command: Command[E]) -> E: ... + def perform(self, command: common._protocol.EntityCommand[E]) -> E: ... def get(self, query: Query[E, R]) -> R: ... @property def config(self) -> Config: ... @@ -88,6 +89,7 @@ class Element(WaitingEntity['Element']): _build_wait_strategy: Callable[[Config], Callable[[E], Wait[E]]] = ..., ) -> Element: ... def locate(self) -> WebElement: ... + def perform(self, command: common._protocol.EntityCommand[Element]) -> Element: ... @property def __raw__(self): ... def __call__(self) -> WebElement: ... diff --git a/selene/core/wait.py b/selene/core/wait.py index 59a4a5ab..30a06ffb 100644 --- a/selene/core/wait.py +++ b/selene/core/wait.py @@ -129,10 +129,14 @@ def logic(fn: Callable[[E], R]) -> R: timeout = self._timeout entity = self.entity + # if it's a normal function, it should have __qualname__, + # then use it, otherwise use str(fn) + fn_name = getattr(fn, '__qualname__', str(fn)) + failure = TimeoutException( f'\n' f'\nTimed out after {timeout}s, while waiting for:' - f'\n{entity}.{fn}' + f'\n{entity}.{fn_name}' f'\n' f'\nReason: {reason_string}' )