pytest-uia 0.7.1__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.
pytest_uia/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """pytest-uia: Windows GUI acceptance testing through the accessibility tree.
2
+
3
+ The names re-exported here are the whole public surface. Everything they are
4
+ built from — locators, adapters, retry policies — is an implementation detail
5
+ that a test should never have to name. Importing this module pulls in no
6
+ Windows-only dependency: the UIA adapter is loaded when a session is first
7
+ wired to a real desktop, not before.
8
+ """
9
+
10
+ from pytest_uia.application.driver import App, Dialog, UIElement
11
+ from pytest_uia.application.session import GuiSession
12
+ from pytest_uia.domain.dump import Dump
13
+ from pytest_uia.domain.errors import (
14
+ DialogNotFound,
15
+ DialogStillOpen,
16
+ ElementNotFound,
17
+ InputRefused,
18
+ LaunchFailed,
19
+ ProcessStillRunning,
20
+ TextNeverSettled,
21
+ WindowNotFound,
22
+ )
23
+ from pytest_uia.domain.query import Query, Role
24
+ from pytest_uia.domain.tree import DumpLimits, TreeNode, WalkEnded
25
+
26
+ __version__ = "0.7.1"
27
+
28
+ __all__ = [
29
+ "App",
30
+ "Dialog",
31
+ "DialogNotFound",
32
+ "DialogStillOpen",
33
+ "Dump",
34
+ "DumpLimits",
35
+ "ElementNotFound",
36
+ "GuiSession",
37
+ "InputRefused",
38
+ "LaunchFailed",
39
+ "ProcessStillRunning",
40
+ "Query",
41
+ "Role",
42
+ "TextNeverSettled",
43
+ "TreeNode",
44
+ "UIElement",
45
+ "WalkEnded",
46
+ "WindowNotFound",
47
+ "__version__",
48
+ ]
pytest_uia/__main__.py ADDED
@@ -0,0 +1,171 @@
1
+ """`python -m pytest_uia --title "..."`: dump a window that is already on screen.
2
+
3
+ Where it plugs in: a thin shell over the same `attach` and `dump` a test makes.
4
+ It exists because the person this feature is for has an application in front of
5
+ them and no test yet, and "first write a test" is the wall the dump was built
6
+ to remove.
7
+
8
+ Imports pytest nowhere, and reaches Windows only through the composition root
9
+ at the bottom, so parsing and running are both specified with no desktop.
10
+
11
+ No console-script entry point on purpose: `python -m` always runs in the
12
+ virtual environment the user is already in, and a `pytest-uia` script would be
13
+ one more thing that can be on the wrong PATH.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from typing import Protocol
22
+
23
+ from pytest_uia.application.session import session_on_this_desktop
24
+ from pytest_uia.domain.dump import Dump
25
+ from pytest_uia.domain.errors import WindowNotFound
26
+ from pytest_uia.domain.tree import DEFAULT_LIMITS, DumpLimits
27
+
28
+ _A_TEST_WOULD_WAIT_THIS_LONG = 10.0
29
+ _FINE = 0
30
+ _NO_SUCH_WINDOW = 1
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Request:
35
+ """What one run of the command line was asked for."""
36
+
37
+ title: str
38
+ limits: DumpLimits = DEFAULT_LIMITS
39
+ attach_timeout: float = _A_TEST_WOULD_WAIT_THIS_LONG
40
+ everything: bool = False
41
+
42
+ def rendering_of(self, dump: Dump) -> str:
43
+ """Which of the dump's two renderings this run asked for.
44
+
45
+ Asked of the request rather than passed as a flag to a function: the
46
+ choice is data about what was typed, and the caller that renders should
47
+ not have to know there are two.
48
+ """
49
+ if self.everything:
50
+ return dump.with_window_chrome()
51
+ return str(dump)
52
+
53
+
54
+ class Screen(Protocol):
55
+ """As much of the desktop as a command line needs.
56
+
57
+ A port rather than a direct call so that both halves of the run — the
58
+ window that was found and the window that was not — are specified with no
59
+ desktop at all.
60
+ """
61
+
62
+ def attach(self, title: str, timeout: float) -> Dumpable: ...
63
+
64
+ def captions(self) -> tuple[str, ...]: ...
65
+
66
+
67
+ class Dumpable(Protocol):
68
+ """The one thing the command line does with what it attached to."""
69
+
70
+ def dump(self, *, limits: DumpLimits) -> Dump: ...
71
+
72
+
73
+ def run(request: Request, screen: Screen) -> int:
74
+ """Dump the window that was asked for, or say what is on screen instead."""
75
+ try:
76
+ app = screen.attach(request.title, request.attach_timeout)
77
+ except WindowNotFound:
78
+ print(_nothing_is_called_that(request.title, screen.captions()))
79
+ return _NO_SUCH_WINDOW
80
+ # The one place the dump prints itself: there is no failing test to attach
81
+ # it to here, and no captured output for it to disappear into.
82
+ print(request.rendering_of(app.dump(limits=request.limits)))
83
+ return _FINE
84
+
85
+
86
+ def _nothing_is_called_that(title: str, on_screen: Sequence[str]) -> str:
87
+ # The captions are listed because titles are matched exactly, so the
88
+ # overwhelmingly likely cause of a miss is one that is nearly right — and
89
+ # "no such window" on its own leaves the reader nothing to correct.
90
+ return "\n".join(
91
+ [
92
+ f"no visible top-level window titled {title!r}. On screen right now:",
93
+ *(f" {caption!r}" for caption in on_screen),
94
+ ]
95
+ )
96
+
97
+
98
+ def parsed(argv: Sequence[str] | None = None) -> Request:
99
+ """Read the command line, or exit the way argparse exits on a bad one."""
100
+ read = _the_arguments().parse_args(argv)
101
+ return Request(
102
+ title=read.title,
103
+ limits=DumpLimits(max_nodes=read.max_nodes, budget=read.budget),
104
+ attach_timeout=read.attach_timeout,
105
+ everything=read.all,
106
+ )
107
+
108
+
109
+ class ThisDesktop:
110
+ """Composition root: the real session, and the real list of windows.
111
+
112
+ The UIA adapter is reached only through `session_on_this_desktop`, which
113
+ defers its import, and through the one inside `captions` — so importing
114
+ this module, and `--help` with it, needs no Windows at all.
115
+
116
+ `attach` is deliberately the same call a test makes, and a session that did
117
+ not start a process never ends one: this command dumps applications people
118
+ are using.
119
+ """
120
+
121
+ def attach(self, title: str, timeout: float) -> Dumpable:
122
+ return session_on_this_desktop().attach(title=title, timeout=timeout)
123
+
124
+ def captions(self) -> tuple[str, ...]:
125
+ from pytest_uia.adapters.uia import the_desktop, visible_top_level_titles
126
+
127
+ return visible_top_level_titles(the_desktop())
128
+
129
+
130
+ def main(argv: Sequence[str] | None = None) -> int:
131
+ return run(parsed(argv), ThisDesktop())
132
+
133
+
134
+ def _the_arguments() -> argparse.ArgumentParser:
135
+ parser = argparse.ArgumentParser(
136
+ prog="python -m pytest_uia",
137
+ description="Print the accessibility tree of a window already on screen.",
138
+ )
139
+ parser.add_argument(
140
+ "--title",
141
+ required=True,
142
+ help="the exact caption of the window to dump, as `gui.attach` matches it",
143
+ )
144
+ parser.add_argument(
145
+ "--all",
146
+ action="store_true",
147
+ help="list the window chrome the dump otherwise folds into one line",
148
+ )
149
+ parser.add_argument(
150
+ "--max-nodes",
151
+ type=int,
152
+ default=DEFAULT_LIMITS.max_nodes,
153
+ help="how many controls to read before stopping and saying there are more",
154
+ )
155
+ parser.add_argument(
156
+ "--budget",
157
+ type=float,
158
+ default=DEFAULT_LIMITS.budget,
159
+ help="how long to spend walking before stopping and saying so",
160
+ )
161
+ parser.add_argument(
162
+ "--attach-timeout",
163
+ type=float,
164
+ default=_A_TEST_WOULD_WAIT_THIS_LONG,
165
+ help="how long to wait for the window to appear",
166
+ )
167
+ return parser
168
+
169
+
170
+ if __name__ == "__main__":
171
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,65 @@
1
+ """Adapter over `mss`: a rectangle of the screen, as raw pixels.
2
+
3
+ Where it plugs in: :mod:`pytest_uia.adapters.ocr` grabs the region a window
4
+ occupies and hands the pixels to Windows' OCR engine. The format is chosen for
5
+ that consumer — BGRA is what a `SoftwareBitmap` takes and what `mss` already
6
+ produces, so no image library sits between the screen and the recogniser.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Protocol
13
+
14
+ import mss
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ScreenRegion:
19
+ """A rectangle of the desktop, in physical pixels.
20
+
21
+ Physical, not logical: importing `uiautomation` makes this process
22
+ per-monitor DPI aware, so UIA's rectangles, `mss`'s grabs and the
23
+ coordinates a click is aimed at are all in the same units already.
24
+ """
25
+
26
+ left: int
27
+ top: int
28
+ width: int
29
+ height: int
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CapturedImage:
34
+ """Pixels read off the screen, four bytes per pixel, blue-green-red-alpha."""
35
+
36
+ width: int
37
+ height: int
38
+ bgra: bytes
39
+
40
+
41
+ class ScreenCapture(Protocol):
42
+ """Where pixels come from: the seam between OCR and the actual desktop."""
43
+
44
+ def grab(self, region: ScreenRegion) -> CapturedImage: ...
45
+
46
+
47
+ class MssScreenCapture:
48
+ """Adapter presenting `mss` as the screen-capture port."""
49
+
50
+ def grab(self, region: ScreenRegion) -> CapturedImage:
51
+ # A fresh grabber per capture on purpose: an `mss` instance is bound to
52
+ # the thread and the device context it was created on, and one that
53
+ # outlives a display change hands back stale pixels.
54
+ with mss.MSS() as screen:
55
+ shot = screen.grab(
56
+ {
57
+ "left": region.left,
58
+ "top": region.top,
59
+ "width": region.width,
60
+ "height": region.height,
61
+ }
62
+ )
63
+ return CapturedImage(
64
+ width=shot.width, height=shot.height, bgra=bytes(shot.bgra)
65
+ )
@@ -0,0 +1,177 @@
1
+ """Adapter over Win32 synthetic input: the mouse, and Windows' answer about it.
2
+
3
+ Where it plugs in: every element with no accessibility pattern left to ask acts
4
+ through this port instead — everything OCR finds, and any UIA control whose
5
+ provider refuses Invoke.
6
+
7
+ The motivating failure: `uiautomation.Click` throws Windows' answer away. While
8
+ a window owned by a higher-integrity process holds the foreground, User
9
+ Interface Privilege Isolation drops every event a medium-integrity process
10
+ injects — `SetCursorPos` returns 0 and the cursor does not move — and a click
11
+ that never happened is then indistinguishable from one the application ignored.
12
+ The test fails seconds later blaming the application, which did nothing wrong.
13
+
14
+ The refusal is real, routine, and almost always transient (a background service
15
+ briefly taking the foreground is enough), which is why the driver retries an
16
+ `InputRefused` inside the element's implicit wait rather than failing on the
17
+ first one. Only when it lasts the whole wait does it reach the reader — by then
18
+ naming the window responsible, because nothing else on the machine will.
19
+
20
+ `ctypes.windll` is reached lazily on purpose: this module has to import on the
21
+ platforms that never call it, so the domain-only unit specs above it run in CI
22
+ on Linux.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import ctypes
28
+ from typing import Protocol
29
+
30
+ from pytest_uia.domain.errors import InputRefused
31
+
32
+ _MOUSE_EVENT = 0
33
+ _LEFT_BUTTON_DOWN = 0x0002
34
+ _LEFT_BUTTON_UP = 0x0004
35
+ _A_PRESS_AND_A_RELEASE = 2
36
+ _LONGEST_WINDOW_CLASS_NAME = 256
37
+
38
+
39
+ class PointerInput(Protocol):
40
+ """The mouse, as the only elements that need one are allowed to see it."""
41
+
42
+ def click(self, x: int, y: int) -> None: ...
43
+
44
+
45
+ class SyntheticMouse(Protocol):
46
+ """Win32's mouse, kept behind a seam so refusal is testable without one."""
47
+
48
+ def click_at(self, x: int, y: int) -> bool: ...
49
+
50
+ def foreground_holder(self) -> str: ...
51
+
52
+
53
+ class CheckedPointer:
54
+ """Adapter presenting a synthetic mouse as the pointer elements act through."""
55
+
56
+ def __init__(self, mouse: SyntheticMouse) -> None:
57
+ self._mouse = mouse
58
+
59
+ def click(self, x: int, y: int) -> None:
60
+ if not self._mouse.click_at(x, y):
61
+ raise InputRefused(self._why_windows_dropped_it())
62
+
63
+ def _why_windows_dropped_it(self) -> str:
64
+ # A bare reason, not a sentence: the caller that owns the deadline
65
+ # prefixes it with how long it kept trying before giving up.
66
+ return (
67
+ f"the foreground is held by {self._mouse.foreground_holder()}, which "
68
+ "runs at a higher integrity level than this process, so Windows "
69
+ "drops every event this process injects; close that window, stop "
70
+ "the service behind it, or run the suite elevated"
71
+ )
72
+
73
+
74
+ class Win32Mouse:
75
+ """The user32 calls a click is made of, with the answers they hand back.
76
+
77
+ Humble object by design: it holds no decision worth a unit test, only the
78
+ ctypes plumbing that cannot exist without a desktop. What it is *for* — the
79
+ refusal — is specified against :class:`CheckedPointer` with doubles, and
80
+ proven end to end by the gui specs.
81
+ """
82
+
83
+ def click_at(self, x: int, y: int) -> bool:
84
+ if not self._cursor_moved_to(x, y):
85
+ # Checked on its own, and first: a refused move injects nothing, so
86
+ # there is no half-pressed button to undo before the next attempt.
87
+ return False
88
+ return self._left_button_pressed_and_released()
89
+
90
+ def foreground_holder(self) -> str:
91
+ window = _foreground_window()
92
+ if window is None:
93
+ # Windows answers NULL while no window on this desktop is active —
94
+ # mid-switch, or with a secure desktop (UAC, the lock screen) up.
95
+ return "a window this process cannot see (the workstation may be locked)"
96
+ return f"{_class_name_of(window)!r} (pid {_process_behind(window)})"
97
+
98
+ def _cursor_moved_to(self, x: int, y: int) -> bool:
99
+ return bool(_user32().SetCursorPos(int(x), int(y)))
100
+
101
+ def _left_button_pressed_and_released(self) -> bool:
102
+ events = (_InputEvent * _A_PRESS_AND_A_RELEASE)(
103
+ _button_event(_LEFT_BUTTON_DOWN),
104
+ _button_event(_LEFT_BUTTON_UP),
105
+ )
106
+ inserted = _user32().SendInput(
107
+ _A_PRESS_AND_A_RELEASE,
108
+ ctypes.byref(events),
109
+ ctypes.sizeof(_InputEvent),
110
+ )
111
+ # SendInput reports how many events reached the queue; UIPI blocks the
112
+ # whole batch, so anything short of both is a refusal.
113
+ return inserted == _A_PRESS_AND_A_RELEASE
114
+
115
+
116
+ class _MouseEvent(ctypes.Structure):
117
+ """Win32's MOUSEINPUT."""
118
+
119
+ _fields_ = (
120
+ ("dx", ctypes.c_long),
121
+ ("dy", ctypes.c_long),
122
+ ("mouse_data", ctypes.c_ulong),
123
+ ("flags", ctypes.c_ulong),
124
+ ("time", ctypes.c_ulong),
125
+ # ULONG_PTR: pointer-sized, and the reason INPUT is 40 bytes on 64-bit.
126
+ ("extra_info", ctypes.c_size_t),
127
+ )
128
+
129
+
130
+ class _InputEvent(ctypes.Structure):
131
+ """Win32's INPUT, in its only shape this module ever sends.
132
+
133
+ The real type holds a union of mouse, keyboard and hardware events, but
134
+ MOUSEINPUT is the largest member, so a struct naming it directly has the
135
+ same size and alignment as the union does.
136
+ """
137
+
138
+ _fields_ = (
139
+ ("type", ctypes.c_ulong),
140
+ ("mouse", _MouseEvent),
141
+ )
142
+
143
+
144
+ def _button_event(flags: int) -> _InputEvent:
145
+ # No MOUSEEVENTF_MOVE and no coordinates: the button acts wherever the
146
+ # cursor already is, which SetCursorPos has just been told about.
147
+ return _InputEvent(_MOUSE_EVENT, _MouseEvent(0, 0, 0, flags, 0, 0))
148
+
149
+
150
+ def _foreground_window() -> int | None:
151
+ user32 = _user32()
152
+ # Without this, ctypes truncates a 64-bit handle to a C int and every
153
+ # question asked about the window afterwards is about a different one.
154
+ user32.GetForegroundWindow.restype = ctypes.c_void_p
155
+ return user32.GetForegroundWindow() or None
156
+
157
+
158
+ def _class_name_of(window: int) -> str:
159
+ name = ctypes.create_unicode_buffer(_LONGEST_WINDOW_CLASS_NAME)
160
+ _user32().GetClassNameW(ctypes.c_void_p(window), name, _LONGEST_WINDOW_CLASS_NAME)
161
+ return name.value
162
+
163
+
164
+ def _process_behind(window: int) -> int:
165
+ pid = ctypes.c_ulong()
166
+ _user32().GetWindowThreadProcessId(ctypes.c_void_p(window), ctypes.byref(pid))
167
+ return pid.value
168
+
169
+
170
+ def _user32() -> ctypes.WinDLL:
171
+ # Resolved per call rather than at import: `ctypes.windll` does not exist
172
+ # off Windows, and this module still has to import there.
173
+ return ctypes.windll.user32
174
+
175
+
176
+ WINDOWS_POINTER: PointerInput = CheckedPointer(Win32Mouse())
177
+ """The pointer every adapter uses unless a spec hands it a double instead."""