alphawindow 0.1.0__tar.gz
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.
- alphawindow-0.1.0/MANIFEST.in +2 -0
- alphawindow-0.1.0/PKG-INFO +72 -0
- alphawindow-0.1.0/README.md +60 -0
- alphawindow-0.1.0/pyproject.toml +30 -0
- alphawindow-0.1.0/setup.cfg +4 -0
- alphawindow-0.1.0/src/alphawindow/__init__.py +53 -0
- alphawindow-0.1.0/src/alphawindow/_version.py +3 -0
- alphawindow-0.1.0/src/alphawindow/backends.py +179 -0
- alphawindow-0.1.0/src/alphawindow/profiles.py +130 -0
- alphawindow-0.1.0/src/alphawindow/py.typed +1 -0
- alphawindow-0.1.0/src/alphawindow/session.py +150 -0
- alphawindow-0.1.0/src/alphawindow/types.py +152 -0
- alphawindow-0.1.0/src/alphawindow.egg-info/PKG-INFO +72 -0
- alphawindow-0.1.0/src/alphawindow.egg-info/SOURCES.txt +23 -0
- alphawindow-0.1.0/src/alphawindow.egg-info/dependency_links.txt +1 -0
- alphawindow-0.1.0/src/alphawindow.egg-info/requires.txt +9 -0
- alphawindow-0.1.0/src/alphawindow.egg-info/top_level.txt +1 -0
- alphawindow-0.1.0/tests/test_generic_backends.py +83 -0
- alphawindow-0.1.0/tests/test_github_workflows.py +27 -0
- alphawindow-0.1.0/tests/test_hook_backends.py +92 -0
- alphawindow-0.1.0/tests/test_mode_coverage.py +58 -0
- alphawindow-0.1.0/tests/test_packaging.py +58 -0
- alphawindow-0.1.0/tests/test_profiles.py +21 -0
- alphawindow-0.1.0/tests/test_session_operations.py +114 -0
- alphawindow-0.1.0/tests/test_session_prefetch.py +160 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alphawindow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Windows-first window automation abstractions with pluggable input and output modes.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: native
|
|
8
|
+
Requires-Dist: pywin32>=306; platform_system == "Windows" and extra == "native"
|
|
9
|
+
Requires-Dist: pynput>=1.7.6; platform_system == "Windows" and extra == "native"
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
12
|
+
|
|
13
|
+
# AlphaWindow
|
|
14
|
+
|
|
15
|
+
AlphaWindow is a Windows-first automation abstraction layer. It exposes one session API for window operations while allowing callers to choose different input and output modes.
|
|
16
|
+
|
|
17
|
+
The first implementation includes:
|
|
18
|
+
|
|
19
|
+
- Built-in I/O profiles for every declared input and output mode, including `inspect`, `observe`, `desktop_observe`, `dry_run`, `win32_message`, `message_only`, `uia_control`, `uia_visual`, `global_input`, `foreground_visual`, `guarded_input`, `guarded_desktop`, `isolated_observe`, `isolated_telemetry`, `virtual_window`, `virtual_telemetry`, and `virtual_input_only`.
|
|
20
|
+
- A `WindowSession` API for `prefetch`, `capture`, `observe`, `click`, `key_down`, `key_up`, and `type_text`.
|
|
21
|
+
- Generic delegation backends for message, UIA, foreground, guarded foreground, state, desktop-region, hook-telemetry, and no-output modes.
|
|
22
|
+
- Compatibility checks so a backend cannot silently fall back to a broader side-effect mode.
|
|
23
|
+
- Hook-aware `isolated` and `virtualized` input backends that accept pluggable hook managers and virtual input transports.
|
|
24
|
+
|
|
25
|
+
Native pywin32, pynput, and DLL hook adapters are intended to plug into the same protocols in later slices.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
From PyPI after publishing:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
pip install alphawindow
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
From this checkout:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
pip install .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Import
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import alphawindow
|
|
45
|
+
|
|
46
|
+
from alphawindow import (
|
|
47
|
+
AutomationConfig,
|
|
48
|
+
InputMode,
|
|
49
|
+
OutputMode,
|
|
50
|
+
WindowSelector,
|
|
51
|
+
WindowSession,
|
|
52
|
+
get_profile,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
print(alphawindow.__version__)
|
|
56
|
+
print(get_profile("observe").input_mode is InputMode.DRY_RUN)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Release
|
|
60
|
+
|
|
61
|
+
Publishing is handled by GitHub Actions in `.github/workflows/publish.yml`.
|
|
62
|
+
|
|
63
|
+
1. Configure PyPI Trusted Publishing for this repository, environment `pypi`, and workflow `publish.yml`.
|
|
64
|
+
2. Update the version in `pyproject.toml` and `src/alphawindow/_version.py`.
|
|
65
|
+
3. Push a version tag:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
git tag v0.1.0
|
|
69
|
+
git push origin v0.1.0
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The workflow runs tests on Windows for Python 3.11, 3.12, and 3.13, builds the package, checks distribution metadata, and publishes to PyPI only for `v*` tags.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# AlphaWindow
|
|
2
|
+
|
|
3
|
+
AlphaWindow is a Windows-first automation abstraction layer. It exposes one session API for window operations while allowing callers to choose different input and output modes.
|
|
4
|
+
|
|
5
|
+
The first implementation includes:
|
|
6
|
+
|
|
7
|
+
- Built-in I/O profiles for every declared input and output mode, including `inspect`, `observe`, `desktop_observe`, `dry_run`, `win32_message`, `message_only`, `uia_control`, `uia_visual`, `global_input`, `foreground_visual`, `guarded_input`, `guarded_desktop`, `isolated_observe`, `isolated_telemetry`, `virtual_window`, `virtual_telemetry`, and `virtual_input_only`.
|
|
8
|
+
- A `WindowSession` API for `prefetch`, `capture`, `observe`, `click`, `key_down`, `key_up`, and `type_text`.
|
|
9
|
+
- Generic delegation backends for message, UIA, foreground, guarded foreground, state, desktop-region, hook-telemetry, and no-output modes.
|
|
10
|
+
- Compatibility checks so a backend cannot silently fall back to a broader side-effect mode.
|
|
11
|
+
- Hook-aware `isolated` and `virtualized` input backends that accept pluggable hook managers and virtual input transports.
|
|
12
|
+
|
|
13
|
+
Native pywin32, pynput, and DLL hook adapters are intended to plug into the same protocols in later slices.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
From PyPI after publishing:
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
pip install alphawindow
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
From this checkout:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
pip install .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Import
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import alphawindow
|
|
33
|
+
|
|
34
|
+
from alphawindow import (
|
|
35
|
+
AutomationConfig,
|
|
36
|
+
InputMode,
|
|
37
|
+
OutputMode,
|
|
38
|
+
WindowSelector,
|
|
39
|
+
WindowSession,
|
|
40
|
+
get_profile,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
print(alphawindow.__version__)
|
|
44
|
+
print(get_profile("observe").input_mode is InputMode.DRY_RUN)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Release
|
|
48
|
+
|
|
49
|
+
Publishing is handled by GitHub Actions in `.github/workflows/publish.yml`.
|
|
50
|
+
|
|
51
|
+
1. Configure PyPI Trusted Publishing for this repository, environment `pypi`, and workflow `publish.yml`.
|
|
52
|
+
2. Update the version in `pyproject.toml` and `src/alphawindow/_version.py`.
|
|
53
|
+
3. Push a version tag:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
git tag v0.1.0
|
|
57
|
+
git push origin v0.1.0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The workflow runs tests on Windows for Python 3.11, 3.12, and 3.13, builds the package, checks distribution metadata, and publishes to PyPI only for `v*` tags.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alphawindow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Windows-first window automation abstractions with pluggable input and output modes."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
native = [
|
|
15
|
+
"pywin32>=306; platform_system == 'Windows'",
|
|
16
|
+
"pynput>=1.7.6; platform_system == 'Windows'",
|
|
17
|
+
]
|
|
18
|
+
test = [
|
|
19
|
+
"pytest>=8",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
alphawindow = ["py.typed"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
pythonpath = ["src"]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .backends import (
|
|
4
|
+
DelegatingInputBackend,
|
|
5
|
+
DelegatingOutputBackend,
|
|
6
|
+
DryRunInputBackend,
|
|
7
|
+
IsolatedInputBackend,
|
|
8
|
+
NullOutputBackend,
|
|
9
|
+
StateOutputBackend,
|
|
10
|
+
VirtualizedInputBackend,
|
|
11
|
+
)
|
|
12
|
+
from .profiles import BUILTIN_PROFILES, get_profile
|
|
13
|
+
from .session import WindowSession, open_window
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .types import (
|
|
16
|
+
AutomationConfig,
|
|
17
|
+
AutomationProfile,
|
|
18
|
+
BackendCompatibilityError,
|
|
19
|
+
Capability,
|
|
20
|
+
InputMode,
|
|
21
|
+
Operation,
|
|
22
|
+
OperationResult,
|
|
23
|
+
OutputMode,
|
|
24
|
+
WindowNotFoundError,
|
|
25
|
+
WindowSelector,
|
|
26
|
+
WindowSnapshot,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AutomationConfig",
|
|
31
|
+
"AutomationProfile",
|
|
32
|
+
"BUILTIN_PROFILES",
|
|
33
|
+
"BackendCompatibilityError",
|
|
34
|
+
"Capability",
|
|
35
|
+
"DelegatingInputBackend",
|
|
36
|
+
"DelegatingOutputBackend",
|
|
37
|
+
"DryRunInputBackend",
|
|
38
|
+
"InputMode",
|
|
39
|
+
"IsolatedInputBackend",
|
|
40
|
+
"NullOutputBackend",
|
|
41
|
+
"Operation",
|
|
42
|
+
"OperationResult",
|
|
43
|
+
"OutputMode",
|
|
44
|
+
"StateOutputBackend",
|
|
45
|
+
"VirtualizedInputBackend",
|
|
46
|
+
"WindowNotFoundError",
|
|
47
|
+
"WindowSelector",
|
|
48
|
+
"WindowSession",
|
|
49
|
+
"WindowSnapshot",
|
|
50
|
+
"__version__",
|
|
51
|
+
"get_profile",
|
|
52
|
+
"open_window",
|
|
53
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .types import (
|
|
8
|
+
BackendCompatibilityError,
|
|
9
|
+
Capability,
|
|
10
|
+
HookManager,
|
|
11
|
+
InputBackend,
|
|
12
|
+
InputMode,
|
|
13
|
+
Operation,
|
|
14
|
+
OperationResult,
|
|
15
|
+
OutputMode,
|
|
16
|
+
VirtualInputTransport,
|
|
17
|
+
WindowSnapshot,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
INPUT_MODE_CAPABILITIES: dict[InputMode, frozenset[Capability]] = {
|
|
22
|
+
InputMode.DRY_RUN: frozenset(),
|
|
23
|
+
InputMode.MESSAGE: frozenset({Capability.WINDOW_MESSAGE_INPUT}),
|
|
24
|
+
InputMode.UIA: frozenset({Capability.UIA_INPUT}),
|
|
25
|
+
InputMode.FOREGROUND: frozenset({Capability.GLOBAL_INPUT}),
|
|
26
|
+
InputMode.GUARDED_FOREGROUND: frozenset({Capability.GUARDED_GLOBAL_INPUT}),
|
|
27
|
+
InputMode.ISOLATED: frozenset({Capability.HOOKED_INPUT}),
|
|
28
|
+
InputMode.VIRTUALIZED: frozenset({Capability.HOOKED_INPUT, Capability.VIRTUAL_INPUT}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
OUTPUT_MODE_CAPABILITIES: dict[OutputMode, frozenset[Capability]] = {
|
|
32
|
+
OutputMode.STATE: frozenset({Capability.WINDOW_STATE}),
|
|
33
|
+
OutputMode.WINDOW_CAPTURE: frozenset({Capability.WINDOW_CAPTURE}),
|
|
34
|
+
OutputMode.DESKTOP_REGION: frozenset({Capability.DESKTOP_CAPTURE}),
|
|
35
|
+
OutputMode.HOOK_TELEMETRY: frozenset({Capability.HOOK_TELEMETRY}),
|
|
36
|
+
OutputMode.NONE: frozenset(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DryRunInputBackend:
|
|
41
|
+
mode = InputMode.DRY_RUN
|
|
42
|
+
capabilities = frozenset()
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self.operations: list[Operation] = []
|
|
46
|
+
|
|
47
|
+
def perform(self, target: WindowSnapshot, operation: Operation) -> OperationResult:
|
|
48
|
+
self.operations.append(operation)
|
|
49
|
+
return OperationResult(
|
|
50
|
+
operation=operation,
|
|
51
|
+
executed=False,
|
|
52
|
+
backend="dry_run",
|
|
53
|
+
details={"hwnd": target.hwnd},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class DelegatingInputBackend:
|
|
59
|
+
mode: InputMode
|
|
60
|
+
performer: Callable[[WindowSnapshot, Operation], OperationResult | None]
|
|
61
|
+
name: str | None = None
|
|
62
|
+
capabilities: frozenset[Capability] | None = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if self.mode in {InputMode.ISOLATED, InputMode.VIRTUALIZED}:
|
|
66
|
+
raise BackendCompatibilityError(
|
|
67
|
+
"use dedicated hook-aware backends for isolated and virtualized input"
|
|
68
|
+
)
|
|
69
|
+
if self.capabilities is None:
|
|
70
|
+
self.capabilities = INPUT_MODE_CAPABILITIES[self.mode]
|
|
71
|
+
if self.name is None:
|
|
72
|
+
self.name = self.mode.value
|
|
73
|
+
|
|
74
|
+
def perform(self, target: WindowSnapshot, operation: Operation) -> OperationResult:
|
|
75
|
+
result = self.performer(target, operation)
|
|
76
|
+
if isinstance(result, OperationResult):
|
|
77
|
+
return result
|
|
78
|
+
return OperationResult(
|
|
79
|
+
operation=operation,
|
|
80
|
+
executed=True,
|
|
81
|
+
backend=str(self.name),
|
|
82
|
+
details={"hwnd": target.hwnd},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class StateOutputBackend:
|
|
87
|
+
mode = OutputMode.STATE
|
|
88
|
+
capabilities = frozenset({Capability.WINDOW_STATE})
|
|
89
|
+
|
|
90
|
+
def capture(self, target: WindowSnapshot) -> WindowSnapshot:
|
|
91
|
+
return target
|
|
92
|
+
|
|
93
|
+
def observe(self, target: WindowSnapshot) -> WindowSnapshot:
|
|
94
|
+
return target
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NullOutputBackend:
|
|
98
|
+
mode = OutputMode.NONE
|
|
99
|
+
capabilities = frozenset()
|
|
100
|
+
|
|
101
|
+
def capture(self, target: WindowSnapshot) -> None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def observe(self, target: WindowSnapshot) -> WindowSnapshot:
|
|
105
|
+
return target
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class DelegatingOutputBackend:
|
|
110
|
+
mode: OutputMode
|
|
111
|
+
capture_fn: Callable[[WindowSnapshot], Any] | None = None
|
|
112
|
+
observe_fn: Callable[[WindowSnapshot], WindowSnapshot] | None = None
|
|
113
|
+
name: str | None = None
|
|
114
|
+
capabilities: frozenset[Capability] | None = None
|
|
115
|
+
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
if self.capabilities is None:
|
|
118
|
+
self.capabilities = OUTPUT_MODE_CAPABILITIES[self.mode]
|
|
119
|
+
if self.name is None:
|
|
120
|
+
self.name = self.mode.value
|
|
121
|
+
|
|
122
|
+
def capture(self, target: WindowSnapshot) -> Any:
|
|
123
|
+
if self.capture_fn is None:
|
|
124
|
+
if self.mode is OutputMode.NONE:
|
|
125
|
+
return None
|
|
126
|
+
if self.mode is OutputMode.STATE:
|
|
127
|
+
return target
|
|
128
|
+
raise BackendCompatibilityError(
|
|
129
|
+
f"output backend {self.name!r} has no capture function"
|
|
130
|
+
)
|
|
131
|
+
return self.capture_fn(target)
|
|
132
|
+
|
|
133
|
+
def observe(self, target: WindowSnapshot) -> WindowSnapshot:
|
|
134
|
+
if self.observe_fn is None:
|
|
135
|
+
return target
|
|
136
|
+
return self.observe_fn(target)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class IsolatedInputBackend:
|
|
141
|
+
hook_manager: HookManager
|
|
142
|
+
delegate: InputBackend | None = None
|
|
143
|
+
|
|
144
|
+
mode = InputMode.ISOLATED
|
|
145
|
+
capabilities = frozenset({Capability.HOOKED_INPUT})
|
|
146
|
+
|
|
147
|
+
def __post_init__(self) -> None:
|
|
148
|
+
if self.hook_manager is None:
|
|
149
|
+
raise BackendCompatibilityError("isolated input requires a hook manager")
|
|
150
|
+
|
|
151
|
+
def perform(self, target: WindowSnapshot, operation: Operation) -> OperationResult:
|
|
152
|
+
self.hook_manager.ensure_injected(target)
|
|
153
|
+
if self.delegate is not None:
|
|
154
|
+
return self.delegate.perform(target, operation)
|
|
155
|
+
return OperationResult(
|
|
156
|
+
operation=operation,
|
|
157
|
+
executed=False,
|
|
158
|
+
backend="isolated",
|
|
159
|
+
details={"hwnd": target.hwnd, "hooked": True},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class VirtualizedInputBackend:
|
|
165
|
+
hook_manager: HookManager
|
|
166
|
+
transport: VirtualInputTransport
|
|
167
|
+
|
|
168
|
+
mode = InputMode.VIRTUALIZED
|
|
169
|
+
capabilities = frozenset({Capability.HOOKED_INPUT, Capability.VIRTUAL_INPUT})
|
|
170
|
+
|
|
171
|
+
def __post_init__(self) -> None:
|
|
172
|
+
if self.hook_manager is None:
|
|
173
|
+
raise BackendCompatibilityError("virtualized input requires a hook manager")
|
|
174
|
+
if self.transport is None:
|
|
175
|
+
raise BackendCompatibilityError("virtualized input requires a transport")
|
|
176
|
+
|
|
177
|
+
def perform(self, target: WindowSnapshot, operation: Operation) -> OperationResult:
|
|
178
|
+
self.hook_manager.ensure_injected(target)
|
|
179
|
+
return self.transport.send(target, operation)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .types import (
|
|
4
|
+
AutomationProfile,
|
|
5
|
+
BackendCompatibilityError,
|
|
6
|
+
InputMode,
|
|
7
|
+
OutputMode,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
BUILTIN_PROFILES: dict[str, AutomationProfile] = {
|
|
12
|
+
"inspect": AutomationProfile(
|
|
13
|
+
name="inspect",
|
|
14
|
+
input_mode=InputMode.DRY_RUN,
|
|
15
|
+
output_mode=OutputMode.STATE,
|
|
16
|
+
description="Inspect window state without capture or input.",
|
|
17
|
+
),
|
|
18
|
+
"observe": AutomationProfile(
|
|
19
|
+
name="observe",
|
|
20
|
+
input_mode=InputMode.DRY_RUN,
|
|
21
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
22
|
+
description="Capture or inspect a window without sending input.",
|
|
23
|
+
),
|
|
24
|
+
"desktop_observe": AutomationProfile(
|
|
25
|
+
name="desktop_observe",
|
|
26
|
+
input_mode=InputMode.DRY_RUN,
|
|
27
|
+
output_mode=OutputMode.DESKTOP_REGION,
|
|
28
|
+
description="Observe a target through desktop-region capture.",
|
|
29
|
+
),
|
|
30
|
+
"dry_run": AutomationProfile(
|
|
31
|
+
name="dry_run",
|
|
32
|
+
input_mode=InputMode.DRY_RUN,
|
|
33
|
+
output_mode=OutputMode.NONE,
|
|
34
|
+
description="Plan operations without output or input side effects.",
|
|
35
|
+
),
|
|
36
|
+
"win32_message": AutomationProfile(
|
|
37
|
+
name="win32_message",
|
|
38
|
+
input_mode=InputMode.MESSAGE,
|
|
39
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
40
|
+
description="Send window-message input while capturing the target window.",
|
|
41
|
+
),
|
|
42
|
+
"message_only": AutomationProfile(
|
|
43
|
+
name="message_only",
|
|
44
|
+
input_mode=InputMode.MESSAGE,
|
|
45
|
+
output_mode=OutputMode.NONE,
|
|
46
|
+
description="Send window-message input without output capture.",
|
|
47
|
+
),
|
|
48
|
+
"uia_control": AutomationProfile(
|
|
49
|
+
name="uia_control",
|
|
50
|
+
input_mode=InputMode.UIA,
|
|
51
|
+
output_mode=OutputMode.STATE,
|
|
52
|
+
description="Drive standard controls through Windows UI Automation.",
|
|
53
|
+
),
|
|
54
|
+
"uia_visual": AutomationProfile(
|
|
55
|
+
name="uia_visual",
|
|
56
|
+
input_mode=InputMode.UIA,
|
|
57
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
58
|
+
description="Drive UI Automation controls while capturing the window.",
|
|
59
|
+
),
|
|
60
|
+
"global_input": AutomationProfile(
|
|
61
|
+
name="global_input",
|
|
62
|
+
input_mode=InputMode.FOREGROUND,
|
|
63
|
+
output_mode=OutputMode.DESKTOP_REGION,
|
|
64
|
+
description="Use global foreground input such as pynput or SendInput.",
|
|
65
|
+
),
|
|
66
|
+
"foreground_visual": AutomationProfile(
|
|
67
|
+
name="foreground_visual",
|
|
68
|
+
input_mode=InputMode.FOREGROUND,
|
|
69
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
70
|
+
description="Use global foreground input with direct window capture.",
|
|
71
|
+
),
|
|
72
|
+
"guarded_input": AutomationProfile(
|
|
73
|
+
name="guarded_input",
|
|
74
|
+
input_mode=InputMode.GUARDED_FOREGROUND,
|
|
75
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
76
|
+
description="Use foreground input with cleanup and focus restoration.",
|
|
77
|
+
),
|
|
78
|
+
"guarded_desktop": AutomationProfile(
|
|
79
|
+
name="guarded_desktop",
|
|
80
|
+
input_mode=InputMode.GUARDED_FOREGROUND,
|
|
81
|
+
output_mode=OutputMode.DESKTOP_REGION,
|
|
82
|
+
description="Use guarded foreground input with desktop-region capture.",
|
|
83
|
+
),
|
|
84
|
+
"isolated_observe": AutomationProfile(
|
|
85
|
+
name="isolated_observe",
|
|
86
|
+
input_mode=InputMode.ISOLATED,
|
|
87
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
88
|
+
requires_hook=True,
|
|
89
|
+
description="Inject an isolation hook before any optional delegated input.",
|
|
90
|
+
),
|
|
91
|
+
"isolated_telemetry": AutomationProfile(
|
|
92
|
+
name="isolated_telemetry",
|
|
93
|
+
input_mode=InputMode.ISOLATED,
|
|
94
|
+
output_mode=OutputMode.HOOK_TELEMETRY,
|
|
95
|
+
requires_hook=True,
|
|
96
|
+
description="Inject an isolation hook and read hook telemetry.",
|
|
97
|
+
),
|
|
98
|
+
"virtual_window": AutomationProfile(
|
|
99
|
+
name="virtual_window",
|
|
100
|
+
input_mode=InputMode.VIRTUALIZED,
|
|
101
|
+
output_mode=OutputMode.WINDOW_CAPTURE,
|
|
102
|
+
requires_hook=True,
|
|
103
|
+
description="Route virtual mouse and keyboard operations through a hook transport.",
|
|
104
|
+
),
|
|
105
|
+
"virtual_telemetry": AutomationProfile(
|
|
106
|
+
name="virtual_telemetry",
|
|
107
|
+
input_mode=InputMode.VIRTUALIZED,
|
|
108
|
+
output_mode=OutputMode.HOOK_TELEMETRY,
|
|
109
|
+
requires_hook=True,
|
|
110
|
+
description="Route virtual input and read hook telemetry.",
|
|
111
|
+
),
|
|
112
|
+
"virtual_input_only": AutomationProfile(
|
|
113
|
+
name="virtual_input_only",
|
|
114
|
+
input_mode=InputMode.VIRTUALIZED,
|
|
115
|
+
output_mode=OutputMode.NONE,
|
|
116
|
+
requires_hook=True,
|
|
117
|
+
description="Route virtual input without output capture.",
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_profile(profile: str | AutomationProfile) -> AutomationProfile:
|
|
123
|
+
if isinstance(profile, AutomationProfile):
|
|
124
|
+
return profile
|
|
125
|
+
try:
|
|
126
|
+
return BUILTIN_PROFILES[profile]
|
|
127
|
+
except KeyError as exc:
|
|
128
|
+
raise BackendCompatibilityError(
|
|
129
|
+
f"unknown automation profile: {profile}"
|
|
130
|
+
) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .profiles import get_profile
|
|
7
|
+
from .types import (
|
|
8
|
+
AutomationConfig,
|
|
9
|
+
AutomationProfile,
|
|
10
|
+
BackendCompatibilityError,
|
|
11
|
+
Capability,
|
|
12
|
+
InputBackend,
|
|
13
|
+
InputMode,
|
|
14
|
+
Operation,
|
|
15
|
+
OperationResult,
|
|
16
|
+
OutputBackend,
|
|
17
|
+
WindowNotFoundError,
|
|
18
|
+
WindowResolver,
|
|
19
|
+
WindowSelector,
|
|
20
|
+
WindowSnapshot,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WindowSession:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
selector: WindowSelector,
|
|
29
|
+
resolver: WindowResolver,
|
|
30
|
+
input_backend: InputBackend,
|
|
31
|
+
output_backend: OutputBackend,
|
|
32
|
+
config: AutomationConfig | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.selector = selector
|
|
35
|
+
self.resolver = resolver
|
|
36
|
+
self.input_backend = input_backend
|
|
37
|
+
self.output_backend = output_backend
|
|
38
|
+
self.config = config or AutomationConfig()
|
|
39
|
+
self.profile: AutomationProfile = get_profile(self.config.profile)
|
|
40
|
+
self._prefetched: tuple[float, WindowSnapshot] | None = None
|
|
41
|
+
self._validate_backends()
|
|
42
|
+
|
|
43
|
+
def prefetch(self, *, now: float | None = None) -> WindowSnapshot:
|
|
44
|
+
snapshot = self._resolve_snapshot()
|
|
45
|
+
self._prefetched = (self._clock(now), snapshot)
|
|
46
|
+
return snapshot
|
|
47
|
+
|
|
48
|
+
def capture(self, *, now: float | None = None) -> Any:
|
|
49
|
+
return self.output_backend.capture(self._target_snapshot(now=now))
|
|
50
|
+
|
|
51
|
+
def observe(self, *, now: float | None = None) -> WindowSnapshot:
|
|
52
|
+
observe = getattr(self.output_backend, "observe", None)
|
|
53
|
+
target = self._target_snapshot(now=now)
|
|
54
|
+
if observe is None:
|
|
55
|
+
return target
|
|
56
|
+
return observe(target)
|
|
57
|
+
|
|
58
|
+
def click(self, x: int | float, y: int | float, *, now: float | None = None) -> OperationResult:
|
|
59
|
+
target = self._target_snapshot(now=now)
|
|
60
|
+
px, py = target.normalize_point(x, y)
|
|
61
|
+
return self._perform(target, Operation(kind="click", x=px, y=py))
|
|
62
|
+
|
|
63
|
+
def key_down(self, key: str, *, now: float | None = None) -> OperationResult:
|
|
64
|
+
return self._perform(
|
|
65
|
+
self._target_snapshot(now=now),
|
|
66
|
+
Operation(kind="key_down", key=key),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def key_up(self, key: str, *, now: float | None = None) -> OperationResult:
|
|
70
|
+
return self._perform(
|
|
71
|
+
self._target_snapshot(now=now),
|
|
72
|
+
Operation(kind="key_up", key=key),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def type_text(self, text: str, *, now: float | None = None) -> OperationResult:
|
|
76
|
+
return self._perform(
|
|
77
|
+
self._target_snapshot(now=now),
|
|
78
|
+
Operation(kind="type_text", text=text),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _perform(self, target: WindowSnapshot, operation: Operation) -> OperationResult:
|
|
82
|
+
return self.input_backend.perform(target, operation)
|
|
83
|
+
|
|
84
|
+
def _target_snapshot(self, *, now: float | None = None) -> WindowSnapshot:
|
|
85
|
+
current = self._clock(now)
|
|
86
|
+
if self._prefetched is not None:
|
|
87
|
+
resolved_at, snapshot = self._prefetched
|
|
88
|
+
fresh = current - resolved_at <= self.config.prefetch_ttl_seconds
|
|
89
|
+
if fresh and self._snapshot_is_valid(snapshot):
|
|
90
|
+
return snapshot
|
|
91
|
+
return self.prefetch(now=current)
|
|
92
|
+
|
|
93
|
+
def _resolve_snapshot(self) -> WindowSnapshot:
|
|
94
|
+
snapshot = self.resolver.resolve(self.selector)
|
|
95
|
+
if snapshot is None:
|
|
96
|
+
raise WindowNotFoundError(f"window not found: {self.selector}")
|
|
97
|
+
return snapshot
|
|
98
|
+
|
|
99
|
+
def _snapshot_is_valid(self, snapshot: WindowSnapshot) -> bool:
|
|
100
|
+
validator = getattr(self.resolver, "is_valid", None)
|
|
101
|
+
if validator is None:
|
|
102
|
+
return snapshot.visible
|
|
103
|
+
return bool(validator(snapshot))
|
|
104
|
+
|
|
105
|
+
def _validate_backends(self) -> None:
|
|
106
|
+
input_mode = getattr(self.input_backend, "mode", None)
|
|
107
|
+
allowed_input_modes = {
|
|
108
|
+
self.profile.input_mode,
|
|
109
|
+
*self.config.allowed_input_fallbacks,
|
|
110
|
+
}
|
|
111
|
+
if input_mode not in allowed_input_modes:
|
|
112
|
+
raise BackendCompatibilityError(
|
|
113
|
+
"input backend mode "
|
|
114
|
+
f"{input_mode!r} is not allowed for profile {self.profile.name!r}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
output_mode = getattr(self.output_backend, "mode", None)
|
|
118
|
+
if output_mode != self.profile.output_mode:
|
|
119
|
+
raise BackendCompatibilityError(
|
|
120
|
+
"output backend mode "
|
|
121
|
+
f"{output_mode!r} does not match profile {self.profile.name!r}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if self.profile.requires_hook:
|
|
125
|
+
capabilities = getattr(self.input_backend, "capabilities", frozenset())
|
|
126
|
+
if Capability.HOOKED_INPUT not in capabilities:
|
|
127
|
+
raise BackendCompatibilityError(
|
|
128
|
+
f"profile {self.profile.name!r} requires a hook-capable input backend"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _clock(now: float | None) -> float:
|
|
133
|
+
return time.monotonic() if now is None else now
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def open_window(
|
|
137
|
+
*,
|
|
138
|
+
selector: WindowSelector,
|
|
139
|
+
resolver: WindowResolver,
|
|
140
|
+
input_backend: InputBackend,
|
|
141
|
+
output_backend: OutputBackend,
|
|
142
|
+
config: AutomationConfig | None = None,
|
|
143
|
+
) -> WindowSession:
|
|
144
|
+
return WindowSession(
|
|
145
|
+
selector=selector,
|
|
146
|
+
resolver=resolver,
|
|
147
|
+
input_backend=input_backend,
|
|
148
|
+
output_backend=output_backend,
|
|
149
|
+
config=config,
|
|
150
|
+
)
|