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 +48 -0
- pytest_uia/__main__.py +171 -0
- pytest_uia/adapters/__init__.py +0 -0
- pytest_uia/adapters/capture.py +65 -0
- pytest_uia/adapters/input.py +177 -0
- pytest_uia/adapters/ocr.py +282 -0
- pytest_uia/adapters/process_tree.py +116 -0
- pytest_uia/adapters/uia.py +659 -0
- pytest_uia/application/__init__.py +0 -0
- pytest_uia/application/app_process.py +112 -0
- pytest_uia/application/driver.py +465 -0
- pytest_uia/application/session.py +197 -0
- pytest_uia/domain/__init__.py +0 -0
- pytest_uia/domain/dump.py +635 -0
- pytest_uia/domain/errors.py +84 -0
- pytest_uia/domain/locator.py +68 -0
- pytest_uia/domain/query.py +61 -0
- pytest_uia/domain/text_match.py +69 -0
- pytest_uia/domain/tree.py +81 -0
- pytest_uia/domain/waiting.py +48 -0
- pytest_uia/hooks.py +72 -0
- pytest_uia/plugin.py +14 -0
- pytest_uia/py.typed +0 -0
- pytest_uia-0.7.1.dist-info/METADATA +802 -0
- pytest_uia-0.7.1.dist-info/RECORD +28 -0
- pytest_uia-0.7.1.dist-info/WHEEL +4 -0
- pytest_uia-0.7.1.dist-info/entry_points.txt +2 -0
- pytest_uia-0.7.1.dist-info/licenses/LICENSE +21 -0
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."""
|