use-computer-cli 0.2.2__tar.gz → 0.3.1__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 (52) hide show
  1. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/PKG-INFO +2 -1
  2. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/pyproject.toml +7 -1
  3. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/atspi.py +85 -4
  4. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/ax.py +23 -1
  5. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/base.py +19 -1
  6. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/uia.py +23 -1
  7. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/actions.py +106 -3
  8. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/cli.py +175 -29
  9. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/compare.py +40 -14
  10. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/errors.py +5 -5
  11. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/render.py +51 -2
  12. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/runner.py +178 -10
  13. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/selectors.py +185 -2
  14. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/skill/SKILL.md +116 -21
  15. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/tree.py +27 -0
  16. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/fake_provider.py +51 -3
  17. use_computer_cli-0.3.1/tests/test_blind_spots.py +157 -0
  18. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_cli.py +50 -3
  19. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_compare.py +28 -0
  20. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_selectors.py +53 -0
  21. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_skill.py +6 -6
  22. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_skill_is_true.py +41 -0
  23. use_computer_cli-0.3.1/tests/test_trusting_a_result.py +216 -0
  24. use_computer_cli-0.3.1/tests/test_which_window.py +113 -0
  25. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/.gitignore +0 -0
  26. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/LICENSE +0 -0
  27. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/README.md +0 -0
  28. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/__init__.py +0 -0
  29. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/__init__.py +0 -0
  30. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/roles.py +0 -0
  31. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/__init__.py +0 -0
  32. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/base.py +0 -0
  33. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/local.py +0 -0
  34. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/vnc.py +0 -0
  35. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/config.py +0 -0
  36. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/coordinates.py +0 -0
  37. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/keys.py +0 -0
  38. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/prune.py +0 -0
  39. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/skill/__init__.py +0 -0
  40. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/__init__.py +0 -0
  41. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/conftest.py +0 -0
  42. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/fake_backend.py +0 -0
  43. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_atspi_hint.py +0 -0
  44. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_backends.py +0 -0
  45. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_config.py +0 -0
  46. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_config_init.py +0 -0
  47. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_coordinates.py +0 -0
  48. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_elements.py +0 -0
  49. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_keys.py +0 -0
  50. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_prune.py +0 -0
  51. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_render.py +0 -0
  52. {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: use-computer-cli
3
- Version: 0.2.2
3
+ Version: 0.3.1
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
@@ -52,6 +52,7 @@ Requires-Dist: mss>=10; extra == 'local'
52
52
  Requires-Dist: pynput>=1.8; extra == 'local'
53
53
  Provides-Extra: tree
54
54
  Requires-Dist: pyobjc-framework-applicationservices>=10; (sys_platform == 'darwin') and extra == 'tree'
55
+ Requires-Dist: python-xlib>=0.33; (sys_platform == 'linux') and extra == 'tree'
55
56
  Requires-Dist: uiautomation>=2.0; (sys_platform == 'win32') and extra == 'tree'
56
57
  Provides-Extra: vnc
57
58
  Requires-Dist: vncdotool>=1.3; extra == 'vnc'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "use-computer-cli"
7
- version = "0.2.2"
7
+ version = "0.3.1"
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"
@@ -52,6 +52,11 @@ vnc = ["vncdotool>=1.3"]
52
52
  # bindings come from the distro (python3-gi, gir1.2-atspi-2.0) and are reached with a
53
53
  # --system-site-packages virtualenv; the error message says so when they are absent.
54
54
  tree = [
55
+ # python-xlib reads _NET_ACTIVE_WINDOW, the only reliable answer to "which window is in
56
+ # front" -- AT-SPI reports `active` per application and marks several at once. A pure-Python
57
+ # wheel with no build step, which is exactly what PyGObject was not, so it does not
58
+ # reintroduce BUG-003. Imported softly: absent, or on Wayland, the tool falls back.
59
+ "python-xlib>=0.33; sys_platform == 'linux'",
55
60
  "uiautomation>=2.0; sys_platform == 'win32'",
56
61
  "pyobjc-framework-ApplicationServices>=10; sys_platform == 'darwin'",
57
62
  ]
@@ -102,6 +107,7 @@ module = [
102
107
  "ApplicationServices.*",
103
108
  "Quartz.*",
104
109
  "gi.*",
110
+ "Xlib.*",
105
111
  "uiautomation.*",
106
112
  "tomli",
107
113
  ]
@@ -17,7 +17,14 @@ from typing import Any
17
17
  from use_computer.accessibility import roles
18
18
  from use_computer.accessibility.base import require
19
19
  from use_computer.errors import UITreeUnavailableError, UseComputerError
20
- from use_computer.tree import Box, TreeScope, TreeScopeKind, UINode, WindowInfo
20
+ from use_computer.tree import (
21
+ ActiveWindow,
22
+ Box,
23
+ TreeScope,
24
+ TreeScopeKind,
25
+ UINode,
26
+ WindowInfo,
27
+ )
21
28
 
22
29
  #: Where a distro puts PyGObject. The compiled part carries the Python version it was built for,
23
30
  #: which is the fact that decides whether any of the advice below will work.
@@ -126,7 +133,31 @@ class AtspiProvider:
126
133
  # --- reading -----------------------------------------------------------------------------
127
134
 
128
135
  def windows(self) -> list[WindowInfo]:
129
- return [info for info, _ in self._window_pairs()]
136
+ found = [info for info, _ in self._window_pairs()]
137
+ if any(info.active for info in found):
138
+ return found
139
+ # Nothing claims `active`. Some desktops only ever report `focused`, so fall back to it
140
+ # rather than returning a list with no mark at all -- but never mix the two: `focused` on
141
+ # this desktop marks the shell, which is not a window anybody wants to act in.
142
+ return [
143
+ info.model_copy(update={"active": info.id in self._focused_ids()})
144
+ for info in found
145
+ ]
146
+
147
+ def _focused_ids(self) -> frozenset[str]:
148
+ ids: set[str] = set()
149
+ try:
150
+ desktop = self._desktop()
151
+ for app_index, app in enumerate(self._children(desktop)):
152
+ try:
153
+ for index, window in enumerate(self._children(app)):
154
+ if "focused" in set(self._states(window)):
155
+ ids.add(f"0/{app_index}/{index}")
156
+ except Exception:
157
+ continue
158
+ except Exception:
159
+ return frozenset()
160
+ return frozenset(ids)
130
161
 
131
162
  def _window_pairs(self) -> list[tuple[WindowInfo, Any]]:
132
163
  """Every window, with the accessible it describes, so a match can be acted on."""
@@ -147,7 +178,7 @@ class AtspiProvider:
147
178
  app=app_name,
148
179
  pid=pid if pid > 0 else None,
149
180
  box=self._box(window),
150
- active="active" in states or "focused" in states,
181
+ active="active" in states,
151
182
  ),
152
183
  window,
153
184
  )
@@ -262,7 +293,7 @@ class AtspiProvider:
262
293
 
263
294
  def _is_active(self, window: Any) -> bool:
264
295
  states = set(self._states(window))
265
- return "active" in states or "focused" in states
296
+ return "active" in states
266
297
 
267
298
  #: AT-SPI reports an element that is not currently rendered at INT_MIN with a 1x1 size --
268
299
  #: the items of a closed menu, for instance. That is a sentinel, not a position, and letting
@@ -378,6 +409,56 @@ class AtspiProvider:
378
409
  return bool(interface.do_action(index))
379
410
  return False
380
411
 
412
+ def active_window(self) -> ActiveWindow | None:
413
+ """Ask the window manager, because AT-SPI cannot answer this.
414
+
415
+ `_NET_ACTIVE_WINDOW` on the root window names exactly one window; AT-SPI's `active` state
416
+ is per application and marked three at once on the desktop that produced the bug. Imported
417
+ softly on purpose: no python-xlib, or a Wayland session that publishes no such property,
418
+ and the caller falls back to the flags.
419
+ """
420
+ try:
421
+ from Xlib import X, display
422
+ except ImportError:
423
+ return None
424
+ try:
425
+ connection = display.Display()
426
+ except Exception:
427
+ return None # no X server: Wayland, or no session at all
428
+ try:
429
+ root = connection.screen().root
430
+ active = root.get_full_property(
431
+ connection.intern_atom("_NET_ACTIVE_WINDOW"), X.AnyPropertyType
432
+ )
433
+ if active is None or not active.value:
434
+ return None
435
+ window = connection.create_resource_object("window", active.value[0])
436
+ pid = window.get_full_property(
437
+ connection.intern_atom("_NET_WM_PID"), X.AnyPropertyType
438
+ )
439
+ name = window.get_full_property(
440
+ connection.intern_atom("_NET_WM_NAME"), connection.intern_atom("UTF8_STRING")
441
+ )
442
+ return ActiveWindow(
443
+ pid=int(pid.value[0]) if pid is not None and pid.value else None,
444
+ title=name.value.decode("utf-8", "replace") if name is not None else None,
445
+ )
446
+ except Exception:
447
+ # A hint that fails is not an error: the caller has a working fallback.
448
+ return None
449
+ finally:
450
+ with suppress(Exception):
451
+ connection.close()
452
+
453
+ def activate(self, window_id: str) -> bool:
454
+ """AT-SPI offers no raise for a window.
455
+
456
+ A window accessible reports no actions at all -- verified: `It supports: none` -- so there
457
+ is nothing native to call here. False tells the caller to focus a descendant instead,
458
+ which is what actually moves `_NET_ACTIVE_WINDOW`.
459
+ """
460
+ return False
461
+
381
462
  def close(self) -> None:
382
463
  self._index = {}
383
464
 
@@ -15,7 +15,14 @@ from typing import Any
15
15
  from use_computer.accessibility import roles
16
16
  from use_computer.accessibility.base import require
17
17
  from use_computer.errors import PermissionDeniedError
18
- from use_computer.tree import Box, TreeScope, TreeScopeKind, UINode, WindowInfo
18
+ from use_computer.tree import (
19
+ ActiveWindow,
20
+ Box,
21
+ TreeScope,
22
+ TreeScopeKind,
23
+ UINode,
24
+ WindowInfo,
25
+ )
19
26
 
20
27
  #: kAXErrorAPIDisabled -- the process is not trusted for accessibility.
21
28
  API_DISABLED = -25211
@@ -197,6 +204,21 @@ class AxProvider:
197
204
  roles.COLLAPSE: "AXPress",
198
205
  }
199
206
 
207
+ def active_window(self) -> ActiveWindow | None:
208
+ """This platform's own `active` flag is already per window, so there is nothing to add."""
209
+ return None
210
+
211
+ def activate(self, window_id: str) -> bool:
212
+ """macOS windows do accept a raise of their own: AXRaise."""
213
+ element = self._index.get(window_id)
214
+ if element is None:
215
+ return False
216
+ try:
217
+ status = self._api.AXUIElementPerformAction(element, "AXRaise")
218
+ except Exception:
219
+ return False
220
+ return bool(status == 0)
221
+
200
222
  def perform(self, node_id: str, action: str, value: str | None) -> bool:
201
223
  element = self._index.get(node_id)
202
224
  if element is None:
@@ -15,7 +15,7 @@ from types import ModuleType
15
15
  from typing import Protocol, runtime_checkable
16
16
 
17
17
  from use_computer.errors import UITreeUnavailableError
18
- from use_computer.tree import TreeScope, UINode, WindowInfo
18
+ from use_computer.tree import ActiveWindow, TreeScope, UINode, WindowInfo
19
19
 
20
20
 
21
21
  @runtime_checkable
@@ -50,6 +50,24 @@ class AccessibilityProvider(Protocol):
50
50
  nothing at all. ``False`` is what makes ``--via auto`` fall back to a coordinate click.
51
51
  """
52
52
 
53
+ def activate(self, window_id: str) -> bool:
54
+ """Bring a window forward, natively, if this platform has a way.
55
+
56
+ ``True`` means the window was raised. ``False`` is not a failure: it means this platform
57
+ offers no raise for a window object, and the caller should focus a descendant instead --
58
+ which raises the top-level window on all three. AT-SPI is the ``False`` case; a window
59
+ accessible there reports no actions at all.
60
+ """
61
+
62
+ def active_window(self) -> ActiveWindow | None:
63
+ """What the window manager says is in front, as a pid and a title.
64
+
65
+ A *hint*. ``None`` means this platform has nothing better than the per-window flags, which
66
+ is the honest answer on Wayland or without the optional binding. Matching it to a window is
67
+ policy and lives in ``selectors.mark_active``: a rule that lives in a provider is a rule
68
+ the other two platforms drift from, which has already happened once here.
69
+ """
70
+
53
71
  def close(self) -> None:
54
72
  """Release whatever the provider holds."""
55
73
 
@@ -11,7 +11,14 @@ from typing import Any
11
11
  from use_computer.accessibility import roles
12
12
  from use_computer.accessibility.base import require
13
13
  from use_computer.errors import UITreeUnavailableError
14
- from use_computer.tree import Box, TreeScope, TreeScopeKind, UINode, WindowInfo
14
+ from use_computer.tree import (
15
+ ActiveWindow,
16
+ Box,
17
+ TreeScope,
18
+ TreeScopeKind,
19
+ UINode,
20
+ WindowInfo,
21
+ )
15
22
 
16
23
 
17
24
  class UiaProvider:
@@ -167,6 +174,21 @@ class UiaProvider:
167
174
 
168
175
  # --- acting ------------------------------------------------------------------------------
169
176
 
177
+ def active_window(self) -> ActiveWindow | None:
178
+ """This platform's own `active` flag is already per window, so there is nothing to add."""
179
+ return None
180
+
181
+ def activate(self, window_id: str) -> bool:
182
+ """Windows has a native raise: `SetActive` on the top-level control."""
183
+ control = self._index.get(window_id)
184
+ if control is None:
185
+ return False
186
+ try:
187
+ return bool(control.SetActive())
188
+ except Exception:
189
+ # Not every control exposes it, and the caller has a working fallback.
190
+ return False
191
+
170
192
  def perform(self, node_id: str, action: str, value: str | None) -> bool:
171
193
  control = self._index.get(node_id)
172
194
  if control is None:
@@ -7,6 +7,7 @@ in actuation units and canonical key names.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from difflib import get_close_matches
10
11
  from enum import Enum
11
12
  from pathlib import Path
12
13
  from typing import Annotated, Any, Literal
@@ -48,7 +49,25 @@ class BaseAction(BaseModel):
48
49
  return None
49
50
 
50
51
 
51
- class _Positioned(BaseAction):
52
+ class _InAWindow(BaseAction):
53
+ """An action whose coordinate belongs to a particular window.
54
+
55
+ A coordinate lands on whatever window is in front. That is what a bare coordinate has always
56
+ meant, and it is fine until the agent meant a particular window -- which is most of the time.
57
+ Naming it here is how the caller says so, and the window is brought forward before the
58
+ coordinate is sent. Measured: two drags aimed at a canvas selected text in a terminal instead,
59
+ and `--verify` then confirmed the wrong action with the wrong evidence.
60
+ """
61
+
62
+ window: TreeScope | None = None
63
+
64
+ @field_validator("window", mode="before")
65
+ @classmethod
66
+ def _parse_scope(cls, value: Any) -> Any:
67
+ return TreeScope.parse(value) if isinstance(value, str) else value
68
+
69
+
70
+ class _Positioned(_InAWindow):
52
71
  """An action that may carry a coordinate. Omitting it acts where the pointer already is."""
53
72
 
54
73
  x: int | None = None
@@ -130,7 +149,7 @@ class RightClickAction(_PositionedOrSelected):
130
149
  action: Literal["right_click"] = "right_click"
131
150
 
132
151
 
133
- class DragAction(BaseAction):
152
+ class DragAction(_InAWindow):
134
153
  action: Literal["drag"] = "drag"
135
154
  from_x: int
136
155
  from_y: int
@@ -297,6 +316,23 @@ class ShowMenuAction(_ElementAction):
297
316
  action: Literal["show_menu"] = "show_menu"
298
317
 
299
318
 
319
+ class ActivateAction(BaseAction):
320
+ """Bring a window forward and give it keyboard focus.
321
+
322
+ The only action whose target is a window rather than an element or a point. A window object
323
+ exposes no actions of its own on any of the three platforms -- AT-SPI answers `It supports:
324
+ none` -- so it is done by focusing a descendant, which raises the top-level window everywhere.
325
+ """
326
+
327
+ action: Literal["activate"] = "activate"
328
+ window: TreeScope
329
+
330
+ @field_validator("window", mode="before")
331
+ @classmethod
332
+ def _parse_scope(cls, value: Any) -> Any:
333
+ return TreeScope.parse(value) if isinstance(value, str) else value
334
+
335
+
300
336
  Action = Annotated[
301
337
  MoveAction
302
338
  | ClickAction
@@ -315,7 +351,8 @@ Action = Annotated[
315
351
  | CollapseAction
316
352
  | SelectAction
317
353
  | SetValueAction
318
- | ShowMenuAction,
354
+ | ShowMenuAction
355
+ | ActivateAction,
319
356
  Field(discriminator="action"),
320
357
  ]
321
358
 
@@ -339,6 +376,72 @@ def selector_of(action: Action) -> NodeSelector | None:
339
376
  #: Parses a batch file: a JSON array of action objects, discriminated on `action`.
340
377
  ActionListAdapter: TypeAdapter[list[Action]] = TypeAdapter(list[Action])
341
378
 
379
+ #: Every action name, in the spelling the discriminator uses.
380
+ ACTION_NAMES: tuple[str, ...] = (
381
+ "move",
382
+ "click",
383
+ "double_click",
384
+ "right_click",
385
+ "drag",
386
+ "scroll",
387
+ "type",
388
+ "key",
389
+ "screenshot",
390
+ "tree",
391
+ "windows",
392
+ "focus",
393
+ "toggle",
394
+ "expand",
395
+ "collapse",
396
+ "select",
397
+ "set_value",
398
+ "show_menu",
399
+ "activate",
400
+ )
401
+
402
+
403
+ def _cli_spelling(name: str) -> str:
404
+ """How this action is spelled as a command: `set_value` is typed `set-value`."""
405
+ return name.replace("_", "-")
406
+
407
+
408
+ def normalise_action_names(data: Any) -> Any:
409
+ """Accept the CLI's own spelling of an action name inside a batch.
410
+
411
+ The command is `use-computer set-value`; the batch wanted `set_value`. Same action, two
412
+ spellings, and nothing said so -- an agent that has just read `set-value --help` has no reason
413
+ to expect a different name three lines later.
414
+ """
415
+ if not isinstance(data, list):
416
+ return data
417
+ out = []
418
+ for item in data:
419
+ if isinstance(item, dict) and isinstance(item.get("action"), str):
420
+ item = {**item, "action": item["action"].replace("-", "_")}
421
+ out.append(item)
422
+ return out
423
+
424
+
425
+ def check_action_names(data: Any) -> None:
426
+ """Fail on an unknown action with a sentence, before pydantic offers its union.
427
+
428
+ The union's own error is four hundred characters naming every variant except the one the
429
+ caller should have written. This is a message to somebody mid-task who cannot see the code.
430
+ """
431
+ if not isinstance(data, list):
432
+ return
433
+ for index, item in enumerate(data):
434
+ if not isinstance(item, dict):
435
+ continue
436
+ name = item.get("action")
437
+ if not isinstance(name, str) or name in ACTION_NAMES:
438
+ continue
439
+ close = get_close_matches(name, ACTION_NAMES, n=1, cutoff=0.6)
440
+ suggestion = f" Did you mean {_cli_spelling(close[0])!r}?" if close else ""
441
+ raise ValueError(
442
+ f"unknown action {_cli_spelling(name)!r} at index {index}.{suggestion}"
443
+ )
444
+
342
445
  #: Parses a single action object.
343
446
  ActionAdapter: TypeAdapter[Action] = TypeAdapter(Action)
344
447