use-computer-cli 0.2.0__tar.gz → 0.2.2__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.
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/PKG-INFO +16 -7
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/README.md +15 -5
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/pyproject.toml +6 -2
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/atspi.py +109 -19
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/ax.py +6 -4
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/base.py +3 -2
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/uia.py +15 -10
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/actions.py +20 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/cli.py +249 -31
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/compare.py +33 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/config.py +37 -2
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/errors.py +25 -2
- use_computer_cli-0.2.2/src/use_computer/prune.py +95 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/render.py +51 -3
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/runner.py +76 -5
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/selectors.py +37 -3
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/skill/SKILL.md +113 -16
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/tree.py +9 -1
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/fake_provider.py +1 -0
- use_computer_cli-0.2.2/tests/test_atspi_hint.py +70 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_cli.py +208 -19
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_config.py +9 -2
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_elements.py +22 -1
- use_computer_cli-0.2.2/tests/test_prune.py +76 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_render.py +16 -3
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_selectors.py +44 -2
- use_computer_cli-0.2.2/tests/test_skill_is_true.py +90 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/.gitignore +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/LICENSE +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/__init__.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/__init__.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/roles.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/__init__.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/base.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/local.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/vnc.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/coordinates.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/keys.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/skill/__init__.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/__init__.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/conftest.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/fake_backend.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_backends.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_compare.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_config_init.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_coordinates.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_keys.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_runner.py +0 -0
- {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_skill.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: use-computer-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot.
|
|
5
5
|
Project-URL: Homepage, https://github.com/applica-software-guru/use-computer
|
|
6
6
|
Author: Bruno Fortunato
|
|
@@ -51,7 +51,6 @@ Provides-Extra: local
|
|
|
51
51
|
Requires-Dist: mss>=10; extra == 'local'
|
|
52
52
|
Requires-Dist: pynput>=1.8; extra == 'local'
|
|
53
53
|
Provides-Extra: tree
|
|
54
|
-
Requires-Dist: pygobject>=3.46; (sys_platform == 'linux') and extra == 'tree'
|
|
55
54
|
Requires-Dist: pyobjc-framework-applicationservices>=10; (sys_platform == 'darwin') and extra == 'tree'
|
|
56
55
|
Requires-Dist: uiautomation>=2.0; (sys_platform == 'win32') and extra == 'tree'
|
|
57
56
|
Provides-Extra: vnc
|
|
@@ -84,16 +83,26 @@ with a Python API underneath.
|
|
|
84
83
|
pip install use-computer-cli # no backend
|
|
85
84
|
pip install "use-computer-cli[local]" # drive this machine's display (pynput + mss)
|
|
86
85
|
pip install "use-computer-cli[vnc]" # drive a remote framebuffer over RFB (vncdotool)
|
|
87
|
-
pip install "use-computer-cli[tree]" # read the accessibility tree
|
|
86
|
+
pip install "use-computer-cli[tree]" # read the accessibility tree (Windows, macOS)
|
|
88
87
|
```
|
|
89
88
|
|
|
90
89
|
Backends and the accessibility bindings are optional extras, imported lazily, so the package
|
|
91
90
|
installs without them.
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
**On Linux, do not use the `tree` extra.** PyGObject has no Linux wheel, so pip would build it from
|
|
93
|
+
source and fail. The bindings are already on almost every desktop; the virtualenv just has to see
|
|
94
|
+
them:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
sudo apt install python3-gi gir1.2-atspi-2.0
|
|
98
|
+
python3 -m venv --system-site-packages .venv
|
|
99
|
+
.venv/bin/pip install "use-computer-cli[local]"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`gi` is a compiled extension built for one Python minor version -- Ubuntu 22.04 ships it for 3.10 --
|
|
103
|
+
so the virtualenv has to be the distro's `python3`, not another minor version. Both packages report
|
|
104
|
+
as installed either way, which is why the error message checks and says which case you are in.
|
|
105
|
+
With uv: `uv venv --python /usr/bin/python3 --system-site-packages`.
|
|
97
106
|
|
|
98
107
|
The distribution is `use-computer-cli` because `use-computer` is taken on PyPI by an unrelated
|
|
99
108
|
project. The command it installs is `use-computer`, and the package it imports is `use_computer`.
|
|
@@ -24,16 +24,26 @@ with a Python API underneath.
|
|
|
24
24
|
pip install use-computer-cli # no backend
|
|
25
25
|
pip install "use-computer-cli[local]" # drive this machine's display (pynput + mss)
|
|
26
26
|
pip install "use-computer-cli[vnc]" # drive a remote framebuffer over RFB (vncdotool)
|
|
27
|
-
pip install "use-computer-cli[tree]" # read the accessibility tree
|
|
27
|
+
pip install "use-computer-cli[tree]" # read the accessibility tree (Windows, macOS)
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Backends and the accessibility bindings are optional extras, imported lazily, so the package
|
|
31
31
|
installs without them.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
**On Linux, do not use the `tree` extra.** PyGObject has no Linux wheel, so pip would build it from
|
|
34
|
+
source and fail. The bindings are already on almost every desktop; the virtualenv just has to see
|
|
35
|
+
them:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
sudo apt install python3-gi gir1.2-atspi-2.0
|
|
39
|
+
python3 -m venv --system-site-packages .venv
|
|
40
|
+
.venv/bin/pip install "use-computer-cli[local]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`gi` is a compiled extension built for one Python minor version -- Ubuntu 22.04 ships it for 3.10 --
|
|
44
|
+
so the virtualenv has to be the distro's `python3`, not another minor version. Both packages report
|
|
45
|
+
as installed either way, which is why the error message checks and says which case you are in.
|
|
46
|
+
With uv: `uv venv --python /usr/bin/python3 --system-site-packages`.
|
|
37
47
|
|
|
38
48
|
The distribution is `use-computer-cli` because `use-computer` is taken on PyPI by an unrelated
|
|
39
49
|
project. The command it installs is `use-computer`, and the package it imports is `use_computer`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "use-computer-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -46,10 +46,14 @@ vnc = ["vncdotool>=1.3"]
|
|
|
46
46
|
#
|
|
47
47
|
# Not dogtail (GPLv2 -- fine to run, wrong to import from a permissively licensed library) and not
|
|
48
48
|
# atomacos (last release 3.3.0, May 2021 -- the same staleness argument that ruled out pyautogui).
|
|
49
|
+
# Linux is deliberately absent. PyGObject has no Linux wheel, so listing it here made
|
|
50
|
+
# `pip install "use-computer-cli[tree]"` build from source, need pycairo and system headers, and
|
|
51
|
+
# fail -- leaving the user with no CLI at all rather than a CLI missing one capability. The AT-SPI
|
|
52
|
+
# bindings come from the distro (python3-gi, gir1.2-atspi-2.0) and are reached with a
|
|
53
|
+
# --system-site-packages virtualenv; the error message says so when they are absent.
|
|
49
54
|
tree = [
|
|
50
55
|
"uiautomation>=2.0; sys_platform == 'win32'",
|
|
51
56
|
"pyobjc-framework-ApplicationServices>=10; sys_platform == 'darwin'",
|
|
52
|
-
"PyGObject>=3.46; sys_platform == 'linux'",
|
|
53
57
|
]
|
|
54
58
|
dev = [
|
|
55
59
|
"pytest>=8",
|
|
@@ -8,15 +8,71 @@ error, because the agent reading it is the one that has to get unstuck.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import sys
|
|
11
12
|
from contextlib import suppress
|
|
13
|
+
from glob import glob
|
|
14
|
+
from pathlib import Path
|
|
12
15
|
from typing import Any
|
|
13
16
|
|
|
14
17
|
from use_computer.accessibility import roles
|
|
15
18
|
from use_computer.accessibility.base import require
|
|
16
|
-
from use_computer.errors import UITreeUnavailableError
|
|
19
|
+
from use_computer.errors import UITreeUnavailableError, UseComputerError
|
|
17
20
|
from use_computer.tree import Box, TreeScope, TreeScopeKind, UINode, WindowInfo
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
#: Where a distro puts PyGObject. The compiled part carries the Python version it was built for,
|
|
23
|
+
#: which is the fact that decides whether any of the advice below will work.
|
|
24
|
+
DISTRO_PATHS = (
|
|
25
|
+
"/usr/lib/python3/dist-packages/gi",
|
|
26
|
+
"/usr/lib/python3*/site-packages/gi",
|
|
27
|
+
"/usr/lib64/python3*/site-packages/gi",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
#: What actually gets AT-SPI working on Linux. Not an extra: PyGObject has no Linux wheel, so
|
|
31
|
+
#: asking pip for it builds from source and fails. The distro has it; a virtualenv only has to be
|
|
32
|
+
#: allowed to see it -- and has to be the same Python it was built for.
|
|
33
|
+
INSTALL_HINT = (
|
|
34
|
+
"install the distro packages and let the virtualenv see them: "
|
|
35
|
+
"`sudo apt install python3-gi gir1.2-atspi-2.0` then "
|
|
36
|
+
"`python3 -m venv --system-site-packages .venv`"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def distro_python() -> str | None:
|
|
41
|
+
"""The Python version the installed PyGObject was built for, or None if none is installed.
|
|
42
|
+
|
|
43
|
+
`gi` is a compiled extension and its shared object names the version:
|
|
44
|
+
``_gi.cpython-310-x86_64-linux-gnu.so``. Reading it is what turns "install these packages"
|
|
45
|
+
-- advice that is useless to someone who already has them -- into the reason it is not working.
|
|
46
|
+
"""
|
|
47
|
+
for pattern in DISTRO_PATHS:
|
|
48
|
+
for directory in glob(pattern):
|
|
49
|
+
for shared_object in glob(f"{directory}/_gi.cpython-*.so"):
|
|
50
|
+
tag = Path(shared_object).name.split(".")[1] # cpython-310-x86_64-linux-gnu
|
|
51
|
+
digits = tag.split("-")[1] if "-" in tag else ""
|
|
52
|
+
if digits.isdigit() and len(digits) >= 2:
|
|
53
|
+
return f"{digits[0]}.{digits[1:]}"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def system_hint() -> str:
|
|
58
|
+
"""Say what is actually wrong here, not what is usually wrong."""
|
|
59
|
+
built_for = distro_python()
|
|
60
|
+
running = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
61
|
+
if built_for is None:
|
|
62
|
+
return INSTALL_HINT
|
|
63
|
+
if built_for == running:
|
|
64
|
+
# It is installed for this interpreter, so the virtualenv simply cannot see it.
|
|
65
|
+
return (
|
|
66
|
+
f"the distro's PyGObject is installed for Python {built_for}, but this environment "
|
|
67
|
+
"cannot see it. Recreate it with: "
|
|
68
|
+
f"`python{built_for} -m venv --system-site-packages .venv`"
|
|
69
|
+
)
|
|
70
|
+
return (
|
|
71
|
+
f"the distro's PyGObject is built for Python {built_for}, but this interpreter is "
|
|
72
|
+
f"{running}, so --system-site-packages would expose a module it cannot import. Create the "
|
|
73
|
+
f"environment with the matching interpreter: "
|
|
74
|
+
f"`python{built_for} -m venv --system-site-packages .venv`"
|
|
75
|
+
)
|
|
20
76
|
|
|
21
77
|
#: Milliseconds any single AT-SPI call may take. AT-SPI is D-Bus, and every property read is a
|
|
22
78
|
#: round trip into another process: one application that is wedged, or merely slow to answer,
|
|
@@ -36,14 +92,29 @@ class AtspiProvider:
|
|
|
36
92
|
name = "atspi"
|
|
37
93
|
|
|
38
94
|
def __init__(self) -> None:
|
|
39
|
-
gi = require("gi", extra=
|
|
95
|
+
gi = require("gi", extra=None, system=system_hint())
|
|
40
96
|
try:
|
|
41
97
|
gi.require_version("Atspi", "2.0")
|
|
42
98
|
from gi.repository import Atspi # noqa: PLC0415 - deliberately not at module import
|
|
43
99
|
except (ImportError, ValueError) as exc:
|
|
44
100
|
raise UITreeUnavailableError(
|
|
45
|
-
"the Atspi typelib is not installed.",
|
|
101
|
+
"the Atspi typelib is not installed.", system=system_hint()
|
|
46
102
|
) from exc
|
|
103
|
+
# GLib talks over our stderr from inside the C library, about a stale socket it then
|
|
104
|
+
# recovers from. An agent reading stderr to understand a failure would see it immediately
|
|
105
|
+
# before output that is entirely fine. A log handler, not a redirect of fd 2: a redirect
|
|
106
|
+
# would swallow our own errors during the same window.
|
|
107
|
+
with suppress(Exception):
|
|
108
|
+
from gi.repository import GLib # noqa: PLC0415
|
|
109
|
+
|
|
110
|
+
GLib.log_set_handler(
|
|
111
|
+
"dbind",
|
|
112
|
+
GLib.LogLevelFlags.LEVEL_MASK
|
|
113
|
+
| GLib.LogLevelFlags.FLAG_FATAL
|
|
114
|
+
| GLib.LogLevelFlags.FLAG_RECURSION,
|
|
115
|
+
lambda *_: None,
|
|
116
|
+
None,
|
|
117
|
+
)
|
|
47
118
|
self._atspi = Atspi
|
|
48
119
|
# Older bindings do not expose it; the default timeout then applies.
|
|
49
120
|
with suppress(Exception):
|
|
@@ -55,21 +126,30 @@ class AtspiProvider:
|
|
|
55
126
|
# --- reading -----------------------------------------------------------------------------
|
|
56
127
|
|
|
57
128
|
def windows(self) -> list[WindowInfo]:
|
|
129
|
+
return [info for info, _ in self._window_pairs()]
|
|
130
|
+
|
|
131
|
+
def _window_pairs(self) -> list[tuple[WindowInfo, Any]]:
|
|
132
|
+
"""Every window, with the accessible it describes, so a match can be acted on."""
|
|
58
133
|
desktop = self._desktop()
|
|
59
|
-
found: list[WindowInfo] = []
|
|
134
|
+
found: list[tuple[WindowInfo, Any]] = []
|
|
60
135
|
for app_index, app in enumerate(self._children(desktop)):
|
|
61
136
|
try:
|
|
62
137
|
pid = self._pid(app)
|
|
138
|
+
app_name = app.get_name() or None
|
|
63
139
|
for index, window in enumerate(self._children(app)):
|
|
64
140
|
states = set(self._states(window))
|
|
65
141
|
found.append(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
142
|
+
(
|
|
143
|
+
WindowInfo(
|
|
144
|
+
id=f"0/{app_index}/{index}",
|
|
145
|
+
title=window.get_name() or None,
|
|
146
|
+
role=roles.atspi_role(window.get_role_name() or ""),
|
|
147
|
+
app=app_name,
|
|
148
|
+
pid=pid if pid > 0 else None,
|
|
149
|
+
box=self._box(window),
|
|
150
|
+
active="active" in states or "focused" in states,
|
|
151
|
+
),
|
|
152
|
+
window,
|
|
73
153
|
)
|
|
74
154
|
)
|
|
75
155
|
except Exception:
|
|
@@ -82,7 +162,9 @@ class AtspiProvider:
|
|
|
82
162
|
try:
|
|
83
163
|
root = self._root_for(scope)
|
|
84
164
|
return self._build(root, "0", depth)
|
|
85
|
-
except
|
|
165
|
+
except UseComputerError:
|
|
166
|
+
# Our own errors already say what to do. Relabelling one as "AT-SPI did not answer"
|
|
167
|
+
# would hide an ambiguous window behind a transport failure it has nothing to do with.
|
|
86
168
|
raise
|
|
87
169
|
except Exception as exc:
|
|
88
170
|
# A GLib/dbind failure is not a Python error the caller can act on. Turning it into
|
|
@@ -109,6 +191,9 @@ class AtspiProvider:
|
|
|
109
191
|
if scope.kind is TreeScopeKind.ALL:
|
|
110
192
|
return desktop
|
|
111
193
|
|
|
194
|
+
if scope.kind is TreeScopeKind.ID and scope.value:
|
|
195
|
+
return self._by_path(desktop, scope.value)
|
|
196
|
+
|
|
112
197
|
for app in self._children(desktop):
|
|
113
198
|
# One unresponsive application must not cost the whole snapshot. Skipping it loses
|
|
114
199
|
# that application; letting it raise loses the desktop.
|
|
@@ -118,12 +203,6 @@ class AtspiProvider:
|
|
|
118
203
|
for window in self._children(app):
|
|
119
204
|
if scope.kind is TreeScopeKind.FOCUSED and self._is_active(window):
|
|
120
205
|
return window
|
|
121
|
-
if (
|
|
122
|
-
scope.kind is TreeScopeKind.TITLE
|
|
123
|
-
and scope.value
|
|
124
|
-
and scope.value.casefold() in (window.get_name() or "").casefold()
|
|
125
|
-
):
|
|
126
|
-
return window
|
|
127
206
|
if scope.kind is TreeScopeKind.PID:
|
|
128
207
|
return window
|
|
129
208
|
except Exception:
|
|
@@ -138,6 +217,17 @@ class AtspiProvider:
|
|
|
138
217
|
)
|
|
139
218
|
raise UITreeUnavailableError(f"no window matches {scope.value!r}.")
|
|
140
219
|
|
|
220
|
+
def _by_path(self, desktop: Any, path: str) -> Any:
|
|
221
|
+
"""The window an id names. Ids are index paths, so this is a walk."""
|
|
222
|
+
node = desktop
|
|
223
|
+
for step in path.split("/")[1:]:
|
|
224
|
+
children = self._children(node)
|
|
225
|
+
index = int(step) if step.isdigit() else -1
|
|
226
|
+
if not 0 <= index < len(children):
|
|
227
|
+
raise UITreeUnavailableError(f"no window at {path!r}.")
|
|
228
|
+
node = children[index]
|
|
229
|
+
return node
|
|
230
|
+
|
|
141
231
|
def _children(self, obj: Any) -> list[Any]:
|
|
142
232
|
try:
|
|
143
233
|
total = obj.get_child_count()
|
|
@@ -51,6 +51,7 @@ class AxProvider:
|
|
|
51
51
|
if app is None:
|
|
52
52
|
return []
|
|
53
53
|
focused = self._attr(app, "AXFocusedWindow")
|
|
54
|
+
app_name = self._attr(app, "AXTitle")
|
|
54
55
|
found: list[WindowInfo] = []
|
|
55
56
|
for index, window in enumerate(self._attr(app, "AXWindows") or []):
|
|
56
57
|
title = self._attr(window, "AXTitle")
|
|
@@ -59,6 +60,7 @@ class AxProvider:
|
|
|
59
60
|
id=f"0/{index}",
|
|
60
61
|
title=str(title) if title else None,
|
|
61
62
|
role=roles.ax_role(str(self._attr(window, "AXRole") or "")),
|
|
63
|
+
app=str(app_name) if app_name else None,
|
|
62
64
|
pid=None,
|
|
63
65
|
box=self._box(window),
|
|
64
66
|
active=bool(focused is not None and window == focused),
|
|
@@ -88,10 +90,10 @@ class AxProvider:
|
|
|
88
90
|
app = self._attr(system, "AXFocusedApplication")
|
|
89
91
|
if scope.kind is TreeScopeKind.ALL:
|
|
90
92
|
return app or system
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if scope.value
|
|
93
|
+
# A title is resolved to an id above the Protocol, so a provider only ever sees an id.
|
|
94
|
+
if scope.kind is TreeScopeKind.ID and scope.value and app is not None:
|
|
95
|
+
for index, window in enumerate(self._attr(app, "AXWindows") or []):
|
|
96
|
+
if scope.value == f"0/{index}":
|
|
95
97
|
return window
|
|
96
98
|
focused = self._attr(app, "AXFocusedWindow") if app is not None else None
|
|
97
99
|
return focused or app or system
|
|
@@ -54,14 +54,15 @@ class AccessibilityProvider(Protocol):
|
|
|
54
54
|
"""Release whatever the provider holds."""
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def require(module: str, *, extra: str, system: str | None = None) -> ModuleType:
|
|
57
|
+
def require(module: str, *, extra: str | None, system: str | None = None) -> ModuleType:
|
|
58
58
|
"""Import a platform binding, or say exactly what to install.
|
|
59
59
|
|
|
60
60
|
Called inside a provider's constructor, never at module import: otherwise
|
|
61
61
|
``use-computer --help`` stops working on a machine without the extra.
|
|
62
62
|
|
|
63
63
|
Raises:
|
|
64
|
-
UITreeUnavailableError: naming the extra
|
|
64
|
+
UITreeUnavailableError: naming the extra where there is one, and the system packages where
|
|
65
|
+
those are what actually works -- on Linux there is no useful extra to name.
|
|
65
66
|
"""
|
|
66
67
|
try:
|
|
67
68
|
return import_module(module)
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
from use_computer.accessibility import roles
|
|
12
12
|
from use_computer.accessibility.base import require
|
|
13
|
+
from use_computer.errors import UITreeUnavailableError
|
|
13
14
|
from use_computer.tree import Box, TreeScope, TreeScopeKind, UINode, WindowInfo
|
|
14
15
|
|
|
15
16
|
|
|
@@ -33,6 +34,10 @@ class UiaProvider:
|
|
|
33
34
|
id=f"0/{index}",
|
|
34
35
|
title=window.Name or None,
|
|
35
36
|
role=roles.uia_role(window.ControlTypeName or ""),
|
|
37
|
+
# UI Automation reports the process id and not its name, and turning one
|
|
38
|
+
# into the other needs an API this package does not carry. Left empty
|
|
39
|
+
# rather than guessed; the pid still tells two windows apart.
|
|
40
|
+
app=None,
|
|
36
41
|
pid=int(window.ProcessId),
|
|
37
42
|
box=self._box(window),
|
|
38
43
|
active=bool(window.HasKeyboardFocus),
|
|
@@ -48,21 +53,21 @@ class UiaProvider:
|
|
|
48
53
|
|
|
49
54
|
def _root_for(self, scope: TreeScope) -> Any:
|
|
50
55
|
auto = self._auto
|
|
56
|
+
root = auto.GetRootControl()
|
|
51
57
|
if scope.kind is TreeScopeKind.ALL:
|
|
52
|
-
return
|
|
58
|
+
return root
|
|
53
59
|
if scope.kind is TreeScopeKind.FOCUSED:
|
|
54
60
|
control = auto.GetFocusedControl()
|
|
55
|
-
return self._top_window(control) if control else
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
return self._top_window(control) if control else root
|
|
62
|
+
# A title has already been resolved to an id above the Protocol, so only an id or a pid
|
|
63
|
+
# reaches a provider. Keeping the matching in one place is what stops three platforms
|
|
64
|
+
# drifting apart on it.
|
|
65
|
+
for index, window in enumerate(root.GetChildren()):
|
|
66
|
+
if scope.kind is TreeScopeKind.ID and scope.value == f"0/{index}":
|
|
58
67
|
return window
|
|
59
|
-
if (
|
|
60
|
-
scope.kind is TreeScopeKind.TITLE
|
|
61
|
-
and scope.value
|
|
62
|
-
and scope.value.casefold() in (window.Name or "").casefold()
|
|
63
|
-
):
|
|
68
|
+
if scope.kind is TreeScopeKind.PID and str(window.ProcessId) == scope.value:
|
|
64
69
|
return window
|
|
65
|
-
|
|
70
|
+
raise UITreeUnavailableError(f"no window matches {scope.value!r}.")
|
|
66
71
|
|
|
67
72
|
def _top_window(self, control: Any) -> Any:
|
|
68
73
|
"""Walk up to the window the focused control lives in."""
|
|
@@ -186,9 +186,29 @@ class KeyAction(BaseAction):
|
|
|
186
186
|
|
|
187
187
|
class ScreenshotAction(BaseAction):
|
|
188
188
|
action: Literal["screenshot"] = "screenshot"
|
|
189
|
+
|
|
190
|
+
@field_validator("window", mode="before")
|
|
191
|
+
@classmethod
|
|
192
|
+
def _parse_scope(cls, value: Any) -> Any:
|
|
193
|
+
return TreeScope.parse(value) if isinstance(value, str) else value
|
|
194
|
+
|
|
189
195
|
out: Path | None = Field(
|
|
190
196
|
default=None, description="Where to write it. None means the screenshot directory."
|
|
191
197
|
)
|
|
198
|
+
of: str | None = Field(
|
|
199
|
+
default=None,
|
|
200
|
+
description="Crop to this node's box. The tree knows where; only what is missing.",
|
|
201
|
+
)
|
|
202
|
+
window: TreeScope = Field(
|
|
203
|
+
default_factory=TreeScope,
|
|
204
|
+
description="Which tree `of` is an id in. An id means nothing without its scope.",
|
|
205
|
+
)
|
|
206
|
+
pad: int = Field(
|
|
207
|
+
default=0,
|
|
208
|
+
ge=0,
|
|
209
|
+
description="Grow the crop by this many pixels each side; a control's box often "
|
|
210
|
+
"excludes the label beside it.",
|
|
211
|
+
)
|
|
192
212
|
|
|
193
213
|
|
|
194
214
|
class TreeAction(BaseAction):
|