diff --git a/README.md b/README.md index 9618586..9f6363e 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,6 @@ Below is the code that opens ``PyPI.org``, searches for packages by name and pri class PackageList(ListComponent[Package, PyPIPage]): - item_class = Package relative_item_locator = locators.ClassLocator("snippet__name") @property diff --git a/cspell.config.yaml b/cspell.config.yaml index b96465b..eab864e 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -49,6 +49,7 @@ words: - STDLIB - pydocstyle - redef + - prefs # RST blocks - autodoc diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/conftest.py b/demo/conftest.py index a520806..09a45c6 100644 --- a/demo/conftest.py +++ b/demo/conftest.py @@ -1,16 +1,23 @@ -from pages import HelpPage, IndexPage, SearchPage from selenium import webdriver as selenium_webdriver from selenium.webdriver.remote.webdriver import WebDriver import pytest +from demo.pages import HelpPage, IndexPage, SearchPage + # You can implement your own logic to initialize a webdriver. # An example of Chrome initialization is described below. @pytest.fixture(scope="session") def webdriver() -> WebDriver: """Initialize `Chrome` webdriver.""" - webdriver = selenium_webdriver.Chrome() + options = selenium_webdriver.ChromeOptions() + + # Set browser's language to English + prefs = {"intl.accept_languages": "en,en_U"} + options.add_experimental_option("prefs", prefs) + + webdriver = selenium_webdriver.Chrome(options) webdriver.set_window_size(1920, 1080) return webdriver diff --git a/demo/pages/base/base_page.py b/demo/pages/base/base_page.py index 6a7b910..ecab1fa 100644 --- a/demo/pages/base/base_page.py +++ b/demo/pages/base/base_page.py @@ -7,8 +7,8 @@ from pomcorn import Page, locators if TYPE_CHECKING: - from pages import IndexPage - from pages.common import Navbar + from demo.pages import IndexPage + from demo.pages.common import Navbar class PyPIPage(Page): @@ -56,7 +56,7 @@ def __init__( @property def navbar(self) -> Navbar: """Get a component for working with the page navigation panel.""" - from pages.common import Navbar + from demo.pages.common import Navbar return Navbar(self) @@ -82,7 +82,7 @@ def check_page_is_loaded(self) -> bool: def click_on_logo(self) -> IndexPage: """Click on the logo and redirect to `IndexPage`.""" - from pages import IndexPage + from demo.pages import IndexPage self.logo.click() return IndexPage(self.webdriver) diff --git a/demo/pages/common/navigation_bar.py b/demo/pages/common/navigation_bar.py index efe2449..c85bc52 100644 --- a/demo/pages/common/navigation_bar.py +++ b/demo/pages/common/navigation_bar.py @@ -2,12 +2,11 @@ from typing import TYPE_CHECKING -from pages import PyPIComponent - +from demo.pages import PyPIComponent from pomcorn import Element, locators if TYPE_CHECKING: - from pages.help_page import HelpPage + from demo.pages.help_page import HelpPage # `Component` implements methods of waiting until the component becomes @@ -30,7 +29,7 @@ class Navbar(PyPIComponent): def open_help(self) -> HelpPage: """Click on `Help` button and redirect to HelpPage.""" - from pages.help_page import HelpPage + from demo.pages.help_page import HelpPage self.help_button.click() return HelpPage(self.webdriver) diff --git a/demo/pages/common/search.py b/demo/pages/common/search.py index 2d0208b..ed3f66a 100644 --- a/demo/pages/common/search.py +++ b/demo/pages/common/search.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING -from pages import PyPIComponent from selenium.webdriver.common.keys import Keys +from demo.pages import PyPIComponent from pomcorn import locators if TYPE_CHECKING: - from pages.search_page import SearchPage + from demo.pages.search_page import SearchPage class Search(PyPIComponent): @@ -24,7 +24,7 @@ def find(self, text: str) -> SearchPage: Redirect to `SearchPage` and return its instance. """ - from pages.search_page import SearchPage + from demo.pages.search_page import SearchPage self.body.fill(text) self.body.send_keys(Keys.ENTER) diff --git a/demo/pages/help_page.py b/demo/pages/help_page.py index 519de85..1751b5a 100644 --- a/demo/pages/help_page.py +++ b/demo/pages/help_page.py @@ -1,8 +1,8 @@ from __future__ import annotations -from pages.base import PyPIPage from selenium.webdriver.remote.webdriver import WebDriver +from demo.pages.base import PyPIPage from pomcorn import Element, locators @@ -20,7 +20,7 @@ def open( app_root: str | None = None, ) -> HelpPage: """Open the help page via the index page.""" - from pages.index_page import IndexPage + from demo.pages.index_page import IndexPage # Reusing already implemented methods of opening a page instead of # overriding `app_root` allows us to be independent from URK changes: diff --git a/demo/pages/index_page.py b/demo/pages/index_page.py index 7e81b33..f76b9fc 100644 --- a/demo/pages/index_page.py +++ b/demo/pages/index_page.py @@ -1,7 +1,8 @@ -from pages.base import PyPIPage -from pages.common import Search from selenium.webdriver.remote.webdriver import WebDriver +from demo.pages.base import PyPIPage +from demo.pages.common import Search + class IndexPage(PyPIPage): """Represent the index page.""" diff --git a/demo/pages/package_details_page.py b/demo/pages/package_details_page.py index 9417fb7..2069b7c 100644 --- a/demo/pages/package_details_page.py +++ b/demo/pages/package_details_page.py @@ -1,8 +1,8 @@ from __future__ import annotations -from pages.base import PyPIPage from selenium.webdriver.remote.webdriver import WebDriver +from demo.pages.base import PyPIPage from pomcorn import locators @@ -25,7 +25,7 @@ def open( app_root: str | None = None, ) -> PackageDetailsPage: """Search and open the package details page by its name.""" - from pages import IndexPage + from demo.pages import IndexPage search_page = IndexPage.open( webdriver, diff --git a/demo/pages/search_page/components/package.py b/demo/pages/search_page/components/package.py index 9dee38d..4902dae 100644 --- a/demo/pages/search_page/components/package.py +++ b/demo/pages/search_page/components/package.py @@ -2,12 +2,11 @@ from typing import TYPE_CHECKING -from pages import PyPIComponent - +from demo.pages import PyPIComponent from pomcorn import locators if TYPE_CHECKING: - from pages import PackageDetailsPage + from demo.pages import PackageDetailsPage class Package(PyPIComponent): @@ -22,7 +21,7 @@ def name(self) -> str: def open(self) -> PackageDetailsPage: """Click on the package and open its details page.""" - from pages import PackageDetailsPage + from demo.pages import PackageDetailsPage # The property `body` is available because the package is descendant of # `Component`. It allows us to interact with the body of the component diff --git a/demo/pages/search_page/components/package_list.py b/demo/pages/search_page/components/package_list.py index 51ac620..f180aca 100644 --- a/demo/pages/search_page/components/package_list.py +++ b/demo/pages/search_page/components/package_list.py @@ -1,5 +1,4 @@ -from pages import PyPIPage - +from demo.pages import PyPIPage from pomcorn import ListComponent, locators from .package import Package @@ -8,9 +7,9 @@ class PackageList(ListComponent[Package, PyPIPage]): """Represent the list of search results on `SearchPage`.""" - # The ``ListComponent`` item should always be ``Component``, because all - # its methods depend on `base_locator`. Also this attribute is required. - item_class = Package + # By default `ListComponent` have `item_class` attribute with stored first + # Generic variable (Package in current case). This attribute is responsible + # for the class that will be used for list items. base_locator = locators.PropertyLocator( prop="aria-label", diff --git a/demo/pages/search_page/search_page.py b/demo/pages/search_page/search_page.py index 52a3d5f..1a07e17 100644 --- a/demo/pages/search_page/search_page.py +++ b/demo/pages/search_page/search_page.py @@ -1,8 +1,9 @@ from __future__ import annotations -from pages import IndexPage, PyPIPage from selenium.webdriver.remote.webdriver import WebDriver +from demo.pages import IndexPage, PyPIPage + from .components import PackageList diff --git a/demo/tests/test_logo.py b/demo/tests/test_logo.py index c1c28a2..2a2771b 100644 --- a/demo/tests/test_logo.py +++ b/demo/tests/test_logo.py @@ -1,4 +1,4 @@ -from pages import HelpPage +from demo.pages import HelpPage def test_logo(help_page: HelpPage): diff --git a/demo/tests/test_search.py b/demo/tests/test_search.py index 81b9ef9..66e657b 100644 --- a/demo/tests/test_search.py +++ b/demo/tests/test_search.py @@ -1,4 +1,4 @@ -from pages import IndexPage +from demo.pages import IndexPage def test_search(index_page: IndexPage): diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 8fcf49b..2a3e56e 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -3,6 +3,14 @@ Version history We follow `Semantic Versions `_. +0.8.1 (25.11.24) +******************************************************************************* +- Improve getting ``item class`` from first ``ListComponent`` generic variable. + There were several cases where this didn't work correctly (for multiple generic variables + and inheritance). Examples of such cases are presented in `this PR `_.\ + +**Warning**: The ``item_class`` class attribute was removed. + 0.8.0 (05.07.24) ******************************************************************************* - Add ability to not specify ``item_class`` in ``ListComponent``. Instead, it @@ -21,19 +29,16 @@ deprecated and will be removed soon. - Improve ``Page.click_on_page()`` method to click the page coordinates instead of offset relative to current mouse position - 0.7.3 ******************************************************************************* - Add ability to not specify ``app_root`` in ``Page.open_from_url()`` as in ``Page.open()`` - 0.7.2 ******************************************************************************* - Improve ``Page.click_on_page()`` method to click on tag - Improve ``Page.open_from_url()`` to support kwargs - Fix ``\`` related problems in ``Page._get_full_relative_url()`` - 0.7.1 ******************************************************************************* @@ -42,7 +47,6 @@ deprecated and will be removed soon. - Fix some possible xpath errors depending on empty locators queries and brackets. - 0.7.0 ******************************************************************************* diff --git a/docs/developer_interface.rst b/docs/developer_interface.rst index 4550b4a..b1445e2 100644 --- a/docs/developer_interface.rst +++ b/docs/developer_interface.rst @@ -22,7 +22,6 @@ Components .. automodule:: pomcorn.component :members: - PomcornElement ******************************************************************************* diff --git a/pomcorn/component.py b/pomcorn/component.py index c36f0c5..b1376ad 100644 --- a/pomcorn/component.py +++ b/pomcorn/component.py @@ -1,4 +1,13 @@ -from typing import Generic, TypeVar, get_args, overload +from inspect import isclass +from typing import ( + Any, + Generic, + Literal, + TypeVar, + get_args, + get_origin, + overload, +) from . import locators from .element import XPathElement @@ -8,6 +17,17 @@ 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() + + class Component(Generic[TPage], WebView): """The class to represent a page component that depends on base locator. @@ -182,31 +202,30 @@ class ListComponent(Generic[ListItemType, TPage], Component[TPage]): """ + _item_class: type[ListItemType] = EmptyValue + item_locator: locators.XPathLocator | None = None relative_item_locator: locators.XPathLocator | None = None - def __init__( - self, - page: TPage, - base_locator: locators.XPathLocator | None = None, - wait_until_visible: bool = True, - ): - super().__init__(page, base_locator, wait_until_visible) - if item_class := getattr(self, "item_class", None): - import warnings - - warnings.warn( - DeprecationWarning( - "\nSpecifying `item_class` attribute in `ListComponent` " - f"({self.__class__}) is DEPRECATED. It is now " - "automatically substituted from Generic[ListItemType]. " - "Ability to specify this attribute will be removed soon.", - ), - stacklevel=2, - ) - self._item_class = item_class - else: - self._item_class = self._get_list_item_class() + 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: @@ -257,6 +276,32 @@ def all(self) -> list[ListItemType]: ) return items + @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 + + @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``. + + """ + return isclass(item_class) and issubclass(item_class, Component) + def get_item_by_text(self, text: str) -> ListItemType: """Get list item by text.""" locator = self.base_item_locator.extend_query( @@ -264,10 +309,6 @@ def get_item_by_text(self, text: str) -> ListItemType: ) return self._item_class(page=self.page, base_locator=locator) - def _get_list_item_class(self) -> type[ListItemType]: - """Return class passed in `Generic[ListItemType]`.""" - return get_args(self.__orig_bases__[0])[0] # type: ignore - def __repr__(self) -> str: return ( "ListComponent(" diff --git a/pyproject.toml b/pyproject.toml index 46fb021..e9936d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pomcorn" -version = "0.8.0" +version = "0.8.1" description = "Base implementation of Page Object Model" authors = [ "Saritasa ", @@ -171,6 +171,11 @@ pytest-parametrize-names-type = "list" pytest-parametrize-values-type = "list" pytest-parametrize-values-row-type = "list" +per-file-ignores = [ + # Ignore using lambda in `item_class` tests + "tests/list_component/test_item_class.py: E731", +] + [tool.mypy] # mypy configurations: https://mypy.readthedocs.io/en/latest/config_file.html # https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6889b33 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from pomcorn import Page + + +@pytest.fixture +def fake_page() -> Page: + """Prepare fake page object for run tests without browser.""" + return Page(webdriver=None, app_root="None") # type: ignore diff --git a/tests/list_component/__init__.py b/tests/list_component/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/list_component/test_item_class.py b/tests/list_component/test_item_class.py new file mode 100644 index 0000000..d7bde11 --- /dev/null +++ b/tests/list_component/test_item_class.py @@ -0,0 +1,107 @@ +from typing import Generic, TypeVar + +import pytest + +from pomcorn import Component, ListComponent, Page, locators + +TItem = TypeVar("TItem", bound=Component[Page]) +TPage = TypeVar("TPage", bound=Page) + + +class ItemClass(Component[Page]): + """Common test component for represent item class.""" + + +def test_set_item_class_in_parent_class(fake_page: Page) -> None: + """Check that we can specify only `item_class` in base class. + + And after specifying other generic arguments in subclasses, `item_class` + will still be correct. + + """ + + class BaseList(Generic[TPage], ListComponent[ItemClass, TPage]): + """Base list component with specified item_class in Generic.""" + + base_locator = locators.XPathLocator("html") # required + wait_until_visible = lambda _: True # to not wait anything + + # Inherit from `BaseList` and specify page generic variable + class InheritedList(BaseList[Page]): + ... + + # Ensure that `InheritedList.item_class` has correct type + list_cls = InheritedList(fake_page) + assert list_cls._item_class is ItemClass + + +def test_no_set_item_class(fake_page: Page) -> None: + """Check that we can't not specify `item_class`.""" + + class BaseList(Generic[TItem, TPage], ListComponent[TItem, TPage]): + """Base list component without specified Generic variables.""" + + base_locator = locators.XPathLocator("html") # required + relative_item_locator = locators.XPathLocator("body") # required + wait_until_visible = lambda _: True # to not wait anything + + # Inherit from `BaseList` and specify only page generic variable + class InheritedList(Generic[TItem], BaseList[TItem, Page]): + ... + + list_cls = InheritedList(fake_page) # type: ignore + + assert not list_cls._item_class + with pytest.raises(TypeError, match=r"object is not callable"): + list_cls.get_item_by_text("item") + + +def test_set_item_class_in_child_via_generic(fake_page: Page) -> None: + """Check that we can specify `item_class` only in the child class.""" + + class BaseList(Generic[TItem, TPage], ListComponent[TItem, TPage]): + """Base list component without specified Generic variables.""" + + base_locator = locators.XPathLocator("html") # required + wait_until_visible = lambda _: True # to not wait anything + + # Prepare base list component without specified Generic variables + class InheritedList(BaseList[ItemClass, Page]): + ... + + # Ensure that `InheritedList.item_class` has correct type + list_cls = InheritedList(fake_page) + assert list_cls._item_class is ItemClass + + +def test_specify_all_generic_variables(fake_page: Page) -> None: + """Check that item_class will be correct if fill all generic variables.""" + + class List(ListComponent[ItemClass, Page]): + base_locator = locators.XPathLocator("html") # required + wait_until_visible = lambda _: True # to not wait anything + + list_cls = List(fake_page) + assert list_cls._item_class is ItemClass + + +def test_set_item_class_with_extra_generic_variable(fake_page: Page) -> None: + """Check that item_class will be correct if add new generic variable.""" + TParam = TypeVar("TParam") + + class BaseList(Generic[TParam], ListComponent[ItemClass, Page]): + """Base list component with new generic variable.""" + + base_locator = locators.XPathLocator("html") # required + wait_until_visible = lambda _: True # to not wait anything + + # Inherit from `BaseList` and specify new generic variable + class Param(Component[Page]): + ... + + class List(BaseList[Param]): + ... + + # Ensure that `List.item_class` has correct type + list_cls = List(fake_page) + assert list_cls._item_class is ItemClass