diff --git a/ffi/python/.gitignore b/ffi/python/.gitignore new file mode 100644 index 0000000..c78b829 --- /dev/null +++ b/ffi/python/.gitignore @@ -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 diff --git a/ffi/python/README.md b/ffi/python/README.md new file mode 100644 index 0000000..bb0a50e --- /dev/null +++ b/ffi/python/README.md @@ -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. diff --git a/ffi/python/ant_ffi/__init__.py b/ffi/python/ant_ffi/__init__.py new file mode 100644 index 0000000..00fd315 --- /dev/null +++ b/ffi/python/ant_ffi/__init__.py @@ -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 diff --git a/ffi/python/examples/upload_download_demo.py b/ffi/python/examples/upload_download_demo.py new file mode 100644 index 0000000..c470578 --- /dev/null +++ b/ffi/python/examples/upload_download_demo.py @@ -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())) diff --git a/ffi/python/pyproject.toml b/ffi/python/pyproject.toml new file mode 100644 index 0000000..6f76b0a --- /dev/null +++ b/ffi/python/pyproject.toml @@ -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-), +# one per OS/arch — the CI matrix builds the right binary per wheel. +ant_ffi = ["*.dylib", "*.so", "*.dll"] diff --git a/ffi/python/setup.py b/ffi/python/setup.py new file mode 100644 index 0000000..c1738ca --- /dev/null +++ b/ffi/python/setup.py @@ -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-`: 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", ) -> 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}) diff --git a/ffi/python/tests/test_smoke.py b/ffi/python/tests/test_smoke.py new file mode 100644 index 0000000..e0a3d91 --- /dev/null +++ b/ffi/python/tests/test_smoke.py @@ -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") diff --git a/ffi/rust/Cargo.lock b/ffi/rust/Cargo.lock index 242ec62..05bba1b 100644 --- a/ffi/rust/Cargo.lock +++ b/ffi/rust/Cargo.lock @@ -787,7 +787,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -798,7 +798,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -967,6 +967,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -997,6 +1014,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-ff-macros" version = "0.3.0" @@ -1035,6 +1062,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -1068,6 +1108,30 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -1098,6 +1162,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -2206,7 +2280,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2380,7 +2454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3660,7 +3734,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4231,7 +4305,7 @@ dependencies = [ "libc", "socket2 0.6.3", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4629,14 +4703,15 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.2" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -4710,7 +4785,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4778,7 +4853,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5471,7 +5546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5664,7 +5739,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6619,7 +6694,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/ffi/scripts/build.sh b/ffi/scripts/build.sh index 672c779..d6a1243 100644 --- a/ffi/scripts/build.sh +++ b/ffi/scripts/build.sh @@ -62,6 +62,18 @@ mkdir -p "$SWIFT_GENERATED_DIR" uniffi-bindgen generate --library "$LIB_PATH" --language swift --out-dir "$SWIFT_GENERATED_DIR" echo "Generated Swift bindings in $SWIFT_GENERATED_DIR" +echo "" +echo "=== Step 2d: Generate Python bindings ===" +PYTHON_DIR="$FFI_DIR/python" +PYTHON_GENERATED_DIR="$PYTHON_DIR/ant_ffi" +mkdir -p "$PYTHON_GENERATED_DIR" + +uniffi-bindgen generate --library "$LIB_PATH" --language python --out-dir "$PYTHON_GENERATED_DIR" +# Bundle the native library next to the generated module; the loader resolves it +# from the package directory ($PYTHON_GENERATED_DIR). +cp "$LIB_PATH" "$PYTHON_GENERATED_DIR/" +echo "Generated Python bindings in $PYTHON_GENERATED_DIR" + echo "" echo "=== Step 3: Build .NET solution ===" @@ -93,3 +105,4 @@ echo "Native library: $LIB_PATH" echo "C# bindings: $GENERATED_DIR" echo "Kotlin bindings: $KOTLIN_GENERATED_DIR" echo "Swift bindings: $SWIFT_GENERATED_DIR" +echo "Python bindings: $PYTHON_GENERATED_DIR"