Implement Python asynchronous client SDK

Add complete async Python SDK implementation with all 13 API functions using aiohttp:
- execute_code: Execute code asynchronously
- execute_async: Start async code execution
- get_job: Get job status
- wait_for_job: Poll job with exponential backoff (async)
- cancel_job: Cancel running job
- list_jobs: List all jobs
- get_languages: Get supported languages (1-hour cache)
- detect_language: Detect language from filename extension
- session_snapshot: Create session snapshot (NEW)
- service_snapshot: Create service snapshot (NEW)
- list_snapshots: List all snapshots (NEW)
- restore_snapshot: Restore from snapshot (NEW)
- delete_snapshot: Delete snapshot (NEW)

Features:
- Full async/await support with aiohttp
- 4-tier credentials resolution
- HMAC-SHA256 authentication
- Exponential backoff polling with asyncio.sleep
- Languages cache (1-hour TTL)
- Type hints and comprehensive docstrings
This commit is contained in:
russell@unturf.com 2026-01-15 16:01:39 -05:00
parent 9b9a76a79a
commit 8acb023bc2

View file

@ -0,0 +1,704 @@
"""
PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
unsandbox.com Python SDK (Asynchronous)
Library Usage:
import asyncio
from un_async import (
execute_code,
execute_async,
get_job,
wait_for_job,
cancel_job,
list_jobs,
get_languages,
detect_language,
session_snapshot,
service_snapshot,
list_snapshots,
restore_snapshot,
delete_snapshot,
)
async def main():
# Execute code synchronously
result = await execute_code("python", 'print("hello")', public_key, secret_key)
# Execute asynchronously
job_id = await execute_async("javascript", 'console.log("hello")', public_key, secret_key)
# Wait for job completion with exponential backoff
result = await wait_for_job(job_id, public_key, secret_key)
# List all jobs
jobs = await list_jobs(public_key, secret_key)
# Get supported languages
languages = await get_languages(public_key, secret_key)
# Snapshot operations
snapshot_id = await session_snapshot(session_id, public_key, secret_key)
asyncio.run(main())
Authentication Priority (4-tier):
1. Function arguments (public_key, secret_key)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
4. Local directory (./accounts.csv, line 0 by default)
Request Authentication (HMAC-SHA256):
Authorization: Bearer <public_key>
X-Timestamp: <unix_seconds>
X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
Languages Cache:
- Cached in ~/.unsandbox/languages.json
- TTL: 1 hour
- Updated on successful API calls
"""
import asyncio
import hashlib
import hmac
import json
import os
import time
import aiohttp
from pathlib import Path
from typing import Optional, Dict, Any, List
API_BASE = "https://api.unsandbox.com"
POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000]
LANGUAGES_CACHE_TTL = 3600 # 1 hour
class CredentialsError(Exception):
"""Raised when credentials cannot be found or are invalid."""
pass
def _get_unsandbox_dir() -> Path:
"""Get ~/.unsandbox directory path, creating if necessary."""
home = Path.home()
unsandbox_dir = home / ".unsandbox"
unsandbox_dir.mkdir(exist_ok=True, mode=0o700)
return unsandbox_dir
def _load_credentials_from_csv(csv_path: Path, account_index: int = 0) -> Optional[tuple[str, str]]:
"""Load credentials from CSV file (public_key,secret_key per line)."""
if not csv_path.exists():
return None
try:
with open(csv_path, "r") as f:
for i, line in enumerate(f):
line = line.strip()
if not line or line.startswith("#"):
continue
if i == account_index:
parts = line.split(",")
if len(parts) >= 2:
return (parts[0].strip(), parts[1].strip())
return None
except Exception:
return None
def _resolve_credentials(
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
account_index: Optional[int] = None,
) -> tuple[str, str]:
"""
Resolve credentials from 4-tier priority system.
Priority:
1. Function arguments
2. Environment variables
3. ~/.unsandbox/accounts.csv
4. ./accounts.csv
"""
# Tier 1: Function arguments
if public_key and secret_key:
return (public_key, secret_key)
# Tier 2: Environment variables
env_pk = os.environ.get("UNSANDBOX_PUBLIC_KEY")
env_sk = os.environ.get("UNSANDBOX_SECRET_KEY")
if env_pk and env_sk:
return (env_pk, env_sk)
# Determine account index
if account_index is None:
account_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0"))
# Tier 3: ~/.unsandbox/accounts.csv
unsandbox_dir = _get_unsandbox_dir()
creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", account_index)
if creds:
return creds
# Tier 4: ./accounts.csv
creds = _load_credentials_from_csv(Path("accounts.csv"), account_index)
if creds:
return creds
raise CredentialsError(
"No credentials found. Please provide via:\n"
" 1. Function arguments (public_key, secret_key)\n"
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n"
" 3. ~/.unsandbox/accounts.csv\n"
" 4. ./accounts.csv"
)
def _sign_request(
secret_key: str,
timestamp: int,
method: str,
path: str,
body: Optional[str] = None,
) -> str:
"""
Sign a request using HMAC-SHA256.
Message format: "timestamp:METHOD:path:body"
Returns: 64-character hex string
"""
body_str = body or ""
message = f"{timestamp}:{method}:{path}:{body_str}"
signature = hmac.new(
secret_key.encode(),
message.encode(),
hashlib.sha256,
).hexdigest()
return signature
async def _make_request(
method: str,
path: str,
public_key: str,
secret_key: str,
data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Make an authenticated HTTP request to the API (async).
Raises aiohttp.ClientError on network errors.
Raises ValueError if response is not valid JSON.
"""
url = f"{API_BASE}{path}"
timestamp = int(time.time())
body = json.dumps(data) if data else ""
signature = _sign_request(secret_key, timestamp, method, path, body if data else None)
headers = {
"Authorization": f"Bearer {public_key}",
"X-Timestamp": str(timestamp),
"X-Signature": signature,
"Content-Type": "application/json",
}
async with aiohttp.ClientSession() as session:
if method == "GET":
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp:
resp.raise_for_status()
return await resp.json()
elif method == "POST":
async with session.post(url, headers=headers, json=data, timeout=aiohttp.ClientTimeout(total=120)) as resp:
resp.raise_for_status()
return await resp.json()
elif method == "DELETE":
async with session.delete(url, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp:
resp.raise_for_status()
return await resp.json()
else:
raise ValueError(f"Unsupported HTTP method: {method}")
def _get_languages_cache_path() -> Path:
"""Get path to languages cache file."""
return _get_unsandbox_dir() / "languages.json"
def _load_languages_cache() -> Optional[List[str]]:
"""Load languages from cache if valid (< 1 hour old)."""
cache_path = _get_languages_cache_path()
if not cache_path.exists():
return None
try:
with open(cache_path, "r") as f:
data = json.load(f)
# Check if cache is fresh
mtime = cache_path.stat().st_mtime
age_seconds = time.time() - mtime
if age_seconds < LANGUAGES_CACHE_TTL:
return data.get("languages")
except Exception:
pass
return None
def _save_languages_cache(languages: List[str]) -> None:
"""Save languages to cache."""
try:
cache_path = _get_languages_cache_path()
with open(cache_path, "w") as f:
json.dump({"languages": languages, "timestamp": int(time.time())}, f)
except Exception:
pass # Cache failures are non-fatal
async def execute_code(
language: str,
code: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Execute code synchronously (awaits until completion).
Args:
language: Programming language (e.g., "python", "javascript", "go")
code: Source code to execute
public_key: Optional API key (uses credentials resolution if not provided)
secret_key: Optional API secret (uses credentials resolution if not provided)
Returns:
Response dict containing stdout, stderr, exit code, etc.
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
response = await _make_request(
"POST",
"/execute",
public_key,
secret_key,
{"language": language, "code": code},
)
# If we got a job_id, poll until completion
job_id = response.get("job_id")
status = response.get("status")
if job_id and status in ("pending", "running"):
return await wait_for_job(job_id, public_key, secret_key)
return response
async def execute_async(
language: str,
code: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> str:
"""
Execute code asynchronously (returns immediately with job_id).
Args:
language: Programming language (e.g., "python", "javascript")
code: Source code to execute
public_key: Optional API key
secret_key: Optional API secret
Returns:
Job ID string
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
response = await _make_request(
"POST",
"/execute",
public_key,
secret_key,
{"language": language, "code": code},
)
return response.get("job_id")
async def get_job(
job_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Get current status/result of a job (single poll, no waiting).
Args:
job_id: Job ID from execute_async()
public_key: Optional API key
secret_key: Optional API secret
Returns:
Job response dict
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
return await _make_request("GET", f"/jobs/{job_id}", public_key, secret_key)
async def wait_for_job(
job_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Wait for job completion with exponential backoff polling (async).
Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
Args:
job_id: Job ID from execute_async()
public_key: Optional API key
secret_key: Optional API secret
Returns:
Final job result when status is terminal (completed, failed, timeout, cancelled)
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
poll_count = 0
while True:
# Sleep before polling
delay_idx = min(poll_count, len(POLL_DELAYS_MS) - 1)
await asyncio.sleep(POLL_DELAYS_MS[delay_idx] / 1000.0)
poll_count += 1
response = await get_job(job_id, public_key, secret_key)
status = response.get("status")
if status in ("completed", "failed", "timeout", "cancelled"):
return response
# Still running, continue polling
async def cancel_job(
job_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Cancel a running job.
Args:
job_id: Job ID to cancel
public_key: Optional API key
secret_key: Optional API secret
Returns:
Response dict with cancellation confirmation
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
return await _make_request("DELETE", f"/jobs/{job_id}", public_key, secret_key)
async def list_jobs(
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
List all jobs for the authenticated account.
Args:
public_key: Optional API key
secret_key: Optional API secret
Returns:
List of job dicts
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
response = await _make_request("GET", "/jobs", public_key, secret_key)
return response.get("jobs", [])
async def get_languages(
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> List[str]:
"""
Get list of supported programming languages.
Results are cached for 1 hour in ~/.unsandbox/languages.json
Args:
public_key: Optional API key
secret_key: Optional API secret
Returns:
List of language identifiers (e.g., ["python", "javascript", "go", ...])
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
# Try cache first
cached = _load_languages_cache()
if cached:
return cached
public_key, secret_key = _resolve_credentials(public_key, secret_key)
response = await _make_request("GET", "/languages", public_key, secret_key)
languages = response.get("languages", [])
# Cache the result
_save_languages_cache(languages)
return languages
# Language detection mapping (file extension -> language)
_LANGUAGE_MAP = {
"py": "python",
"js": "javascript",
"ts": "typescript",
"rb": "ruby",
"php": "php",
"pl": "perl",
"sh": "bash",
"r": "r",
"R": "r",
"lua": "lua",
"go": "go",
"rs": "rust",
"c": "c",
"cpp": "cpp",
"cc": "cpp",
"cxx": "cpp",
"java": "java",
"kt": "kotlin",
"m": "objc",
"cs": "csharp",
"fs": "fsharp",
"hs": "haskell",
"ml": "ocaml",
"clj": "clojure",
"scm": "scheme",
"ss": "scheme",
"erl": "erlang",
"ex": "elixir",
"exs": "elixir",
"jl": "julia",
"d": "d",
"nim": "nim",
"zig": "zig",
"v": "v",
"cr": "crystal",
"dart": "dart",
"groovy": "groovy",
"f90": "fortran",
"f95": "fortran",
"lisp": "commonlisp",
"lsp": "commonlisp",
"cob": "cobol",
"tcl": "tcl",
"raku": "raku",
"pro": "prolog",
"p": "prolog",
"4th": "forth",
"forth": "forth",
"fth": "forth",
}
def detect_language(filename: str) -> Optional[str]:
"""
Detect programming language from filename extension.
Args:
filename: Filename to detect language from (e.g., "script.py")
Returns:
Language identifier (e.g., "python") or None if unknown
Examples:
detect_language("hello.py") # -> "python"
detect_language("script.js") # -> "javascript"
detect_language("main.go") # -> "go"
detect_language("unknown") # -> None
"""
if not filename or "." not in filename:
return None
ext = filename.rsplit(".", 1)[-1].lower()
return _LANGUAGE_MAP.get(ext)
async def session_snapshot(
session_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
name: Optional[str] = None,
hot: bool = False,
) -> str:
"""
Create a snapshot of a session (NEW).
Args:
session_id: Session ID to snapshot
public_key: Optional API key
secret_key: Optional API secret
name: Optional snapshot name
hot: If True, snapshot running session (hot snapshot)
Returns:
Snapshot ID
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
data = {"session_id": session_id, "hot": hot}
if name:
data["name"] = name
response = await _make_request("POST", "/snapshots", public_key, secret_key, data)
return response.get("snapshot_id")
async def service_snapshot(
service_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
name: Optional[str] = None,
hot: bool = False,
) -> str:
"""
Create a snapshot of a service (NEW).
Args:
service_id: Service ID to snapshot
public_key: Optional API key
secret_key: Optional API secret
name: Optional snapshot name
hot: If True, snapshot running service (hot snapshot)
Returns:
Snapshot ID
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
data = {"service_id": service_id, "hot": hot}
if name:
data["name"] = name
response = await _make_request("POST", "/snapshots", public_key, secret_key, data)
return response.get("snapshot_id")
async def list_snapshots(
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
List all snapshots (NEW).
Args:
public_key: Optional API key
secret_key: Optional API secret
Returns:
List of snapshot dicts
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
response = await _make_request("GET", "/snapshots", public_key, secret_key)
return response.get("snapshots", [])
async def restore_snapshot(
snapshot_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Restore a snapshot (NEW).
Args:
snapshot_id: Snapshot ID to restore
public_key: Optional API key
secret_key: Optional API secret
Returns:
Response dict with restored resource info
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
return await _make_request("POST", f"/snapshots/{snapshot_id}/restore", public_key, secret_key, {})
async def delete_snapshot(
snapshot_id: str,
public_key: Optional[str] = None,
secret_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Delete a snapshot (NEW).
Args:
snapshot_id: Snapshot ID to delete
public_key: Optional API key
secret_key: Optional API secret
Returns:
Response dict with deletion confirmation
Raises:
aiohttp.ClientError: Network errors
ValueError: Invalid response format
CredentialsError: Missing credentials
"""
public_key, secret_key = _resolve_credentials(public_key, secret_key)
return await _make_request("DELETE", f"/snapshots/{snapshot_id}", public_key, secret_key)