idevice 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- idevice/__init__.py +5 -0
- idevice/device/__init__.py +29 -0
- idevice/device/android/__init__.py +5 -0
- idevice/device/android/device.py +217 -0
- idevice/device/base/__init__.py +20 -0
- idevice/device/base/device.py +151 -0
- idevice/device/base/errors.py +32 -0
- idevice/device/base/runner.py +83 -0
- idevice/device/cache.py +52 -0
- idevice/device/config.py +26 -0
- idevice/device/factory.py +37 -0
- idevice/device/ios/__init__.py +6 -0
- idevice/device/ios/device.py +202 -0
- idevice/device/windows/__init__.py +5 -0
- idevice/device/windows/device.py +155 -0
- idevice/uiauto/__init__.py +15 -0
- idevice/uiauto/android/__init__.py +5 -0
- idevice/uiauto/android/automation.py +100 -0
- idevice/uiauto/android/dialogs.py +44 -0
- idevice/uiauto/android/hierarchy.py +87 -0
- idevice/uiauto/base/__init__.py +6 -0
- idevice/uiauto/base/automation.py +43 -0
- idevice/uiauto/base/errors.py +9 -0
- idevice/uiauto/config.py +15 -0
- idevice/uiauto/factory.py +27 -0
- idevice-0.1.0.dist-info/METADATA +17 -0
- idevice-0.1.0.dist-info/RECORD +28 -0
- idevice-0.1.0.dist-info/WHEEL +4 -0
idevice/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public API for ``DeviceBase`` and platform-specific device implementations."""
|
|
2
|
+
|
|
3
|
+
from idevice.device.android.device import AndroidDevice
|
|
4
|
+
from idevice.device.base.device import DeviceBase
|
|
5
|
+
from idevice.device.base.errors import (
|
|
6
|
+
AppNotInstalledError,
|
|
7
|
+
CommandExecutionError,
|
|
8
|
+
DeviceError,
|
|
9
|
+
DeviceNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from idevice.device.base.runner import CommandResult, SubprocessRunner
|
|
12
|
+
from idevice.device.factory import Platform, create_device
|
|
13
|
+
from idevice.device.ios.device import IOSDevice
|
|
14
|
+
from idevice.device.windows.device import WindowsDevice
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AndroidDevice",
|
|
18
|
+
"AppNotInstalledError",
|
|
19
|
+
"CommandExecutionError",
|
|
20
|
+
"CommandResult",
|
|
21
|
+
"DeviceError",
|
|
22
|
+
"DeviceNotFoundError",
|
|
23
|
+
"DeviceBase",
|
|
24
|
+
"IOSDevice",
|
|
25
|
+
"Platform",
|
|
26
|
+
"SubprocessRunner",
|
|
27
|
+
"WindowsDevice",
|
|
28
|
+
"create_device",
|
|
29
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Android ``DeviceBase`` implementation via adb."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from idevice.device.base.device import DeviceBase
|
|
13
|
+
from idevice.device.base.errors import AppNotInstalledError
|
|
14
|
+
from idevice.device.base.runner import SubprocessRunner
|
|
15
|
+
from idevice.device.config import adb_binary
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class InstallResult:
|
|
22
|
+
ok: bool
|
|
23
|
+
returncode: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AndroidDeviceError(RuntimeError):
|
|
29
|
+
"""Raised when an Android device operation fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AndroidDevice(DeviceBase):
|
|
33
|
+
"""``DeviceBase`` implementation for Android using adb."""
|
|
34
|
+
|
|
35
|
+
DEFAULT_BINARY = "adb"
|
|
36
|
+
|
|
37
|
+
def __init__(self, device_id: str) -> None:
|
|
38
|
+
super().__init__(device_id)
|
|
39
|
+
self._binary = self.DEFAULT_BINARY
|
|
40
|
+
self._runner = SubprocessRunner()
|
|
41
|
+
if shutil.which(self._binary) is None:
|
|
42
|
+
logger.error(f"[AndroidDevice] `{self._binary}` CLI not found on PATH")
|
|
43
|
+
raise AndroidDeviceError(
|
|
44
|
+
f"`{self._binary}` CLI not found on PATH. "
|
|
45
|
+
"Install adb: https://developer.android.com/studio/releases/platform-tools"
|
|
46
|
+
)
|
|
47
|
+
self._installed_pkg_names: dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
def _base_command(self) -> list[str]:
|
|
50
|
+
return [adb_binary(), "-s", self.device_id]
|
|
51
|
+
|
|
52
|
+
def install(self, package_path: Path, app_id: str | None = None) -> None:
|
|
53
|
+
"""Install an APK on the bound device via uiautomator2.
|
|
54
|
+
|
|
55
|
+
Example::
|
|
56
|
+
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from idevice.device.android.device import AndroidDevice
|
|
60
|
+
|
|
61
|
+
device = AndroidDevice("e8b2b043")
|
|
62
|
+
apk = Path("tests/apk/app.apk")
|
|
63
|
+
device.install(apk, app_id="com.example.app")
|
|
64
|
+
assert device.is_installed("com.example.app")
|
|
65
|
+
"""
|
|
66
|
+
logger.info(f"[AndroidDevice] Installing package on {self.device_id}: {package_path}")
|
|
67
|
+
if not package_path.exists():
|
|
68
|
+
raise FileNotFoundError(f"Package not found: {package_path}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
cmd = self._base_command()
|
|
72
|
+
cmd.extend(["install", "-r", str(package_path)])
|
|
73
|
+
result = self._install_with_uiautomator2(cmd, device_id=self.device_id)
|
|
74
|
+
if not result.ok:
|
|
75
|
+
raise AndroidDeviceError(f"Package install failed on {self.device_id}: {result.stderr}")
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise AndroidDeviceError(f"Package install failed on {self.device_id}: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
if app_id:
|
|
80
|
+
self._installed_pkg_names[app_id] = package_path.name
|
|
81
|
+
logger.debug(f"[AndroidDevice] Cached package name for app_id={app_id}")
|
|
82
|
+
|
|
83
|
+
def uninstall(self, app_id: str) -> None:
|
|
84
|
+
logger.info(f"[AndroidDevice] Uninstalling {app_id} on {self.device_id}")
|
|
85
|
+
command = self._base_command()
|
|
86
|
+
command.extend(["uninstall", app_id])
|
|
87
|
+
self._runner.run(command)
|
|
88
|
+
self._installed_pkg_names.pop(app_id, None)
|
|
89
|
+
|
|
90
|
+
def is_installed(self, app_id: str) -> bool:
|
|
91
|
+
command = self._base_command()
|
|
92
|
+
command.extend(["shell", "pm", "list", "packages", app_id])
|
|
93
|
+
result = self._runner.run(command)
|
|
94
|
+
prefix = f"package:{app_id}"
|
|
95
|
+
installed = any(line.strip() == prefix for line in result.stdout.splitlines())
|
|
96
|
+
logger.debug(f"App {app_id} installed on Android device {self.device_id}: {installed}")
|
|
97
|
+
return installed
|
|
98
|
+
|
|
99
|
+
def launch_app(self, app_id: str) -> None:
|
|
100
|
+
if not app_id:
|
|
101
|
+
raise ValueError("app_id is required and must be a non-empty string")
|
|
102
|
+
if not self.is_installed(app_id):
|
|
103
|
+
raise AppNotInstalledError(f"App not installed: {app_id}")
|
|
104
|
+
logger.info(f"[AndroidDevice] Launching {app_id} on {self.device_id}")
|
|
105
|
+
command = self._base_command()
|
|
106
|
+
command.extend(
|
|
107
|
+
[
|
|
108
|
+
"shell",
|
|
109
|
+
"monkey",
|
|
110
|
+
"-p",
|
|
111
|
+
app_id,
|
|
112
|
+
"-c",
|
|
113
|
+
"android.intent.category.LAUNCHER",
|
|
114
|
+
"1",
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
self._runner.run(command)
|
|
118
|
+
|
|
119
|
+
def stop_app(self, app_id: str) -> None:
|
|
120
|
+
if not app_id:
|
|
121
|
+
raise ValueError("app_id is required and must be a non-empty string")
|
|
122
|
+
logger.info(f"Stopping app on Android device {self.device_id}: {app_id}")
|
|
123
|
+
command = self._base_command()
|
|
124
|
+
command.extend(["shell", "am", "force-stop", app_id])
|
|
125
|
+
self._runner.run(command)
|
|
126
|
+
|
|
127
|
+
def get_installed_pkg_name(self, app_id: str) -> str | None:
|
|
128
|
+
if not self.is_installed(app_id):
|
|
129
|
+
return None
|
|
130
|
+
return self._installed_pkg_names.get(app_id)
|
|
131
|
+
|
|
132
|
+
def host_is_running(self) -> bool:
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def push(
|
|
136
|
+
self,
|
|
137
|
+
local: Path | str,
|
|
138
|
+
remote: str,
|
|
139
|
+
*,
|
|
140
|
+
app_id: str | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Push a local file or directory to the device via ``adb push``."""
|
|
143
|
+
del app_id
|
|
144
|
+
if not remote:
|
|
145
|
+
raise ValueError("remote is required and must be a non-empty string")
|
|
146
|
+
local_path = Path(local)
|
|
147
|
+
if not local_path.exists():
|
|
148
|
+
raise FileNotFoundError(f"Local path not found: {local_path}")
|
|
149
|
+
logger.info(f"[AndroidDevice] Pushing {local_path} to {self.device_id}:{remote}")
|
|
150
|
+
command = self._base_command()
|
|
151
|
+
command.extend(["push", str(local_path), remote])
|
|
152
|
+
self._runner.run(command)
|
|
153
|
+
|
|
154
|
+
def pull(
|
|
155
|
+
self,
|
|
156
|
+
remote: str,
|
|
157
|
+
local: Path | str,
|
|
158
|
+
*,
|
|
159
|
+
app_id: str | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Pull a remote file or directory from the device via ``adb pull``."""
|
|
162
|
+
del app_id
|
|
163
|
+
if not remote:
|
|
164
|
+
raise ValueError("remote is required and must be a non-empty string")
|
|
165
|
+
local_path = Path(local)
|
|
166
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
logger.info(f"[AndroidDevice] Pulling {self.device_id}:{remote} to {local_path}")
|
|
168
|
+
command = self._base_command()
|
|
169
|
+
command.extend(["pull", remote, str(local_path)])
|
|
170
|
+
self._runner.run(command)
|
|
171
|
+
|
|
172
|
+
def _install_with_uiautomator2(self, cmd: list[str], *, device_id: str | None) -> InstallResult:
|
|
173
|
+
"""Use uiautomator2 WatchContext (builtin + extra) while adb install runs."""
|
|
174
|
+
import uiautomator2 as u2
|
|
175
|
+
|
|
176
|
+
logger.info(f"install with uiautomator2: {cmd}")
|
|
177
|
+
d = u2.connect(device_id) if device_id else u2.connect()
|
|
178
|
+
# autostart=False so we can register rules before the background thread runs.
|
|
179
|
+
with d.watch_context(builtin=True, autostart=False) as ctx:
|
|
180
|
+
# Builtin rules cover common install prompts (继续安装, ALLOW, Agree, …).
|
|
181
|
+
ctx.when("仍要安装").click()
|
|
182
|
+
ctx.when("Install").click()
|
|
183
|
+
ctx.start()
|
|
184
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
185
|
+
|
|
186
|
+
if p.returncode == 0:
|
|
187
|
+
self._dismiss_post_install_popups(d)
|
|
188
|
+
|
|
189
|
+
out = (p.stdout or "").strip()
|
|
190
|
+
err = (p.stderr or "").strip()
|
|
191
|
+
ok = p.returncode == 0
|
|
192
|
+
return InstallResult(ok, p.returncode, out, err)
|
|
193
|
+
|
|
194
|
+
def _dismiss_post_install_popups(self, d) -> None:
|
|
195
|
+
"""Dismiss OEM dialogs after adb install; watcher already stopped."""
|
|
196
|
+
stable = float(os.environ.get("GAUTO_APK_POST_INSTALL_STABLE_SEC", "2"))
|
|
197
|
+
timeout_sec = float(os.environ.get("GAUTO_APK_POST_INSTALL_TIMEOUT_SEC", "30"))
|
|
198
|
+
with d.watch_context(builtin=True, autostart=False) as ctx:
|
|
199
|
+
# Prefer dismiss over launching the app when both exist.
|
|
200
|
+
ctx.when("完成").click()
|
|
201
|
+
ctx.when("完成安装").click()
|
|
202
|
+
ctx.when("知道了").click()
|
|
203
|
+
ctx.when("我知道了").click()
|
|
204
|
+
ctx.when("以后再说").click()
|
|
205
|
+
ctx.when("稍后").click()
|
|
206
|
+
ctx.when("暂不").click()
|
|
207
|
+
ctx.when("跳过").click()
|
|
208
|
+
ctx.when("关闭").click()
|
|
209
|
+
ctx.when("Done").click()
|
|
210
|
+
ctx.when("OPEN").click()
|
|
211
|
+
ctx.when("打开").click()
|
|
212
|
+
ctx.when("立即打开").click()
|
|
213
|
+
ctx.start()
|
|
214
|
+
try:
|
|
215
|
+
ctx.wait_stable(seconds=stable, timeout=timeout_sec)
|
|
216
|
+
except TimeoutError:
|
|
217
|
+
logger.debug(f"post-install popups did not stabilize within {timeout_sec}s")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""``DeviceBase`` and shared utilities for device control."""
|
|
2
|
+
|
|
3
|
+
from idevice.device.base.device import DeviceBase
|
|
4
|
+
from idevice.device.base.errors import (
|
|
5
|
+
AppNotInstalledError,
|
|
6
|
+
CommandExecutionError,
|
|
7
|
+
DeviceError,
|
|
8
|
+
DeviceNotFoundError,
|
|
9
|
+
)
|
|
10
|
+
from idevice.device.base.runner import CommandResult, SubprocessRunner
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AppNotInstalledError",
|
|
14
|
+
"CommandExecutionError",
|
|
15
|
+
"CommandResult",
|
|
16
|
+
"DeviceError",
|
|
17
|
+
"DeviceNotFoundError",
|
|
18
|
+
"DeviceBase",
|
|
19
|
+
"SubprocessRunner",
|
|
20
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Abstract ``DeviceBase`` for cross-platform device app lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeviceBase(ABC):
|
|
10
|
+
"""Install packages and launch apps on a device (adb / ios / hdc, etc.).
|
|
11
|
+
|
|
12
|
+
A device instance is always bound to a single ``device_id`` (UDID / serial).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, device_id: str):
|
|
16
|
+
if not device_id or not isinstance(device_id, str):
|
|
17
|
+
raise ValueError("device_id is required and must be a non-empty string")
|
|
18
|
+
self._device_id = device_id
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def device_id(self) -> str:
|
|
22
|
+
"""Device id (UDID / serial) bound to this instance."""
|
|
23
|
+
return self._device_id
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def install(self, package_path: Path, app_id: str | None = None) -> None:
|
|
27
|
+
"""Install a package on the bound device.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
package_path: Path to the package to install.
|
|
31
|
+
app_id: Optional app identifier (bundle id / package name) associated
|
|
32
|
+
with ``package_path``. When provided, implementations should
|
|
33
|
+
record the ``app_id -> package file name`` mapping so it can
|
|
34
|
+
later be retrieved via :meth:`get_installed_pkg_name`.
|
|
35
|
+
"""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def uninstall(self, app_id: str) -> None:
|
|
40
|
+
"""Remove an installed app (package / bundle name per platform).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
app_id: ID of the app to uninstall.
|
|
44
|
+
"""
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def is_installed(self, app_id: str) -> bool:
|
|
49
|
+
"""Check if an app is installed on the bound device.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
app_id: ID of the app to check.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
bool: True if the app is installed, False otherwise.
|
|
56
|
+
"""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def launch_app(self, app_id: str) -> None:
|
|
61
|
+
"""Launch an installed app on the bound device.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
app_id: ID of the app to launch (bundle id / package name).
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If ``app_id`` is empty.
|
|
68
|
+
"""
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def stop_app(self, app_id: str) -> None:
|
|
73
|
+
"""Stop (kill) a running app on the bound device.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
app_id: ID of the app to stop (bundle id / package name).
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If ``app_id`` is empty.
|
|
80
|
+
"""
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def get_installed_pkg_name(self, app_id: str) -> str | None:
|
|
85
|
+
"""Return the installed package file name for an app on the bound device.
|
|
86
|
+
|
|
87
|
+
Implementations should return the original package file name
|
|
88
|
+
(e.g. the ``.ipa`` / ``.apk`` / ``.hap`` filename) recorded at install
|
|
89
|
+
time, or ``None`` if the app is not installed or no record is found.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
app_id: ID of the app to look up (bundle id / package name).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
str | None: The installed package file name, or ``None`` if not found.
|
|
96
|
+
"""
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def host_is_running(self) -> bool:
|
|
101
|
+
"""Check if the WDA/UIAutomator2 is running on the bound device.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
bool: True if the WDA/UIAutomator2 is running, False otherwise.
|
|
105
|
+
"""
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def push(
|
|
110
|
+
self,
|
|
111
|
+
local: Path | str,
|
|
112
|
+
remote: str,
|
|
113
|
+
*,
|
|
114
|
+
app_id: str | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Push a local file or directory to the bound device.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
local: Path to the local file or directory.
|
|
120
|
+
remote: Destination path on the device.
|
|
121
|
+
app_id: Optional app identifier (bundle id / package name) when
|
|
122
|
+
the transfer targets an app sandbox. Ignored on platforms
|
|
123
|
+
that do not support scoped transfers.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If ``remote`` is empty.
|
|
127
|
+
FileNotFoundError: If ``local`` does not exist.
|
|
128
|
+
"""
|
|
129
|
+
raise NotImplementedError
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def pull(
|
|
133
|
+
self,
|
|
134
|
+
remote: str,
|
|
135
|
+
local: Path | str,
|
|
136
|
+
*,
|
|
137
|
+
app_id: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Pull a remote file or directory from the bound device.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
remote: Source path on the device.
|
|
143
|
+
local: Destination path on the host.
|
|
144
|
+
app_id: Optional app identifier (bundle id / package name) when
|
|
145
|
+
the transfer targets an app sandbox. Ignored on platforms
|
|
146
|
+
that do not support scoped transfers.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If ``remote`` is empty.
|
|
150
|
+
"""
|
|
151
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Exceptions raised by ``DeviceBase`` implementations."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DeviceError(Exception):
|
|
5
|
+
"""Base exception for ``DeviceBase`` and platform device classes."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DeviceNotFoundError(DeviceError):
|
|
9
|
+
"""Raised when the specified device is not found."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppNotInstalledError(DeviceError):
|
|
13
|
+
"""Raised when an operation requires an app that is not installed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommandExecutionError(DeviceError):
|
|
17
|
+
"""Raised when an underlying CLI command fails."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
command: list[str] | None = None,
|
|
24
|
+
returncode: int | None = None,
|
|
25
|
+
stdout: str = "",
|
|
26
|
+
stderr: str = "",
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.command = command
|
|
30
|
+
self.returncode = returncode
|
|
31
|
+
self.stdout = stdout
|
|
32
|
+
self.stderr = stderr
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Shared subprocess runner for device CLI tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from idevice.device.base.errors import CommandExecutionError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_COMMAND_TIMEOUT = 120
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CommandResult:
|
|
19
|
+
"""Result of a subprocess command."""
|
|
20
|
+
|
|
21
|
+
returncode: int
|
|
22
|
+
stdout: str
|
|
23
|
+
stderr: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SubprocessRunner:
|
|
27
|
+
"""Execute external CLI commands with consistent error handling."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, timeout: int | None = None) -> None:
|
|
30
|
+
env_timeout = os.environ.get("IDEVICE_COMMAND_TIMEOUT")
|
|
31
|
+
if timeout is not None:
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
elif env_timeout:
|
|
34
|
+
self.timeout = int(env_timeout)
|
|
35
|
+
else:
|
|
36
|
+
self.timeout = DEFAULT_COMMAND_TIMEOUT
|
|
37
|
+
|
|
38
|
+
def run(
|
|
39
|
+
self,
|
|
40
|
+
command: list[str],
|
|
41
|
+
*,
|
|
42
|
+
check: bool = True,
|
|
43
|
+
input_text: str | None = None,
|
|
44
|
+
) -> CommandResult:
|
|
45
|
+
"""Run a command and optionally raise on non-zero exit."""
|
|
46
|
+
logger.debug(f"Running command: {command}")
|
|
47
|
+
try:
|
|
48
|
+
completed = subprocess.run(
|
|
49
|
+
command,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=self.timeout,
|
|
53
|
+
input=input_text,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
except subprocess.TimeoutExpired as exc:
|
|
57
|
+
logger.warning(f"Command timed out after {self.timeout}s: {' '.join(command)}")
|
|
58
|
+
raise CommandExecutionError(
|
|
59
|
+
f"Command timed out after {self.timeout}s: {' '.join(command)}",
|
|
60
|
+
command=command,
|
|
61
|
+
) from exc
|
|
62
|
+
except FileNotFoundError as exc:
|
|
63
|
+
logger.warning(f"Command not found: {command[0]}")
|
|
64
|
+
raise CommandExecutionError(
|
|
65
|
+
f"Command not found: {command[0]}",
|
|
66
|
+
command=command,
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
result = CommandResult(
|
|
70
|
+
returncode=completed.returncode,
|
|
71
|
+
stdout=completed.stdout,
|
|
72
|
+
stderr=completed.stderr,
|
|
73
|
+
)
|
|
74
|
+
if check and completed.returncode != 0:
|
|
75
|
+
logger.warning(f"Command failed with exit code {completed.returncode}: {' '.join(command)}")
|
|
76
|
+
raise CommandExecutionError(
|
|
77
|
+
f"Command failed with exit code {completed.returncode}: {' '.join(command)}",
|
|
78
|
+
command=command,
|
|
79
|
+
returncode=completed.returncode,
|
|
80
|
+
stdout=completed.stdout,
|
|
81
|
+
stderr=completed.stderr,
|
|
82
|
+
)
|
|
83
|
+
return result
|
idevice/device/cache.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Persistent cache for device app_id -> package_path mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from idevice.device.config import user_data_dir
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InstalledAppCache:
|
|
15
|
+
"""Persist ``app_id -> package_path`` mappings per device in user data dir."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, device_id: str, *, cache_dir: Path | None = None) -> None:
|
|
18
|
+
if not device_id:
|
|
19
|
+
raise ValueError("device_id is required and must be a non-empty string")
|
|
20
|
+
self._device_id = device_id
|
|
21
|
+
self._cache_dir = cache_dir or user_data_dir()
|
|
22
|
+
self._cache_file = self._cache_dir / f"{device_id}.json"
|
|
23
|
+
|
|
24
|
+
def _load(self) -> dict[str, dict[str, str]]:
|
|
25
|
+
if not self._cache_file.exists():
|
|
26
|
+
return {}
|
|
27
|
+
with open(self._cache_file, encoding="utf-8") as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
|
|
30
|
+
def _save(self, data: dict[str, dict[str, str]]) -> None:
|
|
31
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with open(self._cache_file, "w", encoding="utf-8") as f:
|
|
33
|
+
json.dump(data, f)
|
|
34
|
+
|
|
35
|
+
def add(self, app_id: str, package_path: Path) -> None:
|
|
36
|
+
"""Record ``app_id`` mapped to ``package_path`` for this device."""
|
|
37
|
+
logger.debug(f"Caching app_id={app_id} -> {package_path} for device {self._device_id}")
|
|
38
|
+
data = self._load()
|
|
39
|
+
data[app_id] = str(package_path.resolve())
|
|
40
|
+
self._save(data)
|
|
41
|
+
|
|
42
|
+
def remove(self, app_id: str) -> None:
|
|
43
|
+
"""Remove the cached mapping for ``app_id`` on this device."""
|
|
44
|
+
logger.debug(f"Removing cached app_id={app_id} for device {self._device_id}")
|
|
45
|
+
data = self._load()
|
|
46
|
+
data.pop(app_id, None)
|
|
47
|
+
self._save(data)
|
|
48
|
+
|
|
49
|
+
def get(self, app_id: str) -> Path | None:
|
|
50
|
+
"""Return the cached package path for ``app_id``, if present."""
|
|
51
|
+
path_str = self._load().get(self._device_id, {}).get(app_id)
|
|
52
|
+
return Path(path_str) if path_str else None
|
idevice/device/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Environment-based configuration for ``DeviceBase`` implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ios_binary() -> str:
|
|
10
|
+
"""Return the go-ios CLI binary path."""
|
|
11
|
+
return os.environ.get("IDEVICE_IOS_BINARY", "ios")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def adb_binary() -> str:
|
|
15
|
+
"""Return the adb CLI binary path."""
|
|
16
|
+
return os.environ.get("IDEVICE_ADB_BINARY", "adb")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def powershell_binary() -> str:
|
|
20
|
+
"""Return the PowerShell binary path."""
|
|
21
|
+
return os.environ.get("IDEVICE_POWERSHELL_BINARY", "powershell")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_data_dir() -> Path:
|
|
25
|
+
"""Return the default directory for idevice user data."""
|
|
26
|
+
return Path.home() / ".idevice"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Device factory for platform-specific implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from idevice.device.android.device import AndroidDevice
|
|
10
|
+
from idevice.device.base.device import DeviceBase
|
|
11
|
+
from idevice.device.ios.device import IOSDevice
|
|
12
|
+
from idevice.device.windows.device import WindowsDevice
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Platform(StrEnum):
|
|
18
|
+
"""Supported device platforms."""
|
|
19
|
+
|
|
20
|
+
IOS = "ios"
|
|
21
|
+
ANDROID = "android"
|
|
22
|
+
WINDOWS = "windows"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_device(platform: Platform, **kwargs: Any) -> DeviceBase:
|
|
26
|
+
"""Create a platform-specific ``DeviceBase`` subclass instance."""
|
|
27
|
+
logger.debug(f"Creating device for platform={platform} kwargs={kwargs}")
|
|
28
|
+
if platform is Platform.IOS:
|
|
29
|
+
device = IOSDevice(**kwargs)
|
|
30
|
+
elif platform is Platform.ANDROID:
|
|
31
|
+
device = AndroidDevice(**kwargs)
|
|
32
|
+
elif platform is Platform.WINDOWS:
|
|
33
|
+
device = WindowsDevice(**kwargs)
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError(f"Unsupported platform: {platform}")
|
|
36
|
+
logger.info(f"Created {type(device).__name__} for device_id={device.device_id}")
|
|
37
|
+
return device
|