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.markers.push(format!("fixture_param:{}={}", fixture_name, param_value));
196 expanded.push(variant);
197 }
198 } else {
199 let combinations = self.cartesian_product_of_fixture_params(&fixture_params_for_test);
201 for combo in combinations {
202 let mut variant = test.clone();
203 let id_parts: Vec<String> = combo.iter()
205 .enumerate()
206 .map(|(idx, (_, param_value))| format!("{}={}", idx + 1, param_value))
207 .collect();
208 variant.node_id = format!("{}[{}]", test.node_id, id_parts.join("-"));
209 expanded.push(variant);
210 }
211 }
212 }
213
214 expanded
215 }
216
217 fn get_fixture_params_for_test(
219 &self,
220 test: &NativeTestNode,
221 fixture_params: &HashMap<String, Vec<String>>,
222 ) -> Vec<(String, Vec<String>)> {
223 let mut result = Vec::new();
224
225 for (fixture_name, param_values) in fixture_params {
227 let expected_marker = format!("uses_fixture:{}", fixture_name);
231 for marker in &test.markers {
232 if marker == &expected_marker {
233 result.push((fixture_name.clone(), param_values.clone()));
234 break;
235 }
236 }
237 }
238
239 result
240 }
241
242 fn cartesian_product_of_fixture_params(
244 &self,
245 fixture_params: &[(String, Vec<String>)],
246 ) -> Vec<Vec<(String, String)>> {
247 if fixture_params.is_empty() {
248 return Vec::new();
249 }
250
251 if fixture_params.len() == 1 {
252 return fixture_params[0].1.iter()
253 .map(|p| vec![(fixture_params[0].0.clone(), p.clone())])
254 .collect();
255 }
256
257 fn compute_product(
259 params: &[(String, Vec<String>)],
260 index: usize,
261 ) -> Vec<Vec<(String, String)>> {
262 if index >= params.len() {
263 return vec![Vec::new()];
264 }
265
266 let (name, values) = ¶ms[index];
267 let rest = compute_product(params, index + 1);
268
269 let mut result = Vec::new();
270 for value in values {
271 for mut combo in rest.clone() {
272 combo.insert(0, (name.clone(), value.clone()));
273 result.push(combo);
274 }
275 }
276
277 result
278 }
279
280 compute_product(fixture_params, 0)
281 }
282
283 fn find_test_files(&self) -> Result<Vec<PathBuf>> {
285 let mut test_files = Vec::new();
286
287 for entry in walkdir::WalkDir::new(&self.repo_path)
289 .follow_links(true)
290 .into_iter()
291 {
292 let entry = entry?;
293 let path = entry.path().to_path_buf();
294
295 if path.is_dir() {
297 continue;
298 }
299
300 let path_str = path.to_string_lossy();
302 if path_str.contains("/.venv/")
303 || path_str.contains("/venv/")
304 || path_str.contains("/node_modules/")
305 || path_str.contains("/.git/")
306 {
307 continue;
308 }
309
310 if let Some(ext) = path.extension() {
312 if ext == "py" {
313 let file_name = path.file_name().unwrap().to_string_lossy();
314 if file_name.starts_with("test_") || file_name.ends_with("_test.py") {
316 test_files.push(path);
317 }
318 }
319 }
320 }
321
322 debug!("Found {} test files", test_files.len());
323 Ok(test_files)
324 }
325
326 fn parse_test_file(&self, path: &PathBuf) -> Result<Vec<NativeTestNode>> {
328 let content = match std::fs::read_to_string(path) {
329 Ok(c) => c,
330 Err(e) => {
331 warn!("Failed to read {}: {}", path.display(), e);
332 return Ok(Vec::new());
333 }
334 };
335
336 let syntax = ast::Suite::parse(&content, "<test>");
338
339 match syntax {
340 Ok(stmts) => self.extract_tests_from_ast(&stmts, path, &content),
341 Err(e) => {
342 warn!("Failed to parse {}: {}", path.display(), e);
343 Ok(Vec::new())
344 }
345 }
346 }
347
348 fn byte_offset_to_line(&self, source: &str, offset: usize) -> u32 {
350 let offset = offset.min(source.len());
351 let line = source[..offset].chars().filter(|&c| c == '\n').count() + 1;
352 line as u32
353 }
354
355 fn extract_tests_from_ast(
357 &self,
358 stmts: &[ast::Stmt],
359 path: &Path,
360 source: &str,
361 ) -> Result<Vec<NativeTestNode>> {
362 let mut tests = Vec::new();
363 let file_path = path.to_string_lossy().to_string();
364
365 let relative_path = if let Ok(stripped) = path.strip_prefix(&self.repo_path) {
367 stripped.to_string_lossy().to_string()
368 } else {
369 file_path.clone()
370 };
371
372 let mut conftest_fixtures: Vec<String> = Vec::new();
374 let is_conftest = path.file_name().unwrap().to_string_lossy() == "conftest.py";
375
376 if is_conftest {
377 conftest_fixtures = self.extract_conftest_fixtures(stmts);
378 }
379
380 let file_fixtures = self.extract_conftest_fixtures(stmts);
382
383 let mut all_fixtures = conftest_fixtures;
385 for fixture in &file_fixtures {
386 if !all_fixtures.contains(fixture) {
387 all_fixtures.push(fixture.clone());
388 }
389 }
390
391 self.extract_stmt_items(stmts, "", &relative_path, &all_fixtures, source, &mut tests);
393
394 Ok(tests)
395 }
396
397 fn extract_conftest_fixtures(&self, stmts: &[ast::Stmt]) -> Vec<String> {
399 let mut fixtures = Vec::new();
400
401 for stmt in stmts {
402 match stmt {
403 ast::Stmt::FunctionDef(func) => {
404 self.check_for_fixture(func, &mut fixtures);
405 }
406 ast::Stmt::AsyncFunctionDef(func) => {
407 self.check_for_async_fixture(func, &mut fixtures);
408 }
409 _ => {}
410 }
411 }
412
413 fixtures
414 }
415
416 fn check_for_fixture(&self, func: &ast::StmtFunctionDef, fixtures: &mut Vec<String>) {
418 for decorator in &func.decorator_list {
419 if let Some(name) = self.get_decorator_name(decorator) {
420 if name == "fixture" || name == "pytest.fixture" {
422 fixtures.push(func.name.to_string());
423 self.extract_fixture_params(decorator, &func.name);
425 }
426 }
427 }
428 }
429
430 fn extract_fixture_params(&self, decorator: &ast::Expr, fixture_name: &str) {
432 let keywords = match decorator {
434 ast::Expr::Call(ast::ExprCall { keywords, .. }) => keywords,
435 _ => return,
436 };
437
438 for keyword in keywords {
440 if keyword.arg.as_deref() == Some("params") {
441 if let Some(param_values) = self.extract_list_param_values(&keyword.value) {
443 let mut fixture_params = self.fixture_params.lock().unwrap();
444 fixture_params.insert(fixture_name.to_string(), param_values);
445 }
446 }
447 }
448 }
449
450 fn extract_list_param_values(&self, expr: &ast::Expr) -> Option<Vec<String>> {
452 match expr {
453 ast::Expr::List(ast::ExprList { elts, .. }) => {
454 let mut values = Vec::new();
455 for elt in elts {
456 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
457 values.push(self.format_constant(value));
458 } else {
459 values.push(format!("{:?}", elt));
461 }
462 }
463 Some(values)
464 }
465 _ => None,
466 }
467 }
468
469 fn check_for_async_fixture(&self, func: &ast::StmtAsyncFunctionDef, fixtures: &mut Vec<String>) {
471 for decorator in &func.decorator_list {
472 if let Some(name) = self.get_decorator_name(decorator) {
473 if name == "fixture" || name == "pytest.fixture" {
475 fixtures.push(func.name.to_string());
476 self.extract_fixture_params(decorator, &func.name);
478 }
479 }
480 }
481 }
482
483 fn get_decorator_name(&self, decorator: &ast::Expr) -> Option<String> {
485 match decorator {
486 ast::Expr::Call(ast::ExprCall { func, .. }) => self.get_decorator_name(func),
488 ast::Expr::Attribute(ExprAttribute { attr, value, .. }) => {
490 let attr_str = attr.to_string();
491 if let Some(inner) = self.get_decorator_name(value) {
492 Some(format!("{}.{}", inner, attr_str))
494 } else {
495 Some(attr_str)
497 }
498 }
499 ast::Expr::Name(ExprName { id, .. }) => Some(id.to_string()),
500 _ => None,
501 }
502 }
503
504 fn extract_parametrize_info(&self, decorator: &ast::Expr) -> Option<ParameterizedTestNode> {
506 if let ast::Expr::Call(ast::ExprCall { func, args, .. }) = decorator {
508 let func_name = self.get_decorator_name(func);
509 if func_name.as_deref() != Some("pytest.mark.parametrize") {
510 return None;
511 }
512
513 if args.len() < 2 {
516 return None;
517 }
518
519 let param_names: Vec<String> = match &args[0] {
521 ast::Expr::Constant(ast::ExprConstant { value: ast::Constant::Str(s), .. }) => {
522 s.split(',').map(|s| s.trim().to_string()).collect()
523 }
524 _ => return None,
525 };
526
527 let param_values_with_info = self.extract_param_values_with_info(&args[1])?;
529
530 let param_values: Vec<(Vec<String>, Option<String>, Vec<String>)> = param_values_with_info
533 .iter()
534 .map(|(values, custom_id, marks)| (values.clone(), custom_id.clone(), marks.clone()))
535 .collect();
536
537 Some(ParameterizedTestNode {
538 param_names,
539 param_values,
540 test_id: String::new(),
541 })
542 } else {
543 None
544 }
545 }
546
547 #[allow(dead_code)]
549 fn extract_param_values(&self, expr: &ast::Expr) -> Option<Vec<Vec<String>>> {
550 match expr {
551 ast::Expr::List(ast::ExprList { elts, .. }) => {
553 let mut all_values = Vec::new();
554 for elt in elts {
555 if let Some((values, _, _)) = self.extract_single_param_value_full(elt) {
556 all_values.push(values);
557 } else {
558 return None;
559 }
560 }
561 Some(all_values)
562 }
563 _ => None,
564 }
565 }
566
567 fn extract_single_param_value_full(&self, expr: &ast::Expr) -> Option<(Vec<String>, Option<String>, Vec<String>)> {
570 match expr {
571 ast::Expr::Constant(ast::ExprConstant { value, .. }) => {
573 Some((vec![self.format_constant(value)], None, Vec::new()))
574 }
575 ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
577 let mut values = Vec::new();
578 for elt in elts {
579 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
580 values.push(self.format_constant(value));
581 } else {
582 values.push(format!("{:?}", elt));
584 }
585 }
586 Some((values, None, Vec::new()))
587 }
588 ast::Expr::Dict(ast::ExprDict { keys, values, .. }) => {
590 let parts: Vec<String> = keys.iter().zip(values.iter()).filter_map(|(k, v)| {
592 if let (
594 Some(k_const),
595 ast::Expr::Constant(ast::ExprConstant { value, .. })
596 ) = (
597 k.as_ref().and_then(|k| {
598 if let ast::Expr::Constant(ast::ExprConstant { value: ast::Constant::Str(s), .. }) = k {
599 Some(s)
600 } else {
601 None
602 }
603 }),
604 v
605 ) {
606 Some(format!("{}={}", k_const, self.format_constant(value)))
607 } else {
608 None
609 }
610 }).collect();
611 let repr = if parts.is_empty() {
612 "{}".to_string()
613 } else {
614 format!("{{{}}}", parts.join(","))
615 };
616 Some((vec![repr], None, Vec::new()))
617 }
618 ast::Expr::List(ast::ExprList { elts, .. }) => {
620 let parts: Vec<String> = elts.iter().filter_map(|elt| {
621 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
622 Some(self.format_constant(value))
623 } else {
624 None
625 }
626 }).collect();
627 let repr = if parts.is_empty() {
628 "[]".to_string()
629 } else {
630 format!("[{}]", parts.join(","))
631 };
632 Some((vec![repr], None, Vec::new()))
633 }
634 ast::Expr::Call(ast::ExprCall { func, args, keywords, .. }) => {
636 let is_param = match func.as_ref() {
641 ast::Expr::Name(name_expr) => name_expr.id.as_str() == "param",
642 ast::Expr::Attribute(attr) => {
643 if attr.attr.as_str() == "param" {
644 if let ast::Expr::Name(name_expr) = &*attr.value {
645 name_expr.id.as_str() == "param" || name_expr.id.as_str() == "pytest"
646 } else {
647 false
648 }
649 } else {
650 false
651 }
652 }
653 _ => false,
654 };
655
656 if is_param {
657 let custom_id = keywords.iter()
659 .find(|kw| kw.arg.as_deref() == Some("id"))
660 .and_then(|kw| {
661 if let ast::Expr::Constant(ast::ExprConstant { value: ast::Constant::Str(s), .. }) = &kw.value {
662 Some(s.clone())
663 } else {
664 None
665 }
666 });
667
668 let marks = self.parse_marks_from_keywords(keywords);
670
671 if args.is_empty() {
673 return Some((vec!["param".to_string()], custom_id, marks));
674 }
675 if let Some((values, _, _)) = self.extract_single_param_value_full(&args[0]) {
677 return Some((values, custom_id, marks));
678 }
679 }
680 None
681 }
682 ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => {
684 match (op, operand.as_ref()) {
685 (ast::UnaryOp::USub, ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
686 if let ast::Constant::Int(n) = value {
689 if n.to_string() == "0" {
690 return Some((vec!["0".to_string()], None, Vec::new()));
692 }
693 }
694 let formatted = self.format_constant(value);
695 Some((vec![format!("-{}", formatted)], None, Vec::new()))
696 }
697 (ast::UnaryOp::UAdd, ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
698 Some((vec![self.format_constant(value)], None, Vec::new()))
700 }
701 (ast::UnaryOp::Not, _) => {
702 Some((vec![format!("{:?}", expr)], None, Vec::new()))
704 }
705 _ => Some((vec![format!("{:?}", expr)], None, Vec::new())),
706 }
707 }
708 _ => Some((vec![format!("{:?}", expr)], None, Vec::new())),
710 }
711 }
712
713 fn parse_marks_from_keywords(&self, keywords: &[ast::Keyword]) -> Vec<String> {
715 let mut marks = Vec::new();
716
717 for keyword in keywords {
718 if keyword.arg.as_deref() == Some("marks") {
719 self.extract_marks_from_expr(&keyword.value, &mut marks);
721 }
722 }
723
724 marks
725 }
726
727 fn extract_marks_from_expr(&self, expr: &ast::Expr, marks: &mut Vec<String>) {
729 match expr {
730 ast::Expr::Call(call) => {
732 if let Some(name) = self.get_decorator_name(&call.func) {
733 let marker = name.replace("pytest.mark.", "");
734 marks.push(marker);
735 }
736 }
737 ast::Expr::List(list) => {
739 for elt in &list.elts {
740 self.extract_marks_from_expr(elt, marks);
741 }
742 }
743 ast::Expr::Attribute(_attr) => {
745 if let Some(name) = self.get_decorator_name(expr) {
746 let marker = name.replace("pytest.mark.", "");
747 marks.push(marker);
748 }
749 }
750 _ => {}
751 }
752 }
753
754 fn extract_param_values_with_info(&self, expr: &ast::Expr) -> Option<Vec<ParamValueInfo>> {
757 match expr {
758 ast::Expr::List(ast::ExprList { elts, .. }) => {
760 let mut all_values = Vec::new();
761 for elt in elts {
762 if let Some((values, custom_id, marks)) = self.extract_single_param_value_full(elt) {
763 all_values.push((values, custom_id, marks));
764 } else {
765 return None;
766 }
767 }
768 Some(all_values)
769 }
770 _ => None,
771 }
772 }
773
774 fn format_constant(&self, value: &ast::Constant) -> String {
776 match value {
777 ast::Constant::Str(s) => {
778 s.clone()
781 }
782 ast::Constant::Int(n) => n.to_string(),
783 ast::Constant::Float(n) => {
784 let s = n.to_string();
785 if s.contains('e') || s.contains('E') {
787 format!("{:?}", n)
788 } else if !s.contains('.') {
789 format!("{}.0", s)
791 } else {
792 s
793 }
794 }
795 ast::Constant::Bool(b) => {
796 if *b { "True" } else { "False" }.to_string()
797 }
798 ast::Constant::None => "None".to_string(),
799 _ => format!("{:?}", value),
800 }
801 }
802
803 fn format_param_value(&self, value: &str) -> String {
806 if value == "{}" {
808 "dct".to_string()
810 } else if value.starts_with('{') {
811 "dct".to_string()
813 } else if value == "[]" {
814 "lst".to_string()
816 } else if value.starts_with('[') {
817 "lst".to_string()
819 } else if value == "None" {
820 "None".to_string()
822 } else if value == "True" || value == "False" {
823 value.to_string()
825 } else {
826 let sanitized = value
830 .replace('[', "_")
831 .replace(']', "_")
832 .replace("::", "_")
833 .replace('/', "_")
834 .replace('\n', "\\n")
836 .replace('\r', "\\r")
837 .replace('\t', "\\t")
838 .replace('\\', "\\");
840 if sanitized.len() > 30 {
842 format!("{}...", &sanitized[..27])
843 } else {
844 sanitized
845 }
846 }
847 }
848
849 fn categorize_value(&self, value: &str) -> ValueCategory {
851 if value == "{}" || value.starts_with('{') {
852 ValueCategory::Dict
853 } else if value == "[]" || value.starts_with('[') {
854 ValueCategory::List
855 } else {
856 ValueCategory::Simple
857 }
858 }
859
860 fn has_mixed_param_types(&self, param_values: &[(Vec<String>, Option<String>, Vec<String>)]) -> bool {
863 let mut has_dict = false;
864 let mut has_list = false;
865 let mut has_simple = false;
866
867 for (values, _, _) in param_values {
868 for value in values {
869 match self.categorize_value(value) {
870 ValueCategory::Dict => has_dict = true,
871 ValueCategory::List => has_list = true,
872 ValueCategory::Simple => has_simple = true,
873 }
874 }
875 }
876
877 (has_dict || has_list) && has_simple
880 }
881
882 fn deduplicate_ids(&self, ids: &[String]) -> Vec<String> {
885 let mut result = Vec::with_capacity(ids.len());
886 let mut seen: std::collections::HashMap<&str, Vec<usize>> = std::collections::HashMap::new();
887
888 for (idx, id) in ids.iter().enumerate() {
890 seen.entry(id.as_str()).or_default().push(idx);
891 }
892
893 let mut counters: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
895 for id in ids {
896 let indices = &seen[id.as_str()];
897 if indices.len() > 1 {
898 let counter = counters.entry(id.as_str()).or_insert(0);
900 result.push(format!("{}_{}", id, counter));
901 *counter += 1;
902 } else {
903 result.push(id.clone());
905 }
906 }
907
908 result
909 }
910
911 fn generate_pytest_id(&self, values: &[String], idx: usize) -> String {
914 self.generate_pytest_id_with_context(values, idx, false)
915 }
916
917 fn generate_pytest_id_with_context(&self, values: &[String], idx: usize, use_value_for_containers: bool) -> String {
920 if values.len() == 1 {
921 let formatted = if use_value_for_containers {
923 self.format_param_value_with_context(&values[0], true)
924 } else {
925 self.format_param_value(&values[0])
926 };
927 if formatted == "dct" || formatted == "lst" || formatted == "value" {
930 format!("{}{}", formatted, idx)
931 } else {
932 formatted
933 }
934 } else {
935 let formatted: Vec<String> = values
937 .iter()
938 .map(|v| {
939 if use_value_for_containers {
940 self.format_param_value_with_context(v, true)
941 } else {
942 self.format_param_value(v)
943 }
944 })
945 .collect();
946 formatted.join("-")
947 }
948 }
949
950 fn format_param_value_with_context(&self, value: &str, use_value_for_containers: bool) -> String {
952 if use_value_for_containers {
953 if value == "{}" || value.starts_with('{') || value == "[]" || value.starts_with('[') {
955 return "value".to_string();
956 }
957 }
958 self.format_param_value(value)
959 }
960
961 fn extract_pytest_markers(&self, decorators: &[ast::Expr]) -> Vec<String> {
963 let mut markers = Vec::new();
964
965 for decorator in decorators {
966 if let Some(name) = self.get_decorator_name(decorator) {
967 if self.builtin_fixtures.contains(&name.as_str()) {
969 continue;
970 }
971
972 if name.starts_with("pytest.mark.") {
974 let marker = name.replace("pytest.mark.", "");
975 markers.push(marker);
976 }
977 }
978 }
979
980 markers
981 }
982
983 fn extract_stmt_items(
985 &self,
986 stmts: &[ast::Stmt],
987 class_name: &str,
988 file_path: &str,
989 conftest_fixtures: &[String],
990 source: &str,
991 tests: &mut Vec<NativeTestNode>,
992 ) {
993 for stmt in stmts {
994 match stmt {
995 ast::Stmt::FunctionDef(func) => {
997 self.process_function_def(func, class_name, file_path, conftest_fixtures, source, tests);
998 }
999 ast::Stmt::AsyncFunctionDef(func) => {
1001 self.process_async_function_def(func, class_name, file_path, conftest_fixtures, source, tests);
1002 }
1003 ast::Stmt::ClassDef(class_def) => {
1004 let class_name_str = &class_def.name;
1005
1006 if class_name_str.starts_with("Test") {
1009 let class_markers = self.extract_pytest_markers(&class_def.decorator_list);
1011
1012 let ctx = TestProcessingContext {
1014 file_path,
1015 class_name: class_name_str,
1016 class_markers: &class_markers,
1017 source,
1018 };
1019 self.extract_stmt_items_with_class_markers(
1020 &class_def.body,
1021 &ctx,
1022 conftest_fixtures,
1023 tests,
1024 );
1025
1026 let base_class_names = self.extract_base_class_names(class_def);
1029 for base_name in base_class_names {
1030 if let Some(base_class) = self.find_class_in_stmts(stmts, &base_name) {
1032 self.collect_inherited_tests(
1034 base_class,
1035 class_name_str, &class_markers,
1037 file_path,
1038 conftest_fixtures,
1039 source,
1040 tests,
1041 stmts, );
1043 }
1044 }
1045 }
1046 }
1047 _ => {}
1048 }
1049 }
1050 }
1051
1052 fn extract_stmt_items_with_class_markers(
1054 &self,
1055 stmts: &[ast::Stmt],
1056 ctx: &TestProcessingContext,
1057 conftest_fixtures: &[String],
1058 tests: &mut Vec<NativeTestNode>,
1059 ) {
1060 for stmt in stmts {
1061 match stmt {
1062 ast::Stmt::FunctionDef(func) => {
1063 self.process_function_def_with_class_markers(func, ctx, conftest_fixtures, tests);
1064 }
1065 ast::Stmt::AsyncFunctionDef(func) => {
1066 self.process_async_function_def_with_class_markers(func, ctx, conftest_fixtures, tests);
1067 }
1068 ast::Stmt::ClassDef(nested_class) => {
1069 if nested_class.name.starts_with("Test") {
1072 let nested_class_markers = self.extract_pytest_markers(&nested_class.decorator_list);
1073 let mut combined_markers = ctx.class_markers.to_vec();
1075 combined_markers.extend(nested_class_markers);
1076
1077 let nested_ctx = TestProcessingContext {
1078 file_path: ctx.file_path,
1079 class_name: &nested_class.name,
1080 class_markers: &combined_markers,
1081 source: ctx.source,
1082 };
1083 self.extract_stmt_items_with_class_markers(
1084 &nested_class.body,
1085 &nested_ctx,
1086 conftest_fixtures,
1087 tests,
1088 );
1089 }
1090 }
1091 _ => {}
1092 }
1093 }
1094 }
1095
1096 fn process_function_def(
1098 &self,
1099 func: &ast::StmtFunctionDef,
1100 class_name: &str,
1101 file_path: &str,
1102 conftest_fixtures: &[String],
1103 source: &str,
1104 tests: &mut Vec<NativeTestNode>,
1105 ) {
1106 let ctx = TestProcessingContext {
1107 file_path,
1108 class_name,
1109 class_markers: &[],
1110 source,
1111 };
1112 self.process_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1113 }
1114
1115 fn process_function_def_with_class_markers(
1117 &self,
1118 func: &ast::StmtFunctionDef,
1119 ctx: &TestProcessingContext,
1120 conftest_fixtures: &[String],
1121 tests: &mut Vec<NativeTestNode>,
1122 ) {
1123 let fn_name = &func.name;
1124
1125 if fn_name.starts_with("test_") || fn_name.starts_with("Test") {
1127 let decorators = &func.decorator_list;
1128
1129 let has_skip_decorator = decorators.iter().any(|d| {
1132 if let Some(name) = self.get_decorator_name(d) {
1133 SKIP_DECORATORS.contains(&name.as_str())
1134 } else {
1135 false
1136 }
1137 });
1138 if has_skip_decorator {
1139 return;
1140 }
1141
1142 let line_number = self.extract_line_number_stmt(func, ctx.source);
1143
1144 let (method_markers, parametrize_infos) = self.extract_markers_and_parametrize(decorators);
1146
1147 let mut all_markers = ctx.class_markers.to_vec();
1149 all_markers.extend(method_markers);
1150
1151 let used_fixtures = self.get_pytest_fixture_usage_func(&func.args, conftest_fixtures);
1153 let uses_external_fixtures = !used_fixtures.is_empty();
1154 for fixture_name in &used_fixtures {
1155 all_markers.push(format!("uses_fixture:{}", fixture_name));
1156 }
1157
1158 let skip_xfail = self.extract_skip_xfail_info(decorators);
1160
1161 if parametrize_infos.is_empty() {
1163 let test_ctx = TestCreationContext {
1165 fn_name,
1166 line_number,
1167 markers: &all_markers,
1168 uses_external_fixtures,
1169 skip_xfail,
1170 };
1171 self.create_test_node(&test_ctx, ctx.file_path, ctx.class_name, tests);
1172 } else if parametrize_infos.len() == 1 {
1173 let test_ctx = TestCreationContext {
1175 fn_name,
1176 line_number,
1177 markers: &all_markers,
1178 uses_external_fixtures,
1179 skip_xfail,
1180 };
1181 let param_info = parametrize_infos.into_iter().next().unwrap();
1182 self.expand_parametrized_tests(&test_ctx, ctx.file_path, ctx.class_name, param_info, tests);
1183 } else {
1184 let mut reversed_infos = parametrize_infos.clone();
1187 reversed_infos.reverse();
1188 let test_ctx = TestCreationContext {
1189 fn_name,
1190 line_number,
1191 markers: &all_markers,
1192 uses_external_fixtures,
1193 skip_xfail,
1194 };
1195 self.expand_stacked_parametrized_tests(&test_ctx, ctx.file_path, ctx.class_name, reversed_infos, tests);
1196 }
1197 }
1198 }
1199
1200 fn process_async_function_def(
1202 &self,
1203 func: &ast::StmtAsyncFunctionDef,
1204 class_name: &str,
1205 file_path: &str,
1206 conftest_fixtures: &[String],
1207 source: &str,
1208 tests: &mut Vec<NativeTestNode>,
1209 ) {
1210 let ctx = TestProcessingContext {
1211 file_path,
1212 class_name,
1213 class_markers: &[],
1214 source,
1215 };
1216 self.process_async_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1217 }
1218
1219 fn process_async_function_def_with_class_markers(
1221 &self,
1222 func: &ast::StmtAsyncFunctionDef,
1223 ctx: &TestProcessingContext,
1224 conftest_fixtures: &[String],
1225 tests: &mut Vec<NativeTestNode>,
1226 ) {
1227 let fn_name = &func.name;
1228
1229 if fn_name.starts_with("test_") || fn_name.starts_with("Test") {
1231 let decorators = &func.decorator_list;
1232
1233 let has_skip_decorator = decorators.iter().any(|d| {
1236 if let Some(name) = self.get_decorator_name(d) {
1237 SKIP_DECORATORS.contains(&name.as_str())
1238 } else {
1239 false
1240 }
1241 });
1242 if has_skip_decorator {
1243 return;
1244 }
1245
1246 let line_number = self.extract_line_number_async_stmt(func, ctx.source);
1247
1248 let (method_markers, parametrize_infos) = self.extract_markers_and_parametrize(decorators);
1250
1251 let mut all_markers = ctx.class_markers.to_vec();
1253 all_markers.extend(method_markers);
1254
1255 let uses_external_fixtures = self.check_pytest_fixture_usage_async(&func.args, conftest_fixtures);
1257
1258 let skip_xfail = self.extract_skip_xfail_info(decorators);
1260
1261 if parametrize_infos.is_empty() {
1263 let test_ctx = TestCreationContext {
1265 fn_name,
1266 line_number,
1267 markers: &all_markers,
1268 uses_external_fixtures,
1269 skip_xfail,
1270 };
1271 self.create_test_node(&test_ctx, ctx.file_path, ctx.class_name, tests);
1272 } else if parametrize_infos.len() == 1 {
1273 let test_ctx = TestCreationContext {
1275 fn_name,
1276 line_number,
1277 markers: &all_markers,
1278 uses_external_fixtures,
1279 skip_xfail,
1280 };
1281 let param_info = parametrize_infos.into_iter().next().unwrap();
1282 self.expand_parametrized_tests(&test_ctx, ctx.file_path, ctx.class_name, param_info, tests);
1283 } else {
1284 let mut reversed_infos = parametrize_infos.clone();
1287 reversed_infos.reverse();
1288 let test_ctx = TestCreationContext {
1289 fn_name,
1290 line_number,
1291 markers: &all_markers,
1292 uses_external_fixtures,
1293 skip_xfail,
1294 };
1295 self.expand_stacked_parametrized_tests(&test_ctx, ctx.file_path, ctx.class_name, reversed_infos, tests);
1296 }
1297 }
1298 }
1299
1300 fn create_test_node(
1302 &self,
1303 ctx: &TestCreationContext,
1304 file_path: &str,
1305 class_name: &str,
1306 tests: &mut Vec<NativeTestNode>,
1307 ) {
1308 let node_id = if class_name.is_empty() {
1309 format!("{}::{}", file_path, ctx.fn_name)
1310 } else {
1311 format!("{}::{}::{}", file_path, class_name, ctx.fn_name)
1312 };
1313
1314 let mut final_markers = ctx.markers.to_vec();
1315 if ctx.skip_xfail.skip {
1316 final_markers.push("skip".to_string());
1317 }
1318 if ctx.skip_xfail.skipif {
1319 final_markers.push("skipif".to_string());
1320 }
1321 if ctx.skip_xfail.xfail {
1322 final_markers.push("xfail".to_string());
1323 }
1324
1325 tests.push(NativeTestNode {
1326 node_id,
1327 file_path: file_path.to_string(),
1328 name: ctx.fn_name.to_string(),
1329 class_name: if class_name.is_empty() { None } else { Some(class_name.to_string()) },
1330 line_number: ctx.line_number,
1331 markers: final_markers,
1332 is_simple: !ctx.uses_external_fixtures,
1333 parameters: Vec::new(),
1334 skip: ctx.skip_xfail.skip,
1337 skip_reason: ctx.skip_xfail.skip_reason.clone().or(ctx.skip_xfail.skipif_condition.clone()),
1338 xfail: ctx.skip_xfail.xfail,
1339 xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1340 xfail_strict: ctx.skip_xfail.xfail_strict,
1341 });
1342 }
1343
1344 fn extract_line_number_stmt(&self, func: &ast::StmtFunctionDef, source: &str) -> u32 {
1346 let offset = func.range.start().to_usize();
1347 self.byte_offset_to_line(source, offset)
1348 }
1349
1350 fn extract_line_number_async_stmt(&self, func: &ast::StmtAsyncFunctionDef, source: &str) -> u32 {
1352 let offset = func.range.start().to_usize();
1353 self.byte_offset_to_line(source, offset)
1354 }
1355
1356 fn extract_markers_and_parametrize(&self, decorators: &[ast::Expr]) -> (Vec<String>, Vec<ParameterizedTestNode>) {
1359 let mut markers = Vec::new();
1360 let mut parametrize_infos = Vec::new();
1361
1362 for decorator in decorators {
1363 if let Some(name) = self.get_decorator_name(decorator) {
1364 if self.builtin_fixtures.contains(&name.as_str()) {
1366 continue;
1367 }
1368
1369 if name == "pytest.mark.parametrize" {
1371 if let Some(info) = self.extract_parametrize_info(decorator) {
1372 parametrize_infos.push(info);
1373 }
1374 continue;
1375 }
1376
1377 if name.starts_with("pytest.mark.") {
1379 let marker = name.replace("pytest.mark.", "");
1380 markers.push(marker);
1381 }
1382 else if !name.starts_with("pytest.") {
1384 markers.push(name);
1385 }
1386 }
1387 }
1388
1389 (markers, parametrize_infos)
1390 }
1391
1392 fn extract_skip_xfail_info(&self, decorators: &[ast::Expr]) -> SkipXfailInfo {
1394 let mut info = SkipXfailInfo::default();
1395
1396 for decorator in decorators {
1397 if let Some(name) = self.get_decorator_name(decorator) {
1398 if name == "pytest.mark.skip" || name.starts_with("pytest.mark.skip(") {
1400 info.skip = true;
1401 info.skip_reason = self.extract_kwarg_string(decorator, "reason");
1402 }
1403 else if name == "pytest.mark.skipif" || name.starts_with("pytest.mark.skipif(") {
1405 info.skipif = true;
1406 info.skipif_condition = self.extract_skipif_condition(decorator);
1407 info.skip_reason = self.extract_kwarg_string(decorator, "reason");
1408 }
1409 else if name == "pytest.mark.xfail" || name.starts_with("pytest.mark.xfail(") {
1411 info.xfail = true;
1412 info.xfail_condition = self.extract_xfail_condition(decorator);
1413 info.xfail_reason = self.extract_kwarg_string(decorator, "reason");
1414 info.xfail_strict = self.extract_kwarg_bool(decorator, "strict");
1415 info.xfail_raises = self.extract_kwarg_string(decorator, "raises");
1416 }
1417 }
1418 }
1419
1420 info
1421 }
1422
1423 fn extract_kwarg_string(&self, decorator: &ast::Expr, arg: &str) -> Option<String> {
1425 if let ast::Expr::Call(ast::ExprCall { keywords, .. }) = decorator {
1426 for keyword in keywords {
1427 if keyword.arg.as_deref() == Some(arg) {
1428 if let ast::Expr::Constant(ast::ExprConstant { value: ast::Constant::Str(s), .. }) = &keyword.value {
1429 return Some(s.clone());
1430 }
1431 }
1432 }
1433 }
1434 None
1435 }
1436
1437 fn extract_kwarg_bool(&self, decorator: &ast::Expr, arg: &str) -> bool {
1439 if let ast::Expr::Call(ast::ExprCall { keywords, .. }) = decorator {
1440 for keyword in keywords {
1441 if keyword.arg.as_deref() == Some(arg) {
1442 if let ast::Expr::Constant(ast::ExprConstant { value: ast::Constant::Bool(b), .. }) = &keyword.value {
1443 return *b;
1444 }
1445 }
1446 }
1447 }
1448 false
1449 }
1450
1451 fn extract_skipif_condition(&self, decorator: &ast::Expr) -> Option<String> {
1453 if let ast::Expr::Call(ast::ExprCall { args, keywords, .. }) = decorator {
1454 if let Some(first_arg) = args.first() {
1456 return Some(format!("{:?}", first_arg));
1457 }
1458 for keyword in keywords {
1460 if keyword.arg.as_deref() == Some("condition") {
1461 return Some(format!("{:?}", keyword.value));
1462 }
1463 }
1464 }
1465 None
1466 }
1467
1468 fn extract_xfail_condition(&self, decorator: &ast::Expr) -> Option<String> {
1470 if let ast::Expr::Call(ast::ExprCall { args, keywords, .. }) = decorator {
1471 if let Some(first_arg) = args.first() {
1473 return Some(format!("{:?}", first_arg));
1474 }
1475 for keyword in keywords {
1477 if keyword.arg.as_deref() == Some("condition") {
1478 return Some(format!("{:?}", keyword.value));
1479 }
1480 }
1481 }
1482 None
1483 }
1484
1485 fn extract_base_class_names(&self, class_def: &ast::StmtClassDef) -> Vec<String> {
1487 let mut base_names = Vec::new();
1488 for base in &class_def.bases {
1489 if let ast::Expr::Name(name) = base {
1490 base_names.push(name.id.to_string());
1491 } else if let ast::Expr::Attribute(attr) = base {
1492 base_names.push(attr.attr.to_string());
1494 }
1495 }
1496 base_names
1497 }
1498
1499 fn find_class_in_stmts<'a>(&self, stmts: &'a [ast::Stmt], name: &str) -> Option<&'a ast::StmtClassDef> {
1501 for stmt in stmts {
1502 if let ast::Stmt::ClassDef(class_def) = stmt {
1503 if class_def.name.as_str() == name {
1504 return Some(class_def);
1505 }
1506 }
1507 }
1508 None
1509 }
1510
1511 fn collect_inherited_tests(
1513 &self,
1514 base_class: &ast::StmtClassDef,
1515 derived_class_name: &str,
1516 derived_class_markers: &[String],
1517 file_path: &str,
1518 conftest_fixtures: &[String],
1519 source: &str,
1520 tests: &mut Vec<NativeTestNode>,
1521 all_stmts: &[ast::Stmt],
1522 ) {
1523 let existing_methods: std::collections::HashSet<String> = tests
1525 .iter()
1526 .filter(|t| t.class_name.as_deref() == Some(derived_class_name))
1527 .map(|t| t.name.clone())
1528 .collect();
1529
1530 let ctx = TestProcessingContext {
1532 file_path,
1533 class_name: derived_class_name, class_markers: derived_class_markers,
1535 source,
1536 };
1537
1538 for stmt in &base_class.body {
1539 match stmt {
1540 ast::Stmt::FunctionDef(func) => {
1541 if func.name.starts_with("test_") && !existing_methods.contains(&func.name.to_string()) {
1543 self.process_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1544 }
1545 }
1546 ast::Stmt::AsyncFunctionDef(func) => {
1547 if func.name.starts_with("test_") && !existing_methods.contains(&func.name.to_string()) {
1548 self.process_async_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1549 }
1550 }
1551 _ => {}
1552 }
1553 }
1554
1555 let base_base_names = self.extract_base_class_names(base_class);
1557 for base_name in base_base_names {
1558 if let Some(base_base_class) = self.find_class_in_stmts(all_stmts, &base_name) {
1559 self.collect_inherited_tests(
1560 base_base_class,
1561 derived_class_name,
1562 derived_class_markers,
1563 file_path,
1564 conftest_fixtures,
1565 source,
1566 tests,
1567 all_stmts,
1568 );
1569 }
1570 }
1571 }
1572
1573 fn expand_parametrized_tests(
1575 &self,
1576 ctx: &TestCreationContext,
1577 file_path: &str,
1578 class_name: &str,
1579 param_info: ParameterizedTestNode,
1580 tests: &mut Vec<NativeTestNode>,
1581 ) {
1582 let param_names = ¶m_info.param_names;
1583 let param_values = ¶m_info.param_values;
1584
1585 let use_value_for_containers = self.has_mixed_param_types(param_values);
1589
1590 let mut base_ids: Vec<String> = Vec::with_capacity(param_values.len());
1592 for (idx, (values, custom_id, _)) in param_values.iter().enumerate() {
1593 let base_id = if let Some(id) = custom_id {
1594 id.clone()
1595 } else {
1596 self.generate_pytest_id_with_context(values, idx, use_value_for_containers)
1597 };
1598 base_ids.push(base_id);
1599 }
1600
1601 let final_ids = self.deduplicate_ids(&base_ids);
1603
1604 for (idx, (values, _custom_id, variant_marks)) in param_values.iter().enumerate() {
1605 let test_id = final_ids[idx].clone();
1607
1608 let node_id = if class_name.is_empty() {
1610 format!("{}::{}[{}]", file_path, ctx.fn_name, test_id)
1611 } else {
1612 format!("{}::{}::{}[{}]", file_path, class_name, ctx.fn_name, test_id)
1613 };
1614
1615 let mut markers = ctx.markers.to_vec();
1617 markers.extend(variant_marks.clone());
1618 if ctx.skip_xfail.skip {
1619 markers.push("skip".to_string());
1620 }
1621 if ctx.skip_xfail.skipif {
1622 markers.push("skipif".to_string());
1623 }
1624 if ctx.skip_xfail.xfail {
1625 markers.push("xfail".to_string());
1626 }
1627
1628 let parameters = vec![serde_json::json!({
1630 "names": param_names,
1631 "values": values,
1632 "id": test_id
1633 })];
1634
1635 tests.push(NativeTestNode {
1636 node_id,
1637 file_path: file_path.to_string(),
1638 name: ctx.fn_name.to_string(),
1639 class_name: if class_name.is_empty() { None } else { Some(class_name.to_string()) },
1640 line_number: ctx.line_number,
1641 markers,
1642 is_simple: !ctx.uses_external_fixtures,
1643 parameters,
1644 skip: ctx.skip_xfail.skip,
1647 skip_reason: ctx.skip_xfail.skip_reason.clone().or(ctx.skip_xfail.skipif_condition.clone()),
1648 xfail: ctx.skip_xfail.xfail,
1649 xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1650 xfail_strict: ctx.skip_xfail.xfail_strict,
1651 });
1652 }
1653 }
1654
1655 fn expand_stacked_parametrized_tests(
1658 &self,
1659 ctx: &TestCreationContext,
1660 file_path: &str,
1661 class_name: &str,
1662 param_infos: Vec<ParameterizedTestNode>,
1663 tests: &mut Vec<NativeTestNode>,
1664 ) {
1665 let combinations = self.generate_cartesian_product(¶m_infos);
1667
1668 for (combo_idx, combo) in combinations.into_iter().enumerate() {
1669 let mut all_names = Vec::new();
1672 let mut all_values = Vec::new();
1673 let mut all_marks = Vec::new(); for (names, values, _, marks) in combo {
1676 all_names.extend(names);
1677 all_values.extend(values);
1678 all_marks.extend(marks);
1679 }
1680
1681 let test_id = self.generate_pytest_id(&all_values, combo_idx);
1683
1684 let node_id = if class_name.is_empty() {
1686 format!("{}::{}[{}]", file_path, ctx.fn_name, test_id)
1687 } else {
1688 format!("{}::{}::{}[{}]", file_path, class_name, ctx.fn_name, test_id)
1689 };
1690
1691 let mut markers = ctx.markers.to_vec();
1693 markers.extend(all_marks);
1694 if ctx.skip_xfail.skip {
1695 markers.push("skip".to_string());
1696 }
1697 if ctx.skip_xfail.skipif {
1698 markers.push("skipif".to_string());
1699 }
1700 if ctx.skip_xfail.xfail {
1701 markers.push("xfail".to_string());
1702 }
1703
1704 let parameters = vec![serde_json::json!({
1706 "names": all_names,
1707 "values": all_values,
1708 "id": test_id
1709 })];
1710
1711 tests.push(NativeTestNode {
1712 node_id,
1713 file_path: file_path.to_string(),
1714 name: ctx.fn_name.to_string(),
1715 class_name: if class_name.is_empty() { None } else { Some(class_name.to_string()) },
1716 line_number: ctx.line_number,
1717 markers,
1718 is_simple: !ctx.uses_external_fixtures,
1719 parameters,
1720 skip: ctx.skip_xfail.skip,
1723 skip_reason: ctx.skip_xfail.skip_reason.clone().or(ctx.skip_xfail.skipif_condition.clone()),
1724 xfail: ctx.skip_xfail.xfail,
1725 xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1726 xfail_strict: ctx.skip_xfail.xfail_strict,
1727 });
1728 }
1729 }
1730
1731 fn generate_cartesian_product(
1736 &self,
1737 param_infos: &[ParameterizedTestNode],
1738 ) -> Vec<Vec<ParamCombination>> {
1739 if param_infos.is_empty() {
1740 return Vec::new();
1741 }
1742
1743 if param_infos.len() == 1 {
1744 let info = ¶m_infos[0];
1746 return info.param_values.iter()
1747 .map(|(values, custom_id, marks)| vec![(info.param_names.clone(), values.clone(), custom_id.clone(), marks.clone())])
1748 .collect();
1749 }
1750
1751 self.compute_cartesian_recursive(param_infos, 0)
1753 }
1754
1755 fn compute_cartesian_recursive(
1757 &self,
1758 param_infos: &[ParameterizedTestNode],
1759 index: usize,
1760 ) -> Vec<Vec<ParamCombination>> {
1761 if index >= param_infos.len() {
1762 return vec![Vec::new()];
1764 }
1765
1766 let info = ¶m_infos[index];
1767 let remaining = self.compute_cartesian_recursive(param_infos, index + 1);
1768
1769 let mut result = Vec::new();
1770 for (values, custom_id, marks) in info.param_values.iter() {
1771 for mut combo in remaining.clone() {
1772 combo.insert(0, (info.param_names.clone(), values.clone(), custom_id.clone(), marks.clone()));
1773 result.push(combo);
1774 }
1775 }
1776
1777 result
1778 }
1779
1780 fn get_pytest_fixture_usage_func(
1782 &self,
1783 args: &ast::Arguments,
1784 conftest_fixtures: &[String],
1785 ) -> Vec<String> {
1786 let mut used = Vec::new();
1787 for arg in &args.args {
1788 let arg_name = &arg.def.arg.to_string();
1789 if conftest_fixtures.contains(arg_name) {
1790 used.push(arg_name.clone());
1791 }
1792 }
1793 used
1794 }
1795
1796 fn check_pytest_fixture_usage_async(
1798 &self,
1799 args: &ast::Arguments,
1800 conftest_fixtures: &[String],
1801 ) -> bool {
1802 for arg in &args.args {
1803 let arg_name = &arg.def.arg.to_string();
1804 if conftest_fixtures.contains(arg_name) {
1805 return true;
1806 }
1807 }
1808 false
1809 }
1810}
1811
1812pub fn is_test_file(path: &Path) -> bool {
1814 if let Some(ext) = path.extension() {
1815 if ext != "py" {
1816 return false;
1817 }
1818 } else {
1819 return false;
1820 }
1821
1822 if let Some(file_name) = path.file_name() {
1823 let name = file_name.to_string_lossy();
1824 name.starts_with("test_") || name.ends_with("_test.py")
1825 } else {
1826 false
1827 }
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832 use super::*;
1833 use std::fs;
1834 use tempfile::TempDir;
1835
1836 #[test]
1837 fn test_collect_simple() {
1838 let dir = TempDir::new().unwrap();
1839 let test_file = dir.path().join("test_example.py");
1840 fs::write(
1841 &test_file,
1842 r#"
1843def test_simple():
1844 assert True
1845
1846def test_another():
1847 assert 1 + 1 == 2
1848
1849class TestClass:
1850 def test_method(self):
1851 assert True
1852"#,
1853 )
1854 .unwrap();
1855
1856 let collector = NativeCollector::new(dir.path());
1857 let tests = collector.collect().unwrap();
1858
1859 assert_eq!(tests.len(), 3);
1861
1862 let test_names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
1864 assert!(test_names.contains(&"test_simple"));
1865 assert!(test_names.contains(&"test_another"));
1866 assert!(test_names.contains(&"test_method"));
1867 }
1868
1869 #[test]
1870 fn test_ignore_non_test_files() {
1871 let dir = TempDir::new().unwrap();
1872
1873 let regular_file = dir.path().join("regular.py");
1875 fs::write(®ular_file, "def regular_func():\n pass").unwrap();
1876
1877 let collector = NativeCollector::new(dir.path());
1878 let tests = collector.collect().unwrap();
1879
1880 assert!(tests.is_empty());
1882 }
1883
1884 #[test]
1885 fn test_collect_with_markers() {
1886 let dir = TempDir::new().unwrap();
1887 let test_file = dir.path().join("test_marked.py");
1888 fs::write(
1889 &test_file,
1890 r#"
1891import pytest
1892
1893@pytest.mark.slow
1894def test_expensive():
1895 pass
1896
1897@pytest.mark.parametrize("x", [1, 2, 3])
1898def test_param(x):
1899 pass
1900"#,
1901 )
1902 .unwrap();
1903
1904 let collector = NativeCollector::new(dir.path());
1905 let tests = collector.collect().unwrap();
1906
1907 assert_eq!(tests.len(), 4);
1909
1910 let slow_test = tests.iter().find(|t| t.name == "test_expensive").unwrap();
1912 assert!(slow_test.markers.contains(&"slow".to_string()));
1913
1914 let param_tests: Vec<&NativeTestNode> = tests.iter().filter(|t| t.name == "test_param").collect();
1916 assert_eq!(param_tests.len(), 3);
1917
1918 let ids: Vec<&str> = param_tests.iter()
1920 .filter_map(|t| {
1921 if let Some(start) = t.node_id.find("[") {
1923 if let Some(end) = t.node_id.find("]") {
1924 return Some(&t.node_id[start+1..end]);
1925 }
1926 }
1927 None
1928 })
1929 .collect();
1930 assert_eq!(ids.len(), 3);
1931 }
1932
1933 #[test]
1934 fn test_collect_async() {
1935 let dir = TempDir::new().unwrap();
1936 let test_file = dir.path().join("test_async.py");
1937 fs::write(
1938 &test_file,
1939 r#"
1940import pytest
1941
1942@pytest.mark.asyncio
1943async def test_async_basic():
1944 assert True
1945
1946async def test_async_another():
1947 assert True
1948"#,
1949 )
1950 .unwrap();
1951
1952 let collector = NativeCollector::new(dir.path());
1953 let tests = collector.collect().unwrap();
1954
1955 assert_eq!(tests.len(), 2);
1957
1958 let test_names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
1959 assert!(test_names.contains(&"test_async_basic"));
1960 assert!(test_names.contains(&"test_async_another"));
1961 }
1962
1963 #[test]
1964 fn test_collect_skip_xfail() {
1965 let dir = TempDir::new().unwrap();
1966 let test_file = dir.path().join("test_skip.py");
1967 fs::write(
1968 &test_file,
1969 r#"
1970import pytest
1971
1972@pytest.mark.skip(reason="intentional")
1973def test_skipped():
1974 pass
1975
1976@pytest.mark.xfail
1977def test_xfailed():
1978 assert False
1979"#,
1980 )
1981 .unwrap();
1982
1983 let collector = NativeCollector::new(dir.path());
1984 let tests = collector.collect().unwrap();
1985
1986 assert_eq!(tests.len(), 2);
1988
1989 let skipped = tests.iter().find(|t| t.name == "test_skipped").unwrap();
1990 assert!(skipped.markers.contains(&"skip".to_string()));
1991
1992 let xfailed = tests.iter().find(|t| t.name == "test_xfailed").unwrap();
1993 assert!(xfailed.markers.contains(&"xfail".to_string()));
1994 }
1995
1996 #[test]
1997 fn test_collect_parametrized_class_methods() {
1998 let dir = TempDir::new().unwrap();
1999 let test_file = dir.path().join("test_class_param.py");
2000 fs::write(
2001 &test_file,
2002 r#"
2003import pytest
2004
2005class TestParametrized:
2006 @pytest.mark.parametrize("n", [1, 2, 3])
2007 def test_method(self, n):
2008 assert n > 0
2009"#,
2010 )
2011 .unwrap();
2012
2013 let collector = NativeCollector::new(dir.path());
2014 let tests = collector.collect().unwrap();
2015
2016 assert_eq!(tests.len(), 3);
2018
2019 let param_tests: Vec<&NativeTestNode> = tests.iter()
2021 .filter(|t| t.name == "test_method" && t.class_name.is_some())
2022 .collect();
2023 assert_eq!(param_tests.len(), 3);
2024 assert_eq!(param_tests[0].class_name, Some("TestParametrized".to_string()));
2025 }
2026
2027 #[test]
2028 fn test_collect_stacked_parametrize() {
2029 let dir = TempDir::new().unwrap();
2030 let test_file = dir.path().join("test_stacked.py");
2031 fs::write(
2032 &test_file,
2033 r#"
2034import pytest
2035
2036@pytest.mark.parametrize("x", [1, 2])
2037@pytest.mark.parametrize("y", [10, 20])
2038def test_stacked_parametrize(x, y):
2039 """Stacked parametrize creates cartesian product."""
2040 assert True
2041"#,
2042 )
2043 .unwrap();
2044
2045 let collector = NativeCollector::new(dir.path());
2046 let tests = collector.collect().unwrap();
2047
2048 assert_eq!(tests.len(), 4);
2050
2051 let node_ids: Vec<&str> = tests.iter()
2053 .filter_map(|t| {
2054 if t.name == "test_stacked_parametrize" {
2055 if let Some(start) = t.node_id.find("[") {
2057 if let Some(end) = t.node_id.find("]") {
2058 return Some(&t.node_id[start+1..end]);
2059 }
2060 }
2061 }
2062 None
2063 })
2064 .collect();
2065
2066 assert_eq!(node_ids.len(), 4);
2067 assert!(node_ids.contains(&"10-1"));
2069 assert!(node_ids.contains(&"10-2"));
2070 assert!(node_ids.contains(&"20-1"));
2071 assert!(node_ids.contains(&"20-2"));
2072 }
2073
2074 #[test]
2075 fn test_collect_class_markers() {
2076 let dir = TempDir::new().unwrap();
2077 let test_file = dir.path().join("test_class_markers.py");
2078 fs::write(
2079 &test_file,
2080 r#"
2081import pytest
2082
2083@pytest.mark.slow
2084class TestMarkedClass:
2085 def test_one(self):
2086 assert True
2087
2088 def test_two(self):
2089 assert True
2090"#,
2091 )
2092 .unwrap();
2093
2094 let collector = NativeCollector::new(dir.path());
2095 let tests = collector.collect().unwrap();
2096
2097 assert_eq!(tests.len(), 2);
2099
2100 for test in &tests {
2102 assert!(test.markers.contains(&"slow".to_string()),
2103 "Test {} should have 'slow' marker from class", test.name);
2104 }
2105 }
2106
2107 #[test]
2108 fn test_collect_skipif() {
2109 let dir = TempDir::new().unwrap();
2110 let test_file = dir.path().join("test_skipif.py");
2111 fs::write(
2112 &test_file,
2113 r#"
2114import pytest
2115import sys
2116
2117@pytest.mark.skipif(sys.version_info > (0, 0), reason="always skips")
2118def test_skipif():
2119 pass
2120"#,
2121 )
2122 .unwrap();
2123
2124 let collector = NativeCollector::new(dir.path());
2125 let tests = collector.collect().unwrap();
2126
2127 assert_eq!(tests.len(), 1);
2129
2130 let test = &tests[0];
2131 assert!(test.markers.contains(&"skipif".to_string()));
2133 assert!(!test.skip);
2135 }
2136
2137 #[test]
2138 fn test_collect_xfail_with_condition() {
2139 let dir = TempDir::new().unwrap();
2140 let test_file = dir.path().join("test_xfail_cond.py");
2141 fs::write(
2142 &test_file,
2143 r#"
2144import pytest
2145
2146@pytest.mark.xfail(condition=False, reason="condition is false")
2147def test_xfail_false_condition():
2148 assert True
2149"#,
2150 )
2151 .unwrap();
2152
2153 let collector = NativeCollector::new(dir.path());
2154 let tests = collector.collect().unwrap();
2155
2156 assert_eq!(tests.len(), 1);
2158
2159 let test = &tests[0];
2160 assert!(test.markers.contains(&"xfail".to_string()));
2162 }
2163
2164 #[test]
2165 fn test_collect_param_with_per_variant_marks() {
2166 let dir = TempDir::new().unwrap();
2167 let test_file = dir.path().join("test_param_marks.py");
2168 fs::write(
2169 &test_file,
2170 r#"
2171import pytest
2172
2173class TestParamMarks:
2174 @pytest.mark.parametrize("x", [
2175 pytest.param(1, marks=pytest.mark.slow),
2176 pytest.param(2),
2177 pytest.param(3, marks=[pytest.mark.skip(reason="test skip")]),
2178 ])
2179 def test_param_marks(self, x):
2180 assert x > 0
2181"#,
2182 )
2183 .unwrap();
2184
2185 let collector = NativeCollector::new(dir.path());
2186 let tests = collector.collect().unwrap();
2187
2188 assert_eq!(tests.len(), 3);
2190
2191 let test1 = tests.iter().find(|t| t.node_id.contains("[1]")).unwrap();
2194 assert!(test1.markers.contains(&"slow".to_string()),
2195 "Test 1 should have 'slow' marker");
2196
2197 let test2 = tests.iter().find(|t| t.node_id.contains("[2]")).unwrap();
2199 assert!(!test2.markers.contains(&"slow".to_string()),
2200 "Test 2 should not have 'slow' marker");
2201 assert!(!test2.markers.contains(&"skip".to_string()),
2202 "Test 2 should not have 'skip' marker");
2203
2204 let test3 = tests.iter().find(|t| t.node_id.contains("[3]")).unwrap();
2206 assert!(test3.markers.contains(&"skip".to_string()),
2207 "Test 3 should have 'skip' marker from pytest.param");
2208 }
2209
2210 #[test]
2211 fn test_line_numbers() {
2212 let dir = TempDir::new().unwrap();
2213 let test_file = dir.path().join("test_lines.py");
2214 fs::write(
2227 &test_file,
2228 r#"# Line 1 - comment
2229# Line 2 - comment
2230
2231def test_first():
2232 assert True
2233
2234def test_second():
2235 pass
2236
2237class TestClass:
2238 def test_method(self):
2239 pass
2240"#,
2241 )
2242 .unwrap();
2243
2244 let collector = NativeCollector::new(dir.path());
2245 let tests = collector.collect().unwrap();
2246
2247 assert_eq!(tests.len(), 3);
2249
2250 let test_first = tests.iter().find(|t| t.name == "test_first").unwrap();
2252 assert_eq!(test_first.line_number, 4, "test_first should be at line 4");
2253
2254 let test_second = tests.iter().find(|t| t.name == "test_second").unwrap();
2255 assert_eq!(test_second.line_number, 7, "test_second should be at line 7");
2256
2257 let test_method = tests.iter().find(|t| t.name == "test_method").unwrap();
2258 assert_eq!(test_method.line_number, 11, "test_method should be at line 11");
2259 }
2260}