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,74 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Mon Mar 04 2024 14:10:00 by codeskyblue
5
+ """
6
+
7
+ from PIL import Image, ImageDraw
8
+
9
+ from uiautodev.driver.base_driver import BaseDriver
10
+ from uiautodev.model import Node, ShellResponse, WindowSize
11
+
12
+
13
+ class MockDriver(BaseDriver):
14
+ def screenshot(self, id: int):
15
+ im = Image.new("RGB", (500, 800), "gray")
16
+ draw = ImageDraw.Draw(im)
17
+ draw.text((10, 10), "mock", fill="white")
18
+ draw.rectangle([100, 100, 200, 200], outline="red", fill="blue")
19
+ del draw
20
+ return im
21
+
22
+ def dump_hierarchy(self):
23
+ return "", Node(
24
+ key="0",
25
+ name="root",
26
+ bounds=(0, 0, 1, 1),
27
+ properties={
28
+ "class": "android.view.View",
29
+ },
30
+ children=[
31
+ Node(
32
+ key="0-0",
33
+ name="mock1",
34
+ bounds=(0.1, 0.1, 0.5, 0.5),
35
+ properties={
36
+ "class": "android.widget.FrameLayout",
37
+ "text": "mock1",
38
+ "accessible": "true",
39
+ },
40
+ ),
41
+ Node(
42
+ key="0-1",
43
+ name="mock2",
44
+ bounds=(0.4, 0.4, 0.6, 0.6),
45
+ properties={
46
+ "class": "android.widget.ImageView",
47
+ "text": "mock2",
48
+ "accessible": "true",
49
+ },
50
+ children=[
51
+ Node(
52
+ key="0-1-0",
53
+ name="mock2-1",
54
+ bounds=(0.42, 0.42, 0.45, 0.45),
55
+ properties={
56
+ "class": "android.widget.ImageView",
57
+ "text": "mock2-1",
58
+ "visible": "true",
59
+ },
60
+ ),
61
+ ]
62
+ ),
63
+ Node(
64
+ key="0-2",
65
+ name="mock-should-not-show",
66
+ bounds=(0.4, 0.4, 0.6, 0.6),
67
+ properties={
68
+ "class": "android.widget.ImageView",
69
+ "text": "mock3",
70
+ "visible": "false",
71
+ },
72
+ ),
73
+ ],
74
+ )
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Sun Apr 21 2024 21:15:15 by codeskyblue
5
+ """
6
+
7
+
8
+ import atexit
9
+ from base64 import b64decode
10
+ import enum
11
+ import io
12
+ import json
13
+ import logging
14
+ from pprint import pprint
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from PIL import Image
21
+ import adbutils
22
+ import requests
23
+ from pydantic import BaseModel
24
+
25
+ """
26
+ shell steps:
27
+ adb push appium-uiautomator2-v5.12.4.apk /data/local/tmp/udt.jar
28
+ adb shell CLASSPATH=/data/local/tmp/udt.jar app_process / "com.wetest.uia2.Main"
29
+ adb forward tcp:6790 tcp:6790
30
+ # 创建session
31
+ echo '{"capabilities": {}}' | http POST :6790/session
32
+ # 获取当前所有session
33
+ http GET :6790/sessions
34
+ # 获取pageSource
35
+ http GET :6790/session/{session_id}/source
36
+
37
+ # TODO
38
+ # /appium/settins 中waitForIdleTimeout需要调整,其他的再看看
39
+ """
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ class UDTError(Exception):
44
+ pass
45
+
46
+
47
+ class HTTPError(UDTError):
48
+ pass
49
+
50
+
51
+ class AppiumErrorEnum(str, enum.Enum):
52
+ InvalidSessionID = 'invalid session id'
53
+
54
+
55
+ class AppiumError(UDTError):
56
+ def __init__(self, error: str, message):
57
+ self.error = error
58
+ self.message = message
59
+
60
+
61
+ class AppiumResponseValue(BaseModel):
62
+ error: Optional[str] = None
63
+ message: Optional[str] = None
64
+ stacktrace: Optional[str] = None
65
+
66
+
67
+ class AppiumResponse(BaseModel):
68
+ sessionId: Optional[str] = None
69
+ value: Any = None
70
+
71
+
72
+ class MockAdbProcess:
73
+ def __init__(self, conn: adbutils.AdbConnection) -> None:
74
+ self._conn = conn
75
+ self._event = threading.Event()
76
+
77
+ def wait_finished():
78
+ try:
79
+ self._conn.read_until_close()
80
+ except:
81
+ pass
82
+ self._event.set()
83
+
84
+ t = threading.Thread(target=wait_finished)
85
+ t.daemon = True
86
+ t.name = "wait_adb_conn"
87
+ t.start()
88
+
89
+ def wait(self) -> int:
90
+ self._event.wait()
91
+ return 0
92
+
93
+ def pool(self) -> Optional[int]:
94
+ if self._event.is_set():
95
+ return 0
96
+ return None
97
+
98
+ def kill(self):
99
+ self._conn.close()
100
+
101
+
102
+ class UDT:
103
+ def __init__(self, device: adbutils.AdbDevice):
104
+ self._device = device
105
+ self._lport = None
106
+ self._process = None
107
+ self._lock = threading.Lock()
108
+ self._session_id = None
109
+ atexit.register(self.release)
110
+
111
+ def get_session_id(self) -> str:
112
+ if self._session_id:
113
+ return self._session_id
114
+ self._session_id = self._new_session()
115
+ logger.debug("update waitForIdleTimeout to 0ms")
116
+ self._dev_request("POST", f"/session/{self._session_id}/appium/settings", payload={
117
+ "settings": {
118
+ "waitForIdleTimeout": 10,
119
+ "waitForSelectorTimeout": 10,
120
+ "actionAcknowledgmentTimeout": 10,
121
+ "scrollAcknowledgmentTimeout": 10,
122
+ "trackScrollEvents": False,
123
+ }
124
+ })
125
+ result = self._dev_request("GET", f"/session/{self._session_id}/appium/settings")
126
+ return self._session_id
127
+
128
+ def dev_request(self, method: str, path: str, **kwargs) -> AppiumResponse:
129
+ """send http request to device
130
+ :param method: GET, POST, DELETE, PUT
131
+ :param path: url path, path start with @ means with_session=True
132
+
133
+ :return: response json
134
+ """
135
+ try:
136
+ if path.startswith("@"):
137
+ path = path[1:]
138
+ kwargs['with_session'] = True
139
+ return self._dev_request(method, path, **kwargs)
140
+ except HTTPError:
141
+ self.launch_server()
142
+ return self._dev_request(method, path, **kwargs)
143
+ except AppiumError as e:
144
+ if e.error == AppiumErrorEnum.InvalidSessionID:
145
+ self._session_id = self._new_session()
146
+ return self._dev_request(method, path, **kwargs)
147
+ raise
148
+
149
+ def _dev_request(self, method: str, path: str, payload=None, timeout: float = 10.0, with_session: bool = False) -> AppiumResponse:
150
+ try:
151
+ if with_session:
152
+ sid = self.get_session_id()
153
+ path = f"/session/{sid}{path}"
154
+ url = f"http://localhost:{self._lport}{path}"
155
+ logger.debug("request %s %s", method, url)
156
+ r = requests.request(method, url, json=payload, timeout=timeout)
157
+ response_json = r.json()
158
+ resp = AppiumResponse.model_validate(response_json)
159
+ if isinstance(resp.value, dict):
160
+ value = AppiumResponseValue.model_validate(resp.value)
161
+ if value.error:
162
+ raise AppiumError(value.error, value.message)
163
+ return resp
164
+ except requests.RequestException as e:
165
+ raise HTTPError(f"{method} to {path!r} error", payload)
166
+ except json.JSONDecodeError as e:
167
+ raise HTTPError("JSON decode error", e.msg)
168
+
169
+ def _new_session(self) -> str:
170
+ resp = self._dev_request("POST", "/session", payload={"capabilities": {}})
171
+ value = resp.value
172
+ if not isinstance(value, dict) and 'sessionId' not in value:
173
+ raise UDTError("session create failed", resp)
174
+ sid = value['sessionId']
175
+ if not sid:
176
+ raise UDTError("session create failed", resp)
177
+ return sid
178
+
179
+ def post(self, path: str, payload=None) -> AppiumResponse:
180
+ return self.dev_request("POST", path, payload=payload)
181
+
182
+ def get(self, path: str, ) -> AppiumResponse:
183
+ return self.dev_request("GET", path)
184
+
185
+ def _update_process_status(self):
186
+ if self._process:
187
+ if self._process.pool() is not None:
188
+ self._process = None
189
+
190
+ def release(self):
191
+ logger.debug("Releasing")
192
+ with self._lock:
193
+ if self._process is not None:
194
+ logger.debug("Killing process")
195
+ self._process.kill()
196
+ self._process.wait()
197
+ self._process = None
198
+
199
+ def launch_server(self):
200
+ try:
201
+ self._launch_server()
202
+ self._device.keyevent("WAKEUP")
203
+ except adbutils.AdbError as e:
204
+ raise UDTError("fail to start udt", str(e))
205
+ self._wait_ready()
206
+
207
+ def _launch_server(self):
208
+ with self._lock:
209
+ self._update_process_status()
210
+ if self._process:
211
+ logger.debug("Process already running")
212
+ return
213
+ logger.debug("Launching process")
214
+ dex_local_path = Path(__file__).parent.joinpath("appium-uiautomator2-v5.12.4-light.apk")
215
+ logger.debug("dex_local_path: %s", dex_local_path)
216
+ dex_remote_path = "/data/local/tmp/udt/udt-5.12.4-light.dex"
217
+ info = self._device.sync.stat(dex_remote_path)
218
+ if info.size == dex_local_path.stat().st_size:
219
+ logger.debug("%s already exists", dex_remote_path)
220
+ else:
221
+ logger.debug("push dex(%d) to %s", dex_local_path.stat().st_size, dex_remote_path)
222
+ self._device.shell("mkdir -p /data/local/tmp/udt")
223
+ self._device.sync.push(dex_local_path, dex_remote_path, 0o644)
224
+ logger.debug("CLASSPATH=%s app_process / com.wetest.uia2.Main", dex_remote_path)
225
+ conn = self._device.shell(f"CLASSPATH={dex_remote_path} app_process / com.wetest.uia2.Main", stream=True)
226
+ self._process = MockAdbProcess(conn)
227
+
228
+ self._lport = self._device.forward_port(6790)
229
+ logger.debug("forward tcp:6790 -> tcp:%d", self._lport)
230
+
231
+ def _wait_ready(self):
232
+ deadline = time.time() + 10
233
+ while time.time() < deadline:
234
+ try:
235
+ self._dev_request("GET", "/status", timeout=1)
236
+ return
237
+ except HTTPError:
238
+ time.sleep(0.5)
239
+ raise UDTError("Service not ready")
240
+
241
+ def dump_hierarchy(self) -> str:
242
+ resp = self.get(f"@/source")
243
+ return resp.value
244
+
245
+ def status(self):
246
+ return self.get("/status")
247
+
248
+ def screenshot(self) -> Image.Image:
249
+ resp = self.get(f"@/screenshot")
250
+ raw = b64decode(resp.value)
251
+ return Image.open(io.BytesIO(raw))
252
+
253
+
254
+
255
+ if __name__ == '__main__':
256
+ logging.basicConfig(level=logging.DEBUG)
257
+ r = UDT(adbutils.device())
258
+ print(r.status())
259
+ r.dump_hierarchy()
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Tue Mar 05 2024 11:16:29 by codeskyblue
5
+ """
6
+
7
+ class UiautoException(Exception):
8
+ pass
9
+
10
+
11
+ class IOSDriverException(UiautoException):
12
+ pass
13
+
14
+
15
+ class AndroidDriverException(UiautoException):
16
+ pass
17
+
18
+
19
+ class AppiumDriverException(UiautoException):
20
+ pass
21
+
22
+
23
+ class MethodError(UiautoException):
24
+ pass
25
+
26
+
27
+ class ElementNotFoundError(MethodError):
28
+ pass
29
+
30
+
31
+ class RequestError(UiautoException):
32
+ pass
uiautodev/model.py ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Sun Feb 18 2024 11:12:33 by codeskyblue
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import typing
9
+ from typing import Dict, List, Optional, Tuple, Union
10
+
11
+ from pydantic import BaseModel
12
+
13
+
14
+ class DeviceInfo(BaseModel):
15
+ serial: str
16
+ model: str = ""
17
+ name: str = ""
18
+ status: str = ""
19
+ enabled: bool = True
20
+
21
+
22
+ class ShellResponse(BaseModel):
23
+ output: str
24
+ error: Optional[str] = ""
25
+
26
+
27
+ class Node(BaseModel):
28
+ key: str
29
+ name: str
30
+ bounds: Optional[Tuple[float, float, float, float]] = None
31
+ properties: Dict[str, Union[str, bool]] = []
32
+ children: List[Node] = []
33
+
34
+
35
+ class WindowSize(typing.NamedTuple):
36
+ width: int
37
+ height: int
uiautodev/provider.py ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Sun Feb 18 2024 11:10:58 by codeskyblue
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import abc
9
+ from functools import lru_cache
10
+
11
+ import adbutils
12
+
13
+ from uiautodev.driver.android import AndroidDriver
14
+ from uiautodev.driver.base_driver import BaseDriver
15
+ from uiautodev.driver.ios import IOSDriver
16
+ from uiautodev.driver.mock import MockDriver
17
+ from uiautodev.exceptions import UiautoException
18
+ from uiautodev.model import DeviceInfo
19
+ from uiautodev.utils.usbmux import MuxDevice, list_devices
20
+
21
+
22
+ class BaseProvider(abc.ABC):
23
+ @abc.abstractmethod
24
+ def list_devices(self) -> list[DeviceInfo]:
25
+ raise NotImplementedError()
26
+
27
+ @abc.abstractmethod
28
+ def get_device_driver(self, serial: str) -> BaseDriver:
29
+ raise NotImplementedError()
30
+
31
+ def get_single_device_driver(self) -> BaseDriver:
32
+ """ debug use """
33
+ devs = self.list_devices()
34
+ if len(devs) == 0:
35
+ raise UiautoException("No device found")
36
+ if len(devs) > 1:
37
+ raise UiautoException("More than one device found")
38
+ return self.get_device_driver(devs[0].serial)
39
+
40
+
41
+ class AndroidProvider(BaseProvider):
42
+ def __init__(self):
43
+ pass
44
+
45
+ def list_devices(self) -> list[DeviceInfo]:
46
+ adb = adbutils.AdbClient()
47
+ ret: list[DeviceInfo] = []
48
+ for d in adb.list():
49
+ if d.state != "device":
50
+ ret.append(DeviceInfo(serial=d.serial, status=d.state, enabled=False))
51
+ else:
52
+ dev = adb.device(d.serial)
53
+ ret.append(DeviceInfo(serial=d.serial, model=dev.prop.model, name=dev.prop.name))
54
+ return ret
55
+
56
+ @lru_cache
57
+ def get_device_driver(self, serial: str) -> AndroidDriver:
58
+ return AndroidDriver(serial)
59
+
60
+
61
+ class IOSProvider(BaseProvider):
62
+ def list_devices(self) -> list[DeviceInfo]:
63
+ devs = list_devices()
64
+ return [DeviceInfo(serial=d.serial, model="unknown", name="unknown") for d in devs]
65
+
66
+ @lru_cache
67
+ def get_device_driver(self, serial: str) -> BaseDriver:
68
+ return IOSDriver(serial)
69
+
70
+
71
+ class MockProvider(BaseProvider):
72
+ def list_devices(self) -> list[DeviceInfo]:
73
+ return [DeviceInfo(serial="mock-serial", model="mock-model", name="mock-name")]
74
+
75
+ def get_device_driver(self, serial: str) -> BaseDriver:
76
+ return MockDriver(serial)
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Fri Mar 01 2024 14:00:10 by codeskyblue
5
+ """
6
+
7
+ import io
8
+ from typing import Any, List
9
+
10
+ from fastapi import APIRouter, Response
11
+ from pydantic import BaseModel
12
+
13
+ from uiautodev import command_proxy
14
+ from uiautodev.command_types import Command, CurrentAppResponse, InstallAppRequest, InstallAppResponse, TapRequest
15
+ from uiautodev.model import DeviceInfo, Node, ShellResponse
16
+ from uiautodev.provider import BaseProvider
17
+
18
+
19
+ class AndroidShellPayload(BaseModel):
20
+ command: str
21
+
22
+
23
+ def make_router(provider: BaseProvider) -> APIRouter:
24
+ router = APIRouter()
25
+
26
+ @router.get("/list")
27
+ def _list() -> List[DeviceInfo]:
28
+ """List of Android devices"""
29
+ try:
30
+ return provider.list_devices()
31
+ except NotImplementedError as e:
32
+ return Response(content="list_devices not implemented", media_type="text/plain", status_code=501)
33
+ except Exception as e:
34
+ return Response(content=str(e), media_type="text/plain", status_code=500)
35
+
36
+ @router.post("/{serial}/shell")
37
+ def android_shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
38
+ """Run a shell command on an Android device"""
39
+ try:
40
+ driver = provider.get_device_driver(serial)
41
+ return driver.shell(payload.command)
42
+ except NotImplementedError as e:
43
+ return Response(content="shell not implemented", media_type="text/plain", status_code=501)
44
+ except Exception as e:
45
+ return ShellResponse(output="", error=str(e))
46
+
47
+ @router.get(
48
+ "/{serial}/screenshot/{id}",
49
+ responses={200: {"content": {"image/jpeg": {}}}},
50
+ response_class=Response,
51
+ )
52
+ def _screenshot(serial: str, id: int) -> Response:
53
+ """Take a screenshot of device"""
54
+ try:
55
+ driver = provider.get_device_driver(serial)
56
+ pil_img = driver.screenshot(id)
57
+ buf = io.BytesIO()
58
+ pil_img.save(buf, format="JPEG")
59
+ image_bytes = buf.getvalue()
60
+ return Response(content=image_bytes, media_type="image/jpeg")
61
+ except Exception as e:
62
+ return Response(content=str(e), media_type="text/plain", status_code=500)
63
+
64
+ @router.get("/{serial}/hierarchy")
65
+ def dump_hierarchy(serial: str) -> Node:
66
+ """Dump the view hierarchy of an Android device"""
67
+ try:
68
+ driver = provider.get_device_driver(serial)
69
+ xml_data, hierarchy = driver.dump_hierarchy()
70
+ return hierarchy
71
+ except Exception as e:
72
+ return Response(content=str(e), media_type="text/plain", status_code=500)
73
+
74
+ @router.post('/{serial}/command/tap')
75
+ def command_tap(serial: str, params: TapRequest):
76
+ """Run a command on the device"""
77
+ driver = provider.get_device_driver(serial)
78
+ command_proxy.tap(driver, params)
79
+ return {"status": "ok"}
80
+
81
+ @router.post('/{serial}/command/installApp')
82
+ def install_app(serial: str, params: InstallAppRequest) -> InstallAppResponse:
83
+ """Install app"""
84
+ driver = provider.get_device_driver(serial)
85
+ return command_proxy.app_install(driver, params)
86
+
87
+ @router.get('/{serial}/command/currentApp')
88
+ def current_app(serial: str) -> CurrentAppResponse:
89
+ """Get current app"""
90
+ driver = provider.get_device_driver(serial)
91
+ return command_proxy.app_current(driver)
92
+
93
+ @router.post('/{serial}/command/{command}')
94
+ def _command_proxy_other(serial: str, command: Command, params: Any = None):
95
+ """Run a command on the device"""
96
+ driver = provider.get_device_driver(serial)
97
+ func = command_proxy.COMMANDS[command]
98
+ if params is None:
99
+ response = func(driver)
100
+ else:
101
+ response = func(driver, params)
102
+ return response
103
+
104
+ return router
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Tue Mar 05 2024 16:59:19 by codeskyblue
5
+ """
6
+
7
+ from fastapi import APIRouter, Form, Response
8
+ from lxml import etree
9
+ from typing_extensions import Annotated
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/check/xpath")
15
+ def check_xpath(xml: Annotated[str, Form()], xpath: Annotated[str, Form()]) -> Response:
16
+ """Check if the XPath expression is valid"""
17
+ try:
18
+ children = []
19
+ for child in etree.fromstring(xml).xpath(xpath):
20
+ children.append(child)
21
+ if len(children) > 0:
22
+ return Response(content=children[0].tag, media_type="text/plain")
23
+ else:
24
+ return Response(
25
+ content="XPath is valid but not node matches", media_type="text/plain"
26
+ )
27
+ except Exception as e:
28
+ return Response(content=str(e), media_type="text/plain", status_code=400)
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AppInspector Demo</title>
7
+ </head>
8
+ <body>
9
+ <h1>App Inspector</h1>
10
+ <div id="message"></div>
11
+ <button id="test">Test</button>
12
+ <script>
13
+ window.onload = function() {
14
+ let message = "<span style='color: green'>App Inspector is installed.</span>"
15
+ if (chrome.runtime === undefined) {
16
+ message = "Please install the App Inspector Chrome Extension to use this feature.";
17
+ }
18
+ document.getElementById('message').innerHTML = `<span>${message}</span>`;
19
+ }
20
+
21
+ document.getElementById('test').addEventListener('click', function() {
22
+ const extensionId = "fjbboaelofjaabjmlphndicacmapbalm"
23
+ chrome.runtime.sendMessage(extensionId, {url: "/info"}, function(response) {
24
+ console.log(response);
25
+ if (response.error) {
26
+ console.error(response.error)
27
+ } else {
28
+ document.getElementById('message').innerHTML = response.data.description
29
+ }
30
+ });
31
+ });
32
+ </script>
33
+ </body>
34
+ </html>