pantoqa-bridge 0.4.4__py3-none-any.whl → 0.4.20__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.
pantoqa_bridge/cli.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import click
2
2
 
3
3
  from pantoqa_bridge.config import PKG_NAME, SERVER_HOST, SERVER_PORT
4
- from pantoqa_bridge.server import start_bridge_server
4
+ from pantoqa_bridge.server import precheck_required_tools, start_bridge_server
5
5
  from pantoqa_bridge.tasks.executor import AppiumExecutable, MaestroExecutable, QAExecutable
6
6
  from pantoqa_bridge.utils.misc import make_sync
7
7
  from pantoqa_bridge.utils.pkg import get_pkg_version
@@ -9,12 +9,21 @@ from pantoqa_bridge.utils.pkg import get_pkg_version
9
9
 
10
10
  @click.group(help="PantoAI QA Extension CLI", invoke_without_command=True)
11
11
  @click.option("--version", is_flag=True, help="Show version and exit")
12
+ @click.option(
13
+ "--skip-pre-check",
14
+ is_flag=True,
15
+ default=False,
16
+ help="Skip pre-check of required tools",
17
+ )
12
18
  @click.pass_context
13
- def cli(ctx: click.Context, version: bool) -> None:
19
+ def cli(ctx: click.Context, version: bool, skip_pre_check: bool) -> None:
14
20
  if version:
15
21
  click.echo(f"PantoQA Bridge version: {get_pkg_version(PKG_NAME)}")
16
22
  ctx.exit(0)
17
23
 
24
+ if not skip_pre_check:
25
+ precheck_required_tools()
26
+
18
27
  if ctx.invoked_subcommand is None:
19
28
  ctx.invoke(serve)
20
29
 
@@ -34,19 +43,21 @@ def serve(host: str, port: int) -> None:
34
43
  @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
35
44
  @click.option("--maestro-bin", help="Path to Maestro binary (for maestro framework)")
36
45
  @click.option("--appium-url", help="Appium server URL (for appium framework)")
46
+ @click.option("--device", help="Device serial number to run tests on")
37
47
  @make_sync
38
48
  async def execute(
39
49
  framework: str,
40
50
  files: list[str],
41
51
  maestro_bin: str | None = None,
42
52
  appium_url: str | None = None,
53
+ device: str | None = None,
43
54
  ) -> None:
44
55
 
45
56
  executable: QAExecutable | None = None
46
57
  if framework.lower() == "maestro":
47
- executable = MaestroExecutable(files=files, maestro_bin=maestro_bin)
58
+ executable = MaestroExecutable(files=files, maestro_bin=maestro_bin, device_serial=device)
48
59
  elif framework.lower() == "appium":
49
- executable = AppiumExecutable(files=files, appium_url=appium_url)
60
+ executable = AppiumExecutable(files=files, appium_url=appium_url, device_serial=device)
50
61
  else:
51
62
  raise click.ClickException(f"Unsupported framework: {framework}")
52
63
 
pantoqa_bridge/config.py CHANGED
@@ -33,3 +33,9 @@ APPIUM_SERVER_PORT = int(os.getenv("APPIUM_SERVER_PORT") or "6566")
33
33
  APPIUM_SERVER_URL = f"http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}"
34
34
 
35
35
  MAESTRO_BIN = "maestro"
36
+ INACTIVITY_TIMEOUT = 5 * 60
37
+
38
+ APPIUM_BIN = os.getenv("APPIUM_BIN") or "appium"
39
+ NODE_PATH = os.getenv("NODE_PATH") or "node"
40
+ # ADBUTILS_ADB_PATH is also used by https://github.com/openatx/adbutils package
41
+ ADB_BIN = os.getenv("ADBUTILS_ADB_PATH") or "adb"
pantoqa_bridge/lib/adb.py CHANGED
@@ -3,7 +3,8 @@ import subprocess
3
3
  from asyncio.subprocess import Process
4
4
  from typing import Literal, overload
5
5
 
6
- ADB_BIN = "adb"
6
+ from pantoqa_bridge.config import ADB_BIN
7
+ from pantoqa_bridge.logger import logger
7
8
 
8
9
 
9
10
  class ADB:
@@ -89,7 +90,7 @@ class ADB:
89
90
  else:
90
91
  cmds = [self.adb] + cmd
91
92
 
92
- print(f"Executing ADB command: {' '.join(cmds)}")
93
+ logger.info(f"Executing ADB command: {' '.join(cmds)}")
93
94
 
94
95
  process = await asyncio.create_subprocess_exec(
95
96
  *cmds,
@@ -6,8 +6,7 @@ from collections.abc import Awaitable, Callable
6
6
 
7
7
  from pantoqa_bridge.config import SCRCPY_SERVER_BIN, SCRCPY_SERVER_VERSION, SCRCPY_SOCKET_PORT
8
8
  from pantoqa_bridge.lib.adb import ADB
9
-
10
- # ADB = "adb"
9
+ from pantoqa_bridge.logger import logger
11
10
 
12
11
 
13
12
  class InvalidDummyByte(Exception):
@@ -34,7 +33,7 @@ class ScrcpyPyClient:
34
33
  self.server_version = server_version
35
34
  self.max_size = max_size or 720
36
35
  self._is_stopping = asyncio.Event()
37
- self._remote_tmp_path = f"/data/local/tmp/scrcpy-server{server_version}"
36
+ self._remote_tmp_path = f"/data/local/tmp/scrcpy-server{self.server_version}"
38
37
  self._scrcpy_process: Process | None = None
39
38
 
40
39
  async def push_server(self):
@@ -57,8 +56,8 @@ class ScrcpyPyClient:
57
56
  self._scrcpy_process = process
58
57
  assert process.stdout, "Scrcpy process stdout is None."
59
58
  data = await process.stdout.readline() # some delay until server starts
60
- print(f"[Scrcpy]: {data!r}")
61
- print("Scrcpy server started.")
59
+ logger.info(f"[Scrcpy]: {data!r}")
60
+ logger.info("Scrcpy server started.")
62
61
 
63
62
  async def forward_video_socket(self):
64
63
  cmd = ["forward", f"tcp:{self.socket_port}", "localabstract:scrcpy"]
@@ -67,14 +66,14 @@ class ScrcpyPyClient:
67
66
  async def read_video_socket(self, connect_timeout: int = 30):
68
67
 
69
68
  async def connect() -> socket.socket:
70
- print("Trying to connect to video socket...")
69
+ logger.info("Trying to connect to video socket...")
71
70
  sock = socket.create_connection(("127.0.0.1", self.socket_port), timeout=5)
72
71
  sock.setblocking(False)
73
72
  loop = asyncio.get_running_loop()
74
73
  DUMMY_FIELD_LENGTH = 1
75
74
  dummy_byte = await loop.sock_recv(sock, DUMMY_FIELD_LENGTH)
76
75
  if not len(dummy_byte) or dummy_byte != b"\x00":
77
- print(f"Received invalid dummy byte: {dummy_byte!r}")
76
+ logger.info(f"Received invalid dummy byte: {dummy_byte!r}")
78
77
  sock.close()
79
78
  raise InvalidDummyByte("invalid_dummy_byte")
80
79
 
@@ -83,37 +82,50 @@ class ScrcpyPyClient:
83
82
  async def connect_with_retries(connect_timeout: int = 30) -> socket.socket:
84
83
  start_time = asyncio.get_running_loop().time()
85
84
  while True:
86
- print("Attempting to connect to scrcpy video socket...")
85
+ logger.info("Attempting to connect to scrcpy video socket...")
87
86
  try:
88
87
  sock = await connect()
89
88
  return sock
90
89
  except InvalidDummyByte as e:
91
90
  if asyncio.get_running_loop().time() - start_time > connect_timeout:
92
91
  raise TimeoutError("Timeout connecting to scrcpy video socket.")
93
- print(f"Connection to video socket failed: {e}. Retrying...")
92
+ logger.info(f"Connection to video socket failed: {e}. Retrying...")
94
93
  await asyncio.sleep(1)
95
94
 
96
95
  sock = await connect_with_retries(connect_timeout)
97
- print("Connected to video socket. Streaming video data...")
96
+ logger.info("Connected to video socket. Streaming video data...")
98
97
 
99
98
  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...")
99
+ try:
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
+ logger.info("No more data from video stream. Exiting...")
105
+ break
106
+ await self.on_frame(raw_data)
107
+ except asyncio.CancelledError:
108
+ logger.info("Video socket read task cancelled.")
105
109
  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
+ except Exception as e:
111
+ logger.info(f"Error reading video stream: {e}")
112
+ break
113
+ finally:
114
+ logger.info("Closing video socket...")
115
+ try:
116
+ sock.close()
117
+ except Exception:
118
+ pass
110
119
 
111
120
  async def stop(self):
112
121
  self._is_stopping.set()
113
122
  cmd = ["forward", "--remove", f"tcp:{self.socket_port}"]
114
123
  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()
124
+ logger.info("Stopped scrcpy client.")
125
+ try:
126
+ if self._scrcpy_process:
127
+ logger.info("Terminating scrcpy server process...")
128
+ self._scrcpy_process.send_signal(signal.SIGTERM)
129
+ await self._scrcpy_process.wait()
130
+ except ProcessLookupError:
131
+ pass
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Action(BaseModel):
5
+ action_type: str
6
+ params: dict | None = None
@@ -3,16 +3,12 @@ from typing import Any
3
3
  from fastapi import APIRouter
4
4
  from pydantic import BaseModel
5
5
 
6
- from pantoqa_bridge.service.uiautomator_action import LocalBridgeService
6
+ from pantoqa_bridge.models.misc import Action
7
+ from pantoqa_bridge.utils.service_manager import get_service
7
8
 
8
9
  router = APIRouter()
9
10
 
10
11
 
11
- class Action(BaseModel):
12
- action_type: str
13
- params: dict | None = None
14
-
15
-
16
12
  class ActionRequestModel(BaseModel):
17
13
  id: str
18
14
  action: Action
@@ -27,88 +23,11 @@ class ActionResponseModel(BaseModel):
27
23
 
28
24
  @router.post('/perform-action', response_model=ActionResponseModel)
29
25
  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}")
26
+ srv = get_service(request.device_serial_no)
27
+ result = srv.process(request.action)
28
+
29
+ return ActionResponseModel(
30
+ id=request.id,
31
+ type="driver_action_response",
32
+ data=result,
33
+ )
@@ -13,7 +13,9 @@ from pydantic import BaseModel
13
13
  from pantoqa_bridge.config import PKG_NAME
14
14
  from pantoqa_bridge.logger import logger
15
15
  from pantoqa_bridge.models.code_execute import CodeFile
16
- from pantoqa_bridge.utils.process import create_stream_pipe, stream_process
16
+ from pantoqa_bridge.utils.process import (create_stream_pipe, kill_process_gracefully,
17
+ stream_process)
18
+ from pantoqa_bridge.utils.service_manager import remove_all_services
17
19
 
18
20
  route = APIRouter()
19
21
 
@@ -21,6 +23,7 @@ route = APIRouter()
21
23
  class ExecutionRequest(BaseModel):
22
24
  files: list[CodeFile]
23
25
  framework: Literal['APPIUM', 'MAESTRO']
26
+ device_serial: str | None = None
24
27
 
25
28
 
26
29
  class ExecutionResult(BaseModel):
@@ -52,6 +55,11 @@ async def execute(rawrequest: Request) -> StreamingResponse:
52
55
  return StreamingResponse(stream, media_type="text/event-stream")
53
56
 
54
57
 
58
+ @route.delete("/stop-test/{process_id}")
59
+ async def stop_test(process_id: str):
60
+ kill_process_gracefully(int(process_id))
61
+
62
+
55
63
  def _format_sse(event: str, data: str) -> str:
56
64
  json_dict = {
57
65
  "event": event,
@@ -72,23 +80,27 @@ async def _execute_qacode(
72
80
  for code_file in request.files:
73
81
  target = workdir / code_file.path
74
82
  target.parent.mkdir(parents=True, exist_ok=True)
75
- target.write_text(code_file.content)
83
+ target.write_text(code_file.content, encoding="utf-8")
76
84
  copiedfiles.append(str(target))
77
85
 
78
86
  await on_data("status", "Starting testing...")
79
87
 
80
88
  process: asyncio.subprocess.Process | None = None
81
89
  try:
90
+ remove_all_services() # kill all the LocalBridge instance and automator connection.
82
91
  cmd = [
83
92
  sys.executable,
84
93
  "-u", # unbuffered output
85
94
  "-m",
86
95
  PKG_NAME,
96
+ "--skip-pre-check",
87
97
  "execute",
88
98
  "--framework",
89
99
  request.framework.lower(),
90
- *copiedfiles,
91
100
  ]
101
+ if request.device_serial:
102
+ cmd.extend(["--device", request.device_serial])
103
+ cmd.extend(copiedfiles)
92
104
  process = await asyncio.create_subprocess_exec(
93
105
  *cmd,
94
106
  stdout=asyncio.subprocess.PIPE,
@@ -98,6 +110,7 @@ async def _execute_qacode(
98
110
  # env={**os.environ.copy(), "PYTHONUNBUFFERED": "1"},
99
111
  )
100
112
  logger.info(f"Started execution process with PID: {process.pid}")
113
+ await on_data("pid", str(process.pid))
101
114
  await stream_process(process, on_data)
102
115
  exit_code = process.returncode
103
116
  except asyncio.CancelledError:
@@ -3,6 +3,7 @@ import asyncio
3
3
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4
4
 
5
5
  from pantoqa_bridge.lib.scrcpy import ScrcpyPyClient
6
+ from pantoqa_bridge.logger import logger
6
7
 
7
8
  route = APIRouter()
8
9
 
@@ -14,16 +15,14 @@ async def ws_endpoint(ws: WebSocket):
14
15
  try:
15
16
  params = ws.query_params
16
17
  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)
18
+ device_serial = params.get("device_serial") if params.get("device_serial") else None
19
+ streaming = ScreenMirrorOverWS(ws, max_size, device_serial)
19
20
  await streaming.stream() # blocking call
20
- await streaming.teardown()
21
21
  except WebSocketDisconnect:
22
- print("WebSocket disconnected.")
23
- if streaming:
24
- await streaming.teardown()
22
+ logger.warning("WebSocket disconnected.")
25
23
  except Exception as e:
26
- print(f"Error in WebSocket endpoint: {e}")
24
+ logger.exception(f"Error in WebSocket endpoint: {e}")
25
+ finally:
27
26
  if streaming:
28
27
  await streaming.teardown()
29
28
 
@@ -49,7 +48,7 @@ class ScreenMirrorOverWS:
49
48
  await self.scrcpy.forward_video_socket()
50
49
  await self.scrcpy.start_server()
51
50
  self._socket_read_task = asyncio.create_task(self.scrcpy.read_video_socket())
52
- print("Started streaming screen over WebSocket.")
51
+ logger.info("Started streaming screen over WebSocket.")
53
52
  await self._socket_read_task # Blocking call to keep streaming
54
53
 
55
54
  async def on_frame_update(self, frame: bytes):
@@ -59,17 +58,17 @@ class ScreenMirrorOverWS:
59
58
  self._is_streaming = False
60
59
 
61
60
  if self.scrcpy:
62
- print("[teardown]Stopping scrcpy client...")
61
+ logger.info("[teardown]Stopping scrcpy client...")
63
62
  try:
64
63
  await self.scrcpy.stop()
65
64
  finally:
66
65
  self.scrcpy = None
67
66
 
68
67
  if self._socket_read_task and not self._socket_read_task.done():
69
- print("[teardown]Cancelling socket read task...")
68
+ logger.info("[teardown]Cancelling socket read task...")
70
69
  try:
71
70
  self._socket_read_task.cancel()
72
71
  finally:
73
72
  self._socket_read_task = None
74
73
 
75
- print("[teardown]Teardown complete.")
74
+ logger.info("[teardown]Teardown complete.")
pantoqa_bridge/server.py CHANGED
@@ -7,7 +7,7 @@ import uvicorn
7
7
  from fastapi import FastAPI
8
8
  from fastapi.middleware.cors import CORSMiddleware
9
9
 
10
- from pantoqa_bridge.config import (APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, PKG_NAME,
10
+ from pantoqa_bridge.config import (APPIUM_BIN, APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, PKG_NAME,
11
11
  SCRCPY_SERVER_BIN, SERVER_HOST, SERVER_PORT)
12
12
  from pantoqa_bridge.logger import logger
13
13
  from pantoqa_bridge.routes.action import router as action_router
@@ -15,28 +15,30 @@ from pantoqa_bridge.routes.adb import router as adb_router
15
15
  from pantoqa_bridge.routes.executor import route as executor_route
16
16
  from pantoqa_bridge.routes.misc import route as misc_route
17
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
18
+ from pantoqa_bridge.utils.misc import ensure_android_home, start_appium_process_in_bg
19
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)
20
+ from pantoqa_bridge.utils.process import (kill_process_by_port, kill_self_process,
21
+ wait_for_port_to_alive)
22
22
 
23
23
 
24
24
  def create_app() -> FastAPI:
25
- logger.info("Checking required tools...")
26
- _pre_check_required_tools()
27
- appium_pid = start_appium_process_sync()
25
+
26
+ def on_exit(pid: int, returncode: int) -> None:
27
+ if returncode == 0:
28
+ logger.info(f"Appium process exited normally PID={pid}.")
29
+ return
30
+
31
+ logger.info(f"Appium process exited PID={pid}. Return code: {returncode}. ")
32
+ logger.info("Killing bridge server...")
33
+ kill_self_process()
34
+
35
+ start_appium_process_in_bg(on_exit=on_exit)
28
36
 
29
37
  @asynccontextmanager
30
38
  async def lifespan(app: FastAPI):
31
39
  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
40
  yield
39
- kill_process_sync(appium_pid, timeout=10)
41
+ kill_process_by_port(APPIUM_SERVER_PORT, timeout=5)
40
42
 
41
43
  app = FastAPI(
42
44
  title="PantoAI QA Ext",
@@ -79,7 +81,7 @@ class DependencyNotInstalledError(Exception):
79
81
  pass
80
82
 
81
83
 
82
- def _pre_check_required_tools():
84
+ def precheck_required_tools():
83
85
 
84
86
  def _current_package_check():
85
87
  curent_pkg_version = get_pkg_version(PKG_NAME)
@@ -95,7 +97,7 @@ def _pre_check_required_tools():
95
97
 
96
98
  def _install_uiautomator2() -> None:
97
99
  subprocess.check_output(
98
- ["appium", "driver", "install", "uiautomator2"],
100
+ [APPIUM_BIN, "driver", "install", "uiautomator2"],
99
101
  stderr=subprocess.STDOUT,
100
102
  text=True,
101
103
  )
@@ -112,7 +114,7 @@ def _pre_check_required_tools():
112
114
  required_tools = [
113
115
  ("node", "node --version", "Node.js", None),
114
116
  ("npm", "npm --version", "npm", None),
115
- ("appium", "appium --version", "Appium", _install_appium),
117
+ (APPIUM_BIN, f"{APPIUM_BIN} --version", "Appium", _install_appium),
116
118
  ]
117
119
  for cmd, version_cmd, name, install_func in required_tools:
118
120
  if shutil.which(cmd) is None:
@@ -130,7 +132,7 @@ def _pre_check_required_tools():
130
132
 
131
133
  # Check if uiautomator2 server is installed
132
134
  uiautomator2_check = subprocess.check_output(
133
- "appium driver list --installed --json",
135
+ f"{APPIUM_BIN} driver list --installed --json",
134
136
  shell=True,
135
137
  text=True,
136
138
  )
@@ -1,45 +1,89 @@
1
1
  import base64
2
+ import re
2
3
  from io import BytesIO
3
4
 
4
5
  import uiautomator2 as u2 # type: ignore
5
6
  from adbutils import adb # type: ignore
6
7
 
8
+ from pantoqa_bridge.logger import logger
9
+
7
10
 
8
11
  class LocalBridgeService():
9
12
 
10
- def __init__(self, device_serial: str | None = None):
11
- self.uiautomator = u2.connect(device_serial)
13
+ def __init__(self, device_serial: str):
14
+ self.uiautomator: u2.Device | None = None
12
15
  self.adb = adb.device(device_serial)
16
+ self._device_resolution: dict | None = None
17
+
18
+ def __enter__(self):
19
+ # self.uiautomator.start_uiautomator()
20
+ return self
21
+
22
+ def __exit__(self, exc_type, exc, tb):
23
+ self.stop()
24
+ return False
25
+
26
+ def stop(self):
27
+ if self.uiautomator:
28
+ self.uiautomator.stop_uiautomator()
29
+ self.uiautomator = None
30
+ ...
13
31
 
14
32
  def take_screenshot(self) -> str:
15
- image = self.uiautomator.screenshot()
33
+ image = self.adb.screenshot()
16
34
  buf = BytesIO()
17
35
  image.save(buf, format="PNG")
18
36
  return base64.b64encode(buf.getvalue()).decode("utf-8")
19
37
 
20
38
  def click(self, x: int, y: int):
21
- self.uiautomator.click(x, y)
39
+ self.adb.click(x, y)
22
40
 
23
41
  def long_click(self, x: int, y: int):
24
- self.uiautomator.long_click(x, y)
42
+ duration = 100
43
+ self.adb.swipe(x, y, x, y, duration=duration)
25
44
 
26
45
  def get_os_version(self) -> str:
27
- return str(self.uiautomator.device_info["version"])
46
+ version = self.adb.shell("getprop ro.build.version.release")
47
+ return f"Android {version.strip()}" if version else "unknown"
28
48
 
29
49
  def get_device_model(self) -> str:
30
- return self.uiautomator.device_info["model"]
50
+ result = self.adb.shell("getprop ro.product.model")
51
+ return result.strip()
31
52
 
32
53
  def device_resolution(self) -> dict:
33
- w, h = self.uiautomator.window_size()
34
- return {"width": w, "height": h}
54
+ if self._device_resolution:
55
+ return self._device_resolution
56
+ txt_result = self.adb.shell("wm size")
57
+
58
+ txt_result = txt_result.replace("Physical size:", "").strip()
59
+
60
+ match = re.search(r'(\d+)\s*x\s*(\d+)', txt_result)
61
+ if not match:
62
+ raise ValueError(f"Could not parse resolution from: {txt_result!r}")
63
+
64
+ width, height = map(int, match.groups())
65
+ logger.debug(f"Device resolution: {width}x{height}")
66
+ self._device_resolution = {"width": width, "height": height}
67
+ return self._device_resolution
35
68
 
36
69
  def get_ui_dump(self) -> str:
70
+ try:
71
+ xml = self.adb.dump_hierarchy()
72
+ return xml
73
+ except Exception:
74
+ pass
75
+
76
+ if not self.uiautomator:
77
+ self.uiautomator = u2.connect_adb(self.adb.serial)
78
+
37
79
  xml = self.uiautomator.dump_hierarchy()
38
80
  return xml
39
81
 
40
82
  def get_current_app_activity(self) -> str:
41
- activity = self.uiautomator.app_current()
42
- return activity['activity']
83
+ data = self.adb.app_current()
84
+ package = data.package
85
+ activity = data.activity
86
+ return f"{package}/{activity}"
43
87
 
44
88
  def get_main_activity(self, package_name: str) -> str | None:
45
89
  result = self.adb.shell(f"cmd package resolve-activity --brief {package_name}")
@@ -51,52 +95,74 @@ class LocalBridgeService():
51
95
  return None
52
96
 
53
97
  def get_all_packages(self) -> list[str]:
54
- packages = self.uiautomator.app_list()
98
+ packages = self.adb.list_packages()
55
99
  return packages
56
100
 
57
101
  def open_app(self, package_name: str, activity_name: str = ".MainActivity"):
58
- self.uiautomator.app_start(package_name=package_name)
102
+ self.adb.app_start(package_name, activity_name)
59
103
 
60
104
  def close_keyboard(self):
61
- self.uiautomator.hide_keyboard()
105
+ self.go_back()
62
106
 
63
107
  def press_enter(self):
64
- self.uiautomator.keyevent("66")
108
+ self.adb.shell("input keyevent 66")
65
109
 
66
110
  def go_back(self):
67
- self.uiautomator.press("back")
111
+ self.adb.shell("input keyevent 4")
68
112
 
69
113
  def is_keyboard_open(self) -> bool:
70
114
  result = self.adb.shell("dumpsys input_method")
71
115
  return "mInputShown=true" in result
72
116
 
73
117
  def goto_home(self):
74
- self.uiautomator.press("home")
118
+ self.adb.shell("input keyevent 3")
75
119
 
76
120
  def clear_all_inputs(self):
77
- self.uiautomator.clear_text()
121
+ self.adb.shell("input keycombination 113 29") # select all (ctrl + a)
122
+ self.backspace()
78
123
 
79
124
  def backspace(self):
80
- self.uiautomator.keyevent("67")
125
+ self.adb.shell("input keyevent 67")
81
126
 
82
127
  def input_text(self, text: str):
83
- self.uiautomator.send_keys(text)
128
+ text = _adb_encode(text)
129
+ self.adb.shell(f'input text "{text}"')
84
130
 
85
131
  def swipe(self, x1: int, y1: int, x2: int, y2: int):
86
- self.uiautomator.swipe(x1, y1, x2, y2)
132
+ self.adb.swipe(x1, y1, x2, y2)
87
133
 
88
134
  def long_press(self, x: int, y: int):
89
- self.uiautomator.long_click(x, y)
135
+ duration = 100
136
+ self.adb.swipe(x, y, x, y, duration=duration)
90
137
 
91
138
  def get_oem_name(self) -> str:
92
- info = self.uiautomator.device_info
93
- return info["brand"]
139
+ result = self.adb.shell("getprop ro.product.manufacturer")
140
+ return result.strip()
94
141
 
95
142
  def get_device_name(self) -> str:
96
-
97
- info = self.uiautomator.device_info
98
- return info["model"]
143
+ result = self.adb.shell("getprop ro.product.model")
144
+ return result.strip()
99
145
 
100
146
  def get_os_build_version(self) -> str:
101
- info = self.uiautomator.device_info
102
- return str(info["sdk"])
147
+ result = self.adb.shell("getprop ro.build.display.id")
148
+ return result.strip()
149
+
150
+
151
+ def _adb_encode(text: str) -> str:
152
+ escape_map = {
153
+ '%': r'\%',
154
+ ' ': '%s',
155
+ "'": r"\'",
156
+ '"': r'\"',
157
+ '&': r'\&',
158
+ '(': r'\(',
159
+ ')': r'\)',
160
+ ';': r'\;',
161
+ '<': r'\<',
162
+ '>': r'\>',
163
+ '\\': r'\\',
164
+ }
165
+ encoded = ""
166
+ for ch in text:
167
+ encoded += escape_map.get(ch, ch)
168
+ return encoded
@@ -25,16 +25,23 @@ class QAExecutable(abc.ABC):
25
25
 
26
26
  class MaestroExecutable(QAExecutable):
27
27
 
28
- def __init__(self, files: list[str], maestro_bin: str | None = None):
28
+ def __init__(self,
29
+ files: list[str],
30
+ maestro_bin: str | None = None,
31
+ device_serial: str | None = None):
29
32
  super().__init__(files)
30
33
  self.maestro_bin = maestro_bin or MAESTRO_BIN
34
+ self.device_serial = device_serial
31
35
 
32
36
  async def execute(self):
33
37
  yml_files = [f for f in self.files if (f.endswith('.yml') or f.endswith('.yaml'))]
34
38
  if not yml_files:
35
39
  logger.error("No Maestro test files found.")
36
40
  return
37
- cmd = [self.maestro_bin, "test"] + yml_files
41
+ cmd = [self.maestro_bin, "test"]
42
+ if self.device_serial:
43
+ cmd.extend(["--device", self.device_serial])
44
+ cmd.extend(yml_files)
38
45
  logger.debug(f"Maestro command: {' '.join(cmd)}")
39
46
 
40
47
  logger.info(f"Running Maestro: {' '.join(cmd)}")
@@ -56,14 +63,20 @@ class MaestroExecutable(QAExecutable):
56
63
 
57
64
  class AppiumExecutable(QAExecutable):
58
65
 
59
- def __init__(self, files: list[str], appium_url: str | None = None):
66
+ def __init__(self,
67
+ files: list[str],
68
+ appium_url: str | None = None,
69
+ device_serial: str | None = None):
60
70
  super().__init__(files)
61
71
  options = UiAutomator2Options()
62
72
  options.set_capability("platformName", "Android")
63
73
  options.set_capability("automationName", "UiAutomator2")
64
74
  options.set_capability("deviceName", "Android Device")
75
+ if device_serial:
76
+ options.set_capability("udid", device_serial)
65
77
  options.set_capability("noReset", True)
66
78
  options.set_capability("fullReset", False)
79
+ options.set_capability("ignoreHiddenApiPolicyError", True)
67
80
  self.default_wait_in_sec = 20
68
81
  self.options = options
69
82
  self.appium_url = appium_url or APPIUM_SERVER_URL
@@ -3,10 +3,15 @@ import functools
3
3
  import os
4
4
  import shutil
5
5
  import subprocess
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
6
9
  from pathlib import Path
7
10
 
8
- from pantoqa_bridge.config import APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, IS_WINDOWS
11
+ from pantoqa_bridge.config import (APPIUM_BIN, APPIUM_SERVER_HOST, APPIUM_SERVER_PORT, IS_WINDOWS,
12
+ NODE_PATH)
9
13
  from pantoqa_bridge.logger import logger
14
+ from pantoqa_bridge.utils.process import kill_process_by_port
10
15
 
11
16
 
12
17
  def find_android_home() -> str | None:
@@ -75,51 +80,65 @@ def ensure_android_home() -> tuple[str | None, bool]:
75
80
  return android_home, True
76
81
 
77
82
 
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
83
+ def start_appium_process_in_bg(on_exit: Callable[[int, int], None]):
84
+
85
+ def start():
86
+ if not APPIUM_BIN.endswith(".js"):
87
+ cmd = [
88
+ APPIUM_BIN,
89
+ "--session-override",
90
+ "--port",
91
+ str(APPIUM_SERVER_PORT),
92
+ "--address",
93
+ APPIUM_SERVER_HOST,
94
+ ]
95
+ else:
96
+ cmd = [
97
+ NODE_PATH,
98
+ APPIUM_BIN,
99
+ "--session-override",
100
+ "--port",
101
+ str(APPIUM_SERVER_PORT),
102
+ "--address",
103
+ APPIUM_SERVER_HOST,
104
+ ]
105
+ logger.info(f"Starting Appium at http://{APPIUM_SERVER_HOST}:{APPIUM_SERVER_PORT}")
106
+
107
+ proc = subprocess.Popen(
108
+ cmd,
109
+ stdout=subprocess.DEVNULL,
110
+ stderr=subprocess.DEVNULL,
111
+ start_new_session=True,
112
+ shell=False,
113
+ env=os.environ.copy(),
114
+ )
115
+ logger.info("Appium process started with PID: %d", proc.pid)
116
+ return proc
117
+
118
+ def start_in_loop():
119
+ max_retries = 2
120
+ proc: subprocess.Popen[bytes] | None = None
121
+ while max_retries > 0:
122
+ max_retries -= 1
123
+ proc = start()
124
+ proc.wait()
125
+ if proc.returncode != 1:
126
+ break
127
+ logger.error("Appium process exited with errors. Return code: %d", proc.returncode)
128
+ kill_process_by_port(APPIUM_SERVER_PORT, timeout=5)
129
+ time.sleep(1)
130
+ if proc:
131
+ on_exit(proc.pid, proc.returncode)
132
+ else:
133
+ on_exit(-1, -1)
134
+
135
+ try:
136
+ kill_process_by_port(APPIUM_SERVER_PORT, timeout=5)
137
+ except Exception:
138
+ pass
139
+ thread = threading.Thread(target=start_in_loop, daemon=True)
140
+ thread.start()
141
+ logger.info("Starting Appium process in background.")
123
142
 
124
143
 
125
144
  def make_sync(func):
@@ -5,6 +5,7 @@ from importlib.metadata import version as importlib_version
5
5
 
6
6
  from packaging import version as pkg_version
7
7
 
8
+ from pantoqa_bridge.config import IS_WINDOWS
8
9
  from pantoqa_bridge.logger import logger
9
10
 
10
11
 
@@ -29,17 +30,24 @@ def get_latest_package_version(package_name: str) -> pkg_version.Version:
29
30
 
30
31
  def upgrade_package(package_name: str) -> None:
31
32
  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
- )
33
+ try:
34
+ if shutil.which("pipx"):
35
+ subprocess.check_output(
36
+ ["pipx", "upgrade", package_name],
37
+ stderr=subprocess.STDOUT,
38
+ text=True,
39
+ )
40
+ else:
41
+ subprocess.check_output(
42
+ [sys.executable, "-m", "pipx", "upgrade", package_name],
43
+ stderr=subprocess.STDOUT,
44
+ text=True,
45
+ )
46
+ except subprocess.CalledProcessError as e:
47
+ if IS_WINDOWS and "PermissionError" in e.output and "WinError 32" in e.output:
48
+ return
49
+ logger.error(
50
+ f"Failed to upgrade package {package_name}: {e.output}. Return Code: {e.returncode}")
51
+ raise
44
52
 
45
53
  logger.info(f"Package {package_name} upgraded successfully.")
@@ -77,7 +77,7 @@ async def wait_for_port_to_alive(port: int, host: str = "127.0.0.1", timeout=15)
77
77
  with socket.create_connection((host, port), timeout=1):
78
78
  return True
79
79
  except OSError:
80
- await asyncio.sleep(0.5)
80
+ await asyncio.sleep(0.25)
81
81
  raise TimeoutError("Appium did not start in time")
82
82
 
83
83
 
@@ -154,7 +154,7 @@ def kill_self_process():
154
154
  kill_by_pid(self_pid)
155
155
 
156
156
 
157
- def kill_process_sync(pid: int, timeout: int = 10) -> None:
157
+ def kill_process_gracefully(pid: int, timeout: int = 10) -> None:
158
158
  if pid <= 0:
159
159
  return
160
160
 
@@ -181,3 +181,47 @@ def kill_process_sync(pid: int, timeout: int = 10) -> None:
181
181
  # Step 3: force kill
182
182
  logger.info(f"Killing process {pid}...")
183
183
  force_kill_by_pid(pid)
184
+
185
+
186
+ def find_pids_by_port(port: int) -> list[int]:
187
+ pids: list[int] = []
188
+
189
+ try:
190
+ if IS_WINDOWS:
191
+ out = subprocess.check_output(
192
+ f'netstat -ano | findstr :{port}',
193
+ shell=True,
194
+ text=True,
195
+ )
196
+ for line in out.splitlines():
197
+ if "LISTENING" in line:
198
+ pid_str = line.split()[-1]
199
+ pids.append(int(pid_str))
200
+
201
+ return pids
202
+
203
+ out = subprocess.check_output(
204
+ ["lsof", "-t", f"-i:{port}"],
205
+ text=True,
206
+ )
207
+ for pid_str in out.split():
208
+ pids.append(int(pid_str))
209
+ except subprocess.CalledProcessError:
210
+ # No process found
211
+ pass
212
+
213
+ return pids
214
+
215
+
216
+ def kill_process_by_port(port: int, timeout: int | None = None):
217
+ pids = find_pids_by_port(port)
218
+ for pid in pids:
219
+ logger.info(f"Killing process {pid} on port {port}...")
220
+ try:
221
+ if timeout and timeout > 0:
222
+ kill_process_gracefully(pid, timeout=timeout)
223
+ else:
224
+ kill_by_pid(pid)
225
+ except ProcessLookupError:
226
+ # Process already terminated
227
+ pass
@@ -0,0 +1,166 @@
1
+ import threading
2
+ import time
3
+
4
+ import adbutils # type: ignore
5
+
6
+ from pantoqa_bridge.config import INACTIVITY_TIMEOUT
7
+ from pantoqa_bridge.logger import logger
8
+ from pantoqa_bridge.models.misc import Action
9
+ from pantoqa_bridge.service.uiautomator_action import LocalBridgeService
10
+
11
+
12
+ class ServiceManager:
13
+
14
+ def __init__(self, device_serial: str):
15
+ self.device_serial = device_serial
16
+ self.srv = LocalBridgeService(device_serial)
17
+ self.last_used = time.time()
18
+ self._lock = threading.Lock()
19
+ self._closed = False
20
+
21
+ self._cleanup_thread = threading.Thread(target=self._auto_cleanup, daemon=True)
22
+ self._cleanup_thread.start()
23
+
24
+ def touch(self):
25
+ with self._lock:
26
+ self.last_used = time.time()
27
+
28
+ def process(self, action: Action):
29
+ self.touch()
30
+ return _process_action(self.srv, action)
31
+
32
+ def _auto_cleanup(self):
33
+ while True:
34
+ time.sleep(30)
35
+ with self._lock:
36
+ if self._closed:
37
+ return
38
+
39
+ if time.time() - self.last_used > INACTIVITY_TIMEOUT:
40
+ self.close()
41
+ return
42
+
43
+ def close(self):
44
+ if self._closed:
45
+ return
46
+ self._closed = True
47
+ try:
48
+ self.srv.stop()
49
+ finally:
50
+ remove_service(self.device_serial)
51
+
52
+
53
+ _services: dict[str, ServiceManager] = {}
54
+ _services_lock = threading.Lock()
55
+
56
+
57
+ def get_service(device_serial: str | None = None) -> ServiceManager:
58
+ adb = adbutils.AdbClient()
59
+ devices = adb.device_list()
60
+
61
+ if not device_serial:
62
+ device_serial = devices[0].serial
63
+
64
+ with _services_lock:
65
+ if device_serial not in _services:
66
+ _services[device_serial] = ServiceManager(device_serial)
67
+ return _services[device_serial]
68
+
69
+
70
+ def remove_service(device_serial: str):
71
+ with _services_lock:
72
+ _services.pop(device_serial, None)
73
+
74
+
75
+ def remove_all_services():
76
+ with _services_lock:
77
+ services = list(_services.values())
78
+ _services.clear()
79
+
80
+ for srv_mgr in services:
81
+ try:
82
+ srv_mgr.close()
83
+ except Exception as e:
84
+ logger.error(f"Error while closing service managers:{e}")
85
+
86
+
87
+ def _process_action(srv: LocalBridgeService, action: Action):
88
+ action_type = action.action_type
89
+ if action_type == "screenshot":
90
+ return srv.take_screenshot()
91
+ if action_type == "click":
92
+ params = action.params
93
+ assert params, "params is not present."
94
+ srv.click(params['x'], params['y'])
95
+ return
96
+ if action_type == "long_click":
97
+ params = action.params
98
+ assert params, "params is not present."
99
+ srv.long_click(params['x'], params['y'])
100
+ return
101
+ if action_type == "get_os_version":
102
+ return srv.get_os_version()
103
+ if action_type == "get_device_model":
104
+ return srv.get_device_model()
105
+ if action_type == "get_device_resolution":
106
+ return srv.device_resolution()
107
+ if action_type == "get_ui_dump":
108
+ return srv.get_ui_dump()
109
+ if action_type == "get_current_app_activity":
110
+ return srv.get_current_app_activity()
111
+ if action_type == "get_main_activity":
112
+ params = action.params
113
+ assert params, "params is not present."
114
+ return srv.get_main_activity(params['package_name'])
115
+ if action_type == "get_all_packages":
116
+ return srv.get_all_packages()
117
+ if action_type == "open_app":
118
+ params = action.params
119
+ assert params, "params is not present."
120
+ srv.open_app(params['package_name'], params['activity_name'])
121
+ return
122
+ if action_type == "close_keyboard":
123
+ srv.close_keyboard()
124
+ return
125
+ if action_type == "press_enter":
126
+ srv.press_enter()
127
+ return
128
+ if action_type == "go_back":
129
+ srv.go_back()
130
+ return
131
+ if action_type == "is_keyboard_open":
132
+ return srv.is_keyboard_open()
133
+ if action_type == "goto_home":
134
+ srv.goto_home()
135
+ return
136
+ if action_type == "clear_all_inputs":
137
+ srv.clear_all_inputs()
138
+ return
139
+ if action_type == "backspace":
140
+ srv.backspace()
141
+ return
142
+ if action_type == "input_text":
143
+ params = action.params
144
+ assert params, "params is not present."
145
+ srv.input_text(params['text'])
146
+ return
147
+ if action_type == "swipe":
148
+ params = action.params
149
+ assert params, "params is not present."
150
+ srv.swipe(params['x1'], params['y1'], params['x2'], params['y2'])
151
+ return
152
+ if action_type == "long_press":
153
+ params = action.params
154
+ assert params, "params is not present."
155
+ srv.long_press(params['x'], params['y'])
156
+ return
157
+ if action_type == "get_oem_name":
158
+ return srv.get_oem_name()
159
+ if action_type == "get_device_name":
160
+ return srv.get_device_name()
161
+ if action_type == "get_os_build_version":
162
+ return srv.get_os_build_version()
163
+ if action_type == "stop":
164
+ return srv.stop()
165
+
166
+ raise ValueError(f"Unsupported action type: {action_type}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pantoqa_bridge
3
- Version: 0.4.4
3
+ Version: 0.4.20
4
4
  Summary: Panto QA Bridge
5
5
  Author-email: Ritwick Dey <ritwick@getpanto.ai>
6
6
  Requires-Python: >=3.10
@@ -14,7 +14,7 @@ Requires-Dist: aiohttp>=3.13.2
14
14
  Requires-Dist: rich>=14.2.0
15
15
  Requires-Dist: uiautomator2>=3.5.0
16
16
  Requires-Dist: adbutils>=2.12.0
17
- Requires-Dist: appium-utility>=0.3.0
17
+ Requires-Dist: appium-utility>=0.3.7
18
18
  Requires-Dist: packaging>=25.0
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: pytest>=7.4.0; extra == "dev"
@@ -0,0 +1,27 @@
1
+ pantoqa_bridge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pantoqa_bridge/__main__.py,sha256=5m0xHXIz0lDmzAOmkxmaRSUUVKA6_CDisaFxfd9Xe08,57
3
+ pantoqa_bridge/cli.py,sha256=FiY0pBJoWO7rD8sEUNsu3cRISu7ZSvDasl7iTESuqGk,2378
4
+ pantoqa_bridge/config.py,sha256=i9zBJ4gFYwx08kHUOTlwoJPi1HFyTRzMqAVwJZ-iSYw,1105
5
+ pantoqa_bridge/logger.py,sha256=emPHG2YMPvHifHVbmzh3G2ZHDiX7tiS-iB4mUtSMfMY,899
6
+ pantoqa_bridge/scrcpy-server-v3.3.1,sha256=oPcLIKpJmPv2WMlBGM1sjatqu7Bkejvas0TXC8Hry7g,90788
7
+ pantoqa_bridge/server.py,sha256=Vj2bAmxVWoxq_C7K49997JubzJvyGwjA01HIx8x2g-Y,6570
8
+ pantoqa_bridge/lib/adb.py,sha256=TBekDIDTh5iUNphXxDwZDSsj8JghJ6sc9JGAIJvFTHM,2605
9
+ pantoqa_bridge/lib/scrcpy.py,sha256=On-VHpKBOv7M6y9_2xtlcEDpXqYpYL-C9AnwgZuSnuw,4447
10
+ pantoqa_bridge/models/code_execute.py,sha256=SsvzgBmP1FweXhRXIirmQ_R2EtFmf6Kox567mrGht9U,87
11
+ pantoqa_bridge/models/misc.py,sha256=5EQDdxOzItoD_n8nWbvh5V5pPGplnuqlWZYOks1h8I4,106
12
+ pantoqa_bridge/routes/action.py,sha256=PM5Rm8epADNXIAZvOx_z4B0MCYUnlYLODRdmPqe4q8s,723
13
+ pantoqa_bridge/routes/adb.py,sha256=sDy3UjeB9bNZPn7w07avoLKJ1K3KCk0V5gSX3pxoDdE,369
14
+ pantoqa_bridge/routes/executor.py,sha256=HdQXCJNr6deiPCteu0ZrysE9cXlETmME4oQlghegtdA,4078
15
+ pantoqa_bridge/routes/misc.py,sha256=TWz9aErGGdg96K-wnxvCfh2vjmdZmJEy9Wmih0YsbWg,2122
16
+ pantoqa_bridge/routes/screen_mirror.py,sha256=EidHlobyMNLJwQrO5T7Gb-JTY3CjGe0a9IiLiw1AcQ4,2429
17
+ pantoqa_bridge/service/uiautomator_action.py,sha256=B-5iPzYBiG3zfjsUieDAKvKQgmo9d6fDKNouG87duDI,4427
18
+ pantoqa_bridge/tasks/executor.py,sha256=cW-u_BK6QSTZ1Kex4kuZBhibP6qWR-XI10UALYwbQw0,3109
19
+ pantoqa_bridge/utils/misc.py,sha256=o-Zrpq2ON6MTU0ZKBi5ZRKPhIPBm1swhyEOVS7xdG-k,3948
20
+ pantoqa_bridge/utils/pkg.py,sha256=N9z_C7aQYU2VAdp3ptigp50331pxTwErVrT5I9Htz-c,1634
21
+ pantoqa_bridge/utils/process.py,sha256=M9xMNn8xLo_PLWlCkN7bQCvYGuOg1nWvqzbpx6ifJTA,5703
22
+ pantoqa_bridge/utils/service_manager.py,sha256=gycC6Nn9Cd9tNwZ3R9M2DfeFq1GTO3f74XUoCN3nGeM,4601
23
+ pantoqa_bridge-0.4.20.dist-info/METADATA,sha256=gnDd7XG-pc-BEiLDWlCIGccUsIDuvrekHadwAxVOcX8,1351
24
+ pantoqa_bridge-0.4.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
25
+ pantoqa_bridge-0.4.20.dist-info/entry_points.txt,sha256=GBytwhIuiZGX_cj_1JQpUteBsP51TSxTk4NxTp4bN_I,58
26
+ pantoqa_bridge-0.4.20.dist-info/top_level.txt,sha256=r03tgM1pQrHwfxF9gkvU94HgR_s8tqDvZVSAzq8qwrA,15
27
+ pantoqa_bridge-0.4.20.dist-info/RECORD,,
@@ -1,25 +0,0 @@
1
- pantoqa_bridge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pantoqa_bridge/__main__.py,sha256=5m0xHXIz0lDmzAOmkxmaRSUUVKA6_CDisaFxfd9Xe08,57
3
- pantoqa_bridge/cli.py,sha256=1EK3P3XcmFl4Il3-KNBNWddbL11ENR7Fi8zstXj1QIM,2016
4
- pantoqa_bridge/config.py,sha256=yJ_860ih9BG5e6n70tN_3EkpxCLzjgrpvDx-QFo86Fc,852
5
- pantoqa_bridge/logger.py,sha256=emPHG2YMPvHifHVbmzh3G2ZHDiX7tiS-iB4mUtSMfMY,899
6
- pantoqa_bridge/scrcpy-server-v3.3.1,sha256=oPcLIKpJmPv2WMlBGM1sjatqu7Bkejvas0TXC8Hry7g,90788
7
- pantoqa_bridge/server.py,sha256=tFvnQ9xbJQ14vuDEuJLuisxwdaXAIDhiBou8Yy59PGA,6509
8
- pantoqa_bridge/lib/adb.py,sha256=_4Vd3VUfklRqHFLhoMQdbzH2ZnS-lhQcCwu-p3a-SLY,2532
9
- pantoqa_bridge/lib/scrcpy.py,sha256=UNesa2XrV54r56MIm15X67HLka94I6mheNqKh4Bw54A,4023
10
- pantoqa_bridge/models/code_execute.py,sha256=SsvzgBmP1FweXhRXIirmQ_R2EtFmf6Kox567mrGht9U,87
11
- pantoqa_bridge/routes/action.py,sha256=pplP4eQI1qK7LIKxXb4I5j6tkcT6MowoIAtz-RMPwQI,3339
12
- pantoqa_bridge/routes/adb.py,sha256=sDy3UjeB9bNZPn7w07avoLKJ1K3KCk0V5gSX3pxoDdE,369
13
- pantoqa_bridge/routes/executor.py,sha256=gHRlNcncohPsD-VtaFTTBcu6JHrD4F-LvvETdoisuZI,3503
14
- pantoqa_bridge/routes/misc.py,sha256=TWz9aErGGdg96K-wnxvCfh2vjmdZmJEy9Wmih0YsbWg,2122
15
- pantoqa_bridge/routes/screen_mirror.py,sha256=KXpqKC0GzTW5JAoLZ0cKUY1RqZE3bNpk3zVVc5MReRE,2380
16
- pantoqa_bridge/service/uiautomator_action.py,sha256=UE_5-oyE_z5SjGa6Mtioap5jEM5MR9leXTdRuDl-vDI,2766
17
- pantoqa_bridge/tasks/executor.py,sha256=PWAS5hP79nYMLz4sNHLEYhF33YmslbcrjnqVr78rs2k,2683
18
- pantoqa_bridge/utils/misc.py,sha256=8tFn8p2OhLNIdT31w0LczQ8hjkhkpx3fkaeeMgs71sM,3668
19
- pantoqa_bridge/utils/pkg.py,sha256=difSA1MW6auFUNzOH7Emmukh5mqdsKb-Ow3jdiKKatg,1297
20
- pantoqa_bridge/utils/process.py,sha256=O71jC8twabT2MfcowAlMTOFeCQRb17MGTc3BFG4E9Bs,4674
21
- pantoqa_bridge-0.4.4.dist-info/METADATA,sha256=XuyB6iCj-9sH0RyU4h7Xzr2pZylaAkNJJi02z-PLXBE,1350
22
- pantoqa_bridge-0.4.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
- pantoqa_bridge-0.4.4.dist-info/entry_points.txt,sha256=GBytwhIuiZGX_cj_1JQpUteBsP51TSxTk4NxTp4bN_I,58
24
- pantoqa_bridge-0.4.4.dist-info/top_level.txt,sha256=r03tgM1pQrHwfxF9gkvU94HgR_s8tqDvZVSAzq8qwrA,15
25
- pantoqa_bridge-0.4.4.dist-info/RECORD,,