cua-computer 0.1.29__tar.gz → 0.2.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 (30) hide show
  1. {cua_computer-0.1.29 → cua_computer-0.2.1}/PKG-INFO +7 -2
  2. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/__init__.py +5 -1
  3. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/computer.py +268 -140
  4. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/interface/factory.py +4 -1
  5. cua_computer-0.2.1/computer/interface/linux.py +599 -0
  6. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/interface/macos.py +9 -1
  7. cua_computer-0.2.1/computer/models.py +47 -0
  8. cua_computer-0.2.1/computer/providers/__init__.py +4 -0
  9. cua_computer-0.2.1/computer/providers/base.py +105 -0
  10. cua_computer-0.2.1/computer/providers/cloud/__init__.py +5 -0
  11. cua_computer-0.2.1/computer/providers/cloud/provider.py +100 -0
  12. cua_computer-0.2.1/computer/providers/factory.py +118 -0
  13. cua_computer-0.2.1/computer/providers/lume/__init__.py +9 -0
  14. cua_computer-0.2.1/computer/providers/lume/provider.py +541 -0
  15. cua_computer-0.2.1/computer/providers/lume_api.py +559 -0
  16. cua_computer-0.2.1/computer/providers/lumier/__init__.py +8 -0
  17. cua_computer-0.2.1/computer/providers/lumier/provider.py +943 -0
  18. {cua_computer-0.1.29 → cua_computer-0.2.1}/pyproject.toml +10 -4
  19. cua_computer-0.1.29/computer/interface/linux.py +0 -27
  20. cua_computer-0.1.29/computer/models.py +0 -35
  21. {cua_computer-0.1.29 → cua_computer-0.2.1}/README.md +0 -0
  22. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/interface/__init__.py +0 -0
  23. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/interface/base.py +0 -0
  24. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/interface/models.py +0 -0
  25. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/logger.py +0 -0
  26. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/telemetry.py +0 -0
  27. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/ui/__init__.py +0 -0
  28. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/ui/gradio/__init__.py +0 -0
  29. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/ui/gradio/app.py +0 -0
  30. {cua_computer-0.1.29 → cua_computer-0.2.1}/computer/utils.py +0 -0
@@ -1,20 +1,25 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cua-computer
3
- Version: 0.1.29
3
+ Version: 0.2.1
4
4
  Summary: Computer-Use Interface (CUI) framework powering Cua
5
5
  Author-Email: TryCua <gh@trycua.com>
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pylume>=0.1.8
8
7
  Requires-Dist: pillow>=10.0.0
9
8
  Requires-Dist: websocket-client>=1.8.0
10
9
  Requires-Dist: websockets>=12.0
11
10
  Requires-Dist: aiohttp>=3.9.0
12
11
  Requires-Dist: cua-core<0.2.0,>=0.1.0
13
12
  Requires-Dist: pydantic>=2.11.1
13
+ Provides-Extra: lume
14
+ Provides-Extra: lumier
14
15
  Provides-Extra: ui
15
16
  Requires-Dist: gradio<6.0.0,>=5.23.3; extra == "ui"
16
17
  Requires-Dist: python-dotenv<2.0.0,>=1.0.1; extra == "ui"
17
18
  Requires-Dist: datasets<4.0.0,>=3.6.0; extra == "ui"
19
+ Provides-Extra: all
20
+ Requires-Dist: gradio<6.0.0,>=5.23.3; extra == "all"
21
+ Requires-Dist: python-dotenv<2.0.0,>=1.0.1; extra == "all"
22
+ Requires-Dist: datasets<4.0.0,>=3.6.0; extra == "all"
18
23
  Description-Content-Type: text/markdown
19
24
 
20
25
  <div align="center">
@@ -42,6 +42,10 @@ except Exception as e:
42
42
  # Other issues with telemetry
43
43
  logger.warning(f"Error initializing telemetry: {e}")
44
44
 
45
+ # Core components
45
46
  from .computer import Computer
46
47
 
47
- __all__ = ["Computer"]
48
+ # Provider components
49
+ from .providers.base import VMProviderType
50
+
51
+ __all__ = ["Computer", "VMProviderType"]
@@ -1,6 +1,4 @@
1
1
  from typing import Optional, List, Literal, Dict, Any, Union, TYPE_CHECKING, cast
2
- from pylume import PyLume
3
- from pylume.models import VMRunOpts, VMUpdateOpts, ImageRef, SharedDirectory, VMStatus
4
2
  import asyncio
5
3
  from .models import Computer as ComputerConfig, Display
6
4
  from .interface.factory import InterfaceFactory
@@ -14,12 +12,11 @@ import logging
14
12
  from .telemetry import record_computer_initialization
15
13
  import os
16
14
 
17
- OSType = Literal["macos", "linux"]
18
-
19
- # Import BaseComputerInterface for type annotations
20
- if TYPE_CHECKING:
21
- from .interface.base import BaseComputerInterface
15
+ # Import provider related modules
16
+ from .providers.base import VMProviderType
17
+ from .providers.factory import VMProviderFactory
22
18
 
19
+ OSType = Literal["macos", "linux", "windows"]
23
20
 
24
21
  class Computer:
25
22
  """Computer is the main class for interacting with the computer."""
@@ -36,8 +33,12 @@ class Computer:
36
33
  use_host_computer_server: bool = False,
37
34
  verbosity: Union[int, LogLevel] = logging.INFO,
38
35
  telemetry_enabled: bool = True,
39
- port: Optional[int] = 3000,
36
+ provider_type: Union[str, VMProviderType] = VMProviderType.LUME,
37
+ port: Optional[int] = 7777,
38
+ noVNC_port: Optional[int] = 8006,
40
39
  host: str = os.environ.get("PYLUME_HOST", "localhost"),
40
+ storage: Optional[str] = None,
41
+ ephemeral: bool = False
41
42
  ):
42
43
  """Initialize a new Computer instance.
43
44
 
@@ -49,7 +50,7 @@ class Computer:
49
50
  Defaults to "1024x768"
50
51
  memory: The VM memory allocation. Defaults to "8GB"
51
52
  cpu: The VM CPU allocation. Defaults to "4"
52
- os: The operating system type ('macos' or 'linux')
53
+ os_type: The operating system type ('macos' or 'linux')
53
54
  name: The VM name
54
55
  image: The VM image name
55
56
  shared_directories: Optional list of directory paths to share with the VM
@@ -57,8 +58,12 @@ class Computer:
57
58
  verbosity: Logging level (standard Python logging levels: logging.DEBUG, logging.INFO, etc.)
58
59
  LogLevel enum values are still accepted for backward compatibility
59
60
  telemetry_enabled: Whether to enable telemetry tracking. Defaults to True.
60
- port: Optional port to use for the PyLume server
61
- host: Host to use for PyLume connections (e.g. "localhost", "host.docker.internal")
61
+ provider_type: The VM provider type to use (lume, qemu, cloud)
62
+ port: Optional port to use for the VM provider server
63
+ noVNC_port: Optional port for the noVNC web interface (Lumier provider)
64
+ host: Host to use for VM provider connections (e.g. "localhost", "host.docker.internal")
65
+ storage: Optional path for persistent VM storage (Lumier provider)
66
+ ephemeral: Whether to use ephemeral storage
62
67
  """
63
68
 
64
69
  self.logger = Logger("cua.computer", verbosity)
@@ -67,8 +72,23 @@ class Computer:
67
72
  # Store original parameters
68
73
  self.image = image
69
74
  self.port = port
75
+ self.noVNC_port = noVNC_port
70
76
  self.host = host
71
77
  self.os_type = os_type
78
+ self.provider_type = provider_type
79
+ self.ephemeral = ephemeral
80
+
81
+ if ephemeral:
82
+ self.storage = "ephemeral"
83
+ else:
84
+ self.storage = storage
85
+
86
+ # For Lumier provider, store the first shared directory path to use
87
+ # for VM file sharing
88
+ self.shared_path = None
89
+ if shared_directories and len(shared_directories) > 0:
90
+ self.shared_path = shared_directories[0]
91
+ self.logger.info(f"Using first shared directory for VM file sharing: {self.shared_path}")
72
92
 
73
93
  # Store telemetry preference
74
94
  self._telemetry_enabled = telemetry_enabled
@@ -116,25 +136,17 @@ class Computer:
116
136
  memory=memory,
117
137
  cpu=cpu,
118
138
  )
119
- # Initialize PyLume but don't start the server yet - we'll do that in run()
120
- self.config.pylume = PyLume(
121
- debug=(self.verbosity == LogLevel.DEBUG),
122
- port=3000,
123
- use_existing_server=False,
124
- server_start_timeout=120, # Increase timeout to 2 minutes
125
- )
139
+ # Initialize VM provider but don't start it yet - we'll do that in run()
140
+ self.config.vm_provider = None # Will be initialized in run()
141
+
142
+ # Store shared directories config
143
+ self.shared_directories = shared_directories or []
144
+
145
+ # Placeholder for VM provider context manager
146
+ self._provider_context = None
126
147
 
127
148
  # Initialize with proper typing - None at first, will be set in run()
128
149
  self._interface = None
129
- self.os = os
130
- self.shared_paths = []
131
- if shared_directories:
132
- for path in shared_directories:
133
- abs_path = os.path.abspath(os.path.expanduser(path))
134
- if not os.path.exists(abs_path):
135
- raise ValueError(f"Shared directory does not exist: {path}")
136
- self.shared_paths.append(abs_path)
137
- self._pylume_context = None
138
150
  self.use_host_computer_server = use_host_computer_server
139
151
 
140
152
  # Record initialization in telemetry (if enabled)
@@ -145,7 +157,6 @@ class Computer:
145
157
 
146
158
  async def __aenter__(self):
147
159
  """Enter async context manager."""
148
- await self.run()
149
160
  return self
150
161
 
151
162
  async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -164,7 +175,7 @@ class Computer:
164
175
  # We could add cleanup here if needed in the future
165
176
  pass
166
177
 
167
- async def run(self) -> None:
178
+ async def run(self) -> Optional[str]:
168
179
  """Initialize the VM and computer interface."""
169
180
  if TYPE_CHECKING:
170
181
  from .interface.base import BaseComputerInterface
@@ -199,87 +210,166 @@ class Computer:
199
210
  else:
200
211
  # Start or connect to VM
201
212
  self.logger.info(f"Starting VM: {self.image}")
202
- if not self._pylume_context:
213
+ if not self._provider_context:
203
214
  try:
204
- self.logger.verbose("Initializing PyLume context...")
205
-
206
- # Configure PyLume based on initialization parameters
207
- pylume_kwargs = {
208
- "debug": self.verbosity <= LogLevel.DEBUG,
209
- "server_start_timeout": 120, # Increase timeout to 2 minutes
210
- }
211
-
212
- # Add port if specified
213
- if hasattr(self, "port") and self.port is not None:
214
- pylume_kwargs["port"] = self.port
215
- self.logger.verbose(f"Using specified port for PyLume: {self.port}")
216
-
217
- # Add host if specified
218
- if hasattr(self, "host") and self.host != "localhost":
219
- pylume_kwargs["host"] = self.host
220
- self.logger.verbose(f"Using specified host for PyLume: {self.host}")
221
-
222
- # Create PyLume instance with configured parameters
223
- self.config.pylume = PyLume(**pylume_kwargs)
224
-
225
- self._pylume_context = await self.config.pylume.__aenter__() # type: ignore[attr-defined]
226
- self.logger.verbose("PyLume context initialized successfully")
215
+ provider_type_name = self.provider_type.name if isinstance(self.provider_type, VMProviderType) else self.provider_type
216
+ self.logger.verbose(f"Initializing {provider_type_name} provider context...")
217
+
218
+ # Explicitly set provider parameters
219
+ storage = "ephemeral" if self.ephemeral else self.storage
220
+ verbose = self.verbosity >= LogLevel.DEBUG
221
+ ephemeral = self.ephemeral
222
+ port = self.port if self.port is not None else 7777
223
+ host = self.host if self.host else "localhost"
224
+ image = self.image
225
+ shared_path = self.shared_path
226
+ noVNC_port = self.noVNC_port
227
+
228
+ # Create VM provider instance with explicit parameters
229
+ try:
230
+ if self.provider_type == VMProviderType.LUMIER:
231
+ self.logger.info(f"Using VM image for Lumier provider: {image}")
232
+ if shared_path:
233
+ self.logger.info(f"Using shared path for Lumier provider: {shared_path}")
234
+ if noVNC_port:
235
+ self.logger.info(f"Using noVNC port for Lumier provider: {noVNC_port}")
236
+ self.config.vm_provider = VMProviderFactory.create_provider(
237
+ self.provider_type,
238
+ port=port,
239
+ host=host,
240
+ storage=storage,
241
+ shared_path=shared_path,
242
+ image=image,
243
+ verbose=verbose,
244
+ ephemeral=ephemeral,
245
+ noVNC_port=noVNC_port,
246
+ )
247
+ elif self.provider_type == VMProviderType.LUME:
248
+ self.config.vm_provider = VMProviderFactory.create_provider(
249
+ self.provider_type,
250
+ port=port,
251
+ host=host,
252
+ storage=storage,
253
+ verbose=verbose,
254
+ ephemeral=ephemeral,
255
+ )
256
+ elif self.provider_type == VMProviderType.CLOUD:
257
+ self.config.vm_provider = VMProviderFactory.create_provider(
258
+ self.provider_type,
259
+ port=port,
260
+ host=host,
261
+ storage=storage,
262
+ verbose=verbose,
263
+ )
264
+ else:
265
+ raise ValueError(f"Unsupported provider type: {self.provider_type}")
266
+ self._provider_context = await self.config.vm_provider.__aenter__()
267
+ self.logger.verbose("VM provider context initialized successfully")
268
+ except ImportError as ie:
269
+ self.logger.error(f"Failed to import provider dependencies: {ie}")
270
+ if str(ie).find("lume") >= 0 and str(ie).find("lumier") < 0:
271
+ self.logger.error("Please install with: pip install cua-computer[lume]")
272
+ elif str(ie).find("lumier") >= 0 or str(ie).find("docker") >= 0:
273
+ self.logger.error("Please install with: pip install cua-computer[lumier] and make sure Docker is installed")
274
+ elif str(ie).find("cloud") >= 0:
275
+ self.logger.error("Please install with: pip install cua-computer[cloud]")
276
+ raise
227
277
  except Exception as e:
228
- self.logger.error(f"Failed to initialize PyLume context: {e}")
229
- raise RuntimeError(f"Failed to initialize PyLume: {e}")
278
+ self.logger.error(f"Failed to initialize provider context: {e}")
279
+ raise RuntimeError(f"Failed to initialize VM provider: {e}")
230
280
 
231
- # Try to get the VM, if it doesn't exist, return an error
281
+ # Check if VM exists or create it
232
282
  try:
233
- vm = await self.config.pylume.get_vm(self.config.name) # type: ignore[attr-defined]
283
+ if self.config.vm_provider is None:
284
+ raise RuntimeError(f"VM provider not initialized for {self.config.name}")
285
+
286
+ vm = await self.config.vm_provider.get_vm(self.config.name)
234
287
  self.logger.verbose(f"Found existing VM: {self.config.name}")
235
288
  except Exception as e:
236
289
  self.logger.error(f"VM not found: {self.config.name}")
237
- self.logger.error(
238
- f"Please pull the VM first with lume pull macos-sequoia-cua-sparse:latest: {e}"
239
- )
290
+ self.logger.error(f"Error: {e}")
240
291
  raise RuntimeError(
241
- f"VM not found: {self.config.name}. Please pull the VM first."
292
+ f"VM {self.config.name} could not be found or created."
242
293
  )
243
294
 
244
- # Convert paths to SharedDirectory objects
245
- shared_directories = []
246
- for path in self.shared_paths:
295
+ # Convert paths to dictionary format for shared directories
296
+ shared_dirs = []
297
+ for path in self.shared_directories:
247
298
  self.logger.verbose(f"Adding shared directory: {path}")
248
- shared_directories.append(
249
- SharedDirectory(host_path=path) # type: ignore[arg-type]
250
- )
251
-
252
- # Run with shared directories
253
- self.logger.info(f"Starting VM {self.config.name}...")
254
- run_opts = VMRunOpts(
255
- no_display=False, # type: ignore[arg-type]
256
- shared_directories=shared_directories, # type: ignore[arg-type]
257
- )
258
-
259
- # Log the run options for debugging
260
- self.logger.info(f"VM run options: {vars(run_opts)}")
261
-
262
- # Log the equivalent curl command for debugging
263
- payload = json.dumps({"noDisplay": False, "sharedDirectories": []})
264
- curl_cmd = f"curl -X POST 'http://localhost:3000/lume/vms/{self.config.name}/run' -H 'Content-Type: application/json' -d '{payload}'"
265
- self.logger.info(f"Equivalent curl command:")
266
- self.logger.info(f"{curl_cmd}")
267
-
299
+ path = os.path.abspath(os.path.expanduser(path))
300
+ if os.path.exists(path):
301
+ # Add path in format expected by Lume API
302
+ shared_dirs.append({
303
+ "hostPath": path,
304
+ "readOnly": False
305
+ })
306
+ else:
307
+ self.logger.warning(f"Shared directory does not exist: {path}")
308
+
309
+ # Prepare run options to pass to the provider
310
+ run_opts = {}
311
+
312
+ # Add display information if available
313
+ if self.config.display is not None:
314
+ display_info = {
315
+ "width": self.config.display.width,
316
+ "height": self.config.display.height,
317
+ }
318
+
319
+ # Check if scale_factor exists before adding it
320
+ if hasattr(self.config.display, "scale_factor"):
321
+ display_info["scale_factor"] = self.config.display.scale_factor
322
+
323
+ run_opts["display"] = display_info
324
+
325
+ # Add shared directories if available
326
+ if self.shared_directories:
327
+ run_opts["shared_directories"] = shared_dirs.copy()
328
+
329
+ # Run the VM with the provider
268
330
  try:
269
- response = await self.config.pylume.run_vm(self.config.name, run_opts) # type: ignore[attr-defined]
331
+ if self.config.vm_provider is None:
332
+ raise RuntimeError(f"VM provider not initialized for {self.config.name}")
333
+
334
+ # Use the complete run_opts we prepared earlier
335
+ # Handle ephemeral storage for run_vm method too
336
+ storage_param = "ephemeral" if self.ephemeral else self.storage
337
+
338
+ # Log the image being used
339
+ self.logger.info(f"Running VM using image: {self.image}")
340
+
341
+ # Call provider.run_vm with explicit image parameter
342
+ response = await self.config.vm_provider.run_vm(
343
+ image=self.image,
344
+ name=self.config.name,
345
+ run_opts=run_opts,
346
+ storage=storage_param
347
+ )
270
348
  self.logger.info(f"VM run response: {response if response else 'None'}")
271
349
  except Exception as run_error:
272
350
  self.logger.error(f"Failed to run VM: {run_error}")
273
351
  raise RuntimeError(f"Failed to start VM: {run_error}")
274
352
 
275
- # Wait for VM to be ready with required properties
276
- self.logger.info("Waiting for VM to be ready...")
353
+ # Wait for VM to be ready with a valid IP address
354
+ self.logger.info("Waiting for VM to be ready with a valid IP address...")
277
355
  try:
278
- vm = await self.wait_vm_ready()
279
- if not vm or not vm.ip_address: # type: ignore[attr-defined]
280
- raise RuntimeError(f"VM {self.config.name} failed to get IP address")
281
- ip_address = vm.ip_address # type: ignore[attr-defined]
282
- self.logger.info(f"VM is ready with IP: {ip_address}")
356
+ # Increased values for Lumier provider which needs more time for initial setup
357
+ if self.provider_type == VMProviderType.LUMIER:
358
+ max_retries = 60 # Increased for Lumier VM startup which takes longer
359
+ retry_delay = 3 # 3 seconds between retries for Lumier
360
+ else:
361
+ max_retries = 30 # Default for other providers
362
+ retry_delay = 2 # 2 seconds between retries
363
+
364
+ self.logger.info(f"Waiting up to {max_retries * retry_delay} seconds for VM to be ready...")
365
+ ip = await self.get_ip(max_retries=max_retries, retry_delay=retry_delay)
366
+
367
+ # If we get here, we have a valid IP
368
+ self.logger.info(f"VM is ready with IP: {ip}")
369
+ ip_address = ip
370
+ except TimeoutError as timeout_error:
371
+ self.logger.error(str(timeout_error))
372
+ raise RuntimeError(f"VM startup timed out: {timeout_error}")
283
373
  except Exception as wait_error:
284
374
  self.logger.error(f"Error waiting for VM: {wait_error}")
285
375
  raise RuntimeError(f"VM failed to become ready: {wait_error}")
@@ -288,6 +378,10 @@ class Computer:
288
378
  raise RuntimeError(f"Failed to initialize computer: {e}")
289
379
 
290
380
  try:
381
+ # Verify we have a valid IP before initializing the interface
382
+ if not ip_address or ip_address == "unknown" or ip_address == "0.0.0.0":
383
+ raise RuntimeError(f"Cannot initialize interface - invalid IP address: {ip_address}")
384
+
291
385
  # Initialize the interface using the factory with the specified OS
292
386
  self.logger.info(f"Initializing interface for {self.os_type} at {ip_address}")
293
387
  from .interface.base import BaseComputerInterface
@@ -304,10 +398,11 @@ class Computer:
304
398
 
305
399
  try:
306
400
  # Use a single timeout for the entire connection process
307
- await self._interface.wait_for_ready(timeout=60)
401
+ # The VM should already be ready at this point, so we're just establishing the connection
402
+ await self._interface.wait_for_ready(timeout=30)
308
403
  self.logger.info("WebSocket interface connected successfully")
309
404
  except TimeoutError as e:
310
- self.logger.error("Failed to connect to WebSocket interface")
405
+ self.logger.error(f"Failed to connect to WebSocket interface at {ip_address}")
311
406
  raise TimeoutError(
312
407
  f"Could not connect to WebSocket interface at {ip_address}:8000/ws: {str(e)}"
313
408
  )
@@ -335,41 +430,26 @@ class Computer:
335
430
  start_time = time.time()
336
431
 
337
432
  try:
338
- if self._running:
339
- self._running = False
340
- self.logger.info("Stopping Computer...")
341
-
342
- if hasattr(self, "_stop_event"):
343
- self._stop_event.set()
344
- if hasattr(self, "_keep_alive_task"):
345
- await self._keep_alive_task
346
-
347
- if self._interface: # Only try to close interface if it exists
348
- self.logger.verbose("Closing interface...")
349
- # For host computer server, just use normal close to keep the server running
350
- if self.use_host_computer_server:
351
- self._interface.close()
352
- else:
353
- # For VM mode, force close the connection
354
- if hasattr(self._interface, "force_close"):
355
- self._interface.force_close()
356
- else:
357
- self._interface.close()
433
+ self.logger.info("Stopping Computer...")
358
434
 
359
- if not self.use_host_computer_server and self._pylume_context:
435
+ # In VM mode, first explicitly stop the VM, then exit the provider context
436
+ if not self.use_host_computer_server and self._provider_context and self.config.vm_provider is not None:
360
437
  try:
361
438
  self.logger.info(f"Stopping VM {self.config.name}...")
362
- await self.config.pylume.stop_vm(self.config.name) # type: ignore[attr-defined]
439
+ await self.config.vm_provider.stop_vm(
440
+ name=self.config.name,
441
+ storage=self.storage # Pass storage explicitly for clarity
442
+ )
363
443
  except Exception as e:
364
- self.logger.verbose(f"Error stopping VM: {e}") # VM might already be stopped
365
- self.logger.verbose("Closing PyLume context...")
366
- await self.config.pylume.__aexit__(None, None, None) # type: ignore[attr-defined]
367
- self._pylume_context = None
444
+ self.logger.error(f"Error stopping VM: {e}")
445
+
446
+ self.logger.verbose("Closing VM provider context...")
447
+ await self.config.vm_provider.__aexit__(None, None, None)
448
+ self._provider_context = None
449
+
368
450
  self.logger.info("Computer stopped")
369
451
  except Exception as e:
370
- self.logger.debug(
371
- f"Error during cleanup: {e}"
372
- ) # Log as debug since this might be expected
452
+ self.logger.debug(f"Error during cleanup: {e}") # Log as debug since this might be expected
373
453
  finally:
374
454
  # Log stop time for performance monitoring
375
455
  duration_ms = (time.time() - start_time) * 1000
@@ -377,14 +457,44 @@ class Computer:
377
457
  return
378
458
 
379
459
  # @property
380
- async def get_ip(self) -> str:
381
- """Get the IP address of the VM or localhost if using host computer server."""
460
+ async def get_ip(self, max_retries: int = 15, retry_delay: int = 2) -> str:
461
+ """Get the IP address of the VM or localhost if using host computer server.
462
+
463
+ This method delegates to the provider's get_ip method, which waits indefinitely
464
+ until the VM has a valid IP address.
465
+
466
+ Args:
467
+ max_retries: Unused parameter, kept for backward compatibility
468
+ retry_delay: Delay between retries in seconds (default: 2)
469
+
470
+ Returns:
471
+ IP address of the VM or localhost if using host computer server
472
+ """
473
+ # For host computer server, always return localhost immediately
382
474
  if self.use_host_computer_server:
383
475
  return "127.0.0.1"
384
- ip = await self.config.get_ip()
385
- return ip or "unknown" # Return "unknown" if ip is None
476
+
477
+ # Get IP from the provider - each provider implements its own waiting logic
478
+ if self.config.vm_provider is None:
479
+ raise RuntimeError("VM provider is not initialized")
480
+
481
+ # Log that we're waiting for the IP
482
+ self.logger.info(f"Waiting for VM {self.config.name} to get an IP address...")
483
+
484
+ # Call the provider's get_ip method which will wait indefinitely
485
+ storage_param = "ephemeral" if self.ephemeral else self.storage
486
+ ip = await self.config.vm_provider.get_ip(
487
+ name=self.config.name,
488
+ storage=storage_param,
489
+ retry_delay=retry_delay
490
+ )
491
+
492
+ # Log success
493
+ self.logger.info(f"VM {self.config.name} has IP address: {ip}")
494
+ return ip
495
+
386
496
 
387
- async def wait_vm_ready(self) -> Optional[Union[Dict[str, Any], "VMStatus"]]:
497
+ async def wait_vm_ready(self) -> Optional[Dict[str, Any]]:
388
498
  """Wait for VM to be ready with an IP address.
389
499
 
390
500
  Returns:
@@ -407,7 +517,11 @@ class Computer:
407
517
 
408
518
  try:
409
519
  # Keep polling for VM info
410
- vm = await self.config.pylume.get_vm(self.config.name) # type: ignore[attr-defined]
520
+ if self.config.vm_provider is None:
521
+ self.logger.error("VM provider is not initialized")
522
+ vm = None
523
+ else:
524
+ vm = await self.config.vm_provider.get_vm(self.config.name)
411
525
 
412
526
  # Log full VM properties for debugging (every 30 attempts)
413
527
  if attempts % 30 == 0:
@@ -447,10 +561,11 @@ class Computer:
447
561
  self.logger.error(f"Persistent error getting VM status: {str(e)}")
448
562
  self.logger.info("Trying to get VM list for debugging...")
449
563
  try:
450
- vms = await self.config.pylume.list_vms() # type: ignore[attr-defined]
451
- self.logger.info(
452
- f"Available VMs: {[vm.name for vm in vms if hasattr(vm, 'name')]}"
453
- )
564
+ if self.config.vm_provider is not None:
565
+ vms = await self.config.vm_provider.list_vms()
566
+ self.logger.info(
567
+ f"Available VMs: {[getattr(vm, 'name', None) for vm in vms if hasattr(vm, 'name')]}"
568
+ )
454
569
  except Exception as list_error:
455
570
  self.logger.error(f"Failed to list VMs: {str(list_error)}")
456
571
 
@@ -462,9 +577,14 @@ class Computer:
462
577
 
463
578
  # Try to get final VM status for debugging
464
579
  try:
465
- vm = await self.config.pylume.get_vm(self.config.name) # type: ignore[attr-defined]
466
- status = getattr(vm, "status", "unknown") if vm else "unknown"
467
- ip = getattr(vm, "ip_address", None) if vm else None
580
+ if self.config.vm_provider is not None:
581
+ vm = await self.config.vm_provider.get_vm(self.config.name)
582
+ # VM data is returned as a dictionary from the Lumier provider
583
+ status = vm.get('status', 'unknown') if vm else "unknown"
584
+ ip = vm.get('ip_address') if vm else None
585
+ else:
586
+ status = "unknown"
587
+ ip = None
468
588
  self.logger.error(f"Final VM status: {status}, IP: {ip}")
469
589
  except Exception as e:
470
590
  self.logger.error(f"Failed to get final VM status: {str(e)}")
@@ -478,10 +598,18 @@ class Computer:
478
598
  self.logger.info(
479
599
  f"Updating VM settings: CPU={cpu or self.config.cpu}, Memory={memory or self.config.memory}"
480
600
  )
481
- update_opts = VMUpdateOpts(
482
- cpu=cpu or int(self.config.cpu), memory=memory or self.config.memory
483
- )
484
- await self.config.pylume.update_vm(self.config.image, update_opts) # type: ignore[attr-defined]
601
+ update_opts = {
602
+ "cpu": cpu or int(self.config.cpu),
603
+ "memory": memory or self.config.memory
604
+ }
605
+ if self.config.vm_provider is not None:
606
+ await self.config.vm_provider.update_vm(
607
+ name=self.config.name,
608
+ update_opts=update_opts,
609
+ storage=self.storage # Pass storage explicitly for clarity
610
+ )
611
+ else:
612
+ raise RuntimeError("VM provider not initialized")
485
613
 
486
614
  def get_screenshot_size(self, screenshot: bytes) -> Dict[str, int]:
487
615
  """Get the dimensions of a screenshot.
@@ -25,8 +25,11 @@ class InterfaceFactory:
25
25
  """
26
26
  # Import implementations here to avoid circular imports
27
27
  from .macos import MacOSComputerInterface
28
+ from .linux import LinuxComputerInterface
28
29
 
29
30
  if os == 'macos':
30
31
  return MacOSComputerInterface(ip_address)
32
+ elif os == 'linux':
33
+ return LinuxComputerInterface(ip_address)
31
34
  else:
32
- raise ValueError(f"Unsupported OS type: {os}")
35
+ raise ValueError(f"Unsupported OS type: {os}")