Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,12 @@ Selector is a handy mechanism to identify a specific UI object in the current wi
```python
# Select the object with text 'Clock' and its className is 'android.widget.TextView'
d(text='Clock', className='android.widget.TextView')
# Make selector reusable
clock_icon = Selector(text='Clock', className='android.widget.TextView')
# Use selector as positional argument
d(clock_icon)
# Use selector as keyword argument
d(selector=clock_icon)
Comment on lines +495 to +501

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README example creates clock_icon = Selector(...) but the surrounding Quick Start examples only import uiautomator2 as u2 (no Selector symbol in scope). This snippet will raise NameError as written. Please update the example to either reference u2.Selector(...) or add an explicit from uiautomator2 import Selector in the snippet/context.

Copilot uses AI. Check for mistakes.
```

Selector supports the below parameters. Refer to [UiSelector Java doc](http://developer.android.com/tools/help/uiautomator/UiSelector.html) for detailed information.
Expand Down
18 changes: 17 additions & 1 deletion demo_tests/test_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,23 @@ def test_exists(app: u2.Device):
assert app(text='Addition').exists(timeout=.1)
assert not app(text='should-not-exists').exists
assert not app(text='should-not-exists').exists(timeout=.1)



def test_exists2(app: u2.Device):
# New writing style
addition_label = Selector(text="Addition")
# Use selector as positional argument
assert app(addition_label).exists
# Use selector as keyword argument
assert app(selector=addition_label).exists(timeout=.1)

not_exist_label = Selector(text="should-not-exists")
assert not app(not_exist_label).exists
assert not app(selector=not_exist_label).exists(timeout=.1)

# Use selector as parameter for other methods
assert app(addition_label).right(not_exist_label) is None

Comment on lines +28 to +42

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new assertions cover the reusable Selector feature, but make cov (used by CI) only runs tests/ and does not execute demo_tests/, so this behavior may remain untested in automated checks. Consider adding a small unit test under tests/ that validates Selector positional/keyword passing (and any expected TypeError cases) without requiring a device.

Copilot uses AI. Check for mistakes.

def test_selector_info(app: u2.Device):
_info = app(text="Addition").info
Expand Down
4 changes: 2 additions & 2 deletions uiautomator2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,8 +463,8 @@ def serial(self) -> str:
return self._serial
return self.shell(['getprop', 'ro.serialno']).output.strip()

def __call__(self, **kwargs) -> 'UiObject':
return UiObject(self, Selector(**kwargs))
def __call__(self, *args, **kwargs) -> 'UiObject':
return UiObject(self, Selector(*args, **kwargs))


Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widening Device.call to accept *args means invalid positional calls that previously raised (e.g., d('Clock')) will now be accepted and forwarded into Selector(*args, **kwargs). This is safe only if Selector strictly validates positional args; otherwise the call can silently create an empty selector. After adding validation in Selector, consider also raising early here (or documenting the accepted positional form) to keep the public API predictable.

Suggested change
return UiObject(self, Selector(*args, **kwargs))
"""
Create a UiObject selector.
Use keyword arguments, for example:
d(text="Clock")
Positional arguments are not supported and will raise TypeError
to avoid silently creating an unintended empty selector.
"""
if args:
raise TypeError(
"Positional arguments are not supported in Device.__call__. "
"Use keyword arguments, e.g. d(text='Clock')."
)
return UiObject(self, Selector(**kwargs))

Copilot uses AI. Check for mistakes.
class _AppMixIn(AbstractShell):
Expand Down
105 changes: 69 additions & 36 deletions uiautomator2/_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,44 @@ class Selector(dict):
}
__mask, __childOrSibling, __childOrSiblingSelector = "mask", "childOrSibling", "childOrSiblingSelector"

def __init__(self, **kwargs):
super(Selector, self).__setitem__(self.__mask, 0)
super(Selector, self).__setitem__(self.__childOrSibling, [])
super(Selector, self).__setitem__(self.__childOrSiblingSelector, [])
for k in kwargs:
self[k] = kwargs[k]
def __init__(self, *args, **kwargs):
super().__init__()

# Initialize internal fields (bypass __setitem__ to avoid mask update)
dict.__setitem__(self, self.__mask, 0)
dict.__setitem__(self, self.__childOrSibling, [])
dict.__setitem__(self, self.__childOrSiblingSelector, [])

# Extract source selector (support both positional and kwarg)
selector = None
if args and len(args) == 1:
selector = args[0]
if "selector" in kwargs:
selector = kwargs.pop("selector")

if selector and isinstance(selector, Selector):
self._copy_from(selector)

# Apply remaining kwargs (triggers __setitem__ + mask update)
for k, v in kwargs.items():
self[k] = v

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selector.init currently accepts arbitrary positional args but silently ignores unsupported values (e.g., Selector('foo') or Selector({'text': 'Clock'}) results in an empty selector). This makes mistakes like d('Clock') no longer raise and can lead to hard-to-debug behavior. Consider validating inputs: allow at most one positional argument and require it (and/or the selector= kwarg) to be a Selector, and raise TypeError when both positional and selector= are provided or when extra positional args are given.

Copilot uses AI. Check for mistakes.

def _copy_from(self, other):
"""Internal: deep copy from another Selector (shared by __init__ and clone)."""
# Copy mask directly
dict.__setitem__(self, self.__mask, other[self.__mask])

# Shallow copy of child/sibling list
dict.__setitem__(self, self.__childOrSibling, other[self.__childOrSibling][:])

# Deep copy of nested selectors
dict.__setitem__(self, self.__childOrSiblingSelector,
[s.clone() for s in other[self.__childOrSiblingSelector]])

# Copy normal fields (triggers __setitem__ to update mask)
for key, value in other.items():
if key not in {self.__mask, self.__childOrSibling, self.__childOrSiblingSelector}:
self[key] = value

def __str__(self):
""" remove useless part for easily debugger """
Expand Down Expand Up @@ -78,25 +110,24 @@ def __delitem__(self, k):
self).__setitem__(self.__mask,
self[self.__mask] & ~self.__fields[k][0])

def __deepcopy__(self, memo):
"""Support copy.deepcopy() - delegates to our clean clone logic."""
return self.clone()

def clone(self):
kwargs = dict((k, self[k]) for k in self if k not in [
self.__mask, self.__childOrSibling, self.__childOrSiblingSelector
])
selector = Selector(**kwargs)
for v in self[self.__childOrSibling]:
selector[self.__childOrSibling].append(v)
for s in self[self.__childOrSiblingSelector]:
selector[self.__childOrSiblingSelector].append(s.clone())
return selector

def child(self, **kwargs):
"""Return a deep clone of this Selector."""
new_selector = Selector()
new_selector._copy_from(self)
return new_selector

def child(self, *args, **kwargs):
self[self.__childOrSibling].append("child")
self[self.__childOrSiblingSelector].append(Selector(**kwargs))
self[self.__childOrSiblingSelector].append(Selector(*args, **kwargs))
return self

def sibling(self, **kwargs):
def sibling(self, *args, **kwargs):
self[self.__childOrSibling].append("sibling")
self[self.__childOrSiblingSelector].append(Selector(**kwargs))
self[self.__childOrSiblingSelector].append(Selector(*args, **kwargs))
return self

def update_instance(self, i):
Expand Down Expand Up @@ -217,7 +248,7 @@ def long_click(self, duration: float = 0.5, timeout=None):
x, y = self.center()
return self.session.long_click(x, y, duration)

def drag_to(self, *args, **kwargs):
def drag_to(self, *args, **kwargs) -> bool:
duration = kwargs.pop('duration', 0.5)
timeout = kwargs.pop('timeout', None)
self.must_wait(timeout=timeout)
Expand All @@ -231,7 +262,9 @@ def drag2xy(x, y):
return self.jsonrpc.dragTo(self.selector, x, y, steps)

return drag2xy(*args, **kwargs)
return self.jsonrpc.dragTo(self.selector, Selector(**kwargs), steps)
elif len(args) == 1 and isinstance(args[0], Selector):
return self.jsonrpc.dragTo(self.selector, args[0], steps)
return self.jsonrpc.dragTo(self.selector, Selector(*args, **kwargs), steps)

def swipe(self, direction, steps=10):
"""
Expand Down Expand Up @@ -357,11 +390,11 @@ def clear_text(self, timeout=None):
self.must_wait(timeout=timeout)
return self.set_text(None)

def child(self, **kwargs):
return UiObject(self.session, self.selector.clone().child(**kwargs))
def child(self, *args, **kwargs) -> 'UiObject':
return UiObject(self.session, self.selector.clone().child(*args, **kwargs))

def sibling(self, **kwargs):
return UiObject(self.session, self.selector.clone().sibling(**kwargs))
def sibling(self, *args, **kwargs) -> 'UiObject':
return UiObject(self.session, self.selector.clone().sibling(*args, **kwargs))

child_selector, from_parent = child, sibling

Expand Down Expand Up @@ -446,38 +479,38 @@ def next(self):

return Iter()

def right(self, **kwargs):
def right(self, *args, **kwargs) -> 'UiObject':
def onrightof(rect1, rect2):
left, top, right, bottom = intersect(rect1, rect2)
return rect2["left"] - rect1["right"] if top < bottom else -1

return self.__view_beside(onrightof, **kwargs)
return self.__view_beside(onrightof, *args, **kwargs)

def left(self, **kwargs):
def left(self, *args, **kwargs) -> 'UiObject':
def onleftof(rect1, rect2):
left, top, right, bottom = intersect(rect1, rect2)
return rect1["left"] - rect2["right"] if top < bottom else -1

return self.__view_beside(onleftof, **kwargs)
return self.__view_beside(onleftof, *args, **kwargs)

def up(self, **kwargs):
def up(self, *args, **kwargs) -> 'UiObject':
def above(rect1, rect2):
left, top, right, bottom = intersect(rect1, rect2)
return rect1["top"] - rect2["bottom"] if left < right else -1

return self.__view_beside(above, **kwargs)
return self.__view_beside(above, *args, **kwargs)

def down(self, **kwargs):
def down(self, *args, **kwargs) -> 'UiObject':
def under(rect1, rect2):
left, top, right, bottom = intersect(rect1, rect2)
return rect2["top"] - rect1["bottom"] if left < right else -1

return self.__view_beside(under, **kwargs)
return self.__view_beside(under, *args, **kwargs)

def __view_beside(self, onsideof, **kwargs):
def __view_beside(self, onsideof, *args, **kwargs):
bounds = self.info["bounds"]
min_dist, found = -1, None
for ui in UiObject(self.session, Selector(**kwargs)):
for ui in UiObject(self.session, Selector(*args, **kwargs)):
dist = onsideof(bounds, ui.info["bounds"])
if dist >= 0 and (min_dist < 0 or dist < min_dist):
min_dist, found = dist, ui
Expand Down
Loading