-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoracles.py
More file actions
217 lines (193 loc) · 8.36 KB
/
Copy pathoracles.py
File metadata and controls
217 lines (193 loc) · 8.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
"""
oracles.py — Declarative state assertions (design doc §15.6).
assert_state(predicate=[…]) takes an AND list of predicates and returns
{ok, all_passed, results: [{kind, args, passed, observed}]}. Never raises.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List, Optional
import element_selectors as sel
from errors import Code, error_dict
from hashing import tree_hash
from observer import ScreenObserver, UIElement, WindowInfo
logger = logging.getLogger(__name__)
PREDICATE_KINDS = {
"element_exists", "element_absent", "value_equals", "value_matches",
"text_visible", "window_focused", "window_exists", "tree_hash_equals",
"screenshot_similar",
}
def evaluate(observer: ScreenObserver,
predicates: List[Dict[str, Any]],
*,
config: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(predicates, list) or not predicates:
return error_dict(Code.BAD_REQUEST, "predicates list required")
windows = observer.list_windows()
results: List[Dict[str, Any]] = []
all_passed = True
for p in predicates:
if not isinstance(p, dict):
results.append({"kind": "?", "passed": False,
"observed": "predicate must be a mapping"})
all_passed = False
continue
kind = p.get("kind")
if kind not in PREDICATE_KINDS:
results.append({"kind": kind or "?", "passed": False,
"observed": "unknown predicate kind",
"args": p})
all_passed = False
continue
try:
passed, observed = _run(kind, p, observer, windows, config)
except Exception as e:
logger.exception(f"oracle {kind} crashed")
passed, observed = False, f"error: {type(e).__name__}: {e}"
entry: Dict[str, Any] = {
"kind": kind, "passed": bool(passed),
"observed": observed,
"args": {k: v for k, v in p.items() if k != "kind"},
}
# When a predicate cannot be evaluated (missing optional
# dependency), surface the structured error code so callers can
# branch on it rather than mistaking it for a real failure.
if isinstance(observed, dict) and observed.get("unsupported"):
entry["error_code"] = observed.get(
"code", Code.PREDICATE_UNSUPPORTED,
)
results.append(entry)
if not passed:
all_passed = False
return {
"ok": True, "success": True,
"all_passed": all_passed,
"results": results,
}
# ─── Implementations ──────────────────────────────────────────────────────────
def _run(kind: str, p: Dict[str, Any], observer: ScreenObserver,
windows: List[WindowInfo], config: Dict[str, Any]
) -> tuple:
if kind == "window_exists":
title_rx = p.get("title_regex")
uid = p.get("window_uid")
for w in windows:
if uid and w.window_uid == uid:
return True, {"window_uid": w.window_uid, "title": w.title}
if title_rx and re.search(title_rx, w.title):
return True, {"window_uid": w.window_uid, "title": w.title}
return False, "no matching window"
if kind == "window_focused":
rx = p.get("title_regex", "")
for w in windows:
if w.is_focused and re.search(rx, w.title):
return True, {"window_uid": w.window_uid, "title": w.title}
return False, "focused window does not match"
info = _resolve_window(observer, windows, p)
tree: Optional[UIElement] = (
observer.get_element_tree(info.handle) if info else None
)
if kind in {"element_exists", "element_absent",
"value_equals", "value_matches"}:
if tree is None:
return False, "no tree"
sel_text = p.get("selector")
if not sel_text:
return False, "selector required"
try:
res = sel.resolve(tree, sel.parse(sel_text))
except sel.SelectorParseError as e:
return False, f"selector parse: {e}"
if kind == "element_exists":
return bool(res.matches), (
{"count": len(res.matches),
"first_id": res.matches[0].element_id if res.matches else None}
)
if kind == "element_absent":
return not res.matches, {"count": len(res.matches)}
if kind == "value_equals":
if not res.matches:
return False, "no matches"
actual = res.matches[0].value
return actual == p.get("expected"), {"actual": actual}
if kind == "value_matches":
if not res.matches:
return False, "no matches"
actual = res.matches[0].value or ""
rx = p.get("regex", "")
return re.search(rx, actual) is not None, {"actual": actual}
if kind == "text_visible":
rx = p.get("regex", "")
mode = p.get("mode", "auto")
# Tree pass.
if tree is not None and mode in ("auto", "tree"):
for elem in tree.flat_list():
joined = (elem.name or "") + " " + (elem.value or "")
if re.search(rx, joined):
return True, {"source": "tree",
"element_id": elem.element_id}
# OCR pass (only when explicitly requested or tree miss in auto).
if mode in ("ocr", "auto") and info is not None:
try:
import io
from PIL import Image
import pytesseract
from ocr_util import configure as _ocr_configure, INSTALL_HINT
_ocr_configure(config)
except Exception:
from ocr_util import INSTALL_HINT
return False, {"ocr_error": "ocr unavailable",
"hint": INSTALL_HINT}
shot = observer.get_screenshot(info.handle)
if shot:
try:
text = pytesseract.image_to_string(Image.open(io.BytesIO(shot)))
except pytesseract.TesseractNotFoundError:
from ocr_util import diagnose as _ocr_diag
return False, {"ocr_error": "tesseract not found",
"diagnose": _ocr_diag(config),
"hint": INSTALL_HINT}
if re.search(rx, text or ""):
return True, {"source": "ocr"}
return False, "no match"
if kind == "tree_hash_equals":
if tree is None:
return False, "no tree"
actual = tree_hash(tree)
return actual == p.get("expected_hash"), {"actual": actual}
if kind == "screenshot_similar":
try:
import io
from PIL import Image
import numpy as np
from skimage.metrics import structural_similarity as ssim
except Exception as e:
return False, {
"unsupported": True,
"code": Code.PREDICATE_UNSUPPORTED,
"reason": (f"scikit-image / Pillow / numpy required for "
f"screenshot_similar: {e}"),
}
ref_path = p.get("reference_path")
min_ssim = float(p.get("min_ssim", 0.95))
if not ref_path or not info:
return False, "reference_path and window required"
shot = observer.get_screenshot(info.handle)
if not shot:
return False, "no screenshot"
a = np.array(Image.open(io.BytesIO(shot)).convert("L"))
b = np.array(Image.open(ref_path).convert("L"))
if a.shape != b.shape:
# Resize ref to actual.
from PIL import Image as _Im
b = np.array(_Im.open(ref_path).convert("L").resize(
(a.shape[1], a.shape[0])))
score = float(ssim(a, b))
return score >= min_ssim, {"ssim": score}
return False, "unhandled"
def _resolve_window(observer: ScreenObserver, windows: List[WindowInfo],
p: Dict[str, Any]) -> Optional[WindowInfo]:
uid = p.get("window_uid")
if uid:
return observer.window_by_uid(windows, uid)
return next((w for w in windows if w.is_focused), windows[0] if windows else None)