From 2228f584a1ab4add68b111775e17b643a2361018 Mon Sep 17 00:00:00 2001 From: Alex Pyattaev Date: Tue, 10 Jan 2023 23:42:07 +0200 Subject: [PATCH] added a plugin example that shows how to integrate a Python plugin into a Rust app while having option to test API without the main app --- examples/README.md | 1 + examples/plugin/Cargo.toml | 12 +++++ examples/plugin/README.md | 48 +++++++++++++++++++ examples/plugin/plugin_api/Cargo.toml | 17 +++++++ examples/plugin/plugin_api/noxfile.py | 9 ++++ examples/plugin/plugin_api/pyproject.toml | 14 ++++++ .../plugin/plugin_api/requirements-dev.txt | 3 ++ examples/plugin/plugin_api/src/lib.rs | 32 +++++++++++++ .../plugin/plugin_api/tests/test_Gadget.py | 22 +++++++++ .../plugin/plugin_api/tests/test_import.py | 2 + .../python_plugin/gadget_init_plugin.py | 12 +++++ examples/plugin/python_plugin/rng.py | 3 ++ examples/plugin/src/main.rs | 44 +++++++++++++++++ 13 files changed, 219 insertions(+) create mode 100644 examples/plugin/Cargo.toml create mode 100644 examples/plugin/README.md create mode 100644 examples/plugin/plugin_api/Cargo.toml create mode 100644 examples/plugin/plugin_api/noxfile.py create mode 100644 examples/plugin/plugin_api/pyproject.toml create mode 100644 examples/plugin/plugin_api/requirements-dev.txt create mode 100644 examples/plugin/plugin_api/src/lib.rs create mode 100644 examples/plugin/plugin_api/tests/test_Gadget.py create mode 100644 examples/plugin/plugin_api/tests/test_import.py create mode 100644 examples/plugin/python_plugin/gadget_init_plugin.py create mode 100644 examples/plugin/python_plugin/rng.py create mode 100644 examples/plugin/src/main.rs diff --git a/examples/README.md b/examples/README.md index 7b1bd4d4..47ab5a9d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,7 @@ Below is a brief description of each of these: | `maturin-starter` | A template project which is configured to use [`maturin`](https://github.com/PyO3/maturin) for development. | | `setuptools-rust-starter` | A template project which is configured to use [`setuptools_rust`](https://github.com/PyO3/setuptools-rust/) for development. | | `word-count` | A quick performance comparison between word counter implementations written in each of Rust and Python. | +| `plugin` | Illustrates how to use Python as a scripting language within a Rust application | ## Creating new projects from these examples diff --git a/examples/plugin/Cargo.toml b/examples/plugin/Cargo.toml new file mode 100644 index 00000000..08127b50 --- /dev/null +++ b/examples/plugin/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "plugin_example" +version = "0.1.0" +edition = "2021" + + +[dependencies] +pyo3={path="../../", features=["macros"]} +plugin_api={path="plugin_api"} + + +[workspace] diff --git a/examples/plugin/README.md b/examples/plugin/README.md new file mode 100644 index 00000000..17f2cf74 --- /dev/null +++ b/examples/plugin/README.md @@ -0,0 +1,48 @@ +# plugin + +An example of a Rust app that uses Python for a plugin. A Python extension module built using PyO3 and [`maturin`](https://github.com/PyO3/maturin) is used to provide +interface types that can be used to exchange data between Rust and Python. This also deals with how to separately test and load python modules. + +# Building and Testing +## Host application +To run the app itself, you only need to run + +```shell +cargo run +``` +It will build the app, as well as the plugin API, then run the app, load the plugin and show it working. + +## Plugin API testing + +The plugin API is in a separate crate `plugin_api`, so you can test it separately from the main app. + +To build the API only package, first install `maturin`: + +```shell +pip install maturin +``` + +When building the plugin, simply using `maturin develop` will fail to produce a viable extension module due to the features arrangement of PyO3. +Instead, one needs to enable the optional feature as follows: + +```shell +cd plugin_api +maturin build --features "extension-module" +``` + +Alternatively, install nox and run the tests inside an isolated environment: + +```shell +nox +``` + +## Copying this example + +Use [`cargo-generate`](https://crates.io/crates/cargo-generate): + +```bash +$ cargo install cargo-generate +$ cargo generate --git https://github.com/PyO3/pyo3 examples/plugin +``` + +(`cargo generate` will take a little while to clone the PyO3 repo first; be patient when waiting for the command to run.) diff --git a/examples/plugin/plugin_api/Cargo.toml b/examples/plugin/plugin_api/Cargo.toml new file mode 100644 index 00000000..870ad76a --- /dev/null +++ b/examples/plugin/plugin_api/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "plugin_api" +version = "0.1.0" +description = "Plugin API example" +edition = "2021" + +[lib] +name = "plugin_api" +crate-type = ["cdylib", "rlib"] + +[dependencies] +#!!! Important - DO NOT ENABLE extension-module FEATURE HERE!!! +pyo3 = { path = "../../../" } + +[features] +# instead extension-module feature for pyo3 is enabled conditionally when we want to build a standalone extension module to test our plugins without "main" program +extension-module = ["pyo3/extension-module"] diff --git a/examples/plugin/plugin_api/noxfile.py b/examples/plugin/plugin_api/noxfile.py new file mode 100644 index 00000000..3b53c0c3 --- /dev/null +++ b/examples/plugin/plugin_api/noxfile.py @@ -0,0 +1,9 @@ +import nox + + +@nox.session +def python(session): + session.install("-rrequirements-dev.txt") + session.install("maturin") + session.run_always("maturin", "develop", "--features", "extension-module") + session.run("pytest") diff --git a/examples/plugin/plugin_api/pyproject.toml b/examples/plugin/plugin_api/pyproject.toml new file mode 100644 index 00000000..114687ed --- /dev/null +++ b/examples/plugin/plugin_api/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["maturin>=0.14,<0.15"] +build-backend = "maturin" + +[project] +name = "plugin_api" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] + + diff --git a/examples/plugin/plugin_api/requirements-dev.txt b/examples/plugin/plugin_api/requirements-dev.txt new file mode 100644 index 00000000..20c7cdfb --- /dev/null +++ b/examples/plugin/plugin_api/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=3.5.0 +pip>=21.3 +maturin>=0.14 diff --git a/examples/plugin/plugin_api/src/lib.rs b/examples/plugin/plugin_api/src/lib.rs new file mode 100644 index 00000000..59aae556 --- /dev/null +++ b/examples/plugin/plugin_api/src/lib.rs @@ -0,0 +1,32 @@ +use pyo3::prelude::*; + +///this is our Gadget that python plugin code can create, and rust app can then access natively. +#[pyclass] +pub struct Gadget { + #[pyo3(get, set)] + pub prop: usize, + //this field will only be accessible to rust code + pub rustonly: Vec, +} + +#[pymethods] +impl Gadget { + #[new] + fn new() -> Self { + Gadget { + prop: 777, + rustonly: Vec::new(), + } + } + + fn push(&mut self, v: usize) { + self.rustonly.push(v); + } +} + +/// A Python module for plugin interface types +#[pymodule] +pub fn plugin_api(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/examples/plugin/plugin_api/tests/test_Gadget.py b/examples/plugin/plugin_api/tests/test_Gadget.py new file mode 100644 index 00000000..f1175f27 --- /dev/null +++ b/examples/plugin/plugin_api/tests/test_Gadget.py @@ -0,0 +1,22 @@ +import pytest + + +@pytest.fixture +def gadget(): + import plugin_api as pa + + g = pa.Gadget() + return g + + +def test_creation(gadget): + pass + + +def test_property(gadget): + gadget.prop = 42 + assert gadget.prop == 42 + + +def test_push(gadget): + gadget.push(42) diff --git a/examples/plugin/plugin_api/tests/test_import.py b/examples/plugin/plugin_api/tests/test_import.py new file mode 100644 index 00000000..ae1d6f67 --- /dev/null +++ b/examples/plugin/plugin_api/tests/test_import.py @@ -0,0 +1,2 @@ +def test_import(): + import plugin_api diff --git a/examples/plugin/python_plugin/gadget_init_plugin.py b/examples/plugin/python_plugin/gadget_init_plugin.py new file mode 100644 index 00000000..2eeba6fa --- /dev/null +++ b/examples/plugin/python_plugin/gadget_init_plugin.py @@ -0,0 +1,12 @@ +import plugin_api +import rng + + +def start(): + """create an instance of Gadget, configure it and return to Rust""" + g = plugin_api.Gadget() + g.push(1) + g.push(2) + g.push(3) + g.prop = rng.get_random_number() + return g diff --git a/examples/plugin/python_plugin/rng.py b/examples/plugin/python_plugin/rng.py new file mode 100644 index 00000000..042e5e4b --- /dev/null +++ b/examples/plugin/python_plugin/rng.py @@ -0,0 +1,3 @@ +def get_random_number(): + # verified by the roll of a fair die to be random + return 4 diff --git a/examples/plugin/src/main.rs b/examples/plugin/src/main.rs new file mode 100644 index 00000000..b50b5454 --- /dev/null +++ b/examples/plugin/src/main.rs @@ -0,0 +1,44 @@ +use plugin_api::plugin_api as pylib_module; +use pyo3::prelude::*; +use pyo3::types::PyList; +use std::path::Path; + +fn main() -> Result<(), Box> { + //"export" our API module to the python runtime + pyo3::append_to_inittab!(pylib_module); + //spawn runtime + pyo3::prepare_freethreaded_python(); + //import path for python + let path = Path::new("./python_plugin/"); + //do useful work + Python::with_gil(|py| { + //add the current directory to import path of Python (do not use this in production!) + let syspath: &PyList = py.import("sys")?.getattr("path")?.extract()?; + syspath.insert(0, &path)?; + println!("Import path is: {:?}", syspath); + + // Now we can load our python_plugin/gadget_init_plugin.py file. + // It can in turn import other stuff as it deems appropriate + let plugin = PyModule::import(py, "gadget_init_plugin")?; + // and call start function there, which will return a python reference to Gadget. + // Gadget here is a "pyclass" object reference + let gadget = plugin.getattr("start")?.call0()?; + + //now we extract (i.e. mutably borrow) the rust struct from python object + { + //this scope will have mutable access to the gadget instance, which will be dropped on + //scope exit so Python can access it again. + let mut gadget_rs: PyRefMut<'_, plugin_api::Gadget> = gadget.extract()?; + // we can now modify it as if it was a native rust struct + gadget_rs.prop = 42; + //which includes access to rust-only fields that are not visible to python + println!("rust-only vec contains {:?}", gadget_rs.rustonly); + gadget_rs.rustonly.clear(); + } + + //any modifications we make to rust object are reflected on Python object as well + let res: usize = gadget.getattr("prop")?.extract()?; + println!("{res}"); + Ok(()) + }) +}