appium-pilot 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.
@@ -0,0 +1,3 @@
1
+ """appium-pilot — agent-first, session-based CLI for driving mobile apps via Appium."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from appium_pilot.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
appium_pilot/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ """appium-pilot front-end: global flags, subcommand dispatch, error mapping.
2
+
3
+ Supports `-s=<name>` session selection (default "default") placed anywhere
4
+ before the subcommand.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+
13
+ from selenium.common.exceptions import WebDriverException
14
+
15
+ from appium_pilot import __version__
16
+ from appium_pilot.commands import (
17
+ app_cmd,
18
+ capture_cmd,
19
+ devices_cmd,
20
+ doctor_cmd,
21
+ gesture_cmd,
22
+ open_cmd,
23
+ session_cmd,
24
+ skills_cmd,
25
+ snapshot_cmd,
26
+ tap_cmd,
27
+ type_cmd,
28
+ video_cmd,
29
+ wait_cmd,
30
+ )
31
+ from appium_pilot.output import CommandError, fail, set_json_mode
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="appium-pilot",
37
+ description="Agent-first, session-based CLI for driving mobile apps via Appium.",
38
+ )
39
+ parser.add_argument("-s", "--session", default="default", help="session name (default: default)")
40
+ parser.add_argument("--json", action="store_true", help="emit structured JSON output")
41
+ parser.add_argument("--version", action="version", version=f"appium-pilot {__version__}")
42
+
43
+ sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
44
+ for module in (
45
+ open_cmd,
46
+ snapshot_cmd,
47
+ capture_cmd,
48
+ tap_cmd,
49
+ type_cmd,
50
+ gesture_cmd,
51
+ wait_cmd,
52
+ video_cmd,
53
+ app_cmd,
54
+ devices_cmd,
55
+ session_cmd,
56
+ skills_cmd,
57
+ doctor_cmd,
58
+ ):
59
+ module.add_parser(sub)
60
+ return parser
61
+
62
+
63
+ def _normalize_session_flag(argv: list[str]) -> list[str]:
64
+ """Allow `-s=name` in addition to argparse's `-s name`."""
65
+ out: list[str] = []
66
+ for arg in argv:
67
+ if arg.startswith("-s=") or arg.startswith("--session="):
68
+ out.append("--session")
69
+ out.append(arg.split("=", 1)[1])
70
+ else:
71
+ out.append(arg)
72
+ return out
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> None:
76
+ argv = _normalize_session_flag(list(argv if argv is not None else sys.argv[1:]))
77
+ args = build_parser().parse_args(argv)
78
+ set_json_mode(args.json)
79
+
80
+ try:
81
+ args.func(args)
82
+ except CommandError as exc:
83
+ fail(str(exc), code=exc.code, **exc.data)
84
+ except WebDriverException as exc:
85
+ # Driver-level failures (e.g. an action the app/platform rejects) become a
86
+ # clean one-line error, not a traceback. Set APPIUM_PILOT_DEBUG=1 to see it.
87
+ if os.environ.get("APPIUM_PILOT_DEBUG"):
88
+ raise
89
+ msg = (getattr(exc, "msg", None) or str(exc)).strip().splitlines()[0]
90
+ fail(f"driver error: {msg}")
91
+ except KeyboardInterrupt:
92
+ fail("interrupted", code=130)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1 @@
1
+ """Command handlers."""
@@ -0,0 +1,106 @@
1
+ """App lifecycle (`launch`/`terminate`/`activate`/`background`/`install`/`remove`/`reset`)
2
+ and `orientation`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ from appium_pilot.output import CommandError, emit
10
+ from appium_pilot.session import Session
11
+
12
+
13
+ def add_parser(sub: argparse._SubParsersAction) -> None:
14
+ for name, help_text in [
15
+ ("launch", "activate (foreground) the app under test"),
16
+ ("activate", "activate (foreground) the app under test"),
17
+ ("terminate", "terminate the app under test"),
18
+ ]:
19
+ p = sub.add_parser(name, help=help_text)
20
+ p.add_argument("app_id", nargs="?", help="app id (default: the session's app)")
21
+ p.set_defaults(func=_make_lifecycle(name))
22
+
23
+ bg = sub.add_parser("background", help="background the app for N seconds (-1 = indefinitely)")
24
+ bg.add_argument("seconds", nargs="?", type=int, default=-1)
25
+ bg.set_defaults(func=run_background)
26
+
27
+ inst = sub.add_parser("install", help="install an app artifact on the device")
28
+ inst.add_argument("path", help="path to .apk/.app/.ipa")
29
+ inst.set_defaults(func=run_install)
30
+
31
+ rm = sub.add_parser("remove", help="uninstall an app from the device")
32
+ rm.add_argument("app_id", nargs="?", help="app id (default: the session's app)")
33
+ rm.set_defaults(func=run_remove)
34
+
35
+ rs = sub.add_parser("reset", help="terminate then re-activate the app under test")
36
+ rs.set_defaults(func=run_reset)
37
+
38
+ o = sub.add_parser("orientation", help="get or set screen orientation")
39
+ o.add_argument("value", nargs="?", choices=["portrait", "landscape"],
40
+ help="omit to read the current orientation")
41
+ o.set_defaults(func=run_orientation)
42
+
43
+
44
+ def _app_id(session: Session, explicit: str | None) -> str:
45
+ app_id = explicit or session.app_id
46
+ if not app_id:
47
+ raise CommandError("no app id known for this session; pass one explicitly")
48
+ return app_id
49
+
50
+
51
+ def _make_lifecycle(action: str):
52
+ def run(args) -> None:
53
+ session = Session.load(args.session)
54
+ driver = session.attach()
55
+ app_id = _app_id(session, args.app_id)
56
+ if action in ("launch", "activate"):
57
+ driver.activate_app(app_id)
58
+ emit(f"activated {app_id}", app=app_id)
59
+ elif action == "terminate":
60
+ driver.terminate_app(app_id)
61
+ emit(f"terminated {app_id}", app=app_id)
62
+
63
+ return run
64
+
65
+
66
+ def run_background(args) -> None:
67
+ session = Session.load(args.session)
68
+ driver = session.attach()
69
+ driver.background_app(args.seconds)
70
+ emit(f"backgrounded app for {args.seconds}s")
71
+
72
+
73
+ def run_install(args) -> None:
74
+ session = Session.load(args.session)
75
+ driver = session.attach()
76
+ path = str(Path(args.path).expanduser().resolve())
77
+ driver.install_app(path)
78
+ emit(f"installed {path}", path=path)
79
+
80
+
81
+ def run_remove(args) -> None:
82
+ session = Session.load(args.session)
83
+ driver = session.attach()
84
+ app_id = _app_id(session, args.app_id)
85
+ driver.remove_app(app_id)
86
+ emit(f"removed {app_id}", app=app_id)
87
+
88
+
89
+ def run_reset(args) -> None:
90
+ session = Session.load(args.session)
91
+ driver = session.attach()
92
+ app_id = _app_id(session, None)
93
+ driver.terminate_app(app_id)
94
+ driver.activate_app(app_id)
95
+ emit(f"reset {app_id}", app=app_id)
96
+
97
+
98
+ def run_orientation(args) -> None:
99
+ session = Session.load(args.session)
100
+ driver = session.attach()
101
+ if args.value:
102
+ driver.orientation = args.value.upper()
103
+ emit(f"orientation set to {args.value}", orientation=args.value.upper())
104
+ else:
105
+ current = driver.orientation
106
+ emit(current, orientation=current)
@@ -0,0 +1,48 @@
1
+ """`screenshot` and `source` — pull pixels / raw page source off the device."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+
8
+ from appium_pilot import config
9
+ from appium_pilot.output import emit, raw
10
+ from appium_pilot.resolve import find_ref
11
+ from appium_pilot.session import Session
12
+
13
+
14
+ def add_parser(sub: argparse._SubParsersAction) -> None:
15
+ sh = sub.add_parser("screenshot", help="save a PNG of the screen (or an element) and print its path")
16
+ sh.add_argument("ref", nargs="?", help="optional element ref to screenshot instead of the screen")
17
+ sh.add_argument("-o", "--out", help="output path (default: session screenshots dir)")
18
+ sh.set_defaults(func=run_screenshot)
19
+
20
+ src = sub.add_parser("source", help="print the full raw page source")
21
+ src.set_defaults(func=run_source)
22
+
23
+
24
+ def run_screenshot(args) -> None:
25
+ session = Session.load(args.session)
26
+ driver = session.attach()
27
+
28
+ if args.out:
29
+ path = args.out
30
+ else:
31
+ shots = config.screenshots_dir()
32
+ shots.mkdir(parents=True, exist_ok=True)
33
+ path = str(shots / f"shot-{time.strftime('%Y%m%d-%H%M%S')}.png")
34
+
35
+ if args.ref:
36
+ locator = session.locator_for(args.ref)
37
+ element = find_ref(driver, locator, args.ref)
38
+ element.screenshot(path)
39
+ else:
40
+ driver.get_screenshot_as_file(path)
41
+
42
+ emit(path, path=path)
43
+
44
+
45
+ def run_source(args) -> None:
46
+ session = Session.load(args.session)
47
+ driver = session.attach()
48
+ raw(driver.page_source)
@@ -0,0 +1,30 @@
1
+ """`devices` — list available simulators/emulators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from appium_pilot import devices
8
+ from appium_pilot.output import emit, json_mode
9
+
10
+
11
+ def add_parser(sub: argparse._SubParsersAction) -> None:
12
+ p = sub.add_parser("devices", help="list available iOS simulators / Android emulators")
13
+ p.set_defaults(func=run)
14
+
15
+
16
+ def run(args) -> None:
17
+ found = devices.list_all()
18
+ payload = [
19
+ {"platform": d.platform, "udid": d.udid, "name": d.name, "booted": d.booted}
20
+ for d in found
21
+ ]
22
+ if json_mode():
23
+ emit(f"{len(found)} devices", devices=payload)
24
+ return
25
+ if not found:
26
+ emit("no devices found (no booted simulators/emulators or AVDs)")
27
+ return
28
+ for d in found:
29
+ state = "booted" if d.booted else "available"
30
+ print(f"{d.platform:8} {state:10} {d.name} ({d.udid})")
@@ -0,0 +1,103 @@
1
+ """`doctor` — diagnose the environment. Never installs anything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+
11
+ from appium_pilot import config, proc
12
+ from appium_pilot.output import emit, json_mode
13
+
14
+
15
+ def add_parser(sub: argparse._SubParsersAction) -> None:
16
+ p = sub.add_parser("doctor", help="diagnose Appium/iOS/Android setup (does not install)")
17
+ p.set_defaults(func=run)
18
+
19
+
20
+ def _version(name: str, *args: str) -> str | None:
21
+ argv = proc.tool(name, *args)
22
+ if argv is None:
23
+ return None
24
+ try:
25
+ out = subprocess.run(argv, capture_output=True, text=True, timeout=15)
26
+ return (out.stdout or out.stderr).strip().splitlines()[0] if out.returncode == 0 else None
27
+ except (OSError, subprocess.SubprocessError):
28
+ return None
29
+
30
+
31
+ def _appium_drivers() -> set[str]:
32
+ argv = proc.tool("appium", "driver", "list", "--installed", "--json")
33
+ if argv is None:
34
+ return set()
35
+ try:
36
+ out = subprocess.run(argv, capture_output=True, text=True, timeout=30)
37
+ data = json.loads(out.stdout or out.stderr or "{}")
38
+ return set(data.keys())
39
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
40
+ return set()
41
+
42
+
43
+ def run(args) -> None:
44
+ checks: list[dict] = []
45
+
46
+ def check(name: str, ok: bool, detail: str, hint: str = "") -> None:
47
+ checks.append({"name": name, "ok": ok, "detail": detail, "hint": hint})
48
+
49
+ # Core toolchain.
50
+ node = _version("node", "--version")
51
+ check("node", node is not None, node or "not found", "install Node.js")
52
+ npm = _version("npm", "--version")
53
+ check("npm", npm is not None, npm or "not found", "install Node.js (bundles npm)")
54
+ appium = _version("appium", "--version")
55
+ check("appium server", appium is not None, appium or "not found", "npm i -g appium")
56
+
57
+ drivers = _appium_drivers() if appium else set()
58
+ check("uiautomator2 driver", "uiautomator2" in drivers,
59
+ "installed" if "uiautomator2" in drivers else "missing",
60
+ "appium driver install uiautomator2")
61
+ check("xcuitest driver", "xcuitest" in drivers,
62
+ "installed" if "xcuitest" in drivers else "missing",
63
+ "appium driver install xcuitest")
64
+
65
+ # iOS.
66
+ xcrun = shutil.which("xcrun")
67
+ check("xcode command line tools (xcrun)", xcrun is not None,
68
+ xcrun or "not found", "xcode-select --install")
69
+
70
+ # Android.
71
+ android_home = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
72
+ check("ANDROID_HOME / ANDROID_SDK_ROOT", bool(android_home),
73
+ android_home or "not set", "export ANDROID_HOME=~/Library/Android/sdk")
74
+ from appium_pilot import devices
75
+
76
+ adb = devices.android_tool("adb")
77
+ check("adb", adb is not None, adb or "not found", "install Android platform-tools")
78
+ emulator = devices.android_tool("emulator")
79
+ check("emulator", emulator is not None, emulator or "not found", "install Android emulator package")
80
+
81
+ # Stale state.
82
+ server_running = config.SERVER_FILE.exists()
83
+ session_count = len(list(config.SESSIONS_DIR.glob("*.json"))) if config.SESSIONS_DIR.exists() else 0
84
+ check("managed appium server record", True,
85
+ "present" if server_running else "none",
86
+ "`appium-pilot kill-all` clears stale server/session state")
87
+ check("persisted sessions", True, f"{session_count} on disk",
88
+ "`appium-pilot list` to inspect, `close-all` to clear")
89
+
90
+ if json_mode():
91
+ ok = all(c["ok"] for c in checks)
92
+ emit("doctor complete", ok=ok, checks=checks)
93
+ return
94
+
95
+ for c in checks:
96
+ mark = "✓" if c["ok"] else "✗"
97
+ line = f" {mark} {c['name']}: {c['detail']}"
98
+ if not c["ok"] and c["hint"]:
99
+ line += f"\n → {c['hint']}"
100
+ print(line)
101
+ missing = [c for c in checks if not c["ok"]]
102
+ print()
103
+ print("doctor: all good" if not missing else f"doctor: {len(missing)} item(s) need attention")
@@ -0,0 +1,89 @@
1
+ """`swipe`, `scroll`, `press`, `hide-keyboard` — gestures and keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from appium_pilot.output import CommandError, emit
8
+ from appium_pilot.resolve import find_ref
9
+ from appium_pilot.session import Session
10
+ from appium_pilot.snapshot import build_snapshot
11
+ from appium_pilot.strategies.base import DIRECTIONS
12
+
13
+
14
+ def add_parser(sub: argparse._SubParsersAction) -> None:
15
+ s = sub.add_parser("swipe", help="swipe the screen in a direction")
16
+ s.add_argument("direction", choices=list(DIRECTIONS) + ["coords"],
17
+ help="direction, or 'coords' for explicit x1 y1 x2 y2")
18
+ s.add_argument("coords", nargs="*", type=int, help="x1 y1 x2 y2 when direction=coords")
19
+ s.add_argument("--amount", type=float, default=1.0,
20
+ help="fraction of screen to swipe (default 1.0); ignored for coords")
21
+ s.set_defaults(func=run_swipe)
22
+
23
+ sc = sub.add_parser("scroll", help="scroll a ref into view, or scroll to text")
24
+ sc.add_argument("ref", nargs="?", help="ref to scroll into view")
25
+ sc.add_argument("--to", dest="to_text", help="scroll until text is visible")
26
+ sc.set_defaults(func=run_scroll)
27
+
28
+ pr = sub.add_parser("press", help="press a hardware/system key (back/home/enter/...)")
29
+ pr.add_argument("key", help="back | home | enter | <android keycode>")
30
+ pr.set_defaults(func=run_press)
31
+
32
+ hk = sub.add_parser("hide-keyboard", help="dismiss the on-screen keyboard")
33
+ hk.set_defaults(func=run_hide_keyboard)
34
+
35
+
36
+ def run_swipe(args) -> None:
37
+ session = Session.load(args.session)
38
+ driver = session.attach()
39
+ if args.direction == "coords":
40
+ if len(args.coords) != 4:
41
+ raise CommandError("coords swipe needs exactly: x1 y1 x2 y2")
42
+ x1, y1, x2, y2 = args.coords
43
+ driver.swipe(x1, y1, x2, y2, 400)
44
+ emit(f"swiped ({x1},{y1})->({x2},{y2})")
45
+ return
46
+ session.strategy.swipe(driver, args.direction, args.amount)
47
+ emit(f"swiped {args.direction}")
48
+
49
+
50
+ def run_scroll(args) -> None:
51
+ session = Session.load(args.session)
52
+ driver = session.attach()
53
+ strategy = session.strategy
54
+
55
+ if args.to_text:
56
+ element = strategy.scroll_to_text(driver, args.to_text)
57
+ if element is None:
58
+ raise CommandError(f"could not find text {args.to_text!r} by scrolling", code=2)
59
+ # Refresh refs so the now-visible element is addressable.
60
+ _refresh_refs(session, driver)
61
+ emit(f"scrolled to text {args.to_text!r}; refs refreshed")
62
+ return
63
+
64
+ if not args.ref:
65
+ raise CommandError("scroll needs a ref or --to <text>")
66
+ locator = session.locator_for(args.ref)
67
+ element = find_ref(driver, locator, args.ref)
68
+ strategy.scroll_to_element(driver, element)
69
+ emit(f"scrolled {args.ref} into view", ref=args.ref)
70
+
71
+
72
+ def run_press(args) -> None:
73
+ session = Session.load(args.session)
74
+ driver = session.attach()
75
+ session.strategy.press_key(driver, args.key)
76
+ emit(f"pressed {args.key}")
77
+
78
+
79
+ def run_hide_keyboard(args) -> None:
80
+ session = Session.load(args.session)
81
+ driver = session.attach()
82
+ session.strategy.hide_keyboard(driver)
83
+ emit("keyboard dismissed")
84
+
85
+
86
+ def _refresh_refs(session: Session, driver) -> None: # noqa: ANN001
87
+ _, refmap = build_snapshot(driver.page_source, session.strategy)
88
+ session.set_refmap(refmap)
89
+ session.save()
@@ -0,0 +1,114 @@
1
+ """`open` — create a session and persist its handle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from appium_pilot import config, devices, server
11
+ from appium_pilot.output import CommandError, emit
12
+ from appium_pilot.session import Session, new_driver
13
+
14
+ AUTOMATION = {"android": "UiAutomator2", "ios": "XCUITest"}
15
+ APP_EXT_PLATFORM = {".apk": "android", ".aab": "android", ".app": "ios", ".ipa": "ios"}
16
+
17
+
18
+ def add_parser(sub: argparse._SubParsersAction) -> None:
19
+ p = sub.add_parser("open", help="open a session against a device/app")
20
+ p.add_argument("app", nargs="?", help="path to an app artifact (.app/.apk/.ipa)")
21
+ p.add_argument("--platform", choices=["android", "ios"], help="target platform")
22
+ p.add_argument("--device", help="device udid or name (else auto-pick/boot)")
23
+ p.add_argument("--app", dest="app_flag", help="app artifact path (alternative to positional)")
24
+ p.add_argument("--app-package", help="Android appPackage of an installed app to launch")
25
+ p.add_argument("--app-activity", help="Android appActivity (optional)")
26
+ p.add_argument("--bundle-id", help="iOS bundleId of an installed app to launch")
27
+ p.add_argument("--cap", action="append", default=[], metavar="k=v",
28
+ help="extra capability (repeatable); appium: prefix added if missing")
29
+ p.add_argument("--caps-file", help="JSON file of capabilities to merge")
30
+ p.set_defaults(func=run)
31
+
32
+
33
+ def _infer_platform(args, app: Optional[str]) -> str:
34
+ if args.platform:
35
+ return args.platform
36
+ if args.bundle_id:
37
+ return "ios"
38
+ if args.app_package or args.app_activity:
39
+ return "android"
40
+ if app:
41
+ ext = Path(app).suffix.lower()
42
+ if ext in APP_EXT_PLATFORM:
43
+ return APP_EXT_PLATFORM[ext]
44
+ raise CommandError("could not infer platform; pass --platform android|ios")
45
+
46
+
47
+ def _parse_caps(pairs: list[str]) -> dict:
48
+ caps: dict = {}
49
+ for pair in pairs:
50
+ if "=" not in pair:
51
+ raise CommandError(f"bad --cap {pair!r}; expected key=value")
52
+ key, value = pair.split("=", 1)
53
+ if ":" not in key and key not in ("platformName",):
54
+ key = f"appium:{key}"
55
+ caps[key] = _coerce(value)
56
+ return caps
57
+
58
+
59
+ def _coerce(value: str):
60
+ low = value.lower()
61
+ if low in ("true", "false"):
62
+ return low == "true"
63
+ if value.isdigit():
64
+ return int(value)
65
+ return value
66
+
67
+
68
+ def run(args) -> None:
69
+ app = args.app_flag or args.app
70
+ platform = _infer_platform(args, app)
71
+ strategy_automation = AUTOMATION[platform]
72
+
73
+ device = devices.resolve(platform, args.device)
74
+
75
+ caps: dict = {
76
+ "platformName": "Android" if platform == "android" else "iOS",
77
+ "appium:automationName": strategy_automation,
78
+ "appium:udid": device.udid,
79
+ "appium:deviceName": device.name,
80
+ "appium:newCommandTimeout": config.NEW_COMMAND_TIMEOUT,
81
+ }
82
+ if app:
83
+ caps["appium:app"] = str(Path(app).expanduser().resolve())
84
+ if args.app_package:
85
+ caps["appium:appPackage"] = args.app_package
86
+ if args.app_activity:
87
+ caps["appium:appActivity"] = args.app_activity
88
+ if args.bundle_id:
89
+ caps["appium:bundleId"] = args.bundle_id
90
+
91
+ if args.caps_file:
92
+ caps.update(json.loads(Path(args.caps_file).read_text()))
93
+ caps.update(_parse_caps(args.cap)) # explicit --cap wins
94
+
95
+ base_url = server.ensure_server()
96
+ driver = new_driver(base_url, caps)
97
+
98
+ session = Session(
99
+ name=args.session,
100
+ server_url=base_url,
101
+ session_id=driver.session_id,
102
+ platform=platform,
103
+ device=device.udid,
104
+ caps=caps,
105
+ )
106
+ session.save()
107
+
108
+ emit(
109
+ f"opened session '{args.session}' on {platform} ({device.name}). Run `snapshot` next.",
110
+ session=args.session,
111
+ platform=platform,
112
+ device=device.udid,
113
+ session_id=driver.session_id,
114
+ )