diff --git a/libs/python/pylume/.bumpversion.cfg b/libs/python/pylume/.bumpversion.cfg
deleted file mode 100644
index 4a316b37..00000000
--- a/libs/python/pylume/.bumpversion.cfg
+++ /dev/null
@@ -1,10 +0,0 @@
-[bumpversion]
-current_version = 0.2.1
-commit = True
-tag = True
-tag_name = pylume-v{new_version}
-message = Bump pylume to v{new_version}
-
-[bumpversion:file:pylume/__init__.py]
-search = __version__ = "{current_version}"
-replace = __version__ = "{new_version}"
diff --git a/libs/python/pylume/README.md b/libs/python/pylume/README.md
deleted file mode 100644
index 459d1ce5..00000000
--- a/libs/python/pylume/README.md
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-[](#)
-[](#)
-[](https://discord.com/invite/mVnXXpdE85)
-[](https://pypi.org/project/pylume/)
-
-
-
-
-**pylume** is a lightweight Python library based on [lume](https://github.com/trycua/lume) to create, run and manage macOS and Linux virtual machines (VMs) natively on Apple Silicon.
-
-```bash
-pip install pylume
-```
-
-## Usage
-
-Please refer to this [Notebook](./samples/nb.ipynb) for a quickstart. More details about the underlying API used by pylume are available [here](https://github.com/trycua/lume/docs/API-Reference.md).
-
-## Prebuilt Images
-
-Pre-built images are available on [ghcr.io/trycua](https://github.com/orgs/trycua/packages).
-These images come pre-configured with an SSH server and auto-login enabled.
-
-## Contributing
-
-We welcome and greatly appreciate contributions to lume! Whether you're improving documentation, adding new features, fixing bugs, or adding new VM images, your efforts help make pylume better for everyone.
-
-Join our [Discord community](https://discord.com/invite/mVnXXpdE85) to discuss ideas or get assistance.
-
-## License
-
-lume is open-sourced under the MIT License - see the [LICENSE](LICENSE) file for details.
-
-## Stargazers over time
-
-[](https://starchart.cc/trycua/pylume)
diff --git a/libs/python/pylume/__init__.py b/libs/python/pylume/__init__.py
deleted file mode 100644
index 128ce121..00000000
--- a/libs/python/pylume/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""
-PyLume Python SDK - A client library for managing macOS VMs with PyLume.
-"""
-
-from pylume.exceptions import *
-from pylume.models import *
-from pylume.pylume import *
-
-__version__ = "0.1.0"
diff --git a/libs/python/pylume/pylume/__init__.py b/libs/python/pylume/pylume/__init__.py
deleted file mode 100644
index adfb15d9..00000000
--- a/libs/python/pylume/pylume/__init__.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-PyLume Python SDK - A client library for managing macOS VMs with PyLume.
-
-Example:
- >>> from pylume import PyLume, VMConfig
- >>> client = PyLume()
- >>> config = VMConfig(name="my-vm", cpu=4, memory="8GB", disk_size="64GB")
- >>> client.create_vm(config)
- >>> client.run_vm("my-vm")
-"""
-
-# Import exceptions then all models
-from .exceptions import (
- LumeConfigError,
- LumeConnectionError,
- LumeError,
- LumeImageError,
- LumeNotFoundError,
- LumeServerError,
- LumeTimeoutError,
- LumeVMError,
-)
-from .models import (
- CloneSpec,
- ImageInfo,
- ImageList,
- ImageRef,
- SharedDirectory,
- VMConfig,
- VMRunOpts,
- VMStatus,
- VMUpdateOpts,
-)
-
-# Import main class last to avoid circular imports
-from .pylume import PyLume
-
-__version__ = "0.2.1"
-
-__all__ = [
- "PyLume",
- "VMConfig",
- "VMStatus",
- "VMRunOpts",
- "VMUpdateOpts",
- "ImageRef",
- "CloneSpec",
- "SharedDirectory",
- "ImageList",
- "ImageInfo",
- "LumeError",
- "LumeServerError",
- "LumeConnectionError",
- "LumeTimeoutError",
- "LumeNotFoundError",
- "LumeConfigError",
- "LumeVMError",
- "LumeImageError",
-]
diff --git a/libs/python/pylume/pylume/client.py b/libs/python/pylume/pylume/client.py
deleted file mode 100644
index 101d5ee8..00000000
--- a/libs/python/pylume/pylume/client.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import asyncio
-import json
-import shlex
-import subprocess
-from typing import Any, Dict, Optional
-
-from .exceptions import (
- LumeConfigError,
- LumeConnectionError,
- LumeError,
- LumeNotFoundError,
- LumeServerError,
- LumeTimeoutError,
-)
-
-
-class LumeClient:
- def __init__(self, base_url: str, timeout: float = 60.0, debug: bool = False):
- self.base_url = base_url
- self.timeout = timeout
- self.debug = debug
-
- def _log_debug(self, message: str, **kwargs) -> None:
- """Log debug information if debug mode is enabled."""
- if self.debug:
- print(f"DEBUG: {message}")
- if kwargs:
- print(json.dumps(kwargs, indent=2))
-
- async def _run_curl(
- self,
- method: str,
- path: str,
- data: Optional[Dict[str, Any]] = None,
- params: Optional[Dict[str, Any]] = None,
- ) -> Any:
- """Execute a curl command and return the response."""
- url = f"{self.base_url}{path}"
- if params:
- param_str = "&".join(f"{k}={v}" for k, v in params.items())
- url = f"{url}?{param_str}"
-
- cmd = ["curl", "-X", method, "-s", "-w", "%{http_code}", "-m", str(self.timeout)]
-
- if data is not None:
- cmd.extend(["-H", "Content-Type: application/json", "-d", json.dumps(data)])
-
- cmd.append(url)
-
- self._log_debug(f"Running curl command: {' '.join(map(shlex.quote, cmd))}")
-
- try:
- process = await asyncio.create_subprocess_exec(
- *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- stdout, stderr = await process.communicate()
-
- if process.returncode != 0:
- raise LumeConnectionError(f"Curl command failed: {stderr.decode()}")
-
- # The last 3 characters are the status code
- response = stdout.decode()
- status_code = int(response[-3:])
- response_body = response[:-3] # Remove status code from response
-
- if status_code >= 400:
- if status_code == 404:
- raise LumeNotFoundError(f"Resource not found: {path}")
- elif status_code == 400:
- raise LumeConfigError(f"Invalid request: {response_body}")
- elif status_code >= 500:
- raise LumeServerError(f"Server error: {response_body}")
- else:
- raise LumeError(f"Request failed with status {status_code}: {response_body}")
-
- return json.loads(response_body) if response_body.strip() else None
-
- except asyncio.TimeoutError:
- raise LumeTimeoutError(f"Request timed out after {self.timeout} seconds")
-
- async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
- """Make a GET request."""
- return await self._run_curl("GET", path, params=params)
-
- async def post(
- self, path: str, data: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None
- ) -> Any:
- """Make a POST request."""
- old_timeout = self.timeout
- if timeout is not None:
- self.timeout = timeout
- try:
- return await self._run_curl("POST", path, data=data)
- finally:
- self.timeout = old_timeout
-
- async def patch(self, path: str, data: Dict[str, Any]) -> None:
- """Make a PATCH request."""
- await self._run_curl("PATCH", path, data=data)
-
- async def delete(self, path: str) -> None:
- """Make a DELETE request."""
- await self._run_curl("DELETE", path)
-
- def print_curl(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> None:
- """Print equivalent curl command for debugging."""
- curl_cmd = f"""curl -X {method} \\
- '{self.base_url}{path}'"""
-
- if data:
- curl_cmd += f" \\\n -H 'Content-Type: application/json' \\\n -d '{json.dumps(data)}'"
-
- print("\nEquivalent curl command:")
- print(curl_cmd)
- print()
-
- async def close(self) -> None:
- """Close the client resources."""
- pass # No shared resources to clean up
diff --git a/libs/python/pylume/pylume/exceptions.py b/libs/python/pylume/pylume/exceptions.py
deleted file mode 100644
index 191718b0..00000000
--- a/libs/python/pylume/pylume/exceptions.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from typing import Optional
-
-
-class LumeError(Exception):
- """Base exception for all PyLume errors."""
-
- pass
-
-
-class LumeServerError(LumeError):
- """Raised when there's an error with the PyLume server."""
-
- def __init__(
- self, message: str, status_code: Optional[int] = None, response_text: Optional[str] = None
- ):
- self.status_code = status_code
- self.response_text = response_text
- super().__init__(message)
-
-
-class LumeConnectionError(LumeError):
- """Raised when there's an error connecting to the PyLume server."""
-
- pass
-
-
-class LumeTimeoutError(LumeError):
- """Raised when a request to the PyLume server times out."""
-
- pass
-
-
-class LumeNotFoundError(LumeError):
- """Raised when a requested resource is not found."""
-
- pass
-
-
-class LumeConfigError(LumeError):
- """Raised when there's an error with the configuration."""
-
- pass
-
-
-class LumeVMError(LumeError):
- """Raised when there's an error with a VM operation."""
-
- pass
-
-
-class LumeImageError(LumeError):
- """Raised when there's an error with an image operation."""
-
- pass
diff --git a/libs/python/pylume/pylume/models.py b/libs/python/pylume/pylume/models.py
deleted file mode 100644
index 021ea8aa..00000000
--- a/libs/python/pylume/pylume/models.py
+++ /dev/null
@@ -1,265 +0,0 @@
-import re
-from typing import Any, Dict, List, Literal, Optional
-
-from pydantic import BaseModel, ConfigDict, Field, RootModel, computed_field, validator
-
-
-class DiskInfo(BaseModel):
- """Information about disk storage allocation.
-
- Attributes:
- total: Total disk space in bytes
- allocated: Currently allocated disk space in bytes
- """
-
- total: int
- allocated: int
-
-
-class VMConfig(BaseModel):
- """Configuration for creating a new VM.
-
- Note: Memory and disk sizes should be specified with units (e.g., "4GB", "64GB")
-
- Attributes:
- name: Name of the virtual machine
- os: Operating system type, either "macOS" or "linux"
- cpu: Number of CPU cores to allocate
- memory: Amount of memory to allocate with units
- disk_size: Size of the disk to create with units
- display: Display resolution in format "widthxheight"
- ipsw: IPSW path or 'latest' for macOS VMs, None for other OS types
- """
-
- name: str
- os: Literal["macOS", "linux"] = "macOS"
- cpu: int = Field(default=2, ge=1)
- memory: str = "4GB"
- disk_size: str = Field(default="64GB", alias="diskSize")
- display: str = "1024x768"
- ipsw: Optional[str] = Field(default=None, description="IPSW path or 'latest', for macOS VMs")
-
- class Config:
- populate_by_alias = True
-
-
-class SharedDirectory(BaseModel):
- """Configuration for a shared directory.
-
- Attributes:
- host_path: Path to the directory on the host system
- read_only: Whether the directory should be mounted as read-only
- """
-
- host_path: str = Field(..., alias="hostPath") # Allow host_path but serialize as hostPath
- read_only: bool = False
-
- class Config:
- populate_by_name = True # Allow both alias and original name
- alias_generator = lambda s: "".join(
- word.capitalize() if i else word for i, word in enumerate(s.split("_"))
- )
-
-
-class VMRunOpts(BaseModel):
- """Configuration for running a VM.
-
- Args:
- no_display: Whether to not display the VNC client
- shared_directories: List of directories to share with the VM
- """
-
- no_display: bool = Field(default=False, alias="noDisplay")
- shared_directories: Optional[list[SharedDirectory]] = Field(
- default=None, alias="sharedDirectories"
- )
-
- model_config = ConfigDict(
- populate_by_name=True,
- alias_generator=lambda s: "".join(
- word.capitalize() if i else word for i, word in enumerate(s.split("_"))
- ),
- )
-
- def model_dump(self, **kwargs):
- """Export model data with proper field name conversion.
-
- Converts shared directory fields to match API expectations when using aliases.
-
- Args:
- **kwargs: Keyword arguments passed to parent model_dump method
-
- Returns:
- dict: Model data with properly formatted field names
- """
- data = super().model_dump(**kwargs)
- # Convert shared directory fields to match API expectations
- if self.shared_directories and "by_alias" in kwargs and kwargs["by_alias"]:
- data["sharedDirectories"] = [
- {"hostPath": d.host_path, "readOnly": d.read_only} for d in self.shared_directories
- ]
- # Remove the snake_case version if it exists
- data.pop("shared_directories", None)
- return data
-
-
-class VMStatus(BaseModel):
- """Status information for a virtual machine.
-
- Attributes:
- name: Name of the virtual machine
- status: Current status of the VM
- os: Operating system type
- cpu_count: Number of CPU cores allocated
- memory_size: Amount of memory allocated in bytes
- disk_size: Disk storage information
- vnc_url: URL for VNC connection if available
- ip_address: IP address of the VM if available
- """
-
- name: str
- status: str
- os: Literal["macOS", "linux"]
- cpu_count: int = Field(alias="cpuCount")
- memory_size: int = Field(alias="memorySize") # API returns memory size in bytes
- disk_size: DiskInfo = Field(alias="diskSize")
- vnc_url: Optional[str] = Field(default=None, alias="vncUrl")
- ip_address: Optional[str] = Field(default=None, alias="ipAddress")
-
- class Config:
- populate_by_alias = True
-
- @computed_field
- @property
- def state(self) -> str:
- """Get the current state of the VM.
-
- Returns:
- str: Current VM status
- """
- return self.status
-
- @computed_field
- @property
- def cpu(self) -> int:
- """Get the number of CPU cores.
-
- Returns:
- int: Number of CPU cores allocated to the VM
- """
- return self.cpu_count
-
- @computed_field
- @property
- def memory(self) -> str:
- """Get memory allocation in human-readable format.
-
- Returns:
- str: Memory size formatted as "{size}GB"
- """
- # Convert bytes to GB
- gb = self.memory_size / (1024 * 1024 * 1024)
- return f"{int(gb)}GB"
-
-
-class VMUpdateOpts(BaseModel):
- """Options for updating VM configuration.
-
- Attributes:
- cpu: Number of CPU cores to update to
- memory: Amount of memory to update to with units
- disk_size: Size of disk to update to with units
- """
-
- cpu: Optional[int] = None
- memory: Optional[str] = None
- disk_size: Optional[str] = None
-
-
-class ImageRef(BaseModel):
- """Reference to a VM image.
-
- Attributes:
- image: Name of the image
- tag: Tag version of the image
- registry: Registry hostname where image is stored
- organization: Organization or namespace in the registry
- """
-
- image: str
- tag: str = "latest"
- registry: Optional[str] = "ghcr.io"
- organization: Optional[str] = "trycua"
-
- def model_dump(self, **kwargs):
- """Override model_dump to return just the image:tag format.
-
- Args:
- **kwargs: Keyword arguments (ignored)
-
- Returns:
- str: Image reference in "image:tag" format
- """
- return f"{self.image}:{self.tag}"
-
-
-class CloneSpec(BaseModel):
- """Specification for cloning a VM.
-
- Attributes:
- name: Name of the source VM to clone
- new_name: Name for the new cloned VM
- """
-
- name: str
- new_name: str = Field(alias="newName")
-
- class Config:
- populate_by_alias = True
-
-
-class ImageInfo(BaseModel):
- """Model for individual image information.
-
- Attributes:
- imageId: Unique identifier for the image
- """
-
- imageId: str
-
-
-class ImageList(RootModel):
- """Response model for the images endpoint.
-
- A list-like container for ImageInfo objects that provides
- iteration and indexing capabilities.
- """
-
- root: List[ImageInfo]
-
- def __iter__(self):
- """Iterate over the image list.
-
- Returns:
- Iterator over ImageInfo objects
- """
- return iter(self.root)
-
- def __getitem__(self, item):
- """Get an item from the image list by index.
-
- Args:
- item: Index or slice to retrieve
-
- Returns:
- ImageInfo or list of ImageInfo objects
- """
- return self.root[item]
-
- def __len__(self):
- """Get the number of images in the list.
-
- Returns:
- int: Number of images in the list
- """
- return len(self.root)
diff --git a/libs/python/pylume/pylume/pylume.py b/libs/python/pylume/pylume/pylume.py
deleted file mode 100644
index 1bbe34b2..00000000
--- a/libs/python/pylume/pylume/pylume.py
+++ /dev/null
@@ -1,315 +0,0 @@
-import asyncio
-import json
-import os
-import re
-import signal
-import subprocess
-import sys
-import time
-from functools import wraps
-from typing import Any, Callable, List, Optional, TypeVar, Union
-
-from .client import LumeClient
-from .exceptions import (
- LumeConfigError,
- LumeConnectionError,
- LumeError,
- LumeImageError,
- LumeNotFoundError,
- LumeServerError,
- LumeTimeoutError,
- LumeVMError,
-)
-from .models import (
- CloneSpec,
- ImageList,
- ImageRef,
- SharedDirectory,
- VMConfig,
- VMRunOpts,
- VMStatus,
- VMUpdateOpts,
-)
-from .server import LumeServer
-
-# Type variable for the decorator
-T = TypeVar("T")
-
-
-def ensure_server(func: Callable[..., T]) -> Callable[..., T]:
- """Decorator to ensure server is running before executing the method."""
-
- @wraps(func)
- async def wrapper(self: "PyLume", *args: Any, **kwargs: Any) -> T:
- # ensure_running is an async method, so we need to await it
- await self.server.ensure_running()
- # Initialize client if needed
- await self._init_client()
- return await func(self, *args, **kwargs) # type: ignore
-
- return wrapper # type: ignore
-
-
-class PyLume:
- def __init__(
- self,
- debug: bool = False,
- server_start_timeout: int = 60,
- port: Optional[int] = None,
- use_existing_server: bool = False,
- host: str = "localhost",
- ):
- """Initialize the async PyLume client.
-
- Args:
- debug: Enable debug logging
- auto_start_server: Whether to automatically start the lume server if not running
- server_start_timeout: Timeout in seconds to wait for server to start
- port: Port number for the lume server. Required when use_existing_server is True.
- use_existing_server: If True, will try to connect to an existing server on the specified port
- instead of starting a new one.
- host: Host to use for connections (e.g., "localhost", "127.0.0.1", "host.docker.internal")
- """
- if use_existing_server and port is None:
- raise LumeConfigError("Port must be specified when using an existing server")
-
- self.server = LumeServer(
- debug=debug,
- server_start_timeout=server_start_timeout,
- port=port,
- use_existing_server=use_existing_server,
- host=host,
- )
- self.client = None
-
- async def __aenter__(self) -> "PyLume":
- """Async context manager entry."""
- if self.server.use_existing_server:
- # Just ensure base_url is set for existing server
- if self.server.requested_port is None:
- raise LumeConfigError("Port must be specified when using an existing server")
-
- if not self.server.base_url:
- self.server.port = self.server.requested_port
- self.server.base_url = f"http://{self.server.host}:{self.server.port}/lume"
-
- # Ensure the server is running (will connect to existing or start new as needed)
- await self.server.ensure_running()
-
- # Initialize the client
- await self._init_client()
- return self
-
- async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
- """Async context manager exit."""
- if self.client is not None:
- await self.client.close()
- await self.server.stop()
-
- async def _init_client(self) -> None:
- """Initialize the client if not already initialized."""
- if self.client is None:
- if self.server.base_url is None:
- raise RuntimeError("Server base URL not set")
- self.client = LumeClient(self.server.base_url, debug=self.server.debug)
-
- def _log_debug(self, message: str, **kwargs) -> None:
- """Log debug information if debug mode is enabled."""
- if self.server.debug:
- print(f"DEBUG: {message}")
- if kwargs:
- print(json.dumps(kwargs, indent=2))
-
- async def _handle_api_error(self, e: Exception, operation: str) -> None:
- """Handle API errors and raise appropriate custom exceptions."""
- if isinstance(e, subprocess.SubprocessError):
- raise LumeConnectionError(f"Failed to connect to PyLume server: {str(e)}")
- elif isinstance(e, asyncio.TimeoutError):
- raise LumeTimeoutError(f"Request timed out: {str(e)}")
-
- if not hasattr(e, "status") and not isinstance(e, subprocess.CalledProcessError):
- raise LumeServerError(f"Unknown error during {operation}: {str(e)}")
-
- status_code = getattr(e, "status", 500)
- response_text = str(e)
-
- self._log_debug(
- f"{operation} request failed", status_code=status_code, response_text=response_text
- )
-
- if status_code == 404:
- raise LumeNotFoundError(f"Resource not found during {operation}")
- elif status_code == 400:
- raise LumeConfigError(f"Invalid configuration for {operation}: {response_text}")
- elif status_code >= 500:
- raise LumeServerError(
- f"Server error during {operation}",
- status_code=status_code,
- response_text=response_text,
- )
- else:
- raise LumeServerError(
- f"Error during {operation}", status_code=status_code, response_text=response_text
- )
-
- async def _read_output(self) -> None:
- """Read and log server output."""
- try:
- while True:
- if not self.server.server_process or self.server.server_process.poll() is not None:
- self._log_debug("Server process ended")
- break
-
- # Read stdout without blocking
- if self.server.server_process.stdout:
- while True:
- line = self.server.server_process.stdout.readline()
- if not line:
- break
- line = line.strip()
- self._log_debug(f"Server stdout: {line}")
- if "Server started" in line.decode("utf-8"):
- self._log_debug("Detected server started message")
- return
-
- # Read stderr without blocking
- if self.server.server_process.stderr:
- while True:
- line = self.server.server_process.stderr.readline()
- if not line:
- break
- line = line.strip()
- self._log_debug(f"Server stderr: {line}")
- if "error" in line.decode("utf-8").lower():
- raise RuntimeError(f"Server error: {line}")
-
- await asyncio.sleep(0.1) # Small delay to prevent CPU spinning
- except Exception as e:
- self._log_debug(f"Error in output reader: {str(e)}")
- raise
-
- @ensure_server
- async def create_vm(self, spec: Union[VMConfig, dict]) -> None:
- """Create a VM with the given configuration."""
- # Ensure client is initialized
- await self._init_client()
-
- if isinstance(spec, VMConfig):
- spec = spec.model_dump(by_alias=True, exclude_none=True)
-
- # Suppress optional attribute access errors
- self.client.print_curl("POST", "/vms", spec) # type: ignore[attr-defined]
- await self.client.post("/vms", spec) # type: ignore[attr-defined]
-
- @ensure_server
- async def run_vm(self, name: str, opts: Optional[Union[VMRunOpts, dict]] = None) -> None:
- """Run a VM."""
- if opts is None:
- opts = VMRunOpts(no_display=False) # type: ignore[attr-defined]
- elif isinstance(opts, dict):
- opts = VMRunOpts(**opts)
-
- payload = opts.model_dump(by_alias=True, exclude_none=True)
- self.client.print_curl("POST", f"/vms/{name}/run", payload) # type: ignore[attr-defined]
- await self.client.post(f"/vms/{name}/run", payload) # type: ignore[attr-defined]
-
- @ensure_server
- async def list_vms(self) -> List[VMStatus]:
- """List all VMs."""
- data = await self.client.get("/vms") # type: ignore[attr-defined]
- return [VMStatus.model_validate(vm) for vm in data]
-
- @ensure_server
- async def get_vm(self, name: str) -> VMStatus:
- """Get VM details."""
- data = await self.client.get(f"/vms/{name}") # type: ignore[attr-defined]
- return VMStatus.model_validate(data)
-
- @ensure_server
- async def update_vm(self, name: str, params: Union[VMUpdateOpts, dict]) -> None:
- """Update VM settings."""
- if isinstance(params, dict):
- params = VMUpdateOpts(**params)
-
- payload = params.model_dump(by_alias=True, exclude_none=True)
- self.client.print_curl("PATCH", f"/vms/{name}", payload) # type: ignore[attr-defined]
- await self.client.patch(f"/vms/{name}", payload) # type: ignore[attr-defined]
-
- @ensure_server
- async def stop_vm(self, name: str) -> None:
- """Stop a VM."""
- await self.client.post(f"/vms/{name}/stop") # type: ignore[attr-defined]
-
- @ensure_server
- async def delete_vm(self, name: str) -> None:
- """Delete a VM."""
- await self.client.delete(f"/vms/{name}") # type: ignore[attr-defined]
-
- @ensure_server
- async def pull_image(
- self, spec: Union[ImageRef, dict, str], name: Optional[str] = None
- ) -> None:
- """Pull a VM image."""
- await self._init_client()
- if isinstance(spec, str):
- if ":" in spec:
- image_str = spec
- else:
- image_str = f"{spec}:latest"
- registry = "ghcr.io"
- organization = "trycua"
- elif isinstance(spec, dict):
- image = spec.get("image", "")
- tag = spec.get("tag", "latest")
- image_str = f"{image}:{tag}"
- registry = spec.get("registry", "ghcr.io")
- organization = spec.get("organization", "trycua")
- else:
- image_str = f"{spec.image}:{spec.tag}"
- registry = spec.registry
- organization = spec.organization
-
- payload = {
- "image": image_str,
- "name": name,
- "registry": registry,
- "organization": organization,
- }
-
- self.client.print_curl("POST", "/pull", payload) # type: ignore[attr-defined]
- await self.client.post("/pull", payload, timeout=300.0) # type: ignore[attr-defined]
-
- @ensure_server
- async def clone_vm(self, name: str, new_name: str) -> None:
- """Clone a VM with the given name to a new VM with new_name."""
- config = CloneSpec(name=name, newName=new_name)
- self.client.print_curl("POST", "/vms/clone", config.model_dump()) # type: ignore[attr-defined]
- await self.client.post("/vms/clone", config.model_dump()) # type: ignore[attr-defined]
-
- @ensure_server
- async def get_latest_ipsw_url(self) -> str:
- """Get the latest IPSW URL."""
- await self._init_client()
- data = await self.client.get("/ipsw") # type: ignore[attr-defined]
- return data["url"]
-
- @ensure_server
- async def get_images(self, organization: Optional[str] = None) -> ImageList:
- """Get list of available images."""
- await self._init_client()
- params = {"organization": organization} if organization else None
- data = await self.client.get("/images", params) # type: ignore[attr-defined]
- return ImageList(root=data)
-
- async def close(self) -> None:
- """Close the client and stop the server."""
- if self.client is not None:
- await self.client.close()
- self.client = None
- await asyncio.sleep(1)
- await self.server.stop()
-
- async def _ensure_client(self) -> None:
- """Ensure client is initialized."""
- if self.client is None:
- await self._init_client()
diff --git a/libs/python/pylume/pylume/server.py b/libs/python/pylume/pylume/server.py
deleted file mode 100644
index cab5f627..00000000
--- a/libs/python/pylume/pylume/server.py
+++ /dev/null
@@ -1,481 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-import random
-import shlex
-import signal
-import socket
-import subprocess
-import sys
-import tempfile
-import time
-from logging import getLogger
-from typing import Optional
-
-from .exceptions import LumeConnectionError
-
-
-class LumeServer:
- def __init__(
- self,
- debug: bool = False,
- server_start_timeout: int = 60,
- port: Optional[int] = None,
- use_existing_server: bool = False,
- host: str = "localhost",
- ):
- """Initialize the LumeServer.
-
- Args:
- debug: Enable debug logging
- server_start_timeout: Timeout in seconds to wait for server to start
- port: Specific port to use for the server
- use_existing_server: If True, will try to connect to an existing server
- instead of starting a new one
- host: Host to use for connections (e.g., "localhost", "127.0.0.1", "host.docker.internal")
- """
- self.debug = debug
- self.server_start_timeout = server_start_timeout
- self.server_process = None
- self.output_file = None
- self.requested_port = port
- self.port = None
- self.base_url = None
- self.use_existing_server = use_existing_server
- self.host = host
-
- # Configure logging
- self.logger = getLogger("pylume.server")
- if not self.logger.handlers:
- handler = logging.StreamHandler()
- formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
- handler.setFormatter(formatter)
- self.logger.addHandler(handler)
- self.logger.setLevel(logging.DEBUG if debug else logging.INFO)
-
- self.logger.debug(f"Server initialized with host: {self.host}")
-
- def _check_port_available(self, port: int) -> bool:
- """Check if a port is available."""
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.settimeout(0.5)
- result = s.connect_ex(("127.0.0.1", port))
- if result == 0: # Port is in use on localhost
- return False
- except:
- pass
-
- # Check the specified host (e.g., "host.docker.internal") if it's not a localhost alias
- if self.host not in ["localhost", "127.0.0.1"]:
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- s.settimeout(0.5)
- result = s.connect_ex((self.host, port))
- if result == 0: # Port is in use on host
- return False
- except:
- pass
-
- return True
-
- def _get_server_port(self) -> int:
- """Get an available port for the server."""
- # Use requested port if specified
- if self.requested_port is not None:
- if not self._check_port_available(self.requested_port):
- raise RuntimeError(f"Requested port {self.requested_port} is not available")
- return self.requested_port
-
- # Find a free port
- for _ in range(10): # Try up to 10 times
- port = random.randint(49152, 65535)
- if self._check_port_available(port):
- return port
-
- raise RuntimeError("Could not find an available port")
-
- async def _ensure_server_running(self) -> None:
- """Ensure the lume server is running, start it if it's not."""
- try:
- self.logger.debug("Checking if lume server is running...")
- # Try to connect to the server with a short timeout
- cmd = ["curl", "-s", "-w", "%{http_code}", "-m", "5", f"{self.base_url}/vms"]
- process = await asyncio.create_subprocess_exec(
- *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- stdout, stderr = await process.communicate()
-
- if process.returncode == 0:
- response = stdout.decode()
- status_code = int(response[-3:])
- if status_code == 200:
- self.logger.debug("PyLume server is running")
- return
-
- self.logger.debug("PyLume server not running, attempting to start it")
- # Server not running, try to start it
- lume_path = os.path.join(os.path.dirname(__file__), "lume")
- if not os.path.exists(lume_path):
- raise RuntimeError(f"Could not find lume binary at {lume_path}")
-
- # Make sure the file is executable
- os.chmod(lume_path, 0o755)
-
- # Create a temporary file for server output
- self.output_file = tempfile.NamedTemporaryFile(mode="w+", delete=False)
- self.logger.debug(f"Using temporary file for server output: {self.output_file.name}")
-
- # Start the server
- self.logger.debug(f"Starting lume server with: {lume_path} serve --port {self.port}")
-
- # Start server in background using subprocess.Popen
- try:
- self.server_process = subprocess.Popen(
- [lume_path, "serve", "--port", str(self.port)],
- stdout=self.output_file,
- stderr=self.output_file,
- cwd=os.path.dirname(lume_path),
- start_new_session=True, # Run in new session to avoid blocking
- )
- except Exception as e:
- self.output_file.close()
- os.unlink(self.output_file.name)
- raise RuntimeError(f"Failed to start lume server process: {str(e)}")
-
- # Wait for server to start
- self.logger.debug(
- f"Waiting up to {self.server_start_timeout} seconds for server to start..."
- )
- start_time = time.time()
- server_ready = False
- last_size = 0
-
- while time.time() - start_time < self.server_start_timeout:
- if self.server_process.poll() is not None:
- # Process has terminated
- self.output_file.seek(0)
- output = self.output_file.read()
- self.output_file.close()
- os.unlink(self.output_file.name)
- error_msg = (
- f"Server process terminated unexpectedly.\n"
- f"Exit code: {self.server_process.returncode}\n"
- f"Output: {output}"
- )
- raise RuntimeError(error_msg)
-
- # Check output file for server ready message
- self.output_file.seek(0, os.SEEK_END)
- size = self.output_file.tell()
- if size > last_size: # Only read if there's new content
- self.output_file.seek(last_size)
- new_output = self.output_file.read()
- if new_output.strip(): # Only log non-empty output
- self.logger.debug(f"Server output: {new_output.strip()}")
- last_size = size
-
- if "Server started" in new_output:
- server_ready = True
- self.logger.debug("Server startup detected")
- break
-
- # Try to connect to the server periodically
- try:
- cmd = ["curl", "-s", "-w", "%{http_code}", "-m", "5", f"{self.base_url}/vms"]
- process = await asyncio.create_subprocess_exec(
- *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- stdout, stderr = await process.communicate()
-
- if process.returncode == 0:
- response = stdout.decode()
- status_code = int(response[-3:])
- if status_code == 200:
- server_ready = True
- self.logger.debug("Server is responding to requests")
- break
- except:
- pass # Server not ready yet
-
- await asyncio.sleep(1.0)
-
- if not server_ready:
- # Cleanup if server didn't start
- if self.server_process:
- self.server_process.terminate()
- try:
- self.server_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.server_process.kill()
- self.output_file.close()
- os.unlink(self.output_file.name)
- raise RuntimeError(
- f"Failed to start lume server after {self.server_start_timeout} seconds. "
- "Check the debug output for more details."
- )
-
- # Give the server a moment to fully initialize
- await asyncio.sleep(2.0)
-
- # Verify server is responding
- try:
- cmd = ["curl", "-s", "-w", "%{http_code}", "-m", "10", f"{self.base_url}/vms"]
- process = await asyncio.create_subprocess_exec(
- *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- stdout, stderr = await process.communicate()
-
- if process.returncode != 0:
- raise RuntimeError(f"Curl command failed: {stderr.decode()}")
-
- response = stdout.decode()
- status_code = int(response[-3:])
-
- if status_code != 200:
- raise RuntimeError(f"Server returned status code {status_code}")
-
- self.logger.debug("PyLume server started successfully")
- except Exception as e:
- self.logger.debug(f"Server verification failed: {str(e)}")
- if self.server_process:
- self.server_process.terminate()
- try:
- self.server_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.server_process.kill()
- self.output_file.close()
- os.unlink(self.output_file.name)
- raise RuntimeError(f"Server started but is not responding: {str(e)}")
-
- self.logger.debug("Server startup completed successfully")
-
- except Exception as e:
- raise RuntimeError(f"Failed to start lume server: {str(e)}")
-
- async def _start_server(self) -> None:
- """Start the lume server using the lume executable."""
- self.logger.debug("Starting PyLume server")
-
- # Get absolute path to lume executable in the same directory as this file
- lume_path = os.path.join(os.path.dirname(__file__), "lume")
- if not os.path.exists(lume_path):
- raise RuntimeError(f"Could not find lume binary at {lume_path}")
-
- try:
- # Make executable
- os.chmod(lume_path, 0o755)
-
- # Get and validate port
- self.port = self._get_server_port()
- self.base_url = f"http://{self.host}:{self.port}/lume"
-
- # Set up output handling
- self.output_file = tempfile.NamedTemporaryFile(mode="w+", delete=False)
-
- # Start the server process with the lume executable
- env = os.environ.copy()
- env["RUST_BACKTRACE"] = "1" # Enable backtrace for better error reporting
-
- # Specify the host to bind to (0.0.0.0 to allow external connections)
- self.server_process = subprocess.Popen(
- [lume_path, "serve", "--port", str(self.port)],
- stdout=self.output_file,
- stderr=subprocess.STDOUT,
- cwd=os.path.dirname(lume_path), # Run from same directory as executable
- env=env,
- )
-
- # Wait for server to initialize
- await asyncio.sleep(2)
- await self._wait_for_server()
-
- except Exception as e:
- await self._cleanup()
- raise RuntimeError(f"Failed to start lume server process: {str(e)}")
-
- async def _tail_log(self) -> None:
- """Read and display server log output in debug mode."""
- while True:
- try:
- self.output_file.seek(0, os.SEEK_END) # type: ignore[attr-defined]
- line = self.output_file.readline() # type: ignore[attr-defined]
- if line:
- line = line.strip()
- if line:
- print(f"SERVER: {line}")
- if self.server_process.poll() is not None: # type: ignore[attr-defined]
- print("Server process ended")
- break
- await asyncio.sleep(0.1)
- except Exception as e:
- print(f"Error reading log: {e}")
- await asyncio.sleep(0.1)
-
- async def _wait_for_server(self) -> None:
- """Wait for server to start and become responsive with increased timeout."""
- start_time = time.time()
- while time.time() - start_time < self.server_start_timeout:
- if self.server_process.poll() is not None: # type: ignore[attr-defined]
- error_msg = await self._get_error_output()
- await self._cleanup()
- raise RuntimeError(error_msg)
-
- try:
- await self._verify_server()
- self.logger.debug("Server is now responsive")
- return
- except Exception as e:
- self.logger.debug(f"Server not ready yet: {str(e)}")
- await asyncio.sleep(1.0)
-
- await self._cleanup()
- raise RuntimeError(f"Server failed to start after {self.server_start_timeout} seconds")
-
- async def _verify_server(self) -> None:
- """Verify server is responding to requests."""
- try:
- cmd = [
- "curl",
- "-s",
- "-w",
- "%{http_code}",
- "-m",
- "10",
- f"http://{self.host}:{self.port}/lume/vms",
- ]
- process = await asyncio.create_subprocess_exec(
- *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- stdout, stderr = await process.communicate()
-
- if process.returncode != 0:
- raise RuntimeError(f"Curl command failed: {stderr.decode()}")
-
- response = stdout.decode()
- status_code = int(response[-3:])
-
- if status_code != 200:
- raise RuntimeError(f"Server returned status code {status_code}")
-
- self.logger.debug("PyLume server started successfully")
- except Exception as e:
- raise RuntimeError(f"Server not responding: {str(e)}")
-
- async def _get_error_output(self) -> str:
- """Get error output from the server process."""
- if not self.output_file:
- return "No output available"
- self.output_file.seek(0)
- output = self.output_file.read()
- return (
- f"Server process terminated unexpectedly.\n"
- f"Exit code: {self.server_process.returncode}\n" # type: ignore[attr-defined]
- f"Output: {output}"
- )
-
- async def _cleanup(self) -> None:
- """Clean up all server resources."""
- if self.server_process:
- try:
- self.server_process.terminate()
- try:
- self.server_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.server_process.kill()
- except:
- pass
- self.server_process = None
-
- # Clean up output file
- if self.output_file:
- try:
- self.output_file.close()
- os.unlink(self.output_file.name)
- except Exception as e:
- self.logger.debug(f"Error cleaning up output file: {e}")
- self.output_file = None
-
- async def ensure_running(self) -> None:
- """Ensure the server is running.
-
- If use_existing_server is True, will only try to connect to an existing server.
- Otherwise will:
- 1. Try to connect to an existing server on the specified port
- 2. If that fails and not in Docker, start a new server
- 3. If in Docker and no existing server is found, raise an error
- """
- # First check if we're in Docker
- in_docker = os.path.exists("/.dockerenv") or (
- os.path.exists("/proc/1/cgroup") and "docker" in open("/proc/1/cgroup", "r").read()
- )
-
- # If using a non-localhost host like host.docker.internal, set up the connection details
- if self.host not in ["localhost", "127.0.0.1"]:
- if self.requested_port is None:
- raise RuntimeError("Port must be specified when using a remote host")
-
- self.port = self.requested_port
- self.base_url = f"http://{self.host}:{self.port}/lume"
- self.logger.debug(f"Using remote host server at {self.base_url}")
-
- # Try to verify the server is accessible
- try:
- await self._verify_server()
- self.logger.debug("Successfully connected to remote server")
- return
- except Exception as e:
- if self.use_existing_server or in_docker:
- # If explicitly requesting an existing server or in Docker, we can't start a new one
- raise RuntimeError(
- f"Failed to connect to remote server at {self.base_url}: {str(e)}"
- )
- else:
- self.logger.debug(f"Remote server not available at {self.base_url}: {str(e)}")
- # Fall back to localhost for starting a new server
- self.host = "localhost"
-
- # If explicitly using an existing server, verify it's running
- if self.use_existing_server:
- if self.requested_port is None:
- raise RuntimeError("Port must be specified when using an existing server")
-
- self.port = self.requested_port
- self.base_url = f"http://{self.host}:{self.port}/lume"
-
- try:
- await self._verify_server()
- self.logger.debug("Successfully connected to existing server")
- except Exception as e:
- raise RuntimeError(
- f"Failed to connect to existing server at {self.base_url}: {str(e)}"
- )
- else:
- # Try to connect to an existing server first
- if self.requested_port is not None:
- self.port = self.requested_port
- self.base_url = f"http://{self.host}:{self.port}/lume"
-
- try:
- await self._verify_server()
- self.logger.debug("Successfully connected to existing server")
- return
- except Exception:
- self.logger.debug(f"No existing server found at {self.base_url}")
-
- # If in Docker and can't connect to existing server, raise an error
- if in_docker:
- raise RuntimeError(
- f"Failed to connect to server at {self.base_url} and cannot start a new server in Docker"
- )
-
- # Start a new server
- self.logger.debug("Starting a new server instance")
- await self._start_server()
-
- async def stop(self) -> None:
- """Stop the server if we're managing it."""
- if not self.use_existing_server:
- self.logger.debug("Stopping lume server...")
- await self._cleanup()
diff --git a/libs/python/pylume/pyproject.toml b/libs/python/pylume/pyproject.toml
deleted file mode 100644
index 976fe6ff..00000000
--- a/libs/python/pylume/pyproject.toml
+++ /dev/null
@@ -1,51 +0,0 @@
-[build-system]
-build-backend = "pdm.backend"
-requires = ["pdm-backend"]
-
-[project]
-authors = [{ name = "TryCua", email = "gh@trycua.com" }]
-classifiers = [
- "Intended Audience :: Developers",
- "License :: OSI Approved :: MIT License",
- "Operating System :: MacOS :: MacOS X",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
-]
-dependencies = ["pydantic>=2.11.1"]
-description = "Python SDK for lume - run macOS and Linux VMs on Apple Silicon"
-dynamic = ["version"]
-keywords = ["apple-silicon", "macos", "virtualization", "vm"]
-license = { text = "MIT" }
-name = "pylume"
-readme = "README.md"
-requires-python = ">=3.12"
-
-[tool.pdm.version]
-path = "pylume/__init__.py"
-source = "file"
-
-[project.urls]
-homepage = "https://github.com/trycua/pylume"
-repository = "https://github.com/trycua/pylume"
-
-[tool.pdm]
-distribution = true
-
-[tool.pdm.dev-dependencies]
-dev = [
- "black>=23.0.0",
- "isort>=5.12.0",
- "pytest-asyncio>=0.23.0",
- "pytest>=7.0.0",
-]
-
-[tool.pytest.ini_options]
-asyncio_mode = "auto"
-python_files = "test_*.py"
-testpaths = ["tests"]
-
-[tool.pdm.build]
-includes = ["pylume/"]
-source-includes = ["LICENSE", "README.md", "tests/"]
\ No newline at end of file