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' diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 2ccc147..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) @@ -500,7 +499,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..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(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 9f77d26..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(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 59114fa..4a82121 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -65,28 +65,55 @@ 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: - """ - Set attributes from base cls onto the class. Uses per-driver shadow - classes when multiple driver types are active. + 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) - :return: None + def _set_static(self: Any, cls: type) -> None: """ - obj_cls = self.__class__ + 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. - if _class_configured.get(obj_cls) is cls: + 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. + + 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) + protected = self._get_protected_attrs(self.__class__) - if with_shadow and self.driver_wrapper.session.has_different_driver_types(): - obj_cls = self._set_shadow_class(protected) + # ── 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: + target_cls = self.__class__ + protected_attrs = protected + # ── Copy —── for name, value in get_static_attributes(cls).items(): - if name not in protected: - 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: """ @@ -101,11 +128,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'