Skip to content

Commit

Permalink
[#518] REFACTOR: apply protocols to commands - try 01
Browse files Browse the repository at this point in the history
  • Loading branch information
yashaka committed Mar 5, 2024
1 parent 16e42dc commit c4db91b
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 15 deletions.
2 changes: 2 additions & 0 deletions selene/common/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# TODO: consider renaming this package to _common

from . import _protocol # type: ignore
53 changes: 53 additions & 0 deletions selene/common/_protocol.py
Original file line number Diff line number Diff line change
@@ -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]
27 changes: 15 additions & 12 deletions selene/core/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'''
Expand Down Expand Up @@ -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)
Expand All @@ -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'
Expand Down
33 changes: 32 additions & 1 deletion selene/core/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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=...)?
Expand Down
4 changes: 3 additions & 1 deletion selene/core/entity.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: ...
Expand Down Expand Up @@ -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: ...
Expand Down
6 changes: 5 additions & 1 deletion selene/core/wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
)
Expand Down

0 comments on commit c4db91b

Please sign in to comment.