Source code for pomcorn.locators.base_locators

"""Module with `XPathLocator`.

Provide only `XPathLocator` because a locator of this type
is sufficient for all operations and
it also allows to combine locators with `/` operator.
It's better to use one type of locators for consistency.

Example:
  # Search button inside base element
  button_element_locator = base_locator / button_locator

"""

from __future__ import annotations

from collections.abc import Iterator
from typing import Literal, TypeVar

from selenium.webdriver.common.by import By


[docs] class Locator: """Base locator for looking for elements in page.""" _ALLOWED_LOCATORS = ( By.ID, By.XPATH, By.LINK_TEXT, By.PARTIAL_LINK_TEXT, By.NAME, By.TAG_NAME, By.CLASS_NAME, By.CSS_SELECTOR, )
[docs] def __init__(self, by: str, query: str): """Init locator. Args: by: One of the supported selenium locator strategies. query: Query for the strategy. """ if by not in self._ALLOWED_LOCATORS: raise ValueError(f"No valid `by` found -> `{by}`") self.by = by self.query: str = query
def __iter__(self) -> Iterator[str]: """Unpack locator. This is necessary because selenium WebDriver methods use `By` and `value` tuples to find elements. Thanks to this we can use `*locator` for these methods. """ return iter((self.by, self.query)) def __repr__(self) -> str: return f"Locator<By `{self.by}`: Query `{self.query}`>" def __str__(self) -> str: return self.query
TLocator = TypeVar("TLocator", bound=Locator, covariant=True) # This need only for element initialization because covariant generic can't be # used as type for parameter of function TInitLocator = TypeVar("TInitLocator", bound=Locator)
[docs] class XPathLocator(Locator): r"""Locator to looking for elements in page by XPath. XPathLocator overrides methods for `/` and `//` operators to provide "path-like" syntax for locators. So we can use `/` and `//` operators to concatenate locators query by `/` and `//` accordingly: # //\*[@class="class"] class_locator = ClassLocator("class") # //container[@prop="value"] property_locator = PropertyLocator( prop="prop", value="value", container="container", ) # //\*[@class="class"]/container[@prop="value"] class_locator / property_locator # //\*[@class="class"]//container[@prop="value"] class_locator // property_locator To extend query of locator you can use `extend_query` method: new_locator = class_locator.extend_query("[@some_prop='value']") new_locator.query # //\*[@class="class"][@some_prop="value"] All custom locators that inherit `XPathLocator` should be independent and start with `//`. """ # We move it to constant to fix flake-8 warning B005: # https://pypi.org/project/flake8-bugbear/#:~:text=B005 divider = "//"
[docs] def __init__(self, query: str): """Set related query for locators concatenation. Args: query: Query for the XPath locator strategy. """ self.related_query = query.lstrip(self.divider) super().__init__(by=By.XPATH, query=query)
def __truediv__(self, other: XPathLocator) -> XPathLocator: """Override `/` operator to implement following XPath locators. "/" used to select the nearest children of the current node. """ return self.prepare_relative_locator(other=other, separator="/") def __floordiv__(self, other: XPathLocator) -> XPathLocator: """Override `//` operator to implement nested XPath locators. "//" used to select all descendants (children, grandchildren, great-grandchildren, etc.) of current node, regardless of their level in hierarchy. """ return self.prepare_relative_locator(other=other, separator="//") def __or__(self, other: XPathLocator) -> XPathLocator: r"""Override `|` operator to implement variant XPath locators. Example: span = XPathLocator("//span") div = XPathLocator("//div") img = XPathLocator("//img") (span | div) // img == XPathLocator("(//span | //div)//img") span | div // img == XPathLocator("(//span | //div//img)") """ return XPathLocator(query=f"({self.query} | {other.query})") def __getitem__(self, value: int | str | XPathLocator) -> XPathLocator: """Allow to set xpath expressions or index into the locator query. Examples: div_locator = XPathLocator("//div") --> `//div` # Indexation div_locator[0] --> `(//div)[1]` # xpath numeration starts with 1 div_locator[-1] --> `(//div)[last()]` # Attribute condition div_locator["@type='black'"] --> `//div[@type='black'] # Checking if there are children child_locator = XPathLocator("//label").contains("Schedule") --> `//label[contains(., 'Star']` div_locator[child_locator] --> `//div[//label[contains(., 'Star']]` """ query = f"({self.query})" if isinstance(value, XPathLocator): value = value.query if isinstance(value, str): return XPathLocator(f"{query}[{value}]") if value >= 0: # `+1` is used here because numeration in xpath starts with 1 query += f"[{value + 1}]" elif value == -1: # To avoid ugly locators with `...)[last() - 0]` query += "[last()]" else: query += f"[last() - {abs(value + 1)}]" return XPathLocator(query) def __bool__(self) -> bool: """Return whether query of current locator is empty or not.""" return bool(self.related_query) @classmethod def _escape_quotes(cls, text: str) -> str: """Escape single and double quotes in given text for use in locators. This method is useful when locating elements with text containing single or double quotes. For example, the text `He's 6'2"` will be transformed into: `concat("He", "'", "s 6", "'", "2", '"')`. The resulting string can be used in XPath expressions like `text()=...` or `contains(.,...)`. Returns: The escaped text wrapped in `concat()` for XPath compatibility, or the original text in double quotes if no escaping is needed. """ if not text or ('"' not in text and "'" not in text): return f'"{text}"' escaped_parts = [] buffer = "" # Temporary storage for normal characters for char in text: if char not in ('"', "'"): buffer += char continue if buffer: escaped_parts.append(f'"{buffer}"') buffer = "" escaped_parts.append( "'" + char + "'" if char == '"' else '"' + char + '"', ) if buffer: escaped_parts.append(f'"{buffer}"') return f"concat({', '.join(escaped_parts)})"
[docs] def extend_query(self, extra_query: str) -> XPathLocator: """Return new XPathLocator with extended query.""" return XPathLocator(query=self.query + extra_query)
[docs] def contains(self, text: str, exact: bool = False) -> XPathLocator: """Return new XPathLocator with search on contained text. This is shortcut for the commonly used `.extend_query(f"[contains(., '{text}')])`. Args: text: The text that should be inside the tag. exact: Specify whether the text being searched must match exactly. By default, the search is based on a partial match. """ partial_query = f"[contains(., {self._escape_quotes(text)})]" exact_query = f"[./text()={self._escape_quotes(text)}]" return self.extend_query(exact_query if exact else partial_query)
[docs] def prepare_relative_locator( self, other: XPathLocator, separator: Literal["/", "//"] = "/", ) -> XPathLocator: """Prepare relative locator base on queries of two locators. If one of parent and other locator queries is empty, the method will return only the filled one. Args: other: Child locator object. separator: Literal which will placed between locators queries - "/" used to select nearest children of current node and "//" used to select all descendants (children, grandchildren, great-grandchildren, etc.) of current node, regardless of their level in hierarchy. Raises: ValueError: If parent and child locators queries are empty. """ related_query = self.related_query if not related_query.startswith("("): # Parent query can be bracketed, in which case we don't need to use # `//` # Example: # (//li)[3] -> valid # //(//li)[3] -> invalid related_query = f"//{self.related_query}" locator = XPathLocator( query=f"{related_query}{separator}{other.related_query}", ) if self and other: return locator if not (self or other): raise ValueError( f"Both of locators have empty query. The `{locator.query}` is " "not a valid locator.", ) return self if self else other