okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffix(python): enforce ruff and ty checks4ad84fd

main
6.4 KiB209 linesraw
1"""Plain-data observation schemas, selection, and rendering. No browser I/O here."""
2
3from __future__ import annotations
4
5import json
6from typing import Any, NotRequired, TypedDict
7
8from .errors import SelectionError
9
10
11class Node(TypedDict):
12    id: str
13    ref: str | None
14    role: str
15    name: str
16    frame_id: str
17    parent: str | None
18    depth: int
19    value: NotRequired[Any]
20    href: NotRequired[str]
21    disabled: NotRequired[bool]
22    checked: NotRequired[Any]
23    selected: NotRequired[bool]
24    expanded: NotRequired[bool]
25    readonly: NotRequired[bool]
26    focused: NotRequired[bool]
27    required: NotRequired[bool]
28    level: NotRequired[int]
29    attributes: NotRequired[dict[str, str]]
30    name_truncated: NotRequired[bool]
31
32
33class Observation(TypedDict):
34    snapshot_id: str
35    target_id: str
36    url: str
37    title: str
38    captured_at: str
39    capture_ms: float
40    nodes: list[Node]
41    frames: list[dict[str, Any]]
42    coverage: dict[str, Any]
43
44
45# Deliberately exclude value, style, inline handlers and arbitrary data attributes.
46IDENTITY_ATTRIBUTES = (
47    "id",
48    "name",
49    "type",
50    "href",
51    "role",
52    "aria-label",
53    "data-testid",
54)
55
56
57def attributes_from_snapshot(data: dict[str, Any]) -> dict[int, dict[str, str]]:
58    strings = data.get("strings", [])
59    result = {}
60    for document in data.get("documents", []):
61        nodes = document.get("nodes", {})
62        for backend_id, attrs in zip(
63            nodes.get("backendNodeId", []), nodes.get("attributes", [])
64        ):
65            pairs = {
66                strings[attrs[i]]: strings[attrs[i + 1]]
67                for i in range(0, len(attrs), 2)
68            }
69            result[backend_id] = {
70                k: pairs[k] for k in IDENTITY_ATTRIBUTES if k in pairs
71            }
72    return result
73
74
75def attributes_from_node(node: dict[str, Any]) -> dict[str, str]:
76    flat = node.get("attributes", [])
77    attrs = dict(zip(flat[::2], flat[1::2]))
78    return {k: attrs[k] for k in IDENTITY_ATTRIBUTES if k in attrs}
79
80
81def ax_properties(node: dict[str, Any]) -> dict[str, Any]:
82    return {
83        p["name"]: p.get("value", {}).get("value") for p in node.get("properties", [])
84    }
85
86
87def walk_ax(
88    nodes: list[dict[str, Any]],
89) -> list[tuple[dict[str, Any], str | None, int]]:
90    """Reorder Chrome's breadth-first response into reading order, keeping context."""
91    by_id = {n["nodeId"]: n for n in nodes}
92    roots = [n for n in nodes if n.get("parentId") not in by_id]
93    stack = [(n, None, 0) for n in reversed(roots)]
94    seen = set()
95    result = []
96    while stack:
97        node, parent, depth = stack.pop()
98        key = node["nodeId"]
99        if key in seen:
100            continue
101        seen.add(key)
102        role = node.get("role", {}).get("value", "")
103        name = node.get("name", {}).get("value", "")
104        keep = not node.get("ignored") and role != "InlineTextBox"
105        if role in ("none", "generic", "LabelText") and not name:
106            keep = False
107        if keep:
108            result.append((node, parent, depth))
109        for child in reversed(node.get("childIds", [])):
110            if child in by_id:
111                stack.append((by_id[child], key if keep else parent, depth + int(keep)))
112    return result
113
114
115def find(
116    observation: Observation,
117    *,
118    role: str | None = None,
119    name: str | None = None,
120    text: str | None = None,
121    frame_id: str | None = None,
122    exact: bool = True,
123    **fields: Any,
124) -> list[Node]:
125    """Pure selection. Exact names are case-sensitive; text is a substring filter."""
126    result = []
127    for node in observation["nodes"]:
128        if role is not None and node["role"] != role:
129            continue
130        if frame_id is not None and node["frame_id"] != frame_id:
131            continue
132        if name is not None and not (
133            node["name"] == name if exact else name in node["name"]
134        ):
135            continue
136        if (
137            text is not None
138            and text not in node["name"]
139            and text not in str(node.get("value", ""))
140        ):
141            continue
142        if any(node.get(k) != v for k, v in fields.items()):
143            continue
144        result.append(node)
145    return result
146
147
148def one(observation: Observation, **query: Any) -> Node:
149    """Require exactly one match; never choose the first ambiguous element."""
150    matches = find(observation, **query)
151    if len(matches) != 1:
152        raise SelectionError(
153            f"Expected one node, found {len(matches)}.",
154            query=query,
155            matches=[
156                {
157                    "ref": n["ref"],
158                    "role": n["role"],
159                    "name": n["name"],
160                    "frame_id": n["frame_id"],
161                }
162                for n in matches[:10]
163            ],
164            observation_complete=observation["coverage"].get("complete", False),
165        )
166    return matches[0]
167
168
169def render(observation: Observation, *, max_chars: int = 8000) -> str:
170    """Bounded, escaped text view. The input observation is never changed."""
171    if max_chars < 128:
172        raise ValueError("max_chars must be at least 128")
173
174    def quote(value: Any) -> str:
175        return json.dumps(value, ensure_ascii=False)
176
177    lines = [f"Snapshot {observation['snapshot_id']} | {quote(observation['url'])}"]
178    if not observation["coverage"].get("complete"):
179        lines.append(
180            "COVERAGE INCOMPLETE: " + quote(observation["coverage"].get("warnings", []))
181        )
182    by_id = {n["id"]: n for n in observation["nodes"]}
183    for node in observation["nodes"]:
184        parent = by_id.get(node["parent"])
185        if node["role"] == "text" and parent and parent["name"] == node["name"]:
186            continue
187        label = f"[{node['ref']}]" if node["ref"] else "[-]"
188        line = f"{'  ' * min(node['depth'], 5)}{label} {node['role']} {quote(node['name'])}"
189        for key in (
190            "value",
191            "href",
192            "disabled",
193            "checked",
194            "selected",
195            "expanded",
196            "required",
197        ):
198            if key in node:
199                line += f" {key}={quote(node[key])}"
200        if node.get("name_truncated"):
201            line += " [name truncated]"
202        if len(observation["frames"]) > 1:
203            line += f" frame={node['frame_id']}"
204        lines.append(line)
205    text = "\n".join(lines)
206    if len(text) <= max_chars:
207        return text
208    suffix = "\n[RENDER TRUNCATED; structured observation is unchanged]"
209    return text[: max_chars - len(suffix)] + suffix