From c9217ed1bf3ee74b8241fa7beeec19e452c5a069 Mon Sep 17 00:00:00 2001 From: smartgoo Date: Mon, 1 Jul 2024 20:43:34 -0400 Subject: [PATCH] Python dict type for RPC method arg/return values (#60) * Python dictionary for request and response * readme update * to_keypair block moves --- Cargo.lock | 11 ++++++++ python/README.md | 22 +++++++++++++++ python/{tests => examples}/addresses.py | 0 python/examples/rpc.py | 29 +++++++++++++++++++ python/{tests => examples}/test.py | 0 python/pyproject.toml | 18 ++++++++---- python/tests/rpc.py | 35 ----------------------- rpc/macros/src/wrpc/python.rs | 20 +++++-------- rpc/wrpc/python/Cargo.toml | 3 +- rpc/wrpc/python/src/client.rs | 37 +++++++------------------ wallet/keys/src/privatekey.rs | 24 ++++++++-------- 11 files changed, 105 insertions(+), 94 deletions(-) create mode 100644 python/README.md rename python/{tests => examples}/addresses.py (100%) create mode 100644 python/examples/rpc.py rename python/{tests => examples}/test.py (100%) delete mode 100644 python/tests/rpc.py diff --git a/Cargo.lock b/Cargo.lock index c4d0ee4920..3ae3e79d38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3720,6 +3720,7 @@ dependencies = [ "kaspa-wrpc-client", "pyo3", "pyo3-asyncio-0-21", + "serde-pyobject", "serde_json", "thiserror", "workflow-core", @@ -5582,6 +5583,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-pyobject" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ffe7ea77d8eba5774068d55b00a4a3a7c6f9fa9b97dd8e3bc8750cc8503fee" +dependencies = [ + "pyo3", + "serde", +] + [[package]] name = "serde-value" version = "0.7.0" diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000..717beb1988 --- /dev/null +++ b/python/README.md @@ -0,0 +1,22 @@ +# Python bindings for Rusty Kaspa +Rusty-Kaspa/Rust bindings for Python, using [PyO3](https://pyo3.rs/v0.20.0/) and [Maturin](https://www.maturin.rs). The result is a Python package that exposes rusty-kaspa/rust source for use in Python programs. + +# Building from Source +1. Ensure Python 3.8 or higher (`python --version`) is installed. [*TODO validate 3.8 or higher is correct*]. Python installers can be found on [python.org](https://www.python.org). +2. `cd ./python` +3. Create Python virtual environment: `python -m venv env` +4. Activate Python virtual env: +- Unix-based systems: `source env/bin/activate` +- Windows: `env/scripts/activate.bat` +5. Install `maturin` build tool: `pip install maturin` +6. Build Python package with Maturin: +- For local development, build and install in active Python virtual env: `maturin develop --release --features py-sdk` +- To build source and built (wheel) distributions: `maturin build --release --strip --sdist --features py-sdk` + +# Usage from Python +See Python files in `./python/examples`. + +# Project Layout +The Python package `kaspapy` is built from the `kaspa-python` crate, which is located at `./python`. + +As such, the `kaspapy` function in `./python/src/lib.rs` is a good starting point. This function uses PyO3 to add functionality to the package. diff --git a/python/tests/addresses.py b/python/examples/addresses.py similarity index 100% rename from python/tests/addresses.py rename to python/examples/addresses.py diff --git a/python/examples/rpc.py b/python/examples/rpc.py new file mode 100644 index 0000000000..9b151be72d --- /dev/null +++ b/python/examples/rpc.py @@ -0,0 +1,29 @@ +import asyncio +import json +import time + +from kaspapy import RpcClient + + +async def main(): + client = await RpcClient.connect(url = "ws://localhost:17110") + print(f'Client is connected: {client.is_connected()}') + + get_server_info_response = await client.get_server_info() + print(get_server_info_response) + + block_dag_info_response = await client.get_block_dag_info() + print(block_dag_info_response) + + tip_hash = block_dag_info_response['tipHashes'][0] + get_block_request = {'hash': tip_hash, 'includeTransactions': True} + get_block_response = await client.get_block_call(get_block_request) + print(get_block_response) + + get_balances_by_addresses_request = {'addresses': ['kaspa:qqxn4k5dchwk3m207cmh9ewagzlwwvfesngkc8l90tj44mufcgmujpav8hakt', 'kaspa:qr5ekyld6j4zn0ngennj9nx5gpt3254fzs77ygh6zzkvyy8scmp97de4ln8v5']} + get_balances_by_addresses_response = await client.get_balances_by_addresses_call(get_balances_by_addresses_request) + print(get_balances_by_addresses_response) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/python/tests/test.py b/python/examples/test.py similarity index 100% rename from python/tests/test.py rename to python/examples/test.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 3605af770f..4bcdcb9260 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,21 +4,27 @@ build-backend = "maturin" [project] name = "kaspapy" -version = "0.0.1" description = "Kaspa Python Bindings" +version = "0.1.0" +requires-python = ">=3.8" license = "ISC" -# readme = "README.md" -# documentation = "https://your_documentation_link" classifiers = [ - "Programming Language :: Python", "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [] +[project.urls] +# homepage = "" +# documentation = "" +# repository = "" +# issues = "" +# changelog = "" + [package.metadata.maturin] name = "kaspapy" description = "Kaspa Python Bindings" [tool.maturin] -name = "kaspapy" -# python-source = "python" \ No newline at end of file +name = "kaspapy" \ No newline at end of file diff --git a/python/tests/rpc.py b/python/tests/rpc.py deleted file mode 100644 index 796bb2ee81..0000000000 --- a/python/tests/rpc.py +++ /dev/null @@ -1,35 +0,0 @@ -import asyncio -import json -import time - -from kaspapy import RpcClient - -async def server_info(client): - sleep_for = 4 - await asyncio.sleep(sleep_for) - - server_info_json_str = await client.get_server_info() - print(f'\nserver_info() slept for {sleep_for} seconds:\n{json.loads(server_info_json_str)}') - -async def block_dag_info(client): - sleep_for = 2 - await asyncio.sleep(sleep_for) - - block_dag_info_json_str = await client.get_block_dag_info() - print(f'\nblock_dag_info() slept for {sleep_for} seconds:\n{json.loads(block_dag_info_json_str)}') - -async def main(): - client = await RpcClient.connect(url = "ws://localhost:17110") - print(f'client is connected: {client.is_connected()}') - - await asyncio.gather( - server_info(client), - block_dag_info(client) - ) - -if __name__ == "__main__": - start = time.time() - - asyncio.run(main()) - - print(f'\ntotal execution time: {time.time() - start}') diff --git a/rpc/macros/src/wrpc/python.rs b/rpc/macros/src/wrpc/python.rs index 68f775fbe9..aad60e7416 100644 --- a/rpc/macros/src/wrpc/python.rs +++ b/rpc/macros/src/wrpc/python.rs @@ -40,23 +40,17 @@ impl ToTokens for RpcTable { #[pymethods] impl RpcClient { - fn #fn_call(&self, py: Python) -> PyResult> { - // Returns result as JSON string + fn #fn_call(&self, py: Python, request: Py) -> PyResult> { let client = self.client.clone(); - // TODO - receive argument from Python and deserialize it - // explore https://docs.rs/serde-pyobject/latest/serde_pyobject/ for arg intake / return + let request : #request_type = serde_pyobject::from_pyobject(request.into_bound(py)).unwrap(); - // TODO replace serde_json with serde_pyobject - let request : #request_type = serde_json::from_str("{}").map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - - let fut = async move { + let py_fut = pyo3_asyncio_0_21::tokio::future_into_py(py, async move { let response : #response_type = client.#fn_call(request).await?; - // TODO - replace serde_json with serde_pyobject - serde_json::to_string(&response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) - }; - - let py_fut = pyo3_asyncio_0_21::tokio::future_into_py(py, fut)?; + Python::with_gil(|py| { + Ok(serde_pyobject::to_pyobject(py, &response).unwrap().to_object(py)) + }) + })?; Python::with_gil(|py| Ok(py_fut.into_py(py))) } diff --git a/rpc/wrpc/python/Cargo.toml b/rpc/wrpc/python/Cargo.toml index 848612d21c..6596e2f308 100644 --- a/rpc/wrpc/python/Cargo.toml +++ b/rpc/wrpc/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kaspa-wrpc-python" -description = "Kaspa gRPC Python client" +description = "Kaspa wRPC Python client" rust-version.workspace = true version.workspace = true edition.workspace = true @@ -26,6 +26,7 @@ kaspa-python-macros.workspace = true pyo3.workspace = true pyo3-asyncio-0-21.workspace = true serde_json.workspace = true +serde-pyobject = "0.3.0" thiserror.workspace = true workflow-core.workspace = true workflow-rpc.workspace = true \ No newline at end of file diff --git a/rpc/wrpc/python/src/client.rs b/rpc/wrpc/python/src/client.rs index ef8e149c2f..8c74cdb1ff 100644 --- a/rpc/wrpc/python/src/client.rs +++ b/rpc/wrpc/python/src/client.rs @@ -6,8 +6,7 @@ use kaspa_wrpc_client::{ client::{ConnectOptions, ConnectStrategy}, KaspaRpcClient, WrpcEncoding, }; -use pyo3::exceptions::PyValueError; -use pyo3::prelude::*; +use pyo3::{prelude::*, types::PyDict}; use std::time::Duration; #[pyclass] @@ -48,38 +47,22 @@ impl RpcClient { } fn get_server_info(&self, py: Python) -> PyResult> { - // Returns result as JSON string let client = self.client.clone(); - - let fut = async move { - let r = client.get_server_info().await?; - serde_json::to_string(&r).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) - }; - - let py_fut = pyo3_asyncio_0_21::tokio::future_into_py(py, fut)?; - - Python::with_gil(|py| Ok(py_fut.into_py(py))) + py_async! {py, async move { + let response = client.get_server_info_call(GetServerInfoRequest { }).await?; + Python::with_gil(|py| { + Ok(serde_pyobject::to_pyobject(py, &response).unwrap().to_object(py)) + }) + }} } - // fn get_block_dag_info(&self, py: Python) -> PyResult> { - // // Returns result as JSON string - // let client = self.client.clone(); - - // let fut = async move { - // let r = client.get_block_dag_info().await?; - // serde_json::to_string(&r).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) - // }; - - // let py_fut = pyo3_asyncio_0_21::tokio::future_into_py(py, fut)?; - - // Python::with_gil(|py| Ok(py_fut.into_py(py))) - // } - fn get_block_dag_info(&self, py: Python) -> PyResult> { let client = self.client.clone(); py_async! {py, async move { let response = client.get_block_dag_info_call(GetBlockDagInfoRequest { }).await?; - serde_json::to_string(&response).map_err(|err| PyValueError::new_err(err.to_string())) + Python::with_gil(|py| { + Ok(serde_pyobject::to_pyobject(py, &response).unwrap().to_object(py)) + }) }} } } diff --git a/wallet/keys/src/privatekey.rs b/wallet/keys/src/privatekey.rs index a2d2440e2c..9f45a6ab12 100644 --- a/wallet/keys/src/privatekey.rs +++ b/wallet/keys/src/privatekey.rs @@ -78,6 +78,12 @@ impl PrivateKey { let address = Address::new(network.try_into()?, AddressVersion::PubKeyECDSA, &payload); Ok(address) } + + /// Generate a [`Keypair`] from this [`PrivateKey`]. + #[wasm_bindgen(js_name = toKeypair)] + pub fn to_keypair(&self) -> Result { + Keypair::from_private_key(self) + } } // PY-NOTE: fns exposed to both WASM and Python @@ -91,13 +97,6 @@ impl PrivateKey { use kaspa_utils::hex::ToHex; self.secret_bytes().to_vec().to_hex() } - - // TODO-PY - // Generate a [`Keypair`] from this [`PrivateKey`]. - // #[wasm_bindgen(js_name = toKeypair)] - // pub fn to_keypair(&self) -> Result { - // Keypair::from_private_key(self) - // } } // PY-NOTE: Python specific fn implementations @@ -109,11 +108,6 @@ impl PrivateKey { Ok(Self { inner: secp256k1::SecretKey::from_str(key)? }) } - // #[wasm_bindgen(js_name = toKeypair)] - // pub fn to_keypair(&self) -> Result { - // Keypair::from_private_key(self) - // } - // PY-NOTE: #[pyo3()] can only be used in block that has #[pymethods] applied directly. applying via #[cfg_attr()] does not work (PyO3 limitation). #[pyo3(name = "to_public_key")] pub fn to_public_key_py(&self) -> PyResult { @@ -140,6 +134,12 @@ impl PrivateKey { let address = Address::new(network.try_into()?, AddressVersion::PubKeyECDSA, &payload); Ok(address) } + + // TODO + // #[wasm_bindgen(js_name = toKeypair)] + // pub fn to_keypair(&self) -> Result { + // Keypair::from_private_key(self) + // } } impl TryCastFromJs for PrivateKey {