From b90e0c812d13a79f4dcebc1903d13408d14eb980 Mon Sep 17 00:00:00 2001 From: Georgi Atsev Date: Fri, 10 Jul 2026 09:04:08 +0300 Subject: [PATCH 1/3] Add Sandbox SDK on a unified httpx client (0.2.0) --- .github/workflows/ci.yml | 42 +- .github/workflows/deploy.yaml | 33 +- CHANGELOG.md | 28 ++ README.md | 109 +++-- deepinfra/__init__.py | 71 +++- deepinfra/_exceptions.py | 181 ++++++++ deepinfra/_sandbox_models.py | 35 ++ deepinfra/_streaming.py | 79 ++++ deepinfra/_utils.py | 51 +++ deepinfra/_version.py | 1 + deepinfra/clients/__init__.py | 4 +- deepinfra/clients/deepinfra.py | 311 ++++++++++++-- deepinfra/exceptions/__init__.py | 46 +- deepinfra/exceptions/max_retries_exceeded.py | 13 +- deepinfra/models/base/__init__.py | 1 + .../base/automatic_speech_recognition.py | 6 +- deepinfra/models/base/base.py | 35 +- deepinfra/models/base/embeddings.py | 4 +- deepinfra/models/base/text_generation.py | 6 +- deepinfra/models/base/text_to_image.py | 5 +- deepinfra/py.typed | 0 deepinfra/sandbox_api.py | 400 ++++++++++++++++++ deepinfra/utils/form_data.py | 19 +- deepinfra/utils/read_stream.py | 5 +- examples/sandbox_async.py | 21 + examples/sandbox_quickstart.py | 24 ++ mypy.ini | 6 - pyproject.toml | 105 +++-- requirements-dev.txt | 5 - requirements.txt | 5 - run_tests.py | 4 - setup.py | 24 -- tests/conftest.py | 25 ++ tests/test_automatic_speech_recognition.py | 42 +- tests/test_client.py | 155 +++++++ tests/test_embeddings.py | 36 +- tests/test_exec.py | 138 ++++++ tests/test_fs.py | 62 +++ tests/test_sandbox.py | 184 ++++++++ tests/test_text_generation.py | 39 +- tests/test_text_to_image.py | 45 +- tests/test_utils.py | 47 ++ 42 files changed, 2148 insertions(+), 304 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 deepinfra/_exceptions.py create mode 100644 deepinfra/_sandbox_models.py create mode 100644 deepinfra/_streaming.py create mode 100644 deepinfra/_utils.py create mode 100644 deepinfra/_version.py create mode 100644 deepinfra/py.typed create mode 100644 deepinfra/sandbox_api.py create mode 100644 examples/sandbox_async.py create mode 100644 examples/sandbox_quickstart.py delete mode 100644 mypy.ini delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 run_tests.py delete mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_exec.py create mode 100644 tests/test_fs.py create mode 100644 tests/test_sandbox.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5d9a92..15410bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,43 +3,27 @@ name: Continuous Integration on: push: branches: - - '**' + - main pull_request: - branches: - - '**' jobs: - unit-tests: + checks: if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11'] - + runs-on: self-hosted + container: + image: python:3.12-slim steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt + - name: Install + run: pip install -e ".[dev]" - - name: Run type check - run: | - mypy --install-types deepinfra --non-interactive --verbose + - name: Lint + run: ruff check . - - name: Run lint check - run: | - black --check --verbose deepinfra + - name: Type check + run: mypy - - name: Run unit tests - run: | - pytest tests \ No newline at end of file + - name: Unit tests + run: pytest tests diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 06787fd..ceaeefb 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -3,32 +3,37 @@ name: Publish to PyPI on: push: tags: - - '*' + - 'v*' jobs: build-n-publish: - name: Build and publish Python ๐Ÿ distributions ๐Ÿ“ฆ to PyPI + name: Build and publish to PyPI runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # PyPI trusted publishing (OIDC) steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: '3.12' - - name: Install dependencies + - name: Check tag matches package version run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine + pkg_version=$(grep -o '"[^"]*"' deepinfra/_version.py | tr -d '"') + tag_version="${GITHUB_REF_NAME#v}" + if [ "$pkg_version" != "$tag_version" ]; then + echo "Tag $GITHUB_REF_NAME does not match _version.py ($pkg_version)" >&2 + exit 1 + fi - name: Build dist run: | - python setup.py sdist bdist_wheel + python -m pip install --upgrade pip build + python -m build - - name: Publish distribution ๐Ÿ“ฆ to PyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ee4ede2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## 0.2.0 (unreleased) + +- New feature: **Sandboxes** โ€” `Sandbox.create/from_id/list`, `exec`, + `run_python`, `fs.read/write`, `stop/start/terminate`, context managers, and + async twins (`acreate`, `aexec`, ...) for everything. +- New unified `DeepInfraClient` built on httpx (sync + async, pooled + connections, explicit timeouts, typed errors, retry policy: connect-error + retries where marked, 502/503/504 retries only on GETs). +- Typed exception hierarchy under `deepinfra` /`deepinfra.exceptions`; + `MaxRetriesExceededError` is now a subclass of `APIConnectionError`. +- The inference wrappers (`TextGeneration`, `Embeddings`, + `AutomaticSpeechRecognition`, `TextToImage`) keep their public API but now + run on the unified client; `requests`/`requests-toolbelt` dependencies + replaced by `httpx` + `pydantic`. +- `from deepinfra import TextGeneration` now works (it was missing from the + package exports in 0.1.0). +- Packaging modernized: pyproject (hatchling), `py.typed`, Python >= 3.9. + +Breaking (low impact): +- `DeepInfraClient(url, token)` positional constructor replaced by + `DeepInfraClient(api_key=..., base_url=...)` with per-request paths. +- Network failures raise SDK exceptions instead of `requests` exceptions. + +## 0.1.0 (2024-03-21) + +- Initial release: inference API wrappers over `requests`. diff --git a/README.md b/README.md index f656e04..e378006 100644 --- a/README.md +++ b/README.md @@ -5,51 +5,112 @@ [![Python Version](https://img.shields.io/pypi/pyversions/deepinfra.svg)](https://pypi.org/project/deepinfra/) [![License](https://img.shields.io/github/license/deepinfra/deepinfra-python.svg)](LICENSE) -`deepinfra` is a Python library designed to provide a simple interface for interacting with DeepInfra's Inference API, facilitating various AI and machine learning tasks. +The official Python SDK for the [DeepInfra](https://deepinfra.com) API: +**Sandboxes** (isolated microVMs for running untrusted code) and inference. ## Installation -To install `deepinfra`, run the following command: - ```bash pip install deepinfra ``` -## Examples +Authentication uses your DeepInfra API key โ€” pass `api_key=` or set the +`DEEPINFRA_API_KEY` environment variable (the same key you use for inference, +from [deepinfra.com/dash/api_keys](https://deepinfra.com/dash/api_keys)). -### Use Automatic Speech Recognition +## Sandboxes -You can use the Automatic Speech Recognition (ASR) API to transcribe audio files, URLs and buffer objects. -#### Transcribe an audio file +Create an isolated Linux microVM, run bash/python inside it, move files in and +out, and tear it down โ€” in a few lines: ```python -from deepinfra import AutomaticSpeechRecognition +from deepinfra import Sandbox -model_name = "openai/whisper-base" -asr = AutomaticSpeechRecognition(model_name) +sb = Sandbox.create(plan="medium", timeout="10m") # blocks until running -file_path = "path/to/audio/file" -body = { - "audio": file_path -} -transcription = asr.generate(body) -print(transcription["text"]) +r = sb.exec("bash", "-c", "pip install pandas && python -c 'import pandas; print(pandas.__version__)'") +print(r.stdout, r.stderr, r.returncode) + +out = sb.run_python("print(21 * 2)").check() # .check() raises on non-zero exit +print(out.stdout) # "42" + +sb.fs.write("/work/in.csv", b"a,b\n1,2\n") +data = sb.fs.read("/work/in.csv") + +sb.stop() # frees compute, keeps disk +sb.start() # resumes on the same disk +sb.terminate() # deletes the sandbox +``` + +Every network method has an async twin prefixed with `a`: + +```python +sb = await Sandbox.acreate(plan="small") +r = await sb.aexec("uname", "-a") +await sb.aterminate() +``` + +Useful patterns: + +```python +# Auto-terminate with a context manager +with Sandbox.create(plan="small") as sb: + sb.run_python("open('/work/out.txt', 'w').write('hi')") + print(sb.fs.read("/work/out.txt")) + +# Find existing sandboxes +sb = Sandbox.from_id("sb_...") +etl_boxes = Sandbox.list(tags={"job": "etl-42"}) + +# Large scripts: upload, then run +sb.fs.write("/work/script.py", open("script.py").read()) +sb.exec("python3", "/work/script.py", timeout="30m") ``` -#### Transcribe an audio URL +Errors are typed: `AuthenticationError` (401), `NotFoundError` (404), +`ConflictError` (409, e.g. exec on a stopped sandbox), `TooManySandboxesError` +(429, per-account cap), `CapacityError` (503), plus SDK-side +`SandboxTimeoutError` / `SandboxFailedError` / `CommandFailedError`. + +Roadmap (API designed, lands in an upcoming release): `exec_stream` (live +output), `snapshot()` / `Sandbox.from_snapshot()`, `expose_port()`, +`fs.upload_dir()`. + +## Inference + +The inference wrappers predate the SDK's OpenAI-compatible endpoints and remain +supported: + +### Automatic Speech Recognition ```python from deepinfra import AutomaticSpeechRecognition -model_name = "openai/whisper-base" -asr = AutomaticSpeechRecognition(model_name) +asr = AutomaticSpeechRecognition("openai/whisper-base") -url = "https://path/to/audio/file" -body = { - "audio": url -} +body = {"audio": "path/to/audio/file"} # or a URL, or raw bytes transcription = asr.generate(body) -print(transcription["text"]) +print(transcription.text) ``` +### Text Generation + +```python +from deepinfra import TextGeneration +llm = TextGeneration("mistralai/Mixtral-8x22B-Instruct-v0.1") +res = llm.generate({"input": "What is the capital of France?"}) +print(res.results[0].generated_text) +``` + +`Embeddings` and `TextToImage` work the same way. For chat-style LLM usage you +can also point the official OpenAI client at +`https://api.deepinfra.com/v1/openai`. + +## Development + +```bash +pip install -e ".[dev]" +pytest tests # unit tests (no network) +mypy && ruff check . # types + lint +``` diff --git a/deepinfra/__init__.py b/deepinfra/__init__.py index aed4fa3..82c319a 100644 --- a/deepinfra/__init__.py +++ b/deepinfra/__init__.py @@ -1 +1,70 @@ -from .models import * +from ._exceptions import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + CapacityError, + CommandFailedError, + ConflictError, + ContentTooLargeError, + DeepInfraError, + InternalServerError, + MaxRetriesExceededError, + NotFoundError, + PermissionDeniedError, + SandboxError, + SandboxExecError, + SandboxFailedError, + SandboxTimeoutError, + TooManySandboxesError, +) +from ._sandbox_models import ExecResult, SandboxInfo +from ._version import __version__ +from .clients import DeepInfraClient, RequestSpec +from .models import ( + AutomaticSpeechRecognition, + BaseModel, + Embeddings, + TextGeneration, + TextToImage, +) +from .sandbox_api import Sandbox, SandboxFS + +__all__ = [ + "__version__", + # sandboxes + "Sandbox", + "SandboxFS", + "SandboxInfo", + "ExecResult", + # client + "DeepInfraClient", + "RequestSpec", + # legacy inference wrappers (re-exported by .models) + "BaseModel", + "TextGeneration", + "TextToImage", + "Embeddings", + "AutomaticSpeechRecognition", + # exceptions + "DeepInfraError", + "APIConnectionError", + "APITimeoutError", + "APIStatusError", + "AuthenticationError", + "BadRequestError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "ContentTooLargeError", + "TooManySandboxesError", + "CapacityError", + "InternalServerError", + "MaxRetriesExceededError", + "SandboxError", + "SandboxTimeoutError", + "SandboxFailedError", + "SandboxExecError", + "CommandFailedError", +] diff --git a/deepinfra/_exceptions.py b/deepinfra/_exceptions.py new file mode 100644 index 0000000..5ac850d --- /dev/null +++ b/deepinfra/_exceptions.py @@ -0,0 +1,181 @@ +"""Exception hierarchy for the DeepInfra SDK.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import httpx + + from ._sandbox_models import ExecResult + + +class DeepInfraError(Exception): + """Base class for all errors raised by this SDK.""" + + +class APIConnectionError(DeepInfraError): + """The request never received a response (DNS, connect, TLS, socket errors).""" + + def __init__(self, message: str = "Connection error") -> None: + super().__init__(message) + + +class APITimeoutError(APIConnectionError): + """The request timed out on the client side.""" + + def __init__(self, message: str = "Request timed out") -> None: + super().__init__(message) + + +class MaxRetriesExceededError(APIConnectionError): + """Retries were exhausted without getting a response. + + Kept name-compatible with the pre-0.2 SDK (`deepinfra.exceptions`). + """ + + def __init__(self, message: str = "Maximum retries exceeded") -> None: + super().__init__(message) + + +class APIStatusError(DeepInfraError): + """The API returned a non-success HTTP status code.""" + + status_code: int + message: str + response: httpx.Response | None + + def __init__( + self, + message: str, + *, + status_code: int, + response: httpx.Response | None = None, + ) -> None: + super().__init__(f"{status_code}: {message}") + self.status_code = status_code + self.message = message + self.response = response + + +class BadRequestError(APIStatusError): + pass + + +class AuthenticationError(APIStatusError): + def __init__( + self, + message: str = "No API key provided. Pass api_key= or set the " + "DEEPINFRA_API_KEY environment variable " + "(https://deepinfra.com/dash/api_keys).", + *, + status_code: int = 401, + response: httpx.Response | None = None, + ) -> None: + super().__init__(message, status_code=status_code, response=response) + + +class PermissionDeniedError(APIStatusError): + pass + + +class NotFoundError(APIStatusError): + pass + + +class ConflictError(APIStatusError): + pass + + +class ContentTooLargeError(APIStatusError): + pass + + +class TooManySandboxesError(APIStatusError): + pass + + +class CapacityError(APIStatusError): + pass + + +class InternalServerError(APIStatusError): + pass + + +_STATUS_TO_EXCEPTION: dict[int, type[APIStatusError]] = { + 400: BadRequestError, + 401: AuthenticationError, + 403: PermissionDeniedError, + 404: NotFoundError, + 409: ConflictError, + 413: ContentTooLargeError, + 429: TooManySandboxesError, + 503: CapacityError, +} + + +def extract_error_message(body: Any) -> str | None: + """Pull a human-readable message out of the known API error body shapes. + + Handles ``{"error": "..."}``, the OpenAI-style ``{"error": {"message": ...}}``, + and FastAPI's ``{"detail": ...}`` wrapping of either. + """ + if not isinstance(body, dict): + return None + for key in ("error", "detail"): + value = body.get(key) + if isinstance(value, str) and value: + return value + if isinstance(value, dict): + nested = extract_error_message(value) or value.get("message") + if isinstance(nested, str) and nested: + return nested + return None + + +def exception_from_response(response: httpx.Response) -> APIStatusError: + """Build the right APIStatusError subclass from an error response.""" + try: + message = extract_error_message(response.json()) + except Exception: + message = None + if not message: + message = response.text[:500] or response.reason_phrase or "API error" + status = response.status_code + cls = _STATUS_TO_EXCEPTION.get(status) + if cls is None: + cls = InternalServerError if status >= 500 else APIStatusError + if cls is AuthenticationError: + return AuthenticationError(message, status_code=status, response=response) + return cls(message, status_code=status, response=response) + + +class SandboxError(DeepInfraError): + """Base class for sandbox-lifecycle errors raised client-side.""" + + +class SandboxTimeoutError(SandboxError): + """Waiting for a sandbox state transition timed out.""" + + +class SandboxFailedError(SandboxError): + """The sandbox entered a terminal failed/deleted state.""" + + +class SandboxExecError(SandboxError): + """The exec stream reported an error or ended without a return code.""" + + +class CommandFailedError(SandboxError): + """Raised by ExecResult.check() when the command exited non-zero.""" + + result: ExecResult + + def __init__(self, result: ExecResult) -> None: + stderr_tail = result.stderr[-500:] if result.stderr else "" + super().__init__( + f"Command exited with code {result.returncode}" + + (f": {stderr_tail}" if stderr_tail else "") + ) + self.result = result diff --git a/deepinfra/_sandbox_models.py b/deepinfra/_sandbox_models.py new file mode 100644 index 0000000..242423c --- /dev/null +++ b/deepinfra/_sandbox_models.py @@ -0,0 +1,35 @@ +"""Pydantic models for the sandbox API wire format.""" + +from __future__ import annotations + +import pydantic + + +class SandboxInfo(pydantic.BaseModel): + """Server-side view of a sandbox (GET /v1/sandboxes/{id}).""" + + sandbox_id: str + plan: str = "" + image: str = "" + state: str = "" + tags: dict[str, str] = pydantic.Field(default_factory=dict) + created_at: int = 0 + provider: str = "" + + model_config = pydantic.ConfigDict(extra="ignore") + + +class ExecResult(pydantic.BaseModel): + """Aggregated result of a sandbox command.""" + + stdout: str = "" + stderr: str = "" + returncode: int + + def check(self) -> ExecResult: + """Return self, or raise CommandFailedError if the command exited non-zero.""" + if self.returncode != 0: + from ._exceptions import CommandFailedError + + raise CommandFailedError(self) + return self diff --git a/deepinfra/_streaming.py b/deepinfra/_streaming.py new file mode 100644 index 0000000..e83871e --- /dev/null +++ b/deepinfra/_streaming.py @@ -0,0 +1,79 @@ +"""NDJSON decoding for the sandbox exec stream. + +The wire format is one JSON object per line: + {"stdout": "chunk"} / {"stderr": "chunk"} interleaved output + {"returncode": 0} terminal line + {"error": "message"} terminal line on failure + {} heartbeat (ignored) +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Iterable, Iterator +from typing import Any + +from ._exceptions import SandboxExecError +from ._sandbox_models import ExecResult + + +def iter_ndjson(lines: Iterable[str]) -> Iterator[dict[str, Any]]: + for line in lines: + line = line.strip() + if not line: + continue + event = json.loads(line) + if isinstance(event, dict) and event: + yield event + + +async def aiter_ndjson(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]: + async for line in lines: + line = line.strip() + if not line: + continue + event = json.loads(line) + if isinstance(event, dict) and event: + yield event + + +class _ExecFolder: + """Accumulate exec-stream events into an ExecResult.""" + + def __init__(self) -> None: + self._stdout: list[str] = [] + self._stderr: list[str] = [] + self._returncode: int | None = None + + def feed(self, event: dict[str, Any]) -> None: + if "error" in event: + raise SandboxExecError(str(event["error"])) + if "stdout" in event: + self._stdout.append(event["stdout"]) + if "stderr" in event: + self._stderr.append(event["stderr"]) + if "returncode" in event: + self._returncode = int(event["returncode"]) + + def result(self) -> ExecResult: + if self._returncode is None: + raise SandboxExecError("Exec stream ended without a return code") + return ExecResult( + stdout="".join(self._stdout), + stderr="".join(self._stderr), + returncode=self._returncode, + ) + + +def fold_exec_events(events: Iterable[dict[str, Any]]) -> ExecResult: + folder = _ExecFolder() + for event in events: + folder.feed(event) + return folder.result() + + +async def afold_exec_events(events: AsyncIterator[dict[str, Any]]) -> ExecResult: + folder = _ExecFolder() + async for event in events: + folder.feed(event) + return folder.result() diff --git a/deepinfra/_utils.py b/deepinfra/_utils.py new file mode 100644 index 0000000..e16d51b --- /dev/null +++ b/deepinfra/_utils.py @@ -0,0 +1,51 @@ +"""Small internal helpers shared by the SDK.""" + +from __future__ import annotations + +import random +import re +from collections.abc import Iterator, Mapping + +_DURATION_RE = re.compile(r"(\d+)(h|m|s)") + + +def parse_duration(value: int | float | str) -> int: + """Parse a duration into whole seconds. + + Accepts plain numbers (seconds) or strings like "90", "90s", "10m", + "2h", and compounds like "1h30m". Raises ValueError on anything else. + """ + if isinstance(value, (int, float)): + if value < 0: + raise ValueError(f"Duration must be non-negative, got {value!r}") + return int(value) + text = value.strip().lower() + if not text: + raise ValueError("Duration string is empty") + if text.isdigit(): + return int(text) + matches = list(_DURATION_RE.finditer(text)) + if not matches or "".join(m.group(0) for m in matches) != text: + raise ValueError( + f"Invalid duration {value!r}; use seconds or e.g. '90s', '10m', '1h30m'" + ) + factors = {"h": 3600, "m": 60, "s": 1} + return sum(int(m.group(1)) * factors[m.group(2)] for m in matches) + + +def backoff_delays( + initial: float = 0.5, + maximum: float = 3.0, + factor: float = 2.0, + jitter: float = 0.2, +) -> Iterator[float]: + """Yield an endless exponential backoff schedule with +/- jitter.""" + delay = initial + while True: + yield delay * random.uniform(1 - jitter, 1 + jitter) + delay = min(delay * factor, maximum) + + +def tags_match(subset: Mapping[str, str], tags: Mapping[str, str]) -> bool: + """True when every key/value in subset is present in tags.""" + return all(tags.get(k) == v for k, v in subset.items()) diff --git a/deepinfra/_version.py b/deepinfra/_version.py new file mode 100644 index 0000000..d3ec452 --- /dev/null +++ b/deepinfra/_version.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/deepinfra/clients/__init__.py b/deepinfra/clients/__init__.py index 6d85dfe..9973523 100644 --- a/deepinfra/clients/__init__.py +++ b/deepinfra/clients/__init__.py @@ -1 +1,3 @@ -from .deepinfra import DeepInfraClient +from .deepinfra import DeepInfraClient, RequestSpec, default_client + +__all__ = ["DeepInfraClient", "RequestSpec", "default_client"] diff --git a/deepinfra/clients/deepinfra.py b/deepinfra/clients/deepinfra.py index d1f6ca1..dac5b09 100644 --- a/deepinfra/clients/deepinfra.py +++ b/deepinfra/clients/deepinfra.py @@ -1,57 +1,282 @@ +"""The unified DeepInfra HTTP client (sync + async), built on httpx. + +Every SDK feature (sandboxes, the inference wrappers) funnels through this +client. Operations are described as immutable RequestSpec values and executed +by thin sync/async executors so the logic exists once. +""" + +from __future__ import annotations + +import os +import platform +import threading import time -import requests - -from deepinfra.exceptions import MaxRetriesExceededError -from deepinfra.constants import ( - MAX_RETRIES, - USER_AGENT, - INITIAL_BACKOFF, - SUBSEQUENT_BACKOFF, +from collections.abc import AsyncIterator, Iterator, Mapping +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from typing import Any + +import httpx + +from deepinfra._exceptions import ( + APIConnectionError, + APITimeoutError, + AuthenticationError, + MaxRetriesExceededError, + exception_from_response, ) +from deepinfra._utils import backoff_delays +from deepinfra._version import __version__ + +DEFAULT_BASE_URL = "https://api.deepinfra.com" +DEFAULT_TIMEOUT = 60.0 +DEFAULT_MAX_RETRIES = 2 +_RETRYABLE_STATUSES = frozenset({502, 503, 504}) + +USER_AGENT = ( + f"deepinfra-python/{__version__}" + f" python/{platform.python_version()} httpx/{httpx.__version__}" +) + + +@dataclass(frozen=True) +class RequestSpec: + """A single API operation, independent of sync/async execution. + + path is relative to the client base URL, or a full absolute URL + (the legacy inference wrappers pass absolute endpoint URLs). + retry_connect: retry on transport errors (and, for GETs, 502/503/504). + """ + + method: str + path: str + params: Mapping[str, Any] | None = None + json: Any = None + content: bytes | None = None + data: Mapping[str, Any] | None = None + files: Mapping[str, Any] | None = None + headers: Mapping[str, str] | None = None + retry_connect: bool = False + timeout: float | None = None class DeepInfraClient: - max_retries = MAX_RETRIES - initial_backoff = INITIAL_BACKOFF - subsequent_backoff = SUBSEQUENT_BACKOFF + """Holds auth/base-url config and lazily-created httpx clients. - def __init__(self, url, auth_token): - self.url = url - self.auth_token = auth_token + The sync and async httpx clients are only instantiated on first use, so + sync-only callers never create an event-loop-bound client and vice versa. + """ - def backoff_delay(self, attempt): - delay = self.initial_backoff if attempt == 1 else self.subsequent_backoff - time.sleep(delay) + def __init__( + self, + api_key: str | None = None, + *, + base_url: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> None: + self._api_key = api_key + self.base_url = ( + base_url or os.getenv("DEEPINFRA_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + self.timeout = timeout + self.max_retries = max_retries + self._sync_client: httpx.Client | None = None + self._async_client: httpx.AsyncClient | None = None + self._lock = threading.Lock() - def post(self, data, config=None): - """ - Performs a POST request. - Config can be used to pass additional parameters to the request. - :param data: - :param config: - :return: + @property + def api_key(self) -> str: + if self._api_key is None: + self._api_key = os.getenv("DEEPINFRA_API_KEY") + if self._api_key is None: + raise AuthenticationError() + return self._api_key + + def close(self) -> None: + if self._sync_client is not None: + self._sync_client.close() + self._sync_client = None + + async def aclose(self) -> None: + if self._async_client is not None: + await self._async_client.aclose() + self._async_client = None + + # -- request execution -- + + def request(self, spec: RequestSpec) -> httpx.Response: + delays = backoff_delays() + for attempt in range(self.max_retries + 1): + try: + response = self._sync().request(**self._request_kwargs(spec)) + except httpx.TimeoutException as exc: + error: APIConnectionError = APITimeoutError(str(exc) or "Request timed out") + except httpx.TransportError as exc: + error = APIConnectionError(str(exc) or "Connection error") + else: + if self._should_retry_status(spec, response, attempt): + time.sleep(next(delays)) + continue + return self._checked(response) + if spec.retry_connect and attempt < self.max_retries: + time.sleep(next(delays)) + continue + raise self._final_error(spec, error, attempt) + raise AssertionError("unreachable") + + async def arequest(self, spec: RequestSpec) -> httpx.Response: + import asyncio + + delays = backoff_delays() + for attempt in range(self.max_retries + 1): + try: + response = await self._async().request(**self._request_kwargs(spec)) + except httpx.TimeoutException as exc: + error: APIConnectionError = APITimeoutError(str(exc) or "Request timed out") + except httpx.TransportError as exc: + error = APIConnectionError(str(exc) or "Connection error") + else: + if self._should_retry_status(spec, response, attempt): + await asyncio.sleep(next(delays)) + continue + return self._checked(response) + if spec.retry_connect and attempt < self.max_retries: + await asyncio.sleep(next(delays)) + continue + raise self._final_error(spec, error, attempt) + raise AssertionError("unreachable") + + @contextmanager + def stream(self, spec: RequestSpec) -> Iterator[httpx.Response]: + """Issue a streaming request; error statuses raise before yielding. + + Streaming requests are never retried (exec is not idempotent). """ - if config is None: - config = {} - config_headers = config.get("headers", {}) + client = self._sync() + request = client.build_request(**self._request_kwargs(spec)) + try: + response = client.send(request, stream=True) + except httpx.TimeoutException as exc: + raise APITimeoutError(str(exc) or "Request timed out") from exc + except httpx.TransportError as exc: + raise APIConnectionError(str(exc) or "Connection error") from exc + try: + if response.is_error: + response.read() + raise exception_from_response(response) + yield response + finally: + response.close() + + @asynccontextmanager + async def astream(self, spec: RequestSpec) -> AsyncIterator[httpx.Response]: + client = self._async() + request = client.build_request(**self._request_kwargs(spec)) + try: + response = await client.send(request, stream=True) + except httpx.TimeoutException as exc: + raise APITimeoutError(str(exc) or "Request timed out") from exc + except httpx.TransportError as exc: + raise APIConnectionError(str(exc) or "Connection error") from exc + try: + if response.is_error: + await response.aread() + raise exception_from_response(response) + yield response + finally: + await response.aclose() + + # -- internals -- + + def _request_kwargs(self, spec: RequestSpec) -> dict[str, Any]: headers = { - "content-type": "application/json", - **config_headers, + "Authorization": f"Bearer {self.api_key}", "User-Agent": USER_AGENT, - "Authorization": f"Bearer {self.auth_token}", } - for attempt in range(self.max_retries + 1): - try: - response = requests.post(self.url, data=data, headers=headers) - response.raise_for_status() - return response - except requests.RequestException as error: - if attempt < self.max_retries: - print( - f"Request failed, retrying... Attempt {attempt + 1}/{self.max_retries}" + if spec.headers: + headers.update(spec.headers) + url = spec.path + if not url.startswith(("http://", "https://")): + url = self.base_url + url + kwargs: dict[str, Any] = { + "method": spec.method, + "url": url, + "headers": headers, + } + if spec.params is not None: + kwargs["params"] = spec.params + if spec.json is not None: + kwargs["json"] = spec.json + if spec.content is not None: + kwargs["content"] = spec.content + if spec.data is not None: + kwargs["data"] = spec.data + if spec.files is not None: + kwargs["files"] = spec.files + if spec.timeout is not None: + kwargs["timeout"] = spec.timeout + return kwargs + + def _should_retry_status( + self, spec: RequestSpec, response: httpx.Response, attempt: int + ) -> bool: + # Status-code retries only for GETs: a 502/503/504 on a POST may have + # already had a side effect (created a sandbox, billed an inference). + return ( + spec.retry_connect + and spec.method == "GET" + and response.status_code in _RETRYABLE_STATUSES + and attempt < self.max_retries + ) + + @staticmethod + def _checked(response: httpx.Response) -> httpx.Response: + if response.is_error: + raise exception_from_response(response) + return response + + def _final_error( + self, spec: RequestSpec, error: APIConnectionError, attempt: int + ) -> APIConnectionError: + if spec.retry_connect and attempt >= self.max_retries: + return MaxRetriesExceededError( + f"Maximum retries exceeded ({self.max_retries}): {error}" + ) + return error + + def _sync(self) -> httpx.Client: + if self._sync_client is None: + with self._lock: + if self._sync_client is None: + self._sync_client = httpx.Client( + timeout=self.timeout, limits=_default_limits() + ) + return self._sync_client + + def _async(self) -> httpx.AsyncClient: + if self._async_client is None: + with self._lock: + if self._async_client is None: + self._async_client = httpx.AsyncClient( + timeout=self.timeout, limits=_default_limits() ) - self.backoff_delay(attempt + 1) - else: - raise error + return self._async_client + + +def _default_limits() -> httpx.Limits: + return httpx.Limits(max_connections=20, max_keepalive_connections=10) + + +_default_client: DeepInfraClient | None = None +_default_client_lock = threading.Lock() + - raise MaxRetriesExceededError() +def default_client() -> DeepInfraClient: + """Lazy process-wide client backing zero-config Sandbox.create() etc.""" + global _default_client + if _default_client is None: + with _default_client_lock: + if _default_client is None: + _default_client = DeepInfraClient() + return _default_client diff --git a/deepinfra/exceptions/__init__.py b/deepinfra/exceptions/__init__.py index 03df37f..f8a7300 100644 --- a/deepinfra/exceptions/__init__.py +++ b/deepinfra/exceptions/__init__.py @@ -1 +1,45 @@ -from .max_retries_exceeded import MaxRetriesExceededError +"""Backward-compat package; the hierarchy lives in deepinfra._exceptions.""" + +from deepinfra._exceptions import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + CapacityError, + CommandFailedError, + ConflictError, + ContentTooLargeError, + DeepInfraError, + InternalServerError, + MaxRetriesExceededError, + NotFoundError, + PermissionDeniedError, + SandboxError, + SandboxExecError, + SandboxFailedError, + SandboxTimeoutError, + TooManySandboxesError, +) + +__all__ = [ + "DeepInfraError", + "APIConnectionError", + "APITimeoutError", + "APIStatusError", + "AuthenticationError", + "BadRequestError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "ContentTooLargeError", + "TooManySandboxesError", + "CapacityError", + "InternalServerError", + "MaxRetriesExceededError", + "SandboxError", + "SandboxTimeoutError", + "SandboxFailedError", + "SandboxExecError", + "CommandFailedError", +] diff --git a/deepinfra/exceptions/max_retries_exceeded.py b/deepinfra/exceptions/max_retries_exceeded.py index 113e399..d1ff61b 100644 --- a/deepinfra/exceptions/max_retries_exceeded.py +++ b/deepinfra/exceptions/max_retries_exceeded.py @@ -1,12 +1,5 @@ -""" -This error is raised when the maximum number of retries is exceeded by DeepInfraClient. -""" +"""Backward-compat re-export; the class now lives in deepinfra._exceptions.""" +from deepinfra._exceptions import MaxRetriesExceededError -class MaxRetriesExceededError(Exception): - """ - This error is raised when the maximum number of retries is exceeded by DeepInfraClient. - """ - - def __init__(self, message="Maximum retries exceeded"): - super().__init__(message) +__all__ = ["MaxRetriesExceededError"] diff --git a/deepinfra/models/base/__init__.py b/deepinfra/models/base/__init__.py index ba188d4..4a4e286 100644 --- a/deepinfra/models/base/__init__.py +++ b/deepinfra/models/base/__init__.py @@ -1,4 +1,5 @@ from .base import BaseModel +from .text_generation import TextGeneration from .text_to_image import TextToImage from .embeddings import Embeddings from .automatic_speech_recognition import AutomaticSpeechRecognition diff --git a/deepinfra/models/base/automatic_speech_recognition.py b/deepinfra/models/base/automatic_speech_recognition.py index 98803b7..27537c5 100644 --- a/deepinfra/models/base/automatic_speech_recognition.py +++ b/deepinfra/models/base/automatic_speech_recognition.py @@ -22,8 +22,6 @@ def generate(self, body) -> AutomaticSpeechRecognitionResponse: """ - form_data = FormDataUtils.get_form_data(body, blob_keys=["audio"]) - response = self.client.post( - form_data, {"headers": {"content-type": form_data.content_type}} - ) + fields, files = FormDataUtils.get_form_data(body, blob_keys=["audio"]) + response = self._post(data=fields, files=files) return AutomaticSpeechRecognitionResponse(**response.json()) diff --git a/deepinfra/models/base/base.py b/deepinfra/models/base/base.py index 0eecdb3..a5bdc36 100644 --- a/deepinfra/models/base/base.py +++ b/deepinfra/models/base/base.py @@ -3,9 +3,11 @@ """ import os -from typing import Optional +from typing import Any, Dict, Optional -from deepinfra.clients import DeepInfraClient +import httpx + +from deepinfra.clients import DeepInfraClient, RequestSpec from deepinfra.constants.client import ROOT_URL from deepinfra.utils.url import URLUtils @@ -17,7 +19,7 @@ class BaseModel: @param auth_token: The API key to authenticate the requests. """ - def __init__(self, endpoint, auth_token: Optional[str] = None): + def __init__(self, endpoint: str, auth_token: Optional[str] = None) -> None: if URLUtils.is_valid_url(endpoint): self.endpoint = endpoint else: @@ -27,17 +29,34 @@ def __init__(self, endpoint, auth_token: Optional[str] = None): or self._get_auth_token_from_env() or self._warn_about_missing_api_key() ) - self.client = DeepInfraClient(self.endpoint, self.auth_token) + self.client = DeepInfraClient(self.auth_token) + + def _post( + self, + *, + json: Any = None, + data: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, + ) -> httpx.Response: + return self.client.request( + RequestSpec( + "POST", + self.endpoint, + json=json, + data=data, + files=files, + retry_connect=True, + ) + ) - def _get_auth_token_from_env(self): + def _get_auth_token_from_env(self) -> Optional[str]: """ Fetches the API key from the environment. @return: The API key. """ - self.auth_token = os.getenv("DEEPINFRA_API_KEY") - return self.auth_token + return os.getenv("DEEPINFRA_API_KEY") - def _warn_about_missing_api_key(self): + def _warn_about_missing_api_key(self) -> str: """ Warns the user about the missing API key. @return: An empty string. diff --git a/deepinfra/models/base/embeddings.py b/deepinfra/models/base/embeddings.py index 31319e9..7ce112f 100644 --- a/deepinfra/models/base/embeddings.py +++ b/deepinfra/models/base/embeddings.py @@ -1,5 +1,3 @@ -import json - from deepinfra.models.base import BaseModel from deepinfra.types.embeddings.response import EmbeddingsResponse @@ -15,5 +13,5 @@ def generate(self, body) -> EmbeddingsResponse: :param body: :return: """ - response = self.client.post(json.dumps(body)) + response = self._post(json=body) return EmbeddingsResponse(**response.json()) diff --git a/deepinfra/models/base/text_generation.py b/deepinfra/models/base/text_generation.py index e728efc..ada947a 100644 --- a/deepinfra/models/base/text_generation.py +++ b/deepinfra/models/base/text_generation.py @@ -3,11 +3,7 @@ which is the base class for all text generation models. """ -import json -from typing import Union - from deepinfra.models.base import BaseModel -from deepinfra.types.text_generation.request import TextGenerationRequest from deepinfra.types.text_generation.response import TextGenerationResponse @@ -23,5 +19,5 @@ def generate(self, body: dict) -> TextGenerationResponse: :param body: :return: """ - response = self.client.post(json.dumps(body)) + response = self._post(json=body) return TextGenerationResponse(**response.json()) diff --git a/deepinfra/models/base/text_to_image.py b/deepinfra/models/base/text_to_image.py index 5944564..7d3ac4d 100644 --- a/deepinfra/models/base/text_to_image.py +++ b/deepinfra/models/base/text_to_image.py @@ -1,5 +1,3 @@ -import json - from deepinfra.models.base import BaseModel from deepinfra.types.text_to_image import TextToImageResponse @@ -16,6 +14,5 @@ def generate(self, input) -> TextToImageResponse: :param input: :return: """ - body = {"input": input} - response = self.client.post(json.dumps(input)) + response = self._post(json=input) return TextToImageResponse(**response.json()) diff --git a/deepinfra/py.typed b/deepinfra/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/deepinfra/sandbox_api.py b/deepinfra/sandbox_api.py new file mode 100644 index 0000000..d6e2cc2 --- /dev/null +++ b/deepinfra/sandbox_api.py @@ -0,0 +1,400 @@ +"""DeepInfra Sandboxes: isolated microVMs for running untrusted code. + + from deepinfra import Sandbox + + sb = Sandbox.create(plan="medium", timeout="10m") + r = sb.exec("bash", "-c", "pip install pandas && python -c 'import pandas'") + print(r.stdout, r.returncode) + sb.fs.write("/work/in.csv", b"a,b\n1,2\n") + data = sb.fs.read("/work/in.csv") + sb.terminate() + +Every network method has an async twin prefixed with "a" (exec/aexec, +create/acreate, ...). +""" + +from __future__ import annotations + +import builtins +import time +from collections.abc import Mapping +from typing import Any, Union + +from ._exceptions import NotFoundError, SandboxFailedError, SandboxTimeoutError +from ._sandbox_models import ExecResult, SandboxInfo +from ._streaming import afold_exec_events, aiter_ndjson, fold_exec_events, iter_ndjson +from ._utils import backoff_delays, parse_duration, tags_match +from .clients.deepinfra import DeepInfraClient, RequestSpec, default_client + +_SANDBOXES = "/v1/sandboxes" +_DEFAULT_WAIT_TIMEOUT = 300.0 +_DEFAULT_EXEC_TIMEOUT = 60 +_EXEC_HTTP_GRACE = 30.0 + +_RUNNING = "running" +_TERMINAL_STATES = ("failed", "deleted") + +Duration = Union[int, float, str] + + +class Sandbox: + """Handle to one sandbox. Fields mirror the server; refresh() updates them.""" + + def __init__( + self, + info: SandboxInfo, + *, + client: DeepInfraClient | None = None, + ) -> None: + self._info = info + self._client = client or default_client() + self.fs = SandboxFS(self) + + # -- cached server fields -- + + @property + def id(self) -> str: + return self._info.sandbox_id + + @property + def plan(self) -> str: + return self._info.plan + + @property + def image(self) -> str: + return self._info.image + + @property + def state(self) -> str: + return self._info.state + + @property + def tags(self) -> dict[str, str]: + return self._info.tags + + @property + def created_at(self) -> int: + return self._info.created_at + + @property + def provider(self) -> str: + return self._info.provider + + def __repr__(self) -> str: + return f"Sandbox(id={self.id!r}, state={self.state!r}, plan={self.plan!r})" + + # -- constructors -- + + @classmethod + def create( + cls, + *, + image: str = "", + plan: str = "", + timeout: Duration | None = None, + tags: Mapping[str, str] | None = None, + wait: bool = True, + wait_timeout: float = _DEFAULT_WAIT_TIMEOUT, + client: DeepInfraClient | None = None, + ) -> Sandbox: + """Create a sandbox; by default block until it is running.""" + client = client or default_client() + reply = client.request(cls._create_spec(image, plan, timeout, tags)).json() + sandbox = cls(SandboxInfo(sandbox_id=reply["sandbox_id"]), client=client) + if wait: + sandbox.wait_until_running(timeout=wait_timeout) + return sandbox + + @classmethod + async def acreate( + cls, + *, + image: str = "", + plan: str = "", + timeout: Duration | None = None, + tags: Mapping[str, str] | None = None, + wait: bool = True, + wait_timeout: float = _DEFAULT_WAIT_TIMEOUT, + client: DeepInfraClient | None = None, + ) -> Sandbox: + client = client or default_client() + reply = (await client.arequest(cls._create_spec(image, plan, timeout, tags))).json() + sandbox = cls(SandboxInfo(sandbox_id=reply["sandbox_id"]), client=client) + if wait: + await sandbox.await_until_running(timeout=wait_timeout) + return sandbox + + @classmethod + def from_id( + cls, sandbox_id: str, *, client: DeepInfraClient | None = None + ) -> Sandbox: + client = client or default_client() + info = SandboxInfo.model_validate( + client.request(_get_spec(sandbox_id)).json() + ) + return cls(info, client=client) + + @classmethod + async def afrom_id( + cls, sandbox_id: str, *, client: DeepInfraClient | None = None + ) -> Sandbox: + client = client or default_client() + info = SandboxInfo.model_validate( + (await client.arequest(_get_spec(sandbox_id))).json() + ) + return cls(info, client=client) + + @classmethod + def list( + cls, + *, + tags: Mapping[str, str] | None = None, + client: DeepInfraClient | None = None, + ) -> builtins.list[Sandbox]: + """List this account's sandboxes, optionally filtered by tag subset.""" + client = client or default_client() + items = client.request(_list_spec()).json() + return cls._from_list(items, tags, client) + + @classmethod + async def alist( + cls, + *, + tags: Mapping[str, str] | None = None, + client: DeepInfraClient | None = None, + ) -> builtins.list[Sandbox]: + client = client or default_client() + items = (await client.arequest(_list_spec())).json() + return cls._from_list(items, tags, client) + + # -- lifecycle -- + + def refresh(self) -> Sandbox: + self._info = SandboxInfo.model_validate( + self._client.request(_get_spec(self.id)).json() + ) + return self + + async def arefresh(self) -> Sandbox: + self._info = SandboxInfo.model_validate( + (await self._client.arequest(_get_spec(self.id))).json() + ) + return self + + def wait_until_running(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> Sandbox: + deadline = time.monotonic() + timeout + for delay in backoff_delays(): + self.refresh() + self._check_wait_state() + if self.state == _RUNNING: + return self + if time.monotonic() + delay > deadline: + raise SandboxTimeoutError( + f"Sandbox {self.id} still {self.state} after " + f"{timeout:.0f}s (terminate it if unwanted)" + ) + time.sleep(delay) + raise AssertionError("unreachable") + + async def await_until_running( + self, timeout: float = _DEFAULT_WAIT_TIMEOUT + ) -> Sandbox: + import asyncio + + deadline = time.monotonic() + timeout + for delay in backoff_delays(): + await self.arefresh() + self._check_wait_state() + if self.state == _RUNNING: + return self + if time.monotonic() + delay > deadline: + raise SandboxTimeoutError( + f"Sandbox {self.id} still {self.state} after " + f"{timeout:.0f}s (terminate it if unwanted)" + ) + await asyncio.sleep(delay) + raise AssertionError("unreachable") + + def stop(self) -> None: + self._client.request(_op_spec(self.id, "stop")) + + async def astop(self) -> None: + await self._client.arequest(_op_spec(self.id, "stop")) + + def start( + self, *, wait: bool = True, wait_timeout: float = _DEFAULT_WAIT_TIMEOUT + ) -> None: + self._client.request(_op_spec(self.id, "start")) + if wait: + self.wait_until_running(timeout=wait_timeout) + + async def astart( + self, *, wait: bool = True, wait_timeout: float = _DEFAULT_WAIT_TIMEOUT + ) -> None: + await self._client.arequest(_op_spec(self.id, "start")) + if wait: + await self.await_until_running(timeout=wait_timeout) + + def terminate(self) -> None: + self._client.request(_delete_spec(self.id)) + + async def aterminate(self) -> None: + await self._client.arequest(_delete_spec(self.id)) + + # -- execution -- + + def exec(self, *command: str, timeout: Duration | None = None) -> ExecResult: + """Run a command and return its aggregated stdout/stderr/returncode.""" + with self._client.stream(self._exec_spec(command, timeout)) as response: + return fold_exec_events(iter_ndjson(response.iter_lines())) + + async def aexec( + self, *command: str, timeout: Duration | None = None + ) -> ExecResult: + async with self._client.astream(self._exec_spec(command, timeout)) as response: + return await afold_exec_events(aiter_ndjson(response.aiter_lines())) + + def run_python(self, code: str, *, timeout: Duration | None = None) -> ExecResult: + """Run a Python snippet (python3 -c). + + For large scripts prefer fs.write("/work/script.py", code) + + exec("python3", "/work/script.py"). + """ + return self.exec("python3", "-c", code, timeout=timeout) + + async def arun_python( + self, code: str, *, timeout: Duration | None = None + ) -> ExecResult: + return await self.aexec("python3", "-c", code, timeout=timeout) + + # -- context managers (terminate on exit) -- + + def __enter__(self) -> Sandbox: + return self + + def __exit__(self, *exc_info: Any) -> None: + try: + self.terminate() + except NotFoundError: + pass + + async def __aenter__(self) -> Sandbox: + return self + + async def __aexit__(self, *exc_info: Any) -> None: + try: + await self.aterminate() + except NotFoundError: + pass + + # -- internals -- + + def _check_wait_state(self) -> None: + if self.state in _TERMINAL_STATES: + raise SandboxFailedError( + f"Sandbox {self.id} entered state {self.state!r}" + ) + + def _exec_spec( + self, command: tuple[str, ...], timeout: Duration | None + ) -> RequestSpec: + if not command: + raise ValueError("exec() needs at least one command argument") + timeout_seconds = parse_duration(timeout) if timeout is not None else 0 + # Read timeout outlives the server-side command timeout so the + # server's kill surfaces as a terminal error line, not a socket error. + http_timeout = (timeout_seconds or _DEFAULT_EXEC_TIMEOUT) + _EXEC_HTTP_GRACE + return RequestSpec( + "POST", + f"{_SANDBOXES}/{self.id}/exec", + json={"command": list(command), "timeout_seconds": timeout_seconds}, + timeout=http_timeout, + ) + + @staticmethod + def _create_spec( + image: str, + plan: str, + timeout: Duration | None, + tags: Mapping[str, str] | None, + ) -> RequestSpec: + return RequestSpec( + "POST", + _SANDBOXES, + json={ + "image": image, + "plan": plan, + "tags": dict(tags or {}), + "timeout_seconds": parse_duration(timeout) if timeout is not None else 0, + }, + ) + + @classmethod + def _from_list( + cls, + items: builtins.list[dict[str, Any]], + tags: Mapping[str, str] | None, + client: DeepInfraClient, + ) -> builtins.list[Sandbox]: + sandboxes = [ + cls(SandboxInfo.model_validate(item), client=client) for item in items + ] + if tags: + sandboxes = [sb for sb in sandboxes if tags_match(tags, sb.tags)] + return sandboxes + + +class SandboxFS: + """File transfer to/from a sandbox (absolute paths inside the guest).""" + + def __init__(self, sandbox: Sandbox) -> None: + self._sandbox = sandbox + + def write(self, path: str, data: bytes | str) -> None: + self._client.request(self._write_spec(path, data)) + + async def awrite(self, path: str, data: bytes | str) -> None: + await self._client.arequest(self._write_spec(path, data)) + + def read(self, path: str) -> bytes: + return self._client.request(self._read_spec(path)).content + + async def aread(self, path: str) -> bytes: + return (await self._client.arequest(self._read_spec(path))).content + + @property + def _client(self) -> DeepInfraClient: + return self._sandbox._client + + def _write_spec(self, path: str, data: bytes | str) -> RequestSpec: + if isinstance(data, str): + data = data.encode("utf-8") + return RequestSpec( + "PUT", + self._content_path(), + params={"path": path}, + content=data, + headers={"Content-Type": "application/octet-stream"}, + ) + + def _read_spec(self, path: str) -> RequestSpec: + return RequestSpec("GET", self._content_path(), params={"path": path}) + + def _content_path(self) -> str: + return f"{_SANDBOXES}/{self._sandbox.id}/fs/content" + + +def _get_spec(sandbox_id: str) -> RequestSpec: + return RequestSpec("GET", f"{_SANDBOXES}/{sandbox_id}", retry_connect=True) + + +def _list_spec() -> RequestSpec: + return RequestSpec("GET", _SANDBOXES, retry_connect=True) + + +def _op_spec(sandbox_id: str, op: str) -> RequestSpec: + return RequestSpec("POST", f"{_SANDBOXES}/{sandbox_id}/{op}") + + +def _delete_spec(sandbox_id: str) -> RequestSpec: + return RequestSpec("DELETE", f"{_SANDBOXES}/{sandbox_id}") diff --git a/deepinfra/utils/form_data.py b/deepinfra/utils/form_data.py index abb4952..a1b541a 100644 --- a/deepinfra/utils/form_data.py +++ b/deepinfra/utils/form_data.py @@ -1,6 +1,4 @@ -from typing import List, Optional - -from requests_toolbelt import MultipartEncoder +from typing import Any, Dict, List, Optional, Tuple from deepinfra.utils.read_stream import ReadStreamUtils @@ -12,21 +10,24 @@ class FormDataUtils: """ @staticmethod - def get_form_data(data: dict, blob_keys: Optional[List[str]] = None): + def get_form_data( + data: dict, blob_keys: Optional[List[str]] = None + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: """ - Creates a MultipartEncoder object from the data. + Splits the data into httpx multipart (fields, files). :param data: :param blob_keys: :return: """ if blob_keys is None: blob_keys = list() - body = {} + fields: Dict[str, Any] = {} + files: Dict[str, Any] = {} for key, value in data.items(): if key in blob_keys: - body[key] = (key, ReadStreamUtils.get_read_stream(value)) + files[key] = (key, ReadStreamUtils.get_read_stream(value)) else: - body[key] = value + fields[key] = value - return MultipartEncoder(fields=body) + return fields, files diff --git a/deepinfra/utils/read_stream.py b/deepinfra/utils/read_stream.py index 9498a7d..1c2a3c1 100644 --- a/deepinfra/utils/read_stream.py +++ b/deepinfra/utils/read_stream.py @@ -1,6 +1,7 @@ from io import BytesIO import base64 -import requests + +import httpx class ReadStreamUtils: @@ -34,7 +35,7 @@ def url_to_stream(url): :param url: The URL of the image. :return: A BytesIO containing the image data. """ - response = requests.get(url) + response = httpx.get(url, follow_redirects=True) return BytesIO(response.content) @staticmethod diff --git a/examples/sandbox_async.py b/examples/sandbox_async.py new file mode 100644 index 0000000..fc16256 --- /dev/null +++ b/examples/sandbox_async.py @@ -0,0 +1,21 @@ +"""Async sandbox usage โ€” e.g. inside an agent loop. + +Needs DEEPINFRA_API_KEY in the environment. +""" + +import asyncio + +from deepinfra import Sandbox + + +async def main() -> None: + async with await Sandbox.acreate(plan="small", timeout="5m") as sb: + r = await sb.aexec("bash", "-c", "echo -n step1 && sleep 1 && echo -n ' step2'") + print(r.check().stdout) + + r = await sb.arun_python("import platform; print(platform.machine())") + print("arch:", r.stdout.strip()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/sandbox_quickstart.py b/examples/sandbox_quickstart.py new file mode 100644 index 0000000..6237b3f --- /dev/null +++ b/examples/sandbox_quickstart.py @@ -0,0 +1,24 @@ +"""Sandbox quickstart: create, exec, file round-trip, terminate. + +Needs DEEPINFRA_API_KEY in the environment. +""" + +from deepinfra import Sandbox + + +def main() -> None: + with Sandbox.create(plan="small", timeout="10m", tags={"demo": "quickstart"}) as sb: + print("sandbox:", sb.id, sb.state) + + r = sb.exec("uname", "-a").check() + print("kernel:", r.stdout.strip()) + + r = sb.run_python("print(sum(range(101)))").check() + print("sum 0..100 =", r.stdout.strip()) + + sb.fs.write("/work/hello.txt", "hello from the host\n") + print("read back:", sb.fs.read("/work/hello.txt").decode().strip()) + + +if __name__ == "__main__": + main() diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 0c2455f..0000000 --- a/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -check_untyped_defs = False - -# requests_toolbelt is not typed -[mypy-requests_toolbelt.*] -ignore_missing_imports = True \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a21ab1f..3f7d0e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,80 @@ -[tool.black] -line-length = 88 -include = '\.pyi?$' -exclude = '''( - /( - \.eggs/ - | \.git/ - | \.hg/ - | \.mypy_cache/ - | \.tox/ - | \.venv/ - | _build/ - | buck-out/ - | build/ - | dist/ - | node_modules/ - ) - | setup\.py - | conftest\.py - | pytest_\w+\.py - | \b__init__\.py - | typing\.py -)''' -skip-string-normalization = true -indent = ' ' +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "deepinfra" +dynamic = ["version"] +description = "Official Python SDK for the DeepInfra API: sandboxes and inference" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.9" +authors = [{ name = "DeepInfra", email = "feedback@deepinfra.com" }] +dependencies = [ + "httpx>=0.27,<1", + "pydantic>=2.5,<3", +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://deepinfra.com" +Repository = "https://github.com/deepinfra/deepinfra-python" + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "respx", + "mypy", + "ruff", + "coverage", +] + +[tool.hatch.version] +path = "deepinfra/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["deepinfra"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.9" +files = ["deepinfra"] +strict = true + +# Legacy (pre-0.2) modules are typed loosely; new code is strict. +[[tool.mypy.overrides]] +module = [ + "deepinfra.models.*", + "deepinfra.types.*", + "deepinfra.utils.*", + "deepinfra.constants.*", +] +disallow_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_calls = false +check_untyped_defs = false +disallow_any_generics = false +implicit_reexport = true + +[tool.ruff] +line-length = 100 +target-version = "py39" +# Legacy modules keep their 2024 style; don't churn them for lint's sake. +extend-exclude = [ + "deepinfra/models", + "deepinfra/types", + "deepinfra/utils", + "deepinfra/constants", + "deepinfra/exceptions", +] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index d2c80bf..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -black==23.3.0 -mypy -types-requests -coverage -pytest \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 135d793..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -setuptools -setuptools-scm -requests -dataclasses -requests-toolbelt \ No newline at end of file diff --git a/run_tests.py b/run_tests.py deleted file mode 100644 index 1c35782..0000000 --- a/run_tests.py +++ /dev/null @@ -1,4 +0,0 @@ -import unittest - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 36baecc..0000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="deepinfra", - version="0.1.0", - author="Oguz Vuruskaner", - author_email="oguzvuruskaner@gmail.com", - description="Unofficial Python wrapper for the DeepInfra Inference API", - long_description=open("README.md").read(), - long_description_content_type="text/markdown", - url="https://github.com/deepinfra/deepinfra-python", - packages=find_packages(), - install_requires=[ - "requests", - ], - include_package_data=True, - use_scm_version=True, - setup_requires=["setuptools_scm"], - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..43e9c7b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest + +from deepinfra.clients.deepinfra import DeepInfraClient + +BASE_URL = "https://api.deepinfra.com" +API_KEY = "test-key" + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + monkeypatch.delenv("DEEPINFRA_BASE_URL", raising=False) + c = DeepInfraClient(API_KEY) + yield c + c.close() + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch): + monkeypatch.setattr("time.sleep", lambda _s: None) + + async def _async_noop(_s): + pass + + monkeypatch.setattr("asyncio.sleep", _async_noop) diff --git a/tests/test_automatic_speech_recognition.py b/tests/test_automatic_speech_recognition.py index 0d6a0d0..a0364ea 100644 --- a/tests/test_automatic_speech_recognition.py +++ b/tests/test_automatic_speech_recognition.py @@ -1,5 +1,6 @@ import unittest -from unittest.mock import patch + +import respx from deepinfra import AutomaticSpeechRecognition @@ -8,28 +9,31 @@ class TestAutomaticSpeechRecognition(unittest.TestCase): - @patch("requests.post") - def test_generate(self, mock_post): - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = { - "text": "Hello, World!", - "segments": [{"start": 0, "end": 1, "text": "Hello"}], - "language": "en", - "input_length_ms": 1000, - "request_id": "123", - "inference_status": None, - } + @respx.mock + def test_generate(self): + route = respx.post( + f"https://api.deepinfra.com/v1/inference/{model_name}" + ).respond( + json={ + "text": "Hello, World!", + "segments": [{"start": 0, "end": 1, "text": "Hello"}], + "language": "en", + "input_length_ms": 1000, + "request_id": "123", + "inference_status": None, + } + ) audio_data = b"audio data" asr = AutomaticSpeechRecognition(model_name, api_key) body = {"audio": audio_data} response = asr.generate(body) - called_args, called_kwargs = mock_post.call_args - url = called_args[0] - self.assertEqual(url, f"https://api.deepinfra.com/v1/inference/{model_name}") - - called_headers = called_kwargs["headers"] - self.assertEqual(called_headers["Authorization"], f"Bearer {api_key}") - + request = route.calls.last.request + self.assertEqual( + str(request.url), f"https://api.deepinfra.com/v1/inference/{model_name}" + ) + self.assertEqual(request.headers["Authorization"], f"Bearer {api_key}") + self.assertIn("multipart/form-data", request.headers["Content-Type"]) + self.assertIn(b"audio data", request.content) self.assertEqual(response.text, "Hello, World!") diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..0c17d08 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,155 @@ +import httpx +import pytest +import respx + +from deepinfra import ( + AuthenticationError, + CapacityError, + ConflictError, + MaxRetriesExceededError, + NotFoundError, + TooManySandboxesError, +) +from deepinfra._exceptions import APIConnectionError, APIStatusError, InternalServerError +from deepinfra.clients.deepinfra import USER_AGENT, DeepInfraClient, RequestSpec + +from .conftest import API_KEY, BASE_URL + + +@respx.mock +def test_auth_and_user_agent_headers(client): + route = respx.get(f"{BASE_URL}/v1/sandboxes").respond(json=[]) + client.request(RequestSpec("GET", "/v1/sandboxes")) + request = route.calls.last.request + assert request.headers["Authorization"] == f"Bearer {API_KEY}" + assert request.headers["User-Agent"] == USER_AGENT + assert USER_AGENT.startswith("deepinfra-python/") + + +def test_api_key_from_env(monkeypatch): + monkeypatch.setenv("DEEPINFRA_API_KEY", "env-key") + assert DeepInfraClient().api_key == "env-key" + + +def test_missing_api_key_raises_with_hint(monkeypatch): + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + with pytest.raises(AuthenticationError, match="DEEPINFRA_API_KEY"): + _ = DeepInfraClient().api_key + + +def test_base_url_from_env(monkeypatch): + monkeypatch.setenv("DEEPINFRA_BASE_URL", "https://example.deepinfra.com/") + assert DeepInfraClient("k").base_url == "https://example.deepinfra.com" + + +@pytest.mark.parametrize( + "status,exc", + [ + (401, AuthenticationError), + (404, NotFoundError), + (409, ConflictError), + (429, TooManySandboxesError), + (503, CapacityError), + (500, InternalServerError), + (418, APIStatusError), + ], +) +@pytest.mark.parametrize( + "body", + [ + {"error": "boom"}, + {"detail": {"error": "boom"}}, + {"detail": "boom"}, + {"error": {"message": "boom"}}, + ], +) +@respx.mock +def test_error_mapping_and_body_shapes(client, status, exc, body): + respx.get(f"{BASE_URL}/x").respond(status_code=status, json=body) + with pytest.raises(exc) as excinfo: + client.request(RequestSpec("GET", "/x")) + assert excinfo.value.status_code == status + assert excinfo.value.message == "boom" + + +@respx.mock +def test_error_non_json_body(client): + respx.get(f"{BASE_URL}/x").respond(status_code=502, text="bad gateway page") + with pytest.raises(InternalServerError, match="bad gateway page"): + client.request(RequestSpec("GET", "/x")) + + +@respx.mock +def test_get_retries_connect_errors(client): + route = respx.get(f"{BASE_URL}/x") + route.side_effect = [httpx.ConnectError("refused"), httpx.Response(200, json={"ok": 1})] + response = client.request(RequestSpec("GET", "/x", retry_connect=True)) + assert response.json() == {"ok": 1} + assert route.call_count == 2 + + +@respx.mock +def test_get_retries_502(client): + route = respx.get(f"{BASE_URL}/x") + route.side_effect = [httpx.Response(502), httpx.Response(200, json={})] + client.request(RequestSpec("GET", "/x", retry_connect=True)) + assert route.call_count == 2 + + +@respx.mock +def test_post_never_retries_502(client): + route = respx.post(f"{BASE_URL}/x").respond(status_code=502) + with pytest.raises(InternalServerError): + client.request(RequestSpec("POST", "/x", retry_connect=True)) + assert route.call_count == 1 + + +@respx.mock +def test_post_retries_connect_errors_when_marked(client): + route = respx.post(f"{BASE_URL}/x") + route.side_effect = [httpx.ConnectError("refused"), httpx.Response(200, json={})] + client.request(RequestSpec("POST", "/x", retry_connect=True)) + assert route.call_count == 2 + + +@respx.mock +def test_unmarked_request_never_retries(client): + route = respx.post(f"{BASE_URL}/x") + route.side_effect = httpx.ConnectError("refused") + with pytest.raises(APIConnectionError): + client.request(RequestSpec("POST", "/x")) + assert route.call_count == 1 + + +@respx.mock +def test_retries_exhausted_raises_max_retries(client): + route = respx.get(f"{BASE_URL}/x") + route.side_effect = httpx.ConnectError("refused") + with pytest.raises(MaxRetriesExceededError): + client.request(RequestSpec("GET", "/x", retry_connect=True)) + assert route.call_count == client.max_retries + 1 + + +def test_legacy_exception_compat(): + assert issubclass(MaxRetriesExceededError, APIConnectionError) + from deepinfra.exceptions import MaxRetriesExceededError as legacy + + assert legacy is MaxRetriesExceededError + + +@respx.mock +def test_absolute_url_bypasses_base_url(client): + route = respx.post("https://other.example.com/v1/inference/m").respond(json={}) + client.request(RequestSpec("POST", "https://other.example.com/v1/inference/m")) + assert route.call_count == 1 + + +@respx.mock +async def test_async_request_and_errors(client): + respx.get(f"{BASE_URL}/x").respond(json={"ok": 1}) + respx.get(f"{BASE_URL}/missing").respond(status_code=404, json={"error": "nope"}) + response = await client.arequest(RequestSpec("GET", "/x")) + assert response.json() == {"ok": 1} + with pytest.raises(NotFoundError, match="nope"): + await client.arequest(RequestSpec("GET", "/missing")) + await client.aclose() diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index 67cdb14..b267b9f 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -1,6 +1,7 @@ import json import unittest -from unittest.mock import patch + +import respx from deepinfra import Embeddings @@ -9,25 +10,26 @@ class TestEmbeddings(unittest.TestCase): - @patch("requests.post") - def test_generate(self, mock_post): - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = { - "embeddings": [1, 2, 3], - "input_tokens": 123, - "inference_status": None, - } + @respx.mock + def test_generate(self): + route = respx.post( + f"https://api.deepinfra.com/v1/inference/{model_name}" + ).respond( + json={ + "embeddings": [1, 2, 3], + "input_tokens": 123, + "inference_status": None, + } + ) embeddings = Embeddings(model_name, api_key) body = {"text": "Hello, World!"} response = embeddings.generate(body) - called_args, called_kwargs = mock_post.call_args - url = called_args[0] - header = called_kwargs["headers"] - data = called_kwargs["data"] - - self.assertEqual(url, f"https://api.deepinfra.com/v1/inference/{model_name}") - self.assertEqual(data, json.dumps(body)) + request = route.calls.last.request + self.assertEqual( + str(request.url), f"https://api.deepinfra.com/v1/inference/{model_name}" + ) + self.assertEqual(json.loads(request.content), body) self.assertEqual(response.embeddings, [1, 2, 3]) - self.assertEqual(header["Authorization"], f"Bearer {api_key}") + self.assertEqual(request.headers["Authorization"], f"Bearer {api_key}") diff --git a/tests/test_exec.py b/tests/test_exec.py new file mode 100644 index 0000000..7c3f0cc --- /dev/null +++ b/tests/test_exec.py @@ -0,0 +1,138 @@ +import json + +import httpx +import pytest +import respx + +from deepinfra import CommandFailedError, ConflictError, Sandbox, SandboxExecError +from deepinfra._sandbox_models import SandboxInfo +from deepinfra._streaming import fold_exec_events, iter_ndjson + +from .conftest import BASE_URL + +SB_ID = "sb_test123" + + +def _sandbox(client): + return Sandbox(SandboxInfo(sandbox_id=SB_ID, state="running"), client=client) + + +def _ndjson(*events): + return "".join(json.dumps(e) + "\n" for e in events) + + +@respx.mock +def test_exec_aggregates_stream(client): + route = respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson( + {"stdout": "hello "}, + {"stderr": "warn\n"}, + {"stdout": "world"}, + {}, # heartbeat + {"returncode": 0}, + ), + content_type="application/x-ndjson", + ) + result = _sandbox(client).exec("bash", "-c", "echo hello world") + assert result.stdout == "hello world" + assert result.stderr == "warn\n" + assert result.returncode == 0 + body = json.loads(route.calls.last.request.content) + assert body == {"command": ["bash", "-c", "echo hello world"], "timeout_seconds": 0} + + +@respx.mock +def test_exec_timeout_param_and_check(client): + route = respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson({"stderr": "boom"}, {"returncode": 3}), + ) + result = _sandbox(client).exec("false", timeout="2m") + assert result.returncode == 3 + with pytest.raises(CommandFailedError, match="code 3"): + result.check() + body = json.loads(route.calls.last.request.content) + assert body["timeout_seconds"] == 120 + + +@respx.mock +def test_exec_mid_stream_error(client): + respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson({"stdout": "partial"}, {"error": "sandbox died"}), + ) + with pytest.raises(SandboxExecError, match="sandbox died"): + _sandbox(client).exec("sleep", "100") + + +@respx.mock +def test_exec_missing_terminal_line(client): + respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson({"stdout": "partial"}), + ) + with pytest.raises(SandboxExecError, match="without a return code"): + _sandbox(client).exec("true") + + +@respx.mock +def test_exec_pre_stream_http_error(client): + respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + status_code=409, json={"detail": {"error": "Sandbox is stopped, not running"}} + ) + with pytest.raises(ConflictError, match="not running"): + _sandbox(client).exec("true") + + +@respx.mock +def test_run_python_wraps_exec(client): + route = respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson({"stdout": "2\n"}, {"returncode": 0}), + ) + result = _sandbox(client).run_python("print(1+1)") + assert result.stdout == "2\n" + body = json.loads(route.calls.last.request.content) + assert body["command"] == ["python3", "-c", "print(1+1)"] + + +def test_exec_requires_command(client): + with pytest.raises(ValueError): + _sandbox(client).exec() + + +def test_fold_handles_split_reads(): + # Lines arriving fragmented across reads are reassembled by iter_lines; + # fold only sees whole lines. Simulate the folded path directly. + lines = ['{"stdout": "a"}', "", '{"stdout": "b"}', '{"returncode": 1}'] + result = fold_exec_events(iter_ndjson(lines)) + assert result.stdout == "ab" + assert result.returncode == 1 + + +@respx.mock +async def test_aexec(client): + respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson({"stdout": "async"}, {"returncode": 0}), + ) + result = await _sandbox(client).aexec("echo", "async") + assert result.stdout == "async" + assert result.returncode == 0 + + +@respx.mock +async def test_arun_python_error(client): + respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").respond( + text=_ndjson({"error": "gone"}), + ) + with pytest.raises(SandboxExecError, match="gone"): + await _sandbox(client).arun_python("print(1)") + + +@respx.mock +def test_exec_http_read_timeout_derived(client): + captured = {} + + def _capture(request): + captured["timeout"] = request.extensions.get("timeout") + return httpx.Response(200, text=_ndjson({"returncode": 0})) + + respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/exec").mock(side_effect=_capture) + _sandbox(client).exec("true", timeout=600) + assert captured["timeout"]["read"] == 630.0 diff --git a/tests/test_fs.py b/tests/test_fs.py new file mode 100644 index 0000000..c76faa7 --- /dev/null +++ b/tests/test_fs.py @@ -0,0 +1,62 @@ +import pytest +import respx + +from deepinfra import BadRequestError, NotFoundError, Sandbox +from deepinfra._sandbox_models import SandboxInfo + +from .conftest import BASE_URL + +SB_ID = "sb_fs" +FS_URL = f"{BASE_URL}/v1/sandboxes/{SB_ID}/fs/content" + + +def _sandbox(client): + return Sandbox(SandboxInfo(sandbox_id=SB_ID, state="running"), client=client) + + +@respx.mock +def test_fs_write_bytes(client): + route = respx.put(FS_URL).respond(json={}) + payload = bytes(range(256)) + _sandbox(client).fs.write("/work/blob.bin", payload) + request = route.calls.last.request + assert request.url.params["path"] == "/work/blob.bin" + assert request.headers["Content-Type"] == "application/octet-stream" + assert request.content == payload + + +@respx.mock +def test_fs_write_str_encodes_utf8(client): + route = respx.put(FS_URL).respond(json={}) + _sandbox(client).fs.write("/work/ั‚.txt", "ะทะดั€ะฐัั‚ะธ") + assert route.calls.last.request.content == "ะทะดั€ะฐัั‚ะธ".encode() + + +@respx.mock +def test_fs_read_bytes_roundtrip(client): + payload = bytes(range(256)) * 3 + respx.get(FS_URL).respond(content=payload, content_type="application/octet-stream") + assert _sandbox(client).fs.read("/work/blob.bin") == payload + + +@respx.mock +def test_fs_read_missing_file(client): + respx.get(FS_URL).respond(status_code=404, json={"detail": {"error": "File not found"}}) + with pytest.raises(NotFoundError, match="File not found"): + _sandbox(client).fs.read("/nope") + + +@respx.mock +def test_fs_read_directory_rejected(client): + respx.get(FS_URL).respond(status_code=400, json={"error": "Is a directory"}) + with pytest.raises(BadRequestError, match="directory"): + _sandbox(client).fs.read("/work") + + +@respx.mock +async def test_fs_async_roundtrip(client): + respx.put(FS_URL).respond(json={}) + respx.get(FS_URL).respond(content=b"abc") + sb = _sandbox(client) + await sb.fs.awrite("/work/a", b"abc") + assert await sb.fs.aread("/work/a") == b"abc" diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..9082911 --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,184 @@ +import json + +import httpx +import pytest +import respx + +from deepinfra import ( + NotFoundError, + Sandbox, + SandboxFailedError, + SandboxTimeoutError, + TooManySandboxesError, +) + +from .conftest import BASE_URL + +SB_ID = "sb_abc" + + +def _info(state="running", **kw): + return { + "sandbox_id": SB_ID, + "plan": kw.get("plan", "medium"), + "image": kw.get("image", ""), + "state": state, + "tags": kw.get("tags", {}), + "created_at": 1, + "provider": "kata-qemu", + } + + +@respx.mock +def test_create_waits_until_running(client): + create = respx.post(f"{BASE_URL}/v1/sandboxes").respond(json={"sandbox_id": SB_ID}) + get = respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}") + get.side_effect = [ + httpx.Response(200, json=_info("creating")), + httpx.Response(200, json=_info("starting")), + httpx.Response(200, json=_info("running")), + ] + sb = Sandbox.create(plan="medium", timeout="10m", tags={"job": "t"}, client=client) + assert sb.id == SB_ID + assert sb.state == "running" + assert get.call_count == 3 + body = json.loads(create.calls.last.request.content) + assert body == { + "image": "", + "plan": "medium", + "tags": {"job": "t"}, + "timeout_seconds": 600, + } + + +@respx.mock +def test_create_no_wait(client): + respx.post(f"{BASE_URL}/v1/sandboxes").respond(json={"sandbox_id": SB_ID}) + sb = Sandbox.create(wait=False, client=client) + assert sb.id == SB_ID + assert sb.state == "" + + +@respx.mock +def test_create_failed_state_raises(client): + respx.post(f"{BASE_URL}/v1/sandboxes").respond(json={"sandbox_id": SB_ID}) + respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("failed")) + with pytest.raises(SandboxFailedError, match="failed"): + Sandbox.create(client=client) + + +@respx.mock +def test_wait_timeout_mentions_id(client, monkeypatch): + respx.post(f"{BASE_URL}/v1/sandboxes").respond(json={"sandbox_id": SB_ID}) + respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("creating")) + with pytest.raises(SandboxTimeoutError, match=SB_ID): + Sandbox.create(wait_timeout=0.5, client=client) + + +@respx.mock +def test_create_cap_error(client): + respx.post(f"{BASE_URL}/v1/sandboxes").respond( + status_code=429, + json={"detail": {"error": "You have reached the maximum of 5 active sandboxes"}}, + ) + with pytest.raises(TooManySandboxesError, match="maximum of 5"): + Sandbox.create(client=client) + + +@respx.mock +def test_from_id_and_refresh(client): + get = respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}") + get.side_effect = [ + httpx.Response(200, json=_info("stopped")), + httpx.Response(200, json=_info("running")), + ] + sb = Sandbox.from_id(SB_ID, client=client) + assert sb.state == "stopped" + sb.refresh() + assert sb.state == "running" + + +@respx.mock +def test_from_id_not_found(client): + respx.get(f"{BASE_URL}/v1/sandboxes/missing").respond( + status_code=404, json={"detail": {"error": "Sandbox not found"}} + ) + with pytest.raises(NotFoundError): + Sandbox.from_id("missing", client=client) + + +@respx.mock +def test_list_with_client_side_tag_filter(client): + respx.get(f"{BASE_URL}/v1/sandboxes").respond( + json=[ + {**_info(), "sandbox_id": "sb_1", "tags": {"job": "etl", "env": "prod"}}, + {**_info(), "sandbox_id": "sb_2", "tags": {"job": "other"}}, + ] + ) + all_sbs = Sandbox.list(client=client) + assert [sb.id for sb in all_sbs] == ["sb_1", "sb_2"] + filtered = Sandbox.list(tags={"job": "etl"}, client=client) + assert [sb.id for sb in filtered] == ["sb_1"] + + +@respx.mock +def test_stop_start_terminate(client): + stop = respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/stop").respond(json={}) + start = respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/start").respond(json={}) + respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("running")) + delete = respx.delete(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json={}) + + sb = Sandbox.from_id(SB_ID, client=client) + sb.stop() + sb.start() + sb.terminate() + assert stop.call_count == 1 + assert start.call_count == 1 + assert delete.call_count == 1 + + +@respx.mock +def test_context_manager_terminates(client): + respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("running")) + delete = respx.delete(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json={}) + with Sandbox.from_id(SB_ID, client=client) as sb: + assert sb.state == "running" + assert delete.call_count == 1 + + +@respx.mock +def test_context_manager_tolerates_already_deleted(client): + respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("running")) + respx.delete(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond( + status_code=404, json={"error": "Sandbox not found"} + ) + with Sandbox.from_id(SB_ID, client=client): + pass + + +@respx.mock +async def test_async_lifecycle(client): + respx.post(f"{BASE_URL}/v1/sandboxes").respond(json={"sandbox_id": SB_ID}) + get = respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}") + get.side_effect = [ + httpx.Response(200, json=_info("creating")), + httpx.Response(200, json=_info("running")), + ] + stop = respx.post(f"{BASE_URL}/v1/sandboxes/{SB_ID}/stop").respond(json={}) + delete = respx.delete(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json={}) + + sb = await Sandbox.acreate(client=client) + assert sb.state == "running" + await sb.astop() + await sb.aterminate() + assert stop.call_count == 1 + assert delete.call_count == 1 + + +@respx.mock +async def test_async_context_manager(client): + respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("running")) + delete = respx.delete(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json={}) + async with await Sandbox.afrom_id(SB_ID, client=client): + pass + assert delete.call_count == 1 diff --git a/tests/test_text_generation.py b/tests/test_text_generation.py index 233c572..3f41e0a 100644 --- a/tests/test_text_generation.py +++ b/tests/test_text_generation.py @@ -1,6 +1,7 @@ import json import unittest -from unittest.mock import patch + +import respx from deepinfra.models.base.text_generation import TextGeneration @@ -9,27 +10,27 @@ class TestTextGeneration(unittest.TestCase): - @patch("requests.post") - def test_generate(self, mock_post): - - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = { - "results": [], - "num_tokens": 0, - "num_input_tokens": 0, - "inference_status": None, - } + @respx.mock + def test_generate(self): + route = respx.post( + f"https://api.deepinfra.com/v1/inference/{model_name}" + ).respond( + json={ + "results": [], + "num_tokens": 0, + "num_input_tokens": 0, + "inference_status": None, + } + ) text_generation = TextGeneration(model_name, api_key) body = {"text": "Hello, World!"} response = text_generation.generate(body) - called_args, called_kwargs = mock_post.call_args - url = called_args[0] - data = called_kwargs["data"] - header = called_kwargs["headers"] - self.assertEqual(url, f"https://api.deepinfra.com/v1/inference/{model_name}") - + request = route.calls.last.request + self.assertEqual( + str(request.url), f"https://api.deepinfra.com/v1/inference/{model_name}" + ) self.assertEqual(response.results, []) - self.assertEqual(header["Authorization"], f"Bearer {api_key}") - self.assertEqual(data, json.dumps(body)) + self.assertEqual(request.headers["Authorization"], f"Bearer {api_key}") + self.assertEqual(json.loads(request.content), body) diff --git a/tests/test_text_to_image.py b/tests/test_text_to_image.py index 6088534..d6827f1 100644 --- a/tests/test_text_to_image.py +++ b/tests/test_text_to_image.py @@ -1,6 +1,7 @@ import json import unittest -from unittest.mock import patch + +import respx from deepinfra import TextToImage @@ -9,31 +10,31 @@ class TestTextToImage(unittest.TestCase): - @patch("requests.post") - def test_generate(self, mock_post): - mock_post.return_value.status_code = 200 + @respx.mock + def test_generate(self): images = ["image data"] - - mock_post.return_value.json.return_value = { - "request_id": "123", - "inference_status": None, - "images": images, - "nsfw_content_detected": False, - "seed": "seed", - "version": "1.0", - "created_at": "2022-01-01", - } + route = respx.post( + f"https://api.deepinfra.com/v1/inference/{model_name}" + ).respond( + json={ + "request_id": "123", + "inference_status": None, + "images": images, + "nsfw_content_detected": False, + "seed": "seed", + "version": "1.0", + "created_at": "2022-01-01", + } + ) text_to_image = TextToImage(model_name, api_key) body = {"text": "Hello, World!"} response = text_to_image.generate(body) - called_args, called_kwargs = mock_post.call_args - url = called_args[0] - header = called_kwargs["headers"] - data = called_kwargs["data"] - self.assertEqual(url, f"https://api.deepinfra.com/v1/inference/{model_name}") - + request = route.calls.last.request + self.assertEqual( + str(request.url), f"https://api.deepinfra.com/v1/inference/{model_name}" + ) self.assertEqual(response.images, images) - self.assertEqual(header["Authorization"], f"Bearer {api_key}") - self.assertEqual(data, json.dumps(body)) + self.assertEqual(request.headers["Authorization"], f"Bearer {api_key}") + self.assertEqual(json.loads(request.content), body) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3706f6b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,47 @@ +import pytest + +from deepinfra._utils import backoff_delays, parse_duration, tags_match + + +@pytest.mark.parametrize( + "value,expected", + [ + (0, 0), + (90, 90), + (90.9, 90), + ("90", 90), + ("90s", 90), + ("10m", 600), + ("2h", 7200), + ("1h30m", 5400), + ("1h30m15s", 5415), + (" 10M ", 600), + ], +) +def test_parse_duration_valid(value, expected): + assert parse_duration(value) == expected + + +@pytest.mark.parametrize("value", ["", "abc", "10x", "m10", "1.5m", "10m5", -5]) +def test_parse_duration_invalid(value): + with pytest.raises(ValueError): + parse_duration(value) + + +def test_backoff_delays_grow_and_cap(): + delays = backoff_delays(initial=0.5, maximum=3.0, factor=2.0, jitter=0.0) + values = [next(delays) for _ in range(5)] + assert values == [0.5, 1.0, 2.0, 3.0, 3.0] + + +def test_backoff_jitter_bounds(): + delays = backoff_delays(initial=1.0, maximum=1.0, jitter=0.2) + for _ in range(50): + assert 0.8 <= next(delays) <= 1.2 + + +def test_tags_match(): + assert tags_match({}, {"a": "1"}) + assert tags_match({"a": "1"}, {"a": "1", "b": "2"}) + assert not tags_match({"a": "2"}, {"a": "1"}) + assert not tags_match({"c": "3"}, {"a": "1"}) From 12ea33bc3120296f7f7d57e0b6bd4f11468cb44f Mon Sep 17 00:00:00 2001 From: Georgi Atsev Date: Fri, 10 Jul 2026 12:15:58 +0300 Subject: [PATCH 2/3] ci: GitHub-hosted runners with 3.9-3.13 matrix; date 0.2.0 changelog Self-hosted runners are unsafe on a public repo (fork PRs execute arbitrary code on the runner). Restore the GitHub-hosted matrix so the full supported Python range (>=3.9) is actually tested, and advertise it in the trove classifiers. --- .github/workflows/ci.yml | 14 +++++++++++--- CHANGELOG.md | 2 +- pyproject.toml | 5 +++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15410bf..0d3a44b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,21 @@ on: jobs: checks: if: github.event.pull_request.draft == false - runs-on: self-hosted - container: - image: python:3.12-slim + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + steps: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install run: pip install -e ".[dev]" diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4ede2..9f32c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.2.0 (unreleased) +## 0.2.0 (2026-07-10) - New feature: **Sandboxes** โ€” `Sandbox.create/from_id/list`, `exec`, `run_python`, `fs.read/write`, `stop/start/terminate`, context managers, and diff --git a/pyproject.toml b/pyproject.toml index 3f7d0e1..6fd11dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ ] classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] From 997a5bdbb57175392b95f4e25ec079b0df7d874f Mon Sep 17 00:00:00 2001 From: Georgi Atsev Date: Fri, 10 Jul 2026 16:56:48 +0300 Subject: [PATCH 3/3] Quote sandbox ids in URL paths; populate fields on create(wait=False) --- deepinfra/clients/deepinfra.py | 22 ++++++++++------------ deepinfra/sandbox_api.py | 26 +++++++++++++++++++++----- tests/test_sandbox.py | 22 ++++++++++++++++++++-- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/deepinfra/clients/deepinfra.py b/deepinfra/clients/deepinfra.py index dac5b09..ee668b7 100644 --- a/deepinfra/clients/deepinfra.py +++ b/deepinfra/clients/deepinfra.py @@ -110,10 +110,8 @@ def request(self, spec: RequestSpec) -> httpx.Response: for attempt in range(self.max_retries + 1): try: response = self._sync().request(**self._request_kwargs(spec)) - except httpx.TimeoutException as exc: - error: APIConnectionError = APITimeoutError(str(exc) or "Request timed out") except httpx.TransportError as exc: - error = APIConnectionError(str(exc) or "Connection error") + error = _map_transport_error(exc) else: if self._should_retry_status(spec, response, attempt): time.sleep(next(delays)) @@ -132,10 +130,8 @@ async def arequest(self, spec: RequestSpec) -> httpx.Response: for attempt in range(self.max_retries + 1): try: response = await self._async().request(**self._request_kwargs(spec)) - except httpx.TimeoutException as exc: - error: APIConnectionError = APITimeoutError(str(exc) or "Request timed out") except httpx.TransportError as exc: - error = APIConnectionError(str(exc) or "Connection error") + error = _map_transport_error(exc) else: if self._should_retry_status(spec, response, attempt): await asyncio.sleep(next(delays)) @@ -157,10 +153,8 @@ def stream(self, spec: RequestSpec) -> Iterator[httpx.Response]: request = client.build_request(**self._request_kwargs(spec)) try: response = client.send(request, stream=True) - except httpx.TimeoutException as exc: - raise APITimeoutError(str(exc) or "Request timed out") from exc except httpx.TransportError as exc: - raise APIConnectionError(str(exc) or "Connection error") from exc + raise _map_transport_error(exc) from exc try: if response.is_error: response.read() @@ -175,10 +169,8 @@ async def astream(self, spec: RequestSpec) -> AsyncIterator[httpx.Response]: request = client.build_request(**self._request_kwargs(spec)) try: response = await client.send(request, stream=True) - except httpx.TimeoutException as exc: - raise APITimeoutError(str(exc) or "Request timed out") from exc except httpx.TransportError as exc: - raise APIConnectionError(str(exc) or "Connection error") from exc + raise _map_transport_error(exc) from exc try: if response.is_error: await response.aread() @@ -264,6 +256,12 @@ def _async(self) -> httpx.AsyncClient: return self._async_client +def _map_transport_error(exc: httpx.TransportError) -> APIConnectionError: + if isinstance(exc, httpx.TimeoutException): + return APITimeoutError(str(exc) or "Request timed out") + return APIConnectionError(str(exc) or "Connection error") + + def _default_limits() -> httpx.Limits: return httpx.Limits(max_connections=20, max_keepalive_connections=10) diff --git a/deepinfra/sandbox_api.py b/deepinfra/sandbox_api.py index d6e2cc2..439f22a 100644 --- a/deepinfra/sandbox_api.py +++ b/deepinfra/sandbox_api.py @@ -19,6 +19,7 @@ import time from collections.abc import Mapping from typing import Any, Union +from urllib.parse import quote from ._exceptions import NotFoundError, SandboxFailedError, SandboxTimeoutError from ._sandbox_models import ExecResult, SandboxInfo @@ -103,6 +104,8 @@ def create( sandbox = cls(SandboxInfo(sandbox_id=reply["sandbox_id"]), client=client) if wait: sandbox.wait_until_running(timeout=wait_timeout) + else: + sandbox.refresh() return sandbox @classmethod @@ -122,6 +125,8 @@ async def acreate( sandbox = cls(SandboxInfo(sandbox_id=reply["sandbox_id"]), client=client) if wait: await sandbox.await_until_running(timeout=wait_timeout) + else: + await sandbox.arefresh() return sandbox @classmethod @@ -306,7 +311,7 @@ def _exec_spec( http_timeout = (timeout_seconds or _DEFAULT_EXEC_TIMEOUT) + _EXEC_HTTP_GRACE return RequestSpec( "POST", - f"{_SANDBOXES}/{self.id}/exec", + _id_path(self.id, "exec"), json={"command": list(command), "timeout_seconds": timeout_seconds}, timeout=http_timeout, ) @@ -381,11 +386,22 @@ def _read_spec(self, path: str) -> RequestSpec: return RequestSpec("GET", self._content_path(), params={"path": path}) def _content_path(self) -> str: - return f"{_SANDBOXES}/{self._sandbox.id}/fs/content" + return _id_path(self._sandbox.id, "fs", "content") + + +def _id_path(sandbox_id: str, *suffix: str) -> str: + """Build a /v1/sandboxes/{id}[/...] path with the id URL-quoted. + + Quoting keeps ids like "../models" from escaping the path, so a bad id + always surfaces as a clean 404 instead of hitting an unrelated endpoint. + """ + if not sandbox_id: + raise ValueError("sandbox_id must not be empty") + return "/".join((_SANDBOXES, quote(sandbox_id, safe=""), *suffix)) def _get_spec(sandbox_id: str) -> RequestSpec: - return RequestSpec("GET", f"{_SANDBOXES}/{sandbox_id}", retry_connect=True) + return RequestSpec("GET", _id_path(sandbox_id), retry_connect=True) def _list_spec() -> RequestSpec: @@ -393,8 +409,8 @@ def _list_spec() -> RequestSpec: def _op_spec(sandbox_id: str, op: str) -> RequestSpec: - return RequestSpec("POST", f"{_SANDBOXES}/{sandbox_id}/{op}") + return RequestSpec("POST", _id_path(sandbox_id, op)) def _delete_spec(sandbox_id: str) -> RequestSpec: - return RequestSpec("DELETE", f"{_SANDBOXES}/{sandbox_id}") + return RequestSpec("DELETE", _id_path(sandbox_id)) diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 9082911..aa174ab 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -52,11 +52,29 @@ def test_create_waits_until_running(client): @respx.mock -def test_create_no_wait(client): +def test_create_no_wait_populates_fields(client): respx.post(f"{BASE_URL}/v1/sandboxes").respond(json={"sandbox_id": SB_ID}) + get = respx.get(f"{BASE_URL}/v1/sandboxes/{SB_ID}").respond(json=_info("creating")) sb = Sandbox.create(wait=False, client=client) assert sb.id == SB_ID - assert sb.state == "" + assert sb.state == "creating" + assert sb.plan == "medium" + assert get.call_count == 1 + + +@respx.mock +def test_sandbox_id_is_url_quoted(client): + quoted = f"{BASE_URL}/v1/sandboxes/..%2Fmodels" + get = respx.get(quoted).respond(status_code=404, json={"error": "Sandbox not found"}) + with pytest.raises(NotFoundError): + Sandbox.from_id("../models", client=client) + assert get.call_count == 1 + assert get.calls.last.request.url.raw_path.endswith(b"/v1/sandboxes/..%2Fmodels") + + +def test_from_id_empty_raises(client): + with pytest.raises(ValueError): + Sandbox.from_id("", client=client) @respx.mock