Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/python-http-client/README.md
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 30 additions & 0 deletions apps/python-http-client/examples/01_basic_upload_download.py
Original file line number Diff line number Diff line change
@@ -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.")
31 changes: 31 additions & 0 deletions apps/python-http-client/examples/02_multipart_upload.py
Original file line number Diff line number Diff line change
@@ -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}")
35 changes: 35 additions & 0 deletions apps/python-http-client/examples/03_byte_range_download.py
Original file line number Diff line number Diff line change
@@ -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.")
6 changes: 6 additions & 0 deletions apps/python-http-client/package.json
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions apps/python-http-client/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests>=2.31.0
4 changes: 4 additions & 0 deletions apps/python-http-client/shelby/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .client import BlobInfo, ShelbyClient, ShelbyConfig, ShelbyError

__all__ = ["ShelbyClient", "ShelbyConfig", "BlobInfo", "ShelbyError"]
__version__ = "0.1.0"
159 changes: 159 additions & 0 deletions apps/python-http-client/shelby/client.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upload_file loads entire large file into memory

Low Severity

upload_file reads the entire file into memory with f.read() before deciding whether to use multipart upload. Since multipart upload is specifically intended for large files (those exceeding the 5 MB multipart_threshold), this means the exact files meant to benefit from chunked uploading are fully loaded into memory first. For very large files, this can cause MemoryError or excessive memory usage, undermining the stated "chunked upload for large files" feature.

Fix in Cursor Fix in Web


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)