Skip to content
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
676 changes: 674 additions & 2 deletions crates/karva/tests/it/extensions/fixtures/request.rs

Large diffs are not rendered by default.

167 changes: 167 additions & 0 deletions crates/karva/tests/it/extensions/tags/custom.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
use insta_cmd::assert_cmd_snapshot;

use crate::common::TestContext;

#[test]
#[ignore = "Will fail unless `maturin build` is ran"]
fn test_custom_tag_basic() {
let context = TestContext::with_file(
"test.py",
r"
import karva

@karva.tags.slow
def test_1():
assert True
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_1 ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
#[ignore = "Will fail unless `maturin build` is ran"]
fn test_custom_tag_with_args() {
let context = TestContext::with_file(
"test.py",
r#"
import karva

@karva.tags.timeout(30, "seconds")
def test_1():
assert True
"#,
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_1 ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
#[ignore = "Will fail unless `maturin build` is ran"]
fn test_custom_tag_with_kwargs() {
let context = TestContext::with_file(
"test.py",
r"
import karva

@karva.tags.flaky(retries=3, delay=1.5)
def test_1():
assert True
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_1 ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
#[ignore = "Will fail unless `maturin build` is ran"]
fn test_custom_tag_with_mixed_args_and_kwargs() {
let context = TestContext::with_file(
"test.py",
r#"
import karva

@karva.tags.marker("value1", 42, key="value2")
def test_1():
assert True
"#,
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_1 ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
#[ignore = "Will fail unless `maturin build` is ran"]
fn test_multiple_custom_tags() {
let context = TestContext::with_file(
"test.py",
r"
import karva

@karva.tags.slow
@karva.tags.integration
@karva.tags.priority(1)
def test_1():
assert True
",
);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_1 ... ok

test result: ok. 1 passed; 0 failed; 0 skipped; finished in [TIME]

----- stderr -----
");
}

#[test]
#[ignore = "Will fail unless `maturin build` is ran"]
fn test_custom_tags_combined_with_builtin_tags() {
let context = TestContext::with_file(
"test.py",
r"
import karva

@karva.tags.slow
@karva.tags.skip
def test_skipped():
assert False

@karva.tags.integration
def test_runs():
assert True
",
);

assert_cmd_snapshot!(context.command_no_parallel(), @r"
success: true
exit_code: 0
----- stdout -----
test test::test_skipped ... skipped
test test::test_runs ... ok

test result: ok. 1 passed; 0 failed; 1 skipped; finished in [TIME]

----- stderr -----
");
}
1 change: 1 addition & 0 deletions crates/karva/tests/it/extensions/tags/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod custom;
pub mod expect_fail;
pub mod parametrize;
pub mod skip;
Expand Down
32 changes: 30 additions & 2 deletions crates/karva_core/src/extensions/fixtures/normalized_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use pyo3::prelude::*;
use pyo3::types::PyDict;
use ruff_python_ast::StmtFunctionDef;

use crate::extensions::fixtures::{FixtureRequest, FixtureScope, RequiresFixtures};
use crate::extensions::fixtures::{
FixtureRequest, FixtureScope, RequiresFixtures,
python::{TestMarkers, TestNode},
};
use crate::extensions::tags::{Parametrization, Tags};

/// Built-in fixture data
Expand Down Expand Up @@ -41,6 +44,10 @@ pub struct UserDefinedFixture {
pub(crate) py_function: Py<PyAny>,
/// The function definition for this fixture
pub(crate) stmt_function_def: Arc<StmtFunctionDef>,
/// Scope-based name for request.node.name:
pub(crate) scope_name: String,
/// Test markers (either Karva tags or Pytest marks)
pub(crate) markers: Option<TestMarkers>,
}

/// A normalized fixture represents a concrete variant of a fixture after parametrization.
Expand Down Expand Up @@ -167,7 +174,20 @@ impl NormalizedFixture {
.and_then(|param| param.values.first())
.cloned();

if let Ok(request_obj) = Py::new(py, FixtureRequest::new(param_value)) {
// Create TestNode with scope name and markers
let markers = user_defined_fixture.markers.clone().unwrap_or_else(|| {
TestMarkers::from_karva(crate::extensions::tags::python::PyTags {
inner: vec![],
})
});
let node = Some(TestNode::new(
user_defined_fixture.scope_name.clone(),
markers,
));

if let Ok(request_obj) =
FixtureRequest::new(py, param_value, node).and_then(|req| Py::new(py, req))
{
kwargs_dict.set_item("request", request_obj).ok();
}
}
Expand All @@ -182,4 +202,12 @@ impl NormalizedFixture {
}
}
}

/// Returns `true` if the normalized fixture is [`UserDefined`].
///
/// [`UserDefined`]: NormalizedFixture::UserDefined
#[must_use]
pub fn is_user_defined(&self) -> bool {
matches!(self, Self::UserDefined(..))
}
}
131 changes: 129 additions & 2 deletions crates/karva_core/src/extensions/fixtures/python.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,122 @@
use pyo3::IntoPyObjectExt;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyTuple};

use crate::extensions::tags::python::PyTags;

/// Represents the source of test markers - either Karva tags or Pytest marks.
///
/// This enum ensures we always know where markers came from and can look them up correctly.
#[derive(Debug, Clone)]
pub enum TestMarkers {
/// Karva-native tags (from `@karva.tags.xxx` decorators)
Karva(PyTags),
/// Pytest marks (from `@pytest.mark.xxx` decorators)
/// We store both the original pytest marks and the converted karva tags
Pytest {
marks: Py<PyAny>,
converted_tags: PyTags,
},
}

impl TestMarkers {
/// Create markers from karva tags
pub fn from_karva(tags: PyTags) -> Self {
Self::Karva(tags)
}

/// Create markers from pytest marks with converted karva tags
pub fn from_pytest(marks: Py<PyAny>, converted_tags: PyTags) -> Self {
Self::Pytest {
marks,
converted_tags,
}
}

/// Get karva tags (either native or converted from pytest)
pub fn karva_tags(&self) -> &PyTags {
match self {
Self::Karva(tags) => tags,
Self::Pytest { converted_tags, .. } => converted_tags,
}
}

/// Get pytest marks if available
pub fn pytest_marks(&self) -> Option<&Py<PyAny>> {
match self {
Self::Karva(_) => None,
Self::Pytest { marks, .. } => Some(marks),
}
}
}

/// Represents a test node that can be accessed via request.node
///
/// This provides access to the test's tags/markers similar to pytest's Node.
#[pyclass]
#[derive(Debug, Clone)]
pub struct TestNode {
/// Name based on fixture scope:
/// - Function scope: test function name (e.g., `"test_login"`)
/// - Module scope: module file name (e.g., `"test_auth"`)
/// - Package scope: package path (e.g., `"tests/unit"`)
/// - Session scope: empty string
#[pyo3(get)]
pub name: String,
/// Test markers (either Karva tags or Pytest marks)
markers: TestMarkers,
}

#[pymethods]
impl TestNode {
/// Get the first (closest) tag/marker with the given name.
///
/// This is similar to pytest's `get_closest_marker`.
/// It checks both Karva tags and pytest markers for compatibility.
/// Returns None if no tag/marker with the given name is found.
pub fn get_closest_tag(&self, py: Python<'_>, name: &str) -> Option<Py<PyAny>> {
// First check pytest marks if available (to preserve original mark objects)
if let Some(pytest_marks) = self.markers.pytest_marks() {
if let Ok(marks_list) = pytest_marks.extract::<Vec<Bound<'_, PyAny>>>(py) {
for mark in marks_list {
if let Ok(mark_name) = mark.getattr("name") {
if let Ok(mark_name_str) = mark_name.extract::<String>() {
if mark_name_str == name {
return Some(mark.unbind());
}
}
}
}
}
}

// Then check Karva tags
for tag in &self.markers.karva_tags().inner {
if tag.name() == name {
if let Ok(py_tag) = tag.clone().into_py_any(py) {
return Some(py_tag);
}
}
}

None
}

/// Alias for `get_closest_tag` for pytest compatibility
pub fn get_closest_marker(&self, py: Python<'_>, name: &str) -> Option<Py<PyAny>> {
self.get_closest_tag(py, name)
}
}

impl TestNode {
pub(crate) fn new(scope_name: String, markers: TestMarkers) -> Self {
Self {
name: scope_name,
markers,
}
}
}

/// Request context object that fixtures can access via the 'request' parameter.
///
/// This provides access to metadata about the current test/fixture context,
Expand All @@ -10,11 +126,22 @@ use pyo3::types::{PyDict, PyTuple};
pub struct FixtureRequest {
#[pyo3(get)]
pub param: Option<Py<PyAny>>,

#[pyo3(get)]
pub node: Option<Py<TestNode>>,
}

impl FixtureRequest {
pub(crate) const fn new(param: Option<Py<PyAny>>) -> Self {
Self { param }
pub(crate) fn new(
py: Python<'_>,
param: Option<Py<PyAny>>,
node: Option<TestNode>,
) -> PyResult<Self> {
let node_py = node.map(|n| Py::new(py, n)).transpose()?;
Ok(Self {
param,
node: node_py,
})
}
}

Expand Down
Loading
Loading