handsets 0.1.2__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.
handsets/__init__.py 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"
handsets/errors.py ADDED
@@ -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)
handsets/session.py ADDED
@@ -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,7 @@
1
+ handsets/__init__.py,sha256=bpMnDlHGIMdttR6hTfOsLPttBYhkWlVan6lulBkKP2w,1017
2
+ handsets/errors.py,sha256=INKACmQiTxPdXRlPGrGXoI6Z6ShGKE7dq628axC2eHc,2768
3
+ handsets/session.py,sha256=PVlAlbYbzbQ0J0O-lDIKOYqhxFuDqNai5ZhphW93TQc,11651
4
+ handsets-0.1.2.dist-info/METADATA,sha256=PqTTcmAq9W3N7kZkCQmxoA_0UQpjPje-o59qvUx_zmQ,2730
5
+ handsets-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ handsets-0.1.2.dist-info/top_level.txt,sha256=WT96qUimGqGz2hcryXZseBQ9v5l2wm1uca20EIUKm7E,9
7
+ handsets-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ handsets