├── .coveragerc
├── .github
└── workflows
│ └── publish_to_pypi.yml
├── .gitignore
├── .idea
├── .gitignore
├── inspectionProfiles
│ └── profiles_settings.xml
├── misc.xml
├── modules.xml
├── vcs.xml
└── wdapy.iml
├── DEVELOP.md
├── LICENSE
├── README.md
├── README_CN.md
├── examples
├── draw_robot.py
└── swipe.py
├── requirements.txt
├── setup.cfg
├── setup.py
├── tests
├── test_actions.py
├── test_common_client.py
└── test_utils.py
└── wdapy
├── __init__.py
├── _alert.py
├── _base.py
├── _proto.py
├── _types.py
├── _utils.py
├── _wdapy.py
├── _wrap.py
├── actions.py
├── exceptions.py
└── usbmux
├── __init__.py
├── exceptions.py
└── pyusbmux.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 |
4 | omit =
5 | **/wdapy/usbmux/**
6 |
7 | [report]
8 | ignore_errors = True
9 |
10 | exclude_lines =
11 | pragma: no cover
12 | if self.debug:
13 | if settings.DEBUG
14 | if 0:
15 | raise NotImplementedError
16 | ^(from|import) .*
17 | ^"""
18 | @abc.abstractmethod
19 | def __repr__
20 | class .*(enum\.Enum|StrEnum).*:$
21 |
--------------------------------------------------------------------------------
/.github/workflows/publish_to_pypi.yml:
--------------------------------------------------------------------------------
1 | name: Publish to PyPI
2 | on: push
3 | jobs:
4 | build-n-publish:
5 | name: Build, test and publish Python 🐍 distributions 📦 to PyPI
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@master
9 | with:
10 | fetch-depth: 5
11 | - name: Set up Python
12 | uses: actions/setup-python@v1
13 | with:
14 | python-version: 3.9
15 | cache: 'pip'
16 | - name: Install requirements
17 | run: |
18 | pip3 install -e .
19 | pip3 install pytest coverage
20 | - name: Run UnitTest
21 | run: |
22 | python3 -m coverage run -m pytest -vv tests/
23 | python3 -m coverage xml --include "wdapy/**"
24 | - name: Install pypa/build and Build targz and wheel
25 | run: |
26 | python3 -m pip install wheel
27 | python3 setup.py sdist bdist_wheel
28 | - name: Publish distribution 📦 to PyPI
29 | if: startsWith(github.ref, 'refs/tags')
30 | uses: pypa/gh-action-pypi-publish@master
31 | with:
32 | skip_existing: true
33 | password: ${{ secrets.PYPI_API_TOKEN }}
34 | # coverage:
35 | # runs-on: ubuntu-latest
36 | # steps:
37 | # - name: Get Cover
38 | # uses: orgoro/coverage@v3
39 | # with:
40 | # coverageFile: coverage.xml
41 | # token: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98 | __pypackages__/
99 |
100 | # Celery stuff
101 | celerybeat-schedule
102 | celerybeat.pid
103 |
104 | # SageMath parsed files
105 | *.sage.py
106 |
107 | # Environments
108 | .env
109 | .venv
110 | env/
111 | venv/
112 | ENV/
113 | env.bak/
114 | venv.bak/
115 |
116 | # Spyder project settings
117 | .spyderproject
118 | .spyproject
119 |
120 | # Rope project settings
121 | .ropeproject
122 |
123 | # mkdocs documentation
124 | /site
125 |
126 | # mypy
127 | .mypy_cache/
128 | .dmypy.json
129 | dmypy.json
130 |
131 | # Pyre type checker
132 | .pyre/
133 |
134 | # pytype static type analyzer
135 | .pytype/
136 |
137 | # Cython debug symbols
138 | cython_debug/
139 |
140 |
141 | .DS_Store
142 | */.DS_Store
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Datasource local storage ignored files
5 | /dataSources/
6 | /dataSources.local.xml
7 | # Editor-based HTTP Client requests
8 | /httpRequests/
9 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/wdapy.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/DEVELOP.md:
--------------------------------------------------------------------------------
1 | # 开发文档
2 | https://github.com/appium/WebDriverAgent/tree/master/WebDriverAgentLib/Commands
3 |
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2021 codeskyblue
3 |
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
21 | OR OTHER DEALINGS IN THE SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # wdapy
2 | [](https://pypi.org/project/wdapy/)
3 |
4 | [中文](README_CN.md)
5 |
6 | Follow WDA API written in
7 |
8 | Current WDA: 9.3.3
9 |
10 | ## Requires
11 | Recommended Python **3.9+**
12 |
13 | > Might be work with Python 3.7+
14 | > Run unittest require py 3.8+
15 |
16 | ## Installation
17 | ```bash
18 | pip3 install wdapy
19 |
20 | # Optional
21 | # Support launch WDA with tidevice when WDA is dead
22 | pip3 install tidevice[openssl]
23 | ```
24 |
25 | ## Usage
26 |
27 | Create Client instance
28 | ```python
29 | import wdapy
30 |
31 | # Based on project: https://github.com/appium/WebDriverAgent
32 | c = wdapy.AppiumClient()
33 | # or
34 | c = wdapy.AppiumClient("http://localhost:8100")
35 | # or
36 | c = wdapy.AppiumUSBClient("00008101-001234567890ABCDEF")
37 | # or (only works when only one device)
38 | c = wdapy.AppiumUSBClient()
39 |
40 | # Based on project: https://github.com/codeskyblue/WebDriverAgent
41 | # with fast touch and swipe
42 | c = wdapy.NanoClient("http://localhost:8100")
43 | c = wdapy.NanoUSBClient()
44 | ```
45 |
46 | Call WDA method
47 |
48 | ```python
49 | print(c.request_timeout) # show request timeout (default 120s)
50 | c.request_timeout = 60 # change to 60
51 |
52 | print(c.scale) # 2 or 3
53 | print(c.window_size()) # (width, height)
54 | print(c.debug) # output True or False (default False)
55 | c.debug = True
56 |
57 | c.app_start("com.apple.Preferences")
58 | c.app_terminate("com.apple.stocks")
59 | c.app_state("com.apple.mobilesafari")
60 | c.app_list() # like app_current
61 |
62 | c.app_current()
63 | # Output example
64 | #
65 |
66 | # put current app to background 2 seconds and put back to foreground
67 | c.deactivate(2.0)
68 |
69 | c.alert.exists # bool
70 | c.alert.buttons()
71 | c.alert.accept()
72 | c.alert.dismiss()
73 | c.alert.click("Accept")
74 |
75 | c.open_url("https://www.baidu.com")
76 |
77 | # clipboard only works when WebDriverAgent app in foreground
78 | c.app_start("com.facebook.WebDriverAgentRunner.xctrunner")
79 | c.set_clipboard("foo")
80 | c.get_clipboard() # output: foo
81 |
82 | c.is_locked() # bool
83 | c.unlock()
84 | c.lock()
85 | c.homescreen()
86 | c.shutdown() # shutdown WebDriverAgent
87 |
88 | c.send_keys("foo")
89 | c.send_keys("\n") # simulator enter
90 |
91 | # seems missing c.get_clipboard()
92 | c.screenshot() # PIL.Image.Image
93 | c.screenshot().save("screenshot.jpg")
94 |
95 | c.get_orientation()
96 | # PORTRAIT | LANDSCAPE
97 |
98 | c.window_size() # width, height
99 | print(c.status_barsize) # (width, height)
100 | print(c.device_info()) # (timeZone, currentLocation, model and so on)
101 | print(c.battery_info()) # (level, state)
102 |
103 | print(c.sourcetree())
104 |
105 | c.press_duration(name="power_plus_home", duration=1) #take a screenshot
106 | # todo, need to add more method
107 |
108 | c.volume_up()
109 | c.volume_down()
110 |
111 | # tap x:100, y:200
112 | c.tap(100, 200)
113 |
114 | # dismiss keyboard
115 | # by tap keyboard button to dismiss, default keyNames are ["前往", "发送", "Send", "Done", "Return"]
116 | c.keyboard_dismiss(["Done", "Return"])
117 | ```
118 |
119 | Touch Actions
120 |
121 | ```python
122 | # simulate swipe right in 1000ms
123 | from wdapy.actions import TouchActions, PointerAction
124 | finger1 = TouchActions.pointer("finger1", actions=[
125 | PointerAction.move(200, 300),
126 | PointerAction.down(),
127 | PointerAction.move(50, 0, duration=1000, origin=Origin.POINTER),
128 | # same as
129 | # PointerAction.move(250, 300, duration=1000, origin=Origin.VIEWPORT),
130 | PointerAction.up(),
131 | ])
132 | c.touch_perform([finger1])
133 |
134 | # simulate pinchOut
135 | finger2 = TouchActions.pointer("finger2", actions=[
136 | PointerAction.move(150, 300),
137 | PointerAction.down(),
138 | PointerAction.move(-50, 0, duration=1000, origin=Origin.POINTER),
139 | PointerAction.up(),
140 | ])
141 | c.touch_perform([finger1, finger2])
142 |
143 | # even through touch actions can simulate key events
144 | # but it is not recommended, it's better to use send_keys instead
145 | ```
146 |
147 | ## Breaking change
148 |
149 | Removed in WDA 7.0 and wdapy 1.0
150 |
151 | ```
152 | from wdapy import Gesture, GestureOption as Option
153 | c.touch_perform([
154 | Gesture("press", Option(x=100, y=200)),
155 | Gesture("wait", Option(ms=100)), # ms should > 17
156 | Gesture("moveTo", Option(x=100, y = 100)),
157 | Gesture("release")
158 | ])
159 | ```
160 |
161 |
162 | ## How to contribute
163 | Assume that you want to add a new method
164 |
165 | - First step, add method usage to README.md, README_CN.md
166 | - Add unit test in under direction tests/
167 | - Add your name in the section `## Contributors`
168 |
169 | The repo is distributed by github actions.
170 | The code master just need to create a version through `git tag $VERSION` and `git push --tags`
171 | Github actions will build targz and wheel and publish to https://pypi.org
172 |
173 | ## Contributors
174 |
175 | - [codeskyblue](https://github.com/codeskyblue)
176 | - [justinxiang](https://github.com/Justin-Xiang)
177 |
178 | ## Alternative
179 | - https://github.com/openatx/facebook-wda
180 |
181 | ## LICENSE
182 | [MIT](LICENSE)
183 |
--------------------------------------------------------------------------------
/README_CN.md:
--------------------------------------------------------------------------------
1 | # wdapy
2 | [](https://pypi.org/project/wdapy/)
3 |
4 | [English](README.md)
5 |
6 | 遵循 中的 WDA API
7 |
8 | ## 环境要求
9 | Python 3.7+
10 |
11 | > 运行单元测试需要 Python 3.8+
12 |
13 | ## 安装
14 | ```bash
15 | pip3 install wdapy
16 |
17 | # 可选
18 | # 当 WDA 无响应时,支持使用 tidevice 启动 WDA
19 | pip3 install tidevice[openssl]
20 | ```
21 |
22 | ## 使用方法
23 |
24 | 创建客户端实例
25 | ```python
26 | import wdapy
27 |
28 | # 基于项目: https://github.com/appium/WebDriverAgent
29 | c = wdapy.AppiumClient()
30 | # 或者
31 | c = wdapy.AppiumClient("http://localhost:8100")
32 | # 或者
33 | c = wdapy.AppiumUSBClient("00008101-001234567890ABCDEF")
34 | # 或者 (仅当只有一台设备连接时有效)
35 | c = wdapy.AppiumUSBClient()
36 |
37 | # 基于项目: https://github.com/codeskyblue/WebDriverAgent
38 | # 支持快速点击和滑动
39 | c = wdapy.NanoClient("http://localhost:8100")
40 | c = wdapy.NanoUSBClient()
41 | ```
42 |
43 | 调用 WDA 方法
44 |
45 | ```python
46 | print(c.request_timeout) # 显示请求超时时间 (默认 120 秒)
47 | c.request_timeout = 60 # 修改为 60 秒
48 |
49 | print(c.scale) # 2 或 3
50 | print(c.window_size()) # (宽度, 高度)
51 | print(c.debug) # 输出 True 或 False (默认 False)
52 | c.debug = True
53 |
54 | c.app_start("com.apple.Preferences")
55 | c.app_terminate("com.apple.stocks")
56 | c.app_state("com.apple.mobilesafari")
57 | c.app_list() # 类似 app_current
58 |
59 | c.app_current()
60 | # 输出示例
61 | #
62 |
63 | # 将当前应用置于后台 2 秒,然后返回前台
64 | c.deactivate(2.0)
65 |
66 | c.alert.exists # 布尔值
67 | c.alert.buttons()
68 | c.alert.accept()
69 | c.alert.dismiss()
70 | c.alert.click("接受")
71 |
72 | c.open_url("https://www.baidu.com")
73 |
74 | # 剪贴板功能仅在 WebDriverAgent 应用在前台时有效
75 | c.app_start("com.facebook.WebDriverAgentRunner.xctrunner")
76 | c.set_clipboard("foo")
77 | c.get_clipboard() # 输出: foo
78 |
79 | c.is_locked() # 布尔值
80 | c.unlock()
81 | c.lock()
82 | c.homescreen()
83 | c.shutdown() # 关闭 WebDriverAgent
84 |
85 | c.send_keys("foo")
86 | c.send_keys("\n") # 模拟回车键
87 |
88 | # 似乎缺少 c.get_clipboard()
89 | c.screenshot() # PIL.Image.Image
90 | c.screenshot().save("screenshot.jpg")
91 |
92 | c.get_orientation()
93 | # PORTRAIT | LANDSCAPE (竖屏 | 横屏)
94 |
95 | c.window_size() # 宽度, 高度
96 | print(c.status_barsize) # (宽度, 高度)
97 | print(c.device_info()) # (时区, 当前位置, 型号等)
98 | print(c.battery_info()) # (电量, 状态)
99 |
100 | print(c.sourcetree())
101 |
102 | c.press_duration(name="power_plus_home", duration=1) # 截屏
103 | # 待添加更多方法
104 |
105 | c.volume_up()
106 | c.volume_down()
107 |
108 | # 点击坐标 x:100, y:200
109 | c.tap(100, 200)
110 |
111 | # 关闭键盘
112 | # 通过点击键盘按钮关闭,默认按钮名称为 ["前往", "发送", "Send", "Done", "Return"]
113 | c.keyboard_dismiss(["Done", "Return"])
114 | ```
115 |
116 | 触摸操作
117 |
118 | ```python
119 | # 模拟向右滑动,持续 1000 毫秒
120 | from wdapy.actions import TouchActions, PointerAction
121 | finger1 = TouchActions.pointer("finger1", actions=[
122 | PointerAction.move(200, 300),
123 | PointerAction.down(),
124 | PointerAction.move(50, 0, duration=1000, origin=Origin.POINTER),
125 | # 等同于
126 | # PointerAction.move(250, 300, duration=1000, origin=Origin.VIEWPORT),
127 | PointerAction.up(),
128 | ])
129 | c.touch_perform([finger1])
130 |
131 | # 模拟两指外扩
132 | finger2 = TouchActions.pointer("finger2", actions=[
133 | PointerAction.move(150, 300),
134 | PointerAction.down(),
135 | PointerAction.move(-50, 0, duration=1000, origin=Origin.POINTER),
136 | PointerAction.up(),
137 | ])
138 | c.touch_perform([finger1, finger2])
139 | ```
140 |
141 | ## 重大变更
142 |
143 | 在 WDA 7.0 和 wdapy 1.0 中已移除
144 |
145 | ```
146 | from wdapy import Gesture, GestureOption as Option
147 | c.touch_perform([
148 | Gesture("press", Option(x=100, y=200)),
149 | Gesture("wait", Option(ms=100)), # ms 应大于 17
150 | Gesture("moveTo", Option(x=100, y = 100)),
151 | Gesture("release")
152 | ])
153 | ```
154 |
155 |
156 | ## 如何贡献
157 | 假设你想添加一个新方法
158 |
159 | - 第一步,在 README.md 和 README_CN.md 中添加方法用法
160 | - 在 tests/ 目录下添加单元测试
161 | - 在 `## 贡献者` 部分添加你的名字
162 |
163 | 本仓库通过 GitHub Actions 发布。
164 | 代码管理员只需要通过 `git tag $VERSION` 和 `git push --tags` 创建版本
165 | GitHub Actions 将构建 targz 和 wheel 并发布到 https://pypi.org
166 |
167 | ## 贡献者
168 |
169 | - [codeskyblue](https://github.com/codeskyblue)
170 | - [justinxiang](https://github.com/Justin-Xiang)
171 |
172 | ## 替代方案
173 | - https://github.com/openatx/facebook-wda
174 |
175 | ## 许可证
176 | [MIT](LICENSE)
--------------------------------------------------------------------------------
/examples/draw_robot.py:
--------------------------------------------------------------------------------
1 | from wdapy import AppiumClient, AppiumUSBClient
2 | from wdapy.actions import TouchActions, PointerAction, Origin
3 |
4 |
5 | def draw_robot(client: AppiumClient):
6 | # Calculate usable area with 20% padding
7 | width, height = client.window_size()
8 | padding_x = width * 0.2
9 | padding_y = height * 0.2
10 | usable_width = width - 2 * padding_x
11 | usable_height = height - 2 * padding_y
12 |
13 | # Starting position (center of usable area)
14 | center_x = width / 2
15 | center_y = height / 2
16 |
17 | # Draw the robot head (square)
18 | head_size = usable_width * 0.3
19 | head_top = center_y - head_size * 0.8
20 | head_left = center_x - head_size / 2
21 |
22 | # Draw the head (square)
23 | head = TouchActions.pointer("robot_finger", actions=[
24 | PointerAction.move(int(head_left), int(head_top), origin=Origin.VIEWPORT),
25 | PointerAction.down(),
26 | PointerAction.move(int(head_left + head_size), int(head_top), origin=Origin.VIEWPORT),
27 | PointerAction.move(int(head_left + head_size), int(head_top + head_size), origin=Origin.VIEWPORT),
28 | PointerAction.move(int(head_left), int(head_top + head_size), origin=Origin.VIEWPORT),
29 | PointerAction.move(int(head_left), int(head_top), origin=Origin.VIEWPORT),
30 | PointerAction.up()
31 | ])
32 |
33 | # Draw the eyes (two small circles)
34 | eye_left = TouchActions.pointer("robot_finger", actions=[
35 | # Left eye
36 | PointerAction.move(int(head_left + head_size * 0.25), int(head_top + head_size * 0.3), origin=Origin.VIEWPORT),
37 | PointerAction.down(),
38 | PointerAction.pause(500),
39 | PointerAction.up()
40 | ])
41 | eye_right = TouchActions.pointer("robot_finger", actions=[
42 | # Right eye
43 | PointerAction.move(int(head_left + head_size * 0.75), int(head_top + head_size * 0.3), origin=Origin.VIEWPORT),
44 | PointerAction.down(),
45 | PointerAction.pause(500),
46 | PointerAction.up()
47 | ])
48 |
49 | # Draw the mouth (horizontal line)
50 | mouth = TouchActions.pointer("robot_finger", actions=[
51 | PointerAction.move(int(head_left + head_size * 0.25), int(head_top + head_size * 0.7), origin=Origin.VIEWPORT),
52 | PointerAction.down(),
53 | PointerAction.move(int(head_left + head_size * 0.75), int(head_top + head_size * 0.7), origin=Origin.VIEWPORT),
54 | PointerAction.up()
55 | ])
56 |
57 | # Draw the body (rectangle)
58 | body = TouchActions.pointer("robot_finger", actions=[
59 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size), origin=Origin.VIEWPORT),
60 | PointerAction.down(),
61 | PointerAction.move(int(center_x + head_size * 0.4), int(head_top + head_size), origin=Origin.VIEWPORT),
62 | PointerAction.move(int(center_x + head_size * 0.4), int(head_top + head_size * 2), origin=Origin.VIEWPORT),
63 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size * 2), origin=Origin.VIEWPORT),
64 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size), origin=Origin.VIEWPORT),
65 | PointerAction.up()
66 | ])
67 |
68 | # Draw the arms (two horizontal lines)
69 | arm_left = TouchActions.pointer("robot_finger", actions=[
70 | # Left arm
71 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT),
72 | PointerAction.down(),
73 | PointerAction.move(int(center_x - head_size * 0.9), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT),
74 | PointerAction.up()
75 | ])
76 | arm_right = TouchActions.pointer("robot_finger", actions=[
77 | PointerAction.move(int(center_x + head_size * 0.4), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT),
78 | PointerAction.down(),
79 | PointerAction.move(int(center_x + head_size * 0.9), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT),
80 | PointerAction.up()
81 | ])
82 |
83 | # Draw the legs (two vertical lines)
84 | leg_left = TouchActions.pointer("robot_finger", actions=[
85 | PointerAction.move(int(center_x - head_size * 0.2), int(head_top + head_size * 2), origin=Origin.VIEWPORT),
86 | PointerAction.down(),
87 | PointerAction.move(int(center_x - head_size * 0.2), int(head_top + head_size * 2.5), origin=Origin.VIEWPORT),
88 | PointerAction.up()
89 | ])
90 | leg_right = TouchActions.pointer("robot_finger", actions=[
91 | PointerAction.move(int(center_x + head_size * 0.2), int(head_top + head_size * 2), origin=Origin.VIEWPORT),
92 | PointerAction.down(),
93 | PointerAction.move(int(center_x + head_size * 0.2), int(head_top + head_size * 2.5), origin=Origin.VIEWPORT),
94 | PointerAction.up()
95 | ])
96 |
97 | for action in [head, eye_left, eye_right, mouth, body, arm_left, arm_right, leg_left, leg_right]:
98 | client.perform_actions([action])
99 |
100 |
101 | if __name__ == '__main__':
102 | client = AppiumUSBClient()
103 | draw_robot(client)
104 |
105 |
--------------------------------------------------------------------------------
/examples/swipe.py:
--------------------------------------------------------------------------------
1 | import random
2 | import wdapy
3 |
4 | c = wdapy.AppiumUSBClient()
5 | w, h = c.window_size()
6 | # print(w, h)
7 | x = w//10 * random.randint(1, 9)
8 | print(x)
9 | sy = h//10*7
10 | ey = h//10*2
11 |
12 | steps = 10
13 | step_size = (ey - sy) // steps
14 | ys = list(range(sy+step_size, ey, step_size)) + [ey]
15 |
16 | gestures = []
17 | gestures.append(wdapy.Gesture("press", options={"x": x, "y": sy}))
18 | for y in ys:
19 | gestures.append(wdapy.Gesture("wait", options={"ms": 100}))
20 | gestures.append(wdapy.Gesture("moveTo", options={"x": x, "y": y}))
21 | gestures.append(wdapy.Gesture("wait", options={"ms": 100}))
22 | gestures.append(wdapy.Gesture("release"))
23 |
24 |
25 | for g in gestures:
26 | print(g.action, g.options)
27 |
28 | # c.debug = True
29 | c.appium_settings({"snapshotMaxDepth": 3})
30 | c.touch_perform(gestures)
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Deprecated>=1.2.6
2 | Pillow
3 | retry
4 | construct>=2
5 | pydantic>=2.5.1
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = wdapy
3 | author = "codeskyblue"
4 | summary = Python Client for Facebook WebDriverAgent
5 | license = MIT
6 | home-page = https://github.com/openatx/wdapy
7 | classifier =
8 | Development Status :: 3 - Alpha
9 | Environment :: Console
10 | Intended Audience :: Developers
11 | Operating System :: POSIX :: Linux
12 | Programming Language :: Python :: 3.6
13 | Programming Language :: Python :: 3.7
14 | Programming Language :: Python :: 3.8
15 | Programming Language :: Python :: 3.9
16 | Topic :: Software Development :: Libraries :: Python Modules
17 | Topic :: Software Development :: Testing
18 |
19 | [files]
20 | packages =
21 | wdapy
22 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # coding: utf-8
3 | #
4 | # Licensed under MIT
5 | #
6 |
7 | import setuptools
8 | setuptools.setup(setup_requires=['pbr'], pbr=True, python_requires='>=3.8')
9 |
--------------------------------------------------------------------------------
/tests/test_actions.py:
--------------------------------------------------------------------------------
1 | from wdapy.actions import TouchActions, PointerAction, Origin
2 | from unittest.mock import MagicMock
3 | from wdapy import AppiumClient
4 | from wdapy._proto import POST
5 |
6 |
7 | def test_perform_actions():
8 | # Create a mock client
9 | client = AppiumClient("http://localhost:8100")
10 | client.session_request = MagicMock(return_value={"value": None})
11 |
12 | # Create test actions
13 | finger1 = TouchActions.pointer("finger1", actions=[
14 | PointerAction.move(100, 200),
15 | PointerAction.down(),
16 | PointerAction.move(150, 250, duration=500, origin=Origin.POINTER),
17 | PointerAction.up()
18 | ])
19 |
20 | # Call the method being tested
21 | client.perform_actions([finger1])
22 |
23 | # Verify the session_request was called with correct parameters
24 | client.session_request.assert_called_once()
25 | args = client.session_request.call_args.args
26 |
27 | # Check that the method, endpoint and payload structure are correct
28 | assert args[0] == POST
29 | assert args[1] == "/actions"
30 |
31 | # Verify the payload contains the expected actions
32 | payload = args[2]
33 | assert "actions" in payload
34 | actions = payload["actions"]
35 | assert len(actions) == 1
36 |
37 | # Verify the first action's properties
38 | action = actions[0]
39 | assert action["type"] == "pointer"
40 | assert action["id"] == "finger1"
41 | assert "parameters" in action
42 | assert action["parameters"]["pointerType"] == "touch"
43 |
44 | # Verify the action sequence
45 | action_sequence = action["actions"]
46 | assert len(action_sequence) == 4
47 |
48 | # Check first action (move)
49 | assert action_sequence[0]["type"] == "pointerMove"
50 | assert action_sequence[0]["x"] == 100
51 | assert action_sequence[0]["y"] == 200
52 |
53 | # Check second action (down)
54 | assert action_sequence[1]["type"] == "pointerDown"
55 |
56 | # Check third action (move with duration and origin)
57 | assert action_sequence[2]["type"] == "pointerMove"
58 | assert action_sequence[2]["x"] == 150
59 | assert action_sequence[2]["y"] == 250
60 | assert action_sequence[2]["duration"] == 500
61 | assert action_sequence[2]["origin"] == "pointer"
62 |
63 | # Check fourth action (up)
64 | assert action_sequence[3]["type"] == "pointerUp"
--------------------------------------------------------------------------------
/tests/test_common_client.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | import unittest
5 | from unittest.mock import MagicMock
6 | import wdapy
7 | from wdapy import AppiumClient
8 | from wdapy._types import BatteryState
9 |
10 |
11 | class SimpleTest(unittest.TestCase):
12 | def setUp(self):
13 | self._client = AppiumClient("http://localhost:8100")
14 |
15 | def test_status(self):
16 | self._client.request = MagicMock(return_value={
17 | "sessionId": "yyds",
18 | "value": {
19 | "message": "WebDriverAgent is ready to accept commands",
20 | "ios": {
21 | "ip": "1.2.3.4",
22 | }
23 | }
24 | })
25 | st = self._client.status()
26 | self.assertEqual("1.2.3.4", st.ip)
27 | self.assertEqual("yyds", st.session_id)
28 | self.assertEqual(
29 | "WebDriverAgent is ready to accept commands", st.message)
30 |
31 | def test_sourcetree(self):
32 | self._client.request = MagicMock(return_value={
33 | "value": "\n",
34 | "sessionId": "test"
35 | })
36 | sourcetree = self._client.sourcetree()
37 | self.assertEqual("\n", sourcetree.value)
38 |
39 |
40 | def test_is_locked(self):
41 | m = MagicMock(return_value={
42 | "value": False,
43 | })
44 | self._client.request = m
45 | self.assertEqual(False, self._client.is_locked())
46 |
47 | def test_app_start(self):
48 | pass
49 |
50 | def test_app_terminate(self):
51 | pass
52 |
53 | def test_unlock(self):
54 | pass
55 |
56 | def test_deviceinfo(self):
57 | self._client.session_request = MagicMock(return_value={
58 | "value": {
59 | "timeZone": "GMT+0800",
60 | "currentLocale": "zh_CN",
61 | "model": "iPhone",
62 | "uuid": "12345678-ABCD-1234-ABCD-123456789ABC",
63 | "userInterfaceIdiom": 0,
64 | "userInterfaceStyle": "light",
65 | "name": "iPhone X",
66 | "isSimulator": False
67 | }
68 | })
69 | di = self._client.device_info()
70 | self.assertEqual("GMT+0800", di.time_zone)
71 | self.assertEqual("zh_CN", di.current_locale)
72 | self.assertEqual("iPhone", di.model)
73 | self.assertEqual("12345678-ABCD-1234-ABCD-123456789ABC", di.uuid)
74 | self.assertEqual(0, di.user_interface_idiom)
75 | self.assertEqual("light", di.user_interface_style)
76 | self.assertEqual("iPhone X", di.name)
77 | self.assertEqual(False, di.is_simulator)
78 |
79 | def test_orientation(self):
80 | self._client.session_request = MagicMock(return_value={
81 | "value": "PORTRAIT"
82 | })
83 | ot = self._client.get_orientation()
84 | self.assertEqual("PORTRAIT", ot)
85 |
86 | def test_batteryinfo(self):
87 | self._client.session_request = MagicMock(return_value={
88 | "value": {
89 | "level": 0.9999999999999999,
90 | "state": 0
91 | }
92 | })
93 | bi = self._client.battery_info()
94 | self.assertEqual(0.9999999999999999, bi.level)
95 | self.assertEqual(0, bi.state)
96 | self.assertIn(bi.state, BatteryState)
97 |
98 | def test_statusbarsize(self):
99 | self._client.session_request = MagicMock(return_value={
100 | "value": {
101 | "statusBarSize": {
102 | "width": 320,
103 | "height": 20
104 | }
105 | }
106 | })
107 | sts = self._client.status_barsize()
108 | self.assertEqual(320, sts.width)
109 | self.assertEqual(20, sts.height)
110 |
111 | def test_applist(self):
112 | self._client.session_request = MagicMock(return_value={
113 | "value": {
114 | "AppList": {
115 | "pid": 4453,
116 | "bundle_id": "com.apple.springboard"
117 | }
118 | }
119 | })
120 | with self.assertRaises(KeyError):
121 | al = self._client.app_list()
122 | self.assertEqual(4453, al.pid)
123 | self.assertEqual("com.apple.springboard", al.bundle_id)
124 |
125 | def test_press(self):
126 | self._client.session_request = MagicMock(return_value={"value": None})
127 | self._client.press(wdapy.Keycode.HOME)
128 | payload = self._client.session_request.call_args.args[-1]
129 | self.assertEqual(wdapy.Keycode.HOME, payload['name'])
130 |
131 | def test_keyboard_dismiss(self):
132 | self._client.session_request = MagicMock(return_value={"value": None})
133 | self._client.keyboard_dismiss(["Done"])
134 | payload = self._client.session_request.call_args.args[-1]
135 | self.assertEqual(["Done"], payload['keyNames'])
136 |
137 |
138 | if __name__ == "__main__":
139 | unittest.main()
140 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | from wdapy._utils import camel_to_snake, json_dumps_omit_empty
5 | from wdapy._proto import *
6 | from wdapy._types import *
7 |
8 | def test_camel_to_snake():
9 | assert "this_is_my_string" == camel_to_snake("ThisIsMyString")
10 |
11 |
12 | def test_json_dumps_omit_empty():
13 | # Test with a mix of None and non-None values
14 | data = {
15 | "a": 1,
16 | "b": None,
17 | "c": "test",
18 | "d": [1, 2, 3],
19 | "e": [{
20 | "f": 1,
21 | "g": None
22 | }]
23 | }
24 | expected_json = '{"a": 1, "c": "test", "d": [1, 2, 3], "e": [{"f": 1}]}'
25 | assert json_dumps_omit_empty(data) == expected_json
26 |
27 | # Test with all values as None
28 | data_all_none = {
29 | "a": None,
30 | "b": None
31 | }
32 | expected_json_all_none = '{}'
33 | assert json_dumps_omit_empty(data_all_none) == expected_json_all_none
34 |
35 | # Test with no None values
36 | data_no_none = {
37 | "a": 1,
38 | "b": "test"
39 | }
40 | expected_json_no_none = '{"a": 1, "b": "test"}'
41 | assert json_dumps_omit_empty(data_no_none) == expected_json_no_none
42 |
43 | # Test with empty dictionary
44 | data_empty = {}
45 | expected_json_empty = '{}'
46 | assert json_dumps_omit_empty(data_empty) == expected_json_empty
47 |
--------------------------------------------------------------------------------
/wdapy/__init__.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | from ._wdapy import (AppiumClient, AppiumUSBClient, NanoClient, NanoUSBClient)
5 |
6 | from wdapy import exceptions
7 | from wdapy import _types as types
8 | from wdapy._proto import *
9 | from wdapy._types import *
--------------------------------------------------------------------------------
/wdapy/_alert.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Tue Sep 14 2021 15:24:46 by codeskyblue
5 | """
6 |
7 | import logging
8 | import typing
9 | from wdapy.exceptions import RequestError
10 | from wdapy._proto import *
11 | from wdapy._base import BaseClient
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 | class Alert:
16 | def __init__(self, client: BaseClient):
17 | self._client = client
18 |
19 | @property
20 | def exists(self) -> bool:
21 | try:
22 | self.get_text()
23 | return True
24 | except RequestError:
25 | return False
26 |
27 | def buttons(self) -> typing.List[str]:
28 | return self._client.session_request(GET, "/wda/alert/buttons")["value"]
29 |
30 | def get_text(self) -> str:
31 | return self._client.session_request(GET, "/alert/text")["value"]
32 |
33 | def accept(self):
34 | return self._client.session_request(POST, "/alert/accept")
35 |
36 | def dismiss(self):
37 | return self._client.session_request(POST, "/alert/dismiss")
38 |
39 | def click(self, button_name: typing.Union[str, list]):
40 | if isinstance(button_name, str):
41 | self._client.session_request(POST, "/alert/accept", {"name": button_name})
42 | return
43 | elif isinstance(button_name, list):
44 | expect_buttons = button_name
45 | buttons = self.buttons()
46 | for name in expect_buttons:
47 | if name in buttons:
48 | return self.click(name)
49 | logger.debug("alert not clicked, buttons: %s, expect buttons: %s", buttons, expect_buttons)
--------------------------------------------------------------------------------
/wdapy/_base.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Tue Sep 14 2021 15:26:27 by codeskyblue
5 | """
6 |
7 | from http.client import HTTPConnection, HTTPSConnection
8 | import logging
9 | import typing
10 | from typing import Optional, Union
11 |
12 | import json
13 | from retry import retry
14 | from urllib.parse import urlparse
15 |
16 | from wdapy._proto import *
17 | from wdapy._types import Recover, StatusInfo
18 | from wdapy.exceptions import *
19 | from wdapy.usbmux.exceptions import MuxConnectError
20 | from wdapy.usbmux.pyusbmux import select_device
21 |
22 |
23 | logger = logging.getLogger(__name__)
24 |
25 | class HTTPResponseWrapper:
26 | def __init__(self, content: Union[bytes, bytearray], status_code: int):
27 | self.content = content
28 | self.status_code = status_code
29 |
30 | def json(self):
31 | return json.loads(self.content)
32 |
33 | @property
34 | def text(self) -> str:
35 | return self.content.decode("utf-8")
36 |
37 | def getcode(self) -> int:
38 | return self.status_code
39 |
40 |
41 | def http_create(url: str) -> typing.Union[HTTPConnection, HTTPSConnection]:
42 | u = urlparse(url)
43 | if u.scheme == "http+usbmux":
44 | udid, device_wda_port = u.netloc.split(":")
45 | device = select_device(udid)
46 | return device.make_http_connection(int(device_wda_port))
47 | elif u.scheme == "http":
48 | return HTTPConnection(u.netloc)
49 | elif u.scheme == "https":
50 | return HTTPSConnection(u.netloc)
51 | else:
52 | raise ValueError(f"unknown scheme: {u.scheme}")
53 |
54 |
55 | class BaseClient:
56 | def __init__(self, wda_url: str):
57 | self._wda_url = wda_url.rstrip("/") + "/"
58 |
59 | self._session_id: Optional[str] = None
60 | self._recover: Optional[Recover] = None
61 |
62 | self.__request_timeout = DEFAULT_HTTP_TIMEOUT
63 | self.__debug = False
64 |
65 | @property
66 | def debug(self) -> bool:
67 | return self.__debug
68 |
69 | @debug.setter
70 | def debug(self, v: bool):
71 | self.__debug = v
72 |
73 | @property
74 | def request_timeout(self) -> float:
75 | return self.__request_timeout
76 |
77 | @request_timeout.setter
78 | def request_timeout(self, timeout: float):
79 | self.__request_timeout = timeout
80 |
81 | def status(self) -> StatusInfo:
82 | data = self.request(GET, "/status")
83 | return StatusInfo.value_of(data)
84 |
85 | def session(self,
86 | bundle_id: Optional[str] = None,
87 | arguments: Optional[list] = None,
88 | environment: Optional[dict] = None) -> Optional[str]:
89 | """ create session and return session id """
90 | capabilities = {}
91 | if bundle_id:
92 | always_match = {
93 | "bundleId": bundle_id,
94 | "arguments": arguments or [],
95 | "environment": environment or {},
96 | "shouldWaitForQuiescence": False,
97 | }
98 | capabilities['alwaysMatch'] = always_match
99 | payload = {
100 | "capabilities": capabilities,
101 | "desiredCapabilities": capabilities.get('alwaysMatch',
102 | {}), # 兼容旧版的wda
103 | }
104 | data = self.request(POST, "/session", payload)
105 |
106 | # update cached session_id
107 | self._session_id = data['sessionId']
108 | return self._session_id
109 |
110 | def set_recover_handler(self, recover: Recover):
111 | self._recover = recover
112 |
113 | def _get_valid_session_id(self) -> Optional[str]:
114 | if self._session_id:
115 | return self._session_id
116 | old_session_id = self.status().session_id
117 | if old_session_id:
118 | self._session_id = old_session_id
119 | else:
120 | self._session_id = self.session()
121 | return self._session_id
122 |
123 | def session_request(self, method: RequestMethod, urlpath: str, payload: Optional[dict] = None) -> dict:
124 | """ request with session_id """
125 | session_id = self._get_valid_session_id()
126 | session_urlpath = f"/session/{session_id}/" + urlpath.lstrip("/")
127 | try:
128 | return self.request(method, session_urlpath, payload)
129 | except WDASessionDoesNotExist:
130 | # In some condition, session_id exist in /status, but not working
131 | # The bellow code fix that case
132 | logger.info("session %r does not exist, generate new one", session_id)
133 | session_id = self._session_id = self.session()
134 | session_urlpath = f"/session/{session_id}/" + urlpath.lstrip("/")
135 | return self.request(method, session_urlpath, payload)
136 |
137 | def request(self, method: RequestMethod, urlpath: str, payload: Optional[dict] = None) -> dict:
138 | """
139 | Raises:
140 | RequestError, WDASessionDoesNotExist
141 | """
142 | full_url = self._wda_url.rstrip("/") + "/" + urlpath.lstrip("/")
143 | payload_debug = payload or ""
144 | payload_debug = json.dumps(payload_debug, ensure_ascii=False)
145 | if self.debug:
146 | print(f"$ curl -X{method} --max-time {self.request_timeout:d} {full_url} -d {payload_debug!r}")
147 | resp = self._request_http(method, full_url, payload)
148 | try:
149 | short_json = resp.json().copy()
150 | except json.JSONDecodeError:
151 | raise RequestError("response is not json format", resp.text)
152 |
153 | for k, v in short_json.items():
154 | if isinstance(v, str) and len(v) > 40:
155 | v = v[:20] + "... skip ..." + v[-10:]
156 | short_json[k] = v
157 | if self.debug:
158 | print(f"==> Response <==\n{json.dumps(short_json, indent=4, ensure_ascii=False)}")
159 |
160 | value = resp.json().get("value")
161 | if value and isinstance(value, dict) and value.get("error"):
162 | raise ApiError(resp.status_code, value["error"], value.get("message"))
163 |
164 | return resp.json()
165 |
166 | @retry(RequestError, tries=2, delay=0.2, jitter=0.1, logger=logging)
167 | def _request_http(self, method: RequestMethod, url: str, payload: Optional[dict] = None, **kwargs) -> HTTPResponseWrapper:
168 | """
169 | Raises:
170 | RequestError, WDAFatalError
171 | WDASessionDoesNotExist
172 | """
173 | logger.info("request: %s %s %s", method, url, payload)
174 | try:
175 | conn = http_create(url)
176 | conn.timeout = kwargs.get("timeout", self.request_timeout)
177 | u = urlparse(url)
178 | urlpath = url[len(u.scheme) + len(u.netloc) + 3:]
179 |
180 | if not payload:
181 | conn.request(method.value, urlpath)
182 | else:
183 | conn.request(method.value, urlpath, json.dumps(payload), headers={"Content-Type": "application/json"})
184 | response = conn.getresponse()
185 | content = bytearray()
186 | while chunk := response.read(4096):
187 | content.extend(chunk)
188 | resp = HTTPResponseWrapper(content, response.status)
189 |
190 | if response.getcode() == 200:
191 | return resp
192 | else:
193 | # handle unexpected response
194 | if "Session does not exist" in resp.text:
195 | raise WDASessionDoesNotExist(resp.text)
196 | else:
197 | raise RequestError(f"response code: {response.getcode()}", resp.text)
198 | except MuxConnectError as err:
199 | if self._recover:
200 | if not self._recover.recover():
201 | raise WDAFatalError("recover failed")
202 | raise RequestError("ConnectionBroken", err)
203 |
204 |
--------------------------------------------------------------------------------
/wdapy/_proto.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | import enum
5 |
6 | NAME = "wdapy"
7 | DEFAULT_WDA_URL = "http://localhost:8100"
8 | DEFAULT_HTTP_TIMEOUT = 90
9 |
10 |
11 | class RequestMethod(str, enum.Enum):
12 | GET = "GET"
13 | POST = "POST"
14 |
15 |
16 | GET = RequestMethod.GET
17 | POST = RequestMethod.POST
18 |
19 |
20 | class AppState(enum.IntEnum):
21 | STOPPED = 1
22 | BACKGROUND = 2
23 | RUNNING = 4
24 |
25 |
26 | class Orientation(str, enum.Enum):
27 | LANDSCAPE = 'LANDSCAPE'
28 | PORTRAIT = 'PORTRAIT'
29 | LANDSCAPE_RIGHT = 'UIA_DEVICE_ORIENTATION_LANDSCAPERIGHT'
30 | PORTRAIT_UPSIDEDOWN = 'UIA_DEVICE_ORIENTATION_PORTRAIT_UPSIDEDOWN'
31 |
32 |
33 | class Keycode(str, enum.Enum):
34 | HOME = "home"
35 | VOLUME_UP = "volumeUp"
36 | VOLUME_DOWN = "volumeDown"
37 |
38 | # allow only for press_duration
39 | POWER = "power"
40 | SNAPSHOT = "snapshot"
41 | POWER_PLUS_HOME = "power_plus_home"
42 |
43 |
44 | class GestureAction(str, enum.Enum):
45 | TAP = "tap"
46 | PRESS = "press"
47 | MOVE_TO = "moveTo"
48 | WAIT = "wait"
49 | RELEASE = "release"
50 |
51 |
52 | class BatteryState(enum.IntEnum):
53 | Unknown = 0
54 | Unplugged = 1
55 | Charging = 2
56 | Full = 3
57 |
--------------------------------------------------------------------------------
/wdapy/_types.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 | __all__ = ["Recover", "StatusInfo", "AppInfo", "DeviceInfo", "BatteryInfo", "SourceTree",
4 | "StatusBarSize", "AppList"]
5 |
6 | import abc
7 | import enum
8 | import typing
9 | from dataclasses import dataclass
10 | from typing import Optional, Union
11 |
12 | from wdapy._proto import *
13 | from wdapy._utils import camel_to_snake
14 |
15 |
16 | def smart_value_of(obj, data: dict):
17 | for key, val in data.items():
18 | setattr(obj, key, val)
19 |
20 |
21 | class Recover(abc.ABC):
22 | @abc.abstractmethod
23 | def recover(self) -> bool:
24 | pass
25 |
26 |
27 | class _Base:
28 | def __init__(self):
29 | # set default value
30 | for k, _ in typing.get_type_hints(self).items():
31 | if not hasattr(self, k):
32 | setattr(self, k, None)
33 |
34 | def __repr__(self):
35 | attrs = []
36 | for k, v in self.__dict__.items():
37 | attrs.append(f"{k}={v!r}")
38 | return f"<{self.__class__.__name__} " + ", ".join(attrs) + ">"
39 |
40 | @classmethod
41 | def value_of(cls, data: dict):
42 | instance = cls()
43 | for k, v in data.items():
44 | key = camel_to_snake(k)
45 | if hasattr(instance, key):
46 | setattr(instance, key, v)
47 |
48 | return instance
49 |
50 |
51 | class AppInfo(_Base):
52 | name: str
53 | process_arguments: dict
54 | pid: int
55 | bundle_id: str
56 |
57 |
58 | class StatusInfo(_Base):
59 | ip: str
60 | session_id: str
61 | message: str
62 |
63 | @staticmethod
64 | def value_of(data: dict) -> "StatusInfo":
65 | info = StatusInfo()
66 | value = data['value']
67 | info.session_id = data['sessionId']
68 | info.ip = value['ios']['ip']
69 | info.message = value['message']
70 | return info
71 |
72 | class DeviceInfo(_Base):
73 | time_zone: str
74 | current_locale: str
75 | model: str
76 | uuid: str
77 | user_interface_idiom: int
78 | user_interface_style: str
79 | name: str
80 | is_simulator: bool
81 |
82 |
83 | class BatteryInfo(_Base):
84 | level: float
85 | state: BatteryState
86 |
87 | @staticmethod
88 | def value_of(data: dict) -> "BatteryInfo":
89 | info = BatteryInfo()
90 | info.level = data['level']
91 | info.state = BatteryState(data['state'])
92 | return info
93 |
94 |
95 | class SourceTree(_Base):
96 | value: str
97 | sessionId: str
98 |
99 | class StatusBarSize(_Base):
100 | width: int
101 | height: int
102 |
103 |
104 | class AppList(_Base):
105 | pid: int
106 | bundle_id: str
107 |
--------------------------------------------------------------------------------
/wdapy/_utils.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | from __future__ import annotations
5 |
6 | import dataclasses
7 | import json
8 |
9 |
10 | def camel_to_snake(s: str) -> str:
11 | return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip("_")
12 |
13 |
14 | def omit_empty(d: list | dict | dataclasses.dataclass | None):
15 | if isinstance(d, list):
16 | return [omit_empty(v) for v in d]
17 | elif isinstance(d, dict):
18 | return {k: omit_empty(v) for k, v in d.items() if v is not None}
19 | elif dataclasses.is_dataclass(d):
20 | return omit_empty(dataclasses.asdict(d))
21 | else:
22 | return d
23 |
24 |
25 | def json_dumps_omit_empty(data: dict) -> str:
26 | """
27 | Convert a dictionary to a JSON string, omitting any items with a value of None.
28 |
29 | Parameters:
30 | data (dict): The dictionary to convert to a JSON string.
31 |
32 | Returns:
33 | str: A JSON string representation of the dictionary with None values omitted.
34 | """
35 | return json.dumps(omit_empty(data))
--------------------------------------------------------------------------------
/wdapy/_wdapy.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | from __future__ import annotations
4 |
5 | import atexit
6 | import base64
7 | import io
8 | import logging
9 | import queue
10 | import subprocess
11 | import sys
12 | import threading
13 | import time
14 | import typing
15 |
16 | from typing import Optional
17 | from functools import cached_property
18 | from PIL import Image
19 |
20 | from wdapy._alert import Alert
21 | from wdapy._base import BaseClient
22 | from wdapy._proto import *
23 | from wdapy._types import *
24 | from wdapy._utils import omit_empty
25 |
26 | from wdapy.exceptions import *
27 | from wdapy.actions import TouchActionsClient
28 | from wdapy.usbmux.pyusbmux import list_devices
29 |
30 |
31 | logger = logging.getLogger(__name__)
32 |
33 | class CommonClient(BaseClient):
34 | def __init__(self, wda_url: str):
35 | super().__init__(wda_url)
36 | self.__ui_size = None
37 |
38 | def app_start(self, bundle_id: str, arguments: typing.List[str] = [], environment: typing.Dict[str, str] = {}):
39 | self.session_request(POST, "/wda/apps/launch", {
40 | "bundleId": bundle_id,
41 | "arguments": arguments,
42 | "environment": environment,
43 | })
44 |
45 | def app_terminate(self, bundle_id: str):
46 | self.session_request(POST, "/wda/apps/terminate", {
47 | "bundleId": bundle_id
48 | })
49 |
50 | def app_state(self, bundle_id: str) -> AppState:
51 | value = self.session_request(POST, "/wda/apps/state", {
52 | "bundleId": bundle_id
53 | })["value"]
54 | return AppState(value)
55 |
56 | def app_current(self) -> AppInfo:
57 | self.unlock()
58 | st = self.status()
59 | if st.session_id is None:
60 | self.session()
61 | data = self.request(GET, "/wda/activeAppInfo")
62 | value = data['value']
63 | return AppInfo.value_of(value)
64 |
65 | def app_list(self) -> AppList:
66 | value = self.session_request(GET, "/wda/apps/list")["value"][0]
67 | return AppList.value_of(value)
68 |
69 | def deactivate(self, duration: float):
70 | self.session_request(POST, "/wda/deactivateApp", {
71 | "duration": duration
72 | })
73 |
74 | @cached_property
75 | def alert(self) -> Alert:
76 | return Alert(self)
77 |
78 | def sourcetree(self) -> SourceTree:
79 | data = self.request(GET, "/source")
80 | return SourceTree.value_of(data)
81 |
82 | def open_url(self, url: str):
83 | self.session_request(POST, "/url", {
84 | "url": url
85 | })
86 |
87 | def set_clipboard(self, content: str, content_type="plaintext"):
88 | """ only works when WDA app is foreground """
89 | self.session_request(POST, "/wda/setPasteboard",{
90 | "content": base64.b64encode(content.encode()).decode(),
91 | "contentType": content_type
92 | })
93 |
94 | def get_clipboard(self, content_type="plaintext") -> str:
95 | data = self.session_request(POST, "/wda/getPasteboard",{
96 | "contentType": content_type
97 | })
98 | return base64.b64decode(data['value']).decode('utf-8')
99 |
100 | def appium_settings(self, kwargs: dict = None) -> dict:
101 | if kwargs is None:
102 | return self.session_request(GET, "/appium/settings")["value"]
103 | payload = {"settings": kwargs}
104 | return self.session_request(POST, "/appium/settings", payload)["value"]
105 |
106 | def is_locked(self) -> bool:
107 | return self.request(GET, "/wda/locked")["value"]
108 |
109 | def unlock(self):
110 | self.request(POST, "/wda/unlock")
111 |
112 | def lock(self):
113 | self.request(POST, "/wda/lock")
114 |
115 | def homescreen(self):
116 | self.request(POST, "/wda/homescreen")
117 |
118 | def shutdown(self):
119 | self.request(GET, "/wda/shutdown")
120 |
121 | def get_orientation(self) -> Orientation:
122 | value = self.session_request(GET, '/orientation')['value']
123 | return Orientation(value)
124 |
125 | def window_size(self) -> typing.Tuple[int, int]:
126 | """
127 | Returns:
128 | UISize
129 |
130 | Ref:
131 | FBElementCommands.m
132 | """
133 | data = self.session_request(GET, "/window/size")
134 | return data['value']['width'], data['value']['height']
135 |
136 | # 代码暂时保留,下面的方法为通过截图获取屏幕大小
137 | # # 这里做了一点速度优化,跟进图像大小获取屏幕尺寸
138 | # orientation = self.get_orientation()
139 | # if self.__ui_size is None:
140 | # # 这里认为screenshot返回的屏幕转向时正确的
141 | # pixel_width, pixel_height = self.screenshot().size
142 | # w, h = pixel_width//self.scale, pixel_height//self.scale
143 | # if self.get_orientation() == Orientation.PORTRAIT:
144 | # self.__ui_size = (w, h)
145 | # else:
146 | # self.__ui_size = (h, w)
147 |
148 | # if orientation == Orientation.LANDSCAPE:
149 | # return self.__ui_size[::-1]
150 | # else:
151 | # return self.__ui_size
152 |
153 | def send_keys(self, value: str):
154 | """ input with some text """
155 | self.session_request(POST, "/wda/keys", {"value": list(value)})
156 |
157 | def tap(self, x: int, y: int):
158 | try:
159 | self.session_request(POST, "/wda/tap", {"x": x, "y": y})
160 | except RequestError:
161 | self.session_request(POST, "/wda/tap/0", {"x": x, "y": y})
162 |
163 | def touch_and_hold(self, x: int, y: int, duration: float):
164 | """ touch and hold
165 |
166 | Ref:
167 | FBElementCommands.m
168 | """
169 | self.session_request(POST, "/wda/touchAndHold", {"x": x, "y": y, "duration": duration})
170 |
171 | def swipe(self,
172 | from_x: int,
173 | from_y: int,
174 | to_x: int,
175 | to_y: int,
176 | duration: float = 0.5):
177 | payload = {
178 | "fromX": from_x,
179 | "fromY": from_y,
180 | "toX": to_x,
181 | "toY": to_y,
182 | "duration": duration}
183 | self.session_request(POST, "/wda/dragfromtoforduration", payload)
184 |
185 | def press(self, name: Keycode):
186 | payload = {
187 | "name": name
188 | }
189 | self.session_request(POST, "/wda/pressButton", payload)
190 |
191 | def press_duration(self, name: Keycode, duration: float):
192 | hid_usages = {
193 | "home": 0x40,
194 | "volumeup": 0xE9,
195 | "volumedown": 0xEA,
196 | "power": 0x30,
197 | "snapshot": 0x65,
198 | "power_plus_home": 0x65
199 | }
200 | name = name.lower()
201 | if name not in hid_usages:
202 | raise ValueError("Invalid name:", name)
203 | hid_usages = hid_usages[name]
204 | payload = {
205 | "page": 0x0C,
206 | "usage": hid_usages,
207 | "duration": duration
208 | }
209 | return self.session_request(POST, "/wda/performIoHidEvent", payload)
210 |
211 | def volume_up(self):
212 | self.press(Keycode.VOLUME_UP)
213 |
214 | def volume_down(self):
215 | self.press(Keycode.VOLUME_DOWN)
216 |
217 | @cached_property
218 | def scale(self) -> int:
219 | # Response example
220 | # {"statusBarSize": {'width': 320, 'height': 20}, 'scale': 2}
221 | value = self.session_request(GET, "/wda/screen")['value']
222 | return value['scale']
223 |
224 | def status_barsize(self) -> StatusBarSize:
225 | # Response example
226 | # {"statusBarSize": {'width': 320, 'height': 20}, 'scale': 2}
227 | value = self.session_request(GET, "/wda/screen")['value']
228 | return StatusBarSize.value_of(value['statusBarSize'])
229 |
230 | def screenshot(self) -> Image.Image:
231 | """ take screenshot """
232 | value = self.request(GET, "/screenshot")["value"]
233 | raw_value = base64.b64decode(value)
234 | buf = io.BytesIO(raw_value)
235 | im = Image.open(buf)
236 | return im.convert("RGB")
237 |
238 | def battery_info(self) -> BatteryInfo:
239 | data = self.session_request(GET, "/wda/batteryInfo")["value"]
240 | return BatteryInfo.value_of(data)
241 |
242 | @property
243 | def info(self) -> DeviceInfo:
244 | return self.device_info()
245 |
246 | def device_info(self) -> DeviceInfo:
247 | data = self.session_request(GET, "/wda/device/info")["value"]
248 | return DeviceInfo.value_of(data)
249 |
250 | def keyboard_dismiss(self, key_names: typing.List[str] = ["前往", "发送", "Send", "Done", "Return"]):
251 | """ dismiss keyboard
252 | 相当于通过点击键盘上的按钮来关闭键盘
253 |
254 | Args:
255 | key_names: list of keys to tap to dismiss keyboard
256 | """
257 | self.session_request(POST, "/wda/keyboard/dismiss", {"keyNames": key_names})
258 |
259 |
260 | class XCUITestRecover(Recover):
261 | def __init__(self, udid: str):
262 | self._udid = udid
263 |
264 | def recover(self) -> bool:
265 | """ launch by tidevice
266 |
267 | https://github.com/alibaba/tidevice
268 | """
269 | logger.info("WDA is starting using tidevice ...")
270 | args = [sys.executable, '-m', 'tidevice', '-u', self._udid, 'xctest']
271 | p = subprocess.Popen(args,
272 | stdin=subprocess.DEVNULL,
273 | stdout=subprocess.PIPE,
274 | stderr=subprocess.STDOUT,
275 | start_new_session=True,
276 | close_fds=True, encoding="utf-8")
277 |
278 | que = queue.Queue()
279 | threading.Thread(target=self.drain_process_output, args=(p, que), daemon=True).start()
280 | try:
281 | success = que.get(timeout=20)
282 | return success
283 | except queue.Empty:
284 | logger.warning("WDA launch timeout 20s")
285 | p.kill()
286 | return False
287 |
288 | def drain_process_output(self, p: subprocess.Popen, msg_queue: queue.Queue):
289 | deadline = time.time() + 10
290 | lines = []
291 | while time.time() < deadline:
292 | if p.poll() is not None:
293 | logger.warning("xctest exited, output --.\n %s", "\n".join(lines)) # p.stdout.read())
294 | msg_queue.put(False)
295 | return
296 | line = p.stdout.readline().strip()
297 | lines.append(line)
298 | # logger.info("%s", line)
299 | if "WebDriverAgent start successfully" in line:
300 | logger.info("WDA started")
301 | msg_queue.put(True)
302 | break
303 |
304 | atexit.register(p.terminate)
305 | while p.stdout.read() != "":
306 | pass
307 |
308 |
309 | class AppiumClient(CommonClient, TouchActionsClient):
310 | """
311 | client for https://github.com/appium/WebDriverAgent
312 | """
313 |
314 | def __init__(self, wda_url: str = DEFAULT_WDA_URL):
315 | super().__init__(wda_url)
316 |
317 |
318 | def get_single_device_udid() -> str:
319 | devices = list_devices()
320 | if len(devices) == 0:
321 | raise WDAException("No device connected")
322 | if len(devices) > 1:
323 | raise WDAException("More than one device connected")
324 | return devices[0].serial
325 |
326 |
327 | class AppiumUSBClient(AppiumClient):
328 | def __init__(self, udid: Optional[str] = None, port: int = 8100):
329 | if udid is None:
330 | udid = get_single_device_udid()
331 | super().__init__(f"http+usbmux://{udid}:{port}")
332 | self.set_recover_handler(XCUITestRecover(udid))
333 |
334 |
335 | class NanoClient(AppiumClient):
336 | """
337 | Repo: https://github.com/nanoscopic/WebDriverAgent
338 |
339 | This repo changes a lot recently and the new version code drop the HTTP API to NNG
340 | So here use the old commit version
341 | https://github.com/nanoscopic/WebDriverAgent/tree/d07372d73a4cc4dc0b0d7807271e6d7958e57302
342 | """
343 |
344 | def tap(self, x: int, y: int):
345 | """ fast tap """
346 | self.request(POST, "/wda/tap", {
347 | "x": x,
348 | "y": y,
349 | })
350 |
351 | def fast_swipe(self,
352 | from_x: int,
353 | from_y: int,
354 | to_x: int,
355 | to_y: int,
356 | duration: float = .5):
357 | """ fast swipe, this method can not simulate back action by swipe left to right """
358 | self.request(POST, "/wda/swipe", {
359 | "x1": from_x,
360 | "y1": from_y,
361 | "x2": to_x,
362 | "y2": to_y,
363 | "delay": duration})
364 |
365 |
366 | class NanoUSBClient(NanoClient):
367 | def __init__(self, udid: Optional[str] = None, port: int = 8100):
368 | if udid is None:
369 | udid = get_single_device_udid()
370 | super().__init__(f"http+usbmux://{udid}:{port}")
371 | self.set_recover_handler(XCUITestRecover(udid))
372 |
--------------------------------------------------------------------------------
/wdapy/_wrap.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | import typing
5 |
6 |
7 | class Wrapper:
8 | """
9 | MetaClass ref:
10 | https://www.jianshu.com/p/224ffcb8e73e
11 | """
12 |
13 | def __init__(self):
14 | self._request_timeout = None
15 |
16 |
17 | def timeout(seconds: typing.Union[float, int]):
18 | def _timeout_wrapper(fn):
19 | def _inner(self, *args, **kwargs):
20 | old_timeout = self._request_timeout
21 | try:
22 | return fn(self, *args, **kwargs)
23 | finally:
24 | self._request_timeout = old_timeout
25 | return _inner
26 | return _timeout_wrapper
27 |
--------------------------------------------------------------------------------
/wdapy/actions.py:
--------------------------------------------------------------------------------
1 | """
2 | See
3 | - https://www.w3.org/TR/webdriver/#actions
4 | - https://appium.github.io/appium.io/docs/en/commands/interactions/actions/#http-api-specifications
5 |
6 | Usage example:
7 | # 起点142, 240 画一个长度为100的正方形
8 | finger1 = TouchActions.pointer("finger1", actions=[
9 | PointerAction.move(142, 240, origin=Origin.VIEWPORT),
10 | PointerAction.down(),
11 | PointerAction.pause(1000),
12 | PointerAction.move(0, 100, origin=Origin.POINTER),
13 | PointerAction.pause(1000),
14 | PointerAction.move(100, 0, origin=Origin.POINTER),
15 | PointerAction.pause(1000),
16 | PointerAction.move(0, -100, origin=Origin.POINTER),
17 | PointerAction.pause(1000),
18 | PointerAction.move(-100, 0, origin=Origin.POINTER),
19 | PointerAction.up(),
20 | ])
21 | client.perform_actions([finger1])
22 | """
23 | __all__ = [
24 | "TouchActionType",
25 | "PointerActionType",
26 | "KeyActionType",
27 | "PointerType",
28 | "Origin",
29 | "PointerAction",
30 | "KeyAction",
31 | "Parameters",
32 | "TouchActions",
33 | ]
34 |
35 | from enum import Enum
36 |
37 | from typing import List, Optional, Union
38 | from pydantic import BaseModel
39 |
40 | from ._proto import POST
41 | from ._base import BaseClient
42 |
43 | class TouchActionType(str, Enum):
44 | POINTER = "pointer"
45 | KEY = "key"
46 | NULL = "null"
47 |
48 |
49 | class PointerActionType(str, Enum):
50 | POINTER_MOVE = "pointerMove"
51 | POINTER_DOWN = "pointerDown"
52 | POINTER_UP = "pointerUp"
53 | PAUSE = "pause"
54 |
55 | class KeyActionType(str, Enum):
56 | KEY_DOWN = "keyDown"
57 | KEY_UP = "keyUp"
58 |
59 | class PointerType(str, Enum):
60 | TOUCH = "touch"
61 | MOUSE = "mouse"
62 | PEN = "pen"
63 |
64 | class Origin(str, Enum):
65 | VIEWPORT = "viewport"
66 | POINTER = "pointer"
67 | # or: {'element-6066-11e4-a52e-4f735466cecf': ''} # not tested
68 |
69 |
70 | class PointerAction(BaseModel):
71 | type: PointerActionType
72 | duration: Optional[int] = None
73 | x: Optional[int] = None
74 | y: Optional[int] = None
75 | origin: Optional[Origin] = None
76 | button: Optional[int] = None
77 |
78 | @staticmethod
79 | def move(x: int, y: int, *, duration: int=100, origin: Origin = Origin.VIEWPORT) -> "PointerAction":
80 | """
81 | Move pointer to (x, y) with duration(ms)
82 |
83 | Args:
84 | x: x coordinate
85 | y: y coordinate
86 | duration: duration(ms) default 100
87 | origin: pointer(relative to last position) or viewport(relative to (0, 0))
88 | """
89 | # pointerMove $x, $y, $duration
90 | # same as
91 | # pause $duration
92 | # pointerMove $x, $y, duration=0
93 | return PointerAction(type=PointerActionType.POINTER_MOVE, x=x, y=y, duration=duration, origin=origin)
94 |
95 | @staticmethod
96 | def down() -> "PointerAction":
97 | # button=0 # left
98 | return PointerAction(type=PointerActionType.POINTER_DOWN, button=0)
99 |
100 | @staticmethod
101 | def up() -> "PointerAction":
102 | return PointerAction(type=PointerActionType.POINTER_UP, button=0)
103 |
104 | @staticmethod
105 | def pause(duration: int) -> "PointerAction":
106 | return PointerAction(type=PointerActionType.PAUSE, duration=duration)
107 |
108 |
109 | class KeyAction(BaseModel):
110 | type: KeyActionType
111 | value: str
112 |
113 | @staticmethod
114 | def down(value: str) -> "KeyAction":
115 | return KeyAction(type=KeyActionType.KEY_DOWN, value=value)
116 |
117 | @staticmethod
118 | def up(value: str) -> "KeyAction":
119 | return KeyAction(type=KeyActionType.KEY_UP, value=value)
120 |
121 |
122 | class Parameters(BaseModel):
123 | pointerType: PointerType
124 |
125 |
126 | class TouchActions(BaseModel):
127 | type: TouchActionType
128 | id: str
129 | parameters: Optional[Parameters] = None
130 | actions: Union[List[PointerAction], List[KeyAction]]
131 |
132 | @staticmethod
133 | def pointer(id: str, actions: List[PointerAction], pointer_type: PointerType = PointerType.TOUCH) -> "TouchActions":
134 | return TouchActions(
135 | type=TouchActionType.POINTER,
136 | id=id,
137 | parameters=Parameters(pointerType=pointer_type),
138 | actions=actions
139 | )
140 |
141 | @staticmethod
142 | def key(id: str, actions: List[KeyAction]) -> "TouchActions":
143 | return TouchActions(type=TouchActionType.KEY, id=id, actions=actions)
144 |
145 | @staticmethod
146 | def null(id: str) -> "TouchActions":
147 | return TouchActions(type=TouchActionType.NULL, id=id, actions=[])
148 |
149 |
150 | class TouchActionsClient(BaseClient):
151 | def perform_actions(self, actions: List[TouchActions]):
152 | self.session_request(POST, "/actions", {"actions": [a.model_dump(exclude_none=True) for a in actions]})
--------------------------------------------------------------------------------
/wdapy/exceptions.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | #
3 |
4 | class WDAException(Exception):
5 | pass
6 |
7 |
8 | class RequestError(WDAException):
9 | pass
10 |
11 |
12 | class WDASessionDoesNotExist(WDAException):
13 | """ Session does not exist """
14 |
15 |
16 | class ApiError(RequestError):
17 | """ request error, but with formated data """
18 |
19 |
20 | class WDAFatalError(RequestError):
21 | """ unrecoverable error """
--------------------------------------------------------------------------------
/wdapy/usbmux/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Thu Dec 09 2021 09:56:30 by codeskyblue
5 | """
6 |
7 | from wdapy.usbmux.exceptions import MuxError, MuxConnectError
--------------------------------------------------------------------------------
/wdapy/usbmux/exceptions.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Tue Mar 05 2024 10:18:09 by codeskyblue
5 |
6 | Copy from https://github.com/doronz88/pymobiledevice3
7 | """
8 |
9 | class NotPairedError(Exception):
10 | pass
11 |
12 |
13 | class MuxError(Exception):
14 | pass
15 |
16 |
17 | class MuxVersionError(MuxError):
18 | pass
19 |
20 |
21 | class BadCommandError(MuxError):
22 | pass
23 |
24 |
25 | class BadDevError(MuxError):
26 | pass
27 |
28 |
29 | class MuxConnectError(MuxError):
30 | pass
31 |
32 |
33 | class MuxConnectToUsbmuxdError(MuxConnectError):
34 | pass
35 |
36 |
37 | class ArgumentError(Exception):
38 | pass
39 |
--------------------------------------------------------------------------------
/wdapy/usbmux/pyusbmux.py:
--------------------------------------------------------------------------------
1 | """
2 | Copy from https://github.com/doronz88/pymobiledevice3
3 |
4 | Add http.client.HTTPConnection
5 | """
6 | import abc
7 | import plistlib
8 | import socket
9 | import sys
10 | import time
11 | from dataclasses import dataclass
12 | from http.client import HTTPConnection
13 | from typing import List, Mapping, Optional
14 |
15 | from construct import Const, CString, Enum, FixedSized, GreedyBytes, Int16ul, Int32ul, Padding, Prefixed, StreamError, \
16 | Struct, Switch, this
17 |
18 | from wdapy.usbmux.exceptions import BadCommandError, BadDevError, MuxConnectError, \
19 | MuxConnectToUsbmuxdError, MuxError, MuxVersionError, NotPairedError
20 |
21 | usbmuxd_version = Enum(Int32ul,
22 | BINARY=0,
23 | PLIST=1,
24 | )
25 |
26 | usbmuxd_result = Enum(Int32ul,
27 | OK=0,
28 | BADCOMMAND=1,
29 | BADDEV=2,
30 | CONNREFUSED=3,
31 | BADVERSION=6,
32 | )
33 |
34 | usbmuxd_msgtype = Enum(Int32ul,
35 | RESULT=1,
36 | CONNECT=2,
37 | LISTEN=3,
38 | ADD=4,
39 | REMOVE=5,
40 | PAIRED=6,
41 | PLIST=8,
42 | )
43 |
44 | usbmuxd_header = Struct(
45 | 'version' / usbmuxd_version, # protocol version
46 | 'message' / usbmuxd_msgtype, # message type
47 | 'tag' / Int32ul, # responses to this query will echo back this tag
48 | )
49 |
50 | usbmuxd_request = Prefixed(Int32ul, Struct(
51 | 'header' / usbmuxd_header,
52 | 'data' / Switch(this.header.message, {
53 | usbmuxd_msgtype.CONNECT: Struct(
54 | 'device_id' / Int32ul,
55 | 'port' / Int16ul, # TCP port number
56 | 'reserved' / Const(0, Int16ul),
57 | ),
58 | usbmuxd_msgtype.PLIST: GreedyBytes,
59 | }),
60 | ), includelength=True)
61 |
62 | usbmuxd_device_record = Struct(
63 | 'device_id' / Int32ul,
64 | 'product_id' / Int16ul,
65 | 'serial_number' / FixedSized(256, CString('ascii')),
66 | Padding(2),
67 | 'location' / Int32ul
68 | )
69 |
70 | usbmuxd_response = Prefixed(Int32ul, Struct(
71 | 'header' / usbmuxd_header,
72 | 'data' / Switch(this.header.message, {
73 | usbmuxd_msgtype.RESULT: Struct(
74 | 'result' / usbmuxd_result,
75 | ),
76 | usbmuxd_msgtype.ADD: usbmuxd_device_record,
77 | usbmuxd_msgtype.REMOVE: Struct(
78 | 'device_id' / Int32ul,
79 | ),
80 | usbmuxd_msgtype.PLIST: GreedyBytes,
81 | }),
82 | ), includelength=True)
83 |
84 |
85 |
86 |
87 | @dataclass
88 | class MuxDevice:
89 | devid: int
90 | serial: str
91 | connection_type: str
92 |
93 | def connect(self, port: int, usbmux_address: Optional[str] = None) -> socket.socket:
94 | mux = create_mux(usbmux_address=usbmux_address)
95 | try:
96 | return mux.connect(self, port)
97 | except: # noqa: E722
98 | mux.close()
99 | raise
100 |
101 | @property
102 | def is_usb(self) -> bool:
103 | return self.connection_type == 'USB'
104 |
105 | @property
106 | def is_network(self) -> bool:
107 | return self.connection_type == 'Network'
108 |
109 | def matches_udid(self, udid: str) -> bool:
110 | return self.serial.replace('-', '') == udid.replace('-', '')
111 |
112 | def make_http_connection(self, port: int) -> HTTPConnection:
113 | return USBMuxHTTPConnection(self, port)
114 |
115 |
116 | class SafeStreamSocket:
117 | """ wrapper to native python socket object to be used with construct as a stream """
118 |
119 | def __init__(self, address, family):
120 | self._offset = 0
121 | self.sock = socket.socket(family, socket.SOCK_STREAM)
122 | self.sock.connect(address)
123 |
124 | def send(self, msg: bytes) -> int:
125 | self._offset += len(msg)
126 | self.sock.sendall(msg)
127 | return len(msg)
128 |
129 | def recv(self, size: int) -> bytes:
130 | msg = b''
131 | while len(msg) < size:
132 | chunk = self.sock.recv(size - len(msg))
133 | self._offset += len(chunk)
134 | if not chunk:
135 | raise MuxError('socket connection broken')
136 | msg += chunk
137 | return msg
138 |
139 | def close(self) -> None:
140 | self.sock.close()
141 |
142 | def settimeout(self, interval: float) -> None:
143 | self.sock.settimeout(interval)
144 |
145 | def setblocking(self, blocking: bool) -> None:
146 | self.sock.setblocking(blocking)
147 |
148 | def tell(self) -> int:
149 | return self._offset
150 |
151 | read = recv
152 | write = send
153 |
154 |
155 | class MuxConnection:
156 | # used on Windows
157 | ITUNES_HOST = ('127.0.0.1', 27015)
158 |
159 | # used for macOS and Linux
160 | USBMUXD_PIPE = '/var/run/usbmuxd'
161 |
162 | @staticmethod
163 | def create_usbmux_socket(usbmux_address: Optional[str] = None) -> SafeStreamSocket:
164 | try:
165 | if usbmux_address is not None:
166 | if ':' in usbmux_address:
167 | # assume tcp address
168 | hostname, port = usbmux_address.split(':')
169 | port = int(port)
170 | address = (hostname, port)
171 | family = socket.AF_INET
172 | else:
173 | # assume unix domain address
174 | address = usbmux_address
175 | family = socket.AF_UNIX
176 | else:
177 | if sys.platform in ['win32', 'cygwin']:
178 | address = MuxConnection.ITUNES_HOST
179 | family = socket.AF_INET
180 | else:
181 | address = MuxConnection.USBMUXD_PIPE
182 | family = socket.AF_UNIX
183 | return SafeStreamSocket(address, family)
184 | except ConnectionRefusedError:
185 | raise MuxConnectToUsbmuxdError()
186 |
187 | @staticmethod
188 | def create(usbmux_address: Optional[str] = None):
189 | # first attempt to connect with possibly the wrong version header (plist protocol)
190 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address)
191 |
192 | message = usbmuxd_request.build({
193 | 'header': {'version': usbmuxd_version.PLIST, 'message': usbmuxd_msgtype.PLIST, 'tag': 1},
194 | 'data': plistlib.dumps({'MessageType': 'ReadBUID'})
195 | })
196 | sock.send(message)
197 | response = usbmuxd_response.parse_stream(sock)
198 |
199 | # if we sent a bad request, we should re-create the socket in the correct version this time
200 | sock.close()
201 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address)
202 |
203 | if response.header.version == usbmuxd_version.BINARY:
204 | return BinaryMuxConnection(sock)
205 | elif response.header.version == usbmuxd_version.PLIST:
206 | return PlistMuxConnection(sock)
207 |
208 | raise MuxVersionError(f'usbmuxd returned unsupported version: {response.version}')
209 |
210 | def __init__(self, sock: SafeStreamSocket):
211 | self._sock = sock
212 |
213 | # after initiating the "Connect" packet, this same socket will be used to transfer data into the service
214 | # residing inside the target device. when this happens, we can no longer send/receive control commands to
215 | # usbmux on same socket
216 | self._connected = False
217 |
218 | # message sequence number. used when verifying the response matched the request
219 | self._tag = 1
220 |
221 | self.devices = []
222 |
223 | @abc.abstractmethod
224 | def _connect(self, device_id: int, port: int):
225 | """ initiate a "Connect" request to target port """
226 | pass
227 |
228 | @abc.abstractmethod
229 | def get_device_list(self, timeout: float = None):
230 | """
231 | request an update to current device list
232 | """
233 | pass
234 |
235 | def connect(self, device: MuxDevice, port: int) -> socket.socket:
236 | """ connect to a relay port on target machine and get a raw python socket object for the connection """
237 | self._connect(device.devid, socket.htons(port))
238 | self._connected = True
239 | return self._sock.sock
240 |
241 | def close(self):
242 | """ close current socket """
243 | self._sock.close()
244 |
245 | def _assert_not_connected(self):
246 | """ verify active state is in state for control messages """
247 | if self._connected:
248 | raise MuxError('Mux is connected, cannot issue control packets')
249 |
250 | def _raise_mux_exception(self, result: int, message: str = None):
251 | exceptions = {
252 | int(usbmuxd_result.BADCOMMAND): BadCommandError,
253 | int(usbmuxd_result.BADDEV): BadDevError,
254 | int(usbmuxd_result.CONNREFUSED): MuxConnectError,
255 | int(usbmuxd_result.BADVERSION): MuxVersionError,
256 | }
257 | exception = exceptions.get(result, MuxError)
258 | raise exception(message)
259 |
260 | def __enter__(self):
261 | return self
262 |
263 | def __exit__(self, exc_type, exc_val, exc_tb):
264 | self.close()
265 |
266 |
267 | class BinaryMuxConnection(MuxConnection):
268 | """ old binary protocol """
269 |
270 | def __init__(self, sock: SafeStreamSocket):
271 | super().__init__(sock)
272 | self._version = usbmuxd_version.BINARY
273 |
274 | def get_device_list(self, timeout: float = None):
275 | """ use timeout to wait for the device list to be fully populated """
276 | self._assert_not_connected()
277 | end = time.time() + timeout
278 | self.listen()
279 | while time.time() < end:
280 | self._sock.settimeout(end - time.time())
281 | try:
282 | self._receive_device_state_update()
283 | except (BlockingIOError, StreamError):
284 | continue
285 | except IOError:
286 | try:
287 | self._sock.setblocking(True)
288 | self.close()
289 | except OSError:
290 | pass
291 | raise MuxError('Exception in listener socket')
292 |
293 | def listen(self):
294 | """ start listening for events of attached and detached devices """
295 | self._send_receive(usbmuxd_msgtype.LISTEN)
296 |
297 | def _connect(self, device_id: int, port: int):
298 | self._send({'header': {'version': self._version,
299 | 'message': usbmuxd_msgtype.CONNECT,
300 | 'tag': self._tag},
301 | 'data': {'device_id': device_id, 'port': port},
302 | })
303 | response = self._receive()
304 | if response.header.message != usbmuxd_msgtype.RESULT:
305 | raise MuxError(f'unexpected message type received: {response}')
306 |
307 | if response.data.result != usbmuxd_result.OK:
308 | raise self._raise_mux_exception(int(response.data.result),
309 | f'failed to connect to device: {device_id} at port: {port}. reason: '
310 | f'{response.data.result}')
311 |
312 | def _send(self, data: Mapping):
313 | self._assert_not_connected()
314 | self._sock.send(usbmuxd_request.build(data))
315 | self._tag += 1
316 |
317 | def _receive(self, expected_tag: Optional[int] = None):
318 | self._assert_not_connected()
319 | response = usbmuxd_response.parse_stream(self._sock)
320 | if expected_tag and response.header.tag != expected_tag:
321 | raise MuxError(f'Reply tag mismatch: expected {expected_tag}, got {response.header.tag}')
322 | return response
323 |
324 | def _send_receive(self, message_type: int):
325 | self._send({'header': {'version': self._version, 'message': message_type, 'tag': self._tag},
326 | 'data': b''})
327 | response = self._receive(self._tag - 1)
328 | if response.header.message != usbmuxd_msgtype.RESULT:
329 | raise MuxError(f'unexpected message type received: {response}')
330 |
331 | result = response.data.result
332 | if result != usbmuxd_result.OK:
333 | raise self._raise_mux_exception(int(result), f'{message_type} failed: error {result}')
334 |
335 | def _add_device(self, device: MuxDevice):
336 | self.devices.append(device)
337 |
338 | def _remove_device(self, device_id: int):
339 | self.devices = [device for device in self.devices if device.devid != device_id]
340 |
341 | def _receive_device_state_update(self):
342 | response = self._receive()
343 | if response.header.message == usbmuxd_msgtype.ADD:
344 | # old protocol only supported USB devices
345 | self._add_device(MuxDevice(response.data.device_id, response.data.serial_number, 'USB'))
346 | elif response.header.message == usbmuxd_msgtype.REMOVE:
347 | self._remove_device(response.data.device_id)
348 | else:
349 | raise MuxError(f'Invalid packet type received: {response}')
350 |
351 |
352 | class PlistMuxConnection(BinaryMuxConnection):
353 | def __init__(self, sock: SafeStreamSocket):
354 | super().__init__(sock)
355 | self._version = usbmuxd_version.PLIST
356 |
357 | def listen(self) -> None:
358 | self._send_receive({'MessageType': 'Listen'})
359 |
360 | def get_pair_record(self, serial: str) -> Mapping:
361 | # serials are saved inside usbmuxd without '-'
362 | self._send({'MessageType': 'ReadPairRecord', 'PairRecordID': serial})
363 | response = self._receive(self._tag - 1)
364 | pair_record = response.get('PairRecordData')
365 | if pair_record is None:
366 | raise NotPairedError('device should be paired first')
367 | return plistlib.loads(pair_record)
368 |
369 | def get_device_list(self, timeout: float = None) -> None:
370 | """ get device list synchronously without waiting the timeout """
371 | self.devices = []
372 | self._send_receive({'MessageType': 'Listen'})
373 | end = time.time() + timeout
374 | self._sock.settimeout(timeout)
375 | while time.time() < end:
376 | try:
377 | response = self._receive()
378 | if response['MessageType'] == 'Attached':
379 | super()._add_device(MuxDevice(response['DeviceID'], response['Properties']['SerialNumber'],
380 | response['Properties']['ConnectionType']))
381 | elif response['MessageType'] == 'Detached':
382 | super()._remove_device(response['DeviceID'])
383 | else:
384 | raise MuxError(f'Invalid packet type received: {response}')
385 | except (BlockingIOError, StreamError):
386 | continue
387 | except IOError:
388 | try:
389 | self._sock.setblocking(True)
390 | self.close()
391 | except OSError:
392 | pass
393 | raise MuxError('Exception in listener socket')
394 |
395 |
396 | def get_buid(self) -> str:
397 | """ get SystemBUID """
398 | self._send({'MessageType': 'ReadBUID'})
399 | return self._receive(self._tag - 1)['BUID']
400 |
401 | def save_pair_record(self, serial: str, device_id: int, record_data: bytes):
402 | # serials are saved inside usbmuxd without '-'
403 | self._send_receive({'MessageType': 'SavePairRecord',
404 | 'PairRecordID': serial,
405 | 'PairRecordData': record_data,
406 | 'DeviceID': device_id})
407 |
408 | def _connect(self, device_id: int, port: int):
409 | self._send_receive({'MessageType': 'Connect', 'DeviceID': device_id, 'PortNumber': port})
410 |
411 | def _send(self, data: Mapping):
412 | request = {'ClientVersionString': 'qt4i-usbmuxd', 'ProgName': 'pymobiledevice3', 'kLibUSBMuxVersion': 3}
413 | request.update(data)
414 | super()._send({'header': {'version': self._version,
415 | 'message': usbmuxd_msgtype.PLIST,
416 | 'tag': self._tag},
417 | 'data': plistlib.dumps(request),
418 | })
419 |
420 | def _receive(self, expected_tag: Optional[int] = None) -> Mapping:
421 | response = super()._receive(expected_tag=expected_tag)
422 | if response.header.message != usbmuxd_msgtype.PLIST:
423 | raise MuxError(f'Received non-plist type {response}')
424 | return plistlib.loads(response.data)
425 |
426 | def _send_receive(self, data: Mapping):
427 | self._send(data)
428 | response = self._receive(self._tag - 1)
429 | if response['MessageType'] != 'Result':
430 | raise MuxError(f'got an invalid message: {response}')
431 | if response['Number'] != 0:
432 | raise self._raise_mux_exception(response['Number'], f'got an error message: {response}')
433 |
434 |
435 | def create_mux(usbmux_address: Optional[str] = None) -> MuxConnection:
436 | return MuxConnection.create(usbmux_address=usbmux_address)
437 |
438 |
439 | def list_devices(usbmux_address: Optional[str] = None) -> List[MuxDevice]:
440 | mux = create_mux(usbmux_address=usbmux_address)
441 | mux.get_device_list(0.1)
442 | devices = mux.devices
443 | mux.close()
444 | return devices
445 |
446 |
447 | def select_device(udid: Optional[str] = None, connection_type: Optional[str] = None, usbmux_address: Optional[str] = None) \
448 | -> Optional[MuxDevice]:
449 | """
450 | select a UsbMux device according to given arguments.
451 | if more than one device could be selected, always prefer the usb one.
452 | """
453 | tmp = None
454 | for device in list_devices(usbmux_address=usbmux_address):
455 | if connection_type is not None and device.connection_type != connection_type:
456 | # if a specific connection_type was desired and not of this one then skip
457 | continue
458 |
459 | if udid is not None and not device.matches_udid(udid):
460 | # if a specific udid was desired and not of this one then skip
461 | continue
462 |
463 | # save best result as a temporary
464 | tmp = device
465 |
466 | if device.is_usb:
467 | # always prefer usb connection
468 | return device
469 |
470 | return tmp
471 |
472 |
473 | def select_devices_by_connection_type(connection_type: str, usbmux_address: Optional[str] = None) -> List[MuxDevice]:
474 | """
475 | select all UsbMux devices by connection type
476 | """
477 | tmp = []
478 | for device in list_devices(usbmux_address=usbmux_address):
479 | if device.connection_type == connection_type:
480 | tmp.append(device)
481 |
482 | return tmp
483 |
484 |
485 |
486 | class USBMuxHTTPConnection(HTTPConnection):
487 | def __init__(self, device: MuxDevice, port=8100):
488 | super().__init__("localhost", port)
489 | self.__device = device
490 | self.__port = port
491 |
492 | def connect(self):
493 | self.sock = self.__device.connect(self.__port)
494 |
495 | def __enter__(self) -> HTTPConnection:
496 | return self
497 |
498 | def __exit__(self, exc_type, exc_value, traceback):
499 | self.close()
500 |
--------------------------------------------------------------------------------