uiautodev 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of uiautodev might be problematic. Click here for more details.

@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Tue Mar 19 2024 10:19:27 by codeskyblue
5
+ """
6
+
7
+
8
+ # Request and Response
9
+ import enum
10
+ from typing import List, Optional, Union
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from uiautodev.model import Node
15
+
16
+
17
+ # POST /api/v1/device/{serial}/command/{command}
18
+ class Command(str, enum.Enum):
19
+ TAP = "tap"
20
+ TAP_ELEMENT = "tapElement"
21
+ APP_INSTALL = "installApp"
22
+ APP_CURRENT = "currentApp"
23
+ APP_LAUNCH = "appLaunch"
24
+ APP_TERMINATE = "appTerminate"
25
+
26
+ GET_WINDOW_SIZE = "getWindowSize"
27
+ HOME = "home"
28
+ DUMP = "dump"
29
+ WAKE_UP = "wakeUp"
30
+ FIND_ELEMENTS = "findElements"
31
+ CLICK_ELEMENT = "clickElement"
32
+
33
+ LIST = "list"
34
+
35
+
36
+ class TapRequest(BaseModel):
37
+ x: Union[int, float]
38
+ y: Union[int, float]
39
+ isPercent: bool = False
40
+
41
+
42
+ class InstallAppRequest(BaseModel):
43
+ url: str
44
+
45
+
46
+ class InstallAppResponse(BaseModel):
47
+ success: bool
48
+ id: Optional[str] = None
49
+
50
+
51
+ class CurrentAppResponse(BaseModel):
52
+ package: str
53
+ activity: Optional[str] = None
54
+ pid: Optional[int] = None
55
+
56
+
57
+ class AppLaunchRequest(BaseModel):
58
+ package: str
59
+ stop: bool = False
60
+
61
+
62
+ class AppTerminateRequest(BaseModel):
63
+ package: str
64
+
65
+
66
+ class WindowSizeResponse(BaseModel):
67
+ width: int
68
+ height: int
69
+
70
+
71
+ class DumpResponse(BaseModel):
72
+ value: str
73
+
74
+
75
+ class By(str, enum.Enum):
76
+ ID = "id"
77
+ TEXT = "text"
78
+ XPATH = "xpath"
79
+ CLASS_NAME = "className"
80
+
81
+ class FindElementRequest(BaseModel):
82
+ by: str
83
+ value: str
84
+ timeout: float = 10.0
85
+
86
+
87
+ class FindElementResponse(BaseModel):
88
+ count: int
89
+ value: List[Node]
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Fri Mar 01 2024 14:19:29 by codeskyblue
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import re
10
+ import socket
11
+ import time
12
+ from functools import cached_property, partial
13
+ from typing import List, Tuple
14
+ from xml.etree import ElementTree
15
+
16
+ import adbutils
17
+ import requests
18
+ from PIL import Image
19
+
20
+ from uiautodev.command_types import CurrentAppResponse
21
+ from uiautodev.driver.base_driver import BaseDriver
22
+ from uiautodev.driver.udt.udt import UDT, UDTError
23
+ from uiautodev.exceptions import AndroidDriverException, RequestError
24
+ from uiautodev.model import Node, ShellResponse, WindowSize
25
+ from uiautodev.utils.common import fetch_through_socket
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ class AndroidDriver(BaseDriver):
30
+ def __init__(self, serial: str):
31
+ super().__init__(serial)
32
+ self.device = adbutils.device(serial)
33
+ self._try_dump_list = [
34
+ self._get_u2_hierarchy,
35
+ self._get_appium_hierarchy,
36
+ self._get_udt_dump_hierarchy,
37
+ self.device.dump_hierarchy,
38
+ self._get_u2_lib_hierarchy,
39
+ ]
40
+
41
+ @cached_property
42
+ def udt(self) -> UDT:
43
+ return UDT(self.device)
44
+
45
+ def screenshot(self, id: int) -> Image.Image:
46
+ # TODO: support multi-display
47
+ if id > 0:
48
+ raise ValueError("multi-display is not supported yet")
49
+ try:
50
+ img = self.device.screenshot()
51
+ return img.convert("RGB")
52
+ except adbutils.AdbError as e:
53
+ logger.warning("screenshot error: %s", str(e))
54
+ return self.udt.screenshot()
55
+
56
+ def shell(self, command: str) -> ShellResponse:
57
+ try:
58
+ ret = self.device.shell2(command, rstrip=True, timeout=20)
59
+ if ret.returncode == 0:
60
+ return ShellResponse(output=ret.output, error=None)
61
+ else:
62
+ return ShellResponse(
63
+ output="", error=f"exit:{ret.returncode}, output:{ret.output}"
64
+ )
65
+ except Exception as e:
66
+ return ShellResponse(output="", error=f"adb error: {str(e)}")
67
+
68
+ def dump_hierarchy(self) -> Tuple[str, Node]:
69
+ """returns xml string and hierarchy object"""
70
+ wsize = self.device.window_size()
71
+ logger.debug("window size: %s", wsize)
72
+ start = time.time()
73
+ xml_data = self._dump_hierarchy_raw()
74
+ logger.debug("dump_hierarchy cost: %s", time.time() - start)
75
+
76
+ return xml_data, parse_xml(
77
+ xml_data, WindowSize(width=wsize[0], height=wsize[1])
78
+ )
79
+
80
+ def _dump_hierarchy_raw(self) -> str:
81
+ """
82
+ uiautomator2 server is conflict with "uiautomator dump" command.
83
+
84
+ uiautomator dump errors:
85
+ - ERROR: could not get idle state.
86
+
87
+ """
88
+ for dump_func in self._try_dump_list[:]:
89
+ try:
90
+ logger.debug(f"try to dump with %s", dump_func.__name__)
91
+ result = dump_func()
92
+ logger.debug("dump success")
93
+ self._try_dump_list.remove(dump_func)
94
+ self._try_dump_list.insert(0, dump_func)
95
+ return result
96
+ except (
97
+ requests.RequestException,
98
+ AndroidDriverException,
99
+ UDTError,
100
+ adbutils.AdbError,
101
+ socket.timeout,
102
+ ) as e:
103
+ logger.warning("dump error: %s", e)
104
+ except Exception as e:
105
+ logger.exception("unexpected dump error: %s", e)
106
+ raise AndroidDriverException("Failed to dump hierarchy")
107
+
108
+ def _get_u2_hierarchy(self) -> str:
109
+ import uiautomator2 as u2
110
+ d = u2.connect_usb(self.serial)
111
+ return d.dump_hierarchy()
112
+ # c = self.device.create_connection(adbutils.Network.TCP, 9008)
113
+ # try:
114
+ # compressed = False
115
+ # payload = {
116
+ # "jsonrpc": "2.0",
117
+ # "method": "dumpWindowHierarchy",
118
+ # "params": [compressed],
119
+ # "id": 1,
120
+ # }
121
+ # content = fetch_through_socket(
122
+ # c, "/jsonrpc/0", method="POST", json=payload, timeout=5
123
+ # )
124
+ # json_resp = json.loads(content)
125
+ # if "error" in json_resp:
126
+ # raise AndroidDriverException(json_resp["error"])
127
+ # return json_resp["result"]
128
+ # except adbutils.AdbError as e:
129
+ # raise AndroidDriverException(
130
+ # f"Failed to get hierarchy from u2 server: {str(e)}"
131
+ # )
132
+ # finally:
133
+ # c.close()
134
+
135
+ def _get_appium_hierarchy(self) -> str:
136
+ c = self.device.create_connection(adbutils.Network.TCP, 6790)
137
+ try:
138
+ content = fetch_through_socket(c, "/wd/hub/session/0/source", timeout=10)
139
+ return json.loads(content)["value"]
140
+ except (adbutils.AdbError, RequestError) as e:
141
+ raise AndroidDriverException(
142
+ f"Failed to get hierarchy from appium server: {str(e)}"
143
+ )
144
+ finally:
145
+ c.close()
146
+
147
+ def _get_udt_dump_hierarchy(self) -> str:
148
+ return self.udt.dump_hierarchy()
149
+
150
+ def _get_u2_lib_hierarchy(self) -> str:
151
+ try:
152
+ import uiautomator2 as u2
153
+ d = u2.connect_usb(self.serial)
154
+ return d.dump_hierarchy()
155
+ except ModuleNotFoundError:
156
+ raise AndroidDriverException("uiautomator2 lib not installed")
157
+
158
+ def tap(self, x: int, y: int):
159
+ self.device.click(x, y)
160
+
161
+ def window_size(self) -> Tuple[int, int]:
162
+ w, h = self.device.window_size()
163
+ return (w, h)
164
+
165
+ def app_install(self, app_path: str):
166
+ self.device.install(app_path)
167
+
168
+ def app_current(self) -> CurrentAppResponse:
169
+ info = self.device.app_current()
170
+ return CurrentAppResponse(
171
+ package=info.package, activity=info.activity, pid=info.pid
172
+ )
173
+
174
+ def app_launch(self, package: str):
175
+ if self.device.package_info(package) is None:
176
+ raise AndroidDriverException(f"App not installed: {package}")
177
+ self.device.app_start(package)
178
+
179
+ def app_terminate(self, package: str):
180
+ self.device.app_stop(package)
181
+
182
+ def home(self):
183
+ self.device.keyevent("HOME")
184
+
185
+ def wake_up(self):
186
+ self.device.keyevent("WAKEUP")
187
+
188
+
189
+ def parse_xml(xml_data: str, wsize: WindowSize) -> Node:
190
+ root = ElementTree.fromstring(xml_data)
191
+ return parse_xml_element(root, wsize)
192
+
193
+
194
+ def parse_xml_element(
195
+ element, wsize: WindowSize, indexes: List[int] = [0]
196
+ ) -> Node:
197
+ """
198
+ Recursively parse an XML element into a dictionary format.
199
+ """
200
+ name = element.tag
201
+ if name == "node":
202
+ name = element.attrib.get("class", "node")
203
+ bounds = None
204
+ # eg: bounds="[883,2222][1008,2265]"
205
+ if "bounds" in element.attrib:
206
+ bounds = element.attrib["bounds"]
207
+ bounds = list(map(int, re.findall(r"\d+", bounds)))
208
+ assert len(bounds) == 4
209
+ bounds = (
210
+ bounds[0] / wsize.width,
211
+ bounds[1] / wsize.height,
212
+ bounds[2] / wsize.width,
213
+ bounds[3] / wsize.height,
214
+ )
215
+ bounds = map(partial(round, ndigits=4), bounds)
216
+ elem = Node(
217
+ key="-".join(map(str, indexes)),
218
+ name=name,
219
+ bounds=bounds,
220
+ properties={key: element.attrib[key] for key in element.attrib},
221
+ children=[],
222
+ )
223
+
224
+ # Construct xpath for children
225
+ for index, child in enumerate(element):
226
+ elem.children.append(parse_xml_element(child, wsize, indexes + [index]))
227
+
228
+ return elem
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Tue Mar 19 2024 15:51:59 by codeskyblue
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import logging
12
+ from pprint import pprint
13
+ from typing import Tuple
14
+
15
+ import httpretty
16
+ import httpx
17
+ from appium import webdriver
18
+ from appium.options.android import UiAutomator2Options
19
+ from appium.options.ios import XCUITestOptions
20
+ from appium.webdriver.common.appiumby import AppiumBy as By
21
+ from PIL import Image
22
+ from selenium.webdriver.common.proxy import Proxy, ProxyType
23
+
24
+ from uiautodev.command_types import CurrentAppResponse
25
+ from uiautodev.driver.android import parse_xml
26
+ from uiautodev.driver.base_driver import BaseDriver
27
+ from uiautodev.exceptions import AppiumDriverException
28
+ from uiautodev.model import DeviceInfo, Node, ShellResponse, WindowSize
29
+ from uiautodev.provider import BaseProvider
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ class AppiumProvider(BaseProvider):
34
+ sessions = []
35
+
36
+ def __init__(self, command_executor: str = "http://localhost:4723/wd/hub"):
37
+ # command_executor = "http://localhost:4700"
38
+ # command_executor = "http://localhost:4720/wd/hub"
39
+ self.command_executor = command_executor.rstrip('/')
40
+ self.sessions.clear()
41
+
42
+ def list_devices(self) -> list[DeviceInfo]:
43
+ """ appium just return all session_ids """
44
+ response = httpx.get(f"{self.command_executor}/sessions", verify=False)
45
+ if response.status_code >= 400:
46
+ raise AppiumDriverException(f"Failed request to appium server: {self.command_executor} status: {response.status_code}")
47
+ ret = []
48
+ self.sessions = response.json()['value']
49
+ for item in self.sessions:
50
+ item['sessionId'] = item.pop('id')
51
+ print("Active sessionId", item['sessionId'])
52
+ serial = item['capabilities']['platformName'] + ':' + item['sessionId']
53
+ ret.append(DeviceInfo(
54
+ serial=serial,
55
+ model=item['capabilities']['deviceModel'],
56
+ name=item['capabilities']['deviceName'],
57
+ ))
58
+ return ret
59
+
60
+ def get_device_driver(self, serial: str, session_id: str = None) -> BaseDriver:
61
+ """ TODO: attach to the existing session """
62
+ platform_name, session_id = serial.split(':', 1)
63
+ filtered_sessions = [session for session in self.sessions if session['sessionId'] == session_id]
64
+ if len(filtered_sessions) == 1:
65
+ session = filtered_sessions[0]
66
+ driver = self.attach_session(session)
67
+ return AppiumDriver(driver, is_attached=True)
68
+ else:
69
+ options = UiAutomator2Options() if platform_name == "Android" else XCUITestOptions()
70
+ driver = webdriver.Remote(self.command_executor, options=options)
71
+ return AppiumDriver(driver)
72
+
73
+ @httpretty.activate(allow_net_connect=False)
74
+ def attach_session(self, session: dict) -> webdriver.Remote:
75
+ """
76
+ https://github.com/appium/python-client/issues/212
77
+ the author say it can't
78
+ """
79
+ body = json.dumps({'value': session}, indent=4)
80
+ logger.debug("Mock response: POST /wd/hub/session", body)
81
+ httpretty.register_uri(httpretty.POST,
82
+ self.command_executor + '/session',
83
+ body=body,
84
+ headers={'Content-Type': 'application/json'})
85
+ options = UiAutomator2Options()# if platform_name == "Android" else XCUITestOptions()
86
+ driver = webdriver.Remote(command_executor=self.command_executor, strict_ssl=False, options=options)
87
+ return driver
88
+
89
+ def get_single_device_driver(self) -> BaseDriver:
90
+ devices = self.list_devices()
91
+ if len(devices) == 0:
92
+ return self.get_device_driver("Android:12345")
93
+ # raise AppiumDriverException("No device found")
94
+ return self.get_device_driver(devices[0].serial)
95
+
96
+
97
+ class AppiumDriver(BaseDriver):
98
+ def __init__(self, driver: webdriver.Remote, is_attached: bool = False):
99
+ self.driver = driver
100
+ self.is_attached = is_attached
101
+
102
+ # def __del__(self):
103
+ # if not self.is_attached:
104
+ # self.driver.quit()
105
+
106
+ def screenshot(self, id: int) -> Image:
107
+ png_data = self.driver.get_screenshot_as_png()
108
+ return Image.open(io.BytesIO(png_data))
109
+
110
+ def window_size(self) -> WindowSize:
111
+ size = self.driver.get_window_size()
112
+ return WindowSize(width=size["width"], height=size["height"])
113
+
114
+ def dump_hierarchy(self) -> Tuple[str, Node]:
115
+ source = self.driver.page_source
116
+ wsize = self.window_size()
117
+ return source, parse_xml(source, wsize)
118
+
119
+ def shell(self, command: str) -> ShellResponse:
120
+ # self.driver.execute_script(command)
121
+ raise NotImplementedError()
122
+
123
+ def tap(self, x: int, y: int):
124
+ self.driver.tap([(x, y)], 100)
125
+ print("Finished")
126
+
127
+ def app_install(self, app_path: str):
128
+ self.driver.install_app(app_path)
129
+
130
+ def app_current(self) -> CurrentAppResponse:
131
+ package = self.driver.current_package
132
+ activity = self.driver.current_activity
133
+ return CurrentAppResponse(package=package, activity=activity)
134
+
135
+ def home(self):
136
+ self.driver.press_keycode(3)
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Fri Mar 01 2024 14:18:30 by codeskyblue
5
+ """
6
+ import abc
7
+ import enum
8
+ from typing import Tuple
9
+
10
+ from PIL import Image
11
+ from pydantic import BaseModel
12
+
13
+ from uiautodev.command_types import CurrentAppResponse
14
+ from uiautodev.model import Node, ShellResponse, WindowSize
15
+
16
+
17
+ class BaseDriver(abc.ABC):
18
+ def __init__(self, serial: str):
19
+ self.serial = serial
20
+
21
+ @abc.abstractmethod
22
+ def screenshot(self, id: int) -> Image.Image:
23
+ """Take a screenshot of the device
24
+ :param id: physical display ID to capture (normally: 0)
25
+ :return: PIL.Image.Image
26
+ """
27
+ raise NotImplementedError()
28
+
29
+ @abc.abstractmethod
30
+ def dump_hierarchy(self) -> Tuple[str, Node]:
31
+ """Dump the view hierarchy of the device
32
+ :return: xml_source, Hierarchy
33
+ """
34
+ raise NotImplementedError()
35
+
36
+ def shell(self, command: str) -> ShellResponse:
37
+ """Run a shell command on the device
38
+ :param command: shell command
39
+ :return: ShellResponse
40
+ """
41
+ raise NotImplementedError()
42
+
43
+ def tap(self, x: int, y: int):
44
+ """Tap on the screen
45
+ :param x: x coordinate
46
+ :param y: y coordinate
47
+ """
48
+ raise NotImplementedError()
49
+
50
+ def window_size(self) -> WindowSize:
51
+ """ get window UI size """
52
+ raise NotImplementedError()
53
+
54
+ def app_install(self, app_path: str):
55
+ """ install app """
56
+ raise NotImplementedError()
57
+
58
+ def app_current(self) -> CurrentAppResponse:
59
+ """ get current app """
60
+ raise NotImplementedError()
61
+
62
+ def app_launch(self, package: str):
63
+ """ launch app """
64
+ raise NotImplementedError()
65
+
66
+ def app_terminate(self, package: str):
67
+ """ terminate app """
68
+ raise NotImplementedError()
69
+
70
+ def home(self):
71
+ """ press home button """
72
+ raise NotImplementedError()
73
+
74
+ def wake_up(self):
75
+ """ wake up the device """
76
+ raise NotImplementedError()
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Fri Mar 01 2024 14:35:46 by codeskyblue
5
+ """
6
+
7
+
8
+ import base64
9
+ import io
10
+ import json
11
+ import re
12
+ from functools import partial
13
+ from typing import List, Optional, Tuple
14
+ from xml.etree import ElementTree
15
+
16
+ from PIL import Image
17
+
18
+ from uiautodev.command_types import CurrentAppResponse
19
+ from uiautodev.driver.base_driver import BaseDriver
20
+ from uiautodev.exceptions import IOSDriverException
21
+ from uiautodev.model import Node, WindowSize
22
+ from uiautodev.utils.usbmux import MuxDevice, select_device
23
+
24
+
25
+ class IOSDriver(BaseDriver):
26
+ def __init__(self, serial: str):
27
+ """ serial is the udid of the ios device """
28
+ super().__init__(serial)
29
+ self.device = select_device(serial)
30
+
31
+ def _request(self, method: str, path: str, payload: Optional[dict] = None) -> bytes:
32
+ conn = self.device.make_http_connection(port=8100)
33
+ try:
34
+ if payload is None:
35
+ conn.request(method, path)
36
+ else:
37
+ conn.request(method, path, body=json.dumps(payload), headers={"Content-Type": "application/json"})
38
+ response = conn.getresponse()
39
+ if response.getcode() != 200:
40
+ raise IOSDriverException(f"Failed request to device, status: {response.getcode()}")
41
+ content = bytearray()
42
+ while chunk := response.read(4096):
43
+ content.extend(chunk)
44
+ return content
45
+ finally:
46
+ conn.close()
47
+
48
+ def _request_json(self, method: str, path: str) -> dict:
49
+ content = self._request(method, path)
50
+ return json.loads(content)
51
+
52
+ def _request_json_value(self, method: str, path: str) -> dict:
53
+ return self._request_json(method, path)["value"]
54
+
55
+ def status(self):
56
+ return self._request_json("GET", "/status")
57
+
58
+ def screenshot(self, id: int = 0) -> Image.Image:
59
+ png_base64 = self._request_json_value("GET", "/screenshot")
60
+ png_data = base64.b64decode(png_base64)
61
+ return Image.open(io.BytesIO(png_data))
62
+
63
+ def window_size(self):
64
+ return self._request_json_value("GET", "/window/size")
65
+
66
+ def dump_hierarchy(self) -> Tuple[str, Node]:
67
+ """returns xml string and hierarchy object"""
68
+ xml_data = self._request_json_value("GET", "/source")
69
+ root = ElementTree.fromstring(xml_data)
70
+ return xml_data, parse_xml_element(root, WindowSize(width=1, height=1))
71
+
72
+ def tap(self, x: int, y: int):
73
+ self._request("POST", f"/wda/tap/0", {"x": x, "y": y})
74
+
75
+ def app_current(self) -> CurrentAppResponse:
76
+ # {'processArguments': {'env': {}, 'args': []}, 'name': '', 'pid': 32, 'bundleId': 'com.apple.springboard'}
77
+ value = self._request_json_value("GET", "/wda/activeAppInfo")
78
+ return CurrentAppResponse(package=value["bundleId"], pid=value["pid"])
79
+
80
+ def home(self):
81
+ self._request("POST", "/wda/homescreen")
82
+
83
+
84
+ def parse_xml_element(element, wsize: WindowSize, indexes: List[int]=[0]) -> Node:
85
+ """
86
+ Recursively parse an XML element into a dictionary format.
87
+ # <XCUIElementTypeApplication type="XCUIElementTypeApplication" name="设置" label="设置" enabled="true" visible="true" accessible="false" x="0" y="0" width="414" height="896" index="0">
88
+ """
89
+ if element.attrib.get("visible") == "false":
90
+ return None
91
+ if element.tag == "XCUIElementTypeApplication":
92
+ wsize = WindowSize(width=int(element.attrib["width"]), height=int(element.attrib["height"]))
93
+ x = int(element.attrib.get("x", 0))
94
+ y = int(element.attrib.get("y", 0))
95
+ width = int(element.attrib.get("width", 0))
96
+ height = int(element.attrib.get("height", 0))
97
+ bounds = (x / wsize.width, y / wsize.height, (x + width) / wsize.width, (y + height) / wsize.height)
98
+ bounds = list(map(partial(round, ndigits=4), bounds))
99
+ name = element.attrib.get("type", "XCUIElementTypeUnknown")
100
+
101
+ elem = Node(
102
+ key='-'.join(map(str, indexes)),
103
+ name=name,
104
+ bounds=bounds,
105
+ properties={key: element.attrib[key] for key in element.attrib},
106
+ children=[],
107
+ )
108
+ for index, child in enumerate(element):
109
+ child_elem = parse_xml_element(child, wsize, indexes+[index])
110
+ if child_elem:
111
+ elem.children.append(child_elem)
112
+ return elem
113
+
114
+