From cf93851fa385c706731b11686abfd440c97add84 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 18:49:04 +0100 Subject: [PATCH 01/33] 240048 -> 230046 --- mops/base/element.py | 7 +++--- mops/base/group.py | 6 +++--- mops/utils/internal_utils.py | 41 ++++++++++++++++++++++-------------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index 0722b7de..32922eca 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -69,7 +69,7 @@ def __call__(self, driver_wrapper: DriverWrapper = None): return self def __getattribute__(self, item): - if 'element' in item and not safe_getattribute(self, '_initialized'): + if item == 'element' and not safe_getattribute(self, '_initialized'): raise NotInitializedException( f'{repr(self)} object is not initialized. ' 'Try to initialize base object first or call it directly as a method' @@ -758,7 +758,7 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: wrapped_object: Any = copy(self) wrapped_object.element = element wrapped_object._wrapped = True - set_parent_for_attr(wrapped_object, Element, with_copy=True) + set_parent_for_attr(wrapped_object, wrapped_object.sub_elements, Element, with_copy=True) wrapped_elements.append(wrapped_object) return wrapped_elements @@ -768,7 +768,8 @@ def _modify_children(self): Initializing of attributes with type == Element. Required for classes with base == Element. """ - initialize_objects(self, get_child_elements_with_names(self, Element), Element) + self.sub_elements = get_child_elements_with_names(self, Element) + initialize_objects(self, self.sub_elements, Element) def _modify_object(self): """ diff --git a/mops/base/group.py b/mops/base/group.py index ba711190..84bbaccd 100644 --- a/mops/base/group.py +++ b/mops/base/group.py @@ -77,6 +77,6 @@ def _modify_children(self) -> None: Initializing of attributes with type == Group/Element. Required for classes with base == Group. """ - initialize_objects(self, get_child_elements_with_names(self, Element), Element) - set_parent_for_attr(self, Element) - self.child_elements: List[Element] = get_child_elements(self, Element) + self.sub_elements = get_child_elements_with_names(self, Element) + initialize_objects(self, self.sub_elements, Element) + set_parent_for_attr(self, self.sub_elements, Element) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index c43b68a4..a0ee1025 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -99,46 +99,55 @@ def is_driver_wrapper(obj: Any) -> bool: return getattr(obj, '_object', None) == 'driver_wrapper' -def initialize_objects(current_object, objects: dict, cls: Any): +def initialize_objects(current_object, sub_elements: dict, instance_class: Any): """ Copy objects and initializing them with driver_wrapper from current object :param current_object: list of objects to initialize - :param objects: list of objects to initialize - :param cls: class of initializing objects + :param sub_elements: list of objects to initialize + :param instance_class: class of initializing objects :return: None """ - for name, obj in objects.items(): + for name, obj in sub_elements.items(): copied_obj = copy(obj) - promote_parent_element(copied_obj, current_object, cls) + + promote_parent_element(copied_obj, current_object, instance_class) + sub_elements[name] = copied_obj setattr(current_object, name, copied_obj(driver_wrapper=current_object.driver_wrapper)) - initialize_objects(copied_obj, get_child_elements_with_names(copied_obj, cls), cls) + + initialize_objects(copied_obj, copied_obj.sub_elements, instance_class) -def set_parent_for_attr(base_obj: object, instance_class: Union[type, tuple], with_copy: bool = False): +def set_parent_for_attr( + current_object: object, + sub_elements: dict, + instance_class: Union[type, tuple], + with_copy: bool = False +): """ Sets parent for all Elements/Group of given class. Should be called ONLY in Group object or all_elements method. Copy of objects will be executed if with_copy is True. Required for all_elements method :param instance_class: attribute class to looking for - :param base_obj: object of attribute + :param sub_elements: list of objects to initialize + :param current_object: object of attribute :param with_copy: copy child object or not :return: self """ - child_elements = get_child_elements_with_names(base_obj, instance_class).items() - for name, child in child_elements: + for name, obj in sub_elements.items(): if with_copy: - child = copy(child) + obj = copy(obj) - if (is_group(base_obj) and child.parent is None) or is_group(child.parent): - child.parent = base_obj + if (is_group(current_object) and obj.parent is None) or is_group(obj.parent): + obj.parent = current_object if with_copy: - setattr(base_obj, name, child) + sub_elements[name] = obj + setattr(current_object, name, obj) - set_parent_for_attr(child, instance_class, with_copy) + set_parent_for_attr(obj, obj.sub_elements, instance_class, with_copy) def promote_parent_element(obj: Any, base_obj: Any, cls: Any): @@ -180,7 +189,7 @@ def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) elements = {} for attribute, value in get_all_attributes_from_object(obj).items(): - if instance and isinstance(value, instance) or not instance: + if not instance or isinstance(value, instance): if attribute != 'parent' and not attribute.startswith('__') and not attribute.endswith('__'): elements.update({attribute: value}) From f4441e5640c000d3845783397af31ee925fc99ca Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 19:06:01 +0100 Subject: [PATCH 02/33] 240048 -> 10002 --- mops/mixins/internal_mixin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index ea250f24..112dd0a4 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -38,6 +38,8 @@ def get_static(cls: Any): class InternalMixin: + call = 0 + def _safe_setter(self, var: str, value: Any): if not hasattr(self, var): setattr(self, var, value) @@ -50,11 +52,12 @@ def _set_static(self: Any, cls) -> None: """ data = { name: value for name, value in get_static(cls) - if name not in get_all_attributes_from_object(self).keys() }.items() for name, item in data: - setattr(self.__class__, name, item) + cls = self.__class__ + if not hasattr(cls, name): + setattr(cls, name, item) def _repr_builder(self: Any): class_name = self.__class__.__name__ From 79cee070ae6992a77b8ab940fdfe40cff4d4f9e0 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 19:22:11 +0100 Subject: [PATCH 03/33] Fixes --- mops/mixins/internal_mixin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 112dd0a4..f8dce1e9 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -8,7 +8,6 @@ from mops.utils.internal_utils import ( get_child_elements_with_names, get_child_elements, - get_all_attributes_from_object, ) @@ -56,7 +55,7 @@ def _set_static(self: Any, cls) -> None: for name, item in data: cls = self.__class__ - if not hasattr(cls, name): + if name not in cls.__dict__: setattr(cls, name, item) def _repr_builder(self: Any): From 30ee16950ccefbd31614d534827bae3ad401a427 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 19:25:34 +0100 Subject: [PATCH 04/33] get_static used for validation --- mops/mixins/internal_mixin.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index f8dce1e9..1bea674a 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -31,9 +31,9 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: return f"{label}'{selector}'" if label else selector -@lru_cache(maxsize=16) -def get_static(cls: Any): - return get_child_elements_with_names(cls).items() +@lru_cache(maxsize=32) +def get_static(cls: Any) -> dict: + return get_child_elements_with_names(cls) class InternalMixin: @@ -50,13 +50,12 @@ def _set_static(self: Any, cls) -> None: :return: None """ data = { - name: value for name, value in get_static(cls) + name: value for name, value in get_static(cls).items() + if name not in get_static(self.__class__) }.items() for name, item in data: - cls = self.__class__ - if name not in cls.__dict__: - setattr(cls, name, item) + setattr(cls, name, item) def _repr_builder(self: Any): class_name = self.__class__.__name__ From 50d4e6ca4fd09d2d5165707cbe68bbf62207343d Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 19:32:26 +0100 Subject: [PATCH 05/33] Split get_static into 2 methods --- mops/mixins/internal_mixin.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 1bea674a..5ed61dbd 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -8,6 +8,7 @@ from mops.utils.internal_utils import ( get_child_elements_with_names, get_child_elements, + get_attributes_from_object, ) @@ -31,10 +32,14 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: return f"{label}'{selector}'" if label else selector -@lru_cache(maxsize=32) -def get_static(cls: Any) -> dict: +@lru_cache(maxsize=16) +def get_static_with_bases(cls: Any) -> dict: return get_child_elements_with_names(cls) +@lru_cache(maxsize=16) +def get_static_without_bases(cls: Any) -> dict: + return get_attributes_from_object(cls) + class InternalMixin: call = 0 @@ -50,8 +55,8 @@ def _set_static(self: Any, cls) -> None: :return: None """ data = { - name: value for name, value in get_static(cls).items() - if name not in get_static(self.__class__) + name: value for name, value in get_static_with_bases(cls).items() + if name not in get_static_without_bases(self.__class__) }.items() for name, item in data: From af82fe64d0deb25ffce723cd7cd5cdf32167c1ea Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 19:41:06 +0100 Subject: [PATCH 06/33] Fixes --- mops/mixins/internal_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 5ed61dbd..0f711d1a 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -38,7 +38,7 @@ def get_static_with_bases(cls: Any) -> dict: @lru_cache(maxsize=16) def get_static_without_bases(cls: Any) -> dict: - return get_attributes_from_object(cls) + return cls.__dict__ class InternalMixin: From 58cfab90cc2ef5104ca92945c93760bba53ae7a5 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 19:54:02 +0100 Subject: [PATCH 07/33] Fixes: setattr & page --- mops/base/page.py | 3 ++- mops/mixins/internal_mixin.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mops/base/page.py b/mops/base/page.py index 3f871150..795a9236 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -197,7 +197,8 @@ def _modify_children(self): Initializing of attributes with type == Element. Required for classes with base == Page. """ - initialize_objects(self, get_child_elements_with_names(self, Element), Element) + self.sub_elements = get_child_elements_with_names(self, Element) + initialize_objects(self, self.sub_elements, Element) def _modify_page_driver_wrapper(self, driver_wrapper: Any): """ diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 0f711d1a..7b7b8a61 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -8,7 +8,6 @@ from mops.utils.internal_utils import ( get_child_elements_with_names, get_child_elements, - get_attributes_from_object, ) @@ -54,13 +53,14 @@ def _set_static(self: Any, cls) -> None: :return: None """ + current_obj_cls = self.__class__ data = { name: value for name, value in get_static_with_bases(cls).items() - if name not in get_static_without_bases(self.__class__) + if name not in get_static_without_bases(current_obj_cls) }.items() for name, item in data: - setattr(cls, name, item) + setattr(current_obj_cls, name, item) def _repr_builder(self: Any): class_name = self.__class__.__name__ From 2f50ef5ae885ee44b6128f64a2c2aa2246984184 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 20:15:43 +0100 Subject: [PATCH 08/33] _modify_sub_elements reworked for Element --- mops/base/element.py | 11 +++++++---- mops/base/group.py | 2 +- mops/base/page.py | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index 32922eca..1a9a8389 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -131,7 +131,7 @@ def __full_init__(self, driver_wrapper: Any = None): self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) self._modify_object() - self._modify_children() + self._modify_sub_elements() if not self._initialized: self.__init_base_class__() @@ -763,13 +763,16 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: return wrapped_elements - def _modify_children(self): + def _modify_sub_elements(self): """ Initializing of attributes with type == Element. Required for classes with base == Element. """ - self.sub_elements = get_child_elements_with_names(self, Element) - initialize_objects(self, self.sub_elements, Element) + self.sub_elements = {} + + if type(self) is not Element: + self.sub_elements = get_child_elements_with_names(self, Element) + initialize_objects(self, self.sub_elements, Element) def _modify_object(self): """ diff --git a/mops/base/group.py b/mops/base/group.py index 84bbaccd..b4ba2f9d 100644 --- a/mops/base/group.py +++ b/mops/base/group.py @@ -72,7 +72,7 @@ def __init__( driver_wrapper=driver_wrapper, ) - def _modify_children(self) -> None: + def _modify_sub_elements(self) -> None: """ Initializing of attributes with type == Group/Element. Required for classes with base == Group. diff --git a/mops/base/page.py b/mops/base/page.py index 795a9236..1f278835 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -84,7 +84,7 @@ def __init__( self._init_locals = locals() self._modify_page_driver_wrapper(driver_wrapper) - self._modify_children() + self._modify_sub_elements() self._safe_setter('__base_obj_id', id(self)) self.page_elements: List[Element] = get_child_elements(self, Element) @@ -192,7 +192,7 @@ def is_page_opened(self, with_elements: bool = False, with_url: bool = False) -> return result - def _modify_children(self): + def _modify_sub_elements(self): """ Initializing of attributes with type == Element. Required for classes with base == Page. From c120fe923f1b599c26743290437c1fd4c4655b7d Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 21:00:03 +0100 Subject: [PATCH 09/33] NotInitializedException moved to CoreElement/PlayElement # Conflicts: # mops/playwright/play_element.py # mops/selenium/core/core_element.py --- mops/base/element.py | 10 ---------- mops/playwright/play_element.py | 10 +++++++++- mops/selenium/core/core_element.py | 10 +++++++++- mops/utils/internal_utils.py | 4 ---- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index 1a9a8389..a8c32695 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -31,7 +31,6 @@ is_target_on_screen, initialize_objects, get_child_elements_with_names, - safe_getattribute, set_parent_for_attr, is_page, QUARTER_WAIT_EL, @@ -68,15 +67,6 @@ def __call__(self, driver_wrapper: DriverWrapper = None): self.__full_init__(driver_wrapper=get_driver_wrapper_from_object(driver_wrapper)) return self - def __getattribute__(self, item): - if item == 'element' and not safe_getattribute(self, '_initialized'): - raise NotInitializedException( - f'{repr(self)} object is not initialized. ' - 'Try to initialize base object first or call it directly as a method' - ) - - return safe_getattribute(self, item) - def __init__( self, locator: Union[Locator, str], diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index a9cbb4e6..0579b60a 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -15,7 +15,7 @@ from mops.mixins.objects.location import Location from mops.utils.selector_synchronizer import get_platform_locator, set_playwright_locator from mops.abstraction.element_abc import ElementABC -from mops.exceptions import TimeoutException, InvalidSelectorException +from mops.exceptions import TimeoutException, NotInitializedException, InvalidSelectorException from mops.utils.logs import Logging from mops.shared_utils import cut_log_data, get_image from mops.utils.internal_utils import ( @@ -33,6 +33,8 @@ class PlayElement(ElementABC, Logging, ABC): context: BrowserContext driver: Page parent: Union[ElementABC, PlayElement] + + _initialized: bool _element: Locator = None def __init__(self): # noqa @@ -53,6 +55,12 @@ def element(self) -> Locator: :param: kwargs: kwargs from Locator object :return: Locator """ + if not self._initialized: + raise NotInitializedException( + f'{repr(self)} object is not initialized. ' + 'Try to initialize base object first or call it directly as a method' + ) + element = self._element if not element: driver = self._get_base() diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 70e870f2..6a44cb50 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -35,7 +35,7 @@ DriverWrapperException, NoSuchElementException, ElementNotInteractableException, - NoSuchParentException, + NoSuchParentException, NotInitializedException, ) if TYPE_CHECKING: @@ -46,6 +46,8 @@ class CoreElement(ElementABC, ABC): parent: Union[Element] locator_type: str + + _initialized: bool _element: Union[None, SeleniumWebElement, AppiumWebElement] = None _cached_element: Union[None, SeleniumWebElement, AppiumWebElement] = None @@ -58,6 +60,12 @@ def element(self) -> SeleniumWebElement: :return: SeleniumWebElement """ + if not self._initialized: + raise NotInitializedException( + f'{repr(self)} object is not initialized. ' + 'Try to initialize base object first or call it directly as a method' + ) + return self._get_element() @element.setter diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index a0ee1025..4e55cf7f 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -65,10 +65,6 @@ def get_timeout_in_ms(timeout: Union[int, float]): return validate_timeout(timeout) * 1000 -def safe_getattribute(obj, item): - return object.__getattribute__(obj, item) - - def get_frame(frame=1): """ Get frame by given id From 7132da4a839794ba679fb3e100e24f6f6e051692 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 26 Jan 2025 22:37:32 +0100 Subject: [PATCH 10/33] Static test fixes --- mops/mixins/internal_mixin.py | 5 +- .../integration/test_child_elements.py | 4 +- .../performance/test_overall_performance.py | 126 ++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 tests/static_tests/performance/test_overall_performance.py diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 7b7b8a61..9f77db59 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -8,6 +8,7 @@ from mops.utils.internal_utils import ( get_child_elements_with_names, get_child_elements, + get_all_attributes_from_object, ) @@ -35,9 +36,9 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: def get_static_with_bases(cls: Any) -> dict: return get_child_elements_with_names(cls) -@lru_cache(maxsize=16) +@lru_cache(maxsize=64) def get_static_without_bases(cls: Any) -> dict: - return cls.__dict__ + return get_all_attributes_from_object(cls) class InternalMixin: diff --git a/tests/static_tests/integration/test_child_elements.py b/tests/static_tests/integration/test_child_elements.py index 5ebd4969..4cedd772 100644 --- a/tests/static_tests/integration/test_child_elements.py +++ b/tests/static_tests/integration/test_child_elements.py @@ -29,8 +29,8 @@ def test_child_elements(mocked_selenium_driver): section3 = Section3() assert section3.some_element assert section3.some_element._initialized # noqa - assert section3.child_elements + assert section3.sub_elements assert section2.some_element assert section2.some_element._initialized # noqa - assert section2.child_elements + assert section2.sub_elements diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py new file mode 100644 index 00000000..909038b7 --- /dev/null +++ b/tests/static_tests/performance/test_overall_performance.py @@ -0,0 +1,126 @@ +import cProfile +import pstats +import tracemalloc +import time + +import pytest + +from mops.base.element import Element +from mops.base.group import Group +from mops.base.page import Page + + +section_sub_elements_count = 5000 + + +class AnotherSection1(Group): + + def __init__(self): + super().__init__('AnotherSection') + + +class AnotherSection(Group): + + def __init__(self): + super().__init__('AnotherSection') + + another_some_element = Element('AnotherSection_another_some_element') + + +class SomeSection(Group): + + def __init__(self, locator): + super().__init__(locator) + + AnotherSection = AnotherSection() + + +class SomePage(Page): + + def __init__(self, driver_wrapper = None): + super().__init__('SomePage', driver_wrapper=driver_wrapper) + + + +@pytest.fixture(scope='module') +def set_elements_class_var_objects(): + for _i in range(section_sub_elements_count): + _element = Element(f'{_i}_element') + setattr(AnotherSection1, _element.name, _element) + + +@pytest.mark.parametrize('case', range(5)) +def test_performance_element_initialisation(mocked_selenium_driver, case, set_elements_class_var_objects): + tracemalloc.start() + start_cpu = time.process_time() + + with cProfile.Profile() as pr: + section = AnotherSection1() + + end_cpu = time.process_time() + cpu_time = end_cpu - start_cpu # CPU time used + + peak_mem = tracemalloc.get_traced_memory()[1] / 1024**2 + tracemalloc.stop() + + stats: pstats.Stats = pstats.Stats(pr) + stats.strip_dirs().sort_stats("time").print_stats(20) + + print('stats.total_tt=', stats.total_tt) + print('peak_mem=', peak_mem) + print('cpu_time=', cpu_time) + + assert stats.total_tt < 0.45, f"Execution time too high: {stats.total_tt:.3f} sec" + assert peak_mem < 11, f"Peak memory usage too high: {peak_mem:.2f} MB" + assert len(section.sub_elements) == section_sub_elements_count, \ + f"Expected {section_sub_elements_count} elements, got {len(section.sub_elements)}" + assert cpu_time < 0.45, f"CPU execution time too high: {cpu_time:.3f} sec" + + +@pytest.fixture(scope='module') +def set_groups_class_var_objects(): + for _i in range(20): + _element = Element(f'AnotherSection_another_some_element_{_i}') + setattr(AnotherSection, _element.name, _element) + + for _i in range(50): + _element = Element(f'SomeSection_some_element_{_i}') + setattr(SomeSection, _element.name, _element) + + for _i in range(50): + _section = SomeSection(f'{_i}_SomeSection') + setattr(SomePage, _section.name, _section) + + +@pytest.mark.parametrize('case', range(5)) +def test_performance_group_initialisation(mocked_selenium_driver, case, set_groups_class_var_objects): + + tracemalloc.start() + start_cpu = time.process_time() + + with cProfile.Profile() as pr: + page = SomePage() + + end_cpu = time.process_time() + cpu_time = end_cpu - start_cpu # CPU time used + + peak_mem = tracemalloc.get_traced_memory()[1] / 1024**2 + tracemalloc.stop() + + stats: pstats.Stats = pstats.Stats(pr) + stats.strip_dirs().sort_stats("time").print_stats(20) + + count = len(page.sub_elements) + for page_object in page.sub_elements.values(): + count += len(page_object.sub_elements) + for sub_element in page_object.sub_elements.values(): + count += len(sub_element.sub_elements) + + print('stats.total_tt=', stats.total_tt) + print('peak_mem=', peak_mem) + print('cpu_time=', cpu_time) + + assert stats.total_tt < 1.5, f"Execution time too high: {stats.total_tt:.3f} sec" + assert peak_mem < 4, f"Peak memory usage too high: {peak_mem:.2f} MB" + assert cpu_time < 1.5, f"CPU execution time too high: {cpu_time:.3f} sec" + assert count > 3600, f"Expected 3600 elements, got {count}" \ No newline at end of file From 683b1e00825faa00d689c926ce75a2dcba4945a0 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 27 Jan 2025 00:03:55 +0100 Subject: [PATCH 11/33] Static test fixes --- mops/mixins/internal_mixin.py | 2 +- .../performance/test_overall_performance.py | 52 ++++++++++++++++--- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 9f77db59..f4ad1419 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -36,7 +36,7 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: def get_static_with_bases(cls: Any) -> dict: return get_child_elements_with_names(cls) -@lru_cache(maxsize=64) +@lru_cache(maxsize=16) def get_static_without_bases(cls: Any) -> dict: return get_all_attributes_from_object(cls) diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index 909038b7..be76be0b 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -1,5 +1,6 @@ import cProfile import pstats +import sys import tracemalloc import time @@ -70,11 +71,29 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el print('peak_mem=', peak_mem) print('cpu_time=', cpu_time) - assert stats.total_tt < 0.45, f"Execution time too high: {stats.total_tt:.3f} sec" - assert peak_mem < 11, f"Peak memory usage too high: {peak_mem:.2f} MB" + expected_peak_mem = 5.3 + expected_init_duration = 0.6 + + if sys.version_info >= (3, 9): + expected_peak_mem = 5.3 + expected_init_duration = 0.6 + if sys.version_info >= (3, 10): + expected_peak_mem = 5.3 + expected_init_duration = 0.6 + if sys.version_info >= (3, 11): + expected_peak_mem = 4.6 + expected_init_duration = 1.0 + if sys.version_info >= (3, 12): + expected_peak_mem = 4.4 + expected_init_duration = 1.2 + + assert expected_init_duration -0.5 < stats.total_tt < expected_init_duration,\ + f"Execution time too high: {stats.total_tt:.3f} sec" + assert expected_peak_mem -1 < peak_mem < expected_peak_mem,\ + f"Peak memory usage too high: {peak_mem:.2f} MB" assert len(section.sub_elements) == section_sub_elements_count, \ f"Expected {section_sub_elements_count} elements, got {len(section.sub_elements)}" - assert cpu_time < 0.45, f"CPU execution time too high: {cpu_time:.3f} sec" + assert cpu_time < expected_init_duration, f"CPU execution time too high: {cpu_time:.3f} sec" @pytest.fixture(scope='module') @@ -120,7 +139,28 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou print('peak_mem=', peak_mem) print('cpu_time=', cpu_time) - assert stats.total_tt < 1.5, f"Execution time too high: {stats.total_tt:.3f} sec" - assert peak_mem < 4, f"Peak memory usage too high: {peak_mem:.2f} MB" - assert cpu_time < 1.5, f"CPU execution time too high: {cpu_time:.3f} sec" + expected_peak_mem = 3.9 + expected_init_duration = 0.7 + + if sys.version_info >= (3, 9): + expected_peak_mem = 3.9 + expected_init_duration = 1.0 + + if sys.version_info >= (3, 10): + expected_peak_mem = 3.9 + expected_init_duration = 0.95 + + if sys.version_info >= (3, 11): + expected_peak_mem = 3.3 + expected_init_duration = 1.45 + + if sys.version_info >= (3, 12): + expected_peak_mem = 3.1 + expected_init_duration = 1.75 + + assert expected_init_duration -0.5 < stats.total_tt < expected_init_duration, \ + f"Execution time too high: {stats.total_tt:.3f} sec" + assert expected_peak_mem -1 < peak_mem < expected_peak_mem, \ + f"Peak memory usage too high: {peak_mem:.2f} MB" + assert cpu_time < expected_init_duration, f"CPU execution time too high: {cpu_time:.3f} sec" assert count > 3600, f"Expected 3600 elements, got {count}" \ No newline at end of file From c48efd9b2e38704996217e8d24bb5af9fdf851e7 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 27 Jan 2025 00:06:15 +0100 Subject: [PATCH 12/33] Static test fixes --- tests/static_tests/performance/test_overall_performance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index be76be0b..091ea2b2 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -76,10 +76,10 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el if sys.version_info >= (3, 9): expected_peak_mem = 5.3 - expected_init_duration = 0.6 + expected_init_duration = 0.65 if sys.version_info >= (3, 10): expected_peak_mem = 5.3 - expected_init_duration = 0.6 + expected_init_duration = 0.7 if sys.version_info >= (3, 11): expected_peak_mem = 4.6 expected_init_duration = 1.0 @@ -148,7 +148,7 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou if sys.version_info >= (3, 10): expected_peak_mem = 3.9 - expected_init_duration = 0.95 + expected_init_duration = 1.1 if sys.version_info >= (3, 11): expected_peak_mem = 3.3 From e0baedcc9767eee2b5e40e572b0c03d8be88a4ed Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 27 Jan 2025 00:09:14 +0100 Subject: [PATCH 13/33] Static test fixes --- tests/static_tests/performance/test_overall_performance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index 091ea2b2..a9f0e57f 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -144,11 +144,11 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou if sys.version_info >= (3, 9): expected_peak_mem = 3.9 - expected_init_duration = 1.0 + expected_init_duration = 1.2 if sys.version_info >= (3, 10): expected_peak_mem = 3.9 - expected_init_duration = 1.1 + expected_init_duration = 1.2 if sys.version_info >= (3, 11): expected_peak_mem = 3.3 From 9087d2df4cb85b01ee648777215afabac9bd657b Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 30 Jan 2025 20:47:57 +0100 Subject: [PATCH 14/33] Small update --- mops/abstraction/element_abc.py | 4 +++- mops/base/element.py | 12 +++--------- mops/base/group.py | 3 +-- mops/base/page.py | 9 +++------ mops/mixins/internal_mixin.py | 18 +++++++++--------- mops/utils/internal_utils.py | 31 ++++--------------------------- 6 files changed, 23 insertions(+), 54 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 8c8a5b24..5f7b609e 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -25,8 +25,10 @@ class ElementABC(MixinABC, ABC): locator: Union[Locator, str] - name: str = '' + locator_type: str + log_locator: str parent: Union[Any, bool, None] = None + name: str = '' wait: Optional[bool] = None @property diff --git a/mops/base/element.py b/mops/base/element.py index a8c32695..a4b1a7b8 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -95,12 +95,6 @@ def __init__( """ self._validate_inheritance() - if parent: - if not isinstance(parent, (bool, Element)): - error = (f'The given "parent" arg of "{self.name}" should take an Element/Group ' - f'object or False for skip. Get {parent}') - raise ValueError(error) - self.locator = locator self.name = name if name else locator self.parent = parent @@ -132,11 +126,11 @@ def __init_base_class__(self) -> None: :return: None """ - if isinstance(self.driver, PlaywrightDriver): + if self._get_driver_instance(PlaywrightDriver): self._base_cls = PlayElement - elif isinstance(self.driver, AppiumDriver): + elif self._get_driver_instance(AppiumDriver): self._base_cls = MobileElement - elif isinstance(self.driver, SeleniumDriver): + elif self._get_driver_instance(SeleniumDriver): self._base_cls = WebElement else: raise DriverWrapperException(f'Cant specify {self.__class__.__name__}') diff --git a/mops/base/group.py b/mops/base/group.py index b4ba2f9d..5d444217 100644 --- a/mops/base/group.py +++ b/mops/base/group.py @@ -1,13 +1,12 @@ from __future__ import annotations -from typing import Any, Union, List, Optional +from typing import Any, Union, Optional from mops.base.driver_wrapper import DriverWrapper from mops.base.element import Element from mops.mixins.objects.locator import Locator from mops.utils.internal_utils import ( set_parent_for_attr, - get_child_elements, initialize_objects, get_child_elements_with_names ) diff --git a/mops/base/page.py b/mops/base/page.py index 1f278835..8ca63946 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Union, Any, List, Type +from typing import Union, Any, Type from playwright.sync_api import Page as PlaywrightDriver from appium.webdriver.webdriver import WebDriver as AppiumDriver @@ -22,7 +22,6 @@ WAIT_PAGE, initialize_objects, get_child_elements_with_names, - get_child_elements, is_element_instance, ) @@ -87,8 +86,6 @@ def __init__( self._modify_sub_elements() self._safe_setter('__base_obj_id', id(self)) - self.page_elements: List[Element] = get_child_elements(self, Element) - self.__init_base_class__() def __init_base_class__(self) -> None: @@ -159,7 +156,7 @@ def wait_page_loaded(self, silent: bool = False, timeout: Union[int, float] = WA self.anchor.wait_visibility(timeout=timeout, silent=True) - for element in self.page_elements: + for element in self.sub_elements.values(): if getattr(element, 'wait') is False: element.wait_hidden(timeout=timeout, silent=True) elif getattr(element, 'wait') is True: @@ -179,7 +176,7 @@ def is_page_opened(self, with_elements: bool = False, with_url: bool = False) -> result = True if with_elements: - for element in self.page_elements: + for element in self.sub_elements.values(): if getattr(element, 'wait'): result &= element.is_displayed(silent=True) if not result: diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index f4ad1419..0a19b01a 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -3,19 +3,12 @@ from functools import lru_cache from typing import Any -from appium.webdriver.common.appiumby import AppiumBy - from mops.utils.internal_utils import ( get_child_elements_with_names, - get_child_elements, get_all_attributes_from_object, ) -all_locator_types = get_child_elements(AppiumBy, str) -available_kwarg_keys = ('desktop', 'mobile', 'ios', 'android') - - def get_element_info(element: Any, label: str = 'Selector=') -> str: """ Get element selector information with parent object selector if it exists @@ -36,13 +29,20 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: def get_static_with_bases(cls: Any) -> dict: return get_child_elements_with_names(cls) -@lru_cache(maxsize=16) +@lru_cache(maxsize=64) def get_static_without_bases(cls: Any) -> dict: return get_all_attributes_from_object(cls) +@lru_cache(maxsize=16) +def get_driver_instance(driver, instance) -> bool: + return isinstance(driver, instance) + class InternalMixin: - call = 0 + driver: None + + def _get_driver_instance(self, instance): + return get_driver_instance(self.driver, instance) def _safe_setter(self, var: str, value: Any): if not hasattr(self, var): diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index 4e55cf7f..74690aae 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -161,20 +161,11 @@ def promote_parent_element(obj: Any, base_obj: Any, cls: Any): return None if is_element_instance(initial_parent) and initial_parent != base_obj: - for el in get_child_elements(base_obj, cls): + for el in get_child_elements_with_names(base_obj, cls).values(): if obj.parent.__base_obj_id == el.__base_obj_id: obj.parent = el -def get_child_elements(obj: object, instance: Union[type, tuple]) -> list: - """ - Return objects of this object by instance - - :returns: list of page elements and page objects - """ - return list(get_child_elements_with_names(obj, instance).values()) - - def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) -> dict: """ Return all objects of given object or by instance @@ -200,10 +191,6 @@ def get_all_attributes_from_object(reference_obj: Any) -> dict: :return: dict of all attributes """ items = {} - - if not reference_obj: - return items - reference_class = reference_obj if inspect.isclass(reference_obj) else reference_obj.__class__ all_bases = list(inspect.getmro(reference_class)) all_bases.reverse() # Reverse needed for collect subclasses attributes as base one @@ -213,9 +200,9 @@ def get_all_attributes_from_object(reference_obj: Any) -> dict: if 'ABC' in str(parent_class) or parent_class == object: continue - items.update(dict(parent_class.__dict__)) + items.update(get_attributes_from_object(parent_class)) - return {**items, **get_attributes_from_object(reference_obj)} + return {**items, **get_attributes_from_object(reference_class), **get_attributes_from_object(reference_obj)} def get_attributes_from_object(reference_obj: Any) -> dict: @@ -225,17 +212,7 @@ def get_attributes_from_object(reference_obj: Any) -> dict: :param reference_obj: :return: """ - items = {} - - if not reference_obj: - return items - - if not inspect.isclass(reference_obj): - items.update(dict(reference_obj.__class__.__dict__)) - - items.update(dict(reference_obj.__dict__)) - - return items + return dict(reference_obj.__dict__) def is_target_on_screen(x: int, y: int, possible_range: Size): From e7542217ab4f97f7b8b67f8eb1dbf485467390f2 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 31 Jan 2025 01:07:10 +0100 Subject: [PATCH 15/33] get_all_sub_elements added --- mops/base/element.py | 3 -- mops/base/group.py | 1 - mops/base/page.py | 4 +- mops/utils/internal_utils.py | 46 +++++++++++++------ .../performance/test_overall_performance.py | 18 +++----- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index a4b1a7b8..5e27a81d 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -93,15 +93,12 @@ def __init__( an object containing it to be used for this element. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ - self._validate_inheritance() - self.locator = locator self.name = name if name else locator self.parent = parent self.wait = wait self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) - self._init_locals = getattr(self, '_init_locals', locals()) self._safe_setter('__base_obj_id', id(self)) self._initialized = False diff --git a/mops/base/group.py b/mops/base/group.py index 5d444217..cfd32033 100644 --- a/mops/base/group.py +++ b/mops/base/group.py @@ -62,7 +62,6 @@ def __init__( an object containing it to be used for entire group. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ - self._init_locals = locals() super().__init__( locator=locator, name=name, diff --git a/mops/base/page.py b/mops/base/page.py index 8ca63946..4bc94056 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -43,6 +43,7 @@ class Page(DriverMixin, InternalMixin, Logging, PageABC): _base_cls: Type[PlayPage, MobilePage, WebPage] anchor: Element + url: str def __new__(cls, *args, **kwargs): instance = super(Page, cls).__new__(cls) @@ -79,9 +80,6 @@ def __init__( self.log_locator = self.anchor.log_locator self.name = self.anchor.name - self.url = getattr(self, 'url', '') - - self._init_locals = locals() self._modify_page_driver_wrapper(driver_wrapper) self._modify_sub_elements() self._safe_setter('__base_obj_id', id(self)) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index 74690aae..ab546c9d 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -175,42 +175,62 @@ def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) """ elements = {} - for attribute, value in get_all_attributes_from_object(obj).items(): - if not instance or isinstance(value, instance): - if attribute != 'parent' and not attribute.startswith('__') and not attribute.endswith('__'): - elements.update({attribute: value}) + if is_element(instance): + elements = get_all_sub_elements(obj) + + if not elements: + for attribute, value in get_all_attributes_from_object(obj).items(): + if not instance or isinstance(value, instance): + if not attribute.startswith('__') and attribute != 'parent': + elements[attribute] = value return elements +def get_all_sub_elements(instance, sub_elements: dict = None, unique: bool = False): + if sub_elements is None: + sub_elements = {} + + if hasattr(instance, 'sub_elements') and instance.sub_elements: + for key, sub_element in instance.sub_elements.items(): + if unique: + key = f'{hex(id(instance))}.{key}' + sub_elements[key] = sub_element + if hasattr(sub_element, 'sub_elements') and sub_element.sub_elements: + get_all_sub_elements(sub_element, sub_elements, unique=unique) + + return sub_elements + + def get_all_attributes_from_object(reference_obj: Any) -> dict: """ - Get attributes from given object and all its bases + Get attributes from the given object and all its bases. :param reference_obj: reference object :return: dict of all attributes """ items = {} reference_class = reference_obj if inspect.isclass(reference_obj) else reference_obj.__class__ - all_bases = list(inspect.getmro(reference_class)) - all_bases.reverse() # Reverse needed for collect subclasses attributes as base one - - for parent_class in all_bases: + all_bases = inspect.getmro(reference_class) + for parent_class in all_bases[-2::-1]: # Skip the reference class itself if 'ABC' in str(parent_class) or parent_class == object: continue items.update(get_attributes_from_object(parent_class)) - return {**items, **get_attributes_from_object(reference_class), **get_attributes_from_object(reference_obj)} + items.update(get_attributes_from_object(reference_class)) + items.update(get_attributes_from_object(reference_obj)) + + return items def get_attributes_from_object(reference_obj: Any) -> dict: """ - Get attributes from given object + Get attributes from the given object. - :param reference_obj: - :return: + :param reference_obj: reference object + :return: dict of attributes """ return dict(reference_obj.__dict__) diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index a9f0e57f..5e31f1dd 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -9,7 +9,7 @@ from mops.base.element import Element from mops.base.group import Group from mops.base.page import Page - +from mops.utils.internal_utils import get_all_sub_elements section_sub_elements_count = 5000 @@ -129,34 +129,30 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou stats: pstats.Stats = pstats.Stats(pr) stats.strip_dirs().sort_stats("time").print_stats(20) - count = len(page.sub_elements) - for page_object in page.sub_elements.values(): - count += len(page_object.sub_elements) - for sub_element in page_object.sub_elements.values(): - count += len(sub_element.sub_elements) + count = len(get_all_sub_elements(page, unique=True)) print('stats.total_tt=', stats.total_tt) print('peak_mem=', peak_mem) print('cpu_time=', cpu_time) expected_peak_mem = 3.9 - expected_init_duration = 0.7 + expected_init_duration = 0.5 if sys.version_info >= (3, 9): expected_peak_mem = 3.9 - expected_init_duration = 1.2 + expected_init_duration = 0.8 if sys.version_info >= (3, 10): expected_peak_mem = 3.9 - expected_init_duration = 1.2 + expected_init_duration = 0.9 if sys.version_info >= (3, 11): expected_peak_mem = 3.3 - expected_init_duration = 1.45 + expected_init_duration = 1.0 if sys.version_info >= (3, 12): expected_peak_mem = 3.1 - expected_init_duration = 1.75 + expected_init_duration = 1.1 assert expected_init_duration -0.5 < stats.total_tt < expected_init_duration, \ f"Execution time too high: {stats.total_tt:.3f} sec" From 760235b9f73045d8c0a219fe50abf512f5cf73db Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sat, 1 Feb 2025 02:36:43 +0100 Subject: [PATCH 16/33] Final speed up fixes. Static tests need to be fixed --- mops/abstraction/element_abc.py | 9 ++- mops/base/driver_wrapper.py | 4 +- mops/base/element.py | 38 +++++------- mops/base/group.py | 14 ++--- mops/base/page.py | 51 ++++++++-------- mops/mixins/internal_mixin.py | 24 ++++---- mops/playwright/play_element.py | 17 +++--- mops/selenium/core/core_element.py | 7 +++ mops/selenium/elements/mobile_element.py | 14 ++--- mops/selenium/elements/web_element.py | 12 ++-- mops/shared_utils.py | 13 ++++ mops/utils/internal_utils.py | 76 +++++++++++------------- 12 files changed, 143 insertions(+), 136 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 5f7b609e..67f04da4 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -27,9 +27,9 @@ class ElementABC(MixinABC, ABC): locator: Union[Locator, str] locator_type: str log_locator: str - parent: Union[Any, bool, None] = None - name: str = '' - wait: Optional[bool] = None + name: str + parent: Union[Any, bool, None] + wait: Optional[bool] @property def element(self) -> Union[SeleniumWebElement, AppiumWebElement, PlayWebElement]: @@ -879,3 +879,6 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Element]: :return: A list of wrapped :class:`Element` objects. """ raise NotImplementedError() + + def _set_locator(self): + raise NotImplementedError() diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 8b8c8d38..75fae433 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -20,7 +20,7 @@ from mops.selenium.driver.web_driver import WebDriver from mops.exceptions import DriverWrapperException from mops.mixins.internal_mixin import InternalMixin -from mops.utils.internal_utils import get_attributes_from_object, get_child_elements_with_names +from mops.utils.internal_utils import get_attributes_from_object, extract_named_objects from mops.utils.logs import Logging, LogLevel @@ -129,7 +129,7 @@ def __new__(cls, *args, **kwargs): else: cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), get_attributes_from_object(cls))) # noqa - for name, _ in get_child_elements_with_names(cls, bool).items(): + for name, _ in extract_named_objects(cls, bool).items(): setattr(cls, name, False) return cls diff --git a/mops/base/element.py b/mops/base/element.py index 5e27a81d..0d412cdb 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -30,9 +30,8 @@ WAIT_EL, is_target_on_screen, initialize_objects, - get_child_elements_with_names, + extract_named_objects, set_parent_for_attr, - is_page, QUARTER_WAIT_EL, wait_condition, ) @@ -51,9 +50,13 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): and provides a unified interface for UI interactions. """ - _object = 'element' + _object: str = 'element' + _initialized: bool = False _base_cls: Type[PlayElement, MobileElement, WebElement] + driver_wrapper: DriverWrapper + log_locator: Union[str, None] = None + locator_type: Union[str, None] = None def __new__(cls, *args, **kwargs): instance = super(Element, cls).__new__(cls) @@ -93,14 +96,14 @@ def __init__( an object containing it to be used for this element. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ + self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) + self.locator = locator - self.name = name if name else locator + self.name = name or locator self.parent = parent self.wait = wait - self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) self._safe_setter('__base_obj_id', id(self)) - self._initialized = False if self.driver_wrapper: self.__full_init__(driver_wrapper) @@ -108,7 +111,7 @@ def __init__( def __full_init__(self, driver_wrapper: Any = None): self._driver_wrapper_given = bool(driver_wrapper) - if self._driver_wrapper_given and driver_wrapper != self.driver_wrapper: + if driver_wrapper and driver_wrapper != self.driver_wrapper: self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) self._modify_object() @@ -123,11 +126,11 @@ def __init_base_class__(self) -> None: :return: None """ - if self._get_driver_instance(PlaywrightDriver): + if self._driver_is_instance(PlaywrightDriver): self._base_cls = PlayElement - elif self._get_driver_instance(AppiumDriver): + elif self._driver_is_instance(AppiumDriver): self._base_cls = MobileElement - elif self._get_driver_instance(SeleniumDriver): + elif self._driver_is_instance(SeleniumDriver): self._base_cls = WebElement else: raise DriverWrapperException(f'Cant specify {self.__class__.__name__}') @@ -739,7 +742,7 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: wrapped_object: Any = copy(self) wrapped_object.element = element wrapped_object._wrapped = True - set_parent_for_attr(wrapped_object, wrapped_object.sub_elements, Element, with_copy=True) + set_parent_for_attr(wrapped_object, with_copy=True) wrapped_elements.append(wrapped_object) return wrapped_elements @@ -752,8 +755,8 @@ def _modify_sub_elements(self): self.sub_elements = {} if type(self) is not Element: - self.sub_elements = get_child_elements_with_names(self, Element) - initialize_objects(self, self.sub_elements, Element) + self.sub_elements = extract_named_objects(self, Element) + initialize_objects(self, self.sub_elements) def _modify_object(self): """ @@ -762,12 +765,3 @@ def _modify_object(self): """ if not self._driver_wrapper_given: PreviousObjectDriver().set_driver_from_previous_object(self) - - def _validate_inheritance(self): - cls = self.__class__ - mro = cls.__mro__ - - for item in mro: - if is_page(item): - raise TypeError( - f"You cannot make an inheritance for {cls.__name__} from both Element/Group and Page objects") diff --git a/mops/base/group.py b/mops/base/group.py index cfd32033..9a737a63 100644 --- a/mops/base/group.py +++ b/mops/base/group.py @@ -8,7 +8,7 @@ from mops.utils.internal_utils import ( set_parent_for_attr, initialize_objects, - get_child_elements_with_names + extract_named_objects ) @@ -26,11 +26,7 @@ class Group(Element): This class provides functionality for handling element locators, initialization with respect to the driver, and managing sub-elements within the group. """ - - _object = 'group' - - def __repr__(self): - return self._repr_builder() + _object: str = 'group' def __init__( self, @@ -75,6 +71,6 @@ def _modify_sub_elements(self) -> None: Initializing of attributes with type == Group/Element. Required for classes with base == Group. """ - self.sub_elements = get_child_elements_with_names(self, Element) - initialize_objects(self, self.sub_elements, Element) - set_parent_for_attr(self, self.sub_elements, Element) + self.sub_elements = extract_named_objects(self, Element) + initialize_objects(self, self.sub_elements) + set_parent_for_attr(self) diff --git a/mops/base/page.py b/mops/base/page.py index 4bc94056..a6e5bb01 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import cached_property from typing import Union, Any, Type from playwright.sync_api import Page as PlaywrightDriver @@ -21,7 +22,7 @@ from mops.utils.internal_utils import ( WAIT_PAGE, initialize_objects, - get_child_elements_with_names, + extract_named_objects, is_element_instance, ) @@ -42,8 +43,9 @@ class Page(DriverMixin, InternalMixin, Logging, PageABC): _object = 'page' _base_cls: Type[PlayPage, MobilePage, WebPage] - anchor: Element url: str + log_locator: Union[str, None] = None + locator_type: Union[str, None] = None def __new__(cls, *args, **kwargs): instance = super(Page, cls).__new__(cls) @@ -70,15 +72,10 @@ def __init__( an object containing it to be used for entire page. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ - self._validate_inheritance() - self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) - - self.anchor = Element(locator, name=name, driver_wrapper=self.driver_wrapper) - self.locator = self.anchor.locator - self.locator_type = self.anchor.locator_type - self.log_locator = self.anchor.log_locator - self.name = self.anchor.name + + self.locator = locator + self.name = name self._modify_page_driver_wrapper(driver_wrapper) self._modify_sub_elements() @@ -92,11 +89,11 @@ def __init_base_class__(self) -> None: :return: None """ - if isinstance(self.driver, PlaywrightDriver): + if self._driver_is_instance(PlaywrightDriver): self._base_cls = PlayPage - elif isinstance(self.driver, AppiumDriver): + elif self._driver_is_instance(AppiumDriver): self._base_cls = MobilePage - elif isinstance(self.driver, SeleniumDriver): + elif self._driver_is_instance(SeleniumDriver): self._base_cls = WebPage else: raise DriverWrapperException(f'Cant specify {Page.__name__}') @@ -104,6 +101,21 @@ def __init_base_class__(self) -> None: self._set_static(self._base_cls) self._base_cls.__init__(self) + @cached_property + def anchor(self) -> Element: + """ + Return the anchor element of the page + + :return: :base:`.Element` + """ + anchor = Element(self.locator, name=self.name, driver_wrapper=self.driver_wrapper) + self.locator = anchor.locator + self.name = anchor.name + self.locator_type = anchor.locator_type + self.log_locator = anchor.log_locator + + return anchor + # Following methods works same for both Selenium/Appium and Playwright APIs using internal methods def reload_page(self, wait_page_load: bool = True) -> Page: @@ -192,8 +204,8 @@ def _modify_sub_elements(self): Initializing of attributes with type == Element. Required for classes with base == Page. """ - self.sub_elements = get_child_elements_with_names(self, Element) - initialize_objects(self, self.sub_elements, Element) + self.sub_elements = extract_named_objects(self, Element) + initialize_objects(self, self.sub_elements) def _modify_page_driver_wrapper(self, driver_wrapper: Any): """ @@ -202,12 +214,3 @@ def _modify_page_driver_wrapper(self, driver_wrapper: Any): """ if not driver_wrapper: PreviousObjectDriver().set_driver_from_previous_object(self) - - def _validate_inheritance(self): - cls = self.__class__ - mro = cls.__mro__ - - for item in mro: - if is_element_instance(item): - raise TypeError( - f"You cannot make an inheritance for {cls.__name__} from both Page and Group/Element objects") diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 0a19b01a..ec6e7af8 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -4,8 +4,8 @@ from typing import Any from mops.utils.internal_utils import ( - get_child_elements_with_names, - get_all_attributes_from_object, + extract_named_objects, + extract_all_named_objects, ) @@ -26,12 +26,12 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: return f"{label}'{selector}'" if label else selector @lru_cache(maxsize=16) -def get_static_with_bases(cls: Any) -> dict: - return get_child_elements_with_names(cls) +def get_static_attributes(cls: Any) -> dict: + return extract_named_objects(cls) @lru_cache(maxsize=64) -def get_static_without_bases(cls: Any) -> dict: - return get_all_attributes_from_object(cls) +def get_all_static_attributes(cls: Any) -> dict: + return extract_all_named_objects(cls) @lru_cache(maxsize=16) def get_driver_instance(driver, instance) -> bool: @@ -41,7 +41,7 @@ class InternalMixin: driver: None - def _get_driver_instance(self, instance): + def _driver_is_instance(self, instance): return get_driver_instance(self.driver, instance) def _safe_setter(self, var: str, value: Any): @@ -55,13 +55,11 @@ def _set_static(self: Any, cls) -> None: :return: None """ current_obj_cls = self.__class__ - data = { - name: value for name, value in get_static_with_bases(cls).items() - if name not in get_static_without_bases(current_obj_cls) - }.items() + existing_attrs = set(get_all_static_attributes(current_obj_cls)) - for name, item in data: - setattr(current_obj_cls, name, item) + for name, value in get_static_attributes(cls).items(): + if name not in existing_attrs: + setattr(current_obj_cls, name, value) def _repr_builder(self: Any): class_name = self.__class__.__name__ diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 0579b60a..ff7d5500 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -35,15 +35,9 @@ class PlayElement(ElementABC, Logging, ABC): parent: Union[ElementABC, PlayElement] _initialized: bool + _is_locator_configured: bool = False _element: Locator = None - def __init__(self): # noqa - """ - Initializing of web element with playwright driver - """ - self.locator = get_platform_locator(self) - set_playwright_locator(self) - # Element @property @@ -61,7 +55,11 @@ def element(self) -> Locator: 'Try to initialize base object first or call it directly as a method' ) + if not self._is_locator_configured: + self._set_locator() + element = self._element + if not element: driver = self._get_base() element = driver.locator(self.locator) @@ -578,3 +576,8 @@ def _first_element(self): :return: first element """ return self.element.first + + def _set_locator(self): + self.locator = get_platform_locator(self) + set_playwright_locator(self) + self._is_locator_configured = True diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 6a44cb50..109855f5 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -48,6 +48,7 @@ class CoreElement(ElementABC, ABC): locator_type: str _initialized: bool + _is_locator_configured: bool = False _element: Union[None, SeleniumWebElement, AppiumWebElement] = None _cached_element: Union[None, SeleniumWebElement, AppiumWebElement] = None @@ -620,6 +621,9 @@ def _find_element(self, wait_parent: bool = False) -> Union[SeleniumWebElement, self._cached_element = None try: + if not self._is_locator_configured: + self._set_locator() + element = base.find_element(self.locator_type, self.locator) self._cached_element = element return element @@ -639,6 +643,9 @@ def _find_elements(self, wait_parent: bool = False) -> List[Union[SeleniumWebEle self._cached_element = None try: + if not self._is_locator_configured: + self._set_locator() + elements = base.find_elements(self.locator_type, self.locator) if elements: diff --git a/mops/selenium/elements/mobile_element.py b/mops/selenium/elements/mobile_element.py index a4d8dbc8..ae9d704c 100644 --- a/mops/selenium/elements/mobile_element.py +++ b/mops/selenium/elements/mobile_element.py @@ -9,18 +9,11 @@ from mops.mixins.objects.location import Location from mops.mixins.objects.size import Size from mops.utils.internal_utils import calculate_coordinate_to_click -from mops.utils.selector_synchronizer import get_platform_locator, set_selenium_selector, set_appium_selector +from mops.utils.selector_synchronizer import get_platform_locator, set_appium_selector class MobileElement(CoreElement, ABC): - def __init__(self): - """ - Initializing of mobile element with appium driver - """ - self.locator = get_platform_locator(self) - set_appium_selector(self) - def click_outside(self, x: int = -5, y: int = -5) -> MobileElement: """ Perform a click outside the current element, by default 5px left and above it. @@ -174,3 +167,8 @@ def _element_box(self) -> tuple: element_location.x + element_size.width, element_location.y + element_size.height, ) + + def _set_locator(self): + self.locator = get_platform_locator(self) + set_appium_selector(self) + self._is_locator_configured = True diff --git a/mops/selenium/elements/web_element.py b/mops/selenium/elements/web_element.py index 5ab487a7..6a2c5f64 100644 --- a/mops/selenium/elements/web_element.py +++ b/mops/selenium/elements/web_element.py @@ -10,13 +10,6 @@ class WebElement(CoreElement, ABC): - def __init__(self): - """ - Initializing of web element with selenium driver - """ - self.locator = get_platform_locator(self) - set_selenium_selector(self) - def click(self, *, force_wait: bool = True, **kwargs) -> WebElement: """ Clicks on the element. @@ -122,3 +115,8 @@ def click_into_center(self, silent: bool = False) -> WebElement: self.driver_wrapper.click_by_coordinates(x=x, y=y, silent=True) return self + + def _set_locator(self): + self.locator = get_platform_locator(self) + set_selenium_selector(self) + self._is_locator_configured = True diff --git a/mops/shared_utils.py b/mops/shared_utils.py index 687064e3..78ac17b2 100644 --- a/mops/shared_utils.py +++ b/mops/shared_utils.py @@ -67,6 +67,19 @@ def shell_command(cmd, **kwargs): return process +def get_all_sub_elements(instance, sub_elements: list = None) -> list: + if sub_elements is None: + sub_elements = [] + + if hasattr(instance, 'sub_elements') and instance.sub_elements: + for key, sub_element in instance.sub_elements.items(): + sub_elements.append(sub_element) + if hasattr(sub_element, 'sub_elements') and sub_element.sub_elements: + get_all_sub_elements(sub_element, sub_elements) + + return sub_elements + + def cut_log_data(data: str, length=50) -> str: """ Cut given data for reducing log length diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index ab546c9d..8032aea2 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -5,14 +5,19 @@ import time from copy import copy from functools import lru_cache, wraps -from typing import Any, Union, Callable +from typing import Any, Union, Callable, TYPE_CHECKING -from mops.mixins.objects.size import Size -from mops.mixins.objects.wait_result import Result from selenium.common.exceptions import StaleElementReferenceException as SeleniumStaleElementReferenceException +from mops.mixins.objects.size import Size +from mops.mixins.objects.wait_result import Result from mops.exceptions import NoSuchElementException, InvalidSelectorException, TimeoutException, NoSuchParentException +if TYPE_CHECKING: + from mops.base.element import Element + from mops.base.group import Group + from mops.base.page import Page + WAIT_METHODS_DELAY = 0.1 WAIT_UNIT = 1 @@ -95,44 +100,35 @@ def is_driver_wrapper(obj: Any) -> bool: return getattr(obj, '_object', None) == 'driver_wrapper' -def initialize_objects(current_object, sub_elements: dict, instance_class: Any): +def initialize_objects(current_object: Union[Element, Group, Page], sub_elements: dict): """ Copy objects and initializing them with driver_wrapper from current object :param current_object: list of objects to initialize :param sub_elements: list of objects to initialize - :param instance_class: class of initializing objects :return: None """ for name, obj in sub_elements.items(): copied_obj = copy(obj) - promote_parent_element(copied_obj, current_object, instance_class) + promote_parent_element(copied_obj, current_object) sub_elements[name] = copied_obj setattr(current_object, name, copied_obj(driver_wrapper=current_object.driver_wrapper)) - initialize_objects(copied_obj, copied_obj.sub_elements, instance_class) + initialize_objects(copied_obj, copied_obj.sub_elements) -def set_parent_for_attr( - current_object: object, - sub_elements: dict, - instance_class: Union[type, tuple], - with_copy: bool = False -): +def set_parent_for_attr(current_object: Element, with_copy: bool = False): """ Sets parent for all Elements/Group of given class. Should be called ONLY in Group object or all_elements method. Copy of objects will be executed if with_copy is True. Required for all_elements method - :param instance_class: attribute class to looking for - :param sub_elements: list of objects to initialize :param current_object: object of attribute :param with_copy: copy child object or not :return: self """ - - for name, obj in sub_elements.items(): + for name, obj in current_object.sub_elements.items(): if with_copy: obj = copy(obj) @@ -140,19 +136,19 @@ def set_parent_for_attr( obj.parent = current_object if with_copy: - sub_elements[name] = obj + current_object.sub_elements[name] = obj setattr(current_object, name, obj) - set_parent_for_attr(obj, obj.sub_elements, instance_class, with_copy) + if obj.sub_elements: + set_parent_for_attr(obj, with_copy) -def promote_parent_element(obj: Any, base_obj: Any, cls: Any): +def promote_parent_element(obj: Any, base_obj: Any): """ Promote parent object in Element if parent is another Element :param obj: any element :param base_obj: base object of element: Page/Group instance - :param cls: element class :return: None """ initial_parent = getattr(obj, 'parent', None) @@ -161,12 +157,12 @@ def promote_parent_element(obj: Any, base_obj: Any, cls: Any): return None if is_element_instance(initial_parent) and initial_parent != base_obj: - for el in get_child_elements_with_names(base_obj, cls).values(): + for el in base_obj.sub_elements.values(): if obj.parent.__base_obj_id == el.__base_obj_id: obj.parent = el -def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) -> dict: +def extract_named_objects(obj: Any, instance: Union[type, tuple] = None) -> dict: """ Return all objects of given object or by instance Removing parent attribute from list to avoid infinite recursion and all dunder attributes @@ -176,10 +172,10 @@ def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) elements = {} if is_element(instance): - elements = get_all_sub_elements(obj) + elements = get_main_sub_elements(obj) if not elements: - for attribute, value in get_all_attributes_from_object(obj).items(): + for attribute, value in extract_all_named_objects(obj).items(): if not instance or isinstance(value, instance): if not attribute.startswith('__') and attribute != 'parent': elements[attribute] = value @@ -187,22 +183,7 @@ def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) return elements -def get_all_sub_elements(instance, sub_elements: dict = None, unique: bool = False): - if sub_elements is None: - sub_elements = {} - - if hasattr(instance, 'sub_elements') and instance.sub_elements: - for key, sub_element in instance.sub_elements.items(): - if unique: - key = f'{hex(id(instance))}.{key}' - sub_elements[key] = sub_element - if hasattr(sub_element, 'sub_elements') and sub_element.sub_elements: - get_all_sub_elements(sub_element, sub_elements, unique=unique) - - return sub_elements - - -def get_all_attributes_from_object(reference_obj: Any) -> dict: +def extract_all_named_objects(reference_obj: Any) -> dict: """ Get attributes from the given object and all its bases. @@ -225,6 +206,19 @@ def get_all_attributes_from_object(reference_obj: Any) -> dict: return items +def get_main_sub_elements(instance, sub_elements: dict = None): + if sub_elements is None: + sub_elements = {} + + if hasattr(instance, 'sub_elements') and instance.sub_elements: + for key, sub_element in instance.sub_elements.items(): + sub_elements[key] = sub_element + if hasattr(sub_element, 'sub_elements') and sub_element.sub_elements: + get_main_sub_elements(sub_element, sub_elements) + + return sub_elements + + def get_attributes_from_object(reference_obj: Any) -> dict: """ Get attributes from the given object. From 2c53d0452ea5c6af65167f3653a23d0e4d270bc4 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sat, 1 Feb 2025 02:38:04 +0100 Subject: [PATCH 17/33] Version updated --- mops/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mops/__init__.py b/mops/__init__.py index 97b3b9fc..95cc3fae 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,2 +1,2 @@ -__version__ = '3.1.0' +__version__ = '3.2.0' __project_name__ = 'mops' From e8e690b7fba774f0d9f7a4b8add5882591061415 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 2 Feb 2025 01:06:34 +0100 Subject: [PATCH 18/33] Element._element_cls added --- mops/base/element.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index 0d412cdb..e2119bff 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -1,6 +1,7 @@ from __future__ import annotations from copy import copy +from functools import cached_property from typing import Union, List, Type, Tuple, Optional, TYPE_CHECKING from PIL.Image import Image @@ -49,7 +50,6 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): It dynamically adapts to different driver types (Playwright, Appium, Selenium) and provides a unified interface for UI interactions. """ - _object: str = 'element' _initialized: bool = False _base_cls: Type[PlayElement, MobileElement, WebElement] @@ -747,21 +747,35 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: return wrapped_elements - def _modify_sub_elements(self): + def _modify_sub_elements(self) -> None: """ Initializing of attributes with type == Element. Required for classes with base == Element. + + :return: :obj:`None` """ self.sub_elements = {} - if type(self) is not Element: + if type(self) is not self._element_cls: self.sub_elements = extract_named_objects(self, Element) initialize_objects(self, self.sub_elements) - def _modify_object(self): + def _modify_object(self) -> None: """ Modify current object if driver_wrapper is not given. Required for Page that placed into functions: - sets driver from previous object + + :return: :obj:`None` """ if not self._driver_wrapper_given: PreviousObjectDriver().set_driver_from_previous_object(self) + + @cached_property + def _element_cls(self) -> Type[Element]: + """ + Returns the `Element` class. + This can be overridden for performance optimizations. + + :return: :obj:`typing.Type` [:class:`Element`] + """ + return Element From 5fcb9c0698f6c82490fa36e74529d3859ee3bcf5 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sun, 2 Feb 2025 22:54:38 +0100 Subject: [PATCH 19/33] Locator setup fixes --- mops/abstraction/element_abc.py | 4 +- mops/base/element.py | 35 ++++++++- mops/playwright/play_element.py | 3 - mops/selenium/core/core_element.py | 6 -- mops/utils/selector_synchronizer.py | 75 +++++++++---------- .../integration/test_inheritance.py | 36 --------- .../performance/test_overall_performance.py | 69 ++++++++++------- .../unit/test_get_platform_locator.py | 2 +- .../unit/test_selector_synchronizer.py | 14 +--- uv.lock | 2 +- 10 files changed, 118 insertions(+), 128 deletions(-) delete mode 100644 tests/static_tests/integration/test_inheritance.py diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 67f04da4..728bc3ce 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -25,12 +25,12 @@ class ElementABC(MixinABC, ABC): locator: Union[Locator, str] - locator_type: str - log_locator: str name: str parent: Union[Any, bool, None] wait: Optional[bool] + _locator_type: Union[str, None] = None + @property def element(self) -> Union[SeleniumWebElement, AppiumWebElement, PlayWebElement]: """ diff --git a/mops/base/element.py b/mops/base/element.py index e2119bff..14c13d34 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -52,11 +52,10 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): """ _object: str = 'element' _initialized: bool = False + _is_locator_configured: bool = False _base_cls: Type[PlayElement, MobileElement, WebElement] driver_wrapper: DriverWrapper - log_locator: Union[str, None] = None - locator_type: Union[str, None] = None def __new__(cls, *args, **kwargs): instance = super(Element, cls).__new__(cls) @@ -139,6 +138,36 @@ def __init_base_class__(self) -> None: self._base_cls.__init__(self) self._initialized = True + @property + def locator(self): + if not self._is_locator_configured: + self._set_locator() + + return self._locator + + @locator.setter + def locator(self, value: Union[Locator, str]): + self._log_locator = value + self._locator = value + + @property + def locator_type(self): + if not self._is_locator_configured: + self._set_locator() + + return self._locator_type + + @locator_type.setter + def locator_type(self, value: str): + self._locator_type = value + + @property + def log_locator(self): + if not self._is_locator_configured: + self._set_locator() + + return self._log_locator + # Following methods works same for both Selenium/Appium and Playwright APIs using internal methods # Elements interaction @@ -749,7 +778,7 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: def _modify_sub_elements(self) -> None: """ - Initializing of attributes with type == Element. + Initializing of attributes with type == Element. Required for classes with base == Element. :return: :obj:`None` diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index ff7d5500..289cab9f 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -55,9 +55,6 @@ def element(self) -> Locator: 'Try to initialize base object first or call it directly as a method' ) - if not self._is_locator_configured: - self._set_locator() - element = self._element if not element: diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 109855f5..2b0f3ccd 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -621,9 +621,6 @@ def _find_element(self, wait_parent: bool = False) -> Union[SeleniumWebElement, self._cached_element = None try: - if not self._is_locator_configured: - self._set_locator() - element = base.find_element(self.locator_type, self.locator) self._cached_element = element return element @@ -643,9 +640,6 @@ def _find_elements(self, wait_parent: bool = False) -> List[Union[SeleniumWebEle self._cached_element = None try: - if not self._is_locator_configured: - self._set_locator() - elements = base.find_elements(self.locator_type, self.locator) if elements: diff --git a/mops/utils/selector_synchronizer.py b/mops/utils/selector_synchronizer.py index c301b231..b9e194c0 100644 --- a/mops/utils/selector_synchronizer.py +++ b/mops/utils/selector_synchronizer.py @@ -24,7 +24,7 @@ def get_platform_locator(obj: Any): :param obj: Page/Group/Element :return: current platform locator """ - locator: Union[Locator, str] = obj.locator + locator: Union[Locator, str] = obj._locator if type(locator) is str or not obj.driver_wrapper: return locator @@ -52,91 +52,88 @@ def set_selenium_selector(obj: Any): """ Sets selenium locator & locator type """ - locator = obj.locator.strip() - obj.log_locator = locator + locator = obj._locator.strip() # Checking the supported locators if locator.startswith(f"{LocatorType.XPATH}="): - obj.locator = obj.locator.split(f"{LocatorType.XPATH}=")[-1] - obj.locator_type = By.XPATH + obj._locator = locator.split(f"{LocatorType.XPATH}=")[-1] + obj._locator_type = By.XPATH elif locator.startswith(f"{LocatorType.TEXT}="): - locator = obj.locator.split(f"{LocatorType.TEXT}=")[-1] - obj.locator = f'//*[contains(text(), "{locator}")]' - obj.locator_type = By.XPATH + locator = locator.split(f"{LocatorType.TEXT}=")[-1] + obj._locator = f'//*[contains(text(), "{locator}")]' + obj._locator_type = By.XPATH elif locator.startswith(f"{LocatorType.CSS}="): - obj.locator = obj.locator.split(f"{LocatorType.CSS}=")[-1] - obj.locator_type = By.CSS_SELECTOR + obj._locator = locator.split(f"{LocatorType.CSS}=")[-1] + obj._locator_type = By.CSS_SELECTOR elif locator.startswith(f"{LocatorType.ID}="): - locator = obj.locator.split(f"{LocatorType.ID}=")[-1] - obj.locator = f'[{LocatorType.ID}="{locator}"]' - obj.locator_type = By.CSS_SELECTOR + locator = locator.split(f"{LocatorType.ID}=")[-1] + obj._locator = f'[{LocatorType.ID}="{locator}"]' + obj._locator_type = By.CSS_SELECTOR # Checking the regular locators elif locator.startswith(XPATH_MATCH): - obj.locator_type = By.XPATH - obj.log_locator = f'{LocatorType.XPATH}={locator}' + obj._locator_type = By.XPATH + obj._log_locator = f'{LocatorType.XPATH}={locator}' elif locator.startswith(CSS_MATCH) or re.search(CSS_REGEXP, locator): - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.CSS}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.CSS}={locator}' elif locator in all_tags or all(tag in all_tags for tag in locator.split()): - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.CSS}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.CSS}={locator}' elif " " in locator: - obj.locator = f'//*[contains(text(), "{locator}")]' - obj.locator_type = By.XPATH - obj.log_locator = f'{LocatorType.XPATH}={obj.locator}' + obj._locator = f'//*[contains(text(), "{locator}")]' + obj._locator_type = By.XPATH + obj._log_locator = f'{LocatorType.XPATH}={obj._locator}' # Default to ID if nothing else matches else: - locator = obj.locator.split(f"{LocatorType.ID}=")[-1] - obj.locator = f'[{LocatorType.ID}="{locator}"]' - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.ID}={locator}' + locator = locator.split(f"{LocatorType.ID}=")[-1] + obj._locator = f'[{LocatorType.ID}="{locator}"]' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.ID}={locator}' def set_playwright_locator(obj: Any): """ Sets playwright locator & locator type """ - locator = obj.locator.strip() - obj.log_locator = locator + locator = obj._locator.strip() # Checking the supported locators if locator.startswith(DEFAULT_MATCH): - obj.locator_type = locator.partition('=')[0] + obj._locator_type = locator.partition('=')[0] return # Checking the regular locators elif locator.startswith(XPATH_MATCH): - obj.locator_type = LocatorType.XPATH + obj._locator_type = LocatorType.XPATH elif locator.startswith(CSS_MATCH) or re.search(CSS_REGEXP, locator): - obj.locator_type = LocatorType.CSS + obj._locator_type = LocatorType.CSS elif locator in all_tags or all(tag in all_tags for tag in locator.split()): - obj.locator_type = LocatorType.CSS + obj._locator_type = LocatorType.CSS elif " " in locator: - obj.locator_type = LocatorType.TEXT + obj._locator_type = LocatorType.TEXT # Default to ID if nothing else matches else: - obj.locator_type = LocatorType.ID + obj._locator_type = LocatorType.ID - obj.locator = f'{obj.locator_type}={locator}' - obj.log_locator = obj.locator + obj.locator = f'{obj._locator_type}={locator}' def set_appium_selector(obj: Any): @@ -145,10 +142,10 @@ def set_appium_selector(obj: Any): """ set_selenium_selector(obj) - locator = obj.locator.strip() + locator = obj._locator.strip() # Mobile com.android selector if ':id' in locator: - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.ID}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.ID}={locator}' diff --git a/tests/static_tests/integration/test_inheritance.py b/tests/static_tests/integration/test_inheritance.py deleted file mode 100644 index ea1cbdaa..00000000 --- a/tests/static_tests/integration/test_inheritance.py +++ /dev/null @@ -1,36 +0,0 @@ -from mops.base.group import Group -from mops.base.page import Page - - -class WrapperGroup(Group): - pass - - -class SomePage(Page, WrapperGroup): - pass - - -def test_unexpected_page_inheritance(mocked_selenium_driver): - try: - SomePage() - except TypeError: - pass - else: - raise Exception('Unexpected behaviour') - - -class WrapperPage(Page): - pass - - -class Section(Group, WrapperPage): - pass - - -def test_unexpected_element_or_group_inheritance(mocked_selenium_driver): - try: - Section() - except TypeError: - pass - else: - raise Exception('Unexpected behaviour') diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index 5e31f1dd..acc9ea7f 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -9,7 +9,7 @@ from mops.base.element import Element from mops.base.group import Group from mops.base.page import Page -from mops.utils.internal_utils import get_all_sub_elements +from mops.shared_utils import get_all_sub_elements section_sub_elements_count = 5000 @@ -67,29 +67,38 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el stats: pstats.Stats = pstats.Stats(pr) stats.strip_dirs().sort_stats("time").print_stats(20) + init_without_profiling_start_timestamp = time.time() + AnotherSection1() + init_without_profiling_stop_timestamp = time.time() - init_without_profiling_start_timestamp + print('stats.total_tt=', stats.total_tt) print('peak_mem=', peak_mem) print('cpu_time=', cpu_time) + print('init_without_profiling_stop_timestamp=', init_without_profiling_stop_timestamp) - expected_peak_mem = 5.3 - expected_init_duration = 0.6 + expected_peak_mem = 4.7 + expected_init_duration = 0.4 + init_without_profiling_expected = 0.1 if sys.version_info >= (3, 9): - expected_peak_mem = 5.3 - expected_init_duration = 0.65 + expected_peak_mem = 4.7 + expected_init_duration = 0.4 + init_without_profiling_expected = 0.13 if sys.version_info >= (3, 10): - expected_peak_mem = 5.3 - expected_init_duration = 0.7 - if sys.version_info >= (3, 11): expected_peak_mem = 4.6 - expected_init_duration = 1.0 + expected_init_duration = 0.4 + if sys.version_info >= (3, 11): + expected_peak_mem = 4.0 + expected_init_duration = 0.6 if sys.version_info >= (3, 12): - expected_peak_mem = 4.4 - expected_init_duration = 1.2 + expected_peak_mem = 3.8 + expected_init_duration = 0.45 - assert expected_init_duration -0.5 < stats.total_tt < expected_init_duration,\ + assert init_without_profiling_stop_timestamp < init_without_profiling_expected,\ + f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' + assert expected_init_duration -0.25 < stats.total_tt < expected_init_duration,\ f"Execution time too high: {stats.total_tt:.3f} sec" - assert expected_peak_mem -1 < peak_mem < expected_peak_mem,\ + assert expected_peak_mem - 0.8 < peak_mem < expected_peak_mem,\ f"Peak memory usage too high: {peak_mem:.2f} MB" assert len(section.sub_elements) == section_sub_elements_count, \ f"Expected {section_sub_elements_count} elements, got {len(section.sub_elements)}" @@ -127,36 +136,42 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou tracemalloc.stop() stats: pstats.Stats = pstats.Stats(pr) - stats.strip_dirs().sort_stats("time").print_stats(20) + stats.strip_dirs().sort_stats("time").print_stats(200) - count = len(get_all_sub_elements(page, unique=True)) + count = len(get_all_sub_elements(page)) + init_without_profiling_start_timestamp = time.time() + SomePage() + init_without_profiling_stop_timestamp = time.time() - init_without_profiling_start_timestamp print('stats.total_tt=', stats.total_tt) print('peak_mem=', peak_mem) print('cpu_time=', cpu_time) + print('init_without_profiling_stop_timestamp=', init_without_profiling_stop_timestamp) - expected_peak_mem = 3.9 - expected_init_duration = 0.5 + expected_peak_mem = 3.2 + expected_init_duration = 0.4 if sys.version_info >= (3, 9): - expected_peak_mem = 3.9 - expected_init_duration = 0.8 + expected_peak_mem = 3.5 + expected_init_duration = 0.5 if sys.version_info >= (3, 10): - expected_peak_mem = 3.9 - expected_init_duration = 0.9 + expected_peak_mem = 3.3 + expected_init_duration = 0.4 if sys.version_info >= (3, 11): - expected_peak_mem = 3.3 - expected_init_duration = 1.0 + expected_peak_mem = 2.6 + expected_init_duration = 0.75 if sys.version_info >= (3, 12): - expected_peak_mem = 3.1 - expected_init_duration = 1.1 + expected_peak_mem = 2.5 + expected_init_duration = 0.7 - assert expected_init_duration -0.5 < stats.total_tt < expected_init_duration, \ + assert init_without_profiling_stop_timestamp < 0.15,\ + f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' + assert expected_init_duration -0.25 < stats.total_tt < expected_init_duration, \ f"Execution time too high: {stats.total_tt:.3f} sec" - assert expected_peak_mem -1 < peak_mem < expected_peak_mem, \ + assert expected_peak_mem -0.8 < peak_mem < expected_peak_mem, \ f"Peak memory usage too high: {peak_mem:.2f} MB" assert cpu_time < expected_init_duration, f"CPU execution time too high: {cpu_time:.3f} sec" assert count > 3600, f"Expected 3600 elements, got {count}" \ No newline at end of file diff --git a/tests/static_tests/unit/test_get_platform_locator.py b/tests/static_tests/unit/test_get_platform_locator.py index 48748d0d..65c5a68d 100644 --- a/tests/static_tests/unit/test_get_platform_locator.py +++ b/tests/static_tests/unit/test_get_platform_locator.py @@ -21,7 +21,7 @@ @pytest.mark.parametrize('locator', ['tablet', 'android', 'ios', 'mobile', 'desktop']) def test_missed_platform_locator(locator): locator = {locator: None} - element_obj = SimpleNamespace(driver_wrapper=driver_wrapper_mock, locator=Locator(**locator)) + element_obj = SimpleNamespace(driver_wrapper=driver_wrapper_mock, _locator=Locator(**locator)) try: get_platform_locator(element_obj) except InvalidLocatorException as exc: diff --git a/tests/static_tests/unit/test_selector_synchronizer.py b/tests/static_tests/unit/test_selector_synchronizer.py index dd27ed7d..d49a0bfc 100644 --- a/tests/static_tests/unit/test_selector_synchronizer.py +++ b/tests/static_tests/unit/test_selector_synchronizer.py @@ -1,9 +1,7 @@ -from types import SimpleNamespace - import pytest from selenium.webdriver.common.by import By -from mops.utils.selector_synchronizer import set_selenium_selector, set_playwright_locator +from mops.base.element import Element @pytest.mark.parametrize( @@ -23,9 +21,7 @@ ], ) def test_set_selenium_selector(locator_input, expected_locator, expected_locator_type, expected_log_locator): - mock_obj = SimpleNamespace() - mock_obj.locator = locator_input - set_selenium_selector(mock_obj) + mock_obj = Element(locator_input) assert expected_locator == mock_obj.locator assert expected_log_locator == mock_obj.log_locator @@ -45,10 +41,8 @@ def test_set_selenium_selector(locator_input, expected_locator, expected_locator ("[href='/some/url']", "css=[href='/some/url']"), ], ) -def test_set_playwright_locator(locator_input, expected_locator): - mock_obj = SimpleNamespace() - mock_obj.locator = locator_input - set_playwright_locator(mock_obj) +def test_set_playwright_locator(locator_input, expected_locator, mocked_play_driver): + mock_obj = Element(locator_input) assert expected_locator == mock_obj.locator assert expected_locator == mock_obj.log_locator assert expected_locator.partition('=')[0] == mock_obj.locator_type diff --git a/uv.lock b/uv.lock index 1f55bece..3fafd635 100644 --- a/uv.lock +++ b/uv.lock @@ -667,7 +667,7 @@ wheels = [ [[package]] name = "mops" -version = "3.1.0" +version = "3.2.0" source = { virtual = "." } dependencies = [ { name = "appium-python-client" }, From dbebfed386a249f3a299ca0edc5d0143e0f4f14f Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 3 Feb 2025 01:00:59 +0100 Subject: [PATCH 20/33] Fixes and improvements --- mops/abstraction/element_abc.py | 29 +++++++++++++++++-- mops/base/element.py | 13 ++++----- mops/playwright/play_element.py | 6 +--- mops/selenium/core/core_element.py | 4 +-- .../integration/test_initialize_objects.py | 13 +++++++-- .../performance/test_overall_performance.py | 2 +- 6 files changed, 46 insertions(+), 21 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 728bc3ce..9151bfe3 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -24,13 +24,33 @@ class ElementABC(MixinABC, ABC): - locator: Union[Locator, str] name: str parent: Union[Any, bool, None] wait: Optional[bool] + _locator: Union[str, Locator] _locator_type: Union[str, None] = None + @property + def locator(self) -> str: + raise NotImplementedError() + + @locator.setter + def locator(self, value: Union[Locator, str]) -> None: + raise NotImplementedError() + + @property + def locator_type(self) -> str: + raise NotImplementedError() + + @locator_type.setter + def locator_type(self, value: str) -> None: + raise NotImplementedError() + + @property + def log_locator(self) -> str: + raise NotImplementedError() + @property def element(self) -> Union[SeleniumWebElement, AppiumWebElement, PlayWebElement]: """ @@ -880,5 +900,10 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Element]: """ raise NotImplementedError() - def _set_locator(self): + def _set_locator(self) -> None: + """ + Set locator for current object + + :return: :obj:`None` + """ raise NotImplementedError() diff --git a/mops/base/element.py b/mops/base/element.py index 14c13d34..b9ec94b7 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -50,13 +50,12 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): It dynamically adapts to different driver types (Playwright, Appium, Selenium) and provides a unified interface for UI interactions. """ + _object: str = 'element' _initialized: bool = False _is_locator_configured: bool = False _base_cls: Type[PlayElement, MobileElement, WebElement] - driver_wrapper: DriverWrapper - def __new__(cls, *args, **kwargs): instance = super(Element, cls).__new__(cls) set_instance_frame(instance) @@ -139,30 +138,30 @@ def __init_base_class__(self) -> None: self._initialized = True @property - def locator(self): + def locator(self) -> str: if not self._is_locator_configured: self._set_locator() return self._locator @locator.setter - def locator(self, value: Union[Locator, str]): + def locator(self, value: Union[Locator, str]) -> None: self._log_locator = value self._locator = value @property - def locator_type(self): + def locator_type(self) -> str: if not self._is_locator_configured: self._set_locator() return self._locator_type @locator_type.setter - def locator_type(self, value: str): + def locator_type(self, value: str) -> None: self._locator_type = value @property - def log_locator(self): + def log_locator(self) -> str: if not self._is_locator_configured: self._set_locator() diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 289cab9f..96142f5e 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -9,7 +9,7 @@ from mops.mixins.objects.scrolls import ScrollTo, ScrollTypes from playwright.sync_api import TimeoutError as PlayTimeoutError, Error from playwright.sync_api import Page as PlaywrightPage -from playwright.sync_api import Locator, Page, Browser, BrowserContext +from playwright.sync_api import Locator from mops.mixins.objects.size import Size from mops.mixins.objects.location import Location @@ -29,13 +29,9 @@ class PlayElement(ElementABC, Logging, ABC): - instance: Browser - context: BrowserContext - driver: Page parent: Union[ElementABC, PlayElement] _initialized: bool - _is_locator_configured: bool = False _element: Locator = None # Element diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 2b0f3ccd..a7cbf921 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -44,11 +44,9 @@ class CoreElement(ElementABC, ABC): - parent: Union[Element] - locator_type: str + parent: Union[Element, CoreElement] _initialized: bool - _is_locator_configured: bool = False _element: Union[None, SeleniumWebElement, AppiumWebElement] = None _cached_element: Union[None, SeleniumWebElement, AppiumWebElement] = None diff --git a/tests/static_tests/integration/test_initialize_objects.py b/tests/static_tests/integration/test_initialize_objects.py index 8b2de7f4..658277e8 100644 --- a/tests/static_tests/integration/test_initialize_objects.py +++ b/tests/static_tests/integration/test_initialize_objects.py @@ -1,3 +1,5 @@ +import re + from mops.base.element import Element from mops.base.group import Group from mops.exceptions import NotInitializedException @@ -23,13 +25,18 @@ def test_initialize_objects(mocked_selenium_driver): assert root_section.section.el._initialized try: RootSection.section.el.element - except NotInitializedException: - pass + except NotInitializedException as exc: + pattern = r'Element object at .* object is not initialized. Try to initialize base object first or call it directly as a method' + assert re.search(pattern, exc.msg) else: raise AssertionError('NotInitializedException should be raised') def test_initialize_object_manually(mocked_selenium_driver): """ covers __call__ of Element """ - RootSection.section.el() + assert RootSection.section.el.driver_wrapper is None + assert RootSection.section.el._initialized == False + called_object = RootSection.section.el() + assert called_object.driver_wrapper == mocked_selenium_driver + assert called_object._initialized == True diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index acc9ea7f..45c0cbb8 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -165,7 +165,7 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou if sys.version_info >= (3, 12): expected_peak_mem = 2.5 - expected_init_duration = 0.7 + expected_init_duration = 0.6 assert init_without_profiling_stop_timestamp < 0.15,\ f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' From f4911d5c31eedb96d683f983bd9e4c10a72e9156 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Wed, 7 Jan 2026 19:05:59 +0100 Subject: [PATCH 21/33] Post merge fixes --- mops/base/element.py | 4 ++ mops/utils/selector_synchronizer.py | 66 +++++++++---------- .../performance/test_overall_performance.py | 10 +-- .../unit/test_selector_synchronizer.py | 32 ++++----- 4 files changed, 58 insertions(+), 54 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index 0329c837..451bf2e2 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -170,6 +170,10 @@ def log_locator(self) -> str: return self._log_locator + @log_locator.setter + def log_locator(self, value: str) -> None: + self._log_locator = value + # Following methods works same for both Selenium/Appium and Playwright APIs using internal methods # Elements interaction diff --git a/mops/utils/selector_synchronizer.py b/mops/utils/selector_synchronizer.py index 0cc9d17f..60b1a543 100644 --- a/mops/utils/selector_synchronizer.py +++ b/mops/utils/selector_synchronizer.py @@ -52,11 +52,11 @@ def _set_selenium_compatibility_id_locator(obj: Any, split: bool = True) -> Any: - locator = obj.locator.split(f"{LocatorType.ID}=")[-1] if split else obj.locator + locator = obj._locator.split(f"{LocatorType.ID}=")[-1] if split else obj._locator - obj.locator = f'[{LocatorType.ID}="{locator}"]' - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.ID}={locator}' + obj._locator = f'[{LocatorType.ID}="{locator}"]' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.ID}={locator}' def get_platform_locator(obj: Any): @@ -66,7 +66,7 @@ def get_platform_locator(obj: Any): :param obj: Page/Group/Element :return: current platform locator """ - locator: Union[Locator, str] = obj.locator + locator: Union[Locator, str] = obj._locator if type(locator) is str or not obj.driver_wrapper: return locator @@ -94,23 +94,23 @@ def set_selenium_selector(obj: Any): """ Sets selenium locator & locator type """ - locator = obj.locator.strip() - obj.log_locator = locator + locator = obj._locator.strip() + obj._log_locator = locator # Checking the supported locators if locator.startswith(f"{LocatorType.XPATH}="): - obj.locator = obj.locator.split(f"{LocatorType.XPATH}=")[-1] - obj.locator_type = By.XPATH + obj._locator = obj._locator.split(f"{LocatorType.XPATH}=")[-1] + obj._locator_type = By.XPATH elif locator.startswith(f"{LocatorType.TEXT}="): - locator = obj.locator.split(f"{LocatorType.TEXT}=")[-1] - obj.locator = f'//*[contains(text(), "{locator}")]' - obj.locator_type = By.XPATH + locator = obj._locator.split(f"{LocatorType.TEXT}=")[-1] + obj._locator = f'//*[contains(text(), "{locator}")]' + obj._locator_type = By.XPATH elif locator.startswith(f"{LocatorType.CSS}="): - obj.locator = obj.locator.split(f"{LocatorType.CSS}=")[-1] - obj.locator_type = By.CSS_SELECTOR + obj._locator = obj._locator.split(f"{LocatorType.CSS}=")[-1] + obj._locator_type = By.CSS_SELECTOR elif locator.startswith(f"{LocatorType.ID}="): _set_selenium_compatibility_id_locator(obj) @@ -118,16 +118,16 @@ def set_selenium_selector(obj: Any): # Checking the regular locators elif locator.startswith(_XPATH_MATCH): - obj.locator_type = By.XPATH - obj.log_locator = f'{LocatorType.XPATH}={locator}' + obj._locator_type = By.XPATH + obj._log_locator = f'{LocatorType.XPATH}={locator}' elif locator.startswith(_CSS_MATCH) or re.search(_CSS_REGEXP, locator): - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.CSS}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.CSS}={locator}' elif locator in all_tags or all(tag in all_tags for tag in locator.split()): - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.CSS}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.CSS}={locator}' # Default to ID if nothing else matches @@ -139,34 +139,34 @@ def set_playwright_locator(obj: Any): """ Sets playwright locator & locator type """ - locator: str = obj.locator.strip() + locator: str = obj._locator.strip() - obj.log_locator = locator + obj._log_locator = locator # Checking the supported locators if locator.startswith(_DEFAULT_MATCH): - obj.locator_type = locator.partition('=')[0] + obj._locator_type = locator.partition('=')[0] return # Checking the regular locators elif locator.startswith(_XPATH_MATCH): - obj.locator_type = LocatorType.XPATH + obj._locator_type = LocatorType.XPATH elif locator.startswith(_CSS_MATCH) or re.search(_CSS_REGEXP, locator): - obj.locator_type = LocatorType.CSS + obj._locator_type = LocatorType.CSS elif locator in all_tags or all(tag in all_tags for tag in locator.split()): - obj.locator_type = LocatorType.CSS + obj._locator_type = LocatorType.CSS # Default to ID if nothing else matches else: - obj.locator_type = LocatorType.ID + obj._locator_type = LocatorType.ID - obj.locator = f'{obj.locator_type}={locator}' - obj.log_locator = obj.locator + obj._locator = f'{obj._locator_type}={locator}' + obj._log_locator = obj._locator def set_appium_selector(obj: Any): @@ -175,13 +175,13 @@ def set_appium_selector(obj: Any): """ set_selenium_selector(obj) - locator: str = obj.locator.strip() + locator: str = obj._locator.strip() # Mobile com.android selector if ':id/' in locator and not locator.startswith(_APPIUM_MATCH): _set_selenium_compatibility_id_locator(obj) elif locator.startswith(_APPIUM_LOCATOR_TYPES): partition = locator.partition('=') - obj.locator_type = partition[0] - obj.locator = partition[-1] - obj.log_locator = locator + obj._locator_type = partition[0] + obj._locator = partition[-1] + obj._log_locator = locator diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index 45c0cbb8..26387e5d 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -89,10 +89,10 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el expected_init_duration = 0.4 if sys.version_info >= (3, 11): expected_peak_mem = 4.0 - expected_init_duration = 0.6 + expected_init_duration = 0.4 if sys.version_info >= (3, 12): expected_peak_mem = 3.8 - expected_init_duration = 0.45 + expected_init_duration = 0.4 assert init_without_profiling_stop_timestamp < init_without_profiling_expected,\ f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' @@ -153,7 +153,7 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou if sys.version_info >= (3, 9): expected_peak_mem = 3.5 - expected_init_duration = 0.5 + expected_init_duration = 0.4 if sys.version_info >= (3, 10): expected_peak_mem = 3.3 @@ -161,11 +161,11 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou if sys.version_info >= (3, 11): expected_peak_mem = 2.6 - expected_init_duration = 0.75 + expected_init_duration = 0.4 if sys.version_info >= (3, 12): expected_peak_mem = 2.5 - expected_init_duration = 0.6 + expected_init_duration = 0.4 assert init_without_profiling_stop_timestamp < 0.15,\ f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' diff --git a/tests/static_tests/unit/test_selector_synchronizer.py b/tests/static_tests/unit/test_selector_synchronizer.py index 0fc2eb22..4b7bb52f 100644 --- a/tests/static_tests/unit/test_selector_synchronizer.py +++ b/tests/static_tests/unit/test_selector_synchronizer.py @@ -26,11 +26,11 @@ ) def test_set_selenium_selector(locator_input, expected_locator, expected_locator_type, expected_log_locator, method): mock_obj = SimpleNamespace() - mock_obj.locator = locator_input + mock_obj._locator = locator_input method(mock_obj) - assert expected_locator == mock_obj.locator - assert expected_locator_type == mock_obj.locator_type - assert expected_log_locator == mock_obj.log_locator + assert expected_locator == mock_obj._locator + assert expected_locator_type == mock_obj._locator_type + assert expected_log_locator == mock_obj._log_locator @pytest.mark.parametrize( @@ -50,11 +50,11 @@ def test_set_selenium_selector(locator_input, expected_locator, expected_locator ) def test_set_playwright_locator(locator_input, expected_locator): mock_obj = SimpleNamespace() - mock_obj.locator = locator_input + mock_obj._locator = locator_input set_playwright_locator(mock_obj) - assert expected_locator == mock_obj.locator - assert expected_locator == mock_obj.log_locator - assert expected_locator.partition('=')[0] == mock_obj.locator_type + assert expected_locator == mock_obj._locator + assert expected_locator == mock_obj._log_locator + assert expected_locator.partition('=')[0] == mock_obj._locator_type com_android_locator = 'com.android.settings:id/title' @@ -79,11 +79,11 @@ def test_set_playwright_locator(locator_input, expected_locator): def test_set_appium_native_selector(locator, locator_type): mock_obj = SimpleNamespace() log_locator = f'{locator_type}={locator}' - mock_obj.locator = log_locator + mock_obj._locator = log_locator set_appium_selector(mock_obj) - assert locator == mock_obj.locator - assert locator_type == mock_obj.locator_type - assert log_locator == mock_obj.log_locator + assert locator == mock_obj._locator + assert locator_type == mock_obj._locator_type + assert log_locator == mock_obj._log_locator @pytest.mark.parametrize( @@ -95,10 +95,10 @@ def test_set_appium_native_selector(locator, locator_type): ) def test_set_automatically_appium_selector(locator, source_locator_type, expected_locator, expected_log_locator): mock_obj = SimpleNamespace() - mock_obj.locator = locator + mock_obj._locator = locator set_appium_selector(mock_obj) - assert expected_locator == mock_obj.locator - assert source_locator_type == mock_obj.locator_type - assert expected_log_locator == mock_obj.log_locator + assert expected_locator == mock_obj._locator + assert source_locator_type == mock_obj._locator_type + assert expected_log_locator == mock_obj._log_locator From 765942ed1a5ffc1f6e8b17f67d29225bddd9c8c4 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 12 Mar 2026 11:24:34 +0100 Subject: [PATCH 22/33] Claude optimisation --- mops/base/driver_wrapper.py | 4 +-- mops/base/element.py | 5 ++++ mops/base/page.py | 2 +- mops/mixins/internal_mixin.py | 9 ++++++ mops/utils/internal_utils.py | 52 ++++++++++++++--------------------- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index bcce53c4..09de17fe 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -21,7 +21,7 @@ from mops.selenium.driver.web_driver import WebDriver from mops.exceptions import DriverWrapperException from mops.mixins.internal_mixin import InternalMixin -from mops.utils.internal_utils import get_attributes_from_object, extract_named_objects +from mops.utils.internal_utils import extract_named_objects from mops.utils.logs import Logging, LogLevel @@ -128,7 +128,7 @@ def __new__(cls, *args, **kwargs): if cls.session.sessions_count() == 0: cls = super().__new__(cls) else: - cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), get_attributes_from_object(cls))) # noqa + cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), cls.__dict__)) # noqa for name, _ in extract_named_objects(cls, bool).items(): setattr(cls, name, False) diff --git a/mops/base/element.py b/mops/base/element.py index 451bf2e2..a959c6fa 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -64,6 +64,11 @@ def __new__(cls, *args, **kwargs): set_instance_frame(instance) return instance + def __copy__(self): + new = object.__new__(self.__class__) + new.__dict__.update(self.__dict__) + return new + def __repr__(self): return self._repr_builder() diff --git a/mops/base/page.py b/mops/base/page.py index a6e5bb01..c470ccba 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -194,7 +194,7 @@ def is_page_opened(self, with_elements: bool = False, with_url: bool = False) -> result &= self.anchor.is_displayed() - if self.url and with_url: + if with_url: result &= self.driver_wrapper.current_url == self.url return result diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index ec6e7af8..d1b3b7ea 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -37,6 +37,9 @@ def get_all_static_attributes(cls: Any) -> dict: def get_driver_instance(driver, instance) -> bool: return isinstance(driver, instance) +_last_static_cls_for: dict = {} + + class InternalMixin: driver: None @@ -55,12 +58,18 @@ def _set_static(self: Any, cls) -> None: :return: None """ current_obj_cls = self.__class__ + + if _last_static_cls_for.get(current_obj_cls) is cls: + return + existing_attrs = set(get_all_static_attributes(current_obj_cls)) for name, value in get_static_attributes(cls).items(): if name not in existing_attrs: setattr(current_obj_cls, name, value) + _last_static_cls_for[current_obj_cls] = cls + def _repr_builder(self: Any): class_name = self.__class__.__name__ obj_id = hex(id(self)) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index d0cadb19..2794f7ad 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -26,9 +26,9 @@ WAIT_PAGE = 15 -all_tags = {'h1', 'h2', 'h3', 'h4', 'h5', 'head', 'body', 'input', 'section', 'button', 'a', 'link', 'header', 'div', - 'textarea', 'svg', 'circle', 'iframe', 'label', 'p', 'tr', 'th', 'table', 'tbody', 'td', 'select', 'nav', - 'li', 'form', 'footer', 'frame', 'area', 'span', 'video'} +all_tags = frozenset({'h1', 'h2', 'h3', 'h4', 'h5', 'head', 'body', 'input', 'section', 'button', 'a', 'link', 'header', + 'div', 'textarea', 'svg', 'circle', 'iframe', 'label', 'p', 'tr', 'th', 'table', 'tbody', 'td', + 'select', 'nav', 'li', 'form', 'footer', 'frame', 'area', 'span', 'video'}) def get_dict(obj: Any): @@ -124,17 +124,17 @@ def set_parent_for_attr(current_object: Element, with_copy: bool = False): :param with_copy: copy child object or not :return: self """ + current_is_group = is_group(current_object) + for name, obj in current_object.sub_elements.items(): if with_copy: obj = copy(obj) - - if (is_group(current_object) and obj.parent is None) or is_group(obj.parent): - obj.parent = current_object - - if with_copy: current_object.sub_elements[name] = obj setattr(current_object, name, obj) + if (current_is_group and obj.parent is None) or is_group(obj.parent): + obj.parent = current_object + if obj.sub_elements: set_parent_for_attr(obj, with_copy) @@ -147,15 +147,17 @@ def promote_parent_element(obj: Any, base_obj: Any): :param base_obj: base object of element: Page/Group instance :return: None """ - initial_parent = getattr(obj, 'parent', None) + initial_parent = obj.parent if not initial_parent: return None - if is_element_instance(initial_parent) and initial_parent != base_obj: + if is_element_instance(initial_parent) and initial_parent is not base_obj: + parent_id = initial_parent.__base_obj_id for el in base_obj.sub_elements.values(): - if obj.parent.__base_obj_id == el.__base_obj_id: + if parent_id == el.__base_obj_id: obj.parent = el + break def extract_named_objects(obj: Any, instance: Union[type, tuple] = None) -> dict: @@ -191,13 +193,15 @@ def extract_all_named_objects(reference_obj: Any) -> dict: all_bases = inspect.getmro(reference_class) for parent_class in all_bases[-2::-1]: # Skip the reference class itself - if 'ABC' in str(parent_class) or parent_class == object: + if parent_class is object or 'ABC' in parent_class.__name__: continue - items.update(get_attributes_from_object(parent_class)) + items.update(parent_class.__dict__) - items.update(get_attributes_from_object(reference_class)) - items.update(get_attributes_from_object(reference_obj)) + items.update(reference_class.__dict__) + + if not inspect.isclass(reference_obj): + items.update(reference_obj.__dict__) return items @@ -215,22 +219,10 @@ def get_main_sub_elements(instance, sub_elements: dict = None): return sub_elements -def get_attributes_from_object(reference_obj: Any) -> dict: - """ - Get attributes from the given object. - - :param reference_obj: reference object - :return: dict of attributes - """ - return dict(reference_obj.__dict__) - - def is_target_on_screen(x: int, y: int, possible_range: Size): """ Check is given coordinates fit into given range - An safe value will be applied: - 1 - Due to usage of range - 2 - Due to rounding a number when get size/location of element + An safe value will be applied due to rounding a number when get size/location of element :param x: x coordinate :param y: y coordinate @@ -238,9 +230,7 @@ def is_target_on_screen(x: int, y: int, possible_range: Size): :return: bool """ safe_value = 2 - is_x_on_screen = x in range(possible_range.width + safe_value) - is_y_on_screen = y in range(possible_range.height + safe_value) - return is_x_on_screen and is_y_on_screen + return 0 <= x < possible_range.width + safe_value and 0 <= y < possible_range.height + safe_value def calculate_coordinate_to_click(element: Any, x: int = 0, y: int = 0) -> tuple: From 7d76e3fb03b58901adf957afc52aff809028d1e4 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 12 Mar 2026 14:39:51 +0100 Subject: [PATCH 23/33] Fix ShadowDriverWrapper init --- mops/base/driver_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 09de17fe..1666eaa5 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -128,7 +128,7 @@ def __new__(cls, *args, **kwargs): if cls.session.sessions_count() == 0: cls = super().__new__(cls) else: - cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), cls.__dict__)) # noqa + cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), dict(cls.__dict__))) # noqa for name, _ in extract_named_objects(cls, bool).items(): setattr(cls, name, False) From bb58b3d07b9323e9847d88638091ae63750e9241 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 12 Mar 2026 15:00:20 +0100 Subject: [PATCH 24/33] Fixes --- mops/mixins/native_context.py | 7 +++---- mops/shared_utils.py | 2 +- mops/utils/decorators.py | 2 +- mops/visual_comparison.py | 2 +- tests/settings.py | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/mops/mixins/native_context.py b/mops/mixins/native_context.py index a86c8951..fbe12a85 100644 --- a/mops/mixins/native_context.py +++ b/mops/mixins/native_context.py @@ -80,10 +80,9 @@ def get_bottom_bar_height(self) -> int: if not self.custom_bottom_bar_locator: ios_version = float(self.driver_wrapper.driver.caps.get('platformVersion', 18.2)) - if ios_version >= 18.2: - self.bottom_bar.locator = self.ios_18_bottom_bar_locator - elif ios_version >= 26.0: + if ios_version >= 26.0: self.bottom_bar.locator = self.ios_26_bottom_bar_locator - + elif ios_version >= 18.2: + self.bottom_bar.locator = self.ios_18_bottom_bar_locator return self.bottom_bar.size.height diff --git a/mops/shared_utils.py b/mops/shared_utils.py index 78ac17b2..8337bb52 100644 --- a/mops/shared_utils.py +++ b/mops/shared_utils.py @@ -39,7 +39,7 @@ def resize_image(image1: str, image2: str, img_format='JPEG') -> bytes: img2 = Image.open(image2) width, height = img2.size - img1.resize((width, height), Image.Resampling.LANCZOS) + img1 = img1.resize((width, height), Image.Resampling.LANCZOS) return save_image(img1, img_format) diff --git a/mops/utils/decorators.py b/mops/utils/decorators.py index a6abab75..a7b2607e 100644 --- a/mops/utils/decorators.py +++ b/mops/utils/decorators.py @@ -116,7 +116,7 @@ def wrapper( if not result.execution_result: raise ContinuousWaitException( - f'The continuous "{method.__name__}" of the "{self.name}" is no met ' + f'The continuous "{method.__name__}" of the "{self.name}" is not met ' f'after {(time.time() - start_time):.2f} seconds' ) diff --git a/mops/visual_comparison.py b/mops/visual_comparison.py index 8fe1f9a0..a467fbb6 100644 --- a/mops/visual_comparison.py +++ b/mops/visual_comparison.py @@ -158,7 +158,7 @@ def assert_screenshot( return self image = cv2.imread(reference_file) - if isinstance(image, type(None)): + if image is None: self._save_screenshot(reference_file, **screenshot_params) if self.visual_reference_generation or self.soft_visual_reference_generation: diff --git a/tests/settings.py b/tests/settings.py index bb49f40a..dd3148b8 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -30,7 +30,7 @@ def get_ios_desired_caps(): platform_version = '18.6' return { - 'deviceName': 'iPhone 16', + 'deviceName': f'iPhone 16 ({platform_version})', 'platformVersion': platform_version, 'udid': env.get('udid') or '', 'automationName': 'XCUITest', From d3c37105d2387beaf8d1c284d125642d8e0865f3 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 12 Mar 2026 16:37:54 +0100 Subject: [PATCH 25/33] Rollback some changes --- mops/base/driver_wrapper.py | 4 ++-- mops/utils/internal_utils.py | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 1666eaa5..b9bffcb4 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -21,7 +21,7 @@ from mops.selenium.driver.web_driver import WebDriver from mops.exceptions import DriverWrapperException from mops.mixins.internal_mixin import InternalMixin -from mops.utils.internal_utils import extract_named_objects +from mops.utils.internal_utils import extract_named_objects, get_attributes_from_object from mops.utils.logs import Logging, LogLevel @@ -128,7 +128,7 @@ def __new__(cls, *args, **kwargs): if cls.session.sessions_count() == 0: cls = super().__new__(cls) else: - cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), dict(cls.__dict__))) # noqa + cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), get_attributes_from_object(cls))) # noqa for name, _ in extract_named_objects(cls, bool).items(): setattr(cls, name, False) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index 2794f7ad..7b383bb6 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -196,12 +196,10 @@ def extract_all_named_objects(reference_obj: Any) -> dict: if parent_class is object or 'ABC' in parent_class.__name__: continue - items.update(parent_class.__dict__) + items.update(get_attributes_from_object(parent_class)) - items.update(reference_class.__dict__) - - if not inspect.isclass(reference_obj): - items.update(reference_obj.__dict__) + items.update(get_attributes_from_object(reference_class)) + items.update(get_attributes_from_object(reference_obj)) return items @@ -219,6 +217,16 @@ def get_main_sub_elements(instance, sub_elements: dict = None): return sub_elements +def get_attributes_from_object(reference_obj: Any) -> dict: + """ + Get attributes from the given object. + + :param reference_obj: reference object + :return: dict of attributes + """ + return dict(reference_obj.__dict__) + + def is_target_on_screen(x: int, y: int, possible_range: Size): """ Check is given coordinates fit into given range From 4d783d36eb5aab603d7229a7643725d6eac6551b Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 12 Mar 2026 20:09:39 +0100 Subject: [PATCH 26/33] Fix unexpected work with inheritance --- mops/base/page.py | 1 - mops/utils/internal_utils.py | 25 +--- .../test_sub_elements_isolation.py | 110 ++++++++++++++++++ 3 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 tests/static_tests/integration/test_sub_elements_isolation.py diff --git a/mops/base/page.py b/mops/base/page.py index c470ccba..b1e7a685 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -23,7 +23,6 @@ WAIT_PAGE, initialize_objects, extract_named_objects, - is_element_instance, ) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index 7b383bb6..d88633cc 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -169,14 +169,10 @@ def extract_named_objects(obj: Any, instance: Union[type, tuple] = None) -> dict """ elements = {} - if is_element(instance): - elements = get_main_sub_elements(obj) - - if not elements: - for attribute, value in extract_all_named_objects(obj).items(): - if not instance or isinstance(value, instance): - if not attribute.startswith('__') and attribute != 'parent': - elements[attribute] = value + for attribute, value in extract_all_named_objects(obj).items(): + if not instance or isinstance(value, instance): + if not attribute.startswith('__') and attribute != 'parent': + elements[attribute] = value return elements @@ -204,19 +200,6 @@ def extract_all_named_objects(reference_obj: Any) -> dict: return items -def get_main_sub_elements(instance, sub_elements: dict = None): - if sub_elements is None: - sub_elements = {} - - if hasattr(instance, 'sub_elements') and instance.sub_elements: - for key, sub_element in instance.sub_elements.items(): - sub_elements[key] = sub_element - if hasattr(sub_element, 'sub_elements') and sub_element.sub_elements: - get_main_sub_elements(sub_element, sub_elements) - - return sub_elements - - def get_attributes_from_object(reference_obj: Any) -> dict: """ Get attributes from the given object. diff --git a/tests/static_tests/integration/test_sub_elements_isolation.py b/tests/static_tests/integration/test_sub_elements_isolation.py new file mode 100644 index 00000000..26cd51c1 --- /dev/null +++ b/tests/static_tests/integration/test_sub_elements_isolation.py @@ -0,0 +1,110 @@ +from mops.base.element import Element +from mops.base.group import Group +from mops.base.page import Page + + +class BaseRow(Group): + name_cell = Element('css=.name', name='name_cell') + status_cell = Element('css=.status', name='status_cell') + + +class RowA(BaseRow): + extra_a = Element('css=.extra-a', name='extra_a') + + +class RowB(BaseRow): + extra_b = Element('css=.extra-b', name='extra_b') + + +class TableA(Group): + row = RowA('css=tr.a', name='rowA') + + +class TableB(Group): + row = RowB('css=tr.b', name='rowB') + + +# -- Class level -- + +class PageClassLevel(Page): + table_a = TableA('css=.tbl-a', name='tableA') + table_b = TableB('css=.tbl-b', name='tableB') + + +# -- Init level -- + +class PageInitLevel(Page): + def __init__(self): + self.table_a = TableA('css=.tbl-a', name='tableA') + self.table_b = TableB('css=.tbl-b', name='tableB') + super().__init__('body', name='PageInitLevel') + + +def test_class_level_sub_elements_not_leaking(mocked_selenium_driver): + """Sub elements of nested groups should not leak to parent level (class level)""" + page = PageClassLevel() + assert list(page.table_a.sub_elements.keys()) == ['row'] + assert list(page.table_b.sub_elements.keys()) == ['row'] + + +def test_init_level_sub_elements_not_leaking(mocked_selenium_driver): + """Sub elements of nested groups should not leak to parent level (__init__ level)""" + page = PageInitLevel() + assert list(page.table_a.sub_elements.keys()) == ['row'] + assert list(page.table_b.sub_elements.keys()) == ['row'] + + +def test_class_level_correct_row_types(mocked_selenium_driver): + """Nested groups should preserve their correct types (class level)""" + page = PageClassLevel() + assert isinstance(page.table_a.row, RowA) + assert isinstance(page.table_b.row, RowB) + + +def test_init_level_correct_row_types(mocked_selenium_driver): + """Nested groups should preserve their correct types (__init__ level)""" + page = PageInitLevel() + assert isinstance(page.table_a.row, RowA) + assert isinstance(page.table_b.row, RowB) + + +def test_class_level_row_sub_elements(mocked_selenium_driver): + """Row sub elements should contain only own and inherited elements (class level)""" + page = PageClassLevel() + assert 'name_cell' in page.table_a.row.sub_elements + assert 'status_cell' in page.table_a.row.sub_elements + assert 'extra_a' in page.table_a.row.sub_elements + assert 'extra_b' not in page.table_a.row.sub_elements + + assert 'name_cell' in page.table_b.row.sub_elements + assert 'status_cell' in page.table_b.row.sub_elements + assert 'extra_b' in page.table_b.row.sub_elements + assert 'extra_a' not in page.table_b.row.sub_elements + + +def test_init_level_row_sub_elements(mocked_selenium_driver): + """Row sub elements should contain only own and inherited elements (__init__ level)""" + page = PageInitLevel() + assert 'name_cell' in page.table_a.row.sub_elements + assert 'status_cell' in page.table_a.row.sub_elements + assert 'extra_a' in page.table_a.row.sub_elements + assert 'extra_b' not in page.table_a.row.sub_elements + + assert 'name_cell' in page.table_b.row.sub_elements + assert 'status_cell' in page.table_b.row.sub_elements + assert 'extra_b' in page.table_b.row.sub_elements + assert 'extra_a' not in page.table_b.row.sub_elements + + +def test_class_level_no_cross_contamination(mocked_selenium_driver): + """Elements from one table's row should not appear on another table (class level)""" + page = PageClassLevel() + assert not hasattr(page.table_a, 'extra_b') + assert not hasattr(page.table_b, 'extra_a') + + +def test_init_level_no_cross_contamination(mocked_selenium_driver): + """Elements from one table's row should not appear on another table (__init__ level)""" + page = PageInitLevel() + assert not hasattr(page.table_a, 'extra_b') + assert not hasattr(page.table_b, 'extra_a') From c177b710134b521b68384c33ee036ee150ceac8a Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Tue, 24 Mar 2026 11:57:53 +0100 Subject: [PATCH 27/33] Fix missed parent attribute from some elements --- mops/base/element.py | 21 ++++++++- mops/utils/internal_utils.py | 5 +- tests/adata/pages/playground_main_page.py | 1 + .../integration/test_all_elements.py | 47 +++++++++++++++++++ tests/web_tests/test_element.py | 9 ++++ tests/web_tests/test_wait.py | 4 +- tests/web_tests/test_wait_wihtout_error.py | 4 +- 7 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 tests/static_tests/integration/test_all_elements.py diff --git a/mops/base/element.py b/mops/base/element.py index a959c6fa..a31f5ae0 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -1,6 +1,8 @@ from __future__ import annotations +import functools import time +from abc import ABCMeta from copy import copy from functools import cached_property from typing import Union, List, Type, Tuple, Optional, TYPE_CHECKING @@ -44,7 +46,22 @@ from mops.base.group import Group -class Element(DriverMixin, InternalMixin, Logging, ElementABC): +class ElementMeta(ABCMeta): + def __new__(mcs, name, bases, namespace, **kwargs): + cls = super().__new__(mcs, name, bases, namespace, **kwargs) + orig_init = cls.__init__ + + @functools.wraps(orig_init) + def wrapped_init(self, *args, **kw): + orig_init(self, *args, **kw) + if type(self) is cls and getattr(self, '_initialized', False): + self._modify_sub_elements() + + cls.__init__ = wrapped_init + return cls + + +class Element(DriverMixin, InternalMixin, Logging, ElementABC, metaclass=ElementMeta): """ Represents a UI element that serves as a central component for interaction. @@ -121,7 +138,6 @@ def __full_init__(self, driver_wrapper: Any = None): self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) self._modify_object() - self._modify_sub_elements() if not self._initialized: self.__init_base_class__() @@ -973,6 +989,7 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: wrapped_object: Any = copy(self) wrapped_object.element = element wrapped_object._wrapped = True + wrapped_object.sub_elements = dict(self.sub_elements) set_parent_for_attr(wrapped_object, with_copy=True) wrapped_elements.append(wrapped_object) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index d88633cc..b061f25a 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -110,8 +110,7 @@ def initialize_objects(current_object: Union[Element, Group, Page], sub_elements promote_parent_element(copied_obj, current_object) sub_elements[name] = copied_obj setattr(current_object, name, copied_obj(driver_wrapper=current_object.driver_wrapper)) - - initialize_objects(copied_obj, copied_obj.sub_elements) + copied_obj._modify_sub_elements() def set_parent_for_attr(current_object: Element, with_copy: bool = False): @@ -135,7 +134,7 @@ def set_parent_for_attr(current_object: Element, with_copy: bool = False): if (current_is_group and obj.parent is None) or is_group(obj.parent): obj.parent = current_object - if obj.sub_elements: + if getattr(obj, 'sub_elements', None): set_parent_for_attr(obj, with_copy) diff --git a/tests/adata/pages/playground_main_page.py b/tests/adata/pages/playground_main_page.py index c6071e1c..ceb4074e 100644 --- a/tests/adata/pages/playground_main_page.py +++ b/tests/adata/pages/playground_main_page.py @@ -58,5 +58,6 @@ def navigate_to_keyboard_page(self): class Card(Group): def __init__(self, driver_wrapper=None): super().__init__('.card', name='action cards', driver_wrapper=driver_wrapper) + self.any_button = Element('a', name='any button') button = Element('a', name='proceed card button') diff --git a/tests/static_tests/integration/test_all_elements.py b/tests/static_tests/integration/test_all_elements.py new file mode 100644 index 00000000..97f725f9 --- /dev/null +++ b/tests/static_tests/integration/test_all_elements.py @@ -0,0 +1,47 @@ +from mock.mock import MagicMock + +from mops.base.element import Element +from mops.base.group import Group + + +class Card(Group): + def __init__(self, driver_wrapper=None): + super().__init__('.card', name='card', driver_wrapper=driver_wrapper) + self.any_button = Element('a', name='any button') + + button = Element('a', name='button') + + +def test_all_elements_inside_all_elements(mocked_selenium_driver): + """all_elements when parent of Element is Group with instance-level sub-element (static analog)""" + mock_sources = [MagicMock(), MagicMock(), MagicMock()] + all_cards = Card()._get_all_elements(mock_sources) + + for card in all_cards: + assert 'any_button' in card.sub_elements, 'instance-level any_button must be in sub_elements' + assert card.any_button.parent is card + + mock_button_sources = [MagicMock(), MagicMock()] + buttons = card.any_button._get_all_elements(mock_button_sources) + for button in buttons: + assert button.parent is not None + + +def test_all_elements_init_sub_element_isolated(mocked_selenium_driver): + """Each wrapped card must have its own independent any_button instance""" + mock_sources = [MagicMock(), MagicMock(), MagicMock()] + all_cards = Card()._get_all_elements(mock_sources) + + for i, card_a in enumerate(all_cards): + for j, card_b in enumerate(all_cards): + if i != j: + assert card_a.any_button is not card_b.any_button + + +def test_all_elements_class_sub_element_has_parent(mocked_selenium_driver): + """Class-level button must also have correct parent on each wrapped card""" + mock_sources = [MagicMock(), MagicMock()] + all_cards = Card()._get_all_elements(mock_sources) + + for card in all_cards: + assert card.button.parent is card diff --git a/tests/web_tests/test_element.py b/tests/web_tests/test_element.py index 20b1023c..67db9481 100644 --- a/tests/web_tests/test_element.py +++ b/tests/web_tests/test_element.py @@ -99,6 +99,15 @@ def test_element_group_all_elements_child(second_playground_page): assert KeyboardPage().wait_page_loaded().is_page_opened() +def test_all_elements_inside_all_elements(second_playground_page): + """ all_elements when parent of Element is Group """ + all_cards = second_playground_page.get_all_cards() + for card in all_cards: + for button in card.any_button.all_elements: + assert button.parent is not None + assert card.button.get_elements_count() == 1 + + def test_all_elements_recursion(base_playground_page): try: base_playground_page.kube.all_elements[0].all_elements diff --git a/tests/web_tests/test_wait.py b/tests/web_tests/test_wait.py index 4b3b58be..845be0af 100644 --- a/tests/web_tests/test_wait.py +++ b/tests/web_tests/test_wait.py @@ -106,7 +106,7 @@ def test_wait_continuous_hidden_negative(expected_condition_page, caplog): try: expected_condition_page.blinking_card.blinking_panel.wait_hidden(continuous=True) except ContinuousWaitException as exc: - assert 'The continuous "wait_hidden" of the "blinking panel" is no met after 0.' in exc.msg + assert 'The continuous "wait_hidden" of the "blinking panel" is not met after 0.' in exc.msg else: raise Exception('Unexpected behaviour. Case not covered') @@ -116,6 +116,6 @@ def test_wait_continuous_visibility_negative(expected_condition_page, caplog): try: expected_condition_page.blinking_card.blinking_panel.wait_visibility(continuous=True) except ContinuousWaitException as exc: - assert 'The continuous "wait_visibility" of the "blinking panel" is no met after 0.' in exc.msg + assert 'The continuous "wait_visibility" of the "blinking panel" is not met after 0.' in exc.msg else: raise Exception('Unexpected behaviour. Case not covered') diff --git a/tests/web_tests/test_wait_wihtout_error.py b/tests/web_tests/test_wait_wihtout_error.py index a125316b..0bc1bc2e 100644 --- a/tests/web_tests/test_wait_wihtout_error.py +++ b/tests/web_tests/test_wait_wihtout_error.py @@ -53,12 +53,12 @@ def test_wait_continuous_visibility_without_error_positive(expected_condition_pa def test_wait_continuous_hidden_without_error_negative(expected_condition_page, caplog): expected_condition_page.blinking_card.set_interval() expected_condition_page.blinking_card.blinking_panel.wait_hidden_without_error(continuous=True) - assert 'The continuous "wait_hidden" of the "blinking panel" is no met after 0.' in str(caplog.messages) + assert 'The continuous "wait_hidden" of the "blinking panel" is not met after 0.' in str(caplog.messages) assert expected_condition_page.blinking_card.blinking_panel.is_displayed() def test_wait_continuous_visibility_without_error_negative(expected_condition_page, caplog): expected_condition_page.blinking_card.set_interval() expected_condition_page.blinking_card.blinking_panel.wait_visibility_without_error(continuous=True) - assert 'The continuous "wait_visibility" of the "blinking panel" is no met after 0.' in str(caplog.messages) + assert 'The continuous "wait_visibility" of the "blinking panel" is not met after 0.' in str(caplog.messages) assert expected_condition_page.blinking_card.blinking_panel.is_hidden() From c5237e9566d5345b94de0fa22f8f313a1e482f96 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 26 Mar 2026 12:07:00 +0100 Subject: [PATCH 28/33] Test fixes --- .github/workflows/static_tests.yml | 2 +- tests/adata/pull_ci_artifacts.py | 4 ++-- ...en_middle_hidden_macos_selenium_safari.png | Bin 19580 -> 18471 bytes tests/settings.py | 2 +- .../performance/test_overall_performance.py | 14 ++++++++++---- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/static_tests.yml b/.github/workflows/static_tests.yml index 40933975..4464c691 100644 --- a/.github/workflows/static_tests.yml +++ b/.github/workflows/static_tests.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: [ "3.9", "3.11", "3.12", "3.13"] steps: - name: Checkout code diff --git a/tests/adata/pull_ci_artifacts.py b/tests/adata/pull_ci_artifacts.py index 96cff166..337ab1ad 100644 --- a/tests/adata/pull_ci_artifacts.py +++ b/tests/adata/pull_ci_artifacts.py @@ -99,8 +99,8 @@ def download_artefact_and_replace_references(self): print(f"Finding PR associated with commit {self.launch_args.commit_sha}...") pr_number = self._api_request(f"{bae_api_url}/search/issues?q=repo:{REPO}+sha:{self.launch_args.commit_sha}+is:pr")['items'][0]['number'] branch_name = self._api_request(f"{repos_url}/{REPO}/pulls/{pr_number}")['head']['ref'] - runs_response = self._api_request(f"{repos_url}/{REPO}/actions/runs?per_page=100&event=push&branch={branch_name}") - run_ids = [run["id"] for run in runs_response.get("workflow_runs", []) if any(pr.get("number") == pr_number for pr in run.get("pull_requests", []))] + runs_response = self._api_request(f"{repos_url}/{REPO}/actions/runs?per_page=100&branch={branch_name}&head_sha={self.launch_args.commit_sha}") + run_ids = [run["id"] for run in runs_response.get("workflow_runs", [])] for run_id in run_ids: artifacts_response = self._api_request(f"{repos_url}/{REPO}/actions/runs/{run_id}/artifacts") for artifact in artifacts_response.get("artifacts", []): diff --git a/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_middle_hidden_macos_selenium_safari.png b/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_middle_hidden_macos_selenium_safari.png index 3d532f4bced1023ed076ba0103d328bbcf497f6b..d8446103123060545a2639118c132c4660712c74 100644 GIT binary patch literal 18471 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 diff --git a/tests/settings.py b/tests/settings.py index dd3148b8..bb49f40a 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -30,7 +30,7 @@ def get_ios_desired_caps(): platform_version = '18.6' return { - 'deviceName': f'iPhone 16 ({platform_version})', + 'deviceName': 'iPhone 16', 'platformVersion': platform_version, 'udid': env.get('udid') or '', 'automationName': 'XCUITest', diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index 26387e5d..a0d873e2 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -93,12 +93,15 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el if sys.version_info >= (3, 12): expected_peak_mem = 3.8 expected_init_duration = 0.4 + if sys.version_info >= (3, 13): + expected_peak_mem = 4.0 + expected_init_duration = 0.4 assert init_without_profiling_stop_timestamp < init_without_profiling_expected,\ f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' - assert expected_init_duration -0.25 < stats.total_tt < expected_init_duration,\ + assert stats.total_tt < expected_init_duration,\ f"Execution time too high: {stats.total_tt:.3f} sec" - assert expected_peak_mem - 0.8 < peak_mem < expected_peak_mem,\ + assert peak_mem < expected_peak_mem,\ f"Peak memory usage too high: {peak_mem:.2f} MB" assert len(section.sub_elements) == section_sub_elements_count, \ f"Expected {section_sub_elements_count} elements, got {len(section.sub_elements)}" @@ -166,12 +169,15 @@ def test_performance_group_initialisation(mocked_selenium_driver, case, set_grou if sys.version_info >= (3, 12): expected_peak_mem = 2.5 expected_init_duration = 0.4 + if sys.version_info >= (3, 13): + expected_peak_mem = 2.8 + expected_init_duration = 0.4 assert init_without_profiling_stop_timestamp < 0.15,\ f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' - assert expected_init_duration -0.25 < stats.total_tt < expected_init_duration, \ + assert stats.total_tt < expected_init_duration, \ f"Execution time too high: {stats.total_tt:.3f} sec" - assert expected_peak_mem -0.8 < peak_mem < expected_peak_mem, \ + assert peak_mem < expected_peak_mem, \ f"Peak memory usage too high: {peak_mem:.2f} MB" assert cpu_time < expected_init_duration, f"CPU execution time too high: {cpu_time:.3f} sec" assert count > 3600, f"Expected 3600 elements, got {count}" \ No newline at end of file From 9b313b793e0d9207ba02681bff3c7b2eeb0985a8 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 26 Mar 2026 23:00:19 +0100 Subject: [PATCH 29/33] Test fixes --- ...een_sides_hidden_macos_selenium_safari.png | Bin 19549 -> 18438 bytes tests/web_tests/test_assert_screenshot.py | 1 + 2 files changed, 1 insertion(+) diff --git a/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_sides_hidden_macos_selenium_safari.png b/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_sides_hidden_macos_selenium_safari.png index 0f8ad940f2b7f34fd93053934bb758ca72e204ed..06c068a2af88b13fbb0956f7e1d27a1456f11928 100644 GIT binary patch literal 18438 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 diff --git a/tests/web_tests/test_assert_screenshot.py b/tests/web_tests/test_assert_screenshot.py index 52d56a2c..717144da 100644 --- a/tests/web_tests/test_assert_screenshot.py +++ b/tests/web_tests/test_assert_screenshot.py @@ -189,4 +189,5 @@ def test_assert_screenshot_hide_driver_elements(colored_blocks_page, driver_wrap hide=[all_cards[0], all_cards[2]] + colored_blocks_page.navbar.all_elements, name_suffix='sides hidden', delay=0.5, + threshold=0.2 ) From 8261974424241188f25ecb7f65ff31d433b4858a Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 26 Mar 2026 23:38:20 +0100 Subject: [PATCH 30/33] Error message improvement --- mops/base/driver_wrapper.py | 6 +++++- mops/base/element.py | 6 +++++- mops/base/page.py | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index b9bffcb4..84a7f535 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -348,7 +348,11 @@ def __init_base_class__(self) -> None: self.is_selenium = True self._base_cls = WebDriver else: - raise DriverWrapperException(f'Cant specify {self.__class__.__name__}') + raise DriverWrapperException( + f'Cannot initialize {self.__class__.__name__}: ' + f'unsupported driver type "{type(source_driver).__name__}". ' + f'Expected Playwright, Appium or Selenium driver instance' + ) self._set_static(self._base_cls) self._base_cls.__init__(self, driver_container=self.__driver_container) diff --git a/mops/base/element.py b/mops/base/element.py index 9328c6eb..9ddd080e 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -157,7 +157,11 @@ def __init_base_class__(self) -> None: elif self._driver_is_instance(SeleniumDriver): self._base_cls = WebElement else: - raise DriverWrapperException(f'Cant specify {self.__class__.__name__}') + raise DriverWrapperException( + f'Cannot initialize {self.__class__.__name__}: ' + f'unsupported driver type "{type(self.driver).__name__}". ' + f'Expected Playwright, Appium or Selenium driver instance' + ) self._set_static(self._base_cls) self._base_cls.__init__(self) diff --git a/mops/base/page.py b/mops/base/page.py index b1e7a685..594eb782 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -95,7 +95,11 @@ def __init_base_class__(self) -> None: elif self._driver_is_instance(SeleniumDriver): self._base_cls = WebPage else: - raise DriverWrapperException(f'Cant specify {Page.__name__}') + raise DriverWrapperException( + f'Cannot initialize {Page.__name__}: ' + f'unsupported driver type "{type(self.driver).__name__}". ' + f'Expected Playwright, Appium or Selenium driver instance' + ) self._set_static(self._base_cls) self._base_cls.__init__(self) From 9cc48a0d8a985d2c6d69b93ae299d14999502cf0 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Thu, 26 Mar 2026 23:55:57 +0100 Subject: [PATCH 31/33] Changelog & final fixes --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ mops/mixins/internal_mixin.py | 7 ------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f599da4..b45d5b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@
+## v3.4.0 (Performance improvement) +*Release date: 2026-03-26* + +### Breaking Changes +- **`Group` subclasses**: `parent` is now correctly set on sub-elements defined after `super().__init__()` — +previously such elements did not receive `parent` argument + +### Added +- `Element.sub_elements` dict — collected once and reused instead of rescanning on every access +- `ElementMeta` metaclass — triggers `_modify_sub_elements` automatically after `__init__` of the final class +- `get_static_attributes` / `get_all_static_attributes` with `lru_cache` — replaces repeated attribute scanning +- `_last_static_cls_for` guard in `_set_static` — skips redundant `setattr` calls when the class is already configured +- `get_driver_instance` with `lru_cache` — caches `isinstance` results for driver type checks +- `_driver_is_instance` method on `InternalMixin` — single cached entry point for driver type detection + +### Changed +- `all_tags` converted to `frozenset` for O(1) membership checks +- `initialize_objects` no longer recurses manually — delegates to `_modify_sub_elements` on each child +- `set_parent_for_attr` uses `sub_elements` dict instead of rescanning object attributes +- `get_child_elements_with_names` / `safe_getattribute` removed, replaced by `extract_named_objects` / `extract_all_named_objects` +- `locator`, `locator_type`, `log_locator` on `Element` converted to lazy properties — resolved on first access +- `__copy__` added to `Element` for explicit shallow copy control +- `__getattribute__` override removed from `Element` — initialization guard moved to `CoreElement`/`PlayElement` + +### Fixed +- Error messages for unsupported driver type now include the actual driver class name and list expected types + +--- + ## v3.3.2 *Release date: 2026-03-24* @@ -16,6 +45,8 @@ ### Changed - `safe_call` exceptions list +--- + ## v3.3.0 *Release date: 2026-01-05* diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index d1b3b7ea..ced0bd4d 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -37,8 +37,6 @@ def get_all_static_attributes(cls: Any) -> dict: def get_driver_instance(driver, instance) -> bool: return isinstance(driver, instance) -_last_static_cls_for: dict = {} - class InternalMixin: @@ -59,17 +57,12 @@ def _set_static(self: Any, cls) -> None: """ current_obj_cls = self.__class__ - if _last_static_cls_for.get(current_obj_cls) is cls: - return - existing_attrs = set(get_all_static_attributes(current_obj_cls)) for name, value in get_static_attributes(cls).items(): if name not in existing_attrs: setattr(current_obj_cls, name, value) - _last_static_cls_for[current_obj_cls] = cls - def _repr_builder(self: Any): class_name = self.__class__.__name__ obj_id = hex(id(self)) From e8f875aa32ed1d02b42f0eef526b19a0a14c9a21 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Fri, 27 Mar 2026 00:02:55 +0100 Subject: [PATCH 32/33] Final fixes --- mops/mixins/internal_mixin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index ced0bd4d..e810dfa7 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -29,13 +29,13 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: def get_static_attributes(cls: Any) -> dict: return extract_named_objects(cls) -@lru_cache(maxsize=64) +@lru_cache(maxsize=32) def get_all_static_attributes(cls: Any) -> dict: return extract_all_named_objects(cls) @lru_cache(maxsize=16) -def get_driver_instance(driver, instance) -> bool: - return isinstance(driver, instance) +def get_driver_instance(driver_type, instance) -> bool: + return issubclass(driver_type, instance) class InternalMixin: @@ -43,7 +43,7 @@ class InternalMixin: driver: None def _driver_is_instance(self, instance): - return get_driver_instance(self.driver, instance) + return get_driver_instance(type(self.driver), instance) def _safe_setter(self, var: str, value: Any): if not hasattr(self, var): From 47a164c119ad856464d8369e53b9be95042369f1 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Fri, 27 Mar 2026 00:09:19 +0100 Subject: [PATCH 33/33] Rollback performance changes --- CHANGELOG.md | 1 - mops/mixins/internal_mixin.py | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b45d5b0d..cc705b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ previously such elements did not receive `parent` argument - `Element.sub_elements` dict — collected once and reused instead of rescanning on every access - `ElementMeta` metaclass — triggers `_modify_sub_elements` automatically after `__init__` of the final class - `get_static_attributes` / `get_all_static_attributes` with `lru_cache` — replaces repeated attribute scanning -- `_last_static_cls_for` guard in `_set_static` — skips redundant `setattr` calls when the class is already configured - `get_driver_instance` with `lru_cache` — caches `isinstance` results for driver type checks - `_driver_is_instance` method on `InternalMixin` — single cached entry point for driver type detection diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index e810dfa7..f06b602e 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -57,12 +57,17 @@ def _set_static(self: Any, cls) -> None: """ current_obj_cls = self.__class__ + if current_obj_cls.__dict__.get('_configured'): + return + existing_attrs = set(get_all_static_attributes(current_obj_cls)) for name, value in get_static_attributes(cls).items(): if name not in existing_attrs: setattr(current_obj_cls, name, value) + current_obj_cls._configured = True + def _repr_builder(self: Any): class_name = self.__class__.__name__ obj_id = hex(id(self))