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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/publish-python-sdk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Publish Python SDK

on:
push:
tags: ['pyclient-v*']
workflow_dispatch:

defaults:
run:
working-directory: clients/python

jobs:
test:
name: Test wraith-py
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -e ".[dev]"
- run: pytest

publish:
name: Build & publish to PyPI
needs: test
runs-on: ubuntu-latest
# Only publish from a tag, never from a manual test-only dispatch.
if: startsWith(github.ref, 'refs/tags/pyclient-v')
environment: pypi
permissions:
id-token: write # Trusted publishing (OIDC) — no API token needed.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: clients/python/dist
8 changes: 8 additions & 0 deletions clients/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Python build & cache artifacts
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
build/
dist/
.venv/
71 changes: 71 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# wraith-py

Python client for the [Wraith](https://github.com/Miracle656/wraith) Soroban
token transfer indexer REST API. Wraps the REST endpoints in typed dataclasses
so data analysts get autocompletion and full i128 precision (amounts are kept as
strings).

## Install

```bash
pip install wraith-py
```

## Usage

```python
from wraith import WraithClient

client = WraithClient("https://wraith.example.com")

# Transfers received by an address
page = client.incoming_transfers("GABC...", limit=100)
for transfer in page.transfers:
# is_sac distinguishes SAC-wrapped classic assets from native Soroban tokens
print(transfer.contract_id, transfer.amount, transfer.is_sac)

# Paginate with the cursor
if page.next_cursor:
more = client.incoming_transfers("GABC...", cursor=page.next_cursor)

# Per-asset holdings for an account
summary = client.account_summary("GABC...")
for holding in summary.assets:
print(holding.contract_id, holding.net, holding.tx_count)

# Most-active assets
popular = client.popular_assets(window="24h", by="volume")
for asset in popular.assets:
print(asset.contract_id, asset.transfer_count, asset.volume)

client.close() # or use the client as a context manager
```

## Coverage

| Area | Methods |
| --------- | -------------------------------------------------------------------------------------- |
| Transfers | `incoming_transfers`, `outgoing_transfers`, `address_transfers`, `transaction_transfers` |
| Accounts | `account_summary`, `account_transfers` |
| Assets | `popular_assets` |

All responses are returned as typed dataclasses (`Transfer`, `TransferPage`,
`AccountSummary`, `AssetHolding`, `PopularAssets`, `PopularAsset`). Non-2xx
responses raise `WraithAPIError`.

## Development

```bash
pip install -e ".[dev]"
pytest
```

## Publishing

Builds are published to PyPI from CI on tags matching `pyclient-v*` (see
`.github/workflows/publish-python-sdk.yml`). To publish manually:

```bash
python -m build
twine upload dist/*
```
36 changes: 36 additions & 0 deletions clients/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "wraith-py"
version = "0.1.0"
description = "Python client for the Wraith Soroban token transfer indexer REST API"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [{ name = "Wraith contributors" }]
keywords = ["stellar", "soroban", "indexer", "wraith", "sep-41"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"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",
]
dependencies = ["requests>=2.25"]

[project.optional-dependencies]
dev = ["pytest>=7", "responses>=0.23", "build>=1.0", "twine>=4.0"]

[project.urls]
Homepage = "https://github.com/Miracle656/wraith"
Repository = "https://github.com/Miracle656/wraith"
Issues = "https://github.com/Miracle656/wraith/issues"

[tool.hatch.build.targets.wheel]
packages = ["wraith"]
172 changes: 172 additions & 0 deletions clients/python/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Tests for the Wraith Python client.

HTTP is stubbed with the ``responses`` library so the tests run offline and
assert both request shaping (query params) and response parsing.
"""

import pytest
import responses

from wraith import WraithAPIError, WraithClient

BASE = "https://wraith.test"


@pytest.fixture
def client():
with WraithClient(BASE) as c:
yield c


@responses.activate
def test_incoming_transfers_parses_and_tags_sac(client):
responses.add(
responses.GET,
f"{BASE}/transfers/incoming/GABC",
json={
"total": 1,
"limit": 50,
"offset": 0,
"nextCursor": None,
"transfers": [
{
"id": 1,
"contractId": "CSAC",
"eventType": "transfer",
"fromAddress": "GFROM",
"toAddress": "GABC",
"amount": "10000000",
"ledger": 100,
"ledgerClosedAt": "2026-01-01T00:00:00Z",
"txHash": "deadbeef",
"eventId": "evt-1",
"isSac": True,
"displayAmount": "1.0000000",
}
],
},
status=200,
)

page = client.incoming_transfers("GABC", limit=50)

assert page.total == 1
assert len(page.transfers) == 1
transfer = page.transfers[0]
assert transfer.contract_id == "CSAC"
assert transfer.is_sac is True
assert transfer.amount == "10000000"
assert transfer.display_amount == "1.0000000"


@responses.activate
def test_query_params_drop_none_and_pass_through(client):
responses.add(
responses.GET,
f"{BASE}/transfers/address/GABC",
json={"transfers": []},
status=200,
)

client.address_transfers("GABC", contract_id="CXYZ", limit=10)

request = responses.calls[0].request
assert "contractId=CXYZ" in request.url
assert "limit=10" in request.url
# None-valued params (token, cursor, …) must not be serialised.
assert "token=" not in request.url
assert "cursor=" not in request.url


@responses.activate
def test_account_summary(client):
responses.add(
responses.GET,
f"{BASE}/accounts/GABC/summary",
json={
"address": "GABC",
"assets": [
{
"contractId": "CXLM",
"totalSent": "5",
"totalReceived": "12",
"net": "7",
"txCount": 3,
"lastActivityAt": "2026-01-01T00:00:00Z",
}
],
},
status=200,
)

summary = client.account_summary("GABC")

assert summary.address == "GABC"
assert len(summary.assets) == 1
assert summary.assets[0].contract_id == "CXLM"
assert summary.assets[0].tx_count == 3
assert summary.assets[0].net == "7"


@responses.activate
def test_popular_assets(client):
responses.add(
responses.GET,
f"{BASE}/assets/popular",
json={
"window": "24h",
"by": "volume",
"total": 1,
"assets": [
{
"contractId": "CXLM",
"transferCount": 42,
"volume": "1000",
"displayVolume": "0.0001000",
}
],
},
status=200,
)

result = client.popular_assets(window="24h", by="volume")

assert result.by == "volume"
assert result.assets[0].transfer_count == 42
assert result.assets[0].volume == "1000"


@responses.activate
def test_transaction_transfers(client):
responses.add(
responses.GET,
f"{BASE}/transfers/tx/abc123",
json={"transfers": [{"contractId": "C1", "eventType": "mint", "amount": "5"}]},
status=200,
)

transfers = client.transaction_transfers("abc123")

assert len(transfers) == 1
assert transfers[0].event_type == "mint"


@responses.activate
def test_api_error_raises(client):
responses.add(
responses.GET,
f"{BASE}/transfers/incoming/GBAD",
json={"error": "boom"},
status=500,
)

with pytest.raises(WraithAPIError) as exc:
client.incoming_transfers("GBAD")

assert exc.value.status_code == 500
assert "boom" in str(exc.value)


def test_base_url_required():
with pytest.raises(ValueError):
WraithClient("")
36 changes: 36 additions & 0 deletions clients/python/wraith/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""wraith-py — Python client for the Wraith Soroban token transfer indexer.

Quickstart:
from wraith import WraithClient

client = WraithClient("https://wraith.example.com")
page = client.incoming_transfers("GABC...")
for transfer in page.transfers:
print(transfer.contract_id, transfer.amount, transfer.is_sac)
"""

from .client import WraithClient
from .errors import WraithAPIError, WraithError
from .models import (
AccountSummary,
AssetHolding,
PopularAsset,
PopularAssets,
Transfer,
TransferPage,
)

__version__ = "0.1.0"

__all__ = [
"WraithClient",
"WraithError",
"WraithAPIError",
"Transfer",
"TransferPage",
"AccountSummary",
"AssetHolding",
"PopularAsset",
"PopularAssets",
"__version__",
]
Loading