From ab6a0b486599c736992dc7c6231bc23354ae296c Mon Sep 17 00:00:00 2001 From: MatthewMckee4 Date: Sat, 7 Feb 2026 13:03:49 +0000 Subject: [PATCH] Add `karva.raises` context manager for asserting exceptions (#429) Provides a native `pytest.raises`-equivalent so users can assert that code raises a specific exception without depending on pytest. --- crates/karva/tests/it/extensions/functions.rs | 247 ++++++++++++++++++ .../src/extensions/functions/mod.rs | 2 + .../src/extensions/functions/raises.rs | 105 ++++++++ crates/karva_core/src/python.rs | 8 +- python/karva/__init__.py | 6 + python/karva/_karva/__init__.pyi | 40 +++ 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 crates/karva_core/src/extensions/functions/raises.rs diff --git a/crates/karva/tests/it/extensions/functions.rs b/crates/karva/tests/it/extensions/functions.rs index c13f4b92..427ad4c9 100644 --- a/crates/karva/tests/it/extensions/functions.rs +++ b/crates/karva/tests/it/extensions/functions.rs @@ -288,3 +288,250 @@ def test_raise_skip_error(): ----- stderr ----- "); } + +#[test] +fn test_raises_matching_exception() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +def test_raises_value_error(): + with karva.raises(ValueError): + raise ValueError('oops') + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: true + exit_code: 0 + ----- stdout ----- + test test::test_raises_value_error ... ok + + test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} + +#[test] +fn test_raises_no_exception() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +def test_raises_no_exception(): + with karva.raises(ValueError): + pass + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: false + exit_code: 1 + ----- stdout ----- + test test::test_raises_no_exception ... FAILED + + diagnostics: + + error[test-failure]: Test `test_raises_no_exception` failed + --> test.py:4:5 + | + 2 | import karva + 3 | + 4 | def test_raises_no_exception(): + | ^^^^^^^^^^^^^^^^^^^^^^^^ + 5 | with karva.raises(ValueError): + 6 | pass + | + info: Test failed here + --> test.py:5:5 + | + 4 | def test_raises_no_exception(): + 5 | with karva.raises(ValueError): + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 6 | pass + | + info: DID NOT RAISE + + test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} + +#[test] +fn test_raises_with_match() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +def test_raises_match_passes(): + with karva.raises(ValueError, match='oops'): + raise ValueError('oops something happened') + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: true + exit_code: 0 + ----- stdout ----- + test test::test_raises_match_passes ... ok + + test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} + +#[test] +fn test_raises_with_match_fails() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +def test_raises_match_fails(): + with karva.raises(ValueError, match='xyz'): + raise ValueError('oops') + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: false + exit_code: 1 + ----- stdout ----- + test test::test_raises_match_fails ... FAILED + + diagnostics: + + error[test-failure]: Test `test_raises_match_fails` failed + --> test.py:4:5 + | + 2 | import karva + 3 | + 4 | def test_raises_match_fails(): + | ^^^^^^^^^^^^^^^^^^^^^^^ + 5 | with karva.raises(ValueError, match='xyz'): + 6 | raise ValueError('oops') + | + info: Test failed here + --> test.py:5:5 + | + 4 | def test_raises_match_fails(): + 5 | with karva.raises(ValueError, match='xyz'): + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 6 | raise ValueError('oops') + | + info: Raised exception did not match pattern 'xyz' + + test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} + +#[test] +fn test_raises_wrong_exception_type() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +def test_raises_wrong_type(): + with karva.raises(ValueError): + raise TypeError('wrong type') + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: false + exit_code: 1 + ----- stdout ----- + test test::test_raises_wrong_type ... FAILED + + diagnostics: + + error[test-failure]: Test `test_raises_wrong_type` failed + --> test.py:4:5 + | + 2 | import karva + 3 | + 4 | def test_raises_wrong_type(): + | ^^^^^^^^^^^^^^^^^^^^^^ + 5 | with karva.raises(ValueError): + 6 | raise TypeError('wrong type') + | + info: Test failed here + --> test.py:6:9 + | + 4 | def test_raises_wrong_type(): + 5 | with karva.raises(ValueError): + 6 | raise TypeError('wrong type') + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + info: wrong type + + test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} + +#[test] +fn test_raises_exc_info() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +def test_raises_exc_info(): + with karva.raises(ValueError) as exc_info: + raise ValueError('info test') + assert str(exc_info.value) == 'info test' + assert exc_info.type is ValueError + assert exc_info.tb is not None + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: true + exit_code: 0 + ----- stdout ----- + test test::test_raises_exc_info ... ok + + test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} + +#[test] +fn test_raises_subclass() { + let context = TestContext::with_file( + "test.py", + r" +import karva + +class CustomError(ValueError): + pass + +def test_raises_subclass(): + with karva.raises(ValueError): + raise CustomError('subclass') + ", + ); + + assert_cmd_snapshot!(context.command(), @r" + success: true + exit_code: 0 + ----- stdout ----- + test test::test_raises_subclass ... ok + + test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME] + + ----- stderr ----- + "); +} diff --git a/crates/karva_core/src/extensions/functions/mod.rs b/crates/karva_core/src/extensions/functions/mod.rs index d4c6682d..f03e8a6a 100644 --- a/crates/karva_core/src/extensions/functions/mod.rs +++ b/crates/karva_core/src/extensions/functions/mod.rs @@ -1,7 +1,9 @@ +pub use self::raises::{ExceptionInfo, RaisesContext}; use pyo3::prelude::*; pub use python::Param; pub mod python; +pub mod raises; // SkipError exception that can be raised to skip tests at runtime with an optional reason pyo3::create_exception!(karva, SkipError, pyo3::exceptions::PyException); diff --git a/crates/karva_core/src/extensions/functions/raises.rs b/crates/karva_core/src/extensions/functions/raises.rs new file mode 100644 index 00000000..62e9c10b --- /dev/null +++ b/crates/karva_core/src/extensions/functions/raises.rs @@ -0,0 +1,105 @@ +use pyo3::prelude::*; +use pyo3::types::PyType; + +use super::FailError; + +#[pyclass] +pub struct ExceptionInfo { + #[pyo3(get, name = "type")] + pub exc_type: Option>, + + #[pyo3(get)] + pub value: Option>, + + #[pyo3(get)] + pub tb: Option>, +} + +#[pymethods] +impl ExceptionInfo { + #[new] + fn new() -> Self { + Self { + exc_type: None, + value: None, + tb: None, + } + } +} + +#[pyclass] +pub struct RaisesContext { + expected_exception: Py, + match_pattern: Option, + exc_info: Py, +} + +#[pymethods] +impl RaisesContext { + fn __enter__(&self, py: Python<'_>) -> Py { + self.exc_info.clone_ref(py) + } + + fn __exit__( + &self, + py: Python<'_>, + exc_type: Option>, + exc_val: Option>, + exc_tb: Option>, + ) -> PyResult { + let Some(exc_type_obj) = exc_type else { + let repr = self.expected_exception.bind(py).repr()?.to_string(); + return Err(FailError::new_err(format!("DID NOT RAISE {repr}"))); + }; + + let exc_type_bound = exc_type_obj.bind(py); + let expected_bound = self.expected_exception.bind(py); + + let exc_py_type = exc_type_bound.cast::()?; + let expected_py_type = expected_bound.cast::()?; + + if !exc_py_type.is_subclass(expected_py_type)? { + return Ok(false); + } + + if let Some(ref pattern) = self.match_pattern { + let exc_str = if let Some(ref val) = exc_val { + val.bind(py).str()?.to_string() + } else { + String::new() + }; + + let re_module = py.import("re")?; + let result = re_module.call_method1("search", (pattern.as_str(), exc_str.as_str()))?; + + if result.is_none() { + return Err(FailError::new_err(format!( + "Raised exception did not match pattern '{pattern}'" + ))); + } + } + + let mut info = self.exc_info.borrow_mut(py); + info.exc_type = Some(exc_type_obj); + info.value = exc_val; + info.tb = exc_tb; + + Ok(true) + } +} + +/// Assert that a block of code raises a specific exception. +#[pyfunction] +#[pyo3(signature = (expected_exception, *, r#match = None))] +pub fn raises( + py: Python<'_>, + expected_exception: Py, + r#match: Option, +) -> PyResult { + let exc_info = Py::new(py, ExceptionInfo::new())?; + Ok(RaisesContext { + expected_exception, + match_pattern: r#match, + exc_info, + }) +} diff --git a/crates/karva_core/src/python.rs b/crates/karva_core/src/python.rs index 065ce346..9927a211 100644 --- a/crates/karva_core/src/python.rs +++ b/crates/karva_core/src/python.rs @@ -5,7 +5,10 @@ use crate::extensions::fixtures::MockEnv; use crate::extensions::fixtures::python::{ FixtureFunctionDefinition, FixtureFunctionMarker, InvalidFixtureError, fixture_decorator, }; -use crate::extensions::functions::{FailError, SkipError, fail, param, skip}; +use crate::extensions::functions::raises::raises; +use crate::extensions::functions::{ + ExceptionInfo, FailError, RaisesContext, SkipError, fail, param, skip, +}; use crate::extensions::tags::python::{PyTags, PyTestFunction, tags}; pub fn init_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -13,12 +16,15 @@ pub fn init_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(skip, m)?)?; m.add_function(wrap_pyfunction!(fail, m)?)?; m.add_function(wrap_pyfunction!(param, m)?)?; + m.add_function(wrap_pyfunction!(raises, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_wrapped(wrap_pymodule!(tags))?; diff --git a/python/karva/__init__.py b/python/karva/__init__.py index 2446198f..d3c86ebc 100644 --- a/python/karva/__init__.py +++ b/python/karva/__init__.py @@ -1,13 +1,16 @@ """Karva is a Python test runner, written in Rust.""" from karva._karva import ( + ExceptionInfo, FailError, MockEnv, + RaisesContext, SkipError, fail, fixture, karva_run, param, + raises, skip, tags, ) @@ -15,13 +18,16 @@ __version__ = "0.0.1-alpha.2" __all__: list[str] = [ + "ExceptionInfo", "FailError", "MockEnv", + "RaisesContext", "SkipError", "fail", "fixture", "karva_run", "param", + "raises", "skip", "tags", ] diff --git a/python/karva/_karva/__init__.pyi b/python/karva/_karva/__init__.pyi index 4b9af90c..dceada44 100644 --- a/python/karva/_karva/__init__.pyi +++ b/python/karva/_karva/__init__.pyi @@ -1,3 +1,4 @@ +import types from collections.abc import Callable, Sequence from typing import Generic, Literal, NoReturn, Self, TypeAlias, TypeVar, overload @@ -71,6 +72,45 @@ def param( assert input ** 2 == expected """ +class ExceptionInfo: + """Stores information about a caught exception from `karva.raises`.""" + + @property + def type(self) -> type[BaseException] | None: + """The exception type.""" + + @property + def value(self) -> BaseException | None: + """The exception instance.""" + + @property + def tb(self) -> object | None: + """The traceback object.""" + +class RaisesContext: + """Context manager returned by `karva.raises`.""" + + def __enter__(self) -> ExceptionInfo: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> bool: ... + +def raises( + expected_exception: type[BaseException], + *, + match: str | None = None, +) -> RaisesContext: + """Assert that a block of code raises a specific exception. + + Args: + expected_exception: The expected exception type. + match: An optional regex pattern to match against the string + representation of the exception. + """ + class SkipError(Exception): """Raised when `karva.skip` is called."""