Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

<br>

### 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*
Expand Down
2 changes: 1 addition & 1 deletion mops/__init__.py
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 1 addition & 2 deletions mops/base/driver_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion mops/base/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion mops/base/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 42 additions & 18 deletions mops/mixins/internal_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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
Expand Down
108 changes: 30 additions & 78 deletions tests/static_tests/integration/test_element_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading