diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86ef2cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # Check version synchronization + version-check: + name: Version Sync Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check version sync + run: python scripts/sync-versions.py --check + + # Check feature parity + feature-parity: + name: Feature Parity Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check feature parity + run: python scripts/check-feature-parity.py + + # TypeScript CI + typescript: + name: TypeScript + runs-on: ubuntu-latest + defaults: + run: + working-directory: ts + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Run tests + run: npm test + + # Python CI + python: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check . + + - name: Type check with mypy + run: mypy capture_sdk --ignore-missing-imports + + - name: Run tests + run: pytest -v + + # All checks passed + ci-success: + name: CI Success + needs: [version-check, feature-parity, typescript, python] + runs-on: ubuntu-latest + steps: + - name: All checks passed + run: echo "All CI checks passed!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ba8f03e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,143 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + # Validate release + validate: + name: Validate Release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Extract version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Releasing version: $VERSION" + + - name: Verify version sync + run: python scripts/sync-versions.py --check + + - name: Verify tag matches package versions + run: | + VERSION=${{ steps.version.outputs.version }} + TS_VERSION=$(node -p "require('./ts/package.json').version") + PY_VERSION=$(grep -Po '(?<=^version = ")[^"]+' python/pyproject.toml) + + if [ "$VERSION" != "$TS_VERSION" ]; then + echo "Tag version ($VERSION) does not match TypeScript version ($TS_VERSION)" + exit 1 + fi + + if [ "$VERSION" != "$PY_VERSION" ]; then + echo "Tag version ($VERSION) does not match Python version ($PY_VERSION)" + exit 1 + fi + + echo "Version $VERSION verified in both SDKs" + + # Publish TypeScript to npm + publish-npm: + name: Publish to npm + needs: validate + runs-on: ubuntu-latest + defaults: + run: + working-directory: ts + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + cache: "npm" + cache-dependency-path: ts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Publish Python to PyPI + publish-pypi: + name: Publish to PyPI + needs: validate + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: twine upload dist/* + + # Create GitHub Release + github-release: + name: Create GitHub Release + needs: [validate, publish-npm, publish-pypi] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + name: v${{ needs.validate.outputs.version }} + body: | + ## Capture SDK v${{ needs.validate.outputs.version }} + + ### Installation + + **TypeScript/JavaScript:** + ```bash + npm install @numbersprotocol/capture-sdk@${{ needs.validate.outputs.version }} + ``` + + **Python:** + ```bash + pip install capture-sdk==${{ needs.validate.outputs.version }} + ``` + + ### Links + - [npm package](https://www.npmjs.com/package/@numbersprotocol/capture-sdk) + - [PyPI package](https://pypi.org/project/capture-sdk/) + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 42855bb..6f9aa96 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +build/ # IDE .idea/ @@ -25,7 +26,27 @@ npm-debug.log* # Test coverage coverage/ +htmlcov/ +.coverage # Temporary files *.tmp *.temp + +# TypeScript +ts/dist/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +*.egg +.eggs/ +python/dist/ +python/build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +venv/ +.venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..63bf4f4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,185 @@ +# Contributing to Capture SDK + +This repository contains both TypeScript and Python implementations of the Numbers Protocol Capture SDK. This guide explains how to maintain feature parity and version consistency across both languages. + +## Repository Structure + +``` +capture-sdk/ +├── ts/ # TypeScript SDK +│ ├── src/ +│ │ ├── index.ts # Public exports +│ │ ├── client.ts # Capture class +│ │ ├── types.ts # Type definitions +│ │ ├── errors.ts # Error classes +│ │ └── crypto.ts # Cryptographic utilities +│ ├── package.json +│ └── README.md +├── python/ # Python SDK +│ ├── capture_sdk/ +│ │ ├── __init__.py # Public exports +│ │ ├── client.py # Capture class +│ │ ├── types.py # Type definitions +│ │ ├── errors.py # Error classes +│ │ └── crypto.py # Cryptographic utilities +│ ├── tests/ +│ ├── pyproject.toml +│ └── README.md +├── scripts/ # Maintenance scripts +│ ├── sync-versions.py # Version synchronization +│ └── check-feature-parity.py # Feature parity checker +├── .github/workflows/ # CI/CD +│ ├── ci.yml # Test & lint +│ └── release.yml # Publish to npm/PyPI +└── docs/ + └── PLAN.md # Implementation plan +``` + +## Dual-Language Maintenance Guidelines + +### 1. Version Synchronization + +Both SDKs MUST maintain the same version number. Use the sync script: + +```bash +# Check current versions +python scripts/sync-versions.py --check + +# Bump version for both SDKs +python scripts/sync-versions.py --bump patch # 0.1.0 -> 0.1.1 +python scripts/sync-versions.py --bump minor # 0.1.0 -> 0.2.0 +python scripts/sync-versions.py --bump major # 0.1.0 -> 1.0.0 + +# Set specific version +python scripts/sync-versions.py --set 1.0.0 +``` + +### 2. Feature Parity + +When adding new features, implement in BOTH languages: + +```bash +# Check feature parity +python scripts/check-feature-parity.py +``` + +#### Checklist for New Features + +- [ ] TypeScript implementation in `ts/src/` +- [ ] Python implementation in `python/capture_sdk/` +- [ ] TypeScript types in `ts/src/types.ts` +- [ ] Python types in `python/capture_sdk/types.py` +- [ ] Export in `ts/src/index.ts` +- [ ] Export in `python/capture_sdk/__init__.py` +- [ ] Update both README files +- [ ] Add tests for both implementations +- [ ] Run feature parity check + +### 3. Naming Conventions + +| Concept | TypeScript | Python | +|---------|------------|--------| +| Class methods | `camelCase` | `snake_case` | +| Types/Classes | `PascalCase` | `PascalCase` | +| Options fields | `camelCase` | `snake_case` | +| Constants | `UPPER_CASE` | `UPPER_CASE` | + +Example: +```typescript +// TypeScript +capture.getHistory(nid) +interface RegisterOptions { publicAccess: boolean } +``` + +```python +# Python +capture.get_history(nid) +@dataclass +class RegisterOptions: + public_access: bool +``` + +### 4. API Response Mapping + +Both SDKs must map API responses consistently: + +| API Field | TypeScript | Python | +|-----------|------------|--------| +| `asset_file_name` | `filename` | `filename` | +| `asset_file_mime_type` | `mimeType` | `mime_type` | +| `assetTreeCid` | `assetTreeCid` | `asset_tree_cid` | +| `timestampCreated` | `timestamp` | `timestamp` | + +### 5. Error Handling + +Both SDKs must have equivalent error classes: + +| Error | TypeScript | Python | +|-------|------------|--------| +| Base error | `CaptureError` | `CaptureError` | +| Auth failure | `AuthenticationError` | `AuthenticationError` | +| No permission | `PermissionError` | `PermissionError` | +| Not found | `NotFoundError` | `NotFoundError` | +| No funds | `InsufficientFundsError` | `InsufficientFundsError` | +| Invalid input | `ValidationError` | `ValidationError` | +| Network issue | `NetworkError` | `NetworkError` | + +## Development Workflow + +### Adding a New Feature + +1. **Create an Issue**: Describe the feature and its API design +2. **Update Implementation Plan**: Add to `docs/PLAN.md` +3. **Implement TypeScript**: Add to `ts/src/` +4. **Implement Python**: Add to `python/capture_sdk/` +5. **Add Tests**: Both `ts/tests/` and `python/tests/` +6. **Update README**: Both `ts/README.md` and `python/README.md` +7. **Check Parity**: `python scripts/check-feature-parity.py` +8. **Create PR**: Include both implementations + +### Fixing a Bug + +1. **Identify**: Determine if bug affects one or both SDKs +2. **Fix Both**: If logic bug, fix in both implementations +3. **Add Tests**: Prevent regression in both SDKs +4. **Bump Patch**: `python scripts/sync-versions.py --bump patch` + +### Releasing + +1. **Ensure Parity**: `python scripts/check-feature-parity.py` +2. **Sync Versions**: `python scripts/sync-versions.py --check` +3. **Create Release**: Push tag `v{version}` (e.g., `v0.2.0`) +4. **CI Publishes**: GitHub Actions publishes to npm and PyPI + +## Testing + +### TypeScript +```bash +cd ts +npm install +npm run build +npm run typecheck +npm test # When tests are added +``` + +### Python +```bash +cd python +pip install -e ".[dev]" +pytest +ruff check . +mypy capture_sdk +``` + +## Pull Request Requirements + +- [ ] Changes implemented in both languages (if applicable) +- [ ] Tests added/updated for both SDKs +- [ ] Documentation updated in both READMEs +- [ ] Feature parity check passes +- [ ] Version sync check passes +- [ ] CI passes (build, lint, test) + +## Questions? + +Open an issue on GitHub for any questions about maintaining this dual-language SDK. diff --git a/README.md b/README.md index a5f2b7a..164766d 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,107 @@ # Capture SDK -TypeScript SDK for [Numbers Protocol](https://numbersprotocol.io/) Capture API. Register digital assets, commit updates, and retrieve provenance data. +Official SDKs for [Numbers Protocol](https://numbersprotocol.io/) Capture API. Register digital assets with blockchain-backed provenance. -## Install +**Available in two languages:** +- [TypeScript/JavaScript](./ts/) - For Node.js and browser +- [Python](./python/) - For Python 3.10+ +## Installation + +### TypeScript/JavaScript ```bash -npm install capture-sdk +npm install @numbersprotocol/capture-sdk ``` -## Usage +### Python +```bash +pip install capture-sdk +``` +## Quick Start + +### TypeScript ```typescript -import { Capture } from 'capture-sdk' +import { Capture } from '@numbersprotocol/capture-sdk' -const capture = new Capture({ token: 'YOUR_CAPTURE_TOKEN' }) +const capture = new Capture({ token: 'YOUR_TOKEN' }) // Register an asset -const asset = await capture.register('./photo.jpg') -// Or with options: -// await capture.register('./photo.jpg', { caption: 'My photo', headline: 'Demo' }) - -// Update metadata -await capture.update(asset.nid, { caption: 'Updated caption' }) +const asset = await capture.register('./photo.jpg', { caption: 'My photo' }) -// Get provenance tree +// Get provenance data const tree = await capture.getAssetTree(asset.nid) ``` -### File Input +### Python +```python +from capture_sdk import Capture -```typescript -// Node.js - file path -await capture.register('./photo.jpg') - -// Node.js - Buffer -await capture.register(buffer, { filename: 'photo.jpg' }) +capture = Capture(token='YOUR_TOKEN') -// Browser - File input -await capture.register(fileInput.files[0]) +# Register an asset +asset = capture.register('./photo.jpg', caption='My photo') -// Browser - Blob -await capture.register(blob, { filename: 'photo.jpg' }) +# Get provenance data +tree = capture.get_asset_tree(asset.nid) ``` -### With Signing (Optional) +## API Overview -```typescript -await capture.register('./photo.jpg', { - sign: { privateKey: '0x...' } -}) -``` +| Method | TypeScript | Python | +|--------|------------|--------| +| Register asset | `register(file, options?)` | `register(file, **options)` | +| Update metadata | `update(nid, options)` | `update(nid, **options)` | +| Get asset | `get(nid)` | `get(nid)` | +| Get history | `getHistory(nid)` | `get_history(nid)` | +| Get provenance | `getAssetTree(nid)` | `get_asset_tree(nid)` | -## API +## Documentation -| Method | Description | -|--------|-------------| -| `register(file, options?)` | Register a new asset | -| `update(nid, options)` | Update asset metadata | -| `getAssetTree(nid)` | Get merged provenance tree | -| `get(nid)` | Get asset info | -| `getHistory(nid)` | Get commit history | +- [TypeScript SDK](./ts/README.md) - Full API reference and examples +- [Python SDK](./python/README.md) - Full API reference and examples +- [Contributing Guide](./CONTRIBUTING.md) - How to contribute and maintain both SDKs ## Requirements -- Node.js 18+ or modern browser -- [Capture Token](https://docs.captureapp.xyz/) +- **TypeScript**: Node.js 18+ or modern browser +- **Python**: Python 3.10+ +- **API Token**: Get from [Capture Dashboard](https://docs.captureapp.xyz/) + +## Repository Structure + +``` +capture-sdk/ +├── ts/ # TypeScript SDK +│ ├── src/ # Source code +│ ├── package.json # npm package config +│ └── README.md +├── python/ # Python SDK +│ ├── capture_sdk/ # Source code +│ ├── pyproject.toml # PyPI package config +│ └── README.md +├── scripts/ # Maintenance tools +│ ├── sync-versions.py # Version sync tool +│ └── check-feature-parity.py +└── .github/workflows/ # CI/CD + ├── ci.yml # Test & lint + └── release.yml # Publish to npm/PyPI +``` + +## Version Synchronization + +Both SDKs maintain the same version number. To release: + +```bash +# Bump version in both SDKs +python scripts/sync-versions.py --bump minor + +# Create and push tag +git tag v0.2.0 +git push origin v0.2.0 +``` + +CI automatically publishes to npm and PyPI when a tag is pushed. ## License diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..bea70c7 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,39 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg +.eggs/ + +# Virtual environments +venv/ +.venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml + +# Type checking +.mypy_cache/ + +# Environment variables +.env +.env.local + +# Ruff +.ruff_cache/ diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..b629e07 --- /dev/null +++ b/python/README.md @@ -0,0 +1,157 @@ +# Capture SDK (Python) + +Python SDK for the [Numbers Protocol](https://numbersprotocol.io/) Capture API. Register and manage digital assets with blockchain-backed provenance. + +## Installation + +```bash +pip install capture-sdk +``` + +## Quick Start + +```python +from capture_sdk import Capture + +# Initialize client +capture = Capture(token="your-api-token") + +# Register an asset +asset = capture.register("./photo.jpg", caption="My photo") +print(f"Registered: {asset.nid}") + +# Retrieve asset +asset = capture.get(asset.nid) +print(f"Filename: {asset.filename}") + +# Update metadata +updated = capture.update( + asset.nid, + caption="Updated caption", + commit_message="Fixed typo" +) + +# Get commit history +history = capture.get_history(asset.nid) +for commit in history: + print(f"Action: {commit.action}, Author: {commit.author}") + +# Get merged provenance data +tree = capture.get_asset_tree(asset.nid) +print(f"Creator: {tree.creator_name}") +``` + +## File Input Types + +The SDK accepts multiple file input formats: + +```python +# File path (string) +asset = capture.register("./photo.jpg") + +# pathlib.Path +from pathlib import Path +asset = capture.register(Path("./photo.jpg")) + +# Binary data (requires filename) +with open("photo.jpg", "rb") as f: + data = f.read() +asset = capture.register(data, filename="photo.jpg") +``` + +## Optional Signing + +For cryptographic signing of assets using EIP-191: + +```python +asset = capture.register( + "./photo.jpg", + sign={"private_key": "0x...your-private-key"} +) +``` + +## Context Manager + +Use as a context manager for automatic resource cleanup: + +```python +with Capture(token="your-token") as capture: + asset = capture.register("./photo.jpg") +``` + +## API Reference + +### `Capture(token, testnet=False, base_url=None)` + +Initialize the client. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `token` | `str` | API authentication token | +| `testnet` | `bool` | Use testnet (default: False) | +| `base_url` | `str` | Custom API URL | + +### `capture.register(file, **options)` + +Register a new asset. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `file` | `FileInput` | File path, Path, or bytes | +| `filename` | `str` | Required for binary input | +| `caption` | `str` | Asset description | +| `headline` | `str` | Title (max 25 chars) | +| `public_access` | `bool` | IPFS pinning (default: True) | +| `sign` | `dict` | `{"private_key": "0x..."}` | + +### `capture.update(nid, **options)` + +Update asset metadata. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `nid` | `str` | Numbers ID | +| `caption` | `str` | Updated description | +| `headline` | `str` | Updated title | +| `commit_message` | `str` | Change description | +| `custom_metadata` | `dict` | Custom fields | + +### `capture.get(nid)` + +Retrieve a single asset by NID. + +### `capture.get_history(nid)` + +Get commit history of an asset. + +### `capture.get_asset_tree(nid)` + +Get merged provenance data. + +## Error Handling + +```python +from capture_sdk import ( + CaptureError, + AuthenticationError, + NotFoundError, + ValidationError, +) + +try: + asset = capture.get("invalid-nid") +except NotFoundError as e: + print(f"Asset not found: {e}") +except AuthenticationError as e: + print(f"Auth failed: {e}") +except CaptureError as e: + print(f"Error: {e.code} - {e.message}") +``` + +## Requirements + +- Python 3.10+ + +## License + +MIT diff --git a/python/capture_sdk/__init__.py b/python/capture_sdk/__init__.py new file mode 100644 index 0000000..4653178 --- /dev/null +++ b/python/capture_sdk/__init__.py @@ -0,0 +1,61 @@ +""" +Capture SDK - Python SDK for Numbers Protocol Capture API. + +A developer-friendly SDK for registering and managing digital assets +with blockchain-backed provenance. + +Example: + >>> from capture_sdk import Capture + >>> capture = Capture(token="your-api-token") + >>> asset = capture.register("./photo.jpg", caption="My photo") + >>> print(asset.nid) +""" + +from .client import Capture +from .types import ( + FileInput, + CaptureOptions, + RegisterOptions, + UpdateOptions, + SignOptions, + Asset, + Commit, + AssetTree, +) +from .errors import ( + CaptureError, + AuthenticationError, + PermissionError, + NotFoundError, + InsufficientFundsError, + ValidationError, + NetworkError, +) +from .crypto import sha256, verify_signature + +__version__ = "0.1.0" + +__all__ = [ + # Main client + "Capture", + # Types + "FileInput", + "CaptureOptions", + "RegisterOptions", + "UpdateOptions", + "SignOptions", + "Asset", + "Commit", + "AssetTree", + # Errors + "CaptureError", + "AuthenticationError", + "PermissionError", + "NotFoundError", + "InsufficientFundsError", + "ValidationError", + "NetworkError", + # Utilities + "sha256", + "verify_signature", +] diff --git a/python/capture_sdk/client.py b/python/capture_sdk/client.py new file mode 100644 index 0000000..ca42710 --- /dev/null +++ b/python/capture_sdk/client.py @@ -0,0 +1,556 @@ +""" +Main Capture SDK client. +""" + +import json +import mimetypes +from pathlib import Path +from typing import Any, Optional, Union +from urllib.parse import urlencode + +import httpx + +from .types import ( + FileInput, + CaptureOptions, + RegisterOptions, + UpdateOptions, + Asset, + Commit, + AssetTree, +) +from .errors import ValidationError, CaptureError, create_api_error +from .crypto import sha256, create_integrity_proof, sign_integrity_proof + + +DEFAULT_BASE_URL = "https://api.numbersprotocol.io/api/v3" +HISTORY_API_URL = "https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near" +MERGE_TREE_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/get-full-asset-tree" + +# Common MIME types by extension +MIME_TYPES: dict[str, str] = { + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + "svg": "image/svg+xml", + "mp4": "video/mp4", + "webm": "video/webm", + "mov": "video/quicktime", + "mp3": "audio/mpeg", + "wav": "audio/wav", + "pdf": "application/pdf", + "json": "application/json", + "txt": "text/plain", +} + + +def _get_mime_type(filename: str) -> str: + """Detects MIME type from filename extension.""" + ext = Path(filename).suffix.lower().lstrip(".") + if ext in MIME_TYPES: + return MIME_TYPES[ext] + # Try system mimetypes + mime_type, _ = mimetypes.guess_type(filename) + return mime_type or "application/octet-stream" + + +def _normalize_file( + file_input: FileInput, + options: Optional[RegisterOptions] = None, +) -> tuple[bytes, str, str]: + """ + Normalizes various file input types to a common format. + + Returns: + Tuple of (data, filename, mime_type) + """ + # 1. String path + if isinstance(file_input, str): + path = Path(file_input) + if not path.exists(): + raise ValidationError(f"File not found: {file_input}") + data = path.read_bytes() + filename = path.name + mime_type = _get_mime_type(filename) + return data, filename, mime_type + + # 2. Path object + if isinstance(file_input, Path): + if not file_input.exists(): + raise ValidationError(f"File not found: {file_input}") + data = file_input.read_bytes() + filename = file_input.name + mime_type = _get_mime_type(filename) + return data, filename, mime_type + + # 3. bytes or bytearray + if isinstance(file_input, (bytes, bytearray)): + if not options or not options.filename: + raise ValidationError("filename is required for binary input") + data = bytes(file_input) + filename = options.filename + mime_type = _get_mime_type(filename) + return data, filename, mime_type + + raise ValidationError(f"Unsupported file input type: {type(file_input)}") + + +def _to_asset(response: dict[str, Any]) -> Asset: + """Converts API response to Asset type.""" + return Asset( + nid=response["id"], + filename=response["asset_file_name"], + mime_type=response["asset_file_mime_type"], + caption=response.get("caption"), + headline=response.get("headline"), + ) + + +class Capture: + """ + Main Capture SDK client. + + Example: + >>> from capture_sdk import Capture + >>> capture = Capture(token="your-api-token") + >>> asset = capture.register("./photo.jpg", caption="My photo") + >>> print(asset.nid) + """ + + def __init__( + self, + token: Optional[str] = None, + *, + testnet: bool = False, + base_url: Optional[str] = None, + options: Optional[CaptureOptions] = None, + ): + """ + Initialize the Capture client. + + Args: + token: Authentication token for API access. + testnet: Use testnet environment (default: False). + base_url: Custom base URL (overrides testnet setting). + options: CaptureOptions object (alternative to individual args). + """ + if options: + token = options.token + testnet = options.testnet + base_url = options.base_url + + if not token: + raise ValidationError("token is required") + + self._token = token + self._testnet = testnet + self._base_url = base_url or DEFAULT_BASE_URL + self._client = httpx.Client(timeout=30.0) + + def __enter__(self) -> "Capture": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def close(self) -> None: + """Close the HTTP client.""" + self._client.close() + + def _request( + self, + method: str, + url: str, + *, + data: Optional[dict[str, Any]] = None, + files: Optional[dict[str, Any]] = None, + json_body: Optional[dict[str, Any]] = None, + nid: Optional[str] = None, + ) -> dict[str, Any]: + """Makes an authenticated API request.""" + headers = {"Authorization": f"token {self._token}"} + + try: + if files: + response = self._client.request( + method, + url, + headers=headers, + data=data, + files=files, + ) + elif json_body: + headers["Content-Type"] = "application/json" + response = self._client.request( + method, + url, + headers=headers, + json=json_body, + ) + else: + response = self._client.request( + method, + url, + headers=headers, + data=data, + ) + except httpx.RequestError as e: + raise create_api_error(0, f"Network error: {e}", nid) from e + + if not response.is_success: + message = f"API request failed with status {response.status_code}" + try: + error_data = response.json() + message = error_data.get("detail") or error_data.get("message") or message + except Exception: + pass + raise create_api_error(response.status_code, message, nid) + + return response.json() + + def register( + self, + file: FileInput, + *, + filename: Optional[str] = None, + caption: Optional[str] = None, + headline: Optional[str] = None, + public_access: bool = True, + sign: Optional[dict[str, str]] = None, + options: Optional[RegisterOptions] = None, + ) -> Asset: + """ + Registers a new asset. + + Args: + file: File to register (path, Path, bytes, or bytearray). + filename: Filename (required for bytes/bytearray inputs). + caption: Brief description of the asset. + headline: Asset title (max 25 characters). + public_access: Pin to public IPFS gateway (default: True). + sign: Signing configuration with 'private_key' key. + options: RegisterOptions object (alternative to individual args). + + Returns: + Registered Asset information. + + Example: + >>> # File path + >>> asset = capture.register("./photo.jpg") + >>> + >>> # With options + >>> asset = capture.register( + ... "./photo.jpg", + ... caption="My photo", + ... headline="Demo" + ... ) + >>> + >>> # Binary data + >>> asset = capture.register( + ... image_bytes, + ... filename="image.png" + ... ) + """ + # Build options from args if not provided + if options is None: + from .types import SignOptions + + sign_opts = SignOptions(private_key=sign["private_key"]) if sign else None + options = RegisterOptions( + filename=filename, + caption=caption, + headline=headline, + public_access=public_access, + sign=sign_opts, + ) + + # Validate headline length + if options.headline and len(options.headline) > 25: + raise ValidationError("headline must be 25 characters or less") + + # Normalize file input + data, file_name, mime_type = _normalize_file(file, options) + + if len(data) == 0: + raise ValidationError("file cannot be empty") + + # Build form data + form_data: dict[str, Any] = { + "public_access": str(options.public_access).lower(), + } + + if options.caption: + form_data["caption"] = options.caption + if options.headline: + form_data["headline"] = options.headline + + # Handle signing if private key provided + if options.sign and options.sign.private_key: + proof_hash = sha256(data) + proof = create_integrity_proof(proof_hash, mime_type) + signature = sign_integrity_proof(proof, options.sign.private_key) + + proof_dict = { + "proof_hash": proof.proof_hash, + "asset_mime_type": proof.asset_mime_type, + "created_at": proof.created_at, + } + form_data["signed_metadata"] = json.dumps(proof_dict) + + sig_dict = { + "proofHash": signature.proof_hash, + "provider": signature.provider, + "signature": signature.signature, + "publicKey": signature.public_key, + "integritySha": signature.integrity_sha, + } + form_data["signature"] = json.dumps([sig_dict]) + + files = {"asset_file": (file_name, data, mime_type)} + + response = self._request( + "POST", + f"{self._base_url}/assets/", + data=form_data, + files=files, + ) + + return _to_asset(response) + + def update( + self, + nid: str, + *, + caption: Optional[str] = None, + headline: Optional[str] = None, + commit_message: Optional[str] = None, + custom_metadata: Optional[dict[str, Any]] = None, + options: Optional[UpdateOptions] = None, + ) -> Asset: + """ + Updates an existing asset's metadata. + + Args: + nid: Numbers ID of the asset to update. + caption: Updated description. + headline: Updated title (max 25 characters). + commit_message: Description of the changes. + custom_metadata: Custom metadata fields. + options: UpdateOptions object (alternative to individual args). + + Returns: + Updated Asset information. + + Example: + >>> updated = capture.update( + ... asset.nid, + ... caption="Updated caption", + ... commit_message="Fixed typo in caption" + ... ) + """ + if not nid: + raise ValidationError("nid is required") + + # Build options from args if not provided + if options is None: + options = UpdateOptions( + caption=caption, + headline=headline, + commit_message=commit_message, + custom_metadata=custom_metadata, + ) + + if options.headline and len(options.headline) > 25: + raise ValidationError("headline must be 25 characters or less") + + form_data: dict[str, Any] = {} + + if options.caption is not None: + form_data["caption"] = options.caption + if options.headline is not None: + form_data["headline"] = options.headline + if options.commit_message: + form_data["commit_message"] = options.commit_message + if options.custom_metadata: + form_data["nit_commit_custom"] = json.dumps(options.custom_metadata) + + response = self._request( + "PATCH", + f"{self._base_url}/assets/{nid}/", + data=form_data, + nid=nid, + ) + + return _to_asset(response) + + def get(self, nid: str) -> Asset: + """ + Retrieves a single asset by NID. + + Args: + nid: Numbers ID of the asset. + + Returns: + Asset information. + + Example: + >>> asset = capture.get("bafybei...") + >>> print(f"Filename: {asset.filename}") + >>> print(f"Caption: {asset.caption}") + """ + if not nid: + raise ValidationError("nid is required") + + response = self._request( + "GET", + f"{self._base_url}/assets/{nid}/", + nid=nid, + ) + + return _to_asset(response) + + def get_history(self, nid: str) -> list[Commit]: + """ + Retrieves the commit history of an asset. + + Args: + nid: Numbers ID of the asset. + + Returns: + List of Commit objects. + + Example: + >>> history = capture.get_history("bafybei...") + >>> for commit in history: + ... print(f"Action: {commit.action}") + ... print(f"Author: {commit.author}") + """ + if not nid: + raise ValidationError("nid is required") + + params = {"nid": nid} + if self._testnet: + params["testnet"] = "true" + + url = f"{HISTORY_API_URL}?{urlencode(params)}" + + headers = { + "Content-Type": "application/json", + "Authorization": f"token {self._token}", + } + + try: + response = self._client.get(url, headers=headers) + except httpx.RequestError as e: + raise create_api_error(0, f"Network error: {e}", nid) from e + + if not response.is_success: + raise create_api_error( + response.status_code, + "Failed to fetch asset history", + nid, + ) + + data = response.json() + + return [ + Commit( + asset_tree_cid=c["assetTreeCid"], + tx_hash=c["txHash"], + author=c["author"], + committer=c["committer"], + timestamp=c["timestampCreated"], + action=c["action"], + ) + for c in data["commits"] + ] + + def get_asset_tree(self, nid: str) -> AssetTree: + """ + Retrieves the merged asset tree containing full provenance data. + Combines all commits in chronological order. + + Args: + nid: Numbers ID of the asset. + + Returns: + Merged AssetTree. + + Example: + >>> tree = capture.get_asset_tree(asset.nid) + >>> print(f"Creator: {tree.creator_name}") + >>> print(f"Created at: {tree.created_at}") + """ + if not nid: + raise ValidationError("nid is required") + + # First, get the commit history + commits = self.get_history(nid) + + if len(commits) == 0: + raise CaptureError("No commits found for asset", "NO_COMMITS", 404) + + # Prepare the request body for merging + commit_data = [ + { + "assetTreeCid": c.asset_tree_cid, + "timestampCreated": c.timestamp, + } + for c in commits + ] + + headers = { + "Content-Type": "application/json", + "Authorization": f"token {self._token}", + } + + try: + response = self._client.post( + MERGE_TREE_API_URL, + headers=headers, + json=commit_data, + ) + except httpx.RequestError as e: + raise create_api_error(0, f"Network error: {e}", nid) from e + + if not response.is_success: + raise create_api_error( + response.status_code, + "Failed to merge asset trees", + nid, + ) + + data = response.json() + merged = data.get("mergedAssetTree", data) + + # Map known fields and put the rest in extra + known_fields = { + "assetCid", + "assetSha256", + "creatorName", + "creatorWallet", + "createdAt", + "locationCreated", + "caption", + "headline", + "license", + "mimeType", + } + + extra = {k: v for k, v in merged.items() if k not in known_fields} + + return AssetTree( + asset_cid=merged.get("assetCid"), + asset_sha256=merged.get("assetSha256"), + creator_name=merged.get("creatorName"), + creator_wallet=merged.get("creatorWallet"), + created_at=merged.get("createdAt"), + location_created=merged.get("locationCreated"), + caption=merged.get("caption"), + headline=merged.get("headline"), + license=merged.get("license"), + mime_type=merged.get("mimeType"), + extra=extra, + ) diff --git a/python/capture_sdk/crypto.py b/python/capture_sdk/crypto.py new file mode 100644 index 0000000..3909bce --- /dev/null +++ b/python/capture_sdk/crypto.py @@ -0,0 +1,109 @@ +""" +Cryptographic utilities for the Capture SDK. +""" + +import hashlib +import json +import time +from typing import Union + +from eth_account import Account +from eth_account.messages import encode_defunct + +from .types import IntegrityProof, AssetSignature + + +def sha256(data: Union[bytes, bytearray]) -> str: + """ + Computes SHA-256 hash of data. + + Args: + data: Binary data to hash. + + Returns: + Hex-encoded SHA-256 hash string. + """ + if isinstance(data, bytearray): + data = bytes(data) + return hashlib.sha256(data).hexdigest() + + +def create_integrity_proof(proof_hash: str, mime_type: str) -> IntegrityProof: + """ + Creates an integrity proof for asset registration. + + Args: + proof_hash: SHA-256 hash of the asset. + mime_type: MIME type of the asset. + + Returns: + IntegrityProof object. + """ + return IntegrityProof( + proof_hash=proof_hash, + asset_mime_type=mime_type, + created_at=int(time.time() * 1000), + ) + + +def sign_integrity_proof(proof: IntegrityProof, private_key: str) -> AssetSignature: + """ + Signs an integrity proof using EIP-191 standard. + + Args: + proof: IntegrityProof object to sign. + private_key: Ethereum private key (hex string with or without 0x prefix). + + Returns: + AssetSignature containing the signature data. + """ + # Ensure private key has 0x prefix + if not private_key.startswith("0x"): + private_key = f"0x{private_key}" + + account = Account.from_key(private_key) + + # Compute integrity hash of the signed metadata JSON + proof_dict = { + "proof_hash": proof.proof_hash, + "asset_mime_type": proof.asset_mime_type, + "created_at": proof.created_at, + } + proof_json = json.dumps(proof_dict, separators=(",", ":")) + integrity_sha = sha256(proof_json.encode("utf-8")) + + # Sign the integrity hash using EIP-191 + message = encode_defunct(text=integrity_sha) + signed = account.sign_message(message) + + return AssetSignature( + proof_hash=proof.proof_hash, + provider="capture-sdk", + signature=signed.signature.hex(), + public_key=account.address, + integrity_sha=integrity_sha, + ) + + +def verify_signature(message: str, signature: str, expected_address: str) -> bool: + """ + Verifies an EIP-191 signature against a message and expected signer. + + Args: + message: The original message that was signed. + signature: The signature to verify (hex string). + expected_address: Expected signer's Ethereum address. + + Returns: + True if signature is valid and matches expected address. + """ + try: + # Ensure signature has 0x prefix + if not signature.startswith("0x"): + signature = f"0x{signature}" + + msg = encode_defunct(text=message) + recovered = Account.recover_message(msg, signature=signature) + return recovered.lower() == expected_address.lower() + except Exception: + return False diff --git a/python/capture_sdk/errors.py b/python/capture_sdk/errors.py new file mode 100644 index 0000000..e35db54 --- /dev/null +++ b/python/capture_sdk/errors.py @@ -0,0 +1,90 @@ +""" +Error classes for the Capture SDK. +""" + +from typing import Optional + + +class CaptureError(Exception): + """Base error class for all Capture SDK errors.""" + + def __init__( + self, + message: str, + code: str, + status_code: Optional[int] = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + + def __str__(self) -> str: + return self.message + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.message!r}, code={self.code!r}, status_code={self.status_code!r})" + + +class AuthenticationError(CaptureError): + """Thrown when authentication fails (invalid or missing token).""" + + def __init__(self, message: str = "Invalid or missing authentication token"): + super().__init__(message, "AUTHENTICATION_ERROR", 401) + + +class PermissionError(CaptureError): + """Thrown when user lacks permission for the requested operation.""" + + def __init__(self, message: str = "Insufficient permissions for this operation"): + super().__init__(message, "PERMISSION_ERROR", 403) + + +class NotFoundError(CaptureError): + """Thrown when the requested asset is not found.""" + + def __init__(self, nid: Optional[str] = None): + message = f"Asset not found: {nid}" if nid else "Asset not found" + super().__init__(message, "NOT_FOUND", 404) + self.nid = nid + + +class InsufficientFundsError(CaptureError): + """Thrown when wallet has insufficient NUM tokens.""" + + def __init__(self, message: str = "Insufficient NUM tokens for this operation"): + super().__init__(message, "INSUFFICIENT_FUNDS", 400) + + +class ValidationError(CaptureError): + """Thrown when input validation fails.""" + + def __init__(self, message: str): + super().__init__(message, "VALIDATION_ERROR") + + +class NetworkError(CaptureError): + """Thrown when a network or API request fails.""" + + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message, "NETWORK_ERROR", status_code) + + +def create_api_error( + status_code: int, + message: str, + nid: Optional[str] = None, +) -> CaptureError: + """Maps HTTP status codes to appropriate error classes.""" + if status_code == 400: + if "insufficient" in message.lower(): + return InsufficientFundsError(message) + return ValidationError(message) + elif status_code == 401: + return AuthenticationError(message) + elif status_code == 403: + return PermissionError(message) + elif status_code == 404: + return NotFoundError(nid) + else: + return NetworkError(message, status_code) diff --git a/python/capture_sdk/py.typed b/python/capture_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/capture_sdk/types.py b/python/capture_sdk/types.py new file mode 100644 index 0000000..96b01c2 --- /dev/null +++ b/python/capture_sdk/types.py @@ -0,0 +1,181 @@ +""" +Type definitions for the Capture SDK. +""" + +from dataclasses import dataclass, field +from typing import Any, Optional, Union +from pathlib import Path + + +# Flexible file input type - SDK handles all conversions internally +FileInput = Union[str, Path, bytes, bytearray] +""" +Supported file input types: +- str: File path +- Path: pathlib.Path object +- bytes: Binary data +- bytearray: Mutable binary data +""" + + +@dataclass +class CaptureOptions: + """Configuration options for the Capture client.""" + + token: str + """Authentication token for API access.""" + + testnet: bool = False + """Use testnet environment (default: False).""" + + base_url: Optional[str] = None + """Custom base URL (overrides testnet setting).""" + + +@dataclass +class SignOptions: + """Options for signing asset registration.""" + + private_key: str + """Ethereum private key for EIP-191 signing.""" + + +@dataclass +class RegisterOptions: + """Options for registering a new asset.""" + + filename: Optional[str] = None + """Filename (required for bytes/bytearray inputs).""" + + caption: Optional[str] = None + """Brief description of the asset.""" + + headline: Optional[str] = None + """Asset title (max 25 characters).""" + + public_access: bool = True + """Pin to public IPFS gateway (default: True).""" + + sign: Optional[SignOptions] = None + """Optional signing configuration.""" + + +@dataclass +class UpdateOptions: + """Options for updating an existing asset.""" + + caption: Optional[str] = None + """Updated description.""" + + headline: Optional[str] = None + """Updated title (max 25 characters).""" + + commit_message: Optional[str] = None + """Description of the changes.""" + + custom_metadata: Optional[dict[str, Any]] = None + """Custom metadata fields.""" + + +@dataclass +class Asset: + """Registered asset information.""" + + nid: str + """Numbers ID (NID) - unique identifier.""" + + filename: str + """Original filename.""" + + mime_type: str + """MIME type of the asset.""" + + caption: Optional[str] = None + """Asset description.""" + + headline: Optional[str] = None + """Asset title.""" + + +@dataclass +class Commit: + """A single commit in the asset's history.""" + + asset_tree_cid: str + """CID of the asset tree at this commit.""" + + tx_hash: str + """Blockchain transaction hash.""" + + author: str + """Original creator's address.""" + + committer: str + """Address that made this commit.""" + + timestamp: int + """Unix timestamp of the commit.""" + + action: str + """Description of the action.""" + + +@dataclass +class AssetTree: + """Merged asset tree containing full provenance data.""" + + asset_cid: Optional[str] = None + """Asset content identifier.""" + + asset_sha256: Optional[str] = None + """SHA-256 hash of the asset.""" + + creator_name: Optional[str] = None + """Creator's name.""" + + creator_wallet: Optional[str] = None + """Creator's wallet address.""" + + created_at: Optional[int] = None + """Creation timestamp.""" + + location_created: Optional[str] = None + """Location where asset was created.""" + + caption: Optional[str] = None + """Asset description.""" + + headline: Optional[str] = None + """Asset title.""" + + license: Optional[str] = None + """License information.""" + + mime_type: Optional[str] = None + """MIME type.""" + + extra: dict[str, Any] = field(default_factory=dict) + """Additional fields from commits.""" + + +# Internal types + + +@dataclass +class IntegrityProof: + """Integrity proof for asset registration.""" + + proof_hash: str + asset_mime_type: str + created_at: int + + +@dataclass +class AssetSignature: + """Signature data for asset registration.""" + + proof_hash: str + provider: str + signature: str + public_key: str + integrity_sha: str diff --git a/python/example.py b/python/example.py new file mode 100644 index 0000000..fdc4641 --- /dev/null +++ b/python/example.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Capture SDK Example - Demonstrates all SDK features. + +Usage: + CAPTURE_TOKEN=your-token python example.py ./image.jpg + +Or with signing: + CAPTURE_TOKEN=your-token PRIVATE_KEY=0x... python example.py ./image.jpg +""" + +import os +import sys +from datetime import datetime + +from capture_sdk import Capture, NotFoundError + + +def main() -> None: + # Get token from environment + token = os.environ.get("CAPTURE_TOKEN") + if not token: + print("Error: CAPTURE_TOKEN environment variable is required") + print("Usage: CAPTURE_TOKEN=your-token python example.py ./image.jpg") + sys.exit(1) + + # Get file path from command line + if len(sys.argv) < 2: + print("Error: File path is required") + print("Usage: CAPTURE_TOKEN=your-token python example.py ./image.jpg") + sys.exit(1) + + file_path = sys.argv[1] + private_key = os.environ.get("PRIVATE_KEY") + + # Initialize client + with Capture(token=token) as capture: + print("=" * 50) + print("Capture SDK Python Example") + print("=" * 50) + + # 1. Register asset + print("\n1. Registering asset...") + register_opts = { + "caption": "Test asset from Python SDK", + "headline": "Python Demo", + } + if private_key: + register_opts["sign"] = {"private_key": private_key} + print(" (with EIP-191 signing)") + + asset = capture.register(file_path, **register_opts) + print(f" NID: {asset.nid}") + print(f" Filename: {asset.filename}") + print(f" MIME Type: {asset.mime_type}") + print(f" Caption: {asset.caption}") + print(f" Headline: {asset.headline}") + + # 2. Retrieve asset + print("\n2. Retrieving asset...") + retrieved = capture.get(asset.nid) + print(f" Retrieved NID: {retrieved.nid}") + print(f" Filename: {retrieved.filename}") + + # 3. Update metadata + print("\n3. Updating metadata...") + updated = capture.update( + asset.nid, + caption="Updated caption from Python SDK", + commit_message="Updated via Python example script", + ) + print(f" New caption: {updated.caption}") + + # 4. Get commit history + print("\n4. Getting commit history...") + history = capture.get_history(asset.nid) + print(f" Found {len(history)} commit(s)") + for i, commit in enumerate(history): + timestamp = datetime.fromtimestamp(commit.timestamp) + print(f" [{i + 1}] {commit.action}") + print(f" Author: {commit.author}") + print(f" Time: {timestamp}") + print(f" TX: {commit.tx_hash[:16]}...") + + # 5. Get merged asset tree + print("\n5. Getting merged asset tree...") + tree = capture.get_asset_tree(asset.nid) + print(f" Asset CID: {tree.asset_cid or 'N/A'}") + print(f" Creator: {tree.creator_name or 'N/A'}") + print(f" Caption: {tree.caption or 'N/A'}") + if tree.created_at: + created = datetime.fromtimestamp(tree.created_at / 1000) + print(f" Created: {created}") + + # 6. Demonstrate error handling + print("\n6. Demonstrating error handling...") + try: + capture.get("invalid-nid-that-does-not-exist") + except NotFoundError as e: + print(f" Caught expected error: {e}") + + print("\n" + "=" * 50) + print("Example completed successfully!") + print("=" * 50) + + +if __name__ == "__main__": + main() diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..6136790 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,105 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "capture-sdk" +version = "0.1.0" +description = "Python SDK for Numbers Protocol Capture API" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "Numbers Protocol" } +] +keywords = [ + "numbers-protocol", + "capture", + "blockchain", + "provenance", + "digital-assets", + "web3", +] +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.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.25.0", + "eth-account>=0.10.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-asyncio>=0.21.0", + "ruff>=0.1.0", + "mypy>=1.0.0", + "respx>=0.20.0", +] + +[project.urls] +Homepage = "https://github.com/numbersprotocol/capture-sdk" +Documentation = "https://github.com/numbersprotocol/capture-sdk#readme" +Repository = "https://github.com/numbersprotocol/capture-sdk" +Issues = "https://github.com/numbersprotocol/capture-sdk/issues" + +[tool.hatch.build.targets.sdist] +include = [ + "/capture_sdk", +] + +[tool.hatch.build.targets.wheel] +packages = ["capture_sdk"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["capture_sdk"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --cov=capture_sdk --cov-report=term-missing" + +[tool.coverage.run] +source = ["capture_sdk"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..2fb8bbd --- /dev/null +++ b/python/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for capture_sdk package.""" diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 0000000..f231d2c --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,77 @@ +"""Tests for the Capture client.""" + +import pytest +from capture_sdk import Capture, ValidationError + + +class TestCaptureClient: + """Tests for Capture client initialization.""" + + def test_init_with_token(self) -> None: + """Test client initialization with token.""" + capture = Capture(token="test-token") + assert capture._token == "test-token" + assert capture._testnet is False + capture.close() + + def test_init_without_token_raises_error(self) -> None: + """Test that missing token raises ValidationError.""" + with pytest.raises(ValidationError, match="token is required"): + Capture(token="") + + def test_init_with_testnet(self) -> None: + """Test client initialization with testnet flag.""" + capture = Capture(token="test-token", testnet=True) + assert capture._testnet is True + capture.close() + + def test_init_with_custom_base_url(self) -> None: + """Test client initialization with custom base URL.""" + capture = Capture(token="test-token", base_url="https://custom.api.com") + assert capture._base_url == "https://custom.api.com" + capture.close() + + def test_context_manager(self) -> None: + """Test client as context manager.""" + with Capture(token="test-token") as capture: + assert capture._token == "test-token" + + +class TestValidation: + """Tests for input validation.""" + + def test_register_empty_file_raises_error(self, tmp_path) -> None: + """Test that empty file raises ValidationError.""" + empty_file = tmp_path / "empty.txt" + empty_file.write_bytes(b"") + + with Capture(token="test-token") as capture: + with pytest.raises(ValidationError, match="file cannot be empty"): + capture.register(str(empty_file)) + + def test_register_headline_too_long_raises_error(self, tmp_path) -> None: + """Test that headline over 25 chars raises ValidationError.""" + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"test content") + + with Capture(token="test-token") as capture: + with pytest.raises(ValidationError, match="headline must be 25 characters or less"): + capture.register(str(test_file), headline="a" * 30) + + def test_register_binary_without_filename_raises_error(self) -> None: + """Test that binary input without filename raises ValidationError.""" + with Capture(token="test-token") as capture: + with pytest.raises(ValidationError, match="filename is required"): + capture.register(b"test content") + + def test_get_empty_nid_raises_error(self) -> None: + """Test that empty NID raises ValidationError.""" + with Capture(token="test-token") as capture: + with pytest.raises(ValidationError, match="nid is required"): + capture.get("") + + def test_update_empty_nid_raises_error(self) -> None: + """Test that empty NID raises ValidationError.""" + with Capture(token="test-token") as capture: + with pytest.raises(ValidationError, match="nid is required"): + capture.update("", caption="test") diff --git a/python/tests/test_crypto.py b/python/tests/test_crypto.py new file mode 100644 index 0000000..11cef8b --- /dev/null +++ b/python/tests/test_crypto.py @@ -0,0 +1,103 @@ +"""Tests for crypto utilities.""" + +import pytest +from capture_sdk import sha256, verify_signature +from capture_sdk.crypto import create_integrity_proof, sign_integrity_proof + + +class TestSha256: + """Tests for SHA-256 hashing.""" + + def test_sha256_bytes(self) -> None: + """Test SHA-256 hash of bytes.""" + result = sha256(b"hello world") + expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + assert result == expected + + def test_sha256_bytearray(self) -> None: + """Test SHA-256 hash of bytearray.""" + result = sha256(bytearray(b"hello world")) + expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + assert result == expected + + def test_sha256_empty(self) -> None: + """Test SHA-256 hash of empty bytes.""" + result = sha256(b"") + expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + assert result == expected + + +class TestIntegrityProof: + """Tests for integrity proof creation and signing.""" + + def test_create_integrity_proof(self) -> None: + """Test integrity proof creation.""" + proof = create_integrity_proof("abc123", "image/jpeg") + assert proof.proof_hash == "abc123" + assert proof.asset_mime_type == "image/jpeg" + assert proof.created_at > 0 + + def test_sign_integrity_proof(self) -> None: + """Test integrity proof signing.""" + # Test private key (DO NOT use in production) + private_key = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + proof = create_integrity_proof("abc123", "image/jpeg") + signature = sign_integrity_proof(proof, private_key) + + assert signature.proof_hash == "abc123" + assert signature.provider == "capture-sdk" + assert signature.signature.startswith("0x") or len(signature.signature) == 130 + assert signature.public_key.startswith("0x") + assert len(signature.integrity_sha) == 64 + + def test_sign_integrity_proof_without_0x_prefix(self) -> None: + """Test signing works with private key without 0x prefix.""" + private_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + proof = create_integrity_proof("abc123", "image/jpeg") + signature = sign_integrity_proof(proof, private_key) + + assert signature.public_key.startswith("0x") + + +class TestVerifySignature: + """Tests for signature verification.""" + + def test_verify_valid_signature(self) -> None: + """Test verification of a valid signature.""" + # Test private key and its address + private_key = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + expected_address = "0x14791697260E4c9A71f18484C9f997B308e59325" + + proof = create_integrity_proof("abc123", "image/jpeg") + signature = sign_integrity_proof(proof, private_key) + + assert verify_signature( + signature.integrity_sha, + signature.signature, + expected_address, + ) + + def test_verify_invalid_signature(self) -> None: + """Test verification fails for invalid signature.""" + result = verify_signature( + "some message", + "0x" + "00" * 65, + "0x1234567890123456789012345678901234567890", + ) + assert result is False + + def test_verify_wrong_address(self) -> None: + """Test verification fails for wrong address.""" + private_key = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + wrong_address = "0x0000000000000000000000000000000000000000" + + proof = create_integrity_proof("abc123", "image/jpeg") + signature = sign_integrity_proof(proof, private_key) + + assert not verify_signature( + signature.integrity_sha, + signature.signature, + wrong_address, + ) diff --git a/python/tests/test_errors.py b/python/tests/test_errors.py new file mode 100644 index 0000000..2896abd --- /dev/null +++ b/python/tests/test_errors.py @@ -0,0 +1,108 @@ +"""Tests for error classes.""" + +import pytest +from capture_sdk import ( + CaptureError, + AuthenticationError, + PermissionError, + NotFoundError, + InsufficientFundsError, + ValidationError, + NetworkError, +) +from capture_sdk.errors import create_api_error + + +class TestErrorClasses: + """Tests for error class definitions.""" + + def test_capture_error(self) -> None: + """Test base CaptureError.""" + error = CaptureError("Test error", "TEST_CODE", 500) + assert str(error) == "Test error" + assert error.code == "TEST_CODE" + assert error.status_code == 500 + + def test_authentication_error(self) -> None: + """Test AuthenticationError.""" + error = AuthenticationError() + assert "authentication token" in str(error).lower() + assert error.code == "AUTHENTICATION_ERROR" + assert error.status_code == 401 + + def test_permission_error(self) -> None: + """Test PermissionError.""" + error = PermissionError() + assert "permission" in str(error).lower() + assert error.code == "PERMISSION_ERROR" + assert error.status_code == 403 + + def test_not_found_error_without_nid(self) -> None: + """Test NotFoundError without NID.""" + error = NotFoundError() + assert "not found" in str(error).lower() + assert error.code == "NOT_FOUND" + assert error.status_code == 404 + + def test_not_found_error_with_nid(self) -> None: + """Test NotFoundError with NID.""" + error = NotFoundError("bafybei123") + assert "bafybei123" in str(error) + assert error.nid == "bafybei123" + + def test_insufficient_funds_error(self) -> None: + """Test InsufficientFundsError.""" + error = InsufficientFundsError() + assert "insufficient" in str(error).lower() + assert error.code == "INSUFFICIENT_FUNDS" + assert error.status_code == 400 + + def test_validation_error(self) -> None: + """Test ValidationError.""" + error = ValidationError("Invalid input") + assert str(error) == "Invalid input" + assert error.code == "VALIDATION_ERROR" + assert error.status_code is None + + def test_network_error(self) -> None: + """Test NetworkError.""" + error = NetworkError("Connection failed", 503) + assert str(error) == "Connection failed" + assert error.code == "NETWORK_ERROR" + assert error.status_code == 503 + + +class TestCreateApiError: + """Tests for create_api_error helper.""" + + def test_400_insufficient(self) -> None: + """Test 400 with insufficient keyword maps to InsufficientFundsError.""" + error = create_api_error(400, "Insufficient NUM tokens") + assert isinstance(error, InsufficientFundsError) + + def test_400_other(self) -> None: + """Test 400 without insufficient keyword maps to ValidationError.""" + error = create_api_error(400, "Invalid request") + assert isinstance(error, ValidationError) + + def test_401(self) -> None: + """Test 401 maps to AuthenticationError.""" + error = create_api_error(401, "Unauthorized") + assert isinstance(error, AuthenticationError) + + def test_403(self) -> None: + """Test 403 maps to PermissionError.""" + error = create_api_error(403, "Forbidden") + assert isinstance(error, PermissionError) + + def test_404(self) -> None: + """Test 404 maps to NotFoundError.""" + error = create_api_error(404, "Not found", "bafybei123") + assert isinstance(error, NotFoundError) + assert error.nid == "bafybei123" + + def test_500(self) -> None: + """Test 500 maps to NetworkError.""" + error = create_api_error(500, "Internal server error") + assert isinstance(error, NetworkError) + assert error.status_code == 500 diff --git a/scripts/check-feature-parity.py b/scripts/check-feature-parity.py new file mode 100644 index 0000000..6fae416 --- /dev/null +++ b/scripts/check-feature-parity.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Feature parity checker for capture-sdk monorepo. + +This script helps maintain feature parity between TypeScript and Python SDKs +by checking for corresponding implementations. + +Usage: + python scripts/check-feature-parity.py +""" + +import re +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent + + +@dataclass +class Feature: + name: str + ts_implemented: bool = False + py_implemented: bool = False + + +# Define expected features +EXPECTED_FEATURES = { + # Client methods + "Capture.register": Feature("register"), + "Capture.update": Feature("update"), + "Capture.get": Feature("get"), + "Capture.getHistory": Feature("get_history"), + "Capture.getAssetTree": Feature("get_asset_tree"), + # Types + "Type.CaptureOptions": Feature("CaptureOptions"), + "Type.RegisterOptions": Feature("RegisterOptions"), + "Type.UpdateOptions": Feature("UpdateOptions"), + "Type.SignOptions": Feature("SignOptions"), + "Type.Asset": Feature("Asset"), + "Type.Commit": Feature("Commit"), + "Type.AssetTree": Feature("AssetTree"), + # Errors + "Error.CaptureError": Feature("CaptureError"), + "Error.AuthenticationError": Feature("AuthenticationError"), + "Error.PermissionError": Feature("PermissionError"), + "Error.NotFoundError": Feature("NotFoundError"), + "Error.InsufficientFundsError": Feature("InsufficientFundsError"), + "Error.ValidationError": Feature("ValidationError"), + "Error.NetworkError": Feature("NetworkError"), + # Crypto + "Crypto.sha256": Feature("sha256"), + "Crypto.verifySignature": Feature("verify_signature"), +} + + +def check_ts_features() -> None: + """Check which features are implemented in TypeScript.""" + ts_dir = REPO_ROOT / "ts" / "src" + + # Read all TypeScript source files + ts_content = "" + for ts_file in ts_dir.glob("*.ts"): + ts_content += ts_file.read_text() + + # Check client methods + for key, feature in EXPECTED_FEATURES.items(): + if key.startswith("Capture."): + method = key.split(".")[1] + # Look for method definition in class + if re.search(rf"async\s+{method}\s*\(", ts_content): + feature.ts_implemented = True + elif key.startswith("Type."): + type_name = key.split(".")[1] + if re.search(rf"(interface|type)\s+{type_name}", ts_content): + feature.ts_implemented = True + elif key.startswith("Error."): + error_name = key.split(".")[1] + if re.search(rf"class\s+{error_name}", ts_content): + feature.ts_implemented = True + elif key.startswith("Crypto."): + func_name = key.split(".")[1] + if re.search(rf"(export\s+)?(async\s+)?function\s+{func_name}", ts_content): + feature.ts_implemented = True + + +def check_py_features() -> None: + """Check which features are implemented in Python.""" + py_dir = REPO_ROOT / "python" / "capture_sdk" + + # Read all Python source files + py_content = "" + for py_file in py_dir.glob("*.py"): + py_content += py_file.read_text() + + # Check features with Python naming conventions + for key, feature in EXPECTED_FEATURES.items(): + if key.startswith("Capture."): + method = key.split(".")[1] + # Convert camelCase to snake_case for Python + py_method = re.sub(r"([A-Z])", r"_\1", method).lower().lstrip("_") + if re.search(rf"def\s+{py_method}\s*\(", py_content): + feature.py_implemented = True + elif key.startswith("Type."): + type_name = key.split(".")[1] + if re.search(rf"class\s+{type_name}", py_content): + feature.py_implemented = True + elif key.startswith("Error."): + error_name = key.split(".")[1] + if re.search(rf"class\s+{error_name}", py_content): + feature.py_implemented = True + elif key.startswith("Crypto."): + func_name = key.split(".")[1] + # Convert camelCase to snake_case + py_func = re.sub(r"([A-Z])", r"_\1", func_name).lower().lstrip("_") + if re.search(rf"def\s+{py_func}\s*\(", py_content): + feature.py_implemented = True + + +def print_report() -> None: + """Print feature parity report.""" + print("=" * 60) + print("Feature Parity Report") + print("=" * 60) + print() + + categories = { + "Client Methods": [], + "Types": [], + "Errors": [], + "Crypto Utilities": [], + } + + for key, feature in EXPECTED_FEATURES.items(): + if key.startswith("Capture."): + categories["Client Methods"].append((key, feature)) + elif key.startswith("Type."): + categories["Types"].append((key, feature)) + elif key.startswith("Error."): + categories["Errors"].append((key, feature)) + elif key.startswith("Crypto."): + categories["Crypto Utilities"].append((key, feature)) + + total_features = 0 + ts_count = 0 + py_count = 0 + parity_count = 0 + + for category, features in categories.items(): + print(f"{category}:") + print("-" * 40) + print(f"{'Feature':<30} {'TS':<5} {'PY':<5}") + print("-" * 40) + + for key, feature in features: + name = key.split(".")[1] + ts_status = "✓" if feature.ts_implemented else "✗" + py_status = "✓" if feature.py_implemented else "✗" + print(f"{name:<30} {ts_status:<5} {py_status:<5}") + + total_features += 1 + if feature.ts_implemented: + ts_count += 1 + if feature.py_implemented: + py_count += 1 + if feature.ts_implemented and feature.py_implemented: + parity_count += 1 + + print() + + print("=" * 60) + print("Summary") + print("=" * 60) + print(f"Total features: {total_features}") + print(f"TypeScript: {ts_count}/{total_features}") + print(f"Python: {py_count}/{total_features}") + print(f"Feature parity: {parity_count}/{total_features} ({parity_count/total_features*100:.0f}%)") + + if parity_count == total_features: + print("\n✓ Full feature parity achieved!") + else: + missing = total_features - parity_count + print(f"\n✗ {missing} feature(s) missing parity") + + +def main() -> None: + check_ts_features() + check_py_features() + print_report() + + +if __name__ == "__main__": + main() diff --git a/scripts/sync-versions.py b/scripts/sync-versions.py new file mode 100644 index 0000000..90f9938 --- /dev/null +++ b/scripts/sync-versions.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Version synchronization script for capture-sdk monorepo. + +This script ensures both TypeScript and Python SDKs maintain consistent versions. + +Usage: + # Check current versions + python scripts/sync-versions.py --check + + # Bump version (patch/minor/major) + python scripts/sync-versions.py --bump patch + python scripts/sync-versions.py --bump minor + python scripts/sync-versions.py --bump major + + # Set specific version + python scripts/sync-versions.py --set 1.2.3 +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +# Paths relative to repository root +REPO_ROOT = Path(__file__).parent.parent +TS_PACKAGE_JSON = REPO_ROOT / "ts" / "package.json" +PY_PYPROJECT_TOML = REPO_ROOT / "python" / "pyproject.toml" +PY_INIT_FILE = REPO_ROOT / "python" / "capture_sdk" / "__init__.py" + + +def get_ts_version() -> str: + """Get version from TypeScript package.json.""" + with open(TS_PACKAGE_JSON) as f: + data = json.load(f) + return data["version"] + + +def get_py_version() -> str: + """Get version from Python pyproject.toml.""" + content = PY_PYPROJECT_TOML.read_text() + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + if match: + return match.group(1) + raise ValueError("Could not find version in pyproject.toml") + + +def set_ts_version(version: str) -> None: + """Set version in TypeScript package.json.""" + with open(TS_PACKAGE_JSON) as f: + data = json.load(f) + data["version"] = version + with open(TS_PACKAGE_JSON, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + print(f" Updated ts/package.json to {version}") + + +def set_py_version(version: str) -> None: + """Set version in Python pyproject.toml and __init__.py.""" + # Update pyproject.toml + content = PY_PYPROJECT_TOML.read_text() + content = re.sub( + r'^version\s*=\s*"[^"]+"', + f'version = "{version}"', + content, + flags=re.MULTILINE, + ) + PY_PYPROJECT_TOML.write_text(content) + print(f" Updated python/pyproject.toml to {version}") + + # Update __init__.py + init_content = PY_INIT_FILE.read_text() + init_content = re.sub( + r'^__version__\s*=\s*"[^"]+"', + f'__version__ = "{version}"', + init_content, + flags=re.MULTILINE, + ) + PY_INIT_FILE.write_text(init_content) + print(f" Updated python/capture_sdk/__init__.py to {version}") + + +def bump_version(current: str, bump_type: str) -> str: + """Bump version according to semver.""" + parts = current.split(".") + if len(parts) != 3: + raise ValueError(f"Invalid version format: {current}") + + major, minor, patch = map(int, parts) + + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + elif bump_type == "patch": + return f"{major}.{minor}.{patch + 1}" + else: + raise ValueError(f"Invalid bump type: {bump_type}") + + +def check_versions() -> bool: + """Check if versions are in sync.""" + ts_version = get_ts_version() + py_version = get_py_version() + + print("Current versions:") + print(f" TypeScript: {ts_version}") + print(f" Python: {py_version}") + + if ts_version == py_version: + print("\n✓ Versions are in sync") + return True + else: + print("\n✗ Versions are out of sync!") + return False + + +def sync_versions(version: str) -> None: + """Sync both SDKs to the specified version.""" + print(f"Setting version to {version}...") + set_ts_version(version) + set_py_version(version) + print(f"\n✓ Both SDKs updated to version {version}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Synchronize versions across TypeScript and Python SDKs" + ) + parser.add_argument( + "--check", + action="store_true", + help="Check if versions are in sync", + ) + parser.add_argument( + "--bump", + choices=["patch", "minor", "major"], + help="Bump version (patch/minor/major)", + ) + parser.add_argument( + "--set", + dest="set_version", + metavar="VERSION", + help="Set specific version (e.g., 1.2.3)", + ) + + args = parser.parse_args() + + if not any([args.check, args.bump, args.set_version]): + parser.print_help() + sys.exit(1) + + if args.check: + success = check_versions() + sys.exit(0 if success else 1) + + if args.bump: + current = get_ts_version() + new_version = bump_version(current, args.bump) + print(f"Bumping version from {current} to {new_version}") + sync_versions(new_version) + + if args.set_version: + # Validate version format + if not re.match(r"^\d+\.\d+\.\d+$", args.set_version): + print(f"Error: Invalid version format: {args.set_version}") + print("Version must be in semver format (e.g., 1.2.3)") + sys.exit(1) + sync_versions(args.set_version) + + +if __name__ == "__main__": + main() diff --git a/ts/README.md b/ts/README.md new file mode 100644 index 0000000..8c3e1b7 --- /dev/null +++ b/ts/README.md @@ -0,0 +1,142 @@ +# Capture SDK (TypeScript) + +TypeScript SDK for the [Numbers Protocol](https://numbersprotocol.io/) Capture API. Register and manage digital assets with blockchain-backed provenance. + +## Installation + +```bash +npm install @numbersprotocol/capture-sdk +``` + +## Quick Start + +```typescript +import { Capture } from '@numbersprotocol/capture-sdk' + +// Initialize client +const capture = new Capture({ token: 'your-api-token' }) + +// Register an asset +const asset = await capture.register('./photo.jpg', { caption: 'My photo' }) +console.log('Registered:', asset.nid) + +// Retrieve asset +const retrieved = await capture.get(asset.nid) +console.log('Filename:', retrieved.filename) + +// Update metadata +const updated = await capture.update(asset.nid, { + caption: 'Updated caption', + commitMessage: 'Fixed typo', +}) + +// Get commit history +const history = await capture.getHistory(asset.nid) +for (const commit of history) { + console.log('Action:', commit.action, 'Author:', commit.author) +} + +// Get merged provenance data +const tree = await capture.getAssetTree(asset.nid) +console.log('Creator:', tree.creatorName) +``` + +## File Input Types + +The SDK accepts multiple file input formats: + +```typescript +// Node.js: File path +const asset = await capture.register('./photo.jpg') + +// Browser: File input +const asset = await capture.register(fileInput.files[0]) + +// Browser: Blob +const asset = await capture.register(blob, { filename: 'photo.jpg' }) + +// Universal: Uint8Array +const asset = await capture.register(uint8Array, { filename: 'photo.jpg' }) +``` + +## Optional Signing + +For cryptographic signing of assets using EIP-191: + +```typescript +const asset = await capture.register('./photo.jpg', { + sign: { privateKey: '0x...your-private-key' }, +}) +``` + +## API Reference + +### `new Capture(options)` + +| Option | Type | Description | +|--------|------|-------------| +| `token` | `string` | API authentication token (required) | +| `testnet` | `boolean` | Use testnet (default: false) | +| `baseUrl` | `string` | Custom API URL | + +### `capture.register(file, options?)` + +| Option | Type | Description | +|--------|------|-------------| +| `filename` | `string` | Required for binary input | +| `caption` | `string` | Asset description | +| `headline` | `string` | Title (max 25 chars) | +| `publicAccess` | `boolean` | IPFS pinning (default: true) | +| `sign` | `{ privateKey: string }` | Signing configuration | + +### `capture.update(nid, options)` + +| Option | Type | Description | +|--------|------|-------------| +| `caption` | `string` | Updated description | +| `headline` | `string` | Updated title | +| `commitMessage` | `string` | Change description | +| `customMetadata` | `object` | Custom fields | + +### `capture.get(nid)` + +Retrieve a single asset by NID. + +### `capture.getHistory(nid)` + +Get commit history of an asset. + +### `capture.getAssetTree(nid)` + +Get merged provenance data. + +## Error Handling + +```typescript +import { + CaptureError, + AuthenticationError, + NotFoundError, + ValidationError, +} from '@numbersprotocol/capture-sdk' + +try { + await capture.get('invalid-nid') +} catch (error) { + if (error instanceof NotFoundError) { + console.log('Asset not found') + } else if (error instanceof AuthenticationError) { + console.log('Auth failed') + } else if (error instanceof CaptureError) { + console.log('Error:', error.code, error.message) + } +} +``` + +## Requirements + +- Node.js 18+ or modern browser with Web Crypto API + +## License + +MIT diff --git a/example.ts b/ts/example.ts similarity index 100% rename from example.ts rename to ts/example.ts diff --git a/package-lock.json b/ts/package-lock.json similarity index 100% rename from package-lock.json rename to ts/package-lock.json diff --git a/package.json b/ts/package.json similarity index 63% rename from package.json rename to ts/package.json index db73f9a..a15f716 100644 --- a/package.json +++ b/ts/package.json @@ -1,5 +1,5 @@ { - "name": "capture-sdk", + "name": "@numbersprotocol/capture-sdk", "version": "0.1.0", "description": "TypeScript SDK for Numbers Protocol Capture API", "type": "module", @@ -25,7 +25,9 @@ "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "echo \"No tests configured yet\"", + "prepublishOnly": "npm run build" }, "keywords": [ "numbers-protocol", @@ -35,8 +37,17 @@ "digital-assets", "web3" ], - "author": "", + "author": "Numbers Protocol", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/numbersprotocol/capture-sdk.git", + "directory": "ts" + }, + "homepage": "https://github.com/numbersprotocol/capture-sdk#readme", + "bugs": { + "url": "https://github.com/numbersprotocol/capture-sdk/issues" + }, "dependencies": { "ethers": "^6.13.0" }, @@ -47,5 +58,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" } } diff --git a/src/client.ts b/ts/src/client.ts similarity index 100% rename from src/client.ts rename to ts/src/client.ts diff --git a/src/crypto.ts b/ts/src/crypto.ts similarity index 100% rename from src/crypto.ts rename to ts/src/crypto.ts diff --git a/src/errors.ts b/ts/src/errors.ts similarity index 100% rename from src/errors.ts rename to ts/src/errors.ts diff --git a/src/index.ts b/ts/src/index.ts similarity index 100% rename from src/index.ts rename to ts/src/index.ts diff --git a/src/types.ts b/ts/src/types.ts similarity index 100% rename from src/types.ts rename to ts/src/types.ts diff --git a/tsconfig.json b/ts/tsconfig.json similarity index 100% rename from tsconfig.json rename to ts/tsconfig.json diff --git a/tsup.config.ts b/ts/tsup.config.ts similarity index 100% rename from tsup.config.ts rename to ts/tsup.config.ts