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