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.
Files changed (49) hide show
  1. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/PKG-INFO +16 -7
  2. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/README.md +15 -5
  3. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/pyproject.toml +6 -2
  4. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/atspi.py +109 -19
  5. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/ax.py +6 -4
  6. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/base.py +3 -2
  7. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/uia.py +15 -10
  8. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/actions.py +20 -0
  9. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/cli.py +249 -31
  10. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/compare.py +33 -0
  11. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/config.py +37 -2
  12. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/errors.py +25 -2
  13. use_computer_cli-0.2.2/src/use_computer/prune.py +95 -0
  14. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/render.py +51 -3
  15. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/runner.py +76 -5
  16. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/selectors.py +37 -3
  17. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/skill/SKILL.md +113 -16
  18. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/tree.py +9 -1
  19. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/fake_provider.py +1 -0
  20. use_computer_cli-0.2.2/tests/test_atspi_hint.py +70 -0
  21. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_cli.py +208 -19
  22. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_config.py +9 -2
  23. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_elements.py +22 -1
  24. use_computer_cli-0.2.2/tests/test_prune.py +76 -0
  25. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_render.py +16 -3
  26. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_selectors.py +44 -2
  27. use_computer_cli-0.2.2/tests/test_skill_is_true.py +90 -0
  28. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/.gitignore +0 -0
  29. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/LICENSE +0 -0
  30. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/__init__.py +0 -0
  31. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/__init__.py +0 -0
  32. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/accessibility/roles.py +0 -0
  33. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/__init__.py +0 -0
  34. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/base.py +0 -0
  35. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/local.py +0 -0
  36. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/backends/vnc.py +0 -0
  37. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/coordinates.py +0 -0
  38. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/keys.py +0 -0
  39. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/src/use_computer/skill/__init__.py +0 -0
  40. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/__init__.py +0 -0
  41. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/conftest.py +0 -0
  42. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/fake_backend.py +0 -0
  43. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_backends.py +0 -0
  44. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_compare.py +0 -0
  45. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_config_init.py +0 -0
  46. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_coordinates.py +0 -0
  47. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_keys.py +0 -0
  48. {use_computer_cli-0.2.0 → use_computer_cli-0.2.2}/tests/test_runner.py +0 -0
  49. {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.0
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
- The `tree` extra installs one binding per platform: `uiautomation` on Windows,
94
- `pyobjc-framework-ApplicationServices` on macOS, PyGObject on Linux. **Linux needs a system package
95
- as well** -- there is no `pyatspi` on PyPI, so the AT-SPI bindings come from `gir1.2-atspi-2.0`
96
- (plus `python3-pyatspi` on Debian and Ubuntu). The error names both halves when one is missing.
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
- The `tree` extra installs one binding per platform: `uiautomation` on Windows,
34
- `pyobjc-framework-ApplicationServices` on macOS, PyGObject on Linux. **Linux needs a system package
35
- as well** -- there is no `pyatspi` on PyPI, so the AT-SPI bindings come from `gir1.2-atspi-2.0`
36
- (plus `python3-pyatspi` on Debian and Ubuntu). The error names both halves when one is missing.
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.0"
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
- SYSTEM_PACKAGE = "gir1.2-atspi-2.0 (and python3-pyatspi on Debian/Ubuntu)"
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="tree", system=SYSTEM_PACKAGE)
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.", extra="tree", system=SYSTEM_PACKAGE
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
- WindowInfo(
67
- id=f"0/{app_index}/{index}",
68
- title=window.get_name() or None,
69
- role=roles.atspi_role(window.get_role_name() or ""),
70
- pid=pid if pid > 0 else None,
71
- box=self._box(window),
72
- active="active" in states or "focused" in states,
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 UITreeUnavailableError:
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
- if scope.kind is TreeScopeKind.TITLE and scope.value and app is not None:
92
- for window in self._attr(app, "AXWindows") or []:
93
- title = self._attr(window, "AXTitle") or ""
94
- if scope.value.casefold() in str(title).casefold():
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 and, where one is needed, the system package.
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 auto.GetRootControl()
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 auto.GetRootControl()
56
- for window in auto.GetRootControl().GetChildren():
57
- if scope.kind is TreeScopeKind.PID and str(window.ProcessId) == scope.value:
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
- return auto.GetRootControl()
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):