1use crate::error::Result;
7use crate::models::{NativeTestNode, ParameterizedTestNode};
8use rayon::prelude::*;
9use rustpython_ast::{self as ast, ExprAttribute, ExprName};
10use rustpython_parser::Parse;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14use tracing::{debug, warn};
15
16type ParamValueInfo = (Vec<String>, Option<String>, Vec<String>);
19type ParamCombination = (Vec<String>, Vec<String>, Option<String>, Vec<String>);
21
22#[allow(dead_code)]
24#[derive(Debug, Clone)]
25struct ParamValue {
26 values: Vec<String>, id: Option<String>, marks: Vec<String>, }
30
31#[derive(Debug, Default, Clone)]
33struct SkipXfailInfo {
34 skip: bool,
35 skip_reason: Option<String>,
36 skipif: bool,
37 skipif_condition: Option<String>,
38 xfail: bool,
39 xfail_reason: Option<String>,
40 xfail_condition: Option<String>,
41 xfail_strict: bool,
42 xfail_raises: Option<String>,
43}
44
45struct TestProcessingContext<'a> {
47 file_path: &'a str,
48 class_name: &'a str,
49 class_markers: &'a [String],
50 source: &'a str,
51}
52
53struct TestCreationContext<'a> {
55 fn_name: &'a str,
56 line_number: u32,
57 markers: &'a [String],
58 uses_external_fixtures: bool,
59 skip_xfail: SkipXfailInfo,
60}
61
62const SKIP_DECORATORS: &[&str] = &["staticmethod", "classmethod", "property", "abstractmethod"];
64
65#[derive(Debug, Clone, PartialEq)]
67enum ValueCategory {
68 Dict,
69 List,
70 Simple,
71}
72
73#[derive(Debug, Clone)]
75pub struct NativeCollector {
76 repo_path: PathBuf,
78 builtin_fixtures: Vec<&'static str>,
80 tests: Arc<Mutex<Vec<NativeTestNode>>>,
82 fixture_params: Arc<Mutex<HashMap<String, Vec<String>>>>,
84}
85
86impl Default for NativeCollector {
87 fn default() -> Self {
88 NativeCollector {
89 repo_path: PathBuf::from("."),
90 builtin_fixtures: vec![
91 "capsys",
92 "capfd",
93 "capsysbinary",
94 "capfdbinary",
95 "caplog",
96 "tmp_path",
97 "tmp_path_factory",
98 "tmpdir",
99 "tmpdir_factory",
100 "request",
101 "pytestconfig",
102 "cache",
103 "recwarn",
104 "monkeypatch",
105 "doctest_namespace",
106 ],
107 tests: Arc::new(Mutex::new(Vec::new())),
108 fixture_params: Arc::new(Mutex::new(HashMap::new())),
109 }
110 }
111}
112
113impl NativeCollector {
114 pub fn new(repo_path: &Path) -> Self {
116 NativeCollector {
117 repo_path: repo_path.to_path_buf(),
118 builtin_fixtures: vec![
119 "capsys",
120 "capfd",
121 "capsysbinary",
122 "capfdbinary",
123 "caplog",
124 "tmp_path",
125 "tmp_path_factory",
126 "tmpdir",
127 "tmpdir_factory",
128 "request",
129 "pytestconfig",
130 "cache",
131 "recwarn",
132 "monkeypatch",
133 "doctest_namespace",
134 ],
135 tests: Arc::new(Mutex::new(Vec::new())),
136 fixture_params: Arc::new(Mutex::new(HashMap::new())),
137 }
138 }
139
140 pub fn collect(&self) -> Result<Vec<NativeTestNode>> {
142 self.fixture_params.lock().unwrap().clear();
143
144 let test_files = self.find_test_files()?;
146
147 let all_tests: Vec<NativeTestNode> = test_files
150 .par_iter()
151 .filter_map(|file| self.parse_test_file(file).ok())
152 .flatten()
153 .collect();
154
155 {
157 let mut tests = self.tests.lock().unwrap();
158 tests.clear();
159 tests.extend(all_tests);
160 }
161
162 let expanded_tests = self.expand_tests_by_fixture_params();
164
165 debug!("Collected {} native tests", expanded_tests.len());
166 Ok(expanded_tests)
167 }
168
169 fn expand_tests_by_fixture_params(&self) -> Vec<NativeTestNode> {
172 let tests = self.tests.lock().unwrap().clone();
173 let fixture_params = self.fixture_params.lock().unwrap().clone();
174
175 if fixture_params.is_empty() {
176 return tests;
177 }
178
179 let mut expanded = Vec::new();
180 for test in tests {
181 let fixture_params_for_test = self.get_fixture_params_for_test(&test, &fixture_params);
183
184 if fixture_params_for_test.is_empty() {
185 expanded.push(test);
187 } else if fixture_params_for_test.len() == 1 {
188 let (fixture_name, param_values) = &fixture_params_for_test[0];
190 for param_value in param_values.iter() {
191 let mut variant = test.clone();
192 variant.node_id = format!("{}[{}]", test.node_id, param_value);
194 variant
196 .markers
197 .push(format!("fixture_param:{}={}", fixture_name, param_value));
198 expanded.push(variant);
199 }
200 } else {
201 let combinations =
203 self.cartesian_product_of_fixture_params(&fixture_params_for_test);
204 for combo in combinations {
205 let mut variant = test.clone();
206 let id_parts: Vec<String> = combo
208 .iter()
209 .enumerate()
210 .map(|(idx, (_, param_value))| format!("{}={}", idx + 1, param_value))
211 .collect();
212 variant.node_id = format!("{}[{}]", test.node_id, id_parts.join("-"));
213 expanded.push(variant);
214 }
215 }
216 }
217
218 expanded
219 }
220
221 fn get_fixture_params_for_test(
223 &self,
224 test: &NativeTestNode,
225 fixture_params: &HashMap<String, Vec<String>>,
226 ) -> Vec<(String, Vec<String>)> {
227 let mut result = Vec::new();
228
229 for (fixture_name, param_values) in fixture_params {
231 let expected_marker = format!("uses_fixture:{}", fixture_name);
235 for marker in &test.markers {
236 if marker == &expected_marker {
237 result.push((fixture_name.clone(), param_values.clone()));
238 break;
239 }
240 }
241 }
242
243 result
244 }
245
246 fn cartesian_product_of_fixture_params(
248 &self,
249 fixture_params: &[(String, Vec<String>)],
250 ) -> Vec<Vec<(String, String)>> {
251 if fixture_params.is_empty() {
252 return Vec::new();
253 }
254
255 if fixture_params.len() == 1 {
256 return fixture_params[0]
257 .1
258 .iter()
259 .map(|p| vec![(fixture_params[0].0.clone(), p.clone())])
260 .collect();
261 }
262
263 fn compute_product(
265 params: &[(String, Vec<String>)],
266 index: usize,
267 ) -> Vec<Vec<(String, String)>> {
268 if index >= params.len() {
269 return vec![Vec::new()];
270 }
271
272 let (name, values) = ¶ms[index];
273 let rest = compute_product(params, index + 1);
274
275 let mut result = Vec::new();
276 for value in values {
277 for mut combo in rest.clone() {
278 combo.insert(0, (name.clone(), value.clone()));
279 result.push(combo);
280 }
281 }
282
283 result
284 }
285
286 compute_product(fixture_params, 0)
287 }
288
289 fn find_test_files(&self) -> Result<Vec<PathBuf>> {
291 let mut test_files = Vec::new();
292
293 for entry in walkdir::WalkDir::new(&self.repo_path)
295 .follow_links(true)
296 .into_iter()
297 {
298 let entry = entry?;
299 let path = entry.path().to_path_buf();
300
301 if path.is_dir() {
303 continue;
304 }
305
306 let path_str = path.to_string_lossy();
308 if path_str.contains("/.venv/")
309 || path_str.contains("/venv/")
310 || path_str.contains("/node_modules/")
311 || path_str.contains("/.git/")
312 {
313 continue;
314 }
315
316 if let Some(ext) = path.extension() {
318 if ext == "py" {
319 let file_name = path.file_name().unwrap().to_string_lossy();
320 if file_name.starts_with("test_") || file_name.ends_with("_test.py") {
322 test_files.push(path);
323 }
324 }
325 }
326 }
327
328 debug!("Found {} test files", test_files.len());
329 Ok(test_files)
330 }
331
332 fn parse_test_file(&self, path: &PathBuf) -> Result<Vec<NativeTestNode>> {
334 let content = match std::fs::read_to_string(path) {
335 Ok(c) => c,
336 Err(e) => {
337 warn!("Failed to read {}: {}", path.display(), e);
338 return Ok(Vec::new());
339 }
340 };
341
342 let syntax = ast::Suite::parse(&content, "<test>");
344
345 match syntax {
346 Ok(stmts) => self.extract_tests_from_ast(&stmts, path, &content),
347 Err(e) => {
348 warn!("Failed to parse {}: {}", path.display(), e);
349 Ok(Vec::new())
350 }
351 }
352 }
353
354 fn byte_offset_to_line(&self, source: &str, offset: usize) -> u32 {
356 let offset = offset.min(source.len());
357 let line = source[..offset].chars().filter(|&c| c == '\n').count() + 1;
358 line as u32
359 }
360
361 fn extract_tests_from_ast(
363 &self,
364 stmts: &[ast::Stmt],
365 path: &Path,
366 source: &str,
367 ) -> Result<Vec<NativeTestNode>> {
368 let mut tests = Vec::new();
369 let file_path = path.to_string_lossy().to_string();
370
371 let relative_path = if let Ok(stripped) = path.strip_prefix(&self.repo_path) {
373 stripped.to_string_lossy().to_string()
374 } else {
375 file_path.clone()
376 };
377
378 let mut conftest_fixtures: Vec<String> = Vec::new();
380 let is_conftest = path.file_name().unwrap().to_string_lossy() == "conftest.py";
381
382 if is_conftest {
383 conftest_fixtures = self.extract_conftest_fixtures(stmts);
384 }
385
386 let file_fixtures = self.extract_conftest_fixtures(stmts);
388
389 let mut all_fixtures = conftest_fixtures;
391 for fixture in &file_fixtures {
392 if !all_fixtures.contains(fixture) {
393 all_fixtures.push(fixture.clone());
394 }
395 }
396
397 self.extract_stmt_items(stmts, "", &relative_path, &all_fixtures, source, &mut tests);
399
400 Ok(tests)
401 }
402
403 fn extract_conftest_fixtures(&self, stmts: &[ast::Stmt]) -> Vec<String> {
405 let mut fixtures = Vec::new();
406
407 for stmt in stmts {
408 match stmt {
409 ast::Stmt::FunctionDef(func) => {
410 self.check_for_fixture(func, &mut fixtures);
411 }
412 ast::Stmt::AsyncFunctionDef(func) => {
413 self.check_for_async_fixture(func, &mut fixtures);
414 }
415 _ => {}
416 }
417 }
418
419 fixtures
420 }
421
422 fn check_for_fixture(&self, func: &ast::StmtFunctionDef, fixtures: &mut Vec<String>) {
424 for decorator in &func.decorator_list {
425 if let Some(name) = self.get_decorator_name(decorator) {
426 if name == "fixture" || name == "pytest.fixture" {
428 fixtures.push(func.name.to_string());
429 self.extract_fixture_params(decorator, &func.name);
431 }
432 }
433 }
434 }
435
436 fn extract_fixture_params(&self, decorator: &ast::Expr, fixture_name: &str) {
438 let keywords = match decorator {
440 ast::Expr::Call(ast::ExprCall { keywords, .. }) => keywords,
441 _ => return,
442 };
443
444 for keyword in keywords {
446 if keyword.arg.as_deref() == Some("params") {
447 if let Some(param_values) = self.extract_list_param_values(&keyword.value) {
449 let mut fixture_params = self.fixture_params.lock().unwrap();
450 fixture_params.insert(fixture_name.to_string(), param_values);
451 }
452 }
453 }
454 }
455
456 fn extract_list_param_values(&self, expr: &ast::Expr) -> Option<Vec<String>> {
458 match expr {
459 ast::Expr::List(ast::ExprList { elts, .. }) => {
460 let mut values = Vec::new();
461 for elt in elts {
462 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
463 values.push(self.format_constant(value));
464 } else {
465 values.push(format!("{:?}", elt));
467 }
468 }
469 Some(values)
470 }
471 _ => None,
472 }
473 }
474
475 fn check_for_async_fixture(
477 &self,
478 func: &ast::StmtAsyncFunctionDef,
479 fixtures: &mut Vec<String>,
480 ) {
481 for decorator in &func.decorator_list {
482 if let Some(name) = self.get_decorator_name(decorator) {
483 if name == "fixture" || name == "pytest.fixture" {
485 fixtures.push(func.name.to_string());
486 self.extract_fixture_params(decorator, &func.name);
488 }
489 }
490 }
491 }
492
493 fn get_decorator_name(&self, decorator: &ast::Expr) -> Option<String> {
495 match decorator {
496 ast::Expr::Call(ast::ExprCall { func, .. }) => self.get_decorator_name(func),
498 ast::Expr::Attribute(ExprAttribute { attr, value, .. }) => {
500 let attr_str = attr.to_string();
501 if let Some(inner) = self.get_decorator_name(value) {
502 Some(format!("{}.{}", inner, attr_str))
504 } else {
505 Some(attr_str)
507 }
508 }
509 ast::Expr::Name(ExprName { id, .. }) => Some(id.to_string()),
510 _ => None,
511 }
512 }
513
514 fn extract_parametrize_info(&self, decorator: &ast::Expr) -> Option<ParameterizedTestNode> {
516 if let ast::Expr::Call(ast::ExprCall { func, args, .. }) = decorator {
518 let func_name = self.get_decorator_name(func);
519 if func_name.as_deref() != Some("pytest.mark.parametrize") {
520 return None;
521 }
522
523 if args.len() < 2 {
526 return None;
527 }
528
529 let param_names: Vec<String> = match &args[0] {
531 ast::Expr::Constant(ast::ExprConstant {
532 value: ast::Constant::Str(s),
533 ..
534 }) => s.split(',').map(|s| s.trim().to_string()).collect(),
535 _ => return None,
536 };
537
538 let param_values_with_info = self.extract_param_values_with_info(&args[1])?;
540
541 let param_values: Vec<(Vec<String>, Option<String>, Vec<String>)> =
544 param_values_with_info
545 .iter()
546 .map(|(values, custom_id, marks)| {
547 (values.clone(), custom_id.clone(), marks.clone())
548 })
549 .collect();
550
551 Some(ParameterizedTestNode {
552 param_names,
553 param_values,
554 test_id: String::new(),
555 })
556 } else {
557 None
558 }
559 }
560
561 #[allow(dead_code)]
563 fn extract_param_values(&self, expr: &ast::Expr) -> Option<Vec<Vec<String>>> {
564 match expr {
565 ast::Expr::List(ast::ExprList { elts, .. }) => {
567 let mut all_values = Vec::new();
568 for elt in elts {
569 if let Some((values, _, _)) = self.extract_single_param_value_full(elt) {
570 all_values.push(values);
571 } else {
572 return None;
573 }
574 }
575 Some(all_values)
576 }
577 _ => None,
578 }
579 }
580
581 fn extract_single_param_value_full(
584 &self,
585 expr: &ast::Expr,
586 ) -> Option<(Vec<String>, Option<String>, Vec<String>)> {
587 match expr {
588 ast::Expr::Constant(ast::ExprConstant { value, .. }) => {
590 Some((vec![self.format_constant(value)], None, Vec::new()))
591 }
592 ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
594 let mut values = Vec::new();
595 for elt in elts {
596 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
597 values.push(self.format_constant(value));
598 } else {
599 values.push(format!("{:?}", elt));
601 }
602 }
603 Some((values, None, Vec::new()))
604 }
605 ast::Expr::Dict(ast::ExprDict { keys, values, .. }) => {
607 let parts: Vec<String> = keys
609 .iter()
610 .zip(values.iter())
611 .filter_map(|(k, v)| {
612 if let (
614 Some(k_const),
615 ast::Expr::Constant(ast::ExprConstant { value, .. }),
616 ) = (
617 k.as_ref().and_then(|k| {
618 if let ast::Expr::Constant(ast::ExprConstant {
619 value: ast::Constant::Str(s),
620 ..
621 }) = k
622 {
623 Some(s)
624 } else {
625 None
626 }
627 }),
628 v,
629 ) {
630 Some(format!("{}={}", k_const, self.format_constant(value)))
631 } else {
632 None
633 }
634 })
635 .collect();
636 let repr = if parts.is_empty() {
637 "{}".to_string()
638 } else {
639 format!("{{{}}}", parts.join(","))
640 };
641 Some((vec![repr], None, Vec::new()))
642 }
643 ast::Expr::List(ast::ExprList { elts, .. }) => {
645 let parts: Vec<String> = elts
646 .iter()
647 .filter_map(|elt| {
648 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
649 Some(self.format_constant(value))
650 } else {
651 None
652 }
653 })
654 .collect();
655 let repr = if parts.is_empty() {
656 "[]".to_string()
657 } else {
658 format!("[{}]", parts.join(","))
659 };
660 Some((vec![repr], None, Vec::new()))
661 }
662 ast::Expr::Call(ast::ExprCall {
664 func,
665 args,
666 keywords,
667 ..
668 }) => {
669 let is_param = match func.as_ref() {
674 ast::Expr::Name(name_expr) => name_expr.id.as_str() == "param",
675 ast::Expr::Attribute(attr) => {
676 if attr.attr.as_str() == "param" {
677 if let ast::Expr::Name(name_expr) = &*attr.value {
678 name_expr.id.as_str() == "param"
679 || name_expr.id.as_str() == "pytest"
680 } else {
681 false
682 }
683 } else {
684 false
685 }
686 }
687 _ => false,
688 };
689
690 if is_param {
691 let custom_id = keywords
693 .iter()
694 .find(|kw| kw.arg.as_deref() == Some("id"))
695 .and_then(|kw| {
696 if let ast::Expr::Constant(ast::ExprConstant {
697 value: ast::Constant::Str(s),
698 ..
699 }) = &kw.value
700 {
701 Some(s.clone())
702 } else {
703 None
704 }
705 });
706
707 let marks = self.parse_marks_from_keywords(keywords);
709
710 if args.is_empty() {
712 return Some((vec!["param".to_string()], custom_id, marks));
713 }
714 if let Some((values, _, _)) = self.extract_single_param_value_full(&args[0]) {
716 return Some((values, custom_id, marks));
717 }
718 }
719 None
720 }
721 ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => {
723 match (op, operand.as_ref()) {
724 (ast::UnaryOp::USub, ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
725 if let ast::Constant::Int(n) = value {
728 if n.to_string() == "0" {
729 return Some((vec!["0".to_string()], None, Vec::new()));
731 }
732 }
733 let formatted = self.format_constant(value);
734 Some((vec![format!("-{}", formatted)], None, Vec::new()))
735 }
736 (ast::UnaryOp::UAdd, ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
737 Some((vec![self.format_constant(value)], None, Vec::new()))
739 }
740 (ast::UnaryOp::Not, _) => {
741 Some((vec![format!("{:?}", expr)], None, Vec::new()))
743 }
744 _ => Some((vec![format!("{:?}", expr)], None, Vec::new())),
745 }
746 }
747 _ => Some((vec![format!("{:?}", expr)], None, Vec::new())),
749 }
750 }
751
752 fn parse_marks_from_keywords(&self, keywords: &[ast::Keyword]) -> Vec<String> {
754 let mut marks = Vec::new();
755
756 for keyword in keywords {
757 if keyword.arg.as_deref() == Some("marks") {
758 self.extract_marks_from_expr(&keyword.value, &mut marks);
760 }
761 }
762
763 marks
764 }
765
766 fn extract_marks_from_expr(&self, expr: &ast::Expr, marks: &mut Vec<String>) {
768 match expr {
769 ast::Expr::Call(call) => {
771 if let Some(name) = self.get_decorator_name(&call.func) {
772 let marker = name.replace("pytest.mark.", "");
773 marks.push(marker);
774 }
775 }
776 ast::Expr::List(list) => {
778 for elt in &list.elts {
779 self.extract_marks_from_expr(elt, marks);
780 }
781 }
782 ast::Expr::Attribute(_attr) => {
784 if let Some(name) = self.get_decorator_name(expr) {
785 let marker = name.replace("pytest.mark.", "");
786 marks.push(marker);
787 }
788 }
789 _ => {}
790 }
791 }
792
793 fn extract_param_values_with_info(&self, expr: &ast::Expr) -> Option<Vec<ParamValueInfo>> {
796 match expr {
797 ast::Expr::List(ast::ExprList { elts, .. }) => {
799 let mut all_values = Vec::new();
800 for elt in elts {
801 if let Some((values, custom_id, marks)) =
802 self.extract_single_param_value_full(elt)
803 {
804 all_values.push((values, custom_id, marks));
805 } else {
806 return None;
807 }
808 }
809 Some(all_values)
810 }
811 _ => None,
812 }
813 }
814
815 fn format_constant(&self, value: &ast::Constant) -> String {
817 match value {
818 ast::Constant::Str(s) => {
819 s.clone()
822 }
823 ast::Constant::Int(n) => n.to_string(),
824 ast::Constant::Float(n) => {
825 let s = n.to_string();
826 if s.contains('e') || s.contains('E') {
828 format!("{:?}", n)
829 } else if !s.contains('.') {
830 format!("{}.0", s)
832 } else {
833 s
834 }
835 }
836 ast::Constant::Bool(b) => if *b { "True" } else { "False" }.to_string(),
837 ast::Constant::None => "None".to_string(),
838 _ => format!("{:?}", value),
839 }
840 }
841
842 fn format_param_value(&self, value: &str) -> String {
845 if value == "{}" {
847 "dct".to_string()
849 } else if value.starts_with('{') {
850 "dct".to_string()
852 } else if value == "[]" {
853 "lst".to_string()
855 } else if value.starts_with('[') {
856 "lst".to_string()
858 } else if value == "None" {
859 "None".to_string()
861 } else if value == "True" || value == "False" {
862 value.to_string()
864 } else {
865 let sanitized = value
869 .replace('[', "_")
870 .replace(']', "_")
871 .replace("::", "_")
872 .replace('/', "_")
873 .replace('\n', "\\n")
875 .replace('\r', "\\r")
876 .replace('\t', "\\t")
877 .replace('\\', "\\");
879 if sanitized.len() > 30 {
881 format!("{}...", &sanitized[..27])
882 } else {
883 sanitized
884 }
885 }
886 }
887
888 fn categorize_value(&self, value: &str) -> ValueCategory {
890 if value == "{}" || value.starts_with('{') {
891 ValueCategory::Dict
892 } else if value == "[]" || value.starts_with('[') {
893 ValueCategory::List
894 } else {
895 ValueCategory::Simple
896 }
897 }
898
899 fn has_mixed_param_types(
902 &self,
903 param_values: &[(Vec<String>, Option<String>, Vec<String>)],
904 ) -> bool {
905 let mut has_dict = false;
906 let mut has_list = false;
907 let mut has_simple = false;
908
909 for (values, _, _) in param_values {
910 for value in values {
911 match self.categorize_value(value) {
912 ValueCategory::Dict => has_dict = true,
913 ValueCategory::List => has_list = true,
914 ValueCategory::Simple => has_simple = true,
915 }
916 }
917 }
918
919 (has_dict || has_list) && has_simple
922 }
923
924 fn deduplicate_ids(&self, ids: &[String]) -> Vec<String> {
927 let mut result = Vec::with_capacity(ids.len());
928 let mut seen: std::collections::HashMap<&str, Vec<usize>> =
929 std::collections::HashMap::new();
930
931 for (idx, id) in ids.iter().enumerate() {
933 seen.entry(id.as_str()).or_default().push(idx);
934 }
935
936 let mut counters: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
938 for id in ids {
939 let indices = &seen[id.as_str()];
940 if indices.len() > 1 {
941 let counter = counters.entry(id.as_str()).or_insert(0);
943 result.push(format!("{}_{}", id, counter));
944 *counter += 1;
945 } else {
946 result.push(id.clone());
948 }
949 }
950
951 result
952 }
953
954 fn generate_pytest_id(&self, values: &[String], idx: usize) -> String {
957 self.generate_pytest_id_with_context(values, idx, false)
958 }
959
960 fn generate_pytest_id_with_context(
963 &self,
964 values: &[String],
965 idx: usize,
966 use_value_for_containers: bool,
967 ) -> String {
968 if values.len() == 1 {
969 let formatted = if use_value_for_containers {
971 self.format_param_value_with_context(&values[0], true)
972 } else {
973 self.format_param_value(&values[0])
974 };
975 if formatted == "dct" || formatted == "lst" || formatted == "value" {
978 format!("{}{}", formatted, idx)
979 } else {
980 formatted
981 }
982 } else {
983 let formatted: Vec<String> = values
985 .iter()
986 .map(|v| {
987 if use_value_for_containers {
988 self.format_param_value_with_context(v, true)
989 } else {
990 self.format_param_value(v)
991 }
992 })
993 .collect();
994 formatted.join("-")
995 }
996 }
997
998 fn format_param_value_with_context(
1000 &self,
1001 value: &str,
1002 use_value_for_containers: bool,
1003 ) -> String {
1004 if use_value_for_containers {
1005 if value == "{}" || value.starts_with('{') || value == "[]" || value.starts_with('[') {
1007 return "value".to_string();
1008 }
1009 }
1010 self.format_param_value(value)
1011 }
1012
1013 fn extract_pytest_markers(&self, decorators: &[ast::Expr]) -> Vec<String> {
1015 let mut markers = Vec::new();
1016
1017 for decorator in decorators {
1018 if let Some(name) = self.get_decorator_name(decorator) {
1019 if self.builtin_fixtures.contains(&name.as_str()) {
1021 continue;
1022 }
1023
1024 if name.starts_with("pytest.mark.") {
1026 let marker = name.replace("pytest.mark.", "");
1027 markers.push(marker);
1028 }
1029 }
1030 }
1031
1032 markers
1033 }
1034
1035 fn extract_stmt_items(
1037 &self,
1038 stmts: &[ast::Stmt],
1039 class_name: &str,
1040 file_path: &str,
1041 conftest_fixtures: &[String],
1042 source: &str,
1043 tests: &mut Vec<NativeTestNode>,
1044 ) {
1045 for stmt in stmts {
1046 match stmt {
1047 ast::Stmt::FunctionDef(func) => {
1049 self.process_function_def(
1050 func,
1051 class_name,
1052 file_path,
1053 conftest_fixtures,
1054 source,
1055 tests,
1056 );
1057 }
1058 ast::Stmt::AsyncFunctionDef(func) => {
1060 self.process_async_function_def(
1061 func,
1062 class_name,
1063 file_path,
1064 conftest_fixtures,
1065 source,
1066 tests,
1067 );
1068 }
1069 ast::Stmt::ClassDef(class_def) => {
1070 let class_name_str = &class_def.name;
1071
1072 if class_name_str.starts_with("Test") {
1075 let class_markers = self.extract_pytest_markers(&class_def.decorator_list);
1077
1078 let ctx = TestProcessingContext {
1080 file_path,
1081 class_name: class_name_str,
1082 class_markers: &class_markers,
1083 source,
1084 };
1085 self.extract_stmt_items_with_class_markers(
1086 &class_def.body,
1087 &ctx,
1088 conftest_fixtures,
1089 tests,
1090 );
1091
1092 let base_class_names = self.extract_base_class_names(class_def);
1095 for base_name in base_class_names {
1096 if let Some(base_class) = self.find_class_in_stmts(stmts, &base_name) {
1098 self.collect_inherited_tests(
1100 base_class,
1101 class_name_str, &class_markers,
1103 file_path,
1104 conftest_fixtures,
1105 source,
1106 tests,
1107 stmts, );
1109 }
1110 }
1111 }
1112 }
1113 _ => {}
1114 }
1115 }
1116 }
1117
1118 fn extract_stmt_items_with_class_markers(
1120 &self,
1121 stmts: &[ast::Stmt],
1122 ctx: &TestProcessingContext,
1123 conftest_fixtures: &[String],
1124 tests: &mut Vec<NativeTestNode>,
1125 ) {
1126 for stmt in stmts {
1127 match stmt {
1128 ast::Stmt::FunctionDef(func) => {
1129 self.process_function_def_with_class_markers(
1130 func,
1131 ctx,
1132 conftest_fixtures,
1133 tests,
1134 );
1135 }
1136 ast::Stmt::AsyncFunctionDef(func) => {
1137 self.process_async_function_def_with_class_markers(
1138 func,
1139 ctx,
1140 conftest_fixtures,
1141 tests,
1142 );
1143 }
1144 ast::Stmt::ClassDef(nested_class) => {
1145 if nested_class.name.starts_with("Test") {
1148 let nested_class_markers =
1149 self.extract_pytest_markers(&nested_class.decorator_list);
1150 let mut combined_markers = ctx.class_markers.to_vec();
1152 combined_markers.extend(nested_class_markers);
1153
1154 let nested_ctx = TestProcessingContext {
1155 file_path: ctx.file_path,
1156 class_name: &nested_class.name,
1157 class_markers: &combined_markers,
1158 source: ctx.source,
1159 };
1160 self.extract_stmt_items_with_class_markers(
1161 &nested_class.body,
1162 &nested_ctx,
1163 conftest_fixtures,
1164 tests,
1165 );
1166 }
1167 }
1168 _ => {}
1169 }
1170 }
1171 }
1172
1173 fn process_function_def(
1175 &self,
1176 func: &ast::StmtFunctionDef,
1177 class_name: &str,
1178 file_path: &str,
1179 conftest_fixtures: &[String],
1180 source: &str,
1181 tests: &mut Vec<NativeTestNode>,
1182 ) {
1183 let ctx = TestProcessingContext {
1184 file_path,
1185 class_name,
1186 class_markers: &[],
1187 source,
1188 };
1189 self.process_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1190 }
1191
1192 fn process_function_def_with_class_markers(
1194 &self,
1195 func: &ast::StmtFunctionDef,
1196 ctx: &TestProcessingContext,
1197 conftest_fixtures: &[String],
1198 tests: &mut Vec<NativeTestNode>,
1199 ) {
1200 let fn_name = &func.name;
1201
1202 if fn_name.starts_with("test_") || fn_name.starts_with("Test") {
1204 let decorators = &func.decorator_list;
1205
1206 let has_skip_decorator = decorators.iter().any(|d| {
1209 if let Some(name) = self.get_decorator_name(d) {
1210 SKIP_DECORATORS.contains(&name.as_str())
1211 } else {
1212 false
1213 }
1214 });
1215 if has_skip_decorator {
1216 return;
1217 }
1218
1219 let line_number = self.extract_line_number_stmt(func, ctx.source);
1220
1221 let (method_markers, parametrize_infos) =
1223 self.extract_markers_and_parametrize(decorators);
1224
1225 let mut all_markers = ctx.class_markers.to_vec();
1227 all_markers.extend(method_markers);
1228
1229 let used_fixtures = self.get_pytest_fixture_usage_func(&func.args, conftest_fixtures);
1231 let uses_external_fixtures = !used_fixtures.is_empty();
1232 for fixture_name in &used_fixtures {
1233 all_markers.push(format!("uses_fixture:{}", fixture_name));
1234 }
1235
1236 let skip_xfail = self.extract_skip_xfail_info(decorators);
1238
1239 if parametrize_infos.is_empty() {
1241 let test_ctx = TestCreationContext {
1243 fn_name,
1244 line_number,
1245 markers: &all_markers,
1246 uses_external_fixtures,
1247 skip_xfail,
1248 };
1249 self.create_test_node(&test_ctx, ctx.file_path, ctx.class_name, tests);
1250 } else if parametrize_infos.len() == 1 {
1251 let test_ctx = TestCreationContext {
1253 fn_name,
1254 line_number,
1255 markers: &all_markers,
1256 uses_external_fixtures,
1257 skip_xfail,
1258 };
1259 let param_info = parametrize_infos.into_iter().next().unwrap();
1260 self.expand_parametrized_tests(
1261 &test_ctx,
1262 ctx.file_path,
1263 ctx.class_name,
1264 param_info,
1265 tests,
1266 );
1267 } else {
1268 let mut reversed_infos = parametrize_infos.clone();
1271 reversed_infos.reverse();
1272 let test_ctx = TestCreationContext {
1273 fn_name,
1274 line_number,
1275 markers: &all_markers,
1276 uses_external_fixtures,
1277 skip_xfail,
1278 };
1279 self.expand_stacked_parametrized_tests(
1280 &test_ctx,
1281 ctx.file_path,
1282 ctx.class_name,
1283 reversed_infos,
1284 tests,
1285 );
1286 }
1287 }
1288 }
1289
1290 fn process_async_function_def(
1292 &self,
1293 func: &ast::StmtAsyncFunctionDef,
1294 class_name: &str,
1295 file_path: &str,
1296 conftest_fixtures: &[String],
1297 source: &str,
1298 tests: &mut Vec<NativeTestNode>,
1299 ) {
1300 let ctx = TestProcessingContext {
1301 file_path,
1302 class_name,
1303 class_markers: &[],
1304 source,
1305 };
1306 self.process_async_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1307 }
1308
1309 fn process_async_function_def_with_class_markers(
1311 &self,
1312 func: &ast::StmtAsyncFunctionDef,
1313 ctx: &TestProcessingContext,
1314 conftest_fixtures: &[String],
1315 tests: &mut Vec<NativeTestNode>,
1316 ) {
1317 let fn_name = &func.name;
1318
1319 if fn_name.starts_with("test_") || fn_name.starts_with("Test") {
1321 let decorators = &func.decorator_list;
1322
1323 let has_skip_decorator = decorators.iter().any(|d| {
1326 if let Some(name) = self.get_decorator_name(d) {
1327 SKIP_DECORATORS.contains(&name.as_str())
1328 } else {
1329 false
1330 }
1331 });
1332 if has_skip_decorator {
1333 return;
1334 }
1335
1336 let line_number = self.extract_line_number_async_stmt(func, ctx.source);
1337
1338 let (method_markers, parametrize_infos) =
1340 self.extract_markers_and_parametrize(decorators);
1341
1342 let mut all_markers = ctx.class_markers.to_vec();
1344 all_markers.extend(method_markers);
1345
1346 let uses_external_fixtures =
1348 self.check_pytest_fixture_usage_async(&func.args, conftest_fixtures);
1349
1350 let skip_xfail = self.extract_skip_xfail_info(decorators);
1352
1353 if parametrize_infos.is_empty() {
1355 let test_ctx = TestCreationContext {
1357 fn_name,
1358 line_number,
1359 markers: &all_markers,
1360 uses_external_fixtures,
1361 skip_xfail,
1362 };
1363 self.create_test_node(&test_ctx, ctx.file_path, ctx.class_name, tests);
1364 } else if parametrize_infos.len() == 1 {
1365 let test_ctx = TestCreationContext {
1367 fn_name,
1368 line_number,
1369 markers: &all_markers,
1370 uses_external_fixtures,
1371 skip_xfail,
1372 };
1373 let param_info = parametrize_infos.into_iter().next().unwrap();
1374 self.expand_parametrized_tests(
1375 &test_ctx,
1376 ctx.file_path,
1377 ctx.class_name,
1378 param_info,
1379 tests,
1380 );
1381 } else {
1382 let mut reversed_infos = parametrize_infos.clone();
1385 reversed_infos.reverse();
1386 let test_ctx = TestCreationContext {
1387 fn_name,
1388 line_number,
1389 markers: &all_markers,
1390 uses_external_fixtures,
1391 skip_xfail,
1392 };
1393 self.expand_stacked_parametrized_tests(
1394 &test_ctx,
1395 ctx.file_path,
1396 ctx.class_name,
1397 reversed_infos,
1398 tests,
1399 );
1400 }
1401 }
1402 }
1403
1404 fn create_test_node(
1406 &self,
1407 ctx: &TestCreationContext,
1408 file_path: &str,
1409 class_name: &str,
1410 tests: &mut Vec<NativeTestNode>,
1411 ) {
1412 let node_id = if class_name.is_empty() {
1413 format!("{}::{}", file_path, ctx.fn_name)
1414 } else {
1415 format!("{}::{}::{}", file_path, class_name, ctx.fn_name)
1416 };
1417
1418 let mut final_markers = ctx.markers.to_vec();
1419 if ctx.skip_xfail.skip {
1420 final_markers.push("skip".to_string());
1421 }
1422 if ctx.skip_xfail.skipif {
1423 final_markers.push("skipif".to_string());
1424 }
1425 if ctx.skip_xfail.xfail {
1426 final_markers.push("xfail".to_string());
1427 }
1428
1429 tests.push(NativeTestNode {
1430 node_id,
1431 file_path: file_path.to_string(),
1432 name: ctx.fn_name.to_string(),
1433 class_name: if class_name.is_empty() {
1434 None
1435 } else {
1436 Some(class_name.to_string())
1437 },
1438 line_number: ctx.line_number,
1439 markers: final_markers,
1440 is_simple: !ctx.uses_external_fixtures,
1441 parameters: Vec::new(),
1442 skip: ctx.skip_xfail.skip,
1445 skip_reason: ctx
1446 .skip_xfail
1447 .skip_reason
1448 .clone()
1449 .or(ctx.skip_xfail.skipif_condition.clone()),
1450 xfail: ctx.skip_xfail.xfail,
1451 xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1452 xfail_strict: ctx.skip_xfail.xfail_strict,
1453 });
1454 }
1455
1456 fn extract_line_number_stmt(&self, func: &ast::StmtFunctionDef, source: &str) -> u32 {
1458 let offset = func.range.start().to_usize();
1459 self.byte_offset_to_line(source, offset)
1460 }
1461
1462 fn extract_line_number_async_stmt(
1464 &self,
1465 func: &ast::StmtAsyncFunctionDef,
1466 source: &str,
1467 ) -> u32 {
1468 let offset = func.range.start().to_usize();
1469 self.byte_offset_to_line(source, offset)
1470 }
1471
1472 fn extract_markers_and_parametrize(
1475 &self,
1476 decorators: &[ast::Expr],
1477 ) -> (Vec<String>, Vec<ParameterizedTestNode>) {
1478 let mut markers = Vec::new();
1479 let mut parametrize_infos = Vec::new();
1480
1481 for decorator in decorators {
1482 if let Some(name) = self.get_decorator_name(decorator) {
1483 if self.builtin_fixtures.contains(&name.as_str()) {
1485 continue;
1486 }
1487
1488 if name == "pytest.mark.parametrize" {
1490 if let Some(info) = self.extract_parametrize_info(decorator) {
1491 parametrize_infos.push(info);
1492 }
1493 continue;
1494 }
1495
1496 if name.starts_with("pytest.mark.") {
1498 let marker = name.replace("pytest.mark.", "");
1499 markers.push(marker);
1500 }
1501 else if !name.starts_with("pytest.") {
1503 markers.push(name);
1504 }
1505 }
1506 }
1507
1508 (markers, parametrize_infos)
1509 }
1510
1511 fn extract_skip_xfail_info(&self, decorators: &[ast::Expr]) -> SkipXfailInfo {
1513 let mut info = SkipXfailInfo::default();
1514
1515 for decorator in decorators {
1516 if let Some(name) = self.get_decorator_name(decorator) {
1517 if name == "pytest.mark.skip" || name.starts_with("pytest.mark.skip(") {
1519 info.skip = true;
1520 info.skip_reason = self.extract_kwarg_string(decorator, "reason");
1521 }
1522 else if name == "pytest.mark.skipif" || name.starts_with("pytest.mark.skipif(") {
1524 info.skipif = true;
1525 info.skipif_condition = self.extract_skipif_condition(decorator);
1526 info.skip_reason = self.extract_kwarg_string(decorator, "reason");
1527 }
1528 else if name == "pytest.mark.xfail" || name.starts_with("pytest.mark.xfail(") {
1530 info.xfail = true;
1531 info.xfail_condition = self.extract_xfail_condition(decorator);
1532 info.xfail_reason = self.extract_kwarg_string(decorator, "reason");
1533 info.xfail_strict = self.extract_kwarg_bool(decorator, "strict");
1534 info.xfail_raises = self.extract_kwarg_string(decorator, "raises");
1535 }
1536 }
1537 }
1538
1539 info
1540 }
1541
1542 fn extract_kwarg_string(&self, decorator: &ast::Expr, arg: &str) -> Option<String> {
1544 if let ast::Expr::Call(ast::ExprCall { keywords, .. }) = decorator {
1545 for keyword in keywords {
1546 if keyword.arg.as_deref() == Some(arg) {
1547 if let ast::Expr::Constant(ast::ExprConstant {
1548 value: ast::Constant::Str(s),
1549 ..
1550 }) = &keyword.value
1551 {
1552 return Some(s.clone());
1553 }
1554 }
1555 }
1556 }
1557 None
1558 }
1559
1560 fn extract_kwarg_bool(&self, decorator: &ast::Expr, arg: &str) -> bool {
1562 if let ast::Expr::Call(ast::ExprCall { keywords, .. }) = decorator {
1563 for keyword in keywords {
1564 if keyword.arg.as_deref() == Some(arg) {
1565 if let ast::Expr::Constant(ast::ExprConstant {
1566 value: ast::Constant::Bool(b),
1567 ..
1568 }) = &keyword.value
1569 {
1570 return *b;
1571 }
1572 }
1573 }
1574 }
1575 false
1576 }
1577
1578 fn extract_skipif_condition(&self, decorator: &ast::Expr) -> Option<String> {
1580 if let ast::Expr::Call(ast::ExprCall { args, keywords, .. }) = decorator {
1581 if let Some(first_arg) = args.first() {
1583 return Some(format!("{:?}", first_arg));
1584 }
1585 for keyword in keywords {
1587 if keyword.arg.as_deref() == Some("condition") {
1588 return Some(format!("{:?}", keyword.value));
1589 }
1590 }
1591 }
1592 None
1593 }
1594
1595 fn extract_xfail_condition(&self, decorator: &ast::Expr) -> Option<String> {
1597 if let ast::Expr::Call(ast::ExprCall { args, keywords, .. }) = decorator {
1598 if let Some(first_arg) = args.first() {
1600 return Some(format!("{:?}", first_arg));
1601 }
1602 for keyword in keywords {
1604 if keyword.arg.as_deref() == Some("condition") {
1605 return Some(format!("{:?}", keyword.value));
1606 }
1607 }
1608 }
1609 None
1610 }
1611
1612 fn extract_base_class_names(&self, class_def: &ast::StmtClassDef) -> Vec<String> {
1614 let mut base_names = Vec::new();
1615 for base in &class_def.bases {
1616 if let ast::Expr::Name(name) = base {
1617 base_names.push(name.id.to_string());
1618 } else if let ast::Expr::Attribute(attr) = base {
1619 base_names.push(attr.attr.to_string());
1621 }
1622 }
1623 base_names
1624 }
1625
1626 fn find_class_in_stmts<'a>(
1628 &self,
1629 stmts: &'a [ast::Stmt],
1630 name: &str,
1631 ) -> Option<&'a ast::StmtClassDef> {
1632 for stmt in stmts {
1633 if let ast::Stmt::ClassDef(class_def) = stmt {
1634 if class_def.name.as_str() == name {
1635 return Some(class_def);
1636 }
1637 }
1638 }
1639 None
1640 }
1641
1642 fn collect_inherited_tests(
1644 &self,
1645 base_class: &ast::StmtClassDef,
1646 derived_class_name: &str,
1647 derived_class_markers: &[String],
1648 file_path: &str,
1649 conftest_fixtures: &[String],
1650 source: &str,
1651 tests: &mut Vec<NativeTestNode>,
1652 all_stmts: &[ast::Stmt],
1653 ) {
1654 let existing_methods: std::collections::HashSet<String> = tests
1656 .iter()
1657 .filter(|t| t.class_name.as_deref() == Some(derived_class_name))
1658 .map(|t| t.name.clone())
1659 .collect();
1660
1661 let ctx = TestProcessingContext {
1663 file_path,
1664 class_name: derived_class_name, class_markers: derived_class_markers,
1666 source,
1667 };
1668
1669 for stmt in &base_class.body {
1670 match stmt {
1671 ast::Stmt::FunctionDef(func) => {
1672 if func.name.starts_with("test_")
1674 && !existing_methods.contains(&func.name.to_string())
1675 {
1676 self.process_function_def_with_class_markers(
1677 func,
1678 &ctx,
1679 conftest_fixtures,
1680 tests,
1681 );
1682 }
1683 }
1684 ast::Stmt::AsyncFunctionDef(func) => {
1685 if func.name.starts_with("test_")
1686 && !existing_methods.contains(&func.name.to_string())
1687 {
1688 self.process_async_function_def_with_class_markers(
1689 func,
1690 &ctx,
1691 conftest_fixtures,
1692 tests,
1693 );
1694 }
1695 }
1696 _ => {}
1697 }
1698 }
1699
1700 let base_base_names = self.extract_base_class_names(base_class);
1702 for base_name in base_base_names {
1703 if let Some(base_base_class) = self.find_class_in_stmts(all_stmts, &base_name) {
1704 self.collect_inherited_tests(
1705 base_base_class,
1706 derived_class_name,
1707 derived_class_markers,
1708 file_path,
1709 conftest_fixtures,
1710 source,
1711 tests,
1712 all_stmts,
1713 );
1714 }
1715 }
1716 }
1717
1718 fn expand_parametrized_tests(
1720 &self,
1721 ctx: &TestCreationContext,
1722 file_path: &str,
1723 class_name: &str,
1724 param_info: ParameterizedTestNode,
1725 tests: &mut Vec<NativeTestNode>,
1726 ) {
1727 let param_names = ¶m_info.param_names;
1728 let param_values = ¶m_info.param_values;
1729
1730 let use_value_for_containers = self.has_mixed_param_types(param_values);
1734
1735 let mut base_ids: Vec<String> = Vec::with_capacity(param_values.len());
1737 for (idx, (values, custom_id, _)) in param_values.iter().enumerate() {
1738 let base_id = if let Some(id) = custom_id {
1739 id.clone()
1740 } else {
1741 self.generate_pytest_id_with_context(values, idx, use_value_for_containers)
1742 };
1743 base_ids.push(base_id);
1744 }
1745
1746 let final_ids = self.deduplicate_ids(&base_ids);
1748
1749 for (idx, (values, _custom_id, variant_marks)) in param_values.iter().enumerate() {
1750 let test_id = final_ids[idx].clone();
1752
1753 let node_id = if class_name.is_empty() {
1755 format!("{}::{}[{}]", file_path, ctx.fn_name, test_id)
1756 } else {
1757 format!(
1758 "{}::{}::{}[{}]",
1759 file_path, class_name, ctx.fn_name, test_id
1760 )
1761 };
1762
1763 let mut markers = ctx.markers.to_vec();
1765 markers.extend(variant_marks.clone());
1766 if ctx.skip_xfail.skip {
1767 markers.push("skip".to_string());
1768 }
1769 if ctx.skip_xfail.skipif {
1770 markers.push("skipif".to_string());
1771 }
1772 if ctx.skip_xfail.xfail {
1773 markers.push("xfail".to_string());
1774 }
1775
1776 let parameters = vec![serde_json::json!({
1778 "names": param_names,
1779 "values": values,
1780 "id": test_id
1781 })];
1782
1783 tests.push(NativeTestNode {
1784 node_id,
1785 file_path: file_path.to_string(),
1786 name: ctx.fn_name.to_string(),
1787 class_name: if class_name.is_empty() {
1788 None
1789 } else {
1790 Some(class_name.to_string())
1791 },
1792 line_number: ctx.line_number,
1793 markers,
1794 is_simple: !ctx.uses_external_fixtures,
1795 parameters,
1796 skip: ctx.skip_xfail.skip,
1799 skip_reason: ctx
1800 .skip_xfail
1801 .skip_reason
1802 .clone()
1803 .or(ctx.skip_xfail.skipif_condition.clone()),
1804 xfail: ctx.skip_xfail.xfail,
1805 xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1806 xfail_strict: ctx.skip_xfail.xfail_strict,
1807 });
1808 }
1809 }
1810
1811 fn expand_stacked_parametrized_tests(
1814 &self,
1815 ctx: &TestCreationContext,
1816 file_path: &str,
1817 class_name: &str,
1818 param_infos: Vec<ParameterizedTestNode>,
1819 tests: &mut Vec<NativeTestNode>,
1820 ) {
1821 let combinations = self.generate_cartesian_product(¶m_infos);
1823
1824 for (combo_idx, combo) in combinations.into_iter().enumerate() {
1825 let mut all_names = Vec::new();
1828 let mut all_values = Vec::new();
1829 let mut all_marks = Vec::new(); for (names, values, _, marks) in combo {
1832 all_names.extend(names);
1833 all_values.extend(values);
1834 all_marks.extend(marks);
1835 }
1836
1837 let test_id = self.generate_pytest_id(&all_values, combo_idx);
1839
1840 let node_id = if class_name.is_empty() {
1842 format!("{}::{}[{}]", file_path, ctx.fn_name, test_id)
1843 } else {
1844 format!(
1845 "{}::{}::{}[{}]",
1846 file_path, class_name, ctx.fn_name, test_id
1847 )
1848 };
1849
1850 let mut markers = ctx.markers.to_vec();
1852 markers.extend(all_marks);
1853 if ctx.skip_xfail.skip {
1854 markers.push("skip".to_string());
1855 }
1856 if ctx.skip_xfail.skipif {
1857 markers.push("skipif".to_string());
1858 }
1859 if ctx.skip_xfail.xfail {
1860 markers.push("xfail".to_string());
1861 }
1862
1863 let parameters = vec![serde_json::json!({
1865 "names": all_names,
1866 "values": all_values,
1867 "id": test_id
1868 })];
1869
1870 tests.push(NativeTestNode {
1871 node_id,
1872 file_path: file_path.to_string(),
1873 name: ctx.fn_name.to_string(),
1874 class_name: if class_name.is_empty() {
1875 None
1876 } else {
1877 Some(class_name.to_string())
1878 },
1879 line_number: ctx.line_number,
1880 markers,
1881 is_simple: !ctx.uses_external_fixtures,
1882 parameters,
1883 skip: ctx.skip_xfail.skip,
1886 skip_reason: ctx
1887 .skip_xfail
1888 .skip_reason
1889 .clone()
1890 .or(ctx.skip_xfail.skipif_condition.clone()),
1891 xfail: ctx.skip_xfail.xfail,
1892 xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1893 xfail_strict: ctx.skip_xfail.xfail_strict,
1894 });
1895 }
1896 }
1897
1898 fn generate_cartesian_product(
1903 &self,
1904 param_infos: &[ParameterizedTestNode],
1905 ) -> Vec<Vec<ParamCombination>> {
1906 if param_infos.is_empty() {
1907 return Vec::new();
1908 }
1909
1910 if param_infos.len() == 1 {
1911 let info = ¶m_infos[0];
1913 return info
1914 .param_values
1915 .iter()
1916 .map(|(values, custom_id, marks)| {
1917 vec![(
1918 info.param_names.clone(),
1919 values.clone(),
1920 custom_id.clone(),
1921 marks.clone(),
1922 )]
1923 })
1924 .collect();
1925 }
1926
1927 self.compute_cartesian_recursive(param_infos, 0)
1929 }
1930
1931 fn compute_cartesian_recursive(
1933 &self,
1934 param_infos: &[ParameterizedTestNode],
1935 index: usize,
1936 ) -> Vec<Vec<ParamCombination>> {
1937 if index >= param_infos.len() {
1938 return vec![Vec::new()];
1940 }
1941
1942 let info = ¶m_infos[index];
1943 let remaining = self.compute_cartesian_recursive(param_infos, index + 1);
1944
1945 let mut result = Vec::new();
1946 for (values, custom_id, marks) in info.param_values.iter() {
1947 for mut combo in remaining.clone() {
1948 combo.insert(
1949 0,
1950 (
1951 info.param_names.clone(),
1952 values.clone(),
1953 custom_id.clone(),
1954 marks.clone(),
1955 ),
1956 );
1957 result.push(combo);
1958 }
1959 }
1960
1961 result
1962 }
1963
1964 fn get_pytest_fixture_usage_func(
1966 &self,
1967 args: &ast::Arguments,
1968 conftest_fixtures: &[String],
1969 ) -> Vec<String> {
1970 let mut used = Vec::new();
1971 for arg in &args.args {
1972 let arg_name = &arg.def.arg.to_string();
1973 if conftest_fixtures.contains(arg_name) {
1974 used.push(arg_name.clone());
1975 }
1976 }
1977 used
1978 }
1979
1980 fn check_pytest_fixture_usage_async(
1982 &self,
1983 args: &ast::Arguments,
1984 conftest_fixtures: &[String],
1985 ) -> bool {
1986 for arg in &args.args {
1987 let arg_name = &arg.def.arg.to_string();
1988 if conftest_fixtures.contains(arg_name) {
1989 return true;
1990 }
1991 }
1992 false
1993 }
1994}
1995
1996pub fn is_test_file(path: &Path) -> bool {
1998 if let Some(ext) = path.extension() {
1999 if ext != "py" {
2000 return false;
2001 }
2002 } else {
2003 return false;
2004 }
2005
2006 if let Some(file_name) = path.file_name() {
2007 let name = file_name.to_string_lossy();
2008 name.starts_with("test_") || name.ends_with("_test.py")
2009 } else {
2010 false
2011 }
2012}
2013
2014#[cfg(test)]
2015mod tests {
2016 use super::*;
2017 use std::fs;
2018 use tempfile::TempDir;
2019
2020 #[test]
2021 fn test_collect_simple() {
2022 let dir = TempDir::new().unwrap();
2023 let test_file = dir.path().join("test_example.py");
2024 fs::write(
2025 &test_file,
2026 r#"
2027def test_simple():
2028 assert True
2029
2030def test_another():
2031 assert 1 + 1 == 2
2032
2033class TestClass:
2034 def test_method(self):
2035 assert True
2036"#,
2037 )
2038 .unwrap();
2039
2040 let collector = NativeCollector::new(dir.path());
2041 let tests = collector.collect().unwrap();
2042
2043 assert_eq!(tests.len(), 3);
2045
2046 let test_names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2048 assert!(test_names.contains(&"test_simple"));
2049 assert!(test_names.contains(&"test_another"));
2050 assert!(test_names.contains(&"test_method"));
2051 }
2052
2053 #[test]
2054 fn test_ignore_non_test_files() {
2055 let dir = TempDir::new().unwrap();
2056
2057 let regular_file = dir.path().join("regular.py");
2059 fs::write(®ular_file, "def regular_func():\n pass").unwrap();
2060
2061 let collector = NativeCollector::new(dir.path());
2062 let tests = collector.collect().unwrap();
2063
2064 assert!(tests.is_empty());
2066 }
2067
2068 #[test]
2069 fn test_collect_with_markers() {
2070 let dir = TempDir::new().unwrap();
2071 let test_file = dir.path().join("test_marked.py");
2072 fs::write(
2073 &test_file,
2074 r#"
2075import pytest
2076
2077@pytest.mark.slow
2078def test_expensive():
2079 pass
2080
2081@pytest.mark.parametrize("x", [1, 2, 3])
2082def test_param(x):
2083 pass
2084"#,
2085 )
2086 .unwrap();
2087
2088 let collector = NativeCollector::new(dir.path());
2089 let tests = collector.collect().unwrap();
2090
2091 assert_eq!(tests.len(), 4);
2093
2094 let slow_test = tests.iter().find(|t| t.name == "test_expensive").unwrap();
2096 assert!(slow_test.markers.contains(&"slow".to_string()));
2097
2098 let param_tests: Vec<&NativeTestNode> =
2100 tests.iter().filter(|t| t.name == "test_param").collect();
2101 assert_eq!(param_tests.len(), 3);
2102
2103 let ids: Vec<&str> = param_tests
2105 .iter()
2106 .filter_map(|t| {
2107 if let Some(start) = t.node_id.find("[") {
2109 if let Some(end) = t.node_id.find("]") {
2110 return Some(&t.node_id[start + 1..end]);
2111 }
2112 }
2113 None
2114 })
2115 .collect();
2116 assert_eq!(ids.len(), 3);
2117 }
2118
2119 #[test]
2120 fn test_collect_async() {
2121 let dir = TempDir::new().unwrap();
2122 let test_file = dir.path().join("test_async.py");
2123 fs::write(
2124 &test_file,
2125 r#"
2126import pytest
2127
2128@pytest.mark.asyncio
2129async def test_async_basic():
2130 assert True
2131
2132async def test_async_another():
2133 assert True
2134"#,
2135 )
2136 .unwrap();
2137
2138 let collector = NativeCollector::new(dir.path());
2139 let tests = collector.collect().unwrap();
2140
2141 assert_eq!(tests.len(), 2);
2143
2144 let test_names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2145 assert!(test_names.contains(&"test_async_basic"));
2146 assert!(test_names.contains(&"test_async_another"));
2147 }
2148
2149 #[test]
2150 fn test_collect_skip_xfail() {
2151 let dir = TempDir::new().unwrap();
2152 let test_file = dir.path().join("test_skip.py");
2153 fs::write(
2154 &test_file,
2155 r#"
2156import pytest
2157
2158@pytest.mark.skip(reason="intentional")
2159def test_skipped():
2160 pass
2161
2162@pytest.mark.xfail
2163def test_xfailed():
2164 assert False
2165"#,
2166 )
2167 .unwrap();
2168
2169 let collector = NativeCollector::new(dir.path());
2170 let tests = collector.collect().unwrap();
2171
2172 assert_eq!(tests.len(), 2);
2174
2175 let skipped = tests.iter().find(|t| t.name == "test_skipped").unwrap();
2176 assert!(skipped.markers.contains(&"skip".to_string()));
2177
2178 let xfailed = tests.iter().find(|t| t.name == "test_xfailed").unwrap();
2179 assert!(xfailed.markers.contains(&"xfail".to_string()));
2180 }
2181
2182 #[test]
2183 fn test_collect_parametrized_class_methods() {
2184 let dir = TempDir::new().unwrap();
2185 let test_file = dir.path().join("test_class_param.py");
2186 fs::write(
2187 &test_file,
2188 r#"
2189import pytest
2190
2191class TestParametrized:
2192 @pytest.mark.parametrize("n", [1, 2, 3])
2193 def test_method(self, n):
2194 assert n > 0
2195"#,
2196 )
2197 .unwrap();
2198
2199 let collector = NativeCollector::new(dir.path());
2200 let tests = collector.collect().unwrap();
2201
2202 assert_eq!(tests.len(), 3);
2204
2205 let param_tests: Vec<&NativeTestNode> = tests
2207 .iter()
2208 .filter(|t| t.name == "test_method" && t.class_name.is_some())
2209 .collect();
2210 assert_eq!(param_tests.len(), 3);
2211 assert_eq!(
2212 param_tests[0].class_name,
2213 Some("TestParametrized".to_string())
2214 );
2215 }
2216
2217 #[test]
2218 fn test_collect_stacked_parametrize() {
2219 let dir = TempDir::new().unwrap();
2220 let test_file = dir.path().join("test_stacked.py");
2221 fs::write(
2222 &test_file,
2223 r#"
2224import pytest
2225
2226@pytest.mark.parametrize("x", [1, 2])
2227@pytest.mark.parametrize("y", [10, 20])
2228def test_stacked_parametrize(x, y):
2229 """Stacked parametrize creates cartesian product."""
2230 assert True
2231"#,
2232 )
2233 .unwrap();
2234
2235 let collector = NativeCollector::new(dir.path());
2236 let tests = collector.collect().unwrap();
2237
2238 assert_eq!(tests.len(), 4);
2240
2241 let node_ids: Vec<&str> = tests
2243 .iter()
2244 .filter_map(|t| {
2245 if t.name == "test_stacked_parametrize" {
2246 if let Some(start) = t.node_id.find("[") {
2248 if let Some(end) = t.node_id.find("]") {
2249 return Some(&t.node_id[start + 1..end]);
2250 }
2251 }
2252 }
2253 None
2254 })
2255 .collect();
2256
2257 assert_eq!(node_ids.len(), 4);
2258 assert!(node_ids.contains(&"10-1"));
2260 assert!(node_ids.contains(&"10-2"));
2261 assert!(node_ids.contains(&"20-1"));
2262 assert!(node_ids.contains(&"20-2"));
2263 }
2264
2265 #[test]
2266 fn test_collect_class_markers() {
2267 let dir = TempDir::new().unwrap();
2268 let test_file = dir.path().join("test_class_markers.py");
2269 fs::write(
2270 &test_file,
2271 r#"
2272import pytest
2273
2274@pytest.mark.slow
2275class TestMarkedClass:
2276 def test_one(self):
2277 assert True
2278
2279 def test_two(self):
2280 assert True
2281"#,
2282 )
2283 .unwrap();
2284
2285 let collector = NativeCollector::new(dir.path());
2286 let tests = collector.collect().unwrap();
2287
2288 assert_eq!(tests.len(), 2);
2290
2291 for test in &tests {
2293 assert!(
2294 test.markers.contains(&"slow".to_string()),
2295 "Test {} should have 'slow' marker from class",
2296 test.name
2297 );
2298 }
2299 }
2300
2301 #[test]
2302 fn test_collect_skipif() {
2303 let dir = TempDir::new().unwrap();
2304 let test_file = dir.path().join("test_skipif.py");
2305 fs::write(
2306 &test_file,
2307 r#"
2308import pytest
2309import sys
2310
2311@pytest.mark.skipif(sys.version_info > (0, 0), reason="always skips")
2312def test_skipif():
2313 pass
2314"#,
2315 )
2316 .unwrap();
2317
2318 let collector = NativeCollector::new(dir.path());
2319 let tests = collector.collect().unwrap();
2320
2321 assert_eq!(tests.len(), 1);
2323
2324 let test = &tests[0];
2325 assert!(test.markers.contains(&"skipif".to_string()));
2327 assert!(!test.skip);
2329 }
2330
2331 #[test]
2332 fn test_collect_xfail_with_condition() {
2333 let dir = TempDir::new().unwrap();
2334 let test_file = dir.path().join("test_xfail_cond.py");
2335 fs::write(
2336 &test_file,
2337 r#"
2338import pytest
2339
2340@pytest.mark.xfail(condition=False, reason="condition is false")
2341def test_xfail_false_condition():
2342 assert True
2343"#,
2344 )
2345 .unwrap();
2346
2347 let collector = NativeCollector::new(dir.path());
2348 let tests = collector.collect().unwrap();
2349
2350 assert_eq!(tests.len(), 1);
2352
2353 let test = &tests[0];
2354 assert!(test.markers.contains(&"xfail".to_string()));
2356 }
2357
2358 #[test]
2359 fn test_collect_param_with_per_variant_marks() {
2360 let dir = TempDir::new().unwrap();
2361 let test_file = dir.path().join("test_param_marks.py");
2362 fs::write(
2363 &test_file,
2364 r#"
2365import pytest
2366
2367class TestParamMarks:
2368 @pytest.mark.parametrize("x", [
2369 pytest.param(1, marks=pytest.mark.slow),
2370 pytest.param(2),
2371 pytest.param(3, marks=[pytest.mark.skip(reason="test skip")]),
2372 ])
2373 def test_param_marks(self, x):
2374 assert x > 0
2375"#,
2376 )
2377 .unwrap();
2378
2379 let collector = NativeCollector::new(dir.path());
2380 let tests = collector.collect().unwrap();
2381
2382 assert_eq!(tests.len(), 3);
2384
2385 let test1 = tests.iter().find(|t| t.node_id.contains("[1]")).unwrap();
2388 assert!(
2389 test1.markers.contains(&"slow".to_string()),
2390 "Test 1 should have 'slow' marker"
2391 );
2392
2393 let test2 = tests.iter().find(|t| t.node_id.contains("[2]")).unwrap();
2395 assert!(
2396 !test2.markers.contains(&"slow".to_string()),
2397 "Test 2 should not have 'slow' marker"
2398 );
2399 assert!(
2400 !test2.markers.contains(&"skip".to_string()),
2401 "Test 2 should not have 'skip' marker"
2402 );
2403
2404 let test3 = tests.iter().find(|t| t.node_id.contains("[3]")).unwrap();
2406 assert!(
2407 test3.markers.contains(&"skip".to_string()),
2408 "Test 3 should have 'skip' marker from pytest.param"
2409 );
2410 }
2411
2412 #[test]
2413 fn test_line_numbers() {
2414 let dir = TempDir::new().unwrap();
2415 let test_file = dir.path().join("test_lines.py");
2416 fs::write(
2429 &test_file,
2430 r#"# Line 1 - comment
2431# Line 2 - comment
2432
2433def test_first():
2434 assert True
2435
2436def test_second():
2437 pass
2438
2439class TestClass:
2440 def test_method(self):
2441 pass
2442"#,
2443 )
2444 .unwrap();
2445
2446 let collector = NativeCollector::new(dir.path());
2447 let tests = collector.collect().unwrap();
2448
2449 assert_eq!(tests.len(), 3);
2451
2452 let test_first = tests.iter().find(|t| t.name == "test_first").unwrap();
2454 assert_eq!(test_first.line_number, 4, "test_first should be at line 4");
2455
2456 let test_second = tests.iter().find(|t| t.name == "test_second").unwrap();
2457 assert_eq!(
2458 test_second.line_number, 7,
2459 "test_second should be at line 7"
2460 );
2461
2462 let test_method = tests.iter().find(|t| t.name == "test_method").unwrap();
2463 assert_eq!(
2464 test_method.line_number, 11,
2465 "test_method should be at line 11"
2466 );
2467 }
2468}