From 3b94f4b70c335854020cf877b24253dbc43758fa Mon Sep 17 00:00:00 2001 From: Chris Laplante <40474653+chris-laplante@users.noreply.github.com> Date: Sun, 17 Oct 2021 02:54:29 -0400 Subject: [PATCH] Add `anyhow-integration` feature which implements From for PyErr (#1822) * Add 'anyhow' feature which provides simple From for PyErr impl This makes it possible to use anyhow::Result as the return type for methods and functions exposed to Python. The current implementation just stringifies the anyhow::Error before shoving it into a PyRuntimeError. Conversion back to the anyhow::Error is not possible, but it is better than nothing. Signed-off-by: Chris Laplante * Document `anyhow` feature in the guide Signed-off-by: Chris Laplante * update changelog to document anyhow feature * WIP adding tests * Finish up anyhow feature * Fix formatting * Fix tests * Fix tests * Apply review suggestions Co-authored-by: Bruno Kolenbrander <59372212+mejrs@users.noreply.github.com> Co-authored-by: mejrs --- .github/workflows/ci.yml | 8 +- .github/workflows/guide.yml | 2 +- CHANGELOG.md | 1 + Cargo.toml | 1 + guide/src/features.md | 6 ++ src/conversions/anyhow.rs | 167 ++++++++++++++++++++++++++++++++++++ src/conversions/mod.rs | 1 + src/lib.rs | 18 ++-- tests/test_anyhow.rs | 48 +++++++++++ 9 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 src/conversions/anyhow.rs create mode 100644 tests/test_anyhow.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68aacda7..359200ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,8 @@ jobs: # TODO suppress linking using config file rather than extension-module feature PYO3_BUILD_CONFIG=$(pwd)/config.txt cargo check --all-targets --features "extension-module" PYO3_BUILD_CONFIG=$(pwd)/config.txt cargo check --all-targets --features "extension-module abi3" - PYO3_BUILD_CONFIG=$(pwd)/config.txt cargo check --all-targets --features "extension-module macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre" - PYO3_BUILD_CONFIG=$(pwd)/config.txt cargo check --all-targets --features "extension-module abi3 macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre" + PYO3_BUILD_CONFIG=$(pwd)/config.txt cargo check --all-targets --features "extension-module macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre anyhow" + PYO3_BUILD_CONFIG=$(pwd)/config.txt cargo check --all-targets --features "extension-module abi3 macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre anyhow" done build: @@ -175,7 +175,7 @@ jobs: id: settings shell: bash run: | - echo "::set-output name=all_additive_features::macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre" + echo "::set-output name=all_additive_features::macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre anyhow" - if: matrix.msrv == 'MSRV' name: Prepare minimal package versions (MSRV only) @@ -291,7 +291,7 @@ jobs: cargo llvm-cov clean --workspace cargo llvm-cov --package $ALL_PACKAGES --no-report cargo llvm-cov --package $ALL_PACKAGES --no-report --features abi3 - cargo llvm-cov --package $ALL_PACKAGES --no-report --features macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods + cargo llvm-cov --package $ALL_PACKAGES --no-report --features macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre anyhow cargo llvm-cov --package $ALL_PACKAGES --no-run --lcov --output-path coverage.lcov env: ALL_PACKAGES: pyo3 pyo3-build-config pyo3-macros-backend pyo3-macros diff --git a/.github/workflows/guide.yml b/.github/workflows/guide.yml index 980420fe..923fedd1 100644 --- a/.github/workflows/guide.yml +++ b/.github/workflows/guide.yml @@ -44,7 +44,7 @@ jobs: # This adds the docs to gh-pages-build/doc - name: Build the doc run: | - cargo +nightly rustdoc --lib --no-default-features --features="macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre" -- --cfg docsrs + cargo +nightly rustdoc --lib --no-default-features --features="macros num-bigint num-complex hashbrown indexmap serde multiple-pymethods eyre anyhow" -- --cfg docsrs cp -r target/doc gh-pages-build/doc echo "" > gh-pages-build/doc/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b9878936..c74c2a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support Python 3.10. [#1889](https://github.com/PyO3/pyo3/pull/1889) - Added optional `eyre` feature to convert `eyre::Report` into `PyErr`. [#1893](https://github.com/PyO3/pyo3/pull/1893) +- Added optional `anyhow` feature to convert `anyhow::Error` into `PyErr`. [#1822](https://github.com/PyO3/pyo3/pull/1822) ### Added diff --git a/Cargo.toml b/Cargo.toml index 30acb5e6..a8f4b198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ unindent = { version = "0.1.4", optional = true } hashbrown = { version = ">= 0.9, < 0.12", optional = true } indexmap = { version = ">= 1.6, < 1.8", optional = true } serde = {version = "1.0", optional = true} +anyhow = { version = "1.0", optional = true } [dev-dependencies] assert_approx_eq = "1.1.0" diff --git a/guide/src/features.md b/guide/src/features.md index 393b7088..86447c9d 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -83,6 +83,10 @@ metadata about a Python interpreter. These features enable conversions between Python types and types from other Rust crates, enabling easy access to the rest of the Rust ecosystem. +### `anyhow` + +Adds a dependency on [anyhow](https://docs.rs/anyhow). Enables a conversion from [anyhow](https://docs.rs/anyhow)’s [`Error`]https://docs.rs/anyhow/latest/anyhow/struct.Error.html) type to [`PyErr`](https://docs.rs/pyo3/latest/pyo3/struct.PyErr.html), for easy error handling. + ### `eyre` Adds a dependency on [eyre](https://docs.rs/eyre). Enables a conversion from [eyre](https://docs.rs/eyre)’s [`Report`](https://docs.rs/eyre/latest/eyre/struct.Report.html) type to [`PyErr`](https://docs.rs/pyo3/latest/pyo3/struct.PyErr.html), for easy error handling. @@ -123,3 +127,5 @@ struct User { permissions: Vec> } ``` + + diff --git a/src/conversions/anyhow.rs b/src/conversions/anyhow.rs new file mode 100644 index 00000000..7dd733d7 --- /dev/null +++ b/src/conversions/anyhow.rs @@ -0,0 +1,167 @@ +#![cfg(feature = "anyhow")] + +//! A conversion from [anyhow]’s [`Error`][anyhow_error] type to [`PyErr`]. +//! +//! Use of an error handling library like [anyhow] is common in application code and when you just +//! want error handling to be easy. If you are writing a library or you need more control over your +//! errors you might want to design your own error type instead. +//! +//! This implementation always creates a Python [`RuntimeError`]. You might find that you need to +//! map the error from your Rust code into another Python exception. See [`PyErr::new`] for more +//! information about that. +//! +//! For information about error handling in general, see the [Error handling] chapter of the Rust +//! book. +//! +//! # Setup +//! +//! To use this feature, add this to your **`Cargo.toml`**: +//! +//! ```toml +//! [dependencies] +//! ## change * to the version you want to use, ideally the latest. +//! anyhow = "*" +// workaround for `extended_key_value_attributes`: https://github.com/rust-lang/rust/issues/82768#issuecomment-803935643 +#![cfg_attr(docsrs, cfg_attr(docsrs, doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"anyhow\"] }")))] +#![cfg_attr( + not(docsrs), + doc = "pyo3 = { version = \"*\", features = [\"anyhow\"] }" +)] +//! ``` +//! +//! Note that you must use compatible versions of anyhow and PyO3. +//! The required anyhow version may vary based on the version of PyO3. +//! +//! # Example: Propagating a `PyErr` into [`anyhow::Error`] +//! +//! ```rust +//! use pyo3::prelude::*; +//! use pyo3::wrap_pyfunction; +//! use std::path::PathBuf; +//! +//! // A wrapper around a Rust function. +//! // The pyfunction macro performs the conversion to a PyErr +//! #[pyfunction] +//! fn py_open(filename: PathBuf) -> anyhow::Result> { +//! let data = std::fs::read(filename)?; +//! Ok(data) +//! } +//! +//! fn main() { +//! let error = Python::with_gil(|py| -> PyResult> { +//! let fun = wrap_pyfunction!(py_open, py)?; +//! let text = fun.call1(("foo.txt",))?.extract::>()?; +//! Ok(text) +//! }).unwrap_err(); +//! +//! println!("{}", error); +//! } +//! ``` +//! +//! # Example: Using `anyhow` in general +//! +//! Note that you don't need this feature to convert a [`PyErr`] into an [`anyhow::Error`], because +//! it can already convert anything that implements [`Error`](std::error::Error): +//! +//! ```rust +//! use pyo3::prelude::*; +//! use pyo3::types::PyBytes; +//! +//! // An example function that must handle multiple error types. +//! // +//! // To do this you usually need to design your own error type or use +//! // `Box`. `anyhow` is a convenient alternative for this. +//! pub fn decompress(bytes: &[u8]) -> anyhow::Result { +//! // An arbitrary example of a Python api you +//! // could call inside an application... +//! // This might return a `PyErr`. +//! let res = Python::with_gil(|py| { +//! let zlib = PyModule::import(py, "zlib")?; +//! let decompress = zlib.getattr("decompress")?; +//! let bytes = PyBytes::new(py, bytes); +//! let value = decompress.call1((bytes,))?; +//! value.extract::>() +//! })?; +//! +//! // This might be a `FromUtf8Error`. +//! let text = String::from_utf8(res)?; +//! +//! Ok(text) +//! } +//! +//! fn main() -> anyhow::Result<()> { +//! let bytes: &[u8] = b"x\x9c\x8b\xcc/U(\xce\xc8/\xcdIQ((\xcaOJL\xca\xa9T\ +//! (-NU(\xc9HU\xc8\xc9LJ\xcbI,IUH.\x02\x91\x99y\xc5%\ +//! \xa9\x89)z\x00\xf2\x15\x12\xfe"; +//! let text = decompress(bytes)?; +//! +//! println!("The text is \"{}\"", text); +//! # assert_eq!(text, "You should probably use the libflate crate instead."); +//! Ok(()) +//! } +//! ``` +//! +//! [anyhow]: https://docs.rs/anyhow/ "A trait object based error system for easy idiomatic error handling in Rust applications." +//! [anyhow_error]: https://docs.rs/anyhow/latest/anyhow/struct.Error.html "Anyhows `Error` type, a wrapper around a dynamic error type" +//! [`RuntimeError`]: https://docs.python.org/3/library/exceptions.html#RuntimeError "Built-in Exceptions — Python documentation" +//! [Error handling]: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html "Recoverable Errors with Result - The Rust Programming Language" + +use crate::exceptions::PyRuntimeError; +use crate::PyErr; + +impl From for PyErr { + fn from(err: anyhow::Error) -> Self { + PyRuntimeError::new_err(format!("{:?}", err)) + } +} + +#[cfg(test)] +mod test_anyhow { + use pyo3::prelude::*; + use pyo3::types::IntoPyDict; + + use anyhow::{anyhow, bail, Context, Result}; + + fn f() -> Result<()> { + use std::io; + bail!(io::Error::new(io::ErrorKind::PermissionDenied, "oh no!")); + } + + fn g() -> Result<()> { + f().context("f failed") + } + + fn h() -> Result<()> { + g().context("g failed") + } + + #[test] + fn test_pyo3_exception_contents() { + let err = h().unwrap_err(); + let expected_contents = format!("{:?}", err); + let pyerr = PyErr::from(err); + + Python::with_gil(|py| { + let locals = [("err", pyerr)].into_py_dict(py); + let pyerr = py.run("raise err", None, Some(locals)).unwrap_err(); + assert_eq!(pyerr.pvalue(py).to_string(), expected_contents); + }) + } + + fn k() -> Result<()> { + Err(anyhow!("Some sort of error")) + } + + #[test] + fn test_pyo3_exception_contents2() { + let err = k().unwrap_err(); + let expected_contents = format!("{:?}", err); + let pyerr = PyErr::from(err); + + Python::with_gil(|py| { + let locals = [("err", pyerr)].into_py_dict(py); + let pyerr = py.run("raise err", None, Some(locals)).unwrap_err(); + assert_eq!(pyerr.pvalue(py).to_string(), expected_contents); + }) + } +} diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index 3ae8fb9c..b82f123e 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -1,5 +1,6 @@ //! This module contains conversions between various Rust object and their representation in Python. +pub mod anyhow; mod array; pub mod eyre; pub mod hashbrown; diff --git a/src/lib.rs b/src/lib.rs index 722f3fff..83a3ebbb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,10 +79,11 @@ //! crate, which is not supported on all platforms. //! //! The following features enable interactions with other crates in the Rust ecosystem: +//! - [`anyhow`]: Enables a conversion from [anyhow]’s [`Error`][anyhow_error] type to [`PyErr`]. //! - [`eyre`]: Enables a conversion from [eyre]’s [`Report`] type to [`PyErr`]. //! - [`hashbrown`]: Enables conversions between Python objects and [hashbrown]'s [`HashMap`] and //! [`HashSet`] types. -//! - [`indexmap`]: Enables conversions between Python dictionary and [indexmap]'s [`IndexMap`]. +//! - [`indexmap`][indexmap_feature]: Enables conversions between Python dictionary and [indexmap]'s [`IndexMap`]. //! - [`num-bigint`]: Enables conversions between Python objects and [num-bigint]'s [`BigInt`] and //! [`BigUint`] types. //! - [`num-complex`]: Enables conversions between Python objects and [num-complex]'s [`Complex`] @@ -242,6 +243,9 @@ //! There are many projects using PyO3 - see a list of some at //! . //! +//! [anyhow]: https://docs.rs/anyhow/ "A trait object based error system for easy idiomatic error handling in Rust applications." +//! [anyhow_error]: https://docs.rs/anyhow/latest/anyhow/struct.Error.html "Anyhows `Error` type, a wrapper around a dynamic error type" +//! [`anyhow`]: ./anyhow/index.html "Documentation about the `anyhow` feature." //! [inventory]: https://docs.rs/inventory //! [`HashMap`]: https://docs.rs/hashbrown/latest/hashbrown/struct.HashMap.html //! [`HashSet`]: https://docs.rs/hashbrown/latest/hashbrown/struct.HashSet.html @@ -253,14 +257,14 @@ //! [`Serialize`]: https://docs.rs/serde/latest/serde/trait.Serialize.html //! [eyre]: https://docs.rs/eyre/ "A library for easy idiomatic error handling and reporting in Rust applications." //! [`Report`]: https://docs.rs/eyre/latest/eyre/struct.Report.html -//! [`eyre`]: ./eyre/index.html -//! [`hashbrown`]: ./hashbrown/index.html -//! [`indexmap`]: <./indexmap/index.html> +//! [`eyre`]: ./eyre/index.html "Documentation about the `eyre` feature." +//! [`hashbrown`]: ./hashbrown/index.html "Documentation about the `hashbrown` feature." +//! [indexmap_feature]: ./indexmap/index.html "Documentation about the `indexmap` feature." //! [`maturin`]: https://github.com/PyO3/maturin "Build and publish crates with pyo3, rust-cpython and cffi bindings as well as rust binaries as python packages" -//! [`num-bigint`]: ./num_bigint/index.html -//! [`num-complex`]: ./num_complex/index.html +//! [`num-bigint`]: ./num_bigint/index.html "Documentation about the `num-bigint` feature." +//! [`num-complex`]: ./num_complex/index.html "Documentation about the `num-complex` feature." //! [`pyo3-build-config`]: https://docs.rs/pyo3-build-config -//! [`serde`]: <./serde/index.html> +//! [`serde`]: <./serde/index.html> "Documentation about the `serde` feature." //! [calling_rust]: https://pyo3.rs/latest/python_from_rust.html "Calling Python from Rust - PyO3 user guide" //! [examples subdirectory]: https://github.com/PyO3/pyo3/tree/main/examples //! [feature flags]: https://doc.rust-lang.org/cargo/reference/features.html "Features - The Cargo Book" diff --git a/tests/test_anyhow.rs b/tests/test_anyhow.rs new file mode 100644 index 00000000..33768907 --- /dev/null +++ b/tests/test_anyhow.rs @@ -0,0 +1,48 @@ +#![cfg(feature = "anyhow")] + +#[test] +fn test_anyhow_py_function_ok_result() { + use pyo3::{py_run, pyfunction, wrap_pyfunction, Python}; + + #[pyfunction] + fn produce_ok_result() -> anyhow::Result { + Ok(String::from("OK buddy")) + } + + Python::with_gil(|py| { + let func = wrap_pyfunction!(produce_ok_result)(py).unwrap(); + + py_run!( + py, + func, + r#" + func() + "# + ); + }); +} + +#[test] +fn test_anyhow_py_function_err_result() { + use pyo3::{pyfunction, types::PyDict, wrap_pyfunction, Python}; + + #[pyfunction] + fn produce_err_result() -> anyhow::Result { + anyhow::bail!("error time") + } + + Python::with_gil(|py| { + let func = wrap_pyfunction!(produce_err_result)(py).unwrap(); + let locals = PyDict::new(py); + locals.set_item("func", func).unwrap(); + + py.run( + r#" + func() + "#, + None, + Some(locals), + ) + .unwrap_err(); + }); +}