Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A new example that shows how to integrate Python plugins that use pyclasses into a Rust app #2873

Merged
merged 2 commits into from
Jan 27, 2023
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
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added examples/plugin/.DS_Store
Binary file not shown.
9 changes: 9 additions & 0 deletions examples/plugin/.template/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
authors = ["{{authors}}"]
name = "{{project-name}}"
version = "0.1.0"
edition = "2021"

[dependencies]
pyo3 = "{{PYO3_VERSION}}"
plugin_api = { path = "plugin_api" }
17 changes: 17 additions & 0 deletions examples/plugin/.template/plugin_api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 = "{{PYO3_VERSION}}"

[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"]
4 changes: 4 additions & 0 deletions examples/plugin/.template/pre-script.rhai
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
variable::set("PYO3_VERSION", "0.18.0");
file::rename(".template/Cargo.toml", "Cargo.toml");
file::rename(".template/plugin_api/Cargo.toml", "plugin_api/Cargo.toml");
file::delete(".template");
12 changes: 12 additions & 0 deletions examples/plugin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
48 changes: 48 additions & 0 deletions examples/plugin/README.md
Original file line number Diff line number Diff line change
@@ -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.)
davidhewitt marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 5 additions & 0 deletions examples/plugin/cargo-generate.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[template]
ignore = [".nox"]

[hooks]
pre = [".template/pre-script.rhai"]
17 changes: 17 additions & 0 deletions examples/plugin/plugin_api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions examples/plugin/plugin_api/noxfile.py
Original file line number Diff line number Diff line change
@@ -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")
14 changes: 14 additions & 0 deletions examples/plugin/plugin_api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
]


3 changes: 3 additions & 0 deletions examples/plugin/plugin_api/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pytest>=3.5.0
pip>=21.3
maturin>=0.14
32 changes: 32 additions & 0 deletions examples/plugin/plugin_api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<usize>,
}

#[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::<Gadget>()?;
Ok(())
}
22 changes: 22 additions & 0 deletions examples/plugin/plugin_api/tests/test_Gadget.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions examples/plugin/plugin_api/tests/test_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def test_import():
import plugin_api
12 changes: 12 additions & 0 deletions examples/plugin/python_plugin/gadget_init_plugin.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions examples/plugin/python_plugin/rng.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
def get_random_number():
# verified by the roll of a fair die to be random
return 4
44 changes: 44 additions & 0 deletions examples/plugin/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
//"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
alexpyattaev marked this conversation as resolved.
Show resolved Hide resolved
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(())
})
}