agentremote-cli 1.0.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.
- agentremote_cli-1.0.0.dist-info/METADATA +7 -0
- agentremote_cli-1.0.0.dist-info/RECORD +25 -0
- agentremote_cli-1.0.0.dist-info/WHEEL +5 -0
- agentremote_cli-1.0.0.dist-info/entry_points.txt +2 -0
- agentremote_cli-1.0.0.dist-info/top_level.txt +1 -0
- remotecli/__init__.py +1 -0
- remotecli/agent.py +673 -0
- remotecli/agent_cache.py +274 -0
- remotecli/agent_modes.py +5 -0
- remotecli/agent_open.py +323 -0
- remotecli/agent_projection.py +308 -0
- remotecli/agent_result.py +81 -0
- remotecli/agent_utils.py +113 -0
- remotecli/agent_window.py +249 -0
- remotecli/cli.py +635 -0
- remotecli/client.py +120 -0
- remotecli/config/agentremote.cli.toml +6 -0
- remotecli/cua.py +213 -0
- remotecli/guides/GUIDE.md +397 -0
- remotecli/guides/agent.md +60 -0
- remotecli/guides/cua.md +60 -0
- remotecli/guides/rpa.md +45 -0
- remotecli/guides/shell.md +49 -0
- remotecli/rpa.py +57 -0
- remotecli/shell.py +71 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Compact observe projection for agent-friendly output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Any, Dict, Iterable
|
|
7
|
+
|
|
8
|
+
from .agent_utils import (
|
|
9
|
+
_extract_int,
|
|
10
|
+
_first_present,
|
|
11
|
+
_first_text,
|
|
12
|
+
_optional_bool,
|
|
13
|
+
_target_terms,
|
|
14
|
+
_truncate,
|
|
15
|
+
_truthy,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _compact_window_state(
|
|
20
|
+
payload: Any,
|
|
21
|
+
*,
|
|
22
|
+
title: str,
|
|
23
|
+
trace_id: Any,
|
|
24
|
+
targets: Iterable[str] | str | None,
|
|
25
|
+
element_limit: int,
|
|
26
|
+
) -> Dict[str, Any]:
|
|
27
|
+
data = payload if isinstance(payload, dict) else {}
|
|
28
|
+
raw_elements = _extract_element_list(data)
|
|
29
|
+
elements = [_compact_element(element, index) for index, element in enumerate(raw_elements)]
|
|
30
|
+
selected = _select_elements(elements, targets, element_limit)
|
|
31
|
+
public_items = [_public_element(item) for item in selected]
|
|
32
|
+
by_role = Counter(str(item.get("role") or "Unknown") for item in elements)
|
|
33
|
+
|
|
34
|
+
state: Dict[str, Any] = {
|
|
35
|
+
"title": title,
|
|
36
|
+
"blocking": _compact_blocking(data),
|
|
37
|
+
"focus": _compact_focus(data, elements),
|
|
38
|
+
"elements": {
|
|
39
|
+
"total": len(elements),
|
|
40
|
+
"returned": len(public_items),
|
|
41
|
+
"truncated": len(public_items) < len(elements),
|
|
42
|
+
"by_role": dict(by_role),
|
|
43
|
+
"items": public_items,
|
|
44
|
+
},
|
|
45
|
+
"artifacts": _compact_artifacts(data, trace_id),
|
|
46
|
+
}
|
|
47
|
+
page = _compact_page(data)
|
|
48
|
+
if page:
|
|
49
|
+
state["page"] = page
|
|
50
|
+
return state
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_element_list(payload: Dict[str, Any]) -> list[Dict[str, Any]]:
|
|
54
|
+
for key in ("elements", "uia_elements", "controls", "items"):
|
|
55
|
+
value = payload.get(key)
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
return [item for item in value if isinstance(item, dict)]
|
|
58
|
+
|
|
59
|
+
for key in ("state", "window", "data"):
|
|
60
|
+
value = payload.get(key)
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
nested = _extract_element_list(value)
|
|
63
|
+
if nested:
|
|
64
|
+
return nested
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compact_element(element: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
69
|
+
role = _first_text(element, "role", "control_type", "type", "class_name", "localized_control_type") or "Unknown"
|
|
70
|
+
label = _first_text(element, "label", "name", "text", "title", "automation_id") or ""
|
|
71
|
+
value = _first_text(element, "value", "current_value", "text_value", "display_value")
|
|
72
|
+
is_password = _is_password_element(element, role, label)
|
|
73
|
+
focused = _truthy(_first_present(element, "focused", "is_focused", "has_keyboard_focus", "keyboard_focus"))
|
|
74
|
+
|
|
75
|
+
public: Dict[str, Any] = {
|
|
76
|
+
"id": _element_id(element, index),
|
|
77
|
+
"role": role,
|
|
78
|
+
}
|
|
79
|
+
if label:
|
|
80
|
+
public["label"] = label
|
|
81
|
+
|
|
82
|
+
if value is not None:
|
|
83
|
+
value_text = str(value)
|
|
84
|
+
public["filled"] = bool(value_text)
|
|
85
|
+
if is_password:
|
|
86
|
+
public["value"] = "***" if value_text else ""
|
|
87
|
+
public["value_kind"] = "password"
|
|
88
|
+
else:
|
|
89
|
+
public["value"] = _truncate(value_text, 80)
|
|
90
|
+
elif is_password:
|
|
91
|
+
public["value_kind"] = "password"
|
|
92
|
+
|
|
93
|
+
enabled = _optional_bool(element, "enabled", "is_enabled")
|
|
94
|
+
if enabled is not None:
|
|
95
|
+
public["enabled"] = enabled
|
|
96
|
+
visible = _optional_bool(element, "visible", "is_visible", "offscreen")
|
|
97
|
+
if visible is not None:
|
|
98
|
+
public["visible"] = visible
|
|
99
|
+
|
|
100
|
+
bounds = _compact_bounds(element)
|
|
101
|
+
if bounds:
|
|
102
|
+
public["bounds"] = bounds
|
|
103
|
+
|
|
104
|
+
public["_index"] = index
|
|
105
|
+
public["_score"] = _element_score(public, label, role, is_password, focused)
|
|
106
|
+
public["_focused"] = focused
|
|
107
|
+
return public
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _select_elements(
|
|
111
|
+
elements: list[Dict[str, Any]],
|
|
112
|
+
targets: Iterable[str] | str | None,
|
|
113
|
+
element_limit: int,
|
|
114
|
+
) -> list[Dict[str, Any]]:
|
|
115
|
+
if element_limit <= 0 or not elements:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
target_terms = _target_terms(targets)
|
|
119
|
+
scored: list[tuple[int, int, Dict[str, Any]]] = []
|
|
120
|
+
for item in elements:
|
|
121
|
+
score = int(item.get("_score") or 0)
|
|
122
|
+
label = str(item.get("label") or "").lower()
|
|
123
|
+
role = str(item.get("role") or "").lower()
|
|
124
|
+
haystack = f"{label} {role}".strip()
|
|
125
|
+
for target in target_terms:
|
|
126
|
+
if target and target in haystack:
|
|
127
|
+
score += 5000
|
|
128
|
+
scored.append((score, -int(item.get("_index") or 0), item))
|
|
129
|
+
|
|
130
|
+
selected = [
|
|
131
|
+
item
|
|
132
|
+
for score, _negative_index, item in sorted(
|
|
133
|
+
scored,
|
|
134
|
+
key=lambda entry: (entry[0], entry[1]),
|
|
135
|
+
reverse=True,
|
|
136
|
+
)[:element_limit]
|
|
137
|
+
if score > 0
|
|
138
|
+
]
|
|
139
|
+
selected.sort(key=lambda item: int(item.get("_index") or 0))
|
|
140
|
+
return selected
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _public_element(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
144
|
+
return {key: value for key, value in item.items() if not key.startswith("_")}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _element_score(
|
|
148
|
+
item: Dict[str, Any],
|
|
149
|
+
label: str,
|
|
150
|
+
role: str,
|
|
151
|
+
is_password: bool,
|
|
152
|
+
focused: bool,
|
|
153
|
+
) -> int:
|
|
154
|
+
role_text = role.lower()
|
|
155
|
+
label_text = label.lower()
|
|
156
|
+
score = 0
|
|
157
|
+
if focused:
|
|
158
|
+
score += 900
|
|
159
|
+
if role_text in {"edit", "textbox", "text box", "document"}:
|
|
160
|
+
score += 700
|
|
161
|
+
if is_password:
|
|
162
|
+
score += 600
|
|
163
|
+
if role_text in {
|
|
164
|
+
"button",
|
|
165
|
+
"hyperlink",
|
|
166
|
+
"link",
|
|
167
|
+
"combobox",
|
|
168
|
+
"combo box",
|
|
169
|
+
"menuitem",
|
|
170
|
+
"menu item",
|
|
171
|
+
"checkbox",
|
|
172
|
+
"check box",
|
|
173
|
+
"radiobutton",
|
|
174
|
+
"radio button",
|
|
175
|
+
"tabitem",
|
|
176
|
+
"tab item",
|
|
177
|
+
"edit",
|
|
178
|
+
"textbox",
|
|
179
|
+
"text box",
|
|
180
|
+
}:
|
|
181
|
+
score += 400
|
|
182
|
+
if any(keyword in label_text for keyword in ("sign in", "login", "log in", "submit", "save", "ok", "continue", "next")):
|
|
183
|
+
score += 150
|
|
184
|
+
if item.get("enabled") is True:
|
|
185
|
+
score += 30
|
|
186
|
+
if item.get("visible") is True:
|
|
187
|
+
score += 20
|
|
188
|
+
return score
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _compact_focus(payload: Dict[str, Any], elements: list[Dict[str, Any]]) -> Dict[str, Any] | None:
|
|
192
|
+
focus = payload.get("focus") or payload.get("focused") or payload.get("active_element")
|
|
193
|
+
if isinstance(focus, dict):
|
|
194
|
+
return _public_element(_compact_element(focus, -1))
|
|
195
|
+
for item in elements:
|
|
196
|
+
if item.get("_focused"):
|
|
197
|
+
return _public_element(item)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _compact_blocking(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
202
|
+
top_window = payload.get("top_window") or payload.get("foreground_window") or payload.get("active_window")
|
|
203
|
+
overlay = payload.get("overlay") or payload.get("modal") or payload.get("dialog")
|
|
204
|
+
has_overlay = bool(payload.get("has_overlay") or payload.get("blocked") or payload.get("obscured") or overlay)
|
|
205
|
+
result: Dict[str, Any] = {"has_overlay": has_overlay}
|
|
206
|
+
if isinstance(top_window, dict):
|
|
207
|
+
result["top_window"] = _compact_window_like(top_window)
|
|
208
|
+
elif top_window:
|
|
209
|
+
result["top_window"] = str(top_window)
|
|
210
|
+
if isinstance(overlay, dict):
|
|
211
|
+
result["overlay"] = _compact_window_like(overlay)
|
|
212
|
+
elif overlay and not isinstance(overlay, bool):
|
|
213
|
+
result["overlay"] = str(overlay)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _compact_page(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
+
page: Dict[str, Any] = {}
|
|
219
|
+
url = _first_text(payload, "url", "page_url", "browser_url", "address")
|
|
220
|
+
if url:
|
|
221
|
+
page["url"] = url
|
|
222
|
+
page_title = _first_text(payload, "page_title", "document_title")
|
|
223
|
+
if page_title:
|
|
224
|
+
page["title"] = page_title
|
|
225
|
+
loading = _optional_bool(payload, "loading", "is_loading")
|
|
226
|
+
if loading is not None:
|
|
227
|
+
page["loading"] = loading
|
|
228
|
+
return page
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _compact_artifacts(payload: Dict[str, Any], trace_id: Any) -> Dict[str, Any]:
|
|
232
|
+
artifacts: Dict[str, Any] = {}
|
|
233
|
+
if trace_id:
|
|
234
|
+
artifacts["snapshot_id"] = str(trace_id)
|
|
235
|
+
|
|
236
|
+
screenshot = payload.get("screenshot")
|
|
237
|
+
if isinstance(screenshot, dict):
|
|
238
|
+
artifacts["screenshot"] = {
|
|
239
|
+
key: screenshot[key]
|
|
240
|
+
for key in ("local_path", "size_bytes", "width", "height")
|
|
241
|
+
if key in screenshot
|
|
242
|
+
}
|
|
243
|
+
elif isinstance(payload.get("screenshot_png_b64"), str):
|
|
244
|
+
artifacts["screenshot"] = {
|
|
245
|
+
"inline": True,
|
|
246
|
+
"size_bytes": len(payload["screenshot_png_b64"]),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if payload.get("tree_path"):
|
|
250
|
+
artifacts["tree_path"] = str(payload["tree_path"])
|
|
251
|
+
elif payload.get("tree_markdown"):
|
|
252
|
+
artifacts["tree_available"] = True
|
|
253
|
+
return artifacts
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _compact_window_like(value: Dict[str, Any]) -> Dict[str, Any]:
|
|
257
|
+
compact: Dict[str, Any] = {}
|
|
258
|
+
for key in ("pid", "window_id", "title", "name", "class_name"):
|
|
259
|
+
if value.get(key) not in (None, ""):
|
|
260
|
+
compact[key] = value[key]
|
|
261
|
+
return compact
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _element_id(element: Dict[str, Any], index: int) -> str:
|
|
265
|
+
value = _first_present(element, "id", "element_id", "element_index", "index")
|
|
266
|
+
if value not in (None, ""):
|
|
267
|
+
text = str(value)
|
|
268
|
+
return text if text.startswith("e") else f"e{text}"
|
|
269
|
+
return f"e{index}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _compact_bounds(element: Dict[str, Any]) -> Dict[str, int]:
|
|
273
|
+
bounds = element.get("bounds") or element.get("bbox") or element.get("rect")
|
|
274
|
+
if isinstance(bounds, dict):
|
|
275
|
+
x = _extract_int(bounds, "x", "left")
|
|
276
|
+
y = _extract_int(bounds, "y", "top")
|
|
277
|
+
width = _extract_int(bounds, "width", "w")
|
|
278
|
+
height = _extract_int(bounds, "height", "h")
|
|
279
|
+
right = _extract_int(bounds, "right")
|
|
280
|
+
bottom = _extract_int(bounds, "bottom")
|
|
281
|
+
if not width and right:
|
|
282
|
+
width = max(0, right - x)
|
|
283
|
+
if not height and bottom:
|
|
284
|
+
height = max(0, bottom - y)
|
|
285
|
+
result = {"x": x, "y": y}
|
|
286
|
+
if width:
|
|
287
|
+
result["width"] = width
|
|
288
|
+
if height:
|
|
289
|
+
result["height"] = height
|
|
290
|
+
return result
|
|
291
|
+
if isinstance(bounds, (list, tuple)) and len(bounds) >= 4:
|
|
292
|
+
try:
|
|
293
|
+
x1, y1, x2, y2 = [int(float(part)) for part in bounds[:4]]
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
return {}
|
|
296
|
+
return {"x": x1, "y": y1, "width": max(0, x2 - x1), "height": max(0, y2 - y1)}
|
|
297
|
+
return {}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_password_element(element: Dict[str, Any], role: str, label: str) -> bool:
|
|
301
|
+
if _truthy(_first_present(element, "is_password", "password", "isPassword")):
|
|
302
|
+
return True
|
|
303
|
+
role_text = role.lower()
|
|
304
|
+
input_like = role_text in {"edit", "textbox", "text box"} or "password" in role_text
|
|
305
|
+
if not input_like:
|
|
306
|
+
return False
|
|
307
|
+
text = f"{role} {label} {_first_text(element, 'automation_id', 'name') or ''}".lower()
|
|
308
|
+
return any(hint in text for hint in ("password", "passwd", "pwd", "pin", "\u5bc6\u7801", "\u53e3\u4ee4"))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Result normalization helpers for agent-friendly commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_task_result(data: Dict[str, Any], action: str) -> Dict[str, Any]:
|
|
10
|
+
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
|
11
|
+
output = result.get("output") if result else data.get("output")
|
|
12
|
+
parsed_output = _parse_json_if_possible(output)
|
|
13
|
+
|
|
14
|
+
stdout = ""
|
|
15
|
+
stderr = ""
|
|
16
|
+
exit_code = None
|
|
17
|
+
json_payload = None
|
|
18
|
+
error = ""
|
|
19
|
+
success_value = None
|
|
20
|
+
|
|
21
|
+
if isinstance(parsed_output, dict):
|
|
22
|
+
stdout = str(parsed_output.get("stdout") or "")
|
|
23
|
+
stderr = str(parsed_output.get("stderr") or "")
|
|
24
|
+
exit_code = parsed_output.get("exit_code", parsed_output.get("returncode"))
|
|
25
|
+
json_payload = parsed_output.get("json")
|
|
26
|
+
error = str(parsed_output.get("error") or "")
|
|
27
|
+
success_value = parsed_output.get("success")
|
|
28
|
+
elif isinstance(parsed_output, (list, dict)):
|
|
29
|
+
json_payload = parsed_output
|
|
30
|
+
elif isinstance(parsed_output, str):
|
|
31
|
+
stdout = parsed_output
|
|
32
|
+
|
|
33
|
+
if json_payload is None and stdout:
|
|
34
|
+
parsed_stdout = _parse_json_if_possible(stdout.strip())
|
|
35
|
+
if isinstance(parsed_stdout, (dict, list)):
|
|
36
|
+
json_payload = parsed_stdout
|
|
37
|
+
|
|
38
|
+
result_status = result.get("status") if result else data.get("status")
|
|
39
|
+
ok = data.get("status") == "done" or result_status in {"SUCCESS", "success", "ok"}
|
|
40
|
+
if result_status and str(result_status).upper() not in {"SUCCESS", "DONE", "OK"}:
|
|
41
|
+
ok = False
|
|
42
|
+
if success_value is False:
|
|
43
|
+
ok = False
|
|
44
|
+
if exit_code is not None:
|
|
45
|
+
try:
|
|
46
|
+
ok = ok and int(exit_code) == 0
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
ok = False
|
|
49
|
+
if data.get("status") in {"failed", "timeout", "rejected"}:
|
|
50
|
+
ok = False
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"ok": bool(ok),
|
|
54
|
+
"action": action,
|
|
55
|
+
"trace_id": data.get("trace_id"),
|
|
56
|
+
"status": data.get("status"),
|
|
57
|
+
"result_status": result_status,
|
|
58
|
+
"stdout": stdout,
|
|
59
|
+
"stderr": stderr,
|
|
60
|
+
"exit_code": exit_code,
|
|
61
|
+
"json": json_payload,
|
|
62
|
+
"output": parsed_output,
|
|
63
|
+
"error": error or str(data.get("error") or data.get("reason") or ""),
|
|
64
|
+
"raw": data,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_json_if_possible(value: Any) -> Any:
|
|
69
|
+
if not isinstance(value, str):
|
|
70
|
+
return value
|
|
71
|
+
text = value.strip()
|
|
72
|
+
if not text:
|
|
73
|
+
return value
|
|
74
|
+
try:
|
|
75
|
+
return json.loads(text)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _error(action: str, message: str, **extra: Any) -> Dict[str, Any]:
|
|
81
|
+
return {"ok": False, "action": action, "error": message, **extra}
|
remotecli/agent_utils.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared helpers for agent-friendly remotecli wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _coerce_int(value: Any) -> int:
|
|
9
|
+
try:
|
|
10
|
+
return int(value)
|
|
11
|
+
except (TypeError, ValueError):
|
|
12
|
+
return 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _coerce_float(value: Any) -> float:
|
|
16
|
+
try:
|
|
17
|
+
return float(value)
|
|
18
|
+
except (TypeError, ValueError):
|
|
19
|
+
return 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_mode(value: str, *, default: str) -> str:
|
|
23
|
+
mode = str(value or "").strip().lower()
|
|
24
|
+
return mode or default
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize_scroll_direction(value: str) -> str:
|
|
28
|
+
direction = str(value or "down").strip().lower()
|
|
29
|
+
return direction if direction in {"up", "down", "left", "right"} else "down"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_scroll_amount(value: Any) -> int:
|
|
33
|
+
try:
|
|
34
|
+
amount = int(value)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
amount = 3
|
|
37
|
+
return max(1, min(50, amount))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_scroll_by(value: str) -> str:
|
|
41
|
+
by = str(value or "line").strip().lower()
|
|
42
|
+
return by if by in {"line", "page"} else "line"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _extract_int(item: dict[str, Any], *keys: str) -> int:
|
|
46
|
+
for key in keys:
|
|
47
|
+
value = item.get(key)
|
|
48
|
+
if value in (None, ""):
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
return int(value)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
continue
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _target_terms(targets: Iterable[str] | str | None) -> list[str]:
|
|
58
|
+
if targets is None:
|
|
59
|
+
return []
|
|
60
|
+
if isinstance(targets, str):
|
|
61
|
+
raw_terms = targets.split(",")
|
|
62
|
+
else:
|
|
63
|
+
raw_terms = [str(term) for term in targets]
|
|
64
|
+
return [term.strip().lower() for term in raw_terms if term.strip()]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_texts(targets: Iterable[str] | str | None) -> list[str]:
|
|
68
|
+
if targets is None:
|
|
69
|
+
return []
|
|
70
|
+
if isinstance(targets, str):
|
|
71
|
+
raw_terms = targets.split(",")
|
|
72
|
+
else:
|
|
73
|
+
raw_terms = [str(term) for term in targets]
|
|
74
|
+
return [term.strip() for term in raw_terms if term.strip()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _first_text(item: dict[str, Any], *keys: str) -> str | None:
|
|
78
|
+
value = _first_present(item, *keys)
|
|
79
|
+
if value in (None, ""):
|
|
80
|
+
return None
|
|
81
|
+
return str(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _first_present(item: dict[str, Any], *keys: str) -> Any:
|
|
85
|
+
for key in keys:
|
|
86
|
+
if key in item:
|
|
87
|
+
return item[key]
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _optional_bool(item: dict[str, Any], *keys: str) -> bool | None:
|
|
92
|
+
for key in keys:
|
|
93
|
+
if key not in item:
|
|
94
|
+
continue
|
|
95
|
+
value = _truthy(item[key])
|
|
96
|
+
return not value if key == "offscreen" else value
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _truthy(value: Any) -> bool:
|
|
101
|
+
if isinstance(value, bool):
|
|
102
|
+
return value
|
|
103
|
+
if isinstance(value, (int, float)):
|
|
104
|
+
return value != 0
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
107
|
+
return bool(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _truncate(value: str, max_length: int) -> str:
|
|
111
|
+
if len(value) <= max_length:
|
|
112
|
+
return value
|
|
113
|
+
return value[: max_length - 3] + "..."
|