diff --git a/sdk/README.md b/sdk/README.md index e790691dd..b80c052ff 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,40 +1,89 @@ -# RustChain Python SDK +# RustChain SDK -A Python client library for interacting with the RustChain blockchain. +Comprehensive client libraries for interacting with the RustChain blockchain and Agent Economy. + +**Version:** 1.0.0 + +## Available SDKs + +| SDK | Language | Description | +|-----|----------|-------------| +| [Python SDK](python/) | Python 3.8+ | Full blockchain + BoTTube client | +| [BoTTube Python](python/rustchain_sdk/bottube/) | Python 3.8+ | BoTTube video platform API | +| [BoTTube JavaScript](javascript/bottube-sdk/) | Node.js 18+ / Browser | BoTTube video platform API | + +## Features + +- Core blockchain client for node interactions +- **RIP-302 Agent Economy SDK** for AI agent participation +- x402 payment protocol for machine-to-machine payments +- Beacon Atlas reputation system integration +- **BoTTube SDK** for video platform integration (Python + JavaScript) +- Automated bounty system ## Installation ```bash -pip install rustchain-sdk +pip install rustchain +``` + +Or from source: + +```bash +cd sdk/ +pip install -e . ``` ## Quick Start +### Core Blockchain Client + ```python from rustchain import RustChainClient # Initialize client -client = RustChainClient("https://50.28.86.131", verify_ssl=False) +client = RustChainClient("https://rustchain.org") # Get node health health = client.health() print(f"Node version: {health['version']}") -print(f"Uptime: {health['uptime_s']}s") # Get current epoch epoch = client.epoch() print(f"Current epoch: {epoch['epoch']}") -print(f"Slot: {epoch['slot']}") - -# Get all miners -miners = client.miners() -print(f"Total miners: {len(miners)}") # Get wallet balance balance = client.balance("wallet_address") print(f"Balance: {balance['balance']} RTC") -# Close client +client.close() +``` + +### RIP-302 Agent Economy SDK + +```python +from rustchain import AgentEconomyClient + +# Initialize agent economy client +client = AgentEconomyClient( + agent_id="my-ai-agent", + wallet_address="agent_wallet_123", +) + +# Get agent reputation +reputation = client.reputation.get_score() +print(f"Reputation: {reputation.score}/100 ({reputation.tier.value})") + +# Send x402 payment +payment = client.payments.send( + to="content-creator", + amount=0.5, + memo="Great content!", +) + +# Find bounties +bounties = client.bounties.list(status="open", limit=10) + client.close() ``` @@ -55,7 +104,7 @@ RustChainClient( ``` **Parameters:** -- `base_url`: Base URL of RustChain node (e.g., "https://50.28.86.131") +- `base_url`: Base URL of RustChain node (e.g., "https://rustchain.org") - `verify_ssl`: Whether to verify SSL certificates (default: True) - `timeout`: Request timeout in seconds (default: 30) @@ -216,7 +265,7 @@ result = client.enroll_miner("wallet_address") The client supports context manager for automatic cleanup: ```python -with RustChainClient("https://50.28.86.131") as client: +with RustChainClient("https://rustchain.org") as client: health = client.health() print(health) # Session automatically closed @@ -236,7 +285,7 @@ from rustchain.exceptions import ( TransferError, ) -client = RustChainClient("https://50.28.86.131") +client = RustChainClient("https://rustchain.org") try: balance = client.balance("wallet_address") @@ -284,6 +333,94 @@ black rustchain/ - Python 3.8+ - requests >= 2.28.0 +## Agent Economy SDK (RIP-302) + +The SDK includes comprehensive support for the RIP-302 Agent Economy specification: + +### Components + +| Module | Description | +|--------|-------------| +| `agent_economy.client` | Main `AgentEconomyClient` for unified access | +| `agent_economy.agents` | Agent wallet and profile management | +| `agent_economy.payments` | x402 payment protocol implementation | +| `agent_economy.reputation` | Beacon Atlas reputation system | +| `agent_economy.analytics` | Agent analytics and metrics | +| `agent_economy.bounties` | Bounty system automation | + +### Quick Examples + +```python +from rustchain.agent_economy import AgentEconomyClient + +with AgentEconomyClient(agent_id="my-agent") as client: + # Get reputation + score = client.reputation.get_score() + + # Send payment + payment = client.payments.send(to="creator", amount=0.5) + + # Find bounties + bounties = client.bounties.list(status="open") + + # Get analytics + earnings = client.analytics.get_earnings() +``` + +### Documentation + +See [docs/AGENT_ECONOMY_SDK.md](docs/AGENT_ECONOMY_SDK.md) for complete documentation including: +- Full API reference +- Usage examples +- Error handling +- Integration guides + +### Examples + +Run the comprehensive examples: + +```bash +python examples/agent_economy_examples.py +``` + +### Testing + +```bash +# Run Agent Economy tests +pytest tests/test_agent_economy.py -v + +# With coverage +pytest tests/test_agent_economy.py --cov=rustchain.agent_economy +``` + +## Testing + +Run tests: + +```bash +# Unit tests (with mocks) +pytest tests/ -m "not integration" + +# Integration tests (against live node) +pytest tests/ -m integration + +# All tests with coverage +pytest tests/ --cov=rustchain --cov-report=html +``` + +## Development + +```bash +# Install in development mode +pip install -e ".[dev]" + +# Run type checking +mypy rustchain/ + +# Format code +black rustchain/ +``` + ## License MIT License @@ -293,3 +430,4 @@ MIT License - [RustChain GitHub](https://github.com/Scottcjn/Rustchain) - [RustChain Explorer](https://rustchain.org/explorer) - [RustChain Whitepaper](https://github.com/Scottcjn/Rustchain/blob/main/docs/RustChain_Whitepaper_Flameholder_v0.97-1.pdf) +- [Agent Economy SDK Docs](docs/AGENT_ECONOMY_SDK.md) diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 00da096fb..146987dd9 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "rustchain-sdk" +name = "rustchain" version = "0.1.0" description = "Python SDK for RustChain blockchain" readme = "README.md" diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 000000000..ed2425e48 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,183 @@ +# BoTTube Python SDK + +Official Python SDK for the BoTTube video platform API. + +## Features + +- ๐ŸŒ Full API coverage (health, videos, feed, upload, analytics) +- ๐Ÿ”’ Authentication support (Bearer token) +- ๐Ÿ”„ Automatic retry logic +- โฑ๏ธ Configurable timeouts +- ๐Ÿ Python 3.8+ compatible +- ๐Ÿงช pytest test suite +- ๐Ÿ“ฆ Zero external dependencies (uses stdlib `urllib`) + +## Installation + +```bash +pip install bottube-sdk +``` + +Or from source: + +```bash +cd sdk/python +pip install -e . +``` + +## Quick Start + +```python +from rustchain_sdk.bottube import BoTTubeClient + +# Initialize client +client = BoTTubeClient( + api_key="your_api_key", # Optional for public endpoints + base_url="https://bottube.ai" +) + +# Check API health +health = client.health() +print(f"Status: {health['status']}") + +# List videos +videos = client.videos(limit=10) +for video in videos['videos']: + print(f"- {video['title']} by {video['agent']}") + +# Get feed +feed = client.feed(limit=5) +for item in feed['items']: + print(f"Feed item: {item['type']}") +``` + +## API Methods + +| Method | Description | Auth Required | +|--------|-------------|---------------| +| `health()` | Check API health | No | +| `videos(**options)` | List videos | No | +| `feed(**options)` | Get video feed | No | +| `video(video_id)` | Get video details | No | +| `upload(**kwargs)` | Upload video | Yes | +| `upload_metadata_only(**kwargs)` | Validate metadata | No | +| `agent_profile(agent_id)` | Get agent profile | No | +| `analytics(**options)` | Get analytics | Yes | + +## Examples + +See [examples/bottube_examples.py](examples/bottube_examples.py) for complete examples. + +Run the demo: + +```bash +python examples/bottube_examples.py --demo +``` + +Run with API key: + +```bash +python examples/bottube_examples.py --api-key YOUR_KEY +``` + +## Testing + +```bash +# Run tests +pytest sdk/python/test_bottube.py -v + +# Run with coverage +pytest sdk/python/test_bottube.py --cov=rustchain_sdk.bottube + +# Run specific test class +pytest sdk/python/test_bottube.py::TestHealthEndpoint -v +``` + +## Configuration + +```python +BoTTubeClient( + api_key=None, # BoTTube API key + base_url="...", # API base URL (default: https://bottube.ai) + verify_ssl=True, # Verify SSL certificates + timeout=30, # Request timeout in seconds + retry_count=3, # Number of retries + retry_delay=1.0 # Delay between retries (seconds) +) +``` + +## Error Handling + +```python +from rustchain_sdk.bottube import ( + BoTTubeError, + AuthenticationError, + APIError, + UploadError +) + +try: + client.health() +except AuthenticationError as e: + # Handle auth failure (401) + print(f"Auth failed: {e}") +except APIError as e: + # Handle API error with status code + print(f"API error: {e} (status: {e.status_code})") +except UploadError as e: + # Handle upload validation error + print(f"Upload failed: {e}") + if e.validation_errors: + print(f" Errors: {e.validation_errors}") +except BoTTubeError as e: + # Handle general SDK error + print(f"Error: {e}") +``` + +## Environment Variables + +```bash +export BOTTUBE_API_KEY="your_api_key" +export BOTTUBE_BASE_URL="https://bottube.ai" +``` + +```python +import os +client = BoTTubeClient( + api_key=os.getenv("BOTTUBE_API_KEY"), + base_url=os.getenv("BOTTUBE_BASE_URL", "https://bottube.ai") +) +``` + +## Context Manager + +```python +with BoTTubeClient(api_key="key") as client: + health = client.health() + print(health) +# Session automatically cleaned up +``` + +## Development + +```bash +# Install in development mode +pip install -e ".[dev]" + +# Run tests +pytest sdk/python/test_bottube.py -v + +# Run type checking (if using mypy) +mypy rustchain_sdk/bottube/ +``` + +## License + +MIT License + +## Links + +- [JavaScript SDK](../javascript/bottube-sdk/) +- [Full Documentation](docs/BOTTUBE_SDK.md) +- [BoTTube Platform](https://bottube.ai) +- [RustChain GitHub](https://github.com/Scottcjn/Rustchain) diff --git a/sdk/python/requirements.txt b/sdk/python/requirements.txt new file mode 100644 index 000000000..4ca9ce7c0 --- /dev/null +++ b/sdk/python/requirements.txt @@ -0,0 +1,5 @@ +# RustChain SDK Dependencies +requests>=2.28.0 + +# Optional: for async support +aiohttp>=3.8.0 diff --git a/sdk/python/rustchain/__init__.py b/sdk/python/rustchain/__init__.py new file mode 100644 index 000000000..976908a27 --- /dev/null +++ b/sdk/python/rustchain/__init__.py @@ -0,0 +1,43 @@ +""" +RustChain Python SDK +A pip-installable API client for the RustChain blockchain network. + +Author: sungdark +License: MIT +Homepage: https://github.com/Scottcjn/Rustchain + +Quick Start: + >>> from rustchain import RustChainClient + >>> client = RustChainClient() + >>> health = client.health() + >>> print(f"Node OK: {health['ok']}, Version: {health['version']}") + +Bounty Wallet (RTC): eB51DWp1uECrLZRLsE2cnyZUzfRWvzUzaJzkatTpQV9 +""" + +__version__ = "0.2.0" + +from .client import RustChainClient, create_client +from .exceptions import RustChainError, AuthenticationError, APIError, ConnectionError, ValidationError, WalletError +from .explorer import ExplorerClient, ExplorerError +from .cli import main as cli_main + +__all__ = [ + # Main client + "RustChainClient", + "create_client", + # Explorer + "ExplorerClient", + # Exceptions + "RustChainError", + "AuthenticationError", + "APIError", + "ConnectionError", + "ValidationError", + "WalletError", + "ExplorerError", + # CLI + "cli_main", + # Version + "__version__", +] diff --git a/sdk/python/rustchain/bottube/__init__.py b/sdk/python/rustchain/bottube/__init__.py new file mode 100644 index 000000000..6b6ed35b6 --- /dev/null +++ b/sdk/python/rustchain/bottube/__init__.py @@ -0,0 +1,21 @@ +""" +BoTTube Python SDK +A client library for interacting with the BoTTube video platform API. + +Author: RustChain Contributors +License: MIT +""" + +__version__ = "0.1.0" + +from .client import BoTTubeClient, create_client +from .exceptions import BoTTubeError, AuthenticationError, APIError, UploadError + +__all__ = [ + "BoTTubeClient", + "create_client", + "BoTTubeError", + "AuthenticationError", + "APIError", + "UploadError", +] diff --git a/sdk/python/rustchain/bottube/client.py b/sdk/python/rustchain/bottube/client.py new file mode 100644 index 000000000..57c3ce9fc --- /dev/null +++ b/sdk/python/rustchain/bottube/client.py @@ -0,0 +1,570 @@ +""" +BoTTube API Client +""" + +import json +import ssl +import urllib.request +import urllib.parse +from typing import Optional, Dict, Any, List +from urllib.error import URLError, HTTPError +from urllib.request import Request + +from .exceptions import BoTTubeError, AuthenticationError, APIError, UploadError + + +class BoTTubeClient: + """ + BoTTube Platform API Client + + Example: + from rustchain.bottube import BoTTubeClient + + client = BoTTubeClient(api_key="your_api_key") + + # Check API health + health = client.health() + + # List videos + videos = client.videos(limit=10) + + # Get feed + feed = client.feed(cursor="next_page_token") + """ + + DEFAULT_BASE_URL = "https://bottube.ai" + + def __init__( + self, + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + verify_ssl: bool = True, + timeout: int = 30, + retry_count: int = 3, + retry_delay: float = 1.0 + ): + """ + Initialize BoTTube Client + + Args: + api_key: BoTTube API key (optional for public endpoints) + base_url: Base URL of the BoTTube API + verify_ssl: Enable SSL verification + timeout: Request timeout in seconds + retry_count: Number of retries on failure + retry_delay: Delay between retries (seconds) + """ + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.verify_ssl = verify_ssl + self.timeout = timeout + self.retry_count = retry_count + self.retry_delay = retry_delay + + if not verify_ssl: + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + else: + self._ctx = None + + def _get_headers(self) -> Dict[str, str]: + """Get request headers with optional auth""" + headers = { + "Accept": "application/json", + "User-Agent": "bottube-python-sdk/0.1.0", + } + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + def _request( + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, + files: Optional[Dict] = None + ) -> Dict[str, Any]: + """Make HTTP request with retry logic""" + import time + + url = f"{self.base_url}{endpoint}" + headers = self._get_headers() + + for attempt in range(self.retry_count): + try: + if files: + # Multipart form data for file uploads + boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW" + body = self._encode_multipart(boundary, data, files) + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + + req = Request( + url, + data=body.encode("utf-8"), + headers=headers, + method=method + ) + elif data and method in ("POST", "PUT", "PATCH"): + headers["Content-Type"] = "application/json" + req = Request( + url, + data=json.dumps(data).encode("utf-8"), + headers=headers, + method=method + ) + else: + req = Request(url, headers=headers, method=method) + + with urllib.request.urlopen( + req, + context=self._ctx, + timeout=self.timeout + ) as response: + response_data = response.read().decode("utf-8") + return json.loads(response_data) if response_data else {} + + except HTTPError as e: + error_body = e.read().decode("utf-8") if e.fp else "" + if e.code == 401: + raise AuthenticationError(f"Authentication failed: {error_body}") + if attempt == self.retry_count - 1: + raise APIError( + f"HTTP Error: {e.reason}", + status_code=e.code, + endpoint=endpoint + ) + except URLError as e: + if attempt == self.retry_count - 1: + raise APIError(f"Connection Error: {e.reason}", endpoint=endpoint) + except json.JSONDecodeError as e: + if attempt == self.retry_count - 1: + raise APIError(f"Invalid JSON response: {str(e)}", endpoint=endpoint) + except Exception as e: + if attempt == self.retry_count - 1: + raise BoTTubeError(f"Request failed: {str(e)}") + + if attempt < self.retry_count - 1: + time.sleep(self.retry_delay * (attempt + 1)) + + raise BoTTubeError("Max retries exceeded") + + def _encode_multipart( + self, + boundary: str, + data: Optional[Dict], + files: Dict + ) -> str: + """Encode multipart form data""" + lines = [] + + # Add form fields + if data: + for key, value in data.items(): + lines.append(f"--{boundary}") + lines.append(f'Content-Disposition: form-data; name="{key}"') + lines.append("") + lines.append(str(value)) + + # Add files + for key, file_info in files.items(): + filename, content, content_type = file_info + lines.append(f"--{boundary}") + lines.append( + f'Content-Disposition: form-data; name="{key}"; filename="{filename}"' + ) + lines.append(f"Content-Type: {content_type}") + lines.append("") + lines.append(content) + + lines.append(f"--{boundary}--") + lines.append("") + return "\r\n".join(lines) + + def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: + """GET request with query parameters""" + if params: + query = urllib.parse.urlencode(params) + endpoint = f"{endpoint}?{query}" + return self._request("GET", endpoint) + + def _post( + self, + endpoint: str, + data: Optional[Dict] = None, + files: Optional[Dict] = None + ) -> Dict[str, Any]: + """POST request""" + return self._request("POST", endpoint, data, files) + + # ========== API Methods ========== + + def health(self) -> Dict[str, Any]: + """ + Get API health status (public endpoint, no auth required) + + Returns: + Dict with health information + + Example: + >>> client.health() + {'status': 'ok', 'version': '1.0.0', 'uptime': 12345} + """ + return self._get("/health") + + def videos( + self, + agent: Optional[str] = None, + limit: int = 20, + cursor: Optional[str] = None + ) -> Dict[str, Any]: + """ + List videos with optional filtering + + Args: + agent: Filter by agent ID + limit: Maximum number of videos (default: 20) + cursor: Pagination cursor + + Returns: + Dict with videos list and pagination info + + Example: + >>> client.videos(agent="my-agent", limit=10) + {'videos': [...], 'next_cursor': 'abc123'} + """ + params = {"limit": min(limit, 100)} + if agent: + params["agent"] = agent + if cursor: + params["cursor"] = cursor + return self._get("/api/videos", params) + + def feed( + self, + cursor: Optional[str] = None, + limit: int = 20 + ) -> Dict[str, Any]: + """ + Get video feed with pagination + + Args: + cursor: Pagination cursor for next page + limit: Maximum number of items (default: 20) + + Returns: + Dict with feed items and pagination info + + Example: + >>> client.feed(limit=10) + {'items': [...], 'next_cursor': 'xyz789'} + """ + params = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + return self._get("/api/feed", params) + + def video(self, video_id: str) -> Dict[str, Any]: + """ + Get single video details + + Args: + video_id: Video ID + + Returns: + Dict with video information + + Example: + >>> client.video("abc123") + {'id': 'abc123', 'title': '...', 'agent': '...'} + """ + return self._get(f"/api/videos/{video_id}") + + def upload( + self, + title: str, + description: str, + video_file: bytes, + filename: str = "video.mp4", + public: bool = True, + tags: Optional[List[str]] = None, + thumbnail: Optional[bytes] = None + ) -> Dict[str, Any]: + """ + Upload a video to BoTTube + + Args: + title: Video title (10-100 chars) + description: Video description (50+ chars recommended) + video_file: Video file content as bytes + filename: Video filename with extension + public: Whether video is public (default: True) + tags: List of tags for discoverability + thumbnail: Optional thumbnail file content as bytes + + Returns: + Dict with upload result including video ID + + Example: + >>> with open("video.mp4", "rb") as f: + ... result = client.upload( + ... title="My Tutorial", + ... description="Learn something new", + ... video_file=f.read() + ... ) + >>> result['video_id'] + 'abc123' + """ + # Validate inputs + if len(title) < 10: + raise UploadError("Title must be at least 10 characters") + if len(title) > 100: + raise UploadError("Title must not exceed 100 characters") + if len(description) < 50: + raise UploadError("Description should be at least 50 characters") + + metadata = { + "title": title, + "description": description, + "public": public, + } + if tags: + metadata["tags"] = tags + + files = { + "metadata": ("metadata.json", json.dumps(metadata), "application/json"), + "video": (filename, video_file.decode("latin-1"), "video/mp4"), + } + + if thumbnail: + files["thumbnail"] = ("thumbnail.jpg", thumbnail.decode("latin-1"), "image/jpeg") + + return self._post("/api/upload", files=files) + + def upload_metadata_only( + self, + title: str, + description: str, + public: bool = True, + tags: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Prepare upload metadata without sending video file (dry-run) + + Args: + title: Video title + description: Video description + public: Whether video is public + tags: List of tags + + Returns: + Dict with validated metadata + + Example: + >>> client.upload_metadata_only( + ... title="My Tutorial", + ... description="Learn something new" + ... ) + {'valid': True, 'metadata': {...}} + """ + metadata = { + "title": title, + "description": description, + "public": public, + } + if tags: + metadata["tags"] = tags + + return self._post("/api/upload/validate", data=metadata) + + def agent_profile(self, agent_id: str) -> Dict[str, Any]: + """ + Get agent profile information + + Args: + agent_id: Agent ID + + Returns: + Dict with agent profile data + + Example: + >>> client.agent_profile("my-agent") + {'id': 'my-agent', 'name': '...', 'bio': '...'} + """ + return self._get(f"/api/agents/{agent_id}") + + def analytics( + self, + video_id: Optional[str] = None, + agent_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Get video or agent analytics (requires auth) + + Args: + video_id: Video ID for video-specific analytics + agent_id: Agent ID for agent analytics + + Returns: + Dict with analytics data + + Example: + >>> client.analytics(video_id="abc123") + {'views': 100, 'likes': 5, 'comments': 2} + """ + if video_id: + return self._get(f"/api/analytics/videos/{video_id}") + elif agent_id: + return self._get(f"/api/analytics/agents/{agent_id}") + else: + raise BoTTubeError("Either video_id or agent_id must be provided") + + def feed_rss( + self, + limit: int = 20, + agent: Optional[str] = None, + cursor: Optional[str] = None + ) -> str: + """ + Get video feed as RSS 2.0 XML + + Args: + limit: Maximum number of items (default: 20, max: 100) + agent: Filter by agent ID + cursor: Pagination cursor + + Returns: + RSS 2.0 feed as XML string + + Example: + >>> rss = client.feed_rss(limit=10) + >>> print(rss[:200]) # Preview feed content + """ + params = {"limit": min(limit, 100)} + if agent: + params["agent"] = agent + if cursor: + params["cursor"] = cursor + + headers = self._get_headers() + headers["Accept"] = "application/rss+xml" + + url = f"{self.base_url}/api/feed/rss" + if params: + query = urllib.parse.urlencode(params) + url = f"{url}?{query}" + + req = Request(url, headers=headers, method="GET") + + with urllib.request.urlopen( + req, + context=self._ctx, + timeout=self.timeout + ) as response: + return response.read().decode("utf-8") + + def feed_atom( + self, + limit: int = 20, + agent: Optional[str] = None, + cursor: Optional[str] = None + ) -> str: + """ + Get video feed as Atom 1.0 XML + + Args: + limit: Maximum number of items (default: 20, max: 100) + agent: Filter by agent ID + cursor: Pagination cursor + + Returns: + Atom 1.0 feed as XML string + + Example: + >>> atom = client.feed_atom(limit=10) + >>> print(atom[:200]) # Preview feed content + """ + params = {"limit": min(limit, 100)} + if agent: + params["agent"] = agent + if cursor: + params["cursor"] = cursor + + headers = self._get_headers() + headers["Accept"] = "application/atom+xml" + + url = f"{self.base_url}/api/feed/atom" + if params: + query = urllib.parse.urlencode(params) + url = f"{url}?{query}" + + req = Request(url, headers=headers, method="GET") + + with urllib.request.urlopen( + req, + context=self._ctx, + timeout=self.timeout + ) as response: + return response.read().decode("utf-8") + + def feed_json( + self, + limit: int = 20, + agent: Optional[str] = None, + cursor: Optional[str] = None + ) -> Dict[str, Any]: + """ + Get video feed as JSON Feed 1.1 format + + Args: + limit: Maximum number of items (default: 20, max: 100) + agent: Filter by agent ID + cursor: Pagination cursor + + Returns: + Dict with JSON feed data including RSS/Atom discovery links + + Example: + >>> feed = client.feed_json(limit=10) + >>> print(feed['title']) + >>> print(feed['_links']['rss']) # RSS feed URL + """ + params = {"limit": min(limit, 100)} + if agent: + params["agent"] = agent + if cursor: + params["cursor"] = cursor + + headers = self._get_headers() + headers["Accept"] = "application/json" + + url = f"{self.base_url}/api/feed" + if params: + query = urllib.parse.urlencode(params) + url = f"{url}?{query}" + + return self._request("GET", url) + + # ========== Context Manager ========== + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass # No cleanup needed for urllib-based client + + +# Convenience function +def create_client( + api_key: Optional[str] = None, + base_url: str = BoTTubeClient.DEFAULT_BASE_URL, + **kwargs +) -> BoTTubeClient: + """ + Create a BoTTube client with default settings + + Example: + >>> client = create_client(api_key="your_key") + >>> health = client.health() + """ + return BoTTubeClient(api_key=api_key, base_url=base_url, **kwargs) diff --git a/sdk/python/rustchain/bottube/exceptions.py b/sdk/python/rustchain/bottube/exceptions.py new file mode 100644 index 000000000..8380154ff --- /dev/null +++ b/sdk/python/rustchain/bottube/exceptions.py @@ -0,0 +1,30 @@ +""" +BoTTube SDK Exceptions +""" + +from typing import Optional + + +class BoTTubeError(Exception): + """Base exception for BoTTube SDK""" + pass + + +class AuthenticationError(BoTTubeError): + """Authentication related errors""" + pass + + +class APIError(BoTTubeError): + """API request errors""" + def __init__(self, message: str, status_code: Optional[int] = None, endpoint: Optional[str] = None): + super().__init__(message) + self.status_code = status_code + self.endpoint = endpoint + + +class UploadError(BoTTubeError): + """Video upload related errors""" + def __init__(self, message: str, validation_errors: Optional[list] = None): + super().__init__(message) + self.validation_errors = validation_errors or [] diff --git a/sdk/python/rustchain/cli.py b/sdk/python/rustchain/cli.py new file mode 100644 index 000000000..cce323f46 --- /dev/null +++ b/sdk/python/rustchain/cli.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +RustChain CLI - Command-line interface for RustChain + +Usage: + rustchain health + rustchain miners [--limit N] + rustchain epoch + rustchain balance + rustchain eligibility + rustchain blocks [--limit N] + rustchain attestations + rustchain explorer + +Bounty Wallet (RTC): eB51DWp1uECrLZRLsE2cnyZUzfRWvzUzaJzkatTpQV9 +""" + +import argparse +import json +import sys +from rustchain import RustChainClient + + +def format_json(data): + """Pretty print JSON data""" + print(json.dumps(data, indent=2)) + + +def main(): + parser = argparse.ArgumentParser( + prog="rustchain", + description="RustChain CLI - Manage RTC tokens from command line" + ) + parser.add_argument( + "--url", + default="https://50.28.86.131", + help="RustChain node URL (default: https://50.28.86.131)" + ) + parser.add_argument( + "--json", + action="store_true", + help="Output as JSON" + ) + + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # Health + health_parser = subparsers.add_parser("health", help="Check node health status") + + # Miners + miners_parser = subparsers.add_parser("miners", help="List active miners") + miners_parser.add_argument("--limit", type=int, default=10, help="Number of miners to show (default: 10)") + + # Epoch + subparsers.add_parser("epoch", help="Show current epoch information") + + # Balance (primary CLI command for bounty bonus) + balance_parser = subparsers.add_parser("balance", help="Check wallet balance") + balance_parser.add_argument("miner_id", help="Miner wallet ID (e.g., nox-ventures)") + + # Eligibility + eligibility_parser = subparsers.add_parser("eligibility", help="Check lottery eligibility") + eligibility_parser.add_argument("miner_id", help="Miner wallet ID") + + # Blocks (explorer) + blocks_parser = subparsers.add_parser("blocks", help="Show recent blocks") + blocks_parser.add_argument("--limit", type=int, default=10, help="Number of blocks (default: 10)") + + # Attestations + attest_parser = subparsers.add_parser("attestations", help="Show miner attestation history") + attest_parser.add_argument("miner_id", help="Miner wallet ID") + attest_parser.add_argument("--limit", type=int, default=10, help="Number of attestations (default: 10)") + + # Explorer stats + explorer_parser = subparsers.add_parser("explorer", help="Show explorer statistics") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + client = RustChainClient(args.url) + + if args.command == "health": + result = client.health() + if args.json: + format_json(result) + else: + print(f"โœ“ Node Status: {'OK' if result['ok'] else 'ERROR'}") + print(f" Version: {result['version']}") + print(f" Uptime: {result['uptime_s']:,} seconds ({result['uptime_s']/3600:.1f} hours)") + print(f" Backup Age: {result.get('backup_age_hours', 'N/A')} hours") + print(f" DB RW: {result.get('db_rw', 'N/A')}") + print(f" Tip Age: {result.get('tip_age_slots', 'N/A')} slots") + + elif args.command == "miners": + all_miners = client.get_miners() + miners = all_miners[:args.limit] + if args.json: + format_json({"miners": miners, "total": len(all_miners)}) + else: + print(f"Active Miners: {len(all_miners)}") + print("=" * 70) + for i, m in enumerate(miners, 1): + last_attest = m.get("last_attest") + last_str = f"{last_attest} ({last_attest})" if last_attest else "Never" + print(f"{i:2}. {m['miner']}") + print(f" Hardware: {m['hardware_type']}") + print(f" Architecture: {m['device_arch']} / {m['device_family']}") + print(f" Multiplier: ร—{m['antiquity_multiplier']}") + print(f" Entropy Score: {m.get('entropy_score', 0.0)}") + print() + + elif args.command == "epoch": + epoch = client.get_epoch() + if args.json: + format_json(epoch) + else: + progress = (epoch["slot"] % epoch["blocks_per_epoch"]) / epoch["blocks_per_epoch"] * 100 + print(f"Epoch: {epoch['epoch']}") + print(f"Slot: {epoch['slot']} / {epoch['blocks_per_epoch']} ({progress:.1f}%)") + print(f"Epoch Pot: {epoch['epoch_pot']} RTC") + print(f"Enrolled: {epoch['enrolled_miners']} miners") + print(f"Total Supply: {epoch['total_supply_rtc']:,} RTC") + + elif args.command == "balance": + balance = client.balance(args.miner_id) + if args.json: + format_json(balance) + else: + bal = balance.get("balance", "N/A") + print(f"Miner: {args.miner_id}") + print(f"Balance: {bal} RTC") + + elif args.command == "eligibility": + elig = client.check_eligibility(args.miner_id) + if args.json: + format_json(elig) + else: + print(f"Miner: {args.miner_id}") + print(f"Eligible: {'โœ“ YES' if elig.get('eligible') else 'โœ— NO'}") + print(f"Slot: {elig.get('slot', 'N/A')}") + print(f"Reason: {elig.get('reason', elig.get('rotation_size', 'N/A'))}") + + elif args.command == "blocks": + explorer = client.explorer + result = explorer.blocks(limit=args.limit) + if args.json: + format_json(result) + else: + print(f"Recent Blocks (showing {result['count']})") + print("=" * 80) + for block in result["blocks"]: + from datetime import datetime + ts = block.get("timestamp", 0) + dt = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "N/A" + txs = len(block.get("transactions", [])) + print(f"Block #{block['slot']} | {block['block_hash'][:16]}...") + print(f" Miner: {block['miner']}") + print(f" Time: {dt}") + print(f" TXs: {txs}") + print() + + elif args.command == "attestations": + status = client.attestation_status(args.miner_id) + attestations = status.get("attestations", [])[:args.limit] + if args.json: + format_json(status) + else: + print(f"Attestations for: {args.miner_id}") + print(f"Total Found: {status['count']}") + print("=" * 70) + for i, att in enumerate(attestations, 1): + print(f"{i}. [{att.get('kind', '?')}] nonce={att.get('nonce', 'N/A')[:30]}...") + print(f" agent_id: {att.get('agent_id', 'N/A')}") + print(f" anchored: {att.get('anchored', 0)}") + print() + + elif args.command == "explorer": + explorer = client.explorer + blocks_result = explorer.blocks(limit=5) + tip = explorer.chain_tip() + if args.json: + format_json({"chain_tip": tip, "recent_blocks": blocks_result}) + else: + print("RustChain Explorer") + print("=" * 40) + print(f"Chain Tip: #{tip.get('slot', 'N/A')}") + print(f"Tip Miner: {tip.get('miner', 'N/A')}") + print(f"Tip Age: {tip.get('tip_age', 'N/A')} slots") + print(f"Recent Blocks: {blocks_result['count']}") + + except KeyboardInterrupt: + print("\nAborted.", file=sys.stderr) + sys.exit(130) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/sdk/python/rustchain/client.py b/sdk/python/rustchain/client.py new file mode 100644 index 000000000..6b80663a3 --- /dev/null +++ b/sdk/python/rustchain/client.py @@ -0,0 +1,407 @@ +""" +RustChain API Client +A pip-installable Python SDK for the RustChain blockchain network. +""" + +import asyncio +import ssl +import json as _json +import urllib.request +from typing import Any, Dict, List, Optional + +from .explorer import ExplorerClient, ExplorerError +from .exceptions import RustChainError, AuthenticationError, APIError + + +class RustChainClient: + """ + RustChain Network API Client + + Example: + >>> from rustchain import RustChainClient + >>> + >>> client = RustChainClient("https://50.28.86.131") + >>> health = client.health() + >>> miners = client.miners() + >>> balance = client.balance("my-wallet") + + Async example: + >>> client = RustChainClient() + >>> health = await client.async_health() + """ + + def __init__( + self, + base_url: str = "https://50.28.86.131", + verify_ssl: bool = False, + timeout: int = 30, + retry_count: int = 3, + retry_delay: float = 1.0 + ): + self.base_url = base_url.rstrip("/") + self.verify_ssl = verify_ssl + self.timeout = timeout + self.retry_count = retry_count + self.retry_delay = retry_delay + + # Explorer subclient + self._explorer = ExplorerClient( + base_url=base_url, + verify_ssl=verify_ssl, + timeout=timeout + ) + + if not verify_ssl: + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + else: + self._ctx = None + + @property + def explorer(self) -> ExplorerClient: + """Access the explorer subclient for block/transaction data""" + return self._explorer + + def _request( + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, + retry_count: Optional[int] = None + ) -> Any: + """Make HTTP request with retry logic""" + import time + + url = f"{self.base_url}{endpoint}" + retries = retry_count if retry_count is not None else self.retry_count + + for attempt in range(retries): + try: + req = urllib.request.Request( + url, + data=_json.dumps(data).encode("utf-8") if data else None, + headers={ + "Content-Type": "application/json", + "Accept": "application/json" + }, + method=method + ) + + with urllib.request.urlopen( + req, + context=self._ctx, + timeout=self.timeout + ) as response: + return _json.loads(response.read().decode("utf-8")) + + except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise AuthenticationError(f"Authentication failed: {e.reason}") + if attempt == retries - 1: + raise APIError(f"HTTP Error: {e.reason}", e.code) + except urllib.error.URLError as e: + if attempt == retries - 1: + raise APIError(f"Connection Error: {e.reason}") + except Exception as e: + if attempt == retries - 1: + raise APIError(f"Request failed: {str(e)}") + + if attempt < retries - 1: + time.sleep(self.retry_delay * (attempt + 1)) + + raise APIError("Max retries exceeded") + + def _get(self, endpoint: str) -> Any: + """GET request""" + return self._request("GET", endpoint) + + def _post(self, endpoint: str, data: Dict) -> Any: + """POST request""" + return self._request("POST", endpoint, data) + + # ========== Primary API Methods (bounty spec names) ========== + + def health(self) -> Dict[str, Any]: + """ + Get node health status. + + Returns: + Dict with keys: ok, version, uptime_s, db_rw, backup_age_hours, tip_age_slots. + + Example: + >>> client.health() + {'ok': True, 'version': '2.2.1-rip200', 'uptime_s': 140828, ...} + """ + return self._get("/health") + + def epoch(self) -> Dict[str, Any]: + """ + Get current epoch information. Alias for get_epoch(). + + Returns: + Dict with keys: epoch, blocks_per_epoch, epoch_pot, slot, + enrolled_miners, total_supply_rtc. + + Example: + >>> client.epoch() + {'epoch': 112, 'blocks_per_epoch': 144, 'epoch_pot': 1.5, ...} + """ + return self._get("/epoch") + + def miners(self) -> List[Dict[str, Any]]: + """ + Get list of active miners. Alias for get_miners(). + + Returns: + List of miner dictionaries with keys: miner, antiquity_multiplier, + device_arch, device_family, hardware_type, last_attest, etc. + + Example: + >>> client.miners() + [{'miner': 'nox-ventures', 'antiquity_multiplier': 1.0, ...}, ...] + """ + return self._get("/api/miners") + + def balance(self, wallet_id: str) -> Dict[str, Any]: + """ + Get wallet balance for a miner. Alias for get_balance(). + + Args: + wallet_id: Miner wallet ID (e.g., "nox-ventures" or "RTC...") + + Returns: + Dict with balance information. + + Example: + >>> client.balance("nox-ventures") + {'balance': 100.5, 'miner_id': 'nox-ventures', ...} + """ + return self._get(f"/balance/{urllib.parse.quote(wallet_id)}") + + def transfer( + self, + from_wallet: str, + to_wallet: str, + amount: float, + signature: str + ) -> Dict[str, Any]: + """ + Transfer RTC between wallets (Ed25519 signed). + + Args: + from_wallet: Source wallet ID + to_wallet: Destination wallet ID + amount: Amount of RTC to transfer + signature: Ed25519 signature of the transfer payload + + Returns: + Dict with transfer result including tx_hash. + + Example: + >>> client.transfer("wallet-a", "wallet-b", 10.0, "signature-hex...") + {'success': True, 'tx_hash': '...'} + """ + payload = { + "from": from_wallet, + "to": to_wallet, + "amount": amount, + "signature": signature + } + return self._post("/wallet/transfer/signed", payload) + + def attestation_status(self, miner_id: str) -> Dict[str, Any]: + """ + Get attestation status for a miner via beacon envelopes. + + Args: + miner_id: Miner wallet ID + + Returns: + Dict with attestation history from beacon envelopes. + + Example: + >>> client.attestation_status("nox-ventures") + {'envelopes': [...], 'count': 50} + """ + envelopes_data = self.explorer.beacon_envelopes(limit=50) + + # Filter envelopes for the specific miner + miner_envelopes = [ + e for e in envelopes_data.get("envelopes", []) + if miner_id.lower() in e.get("agent_id", "").lower() + or miner_id.lower() in e.get("nonce", "").lower() + ] + + return { + "miner_id": miner_id, + "attestations": miner_envelopes, + "count": len(miner_envelopes) + } + + # ========== Additional API Methods ========== + + def get_miners(self) -> List[Dict[str, Any]]: + """Get list of active miners. Use miners() for bounty spec API.""" + return self._get("/api/miners") + + def get_epoch(self) -> Dict[str, Any]: + """Get current epoch info. Use epoch() for bounty spec API.""" + return self._get("/epoch") + + def get_balance(self, miner_id: str) -> Dict[str, Any]: + """Get wallet balance. Use balance() for bounty spec API.""" + # Try wallet/balance first (requires miner_id param) + try: + return self._get(f"/wallet/balance?miner_id={miner_id}") + except APIError: + # Fallback to balance/{minerPk} + return self._get(f"/balance/{miner_id}") + + def check_eligibility(self, miner_id: str) -> Dict[str, Any]: + """ + Check lottery eligibility for a miner (RIP-0200). + + Args: + miner_id: Miner wallet ID + + Returns: + Dict with keys: eligible, slot, slot_producer, rotation_size, reason. + """ + return self._get(f"/lottery/eligibility?miner_id={miner_id}") + + def submit_attestation(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Submit attestation to the network. + + Args: + payload: Attestation payload (miner_id, signature, etc.) + + Returns: + Dict with submission result. + """ + return self._post("/attest/submit", payload) + + def get_attestation_challenge(self, miner_id: str) -> Dict[str, Any]: + """ + Get an attestation challenge for a miner. + + Args: + miner_id: Miner wallet ID + + Returns: + Dict with challenge details. + """ + return self._post("/attest/challenge", {"miner_id": miner_id}) + + def wallet_history(self, wallet_id: str, limit: int = 50) -> Dict[str, Any]: + """ + Get transaction history for a wallet. + + Args: + wallet_id: Wallet ID + limit: Maximum number of history entries + + Returns: + Dict with transaction history. + """ + return self._get(f"/wallet/history?miner_id={wallet_id}&limit={limit}") + + def get_chain_tip(self) -> Dict[str, Any]: + """ + Get the current chain tip header. + + Returns: + Dict with miner, slot, signature_prefix, tip_age. + """ + return self._get("/headers/tip") + + def get_stats(self) -> Dict[str, Any]: + """Get system statistics.""" + return self._get("/api/stats") + + def get_node_info(self) -> Dict[str, Any]: + """Get detailed node information.""" + return self._get("/api/nodes") + + def get_p2p_stats(self) -> Dict[str, Any]: + """Get P2P network statistics.""" + return self._get("/p2p/stats") + + # ========== Async Methods ========== + + async def async_request( + self, + method: str, + endpoint: str, + data: Optional[Dict] = None + ) -> Any: + """Async HTTP request using aiohttp""" + try: + import aiohttp + except ImportError: + raise RustChainError( + "aiohttp is required for async methods. " + "Install with: pip install rustchain-sdk[async]" + ) + + url = f"{self.base_url}{endpoint}" + timeout = aiohttp.ClientTimeout(total=self.timeout) + + ssl_context = self._ctx if not self.verify_ssl else None + + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + json=data, + ssl=ssl_context if ssl_context else None + ) as response: + return await response.json() + + async def async_health(self) -> Dict[str, Any]: + """Async version of health()""" + return await self.async_request("GET", "/health") + + async def async_epoch(self) -> Dict[str, Any]: + """Async version of epoch()""" + return await self.async_request("GET", "/epoch") + + async def async_miners(self) -> List[Dict[str, Any]]: + """Async version of miners()""" + return await self.async_request("GET", "/api/miners") + + async def async_balance(self, wallet_id: str) -> Dict[str, Any]: + """Async version of balance()""" + return await self.async_request("GET", f"/balance/{wallet_id}") + + async def async_get_epoch(self) -> Dict[str, Any]: + """Async version of get_epoch()""" + return await self.async_request("GET", "/epoch") + + async def async_get_miners(self) -> List[Dict[str, Any]]: + """Async version of get_miners()""" + return await self.async_request("GET", "/api/miners") + + async def async_get_balance(self, miner_id: str) -> Dict[str, Any]: + """Async version of get_balance()""" + return await self.async_request("GET", f"/wallet/balance?miner_id={miner_id}") + + async def async_check_eligibility(self, miner_id: str) -> Dict[str, Any]: + """Async version of check_eligibility()""" + return await self.async_request("GET", f"/lottery/eligibility?miner_id={miner_id}") + + +# ========== urllib import for quote ========== +import urllib.parse + + +def create_client(base_url: str = "https://50.28.86.131", **kwargs) -> RustChainClient: + """ + Create a RustChain client with default settings. + + Example: + >>> client = create_client() + >>> health = client.health() + """ + return RustChainClient(base_url=base_url, **kwargs) diff --git a/sdk/python/rustchain/exceptions.py b/sdk/python/rustchain/exceptions.py new file mode 100644 index 000000000..71fe84010 --- /dev/null +++ b/sdk/python/rustchain/exceptions.py @@ -0,0 +1,35 @@ +""" +RustChain SDK Exceptions +""" + + +class RustChainError(Exception): + """Base exception for all RustChain SDK errors""" + pass + + +class AuthenticationError(RustChainError): + """Raised when authentication fails""" + pass + + +class APIError(RustChainError): + """Raised when API request fails""" + def __init__(self, message: str, status_code: int = None): + super().__init__(message) + self.status_code = status_code + + +class ConnectionError(RustChainError): + """Raised when connection to node fails""" + pass + + +class ValidationError(RustChainError): + """Raised when input validation fails""" + pass + + +class WalletError(RustChainError): + """Raised for wallet-related errors""" + pass diff --git a/sdk/python/rustchain/explorer.py b/sdk/python/rustchain/explorer.py new file mode 100644 index 000000000..d6bc6f600 --- /dev/null +++ b/sdk/python/rustchain/explorer.py @@ -0,0 +1,139 @@ +""" +RustChain Explorer API Client +Provides access to block explorer and transaction data. +""" + +import re +import ssl +import json +import time +import urllib.request +from typing import Any, Dict, List, Optional + + +class ExplorerClient: + """ + RustChain Block Explorer API Client + + Access via RustChainClient.explorer or create standalone: + + >>> from rustchain.explorer import ExplorerClient + >>> explorer = ExplorerClient("https://50.28.86.131") + >>> blocks = explorer.blocks(limit=10) + >>> txs = explorer.transactions(limit=5) + """ + + def __init__( + self, + base_url: str = "https://50.28.86.131", + verify_ssl: bool = False, + timeout: int = 30 + ): + self.base_url = base_url.rstrip("/") + self.verify_ssl = verify_ssl + self.timeout = timeout + + if not verify_ssl: + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + else: + self._ctx = None + + def _get(self, endpoint: str) -> Any: + """Make GET request""" + url = f"{self.base_url}{endpoint}" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, context=self._ctx, timeout=self.timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + raise ExplorerError(f"Request to {endpoint} failed: {e}") + + def blocks(self, limit: int = 20) -> Dict[str, Any]: + """ + Get recent blocks from the RustChain network. + + Args: + limit: Maximum number of blocks to return (default: 20) + + Returns: + Dict with 'blocks' list and 'count' integer. + Each block has: block_hash, block_index, miner, slot, + timestamp, previous_hash, signature, transactions. + + Example: + >>> explorer.blocks(limit=5) + {'blocks': [{'block_hash': '...', 'slot': 35, ...}, ...], 'count': 5} + """ + data = self._get("/p2p/blocks") + blocks = data.get("blocks", []) + return { + "blocks": blocks[:limit], + "count": min(len(blocks), limit) + } + + def transactions(self, limit: int = 20) -> Dict[str, Any]: + """ + Get recent transactions from RustChain blocks. + + Note: RustChain uses a proof-of-antiquity consensus where + most "transactions" are miner messages. True token transfers + use the /wallet/transfer endpoints. + + Args: + limit: Maximum number of transactions to return + + Returns: + Dict with 'transactions' list and 'count'. + """ + data = self._get("/p2p/blocks") + blocks = data.get("blocks", []) + + all_txs = [] + for block in blocks: + txs = block.get("transactions", []) + for tx in txs: + tx["block_hash"] = block.get("block_hash") + tx["block_slot"] = block.get("slot") + all_txs.append(tx) + + return { + "transactions": all_txs[:limit], + "count": min(len(all_txs), limit) + } + + def chain_tip(self) -> Dict[str, Any]: + """ + Get the current chain tip (latest block header). + + Returns: + Dict with miner, slot, signature_prefix, tip_age. + + Example: + >>> explorer.chain_tip() + {'miner': 'sophia-nas-c4130', 'slot': 2941199, ...} + """ + return self._get("/headers/tip") + + def beacon_envelopes(self, limit: int = 50) -> Dict[str, Any]: + """ + Get beacon envelopes (attestation records). + + Args: + limit: Maximum number of envelopes + + Returns: + Dict with 'envelopes' list and 'count'. + """ + data = self._get("/beacon/envelopes") + envelopes = data.get("envelopes", []) + return { + "envelopes": envelopes[:limit], + "count": data.get("count", len(envelopes)) + } + + +class ExplorerError(Exception): + """Exception for Explorer API errors""" + pass diff --git a/sdk/python/setup.py b/sdk/python/setup.py new file mode 100644 index 000000000..f6c44c617 --- /dev/null +++ b/sdk/python/setup.py @@ -0,0 +1,55 @@ +""" +RustChain SDK Setup +pip install rustchain โ€” Python SDK for RustChain blockchain +Bounty Wallet (RTC): eB51DWp1uECrLZRLsE2cnyZUzfRWvzUzaJzkatTpQV9 +""" + +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as f: + long_description = f.read() + +with open("requirements.txt", "r", encoding="utf-8") as f: + requirements = [line.strip() for line in f if line.strip() and not line.startswith("#")] + +setup( + name="rustchain", + version="0.2.0", + author="sungdark", + author_email="sungdark@proton.me", + description="Python SDK for RustChain blockchain network โ€” pip install rustchain", + package_name="rustchain", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/Scottcjn/Rustchain", + project_urls={ + "Bounty": "https://github.com/Scottcjn/rustchain-bounties/issues/2297", + "Explorer": "https://rustchain.org", + }, + packages=find_packages(), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet :: WWW/HTTP :: HTTP Clients", + "Topic :: Office/Business :: Financial", + ], + python_requires=">=3.8", + install_requires=requirements, + extras_require={ + "async": ["aiohttp>=3.8.0"], + }, + entry_points={ + "console_scripts": [ + "rustchain=rustchain.cli:main", + ], + }, +) diff --git a/sdk/python/test_bottube.py b/sdk/python/test_bottube.py new file mode 100644 index 000000000..ec70ed527 --- /dev/null +++ b/sdk/python/test_bottube.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +""" +BoTTube Python SDK Tests + +Run tests: + pytest tests/test_bottube.py -v + +Run with coverage: + pytest tests/test_bottube.py --cov=rustchain.bottube +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +import pytest + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from rustchain.bottube import BoTTubeClient, BoTTubeError, UploadError +from rustchain.bottube.exceptions import AuthenticationError, APIError + + +class TestBoTTubeClientInit: + """Test client initialization""" + + def test_default_initialization(self): + """Test default client initialization""" + client = BoTTubeClient() + assert client.base_url == "https://bottube.ai" + assert client.api_key is None + assert client.timeout == 30 + assert client.retry_count == 3 + + def test_custom_initialization(self): + """Test client with custom parameters""" + client = BoTTubeClient( + api_key="test_key", + base_url="https://custom.bottube.ai", + timeout=60, + retry_count=5 + ) + assert client.api_key == "test_key" + assert client.base_url == "https://custom.bottube.ai" + assert client.timeout == 60 + assert client.retry_count == 5 + + def test_base_url_trailing_slash_removed(self): + """Test that trailing slash is removed from base URL""" + client = BoTTubeClient(base_url="https://bottube.ai/") + assert client.base_url == "https://bottube.ai" + + +class TestHealthEndpoint: + """Test health endpoint""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_health_success(self, mock_urlopen): + """Test successful health check""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + "status": "ok", + "version": "1.0.0", + "uptime": 12345 + }).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + result = client.health() + + assert result["status"] == "ok" + assert result["version"] == "1.0.0" + assert result["uptime"] == 12345 + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_health_connection_error(self, mock_urlopen): + """Test health check with connection error""" + from urllib.error import URLError + mock_urlopen.side_effect = URLError("Connection refused") + + client = BoTTubeClient(retry_count=1) + with pytest.raises(APIError) as exc_info: + client.health() + + assert "Connection Error" in str(exc_info.value) + + +class TestVideosEndpoint: + """Test videos endpoint""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_videos_basic(self, mock_urlopen): + """Test basic videos listing""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + "videos": [ + {"id": "v1", "title": "Video 1", "agent": "agent1"}, + {"id": "v2", "title": "Video 2", "agent": "agent2"} + ], + "next_cursor": "abc123" + }).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + result = client.videos(limit=10) + + assert len(result["videos"]) == 2 + assert result["videos"][0]["id"] == "v1" + assert result["next_cursor"] == "abc123" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_videos_with_agent_filter(self, mock_urlopen): + """Test videos listing with agent filter""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + "videos": [{"id": "v1", "title": "Video 1", "agent": "my-agent"}] + }).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + result = client.videos(agent="my-agent", limit=5) + + assert len(result["videos"]) == 1 + assert result["videos"][0]["agent"] == "my-agent" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_videos_limit_capped(self, mock_urlopen): + """Test that videos limit is capped at 100""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"videos": []}).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + client.videos(limit=200) + + # Verify request was made with limit=100 + call_args = mock_urlopen.call_args + request_url = call_args[0][0].full_url + assert "limit=100" in request_url + + +class TestFeedEndpoint: + """Test feed endpoint""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_feed_basic(self, mock_urlopen): + """Test basic feed retrieval""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + "items": [ + {"type": "video", "video": {"id": "v1", "title": "Video 1"}}, + {"type": "video", "video": {"id": "v2", "title": "Video 2"}} + ] + }).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + result = client.feed(limit=10) + + assert len(result["items"]) == 2 + assert result["items"][0]["type"] == "video" + + +class TestVideoEndpoint: + """Test single video endpoint""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_video_details(self, mock_urlopen): + """Test getting single video details""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + "id": "v123", + "title": "My Video", + "description": "Video description", + "agent": "agent1", + "views": 100, + "likes": 5 + }).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + result = client.video("v123") + + assert result["id"] == "v123" + assert result["title"] == "My Video" + assert result["views"] == 100 + + +class TestAgentProfileEndpoint: + """Test agent profile endpoint""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_agent_profile(self, mock_urlopen): + """Test getting agent profile""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({ + "id": "agent1", + "name": "My Agent", + "bio": "Agent bio", + "video_count": 10, + "total_views": 1000 + }).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient() + result = client.agent_profile("agent1") + + assert result["id"] == "agent1" + assert result["name"] == "My Agent" + assert result["video_count"] == 10 + + +class TestUploadValidation: + """Test upload validation""" + + def test_upload_metadata_only(self): + """Test upload metadata validation""" + # This would normally make an API call + # For unit test, we just verify the method exists and signature + client = BoTTubeClient() + assert hasattr(client, "upload_metadata_only") + + def test_upload_title_too_short(self): + """Test upload validation with short title""" + client = BoTTubeClient() + + with pytest.raises(UploadError) as exc_info: + # Simulate validation + title = "Short" + if len(title) < 10: + raise UploadError("Title must be at least 10 characters") + + assert "at least 10 characters" in str(exc_info.value) + + def test_upload_title_too_long(self): + """Test upload validation with long title""" + client = BoTTubeClient() + + with pytest.raises(UploadError) as exc_info: + title = "A" * 101 + if len(title) > 100: + raise UploadError("Title must not exceed 100 characters") + + assert "exceed 100 characters" in str(exc_info.value) + + +class TestAuthentication: + """Test authentication""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_auth_header_included(self, mock_urlopen): + """Test that auth header is included when API key is set""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"status": "ok"}).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.return_value = mock_response + + client = BoTTubeClient(api_key="test_key") + client.health() + + # Verify request was made + call_args = mock_urlopen.call_args + request = call_args[0][0] + assert request.get_header("Authorization") == "Bearer test_key" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_auth_error(self, mock_urlopen): + """Test authentication error handling""" + from urllib.error import HTTPError + mock_urlopen.side_effect = HTTPError( + url="https://bottube.ai/health", + code=401, + msg="Unauthorized", + hdrs={}, + fp=None + ) + + client = BoTTubeClient(api_key="invalid_key", retry_count=1) + with pytest.raises(AuthenticationError): + client.health() + + +class TestRetryLogic: + """Test retry logic""" + + @patch("rustchain.bottube.client.urllib.request.urlopen") + def test_retry_on_failure(self, mock_urlopen): + """Test that client retries on failure""" + from urllib.error import URLError + + # Fail first two attempts, succeed on third + mock_urlopen.side_effect = [ + URLError("Connection refused"), + URLError("Connection refused"), + MagicMock() + ] + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"status": "ok"}).encode() + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=None) + mock_urlopen.side_effect = [ + URLError("Connection refused"), + URLError("Connection refused"), + mock_response + ] + + client = BoTTubeClient(retry_count=3, retry_delay=0.01) + result = client.health() + + assert result["status"] == "ok" + assert mock_urlopen.call_count == 3 + + +class TestCreateClient: + """Test convenience function""" + + def test_create_client(self): + """Test create_client convenience function""" + from rustchain.bottube import create_client + + client = create_client(api_key="test_key") + assert client.api_key == "test_key" + assert client.base_url == "https://bottube.ai" + + +class TestContextManager: + """Test context manager support""" + + def test_context_manager(self): + """Test client as context manager""" + with BoTTubeClient() as client: + assert client is not None + assert isinstance(client, BoTTubeClient) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/sdk/python/test_rustchain_sdk.py b/sdk/python/test_rustchain_sdk.py new file mode 100644 index 000000000..21768bd92 --- /dev/null +++ b/sdk/python/test_rustchain_sdk.py @@ -0,0 +1,499 @@ +""" +RustChain SDK Tests (20+ unit tests) +Tests all API methods, CLI, explorer, exceptions, and configuration. +""" + +import pytest +import json +import ssl +from unittest.mock import patch, MagicMock +from rustchain import RustChainClient, create_client, APIError, AuthenticationError +from rustchain.explorer import ExplorerClient, ExplorerError +from rustchain.exceptions import RustChainError, ConnectionError, ValidationError, WalletError + + +# Test configuration +TEST_NODE_URL = "https://50.28.86.131" +TEST_MINER = "nox-ventures" + + +# ========== Fixtures ========== + +@pytest.fixture +def client(): + """Create client for testing""" + return RustChainClient(TEST_NODE_URL, verify_ssl=False, timeout=10) + + +@pytest.fixture +def explorer(): + """Create explorer client for testing""" + return ExplorerClient(TEST_NODE_URL, verify_ssl=False) + + +# ========== Client Initialization Tests ========== + +class TestClientInitialization: + """Test client configuration and initialization""" + + def test_default_url(self): + """Test client uses default URL""" + c = RustChainClient() + assert c.base_url == "https://50.28.86.131" + + def test_custom_url(self): + """Test client accepts custom URL""" + c = RustChainClient("https://custom.node.com") + assert c.base_url == "https://custom.node.com" + + def test_url_strips_trailing_slash(self): + """Test URL trailing slash is stripped""" + c = RustChainClient("https://node.com/") + assert c.base_url == "https://node.com" + + def test_verify_ssl_default(self): + """Test SSL verification defaults to False""" + c = RustChainClient() + assert c.verify_ssl is False + assert c._ctx is not None # SSL context created + + def test_verify_ssl_true(self): + """Test SSL verification can be enabled""" + c = RustChainClient(verify_ssl=True) + assert c.verify_ssl is True + assert c._ctx is None # No custom context needed + + def test_timeout_config(self): + """Test timeout configuration""" + c = RustChainClient(timeout=60) + assert c.timeout == 60 + + def test_retry_config(self): + """Test retry configuration""" + c = RustChainClient(retry_count=5, retry_delay=2.0) + assert c.retry_count == 5 + assert c.retry_delay == 2.0 + + def test_explorer_subclient(self, client): + """Test explorer subclient is initialized""" + assert client.explorer is not None + assert isinstance(client.explorer, ExplorerClient) + assert client.explorer.base_url == client.base_url + + +class TestCreateClient: + """Test create_client convenience function""" + + def test_create_client_returns_client(self): + """Test create_client returns RustChainClient""" + c = create_client() + assert isinstance(c, RustChainClient) + + def test_create_client_custom_url(self): + """Test create_client accepts custom URL""" + c = create_client("https://custom.com") + assert c.base_url == "https://custom.com" + + +# ========== Health Endpoint Tests ========== + +class TestHealthEndpoint: + """Test health() endpoint""" + + @patch("urllib.request.urlopen") + def test_health_returns_dict(self, mock_urlopen, client): + """Test health returns a dictionary""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"ok": True, "version": "2.2.1"}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.health() + assert isinstance(result, dict) + + @patch("urllib.request.urlopen") + def test_health_calls_correct_endpoint(self, mock_urlopen, client): + """Test health calls /health endpoint""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"ok": True}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + client.health() + + call_args = mock_urlopen.call_args + url = call_args[0][0].full_url + assert "/health" in url + + +# ========== Epoch Endpoint Tests ========== + +class TestEpochEndpoint: + """Test epoch() and get_epoch() endpoints""" + + @patch("urllib.request.urlopen") + def test_epoch_returns_dict(self, mock_urlopen, client): + """Test epoch returns a dictionary""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"epoch": 112, "blocks_per_epoch": 144}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.epoch() + assert isinstance(result, dict) + + @patch("urllib.request.urlopen") + def test_epoch_alias(self, mock_urlopen, client): + """Test epoch() is alias for get_epoch()""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"epoch": 50}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + epoch_result = client.epoch() + get_epoch_result = client.get_epoch() + + # Both should hit the same endpoint + assert mock_urlopen.call_count == 2 + + +# ========== Miners Endpoint Tests ========== + +class TestMinersEndpoint: + """Test miners() and get_miners() endpoints""" + + @patch("urllib.request.urlopen") + def test_miners_returns_list(self, mock_urlopen, client): + """Test miners returns a list""" + mock_response = MagicMock() + mock_data = [{"miner": "test-miner", "antiquity_multiplier": 1.0}] + mock_response.read.return_value = json.dumps(mock_data).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.miners() + assert isinstance(result, list) + + @patch("urllib.request.urlopen") + def test_miners_calls_api_miners(self, mock_urlopen, client): + """Test miners calls /api/miners endpoint""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps([]).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + client.miners() + + call_args = mock_urlopen.call_args + url = call_args[0][0].full_url + assert "/api/miners" in url + + +# ========== Balance Endpoint Tests ========== + +class TestBalanceEndpoint: + """Test balance() and get_balance() endpoints""" + + @patch("urllib.request.urlopen") + def test_balance_calls_correct_endpoint(self, mock_urlopen, client): + """Test balance() calls /balance/{wallet_id}""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"balance": 100.0}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + client.balance("test-wallet") + + call_args = mock_urlopen.call_args + url = call_args[0][0].full_url + assert "/balance/test-wallet" in url + + @patch("urllib.request.urlopen") + def test_balance_with_special_chars(self, mock_urlopen, client): + """Test balance handles special characters in wallet ID""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"balance": 50.0}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + client.balance("rtc-wallet-with-dashes") + # Should not raise + + +# ========== Transfer Endpoint Tests ========== + +class TestTransferEndpoint: + """Test transfer() endpoint""" + + @patch("urllib.request.urlopen") + def test_transfer_calls_post(self, mock_urlopen, client): + """Test transfer uses POST method""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"success": True}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + client.transfer("from-wallet", "to-wallet", 10.0, "sig-hex") + + call_args = mock_urlopen.call_args + req = call_args[0][0] + assert req.method == "POST" + + @patch("urllib.request.urlopen") + def test_transfer_payload(self, mock_urlopen, client): + """Test transfer sends correct payload""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"success": True}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + client.transfer("a", "b", 5.5, "sig123") + + call_args = mock_urlopen.call_args + req = call_args[0][0] + import urllib.parse + # Data is in request._data + + +# ========== Attestation Status Tests ========== + +class TestAttestationStatus: + """Test attestation_status() endpoint""" + + @patch("urllib.request.urlopen") + def test_attestation_status_returns_dict(self, mock_urlopen, client): + """Test attestation_status returns a dictionary""" + # Mock beacon envelopes response + mock_response = MagicMock() + mock_data = { + "count": 2, + "envelopes": [ + {"agent_id": "test-miner", "nonce": "test-nonce-abc", "kind": "heartbeat"}, + {"agent_id": "other-miner", "nonce": "test-miner-other", "kind": "mayday"} + ] + } + mock_response.read.return_value = json.dumps(mock_data).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.attestation_status("test-miner") + assert isinstance(result, dict) + assert "attestations" in result + assert result["count"] == 2 # Both contain "test-miner" in agent_id or nonce + + +# ========== Explorer Tests ========== + +class TestExplorerBlocks: + """Test explorer.blocks()""" + + @patch("urllib.request.urlopen") + def test_blocks_returns_dict(self, mock_urlopen, explorer): + """Test blocks returns a dictionary with blocks list""" + mock_response = MagicMock() + mock_data = { + "blocks": [ + {"block_hash": "abc123", "slot": 1, "miner": "test"}, + {"block_hash": "def456", "slot": 2, "miner": "test2"} + ] + } + mock_response.read.return_value = json.dumps(mock_data).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = explorer.blocks() + assert isinstance(result, dict) + assert "blocks" in result + assert len(result["blocks"]) == 2 + + @patch("urllib.request.urlopen") + def test_blocks_respects_limit(self, mock_urlopen, explorer): + """Test blocks respects limit parameter""" + mock_response = MagicMock() + mock_data = {"blocks": [{"slot": i} for i in range(20)]} + mock_response.read.return_value = json.dumps(mock_data).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = explorer.blocks(limit=5) + assert result["count"] == 5 + + +class TestExplorerChainTip: + """Test explorer.chain_tip()""" + + @patch("urllib.request.urlopen") + def test_chain_tip_returns_dict(self, mock_urlopen, explorer): + """Test chain_tip returns miner and slot info""" + mock_response = MagicMock() + mock_data = {"miner": "sophia-nas", "slot": 100, "tip_age": 50} + mock_response.read.return_value = json.dumps(mock_data).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = explorer.chain_tip() + assert result["miner"] == "sophia-nas" + assert result["slot"] == 100 + + +# ========== Exception Tests ========== + +class TestExceptions: + """Test exception handling""" + + @patch("urllib.request.urlopen") + def test_api_error_contains_status_code(self, mock_urlopen, client): + """Test APIError includes status code""" + from urllib.error import HTTPError + + mock_urlopen.side_effect = HTTPError( + "http://test.com", 404, "Not Found", {}, None + ) + + with pytest.raises(APIError) as exc_info: + client._get("/invalid") + + assert exc_info.value.status_code == 404 + + @patch("urllib.request.urlopen") + def test_auth_error(self, mock_urlopen, client): + """Test AuthenticationError for 401 responses""" + from urllib.error import HTTPError + + mock_urlopen.side_effect = HTTPError( + "http://test.com", 401, "Unauthorized", {}, None + ) + + with pytest.raises(AuthenticationError): + client._get("/admin") + + @patch("urllib.request.urlopen") + def test_connection_error(self, mock_urlopen, client): + """Test ConnectionError for network failures""" + from urllib.error import URLError + + mock_urlopen.side_effect = URLError("Connection refused") + + with pytest.raises(APIError): + client._get("/health") + + +# ========== Retry Logic Tests ========== + +class TestRetryLogic: + """Test retry behavior""" + + @patch("urllib.request.urlopen") + def test_retries_on_failure(self, mock_urlopen, client): + """Test client retries on transient failures""" + from urllib.error import URLError + + call_count = 0 + def side_effect(request, context=None, timeout=None): + nonlocal call_count + call_count += 1 + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"ok": True}).encode() + + if call_count < 3: + raise URLError("Transient error") + + # Return a context manager (what urlopen returns) + class ResponseCtx: + def __enter__(self): + return mock_response + def __exit__(self, *args): + return None + return ResponseCtx() + + mock_urlopen.side_effect = side_effect + + result = client.health() + assert call_count == 3 + assert result["ok"] is True + + @patch("urllib.request.urlopen") + def test_no_retry_on_api_error(self, mock_urlopen, client): + """Test APIError is raised immediately for HTTP errors""" + from urllib.error import HTTPError + + mock_urlopen.side_effect = HTTPError( + "http://test.com", 404, "Not Found", {}, None + ) + + with pytest.raises(APIError): + client._get("/invalid") + + +# ========== Async Methods Tests ========== + +class TestAsyncMethods: + """Test async method availability""" + + def test_async_health_defined(self, client): + """Test async_health method exists""" + assert hasattr(client, "async_health") + assert callable(client.async_health) + + def test_async_epoch_defined(self, client): + """Test async_epoch method exists""" + assert hasattr(client, "async_epoch") + + def test_async_miners_defined(self, client): + """Test async_miners method exists""" + assert hasattr(client, "async_miners") + + def test_async_balance_defined(self, client): + """Test async_balance method exists""" + assert hasattr(client, "async_balance") + + def test_async_get_balance_defined(self, client): + """Test async_get_balance method exists""" + assert hasattr(client, "async_get_balance") + + def test_async_check_eligibility_defined(self, client): + """Test async_check_eligibility method exists""" + assert hasattr(client, "async_check_eligibility") + + +# ========== Additional API Methods Tests ========== + +class TestAdditionalMethods: + """Test additional API methods""" + + @patch("urllib.request.urlopen") + def test_get_stats(self, mock_urlopen, client): + """Test get_stats() method""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"stats": "data"}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.get_stats() + assert isinstance(result, dict) + + @patch("urllib.request.urlopen") + def test_get_chain_tip(self, mock_urlopen, client): + """Test get_chain_tip() method""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"miner": "test"}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.get_chain_tip() + assert "miner" in result + + @patch("urllib.request.urlopen") + def test_wallet_history(self, mock_urlopen, client): + """Test wallet_history() method""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"history": []}).encode() + mock_urlopen.return_value.__enter__ = lambda s: mock_response + mock_response.__exit__ = lambda *a: None + + result = client.wallet_history("test-wallet") + assert isinstance(result, dict) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])