From c8f82be32ce0ad7755a9c9d85ce3f14290a6854e Mon Sep 17 00:00:00 2001 From: David Hewitt <1939362+davidhewitt@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:14:18 +0100 Subject: [PATCH] add assert_warnings test helper --- src/err/mod.rs | 25 +- src/lib.rs | 11 +- src/{test_hygiene => tests/hygiene}/misc.rs | 0 src/{test_hygiene => tests/hygiene}/mod.rs | 0 .../hygiene}/pyclass.rs | 0 .../hygiene}/pyfunction.rs | 0 .../hygiene}/pymethods.rs | 0 .../hygiene}/pymodule.rs | 0 src/tests/mod.rs | 10 + tests/common.rs | 213 +++++++++++------- 10 files changed, 166 insertions(+), 93 deletions(-) rename src/{test_hygiene => tests/hygiene}/misc.rs (100%) rename src/{test_hygiene => tests/hygiene}/mod.rs (100%) rename src/{test_hygiene => tests/hygiene}/pyclass.rs (100%) rename src/{test_hygiene => tests/hygiene}/pyfunction.rs (100%) rename src/{test_hygiene => tests/hygiene}/pymethods.rs (100%) rename src/{test_hygiene => tests/hygiene}/pymodule.rs (100%) create mode 100644 src/tests/mod.rs diff --git a/src/err/mod.rs b/src/err/mod.rs index 2b6e0140..f853dd82 100644 --- a/src/err/mod.rs +++ b/src/err/mod.rs @@ -822,6 +822,7 @@ impl_signed_integer!(isize); mod tests { use super::PyErrState; use crate::exceptions::{self, PyTypeError, PyValueError}; + use crate::tests::common::CatchWarnings; use crate::{PyErr, PyTypeInfo, Python}; #[test] @@ -1019,11 +1020,12 @@ mod tests { let warnings = py.import("warnings").unwrap(); warnings.call_method0("resetwarnings").unwrap(); - // First, test with ignoring the warning - warnings - .call_method1("simplefilter", ("ignore", cls)) - .unwrap(); - PyErr::warn(py, cls, "I am warning you", 0).unwrap(); + // First, test the warning is emitted + assert_warnings!( + py, + { PyErr::warn(py, cls, "I am warning you", 0).unwrap() }, + [(exceptions::PyUserWarning, "I am warning you")] + ); // Test with raising warnings @@ -1031,17 +1033,18 @@ mod tests { .unwrap(); PyErr::warn(py, cls, "I am warning you", 0).unwrap_err(); - // Test with explicit module and specific filter + // Test with error for an explicit module warnings.call_method0("resetwarnings").unwrap(); - warnings - .call_method1("simplefilter", ("ignore", cls)) - .unwrap(); warnings .call_method1("filterwarnings", ("error", "", cls, "pyo3test")) .unwrap(); - // This has the wrong module and will not raise - PyErr::warn(py, cls, "I am warning you", 0).unwrap(); + // This has the wrong module and will not raise, just be emitted + assert_warnings!( + py, + { PyErr::warn(py, cls, "I am warning you", 0).unwrap() }, + [(exceptions::PyUserWarning, "I am warning you")] + ); let err = PyErr::warn_explicit(py, cls, "I am warning you", "pyo3test.py", 427, None, None) diff --git a/src/lib.rs b/src/lib.rs index e8d81336..e39caaba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -386,6 +386,12 @@ pub use { #[doc(hidden)] pub use inventory; // Re-exported for `#[pyclass]` and `#[pymethods]` with `multiple-pymethods`. +/// Tests and helpers which reside inside PyO3's main library. Declared first so that macros +/// are available in unit tests. +#[cfg(test)] +#[macro_use] +mod tests; + #[macro_use] mod internal_tricks; @@ -447,11 +453,6 @@ pub use pyo3_macros::pyclass; #[macro_use] mod macros; -/// Test macro hygiene - this is in the crate since we won't have -/// `pyo3` available in the crate root. -#[cfg(all(test, feature = "macros"))] -mod test_hygiene; - #[cfg(feature = "experimental-inspect")] pub mod inspect; diff --git a/src/test_hygiene/misc.rs b/src/tests/hygiene/misc.rs similarity index 100% rename from src/test_hygiene/misc.rs rename to src/tests/hygiene/misc.rs diff --git a/src/test_hygiene/mod.rs b/src/tests/hygiene/mod.rs similarity index 100% rename from src/test_hygiene/mod.rs rename to src/tests/hygiene/mod.rs diff --git a/src/test_hygiene/pyclass.rs b/src/tests/hygiene/pyclass.rs similarity index 100% rename from src/test_hygiene/pyclass.rs rename to src/tests/hygiene/pyclass.rs diff --git a/src/test_hygiene/pyfunction.rs b/src/tests/hygiene/pyfunction.rs similarity index 100% rename from src/test_hygiene/pyfunction.rs rename to src/tests/hygiene/pyfunction.rs diff --git a/src/test_hygiene/pymethods.rs b/src/tests/hygiene/pymethods.rs similarity index 100% rename from src/test_hygiene/pymethods.rs rename to src/tests/hygiene/pymethods.rs diff --git a/src/test_hygiene/pymodule.rs b/src/tests/hygiene/pymodule.rs similarity index 100% rename from src/test_hygiene/pymodule.rs rename to src/tests/hygiene/pymodule.rs diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 00000000..f2daa4dc --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,10 @@ +#[macro_use] +pub(crate) mod common { + use crate as pyo3; + include!("../../tests/common.rs"); +} + +/// Test macro hygiene - this is in the crate since we won't have +/// `pyo3` available in the crate root. +#[cfg(all(test, feature = "macros"))] +mod hygiene; diff --git a/tests/common.rs b/tests/common.rs index 1003d546..e74b09a7 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,93 +1,152 @@ -//! Some common macros for tests +// the inner mod enables the #![allow(dead_code)] to +// be applied - `test_utils.rs` uses `include!` to pull in this file -#[cfg(all(feature = "macros", Py_3_8))] -use pyo3::prelude::*; +/// Common macros and helpers for tests +#[allow(dead_code)] // many tests do not use the complete set of functionality offered here +#[macro_use] +mod inner { -#[macro_export] -macro_rules! py_assert { - ($py:expr, $($val:ident)+, $assertion:literal) => { - pyo3::py_run!($py, $($val)+, concat!("assert ", $assertion)) - }; - ($py:expr, *$dict:expr, $assertion:literal) => { - pyo3::py_run!($py, *$dict, concat!("assert ", $assertion)) - }; -} + #[allow(unused_imports)] // pulls in `use crate as pyo3` in `test_utils.rs` + use super::*; -#[macro_export] -macro_rules! py_expect_exception { - // Case1: idents & no err_msg - ($py:expr, $($val:ident)+, $code:expr, $err:ident) => {{ - use pyo3::types::IntoPyDict; - let d = [$((stringify!($val), $val.to_object($py)),)+].into_py_dict($py); - py_expect_exception!($py, *d, $code, $err) - }}; - // Case2: dict & no err_msg - ($py:expr, *$dict:expr, $code:expr, $err:ident) => {{ - let res = $py.run($code, None, Some($dict)); - let err = res.expect_err(&format!("Did not raise {}", stringify!($err))); - if !err.matches($py, $py.get_type::()) { - panic!("Expected {} but got {:?}", stringify!($err), err) - } - err - }}; - // Case3: idents & err_msg - ($py:expr, $($val:ident)+, $code:expr, $err:ident, $err_msg:literal) => {{ - let err = py_expect_exception!($py, $($val)+, $code, $err); - // Suppose that the error message looks like 'TypeError: ~' - assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); - err - }}; - // Case4: dict & err_msg - ($py:expr, *$dict:expr, $code:expr, $err:ident, $err_msg:literal) => {{ - let err = py_expect_exception!($py, *$dict, $code, $err); - assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); - err - }}; -} + use pyo3::prelude::*; -// sys.unraisablehook not available until Python 3.8 -#[cfg(all(feature = "macros", Py_3_8))] -#[pyclass] -pub struct UnraisableCapture { - pub capture: Option<(PyErr, PyObject)>, - old_hook: Option, -} + use pyo3::types::{IntoPyDict, PyList}; -#[cfg(all(feature = "macros", Py_3_8))] -#[pymethods] -impl UnraisableCapture { - pub fn hook(&mut self, unraisable: &PyAny) { - let err = PyErr::from_value(unraisable.getattr("exc_value").unwrap()); - let instance = unraisable.getattr("object").unwrap(); - self.capture = Some((err, instance.into())); + #[macro_export] + macro_rules! py_assert { + ($py:expr, $($val:ident)+, $assertion:literal) => { + pyo3::py_run!($py, $($val)+, concat!("assert ", $assertion)) + }; + ($py:expr, *$dict:expr, $assertion:literal) => { + pyo3::py_run!($py, *$dict, concat!("assert ", $assertion)) + }; } -} -#[cfg(all(feature = "macros", Py_3_8))] -impl UnraisableCapture { - pub fn install(py: Python<'_>) -> Py { - let sys = py.import("sys").unwrap(); - let old_hook = sys.getattr("unraisablehook").unwrap().into(); + #[macro_export] + macro_rules! py_expect_exception { + // Case1: idents & no err_msg + ($py:expr, $($val:ident)+, $code:expr, $err:ident) => {{ + use pyo3::types::IntoPyDict; + let d = [$((stringify!($val), $val.to_object($py)),)+].into_py_dict($py); + py_expect_exception!($py, *d, $code, $err) + }}; + // Case2: dict & no err_msg + ($py:expr, *$dict:expr, $code:expr, $err:ident) => {{ + let res = $py.run($code, None, Some($dict)); + let err = res.expect_err(&format!("Did not raise {}", stringify!($err))); + if !err.matches($py, $py.get_type::()) { + panic!("Expected {} but got {:?}", stringify!($err), err) + } + err + }}; + // Case3: idents & err_msg + ($py:expr, $($val:ident)+, $code:expr, $err:ident, $err_msg:literal) => {{ + let err = py_expect_exception!($py, $($val)+, $code, $err); + // Suppose that the error message looks like 'TypeError: ~' + assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); + err + }}; + // Case4: dict & err_msg + ($py:expr, *$dict:expr, $code:expr, $err:ident, $err_msg:literal) => {{ + let err = py_expect_exception!($py, *$dict, $code, $err); + assert_eq!(format!("Py{}", err), concat!(stringify!($err), ": ", $err_msg)); + err + }}; + } - let capture = Py::new( - py, - UnraisableCapture { - capture: None, - old_hook: Some(old_hook), - }, - ) - .unwrap(); + // sys.unraisablehook not available until Python 3.8 + #[cfg(all(feature = "macros", Py_3_8))] + #[pyclass(crate = "pyo3")] + pub struct UnraisableCapture { + pub capture: Option<(PyErr, PyObject)>, + old_hook: Option, + } - sys.setattr("unraisablehook", capture.getattr(py, "hook").unwrap()) + #[cfg(all(feature = "macros", Py_3_8))] + #[pymethods(crate = "pyo3")] + impl UnraisableCapture { + pub fn hook(&mut self, unraisable: &PyAny) { + let err = PyErr::from_value(unraisable.getattr("exc_value").unwrap()); + let instance = unraisable.getattr("object").unwrap(); + self.capture = Some((err, instance.into())); + } + } + + #[cfg(all(feature = "macros", Py_3_8))] + impl UnraisableCapture { + pub fn install(py: Python<'_>) -> Py { + let sys = py.import("sys").unwrap(); + let old_hook = sys.getattr("unraisablehook").unwrap().into(); + + let capture = Py::new( + py, + UnraisableCapture { + capture: None, + old_hook: Some(old_hook), + }, + ) .unwrap(); - capture + sys.setattr("unraisablehook", capture.getattr(py, "hook").unwrap()) + .unwrap(); + + capture + } + + pub fn uninstall(&mut self, py: Python<'_>) { + let old_hook = self.old_hook.take().unwrap(); + + let sys = py.import("sys").unwrap(); + sys.setattr("unraisablehook", old_hook).unwrap(); + } } - pub fn uninstall(&mut self, py: Python<'_>) { - let old_hook = self.old_hook.take().unwrap(); + pub struct CatchWarnings<'py> { + catch_warnings: &'py PyAny, + } - let sys = py.import("sys").unwrap(); - sys.setattr("unraisablehook", old_hook).unwrap(); + impl<'py> CatchWarnings<'py> { + pub fn enter(py: Python<'py>, f: impl FnOnce(&PyList) -> PyResult) -> PyResult { + let warnings = py.import("warnings")?; + let kwargs = [("record", true)].into_py_dict(py); + let catch_warnings = warnings.getattr("catch_warnings")?.call((), Some(kwargs))?; + let list = catch_warnings.call_method0("__enter__")?.extract()?; + let _guard = Self { catch_warnings }; + f(list) + } + } + + impl Drop for CatchWarnings<'_> { + fn drop(&mut self) { + let py = self.catch_warnings.py(); + self.catch_warnings + .call_method1("__exit__", (py.None(), py.None(), py.None())) + .unwrap(); + } + } + + #[macro_export] + macro_rules! assert_warnings { + ($py:expr, $body:expr, [$(($category:ty, $message:literal)),+] $(,)? ) => {{ + CatchWarnings::enter($py, |w| { + $body; + let expected_warnings = [$((<$category>::type_object($py), $message)),+]; + assert_eq!(w.len(), expected_warnings.len()); + for (warning, (category, message)) in w.iter().zip(expected_warnings) { + + assert!(warning.getattr("category").unwrap().is(category)); + assert_eq!( + warning.getattr("message").unwrap().str().unwrap().to_string_lossy(), + message + ); + } + + Ok(()) + }) + .unwrap(); + }}; } } + +pub use inner::*;