import typing
from inspect import isclass
from typing import (
Any,
Generic,
Literal,
TypeVar,
get_args,
get_origin,
overload,
)
from . import locators
from .element import XPathElement
from .page import Page
from .web_view import WebView
TPage = TypeVar("TPage", bound=Page)
class _EmptyValue:
"""Singleton to use as default value for empty class attribute."""
def __bool__(self) -> Literal[False]:
"""Allow `EmptyValue` to be used in bool expressions."""
return False
EmptyValue: Any = _EmptyValue()
[docs]
class Component(Generic[TPage], WebView):
"""The class to represent a page component that depends on base locator.
It contains page elements, components and utils methods for page
manipulation, but as a separate entity that can be reused for different
pages with common elements.
Implement wait methods until the component becomes visible or invisible.
"""
base_locator: locators.XPathLocator
[docs]
def __init__(
self,
page: TPage,
base_locator: locators.XPathLocator | None = None,
wait_until_visible: bool = True,
):
"""Initialize component.
Args:
page: An instance of the page that uses this component.
base_locator: Instance of a class to locate the component in the
browser. Used in relative element initialization methods and
visibility waits. You also can specify it as attribute.
wait_until_visible: Whether to wait for the component to become
visible before completing initialization or not.
"""
super().__init__(
page.webdriver,
app_root=page.app_root,
wait_timeout=page.wait_timeout,
)
self.page = page
self.base_locator = base_locator or self.base_locator
self.body = self.init_element(locator=self.base_locator)
if wait_until_visible:
self.wait_until_visible()
@overload
def init_element(
self,
*,
locator: locators.XPathLocator,
) -> XPathElement: ...
@overload
def init_element(
self,
*,
relative_locator: locators.XPathLocator,
) -> XPathElement: ...
[docs]
def init_element(
self,
*,
relative_locator: locators.XPathLocator | None = None,
locator: locators.XPathLocator | None = None,
) -> XPathElement:
"""Initialize element including base locator.
Use `relative_locator` if you need to include `base_locator`, otherwise
use `locator`.
Raises:
ValueError: If both arguments were passed or neither.
"""
return super().init_element(
locator=self._prepare_locator(
locator=locator,
relative_locator=relative_locator,
),
)
@overload
def init_elements(
self,
*,
locator: locators.XPathLocator | None = None,
) -> list[XPathElement]: ...
@overload
def init_elements(
self,
*,
relative_locator: locators.XPathLocator | None = None,
) -> list[XPathElement]: ...
[docs]
def init_elements(
self,
*,
relative_locator: locators.XPathLocator | None = None,
locator: locators.XPathLocator | None = None,
) -> list[XPathElement]:
"""Initialize list of elements including base locator.
Use `relative_locator` if you need to include `base_locator`, otherwise
use `locator`.
Raises:
ValueError: If both arguments were passed or neither.
"""
return super().init_elements(
locator=self._prepare_locator(
locator=locator,
relative_locator=relative_locator,
),
)
def _prepare_locator(
self,
*,
relative_locator: locators.XPathLocator | None = None,
locator: locators.XPathLocator | None = None,
) -> locators.XPathLocator:
"""Prepare a locator by arguments.
Check that only one locator argument is passed, or none.
If only `relative_locator` was passed, `base_locator` will be added to
it. If only `locator` was passed, it will return itself.
Raises:
ValueError: If both arguments were passed or neither.
"""
if relative_locator and locator:
raise ValueError(
"You need to pass only one of the arguments: "
"`locator` or `relative_locator`.",
)
if not relative_locator:
if not locator:
raise ValueError(
"You need to pass one of the arguments: "
"`locator` or `relative_locator`.",
)
return locator
return self.base_locator // relative_locator
[docs]
def wait_until_visible(self, **kwargs):
"""Wait until component becomes visible."""
self.body.wait_until_visible()
[docs]
def wait_until_invisible(self, **kwargs):
"""Wait until component becomes invisible."""
self.body.wait_until_invisible()
# Here type ignore added because we can't specify TPage as generic for
# Component, but specifying Page is incorrect
ListItemType = TypeVar("ListItemType", bound=Component) # type: ignore
[docs]
class ListComponent(Generic[ListItemType, TPage], Component[TPage]):
"""Class to represent a list-like component.
It contains standard properties and methods for working with list-like
components:
* count
* all
* get_item_by_text()
Waits for `base_item_locator` property to be overridden or one of the
attributes (`item_locator` or `relative_item_locator`) to be specified.
"""
_item_class: type[ListItemType] = EmptyValue
item_locator: locators.XPathLocator | None = None
relative_item_locator: locators.XPathLocator | None = None
def __class_getitem__(cls, item: tuple[type, ...]) -> Any:
"""Create parameterized versions of generic classes.
This method is called when the class is used as a parameterized type,
such as MyGeneric[int] or MyGeneric[List[str]].
We override this method to store values passed in generic parameters.
Args:
cls: The generic class itself.
item: The type used for parameterization.
Returns:
type: A parameterized version of the class with the specified type.
"""
list_cls = super().__class_getitem__(item) # type: ignore
cls.__generic_parameters__ = item # type: ignore
return list_cls
[docs]
def __init__(
self,
page: TPage,
base_locator: locators.XPathLocator | None = None,
wait_until_visible: bool = True,
) -> None:
# If `_item_class` was not specified in `__init_subclass__`, this means
# that `ListComponent` is used as a parameterized type
# (e.g., `List[ItemClass, Page]`).
if isinstance(self._item_class, _EmptyValue):
# In this way we check the stored generic parameters and, if first
# from them is valid, set it as `_item_class`
first_generic_param = self.__generic_parameters__[0]
if self.is_valid_item_class(first_generic_param):
self._item_class = first_generic_param
super().__init__(page, base_locator, wait_until_visible)
def __init_subclass__(cls) -> None:
"""Run logic for getting/overriding item_class attr for subclasses."""
super().__init_subclass__()
# If class has valid `_item_class` attribute from a parent class
if cls.is_valid_item_class(cls._item_class):
# We leave using of parent `item_class`
return
# Try to get `item_class` from first generic variable
list_item_class = cls.get_list_item_class()
if not list_item_class:
# If `item_class` is not specified in generic we leave it empty
# because it maybe not specified in base class but will be
# specified in child
return
cls._item_class = list_item_class
@property
def base_item_locator(self) -> locators.XPathLocator:
"""Get the base locator of list item.
Raises:
ValueError: If both attributes are specified.
NotImplementedError: If no attribute has been specified,
"""
if self.relative_item_locator and self.item_locator:
raise ValueError(
"You only need to specify one of the attributes: "
"`relative_item_locator` - if you want locator nested within "
"`base_locator`, `item_locator` - otherwise. "
"Or override `base_item_locator` property.",
)
if not self.relative_item_locator:
if not self.item_locator:
raise NotImplementedError(
"You need to specify one of the arguments: "
"`relative_item_locator` - if you want locator nested "
"within `base_locator`, `item_locator` - otherwise. "
"Or override `base_item_locator` property.",
)
return self.item_locator
return self.base_locator // self.relative_item_locator
@property
def count(self) -> int:
"""Get count of list items."""
return len(self._get_elements(self.base_item_locator))
@property
def all(self) -> list[ListItemType]:
"""Get all items of list."""
# Sometimes `base_item_locator` exists in dom but is not visible
# and method returns an empty list. That's why we add waiting for this
if (
base_item := self.init_element(locator=self.base_item_locator)
).exists_in_dom:
base_item.wait_until_visible()
items: list[ListItemType] = []
for locator in self.iter_locators(self.base_item_locator):
items.append(
self._item_class(page=self.page, base_locator=locator),
)
return items
[docs]
@classmethod
def get_list_item_class(cls) -> type[ListItemType] | None:
"""Return class passed in `Generic[ListItemType]`."""
base_class = next(
_class
for _class in cls.__orig_bases__ # type: ignore
if isclass(get_origin(_class))
and issubclass(get_origin(_class), ListComponent)
)
# Get first generic variable and return it if it is valid item class
item_class = get_args(base_class)[0]
if cls.is_valid_item_class(item_class):
return item_class
return None
[docs]
@classmethod
def is_valid_item_class(cls, item_class: Any) -> bool:
"""Check that specified ``item_class`` is valid.
Valid ``item_class`` should be
* a class and subclass of ``Component``
* or TypeAlias based on ``Component``
"""
if isclass(item_class) and issubclass(item_class, Component):
return True
if isinstance(item_class, typing._GenericAlias): # type: ignore
type_alias = item_class.__origin__ # type: ignore
return isclass(type_alias) and issubclass(type_alias, Component)
return False
[docs]
def get_item_by_text(self, text: str) -> ListItemType:
"""Get list item by text."""
locator = self.base_item_locator.extend_query(
extra_query=(
f"[contains(., {self.base_item_locator._escape_quotes(text)})]"
),
)
return self._item_class(page=self.page, base_locator=locator)
def __repr__(self) -> str:
return (
"ListComponent("
f"component={self.__class__}, "
f"item_class={self._item_class}, "
f"base_item_locator={self.base_item_locator}, "
f"count={self.count}, "
f"items={self.all}, "
f"page={self.page}"
")"
)
def __str__(self) -> str:
return f"{self.all}"