From 888374ed19fce585db9864d27219588b28d7cfb9 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 24 Jul 2026 20:31:16 +0200 Subject: [PATCH 1/4] Fixes --- mops/base/driver_wrapper.py | 2 +- mops/base/element.py | 2 +- mops/base/page.py | 2 +- mops/mixins/internal_mixin.py | 29 +++-- .../integration/test_element_attrs.py | 108 +++++------------- 5 files changed, 50 insertions(+), 93 deletions(-) diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 2ccc147..7f5aaa5 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -500,7 +500,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(self._base_cls, with_shadow=False) + self._set_static_main(base_cls=self._base_cls) self._base_cls.__init__(self, driver_container=self.__driver_container) for name, value in self.__dict__.items(): diff --git a/mops/base/element.py b/mops/base/element.py index 80f1d34..9fd836e 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -181,7 +181,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(self._base_cls, with_shadow=True) + self._set_static(root_cls=Element, base_cls=self._base_cls, with_shadow=True) self._base_cls.__init__(self) self._initialized = True diff --git a/mops/base/page.py b/mops/base/page.py index 9f77d26..6a81a74 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -108,7 +108,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(self._base_cls, with_shadow=True) + self._set_static(root_cls=Page, base_cls=self._base_cls, with_shadow=True) self._base_cls.__init__(self) @cached_property diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 59114fa..a7789f2 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -65,7 +65,15 @@ def _get_protected_attrs(self: Any, current_obj_cls: type) -> frozenset: return current_obj_cls.__dict__['_framework_attrs'] - def _set_static(self: Any, cls: type, with_shadow: bool) -> None: + def _set_static_main(self, base_cls: type, protected_attrs: set | None = None) -> None: + if not protected_attrs: + protected_attrs = self._get_protected_attrs(self.__class__) + + for name, value in get_static_attributes(base_cls).items(): + if name not in protected_attrs: + setattr(self.__class__, name, value) + + def _set_static(self: Any, root_cls: type, base_cls: type, with_shadow: bool) -> None: """ Set attributes from base cls onto the class. Uses per-driver shadow classes when multiple driver types are active. @@ -74,19 +82,19 @@ def _set_static(self: Any, cls: type, with_shadow: bool) -> None: """ obj_cls = self.__class__ - if _class_configured.get(obj_cls) is cls: + if _class_configured.get(obj_cls) is base_cls: return protected = self._get_protected_attrs(obj_cls) - if with_shadow and self.driver_wrapper.session.has_different_driver_types(): + is_multiple_drivers = self.driver_wrapper.session.has_different_driver_types() + is_root_class = obj_cls is root_cls + if (with_shadow and is_multiple_drivers) or is_root_class: obj_cls = self._set_shadow_class(protected) - for name, value in get_static_attributes(cls).items(): - if name not in protected: - setattr(obj_cls, name, value) + self._set_static_main(base_cls=base_cls, protected_attrs=protected) - _class_configured[obj_cls] = cls + _class_configured[obj_cls] = base_cls def _set_shadow_class(self, protected: frozenset) -> type: """ @@ -101,11 +109,8 @@ def _set_shadow_class(self, protected: frozenset) -> type: key = (original_cls, self.driver_wrapper._base_cls.__name__) obj_cls = _shadow_classes.get(key) if not obj_cls: - obj_cls = type( - original_cls.__name__, - (original_cls,), - {'_shadow_class': True, '_framework_attrs': protected}, - ) + attrs = {'_shadow_class': True, '_framework_attrs': protected} + obj_cls = type(original_cls.__name__, (original_cls,), attrs) _shadow_classes[key] = obj_cls self.__class__ = obj_cls diff --git a/tests/static_tests/integration/test_element_attrs.py b/tests/static_tests/integration/test_element_attrs.py index 2a0f4de..977d319 100644 --- a/tests/static_tests/integration/test_element_attrs.py +++ b/tests/static_tests/integration/test_element_attrs.py @@ -25,102 +25,54 @@ class MockedDW(DriverWrapper): pass -def test_elements_get_own_driver_methods(): - """Elements from the last driver type set the class-level methods.""" - DriverWrapperSessions.all_sessions = [] - - # Mobile driver + element - appium_driver_cls = AppiumDriver - appium_driver_cls.__init__ = lambda *a, **kw: None - appium_driver_cls.session_id = None - appium_driver_cls.command_executor = MagicMock() - appium_driver_cls.error_handler = MagicMock() - appium_driver_cls.capabilities = MagicMock(return_value={ - 'platformName': 'ios', 'browserName': 'safari', 'automationName': 'safari' - })() - mobile_dw = MockedDW(Driver(driver=appium_driver_cls())) - - el_mobile = Element('m1', driver_wrapper=mobile_dw) - _assert_method_from(el_mobile, 'click_into_center', MobileElement) +class CustomElement(Element): + custom_value = "custom_value" + + @property + def value(self): + return self.custom_value + - # Web driver + element - selenium_driver_cls = SeleniumDriver - selenium_driver_cls.__init__ = lambda *a, **kw: None - selenium_driver_cls.session_id = None - selenium_driver_cls.command_executor = MagicMock() - selenium_driver_cls.error_handler = MagicMock() - selenium_driver_cls.caps = {} - web_dw = MockedDW(Driver(driver=selenium_driver_cls())) - el_web = Element('w1', driver_wrapper=web_dw) +def test_elements_get_own_driver_methods(mocked_ios_driver, mocked_selenium_driver): + """Elements from the last driver type set the class-level methods.""" + el_mobile = Element('m1', driver_wrapper=mocked_ios_driver) + _assert_method_from(el_mobile, 'click_into_center', MobileElement) - # Each driver type gets its own cached shadow class + el_web = Element('w1', driver_wrapper=mocked_selenium_driver) _assert_method_from(el_web, 'click_into_center', WebElement) - # Different driver types get different shadow classes when - # `has_different_driver_types` is active (multi-driver scenario) assert type(el_mobile) is not type(el_web), 'Different drivers get different shadow classes' - DriverWrapperSessions.all_sessions = [] - -def test_same_driver_elements_share_class(): +def test_same_driver_elements_share_class(mocked_ios_driver, mocked_selenium_driver): """Elements from the same driver share the same subclass.""" - DriverWrapperSessions.all_sessions = [] - - appium_driver_cls = AppiumDriver - appium_driver_cls.__init__ = lambda *a, **kw: None - appium_driver_cls.session_id = None - appium_driver_cls.command_executor = MagicMock() - appium_driver_cls.error_handler = MagicMock() - appium_driver_cls.capabilities = MagicMock(return_value={ - 'platformName': 'ios', 'browserName': 'safari', 'automationName': 'safari' - })() - mobile_dw = MockedDW(Driver(driver=appium_driver_cls())) - - selenium_driver_cls = SeleniumDriver - selenium_driver_cls.__init__ = lambda *a, **kw: None - selenium_driver_cls.session_id = None - selenium_driver_cls.command_executor = MagicMock() - selenium_driver_cls.error_handler = MagicMock() - selenium_driver_cls.caps = {} - MockedDW(Driver(driver=selenium_driver_cls())) - - el1 = Element('a', driver_wrapper=mobile_dw) - el2 = Element('b', driver_wrapper=mobile_dw) + el1 = Element('a', driver_wrapper=mocked_ios_driver) + el2 = Element('b', driver_wrapper=mocked_ios_driver) assert type(el1) is type(el2), ( f'Elements with same driver should share class: ' f'{type(el1).__name__} vs {type(el2).__name__}' ) - DriverWrapperSessions.all_sessions = [] - -class CustomElement(Element): - custom_value = "custom_value" - - @property - def value(self): - return self.custom_value - - -def test_user_override_still_protected(): +def test_user_override_still_protected(mocked_ios_driver): """User-defined overrides should still be protected.""" - DriverWrapperSessions.all_sessions = [] + el = CustomElement('test', driver_wrapper=mocked_ios_driver) - appium_driver_cls = AppiumDriver - appium_driver_cls.__init__ = lambda *a, **kw: None - appium_driver_cls.session_id = None - appium_driver_cls.command_executor = MagicMock() - appium_driver_cls.error_handler = MagicMock() - appium_driver_cls.capabilities = MagicMock(return_value={ - 'platformName': 'ios', 'browserName': 'safari', 'automationName': 'safari' - })() + assert el.value == CustomElement.custom_value - dw = MockedDW(Driver(driver=appium_driver_cls())) - el = CustomElement('test', driver_wrapper=dw) - assert el.value == CustomElement.custom_value +def test_base_element_pollution_does_not_leak_to_subclass(mocked_ios_driver, mocked_selenium_driver): + """ + Reproduces the bug: base Element initialised with Appium pollutes + Element.__dict__ with MobileElement methods. A subclass initialised + with Selenium must get WebElement methods, not MobileElement ones. + """ + mobile_el = Element('base', driver_wrapper=mocked_ios_driver) + _assert_method_from(mobile_el, 'click_into_center', MobileElement) - DriverWrapperSessions.all_sessions = [] + web_el = CustomElement('sub', driver_wrapper=mocked_selenium_driver) + _assert_method_from(web_el, 'click_into_center', WebElement) + assert web_el.value == CustomElement.custom_value + assert type(web_el).__name__ == 'CustomElement', 'Shadow class must keep original name' From 2370f399b410e0996a9ce91287224e3cf71847e4 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sat, 25 Jul 2026 17:21:41 +0200 Subject: [PATCH 2/4] Rollback _set_static & shadow_protected added --- mops/base/element.py | 2 +- mops/base/page.py | 2 +- mops/mixins/internal_mixin.py | 26 +++++++++++++++++++------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index 9fd836e..c39ef9e 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -181,7 +181,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(root_cls=Element, base_cls=self._base_cls, with_shadow=True) + self._set_static(cls=self._base_cls, with_shadow=True) self._base_cls.__init__(self) self._initialized = True diff --git a/mops/base/page.py b/mops/base/page.py index 6a81a74..2530085 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -108,7 +108,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(root_cls=Page, base_cls=self._base_cls, with_shadow=True) + self._set_static(cls=self._base_cls, with_shadow=True) self._base_cls.__init__(self) @cached_property diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index a7789f2..129bf58 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -73,7 +73,7 @@ def _set_static_main(self, base_cls: type, protected_attrs: set | None = None) - if name not in protected_attrs: setattr(self.__class__, name, value) - def _set_static(self: Any, root_cls: type, base_cls: type, with_shadow: bool) -> None: + def _set_static(self: Any, cls: type, with_shadow: bool) -> None: """ Set attributes from base cls onto the class. Uses per-driver shadow classes when multiple driver types are active. @@ -82,19 +82,31 @@ def _set_static(self: Any, root_cls: type, base_cls: type, with_shadow: bool) -> """ obj_cls = self.__class__ - if _class_configured.get(obj_cls) is base_cls: + if _class_configured.get(obj_cls) is cls: return protected = self._get_protected_attrs(obj_cls) - is_multiple_drivers = self.driver_wrapper.session.has_different_driver_types() - is_root_class = obj_cls is root_cls - if (with_shadow and is_multiple_drivers) or is_root_class: + if with_shadow and self.driver_wrapper.session.has_different_driver_types(): + original_cls = type(self) obj_cls = self._set_shadow_class(protected) + # Shadow protects only attrs that were originally on this class + # (intersection with clean protected set excludes pollution from + # previous driver's class-level injection) + shadow_protected = frozenset( + k for k in original_cls.__dict__ if not k.startswith('_') and k != 'parent' and k in protected + ) + else: + shadow_protected = None - self._set_static_main(base_cls=base_cls, protected_attrs=protected) + for name, value in get_static_attributes(cls).items(): + if (shadow_protected is None and name in protected) or ( + shadow_protected is not None and name in shadow_protected + ): + continue + setattr(obj_cls, name, value) - _class_configured[obj_cls] = base_cls + _class_configured[obj_cls] = cls def _set_shadow_class(self, protected: frozenset) -> type: """ From c4fab8c5fc5ea51d9307139718ee95bc12cc1067 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sat, 25 Jul 2026 19:06:21 +0200 Subject: [PATCH 3/4] Code clear up --- mops/base/driver_wrapper.py | 1 - mops/base/element.py | 2 +- mops/base/page.py | 2 +- mops/mixins/internal_mixin.py | 57 ++++++++++++++++++++--------------- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 7f5aaa5..38cc67f 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -160,7 +160,6 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: instance = super().__new__(cls) else: attrs = get_attributes_from_object(cls) - attrs.pop('_configured', None) shadow_cls = type('ShadowDriverWrapper', (cls,), attrs) instance = super().__new__(shadow_cls) diff --git a/mops/base/element.py b/mops/base/element.py index c39ef9e..93518a7 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -181,7 +181,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(cls=self._base_cls, with_shadow=True) + self._set_static(cls=self._base_cls) self._base_cls.__init__(self) self._initialized = True diff --git a/mops/base/page.py b/mops/base/page.py index 2530085..6916dde 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -108,7 +108,7 @@ def __init_base_class__(self) -> None: ) raise DriverWrapperException(msg) - self._set_static(cls=self._base_cls, with_shadow=True) + self._set_static(cls=self._base_cls) self._base_cls.__init__(self) @cached_property diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index 129bf58..4a82121 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -73,40 +73,47 @@ def _set_static_main(self, base_cls: type, protected_attrs: set | None = None) - if name not in protected_attrs: setattr(self.__class__, name, value) - def _set_static(self: Any, cls: type, with_shadow: bool) -> None: + def _set_static(self: Any, cls: type) -> None: """ - Set attributes from base cls onto the class. Uses per-driver shadow - classes when multiple driver types are active. + Copy methods/properties from a driver-specific base class (``WebElement``, + ``MobileElement``, ``PlayElement``) onto the element's class so they are + available via normal MRO lookup. - :return: None - """ - obj_cls = self.__class__ + In multi-driver sessions a per-driver **shadow subclass** is used so that + each driver type gets its own copy of the methods without polluting the + original class or other driver's shadow. - if _class_configured.get(obj_cls) is cls: + The *protected* attribute set prevents user-defined overrides on subclasses + (e.g. ``CustomElement.value``) from being overwritten. + """ + # ── Already configured with this exact driver class → nothing to do ── + if _class_configured.get(self.__class__) is cls: return - protected = self._get_protected_attrs(obj_cls) - - if with_shadow and self.driver_wrapper.session.has_different_driver_types(): - original_cls = type(self) - obj_cls = self._set_shadow_class(protected) - # Shadow protects only attrs that were originally on this class - # (intersection with clean protected set excludes pollution from - # previous driver's class-level injection) - shadow_protected = frozenset( - k for k in original_cls.__dict__ if not k.startswith('_') and k != 'parent' and k in protected - ) + protected = self._get_protected_attrs(self.__class__) + + # ── Multi-driver: isolate driver methods in a shadow subclass ── + if self.driver_wrapper.session.has_different_driver_types(): + original_class = self.__class__ + target_cls = self._set_shadow_class(protected) + + # Protect only attrs that the user defined *directly on this class* + # (not inherited attrs which may have been polluted by a previous + # driver's ``_set_static`` call on a parent class). + own_public_attrs = frozenset(k for k in original_class.__dict__ if not k.startswith('_') and k != 'parent') + protected_attrs = protected & own_public_attrs + + # ── Single driver: set methods directly on the class ── else: - shadow_protected = None + target_cls = self.__class__ + protected_attrs = protected + # ── Copy —── for name, value in get_static_attributes(cls).items(): - if (shadow_protected is None and name in protected) or ( - shadow_protected is not None and name in shadow_protected - ): - continue - setattr(obj_cls, name, value) + if name not in protected_attrs: + setattr(target_cls, name, value) - _class_configured[obj_cls] = cls + _class_configured[target_cls] = cls def _set_shadow_class(self, protected: frozenset) -> type: """ From 08efc1c1b5953570d7bf7b8e55de2733cd56d7d0 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Sat, 25 Jul 2026 20:02:01 +0200 Subject: [PATCH 4/4] Version bump & changelog update --- CHANGELOG.md | 11 +++++++++++ mops/__init__.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af12eba..37ce270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@
+### 3.5.4 + +*Release date: 2026-07-25* + +#### Fixed + +- Multi-driver method contamination for CustomElement subclasses by computing the shadow's attrs, +so user-defined overrides are preserved but driver-injected pollution from parent classes is always overwritten. + +--- + ### v3.5.3 *Release date: 2026-07-16* diff --git a/mops/__init__.py b/mops/__init__.py index 2a466ee..13a042d 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,4 +1,4 @@ """Wrapper of Selenium, Appium and Playwright with a single API.""" -__version__ = '3.5.3' +__version__ = '3.5.4' __project_name__ = 'mops'