cua-computer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cua_computer-0.1.0/PKG-INFO +12 -0
- cua_computer-0.1.0/README.md +66 -0
- cua_computer-0.1.0/computer/__init__.py +7 -0
- cua_computer-0.1.0/computer/computer.py +486 -0
- cua_computer-0.1.0/computer/interface/__init__.py +13 -0
- cua_computer-0.1.0/computer/interface/base.py +190 -0
- cua_computer-0.1.0/computer/interface/factory.py +32 -0
- cua_computer-0.1.0/computer/interface/linux.py +27 -0
- cua_computer-0.1.0/computer/interface/macos.py +546 -0
- cua_computer-0.1.0/computer/interface/models.py +97 -0
- cua_computer-0.1.0/computer/logger.py +84 -0
- cua_computer-0.1.0/computer/models.py +35 -0
- cua_computer-0.1.0/computer/utils.py +101 -0
- cua_computer-0.1.0/pyproject.toml +71 -0
- cua_computer-0.1.0/tests/test_computer.py +18 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: cua-computer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Computer-Use Interface (CUI) framework powering Cua
|
|
5
|
+
Author-Email: TryCua <gh@trycua.com>
|
|
6
|
+
Requires-Python: <3.13,>=3.10
|
|
7
|
+
Requires-Dist: pylume>=0.1.8
|
|
8
|
+
Requires-Dist: pillow>=10.0.0
|
|
9
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
10
|
+
Requires-Dist: websockets>=12.0
|
|
11
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
12
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>
|
|
3
|
+
<div class="image-wrapper" style="display: inline-block;">
|
|
4
|
+
<picture>
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" alt="logo" height="150" srcset="../../img/logo_white.png" style="display: block; margin: auto;">
|
|
6
|
+
<source media="(prefers-color-scheme: light)" alt="logo" height="150" srcset="../../img/logo_black.png" style="display: block; margin: auto;">
|
|
7
|
+
<img alt="Shows my svg">
|
|
8
|
+
</picture>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
[](#)
|
|
12
|
+
[](#)
|
|
13
|
+
[](https://discord.com/invite/mVnXXpdE85)
|
|
14
|
+
[](https://pypi.org/project/cua-computer/)
|
|
15
|
+
</h1>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
**Computer** is a Computer-Use Interface (CUI) framework powering Cua for interacting with local macOS and Linux sandboxes, PyAutoGUI-compatible, and pluggable with any AI agent systems (Cua, Langchain, CrewAI, AutoGen). Computer relies on [Lume](https://github.com/trycua/lume) for creating and managing sandbox environments.
|
|
19
|
+
|
|
20
|
+
### Get started with Computer
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
<img src="../../img/computer.png"/>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from computer import Computer
|
|
28
|
+
|
|
29
|
+
computer = Computer(os="macos", display="1024x768", memory="8GB", cpu="4")
|
|
30
|
+
try:
|
|
31
|
+
await computer.run()
|
|
32
|
+
|
|
33
|
+
screenshot = await computer.interface.screenshot()
|
|
34
|
+
with open("screenshot.png", "wb") as f:
|
|
35
|
+
f.write(screenshot)
|
|
36
|
+
|
|
37
|
+
await computer.interface.move_cursor(100, 100)
|
|
38
|
+
await computer.interface.left_click()
|
|
39
|
+
await computer.interface.right_click(300, 300)
|
|
40
|
+
await computer.interface.double_click(400, 400)
|
|
41
|
+
|
|
42
|
+
await computer.interface.type("Hello, World!")
|
|
43
|
+
await computer.interface.press_key("enter")
|
|
44
|
+
|
|
45
|
+
await computer.interface.set_clipboard("Test clipboard")
|
|
46
|
+
content = await computer.interface.copy_to_clipboard()
|
|
47
|
+
print(f"Clipboard content: {content}")
|
|
48
|
+
finally:
|
|
49
|
+
await computer.stop()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
To install the Computer-Use Interface (CUI):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install cua-computer
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The `cua-computer` PyPi package pulls automatically the latest package version of Lume through [pylume](https://github.com/trycua/pylume).
|
|
61
|
+
|
|
62
|
+
## Run
|
|
63
|
+
|
|
64
|
+
Refer to this notebook for a step-by-step guide on how to use the Computer-Use Interface (CUI):
|
|
65
|
+
|
|
66
|
+
- [Computer-Use Interface (CUI)](../../notebooks/computer_nb.ipynb)
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
from typing import Optional, List, Literal, Dict, Any, Union
|
|
2
|
+
from pylume import PyLume, VMRunOpts, SharedDirectory
|
|
3
|
+
import asyncio
|
|
4
|
+
from .models import Computer as ComputerConfig, Image, Display
|
|
5
|
+
from .interface.factory import InterfaceFactory
|
|
6
|
+
from pylume import ImageRef, VMUpdateOpts, PyLume
|
|
7
|
+
import time
|
|
8
|
+
from PIL import Image
|
|
9
|
+
import io
|
|
10
|
+
from .utils import bytes_to_image
|
|
11
|
+
import re
|
|
12
|
+
from .logger import Logger, LogLevel
|
|
13
|
+
import json
|
|
14
|
+
from pylume.models import VMRunOpts, VMUpdateOpts, ImageRef, SharedDirectory, VMStatus
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
OSType = Literal["macos", "linux"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Computer:
|
|
21
|
+
"""Main computer interface."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
display: Union[Display, Dict[str, int], str] = "1024x768",
|
|
26
|
+
memory: str = "8GB",
|
|
27
|
+
cpu: str = "4",
|
|
28
|
+
os: OSType = "macos",
|
|
29
|
+
name: str = "",
|
|
30
|
+
image: str = "macos-sequoia-cua:latest",
|
|
31
|
+
shared_directories: Optional[List[str]] = None,
|
|
32
|
+
use_host_computer_server: bool = False,
|
|
33
|
+
verbosity: Union[int, LogLevel] = logging.INFO,
|
|
34
|
+
):
|
|
35
|
+
"""Initialize a new Computer instance.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
display: The display configuration. Can be:
|
|
39
|
+
- A Display object
|
|
40
|
+
- A dict with 'width' and 'height'
|
|
41
|
+
- A string in format "WIDTHxHEIGHT" (e.g. "1920x1080")
|
|
42
|
+
Defaults to "1024x768"
|
|
43
|
+
memory: The VM memory allocation. Defaults to "8GB"
|
|
44
|
+
cpu: The VM CPU allocation. Defaults to "4"
|
|
45
|
+
os: The operating system type ('macos' or 'linux')
|
|
46
|
+
name: The VM name
|
|
47
|
+
image: The VM image name
|
|
48
|
+
shared_directories: Optional list of directory paths to share with the VM
|
|
49
|
+
use_host_computer_server: If True, target localhost instead of starting a VM
|
|
50
|
+
verbosity: Logging level (standard Python logging levels: logging.DEBUG, logging.INFO, etc.)
|
|
51
|
+
LogLevel enum values are still accepted for backward compatibility
|
|
52
|
+
"""
|
|
53
|
+
self.logger = Logger("cua.computer", verbosity)
|
|
54
|
+
self.logger.info("Initializing Computer...")
|
|
55
|
+
|
|
56
|
+
# Store original parameters
|
|
57
|
+
self.image = image
|
|
58
|
+
|
|
59
|
+
# Set initialization flag
|
|
60
|
+
self._initialized = False
|
|
61
|
+
self._running = False
|
|
62
|
+
|
|
63
|
+
# Configure root logger
|
|
64
|
+
self.verbosity = verbosity
|
|
65
|
+
self.logger = Logger("cua", verbosity)
|
|
66
|
+
|
|
67
|
+
# Configure component loggers with proper hierarchy
|
|
68
|
+
self.vm_logger = Logger("cua.vm", verbosity)
|
|
69
|
+
self.interface_logger = Logger("cua.interface", verbosity)
|
|
70
|
+
|
|
71
|
+
if not use_host_computer_server:
|
|
72
|
+
if ":" not in image or len(image.split(":")) != 2:
|
|
73
|
+
raise ValueError("Image must be in the format <image_name>:<tag>")
|
|
74
|
+
|
|
75
|
+
if not name:
|
|
76
|
+
# Normalize the name to be used for the VM
|
|
77
|
+
name = image.replace(":", "_")
|
|
78
|
+
|
|
79
|
+
# Convert display parameter to Display object
|
|
80
|
+
if isinstance(display, str):
|
|
81
|
+
# Parse string format "WIDTHxHEIGHT"
|
|
82
|
+
match = re.match(r"(\d+)x(\d+)", display)
|
|
83
|
+
if not match:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"Display string must be in format 'WIDTHxHEIGHT' (e.g. '1024x768')"
|
|
86
|
+
)
|
|
87
|
+
width, height = map(int, match.groups())
|
|
88
|
+
display_config = Display(width=width, height=height)
|
|
89
|
+
elif isinstance(display, dict):
|
|
90
|
+
display_config = Display(**display)
|
|
91
|
+
else:
|
|
92
|
+
display_config = display
|
|
93
|
+
|
|
94
|
+
self.config = ComputerConfig(
|
|
95
|
+
image=image.split(":")[0],
|
|
96
|
+
tag=image.split(":")[1],
|
|
97
|
+
name=name,
|
|
98
|
+
display=display_config,
|
|
99
|
+
memory=memory,
|
|
100
|
+
cpu=cpu,
|
|
101
|
+
)
|
|
102
|
+
# Initialize PyLume but don't start the server yet - we'll do that in run()
|
|
103
|
+
self.config.pylume = PyLume(
|
|
104
|
+
debug=(self.verbosity == LogLevel.DEBUG),
|
|
105
|
+
port=3000,
|
|
106
|
+
use_existing_server=False,
|
|
107
|
+
server_start_timeout=120, # Increase timeout to 2 minutes
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self._interface = None
|
|
111
|
+
self.os = os
|
|
112
|
+
self.shared_paths = []
|
|
113
|
+
if shared_directories:
|
|
114
|
+
for path in shared_directories:
|
|
115
|
+
abs_path = os.path.abspath(os.path.expanduser(path)) # type: ignore[attr-defined]
|
|
116
|
+
if not os.path.exists(abs_path): # type: ignore[attr-defined]
|
|
117
|
+
raise ValueError(f"Shared directory does not exist: {path}")
|
|
118
|
+
self.shared_paths.append(abs_path)
|
|
119
|
+
self._pylume_context = None
|
|
120
|
+
self.use_host_computer_server = use_host_computer_server
|
|
121
|
+
|
|
122
|
+
async def __aenter__(self):
|
|
123
|
+
"""Enter async context manager."""
|
|
124
|
+
await self.run()
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
128
|
+
"""Exit async context manager."""
|
|
129
|
+
# await self.stop()
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
async def run(self) -> None:
|
|
133
|
+
"""Initialize the VM and computer interface."""
|
|
134
|
+
# If already initialized, just log and return
|
|
135
|
+
if hasattr(self, "_initialized") and self._initialized:
|
|
136
|
+
self.logger.info("Computer already initialized, skipping initialization")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
self.logger.info("Starting computer...")
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# If using host computer server
|
|
143
|
+
if self.use_host_computer_server:
|
|
144
|
+
self.logger.info("Using host computer server")
|
|
145
|
+
# Set ip_address for host computer server mode
|
|
146
|
+
ip_address = "localhost"
|
|
147
|
+
# Create the interface
|
|
148
|
+
self._interface = InterfaceFactory.create_interface_for_os(
|
|
149
|
+
os=self.os, ip_address=ip_address # type: ignore[arg-type]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
self.logger.info("Waiting for host computer server to be ready...")
|
|
153
|
+
await self._interface.wait_for_ready()
|
|
154
|
+
self.logger.info("Host computer server ready")
|
|
155
|
+
else:
|
|
156
|
+
# Start or connect to VM
|
|
157
|
+
self.logger.info(f"Starting VM: {self.image}")
|
|
158
|
+
if not self._pylume_context:
|
|
159
|
+
try:
|
|
160
|
+
self.logger.verbose("Initializing PyLume context...")
|
|
161
|
+
self._pylume_context = await self.config.pylume.__aenter__() # type: ignore[attr-defined]
|
|
162
|
+
self.logger.verbose("PyLume context initialized successfully")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self.logger.error(f"Failed to initialize PyLume context: {e}")
|
|
165
|
+
raise RuntimeError(f"Failed to initialize PyLume: {e}")
|
|
166
|
+
|
|
167
|
+
# Try to get the VM, if it doesn't exist, create it and pull the image
|
|
168
|
+
try:
|
|
169
|
+
vm = await self.config.pylume.get_vm(self.config.name) # type: ignore[attr-defined]
|
|
170
|
+
self.logger.verbose(f"Found existing VM: {self.config.name}")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
self.logger.verbose(f"VM not found, pulling image: {e}")
|
|
173
|
+
image_ref = ImageRef(
|
|
174
|
+
image=self.config.image,
|
|
175
|
+
tag=self.config.tag,
|
|
176
|
+
registry="ghcr.io",
|
|
177
|
+
organization="trycua",
|
|
178
|
+
)
|
|
179
|
+
self.logger.info(f"Pulling image {self.config.image}:{self.config.tag}...")
|
|
180
|
+
try:
|
|
181
|
+
await self.config.pylume.pull_image(image_ref, name=self.config.name) # type: ignore[attr-defined]
|
|
182
|
+
except Exception as pull_error:
|
|
183
|
+
self.logger.error(f"Failed to pull image: {pull_error}")
|
|
184
|
+
raise RuntimeError(f"Failed to pull VM image: {pull_error}")
|
|
185
|
+
|
|
186
|
+
# Convert paths to SharedDirectory objects
|
|
187
|
+
shared_directories = []
|
|
188
|
+
for path in self.shared_paths:
|
|
189
|
+
self.logger.verbose(f"Adding shared directory: {path}")
|
|
190
|
+
shared_directories.append(
|
|
191
|
+
SharedDirectory(host_path=path) # type: ignore[arg-type]
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Run with shared directories
|
|
195
|
+
self.logger.info(f"Starting VM {self.config.name}...")
|
|
196
|
+
run_opts = VMRunOpts(
|
|
197
|
+
no_display=False, # type: ignore[arg-type]
|
|
198
|
+
shared_directories=shared_directories, # type: ignore[arg-type]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Log the run options for debugging
|
|
202
|
+
self.logger.info(f"VM run options: {vars(run_opts)}")
|
|
203
|
+
|
|
204
|
+
# Log the equivalent curl command for debugging
|
|
205
|
+
payload = json.dumps({"noDisplay": False, "sharedDirectories": []})
|
|
206
|
+
curl_cmd = f"curl -X POST 'http://localhost:3000/lume/vms/{self.config.name}/run' -H 'Content-Type: application/json' -d '{payload}'"
|
|
207
|
+
print(f"\nEquivalent curl command:\n{curl_cmd}\n")
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
response = await self.config.pylume.run_vm(self.config.name, run_opts) # type: ignore[attr-defined]
|
|
211
|
+
self.logger.info(f"VM run response: {response if response else 'None'}")
|
|
212
|
+
except Exception as run_error:
|
|
213
|
+
self.logger.error(f"Failed to run VM: {run_error}")
|
|
214
|
+
raise RuntimeError(f"Failed to start VM: {run_error}")
|
|
215
|
+
|
|
216
|
+
# Wait for VM to be ready with required properties
|
|
217
|
+
self.logger.info("Waiting for VM to be ready...")
|
|
218
|
+
try:
|
|
219
|
+
vm = await self.wait_vm_ready()
|
|
220
|
+
if not vm or not vm.ip_address: # type: ignore[attr-defined]
|
|
221
|
+
raise RuntimeError(f"VM {self.config.name} failed to get IP address")
|
|
222
|
+
ip_address = vm.ip_address # type: ignore[attr-defined]
|
|
223
|
+
self.logger.info(f"VM is ready with IP: {ip_address}")
|
|
224
|
+
except Exception as wait_error:
|
|
225
|
+
self.logger.error(f"Error waiting for VM: {wait_error}")
|
|
226
|
+
raise RuntimeError(f"VM failed to become ready: {wait_error}")
|
|
227
|
+
except Exception as e:
|
|
228
|
+
self.logger.error(f"Failed to initialize computer: {e}")
|
|
229
|
+
raise RuntimeError(f"Failed to initialize computer: {e}")
|
|
230
|
+
|
|
231
|
+
# Initialize the interface using the factory with the specified OS
|
|
232
|
+
self.logger.info(f"Initializing interface for {self.os} at {ip_address}")
|
|
233
|
+
self._interface = InterfaceFactory.create_interface_for_os(
|
|
234
|
+
os=self.os, ip_address=ip_address # type: ignore[arg-type]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Wait for the WebSocket interface to be ready
|
|
238
|
+
self.logger.info("Connecting to WebSocket interface...")
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
# Use a single timeout for the entire connection process
|
|
242
|
+
await self._interface.wait_for_ready(timeout=60)
|
|
243
|
+
self.logger.info("WebSocket interface connected successfully")
|
|
244
|
+
except TimeoutError as e:
|
|
245
|
+
self.logger.error("Failed to connect to WebSocket interface")
|
|
246
|
+
raise TimeoutError(
|
|
247
|
+
f"Could not connect to WebSocket interface at {ip_address}:8000/ws: {str(e)}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Create an event to keep the VM running in background if needed
|
|
251
|
+
if not self.use_host_computer_server:
|
|
252
|
+
self._stop_event = asyncio.Event()
|
|
253
|
+
self._keep_alive_task = asyncio.create_task(self._stop_event.wait())
|
|
254
|
+
|
|
255
|
+
self.logger.info("Computer is ready")
|
|
256
|
+
|
|
257
|
+
# Set the initialization flag and clear the initializing flag
|
|
258
|
+
self._initialized = True
|
|
259
|
+
self.logger.info("Computer successfully initialized")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
async def stop(self) -> None:
|
|
263
|
+
"""Stop computer control."""
|
|
264
|
+
if self._running:
|
|
265
|
+
self._running = False
|
|
266
|
+
self.logger.info("Stopping Computer...")
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
if hasattr(self, "_stop_event"):
|
|
270
|
+
self._stop_event.set()
|
|
271
|
+
if hasattr(self, "_keep_alive_task"):
|
|
272
|
+
await self._keep_alive_task
|
|
273
|
+
|
|
274
|
+
if self._interface: # Only try to close interface if it exists
|
|
275
|
+
self.logger.verbose("Closing interface...")
|
|
276
|
+
# For host computer server, just use normal close to keep the server running
|
|
277
|
+
if self.use_host_computer_server:
|
|
278
|
+
self._interface.close()
|
|
279
|
+
else:
|
|
280
|
+
# For VM mode, force close the connection
|
|
281
|
+
if hasattr(self._interface, "force_close"):
|
|
282
|
+
self._interface.force_close()
|
|
283
|
+
else:
|
|
284
|
+
self._interface.close()
|
|
285
|
+
|
|
286
|
+
if not self.use_host_computer_server and self._pylume_context:
|
|
287
|
+
try:
|
|
288
|
+
self.logger.info(f"Stopping VM {self.config.name}...")
|
|
289
|
+
await self.config.pylume.stop_vm(self.config.name) # type: ignore[attr-defined]
|
|
290
|
+
except Exception as e:
|
|
291
|
+
self.logger.verbose(f"Error stopping VM: {e}") # VM might already be stopped
|
|
292
|
+
self.logger.verbose("Closing PyLume context...")
|
|
293
|
+
await self.config.pylume.__aexit__(None, None, None) # type: ignore[attr-defined]
|
|
294
|
+
self._pylume_context = None
|
|
295
|
+
self.logger.info("Computer stopped")
|
|
296
|
+
except Exception as e:
|
|
297
|
+
self.logger.debug(
|
|
298
|
+
f"Error during cleanup: {e}"
|
|
299
|
+
) # Log as debug since this might be expected
|
|
300
|
+
|
|
301
|
+
# @property
|
|
302
|
+
async def get_ip(self) -> str:
|
|
303
|
+
"""Get the IP address of the VM or localhost if using host computer server."""
|
|
304
|
+
if self.use_host_computer_server:
|
|
305
|
+
return "127.0.0.1"
|
|
306
|
+
ip = await self.config.get_ip()
|
|
307
|
+
return ip or "unknown" # Return "unknown" if ip is None
|
|
308
|
+
|
|
309
|
+
async def wait_vm_ready(self) -> Optional[Union[Dict[str, Any], "VMStatus"]]:
|
|
310
|
+
"""Wait for VM to be ready with an IP address.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
VM status information or None if using host computer server.
|
|
314
|
+
"""
|
|
315
|
+
if self.use_host_computer_server:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
timeout = 600 # 10 minutes timeout (increased from 4 minutes)
|
|
319
|
+
interval = 2.0 # 2 seconds between checks (increased to reduce API load)
|
|
320
|
+
start_time = time.time()
|
|
321
|
+
last_status = None
|
|
322
|
+
attempts = 0
|
|
323
|
+
|
|
324
|
+
self.logger.info(f"Waiting for VM {self.config.name} to be ready (timeout: {timeout}s)...")
|
|
325
|
+
|
|
326
|
+
while time.time() - start_time < timeout:
|
|
327
|
+
attempts += 1
|
|
328
|
+
elapsed = time.time() - start_time
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
# Keep polling for VM info
|
|
332
|
+
vm = await self.config.pylume.get_vm(self.config.name) # type: ignore[attr-defined]
|
|
333
|
+
|
|
334
|
+
# Log full VM properties for debugging (every 30 attempts)
|
|
335
|
+
if attempts % 30 == 0:
|
|
336
|
+
self.logger.info(
|
|
337
|
+
f"VM properties at attempt {attempts}: {vars(vm) if vm else 'None'}"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Get current status for logging
|
|
341
|
+
current_status = getattr(vm, "status", None) if vm else None
|
|
342
|
+
if current_status != last_status:
|
|
343
|
+
self.logger.info(
|
|
344
|
+
f"VM status changed to: {current_status} (after {elapsed:.1f}s)"
|
|
345
|
+
)
|
|
346
|
+
last_status = current_status
|
|
347
|
+
|
|
348
|
+
# Check for IP address - ensure it's not None or empty
|
|
349
|
+
ip = getattr(vm, "ip_address", None) if vm else None
|
|
350
|
+
if ip and ip.strip(): # Check for non-empty string
|
|
351
|
+
self.logger.info(
|
|
352
|
+
f"VM {self.config.name} got IP address: {ip} (after {elapsed:.1f}s)"
|
|
353
|
+
)
|
|
354
|
+
return vm
|
|
355
|
+
|
|
356
|
+
if attempts % 10 == 0: # Log every 10 attempts to avoid flooding
|
|
357
|
+
self.logger.info(
|
|
358
|
+
f"Still waiting for VM IP address... (elapsed: {elapsed:.1f}s)"
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
self.logger.debug(
|
|
362
|
+
f"Waiting for VM IP address... Current IP: {ip}, Status: {current_status}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
except Exception as e:
|
|
366
|
+
self.logger.warning(f"Error checking VM status (attempt {attempts}): {str(e)}")
|
|
367
|
+
# If we've been trying for a while and still getting errors, log more details
|
|
368
|
+
if elapsed > 60: # After 1 minute of errors, log more details
|
|
369
|
+
self.logger.error(f"Persistent error getting VM status: {str(e)}")
|
|
370
|
+
self.logger.info("Trying to get VM list for debugging...")
|
|
371
|
+
try:
|
|
372
|
+
vms = await self.config.pylume.list_vms() # type: ignore[attr-defined]
|
|
373
|
+
self.logger.info(
|
|
374
|
+
f"Available VMs: {[vm.name for vm in vms if hasattr(vm, 'name')]}"
|
|
375
|
+
)
|
|
376
|
+
except Exception as list_error:
|
|
377
|
+
self.logger.error(f"Failed to list VMs: {str(list_error)}")
|
|
378
|
+
|
|
379
|
+
await asyncio.sleep(interval)
|
|
380
|
+
|
|
381
|
+
# If we get here, we've timed out
|
|
382
|
+
elapsed = time.time() - start_time
|
|
383
|
+
self.logger.error(f"VM {self.config.name} not ready after {elapsed:.1f} seconds")
|
|
384
|
+
|
|
385
|
+
# Try to get final VM status for debugging
|
|
386
|
+
try:
|
|
387
|
+
vm = await self.config.pylume.get_vm(self.config.name) # type: ignore[attr-defined]
|
|
388
|
+
status = getattr(vm, "status", "unknown") if vm else "unknown"
|
|
389
|
+
ip = getattr(vm, "ip_address", None) if vm else None
|
|
390
|
+
self.logger.error(f"Final VM status: {status}, IP: {ip}")
|
|
391
|
+
except Exception as e:
|
|
392
|
+
self.logger.error(f"Failed to get final VM status: {str(e)}")
|
|
393
|
+
|
|
394
|
+
raise TimeoutError(
|
|
395
|
+
f"VM {self.config.name} not ready after {elapsed:.1f} seconds - IP address not assigned"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
async def update(self, cpu: Optional[int] = None, memory: Optional[str] = None):
|
|
399
|
+
"""Update VM settings."""
|
|
400
|
+
self.logger.info(
|
|
401
|
+
f"Updating VM settings: CPU={cpu or self.config.cpu}, Memory={memory or self.config.memory}"
|
|
402
|
+
)
|
|
403
|
+
update_opts = VMUpdateOpts(
|
|
404
|
+
cpu=cpu or int(self.config.cpu), memory=memory or self.config.memory
|
|
405
|
+
)
|
|
406
|
+
await self.config.pylume.update_vm(self.config.image, update_opts) # type: ignore[attr-defined]
|
|
407
|
+
|
|
408
|
+
def get_screenshot_size(self, screenshot: bytes) -> Dict[str, int]:
|
|
409
|
+
"""Get the dimensions of a screenshot.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
screenshot: The screenshot bytes
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Dict[str, int]: Dictionary containing 'width' and 'height' of the image
|
|
416
|
+
"""
|
|
417
|
+
image = Image.open(io.BytesIO(screenshot))
|
|
418
|
+
width, height = image.size
|
|
419
|
+
return {"width": width, "height": height}
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def interface(self):
|
|
423
|
+
"""Get the computer interface for interacting with the VM.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
BaseComputerInterface: The interface for controlling the VM
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
RuntimeError: If the interface is not initialized (run() not called)
|
|
430
|
+
"""
|
|
431
|
+
if not hasattr(self, "_interface") or self._interface is None:
|
|
432
|
+
error_msg = "Computer interface not initialized. Call run() first."
|
|
433
|
+
self.logger.error(error_msg)
|
|
434
|
+
self.logger.error(
|
|
435
|
+
"Make sure to call await computer.run() before using any interface methods."
|
|
436
|
+
)
|
|
437
|
+
raise RuntimeError(error_msg)
|
|
438
|
+
return self._interface
|
|
439
|
+
|
|
440
|
+
def __getattr__(self, name: str):
|
|
441
|
+
"""Delegate all other method calls to the interface.
|
|
442
|
+
|
|
443
|
+
This is kept for backward compatibility, prefer using computer.interface directly.
|
|
444
|
+
"""
|
|
445
|
+
# First check if the interface is available
|
|
446
|
+
interface = self.interface # This will raise the proper exception if not initialized
|
|
447
|
+
return getattr(interface, name)
|
|
448
|
+
|
|
449
|
+
async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
|
|
450
|
+
"""Convert normalized coordinates to screen coordinates.
|
|
451
|
+
|
|
452
|
+
This is a convenience method that delegates to the interface.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
x: X coordinate between 0 and 1
|
|
456
|
+
y: Y coordinate between 0 and 1
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
tuple[float, float]: Screen coordinates (x, y)
|
|
460
|
+
"""
|
|
461
|
+
return await self.interface.to_screen_coordinates(x, y)
|
|
462
|
+
|
|
463
|
+
async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]:
|
|
464
|
+
"""Convert screen coordinates to screenshot coordinates.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
x: X coordinate in screen space
|
|
468
|
+
y: Y coordinate in screen space
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
tuple[float, float]: (x, y) coordinates in screenshot space
|
|
472
|
+
"""
|
|
473
|
+
screen_size = await self.interface.get_screen_size()
|
|
474
|
+
screenshot = await self.interface.screenshot()
|
|
475
|
+
screenshot_img = bytes_to_image(screenshot)
|
|
476
|
+
screenshot_width, screenshot_height = screenshot_img.size
|
|
477
|
+
|
|
478
|
+
# Calculate scaling factors
|
|
479
|
+
width_scale = screenshot_width / screen_size["width"]
|
|
480
|
+
height_scale = screenshot_height / screen_size["height"]
|
|
481
|
+
|
|
482
|
+
# Convert coordinates
|
|
483
|
+
screenshot_x = x * width_scale
|
|
484
|
+
screenshot_y = y * height_scale
|
|
485
|
+
|
|
486
|
+
return screenshot_x, screenshot_y
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interface package for Computer SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .factory import InterfaceFactory
|
|
6
|
+
from .base import BaseComputerInterface
|
|
7
|
+
from .macos import MacOSComputerInterface
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"InterfaceFactory",
|
|
11
|
+
"BaseComputerInterface",
|
|
12
|
+
"MacOSComputerInterface",
|
|
13
|
+
]
|