handsets 0.1.2__tar.gz
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.
- handsets-0.1.2/PKG-INFO +84 -0
- handsets-0.1.2/README.md +66 -0
- handsets-0.1.2/pyproject.toml +28 -0
- handsets-0.1.2/setup.cfg +4 -0
- handsets-0.1.2/src/handsets/__init__.py +48 -0
- handsets-0.1.2/src/handsets/errors.py +105 -0
- handsets-0.1.2/src/handsets/session.py +329 -0
- handsets-0.1.2/src/handsets.egg-info/PKG-INFO +84 -0
- handsets-0.1.2/src/handsets.egg-info/SOURCES.txt +9 -0
- handsets-0.1.2/src/handsets.egg-info/dependency_links.txt +1 -0
- handsets-0.1.2/src/handsets.egg-info/top_level.txt +1 -0
handsets-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handsets
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Pythonic bindings for the Handsets Android control CLI
|
|
5
|
+
Author: Handsets contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/elliotgao2/handsets
|
|
8
|
+
Project-URL: Documentation, https://elliotgao2.github.io/handsets/
|
|
9
|
+
Project-URL: Source, https://github.com/elliotgao2/handsets/tree/main/bindings/python
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Classifier: Topic :: System :: Operating System Kernels :: Linux
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# handsets — Python bindings
|
|
20
|
+
|
|
21
|
+
A small, Pythonic wrapper around the [Handsets](https://github.com/elliotgao2/handsets)
|
|
22
|
+
CLI (`hs`). Drives Android devices from Python without reimplementing the
|
|
23
|
+
subprocess + JSON-parse + exit-code boilerplate every caller used to write
|
|
24
|
+
by hand.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install handsets
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
You also need the `hs` binary on `$PATH`. See the project
|
|
33
|
+
[install instructions](https://github.com/elliotgao2/handsets#install).
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from handsets import Session
|
|
39
|
+
|
|
40
|
+
with Session() as d: # `hs use` on enter, `hs drop` on exit
|
|
41
|
+
for node in d.ui():
|
|
42
|
+
print(node.cls, node.text, node.coords)
|
|
43
|
+
|
|
44
|
+
d.tap("Continue") # text lookup
|
|
45
|
+
d.tap(540, 860) # raw coords
|
|
46
|
+
d.type("EditText", "you@x.com") # selector + text — atomic ACTION_SET_TEXT
|
|
47
|
+
d.submit()
|
|
48
|
+
d.wait("Welcome", timeout="15s")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Errors map to typed exceptions:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from handsets import Session, NotFound, Timeout, Ambiguous
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
d.tap("Submit", unique=True, timeout="5s")
|
|
58
|
+
except NotFound:
|
|
59
|
+
... # exit code 2 — selector matched nothing
|
|
60
|
+
except Timeout:
|
|
61
|
+
... # exit code 3 — wait budget exhausted
|
|
62
|
+
except Ambiguous:
|
|
63
|
+
... # exit code 4 — --unique saw multiple matches
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Everything else (daemon errors, bad arguments, secure-window blocks)
|
|
67
|
+
raises a generic `HandsetsError` whose `.code` attribute carries the
|
|
68
|
+
structured `ErrCode` enum value from the CLI's JSON output.
|
|
69
|
+
|
|
70
|
+
## Talking to a specific device
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
Session(serial="PIXEL6_SERIAL")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Multiple sessions can run side-by-side; each one shells out independently.
|
|
77
|
+
|
|
78
|
+
## Why a thin wrapper?
|
|
79
|
+
|
|
80
|
+
The CLI already does the hard work: warm daemon, push-mirrored state,
|
|
81
|
+
millisecond round-trips. The Python layer's job is to make that ergonomic
|
|
82
|
+
— context managers, typed exceptions, no manual `subprocess.run`. Future
|
|
83
|
+
versions may keep an `hs run` subprocess warm and stream commands over its
|
|
84
|
+
stdin to amortise per-call process overhead.
|
handsets-0.1.2/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# handsets — Python bindings
|
|
2
|
+
|
|
3
|
+
A small, Pythonic wrapper around the [Handsets](https://github.com/elliotgao2/handsets)
|
|
4
|
+
CLI (`hs`). Drives Android devices from Python without reimplementing the
|
|
5
|
+
subprocess + JSON-parse + exit-code boilerplate every caller used to write
|
|
6
|
+
by hand.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install handsets
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You also need the `hs` binary on `$PATH`. See the project
|
|
15
|
+
[install instructions](https://github.com/elliotgao2/handsets#install).
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from handsets import Session
|
|
21
|
+
|
|
22
|
+
with Session() as d: # `hs use` on enter, `hs drop` on exit
|
|
23
|
+
for node in d.ui():
|
|
24
|
+
print(node.cls, node.text, node.coords)
|
|
25
|
+
|
|
26
|
+
d.tap("Continue") # text lookup
|
|
27
|
+
d.tap(540, 860) # raw coords
|
|
28
|
+
d.type("EditText", "you@x.com") # selector + text — atomic ACTION_SET_TEXT
|
|
29
|
+
d.submit()
|
|
30
|
+
d.wait("Welcome", timeout="15s")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Errors map to typed exceptions:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from handsets import Session, NotFound, Timeout, Ambiguous
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
d.tap("Submit", unique=True, timeout="5s")
|
|
40
|
+
except NotFound:
|
|
41
|
+
... # exit code 2 — selector matched nothing
|
|
42
|
+
except Timeout:
|
|
43
|
+
... # exit code 3 — wait budget exhausted
|
|
44
|
+
except Ambiguous:
|
|
45
|
+
... # exit code 4 — --unique saw multiple matches
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Everything else (daemon errors, bad arguments, secure-window blocks)
|
|
49
|
+
raises a generic `HandsetsError` whose `.code` attribute carries the
|
|
50
|
+
structured `ErrCode` enum value from the CLI's JSON output.
|
|
51
|
+
|
|
52
|
+
## Talking to a specific device
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
Session(serial="PIXEL6_SERIAL")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Multiple sessions can run side-by-side; each one shells out independently.
|
|
59
|
+
|
|
60
|
+
## Why a thin wrapper?
|
|
61
|
+
|
|
62
|
+
The CLI already does the hard work: warm daemon, push-mirrored state,
|
|
63
|
+
millisecond round-trips. The Python layer's job is to make that ergonomic
|
|
64
|
+
— context managers, typed exceptions, no manual `subprocess.run`. Future
|
|
65
|
+
versions may keep an `hs run` subprocess warm and stream commands over its
|
|
66
|
+
stdin to amortise per-call process overhead.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "handsets"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Pythonic bindings for the Handsets Android control CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Handsets contributors" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Software Development :: Testing",
|
|
17
|
+
"Topic :: System :: Operating System Kernels :: Linux",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/elliotgao2/handsets"
|
|
24
|
+
Documentation = "https://elliotgao2.github.io/handsets/"
|
|
25
|
+
Source = "https://github.com/elliotgao2/handsets/tree/main/bindings/python"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
handsets-0.1.2/setup.cfg
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Pythonic bindings for the Handsets Android control CLI.
|
|
2
|
+
|
|
3
|
+
The CLI (`hs`) does the hard work: warm daemon, push-mirrored state,
|
|
4
|
+
millisecond round-trips. This package wraps the CLI in a small,
|
|
5
|
+
context-managed Session class so Python callers stop reimplementing the
|
|
6
|
+
subprocess + JSON-parse + exit-code dance.
|
|
7
|
+
|
|
8
|
+
from handsets import Session
|
|
9
|
+
with Session() as d:
|
|
10
|
+
d.tap("Continue")
|
|
11
|
+
d.wait("Welcome", timeout="15s")
|
|
12
|
+
|
|
13
|
+
Errors come back as typed exceptions; see ``handsets.errors``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
Ambiguous,
|
|
18
|
+
BadArg,
|
|
19
|
+
DaemonError,
|
|
20
|
+
DeviceGone,
|
|
21
|
+
ErrCode,
|
|
22
|
+
HandsetsError,
|
|
23
|
+
NotFound,
|
|
24
|
+
Precondition,
|
|
25
|
+
SecureWindow,
|
|
26
|
+
Timeout,
|
|
27
|
+
UnknownCmd,
|
|
28
|
+
)
|
|
29
|
+
from .session import Node, Session
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Session",
|
|
33
|
+
"Node",
|
|
34
|
+
# Exception hierarchy
|
|
35
|
+
"HandsetsError",
|
|
36
|
+
"NotFound",
|
|
37
|
+
"Timeout",
|
|
38
|
+
"Ambiguous",
|
|
39
|
+
"DaemonError",
|
|
40
|
+
"DeviceGone",
|
|
41
|
+
"Precondition",
|
|
42
|
+
"BadArg",
|
|
43
|
+
"SecureWindow",
|
|
44
|
+
"UnknownCmd",
|
|
45
|
+
"ErrCode",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.2"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Exception types raised by :class:`handsets.Session`.
|
|
2
|
+
|
|
3
|
+
The CLI's structured exit codes (2 NOT_FOUND, 3 TIMEOUT, 4 AMBIGUOUS) get
|
|
4
|
+
broken-out subclasses since those three are the only ones scripts ever
|
|
5
|
+
branch on in practice. The longer tail (DAEMON_ERROR, BAD_ARG,
|
|
6
|
+
SECURE_WINDOW, ...) all collapse to exit 1 from the CLI but carry their
|
|
7
|
+
full ``ErrCode`` in JSON-mode output, so we surface them as distinct
|
|
8
|
+
exception types here too — anyone who *does* need to dispatch on them
|
|
9
|
+
can catch the specific subclass.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ErrCode(str, Enum):
|
|
19
|
+
"""Structured error code as emitted by ``hs --json``."""
|
|
20
|
+
|
|
21
|
+
NOT_FOUND = "NOT_FOUND"
|
|
22
|
+
TIMEOUT = "TIMEOUT"
|
|
23
|
+
AMBIGUOUS = "AMBIGUOUS"
|
|
24
|
+
DAEMON_ERROR = "DAEMON_ERROR"
|
|
25
|
+
DEVICE_GONE = "DEVICE_GONE"
|
|
26
|
+
PRECONDITION = "PRECONDITION"
|
|
27
|
+
BAD_ARG = "BAD_ARG"
|
|
28
|
+
SECURE_WINDOW = "SECURE_WINDOW"
|
|
29
|
+
UNKNOWN_CMD = "UNKNOWN_CMD"
|
|
30
|
+
INTERNAL = "INTERNAL"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HandsetsError(Exception):
|
|
34
|
+
"""Base class for all Handsets failures."""
|
|
35
|
+
|
|
36
|
+
code: ErrCode = ErrCode.INTERNAL
|
|
37
|
+
|
|
38
|
+
def __init__(self, detail: str = "", *, verb: Optional[str] = None) -> None:
|
|
39
|
+
self.detail = detail
|
|
40
|
+
self.verb = verb
|
|
41
|
+
msg = f"{self.code.value}: {detail}" if detail else self.code.value
|
|
42
|
+
if verb:
|
|
43
|
+
msg = f"{verb}: {msg}"
|
|
44
|
+
super().__init__(msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFound(HandsetsError):
|
|
48
|
+
code = ErrCode.NOT_FOUND
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Timeout(HandsetsError):
|
|
52
|
+
code = ErrCode.TIMEOUT
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Ambiguous(HandsetsError):
|
|
56
|
+
code = ErrCode.AMBIGUOUS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DaemonError(HandsetsError):
|
|
60
|
+
code = ErrCode.DAEMON_ERROR
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DeviceGone(HandsetsError):
|
|
64
|
+
code = ErrCode.DEVICE_GONE
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Precondition(HandsetsError):
|
|
68
|
+
code = ErrCode.PRECONDITION
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BadArg(HandsetsError):
|
|
72
|
+
code = ErrCode.BAD_ARG
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SecureWindow(HandsetsError):
|
|
76
|
+
code = ErrCode.SECURE_WINDOW
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UnknownCmd(HandsetsError):
|
|
80
|
+
code = ErrCode.UNKNOWN_CMD
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_BY_CODE: dict[ErrCode, type[HandsetsError]] = {
|
|
84
|
+
ErrCode.NOT_FOUND: NotFound,
|
|
85
|
+
ErrCode.TIMEOUT: Timeout,
|
|
86
|
+
ErrCode.AMBIGUOUS: Ambiguous,
|
|
87
|
+
ErrCode.DAEMON_ERROR: DaemonError,
|
|
88
|
+
ErrCode.DEVICE_GONE: DeviceGone,
|
|
89
|
+
ErrCode.PRECONDITION: Precondition,
|
|
90
|
+
ErrCode.BAD_ARG: BadArg,
|
|
91
|
+
ErrCode.SECURE_WINDOW: SecureWindow,
|
|
92
|
+
ErrCode.UNKNOWN_CMD: UnknownCmd,
|
|
93
|
+
ErrCode.INTERNAL: HandsetsError,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def from_payload(verb: str, error: dict) -> HandsetsError:
|
|
98
|
+
"""Construct the right subclass from a JSON ``{"code": ..., "detail": ...}``."""
|
|
99
|
+
raw = error.get("code", "INTERNAL")
|
|
100
|
+
try:
|
|
101
|
+
code = ErrCode(raw)
|
|
102
|
+
except ValueError:
|
|
103
|
+
code = ErrCode.INTERNAL
|
|
104
|
+
cls = _BY_CODE.get(code, HandsetsError)
|
|
105
|
+
return cls(error.get("detail", ""), verb=verb)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Session — context-managed driver around the ``hs`` CLI.
|
|
2
|
+
|
|
3
|
+
Each method shells out one ``hs --json`` invocation, parses the single
|
|
4
|
+
JSON line that comes back, and either returns the ``result`` payload or
|
|
5
|
+
raises a typed exception. The implementation is intentionally simple: the
|
|
6
|
+
expensive bits (warm daemon, state mirror) live in the CLI, not here.
|
|
7
|
+
|
|
8
|
+
Future work: keep an ``hs run -`` subprocess warm across calls and write
|
|
9
|
+
verb lines to its stdin to amortise the ~5 ms per-process startup. The
|
|
10
|
+
API surface won't change.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Iterable, List, Optional, Union
|
|
20
|
+
|
|
21
|
+
from .errors import HandsetsError, from_payload
|
|
22
|
+
|
|
23
|
+
Duration = Union[int, float, str]
|
|
24
|
+
"""A wait budget. Integers/floats are milliseconds; strings accept the
|
|
25
|
+
same ``250ms`` / ``5s`` suffixes the CLI's ``--timeout`` flag does."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Node:
|
|
30
|
+
"""One row from ``hs ui`` / ``hs find``."""
|
|
31
|
+
|
|
32
|
+
cls: str
|
|
33
|
+
id: str
|
|
34
|
+
text: str
|
|
35
|
+
desc: str
|
|
36
|
+
flags: str
|
|
37
|
+
x1: int = 0
|
|
38
|
+
y1: int = 0
|
|
39
|
+
x2: int = 0
|
|
40
|
+
y2: int = 0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def coords(self) -> tuple[int, int]:
|
|
44
|
+
"""Centre point in pixels."""
|
|
45
|
+
return ((self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def clickable(self) -> bool:
|
|
49
|
+
return "c" in self.flags
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def visible(self) -> bool:
|
|
53
|
+
return "v" in self.flags
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_duration(d: Duration) -> str:
|
|
57
|
+
"""Normalise to the ``hs`` flag form (``250ms`` / ``5s`` / bare-ms-int)."""
|
|
58
|
+
if isinstance(d, str):
|
|
59
|
+
return d
|
|
60
|
+
return f"{int(d)}ms"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Session:
|
|
64
|
+
"""A connected device. Use as a context manager.
|
|
65
|
+
|
|
66
|
+
>>> with Session() as d:
|
|
67
|
+
... d.tap("Continue")
|
|
68
|
+
... d.wait("Welcome", timeout="15s")
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
serial: Optional[str] = None,
|
|
74
|
+
*,
|
|
75
|
+
binary: str = "hs",
|
|
76
|
+
auto_connect: bool = True,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.serial = serial
|
|
79
|
+
self.binary = binary
|
|
80
|
+
self._connected = False
|
|
81
|
+
self._auto_connect = auto_connect
|
|
82
|
+
if shutil.which(binary) is None:
|
|
83
|
+
raise FileNotFoundError(
|
|
84
|
+
f"`{binary}` not on $PATH — install handsets first "
|
|
85
|
+
"(see https://github.com/elliotgao2/handsets#install)"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ─── context manager ──────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> "Session":
|
|
91
|
+
if self._auto_connect:
|
|
92
|
+
self.connect()
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
96
|
+
if self._connected:
|
|
97
|
+
try:
|
|
98
|
+
self.disconnect()
|
|
99
|
+
except HandsetsError:
|
|
100
|
+
# Best-effort teardown — don't mask the real exception.
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# ─── lifecycle ────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def connect(self) -> None:
|
|
106
|
+
"""`hs use [serial]` — start the daemon and host-side state mirror."""
|
|
107
|
+
argv = ["use"]
|
|
108
|
+
if self.serial is not None:
|
|
109
|
+
argv += ["--device", self.serial]
|
|
110
|
+
self._call_text(argv)
|
|
111
|
+
self._connected = True
|
|
112
|
+
|
|
113
|
+
def disconnect(self, keep_jar: bool = False) -> None:
|
|
114
|
+
"""`hs drop` — tear the daemon down."""
|
|
115
|
+
argv = ["drop"]
|
|
116
|
+
if self.serial is not None:
|
|
117
|
+
argv += ["--device", self.serial]
|
|
118
|
+
if keep_jar:
|
|
119
|
+
argv.append("--keep-jar")
|
|
120
|
+
self._call_text(argv)
|
|
121
|
+
self._connected = False
|
|
122
|
+
|
|
123
|
+
# ─── inspection ───────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def ui(self) -> List[Node]:
|
|
126
|
+
"""Return one :class:`Node` per actionable element on the active window."""
|
|
127
|
+
# `hs find '*'` returns every node; --json gives one structured line each.
|
|
128
|
+
return self.find("*")
|
|
129
|
+
|
|
130
|
+
def find(
|
|
131
|
+
self,
|
|
132
|
+
selector: str,
|
|
133
|
+
*,
|
|
134
|
+
visible: bool = False,
|
|
135
|
+
clickable: bool = False,
|
|
136
|
+
enabled: bool = False,
|
|
137
|
+
unique: bool = False,
|
|
138
|
+
nth: Optional[int] = None,
|
|
139
|
+
timeout: Optional[Duration] = None,
|
|
140
|
+
) -> List[Node]:
|
|
141
|
+
argv = ["find", selector]
|
|
142
|
+
argv += self._action_flags(
|
|
143
|
+
visible=visible, clickable=clickable, enabled=enabled,
|
|
144
|
+
unique=unique, nth=nth, timeout=timeout,
|
|
145
|
+
)
|
|
146
|
+
rows = self._call_json_lines(argv)
|
|
147
|
+
return [self._node_from_payload(r["result"]) for r in rows if r.get("ok")]
|
|
148
|
+
|
|
149
|
+
def info(self) -> dict:
|
|
150
|
+
"""Return the cached device snapshot as a dict."""
|
|
151
|
+
argv = ["show"]
|
|
152
|
+
if self.serial is not None:
|
|
153
|
+
argv = ["--device", self.serial] + argv
|
|
154
|
+
proc = subprocess.run(
|
|
155
|
+
[self.binary, *argv], capture_output=True, text=True, check=False,
|
|
156
|
+
)
|
|
157
|
+
# `hs show` (bare) is a text neofetch-style dump — surface as-is.
|
|
158
|
+
if proc.returncode != 0:
|
|
159
|
+
raise HandsetsError(proc.stderr.strip(), verb="show")
|
|
160
|
+
return {"raw": proc.stdout}
|
|
161
|
+
|
|
162
|
+
# ─── input ────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def tap(
|
|
165
|
+
self,
|
|
166
|
+
target: Union[str, int],
|
|
167
|
+
y: Optional[int] = None,
|
|
168
|
+
*,
|
|
169
|
+
visible: bool = False,
|
|
170
|
+
clickable: bool = False,
|
|
171
|
+
enabled: bool = False,
|
|
172
|
+
unique: bool = False,
|
|
173
|
+
nth: Optional[int] = None,
|
|
174
|
+
timeout: Optional[Duration] = None,
|
|
175
|
+
retries: int = 0,
|
|
176
|
+
retry_delay: Optional[Duration] = None,
|
|
177
|
+
) -> dict:
|
|
178
|
+
"""Tap by text/selector (one arg) or coordinates (``tap(x, y)``)."""
|
|
179
|
+
if y is not None:
|
|
180
|
+
argv = ["tap", str(target), str(y)]
|
|
181
|
+
else:
|
|
182
|
+
argv = ["tap", str(target)]
|
|
183
|
+
argv += self._action_flags(
|
|
184
|
+
visible=visible, clickable=clickable, enabled=enabled,
|
|
185
|
+
unique=unique, nth=nth, timeout=timeout,
|
|
186
|
+
retries=retries, retry_delay=retry_delay,
|
|
187
|
+
)
|
|
188
|
+
return self._call_one_json(argv, verb="tap")
|
|
189
|
+
|
|
190
|
+
def type(
|
|
191
|
+
self,
|
|
192
|
+
selector_or_text: str,
|
|
193
|
+
text: Optional[str] = None,
|
|
194
|
+
*,
|
|
195
|
+
timeout: Optional[Duration] = None,
|
|
196
|
+
) -> dict:
|
|
197
|
+
"""``type(TEXT)`` types into the focused field; ``type(SELECTOR, TEXT)``
|
|
198
|
+
targets a specific node via ``ACTION_SET_TEXT`` (atomic, bypasses IME)."""
|
|
199
|
+
if text is None:
|
|
200
|
+
argv = ["type", selector_or_text]
|
|
201
|
+
else:
|
|
202
|
+
argv = ["type", selector_or_text, text]
|
|
203
|
+
argv += self._action_flags(timeout=timeout)
|
|
204
|
+
return self._call_one_json(argv, verb="type")
|
|
205
|
+
|
|
206
|
+
def submit(self, selector: Optional[str] = None) -> dict:
|
|
207
|
+
"""Press the focused field's IME action key (Go / Search / Send / Done)."""
|
|
208
|
+
argv = ["submit"]
|
|
209
|
+
if selector is not None:
|
|
210
|
+
argv.append(selector)
|
|
211
|
+
return self._call_one_json(argv, verb="submit")
|
|
212
|
+
|
|
213
|
+
def paste(self, selector: Optional[str] = None) -> dict:
|
|
214
|
+
argv = ["paste"]
|
|
215
|
+
if selector is not None:
|
|
216
|
+
argv.append(selector)
|
|
217
|
+
return self._call_one_json(argv, verb="paste")
|
|
218
|
+
|
|
219
|
+
def go(self, key: str) -> dict:
|
|
220
|
+
"""Key event by name (``back``, ``home``, ``recents``, ``enter``, …)."""
|
|
221
|
+
return self._call_one_json(["go", key], verb="go")
|
|
222
|
+
|
|
223
|
+
def swipe(self, direction_or_x1, *args, duration_ms: Optional[int] = None) -> dict:
|
|
224
|
+
argv = ["swipe", str(direction_or_x1), *[str(a) for a in args]]
|
|
225
|
+
if duration_ms is not None:
|
|
226
|
+
argv.append(str(duration_ms))
|
|
227
|
+
return self._call_one_json(argv, verb="swipe")
|
|
228
|
+
|
|
229
|
+
# ─── synchronisation ──────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def wait(
|
|
232
|
+
self,
|
|
233
|
+
spec: str,
|
|
234
|
+
*,
|
|
235
|
+
timeout: Optional[Duration] = None,
|
|
236
|
+
retries: int = 0,
|
|
237
|
+
) -> dict:
|
|
238
|
+
argv = ["wait", spec]
|
|
239
|
+
argv += self._action_flags(timeout=timeout, retries=retries)
|
|
240
|
+
return self._call_one_json(argv, verb="wait")
|
|
241
|
+
|
|
242
|
+
# ─── internals ────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
def _action_flags(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
visible: bool = False,
|
|
248
|
+
clickable: bool = False,
|
|
249
|
+
enabled: bool = False,
|
|
250
|
+
unique: bool = False,
|
|
251
|
+
nth: Optional[int] = None,
|
|
252
|
+
timeout: Optional[Duration] = None,
|
|
253
|
+
retries: int = 0,
|
|
254
|
+
retry_delay: Optional[Duration] = None,
|
|
255
|
+
) -> List[str]:
|
|
256
|
+
out: List[str] = []
|
|
257
|
+
if visible: out.append("--visible")
|
|
258
|
+
if clickable: out.append("--clickable")
|
|
259
|
+
if enabled: out.append("--enabled")
|
|
260
|
+
if unique: out.append("--unique")
|
|
261
|
+
if nth is not None: out += ["--nth", str(nth)]
|
|
262
|
+
if timeout is not None: out += ["--timeout", _fmt_duration(timeout)]
|
|
263
|
+
if retries: out += ["--retries", str(retries)]
|
|
264
|
+
if retry_delay is not None:
|
|
265
|
+
out += ["--retry-delay", _fmt_duration(retry_delay)]
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
def _argv_prefix(self) -> List[str]:
|
|
269
|
+
argv: List[str] = [self.binary, "--json"]
|
|
270
|
+
if self.serial is not None:
|
|
271
|
+
argv += ["--device", self.serial]
|
|
272
|
+
return argv
|
|
273
|
+
|
|
274
|
+
def _call_text(self, argv: Iterable[str]) -> str:
|
|
275
|
+
proc = subprocess.run(
|
|
276
|
+
[self.binary, *argv], capture_output=True, text=True, check=False,
|
|
277
|
+
)
|
|
278
|
+
if proc.returncode != 0:
|
|
279
|
+
raise HandsetsError(
|
|
280
|
+
proc.stderr.strip() or proc.stdout.strip(),
|
|
281
|
+
verb=str(next(iter(argv), "?")),
|
|
282
|
+
)
|
|
283
|
+
return proc.stdout
|
|
284
|
+
|
|
285
|
+
def _call_one_json(self, argv: Iterable[str], *, verb: str) -> dict:
|
|
286
|
+
rows = self._call_json_lines(argv)
|
|
287
|
+
if not rows:
|
|
288
|
+
raise HandsetsError("no JSON line on stdout", verb=verb)
|
|
289
|
+
row = rows[-1]
|
|
290
|
+
if not row.get("ok"):
|
|
291
|
+
raise from_payload(verb, row.get("error", {}))
|
|
292
|
+
return row.get("result", {})
|
|
293
|
+
|
|
294
|
+
def _call_json_lines(self, argv: Iterable[str]) -> List[dict]:
|
|
295
|
+
proc = subprocess.run(
|
|
296
|
+
[*self._argv_prefix(), *argv],
|
|
297
|
+
capture_output=True, text=True, check=False,
|
|
298
|
+
)
|
|
299
|
+
rows: List[dict] = []
|
|
300
|
+
for line in proc.stdout.splitlines():
|
|
301
|
+
line = line.strip()
|
|
302
|
+
if not line:
|
|
303
|
+
continue
|
|
304
|
+
try:
|
|
305
|
+
rows.append(json.loads(line))
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
# Non-JSON lines slip through for verbs that don't honour
|
|
308
|
+
# --json yet — ignore them; the exit code will decide.
|
|
309
|
+
continue
|
|
310
|
+
if proc.returncode != 0 and not rows:
|
|
311
|
+
raise HandsetsError(
|
|
312
|
+
proc.stderr.strip() or f"exit {proc.returncode}",
|
|
313
|
+
verb=str(next(iter(argv), "?")),
|
|
314
|
+
)
|
|
315
|
+
return rows
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _node_from_payload(p: dict) -> Node:
|
|
319
|
+
return Node(
|
|
320
|
+
cls=p.get("class", ""),
|
|
321
|
+
id=p.get("id", ""),
|
|
322
|
+
text=p.get("text", ""),
|
|
323
|
+
desc=p.get("desc", ""),
|
|
324
|
+
flags=p.get("flags", ""),
|
|
325
|
+
x1=int(p.get("x1", 0) or 0),
|
|
326
|
+
y1=int(p.get("y1", 0) or 0),
|
|
327
|
+
x2=int(p.get("x2", 0) or 0),
|
|
328
|
+
y2=int(p.get("y2", 0) or 0),
|
|
329
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handsets
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Pythonic bindings for the Handsets Android control CLI
|
|
5
|
+
Author: Handsets contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/elliotgao2/handsets
|
|
8
|
+
Project-URL: Documentation, https://elliotgao2.github.io/handsets/
|
|
9
|
+
Project-URL: Source, https://github.com/elliotgao2/handsets/tree/main/bindings/python
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Classifier: Topic :: System :: Operating System Kernels :: Linux
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# handsets — Python bindings
|
|
20
|
+
|
|
21
|
+
A small, Pythonic wrapper around the [Handsets](https://github.com/elliotgao2/handsets)
|
|
22
|
+
CLI (`hs`). Drives Android devices from Python without reimplementing the
|
|
23
|
+
subprocess + JSON-parse + exit-code boilerplate every caller used to write
|
|
24
|
+
by hand.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install handsets
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
You also need the `hs` binary on `$PATH`. See the project
|
|
33
|
+
[install instructions](https://github.com/elliotgao2/handsets#install).
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from handsets import Session
|
|
39
|
+
|
|
40
|
+
with Session() as d: # `hs use` on enter, `hs drop` on exit
|
|
41
|
+
for node in d.ui():
|
|
42
|
+
print(node.cls, node.text, node.coords)
|
|
43
|
+
|
|
44
|
+
d.tap("Continue") # text lookup
|
|
45
|
+
d.tap(540, 860) # raw coords
|
|
46
|
+
d.type("EditText", "you@x.com") # selector + text — atomic ACTION_SET_TEXT
|
|
47
|
+
d.submit()
|
|
48
|
+
d.wait("Welcome", timeout="15s")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Errors map to typed exceptions:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from handsets import Session, NotFound, Timeout, Ambiguous
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
d.tap("Submit", unique=True, timeout="5s")
|
|
58
|
+
except NotFound:
|
|
59
|
+
... # exit code 2 — selector matched nothing
|
|
60
|
+
except Timeout:
|
|
61
|
+
... # exit code 3 — wait budget exhausted
|
|
62
|
+
except Ambiguous:
|
|
63
|
+
... # exit code 4 — --unique saw multiple matches
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Everything else (daemon errors, bad arguments, secure-window blocks)
|
|
67
|
+
raises a generic `HandsetsError` whose `.code` attribute carries the
|
|
68
|
+
structured `ErrCode` enum value from the CLI's JSON output.
|
|
69
|
+
|
|
70
|
+
## Talking to a specific device
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
Session(serial="PIXEL6_SERIAL")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Multiple sessions can run side-by-side; each one shells out independently.
|
|
77
|
+
|
|
78
|
+
## Why a thin wrapper?
|
|
79
|
+
|
|
80
|
+
The CLI already does the hard work: warm daemon, push-mirrored state,
|
|
81
|
+
millisecond round-trips. The Python layer's job is to make that ergonomic
|
|
82
|
+
— context managers, typed exceptions, no manual `subprocess.run`. Future
|
|
83
|
+
versions may keep an `hs run` subprocess warm and stream commands over its
|
|
84
|
+
stdin to amortise per-call process overhead.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
handsets
|