use-computer-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ """Coordinate spaces and the conversion between them.
2
+
3
+ A screenshot on a HiDPI display is larger than the space the operating system clicks in. An
4
+ unhandled factor of two makes every click land in the wrong place, and nothing about the
5
+ failure looks like a scaling bug -- so every coordinate carries the space it belongs to, and a
6
+ conversion whose ratio is unknown raises instead of guessing.
7
+
8
+ This module is pure: it knows nothing about backends.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from enum import Enum
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from use_computer.errors import CoordinateSpaceError
18
+
19
+ #: Relative disagreement tolerated between the horizontal and vertical ratios before the two
20
+ #: reported screen sizes are called inconsistent.
21
+ SCALE_TOLERANCE = 0.02
22
+
23
+
24
+ class CoordinateSpace(str, Enum):
25
+ """The space a coordinate is expressed in."""
26
+
27
+ SCREENSHOT = "screenshot"
28
+ """Pixels of the captured image -- what ui-locator returns, because it looked at the image."""
29
+
30
+ ACTUATION = "actuation"
31
+ """The units the backend moves the pointer in."""
32
+
33
+
34
+ class Coordinate(BaseModel):
35
+ """A point, together with the space it belongs to.
36
+
37
+ A bare pair of numbers is not a coordinate; the space is part of the value.
38
+ """
39
+
40
+ model_config = ConfigDict(frozen=True)
41
+
42
+ x: int
43
+ y: int
44
+ space: CoordinateSpace
45
+
46
+
47
+ class ScreenInfo(BaseModel):
48
+ """What a backend reports about its own screen."""
49
+
50
+ model_config = ConfigDict(frozen=True)
51
+
52
+ width: int = Field(description="Screen width in actuation units.")
53
+ height: int = Field(description="Screen height in actuation units.")
54
+ screenshot_width: int = Field(description="Screenshot width in pixels.")
55
+ screenshot_height: int = Field(description="Screenshot height in pixels.")
56
+ scale: float | None = Field(
57
+ default=None,
58
+ description=(
59
+ "Screenshot pixels per actuation unit. None means unknown -- conversion refuses."
60
+ ),
61
+ )
62
+
63
+ def size(self, space: CoordinateSpace) -> tuple[int, int]:
64
+ if space is CoordinateSpace.SCREENSHOT:
65
+ return self.screenshot_width, self.screenshot_height
66
+ return self.width, self.height
67
+
68
+
69
+ def derive_scale(
70
+ width: int, height: int, screenshot_width: int, screenshot_height: int
71
+ ) -> float | None:
72
+ """Derive the screenshot/actuation ratio from two reported sizes.
73
+
74
+ Returns ``None`` when it cannot be derived -- a zero dimension, or horizontal and vertical
75
+ ratios that disagree by more than :data:`SCALE_TOLERANCE`. An inconsistent pair is not
76
+ averaged into a plausible-looking number; it is reported as unknown.
77
+ """
78
+ if width <= 0 or height <= 0 or screenshot_width <= 0 or screenshot_height <= 0:
79
+ return None
80
+ ratio_x = screenshot_width / width
81
+ ratio_y = screenshot_height / height
82
+ if abs(ratio_x - ratio_y) > SCALE_TOLERANCE * max(ratio_x, ratio_y):
83
+ return None
84
+ return (ratio_x + ratio_y) / 2
85
+
86
+
87
+ def convert(coord: Coordinate, target: CoordinateSpace, screen: ScreenInfo) -> Coordinate:
88
+ """Convert ``coord`` into ``target`` space.
89
+
90
+ Raises:
91
+ CoordinateSpaceError: when the scale is unknown, so the conversion would be a guess.
92
+ """
93
+ if coord.space is target:
94
+ return coord
95
+ if screen.scale is None or screen.scale <= 0:
96
+ raise CoordinateSpaceError(
97
+ f"cannot convert {coord.x},{coord.y} from {coord.space.value} to {target.value}: "
98
+ f"the backend reports {screen.width}x{screen.height} actuation units and "
99
+ f"{screen.screenshot_width}x{screen.screenshot_height} screenshot pixels, from which "
100
+ "no consistent scale can be derived. Capture a screenshot to establish the ratio, or "
101
+ "set `scale` explicitly in the profile."
102
+ )
103
+ factor = 1 / screen.scale if coord.space is CoordinateSpace.SCREENSHOT else screen.scale
104
+ return Coordinate(x=round(coord.x * factor), y=round(coord.y * factor), space=target)
use_computer/errors.py ADDED
@@ -0,0 +1,53 @@
1
+ """Exception hierarchy.
2
+
3
+ Every error carries what the caller needs to fix it: the extra to install, the OS permission
4
+ to grant, the config key to set. A message that only says "failed" costs a debugging session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class UseComputerError(Exception):
11
+ """Base class for every error raised by use-computer."""
12
+
13
+
14
+ class BackendNotAvailableError(UseComputerError):
15
+ """A backend's optional dependency is not installed."""
16
+
17
+ def __init__(self, backend: str, extra: str, missing: str) -> None:
18
+ self.backend = backend
19
+ self.extra = extra
20
+ self.missing = missing
21
+ super().__init__(
22
+ f"backend {backend!r} needs {missing!r}, which is not installed. "
23
+ f'Install it with: pip install "use-computer-cli[{extra}]"'
24
+ )
25
+
26
+
27
+ class PermissionDeniedError(UseComputerError):
28
+ """The host denied a permission the backend needs.
29
+
30
+ Without this error the underlying libraries typically do nothing at all, and a click that
31
+ never happened is reported as a click that worked.
32
+ """
33
+
34
+ def __init__(self, permission: str, hint: str) -> None:
35
+ self.permission = permission
36
+ self.hint = hint
37
+ super().__init__(f"{permission} permission denied. {hint}")
38
+
39
+
40
+ class CoordinateSpaceError(UseComputerError):
41
+ """A coordinate cannot be converted because the scale is unknown or inconsistent."""
42
+
43
+
44
+ class KeySyntaxError(UseComputerError):
45
+ """A key combination could not be parsed."""
46
+
47
+
48
+ class ConfigError(UseComputerError):
49
+ """Configuration is missing, malformed, or forbids the requested operation."""
50
+
51
+
52
+ class ActionFailedError(UseComputerError):
53
+ """The backend failed to perform an action."""
use_computer/keys.py ADDED
@@ -0,0 +1,209 @@
1
+ """One key-name syntax, normalised per backend.
2
+
3
+ One spelling of a shortcut must work on both backends. This module owns the canonical
4
+ vocabulary and one mapping table per backend, both keyed by the same canonical names -- so a
5
+ missing entry is a visible hole rather than a divergence between backends.
6
+
7
+ This module is pure: the tables hold strings, never imported symbols, so it stays importable
8
+ with no extras installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+ from typing import Final
15
+
16
+ from pydantic import BaseModel, ConfigDict
17
+
18
+ from use_computer.errors import KeySyntaxError
19
+
20
+ #: Modifier aliases resolved to one canonical name.
21
+ MODIFIER_ALIASES: Final[dict[str, str]] = {
22
+ "ctrl": "ctrl",
23
+ "control": "ctrl",
24
+ "alt": "alt",
25
+ "option": "alt",
26
+ "opt": "alt",
27
+ "shift": "shift",
28
+ "cmd": "cmd",
29
+ "command": "cmd",
30
+ "super": "cmd",
31
+ "win": "cmd",
32
+ "meta": "cmd",
33
+ }
34
+
35
+ #: Canonical modifiers, in the order they are pressed.
36
+ MODIFIER_ORDER: Final[tuple[str, ...]] = ("ctrl", "alt", "shift", "cmd")
37
+
38
+ #: Named-key aliases resolved to one canonical name.
39
+ KEY_ALIASES: Final[dict[str, str]] = {
40
+ "return": "enter",
41
+ "escape": "esc",
42
+ "del": "delete",
43
+ "ins": "insert",
44
+ "pgup": "pageup",
45
+ "page_up": "pageup",
46
+ "pgdn": "pagedown",
47
+ "pagedn": "pagedown",
48
+ "page_down": "pagedown",
49
+ "spacebar": "space",
50
+ "capslock": "caps_lock",
51
+ "printscreen": "print_screen",
52
+ }
53
+
54
+ _NAMED_KEYS: Final[tuple[str, ...]] = (
55
+ "enter",
56
+ "tab",
57
+ "esc",
58
+ "space",
59
+ "backspace",
60
+ "delete",
61
+ "insert",
62
+ "home",
63
+ "end",
64
+ "pageup",
65
+ "pagedown",
66
+ "up",
67
+ "down",
68
+ "left",
69
+ "right",
70
+ "caps_lock",
71
+ "print_screen",
72
+ *(f"f{n}" for n in range(1, 25)),
73
+ )
74
+
75
+ #: Every canonical named key. Single characters are keys too, but are not enumerated.
76
+ NAMED_KEYS: Final[frozenset[str]] = frozenset(_NAMED_KEYS)
77
+
78
+
79
+ class KeyCombo(BaseModel):
80
+ """A parsed key combination, in canonical names."""
81
+
82
+ model_config = ConfigDict(frozen=True)
83
+
84
+ modifiers: tuple[str, ...] = ()
85
+ key: str
86
+
87
+ def __str__(self) -> str:
88
+ return "+".join((*self.modifiers, self.key))
89
+
90
+
91
+ def _suggest(name: str) -> str:
92
+ pool = [*MODIFIER_ALIASES, *NAMED_KEYS, *KEY_ALIASES]
93
+ close = difflib.get_close_matches(name, pool, n=3, cutoff=0.5)
94
+ if not close:
95
+ return ""
96
+ return " Did you mean: " + ", ".join(close) + "?"
97
+
98
+
99
+ def parse_combo(spec: str) -> KeyCombo:
100
+ """Parse ``ctrl+shift+t`` into a :class:`KeyCombo` of canonical names.
101
+
102
+ Raises:
103
+ KeySyntaxError: on an empty spec, a repeated modifier, more than one non-modifier key,
104
+ or an unknown key name. An unknown name is never passed through to the backend to
105
+ fail obscurely there.
106
+ """
107
+ raw = spec.strip()
108
+ if not raw:
109
+ raise KeySyntaxError("empty key combination")
110
+ if raw == "+":
111
+ return KeyCombo(key="+")
112
+ # "ctrl++" means ctrl plus the literal "+" key; the split leaves an empty tail behind.
113
+ body = raw[:-1] if raw.endswith("+") else raw
114
+ parts = [p.strip().lower() for p in body.split("+")]
115
+ if raw.endswith("+"):
116
+ if parts[-1] != "":
117
+ # A trailing separator with nothing after it, e.g. "ctrl+".
118
+ raise KeySyntaxError(f"malformed key combination: {spec!r}")
119
+ parts[-1] = "+"
120
+ if any(not p for p in parts):
121
+ raise KeySyntaxError(f"malformed key combination: {spec!r}")
122
+
123
+ modifiers: list[str] = []
124
+ key: str | None = None
125
+ for part in parts:
126
+ if part in MODIFIER_ALIASES:
127
+ modifier = MODIFIER_ALIASES[part]
128
+ if modifier in modifiers:
129
+ raise KeySyntaxError(f"repeated modifier {modifier!r} in {spec!r}")
130
+ modifiers.append(modifier)
131
+ continue
132
+ if key is not None:
133
+ raise KeySyntaxError(
134
+ f"more than one non-modifier key in {spec!r}: {key!r} and {part!r}"
135
+ )
136
+ key = KEY_ALIASES.get(part, part)
137
+ if len(key) != 1 and key not in NAMED_KEYS:
138
+ raise KeySyntaxError(f"unknown key name {part!r} in {spec!r}.{_suggest(part)}")
139
+
140
+ if key is None:
141
+ # A lone modifier is a key in its own right: `use-computer key shift`.
142
+ if len(modifiers) == 1:
143
+ return KeyCombo(key=modifiers[0])
144
+ raise KeySyntaxError(f"{spec!r} is only modifiers -- no key to press")
145
+
146
+ ordered = tuple(m for m in MODIFIER_ORDER if m in modifiers)
147
+ return KeyCombo(modifiers=ordered, key=key)
148
+
149
+
150
+ def canonical(spec: str) -> str:
151
+ """Return the canonical spelling of ``spec``, so a log line is reproducible input."""
152
+ return str(parse_combo(spec))
153
+
154
+
155
+ # --- Per-backend tables ----------------------------------------------------------------------
156
+ # Both keyed by the same canonical names. Values are the *names* the backend's library uses;
157
+ # resolving a name to a symbol happens in the backend, so this module imports nothing optional.
158
+
159
+ #: canonical name -> attribute of ``pynput.keyboard.Key``.
160
+ PYNPUT_KEYS: Final[dict[str, str]] = {
161
+ "ctrl": "ctrl",
162
+ "alt": "alt",
163
+ "shift": "shift",
164
+ "cmd": "cmd",
165
+ "enter": "enter",
166
+ "tab": "tab",
167
+ "esc": "esc",
168
+ "space": "space",
169
+ "backspace": "backspace",
170
+ "delete": "delete",
171
+ "insert": "insert",
172
+ "home": "home",
173
+ "end": "end",
174
+ "pageup": "page_up",
175
+ "pagedown": "page_down",
176
+ "up": "up",
177
+ "down": "down",
178
+ "left": "left",
179
+ "right": "right",
180
+ "caps_lock": "caps_lock",
181
+ "print_screen": "print_screen",
182
+ **{f"f{n}": f"f{n}" for n in range(1, 21)},
183
+ }
184
+
185
+ #: canonical name -> X11 keysym name understood by vncdotool.
186
+ VNC_KEYS: Final[dict[str, str]] = {
187
+ "ctrl": "ctrl",
188
+ "alt": "alt",
189
+ "shift": "shift",
190
+ "cmd": "super",
191
+ "enter": "return",
192
+ "tab": "tab",
193
+ "esc": "esc",
194
+ "space": "space",
195
+ "backspace": "bsp",
196
+ "delete": "delete",
197
+ "insert": "insert",
198
+ "home": "home",
199
+ "end": "end",
200
+ "pageup": "pgup",
201
+ "pagedown": "pgdn",
202
+ "up": "up",
203
+ "down": "down",
204
+ "left": "left",
205
+ "right": "right",
206
+ "caps_lock": "caps_lock",
207
+ "print_screen": "print",
208
+ **{f"f{n}": f"f{n}" for n in range(1, 21)},
209
+ }
use_computer/runner.py ADDED
@@ -0,0 +1,304 @@
1
+ """Executing a batch: one backend, many actions, one connection.
2
+
3
+ Opening a VNC connection dominates the cost of a single action, so the backend is constructed
4
+ once and every action runs against it. A single action invoked directly is simply a batch of
5
+ one and returns the same shape.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Sequence
12
+ from types import TracebackType
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from use_computer.actions import (
18
+ Action,
19
+ DoubleClickAction,
20
+ DragAction,
21
+ KeyAction,
22
+ MouseButton,
23
+ MoveAction,
24
+ RightClickAction,
25
+ ScreenshotAction,
26
+ ScrollAction,
27
+ TypeAction,
28
+ resolve,
29
+ with_default_space,
30
+ )
31
+ from use_computer.backends import Backend, create_backend
32
+ from use_computer.compare import ChangeReport, Screenshot, compare
33
+ from use_computer.config import BackendProfile, ResolvedConfig, Settings
34
+ from use_computer.config import load as load_config
35
+ from use_computer.coordinates import Coordinate, ScreenInfo
36
+ from use_computer.errors import UseComputerError
37
+
38
+
39
+ class ErrorInfo(BaseModel):
40
+ """A failure, as the calling agent sees it."""
41
+
42
+ model_config = ConfigDict(frozen=True)
43
+
44
+ type: str
45
+ message: str
46
+
47
+ @classmethod
48
+ def of(cls, exc: BaseException) -> ErrorInfo:
49
+ return cls(type=type(exc).__name__, message=str(exc))
50
+
51
+
52
+ class ActionResult(BaseModel):
53
+ """What one action did. Frozen: once it has run, what it did does not change."""
54
+
55
+ model_config = ConfigDict(frozen=True)
56
+
57
+ action: Action
58
+ resolved: Coordinate | None = Field(
59
+ default=None, description="The target, in actuation units."
60
+ )
61
+ resolved_from: Coordinate | None = Field(
62
+ default=None, description="A drag's origin, in actuation units."
63
+ )
64
+ performed: bool
65
+ duration_ms: float
66
+ change: ChangeReport | None = None
67
+ screenshot: Screenshot | None = None
68
+ error: ErrorInfo | None = None
69
+
70
+ @property
71
+ def ok(self) -> bool:
72
+ return self.error is None
73
+
74
+
75
+ class RunResult(BaseModel):
76
+ """One invocation. This is what the CLI serialises to stdout."""
77
+
78
+ model_config = ConfigDict(frozen=True)
79
+
80
+ profile: str
81
+ backend: str
82
+ screen: ScreenInfo
83
+ ok: bool
84
+ failed_index: int | None = None
85
+ results: list[ActionResult] = Field(default_factory=list)
86
+
87
+
88
+ class Session:
89
+ """A backend held open across a batch of actions."""
90
+
91
+ def __init__(
92
+ self,
93
+ backend: Backend,
94
+ *,
95
+ profile: str,
96
+ settings: Settings | None = None,
97
+ ) -> None:
98
+ self._backend = backend
99
+ self._profile = profile
100
+ self._settings = settings or Settings()
101
+ self._screen = backend.screen_info()
102
+
103
+ @classmethod
104
+ def from_profile(
105
+ cls, name: str | None = None, *, config: ResolvedConfig | None = None
106
+ ) -> Session:
107
+ """Open a session from a named profile in the project config."""
108
+ resolved = config or load_config(profile=name)
109
+ profile: BackendProfile = resolved.profile
110
+ return cls(
111
+ create_backend(profile), profile=profile.name, settings=resolved.settings
112
+ )
113
+
114
+ # --- context manager ---------------------------------------------------------------------
115
+
116
+ def __enter__(self) -> Session:
117
+ return self
118
+
119
+ def __exit__(
120
+ self,
121
+ exc_type: type[BaseException] | None,
122
+ exc: BaseException | None,
123
+ tb: TracebackType | None,
124
+ ) -> None:
125
+ self.close()
126
+
127
+ def close(self) -> None:
128
+ self._backend.close()
129
+
130
+ # --- running -----------------------------------------------------------------------------
131
+
132
+ @property
133
+ def screen(self) -> ScreenInfo:
134
+ return self._screen
135
+
136
+ def run(self, actions: Sequence[Action]) -> RunResult:
137
+ """Perform every action in order against the open backend.
138
+
139
+ Stops at the first failure unless ``continue_on_error`` is set: a later action usually
140
+ depends on the state the failed one was meant to produce.
141
+ """
142
+ settings = self._settings
143
+ results: list[ActionResult] = []
144
+ failed_index: int | None = None
145
+ carried: Screenshot | None = None
146
+
147
+ for index, raw in enumerate(actions):
148
+ action = with_default_space(raw, settings.space)
149
+ verify = action.verify or settings.verify
150
+ before = carried if verify else None
151
+ if verify and before is None:
152
+ before = self._safe_screenshot()
153
+
154
+ result = self._run_one(action, before=before, verify=verify)
155
+ results.append(result)
156
+ carried = result.screenshot if verify else None
157
+
158
+ if not result.ok:
159
+ failed_index = index
160
+ if not settings.continue_on_error:
161
+ break
162
+
163
+ return RunResult(
164
+ profile=self._profile,
165
+ backend=self._backend.name,
166
+ screen=self._screen,
167
+ ok=failed_index is None,
168
+ failed_index=failed_index,
169
+ results=results,
170
+ )
171
+
172
+ def _run_one(
173
+ self, action: Action, *, before: Screenshot | None, verify: bool
174
+ ) -> ActionResult:
175
+ settings = self._settings
176
+ started = time.perf_counter()
177
+ target: Coordinate | None = None
178
+ origin: Coordinate | None = None
179
+ screenshot: Screenshot | None = None
180
+ change: ChangeReport | None = None
181
+ performed = False
182
+ error: ErrorInfo | None = None
183
+
184
+ try:
185
+ target, origin = resolve(action, self._screen)
186
+ if settings.dry_run:
187
+ # Everything above ran: the profile, the scaling, the key parsing. Only the
188
+ # actuation is skipped.
189
+ pass
190
+ else:
191
+ screenshot = self._perform(action, target, origin)
192
+ performed = True
193
+
194
+ delay = action.delay if action.delay is not None else settings.delay
195
+ if performed and delay:
196
+ time.sleep(delay)
197
+
198
+ if verify and performed:
199
+ after = self._safe_screenshot()
200
+ if before is not None and after is not None:
201
+ change = compare(before, after, settings.verify_threshold)
202
+ is_capture = isinstance(action, ScreenshotAction)
203
+ screenshot = (screenshot or after) if is_capture else after
204
+ except UseComputerError as exc:
205
+ error = ErrorInfo.of(exc)
206
+ except Exception as exc: # a backend can fail in its own vocabulary
207
+ error = ErrorInfo.of(exc)
208
+
209
+ return ActionResult(
210
+ action=action,
211
+ resolved=target,
212
+ resolved_from=origin,
213
+ performed=performed,
214
+ duration_ms=round((time.perf_counter() - started) * 1000, 3),
215
+ change=change,
216
+ screenshot=screenshot,
217
+ error=error,
218
+ )
219
+
220
+ def _perform(
221
+ self, action: Action, target: Coordinate | None, origin: Coordinate | None
222
+ ) -> Screenshot | None:
223
+ backend = self._backend
224
+ x, y = (target.x, target.y) if target else (None, None)
225
+
226
+ if isinstance(action, MoveAction):
227
+ assert x is not None and y is not None
228
+ backend.move(x, y)
229
+ elif isinstance(action, DoubleClickAction):
230
+ backend.click(x, y, action.button, 2)
231
+ elif isinstance(action, RightClickAction):
232
+ backend.click(x, y, MouseButton.RIGHT, 1)
233
+ elif isinstance(action, DragAction):
234
+ assert origin is not None and target is not None
235
+ backend.drag(origin.x, origin.y, target.x, target.y, action.button)
236
+ elif isinstance(action, ScrollAction):
237
+ backend.scroll(action.amount, action.direction, x, y)
238
+ elif isinstance(action, TypeAction):
239
+ rate = action.rate if action.rate is not None else self._settings.typing_rate
240
+ backend.type_text(action.text, rate)
241
+ elif isinstance(action, KeyAction):
242
+ backend.key(action.key_combo)
243
+ elif isinstance(action, ScreenshotAction):
244
+ return self._capture(action)
245
+ else: # ClickAction, and anything else positional with a button
246
+ backend.click(x, y, getattr(action, "button", MouseButton.LEFT), 1)
247
+ return None
248
+
249
+ def _capture(self, action: ScreenshotAction) -> Screenshot:
250
+ shot = self._backend.screenshot()
251
+ if action.out is not None:
252
+ action.out.parent.mkdir(parents=True, exist_ok=True)
253
+ if shot.data is not None:
254
+ action.out.write_bytes(shot.data)
255
+ shot = shot.model_copy(update={"path": action.out})
256
+ if not action.base64:
257
+ shot = shot.model_copy(update={"data": None if action.out else shot.data})
258
+ return shot
259
+
260
+ def _safe_screenshot(self) -> Screenshot | None:
261
+ """Change detection is advisory; a capture that fails must not fail the action."""
262
+ try:
263
+ return self._backend.screenshot()
264
+ except Exception:
265
+ return None
266
+
267
+
268
+ def run_actions(
269
+ actions: Sequence[Action],
270
+ *,
271
+ profile: str | None = None,
272
+ config: ResolvedConfig | None = None,
273
+ ) -> RunResult:
274
+ """Open a session, run a batch, close it -- including when an action failed."""
275
+ session = Session.from_profile(profile, config=config)
276
+ try:
277
+ return session.run(actions)
278
+ finally:
279
+ session.close()
280
+
281
+
282
+ def as_json(result: RunResult) -> dict[str, Any]:
283
+ """The run payload, with screenshot bytes rendered as base64 where they were requested."""
284
+ payload = result.model_dump(mode="json", exclude={"results": {"__all__": {"screenshot"}}})
285
+ payload["results"] = [
286
+ {**item, "screenshot": _screenshot_json(action.screenshot)}
287
+ for item, action in zip(payload["results"], result.results, strict=True)
288
+ ]
289
+ return payload
290
+
291
+
292
+ def _screenshot_json(shot: Screenshot | None) -> dict[str, Any] | None:
293
+ if shot is None:
294
+ return None
295
+ payload: dict[str, Any] = {
296
+ "path": str(shot.path) if shot.path else None,
297
+ "width": shot.width,
298
+ "height": shot.height,
299
+ "space": shot.space.value,
300
+ "captured_at": shot.captured_at.isoformat(),
301
+ }
302
+ if shot.data is not None:
303
+ payload["base64"] = shot.base64()
304
+ return payload