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.
File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
pantoqa_bridge/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ import click
2
+
3
+ from pantoqa_bridge.config import PKG_NAME, SERVER_HOST, SERVER_PORT
4
+ from pantoqa_bridge.server import start_bridge_server
5
+ from pantoqa_bridge.tasks.executor import AppiumExecutable, MaestroExecutable, QAExecutable
6
+ from pantoqa_bridge.utils.misc import make_sync
7
+ from pantoqa_bridge.utils.pkg import get_pkg_version
8
+
9
+
10
+ @click.group(help="PantoAI QA Extension CLI", invoke_without_command=True)
11
+ @click.option("--version", is_flag=True, help="Show version and exit")
12
+ @click.pass_context
13
+ def cli(ctx: click.Context, version: bool) -> None:
14
+ if version:
15
+ click.echo(f"PantoQA Bridge version: {get_pkg_version(PKG_NAME)}")
16
+ ctx.exit(0)
17
+
18
+ if ctx.invoked_subcommand is None:
19
+ ctx.invoke(serve)
20
+
21
+
22
+ @cli.command()
23
+ @click.option("--host", default=SERVER_HOST, show_default=True, help="Bind address")
24
+ @click.option("--port", default=SERVER_PORT, show_default=True, type=int, help="Port to listen on")
25
+ def serve(host: str, port: int) -> None:
26
+ start_bridge_server(host, port)
27
+
28
+
29
+ @cli.command()
30
+ @click.option("--framework",
31
+ type=click.Choice(["appium", "maestro"], case_sensitive=False),
32
+ required=True,
33
+ help="QA framework to use")
34
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
35
+ @click.option("--maestro-bin", help="Path to Maestro binary (for maestro framework)")
36
+ @click.option("--appium-url", help="Appium server URL (for appium framework)")
37
+ @make_sync
38
+ async def execute(
39
+ framework: str,
40
+ files: list[str],
41
+ maestro_bin: str | None = None,
42
+ appium_url: str | None = None,
43
+ ) -> None:
44
+
45
+ executable: QAExecutable | None = None
46
+ if framework.lower() == "maestro":
47
+ executable = MaestroExecutable(files=files, maestro_bin=maestro_bin)
48
+ elif framework.lower() == "appium":
49
+ executable = AppiumExecutable(files=files, appium_url=appium_url)
50
+ else:
51
+ raise click.ClickException(f"Unsupported framework: {framework}")
52
+
53
+ await executable.execute()
54
+ click.echo("Execution completed.")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ cli()
@@ -0,0 +1,35 @@
1
+ import os
2
+ import platform
3
+ from importlib.resources import files
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv(
8
+ dotenv_path=".envrc",
9
+ verbose=True,
10
+ )
11
+
12
+ PKG_NAME = "pantoqa_bridge"
13
+
14
+
15
+ def resource_path(path: str) -> str:
16
+ try:
17
+ return str(files(PKG_NAME).joinpath(path))
18
+ except Exception:
19
+ return path
20
+
21
+
22
+ SCRCPY_SERVER_BIN = resource_path("scrcpy-server-v3.3.1")
23
+ SCRCPY_SERVER_VERSION = "3.3.1"
24
+ SCRCPY_SOCKET_PORT = 27888
25
+
26
+ IS_WINDOWS = platform.system() == "Windows"
27
+
28
+ SERVER_HOST = os.getenv("SERVER_HOST") or ("0.0.0.0" if not IS_WINDOWS else "127.0.0.1")
29
+ SERVER_PORT = int(os.getenv("SERVER_PORT") or "6565")
30
+
31
+ APPIUM_SERVER_HOST = os.getenv("APPIUM_SERVER_HOST") or SERVER_HOST
32
+ APPIUM_SERVER_PORT = int(os.getenv("APPIUM_SERVER_PORT") or "6566")
33
+ APPIUM_SERVER_URL = f"http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}"
34
+
35
+ MAESTRO_BIN = "maestro"
@@ -0,0 +1,112 @@
1
+ import asyncio
2
+ import subprocess
3
+ from asyncio.subprocess import Process
4
+ from typing import Literal, overload
5
+
6
+ ADB_BIN = "adb"
7
+
8
+
9
+ class ADB:
10
+
11
+ def __init__(
12
+ self,
13
+ adb: str = ADB_BIN,
14
+ device_serial: str | None = None,
15
+ ):
16
+ self.adb = adb
17
+ self.device_serial = device_serial
18
+
19
+ async def device_list(self) -> list[str]:
20
+ cmd = ["devices"]
21
+ res = await self._run_adb_cmd(cmd)
22
+ lines = res.stdout.decode().splitlines()
23
+ devices = []
24
+ for line in lines[1:]:
25
+ if line.strip():
26
+ device_id = line.split("\t")[0]
27
+ devices.append(device_id)
28
+ return devices
29
+
30
+ async def push(
31
+ self,
32
+ local_path: str,
33
+ remote_path: str,
34
+ ) -> subprocess.CompletedProcess:
35
+ cmd = ["push", local_path, remote_path]
36
+ res = await self._run_adb_cmd(cmd)
37
+ return res
38
+
39
+ async def shell(self, command: str) -> subprocess.CompletedProcess:
40
+ cmd = ["shell", command]
41
+ res = await self._run_adb_cmd(cmd)
42
+ return res
43
+
44
+ async def exec(
45
+ self,
46
+ command: list[str],
47
+ skip_check: bool = False,
48
+ ) -> subprocess.CompletedProcess:
49
+ return await self._run_adb_cmd(
50
+ command,
51
+ skip_check=skip_check,
52
+ )
53
+
54
+ async def exec_p(
55
+ self,
56
+ command: list[str],
57
+ ) -> Process:
58
+ return await self._run_adb_cmd(command, return_process=True)
59
+
60
+ @overload
61
+ async def _run_adb_cmd(
62
+ self,
63
+ cmd: list[str],
64
+ *,
65
+ return_process: Literal[True],
66
+ skip_check: bool = False,
67
+ ) -> Process:
68
+ ...
69
+
70
+ @overload
71
+ async def _run_adb_cmd(
72
+ self,
73
+ cmd: list[str],
74
+ *,
75
+ return_process: Literal[False] = False,
76
+ skip_check: bool = False,
77
+ ) -> subprocess.CompletedProcess:
78
+ ...
79
+
80
+ async def _run_adb_cmd(
81
+ self,
82
+ cmd: list[str],
83
+ *,
84
+ return_process: bool = False,
85
+ skip_check=False,
86
+ ) -> subprocess.CompletedProcess | Process:
87
+ if self.device_serial:
88
+ cmds = [self.adb, "-s", self.device_serial] + cmd
89
+ else:
90
+ cmds = [self.adb] + cmd
91
+
92
+ print(f"Executing ADB command: {' '.join(cmds)}")
93
+
94
+ process = await asyncio.create_subprocess_exec(
95
+ *cmds,
96
+ stdout=asyncio.subprocess.PIPE,
97
+ stderr=asyncio.subprocess.PIPE,
98
+ )
99
+ if return_process:
100
+ return process
101
+
102
+ stdout, stderr = await process.communicate()
103
+
104
+ if not skip_check and process.returncode != 0:
105
+ raise subprocess.CalledProcessError(
106
+ process.returncode or -1,
107
+ cmds,
108
+ output=stdout,
109
+ stderr=stderr,
110
+ )
111
+
112
+ return subprocess.CompletedProcess(cmds, process.returncode or 0, stdout, stderr)
@@ -0,0 +1,119 @@
1
+ import asyncio
2
+ import signal
3
+ import socket
4
+ from asyncio.subprocess import Process
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from pantoqa_bridge.config import SCRCPY_SERVER_BIN, SCRCPY_SERVER_VERSION, SCRCPY_SOCKET_PORT
8
+ from pantoqa_bridge.lib.adb import ADB
9
+
10
+ # ADB = "adb"
11
+
12
+
13
+ class InvalidDummyByte(Exception):
14
+ ...
15
+
16
+
17
+ class ScrcpyPyClient:
18
+
19
+ def __init__(
20
+ self,
21
+ on_frame_update: Callable[[bytes], Awaitable[None]],
22
+ max_size: int | None = None,
23
+ server_version: str = SCRCPY_SERVER_VERSION,
24
+ sever_port: int = SCRCPY_SOCKET_PORT,
25
+ server_bin: str = SCRCPY_SERVER_BIN,
26
+ adb: ADB | None = None,
27
+ device_serial: str | None = None,
28
+ ):
29
+ self.on_frame = on_frame_update
30
+ self.device_serial = device_serial
31
+ self.adb = adb or ADB(device_serial=self.device_serial)
32
+ self.server_bin = server_bin
33
+ self.socket_port = sever_port
34
+ self.server_version = server_version
35
+ self.max_size = max_size or 720
36
+ self._is_stopping = asyncio.Event()
37
+ self._remote_tmp_path = f"/data/local/tmp/scrcpy-server{server_version}"
38
+ self._scrcpy_process: Process | None = None
39
+
40
+ async def push_server(self):
41
+ await self.adb.push(self.server_bin, self._remote_tmp_path)
42
+
43
+ async def start_server(self):
44
+ cmd = [
45
+ "shell",
46
+ f"CLASSPATH={self._remote_tmp_path}",
47
+ "app_process",
48
+ "/",
49
+ "com.genymobile.scrcpy.Server",
50
+ self.server_version,
51
+ "control=false",
52
+ "audio=false",
53
+ "tunnel_forward=true",
54
+ f"max_size={self.max_size}",
55
+ ]
56
+ process = await self.adb.exec_p(cmd)
57
+ self._scrcpy_process = process
58
+ assert process.stdout, "Scrcpy process stdout is None."
59
+ data = await process.stdout.readline() # some delay until server starts
60
+ print(f"[Scrcpy]: {data!r}")
61
+ print("Scrcpy server started.")
62
+
63
+ async def forward_video_socket(self):
64
+ cmd = ["forward", f"tcp:{self.socket_port}", "localabstract:scrcpy"]
65
+ await self.adb.exec(cmd)
66
+
67
+ async def read_video_socket(self, connect_timeout: int = 30):
68
+
69
+ async def connect() -> socket.socket:
70
+ print("Trying to connect to video socket...")
71
+ sock = socket.create_connection(("127.0.0.1", self.socket_port), timeout=5)
72
+ sock.setblocking(False)
73
+ loop = asyncio.get_running_loop()
74
+ DUMMY_FIELD_LENGTH = 1
75
+ dummy_byte = await loop.sock_recv(sock, DUMMY_FIELD_LENGTH)
76
+ if not len(dummy_byte) or dummy_byte != b"\x00":
77
+ print(f"Received invalid dummy byte: {dummy_byte!r}")
78
+ sock.close()
79
+ raise InvalidDummyByte("invalid_dummy_byte")
80
+
81
+ return sock
82
+
83
+ async def connect_with_retries(connect_timeout: int = 30) -> socket.socket:
84
+ start_time = asyncio.get_running_loop().time()
85
+ while True:
86
+ print("Attempting to connect to scrcpy video socket...")
87
+ try:
88
+ sock = await connect()
89
+ return sock
90
+ except InvalidDummyByte as e:
91
+ if asyncio.get_running_loop().time() - start_time > connect_timeout:
92
+ raise TimeoutError("Timeout connecting to scrcpy video socket.")
93
+ print(f"Connection to video socket failed: {e}. Retrying...")
94
+ await asyncio.sleep(1)
95
+
96
+ sock = await connect_with_retries(connect_timeout)
97
+ print("Connected to video socket. Streaming video data...")
98
+
99
+ loop = asyncio.get_running_loop()
100
+ while not self._is_stopping.is_set():
101
+ try:
102
+ raw_data = await loop.sock_recv(sock, 0x10000)
103
+ if not raw_data:
104
+ print("No more data from video stream. Exiting...")
105
+ break
106
+ await self.on_frame(raw_data)
107
+ except Exception as e:
108
+ print(f"Error reading video stream: {e}")
109
+ raise e
110
+
111
+ async def stop(self):
112
+ self._is_stopping.set()
113
+ cmd = ["forward", "--remove", f"tcp:{self.socket_port}"]
114
+ await self.adb.exec(cmd, skip_check=True)
115
+ print("Stopped scrcpy client.")
116
+ if self._scrcpy_process:
117
+ print("Terminating scrcpy server process...")
118
+ self._scrcpy_process.send_signal(signal.SIGTERM)
119
+ await self._scrcpy_process.wait()
@@ -0,0 +1,39 @@
1
+ import logging
2
+
3
+ from rich.logging import RichHandler
4
+
5
+ logging.getLogger("httpx").setLevel(logging.ERROR)
6
+
7
+ _logger_name = 'pantoqa.automations'
8
+
9
+
10
+ def setup_logger():
11
+ formatter = logging.Formatter('%(levelname)s: %(asctime)s %(filename)s:%(lineno)d %(message)s')
12
+ loghandler = logging.StreamHandler()
13
+
14
+ logger = logging.getLogger(_logger_name)
15
+ logger.setLevel(logging.INFO)
16
+ loghandler.setFormatter(formatter)
17
+ logger.addHandler(loghandler)
18
+ logger.propagate = False
19
+ return logger
20
+
21
+
22
+ def setup_logger_cli():
23
+ formatter = logging.Formatter("%(message)s")
24
+ loghandler = RichHandler(
25
+ markup=True,
26
+ rich_tracebacks=False,
27
+ show_time=False,
28
+ show_path=False,
29
+ )
30
+
31
+ logger = logging.getLogger(_logger_name)
32
+ logger.setLevel(logging.INFO)
33
+ loghandler.setFormatter(formatter)
34
+ logger.addHandler(loghandler)
35
+ logger.propagate = False
36
+ return logger
37
+
38
+
39
+ logger = setup_logger_cli()
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class CodeFile(BaseModel):
5
+ path: str
6
+ content: str
@@ -0,0 +1,114 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter
4
+ from pydantic import BaseModel
5
+
6
+ from pantoqa_bridge.service.uiautomator_action import LocalBridgeService
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ class Action(BaseModel):
12
+ action_type: str
13
+ params: dict | None = None
14
+
15
+
16
+ class ActionRequestModel(BaseModel):
17
+ id: str
18
+ action: Action
19
+ device_serial_no: str | None = None
20
+
21
+
22
+ class ActionResponseModel(BaseModel):
23
+ id: str
24
+ type: str
25
+ data: Any | None = None
26
+
27
+
28
+ @router.post('/perform-action', response_model=ActionResponseModel)
29
+ async def perform_action(request: ActionRequestModel):
30
+ action = request.action
31
+ srv = LocalBridgeService(request.device_serial_no)
32
+ result = _process_action(srv, action)
33
+ response = ActionResponseModel(id=request.id, type="driver_action_response", data=result)
34
+ return response
35
+
36
+
37
+ def _process_action(srv: LocalBridgeService, action: Action):
38
+ action_type = action.action_type
39
+ if action_type == "screenshot":
40
+ return srv.take_screenshot()
41
+ if action_type == "click":
42
+ params = action.params
43
+ assert params, "params is not present."
44
+ srv.click(params['x'], params['y'])
45
+ return
46
+ if action_type == "long_click":
47
+ params = action.params
48
+ assert params, "params is not present."
49
+ srv.long_click(params['x'], params['y'])
50
+ return
51
+ if action_type == "get_os_version":
52
+ return srv.get_os_version()
53
+ if action_type == "get_device_model":
54
+ return srv.get_device_model()
55
+ if action_type == "get_device_resolution":
56
+ return srv.device_resolution()
57
+ if action_type == "get_ui_dump":
58
+ return srv.get_ui_dump()
59
+ if action_type == "get_current_app_activity":
60
+ return srv.get_current_app_activity()
61
+ if action_type == "get_main_activity":
62
+ params = action.params
63
+ assert params, "params is not present."
64
+ return srv.get_main_activity(params['package_name'])
65
+ if action_type == "get_all_packages":
66
+ return srv.get_all_packages()
67
+ if action_type == "open_app":
68
+ params = action.params
69
+ assert params, "params is not present."
70
+ srv.open_app(params['package_name'], params['activity_name'])
71
+ return
72
+ if action_type == "close_keyboard":
73
+ srv.close_keyboard()
74
+ return
75
+ if action_type == "press_enter":
76
+ srv.press_enter()
77
+ return
78
+ if action_type == "go_back":
79
+ srv.go_back()
80
+ return
81
+ if action_type == "is_keyboard_open":
82
+ return srv.is_keyboard_open()
83
+ if action_type == "goto_home":
84
+ srv.goto_home()
85
+ return
86
+ if action_type == "clear_all_inputs":
87
+ srv.clear_all_inputs()
88
+ return
89
+ if action_type == "backspace":
90
+ srv.backspace()
91
+ return
92
+ if action_type == "input_text":
93
+ params = action.params
94
+ assert params, "params is not present."
95
+ srv.input_text(params['text'])
96
+ return
97
+ if action_type == "swipe":
98
+ params = action.params
99
+ assert params, "params is not present."
100
+ srv.swipe(params['x1'], params['y1'], params['x2'], params['y2'])
101
+ return
102
+ if action_type == "long_press":
103
+ params = action.params
104
+ assert params, "params is not present."
105
+ srv.long_press(params['x'], params['y'])
106
+ return
107
+ if action_type == "get_oem_name":
108
+ return srv.get_oem_name()
109
+ if action_type == "get_device_name":
110
+ return srv.get_device_name()
111
+ if action_type == "get_os_build_version":
112
+ return srv.get_os_build_version()
113
+
114
+ raise ValueError(f"Unsupported action type: {action_type}")
@@ -0,0 +1,16 @@
1
+ from adbutils import adb # type:ignore
2
+ from fastapi import APIRouter
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ADBDeviceList(BaseModel):
7
+ serial_no: str
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/get-devices", response_model=list[ADBDeviceList])
14
+ async def get_available_devices():
15
+ devices = adb.device_list()
16
+ return [ADBDeviceList(serial_no=d.serial) for d in devices]
@@ -0,0 +1,117 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ import tempfile
5
+ from collections.abc import Awaitable, Callable
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from fastapi.responses import StreamingResponse
11
+ from pydantic import BaseModel
12
+
13
+ from pantoqa_bridge.config import PKG_NAME
14
+ from pantoqa_bridge.logger import logger
15
+ from pantoqa_bridge.models.code_execute import CodeFile
16
+ from pantoqa_bridge.utils.process import create_stream_pipe, stream_process
17
+
18
+ route = APIRouter()
19
+
20
+
21
+ class ExecutionRequest(BaseModel):
22
+ files: list[CodeFile]
23
+ framework: Literal['APPIUM', 'MAESTRO']
24
+
25
+
26
+ class ExecutionResult(BaseModel):
27
+ status: str
28
+ exit_code: int | None = None
29
+ message: str | None = None
30
+
31
+
32
+ @route.post("/execute")
33
+ async def execute(rawrequest: Request) -> StreamingResponse:
34
+ request_json = await rawrequest.json()
35
+ request = ExecutionRequest.model_validate(request_json)
36
+ if not request.files:
37
+ raise HTTPException(status_code=400, detail="At least one file is required")
38
+
39
+ stream, push_to_stream, done = create_stream_pipe()
40
+
41
+ async def emit(event: str, data: str) -> None:
42
+ logger.info(f"[SSE]:{event}, data: {data}")
43
+ await push_to_stream(_format_sse(event, data))
44
+
45
+ async def task():
46
+ try:
47
+ await _execute_qacode(request, emit)
48
+ finally:
49
+ done.set()
50
+
51
+ asyncio.create_task(task())
52
+ return StreamingResponse(stream, media_type="text/event-stream")
53
+
54
+
55
+ def _format_sse(event: str, data: str) -> str:
56
+ json_dict = {
57
+ "event": event,
58
+ "data": data,
59
+ }
60
+ json_str = json.dumps(json_dict)
61
+ return f"data: {json_str}\n\n"
62
+
63
+
64
+ async def _execute_qacode(
65
+ request: ExecutionRequest,
66
+ on_data: Callable[[str, str], Awaitable[None]],
67
+ ):
68
+ logger.info(f"Executing QA code with framework: {request.framework}")
69
+ copiedfiles: list[str] = []
70
+ with tempfile.TemporaryDirectory(prefix="pantoqa-qa-run-") as tmpdir:
71
+ workdir = Path(tmpdir)
72
+ for code_file in request.files:
73
+ target = workdir / code_file.path
74
+ target.parent.mkdir(parents=True, exist_ok=True)
75
+ target.write_text(code_file.content)
76
+ copiedfiles.append(str(target))
77
+
78
+ await on_data("status", "Starting testing...")
79
+
80
+ process: asyncio.subprocess.Process | None = None
81
+ try:
82
+ cmd = [
83
+ sys.executable,
84
+ "-u", # unbuffered output
85
+ "-m",
86
+ PKG_NAME,
87
+ "execute",
88
+ "--framework",
89
+ request.framework.lower(),
90
+ *copiedfiles,
91
+ ]
92
+ process = await asyncio.create_subprocess_exec(
93
+ *cmd,
94
+ stdout=asyncio.subprocess.PIPE,
95
+ stderr=asyncio.subprocess.PIPE,
96
+ cwd=workdir,
97
+ start_new_session=True,
98
+ # env={**os.environ.copy(), "PYTHONUNBUFFERED": "1"},
99
+ )
100
+ logger.info(f"Started execution process with PID: {process.pid}")
101
+ await stream_process(process, on_data)
102
+ exit_code = process.returncode
103
+ except asyncio.CancelledError:
104
+ if process and process.returncode is None:
105
+ process.kill()
106
+ await on_data("status", "Execution cancelled.")
107
+ raise
108
+ except Exception as e:
109
+ if process and process.returncode is None:
110
+ process.kill()
111
+ exit_code = process.returncode if process else -1
112
+ await on_data("exception", f"Execution failed: {str(e)}")
113
+ logger.exception(f"Execution failed: {str(e)}")
114
+
115
+ await on_data("status", f"Execution completed with exit code: {exit_code}")
116
+ logger.info(f"Execution completed with exit code: {exit_code}")
117
+ return exit_code