mywebscrcpy-sdk 1.20.1__tar.gz
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.
- mywebscrcpy_sdk-1.20.1/MANIFEST.in +2 -0
- mywebscrcpy_sdk-1.20.1/PKG-INFO +62 -0
- mywebscrcpy_sdk-1.20.1/README.md +50 -0
- mywebscrcpy_sdk-1.20.1/examples/device_query.py +26 -0
- mywebscrcpy_sdk-1.20.1/pyproject.toml +20 -0
- mywebscrcpy_sdk-1.20.1/setup.cfg +4 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy/__init__.py +9 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy/client.py +73 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy/device.py +84 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy/errors.py +27 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy/models.py +16 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy_sdk.egg-info/PKG-INFO +62 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy_sdk.egg-info/SOURCES.txt +15 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy_sdk.egg-info/dependency_links.txt +1 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy_sdk.egg-info/requires.txt +1 -0
- mywebscrcpy_sdk-1.20.1/src/mywebscrcpy_sdk.egg-info/top_level.txt +1 -0
- mywebscrcpy_sdk-1.20.1/tests/test_basic.py +116 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mywebscrcpy-sdk
|
|
3
|
+
Version: 1.20.1
|
|
4
|
+
Summary: Python client for MyWebScrcpy device automation
|
|
5
|
+
Project-URL: Homepage, https://github.com/liuzhuogood/MyWebScrcpy
|
|
6
|
+
Project-URL: Documentation, https://github.com/liuzhuogood/MyWebScrcpy/blob/main/packages/python-sdk/README.md
|
|
7
|
+
Project-URL: Repository, https://github.com/liuzhuogood/MyWebScrcpy
|
|
8
|
+
Project-URL: Issues, https://github.com/liuzhuogood/MyWebScrcpy/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx<1,>=0.28
|
|
12
|
+
|
|
13
|
+
# MyWebScrcpy Python SDK
|
|
14
|
+
|
|
15
|
+
SDK 使用 HTTPX,支持 `Client`、绑定设备的 `Device.adb()`、`Device.page_info()` 和类型化结果。按键、点击、滑动及 ASCII 输入可通过明确的 ADB 参数执行。当前尚不提供视频解码、模板管理和匹配 API,也无需安装 OpenCV/PyAV。
|
|
16
|
+
|
|
17
|
+
使用 Python 3.10+,从 PyPI 安装:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install mywebscrcpy-sdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
也可从仓库根目录安装源码并运行示例:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install ./packages/python-sdk
|
|
27
|
+
python packages/python-sdk/examples/device_query.py DEVICE_SERIAL --base-url http://HOST:PORT
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
也可构建并安装 wheel:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m pip install build
|
|
34
|
+
python -m build packages/python-sdk
|
|
35
|
+
python -m pip install --find-links packages/python-sdk/dist mywebscrcpy-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from mywebscrcpy import Client
|
|
40
|
+
|
|
41
|
+
with Client("http://HOST:PORT") as client:
|
|
42
|
+
with client.device("DEVICE_SERIAL") as device:
|
|
43
|
+
result = device.adb(["shell", "getprop", "ro.product.model"], timeout=10)
|
|
44
|
+
print(result.stdout)
|
|
45
|
+
device.adb(["shell", "input", "keyevent", "3"], timeout=10)
|
|
46
|
+
size = device.page_info()
|
|
47
|
+
device.adb(["shell", "input", "tap", str(size.width // 2), str(size.height // 2)], timeout=10)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`timeout` 为秒,SDK 转换为接口的 `timeout_ms`。ADB 参数不得包含可执行文件名、设备选择或宿主切换选项。SDK 检查 HTTP 响应及实际 `status/exit_code`;失败、截断输出或超时抛异常,不自动重试动作。HTTP 超时后的设备结果可能未知,不应直接重发。
|
|
51
|
+
|
|
52
|
+
`CommandResult` 包含 `status/exit_code/stdout/stderr/truncated`;`PageInfo` 包含正整数 `width/height`。必需字段缺失或类型错误抛 `ProtocolError`,不猜替代字段。`CommandError.result` 保留命令结果,`HTTPError` 保留 `status/code/detail`;连接错误使用 `ConnectionError`。
|
|
53
|
+
|
|
54
|
+
`Client(headers=...)` 接收显式请求头,默认验证 TLS,可传 CA 路径。不会读取未知凭据、自动启动主服务或修改 root logger。Client/Device 可重复关闭,关闭后的对象拒绝操作。
|
|
55
|
+
|
|
56
|
+
应用任务会释放同一套 SDK 源文件,并提供连接环境变量;配置的 Python 环境仍需安装 `httpx`。导出的脚本可在其他目录通过安装本 SDK 独立运行,远程操作要求服务与设备可达。
|
|
57
|
+
|
|
58
|
+
接口依据 `web/openapi.json` 的 `/api/adb/commands` 和 `/api/ui/page-info`。安装版与应用内释放版共享源码,避免两套运行库。仅本机和受控接口已验证,真机及 Windows/Linux 原生链路仍待验收。
|
|
59
|
+
|
|
60
|
+
SDK 版本与仓库根目录 `VERSION` 一致,Python 包版本去掉 `v` 前缀。修改 `VERSION` 后运行 `node scripts/sync-version.mjs`,同时更新主程序与 SDK 版本。
|
|
61
|
+
|
|
62
|
+
GitHub Actions 的 `Publish Python SDK` 工作流在主分支的版本、SDK 或发布配置变更时运行,也支持 `v*` 标签和手动触发;构建检查及安装测试通过后发布未发布的版本,已存在的完整版本会跳过。仓库需在 Settings → Secrets and variables → Actions 配置 `PYPI_API_TOKEN`,值为有此项目发布权限的 PyPI API Token。只修改 SDK 而不更新版本号不会覆盖 PyPI 上已有的版本。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# MyWebScrcpy Python SDK
|
|
2
|
+
|
|
3
|
+
SDK 使用 HTTPX,支持 `Client`、绑定设备的 `Device.adb()`、`Device.page_info()` 和类型化结果。按键、点击、滑动及 ASCII 输入可通过明确的 ADB 参数执行。当前尚不提供视频解码、模板管理和匹配 API,也无需安装 OpenCV/PyAV。
|
|
4
|
+
|
|
5
|
+
使用 Python 3.10+,从 PyPI 安装:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install mywebscrcpy-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
也可从仓库根目录安装源码并运行示例:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python -m pip install ./packages/python-sdk
|
|
15
|
+
python packages/python-sdk/examples/device_query.py DEVICE_SERIAL --base-url http://HOST:PORT
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
也可构建并安装 wheel:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install build
|
|
22
|
+
python -m build packages/python-sdk
|
|
23
|
+
python -m pip install --find-links packages/python-sdk/dist mywebscrcpy-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from mywebscrcpy import Client
|
|
28
|
+
|
|
29
|
+
with Client("http://HOST:PORT") as client:
|
|
30
|
+
with client.device("DEVICE_SERIAL") as device:
|
|
31
|
+
result = device.adb(["shell", "getprop", "ro.product.model"], timeout=10)
|
|
32
|
+
print(result.stdout)
|
|
33
|
+
device.adb(["shell", "input", "keyevent", "3"], timeout=10)
|
|
34
|
+
size = device.page_info()
|
|
35
|
+
device.adb(["shell", "input", "tap", str(size.width // 2), str(size.height // 2)], timeout=10)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`timeout` 为秒,SDK 转换为接口的 `timeout_ms`。ADB 参数不得包含可执行文件名、设备选择或宿主切换选项。SDK 检查 HTTP 响应及实际 `status/exit_code`;失败、截断输出或超时抛异常,不自动重试动作。HTTP 超时后的设备结果可能未知,不应直接重发。
|
|
39
|
+
|
|
40
|
+
`CommandResult` 包含 `status/exit_code/stdout/stderr/truncated`;`PageInfo` 包含正整数 `width/height`。必需字段缺失或类型错误抛 `ProtocolError`,不猜替代字段。`CommandError.result` 保留命令结果,`HTTPError` 保留 `status/code/detail`;连接错误使用 `ConnectionError`。
|
|
41
|
+
|
|
42
|
+
`Client(headers=...)` 接收显式请求头,默认验证 TLS,可传 CA 路径。不会读取未知凭据、自动启动主服务或修改 root logger。Client/Device 可重复关闭,关闭后的对象拒绝操作。
|
|
43
|
+
|
|
44
|
+
应用任务会释放同一套 SDK 源文件,并提供连接环境变量;配置的 Python 环境仍需安装 `httpx`。导出的脚本可在其他目录通过安装本 SDK 独立运行,远程操作要求服务与设备可达。
|
|
45
|
+
|
|
46
|
+
接口依据 `web/openapi.json` 的 `/api/adb/commands` 和 `/api/ui/page-info`。安装版与应用内释放版共享源码,避免两套运行库。仅本机和受控接口已验证,真机及 Windows/Linux 原生链路仍待验收。
|
|
47
|
+
|
|
48
|
+
SDK 版本与仓库根目录 `VERSION` 一致,Python 包版本去掉 `v` 前缀。修改 `VERSION` 后运行 `node scripts/sync-version.mjs`,同时更新主程序与 SDK 版本。
|
|
49
|
+
|
|
50
|
+
GitHub Actions 的 `Publish Python SDK` 工作流在主分支的版本、SDK 或发布配置变更时运行,也支持 `v*` 标签和手动触发;构建检查及安装测试通过后发布未发布的版本,已存在的完整版本会跳过。仓库需在 Settings → Secrets and variables → Actions 配置 `PYPI_API_TOKEN`,值为有此项目发布权限的 PyPI API Token。只修改 SDK 而不更新版本号不会覆盖 PyPI 上已有的版本。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from mywebscrcpy import Client
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(description="查询设备型号")
|
|
8
|
+
parser.add_argument("serial", help="ADB 设备序列号")
|
|
9
|
+
parser.add_argument("--base-url", default=os.environ.get("MYWEBSCRCPY_API_URL", ""))
|
|
10
|
+
parser.add_argument("--token", default=os.environ.get("MYWEBSCRCPY_API_TOKEN", ""))
|
|
11
|
+
options = parser.parse_args()
|
|
12
|
+
if not options.base_url:
|
|
13
|
+
raise SystemExit("请通过 --base-url 指定 MyWebScrcpy 服务地址")
|
|
14
|
+
headers = {}
|
|
15
|
+
if options.token:
|
|
16
|
+
headers["Authorization"] = "Bearer " + options.token
|
|
17
|
+
|
|
18
|
+
with Client(options.base_url, headers=headers) as client:
|
|
19
|
+
with client.device(options.serial) as device:
|
|
20
|
+
print("步骤 1:读取设备型号", flush=True)
|
|
21
|
+
result = device.adb(["shell", "getprop", "ro.product.model"], timeout=10)
|
|
22
|
+
print(result.stdout, end="", flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mywebscrcpy-sdk"
|
|
7
|
+
version = "1.20.1"
|
|
8
|
+
description = "Python client for MyWebScrcpy device automation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["httpx>=0.28,<1"]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/liuzhuogood/MyWebScrcpy"
|
|
15
|
+
Documentation = "https://github.com/liuzhuogood/MyWebScrcpy/blob/main/packages/python-sdk/README.md"
|
|
16
|
+
Repository = "https://github.com/liuzhuogood/MyWebScrcpy"
|
|
17
|
+
Issues = "https://github.com/liuzhuogood/MyWebScrcpy/issues"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Installable MyWebScrcpy SDK. Basic imports do not load vision dependencies."""
|
|
2
|
+
from .client import Client
|
|
3
|
+
from .device import Device
|
|
4
|
+
from .errors import CommandError, ConnectionError, HTTPError, ProtocolError, SDKError
|
|
5
|
+
from .models import CommandResult, PageInfo
|
|
6
|
+
|
|
7
|
+
__version__ = "1.20.1"
|
|
8
|
+
__all__ = ["Client", "Device", "CommandResult", "PageInfo", "SDKError", "CommandError",
|
|
9
|
+
"ConnectionError", "HTTPError", "ProtocolError"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from urllib.parse import urlsplit
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import httpx
|
|
6
|
+
except ImportError as error:
|
|
7
|
+
raise ImportError("请在当前 Python 环境安装 mywebscrcpy-sdk,或安装 httpx") from error
|
|
8
|
+
|
|
9
|
+
from .errors import ConnectionError, HTTPError, ProtocolError, SDKError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Client:
|
|
15
|
+
"""HTTP client. No device process, service or Vision session starts implicitly."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_url: str, *, headers: dict[str, str] | None = None,
|
|
18
|
+
timeout: float = 30, verify: bool | str = True):
|
|
19
|
+
parsed = urlsplit(base_url)
|
|
20
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
21
|
+
raise ValueError("base_url 必须是无凭据的 HTTP(S) 服务地址")
|
|
22
|
+
if parsed.path not in {"", "/"}:
|
|
23
|
+
raise ValueError("base_url 不能包含接口路径")
|
|
24
|
+
if timeout <= 0:
|
|
25
|
+
raise ValueError("timeout 必须为正数秒")
|
|
26
|
+
if verify is False:
|
|
27
|
+
logger.warning("TLS certificate verification explicitly disabled")
|
|
28
|
+
self._http = httpx.Client(base_url=base_url.rstrip("/"), headers=headers,
|
|
29
|
+
timeout=timeout, verify=verify, trust_env=False)
|
|
30
|
+
self._closed = False
|
|
31
|
+
|
|
32
|
+
def device(self, serial: str):
|
|
33
|
+
from .device import Device
|
|
34
|
+
self._check_open()
|
|
35
|
+
return Device(self, serial)
|
|
36
|
+
|
|
37
|
+
def _check_open(self):
|
|
38
|
+
if self._closed:
|
|
39
|
+
raise SDKError("Client 已关闭")
|
|
40
|
+
|
|
41
|
+
def _request(self, method: str, path: str, *, serial: str, timeout: float, body=None):
|
|
42
|
+
self._check_open()
|
|
43
|
+
try:
|
|
44
|
+
response = self._http.request(method, path, params={"serial": serial}, json=body, timeout=timeout)
|
|
45
|
+
except httpx.RequestError as error:
|
|
46
|
+
logger.warning("HTTP connection failed: method=%s path=%s type=%s", method, path, type(error).__name__)
|
|
47
|
+
raise ConnectionError(f"{method} {path}: {type(error).__name__}") from error
|
|
48
|
+
try:
|
|
49
|
+
value = response.json()
|
|
50
|
+
except ValueError as error:
|
|
51
|
+
raise ProtocolError(f"{method} {path}: HTTP {response.status_code} 返回非 JSON") from error
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
raise ProtocolError("API 响应必须是 JSON 对象")
|
|
54
|
+
if response.is_error:
|
|
55
|
+
# ErrorResponse's message is optional; code is always taken from error.
|
|
56
|
+
code = value.get("error", "")
|
|
57
|
+
detail = value.get("message", "")
|
|
58
|
+
if not isinstance(code, str) or not isinstance(detail, str):
|
|
59
|
+
raise ProtocolError("错误响应字段类型不合法")
|
|
60
|
+
raise HTTPError(response.status_code, code, detail)
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
def close(self):
|
|
64
|
+
if not self._closed:
|
|
65
|
+
self._closed = True
|
|
66
|
+
self._http.close()
|
|
67
|
+
|
|
68
|
+
def __enter__(self):
|
|
69
|
+
self._check_open()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
73
|
+
self.close()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
from .errors import CommandError, ProtocolError, SDKError
|
|
5
|
+
from .models import CommandResult, PageInfo
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _seconds(value):
|
|
11
|
+
if type(value) not in {int, float} or not math.isfinite(value) or not 0 < value <= 600:
|
|
12
|
+
raise ValueError("timeout 必须在 0 到 600 秒之间")
|
|
13
|
+
return value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Device:
|
|
17
|
+
"""Basic HTTP operations. Vision and matching APIs are added separately."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client, serial: str):
|
|
20
|
+
if not isinstance(serial, str) or not serial.strip():
|
|
21
|
+
raise ValueError("serial 必须为非空字符串")
|
|
22
|
+
self._client = client
|
|
23
|
+
self.serial = serial
|
|
24
|
+
self._closed = False
|
|
25
|
+
|
|
26
|
+
def _check_open(self):
|
|
27
|
+
if self._closed:
|
|
28
|
+
raise SDKError("Device 已关闭")
|
|
29
|
+
self._client._check_open()
|
|
30
|
+
|
|
31
|
+
def adb(self, args: list[str], timeout: float = 30) -> CommandResult:
|
|
32
|
+
self._check_open()
|
|
33
|
+
seconds = _seconds(timeout)
|
|
34
|
+
if not isinstance(args, list) or not 1 <= len(args) <= 128 or any(not isinstance(v, str) or not v or "\x00" in v for v in args):
|
|
35
|
+
raise ValueError("args 必须包含 1 到 128 个非空字符串")
|
|
36
|
+
forbidden = {"adb", "-s", "--serial", "-H", "-P", "-L"}
|
|
37
|
+
if any(v in forbidden or v.startswith(("-H", "-P", "-L")) for v in args):
|
|
38
|
+
raise ValueError("args 不得改变 ADB 宿主或目标设备")
|
|
39
|
+
logger.debug("ADB command started: serial=%s timeout=%s", self.serial, seconds)
|
|
40
|
+
value = self._client._request("POST", "/api/adb/commands", serial=self.serial,
|
|
41
|
+
timeout=seconds + 10,
|
|
42
|
+
body={"args": args, "timeout_ms": max(1, round(seconds * 1000))})
|
|
43
|
+
try:
|
|
44
|
+
status = value["status"]
|
|
45
|
+
if status not in {"queued", "running", "completed", "failed", "timed_out"}:
|
|
46
|
+
raise ValueError("status")
|
|
47
|
+
# A successful synchronous result must contain its actual exit code.
|
|
48
|
+
exit_code = value["exit_code"] if status == "completed" else value.get("exit_code")
|
|
49
|
+
if (status == "completed" and type(exit_code) is not int) or (exit_code is not None and type(exit_code) is not int):
|
|
50
|
+
raise ValueError("exit_code")
|
|
51
|
+
stdout, stderr = value.get("stdout", ""), value.get("stderr", "")
|
|
52
|
+
truncated = value.get("truncated", False)
|
|
53
|
+
if not isinstance(stdout, str) or not isinstance(stderr, str) or type(truncated) is not bool:
|
|
54
|
+
raise ValueError("output")
|
|
55
|
+
except (KeyError, ValueError, TypeError) as error:
|
|
56
|
+
raise ProtocolError("ADB 响应缺少字段或字段类型不合法") from error
|
|
57
|
+
result = CommandResult(status, exit_code, stdout, stderr, truncated)
|
|
58
|
+
if status != "completed" or exit_code != 0 or truncated:
|
|
59
|
+
logger.warning("ADB command failed: serial=%s status=%s exit_code=%s truncated=%s", self.serial, status, exit_code, truncated)
|
|
60
|
+
raise CommandError(result)
|
|
61
|
+
logger.debug("ADB command completed: serial=%s", self.serial)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def page_info(self, timeout: float = 10) -> PageInfo:
|
|
65
|
+
self._check_open()
|
|
66
|
+
value = self._client._request("GET", "/api/ui/page-info", serial=self.serial, timeout=_seconds(timeout))
|
|
67
|
+
try:
|
|
68
|
+
display = value["display"]
|
|
69
|
+
width, height = display["width"], display["height"]
|
|
70
|
+
if type(width) is not int or type(height) is not int or width <= 0 or height <= 0:
|
|
71
|
+
raise ValueError("display dimensions")
|
|
72
|
+
except (KeyError, ValueError, TypeError) as error:
|
|
73
|
+
raise ProtocolError("响应缺少有效 display.width/height") from error
|
|
74
|
+
return PageInfo(width, height)
|
|
75
|
+
|
|
76
|
+
def close(self):
|
|
77
|
+
self._closed = True
|
|
78
|
+
|
|
79
|
+
def __enter__(self):
|
|
80
|
+
self._check_open()
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
84
|
+
self.close()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Explicit SDK errors; device actions are never automatically retried."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SDKError(RuntimeError):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConnectionError(SDKError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProtocolError(SDKError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPError(SDKError):
|
|
17
|
+
def __init__(self, status: int, code: str, detail: str):
|
|
18
|
+
self.status = status
|
|
19
|
+
self.code = code
|
|
20
|
+
self.detail = detail
|
|
21
|
+
super().__init__(f"HTTP {status}: {code}: {detail}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CommandError(SDKError):
|
|
25
|
+
def __init__(self, result):
|
|
26
|
+
self.result = result
|
|
27
|
+
super().__init__(f"ADB command {result.status}, exit_code={result.exit_code}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class CommandResult:
|
|
6
|
+
status: str
|
|
7
|
+
exit_code: int | None
|
|
8
|
+
stdout: str
|
|
9
|
+
stderr: str
|
|
10
|
+
truncated: bool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PageInfo:
|
|
15
|
+
width: int
|
|
16
|
+
height: int
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mywebscrcpy-sdk
|
|
3
|
+
Version: 1.20.1
|
|
4
|
+
Summary: Python client for MyWebScrcpy device automation
|
|
5
|
+
Project-URL: Homepage, https://github.com/liuzhuogood/MyWebScrcpy
|
|
6
|
+
Project-URL: Documentation, https://github.com/liuzhuogood/MyWebScrcpy/blob/main/packages/python-sdk/README.md
|
|
7
|
+
Project-URL: Repository, https://github.com/liuzhuogood/MyWebScrcpy
|
|
8
|
+
Project-URL: Issues, https://github.com/liuzhuogood/MyWebScrcpy/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx<1,>=0.28
|
|
12
|
+
|
|
13
|
+
# MyWebScrcpy Python SDK
|
|
14
|
+
|
|
15
|
+
SDK 使用 HTTPX,支持 `Client`、绑定设备的 `Device.adb()`、`Device.page_info()` 和类型化结果。按键、点击、滑动及 ASCII 输入可通过明确的 ADB 参数执行。当前尚不提供视频解码、模板管理和匹配 API,也无需安装 OpenCV/PyAV。
|
|
16
|
+
|
|
17
|
+
使用 Python 3.10+,从 PyPI 安装:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install mywebscrcpy-sdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
也可从仓库根目录安装源码并运行示例:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install ./packages/python-sdk
|
|
27
|
+
python packages/python-sdk/examples/device_query.py DEVICE_SERIAL --base-url http://HOST:PORT
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
也可构建并安装 wheel:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m pip install build
|
|
34
|
+
python -m build packages/python-sdk
|
|
35
|
+
python -m pip install --find-links packages/python-sdk/dist mywebscrcpy-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from mywebscrcpy import Client
|
|
40
|
+
|
|
41
|
+
with Client("http://HOST:PORT") as client:
|
|
42
|
+
with client.device("DEVICE_SERIAL") as device:
|
|
43
|
+
result = device.adb(["shell", "getprop", "ro.product.model"], timeout=10)
|
|
44
|
+
print(result.stdout)
|
|
45
|
+
device.adb(["shell", "input", "keyevent", "3"], timeout=10)
|
|
46
|
+
size = device.page_info()
|
|
47
|
+
device.adb(["shell", "input", "tap", str(size.width // 2), str(size.height // 2)], timeout=10)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`timeout` 为秒,SDK 转换为接口的 `timeout_ms`。ADB 参数不得包含可执行文件名、设备选择或宿主切换选项。SDK 检查 HTTP 响应及实际 `status/exit_code`;失败、截断输出或超时抛异常,不自动重试动作。HTTP 超时后的设备结果可能未知,不应直接重发。
|
|
51
|
+
|
|
52
|
+
`CommandResult` 包含 `status/exit_code/stdout/stderr/truncated`;`PageInfo` 包含正整数 `width/height`。必需字段缺失或类型错误抛 `ProtocolError`,不猜替代字段。`CommandError.result` 保留命令结果,`HTTPError` 保留 `status/code/detail`;连接错误使用 `ConnectionError`。
|
|
53
|
+
|
|
54
|
+
`Client(headers=...)` 接收显式请求头,默认验证 TLS,可传 CA 路径。不会读取未知凭据、自动启动主服务或修改 root logger。Client/Device 可重复关闭,关闭后的对象拒绝操作。
|
|
55
|
+
|
|
56
|
+
应用任务会释放同一套 SDK 源文件,并提供连接环境变量;配置的 Python 环境仍需安装 `httpx`。导出的脚本可在其他目录通过安装本 SDK 独立运行,远程操作要求服务与设备可达。
|
|
57
|
+
|
|
58
|
+
接口依据 `web/openapi.json` 的 `/api/adb/commands` 和 `/api/ui/page-info`。安装版与应用内释放版共享源码,避免两套运行库。仅本机和受控接口已验证,真机及 Windows/Linux 原生链路仍待验收。
|
|
59
|
+
|
|
60
|
+
SDK 版本与仓库根目录 `VERSION` 一致,Python 包版本去掉 `v` 前缀。修改 `VERSION` 后运行 `node scripts/sync-version.mjs`,同时更新主程序与 SDK 版本。
|
|
61
|
+
|
|
62
|
+
GitHub Actions 的 `Publish Python SDK` 工作流在主分支的版本、SDK 或发布配置变更时运行,也支持 `v*` 标签和手动触发;构建检查及安装测试通过后发布未发布的版本,已存在的完整版本会跳过。仓库需在 Settings → Secrets and variables → Actions 配置 `PYPI_API_TOKEN`,值为有此项目发布权限的 PyPI API Token。只修改 SDK 而不更新版本号不会覆盖 PyPI 上已有的版本。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
examples/device_query.py
|
|
5
|
+
src/mywebscrcpy/__init__.py
|
|
6
|
+
src/mywebscrcpy/client.py
|
|
7
|
+
src/mywebscrcpy/device.py
|
|
8
|
+
src/mywebscrcpy/errors.py
|
|
9
|
+
src/mywebscrcpy/models.py
|
|
10
|
+
src/mywebscrcpy_sdk.egg-info/PKG-INFO
|
|
11
|
+
src/mywebscrcpy_sdk.egg-info/SOURCES.txt
|
|
12
|
+
src/mywebscrcpy_sdk.egg-info/dependency_links.txt
|
|
13
|
+
src/mywebscrcpy_sdk.egg-info/requires.txt
|
|
14
|
+
src/mywebscrcpy_sdk.egg-info/top_level.txt
|
|
15
|
+
tests/test_basic.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<1,>=0.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mywebscrcpy
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import unittest
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from mywebscrcpy import Client, CommandError, HTTPError, ProtocolError, SDKError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BasicContractTests(unittest.TestCase):
|
|
16
|
+
def fixture(self, response):
|
|
17
|
+
seen = []
|
|
18
|
+
client = Client("http://test.invalid")
|
|
19
|
+
client._http.close()
|
|
20
|
+
def handle(request):
|
|
21
|
+
seen.append(request)
|
|
22
|
+
return httpx.Response(200, json=response)
|
|
23
|
+
client._http = httpx.Client(base_url="http://test.invalid", transport=httpx.MockTransport(handle))
|
|
24
|
+
self.addCleanup(client.close)
|
|
25
|
+
return client, seen
|
|
26
|
+
|
|
27
|
+
def test_binding_units_optional_output_and_close(self):
|
|
28
|
+
client, seen = self.fixture({"status": "completed", "exit_code": 0})
|
|
29
|
+
with client.device("phone-A") as device:
|
|
30
|
+
result = device.adb(["shell", "getprop", "ro.product.model"], timeout=1.25)
|
|
31
|
+
self.assertEqual(result.stdout, "")
|
|
32
|
+
request = seen[0]
|
|
33
|
+
self.assertEqual(request.url.params["serial"], "phone-A")
|
|
34
|
+
self.assertEqual(json.loads(request.content), {"args": ["shell", "getprop", "ro.product.model"], "timeout_ms": 1250})
|
|
35
|
+
with self.assertRaises(SDKError):
|
|
36
|
+
device.adb(["shell"])
|
|
37
|
+
client.close()
|
|
38
|
+
client.close()
|
|
39
|
+
with self.assertRaises(SDKError):
|
|
40
|
+
client.device("phone-A")
|
|
41
|
+
|
|
42
|
+
def test_command_failure_truncation_and_missing_fields(self):
|
|
43
|
+
for response in [{"status": "failed", "exit_code": 1}, {"status": "timed_out"}, {"status": "completed", "exit_code": 0, "truncated": True}]:
|
|
44
|
+
client, seen = self.fixture(response)
|
|
45
|
+
with self.assertRaises(CommandError):
|
|
46
|
+
client.device("phone").adb(["shell"])
|
|
47
|
+
self.assertEqual(len(seen), 1, "actions must never retry")
|
|
48
|
+
for response in [{}, {"state": "completed", "exit_code": 0}, {"status": "completed"}, {"status": "completed", "exit_code": "0"}, {"status": "completed", "exit_code": 0, "stdout": None}]:
|
|
49
|
+
client, _ = self.fixture(response)
|
|
50
|
+
with self.assertRaises(ProtocolError):
|
|
51
|
+
client.device("phone").adb(["shell"])
|
|
52
|
+
|
|
53
|
+
def test_target_override_and_timeouts_reject_before_request(self):
|
|
54
|
+
client, seen = self.fixture({"status": "completed", "exit_code": 0})
|
|
55
|
+
device = client.device("phone")
|
|
56
|
+
for args in [["-s", "other"], ["adb", "devices"], ["shell", "-Hhost"], [], ["shell", ""]]:
|
|
57
|
+
with self.assertRaises(ValueError):
|
|
58
|
+
device.adb(args)
|
|
59
|
+
for timeout in [0, -1, 601, float("nan"), True]:
|
|
60
|
+
with self.assertRaises(ValueError):
|
|
61
|
+
device.adb(["shell"], timeout=timeout)
|
|
62
|
+
self.assertEqual(seen, [])
|
|
63
|
+
|
|
64
|
+
def test_exact_page_info_fields(self):
|
|
65
|
+
client, _ = self.fixture({"display": {"width": 1080, "height": 2400}})
|
|
66
|
+
self.assertEqual(client.device("phone").page_info().height, 2400)
|
|
67
|
+
for response in [{"screen": {"width": 1080, "height": 2400}}, {"display": {"width": 0, "height": 2400}}]:
|
|
68
|
+
client, _ = self.fixture(response)
|
|
69
|
+
with self.assertRaises(ProtocolError):
|
|
70
|
+
client.device("phone").page_info()
|
|
71
|
+
|
|
72
|
+
def test_http_error_retains_exact_error_fields(self):
|
|
73
|
+
client = Client("http://test.invalid")
|
|
74
|
+
client._http.close()
|
|
75
|
+
client._http = httpx.Client(base_url="http://test.invalid", transport=httpx.MockTransport(lambda r: httpx.Response(400, json={"error": "invalid_request", "message": "bad args"})))
|
|
76
|
+
with client:
|
|
77
|
+
with self.assertRaises(HTTPError) as caught:
|
|
78
|
+
client.device("phone").adb(["shell"])
|
|
79
|
+
self.assertEqual((caught.exception.status, caught.exception.code, caught.exception.detail), (400, "invalid_request", "bad args"))
|
|
80
|
+
|
|
81
|
+
def test_portable_example_runs_outside_application_without_vision(self):
|
|
82
|
+
requests = []
|
|
83
|
+
class Handler(BaseHTTPRequestHandler):
|
|
84
|
+
def do_POST(self):
|
|
85
|
+
requests.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"])))))
|
|
86
|
+
value = json.dumps({"status": "completed", "exit_code": 0, "stdout": "Portable Phone\n"}).encode()
|
|
87
|
+
self.send_response(200)
|
|
88
|
+
self.send_header("Content-Type", "application/json")
|
|
89
|
+
self.send_header("Content-Length", str(len(value)))
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(value)
|
|
92
|
+
def log_message(self, *args):
|
|
93
|
+
pass
|
|
94
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
95
|
+
thread = threading.Thread(target=server.serve_forever)
|
|
96
|
+
thread.start()
|
|
97
|
+
try:
|
|
98
|
+
sample = Path(__file__).resolve().parents[1] / "examples/device_query.py"
|
|
99
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
100
|
+
copied = Path(directory) / "workflow.py"
|
|
101
|
+
copied.write_text(sample.read_text(), encoding="utf-8")
|
|
102
|
+
result = subprocess.run([sys.executable, str(copied), "portable-phone", "--base-url", f"http://127.0.0.1:{server.server_port}"], cwd=directory, capture_output=True, text=True, timeout=10)
|
|
103
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
104
|
+
self.assertIn("Portable Phone", result.stdout)
|
|
105
|
+
self.assertEqual(requests[0][1]["timeout_ms"], 10000)
|
|
106
|
+
self.assertIn("serial=portable-phone", requests[0][0])
|
|
107
|
+
self.assertNotIn("cv2", sys.modules)
|
|
108
|
+
self.assertNotIn("av", sys.modules)
|
|
109
|
+
finally:
|
|
110
|
+
server.shutdown()
|
|
111
|
+
server.server_close()
|
|
112
|
+
thread.join()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|