1use super::imports::try_init_stdlib_from_python;
4use super::types::{FixtureDefinition, FixtureScope, TypeImportSpec};
5use super::FixtureDatabase;
6use glob::Pattern;
7use rayon::prelude::*;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use tracing::{debug, error, info, warn};
11use walkdir::WalkDir;
12
13#[derive(Debug, Clone)]
15pub(crate) struct Pytest11EntryPoint {
16 pub(crate) name: String,
18 pub(crate) module_path: String,
20}
21
22impl FixtureDatabase {
23 const SKIP_DIRECTORIES: &'static [&'static str] = &[
26 ".git",
28 ".hg",
29 ".svn",
30 ".venv",
32 "venv",
33 "env",
34 ".env",
35 "__pycache__",
37 ".pytest_cache",
38 ".mypy_cache",
39 ".ruff_cache",
40 ".tox",
41 ".nox",
42 "build",
43 "dist",
44 ".eggs",
45 "node_modules",
47 "bower_components",
48 "target",
50 ".idea",
52 ".vscode",
53 ".cache",
55 ".local",
56 "vendor",
57 "site-packages",
58 ];
59
60 pub(crate) fn should_skip_directory(dir_name: &str) -> bool {
62 if Self::SKIP_DIRECTORIES.contains(&dir_name) {
64 return true;
65 }
66 if dir_name.ends_with(".egg-info") {
68 return true;
69 }
70 false
71 }
72
73 pub fn scan_workspace(&self, root_path: &Path) {
76 self.scan_workspace_with_excludes(root_path, &[]);
77 }
78
79 pub fn scan_workspace_with_excludes(&self, root_path: &Path, exclude_patterns: &[Pattern]) {
81 let root_path_buf = root_path
82 .canonicalize()
83 .unwrap_or_else(|_| root_path.to_path_buf());
84 let root_path = root_path_buf.as_path();
85
86 info!("Scanning workspace: {:?}", root_path);
87
88 *self.workspace_root.lock().unwrap() = Some(root_path.to_path_buf());
89
90 if !root_path.exists() {
91 warn!(
92 "Workspace path does not exist, skipping scan: {:?}",
93 root_path
94 );
95 return;
96 }
97
98 let mut files_to_process: Vec<std::path::PathBuf> = Vec::new();
100 let mut skipped_dirs = 0;
101
102 let walker = WalkDir::new(root_path).into_iter().filter_entry(|entry| {
104 if entry.file_type().is_file() {
106 return true;
107 }
108 if let Some(dir_name) = entry.file_name().to_str() {
110 !Self::should_skip_directory(dir_name)
111 } else {
112 true
113 }
114 });
115
116 for entry in walker {
117 let entry = match entry {
118 Ok(e) => e,
119 Err(err) => {
120 if err
122 .io_error()
123 .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
124 {
125 warn!(
126 "Permission denied accessing path during workspace scan: {}",
127 err
128 );
129 } else {
130 debug!("Error during workspace scan: {}", err);
131 }
132 continue;
133 }
134 };
135
136 let path = entry.path();
137
138 if let Ok(relative) = path.strip_prefix(root_path) {
139 if relative.components().any(|c| {
140 c.as_os_str()
141 .to_str()
142 .is_some_and(Self::should_skip_directory)
143 }) {
144 skipped_dirs += 1;
145 continue;
146 }
147 }
148
149 if !exclude_patterns.is_empty() {
152 if let Ok(relative_path) = path.strip_prefix(root_path) {
153 let relative_str = relative_path.to_string_lossy();
154 if exclude_patterns.iter().any(|p| p.matches(&relative_str)) {
155 debug!("Skipping excluded path: {:?}", path);
156 continue;
157 }
158 }
159 }
160
161 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
163 if filename == "conftest.py"
164 || filename.starts_with("test_") && filename.ends_with(".py")
165 || filename.ends_with("_test.py")
166 {
167 files_to_process.push(path.to_path_buf());
168 }
169 }
170 }
171
172 if skipped_dirs > 0 {
173 debug!("Skipped {} entries in filtered directories", skipped_dirs);
174 }
175
176 let total_files = files_to_process.len();
177 info!("Found {} test/conftest files to process", total_files);
178
179 let error_count = AtomicUsize::new(0);
182 let permission_denied_count = AtomicUsize::new(0);
183
184 files_to_process.par_iter().for_each(|path| {
185 debug!("Found test/conftest file: {:?}", path);
186 match std::fs::read_to_string(path) {
187 Ok(content) => {
188 self.analyze_file_fresh(path.clone(), &content);
189 }
190 Err(err) => {
191 if err.kind() == std::io::ErrorKind::PermissionDenied {
192 debug!("Permission denied reading file: {:?}", path);
193 permission_denied_count.fetch_add(1, Ordering::Relaxed);
194 } else {
195 error!("Failed to read file {:?}: {}", path, err);
196 error_count.fetch_add(1, Ordering::Relaxed);
197 }
198 }
199 }
200 });
201
202 let errors = error_count.load(Ordering::Relaxed);
203 let permission_errors = permission_denied_count.load(Ordering::Relaxed);
204
205 if errors > 0 {
206 warn!("Workspace scan completed with {} read errors", errors);
207 }
208 if permission_errors > 0 {
209 warn!(
210 "Workspace scan: skipped {} files due to permission denied",
211 permission_errors
212 );
213 }
214
215 info!(
216 "Workspace scan complete. Processed {} files ({} permission denied, {} errors)",
217 total_files, permission_errors, errors
218 );
219
220 self.scan_venv_fixtures(root_path);
223
224 self.scan_imported_fixture_modules(root_path);
228
229 info!("Total fixtures defined: {}", self.definitions.len());
230 info!("Total files with fixture usages: {}", self.usages.len());
231 }
232
233 fn scan_imported_fixture_modules(&self, _root_path: &Path) {
237 use std::collections::HashSet;
238
239 info!("Scanning for imported fixture modules");
240
241 let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
243
244 let site_packages_paths = self.site_packages_paths.lock().unwrap().clone();
247 let editable_roots: Vec<PathBuf> = self
248 .editable_install_roots
249 .lock()
250 .unwrap()
251 .iter()
252 .map(|e| e.source_root.clone())
253 .collect();
254 let mut files_to_check: Vec<std::path::PathBuf> = self
255 .file_cache
256 .iter()
257 .filter(|entry| {
258 let key = entry.key();
259 let is_conftest_or_test = key
260 .file_name()
261 .and_then(|n| n.to_str())
262 .map(|n| {
263 n == "conftest.py"
264 || (n.starts_with("test_") && n.ends_with(".py"))
265 || n.ends_with("_test.py")
266 })
267 .unwrap_or(false);
268 let is_venv_plugin = site_packages_paths.iter().any(|sp| key.starts_with(sp));
269 let is_editable_plugin = editable_roots.iter().any(|er| key.starts_with(er));
270 let is_entry_point_plugin = self.plugin_fixture_files.contains_key(key);
271 is_conftest_or_test || is_venv_plugin || is_editable_plugin || is_entry_point_plugin
272 })
273 .map(|entry| entry.key().clone())
274 .collect();
275
276 if files_to_check.is_empty() {
277 debug!("No conftest/test/plugin files found, skipping import scan");
278 return;
279 }
280
281 info!(
282 "Starting import scan with {} conftest/test/plugin files",
283 files_to_check.len()
284 );
285
286 let mut reanalyze_as_plugin: HashSet<std::path::PathBuf> = HashSet::new();
290
291 let mut iteration = 0;
292 while !files_to_check.is_empty() {
293 iteration += 1;
294 debug!(
295 "Import scan iteration {}: checking {} files",
296 iteration,
297 files_to_check.len()
298 );
299
300 let mut new_modules: HashSet<std::path::PathBuf> = HashSet::new();
301
302 for file_path in &files_to_check {
303 if processed_files.contains(file_path) {
304 continue;
305 }
306 processed_files.insert(file_path.clone());
307
308 let importer_is_plugin = self.plugin_fixture_files.contains_key(file_path);
312
313 let Some(content) = self.get_file_content(file_path) else {
315 continue;
316 };
317
318 let Some(parsed) = self.get_parsed_ast(file_path, &content) else {
320 continue;
321 };
322
323 let line_index = self.get_line_index(file_path, &content);
324
325 if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
327 let imports =
328 self.extract_fixture_imports(&module.body, file_path, &line_index);
329
330 for import in imports {
331 if let Some(resolved_path) =
332 self.resolve_module_to_file(&import.module_path, file_path)
333 {
334 let canonical = self.get_canonical_path(resolved_path);
335
336 let should_mark_plugin = importer_is_plugin && import.is_star_import;
341
342 if should_mark_plugin
343 && !self.plugin_fixture_files.contains_key(&canonical)
344 {
345 self.plugin_fixture_files.insert(canonical.clone(), ());
346 if self.file_cache.contains_key(&canonical) {
349 reanalyze_as_plugin.insert(canonical.clone());
350 }
351 }
352
353 if !processed_files.contains(&canonical)
354 && !self.file_cache.contains_key(&canonical)
355 {
356 new_modules.insert(canonical);
357 }
358 }
359 }
360
361 let plugin_modules = self.extract_pytest_plugins(&module.body);
367 for module_path in plugin_modules {
368 if let Some(resolved_path) =
369 self.resolve_module_to_file(&module_path, file_path)
370 {
371 let canonical = self.get_canonical_path(resolved_path);
372
373 if importer_is_plugin
374 && !self.plugin_fixture_files.contains_key(&canonical)
375 {
376 self.plugin_fixture_files.insert(canonical.clone(), ());
377 if self.file_cache.contains_key(&canonical) {
380 reanalyze_as_plugin.insert(canonical.clone());
381 }
382 }
383
384 if !processed_files.contains(&canonical)
385 && !self.file_cache.contains_key(&canonical)
386 {
387 new_modules.insert(canonical);
388 }
389 }
390 }
391 }
392 }
393
394 if new_modules.is_empty() {
395 debug!("No new modules found in iteration {}", iteration);
396 break;
397 }
398
399 info!(
400 "Iteration {}: found {} new modules to analyze",
401 iteration,
402 new_modules.len()
403 );
404
405 for module_path in &new_modules {
407 if module_path.exists() {
408 debug!("Analyzing imported module: {:?}", module_path);
409 match std::fs::read_to_string(module_path) {
410 Ok(content) => {
411 self.analyze_file_fresh(module_path.clone(), &content);
412 }
413 Err(err) => {
414 debug!("Failed to read imported module {:?}: {}", module_path, err);
415 }
416 }
417 }
418 }
419
420 files_to_check = new_modules.into_iter().collect();
422 }
423
424 if !reanalyze_as_plugin.is_empty() {
428 info!(
429 "Re-analyzing {} cached module(s) newly marked as plugin files",
430 reanalyze_as_plugin.len()
431 );
432 for module_path in &reanalyze_as_plugin {
433 if let Some(content) = self.get_file_content(module_path) {
434 debug!("Re-analyzing as plugin: {:?}", module_path);
435 self.analyze_file(module_path.clone(), &content);
438 }
439 }
440 }
441
442 info!(
443 "Imported fixture module scan complete after {} iterations",
444 iteration
445 );
446 }
447
448 fn scan_venv_fixtures(&self, root_path: &Path) {
450 info!("Scanning for pytest plugins in virtual environment");
451
452 let venv_paths = vec![
454 root_path.join(".venv"),
455 root_path.join("venv"),
456 root_path.join("env"),
457 ];
458
459 info!("Checking for venv in: {:?}", root_path);
460 for venv_path in &venv_paths {
461 debug!("Checking venv path: {:?}", venv_path);
462 if venv_path.exists() {
463 info!("Found virtual environment at: {:?}", venv_path);
464 self.scan_venv_site_packages(venv_path);
465 return;
466 } else {
467 debug!(" Does not exist: {:?}", venv_path);
468 }
469 }
470
471 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
473 info!("Found VIRTUAL_ENV environment variable: {}", venv);
474 let venv_path = std::path::PathBuf::from(venv);
475 if venv_path.exists() {
476 let venv_path = venv_path.canonicalize().unwrap_or(venv_path);
477 info!("Using VIRTUAL_ENV: {:?}", venv_path);
478 self.scan_venv_site_packages(&venv_path);
479 return;
480 } else {
481 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
482 }
483 } else {
484 debug!("No VIRTUAL_ENV environment variable set");
485 }
486
487 warn!("No virtual environment found - third-party fixtures will not be available");
488 }
489
490 fn scan_venv_site_packages(&self, venv_path: &Path) {
491 info!("Scanning venv site-packages in: {:?}", venv_path);
492
493 if try_init_stdlib_from_python(venv_path) {
498 info!("stdlib module list populated from venv Python");
499 } else {
500 info!(
501 "using built-in stdlib module list \
502 (Python < 3.10 or binary not found in {:?})",
503 venv_path
504 );
505 }
506
507 let lib_path = venv_path.join("lib");
509 debug!("Checking lib path: {:?}", lib_path);
510
511 if lib_path.exists() {
512 if let Ok(entries) = std::fs::read_dir(&lib_path) {
514 for entry in entries.flatten() {
515 let path = entry.path();
516 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
517 debug!("Found in lib: {:?}", dirname);
518
519 if path.is_dir() && dirname.starts_with("python") {
520 let site_packages = path.join("site-packages");
521 debug!("Checking site-packages: {:?}", site_packages);
522
523 if site_packages.exists() {
524 let site_packages =
525 site_packages.canonicalize().unwrap_or(site_packages);
526 info!("Found site-packages: {:?}", site_packages);
527 self.site_packages_paths
528 .lock()
529 .unwrap()
530 .push(site_packages.clone());
531 self.scan_pytest_plugins(&site_packages);
532 return;
533 }
534 }
535 }
536 }
537 }
538
539 let windows_site_packages = venv_path.join("Lib/site-packages");
541 debug!("Checking Windows path: {:?}", windows_site_packages);
542 if windows_site_packages.exists() {
543 let windows_site_packages = windows_site_packages
544 .canonicalize()
545 .unwrap_or(windows_site_packages);
546 info!("Found site-packages (Windows): {:?}", windows_site_packages);
547 self.site_packages_paths
548 .lock()
549 .unwrap()
550 .push(windows_site_packages.clone());
551 self.scan_pytest_plugins(&windows_site_packages);
552 return;
553 }
554
555 warn!("Could not find site-packages in venv: {:?}", venv_path);
556 }
557
558 fn parse_pytest11_entry_points(content: &str) -> Vec<Pytest11EntryPoint> {
564 let mut results = Vec::new();
565 let mut in_pytest11_section = false;
566
567 for line in content.lines() {
568 let line = line.trim();
569
570 if line.starts_with('[') && line.ends_with(']') {
572 in_pytest11_section = line == "[pytest11]";
573 continue;
574 }
575
576 if in_pytest11_section && !line.is_empty() && !line.starts_with('#') {
578 if let Some((name, module_path)) = line.split_once('=') {
579 results.push(Pytest11EntryPoint {
580 name: name.trim().to_string(),
581 module_path: module_path.trim().to_string(),
582 });
583 }
584 }
585 }
586 results
587 }
588
589 fn resolve_entry_point_module_to_path(
597 site_packages: &Path,
598 module_path: &str,
599 ) -> Option<PathBuf> {
600 let module_path = module_path.split(':').next().unwrap_or(module_path);
602
603 let parts: Vec<&str> = module_path.split('.').collect();
605
606 if parts.is_empty() {
607 return None;
608 }
609
610 if parts
612 .iter()
613 .any(|p| p.contains("..") || p.contains('\0') || p.is_empty())
614 {
615 return None;
616 }
617
618 let mut path = site_packages.to_path_buf();
620 for part in &parts {
621 path.push(part);
622 }
623
624 let check_bounded = |candidate: &Path| -> Option<PathBuf> {
626 let canonical = candidate.canonicalize().ok()?;
627 let base_canonical = site_packages.canonicalize().ok()?;
628 if canonical.starts_with(&base_canonical) {
629 Some(canonical)
630 } else {
631 None
632 }
633 };
634
635 let py_file = path.with_extension("py");
637 if py_file.exists() {
638 return check_bounded(&py_file);
639 }
640
641 if path.is_dir() {
643 let init_file = path.join("__init__.py");
644 if init_file.exists() {
645 return check_bounded(&init_file);
646 }
647 }
648
649 None
650 }
651
652 fn scan_single_plugin_file(&self, file_path: &Path) {
654 if file_path.extension().and_then(|s| s.to_str()) != Some("py") {
655 return;
656 }
657
658 debug!("Scanning plugin file: {:?}", file_path);
659
660 let canonical = file_path
662 .canonicalize()
663 .unwrap_or_else(|_| file_path.to_path_buf());
664 self.plugin_fixture_files.insert(canonical, ());
665
666 if let Ok(content) = std::fs::read_to_string(file_path) {
667 self.analyze_file(file_path.to_path_buf(), &content);
668 }
669 }
670
671 fn load_plugin_from_entry_point(&self, dist_info_path: &Path, site_packages: &Path) -> usize {
678 let entry_points_file = dist_info_path.join("entry_points.txt");
679
680 let content = match std::fs::read_to_string(&entry_points_file) {
681 Ok(c) => c,
682 Err(_) => return 0, };
684
685 let entries = Self::parse_pytest11_entry_points(&content);
686
687 if entries.is_empty() {
688 return 0; }
690
691 let mut scanned_count = 0;
692
693 for entry in entries {
694 debug!(
695 "Found pytest11 entry: {} = {}",
696 entry.name, entry.module_path
697 );
698
699 let resolved =
700 Self::resolve_entry_point_module_to_path(site_packages, &entry.module_path)
701 .or_else(|| self.resolve_entry_point_in_editable_installs(&entry.module_path));
702
703 if let Some(path) = resolved {
704 let scanned = if path.file_name().and_then(|n| n.to_str()) == Some("__init__.py") {
705 let package_dir = path.parent().expect("__init__.py must have parent");
706 info!(
707 "Scanning pytest plugin package directory for {}: {:?}",
708 entry.name, package_dir
709 );
710 self.scan_plugin_directory(package_dir);
711 true
712 } else if path.is_file() {
713 info!("Scanning pytest plugin: {} -> {:?}", entry.name, path);
714 self.scan_single_plugin_file(&path);
715 true
716 } else {
717 debug!(
718 "Resolved module path for plugin {} is not a file: {:?}",
719 entry.name, path
720 );
721 false
722 };
723
724 if scanned {
725 scanned_count += 1;
726 }
727 } else {
728 debug!(
729 "Could not resolve module path: {} for plugin {}",
730 entry.module_path, entry.name
731 );
732 }
733 }
734
735 scanned_count
736 }
737
738 fn scan_pytest_internal_fixtures(&self, site_packages: &Path) {
741 let pytest_internal = site_packages.join("_pytest");
742
743 if !pytest_internal.exists() || !pytest_internal.is_dir() {
744 debug!("_pytest directory not found in site-packages");
745 return;
746 }
747
748 info!(
749 "Scanning pytest internal fixtures in: {:?}",
750 pytest_internal
751 );
752 self.scan_plugin_directory(&pytest_internal);
753
754 self.register_request_builtin_fixture(&pytest_internal);
759 }
760
761 fn register_request_builtin_fixture(&self, pytest_internal: &Path) {
774 let fixtures_py = pytest_internal.join("fixtures.py");
776 let file_path = if fixtures_py.exists() {
777 fixtures_py
778 .canonicalize()
779 .unwrap_or_else(|_| fixtures_py.clone())
780 } else {
781 pytest_internal.join("_pytest_request_builtin.py")
783 };
784
785 if let Some(existing) = self.definitions.get("request") {
790 if existing.iter().any(|d| d.file_path == file_path) {
791 debug!(
792 "Synthetic 'request' fixture already registered for {:?}, skipping",
793 file_path
794 );
795 return;
796 }
797 }
798 drop(self.definitions.remove("request"));
799
800 let docstring = concat!(
801 "Special fixture providing information about the requesting test context.\n",
802 "\n",
803 "See https://docs.pytest.org/en/stable/reference/reference.html#request"
804 );
805
806 let definition = FixtureDefinition {
807 name: "request".to_string(),
808 file_path,
809 line: 1,
810 end_line: 1,
811 start_char: 0,
812 end_char: "request".len(),
813 docstring: Some(docstring.to_string()),
814 return_type: Some("FixtureRequest".to_string()),
815 return_type_imports: vec![TypeImportSpec {
816 check_name: "FixtureRequest".to_string(),
817 import_statement: "from pytest import FixtureRequest".to_string(),
818 }],
819 is_third_party: true,
820 is_plugin: true,
821 dependencies: vec![],
822 scope: FixtureScope::Function,
823 yield_line: None,
824 autouse: false,
825 };
826
827 info!("Registering synthetic 'request' fixture definition");
828 self.record_fixture_definition(definition);
829 }
830
831 fn extract_package_name_from_dist_info(dir_name: &str) -> Option<(String, String)> {
835 let name_version = dir_name
837 .strip_suffix(".dist-info")
838 .or_else(|| dir_name.strip_suffix(".egg-info"))?;
839
840 let name = if let Some(idx) = name_version.char_indices().position(|(i, c)| {
844 c == '-' && name_version[i + 1..].starts_with(|c: char| c.is_ascii_digit())
845 }) {
846 &name_version[..idx]
847 } else {
848 name_version
849 };
850
851 let raw = name.to_string();
852 let normalized = name.replace(['-', '.'], "_").to_lowercase();
854 Some((raw, normalized))
855 }
856
857 fn discover_editable_installs(&self, site_packages: &Path) {
859 info!("Scanning for editable installs in: {:?}", site_packages);
860
861 if !site_packages.is_dir() {
863 warn!(
864 "site-packages path is not a directory, skipping editable install scan: {:?}",
865 site_packages
866 );
867 return;
868 }
869
870 self.editable_install_roots.lock().unwrap().clear();
872
873 let pth_index = Self::build_pth_index(site_packages);
875
876 let entries = match std::fs::read_dir(site_packages) {
877 Ok(e) => e,
878 Err(_) => return,
879 };
880
881 for entry in entries.flatten() {
882 let path = entry.path();
883 let filename = path.file_name().unwrap_or_default().to_string_lossy();
884
885 if !filename.ends_with(".dist-info") {
886 continue;
887 }
888
889 let direct_url_path = path.join("direct_url.json");
890 let content = match std::fs::read_to_string(&direct_url_path) {
891 Ok(c) => c,
892 Err(_) => continue,
893 };
894
895 let json: serde_json::Value = match serde_json::from_str(&content) {
897 Ok(v) => v,
898 Err(_) => continue,
899 };
900
901 let is_editable = json
903 .get("dir_info")
904 .and_then(|d| d.get("editable"))
905 .and_then(|e| e.as_bool())
906 .unwrap_or(false);
907
908 if !is_editable {
909 continue;
910 }
911
912 let Some((raw_name, normalized_name)) =
913 Self::extract_package_name_from_dist_info(&filename)
914 else {
915 continue;
916 };
917
918 let source_root = Self::find_editable_pth_source_root(
920 &pth_index,
921 &raw_name,
922 &normalized_name,
923 site_packages,
924 );
925 let Some(source_root) = source_root else {
926 debug!(
927 "No .pth file found for editable install: {}",
928 normalized_name
929 );
930 continue;
931 };
932
933 info!(
934 "Discovered editable install: {} -> {:?}",
935 normalized_name, source_root
936 );
937 self.editable_install_roots
938 .lock()
939 .unwrap()
940 .push(super::EditableInstall {
941 package_name: normalized_name,
942 raw_package_name: raw_name,
943 source_root,
944 site_packages: site_packages.to_path_buf(),
945 });
946 }
947
948 let count = self.editable_install_roots.lock().unwrap().len();
949 info!("Discovered {} editable install(s)", count);
950 }
951
952 fn build_pth_index(site_packages: &Path) -> std::collections::HashMap<String, PathBuf> {
955 let mut index = std::collections::HashMap::new();
956 if !site_packages.is_dir() {
957 return index;
958 }
959 let entries = match std::fs::read_dir(site_packages) {
960 Ok(e) => e,
961 Err(_) => return index,
962 };
963 for entry in entries.flatten() {
964 let fname = entry.file_name();
965 let fname_str = fname.to_string_lossy();
966 if fname_str.ends_with(".pth") {
967 let stem = fname_str.strip_suffix(".pth").unwrap_or(&fname_str);
968 index.insert(stem.to_string(), entry.path());
969 }
970 }
971 index
972 }
973
974 fn find_editable_pth_source_root(
978 pth_index: &std::collections::HashMap<String, PathBuf>,
979 raw_name: &str,
980 normalized_name: &str,
981 site_packages: &Path,
982 ) -> Option<PathBuf> {
983 let mut candidates: Vec<String> = vec![
987 format!("__editable__.{}", normalized_name),
988 format!("_{}", normalized_name),
989 normalized_name.to_string(),
990 ];
991 if raw_name != normalized_name {
992 candidates.push(format!("__editable__.{}", raw_name));
993 candidates.push(format!("_{}", raw_name));
994 candidates.push(raw_name.to_string());
995 }
996
997 for (stem, pth_path) in pth_index {
999 let matches = candidates.iter().any(|c| {
1000 stem == c
1001 || stem.strip_prefix(c).is_some_and(|rest| {
1002 rest.starts_with('-')
1003 && rest[1..].starts_with(|ch: char| ch.is_ascii_digit())
1004 })
1005 });
1006 if !matches {
1007 continue;
1008 }
1009
1010 let content = match std::fs::read_to_string(pth_path) {
1012 Ok(c) => c,
1013 Err(_) => continue,
1014 };
1015
1016 for line in content.lines() {
1017 let line = line.trim();
1018 if line.is_empty() || line.starts_with('#') || line.starts_with("import ") {
1019 continue;
1020 }
1021 if line.contains('\0')
1024 || line.bytes().any(|b| b < 0x20 && b != b'\t')
1025 || line.contains("..")
1026 {
1027 debug!("Skipping .pth line with invalid characters: {:?}", line);
1028 continue;
1029 }
1030 let candidate = PathBuf::from(line);
1031 let resolved = if candidate.is_absolute() {
1032 candidate
1033 } else {
1034 site_packages.join(&candidate)
1035 };
1036 match resolved.canonicalize() {
1039 Ok(canonical) if canonical.is_dir() => return Some(canonical),
1040 Ok(canonical) => {
1041 debug!(".pth path is not a directory: {:?}", canonical);
1042 continue;
1043 }
1044 Err(_) => {
1045 debug!("Could not canonicalize .pth path: {:?}", resolved);
1046 continue;
1047 }
1048 }
1049 }
1050 }
1051
1052 None
1053 }
1054
1055 fn resolve_entry_point_in_editable_installs(&self, module_path: &str) -> Option<PathBuf> {
1057 let installs = self.editable_install_roots.lock().unwrap();
1058 for install in installs.iter() {
1059 if let Some(path) =
1060 Self::resolve_entry_point_module_to_path(&install.source_root, module_path)
1061 {
1062 return Some(path);
1063 }
1064 }
1065 None
1066 }
1067
1068 fn scan_pytest_plugins(&self, site_packages: &Path) {
1069 info!(
1070 "Scanning for pytest plugins via entry points in: {:?}",
1071 site_packages
1072 );
1073
1074 self.discover_editable_installs(site_packages);
1076
1077 let mut plugin_count = 0;
1078
1079 self.scan_pytest_internal_fixtures(site_packages);
1081
1082 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
1084 let entry = match entry {
1085 Ok(e) => e,
1086 Err(_) => continue,
1087 };
1088
1089 let path = entry.path();
1090 let filename = path.file_name().unwrap_or_default().to_string_lossy();
1091
1092 if !filename.ends_with(".dist-info") && !filename.ends_with(".egg-info") {
1094 continue;
1095 }
1096
1097 let scanned = self.load_plugin_from_entry_point(&path, site_packages);
1099 if scanned > 0 {
1100 plugin_count += scanned;
1101 debug!("Loaded {} plugin module(s) from {}", scanned, filename);
1102 }
1103 }
1104
1105 info!(
1106 "Discovered fixtures from {} pytest plugin modules",
1107 plugin_count
1108 );
1109 }
1110
1111 fn scan_plugin_directory(&self, plugin_dir: &Path) {
1112 for entry in WalkDir::new(plugin_dir)
1114 .max_depth(3) .into_iter()
1116 .filter_map(|e| e.ok())
1117 {
1118 let path = entry.path();
1119
1120 if path.extension().and_then(|s| s.to_str()) == Some("py") {
1121 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
1123 if filename.starts_with("test_") || filename.contains("__pycache__") {
1125 continue;
1126 }
1127
1128 debug!("Scanning plugin file: {:?}", path);
1129
1130 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1132 self.plugin_fixture_files.insert(canonical, ());
1133
1134 if let Ok(content) = std::fs::read_to_string(path) {
1135 self.analyze_file(path.to_path_buf(), &content);
1136 }
1137 }
1138 }
1139 }
1140 }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146 use std::fs;
1147 use tempfile::tempdir;
1148
1149 #[test]
1150 fn test_parse_pytest11_entry_points_basic() {
1151 let content = r#"
1152[console_scripts]
1153my-cli = my_package:main
1154
1155[pytest11]
1156my_plugin = my_package.plugin
1157another = another_pkg
1158
1159[other_section]
1160foo = bar
1161"#;
1162
1163 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1164 assert_eq!(entries.len(), 2);
1165 assert_eq!(entries[0].name, "my_plugin");
1166 assert_eq!(entries[0].module_path, "my_package.plugin");
1167 assert_eq!(entries[1].name, "another");
1168 assert_eq!(entries[1].module_path, "another_pkg");
1169 }
1170
1171 #[test]
1172 fn test_parse_pytest11_entry_points_empty_file() {
1173 let entries = FixtureDatabase::parse_pytest11_entry_points("");
1174 assert!(entries.is_empty());
1175 }
1176
1177 #[test]
1178 fn test_parse_pytest11_entry_points_no_pytest11_section() {
1179 let content = r#"
1180[console_scripts]
1181my-cli = my_package:main
1182"#;
1183 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1184 assert!(entries.is_empty());
1185 }
1186
1187 #[test]
1188 fn test_parse_pytest11_entry_points_with_comments() {
1189 let content = r#"
1190[pytest11]
1191# This is a comment
1192my_plugin = my_package.plugin
1193# Another comment
1194"#;
1195 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1196 assert_eq!(entries.len(), 1);
1197 assert_eq!(entries[0].name, "my_plugin");
1198 }
1199
1200 #[test]
1201 fn test_parse_pytest11_entry_points_with_whitespace() {
1202 let content = r#"
1203[pytest11]
1204 my_plugin = my_package.plugin
1205another=another_pkg
1206"#;
1207 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1208 assert_eq!(entries.len(), 2);
1209 assert_eq!(entries[0].name, "my_plugin");
1210 assert_eq!(entries[0].module_path, "my_package.plugin");
1211 assert_eq!(entries[1].name, "another");
1212 assert_eq!(entries[1].module_path, "another_pkg");
1213 }
1214
1215 #[test]
1216 fn test_parse_pytest11_entry_points_with_attr() {
1217 let content = r#"
1219[pytest11]
1220my_plugin = my_package.module:plugin_entry
1221"#;
1222 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1223 assert_eq!(entries.len(), 1);
1224 assert_eq!(entries[0].module_path, "my_package.module:plugin_entry");
1225 }
1226
1227 #[test]
1228 fn test_parse_pytest11_entry_points_multiple_sections_before_pytest11() {
1229 let content = r#"
1230[console_scripts]
1231cli = pkg:main
1232
1233[gui_scripts]
1234gui = pkg:gui_main
1235
1236[pytest11]
1237my_plugin = my_package.plugin
1238
1239[other]
1240extra = something
1241"#;
1242 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1243 assert_eq!(entries.len(), 1);
1244 assert_eq!(entries[0].name, "my_plugin");
1245 }
1246
1247 #[test]
1248 fn test_resolve_entry_point_module_to_path_package() {
1249 let temp = tempdir().unwrap();
1250 let site_packages = temp.path();
1251
1252 let pkg_dir = site_packages.join("my_plugin");
1254 fs::create_dir_all(&pkg_dir).unwrap();
1255 fs::write(pkg_dir.join("__init__.py"), "# plugin code").unwrap();
1256
1257 let result =
1259 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1260 assert!(result.is_some());
1261 assert_eq!(
1262 result.unwrap(),
1263 pkg_dir.join("__init__.py").canonicalize().unwrap()
1264 );
1265 }
1266
1267 #[test]
1268 fn test_resolve_entry_point_module_to_path_submodule() {
1269 let temp = tempdir().unwrap();
1270 let site_packages = temp.path();
1271
1272 let pkg_dir = site_packages.join("my_plugin");
1274 fs::create_dir_all(&pkg_dir).unwrap();
1275 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1276 fs::write(pkg_dir.join("plugin.py"), "# plugin code").unwrap();
1277
1278 let result =
1280 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin.plugin");
1281 assert!(result.is_some());
1282 assert_eq!(
1283 result.unwrap(),
1284 pkg_dir.join("plugin.py").canonicalize().unwrap()
1285 );
1286 }
1287
1288 #[test]
1289 fn test_resolve_entry_point_module_to_path_single_file() {
1290 let temp = tempdir().unwrap();
1291 let site_packages = temp.path();
1292
1293 fs::write(site_packages.join("my_plugin.py"), "# plugin code").unwrap();
1295
1296 let result =
1298 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1299 assert!(result.is_some());
1300 assert_eq!(
1301 result.unwrap(),
1302 site_packages.join("my_plugin.py").canonicalize().unwrap()
1303 );
1304 }
1305
1306 #[test]
1307 fn test_resolve_entry_point_module_to_path_not_found() {
1308 let temp = tempdir().unwrap();
1309 let site_packages = temp.path();
1310
1311 let result = FixtureDatabase::resolve_entry_point_module_to_path(
1313 site_packages,
1314 "nonexistent_plugin",
1315 );
1316 assert!(result.is_none());
1317 }
1318
1319 #[test]
1320 fn test_resolve_entry_point_module_strips_attr() {
1321 let temp = tempdir().unwrap();
1322 let site_packages = temp.path();
1323
1324 let pkg_dir = site_packages.join("my_plugin");
1326 fs::create_dir_all(&pkg_dir).unwrap();
1327 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1328 fs::write(pkg_dir.join("module.py"), "# plugin code").unwrap();
1329
1330 let result = FixtureDatabase::resolve_entry_point_module_to_path(
1332 site_packages,
1333 "my_plugin.module:entry_function",
1334 );
1335 assert!(result.is_some());
1336 assert_eq!(
1337 result.unwrap(),
1338 pkg_dir.join("module.py").canonicalize().unwrap()
1339 );
1340 }
1341
1342 #[test]
1343 fn test_resolve_entry_point_rejects_path_traversal() {
1344 let temp = tempdir().unwrap();
1345 let site_packages = temp.path();
1346
1347 fs::write(site_packages.join("valid.py"), "# code").unwrap();
1349
1350 let result =
1353 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "..%2Fetc%2Fpasswd");
1354 assert!(result.is_none(), "should reject traversal-like pattern");
1355
1356 let result =
1358 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "valid...secret");
1359 assert!(
1360 result.is_none(),
1361 "should reject module names with consecutive dots (empty segments)"
1362 );
1363
1364 let result =
1366 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "pkg..evil");
1367 assert!(
1368 result.is_none(),
1369 "should reject module names with consecutive dots"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_resolve_entry_point_rejects_null_bytes() {
1375 let temp = tempdir().unwrap();
1376 let site_packages = temp.path();
1377
1378 let result =
1379 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "module\0name");
1380 assert!(result.is_none(), "should reject null bytes");
1381 }
1382
1383 #[test]
1384 fn test_resolve_entry_point_rejects_empty_segments() {
1385 let temp = tempdir().unwrap();
1386 let site_packages = temp.path();
1387
1388 let result = FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "foo..bar");
1390 assert!(result.is_none(), "should reject empty path segments");
1391 }
1392
1393 #[cfg(unix)]
1394 #[test]
1395 fn test_resolve_entry_point_rejects_symlink_escape() {
1396 let temp = tempdir().unwrap();
1397 let site_packages = temp.path();
1398
1399 let outside = tempdir().unwrap();
1401 fs::write(outside.path().join("evil.py"), "# malicious").unwrap();
1402
1403 std::os::unix::fs::symlink(outside.path(), site_packages.join("escaped")).unwrap();
1405
1406 let result =
1407 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "escaped.evil");
1408 assert!(
1409 result.is_none(),
1410 "should reject paths that escape site-packages via symlink"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_entry_point_plugin_discovery_integration() {
1416 let temp = tempdir().unwrap();
1418 let site_packages = temp.path();
1419
1420 let plugin_dir = site_packages.join("my_pytest_plugin");
1422 fs::create_dir_all(&plugin_dir).unwrap();
1423
1424 let plugin_content = r#"
1425import pytest
1426
1427@pytest.fixture
1428def my_dynamic_fixture():
1429 """A fixture discovered via entry points."""
1430 return "discovered via entry point"
1431
1432@pytest.fixture
1433def another_dynamic_fixture():
1434 return 42
1435"#;
1436 fs::write(plugin_dir.join("__init__.py"), plugin_content).unwrap();
1437
1438 let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1440 fs::create_dir_all(&dist_info).unwrap();
1441
1442 let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1443 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1444
1445 let db = FixtureDatabase::new();
1447 db.scan_pytest_plugins(site_packages);
1448
1449 assert!(
1450 db.definitions.contains_key("my_dynamic_fixture"),
1451 "my_dynamic_fixture should be discovered"
1452 );
1453 assert!(
1454 db.definitions.contains_key("another_dynamic_fixture"),
1455 "another_dynamic_fixture should be discovered"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_entry_point_discovery_submodule() {
1461 let temp = tempdir().unwrap();
1462 let site_packages = temp.path();
1463
1464 let plugin_dir = site_packages.join("my_pytest_plugin");
1466 fs::create_dir_all(&plugin_dir).unwrap();
1467 fs::write(plugin_dir.join("__init__.py"), "# main init").unwrap();
1468
1469 let plugin_content = r#"
1470import pytest
1471
1472@pytest.fixture
1473def submodule_fixture():
1474 return "from submodule"
1475"#;
1476 fs::write(plugin_dir.join("plugin.py"), plugin_content).unwrap();
1477
1478 let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1480 fs::create_dir_all(&dist_info).unwrap();
1481
1482 let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin.plugin\n";
1483 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1484
1485 let db = FixtureDatabase::new();
1487 db.scan_pytest_plugins(site_packages);
1488
1489 assert!(
1490 db.definitions.contains_key("submodule_fixture"),
1491 "submodule_fixture should be discovered"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_entry_point_discovery_package_scans_submodules() {
1497 let temp = tempdir().unwrap();
1498 let site_packages = temp.path();
1499
1500 let plugin_dir = site_packages.join("my_pytest_plugin");
1502 fs::create_dir_all(&plugin_dir).unwrap();
1503 fs::write(plugin_dir.join("__init__.py"), "# package init").unwrap();
1504
1505 let plugin_content = r#"
1506import pytest
1507
1508@pytest.fixture
1509def package_submodule_fixture():
1510 return "from package submodule"
1511"#;
1512 fs::write(plugin_dir.join("fixtures.py"), plugin_content).unwrap();
1513
1514 let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1516 fs::create_dir_all(&dist_info).unwrap();
1517
1518 let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1519 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1520
1521 let db = FixtureDatabase::new();
1523 db.scan_pytest_plugins(site_packages);
1524
1525 assert!(
1526 db.definitions.contains_key("package_submodule_fixture"),
1527 "package_submodule_fixture should be discovered"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_entry_point_discovery_no_pytest11_section() {
1533 let temp = tempdir().unwrap();
1534 let site_packages = temp.path();
1535
1536 let pkg_dir = site_packages.join("some_package");
1538 fs::create_dir_all(&pkg_dir).unwrap();
1539
1540 let pkg_content = r#"
1541import pytest
1542
1543@pytest.fixture
1544def should_not_be_found():
1545 return "this package is not a pytest plugin"
1546"#;
1547 fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1548
1549 let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1551 fs::create_dir_all(&dist_info).unwrap();
1552
1553 let entry_points = "[console_scripts]\nsome_cli = some_package:main\n";
1554 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1555
1556 let db = FixtureDatabase::new();
1558 db.scan_pytest_plugins(site_packages);
1559
1560 assert!(
1561 !db.definitions.contains_key("should_not_be_found"),
1562 "should_not_be_found should NOT be discovered (not a pytest plugin)"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_entry_point_discovery_missing_entry_points_txt() {
1568 let temp = tempdir().unwrap();
1569 let site_packages = temp.path();
1570
1571 let pkg_dir = site_packages.join("some_package");
1573 fs::create_dir_all(&pkg_dir).unwrap();
1574
1575 let pkg_content = r#"
1576import pytest
1577
1578@pytest.fixture
1579def should_not_be_found():
1580 return "no entry_points.txt"
1581"#;
1582 fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1583
1584 let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1586 fs::create_dir_all(&dist_info).unwrap();
1587 let db = FixtureDatabase::new();
1591 db.scan_pytest_plugins(site_packages);
1592
1593 assert!(
1594 !db.definitions.contains_key("should_not_be_found"),
1595 "should_not_be_found should NOT be discovered (no entry_points.txt)"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_entry_point_discovery_egg_info() {
1601 let temp = tempdir().unwrap();
1602 let site_packages = temp.path();
1603
1604 let pkg_dir = site_packages.join("legacy_plugin");
1606 fs::create_dir_all(&pkg_dir).unwrap();
1607 fs::write(
1608 pkg_dir.join("__init__.py"),
1609 r#"
1610import pytest
1611
1612@pytest.fixture
1613def legacy_plugin_fixture():
1614 return "from egg-info"
1615"#,
1616 )
1617 .unwrap();
1618
1619 let egg_info = site_packages.join("legacy_plugin-1.0.0.egg-info");
1621 fs::create_dir_all(&egg_info).unwrap();
1622 let entry_points = "[pytest11]\nlegacy_plugin = legacy_plugin\n";
1623 fs::write(egg_info.join("entry_points.txt"), entry_points).unwrap();
1624
1625 let db = FixtureDatabase::new();
1627 db.scan_pytest_plugins(site_packages);
1628
1629 assert!(
1630 db.definitions.contains_key("legacy_plugin_fixture"),
1631 "legacy_plugin_fixture should be discovered"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_entry_point_discovery_multiple_plugins() {
1637 let temp = tempdir().unwrap();
1638 let site_packages = temp.path();
1639
1640 let plugin1_dir = site_packages.join("plugin_one");
1642 fs::create_dir_all(&plugin1_dir).unwrap();
1643 fs::write(
1644 plugin1_dir.join("__init__.py"),
1645 r#"
1646import pytest
1647
1648@pytest.fixture
1649def fixture_from_plugin_one():
1650 return 1
1651"#,
1652 )
1653 .unwrap();
1654
1655 let dist_info1 = site_packages.join("plugin_one-1.0.0.dist-info");
1656 fs::create_dir_all(&dist_info1).unwrap();
1657 fs::write(
1658 dist_info1.join("entry_points.txt"),
1659 "[pytest11]\nplugin_one = plugin_one\n",
1660 )
1661 .unwrap();
1662
1663 let plugin2_dir = site_packages.join("plugin_two");
1665 fs::create_dir_all(&plugin2_dir).unwrap();
1666 fs::write(
1667 plugin2_dir.join("__init__.py"),
1668 r#"
1669import pytest
1670
1671@pytest.fixture
1672def fixture_from_plugin_two():
1673 return 2
1674"#,
1675 )
1676 .unwrap();
1677
1678 let dist_info2 = site_packages.join("plugin_two-2.0.0.dist-info");
1679 fs::create_dir_all(&dist_info2).unwrap();
1680 fs::write(
1681 dist_info2.join("entry_points.txt"),
1682 "[pytest11]\nplugin_two = plugin_two\n",
1683 )
1684 .unwrap();
1685
1686 let db = FixtureDatabase::new();
1688 db.scan_pytest_plugins(site_packages);
1689
1690 assert!(
1691 db.definitions.contains_key("fixture_from_plugin_one"),
1692 "fixture_from_plugin_one should be discovered"
1693 );
1694 assert!(
1695 db.definitions.contains_key("fixture_from_plugin_two"),
1696 "fixture_from_plugin_two should be discovered"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_entry_point_discovery_multiple_entries_in_one_package() {
1702 let temp = tempdir().unwrap();
1703 let site_packages = temp.path();
1704
1705 let plugin_dir = site_packages.join("multi_plugin");
1707 fs::create_dir_all(&plugin_dir).unwrap();
1708 fs::write(plugin_dir.join("__init__.py"), "").unwrap();
1709
1710 fs::write(
1711 plugin_dir.join("fixtures_a.py"),
1712 r#"
1713import pytest
1714
1715@pytest.fixture
1716def fixture_a():
1717 return "A"
1718"#,
1719 )
1720 .unwrap();
1721
1722 fs::write(
1723 plugin_dir.join("fixtures_b.py"),
1724 r#"
1725import pytest
1726
1727@pytest.fixture
1728def fixture_b():
1729 return "B"
1730"#,
1731 )
1732 .unwrap();
1733
1734 let dist_info = site_packages.join("multi_plugin-1.0.0.dist-info");
1736 fs::create_dir_all(&dist_info).unwrap();
1737 fs::write(
1738 dist_info.join("entry_points.txt"),
1739 r#"[pytest11]
1740fixtures_a = multi_plugin.fixtures_a
1741fixtures_b = multi_plugin.fixtures_b
1742"#,
1743 )
1744 .unwrap();
1745
1746 let db = FixtureDatabase::new();
1748 db.scan_pytest_plugins(site_packages);
1749
1750 assert!(
1751 db.definitions.contains_key("fixture_a"),
1752 "fixture_a should be discovered"
1753 );
1754 assert!(
1755 db.definitions.contains_key("fixture_b"),
1756 "fixture_b should be discovered"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_pytest_internal_fixtures_scanned() {
1762 let temp = tempdir().unwrap();
1763 let site_packages = temp.path();
1764
1765 let pytest_internal = site_packages.join("_pytest");
1767 fs::create_dir_all(&pytest_internal).unwrap();
1768
1769 let internal_fixtures = r#"
1770import pytest
1771
1772@pytest.fixture
1773def tmp_path():
1774 """Pytest's built-in tmp_path fixture."""
1775 pass
1776
1777@pytest.fixture
1778def capsys():
1779 """Pytest's built-in capsys fixture."""
1780 pass
1781"#;
1782 fs::write(pytest_internal.join("fixtures.py"), internal_fixtures).unwrap();
1783
1784 let db = FixtureDatabase::new();
1786 db.scan_pytest_plugins(site_packages);
1787
1788 assert!(
1791 db.definitions.contains_key("tmp_path"),
1792 "tmp_path should be discovered from _pytest"
1793 );
1794 assert!(
1795 db.definitions.contains_key("capsys"),
1796 "capsys should be discovered from _pytest"
1797 );
1798 }
1799
1800 #[test]
1801 fn test_extract_package_name_from_dist_info() {
1802 assert_eq!(
1803 FixtureDatabase::extract_package_name_from_dist_info("mypackage-1.0.0.dist-info"),
1804 Some(("mypackage".to_string(), "mypackage".to_string()))
1805 );
1806 assert_eq!(
1807 FixtureDatabase::extract_package_name_from_dist_info("my-package-1.0.0.dist-info"),
1808 Some(("my-package".to_string(), "my_package".to_string()))
1809 );
1810 assert_eq!(
1811 FixtureDatabase::extract_package_name_from_dist_info("My.Package-2.3.4.dist-info"),
1812 Some(("My.Package".to_string(), "my_package".to_string()))
1813 );
1814 assert_eq!(
1815 FixtureDatabase::extract_package_name_from_dist_info("pytest_mock-3.12.0.dist-info"),
1816 Some(("pytest_mock".to_string(), "pytest_mock".to_string()))
1817 );
1818 assert_eq!(
1819 FixtureDatabase::extract_package_name_from_dist_info("mypackage-0.1.0.egg-info"),
1820 Some(("mypackage".to_string(), "mypackage".to_string()))
1821 );
1822 assert_eq!(
1824 FixtureDatabase::extract_package_name_from_dist_info("mypackage.dist-info"),
1825 Some(("mypackage".to_string(), "mypackage".to_string()))
1826 );
1827 }
1828
1829 #[test]
1830 fn test_discover_editable_installs() {
1831 let temp = tempdir().unwrap();
1832 let site_packages = temp.path();
1833
1834 let source_root = tempdir().unwrap();
1836 let pkg_dir = source_root.path().join("mypackage");
1837 fs::create_dir_all(&pkg_dir).unwrap();
1838 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1839
1840 let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
1842 fs::create_dir_all(&dist_info).unwrap();
1843
1844 let direct_url = serde_json::json!({
1845 "url": format!("file://{}", source_root.path().display()),
1846 "dir_info": {
1847 "editable": true
1848 }
1849 });
1850 fs::write(
1851 dist_info.join("direct_url.json"),
1852 serde_json::to_string(&direct_url).unwrap(),
1853 )
1854 .unwrap();
1855
1856 let pth_content = format!("{}\n", source_root.path().display());
1858 fs::write(
1859 site_packages.join("__editable__.mypackage-0.1.0.pth"),
1860 &pth_content,
1861 )
1862 .unwrap();
1863
1864 let db = FixtureDatabase::new();
1865 db.discover_editable_installs(site_packages);
1866
1867 let installs = db.editable_install_roots.lock().unwrap();
1868 assert_eq!(installs.len(), 1, "Should discover one editable install");
1869 assert_eq!(installs[0].package_name, "mypackage");
1870 assert_eq!(
1871 installs[0].source_root,
1872 source_root.path().canonicalize().unwrap()
1873 );
1874 }
1875
1876 #[test]
1877 fn test_discover_editable_installs_pth_with_dashes() {
1878 let temp = tempdir().unwrap();
1879 let site_packages = temp.path();
1880
1881 let source_root = tempdir().unwrap();
1883 let pkg_dir = source_root.path().join("my_package");
1884 fs::create_dir_all(&pkg_dir).unwrap();
1885 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1886
1887 let dist_info = site_packages.join("my-package-0.1.0.dist-info");
1889 fs::create_dir_all(&dist_info).unwrap();
1890 let direct_url = serde_json::json!({
1891 "url": format!("file://{}", source_root.path().display()),
1892 "dir_info": { "editable": true }
1893 });
1894 fs::write(
1895 dist_info.join("direct_url.json"),
1896 serde_json::to_string(&direct_url).unwrap(),
1897 )
1898 .unwrap();
1899
1900 let pth_content = format!("{}\n", source_root.path().display());
1902 fs::write(
1903 site_packages.join("__editable__.my-package-0.1.0.pth"),
1904 &pth_content,
1905 )
1906 .unwrap();
1907
1908 let db = FixtureDatabase::new();
1909 db.discover_editable_installs(site_packages);
1910
1911 let installs = db.editable_install_roots.lock().unwrap();
1912 assert_eq!(
1913 installs.len(),
1914 1,
1915 "Should discover editable install from .pth with dashes"
1916 );
1917 assert_eq!(installs[0].package_name, "my_package");
1918 assert_eq!(
1919 installs[0].source_root,
1920 source_root.path().canonicalize().unwrap()
1921 );
1922 }
1923
1924 #[test]
1925 fn test_discover_editable_installs_pth_with_dots() {
1926 let temp = tempdir().unwrap();
1927 let site_packages = temp.path();
1928
1929 let source_root = tempdir().unwrap();
1931 fs::create_dir_all(source_root.path().join("my_package")).unwrap();
1932 fs::write(source_root.path().join("my_package/__init__.py"), "").unwrap();
1933
1934 let dist_info = site_packages.join("My.Package-1.0.0.dist-info");
1936 fs::create_dir_all(&dist_info).unwrap();
1937 let direct_url = serde_json::json!({
1938 "url": format!("file://{}", source_root.path().display()),
1939 "dir_info": { "editable": true }
1940 });
1941 fs::write(
1942 dist_info.join("direct_url.json"),
1943 serde_json::to_string(&direct_url).unwrap(),
1944 )
1945 .unwrap();
1946
1947 let pth_content = format!("{}\n", source_root.path().display());
1949 fs::write(
1950 site_packages.join("__editable__.My.Package-1.0.0.pth"),
1951 &pth_content,
1952 )
1953 .unwrap();
1954
1955 let db = FixtureDatabase::new();
1956 db.discover_editable_installs(site_packages);
1957
1958 let installs = db.editable_install_roots.lock().unwrap();
1959 assert_eq!(
1960 installs.len(),
1961 1,
1962 "Should discover editable install from .pth with dots"
1963 );
1964 assert_eq!(installs[0].package_name, "my_package");
1965 assert_eq!(
1966 installs[0].source_root,
1967 source_root.path().canonicalize().unwrap()
1968 );
1969 }
1970
1971 #[test]
1972 fn test_discover_editable_installs_dedup_on_rescan() {
1973 let temp = tempdir().unwrap();
1974 let site_packages = temp.path();
1975
1976 let source_root = tempdir().unwrap();
1977 fs::create_dir_all(source_root.path().join("pkg")).unwrap();
1978 fs::write(source_root.path().join("pkg/__init__.py"), "").unwrap();
1979
1980 let dist_info = site_packages.join("pkg-0.1.0.dist-info");
1981 fs::create_dir_all(&dist_info).unwrap();
1982 let direct_url = serde_json::json!({
1983 "url": format!("file://{}", source_root.path().display()),
1984 "dir_info": { "editable": true }
1985 });
1986 fs::write(
1987 dist_info.join("direct_url.json"),
1988 serde_json::to_string(&direct_url).unwrap(),
1989 )
1990 .unwrap();
1991
1992 let pth_content = format!("{}\n", source_root.path().display());
1993 fs::write(site_packages.join("pkg.pth"), &pth_content).unwrap();
1994
1995 let db = FixtureDatabase::new();
1996
1997 db.discover_editable_installs(site_packages);
1999 db.discover_editable_installs(site_packages);
2000
2001 let installs = db.editable_install_roots.lock().unwrap();
2002 assert_eq!(
2003 installs.len(),
2004 1,
2005 "Re-scanning should not produce duplicates"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_editable_install_entry_point_resolution() {
2011 let temp = tempdir().unwrap();
2012 let site_packages = temp.path();
2013
2014 let source_root = tempdir().unwrap();
2016 let pkg_dir = source_root.path().join("mypackage");
2017 fs::create_dir_all(&pkg_dir).unwrap();
2018
2019 let plugin_content = r#"
2020import pytest
2021
2022@pytest.fixture
2023def editable_fixture():
2024 return "from editable install"
2025"#;
2026 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2027 fs::write(pkg_dir.join("plugin.py"), plugin_content).unwrap();
2028
2029 let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
2031 fs::create_dir_all(&dist_info).unwrap();
2032
2033 let direct_url = serde_json::json!({
2034 "url": format!("file://{}", source_root.path().display()),
2035 "dir_info": { "editable": true }
2036 });
2037 fs::write(
2038 dist_info.join("direct_url.json"),
2039 serde_json::to_string(&direct_url).unwrap(),
2040 )
2041 .unwrap();
2042
2043 let entry_points = "[pytest11]\nmypackage = mypackage.plugin\n";
2044 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
2045
2046 let pth_content = format!("{}\n", source_root.path().display());
2048 fs::write(
2049 site_packages.join("__editable__.mypackage-0.1.0.pth"),
2050 &pth_content,
2051 )
2052 .unwrap();
2053
2054 let db = FixtureDatabase::new();
2055 db.scan_pytest_plugins(site_packages);
2056
2057 assert!(
2058 db.definitions.contains_key("editable_fixture"),
2059 "editable_fixture should be discovered via entry point fallback"
2060 );
2061 }
2062
2063 #[test]
2064 fn test_discover_editable_installs_namespace_package() {
2065 let temp = tempdir().unwrap();
2066 let site_packages = temp.path();
2067
2068 let source_root = tempdir().unwrap();
2069 let pkg_dir = source_root.path().join("namespace").join("pkg");
2070 fs::create_dir_all(&pkg_dir).unwrap();
2071 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2072
2073 let dist_info = site_packages.join("namespace.pkg-1.0.0.dist-info");
2074 fs::create_dir_all(&dist_info).unwrap();
2075 let direct_url = serde_json::json!({
2076 "url": format!("file://{}", source_root.path().display()),
2077 "dir_info": { "editable": true }
2078 });
2079 fs::write(
2080 dist_info.join("direct_url.json"),
2081 serde_json::to_string(&direct_url).unwrap(),
2082 )
2083 .unwrap();
2084
2085 let pth_content = format!("{}\n", source_root.path().display());
2086 fs::write(
2087 site_packages.join("__editable__.namespace.pkg-1.0.0.pth"),
2088 &pth_content,
2089 )
2090 .unwrap();
2091
2092 let db = FixtureDatabase::new();
2093 db.discover_editable_installs(site_packages);
2094
2095 let installs = db.editable_install_roots.lock().unwrap();
2096 assert_eq!(
2097 installs.len(),
2098 1,
2099 "Should discover namespace editable install"
2100 );
2101 assert_eq!(installs[0].package_name, "namespace_pkg");
2102 assert_eq!(installs[0].raw_package_name, "namespace.pkg");
2103 assert_eq!(
2104 installs[0].source_root,
2105 source_root.path().canonicalize().unwrap()
2106 );
2107 }
2108
2109 #[test]
2110 fn test_pth_prefix_matching_no_false_positive() {
2111 let temp = tempdir().unwrap();
2113 let site_packages = temp.path();
2114
2115 let source_root_foo = tempdir().unwrap();
2116 fs::create_dir_all(source_root_foo.path()).unwrap();
2117
2118 let source_root_foobar = tempdir().unwrap();
2119 fs::create_dir_all(source_root_foobar.path()).unwrap();
2120
2121 fs::write(
2123 site_packages.join("foo-bar.pth"),
2124 format!("{}\n", source_root_foobar.path().display()),
2125 )
2126 .unwrap();
2127
2128 let pth_index = FixtureDatabase::build_pth_index(site_packages);
2129
2130 let result =
2132 FixtureDatabase::find_editable_pth_source_root(&pth_index, "foo", "foo", site_packages);
2133 assert!(
2134 result.is_none(),
2135 "foo should not match foo-bar.pth (different package)"
2136 );
2137
2138 let result = FixtureDatabase::find_editable_pth_source_root(
2140 &pth_index,
2141 "foo-bar",
2142 "foo_bar",
2143 site_packages,
2144 );
2145 assert!(result.is_some(), "foo-bar should match foo-bar.pth exactly");
2146 }
2147
2148 #[test]
2149 fn test_transitive_plugin_status_via_pytest_plugins() {
2150 let workspace = tempdir().unwrap();
2151 let workspace_canonical = workspace.path().canonicalize().unwrap();
2152
2153 let db = FixtureDatabase::new();
2154 *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2155
2156 let pkg_dir = workspace_canonical.join("mypackage");
2162 fs::create_dir_all(&pkg_dir).unwrap();
2163 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2164
2165 let plugin_content = r#"
2166import pytest
2167
2168pytest_plugins = ["mypackage.helpers"]
2169
2170@pytest.fixture
2171def direct_plugin_fixture():
2172 return "from plugin.py"
2173"#;
2174 let plugin_file = pkg_dir.join("plugin.py");
2175 fs::write(&plugin_file, plugin_content).unwrap();
2176
2177 let helpers_content = r#"
2178import pytest
2179
2180@pytest.fixture
2181def transitive_plugin_fixture():
2182 return "from helpers.py, imported by plugin.py"
2183"#;
2184 let helpers_file = pkg_dir.join("helpers.py");
2185 fs::write(&helpers_file, helpers_content).unwrap();
2186
2187 let canonical_plugin = plugin_file.canonicalize().unwrap();
2189 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2190
2191 db.analyze_file(canonical_plugin.clone(), plugin_content);
2193
2194 db.scan_imported_fixture_modules(&workspace_canonical);
2197
2198 let direct_is_plugin = db
2200 .definitions
2201 .get("direct_plugin_fixture")
2202 .map(|defs| defs[0].is_plugin);
2203 assert_eq!(
2204 direct_is_plugin,
2205 Some(true),
2206 "direct_plugin_fixture should have is_plugin=true"
2207 );
2208
2209 let transitive_is_plugin = db
2211 .definitions
2212 .get("transitive_plugin_fixture")
2213 .map(|defs| defs[0].is_plugin);
2214 assert_eq!(
2215 transitive_is_plugin,
2216 Some(true),
2217 "transitive_plugin_fixture should have is_plugin=true (propagated from plugin.py)"
2218 );
2219
2220 let transitive_is_third_party = db
2221 .definitions
2222 .get("transitive_plugin_fixture")
2223 .map(|defs| defs[0].is_third_party);
2224 assert_eq!(
2225 transitive_is_third_party,
2226 Some(false),
2227 "transitive_plugin_fixture should NOT be third-party (workspace-local)"
2228 );
2229
2230 let tests_dir = workspace_canonical.join("tests");
2232 fs::create_dir_all(&tests_dir).unwrap();
2233 let test_file = tests_dir.join("test_transitive.py");
2234 let test_content = "def test_transitive(): pass\n";
2235 fs::write(&test_file, test_content).unwrap();
2236 let canonical_test = test_file.canonicalize().unwrap();
2237 db.analyze_file(canonical_test.clone(), test_content);
2238
2239 let available = db.get_available_fixtures(&canonical_test);
2240 let available_names: Vec<&str> = available.iter().map(|d| d.name.as_str()).collect();
2241 assert!(
2242 available_names.contains(&"direct_plugin_fixture"),
2243 "direct_plugin_fixture should be available. Got: {:?}",
2244 available_names
2245 );
2246 assert!(
2247 available_names.contains(&"transitive_plugin_fixture"),
2248 "transitive_plugin_fixture should be available (transitively via plugin). Got: {:?}",
2249 available_names
2250 );
2251 }
2252
2253 #[test]
2254 fn test_transitive_plugin_status_via_star_import() {
2255 let workspace = tempdir().unwrap();
2256 let workspace_canonical = workspace.path().canonicalize().unwrap();
2257
2258 let db = FixtureDatabase::new();
2259 *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2260
2261 let pkg_dir = workspace_canonical.join("mypackage");
2267 fs::create_dir_all(&pkg_dir).unwrap();
2268 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2269
2270 let plugin_content = r#"
2271import pytest
2272from .fixtures import *
2273
2274@pytest.fixture
2275def star_direct_fixture():
2276 return "from plugin.py"
2277"#;
2278 let plugin_file = pkg_dir.join("plugin.py");
2279 fs::write(&plugin_file, plugin_content).unwrap();
2280
2281 let fixtures_content = r#"
2282import pytest
2283
2284@pytest.fixture
2285def star_imported_fixture():
2286 return "from fixtures.py, star-imported by plugin.py"
2287"#;
2288 let fixtures_file = pkg_dir.join("fixtures.py");
2289 fs::write(&fixtures_file, fixtures_content).unwrap();
2290
2291 let canonical_plugin = plugin_file.canonicalize().unwrap();
2293 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2294
2295 db.analyze_file(canonical_plugin.clone(), plugin_content);
2297
2298 db.scan_imported_fixture_modules(&workspace_canonical);
2300
2301 let star_is_plugin = db
2303 .definitions
2304 .get("star_imported_fixture")
2305 .map(|defs| defs[0].is_plugin);
2306 assert_eq!(
2307 star_is_plugin,
2308 Some(true),
2309 "star_imported_fixture should have is_plugin=true (propagated from plugin.py via star import)"
2310 );
2311
2312 let test_file = workspace_canonical.join("test_star.py");
2314 let test_content = "def test_star(): pass\n";
2315 fs::write(&test_file, test_content).unwrap();
2316 let canonical_test = test_file.canonicalize().unwrap();
2317 db.analyze_file(canonical_test.clone(), test_content);
2318
2319 let available = db.get_available_fixtures(&canonical_test);
2320 let available_names: Vec<&str> = available.iter().map(|d| d.name.as_str()).collect();
2321 assert!(
2322 available_names.contains(&"star_direct_fixture"),
2323 "star_direct_fixture should be available. Got: {:?}",
2324 available_names
2325 );
2326 assert!(
2327 available_names.contains(&"star_imported_fixture"),
2328 "star_imported_fixture should be available (transitively via star import). Got: {:?}",
2329 available_names
2330 );
2331 }
2332
2333 #[test]
2334 fn test_non_plugin_conftest_import_not_marked_as_plugin() {
2335 let workspace = tempdir().unwrap();
2336 let workspace_canonical = workspace.path().canonicalize().unwrap();
2337
2338 let db = FixtureDatabase::new();
2339 *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2340
2341 let conftest_content = r#"
2344import pytest
2345from .helpers import *
2346"#;
2347 let conftest_file = workspace_canonical.join("conftest.py");
2348 fs::write(&conftest_file, conftest_content).unwrap();
2349
2350 let helpers_content = r#"
2351import pytest
2352
2353@pytest.fixture
2354def conftest_helper_fixture():
2355 return "from helpers, imported by conftest"
2356"#;
2357 let helpers_file = workspace_canonical.join("helpers.py");
2358 fs::write(&helpers_file, helpers_content).unwrap();
2359
2360 let canonical_conftest = conftest_file.canonicalize().unwrap();
2361 db.analyze_file(canonical_conftest.clone(), conftest_content);
2363
2364 db.scan_imported_fixture_modules(&workspace_canonical);
2365
2366 let is_plugin = db
2368 .definitions
2369 .get("conftest_helper_fixture")
2370 .map(|defs| defs[0].is_plugin);
2371 if let Some(is_plugin) = is_plugin {
2372 assert!(
2373 !is_plugin,
2374 "Fixture imported by conftest (not a plugin) should NOT be marked is_plugin"
2375 );
2376 }
2377 }
2381
2382 #[test]
2383 fn test_already_cached_module_marked_plugin_via_pytest_plugins() {
2384 let workspace = tempdir().unwrap();
2389 let workspace_canonical = workspace.path().canonicalize().unwrap();
2390
2391 let db = FixtureDatabase::new();
2392 *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2393
2394 let pkg_dir = workspace_canonical.join("mypackage");
2400 fs::create_dir_all(&pkg_dir).unwrap();
2401 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2402
2403 let helpers_content = r#"
2404import pytest
2405
2406@pytest.fixture
2407def pre_cached_fixture():
2408 return "I was analyzed before the plugin scan"
2409"#;
2410 let helpers_file = pkg_dir.join("helpers.py");
2411 fs::write(&helpers_file, helpers_content).unwrap();
2412 let canonical_helpers = helpers_file.canonicalize().unwrap();
2413
2414 db.analyze_file(canonical_helpers.clone(), helpers_content);
2416
2417 let before = db
2419 .definitions
2420 .get("pre_cached_fixture")
2421 .map(|defs| defs[0].is_plugin);
2422 assert_eq!(
2423 before,
2424 Some(false),
2425 "pre_cached_fixture should initially have is_plugin=false"
2426 );
2427
2428 let plugin_content = r#"
2430import pytest
2431
2432pytest_plugins = ["mypackage.helpers"]
2433
2434@pytest.fixture
2435def direct_fixture():
2436 return "from plugin.py"
2437"#;
2438 let plugin_file = pkg_dir.join("plugin.py");
2439 fs::write(&plugin_file, plugin_content).unwrap();
2440 let canonical_plugin = plugin_file.canonicalize().unwrap();
2441
2442 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2444 db.analyze_file(canonical_plugin.clone(), plugin_content);
2445
2446 db.scan_imported_fixture_modules(&workspace_canonical);
2449
2450 let after = db
2452 .definitions
2453 .get("pre_cached_fixture")
2454 .map(|defs| defs[0].is_plugin);
2455 assert_eq!(
2456 after,
2457 Some(true),
2458 "pre_cached_fixture should have is_plugin=true after re-analysis \
2459 (was already cached when plugin declared pytest_plugins)"
2460 );
2461 }
2462
2463 #[test]
2464 fn test_already_cached_module_marked_plugin_via_star_import() {
2465 let workspace = tempdir().unwrap();
2469 let workspace_canonical = workspace.path().canonicalize().unwrap();
2470
2471 let db = FixtureDatabase::new();
2472 *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2473
2474 let pkg_dir = workspace_canonical.join("mypkg");
2475 fs::create_dir_all(&pkg_dir).unwrap();
2476 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2477
2478 let fixtures_content = r#"
2479import pytest
2480
2481@pytest.fixture
2482def star_pre_cached():
2483 return "cached before plugin scan"
2484"#;
2485 let fixtures_file = pkg_dir.join("fixtures.py");
2486 fs::write(&fixtures_file, fixtures_content).unwrap();
2487 let canonical_fixtures = fixtures_file.canonicalize().unwrap();
2488
2489 db.analyze_file(canonical_fixtures.clone(), fixtures_content);
2491
2492 let before = db
2493 .definitions
2494 .get("star_pre_cached")
2495 .map(|defs| defs[0].is_plugin);
2496 assert_eq!(before, Some(false), "should start as is_plugin=false");
2497
2498 let plugin_content = r#"
2500import pytest
2501from .fixtures import *
2502
2503@pytest.fixture
2504def plugin_direct():
2505 return "direct"
2506"#;
2507 let plugin_file = pkg_dir.join("plugin.py");
2508 fs::write(&plugin_file, plugin_content).unwrap();
2509 let canonical_plugin = plugin_file.canonicalize().unwrap();
2510
2511 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2512 db.analyze_file(canonical_plugin.clone(), plugin_content);
2513
2514 db.scan_imported_fixture_modules(&workspace_canonical);
2515
2516 let after = db
2518 .definitions
2519 .get("star_pre_cached")
2520 .map(|defs| defs[0].is_plugin);
2521 assert_eq!(
2522 after,
2523 Some(true),
2524 "star_pre_cached should have is_plugin=true after re-analysis \
2525 (module was star-imported by a plugin file)"
2526 );
2527 }
2528
2529 #[test]
2530 fn test_explicit_import_does_not_propagate_plugin_status() {
2531 let workspace = tempdir().unwrap();
2535 let workspace_canonical = workspace.path().canonicalize().unwrap();
2536
2537 let db = FixtureDatabase::new();
2538 *db.workspace_root.lock().unwrap() = Some(workspace_canonical.clone());
2539
2540 let pkg_dir = workspace_canonical.join("explpkg");
2541 fs::create_dir_all(&pkg_dir).unwrap();
2542 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
2543
2544 let utils_content = r#"
2545import pytest
2546
2547def helper_function():
2548 return 42
2549
2550@pytest.fixture
2551def utils_fixture():
2552 return "from utils"
2553"#;
2554 let utils_file = pkg_dir.join("utils.py");
2555 fs::write(&utils_file, utils_content).unwrap();
2556
2557 let plugin_content = r#"
2559import pytest
2560from .utils import helper_function
2561
2562@pytest.fixture
2563def explicit_plugin_fixture():
2564 return helper_function()
2565"#;
2566 let plugin_file = pkg_dir.join("plugin.py");
2567 fs::write(&plugin_file, plugin_content).unwrap();
2568 let canonical_plugin = plugin_file.canonicalize().unwrap();
2569
2570 db.plugin_fixture_files.insert(canonical_plugin.clone(), ());
2571 db.analyze_file(canonical_plugin.clone(), plugin_content);
2572
2573 db.scan_imported_fixture_modules(&workspace_canonical);
2574
2575 let plugin_fixture = db
2577 .definitions
2578 .get("explicit_plugin_fixture")
2579 .map(|defs| defs[0].is_plugin);
2580 assert_eq!(
2581 plugin_fixture,
2582 Some(true),
2583 "explicit_plugin_fixture should have is_plugin=true"
2584 );
2585
2586 let utils_is_plugin = db
2590 .definitions
2591 .get("utils_fixture")
2592 .map(|defs| defs[0].is_plugin);
2593 if let Some(is_plugin) = utils_is_plugin {
2594 assert!(
2595 !is_plugin,
2596 "utils_fixture should NOT be is_plugin — the plugin only did \
2597 an explicit import of helper_function, not a star import"
2598 );
2599 }
2600 }
2604}