nolook 0.3.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.
nolook/__init__.py ADDED
File without changes
nolook/cli.py ADDED
@@ -0,0 +1,369 @@
1
+ """nolook: drive an Android device by what is on screen, not by pixels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import tree
11
+ from .config import find_config
12
+ from .device import Device, NolookError, Outcome
13
+ from .tree import Node, QueryError
14
+
15
+ SNAP_CAP = 80
16
+
17
+
18
+ def print_nodes(ns: list[Node], cap: int | None = SNAP_CAP) -> None:
19
+ shown = ns if cap is None else ns[:cap]
20
+ for n in shown:
21
+ print(tree.format_node(n))
22
+ if len(ns) > len(shown):
23
+ print(f"… {len(ns) - len(shown)} more (nolook snap --all)")
24
+
25
+
26
+ def report(verb: str, o: Outcome, quiet: bool = False) -> None:
27
+ head = verb
28
+ if o.target is not None:
29
+ head += " " + tree.format_node(o.target, compact=True)
30
+ head += f" · settled {o.settled}s" if o.settled is not None else " · still changing after timeout"
31
+ print(head)
32
+ for g in o.guard_hits:
33
+ print(f"⚠ guard: {g}")
34
+ if quiet:
35
+ return
36
+ lines, wholesale = tree.delta(o.before, o.after)
37
+ if wholesale:
38
+ print(f"screen changed ({len(o.after)} elements):")
39
+ print_nodes(o.after, cap=40)
40
+ elif lines:
41
+ print("\n".join(lines))
42
+ else:
43
+ print("no visible change")
44
+
45
+
46
+ def parse_box(s: str) -> tuple[int, int, int, int]:
47
+ parts = [int(p) for p in s.split(",")]
48
+ if len(parts) != 4:
49
+ raise argparse.ArgumentTypeError("expected l,t,r,b")
50
+ return tuple(parts) # type: ignore[return-value]
51
+
52
+
53
+ def build() -> argparse.ArgumentParser:
54
+ p = argparse.ArgumentParser(
55
+ prog="nolook",
56
+ description="Drive an Android device by what is on screen. Queries: @e12 (ref), #tag (id), "
57
+ "~part (contains), /regex/, plain text (exact); prefix a role to narrow: btn:回复.",
58
+ )
59
+ p.add_argument("--serial", help="device serial (default: .nolook.toml, then the only device)")
60
+ p.add_argument("-q", "--quiet", action="store_true", help="print the action line only, no changes")
61
+ p.add_argument("--session", help=argparse.SUPPRESS) # the device a Claude Code session last drove, for its pane
62
+ sub = p.add_subparsers(dest="cmd", required=True)
63
+
64
+ s = sub.add_parser("snap", help="list what is on screen, with refs")
65
+ s.add_argument("--all", action="store_true", help=f"no {SNAP_CAP}-line cap")
66
+ s.add_argument("--in", dest="within", metavar="Q", help="only this element and what is inside it")
67
+ s.add_argument("--json", action="store_true", help="one JSON object per element, for scripts")
68
+
69
+ s = sub.add_parser("tap", help="tap one element")
70
+ s.add_argument("query", nargs="?")
71
+ s.add_argument("--nth", type=int, help="pick the nth of several matches (1-based)")
72
+ s.add_argument("--long", action="store_true", help="long press")
73
+ s.add_argument("--force", action="store_true", help="tap even if something covers it")
74
+ s.add_argument("--confirm", action="store_true", help="allow a write action (like, follow, send…); only when the user asked for it")
75
+ s.add_argument("--xy", metavar="X,Y", help="tap raw coordinates (canvas, WebView)")
76
+
77
+ s = sub.add_parser("type", help="set the text of a field")
78
+ s.add_argument("text")
79
+ s.add_argument("--into", metavar="Q", help="field to focus first (default: the focused one)")
80
+ s.add_argument("--append", action="store_true", help="add to the current text instead of replacing it")
81
+ s.add_argument("--enter", action="store_true", help="press enter after")
82
+
83
+ s = sub.add_parser("key", help="press a key: back, home, enter, recent, delete, volume_up …")
84
+ s.add_argument("name")
85
+ sub.add_parser("back", help="press back")
86
+
87
+ s = sub.add_parser("scroll", help="scroll a list; down reveals what is below")
88
+ s.add_argument("direction", nargs="?", default="down", choices=["down", "up", "left", "right"])
89
+ s.add_argument("--in", dest="within", metavar="Q", help="scroll container (default: the largest)")
90
+ s.add_argument("--until", metavar="Q", help="keep scrolling until this is in view")
91
+ s.add_argument("--max", type=int, default=10, help="swipes before giving up (with --until)")
92
+
93
+ s = sub.add_parser("wait", help="wait for an element to appear (or --gone)")
94
+ s.add_argument("query")
95
+ s.add_argument("--gone", action="store_true")
96
+ s.add_argument("--timeout", type=float, default=10.0)
97
+
98
+ s = sub.add_parser("get", help="everything about one element, untruncated")
99
+ s.add_argument("query")
100
+ s.add_argument("--nth", type=int)
101
+
102
+ s = sub.add_parser("open", help="open a URI (deep link) in the app")
103
+ s.add_argument("uri")
104
+
105
+ s = sub.add_parser("launch", help="start the app")
106
+ s.add_argument("package", nargs="?")
107
+ s.add_argument("--fresh", action="store_true", help="force-stop first")
108
+
109
+ s = sub.add_parser("shot", help="save a small screenshot; prints its path, never the image")
110
+ s.add_argument("query", nargs="?", help="crop to this element")
111
+ s.add_argument("--region", type=parse_box, metavar="L,T,R,B")
112
+ s.add_argument("--width", type=int, help="longest edge in px (default 540 whole screen, 900 crops)")
113
+ s.add_argument("--full", action="store_true", help="native resolution PNG")
114
+ s.add_argument("--png", action="store_true", help="PNG at the small size instead of JPEG")
115
+ s.add_argument("-o", "--out", metavar="PATH")
116
+
117
+ s = sub.add_parser("log", help="recent logcat lines of the app")
118
+ s.add_argument("-n", type=int, default=40)
119
+ s.add_argument("--grep", metavar="REGEX")
120
+ s.add_argument("--level", choices=list("VDIWEF"), help="minimum level")
121
+
122
+ s = sub.add_parser("run", help="run a Python flow; see `nolook flows`")
123
+ s.add_argument("flow", help="a .py path, or a name in the project's flows dir")
124
+ s.add_argument("args", nargs=argparse.REMAINDER)
125
+
126
+ sub.add_parser("flows", help="list the project's flows")
127
+ sub.add_parser("doctor", help="check adb, the device, ffmpeg and Perfetto, and say what to fix")
128
+
129
+ s = sub.add_parser("trace", help="summarise a Perfetto trace (no device needed)")
130
+ ts = s.add_subparsers(dest="trace_cmd", required=True)
131
+ t = ts.add_parser("summary", help="frames, jank causes, where the main thread's time went, GC, binder")
132
+ t.add_argument("file")
133
+ t.add_argument("--package", help="app process (default: .nolook.toml's, else the one with most frames)")
134
+ t.add_argument("--top", type=int, default=8, help="slices per thread")
135
+ t = ts.add_parser("sql", help="run a trace processor SQL query")
136
+ t.add_argument("file")
137
+ t.add_argument("query")
138
+ t.add_argument("--limit", type=int, default=40)
139
+
140
+ s = sub.add_parser("topng", help=argparse.SUPPRESS) # for the Claude Code pane: it draws PNGs only
141
+ s.add_argument("src")
142
+ s.add_argument("dst", nargs="?", help="default: beside src, as .png")
143
+ s.add_argument("--width", type=int)
144
+
145
+ s = sub.add_parser("motion", help="record an animation and report its timing, curve and stalls")
146
+ s.add_argument("--tap", metavar="Q", help="start recording, then tap this")
147
+ s.add_argument("--after", metavar="Q", help="start recording, then wait for this to appear")
148
+ s.add_argument("--key", metavar="NAME", help="start recording, then press this key")
149
+ s.add_argument("--el", metavar="Q", help="measure inside this element (default: whole screen)")
150
+ s.add_argument("--region", type=parse_box, metavar="L,T,R,B")
151
+ s.add_argument("--for", dest="seconds", type=float, default=1.5, help="seconds to keep recording after the trigger")
152
+ s.add_argument("--slow", type=float, metavar="N", help="run animations N times slower while recording")
153
+ s.add_argument("--label", help="name this run for `motion compare`")
154
+ s.add_argument("--confirm", action="store_true", help="allow --tap on a write action")
155
+ s.add_argument("--json", action="store_true")
156
+ s = sub.add_parser("motion-compare", help="table of recorded motion runs")
157
+ s.add_argument("labels", nargs="*")
158
+ s.add_argument("--last", type=int, default=10)
159
+
160
+ s = sub.add_parser("perf", help="frame timing, compile modes, traces, ANRs")
161
+ ps = s.add_subparsers(dest="perf_cmd", required=True)
162
+ f = ps.add_parser("frames", help="reset gfxinfo, swipe the list back and forth, print the frame summary")
163
+ f.add_argument("--rounds", type=int, default=4, help="forward+back swipe pairs (default 4)")
164
+ f.add_argument("--in", dest="within", metavar="Q", help="list to swipe (default: the largest)")
165
+ f.add_argument("--speed", type=float, default=10000, help="swipe speed in px/s, which it flings at (default 10000, a quick flick)")
166
+ f.add_argument("--duration", type=float, help="seconds per swipe instead of --speed (0.25 matches `input swipe … 250`)")
167
+ f.add_argument("--gap", type=float, default=0.8, help="seconds between swipes")
168
+ f.add_argument("--axis", choices=["y", "x"], default="y")
169
+ f.add_argument("--label", help="name this run for `perf compare`")
170
+ f.add_argument("--trace", metavar="PATH", help="also record a Perfetto trace to this file or directory")
171
+ f.add_argument("--all", action="store_true", help="print every gfxinfo counter")
172
+ f.add_argument("--json", action="store_true")
173
+ ps.add_parser("reset", help="reset gfxinfo counters (then act, then `perf read`)")
174
+ r = ps.add_parser("read", help="frame summary since the last reset")
175
+ r.add_argument("--label")
176
+ r.add_argument("--all", action="store_true")
177
+ r.add_argument("--json", action="store_true")
178
+ c = ps.add_parser("compare", help="table of recorded runs")
179
+ c.add_argument("labels", nargs="*", help="only these labels")
180
+ c.add_argument("--last", type=int, default=10)
181
+ c.add_argument("--all", action="store_true", help="every counter as a column")
182
+ c = ps.add_parser("compile", help="set the ART compile mode and stop the app so it takes effect")
183
+ c.add_argument("mode", help="speed, speed-profile, verify, quicken, or reset")
184
+ c = ps.add_parser("anr", help="the last ANR's header and main thread stack")
185
+ c.add_argument("-n", type=int, default=40)
186
+ return p
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ a = build().parse_args(argv)
191
+ cfg = find_config()
192
+ if a.session:
193
+ from .config import follow_session
194
+
195
+ follow_session(cfg, a.session)
196
+ if a.serial:
197
+ cfg.serial = a.serial
198
+
199
+ if a.cmd == "topng":
200
+ from PIL import Image
201
+
202
+ img = Image.open(a.src)
203
+ if a.width and img.width > a.width:
204
+ img = img.resize((a.width, round(img.height * a.width / img.width)))
205
+ dst = a.dst or str(Path(a.src).with_suffix(".pane.png"))
206
+ img.save(dst, "PNG")
207
+ print(dst)
208
+ return 0
209
+
210
+ if a.cmd == "doctor":
211
+ from . import doctor
212
+
213
+ return doctor.run(cfg)
214
+
215
+ if a.cmd == "trace":
216
+ from . import tracing
217
+
218
+ try:
219
+ lines = (
220
+ tracing.summary(a.file, a.package or cfg.package, a.top)
221
+ if a.trace_cmd == "summary"
222
+ else tracing.sql(a.file, a.query, a.limit)
223
+ )
224
+ except NolookError as e:
225
+ print(f"error: {e}", file=sys.stderr)
226
+ return 1
227
+ print("\n".join(lines))
228
+ return 0
229
+
230
+ if a.cmd == "flows":
231
+ from .flow import list_flows
232
+
233
+ list_flows(cfg)
234
+ return 0
235
+
236
+ try:
237
+ if a.cmd == "run":
238
+ from .flow import run_flow
239
+
240
+ return run_flow(cfg, a.flow, a.args)
241
+ d = Device(cfg)
242
+ return dispatch(d, a)
243
+ except (NolookError, QueryError) as e:
244
+ print(f"error: {e}", file=sys.stderr)
245
+ return 1
246
+ except KeyboardInterrupt:
247
+ return 130
248
+ except Exception as e: # noqa: BLE001 - one line beats a traceback in an agent's context
249
+ print(f"error: unexpected {type(e).__name__}: {e} (NOLOOK_DEBUG=1 for the traceback)", file=sys.stderr)
250
+ if os.environ.get("NOLOOK_DEBUG"):
251
+ raise
252
+ return 1
253
+
254
+
255
+ def dispatch(d: Device, a: argparse.Namespace) -> int:
256
+ q = a.quiet
257
+ if a.cmd == "snap":
258
+ ns = d.nodes()
259
+ if a.within:
260
+ box = tree.resolve(ns, a.within)
261
+ i = ns.index(box)
262
+ ns = ns[i : box.end]
263
+ if a.json:
264
+ import json
265
+
266
+ for n in ns:
267
+ print(json.dumps({
268
+ "ref": n.ref, "role": n.role, "label": n.label, "text": n.text, "desc": n.desc, "id": n.rid,
269
+ "bounds": list(n.bounds), "center": list(n.center), "depth": n.depth, "covered": n.covered,
270
+ **{k: v for k, v in n.flags.items()},
271
+ }, ensure_ascii=False))
272
+ return 0
273
+ pkgs = sorted({n.pkg for n in ns if n.pkg})
274
+ print(f"{' + '.join(pkgs) or '(empty)'} · {d.size[0]}x{d.size[1]} · {len(ns)} elements")
275
+ print_nodes(ns, cap=None if a.all else SNAP_CAP)
276
+ elif a.cmd == "tap":
277
+ if a.xy:
278
+ x, y = (int(v) for v in a.xy.split(","))
279
+ report(f"tap ({x},{y})", d.tap_xy(x, y), q)
280
+ elif a.query:
281
+ report("long-press" if a.long else "tap", d.tap(a.query, a.nth, long=a.long, force=a.force, confirm=a.confirm), q)
282
+ else:
283
+ raise NolookError("tap what? give a query or --xy X,Y")
284
+ elif a.cmd == "type":
285
+ o = d.type(a.text, a.into, append=a.append)
286
+ if a.enter:
287
+ o2 = d.key("enter")
288
+ o = Outcome(o.target, o.before, o2.after, o2.settled, o.guard_hits + o2.guard_hits)
289
+ report("type into", o, q)
290
+ elif a.cmd in ("key", "back"):
291
+ name = "back" if a.cmd == "back" else a.name
292
+ report(f"key {name}", d.key(name), q)
293
+ elif a.cmd == "scroll":
294
+ o = d.scroll(a.direction, a.within, a.until, a.max)
295
+ report(f"scroll {a.direction}" + (" until" if a.until else ""), o, q)
296
+ elif a.cmd == "wait":
297
+ ns, took = d.wait(a.query, a.gone, a.timeout)
298
+ hit = tree.match(ns, a.query)
299
+ what = "gone" if a.gone else tree.format_node(hit[0], compact=True)
300
+ print(f"{what} · after {took}s")
301
+ elif a.cmd == "get":
302
+ n, _ = d.find(a.query, a.nth)
303
+ print(tree.format_node(n, compact=True))
304
+ for k, v in (("text", n.text), ("desc", n.desc), ("id", n.rid), ("class", n.cls), ("package", n.pkg)):
305
+ if v:
306
+ print(f" {k}: {v}")
307
+ print(f" bounds: {list(n.bounds)}")
308
+ on = [k for k, v in n.flags.items() if v and k != "enabled"]
309
+ if on:
310
+ print(f" flags: {', '.join(on)}")
311
+ elif a.cmd == "open":
312
+ report(f"open {a.uri}", d.open(a.uri), q)
313
+ elif a.cmd == "launch":
314
+ report("launch", d.launch(a.package, a.fresh), q)
315
+ elif a.cmd == "shot":
316
+ r = d.shot(a.query, a.region, a.width, a.full, a.out, a.png)
317
+ w, h = r["size"]
318
+ print(f"{r['path']} {w}x{h} {r['kb']} KB ≈{r['tokens']} tokens")
319
+ elif a.cmd == "perf":
320
+ perf_dispatch(d, a)
321
+ elif a.cmd == "motion":
322
+ import json
323
+
324
+ from . import motion
325
+
326
+ r = motion.record(d, a.tap, a.after, a.key, a.el, a.region, a.seconds, a.slow, a.label, a.confirm)
327
+ if a.json:
328
+ print(json.dumps({k: v for k, v in r.items() if not k.startswith("_")}, ensure_ascii=False))
329
+ else:
330
+ print("\n".join(motion.format_report(r)))
331
+ elif a.cmd == "motion-compare":
332
+ from . import motion
333
+
334
+ print("\n".join(motion.compare_lines(motion.history(d, a.labels or None, a.last))))
335
+ elif a.cmd == "log":
336
+ for ln in d.log(a.n, a.grep, a.level):
337
+ print(ln)
338
+ return 0
339
+
340
+
341
+ def perf_dispatch(d: Device, a: argparse.Namespace) -> None:
342
+ import json
343
+
344
+ from . import perf
345
+
346
+ keys = [k for k, _ in perf._FIELDS] if getattr(a, "all", False) else perf.SHORT
347
+ if a.perf_cmd == "frames":
348
+ rec = perf.frames(d, a.rounds, a.within, a.duration, a.gap, a.axis, a.label, a.trace, speed=a.speed)
349
+ print(json.dumps(rec, ensure_ascii=False) if a.json else perf.format_record(rec, keys))
350
+ if rec.get("trace") and not a.json:
351
+ print(f"trace: {rec['trace']} (open in ui.perfetto.dev)")
352
+ elif a.perf_cmd == "reset":
353
+ perf.reset(d)
354
+ print("gfxinfo reset")
355
+ elif a.perf_cmd == "read":
356
+ rec = {"label": a.label or "", **perf.read(d)}
357
+ print(json.dumps(rec, ensure_ascii=False) if a.json else perf.format_record(rec, keys))
358
+ elif a.perf_cmd == "compare":
359
+ for ln in perf.compare_table(perf.history(d, a.labels or None, a.last), keys):
360
+ print(ln)
361
+ elif a.perf_cmd == "compile":
362
+ print(f"compile status: {perf.compile(d, a.mode) or 'unknown'} · app stopped; relaunch it before measuring")
363
+ elif a.perf_cmd == "anr":
364
+ for ln in perf.last_anr(d, lines=a.n):
365
+ print(ln)
366
+
367
+
368
+ if __name__ == "__main__":
369
+ sys.exit(main())
nolook/config.py ADDED
@@ -0,0 +1,89 @@
1
+ """Project settings from the nearest .nolook.toml, with env overrides.
2
+
3
+ serial = "emulator-5554"
4
+ adb_port = 5037
5
+ package = "com.example.notes"
6
+ flows = "tools/flows"
7
+ guard = ["/api/like", "/api/post"] # logcat regexes flagged after each action
8
+ danger = ["Like", "Send", "Delete"] # labels a tap refuses without --confirm
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tomllib
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+
19
+ @dataclass
20
+ class Config:
21
+ root: Path
22
+ serial: str | None = None
23
+ adb_port: int | None = None
24
+ package: str | None = None
25
+ flows: str = "flows"
26
+ guard: list[str] = field(default_factory=list)
27
+ hide: list[str] = field(default_factory=lambda: ["com.android.systemui"])
28
+ # Taps on these refuse without confirm: the app may be logged in to a
29
+ # real account, and one wrong tap posts, likes or deletes.
30
+ danger: list[str] = field(
31
+ default_factory=lambda: [
32
+ "赞", "踩", "关注", "取消关注", "发送", "发布", "回复", "删除", "收藏", "一键签到", "签到", "举报", "拉黑", "屏蔽",
33
+ "like", "follow", "send", "post", "delete", "submit", "buy", "pay",
34
+ ]
35
+ )
36
+
37
+ @property
38
+ def flows_dir(self) -> Path:
39
+ return self.root / self.flows
40
+
41
+
42
+ def find_config(start: Path | None = None) -> Config:
43
+ here = (start or Path.cwd()).resolve()
44
+ data: dict = {}
45
+ root = here
46
+ for d in (here, *here.parents):
47
+ f = d / ".nolook.toml"
48
+ if f.is_file():
49
+ data = tomllib.loads(f.read_text())
50
+ root = d
51
+ break
52
+ known = {k: v for k, v in data.items() if k in Config.__dataclass_fields__ and k != "root"}
53
+ cfg = Config(root=root, **known)
54
+ cfg.serial = os.environ.get("NOLOOK_SERIAL", cfg.serial)
55
+ port = os.environ.get("NOLOOK_ADB_PORT")
56
+ if port:
57
+ cfg.adb_port = int(port)
58
+ if cfg.adb_port:
59
+ # adbutils reads this when it first connects.
60
+ os.environ["ANDROID_ADB_SERVER_PORT"] = str(cfg.adb_port)
61
+ return cfg
62
+
63
+
64
+ def cache_dir(serial: str) -> Path:
65
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "nolook"
66
+ d = base / serial.replace(":", "_")
67
+ d.mkdir(parents=True, exist_ok=True)
68
+ return d
69
+
70
+
71
+ def session_dir() -> Path:
72
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "nolook" / "sessions"
73
+ base.mkdir(parents=True, exist_ok=True)
74
+ return base
75
+
76
+
77
+ def follow_session(cfg: Config, session: str) -> bool:
78
+ """Point cfg at the device a Claude Code session last drove; False if none is recorded."""
79
+ import json
80
+
81
+ try:
82
+ rec = json.loads((session_dir() / f"{session}.json").read_text())
83
+ except (OSError, ValueError):
84
+ return False
85
+ cfg.serial = rec.get("serial") or cfg.serial
86
+ if rec.get("adb_port"):
87
+ cfg.adb_port = int(rec["adb_port"])
88
+ os.environ["ANDROID_ADB_SERVER_PORT"] = str(cfg.adb_port)
89
+ return True