diff --git a/packages/prime-sandboxes/pyproject.toml b/packages/prime-sandboxes/pyproject.toml index 633c0e93..6b909c15 100644 --- a/packages/prime-sandboxes/pyproject.toml +++ b/packages/prime-sandboxes/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pydantic>=2.0.0", "aiofiles>=23.0.0", "tenacity>=8.0.0", + "pathspec>=1.0.3", ] keywords = ["sandboxes", "remote-execution", "containers", "cloud", "sdk"] classifiers = [ diff --git a/packages/prime-sandboxes/src/prime_sandboxes/__init__.py b/packages/prime-sandboxes/src/prime_sandboxes/__init__.py index 031308d5..5df18295 100644 --- a/packages/prime-sandboxes/src/prime_sandboxes/__init__.py +++ b/packages/prime-sandboxes/src/prime_sandboxes/__init__.py @@ -23,6 +23,7 @@ SandboxUnresponsiveError, UploadTimeoutError, ) +from .image_client import AsyncImageClient, ImageClient from .models import ( AdvancedConfigs, BackgroundJob, @@ -36,6 +37,10 @@ ExposedPort, ExposePortRequest, FileUploadResponse, + Image, + ImageBuildResponse, + ImageBuildStatus, + ImageListResponse, ListExposedPortsResponse, RegistryCredentialSummary, Sandbox, @@ -61,6 +66,9 @@ "AsyncSandboxClient", "TemplateClient", "AsyncTemplateClient", + # Image Clients + "ImageClient", + "AsyncImageClient", # Models "Sandbox", "SandboxStatus", @@ -77,6 +85,11 @@ "AdvancedConfigs", "BackgroundJob", "BackgroundJobStatus", + # Image Models + "Image", + "ImageBuildStatus", + "ImageBuildResponse", + "ImageListResponse", # Port Forwarding "ExposePortRequest", "ExposedPort", diff --git a/packages/prime-sandboxes/src/prime_sandboxes/image_client.py b/packages/prime-sandboxes/src/prime_sandboxes/image_client.py new file mode 100644 index 00000000..be8231ca --- /dev/null +++ b/packages/prime-sandboxes/src/prime_sandboxes/image_client.py @@ -0,0 +1,494 @@ +"""Image client implementations for building and managing container images.""" + +import asyncio +import io +import os +import tarfile +import time +from typing import Any, Dict, Optional + +import httpx +import pathspec + +from .core import APIClient, APIError, AsyncAPIClient +from .models import ( + Image, + ImageBuildResponse, + ImageBuildStatus, + ImageListResponse, +) + + +def _load_dockerignore(context_path: str) -> Optional[pathspec.PathSpec]: + """Load .dockerignore patterns if the file exists.""" + dockerignore_path = os.path.join(context_path, ".dockerignore") + if not os.path.exists(dockerignore_path): + return None + + with open(dockerignore_path, "r") as f: + patterns = f.read().splitlines() + + return pathspec.PathSpec.from_lines("gitwildmatch", patterns) + + +def _create_context_tarball(context_path: str) -> bytes: + """Create a tar.gz archive of the build context directory.""" + buffer = io.BytesIO() + context_path = os.path.abspath(context_path) + spec = _load_dockerignore(context_path) + + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + + def tar_filter(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: + # Get path relative to context (strip leading ./) + rel_path = tarinfo.name.removeprefix("./") + if not rel_path or rel_path == ".": + return tarinfo # Keep root directory + if spec and spec.match_file(rel_path): + return None # Exclude ignored files + return tarinfo + + tar.add(context_path, arcname=".", filter=tar_filter) + + buffer.seek(0) + return buffer.read() + + +class ImageClient: + """Client for building and managing container images.""" + + def __init__(self, api_client: Optional[APIClient] = None): + self.client = api_client or APIClient() + + def build( + self, + name: str, + dockerfile_path: str = "Dockerfile", + tag: str = "latest", + context_path: Optional[str] = None, + ephemeral: bool = False, + team_id: Optional[str] = None, + wait: bool = True, + poll_interval: float = 2.0, + timeout: int = 1800, + ) -> Image: + """Build a container image from a Dockerfile. + + Args: + name: Image name + dockerfile_path: Path to Dockerfile (default: "Dockerfile") + tag: Image tag (default: "latest") + context_path: Build context directory path. If not provided, only + the Dockerfile is sent to the server (fast path, no COPY/ADD support). + ephemeral: Auto-delete after 24 hours (default: False) + team_id: Team ID for team images (optional) + wait: Wait for build to complete (default: True) + poll_interval: Seconds between status polls (default: 2.0) + timeout: Maximum seconds to wait for build (default: 1800) + + Returns: + Image object with build result + + Raises: + FileNotFoundError: If Dockerfile not found + APIError: If build fails + """ + # Auto-populate team_id from config if not specified + if team_id is None: + team_id = self.client.config.team_id + + # Build request + build_request: Dict[str, Any] = { + "image_name": name, + "image_tag": tag, + "dockerfile_path": dockerfile_path, + "is_ephemeral": ephemeral, + } + if team_id: + build_request["team_id"] = team_id + + if context_path: + if not os.path.isdir(context_path): + raise FileNotFoundError(f"Build context directory not found: {context_path}") + dockerfile_in_context = os.path.join(context_path, dockerfile_path) + if not os.path.exists(dockerfile_in_context): + raise FileNotFoundError(f"Dockerfile not found at {dockerfile_in_context}") + + # No context_path means read Dockerfile and send content directly + if not context_path: + if not os.path.exists(dockerfile_path): + raise FileNotFoundError(f"Dockerfile not found: {dockerfile_path}") + + with open(dockerfile_path, "r") as f: + dockerfile_content = f.read() + + build_request["dockerfile_content"] = dockerfile_content + + response = self.client.request("POST", "/images/build", json=build_request) + build_response = ImageBuildResponse.model_validate(response) + + # Check if cached image was found, construct Image directly without extra API call + if build_response.cached: + return Image( + id=build_response.image_id, + image_ref=build_response.image_ref, + name=build_response.image_name, + tag=build_response.image_tag, + status=ImageBuildStatus.COMPLETED, + created_at=build_response.created_at, + ) + + # Upload build context and start build + if build_response.upload_url and context_path: + context_bytes = _create_context_tarball(context_path) + self._upload_context(build_response.upload_url, context_bytes) + + self.client.request( + "POST", + f"/images/build/{build_response.build_id}/start", + json={"context_uploaded": True}, + ) + + if not wait: + return self._get_image_from_build_id(build_response.build_id) + + # Poll for completion + return self._wait_for_build( + build_response.build_id, + poll_interval=poll_interval, + timeout=timeout, + ) + + def _upload_context(self, upload_url: str, context_bytes: bytes) -> None: + """Upload build context to presigned URL.""" + with httpx.Client(timeout=600) as http_client: + response = http_client.put( + upload_url, + content=context_bytes, + headers={"Content-Type": "application/gzip"}, + ) + response.raise_for_status() + + def _get_image_from_build_id(self, build_id: str) -> Image: + """Get Image object from build status.""" + response = self.client.request("GET", f"/images/build/{build_id}") + return Image( + id=response["id"], + imageRef=response.get("fullImagePath", ""), + imageName=response["imageName"], + imageTag=response["imageTag"], + status=ImageBuildStatus(response["status"]), + sizeBytes=response.get("sizeBytes"), + isEphemeral=response.get("isEphemeral", False), + dockerfileHash=response.get("dockerfileHash"), + createdAt=response["createdAt"], + errorMessage=response.get("errorMessage"), + teamId=response.get("teamId"), + ) + + def _wait_for_build( + self, + build_id: str, + poll_interval: float = 2.0, + timeout: int = 1800, + ) -> Image: + """Wait for build to complete and return Image.""" + start_time = time.time() + + while True: + if time.time() - start_time > timeout: + raise APIError(f"Build {build_id} timed out after {timeout} seconds") + + image = self._get_image_from_build_id(build_id) + + if image.status == ImageBuildStatus.COMPLETED: + return image + elif image.status == ImageBuildStatus.FAILED: + raise APIError(f"Build {build_id} failed: {image.error_message or 'Unknown error'}") + elif image.status == ImageBuildStatus.CANCELLED: + raise APIError(f"Build {build_id} was cancelled") + + time.sleep(poll_interval) + + def get_build_status(self, build_id: str) -> Image: + """Get the current status of an image build.""" + return self._get_image_from_build_id(build_id) + + def list(self, team_id: Optional[str] = None) -> ImageListResponse: + """List all images accessible to the current user.""" + if team_id is None: + team_id = self.client.config.team_id + + params = {} + if team_id: + params["teamId"] = team_id + + response = self.client.request("GET", "/images", params=params or None) + images_data = response.get("data", []) + + images = [] + for img in images_data: + images.append( + Image( + id=img["id"], + imageRef=img.get("fullImagePath", ""), + imageName=img["imageName"], + imageTag=img["imageTag"], + status=ImageBuildStatus(img["status"]), + sizeBytes=img.get("sizeBytes"), + isEphemeral=img.get("isEphemeral", False), + dockerfileHash=img.get("dockerfileHash"), + createdAt=img["createdAt"], + errorMessage=img.get("errorMessage"), + teamId=img.get("teamId"), + displayRef=img.get("displayRef"), + ) + ) + + return ImageListResponse(images=images, total=len(images)) + + def delete( + self, + name: str, + tag: str = "latest", + team_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete an image.""" + if team_id is None: + team_id = self.client.config.team_id + + params = {} + if team_id: + params["teamId"] = team_id + + return self.client.request( + "DELETE", + f"/images/{name}/{tag}", + params=params or None, + ) + + +class AsyncImageClient: + """Async client for building and managing container images.""" + + def __init__(self, api_client: Optional[AsyncAPIClient] = None): + self.client = api_client or AsyncAPIClient() + + async def build( + self, + name: str, + dockerfile_path: str = "Dockerfile", + tag: str = "latest", + context_path: Optional[str] = None, + ephemeral: bool = False, + team_id: Optional[str] = None, + wait: bool = True, + poll_interval: float = 2.0, + timeout: int = 1800, + ) -> Image: + """Build a container image from a Dockerfile. + + Args: + name: Image name + dockerfile_path: Path to Dockerfile (default: "Dockerfile") + tag: Image tag (default: "latest") + context_path: Build context directory path. If not provided, only + the Dockerfile is sent to the server (fast path, no COPY/ADD support). + ephemeral: Auto-delete after 24 hours (default: False) + team_id: Team ID for team images (optional) + wait: Wait for build to complete (default: True) + poll_interval: Seconds between status polls (default: 2.0) + timeout: Maximum seconds to wait for build (default: 1800) + + Returns: + Image object with build result + + Raises: + FileNotFoundError: If Dockerfile not found + APIError: If build fails + """ + if team_id is None: + team_id = self.client.config.team_id + + build_request: Dict[str, Any] = { + "image_name": name, + "image_tag": tag, + "dockerfile_path": dockerfile_path, + "is_ephemeral": ephemeral, + } + if team_id: + build_request["team_id"] = team_id + + # Validate context_path and Dockerfile exist before making API call + if context_path: + if not os.path.isdir(context_path): + raise FileNotFoundError(f"Build context directory not found: {context_path}") + dockerfile_in_context = os.path.join(context_path, dockerfile_path) + if not os.path.exists(dockerfile_in_context): + raise FileNotFoundError(f"Dockerfile not found at {dockerfile_in_context}") + + # TODO: Use aiofiles or run_in_executor for file I/O to avoid blocking event loop + # No context_path means read Dockerfile and send content directly + if not context_path: + if not os.path.exists(dockerfile_path): + raise FileNotFoundError(f"Dockerfile not found: {dockerfile_path}") + + with open(dockerfile_path, "r") as f: + dockerfile_content = f.read() + + build_request["dockerfile_content"] = dockerfile_content + + response = await self.client.request("POST", "/images/build", json=build_request) + build_response = ImageBuildResponse.model_validate(response) + + # Check if cached image was found, construct Image directly without extra API call + if build_response.cached: + return Image( + id=build_response.image_id, + image_ref=build_response.image_ref, + name=build_response.image_name, + tag=build_response.image_tag, + status=ImageBuildStatus.COMPLETED, + created_at=build_response.created_at, + ) + + # Upload build context and start build + if build_response.upload_url and context_path: + context_bytes = _create_context_tarball(context_path) + await self._upload_context(build_response.upload_url, context_bytes) + + await self.client.request( + "POST", + f"/images/build/{build_response.build_id}/start", + json={"context_uploaded": True}, + ) + + if not wait: + return await self._get_image_from_build_id(build_response.build_id) + + # Poll for completion + return await self._wait_for_build( + build_response.build_id, + poll_interval=poll_interval, + timeout=timeout, + ) + + async def _upload_context(self, upload_url: str, context_bytes: bytes) -> None: + """Upload build context to presigned URL.""" + async with httpx.AsyncClient(timeout=600) as http_client: + response = await http_client.put( + upload_url, + content=context_bytes, + headers={"Content-Type": "application/gzip"}, + ) + response.raise_for_status() + + async def _get_image_from_build_id(self, build_id: str) -> Image: + """Get Image object from build status.""" + response = await self.client.request("GET", f"/images/build/{build_id}") + return Image( + id=response["id"], + imageRef=response.get("fullImagePath", ""), + imageName=response["imageName"], + imageTag=response["imageTag"], + status=ImageBuildStatus(response["status"]), + sizeBytes=response.get("sizeBytes"), + isEphemeral=response.get("isEphemeral", False), + dockerfileHash=response.get("dockerfileHash"), + createdAt=response["createdAt"], + errorMessage=response.get("errorMessage"), + teamId=response.get("teamId"), + ) + + async def _wait_for_build( + self, + build_id: str, + poll_interval: float = 2.0, + timeout: int = 1800, + ) -> Image: + """Wait for build to complete and return Image.""" + start_time = time.time() + + while True: + if time.time() - start_time > timeout: + raise APIError(f"Build {build_id} timed out after {timeout} seconds") + + image = await self._get_image_from_build_id(build_id) + + if image.status == ImageBuildStatus.COMPLETED: + return image + elif image.status == ImageBuildStatus.FAILED: + raise APIError(f"Build {build_id} failed: {image.error_message or 'Unknown error'}") + elif image.status == ImageBuildStatus.CANCELLED: + raise APIError(f"Build {build_id} was cancelled") + + await asyncio.sleep(poll_interval) + + async def get_build_status(self, build_id: str) -> Image: + """Get the current status of an image build.""" + return await self._get_image_from_build_id(build_id) + + async def list(self, team_id: Optional[str] = None) -> ImageListResponse: + """List all images accessible to the current user.""" + if team_id is None: + team_id = self.client.config.team_id + + params = {} + if team_id: + params["teamId"] = team_id + + response = await self.client.request("GET", "/images", params=params or None) + images_data = response.get("data", []) + + images = [] + for img in images_data: + images.append( + Image( + id=img["id"], + imageRef=img.get("fullImagePath", ""), + imageName=img["imageName"], + imageTag=img["imageTag"], + status=ImageBuildStatus(img["status"]), + sizeBytes=img.get("sizeBytes"), + isEphemeral=img.get("isEphemeral", False), + dockerfileHash=img.get("dockerfileHash"), + createdAt=img["createdAt"], + errorMessage=img.get("errorMessage"), + teamId=img.get("teamId"), + displayRef=img.get("displayRef"), + ) + ) + + return ImageListResponse(images=images, total=len(images)) + + async def delete( + self, + name: str, + tag: str = "latest", + team_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete an image.""" + if team_id is None: + team_id = self.client.config.team_id + + params = {} + if team_id: + params["teamId"] = team_id + + return await self.client.request( + "DELETE", + f"/images/{name}/{tag}", + params=params or None, + ) + + async def aclose(self) -> None: + """Close the async client.""" + await self.client.aclose() + + async def __aenter__(self) -> "AsyncImageClient": + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Async context manager exit.""" + await self.aclose() diff --git a/packages/prime-sandboxes/src/prime_sandboxes/models.py b/packages/prime-sandboxes/src/prime_sandboxes/models.py index 36df7cae..ee610431 100644 --- a/packages/prime-sandboxes/src/prime_sandboxes/models.py +++ b/packages/prime-sandboxes/src/prime_sandboxes/models.py @@ -19,6 +19,36 @@ class SandboxStatus(str, Enum): TIMEOUT = "TIMEOUT" +class ImageBuildStatus(str, Enum): + """Image build status enum""" + + PENDING = "PENDING" + UPLOADING = "UPLOADING" + BUILDING = "BUILDING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class Image(BaseModel): + """Image model representing a built container image.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + image_ref: str = Field(..., alias="imageRef", description="Full image reference") + name: str = Field(..., alias="imageName", description="Image name") + tag: str = Field(..., alias="imageTag", description="Image tag") + status: ImageBuildStatus + size_bytes: Optional[int] = Field(default=None, alias="sizeBytes") + ephemeral: bool = Field(default=False, alias="isEphemeral") + dockerfile_hash: Optional[str] = Field(default=None, alias="dockerfileHash") + created_at: datetime = Field(..., alias="createdAt") + error_message: Optional[str] = Field(default=None, alias="errorMessage") + team_id: Optional[str] = Field(default=None, alias="teamId") + display_ref: Optional[str] = Field(default=None, alias="displayRef") + + class AdvancedConfigs(BaseModel): """Advanced configuration options for sandbox""" @@ -77,7 +107,16 @@ class CreateSandboxRequest(BaseModel): """Create sandbox request model""" name: str - docker_image: str + docker_image: Optional[str] = Field(default=None, description="Pre-built image to use") + dockerfile: Optional[str] = Field( + default=None, + description="Path to Dockerfile for building image (e.g., './Dockerfile')", + ) + build_context: Optional[str] = Field( + default=None, + description="Build context directory path. If not provided, only the " + "Dockerfile is used (no COPY/ADD support).", + ) start_command: Optional[str] = "tail -f /dev/null" cpu_cores: int = 1 memory_gb: int = 2 @@ -239,3 +278,34 @@ class BackgroundJobStatus(BaseModel): exit_code: Optional[int] = None stdout: Optional[str] = None stderr: Optional[str] = None + + +class ImageListResponse(BaseModel): + """Response model for listing images.""" + + images: List[Image] + total: int + + +class ImageBuildResponse(BaseModel): + """Response model for image build initiation.""" + + model_config = ConfigDict(populate_by_name=True) + + build_id: Optional[str] = Field( + default=None, alias="buildId", description="Build ID for tracking (None when cached)" + ) + upload_url: Optional[str] = Field( + default=None, alias="uploadUrl", description="Presigned URL for context upload" + ) + expires_in: Optional[int] = Field( + default=None, alias="expiresIn", description="URL validity in seconds" + ) + image_ref: str = Field(..., alias="fullImagePath", description="Full image reference") + cached: bool = Field(default=False, description="Whether a cached image was found") + image_id: Optional[str] = Field(default=None, alias="imageId", description="Image ID") + image_name: Optional[str] = Field(default=None, alias="imageName", description="Image name") + image_tag: Optional[str] = Field(default=None, alias="imageTag", description="Image tag") + created_at: Optional[datetime] = Field( + default=None, alias="createdAt", description="Creation timestamp" + ) diff --git a/packages/prime/pyproject.toml b/packages/prime/pyproject.toml index e1e4267f..72906505 100644 --- a/packages/prime/pyproject.toml +++ b/packages/prime/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "verifiers>=0.1.9.post1", "build>=1.0.0", "toml>=0.10.0", + "pathspec>=1.0.3", ] keywords = ["cli", "gpu", "cloud", "compute"] classifiers = [ diff --git a/packages/prime/src/prime_cli/commands/images.py b/packages/prime/src/prime_cli/commands/images.py index 4916bd62..60d880bb 100644 --- a/packages/prime/src/prime_cli/commands/images.py +++ b/packages/prime/src/prime_cli/commands/images.py @@ -5,9 +5,10 @@ import tempfile from datetime import datetime from pathlib import Path +from typing import Optional -import click import httpx +import pathspec import typer from prime_sandboxes import APIClient, APIError, Config, UnauthorizedError from rich.console import Console @@ -23,6 +24,16 @@ config = Config() +def _load_dockerignore(context_path: str) -> Optional[pathspec.PathSpec]: + """Load .dockerignore patterns if the file exists.""" + dockerignore_path = Path(context_path) / ".dockerignore" + if not dockerignore_path.exists(): + return None + + patterns = dockerignore_path.read_text().splitlines() + return pathspec.PathSpec.from_lines("gitwildmatch", patterns) + + @app.command("push") def push_image( image_reference: str = typer.Argument( @@ -30,12 +41,6 @@ def push_image( ), dockerfile: str = typer.Option("Dockerfile", "--dockerfile", "-f", help="Path to Dockerfile"), context: str = typer.Option(".", "--context", "-c", help="Build context directory"), - platform: str = typer.Option( - "linux/amd64", - "--platform", - click_type=click.Choice(["linux/amd64", "linux/arm64"]), - help="Target platform (defaults to linux/amd64 for Kubernetes compatibility)", - ), ): """ Build and push a Docker image to Prime Intellect registry. @@ -73,8 +78,19 @@ def push_image( tar_path = tmp_file.name try: + # Load .dockerignore if present + spec = _load_dockerignore(context) + + def tar_filter(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: + rel_path = tarinfo.name.removeprefix("./") + if not rel_path or rel_path == ".": + return tarinfo # Keep root directory + if spec and spec.match_file(rel_path): + return None # Exclude ignored files + return tarinfo + with tarfile.open(tar_path, "w:gz") as tar: - tar.add(context, arcname=".") + tar.add(context, arcname=".", filter=tar_filter) tar_size_mb = Path(tar_path).stat().st_size / (1024 * 1024) console.print(f"[green]✓[/green] Build context packaged ({tar_size_mb:.2f} MB)") @@ -90,7 +106,6 @@ def push_image( "image_name": image_name, "image_tag": image_tag, "dockerfile_path": dockerfile, - "platform": platform, }, ) except UnauthorizedError: diff --git a/uv.lock b/uv.lock index b2bdb53c..815c0a17 100644 --- a/uv.lock +++ b/uv.lock @@ -1609,6 +1609,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + [[package]] name = "pathvalidate" version = "3.3.1" @@ -1659,6 +1668,7 @@ dependencies = [ { name = "build" }, { name = "cryptography" }, { name = "httpx" }, + { name = "pathspec" }, { name = "prime-evals" }, { name = "prime-sandboxes" }, { name = "pydantic" }, @@ -1681,6 +1691,7 @@ requires-dist = [ { name = "build", specifier = ">=1.0.0" }, { name = "cryptography", specifier = ">=41.0.0" }, { name = "httpx", specifier = ">=0.25.0" }, + { name = "pathspec", specifier = ">=1.0.3" }, { name = "prime-evals", editable = "packages/prime-evals" }, { name = "prime-sandboxes", editable = "packages/prime-sandboxes" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -1753,6 +1764,7 @@ source = { editable = "packages/prime-sandboxes" } dependencies = [ { name = "aiofiles" }, { name = "httpx" }, + { name = "pathspec" }, { name = "pydantic" }, { name = "tenacity" }, ] @@ -1769,6 +1781,7 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = ">=23.0.0" }, { name = "httpx", specifier = ">=0.25.0" }, + { name = "pathspec", specifier = ">=1.0.3" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },