diff --git a/apps/python-http-client/README.md b/apps/python-http-client/README.md new file mode 100644 index 0000000..8ca2369 --- /dev/null +++ b/apps/python-http-client/README.md @@ -0,0 +1,69 @@ +# Shelby Python HTTP Client + +A lightweight Python client for the [Shelby Protocol](https://shelby.xyz) decentralized hot storage network. Built entirely on the [Shelby RPC HTTP API](https://docs.shelby.xyz/apis/rpc/shelbynet) — no extra dependencies beyond `requests`. + +## Features + +| Feature | Description | +|---|---| +| Simple upload | Single `PUT` for files under 5 MB | +| Multipart upload | Chunked upload for large files (auto-splits into 1 MB parts) | +| Auto-detection | `upload_file()` picks simple vs multipart automatically | +| Byte-range download | Read arbitrary slices — ideal for AI pipelines and streaming | +| Session support | Decrement micropayment channel sessions | + +## Installation +```bash +pip install requests +``` + +## Quick Start +```python +from shelby import ShelbyClient, ShelbyConfig + +client = ShelbyClient(ShelbyConfig(api_key="aptoslabs_...")) + +# Upload +info = client.upload("0xYourAccount", "data/hello.txt", b"Hello, Shelby!") +print(info.url) + +# Download +data = client.download("0xYourAccount", "data/hello.txt") +print(data.decode()) +``` + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `SHELBY_ACCOUNT` | Yes | Your Aptos account address | +| `SHELBY_API_KEY` | Recommended | Avoids rate limits | + +## Examples +```bash +# Basic upload and download +python examples/01_basic_upload_download.py + +# Multipart upload (8 MB synthetic blob) +python examples/02_multipart_upload.py + +# Byte-range download +python examples/03_byte_range_download.py +``` + +## API Reference + +| Method | Description | +|---|---| +| `upload(account, blob_name, data)` | Upload bytes in a single PUT | +| `upload_multipart(account, blob_name, data, part_size, verbose)` | Chunked multipart upload | +| `upload_file(account, blob_name, file_path, verbose)` | Upload from file path | +| `download(account, blob_name, byte_range?)` | Download blob, returns `bytes` | +| `download_to_file(account, blob_name, output_path)` | Download and save to disk | +| `use_session(session_id)` | Consume a micropayment session chunkset | + +## Related + +- [Shelby TypeScript SDK](https://docs.shelby.xyz/sdks/typescript) +- [shelby-quickstart](https://github.com/shelby/shelby-quickstart) +- [Shelby Docs](https://docs.shelby.xyz) diff --git a/apps/python-http-client/examples/01_basic_upload_download.py b/apps/python-http-client/examples/01_basic_upload_download.py new file mode 100644 index 0000000..43f5f19 --- /dev/null +++ b/apps/python-http-client/examples/01_basic_upload_download.py @@ -0,0 +1,30 @@ +""" +Example 1: Basic upload and download +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from shelby import ShelbyClient, ShelbyConfig + +ACCOUNT = os.environ.get("SHELBY_ACCOUNT", "0xYourAptosAccountAddress") +API_KEY = os.environ.get("SHELBY_API_KEY") + +client = ShelbyClient(ShelbyConfig(api_key=API_KEY)) + +blob_name = "examples/hello.txt" +content = b"Hello from Python! Shelby decentralized hot storage is live." + +print(f"Uploading '{blob_name}' ({len(content)} bytes)...") +info = client.upload(ACCOUNT, blob_name, content) +print(f" Upload successful!") +print(f" URL: {info.url}") + +print(f"\nDownloading '{blob_name}'...") +downloaded = client.download(ACCOUNT, blob_name) +print(f" Content: {downloaded.decode()}") + +assert downloaded == content +print("\n? Upload and download verified successfully.") diff --git a/apps/python-http-client/examples/02_multipart_upload.py b/apps/python-http-client/examples/02_multipart_upload.py new file mode 100644 index 0000000..762449b --- /dev/null +++ b/apps/python-http-client/examples/02_multipart_upload.py @@ -0,0 +1,31 @@ +""" +Example 2: Multipart upload for large files +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from shelby import ShelbyClient, ShelbyConfig + +ACCOUNT = os.environ.get("SHELBY_ACCOUNT", "0xYourAptosAccountAddress") +API_KEY = os.environ.get("SHELBY_API_KEY") + +client = ShelbyClient(ShelbyConfig(api_key=API_KEY)) + +size_mb = 8 +data = os.urandom(size_mb * 1024 * 1024) +blob_name = f"examples/synthetic_{size_mb}mb.bin" + +print(f"Uploading {size_mb} MB synthetic blob via multipart...") +info = client.upload_multipart( + account=ACCOUNT, + blob_name=blob_name, + data=data, + part_size=1024 * 1024, + verbose=True, +) +print(f"\n? Multipart upload complete.") +print(f" Upload ID: {info.upload_id}") +print(f" Blob URL : {info.url}") diff --git a/apps/python-http-client/examples/03_byte_range_download.py b/apps/python-http-client/examples/03_byte_range_download.py new file mode 100644 index 0000000..4fa822f --- /dev/null +++ b/apps/python-http-client/examples/03_byte_range_download.py @@ -0,0 +1,35 @@ +""" +Example 3: Byte-range download (partial reads) +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from shelby import ShelbyClient, ShelbyConfig + +ACCOUNT = os.environ.get("SHELBY_ACCOUNT", "0xYourAptosAccountAddress") +API_KEY = os.environ.get("SHELBY_API_KEY") + +client = ShelbyClient(ShelbyConfig(api_key=API_KEY)) + +blob_name = "examples/structured_data.txt" +records = [f"record_{i:04d}: value={i * 3.14159:.4f}\n" for i in range(100)] +content = "".join(records).encode() + +print(f"Uploading {len(content):,} bytes...") +client.upload(ACCOUNT, blob_name, content) + +print("Downloading first 256 bytes...") +first_chunk = client.download(ACCOUNT, blob_name, byte_range=(0, 255)) +print(f" {first_chunk.decode()[:80]}...") + +print("Downloading bytes 512-1023...") +middle_chunk = client.download(ACCOUNT, blob_name, byte_range=(512, 1023)) +print(f" {middle_chunk.decode()[:80]}...") + +full = client.download(ACCOUNT, blob_name) +assert full[0:256] == first_chunk +assert full[512:1024] == middle_chunk +print("\n? Byte-range reads verified.") diff --git a/apps/python-http-client/package.json b/apps/python-http-client/package.json new file mode 100644 index 0000000..a596c5f --- /dev/null +++ b/apps/python-http-client/package.json @@ -0,0 +1,6 @@ +{ + "name": "@shelby-protocol/python-http-client", + "version": "0.1.0", + "description": "Python HTTP client example for the Shelby Protocol RPC API", + "private": true +} diff --git a/apps/python-http-client/requirements.txt b/apps/python-http-client/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/apps/python-http-client/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/apps/python-http-client/shelby/__init__.py b/apps/python-http-client/shelby/__init__.py new file mode 100644 index 0000000..09803ba --- /dev/null +++ b/apps/python-http-client/shelby/__init__.py @@ -0,0 +1,4 @@ +from .client import BlobInfo, ShelbyClient, ShelbyConfig, ShelbyError + +__all__ = ["ShelbyClient", "ShelbyConfig", "BlobInfo", "ShelbyError"] +__version__ = "0.1.0" diff --git a/apps/python-http-client/shelby/client.py b/apps/python-http-client/shelby/client.py new file mode 100644 index 0000000..20dae3d --- /dev/null +++ b/apps/python-http-client/shelby/client.py @@ -0,0 +1,159 @@ +""" +Shelby Protocol - Python HTTP Client +A lightweight Python client for the Shelby decentralized hot storage network. + +Shelby API base: https://api.shelbynet.shelby.xyz/shelby +Docs: https://docs.shelby.xyz +""" + +import math +import os +from dataclasses import dataclass +from typing import Optional + +import requests + +SHELBYNET_BASE_URL = "https://api.shelbynet.shelby.xyz/shelby" +DEFAULT_PART_SIZE = 1_048_576 # 1 MB (Shelby default) + + +@dataclass +class ShelbyConfig: + """Configuration for the Shelby client.""" + base_url: str = SHELBYNET_BASE_URL + api_key: Optional[str] = None + timeout: int = 60 + + @property + def headers(self) -> dict: + h = {} + if self.api_key: + h["Authorization"] = f"Bearer {self.api_key}" + return h + + +@dataclass +class BlobInfo: + """Metadata returned after a successful upload.""" + account: str + blob_name: str + size: int + upload_id: Optional[str] = None + base_url: str = SHELBYNET_BASE_URL + + @property + def url(self) -> str: + return f"{self.base_url}/v1/blobs/{self.account}/{self.blob_name}" + + +class ShelbyError(Exception): + """Raised when a Shelby API call fails.""" + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class ShelbyClient: + """ + Python HTTP client for the Shelby Protocol RPC API. + + Example: + client = ShelbyClient(ShelbyConfig(api_key="aptoslabs_...")) + info = client.upload("0xYourAccount", "data/hello.txt", b"Hello, Shelby!") + data = client.download("0xYourAccount", "data/hello.txt") + """ + + def __init__(self, config: Optional[ShelbyConfig] = None): + self.config = config or ShelbyConfig( + api_key=os.environ.get("SHELBY_API_KEY") + ) + self.session = requests.Session() + self.session.headers.update(self.config.headers) + + def upload(self, account: str, blob_name: str, data: bytes) -> BlobInfo: + """Upload a blob in a single PUT request. Suitable for files under 5 MB.""" + url = f"{self.config.base_url}/v1/blobs/{account}/{blob_name}" + headers = {"Content-Length": str(len(data))} + resp = self.session.put(url, data=data, headers=headers, timeout=self.config.timeout) + if resp.status_code not in (200, 204): + raise ShelbyError(f"Upload failed: {resp.status_code} {resp.text}", status_code=resp.status_code) + return BlobInfo(account=account, blob_name=blob_name, size=len(data), base_url=self.config.base_url) + + def upload_multipart(self, account: str, blob_name: str, data: bytes, part_size: int = DEFAULT_PART_SIZE, verbose: bool = False) -> BlobInfo: + """Upload a large blob using multipart upload. Auto-chunks the data.""" + upload_id = self._start_multipart(account, blob_name, part_size) + if verbose: + print(f"[shelby] Started multipart upload: {upload_id}") + total_parts = math.ceil(len(data) / part_size) + for idx in range(total_parts): + chunk = data[idx * part_size: (idx + 1) * part_size] + self._upload_part(upload_id, idx, chunk) + if verbose: + print(f"[shelby] Uploaded part {idx + 1}/{total_parts} ({len(chunk)} bytes)") + self._complete_multipart(upload_id) + if verbose: + print(f"[shelby] Multipart upload complete: {blob_name}") + return BlobInfo(account=account, blob_name=blob_name, size=len(data), upload_id=upload_id, base_url=self.config.base_url) + + def _start_multipart(self, account: str, blob_name: str, part_size: int) -> str: + url = f"{self.config.base_url}/v1/multipart-uploads" + payload = {"rawAccount": account, "rawBlobName": blob_name, "rawPartSize": part_size} + resp = self.session.post(url, json=payload, timeout=self.config.timeout) + if resp.status_code != 200: + raise ShelbyError(f"Failed to start multipart upload: {resp.status_code} {resp.text}", status_code=resp.status_code) + return resp.json()["uploadId"] + + def _upload_part(self, upload_id: str, part_idx: int, chunk: bytes) -> None: + url = f"{self.config.base_url}/v1/multipart-uploads/{upload_id}/parts/{part_idx}" + resp = self.session.put(url, data=chunk, timeout=self.config.timeout) + if resp.status_code != 200: + raise ShelbyError(f"Failed to upload part {part_idx}: {resp.status_code} {resp.text}", status_code=resp.status_code) + + def _complete_multipart(self, upload_id: str) -> None: + url = f"{self.config.base_url}/v1/multipart-uploads/{upload_id}/complete" + resp = self.session.post(url, timeout=self.config.timeout) + if resp.status_code != 200: + raise ShelbyError(f"Failed to complete multipart upload: {resp.status_code} {resp.text}", status_code=resp.status_code) + + def upload_file(self, account: str, blob_name: str, file_path: str, multipart_threshold: int = 5 * 1024 * 1024, verbose: bool = False) -> BlobInfo: + """Upload a local file. Auto-selects simple vs multipart based on size.""" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + with open(file_path, "rb") as f: + data = f.read() + if verbose: + print(f"[shelby] Uploading {file_path} ({len(data):,} bytes) -> {blob_name}") + if len(data) > multipart_threshold: + return self.upload_multipart(account, blob_name, data, verbose=verbose) + return self.upload(account, blob_name, data) + + def download(self, account: str, blob_name: str, byte_range: Optional[tuple] = None) -> bytes: + """Download a blob. Optionally specify a (start, end) byte range.""" + url = f"{self.config.base_url}/v1/blobs/{account}/{blob_name}" + headers = {} + if byte_range is not None: + headers["Range"] = f"bytes={byte_range[0]}-{byte_range[1]}" + resp = self.session.get(url, headers=headers, timeout=self.config.timeout) + if resp.status_code not in (200, 206): + raise ShelbyError(f"Download failed: {resp.status_code} {resp.text}", status_code=resp.status_code) + return resp.content + + def download_to_file(self, account: str, blob_name: str, output_path: str, byte_range: Optional[tuple] = None) -> int: + """Download a blob and save to a local file. Returns bytes written.""" + data = self.download(account, blob_name, byte_range=byte_range) + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, "wb") as f: + f.write(data) + return len(data) + + def use_session(self, session_id: str) -> bool: + """Consume one chunkset from a micropayment session.""" + url = f"{self.config.base_url}/v1/sessions/{session_id}/use" + resp = self.session.post(url, timeout=self.config.timeout) + if resp.status_code == 200: + return True + elif resp.status_code == 402: + raise ShelbyError("Session exhausted or insufficient balance.", status_code=402) + elif resp.status_code == 404: + raise ShelbyError(f"Session not found: {session_id}", status_code=404) + raise ShelbyError(f"use_session failed: {resp.status_code} {resp.text}", status_code=resp.status_code)