winauto-cli 0.1.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.
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from winauto_cli.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,635 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from winauto_cli.errors import ActionExecutionError, AssertionExecutionError, ElementResolutionError
10
+ from winauto_cli.models import RuntimeOptions, Selector
11
+
12
+ try:
13
+ from pywinauto import Desktop
14
+ except ImportError: # pragma: no cover - handled at runtime
15
+ Desktop = None
16
+
17
+
18
+ COMMON_PATTERNS = {
19
+ "invoke": "iface_invoke",
20
+ "value": "iface_value",
21
+ "selection": "iface_selection",
22
+ "selection_item": "iface_selection_item",
23
+ "toggle": "iface_toggle",
24
+ "expand_collapse": "iface_expand_collapse",
25
+ "text": "iface_text",
26
+ "grid": "iface_grid",
27
+ "table": "iface_table",
28
+ "legacy": "iface_legacy_iaccessible",
29
+ }
30
+
31
+ ALLOWED_PATH_ATTRS = {"name", "auto_id", "class_name", "control_type", "window_text"}
32
+ PATH_ATTR_PATTERN = re.compile(r"\[@(?P<key>[a-zA-Z_]+)=(?P<quote>['\"])(?P<value>.*?)(?P=quote)\]")
33
+ PATH_INDEX_PATTERN = re.compile(r"\[(?P<index>\d+)\]")
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class PathStep:
38
+ axis: str
39
+ control_type: str | None = None
40
+ filters: dict[str, str] = field(default_factory=dict)
41
+ index: int | None = None
42
+ raw: str = ""
43
+
44
+
45
+
46
+ def _require_pywinauto() -> None:
47
+ if Desktop is None:
48
+ raise ActionExecutionError(
49
+ "pywinauto is not installed. Install dependencies first with: python -m pip install -e ."
50
+ )
51
+
52
+
53
+
54
+ def _log(enabled: bool, message: str) -> None:
55
+ if enabled:
56
+ print(f"[debug] {message}")
57
+
58
+
59
+
60
+ def _desktop(backend: str):
61
+ _require_pywinauto()
62
+ return Desktop(backend=backend)
63
+
64
+
65
+
66
+ def _wait_until(timeout: float):
67
+ deadline = time.monotonic() + max(timeout, 0)
68
+ while True:
69
+ yield
70
+ if time.monotonic() >= deadline:
71
+ break
72
+ time.sleep(0.2)
73
+
74
+
75
+
76
+ def resolve_scope(window_selector: Selector, options: RuntimeOptions):
77
+ if window_selector.is_empty():
78
+ return None
79
+ desktop = _desktop(options.backend)
80
+ return desktop.window(**window_selector.to_kwargs()).wrapper_object()
81
+
82
+
83
+
84
+ def resolve_element(window_selector: Selector, target_selector: Selector, options: RuntimeOptions, path_query: str | None = None):
85
+ matches = find_elements(window_selector, target_selector, options, max_results=1, require_match=True, path_query=path_query)
86
+ return matches[0]
87
+
88
+
89
+
90
+ def find_elements(
91
+ window_selector: Selector,
92
+ target_selector: Selector,
93
+ options: RuntimeOptions,
94
+ max_results: int = 20,
95
+ require_match: bool = False,
96
+ path_query: str | None = None,
97
+ ):
98
+ last_error: Exception | None = None
99
+
100
+ for _ in _wait_until(options.timeout):
101
+ try:
102
+ scope = resolve_scope(window_selector, options)
103
+ if scope is not None:
104
+ _log(options.verbose, f"resolved parent window: {safe_summary(scope)}")
105
+ matches = _collect_matches(scope, target_selector, options, max_results=max_results, path_query=path_query)
106
+ if matches:
107
+ _log(options.verbose, f"resolved {len(matches)} candidate(s)")
108
+ return matches
109
+ last_error = None
110
+ except Exception as exc: # pragma: no cover - depends on UI state
111
+ last_error = exc
112
+
113
+ if require_match:
114
+ selector_blob = {
115
+ "window": window_selector.to_kwargs(),
116
+ "target": target_selector.to_kwargs(),
117
+ "path": path_query,
118
+ "backend": options.backend,
119
+ "top_level_only": options.top_level_only,
120
+ }
121
+ raise ElementResolutionError(
122
+ f"Unable to resolve element within {options.timeout:.1f}s: {json.dumps(selector_blob, ensure_ascii=True)}"
123
+ ) from last_error
124
+
125
+ return []
126
+
127
+
128
+
129
+ def _collect_matches(scope, target_selector: Selector, options: RuntimeOptions, max_results: int, path_query: str | None = None):
130
+ if path_query:
131
+ return _collect_path_matches(scope, path_query, options, max_results=max_results)
132
+
133
+ target_kwargs = target_selector.to_kwargs()
134
+ desktop = _desktop(options.backend)
135
+
136
+ if scope is None:
137
+ if target_selector.is_empty():
138
+ windows = desktop.windows()
139
+ else:
140
+ windows = desktop.windows(**target_kwargs)
141
+ return list(windows)[:max_results]
142
+
143
+ if target_selector.is_empty():
144
+ candidates = scope.children() if options.top_level_only else scope.descendants()
145
+ else:
146
+ candidates = scope.children(**target_kwargs) if options.top_level_only else scope.descendants(**target_kwargs)
147
+ return list(candidates)[:max_results]
148
+
149
+
150
+
151
+ def _collect_path_matches(scope, path_query: str, options: RuntimeOptions, max_results: int):
152
+ steps = parse_path_query(path_query)
153
+ desktop = _desktop(options.backend)
154
+ if scope is None:
155
+ current_nodes = list(desktop.windows())
156
+ else:
157
+ current_nodes = [scope]
158
+
159
+ for position, step in enumerate(steps):
160
+ next_nodes = []
161
+ for node in current_nodes:
162
+ next_nodes.extend(_step_candidates(node, step, root_scope=(position == 0 and scope is None)))
163
+ if step.index is not None:
164
+ next_nodes = next_nodes[step.index:step.index + 1]
165
+ current_nodes = next_nodes
166
+ if not current_nodes:
167
+ break
168
+
169
+ return current_nodes[:max_results]
170
+
171
+
172
+
173
+ def _step_candidates(node, step: PathStep, root_scope: bool = False):
174
+ if root_scope:
175
+ if step.axis == "direct":
176
+ pool = [node]
177
+ else:
178
+ pool = [node]
179
+ try:
180
+ pool.extend(node.descendants())
181
+ except Exception:
182
+ pass
183
+ else:
184
+ try:
185
+ pool = node.children() if step.axis == "direct" else node.descendants()
186
+ except Exception:
187
+ return []
188
+ return [candidate for candidate in pool if _matches_path_step(candidate, step)]
189
+
190
+
191
+
192
+ def _matches_path_step(element, step: PathStep) -> bool:
193
+ summary = safe_summary(element)
194
+ if step.control_type and str(summary.get("control_type") or "") != step.control_type:
195
+ return False
196
+ for key, expected in step.filters.items():
197
+ actual = summary.get(key)
198
+ if actual is None:
199
+ return False
200
+ if str(actual) != expected:
201
+ return False
202
+ return True
203
+
204
+
205
+
206
+ def parse_path_query(path_query: str) -> list[PathStep]:
207
+ query = (path_query or "").strip()
208
+ if not query:
209
+ raise ElementResolutionError("Path query cannot be empty.")
210
+
211
+ steps: list[PathStep] = []
212
+ position = 0
213
+ while position < len(query):
214
+ axis = "descendant"
215
+ if query.startswith("//", position):
216
+ axis = "descendant"
217
+ position += 2
218
+ elif query.startswith("/", position):
219
+ axis = "direct"
220
+ position += 1
221
+ elif steps:
222
+ axis = "direct"
223
+
224
+ start = position
225
+ bracket_depth = 0
226
+ while position < len(query):
227
+ char = query[position]
228
+ if char == '[':
229
+ bracket_depth += 1
230
+ elif char == ']':
231
+ bracket_depth -= 1
232
+ elif char == '/' and bracket_depth == 0:
233
+ break
234
+ position += 1
235
+
236
+ segment = query[start:position].strip()
237
+ if not segment:
238
+ continue
239
+ steps.append(_parse_path_segment(segment, axis))
240
+
241
+ if not steps:
242
+ raise ElementResolutionError(f"Invalid path query: {path_query}")
243
+ return steps
244
+
245
+
246
+
247
+ def _parse_path_segment(segment: str, axis: str) -> PathStep:
248
+ bracket_start = segment.find('[')
249
+ head = segment if bracket_start == -1 else segment[:bracket_start]
250
+ head = head.strip()
251
+ control_type = None if head in ("", "*") else head
252
+
253
+ filters: dict[str, str] = {}
254
+ for match in PATH_ATTR_PATTERN.finditer(segment):
255
+ key = match.group("key")
256
+ if key not in ALLOWED_PATH_ATTRS:
257
+ raise ElementResolutionError(f"Unsupported path attribute: {key}")
258
+ filters[key] = match.group("value")
259
+
260
+ index = None
261
+ for match in PATH_INDEX_PATTERN.finditer(segment):
262
+ if match.group(0).startswith("[@"):
263
+ continue
264
+ index = int(match.group("index"))
265
+
266
+ return PathStep(axis=axis, control_type=control_type, filters=filters, index=index, raw=segment)
267
+
268
+
269
+
270
+ def list_windows(options: RuntimeOptions, max_results: int = 50):
271
+ desktop = _desktop(options.backend)
272
+ windows = desktop.windows()
273
+ return [safe_summary(window) for window in windows[:max_results]]
274
+
275
+
276
+
277
+ def safe_summary(element) -> dict[str, Any]:
278
+ info: dict[str, Any] = {"friendly_class_name": None, "window_text": None}
279
+
280
+ try:
281
+ info["friendly_class_name"] = element.friendly_class_name()
282
+ except Exception:
283
+ pass
284
+
285
+ try:
286
+ info["window_text"] = element.window_text()
287
+ except Exception:
288
+ pass
289
+
290
+ try:
291
+ rect = element.rectangle()
292
+ info["rectangle"] = {
293
+ "left": rect.left,
294
+ "top": rect.top,
295
+ "right": rect.right,
296
+ "bottom": rect.bottom,
297
+ }
298
+ except Exception:
299
+ pass
300
+
301
+ element_info = getattr(element, "element_info", None)
302
+ if element_info is not None:
303
+ for source_name, target_name in [
304
+ ("name", "name"),
305
+ ("automation_id", "automation_id"),
306
+ ("class_name", "class_name"),
307
+ ("control_type", "control_type"),
308
+ ("handle", "handle"),
309
+ ("rich_text", "rich_text"),
310
+ ]:
311
+ try:
312
+ info[target_name] = getattr(element_info, source_name)
313
+ except Exception:
314
+ continue
315
+
316
+ return info
317
+
318
+
319
+
320
+ def inspect_element(element, child_limit: int = 20, parent_limit: int = 8) -> dict[str, Any]:
321
+ summary = safe_summary(element)
322
+ children = []
323
+ parents = []
324
+
325
+ try:
326
+ for child in element.children()[:child_limit]:
327
+ children.append(safe_summary(child))
328
+ except Exception:
329
+ pass
330
+
331
+ current = element
332
+ for _ in range(parent_limit):
333
+ try:
334
+ current = current.parent()
335
+ except Exception:
336
+ break
337
+ if current is None:
338
+ break
339
+ parents.append(safe_summary(current))
340
+
341
+ return {
342
+ "element": summary,
343
+ "children": children,
344
+ "parents": parents,
345
+ "supported_patterns": _supported_patterns(element),
346
+ "recommended_selectors": recommended_selectors(summary),
347
+ "recommended_paths": recommended_paths(summary, parents),
348
+ "relative_path": build_relative_path(summary),
349
+ }
350
+
351
+
352
+
353
+ def recommended_selectors(summary: dict[str, Any]) -> list[dict[str, str]]:
354
+ selectors: list[dict[str, str]] = []
355
+ automation_id = summary.get("automation_id")
356
+ name = summary.get("name") or summary.get("window_text")
357
+ control_type = summary.get("control_type")
358
+ class_name = summary.get("class_name")
359
+
360
+ if automation_id:
361
+ selectors.append({"auto_id": str(automation_id)})
362
+ if name and control_type:
363
+ selectors.append({"name": str(name), "control_type": str(control_type)})
364
+ if name:
365
+ selectors.append({"name": str(name)})
366
+ if class_name and control_type:
367
+ selectors.append({"class_name": str(class_name), "control_type": str(control_type)})
368
+ return selectors
369
+
370
+
371
+
372
+ def recommended_paths(summary: dict[str, Any], parents: list[dict[str, Any]]) -> list[str]:
373
+ leaf = _path_segment_for_summary(summary)
374
+ parent_segments = [_path_segment_for_summary(parent) for parent in reversed(parents[:2])]
375
+
376
+ candidates: list[str] = []
377
+ if leaf:
378
+ candidates.append(f"//{leaf}")
379
+ if parent_segments and leaf:
380
+ candidates.append("//" + "/".join(parent_segments + [leaf]))
381
+ if summary.get("control_type"):
382
+ candidates.append(f"//{summary['control_type']}")
383
+
384
+ seen = set()
385
+ deduped = []
386
+ for candidate in candidates:
387
+ if candidate not in seen:
388
+ seen.add(candidate)
389
+ deduped.append(candidate)
390
+ return deduped
391
+
392
+
393
+
394
+ def build_relative_path(summary: dict[str, Any]) -> str | None:
395
+ segment = _path_segment_for_summary(summary)
396
+ if not segment:
397
+ return None
398
+ return segment
399
+
400
+
401
+
402
+ def _path_segment_for_summary(summary: dict[str, Any]) -> str | None:
403
+ control_type = summary.get("control_type")
404
+ if not control_type:
405
+ return None
406
+
407
+ auto_id = summary.get("automation_id")
408
+ if auto_id:
409
+ return f"{control_type}[@auto_id={json.dumps(str(auto_id), ensure_ascii=True)}]"
410
+
411
+ name = summary.get("name") or summary.get("window_text")
412
+ if name:
413
+ return f"{control_type}[@name={json.dumps(str(name), ensure_ascii=True)}]"
414
+
415
+ class_name = summary.get("class_name")
416
+ if class_name:
417
+ return f"{control_type}[@class_name={json.dumps(str(class_name), ensure_ascii=True)}]"
418
+
419
+ return str(control_type)
420
+
421
+
422
+
423
+ def _supported_patterns(element) -> list[str]:
424
+ supported = []
425
+ for pattern_name, attr_name in COMMON_PATTERNS.items():
426
+ try:
427
+ value = getattr(element, attr_name, None)
428
+ if value is not None:
429
+ supported.append(pattern_name)
430
+ except Exception:
431
+ continue
432
+ return supported
433
+
434
+
435
+
436
+ def dump_identifiers(element) -> str:
437
+ lines: list[str] = []
438
+ _append_tree(lines, element, depth=0, max_depth=8)
439
+ return "\n".join(lines).rstrip()
440
+
441
+
442
+
443
+ def highlight_element(element, duration_ms: int = 800, colour: str = "red", thickness: int = 3) -> dict[str, Any]:
444
+ try:
445
+ element.draw_outline(colour=colour, thickness=thickness)
446
+ time.sleep(max(duration_ms, 0) / 1000.0)
447
+ except Exception as exc: # pragma: no cover - depends on UI state
448
+ raise ActionExecutionError(f"Failed to highlight element: {safe_summary(element)}") from exc
449
+
450
+ return {
451
+ "status": "ok",
452
+ "duration_ms": duration_ms,
453
+ "colour": colour,
454
+ "thickness": thickness,
455
+ "element": safe_summary(element),
456
+ }
457
+
458
+
459
+
460
+ def capture_image(
461
+ element,
462
+ out_path: str,
463
+ highlight: bool = False,
464
+ highlight_duration_ms: int = 800,
465
+ highlight_colour: str = "red",
466
+ highlight_thickness: int = 3,
467
+ ) -> dict[str, Any]:
468
+ if highlight:
469
+ highlight_element(
470
+ element,
471
+ duration_ms=highlight_duration_ms,
472
+ colour=highlight_colour,
473
+ thickness=highlight_thickness,
474
+ )
475
+
476
+ try:
477
+ image = element.capture_as_image()
478
+ image.save(out_path)
479
+ except Exception as exc: # pragma: no cover - depends on UI state
480
+ raise ActionExecutionError(f"Failed to capture screenshot for element: {safe_summary(element)}") from exc
481
+
482
+ return {
483
+ "status": "ok",
484
+ "out": out_path,
485
+ "highlight": highlight,
486
+ "highlight_duration_ms": highlight_duration_ms if highlight else 0,
487
+ "highlight_colour": highlight_colour if highlight else None,
488
+ "highlight_thickness": highlight_thickness if highlight else None,
489
+ "element": safe_summary(element),
490
+ }
491
+
492
+
493
+
494
+ def _append_tree(lines: list[str], element, depth: int, max_depth: int) -> None:
495
+ indent = " " * depth
496
+ summary = safe_summary(element)
497
+ label_parts: list[str] = []
498
+
499
+ control_type = summary.get("control_type")
500
+ friendly_class_name = summary.get("friendly_class_name")
501
+ name = summary.get("name") or summary.get("window_text")
502
+ auto_id = summary.get("automation_id")
503
+ class_name = summary.get("class_name")
504
+
505
+ if control_type:
506
+ label_parts.append(f"control_type={control_type}")
507
+ if friendly_class_name:
508
+ label_parts.append(f"friendly_class={friendly_class_name}")
509
+ if name:
510
+ label_parts.append(f"name={name!r}")
511
+ if auto_id:
512
+ label_parts.append(f"auto_id={auto_id!r}")
513
+ if class_name:
514
+ label_parts.append(f"class_name={class_name!r}")
515
+
516
+ if not label_parts:
517
+ label_parts.append(repr(summary))
518
+
519
+ lines.append(f"{indent}- " + ", ".join(label_parts))
520
+
521
+ if depth >= max_depth:
522
+ return
523
+
524
+ try:
525
+ children = element.children()
526
+ except Exception:
527
+ return
528
+
529
+ for child in children:
530
+ _append_tree(lines, child, depth + 1, max_depth)
531
+
532
+
533
+
534
+ def click_element(element) -> None:
535
+ try:
536
+ element.click_input()
537
+ except Exception as exc: # pragma: no cover - depends on UI state
538
+ raise ActionExecutionError(f"Failed to click element: {safe_summary(element)}") from exc
539
+
540
+
541
+
542
+ def type_into_element(element, text: str) -> None:
543
+ try:
544
+ element.set_focus()
545
+ except Exception:
546
+ pass
547
+
548
+ try:
549
+ element.type_keys(text, with_spaces=True, set_foreground=True)
550
+ except Exception:
551
+ try:
552
+ element.set_edit_text(text)
553
+ except Exception as exc: # pragma: no cover - depends on UI state
554
+ raise ActionExecutionError(f"Failed to type into element: {safe_summary(element)}") from exc
555
+
556
+
557
+
558
+ def invoke_element(element) -> None:
559
+ try:
560
+ element.invoke()
561
+ except Exception as exc: # pragma: no cover - depends on UI state
562
+ raise ActionExecutionError(f"Failed to invoke element: {safe_summary(element)}") from exc
563
+
564
+
565
+
566
+ def perform_action(element, action: str, text: str | None = None) -> dict[str, Any]:
567
+ if action == "click":
568
+ click_element(element)
569
+ elif action == "type":
570
+ if text is None:
571
+ raise ActionExecutionError("The 'type' action requires --text.")
572
+ type_into_element(element, text)
573
+ elif action == "invoke":
574
+ invoke_element(element)
575
+ elif action == "set_text":
576
+ if text is None:
577
+ raise ActionExecutionError("The 'set_text' action requires --text.")
578
+ try:
579
+ element.set_edit_text(text)
580
+ except Exception as exc: # pragma: no cover - depends on UI state
581
+ raise ActionExecutionError(f"Failed to set text on element: {safe_summary(element)}") from exc
582
+ else:
583
+ raise ActionExecutionError(f"Unsupported action: {action}")
584
+
585
+ payload = {"status": "ok", "action": action, "element": safe_summary(element)}
586
+ if text is not None:
587
+ payload["text"] = text
588
+ return payload
589
+
590
+
591
+
592
+ def run_assertion(elements: list, assertion: str, expected: str | None = None, expected_bool: bool | None = None) -> dict[str, Any]:
593
+ if assertion == "exists":
594
+ passed = len(elements) > 0
595
+ actual = len(elements)
596
+ elif assertion == "count":
597
+ if expected is None:
598
+ raise AssertionExecutionError("The 'count' assertion requires --expected.")
599
+ actual = len(elements)
600
+ passed = actual == int(expected)
601
+ else:
602
+ if not elements:
603
+ raise AssertionExecutionError("Target element was not found for assertion.")
604
+ element = elements[0]
605
+ summary = safe_summary(element)
606
+ if assertion == "text_equals":
607
+ if expected is None:
608
+ raise AssertionExecutionError("The 'text_equals' assertion requires --expected.")
609
+ actual = str(summary.get("name") or summary.get("window_text") or "")
610
+ passed = actual == expected
611
+ elif assertion == "enabled":
612
+ actual = bool(element.is_enabled())
613
+ passed = actual is expected_bool
614
+ elif assertion == "visible":
615
+ actual = bool(element.is_visible())
616
+ passed = actual is expected_bool
617
+ else:
618
+ raise AssertionExecutionError(f"Unsupported assertion: {assertion}")
619
+
620
+ result = {
621
+ "status": "passed" if passed else "failed",
622
+ "assertion": assertion,
623
+ "actual": actual,
624
+ }
625
+ if expected is not None:
626
+ result["expected"] = expected
627
+ if expected_bool is not None:
628
+ result["expected"] = expected_bool
629
+ if elements:
630
+ result["element"] = safe_summary(elements[0])
631
+ result["count"] = len(elements)
632
+
633
+ if not passed:
634
+ raise AssertionExecutionError(json.dumps(result, ensure_ascii=True))
635
+ return result