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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ffi/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Build outputs — regenerated by ffi/scripts/build.sh
ant_ffi/ant_ffi.py
ant_ffi/*.dylib
ant_ffi/*.so
ant_ffi/*.dll

# Python build/packaging artifacts
build/
dist/
wheelhouse/
*.egg-info/
__pycache__/
*.pyc
79 changes: 79 additions & 0 deletions ffi/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Autonomi FFI — Python bindings

Direct-network, **daemon-less** Autonomi client for Python, generated by
[UniFFI](https://mozilla.github.io/uniffi-rs/) from the `ant-ffi` Rust crate —
the same compiled library that backs the Swift and Kotlin mobile SDKs. Unlike
[`antd`](../../antd-py) (which talks to a running antd daemon over REST/gRPC),
this embeds the network client in-process, exactly like the mobile bindings.

Async `Client` methods are real `async def` — they run on Rust's tokio runtime
and bridge to asyncio, so `await client.data_put_public(...)` works from plain
asyncio with no extra event-loop setup.

> **Package name.** `ant-ffi` is a **placeholder-but-permanent** working title.
> The product-level names (`autonomi`, `autonomi-client`) are already taken on
> PyPI; `ant-ffi` matches the Rust crate and the AntFfi artifact family and is
> free. The intent is to move to a product-named package under a dedicated org
> later. Track: V2-880.

## Layout

```
python/
├── ant_ffi/
│ ├── __init__.py # re-exports the generated surface
│ ├── ant_ffi.py # generated by uniffi-bindgen (do not edit)
│ └── libant_ffi.dylib # bundled native lib (per-platform; not committed)
├── examples/
│ └── upload_download_demo.py
├── tests/
│ └── test_smoke.py # offline: import + version + address derivation
├── pyproject.toml
└── README.md
```

The generated `ant_ffi.py` and the bundled native library are build outputs —
regenerate them with `ffi/scripts/build.sh` (which now includes a Python step).

## Build the bindings

```bash
cd ffi/rust && cargo build --release -p ant-ffi
./target/release/uniffi-bindgen generate \
--library target/release/libant_ffi.dylib \
--language python --out-dir ../python/ant_ffi/
cp target/release/libant_ffi.dylib ../python/ant_ffi/ # or .so / .dll
```

(`ffi/scripts/build.sh` does all of the above alongside the C#/Kotlin/Swift steps.)

## Smoke test (offline, no network)

```bash
cd ffi/python && python3 tests/test_smoke.py
```

## Upload/download demo (needs a devnet)

Start a local devnet (writes `~/.ant-dev/devnet-manifest.json`):

```bash
cd ant-node && cargo run --release --bin ant-devnet -- \
--preset small --enable-evm --manifest ~/.ant-dev/devnet-manifest.json
```

Then run the round-trip demo — connect from the manifest, upload a file (the
manifest wallet pays inside ant-core), download it back, verify byte-identical:

```bash
cd ffi/python && python3 examples/upload_download_demo.py
```

## Progress callbacks & threading

`_with_progress` / `download_*_to_file` methods take a `ProgressListener`
(subclass it, implement `on_progress`). **Callbacks arrive on a Rust/tokio
worker thread, not the asyncio event loop.** asyncio is not thread-safe, so to
touch event-loop state from a callback, hop back with
`loop.call_soon_threadsafe(...)`. Keep callbacks quick — events flow through a
bounded channel and a slow callback backpressures the transfer.
10 changes: 10 additions & 0 deletions ffi/python/ant_ffi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Python bindings for the Autonomi FFI (direct-network, daemon-less client).

Generated by UniFFI from the `ant-ffi` Rust crate; re-exports the full
generated surface (Client, Wallet, external-signer flow, records/objects).
The native library (libant_ffi.dylib / .so / ant_ffi.dll) is bundled alongside
this module and loaded automatically.
"""

from .ant_ffi import * # noqa: F401,F403
from .ant_ffi import ant_ffi_version # noqa: F401
100 changes: 100 additions & 0 deletions ffi/python/examples/upload_download_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Devnet upload/download round-trip demo for the Python FFI bindings.

Mirrors the Swift/Kotlin demo's devnet path: connect from the devnet manifest,
upload a file with the manifest wallet paying inside ant-core (single-shot),
download it back to disk with live progress, and verify it is byte-identical.

Run against a local devnet started with `ant dev start` (or `ant-devnet`
writing its manifest to ~/.ant-dev/devnet-manifest.json).

python3 examples/upload_download_demo.py
"""

from __future__ import annotations

import asyncio
import hashlib
import os
import sys
import tempfile
from pathlib import Path

# Make the sibling package importable when run straight from the repo.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import ant_ffi
from ant_ffi import Client, PaymentMode, ProgressListener, ProgressUpdate

MANIFEST = Path.home() / ".ant-dev" / "devnet-manifest.json"


class PrintProgress(ProgressListener):
"""Callback interface impl — invoked from a Rust/tokio worker thread."""

def __init__(self, what: str) -> None:
self._what = what
self._last = ""

def on_progress(self, update: ProgressUpdate) -> None:
# phase is an enum; total==0 means indeterminate.
phase = getattr(update.phase, "name", str(update.phase))
if update.total:
line = f" [{self._what}] {phase}: {update.done}/{update.total}"
else:
line = f" [{self._what}] {phase}: {update.done}"
if line != self._last:
print(line)
self._last = line


def _sha256(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()


async def main() -> int:
print(f"ant_ffi version: {ant_ffi.ant_ffi_version()}")
if not MANIFEST.exists():
print(f"ERROR: no devnet manifest at {MANIFEST}", file=sys.stderr)
print("Start a devnet first (ant dev start / ant-devnet).", file=sys.stderr)
return 2

print(f"Connecting from devnet manifest: {MANIFEST}")
client = await Client.connect_from_devnet_manifest(str(MANIFEST))
print("Connected.")

workdir = Path(tempfile.mkdtemp(prefix="ant-ffi-demo-"))
src = workdir / "hello.txt"
payload = b"Hello from the Autonomi Python FFI bindings!\n" * 4096 # ~180 KB
src.write_bytes(payload)
src_hash = _sha256(src)
print(f"\nUploading {src} ({src.stat().st_size} bytes, sha256 {src_hash[:16]}...)")

up = await client.file_upload_public(str(src), PaymentMode.AUTO)
print("Upload complete:")
print(f" address: {up.address}")
print(f" chunks_stored: {up.chunks_stored}")
print(f" storage_cost: {up.storage_cost_atto} atto")
print(f" gas_cost: {up.gas_cost_wei} wei")
print(f" payment_mode: {getattr(up.payment_mode_used, 'name', up.payment_mode_used)}")

dest = workdir / "hello.downloaded.txt"
print(f"\nDownloading {up.address} -> {dest}")
written = await client.download_public_to_file(
up.address, str(dest), PrintProgress("download")
)
print(f"Downloaded {written} bytes.")

dst_hash = _sha256(dest)
ok = dst_hash == src_hash and written == src.stat().st_size
print("\nRound-trip verification:")
print(f" source sha256: {src_hash}")
print(f" downloaded sha256: {dst_hash}")
print(f" RESULT: {'PASS — byte-identical' if ok else 'FAIL — mismatch'}")
return 0 if ok else 1


if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
28 changes: 28 additions & 0 deletions ffi/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
# NAME IS A PLACEHOLDER-BUT-PERMANENT working title.
# `autonomi` and `autonomi-client` on PyPI are already taken (the latter is the
# legacy MaidSafe-era pyo3 binding); `antd` is the daemon SDK. `ant-ffi` matches
# the Rust crate and the AntFfi artifact family (ant-swift / com.autonomi:ant-android)
# and is currently free on PyPI. The intent is to move this to a product-named
# package under a future dedicated org; treat the name as permanent for anything
# already published, but not as the final destination. See V2-880.
name = "ant-ffi"
version = "0.0.8" # tracks the AntFfi release version (ant-ffi crate / ant_ffi_version())
description = "Autonomi FFI — direct-network, daemon-less client for Python (UniFFI bindings)"
readme = "README.md"
requires-python = ">=3.9" # floor of the UniFFI 0.29-generated Python
license = { text = "MIT OR Apache-2.0" }

[tool.setuptools]
# Pure-Python (ctypes) module + the bundled native library live in ant_ffi/.
packages = ["ant_ffi"]

[tool.setuptools.package-data]
# Ship the native library next to the module; the generated loader resolves it
# from the package directory. Wheels are platform-tagged (py3-none-<platform>),
# one per OS/arch — the CI matrix builds the right binary per wheel.
ant_ffi = ["*.dylib", "*.so", "*.dll"]
47 changes: 47 additions & 0 deletions ffi/python/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Platform-tagged wheel build for the UniFFI Python bindings.

The generated bindings are pure-Python ctypes over a bundled native library
(libant_ffi.{so,dylib,dll}). That combination confuses the default wheel
machinery: setuptools sees no C extension and would tag the wheel
`py3-none-any` (a lie — the wheel carries a platform-specific binary), while a
`cpXY` ABI tag would be equally wrong (the ctypes module is ABI-independent and
works on any CPython/PyPy).

The correct tag is `py3-none-<platform>`: one wheel per OS/arch, valid for every
Python 3. This overrides bdist_wheel to force exactly that:
* root_is_pure = False -> emit a platform tag instead of `any`
* get_tag() -> ("py3", "none", <platform>) -> Python- and ABI-agnostic

On Linux the resulting `linux_x86_64` tag is then handed to `auditwheel repair`,
which verifies the glibc floor, bundles any external libs, and retags to the
correct `manylinux_*` — auditwheel is the authority on portability, not us.
"""

from setuptools import setup
from setuptools.dist import Distribution

try: # setuptools >= 70.1 vendors bdist_wheel
from setuptools.command.bdist_wheel import bdist_wheel
except ImportError: # older: fall back to the wheel package
from wheel.bdist_wheel import bdist_wheel


class BinaryDistribution(Distribution):
# The wheel carries a native library (libant_ffi.{so,dylib,dll}). Declaring
# ext modules routes the package into platlib (not purelib) and forces a
# platform tag — required for the wheel to be platlib-compliant so that
# `auditwheel repair` will accept and retag it on Linux.
def has_ext_modules(self):
return True


class PlatformWheel(bdist_wheel):
def get_tag(self):
# Valid for any Python 3 / any ABI (pure ctypes), pinned to this
# platform. Platform component is whatever bdist_wheel resolved (or the
# --plat-name override on the command line).
_python, _abi, plat = super().get_tag()
return "py3", "none", plat


setup(distclass=BinaryDistribution, cmdclass={"bdist_wheel": PlatformWheel})
43 changes: 43 additions & 0 deletions ffi/python/tests/test_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Offline smoke tests for the Python FFI bindings.

Mirror of ffi/csharp/AntFfi.Tests: no network required. Verifies the generated
bindings load (import + version) and a deterministic offline crypto op works
(EVM address derivation from a known private key). The devnet put/get round-trip
is exercised separately by examples/upload_download_demo.py where a devnet exists.

python3 -m pytest tests/ # or: python3 tests/test_smoke.py
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import ant_ffi
from ant_ffi import Wallet

# Standard Anvil dev account #0 — deterministic key -> address, pure crypto,
# no RPC reachability needed.
_ANVIL_KEY = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
_ANVIL_ADDR = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"


def test_bindings_load_and_report_version():
v = ant_ffi.ant_ffi_version()
assert isinstance(v, str) and v, "ant_ffi_version() should return a version string"


def test_wallet_address_derivation_offline():
w = Wallet.from_private_key(
_ANVIL_KEY,
"http://localhost:8545", # not contacted for address derivation
"0x5FbDB2315678afecb367f032d93F642f64180aa3",
"0x5FbDB2315678afecb367f032d93F642f64180aa3",
)
assert w.address().lower() == _ANVIL_ADDR


if __name__ == "__main__":
test_bindings_load_and_report_version()
test_wallet_address_derivation_offline()
print(f"OK — ant_ffi {ant_ffi.ant_ffi_version()}: both offline smoke tests passed")
Loading
Loading