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

pyo3-testing: initial proc_macro to make testing pyo3functions easier #4099

Open
wants to merge 112 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
112 commits
Select commit Hold shift + click to select a range
4ab7334
run a quick CI on every push
MusicalNinjaDad Apr 5, 2024
2867622
speed up tests and reduce action-minutes used
MusicalNinjaDad Apr 5, 2024
0430023
remove msrv check (10min runtime)
MusicalNinjaDad Apr 5, 2024
5a4152f
remove coverage check (runtime 15 min)
MusicalNinjaDad Apr 5, 2024
3c1b643
correctly describe what ci-quick does
MusicalNinjaDad Apr 5, 2024
1d772d2
migrate main function from sandbox
MusicalNinjaDad Apr 6, 2024
07946d1
resolve linting errors (clippy)
MusicalNinjaDad Apr 9, 2024
963d12c
inherit workspace lints
MusicalNinjaDad Apr 9, 2024
aaf7a4e
resolve formatting issues
MusicalNinjaDad Apr 9, 2024
11cf832
parse import statement to a Pyo3Import
MusicalNinjaDad Apr 10, 2024
ccaf55b
check import path before parsing (in test)
MusicalNinjaDad Apr 10, 2024
f59e2b9
check path and parse in dedicated function
MusicalNinjaDad Apr 10, 2024
0b78eba
document the basic idea
MusicalNinjaDad Apr 10, 2024
a5627ef
document the expected format for pyo3import attributes
MusicalNinjaDad Apr 10, 2024
685dce4
more specific function naming
MusicalNinjaDad Apr 10, 2024
00553a1
clarify how import is done
MusicalNinjaDad Apr 10, 2024
43ff10c
helpful comment for future contributors
MusicalNinjaDad Apr 10, 2024
93a1614
macro test is good, import_pyo3_from not adding fn header
MusicalNinjaDad Apr 12, 2024
3d221e1
adjust import_pyo3_from to take FnItem as input
MusicalNinjaDad Apr 12, 2024
bc3ce19
GREEN: implement the macro as a dedicated fn working on TokenStream2
MusicalNinjaDad Apr 12, 2024
ff1fd8a
fmt
MusicalNinjaDad Apr 12, 2024
14922af
improved naming of wrap_testcase
MusicalNinjaDad Apr 12, 2024
3bf69f5
test multi-line blocks are correctly wrapped
MusicalNinjaDad Apr 14, 2024
4ef1f57
wrap_testcase more readable with better variable naming
MusicalNinjaDad Apr 14, 2024
372f10b
allow mutliple imports (BROKEN: without updating everything else to u…
MusicalNinjaDad Apr 14, 2024
0d5216d
test_wrap_testcase passing
MusicalNinjaDad Apr 14, 2024
71f07c5
test_wrap_testcase_multiline_block passing
MusicalNinjaDad Apr 14, 2024
89d050c
make a TestCase struct to contain the imports and relevant bits of th…
MusicalNinjaDad Apr 14, 2024
866847e
GREEN: allow mutliple imports
MusicalNinjaDad Apr 14, 2024
476cb2b
fix: fmt & clippy
MusicalNinjaDad Apr 14, 2024
21c767a
nicer (illegal) formatting
MusicalNinjaDad Apr 14, 2024
aeb3d56
improved readability
MusicalNinjaDad Apr 14, 2024
eccc2c5
convert from ItemFn to Pyo3TestCase using From/Into
MusicalNinjaDad Apr 14, 2024
9c5bc30
ensure it works with no imports
MusicalNinjaDad Apr 14, 2024
f8fe447
do most tests on impl_ not on wrap_testcase to remove unneccessary co…
MusicalNinjaDad Apr 14, 2024
9d922cf
reorder code for better readability
MusicalNinjaDad Apr 14, 2024
517160f
document the main function
MusicalNinjaDad Apr 14, 2024
4c56935
fix docs
MusicalNinjaDad Apr 14, 2024
b89a976
make pyo3test accessible via use pyo3_testing::pyo3test
MusicalNinjaDad Apr 14, 2024
75d1587
add #[test] attribute to wrapped function
MusicalNinjaDad Apr 14, 2024
b30c7ae
DONE: fix hygiene to Span::call_site, macro works correctly
MusicalNinjaDad Apr 14, 2024
6d2770d
fmt & clippy
MusicalNinjaDad Apr 14, 2024
f824761
remove unneeded allow()
MusicalNinjaDad Apr 14, 2024
3c75653
gate behind default feature "testing"
MusicalNinjaDad Apr 14, 2024
5327d26
add to prelude
MusicalNinjaDad Apr 14, 2024
293969b
consistent test file naming
MusicalNinjaDad Apr 14, 2024
5b8c8bf
fix clippy/x86_64-unknown-linux-gnu/beta: needless borrow
MusicalNinjaDad Apr 14, 2024
59aaa8d
add to "full" feature-set
MusicalNinjaDad Apr 14, 2024
a494118
fix clippy/beta: unneeded import
MusicalNinjaDad Apr 14, 2024
709d0df
Don't test pyo3test on GraalPy or PyPy
MusicalNinjaDad Apr 14, 2024
7d9ba2d
testing feature requires macros
MusicalNinjaDad Apr 14, 2024
01890d5
include pyo3-testing unittests in `nox -s test-rust`
MusicalNinjaDad Apr 15, 2024
dabfbe8
restructure for readability
MusicalNinjaDad Apr 15, 2024
9abf88e
RED: import_module_only
MusicalNinjaDad Apr 15, 2024
af82df6
match `import module` as well as `from module import function`
MusicalNinjaDad Apr 17, 2024
d75b730
RED: edgecase `import module`followed by `from module import function`
MusicalNinjaDad Apr 17, 2024
ec3b80b
GREEN: edgecase `import module`followed by `from module import function`
MusicalNinjaDad Apr 17, 2024
33a3806
fmt
MusicalNinjaDad Apr 17, 2024
fde83b0
renaming for clarity
MusicalNinjaDad Apr 17, 2024
38c04ea
if let better than single match arm
MusicalNinjaDad Apr 17, 2024
63f99a1
reorder
MusicalNinjaDad Apr 17, 2024
2ce5c87
simplify Parse for Pyo3Import with let functionname = match
MusicalNinjaDad Apr 17, 2024
591eb25
improve statement ordering in wrap_testcase for better understanding
MusicalNinjaDad Apr 17, 2024
ed179bb
make a PythonImportKeyword enum -> safer than strings
MusicalNinjaDad Apr 17, 2024
6769226
just use the PythonImportKeyword enum, no need for an extra struct wr…
MusicalNinjaDad Apr 17, 2024
4651717
simplify - thankyou clippy
MusicalNinjaDad Apr 17, 2024
4c1fced
add import_module_only integration test and fix span hygiene
MusicalNinjaDad Apr 18, 2024
bd67c4c
leave other attributes in place if they exist
MusicalNinjaDad Apr 18, 2024
ea0ad14
easier way to include the test attribute
MusicalNinjaDad Apr 18, 2024
f377dc3
make conversion ItemFn -> Pyo3TestCase fallible (if args are empty or…
MusicalNinjaDad Apr 18, 2024
0a1cc80
handle parsing errors as compile errors
MusicalNinjaDad Apr 19, 2024
33e76bd
Complete the inline documentation
MusicalNinjaDad Apr 19, 2024
820c211
fix whitespace in comments
MusicalNinjaDad Apr 19, 2024
f7ede02
first ui test passing - typo in import statement "form"
MusicalNinjaDad Apr 19, 2024
056a9c6
also fail if second keyword is invalid
MusicalNinjaDad Apr 19, 2024
29d1f12
test for correct error when no import statement is provided
MusicalNinjaDad Apr 19, 2024
6d236a3
whitespace - fmt
MusicalNinjaDad Apr 19, 2024
939f66a
test missing colon
MusicalNinjaDad Apr 19, 2024
f548b70
test missing function
MusicalNinjaDad Apr 19, 2024
918b554
simplify error handling when parsing import keywords
MusicalNinjaDad Apr 19, 2024
ce4d966
improve error for missing colon (using peek2)
MusicalNinjaDad Apr 19, 2024
a5851c7
fmt: I really need to add a precommit hook ...
MusicalNinjaDad Apr 19, 2024
59c26c5
corect testcase naming
MusicalNinjaDad Apr 20, 2024
529e2a9
add explanation of inability to ui test invalid (out of scope) pyo3me…
MusicalNinjaDad Apr 20, 2024
0fd8167
improve error message mistyped keywords
MusicalNinjaDad Apr 20, 2024
1836345
fmt
MusicalNinjaDad Apr 20, 2024
7e5d6e8
Initial guide section
MusicalNinjaDad Apr 20, 2024
978c8b0
improved variable naming in Pyo3Import::Parse
MusicalNinjaDad Apr 21, 2024
d207bf0
consistently naming variables as pyo3import(s)
MusicalNinjaDad Apr 21, 2024
ee2847c
Derive Debug, PartialEq where possible, explain where not
MusicalNinjaDad Apr 21, 2024
f8dd408
Explain why not PyPy / GraalPy
MusicalNinjaDad Apr 21, 2024
344751a
correct internal link
MusicalNinjaDad Apr 21, 2024
baa4c79
fix grammar and example in Testing your functionality in Rust
MusicalNinjaDad Apr 21, 2024
023af31
provide full rust example code in one place
MusicalNinjaDad Apr 21, 2024
674a850
Improve readability of Testing the final integration in Python
MusicalNinjaDad Apr 21, 2024
a90919c
cargo expand --test test_pyo3test
MusicalNinjaDad Apr 21, 2024
1be772f
unsafe {Bound::from_owned_ptr(py, py_adders::__pyo3_init())} implemen…
MusicalNinjaDad Apr 21, 2024
b9b251c
fmt
MusicalNinjaDad Apr 21, 2024
45971fd
rename expanded - so fmt, clippy, test etc ignore it
MusicalNinjaDad Apr 21, 2024
03adcee
change approach to insert module directly into sys.modules
MusicalNinjaDad Apr 23, 2024
6a9b754
rename from py_modules to sys_modules
MusicalNinjaDad Apr 23, 2024
f896cb4
fmt
MusicalNinjaDad Apr 23, 2024
f99dbd2
format_ident! is much nicer than string concat
MusicalNinjaDad Apr 23, 2024
13ce580
improve readability of parse_quoted function with comments and whites…
MusicalNinjaDad Apr 23, 2024
3086653
only Py>=3.9 - cannot reinitialise modules in 3.8 or lower
MusicalNinjaDad Apr 23, 2024
4173e46
add a comment on compatibilty with older python versions
MusicalNinjaDad Apr 24, 2024
b2be6bc
newsfragment
MusicalNinjaDad Apr 24, 2024
71f677b
Merge: 'upstream/main' - bring fork up to date with upstream ready fo…
MusicalNinjaDad Apr 24, 2024
8a93c48
also not possible on Windows 3.9 according to impl_
MusicalNinjaDad Apr 24, 2024
1cbfd1b
fix link in doc comment
MusicalNinjaDad Apr 24, 2024
373c5a2
Remove reference to external repo
MusicalNinjaDad Apr 25, 2024
48a29ad
Add note that py3.9 required to run tests to avoid:
MusicalNinjaDad May 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/ci-quick.yml
@@ -0,0 +1,66 @@
name: Quick CI

# A short version of the CI, designed to run in seconds (<200s) on every push.
# Runs lints & basic tests on ALL branches EXCEPT main.

on:
push:
branches-ignore:
- main

env:
CARGO_TERM_COLOR: always

jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- run: python -m pip install --upgrade pip && pip install nox
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check python formatting and lints (ruff)
run: nox -s ruff
- name: Check rust formatting (rustfmt)
run: nox -s rustfmt

semver-checks:
if: github.ref != 'refs/heads/main'
needs: [fmt]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: obi1kenobi/cargo-semver-checks-action@v2

clippy:
needs: [fmt]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy,rust-src
- uses: actions/setup-python@v5
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.event_name != 'merge_group' }}
- run: python -m pip install --upgrade pip && pip install nox
- run: nox -s clippy

tests:
name: test rust skip-full
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.event_name != 'merge_group' }}
- uses: dtolnay/rust-toolchain@stable
with:
components: rust-src
- run: python -m pip install --upgrade pip && pip install nox
- run: nox -s test-rust -- skip-full
10 changes: 9 additions & 1 deletion Cargo.toml
Expand Up @@ -32,6 +32,9 @@ unindent = { version = "0.2.1", optional = true }
# support crate for multiple-pymethods feature
inventory = { version = "0.3.0", optional = true }

# support crate for testing pyo3 wrapped functions
pyo3-testing = { path = "pyo3-testing", version = "=0.1.0", optional = true}

# crate integrations that can be added using the eponymous features
anyhow = { version = "1.0", optional = true }
chrono = { version = "0.4.25", default-features = false, optional = true }
Expand Down Expand Up @@ -63,7 +66,7 @@ futures = "0.3.28"
pyo3-build-config = { path = "pyo3-build-config", version = "=0.21.2", features = ["resolve-config"] }

[features]
default = ["macros"]
default = ["macros", "testing"]

# Enables support for `async fn` for `#[pyfunction]` and `#[pymethods]`.
experimental-async = ["macros", "pyo3-macros/experimental-async"]
Expand All @@ -81,6 +84,9 @@ macros = ["pyo3-macros", "indoc", "unindent"]
# Enables multiple #[pymethods] per #[pyclass]
multiple-pymethods = ["inventory", "pyo3-macros/multiple-pymethods"]

# Enables #[pyo3test] and #[pyo3import]
testing = ["pyo3-testing", "macros"]

# Use this feature when building an extension module.
# It tells the linker to keep the python symbols unresolved,
# so that the module can also be used with statically linked python interpreters.
Expand Down Expand Up @@ -113,6 +119,7 @@ nightly = []
# This is mostly intended for testing purposes - activating *all* of these isn't particularly useful.
full = [
"macros",
"testing",
# "multiple-pymethods", # TODO re-add this when MSRV is greater than 1.62
"anyhow",
"chrono",
Expand All @@ -137,6 +144,7 @@ members = [
"pyo3-build-config",
"pyo3-macros",
"pyo3-macros-backend",
"pyo3-testing",
"pytests",
"examples",
]
Expand Down
1 change: 1 addition & 0 deletions guide/src/SUMMARY.md
Expand Up @@ -15,6 +15,7 @@
- [Basic object customization](class/object.md)
- [Emulating numeric types](class/numeric.md)
- [Emulating callable objects](class/call.md)
- [Testing your code](testing.md)
- [Calling Python from Rust](python-from-rust.md)
- [Python object types](types.md)
- [Python exceptions](exception.md)
Expand Down
216 changes: 216 additions & 0 deletions guide/src/testing.md
@@ -0,0 +1,216 @@
# Testing Rust code wrapped for use in Python

Testing that the Rust code you have created works, and works in Python can be simple. PyO3 includes
some helper macros to make this task easier when coupled with a few good practices.

This chapter of the Guide explains:

- [How to structure your code to make testing easier](#structuring-for-testability)
- [How to test your functionality](#testing-your-functionality-in-rust)
- [Testing your wrapping with `#[pyo3test]`](#testing-your-wrapped-functions-in-rust)
- [Final integration testing in Python](#testing-the-final-integration-in-python)
- [Compatibility with older Python versions (CI)](#compatibility-with-older-python-versions)

## Structuring for testability

If your code contains anything more than the most basic logic, you will probably want to test that it
functions correctly. This is best done in the Rust eco-system. Depending on

- whether you want to provide your library for use in rust (via crates.io)
- the overall complexity of your code base

you have two options:

1. For more complex libraries, or where you wish to provide a rust library as well as your Python
package: you should create a dedicated crate for your rust library and a second crate for the PyO3
bindings.
1. For simpler cases, or where your code is only destined to be used in Python: you should create your
basic functionality as rust modules and functions, without wrapping them using `[#pyo3...]`

In the first case: you can create both unit- and integration tests as defined and described in
["The Book"](https://doc.rust-lang.org/stable/book/ch11-00-testing.html) to validate your functionality.

In the second case: you are restricted to "unit tests" within the same source file as the code itself.
This can be perfectly adequate, as you will test integration with Python later...

For the remainder of this guide we will focus on the second case.

## Testing your functionality in Rust

Comprehensively testing your functionality directly in Rust gives you the fastest test execution and
makes finding any issues easier, as you know that they can only originate in the underlying Rust functions,
not in your wrapping, importing or use in Python.

Let's say your library should add one to any integer:

```rust
fn o3_addone(num: isize) -> isize {
num + 1
}
```

You can test this in the same file. More details on how to do this are described in
[Unit tests](https://doc.rust-lang.org/stable/book/ch11-03-test-organization.html#unit-tests)
in "The Book":

```rust
fn o3_addone(num: isize) -> isize {
num + 1
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_one_plus_one () {
let result = o3_addone(1_isize);
asserteq!(result, 2_isize)
}
```

## Testing your wrapped functions in Rust

Once you are confident that your functionality is sound, you can wrap it for Python with a simple
one-liner:

```rust
#[pyfunction]
#[pyo3(name = "addone")]
fn py_addone(num: isize) -> isize {
o3_addone(num)
}
```

and then create a Python module which can be imported:

```rust
#[pymodule]
#[pyo3(name = "adders")]
fn py_adders(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(py_addone, module)?)?;
Ok(())
}
```

Still in Rust, you can test that the wrapped functionality can be executed by the Python interpreter.
PyO3 provides the `#[pyo3test]` proc-macro and associated `#[pyo3import(...)]` attribute to make this
simpler:

```rust
#[pyo3test]
#[pyo3import(py_adders: from adders import addone)]
fn test_one_plus_one_wrapped() {
let result: PyResult<isize> = match addone.call1((1_isize,)) {
Ok(r) => r.extract(),
Err(e) => Err(e),
};
let result = result.unwrap();
let expected_result = 2_isize;
assert_eq!(result, expected_result);
}
```

`#[pyo3test]` takes care of wrapping the whole test case in `Python::with_gil(|py| {...})` and making
`addone` available in Rust.

> **Note:** running multiple tests which `#[pyo3import]` the same wrapped module requires _at least python3.9_.
>
> This does not affect which systems you can build and release for, only the interpreter used for these tests.

In a non-trivial case, you will likely have Type conversions and Error handling which you wish to
validate at this point.

## The full example in Rust

The full code then looks like this:

```rust
use pyo3::prelude::*;

/// Add one to an isize
fn o3_addone(num: isize) -> isize {
num + 1
}

/// Rust function for use in Python which adds one to a given int
#[pyfunction]
#[pyo3(name = "addone")]
fn py_addone(num: isize) -> isize {
o3_addone(num)
}

/// A module containing various "adders", written in Rust, for use in Python.
#[pymodule]
#[pyo3(name = "adders")]
fn py_adders(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(py_addone, module)?)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

/// Check that the `o3_addone` function correctly adds one to 1_isize
#[test]
fn test_one_plus_one () {
let result = o3_addone(1_isize);
asserteq!(result, 2_isize)
}

/// Check that the Python function `adders.addone` can be run in Python
#[pyo3test]
#[pyo3import(py_adders: from adders import addone)]
fn test_one_plus_one_wrapped() {
let result: PyResult<isize> = match addone.call1((1_isize,)) {
Ok(r) => r.extract(),
Err(e) => Err(e),
};
let result = result.unwrap();
let expected_result = 2_isize;
assert_eq!(result, expected_result);
}
}
```

## Testing the final integration in Python

Now that you are confident that your functionality is correct and your wrappings work, you can create
your final tests in Python, using either pytest or unittest. In this guide we will use pytest for the
examples.

```python
from adders import addone

def test_one_plus_one ():
assert addone(1) == 2
```

Before you can execute this test, you need to compile and install your rust library.

The easiest way to do this, with both maturin and setuptools-rust is to run `pip install -e .` in the
root of your Python package. This will build and install the package in editable mode.

Note: If you have additional dependencies for development, e.g.: pytest, then you will need to install
these manually, or include them as optional dependencies in `pyproject.toml` e.g.:

```toml
[project.optional-dependencies]
dev = [
"pytest",
]
```

and then run `pip install -e .[dev]`

## Compatibility with older Python versions

Due to limitations in older Python interpreters the `#[pyo3test]` macro can only be used with cPython >= 3.9,
it is also not compatible with PyPy or GraalPy. This is because the macro attempts to (re-)initialise your
wrapped `PyModule` for each test case and this is not handled well in older versions if done in the same
interpreter instance.

Your wrapped code can still be built for, and used in, other versions of Python as per standard Pyo3 compatibility.
You should ensure that any automated CI tasks which run on multiple versions of Python skip these tests where
applicable and only execute the rust unit tests and python-side integration tests.
1 change: 1 addition & 0 deletions newsfragments/4099.added.md
@@ -0,0 +1 @@
Add `#[pyo3test]` to support easier testing of code wrapped by `#[pyfunction]`. See section 2.4 of the Guide: "Testing your code" for more details.
1 change: 1 addition & 0 deletions noxfile.py
Expand Up @@ -45,6 +45,7 @@ def test_rust(session: nox.Session):
_run_cargo_test(session, package="pyo3-build-config")
_run_cargo_test(session, package="pyo3-macros-backend")
_run_cargo_test(session, package="pyo3-macros")
_run_cargo_test(session, package="pyo3-testing")
_run_cargo_test(session, package="pyo3-ffi")

_run_cargo_test(session)
Expand Down
18 changes: 18 additions & 0 deletions pyo3-testing/Cargo.toml
@@ -0,0 +1,18 @@
[package]
name = "pyo3-testing"
version = "0.1.0"
edition = "2021"

[lib]
name = "pyo3_testing"
path = "src/lib.rs"
# crate-type = ["rlib"] # cdylib required for python import, rlib required for rust tests.
proc-macro = true

[dependencies]
quote = "1.0.35"
proc-macro2 = "1.0.74"
syn = {version = "2.0.55", features = ["full"]}

[lints]
workspace = true