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 ADDED
@@ -0,0 +1,5 @@
1
+ """Cross-platform device automation library."""
2
+
3
+ from idevice import device, uiauto
4
+
5
+ __all__ = ["device", "uiauto"]
@@ -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,5 @@
1
+ """Android ``DeviceBase`` implementation."""
2
+
3
+ from idevice.device.android.device import AndroidDevice
4
+
5
+ __all__ = ["AndroidDevice"]
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
1
+ """iOS ``DeviceBase`` implementation."""
2
+
3
+ from idevice.device.cache import InstalledAppCache
4
+ from idevice.device.ios.device import IOSDevice, IOSDeviceError
5
+
6
+ __all__ = ["IOSDevice", "IOSDeviceError", "InstalledAppCache"]