pantoqa-bridge 0.4.4__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.
@@ -0,0 +1,74 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ import aiohttp
5
+ from fastapi import APIRouter
6
+ from packaging import version as pkg_version
7
+
8
+ from pantoqa_bridge.config import APPIUM_SERVER_URL, PKG_NAME
9
+ from pantoqa_bridge.utils.pkg import get_latest_package_version, get_pkg_version, upgrade_package
10
+ from pantoqa_bridge.utils.process import kill_self_process
11
+
12
+ route = APIRouter()
13
+
14
+ _appium_utility_version = get_pkg_version("appium_utility")
15
+ _pkg_version = get_pkg_version(PKG_NAME)
16
+ _lastest_pkg_version: pkg_version.Version | None = None
17
+
18
+
19
+ async def _unset_pkg_version(timeout: int):
20
+ global _lastest_pkg_version
21
+ await asyncio.sleep(timeout)
22
+ _lastest_pkg_version = None
23
+
24
+
25
+ @route.get("/health", tags=["misc"])
26
+ @route.get("/status", tags=["misc"])
27
+ async def status() -> dict[str, Any]:
28
+ global _lastest_pkg_version
29
+ if not _lastest_pkg_version:
30
+ _lastest_pkg_version = get_latest_package_version(PKG_NAME)
31
+ asyncio.create_task(_unset_pkg_version(timeout=900)) # unset after 15 minutes
32
+
33
+ return {
34
+ "status": "ok",
35
+ "version": str(_pkg_version),
36
+ "lastest_version": str(_lastest_pkg_version),
37
+ "outdated": _lastest_pkg_version > _pkg_version,
38
+ "appium_utility_version": str(_appium_utility_version),
39
+ }
40
+
41
+
42
+ @route.get("/upgrade-bridge", tags=["misc"])
43
+ async def update_bridge():
44
+ curent_pkg_version = get_pkg_version(PKG_NAME)
45
+ latest_pkg_version = get_latest_package_version(PKG_NAME)
46
+
47
+ if latest_pkg_version <= curent_pkg_version:
48
+ return {
49
+ "status": "success",
50
+ "details": "already using the latest version.",
51
+ }
52
+
53
+ upgrade_package(PKG_NAME)
54
+
55
+ async def kill_after_delay():
56
+ await asyncio.sleep(2)
57
+ kill_self_process()
58
+
59
+ asyncio.create_task(kill_after_delay())
60
+
61
+ return {
62
+ "status": "success",
63
+ "details": "Package upgraded successfully."
64
+ " Please restart the application to apply the updates.",
65
+ }
66
+
67
+
68
+ @route.get("/appium-status", tags=["misc"])
69
+ async def get_appium_status():
70
+ async with aiohttp.ClientSession() as session:
71
+ res = await session.get(f"{APPIUM_SERVER_URL}/status")
72
+ res.raise_for_status()
73
+ data = await res.json()
74
+ return data
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+
3
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4
+
5
+ from pantoqa_bridge.lib.scrcpy import ScrcpyPyClient
6
+
7
+ route = APIRouter()
8
+
9
+
10
+ @route.websocket("/ws/mirror")
11
+ async def ws_endpoint(ws: WebSocket):
12
+ await ws.accept()
13
+ streaming: ScreenMirrorOverWS | None = None
14
+ try:
15
+ params = ws.query_params
16
+ max_size = int(params["max_size"]) if params.get("max_size") else None
17
+ params["device_serial"] if params.get('device_serial') else None
18
+ streaming = ScreenMirrorOverWS(ws, max_size)
19
+ await streaming.stream() # blocking call
20
+ await streaming.teardown()
21
+ except WebSocketDisconnect:
22
+ print("WebSocket disconnected.")
23
+ if streaming:
24
+ await streaming.teardown()
25
+ except Exception as e:
26
+ print(f"Error in WebSocket endpoint: {e}")
27
+ if streaming:
28
+ await streaming.teardown()
29
+
30
+
31
+ class ScreenMirrorOverWS:
32
+
33
+ def __init__(self, ws: WebSocket, max_size: int | None = None, device_serial: str | None = None):
34
+ self.ws = ws
35
+ self.scrcpy: ScrcpyPyClient | None = None
36
+ self._is_streaming = False
37
+ self._socket_read_task: asyncio.Task[None] | None = None
38
+ self.max_size = max_size
39
+ self.device_serial = device_serial
40
+
41
+ async def stream(self):
42
+ if self._is_streaming:
43
+ return
44
+ self._is_streaming = True
45
+ self.scrcpy = ScrcpyPyClient(on_frame_update=self.on_frame_update,
46
+ max_size=self.max_size,
47
+ device_serial=self.device_serial)
48
+ await self.scrcpy.push_server()
49
+ await self.scrcpy.forward_video_socket()
50
+ await self.scrcpy.start_server()
51
+ self._socket_read_task = asyncio.create_task(self.scrcpy.read_video_socket())
52
+ print("Started streaming screen over WebSocket.")
53
+ await self._socket_read_task # Blocking call to keep streaming
54
+
55
+ async def on_frame_update(self, frame: bytes):
56
+ await self.ws.send_bytes(frame)
57
+
58
+ async def teardown(self):
59
+ self._is_streaming = False
60
+
61
+ if self.scrcpy:
62
+ print("[teardown]Stopping scrcpy client...")
63
+ try:
64
+ await self.scrcpy.stop()
65
+ finally:
66
+ self.scrcpy = None
67
+
68
+ if self._socket_read_task and not self._socket_read_task.done():
69
+ print("[teardown]Cancelling socket read task...")
70
+ try:
71
+ self._socket_read_task.cancel()
72
+ finally:
73
+ self._socket_read_task = None
74
+
75
+ print("[teardown]Teardown complete.")
Binary file
@@ -0,0 +1,178 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from contextlib import asynccontextmanager
5
+
6
+ import uvicorn
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+
10
+ from pantoqa_bridge.config import (APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, PKG_NAME,
11
+ SCRCPY_SERVER_BIN, SERVER_HOST, SERVER_PORT)
12
+ from pantoqa_bridge.logger import logger
13
+ from pantoqa_bridge.routes.action import router as action_router
14
+ from pantoqa_bridge.routes.adb import router as adb_router
15
+ from pantoqa_bridge.routes.executor import route as executor_route
16
+ from pantoqa_bridge.routes.misc import route as misc_route
17
+ from pantoqa_bridge.routes.screen_mirror import route as screen_mirror_route
18
+ from pantoqa_bridge.utils.misc import ensure_android_home, start_appium_process_sync
19
+ from pantoqa_bridge.utils.pkg import get_latest_package_version, get_pkg_version, upgrade_package
20
+ from pantoqa_bridge.utils.process import (kill_process_sync, kill_self_process,
21
+ wait_for_port_to_alive, watch_process_bg)
22
+
23
+
24
+ def create_app() -> FastAPI:
25
+ logger.info("Checking required tools...")
26
+ _pre_check_required_tools()
27
+ appium_pid = start_appium_process_sync()
28
+
29
+ @asynccontextmanager
30
+ async def lifespan(app: FastAPI):
31
+ await wait_for_port_to_alive(APPIUM_SERVER_PORT, APPIUM_SERVER_HOST, timeout=15)
32
+
33
+ def on_exit(pid: int) -> None:
34
+ logger.info("Appium process exite. Shutting down PantoQA Bridge server...")
35
+ kill_self_process()
36
+
37
+ watch_process_bg(appium_pid, on_exit=on_exit)
38
+ yield
39
+ kill_process_sync(appium_pid, timeout=10)
40
+
41
+ app = FastAPI(
42
+ title="PantoAI QA Ext",
43
+ lifespan=lifespan,
44
+ )
45
+
46
+ # Allow *.getpanto.ai, *.pantomax.co and localhost origins
47
+ allow_origin_regex = r"(https://(([a-zA-Z0-9-]+\.)*pantomax\.co|([a-zA-Z0-9-]+\.)*getpanto\.ai)|http://localhost(:\d+)?)$" # noqa: E501
48
+
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origin_regex=allow_origin_regex,
52
+ allow_credentials=True,
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+ app.include_router(misc_route)
57
+ app.include_router(executor_route)
58
+ app.include_router(action_router)
59
+ app.include_router(screen_mirror_route)
60
+ app.include_router(adb_router)
61
+ return app
62
+
63
+
64
+ def start_bridge_server(host=SERVER_HOST, port=SERVER_PORT):
65
+ try:
66
+ app = create_app()
67
+ uvicorn.run(
68
+ app,
69
+ host=host,
70
+ port=port,
71
+ )
72
+ except DependencyNotInstalledError as e:
73
+ logger.error(e)
74
+ except Exception as e:
75
+ logger.error(f"Failed to start server: {e}")
76
+
77
+
78
+ class DependencyNotInstalledError(Exception):
79
+ pass
80
+
81
+
82
+ def _pre_check_required_tools():
83
+
84
+ def _current_package_check():
85
+ curent_pkg_version = get_pkg_version(PKG_NAME)
86
+ latest_pkg_version = get_latest_package_version(PKG_NAME)
87
+ if curent_pkg_version < latest_pkg_version:
88
+ logger.info(f"[Check] A new version of {PKG_NAME} is available: {latest_pkg_version} "
89
+ f"(current: {curent_pkg_version}). Upgrading...")
90
+ upgrade_package(PKG_NAME)
91
+ logger.info(f"[Check] {PKG_NAME} upgraded successfully. Please restart.")
92
+ exit(1)
93
+ else:
94
+ logger.info(f"[Check] {PKG_NAME} is up-to-date: {curent_pkg_version}")
95
+
96
+ def _install_uiautomator2() -> None:
97
+ subprocess.check_output(
98
+ ["appium", "driver", "install", "uiautomator2"],
99
+ stderr=subprocess.STDOUT,
100
+ text=True,
101
+ )
102
+
103
+ def _install_appium() -> None:
104
+ subprocess.check_output(
105
+ ["npm", "install", "-g", "appium"],
106
+ stderr=subprocess.STDOUT,
107
+ text=True,
108
+ )
109
+
110
+ _current_package_check()
111
+
112
+ required_tools = [
113
+ ("node", "node --version", "Node.js", None),
114
+ ("npm", "npm --version", "npm", None),
115
+ ("appium", "appium --version", "Appium", _install_appium),
116
+ ]
117
+ for cmd, version_cmd, name, install_func in required_tools:
118
+ if shutil.which(cmd) is None:
119
+ if not install_func:
120
+ raise DependencyNotInstalledError(f"{name} is not installed or not found in PATH.")
121
+ logger.info(f"[Check] {name} is not installed. Installing...")
122
+ try:
123
+ install_func()
124
+ logger.info(f"[Check] {name} installed successfully.")
125
+ except subprocess.CalledProcessError as e:
126
+ raise DependencyNotInstalledError(f"Failed to install {name}: {e.output}") from e
127
+ else:
128
+ version_output = subprocess.check_output(version_cmd, shell=True, text=True).strip()
129
+ logger.info(f"[Check] {name} found: {version_output}")
130
+
131
+ # Check if uiautomator2 server is installed
132
+ uiautomator2_check = subprocess.check_output(
133
+ "appium driver list --installed --json",
134
+ shell=True,
135
+ text=True,
136
+ )
137
+ if "uiautomator2" in uiautomator2_check:
138
+ logger.info("[Check] Appium uiautomator2 driver is installed.")
139
+ else:
140
+ logger.info("[Check] Appium uiautomator2 driver is not installed. Installing...")
141
+ try:
142
+ _install_uiautomator2()
143
+ logger.info("[Check] Appium uiautomator2 driver installed successfully.")
144
+ except subprocess.CalledProcessError as e:
145
+ raise DependencyNotInstalledError(
146
+ f"Failed to install Appium uiautomator2 driver: {e.output}") from e
147
+
148
+ android_home, auto_detected = ensure_android_home()
149
+ if auto_detected and android_home:
150
+ os.environ["ANDROID_HOME"] = android_home
151
+ if android_home:
152
+ logger.info(
153
+ f"[Check] ANDROID_HOME: {android_home} {'(auto-detected)' if auto_detected else ''}")
154
+ else:
155
+ logger.warning("[Check] Android SDK not found. Please install Android SDK and set ANDROID_HOME"
156
+ "environment variable.")
157
+
158
+ adb_android_home = os.path.join(android_home, "platform-tools", "adb") if android_home else None
159
+ if shutil.which("adb"):
160
+ logger.info(f"[Check] adb found in PATH: {shutil.which('adb')}")
161
+ elif adb_android_home and os.path.exists(adb_android_home):
162
+ logger.info(f"[Check] adb found at ANDROID_HOME: {adb_android_home}")
163
+ os.environ["PATH"] += os.pathsep + os.path.dirname(adb_android_home)
164
+ else:
165
+ raise DependencyNotInstalledError("Android SDK not found. "
166
+ "Please install Android SDK "
167
+ "and set ANDROID_HOME or ANDROID_SDK_ROOT "
168
+ "environment variable.")
169
+
170
+ if SCRCPY_SERVER_BIN and os.path.exists(SCRCPY_SERVER_BIN):
171
+ logger.info(f"[Check] scrcpy server binary found at: {SCRCPY_SERVER_BIN}")
172
+ else:
173
+ logger.warning(
174
+ "[Check] scrcpy server binary not found. Screen mirroring may not work properly.")
175
+
176
+
177
+ if __name__ == '__main__':
178
+ start_bridge_server()
@@ -0,0 +1,102 @@
1
+ import base64
2
+ from io import BytesIO
3
+
4
+ import uiautomator2 as u2 # type: ignore
5
+ from adbutils import adb # type: ignore
6
+
7
+
8
+ class LocalBridgeService():
9
+
10
+ def __init__(self, device_serial: str | None = None):
11
+ self.uiautomator = u2.connect(device_serial)
12
+ self.adb = adb.device(device_serial)
13
+
14
+ def take_screenshot(self) -> str:
15
+ image = self.uiautomator.screenshot()
16
+ buf = BytesIO()
17
+ image.save(buf, format="PNG")
18
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
19
+
20
+ def click(self, x: int, y: int):
21
+ self.uiautomator.click(x, y)
22
+
23
+ def long_click(self, x: int, y: int):
24
+ self.uiautomator.long_click(x, y)
25
+
26
+ def get_os_version(self) -> str:
27
+ return str(self.uiautomator.device_info["version"])
28
+
29
+ def get_device_model(self) -> str:
30
+ return self.uiautomator.device_info["model"]
31
+
32
+ def device_resolution(self) -> dict:
33
+ w, h = self.uiautomator.window_size()
34
+ return {"width": w, "height": h}
35
+
36
+ def get_ui_dump(self) -> str:
37
+ xml = self.uiautomator.dump_hierarchy()
38
+ return xml
39
+
40
+ def get_current_app_activity(self) -> str:
41
+ activity = self.uiautomator.app_current()
42
+ return activity['activity']
43
+
44
+ def get_main_activity(self, package_name: str) -> str | None:
45
+ result = self.adb.shell(f"cmd package resolve-activity --brief {package_name}")
46
+
47
+ for line in result.splitlines():
48
+ if line.startswith(package_name + "/"):
49
+ return line.replace(package_name + "/", "", 1)
50
+
51
+ return None
52
+
53
+ def get_all_packages(self) -> list[str]:
54
+ packages = self.uiautomator.app_list()
55
+ return packages
56
+
57
+ def open_app(self, package_name: str, activity_name: str = ".MainActivity"):
58
+ self.uiautomator.app_start(package_name=package_name)
59
+
60
+ def close_keyboard(self):
61
+ self.uiautomator.hide_keyboard()
62
+
63
+ def press_enter(self):
64
+ self.uiautomator.keyevent("66")
65
+
66
+ def go_back(self):
67
+ self.uiautomator.press("back")
68
+
69
+ def is_keyboard_open(self) -> bool:
70
+ result = self.adb.shell("dumpsys input_method")
71
+ return "mInputShown=true" in result
72
+
73
+ def goto_home(self):
74
+ self.uiautomator.press("home")
75
+
76
+ def clear_all_inputs(self):
77
+ self.uiautomator.clear_text()
78
+
79
+ def backspace(self):
80
+ self.uiautomator.keyevent("67")
81
+
82
+ def input_text(self, text: str):
83
+ self.uiautomator.send_keys(text)
84
+
85
+ def swipe(self, x1: int, y1: int, x2: int, y2: int):
86
+ self.uiautomator.swipe(x1, y1, x2, y2)
87
+
88
+ def long_press(self, x: int, y: int):
89
+ self.uiautomator.long_click(x, y)
90
+
91
+ def get_oem_name(self) -> str:
92
+ info = self.uiautomator.device_info
93
+ return info["brand"]
94
+
95
+ def get_device_name(self) -> str:
96
+
97
+ info = self.uiautomator.device_info
98
+ return info["model"]
99
+
100
+ def get_os_build_version(self) -> str:
101
+ info = self.uiautomator.device_info
102
+ return str(info["sdk"])
@@ -0,0 +1,88 @@
1
+ import abc
2
+ import asyncio
3
+ import importlib
4
+ import importlib.util
5
+ import sys
6
+ import uuid
7
+ from pathlib import Path
8
+
9
+ from appium import webdriver
10
+ from appium.options.android import UiAutomator2Options
11
+
12
+ from pantoqa_bridge.config import APPIUM_SERVER_URL, MAESTRO_BIN
13
+ from pantoqa_bridge.logger import logger
14
+
15
+
16
+ class QAExecutable(abc.ABC):
17
+
18
+ def __init__(self, files: list[str]):
19
+ self.files = files
20
+
21
+ @abc.abstractmethod
22
+ async def execute(self):
23
+ ...
24
+
25
+
26
+ class MaestroExecutable(QAExecutable):
27
+
28
+ def __init__(self, files: list[str], maestro_bin: str | None = None):
29
+ super().__init__(files)
30
+ self.maestro_bin = maestro_bin or MAESTRO_BIN
31
+
32
+ async def execute(self):
33
+ yml_files = [f for f in self.files if (f.endswith('.yml') or f.endswith('.yaml'))]
34
+ if not yml_files:
35
+ logger.error("No Maestro test files found.")
36
+ return
37
+ cmd = [self.maestro_bin, "test"] + yml_files
38
+ logger.debug(f"Maestro command: {' '.join(cmd)}")
39
+
40
+ logger.info(f"Running Maestro: {' '.join(cmd)}")
41
+ result = await asyncio.create_subprocess_exec(
42
+ *cmd,
43
+ stdout=asyncio.subprocess.PIPE,
44
+ stderr=asyncio.subprocess.STDOUT,
45
+ )
46
+
47
+ if not result.stdout:
48
+ logger.error("No output from Maestro process.")
49
+ return
50
+ async for line in result.stdout:
51
+ logger.info(f"> {line.decode().strip()}")
52
+ await result.wait()
53
+ logger.info(f"Maestro finished with return code: {result.returncode}")
54
+ return result.returncode
55
+
56
+
57
+ class AppiumExecutable(QAExecutable):
58
+
59
+ def __init__(self, files: list[str], appium_url: str | None = None):
60
+ super().__init__(files)
61
+ options = UiAutomator2Options()
62
+ options.set_capability("platformName", "Android")
63
+ options.set_capability("automationName", "UiAutomator2")
64
+ options.set_capability("deviceName", "Android Device")
65
+ options.set_capability("noReset", True)
66
+ options.set_capability("fullReset", False)
67
+ self.default_wait_in_sec = 20
68
+ self.options = options
69
+ self.appium_url = appium_url or APPIUM_SERVER_URL
70
+
71
+ async def execute(self):
72
+ file = self.files[0]
73
+ filepath = Path(file).resolve()
74
+ module_name = f"_dynamic_{uuid.uuid4().hex}"
75
+ spec = importlib.util.spec_from_file_location(module_name, filepath)
76
+ assert spec, "Spec should not be None"
77
+ module = importlib.util.module_from_spec(spec)
78
+ spec.loader.exec_module(module) # type: ignore
79
+
80
+ try:
81
+ with webdriver.Remote(self.appium_url, options=self.options) as driver:
82
+ driver.implicitly_wait(self.default_wait_in_sec)
83
+ output = module.main(driver)
84
+ return output
85
+ finally:
86
+ if module_name in sys.modules:
87
+ del sys.modules[module_name]
88
+ del module
@@ -0,0 +1,131 @@
1
+ import asyncio
2
+ import functools
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from pantoqa_bridge.config import APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, IS_WINDOWS
9
+ from pantoqa_bridge.logger import logger
10
+
11
+
12
+ def find_android_home() -> str | None:
13
+ """
14
+ Find ANDROID_HOME path by checking:
15
+ 1. adb location (SDK root is parent of platform-tools)
16
+ 2. Common SDK installation paths
17
+ Returns the path as a string, or None if not found.
18
+ """
19
+
20
+ if os.environ.get("ANDROID_HOME"):
21
+ return os.environ["ANDROID_HOME"]
22
+
23
+ if os.environ.get("ANDROID_SDK_ROOT"):
24
+ return os.environ["ANDROID_SDK_ROOT"]
25
+
26
+ # Try to find SDK root from adb location
27
+ adb_path = shutil.which("adb")
28
+ if adb_path:
29
+ # adb is in <SDK>/platform-tools/adb, so SDK root is grandparent
30
+ sdk_root = Path(adb_path).resolve().parent.parent
31
+ if sdk_root.exists():
32
+ return str(sdk_root)
33
+
34
+ # Try common SDK locations
35
+ home = Path.home()
36
+ if IS_WINDOWS:
37
+ common_paths = [
38
+ Path(f"{home}/AppData/Local/Android/Sdk"),
39
+ Path("C:/Android/Sdk"),
40
+ Path("C:/Android/android-sdk"),
41
+ ]
42
+ else: # Mac/Linux
43
+ common_paths = [
44
+ Path(f"{home}/Library/Android/sdk"),
45
+ Path(f"{home}/Android/Sdk"),
46
+ Path("/usr/local/android-sdk"),
47
+ ]
48
+
49
+ for sdk_path in common_paths:
50
+ if sdk_path.exists() and (sdk_path / "platform-tools").exists():
51
+ return str(sdk_path)
52
+
53
+ return None
54
+
55
+
56
+ def ensure_android_home() -> tuple[str | None, bool]:
57
+ """
58
+ Ensure ANDROID_HOME is set in environment variables.
59
+ If not set, attempt to auto-detect and set it.
60
+
61
+ :return: A tuple containing the ANDROID_HOME path (or None if not found)
62
+ and a boolean indicating if it was auto-detected and set.
63
+ :rtype: tuple[str | None, bool]
64
+ """
65
+ if os.environ.get("ANDROID_HOME"):
66
+ return os.environ.get("ANDROID_HOME"), False
67
+
68
+ if os.environ.get("ANDROID_SDK_ROOT"):
69
+ return os.environ.get("ANDROID_SDK_ROOT"), False
70
+
71
+ android_home = find_android_home()
72
+ if not android_home:
73
+ return None, False
74
+
75
+ return android_home, True
76
+
77
+
78
+ def _get_appium_cmd() -> str:
79
+ """Get the appium command path based on the platform."""
80
+ appium_path = shutil.which("appium")
81
+ if not appium_path:
82
+ raise RuntimeError("Appium not found in PATH")
83
+ return appium_path
84
+
85
+
86
+ async def start_appium_process() -> int:
87
+ cmd = [
88
+ _get_appium_cmd(),
89
+ "--port",
90
+ str(APPIUM_SERVER_PORT),
91
+ "--address",
92
+ APPIUM_SERVER_HOST,
93
+ ]
94
+ logger.info(f"Starting Appium at http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}")
95
+ result = await asyncio.create_subprocess_exec(
96
+ *cmd,
97
+ stdout=asyncio.subprocess.DEVNULL,
98
+ stderr=asyncio.subprocess.DEVNULL,
99
+ env=os.environ.copy(),
100
+ )
101
+ logger.info(f"Running appium at pid {result.pid}...")
102
+ return result.pid
103
+
104
+
105
+ def start_appium_process_sync() -> int:
106
+ """Start Appium process synchronously. Works on both Windows and Mac."""
107
+ cmd = [_get_appium_cmd(), "--port", str(APPIUM_SERVER_PORT), "--address", APPIUM_SERVER_HOST]
108
+ logger.info(f"Starting Appium at http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}")
109
+
110
+ # On Windows, we need to avoid PIPE for long-running processes since
111
+ # unconsumed pipe buffers can cause the process to hang.
112
+ # Explicitly pass the environment to ensure ANDROID_HOME is inherited.
113
+ process = subprocess.Popen(
114
+ cmd,
115
+ stdout=subprocess.DEVNULL,
116
+ stderr=subprocess.DEVNULL,
117
+ start_new_session=True,
118
+ shell=False,
119
+ env=os.environ.copy(),
120
+ )
121
+ logger.info(f"Running Appium at pid {process.pid}...")
122
+ return process.pid
123
+
124
+
125
+ def make_sync(func):
126
+
127
+ @functools.wraps(func)
128
+ def wrapper(*args, **kwargs):
129
+ return asyncio.run(func(*args, **kwargs))
130
+
131
+ return wrapper
@@ -0,0 +1,45 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+ from importlib.metadata import version as importlib_version
5
+
6
+ from packaging import version as pkg_version
7
+
8
+ from pantoqa_bridge.logger import logger
9
+
10
+
11
+ def get_pkg_version(pkg_name: str) -> pkg_version.Version:
12
+ return pkg_version.parse(importlib_version(pkg_name))
13
+
14
+
15
+ def get_latest_package_version(package_name: str) -> pkg_version.Version:
16
+ result = subprocess.check_output(
17
+ [sys.executable, "-m", "pip", "index", "versions", package_name],
18
+ stderr=subprocess.STDOUT,
19
+ text=True,
20
+ )
21
+
22
+ if "Available versions:" not in result:
23
+ raise ValueError(f"Could not fetch versions for package {package_name}")
24
+
25
+ versions_str = result.split("Available versions:")[1].strip()
26
+ latest_version = versions_str.split(",")[0].strip()
27
+ return pkg_version.parse(latest_version)
28
+
29
+
30
+ def upgrade_package(package_name: str) -> None:
31
+ logger.info(f"Upgrading package {package_name}...")
32
+ if shutil.which("pipx"):
33
+ subprocess.check_output(
34
+ ["pipx", "upgrade", package_name],
35
+ stderr=subprocess.STDOUT,
36
+ text=True,
37
+ )
38
+ else:
39
+ subprocess.check_output(
40
+ [sys.executable, "-m", "pipx", "upgrade", package_name],
41
+ stderr=subprocess.STDOUT,
42
+ text=True,
43
+ )
44
+
45
+ logger.info(f"Package {package_name} upgraded successfully.")