1use super::FixtureDatabase;
4use glob::Pattern;
5use rayon::prelude::*;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use tracing::{debug, error, info, warn};
9use walkdir::WalkDir;
10
11#[derive(Debug, Clone)]
13pub(crate) struct Pytest11EntryPoint {
14 pub(crate) name: String,
16 pub(crate) module_path: String,
18}
19
20impl FixtureDatabase {
21 const SKIP_DIRECTORIES: &'static [&'static str] = &[
24 ".git",
26 ".hg",
27 ".svn",
28 ".venv",
30 "venv",
31 "env",
32 ".env",
33 "__pycache__",
35 ".pytest_cache",
36 ".mypy_cache",
37 ".ruff_cache",
38 ".tox",
39 ".nox",
40 "build",
41 "dist",
42 ".eggs",
43 "node_modules",
45 "bower_components",
46 "target",
48 ".idea",
50 ".vscode",
51 ".cache",
53 ".local",
54 "vendor",
55 "site-packages",
56 ];
57
58 pub(crate) fn should_skip_directory(dir_name: &str) -> bool {
60 if Self::SKIP_DIRECTORIES.contains(&dir_name) {
62 return true;
63 }
64 if dir_name.ends_with(".egg-info") {
66 return true;
67 }
68 false
69 }
70
71 pub fn scan_workspace(&self, root_path: &Path) {
74 self.scan_workspace_with_excludes(root_path, &[]);
75 }
76
77 pub fn scan_workspace_with_excludes(&self, root_path: &Path, exclude_patterns: &[Pattern]) {
79 info!("Scanning workspace: {:?}", root_path);
80
81 *self.workspace_root.lock().unwrap() = Some(
83 root_path
84 .canonicalize()
85 .unwrap_or_else(|_| root_path.to_path_buf()),
86 );
87
88 if !root_path.exists() {
90 warn!(
91 "Workspace path does not exist, skipping scan: {:?}",
92 root_path
93 );
94 return;
95 }
96
97 let mut files_to_process: Vec<std::path::PathBuf> = Vec::new();
99 let mut skipped_dirs = 0;
100
101 let walker = WalkDir::new(root_path).into_iter().filter_entry(|entry| {
103 if entry.file_type().is_file() {
105 return true;
106 }
107 if let Some(dir_name) = entry.file_name().to_str() {
109 !Self::should_skip_directory(dir_name)
110 } else {
111 true
112 }
113 });
114
115 for entry in walker {
116 let entry = match entry {
117 Ok(e) => e,
118 Err(err) => {
119 if err
121 .io_error()
122 .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
123 {
124 warn!(
125 "Permission denied accessing path during workspace scan: {}",
126 err
127 );
128 } else {
129 debug!("Error during workspace scan: {}", err);
130 }
131 continue;
132 }
133 };
134
135 let path = entry.path();
136
137 if path.components().any(|c| {
139 c.as_os_str()
140 .to_str()
141 .is_some_and(Self::should_skip_directory)
142 }) {
143 skipped_dirs += 1;
144 continue;
145 }
146
147 if !exclude_patterns.is_empty() {
150 if let Ok(relative_path) = path.strip_prefix(root_path) {
151 let relative_str = relative_path.to_string_lossy();
152 if exclude_patterns.iter().any(|p| p.matches(&relative_str)) {
153 debug!("Skipping excluded path: {:?}", path);
154 continue;
155 }
156 }
157 }
158
159 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
161 if filename == "conftest.py"
162 || filename.starts_with("test_") && filename.ends_with(".py")
163 || filename.ends_with("_test.py")
164 {
165 files_to_process.push(path.to_path_buf());
166 }
167 }
168 }
169
170 if skipped_dirs > 0 {
171 debug!("Skipped {} entries in filtered directories", skipped_dirs);
172 }
173
174 let total_files = files_to_process.len();
175 info!("Found {} test/conftest files to process", total_files);
176
177 let error_count = AtomicUsize::new(0);
180 let permission_denied_count = AtomicUsize::new(0);
181
182 files_to_process.par_iter().for_each(|path| {
183 debug!("Found test/conftest file: {:?}", path);
184 match std::fs::read_to_string(path) {
185 Ok(content) => {
186 self.analyze_file_fresh(path.clone(), &content);
187 }
188 Err(err) => {
189 if err.kind() == std::io::ErrorKind::PermissionDenied {
190 debug!("Permission denied reading file: {:?}", path);
191 permission_denied_count.fetch_add(1, Ordering::Relaxed);
192 } else {
193 error!("Failed to read file {:?}: {}", path, err);
194 error_count.fetch_add(1, Ordering::Relaxed);
195 }
196 }
197 }
198 });
199
200 let errors = error_count.load(Ordering::Relaxed);
201 let permission_errors = permission_denied_count.load(Ordering::Relaxed);
202
203 if errors > 0 {
204 warn!("Workspace scan completed with {} read errors", errors);
205 }
206 if permission_errors > 0 {
207 warn!(
208 "Workspace scan: skipped {} files due to permission denied",
209 permission_errors
210 );
211 }
212
213 info!(
214 "Workspace scan complete. Processed {} files ({} permission denied, {} errors)",
215 total_files, permission_errors, errors
216 );
217
218 self.scan_venv_fixtures(root_path);
221
222 self.scan_imported_fixture_modules(root_path);
226
227 info!("Total fixtures defined: {}", self.definitions.len());
228 info!("Total files with fixture usages: {}", self.usages.len());
229 }
230
231 fn scan_imported_fixture_modules(&self, _root_path: &Path) {
235 use std::collections::HashSet;
236
237 info!("Scanning for imported fixture modules");
238
239 let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
241
242 let site_packages_paths = self.site_packages_paths.lock().unwrap().clone();
245 let editable_roots: Vec<PathBuf> = self
246 .editable_install_roots
247 .lock()
248 .unwrap()
249 .iter()
250 .map(|e| e.source_root.clone())
251 .collect();
252 let mut files_to_check: Vec<std::path::PathBuf> = self
253 .file_cache
254 .iter()
255 .filter(|entry| {
256 let key = entry.key();
257 let is_conftest_or_test = key
258 .file_name()
259 .and_then(|n| n.to_str())
260 .map(|n| {
261 n == "conftest.py"
262 || (n.starts_with("test_") && n.ends_with(".py"))
263 || n.ends_with("_test.py")
264 })
265 .unwrap_or(false);
266 let is_venv_plugin = site_packages_paths.iter().any(|sp| key.starts_with(sp));
267 let is_editable_plugin = editable_roots.iter().any(|er| key.starts_with(er));
268 is_conftest_or_test || is_venv_plugin || is_editable_plugin
269 })
270 .map(|entry| entry.key().clone())
271 .collect();
272
273 if files_to_check.is_empty() {
274 debug!("No conftest/test/plugin files found, skipping import scan");
275 return;
276 }
277
278 info!(
279 "Starting import scan with {} conftest/test/plugin files",
280 files_to_check.len()
281 );
282
283 let mut iteration = 0;
285 while !files_to_check.is_empty() {
286 iteration += 1;
287 debug!(
288 "Import scan iteration {}: checking {} files",
289 iteration,
290 files_to_check.len()
291 );
292
293 let mut new_modules: HashSet<std::path::PathBuf> = HashSet::new();
294
295 for file_path in &files_to_check {
296 if processed_files.contains(file_path) {
297 continue;
298 }
299 processed_files.insert(file_path.clone());
300
301 let Some(content) = self.get_file_content(file_path) else {
303 continue;
304 };
305
306 let Some(parsed) = self.get_parsed_ast(file_path, &content) else {
308 continue;
309 };
310
311 let line_index = self.get_line_index(file_path, &content);
312
313 if let rustpython_parser::ast::Mod::Module(module) = parsed.as_ref() {
315 let imports =
316 self.extract_fixture_imports(&module.body, file_path, &line_index);
317
318 for import in imports {
319 if let Some(resolved_path) =
320 self.resolve_module_to_file(&import.module_path, file_path)
321 {
322 let canonical = self.get_canonical_path(resolved_path);
323 if !processed_files.contains(&canonical)
324 && !self.file_cache.contains_key(&canonical)
325 {
326 new_modules.insert(canonical);
327 }
328 }
329 }
330
331 let plugin_modules = self.extract_pytest_plugins(&module.body);
333 for module_path in plugin_modules {
334 if let Some(resolved_path) =
335 self.resolve_module_to_file(&module_path, file_path)
336 {
337 let canonical = self.get_canonical_path(resolved_path);
338 if !processed_files.contains(&canonical)
339 && !self.file_cache.contains_key(&canonical)
340 {
341 new_modules.insert(canonical);
342 }
343 }
344 }
345 }
346 }
347
348 if new_modules.is_empty() {
349 debug!("No new modules found in iteration {}", iteration);
350 break;
351 }
352
353 info!(
354 "Iteration {}: found {} new modules to analyze",
355 iteration,
356 new_modules.len()
357 );
358
359 for module_path in &new_modules {
361 if module_path.exists() {
362 debug!("Analyzing imported module: {:?}", module_path);
363 match std::fs::read_to_string(module_path) {
364 Ok(content) => {
365 self.analyze_file_fresh(module_path.clone(), &content);
366 }
367 Err(err) => {
368 debug!("Failed to read imported module {:?}: {}", module_path, err);
369 }
370 }
371 }
372 }
373
374 files_to_check = new_modules.into_iter().collect();
376 }
377
378 info!(
379 "Imported fixture module scan complete after {} iterations",
380 iteration
381 );
382 }
383
384 fn scan_venv_fixtures(&self, root_path: &Path) {
386 info!("Scanning for pytest plugins in virtual environment");
387
388 let venv_paths = vec![
390 root_path.join(".venv"),
391 root_path.join("venv"),
392 root_path.join("env"),
393 ];
394
395 info!("Checking for venv in: {:?}", root_path);
396 for venv_path in &venv_paths {
397 debug!("Checking venv path: {:?}", venv_path);
398 if venv_path.exists() {
399 info!("Found virtual environment at: {:?}", venv_path);
400 self.scan_venv_site_packages(venv_path);
401 return;
402 } else {
403 debug!(" Does not exist: {:?}", venv_path);
404 }
405 }
406
407 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
409 info!("Found VIRTUAL_ENV environment variable: {}", venv);
410 let venv_path = std::path::PathBuf::from(venv);
411 if venv_path.exists() {
412 let venv_path = venv_path.canonicalize().unwrap_or(venv_path);
413 info!("Using VIRTUAL_ENV: {:?}", venv_path);
414 self.scan_venv_site_packages(&venv_path);
415 return;
416 } else {
417 warn!("VIRTUAL_ENV path does not exist: {:?}", venv_path);
418 }
419 } else {
420 debug!("No VIRTUAL_ENV environment variable set");
421 }
422
423 warn!("No virtual environment found - third-party fixtures will not be available");
424 }
425
426 fn scan_venv_site_packages(&self, venv_path: &Path) {
427 info!("Scanning venv site-packages in: {:?}", venv_path);
428
429 let lib_path = venv_path.join("lib");
431 debug!("Checking lib path: {:?}", lib_path);
432
433 if lib_path.exists() {
434 if let Ok(entries) = std::fs::read_dir(&lib_path) {
436 for entry in entries.flatten() {
437 let path = entry.path();
438 let dirname = path.file_name().unwrap_or_default().to_string_lossy();
439 debug!("Found in lib: {:?}", dirname);
440
441 if path.is_dir() && dirname.starts_with("python") {
442 let site_packages = path.join("site-packages");
443 debug!("Checking site-packages: {:?}", site_packages);
444
445 if site_packages.exists() {
446 let site_packages =
447 site_packages.canonicalize().unwrap_or(site_packages);
448 info!("Found site-packages: {:?}", site_packages);
449 self.site_packages_paths
450 .lock()
451 .unwrap()
452 .push(site_packages.clone());
453 self.scan_pytest_plugins(&site_packages);
454 return;
455 }
456 }
457 }
458 }
459 }
460
461 let windows_site_packages = venv_path.join("Lib/site-packages");
463 debug!("Checking Windows path: {:?}", windows_site_packages);
464 if windows_site_packages.exists() {
465 let windows_site_packages = windows_site_packages
466 .canonicalize()
467 .unwrap_or(windows_site_packages);
468 info!("Found site-packages (Windows): {:?}", windows_site_packages);
469 self.site_packages_paths
470 .lock()
471 .unwrap()
472 .push(windows_site_packages.clone());
473 self.scan_pytest_plugins(&windows_site_packages);
474 return;
475 }
476
477 warn!("Could not find site-packages in venv: {:?}", venv_path);
478 }
479
480 fn parse_pytest11_entry_points(content: &str) -> Vec<Pytest11EntryPoint> {
486 let mut results = Vec::new();
487 let mut in_pytest11_section = false;
488
489 for line in content.lines() {
490 let line = line.trim();
491
492 if line.starts_with('[') && line.ends_with(']') {
494 in_pytest11_section = line == "[pytest11]";
495 continue;
496 }
497
498 if in_pytest11_section && !line.is_empty() && !line.starts_with('#') {
500 if let Some((name, module_path)) = line.split_once('=') {
501 results.push(Pytest11EntryPoint {
502 name: name.trim().to_string(),
503 module_path: module_path.trim().to_string(),
504 });
505 }
506 }
507 }
508 results
509 }
510
511 fn resolve_entry_point_module_to_path(
519 site_packages: &Path,
520 module_path: &str,
521 ) -> Option<PathBuf> {
522 let module_path = module_path.split(':').next().unwrap_or(module_path);
524
525 let parts: Vec<&str> = module_path.split('.').collect();
527
528 if parts.is_empty() {
529 return None;
530 }
531
532 if parts
534 .iter()
535 .any(|p| p.contains("..") || p.contains('\0') || p.is_empty())
536 {
537 return None;
538 }
539
540 let mut path = site_packages.to_path_buf();
542 for part in &parts {
543 path.push(part);
544 }
545
546 let check_bounded = |candidate: &Path| -> Option<PathBuf> {
548 let canonical = candidate.canonicalize().ok()?;
549 let base_canonical = site_packages.canonicalize().ok()?;
550 if canonical.starts_with(&base_canonical) {
551 Some(canonical)
552 } else {
553 None
554 }
555 };
556
557 let py_file = path.with_extension("py");
559 if py_file.exists() {
560 return check_bounded(&py_file);
561 }
562
563 if path.is_dir() {
565 let init_file = path.join("__init__.py");
566 if init_file.exists() {
567 return check_bounded(&init_file);
568 }
569 }
570
571 None
572 }
573
574 fn scan_single_plugin_file(&self, file_path: &Path) {
576 if file_path.extension().and_then(|s| s.to_str()) != Some("py") {
577 return;
578 }
579
580 debug!("Scanning plugin file: {:?}", file_path);
581
582 if let Ok(content) = std::fs::read_to_string(file_path) {
583 self.analyze_file(file_path.to_path_buf(), &content);
584 }
585 }
586
587 fn load_plugin_from_entry_point(&self, dist_info_path: &Path, site_packages: &Path) -> usize {
594 let entry_points_file = dist_info_path.join("entry_points.txt");
595
596 let content = match std::fs::read_to_string(&entry_points_file) {
597 Ok(c) => c,
598 Err(_) => return 0, };
600
601 let entries = Self::parse_pytest11_entry_points(&content);
602
603 if entries.is_empty() {
604 return 0; }
606
607 let mut scanned_count = 0;
608
609 for entry in entries {
610 debug!(
611 "Found pytest11 entry: {} = {}",
612 entry.name, entry.module_path
613 );
614
615 let resolved =
616 Self::resolve_entry_point_module_to_path(site_packages, &entry.module_path)
617 .or_else(|| self.resolve_entry_point_in_editable_installs(&entry.module_path));
618
619 if let Some(path) = resolved {
620 let scanned = if path.file_name().and_then(|n| n.to_str()) == Some("__init__.py") {
621 let package_dir = path.parent().expect("__init__.py must have parent");
622 info!(
623 "Scanning pytest plugin package directory for {}: {:?}",
624 entry.name, package_dir
625 );
626 self.scan_plugin_directory(package_dir);
627 true
628 } else if path.is_file() {
629 info!("Scanning pytest plugin: {} -> {:?}", entry.name, path);
630 self.scan_single_plugin_file(&path);
631 true
632 } else {
633 debug!(
634 "Resolved module path for plugin {} is not a file: {:?}",
635 entry.name, path
636 );
637 false
638 };
639
640 if scanned {
641 scanned_count += 1;
642 }
643 } else {
644 debug!(
645 "Could not resolve module path: {} for plugin {}",
646 entry.module_path, entry.name
647 );
648 }
649 }
650
651 scanned_count
652 }
653
654 fn scan_pytest_internal_fixtures(&self, site_packages: &Path) {
657 let pytest_internal = site_packages.join("_pytest");
658
659 if !pytest_internal.exists() || !pytest_internal.is_dir() {
660 debug!("_pytest directory not found in site-packages");
661 return;
662 }
663
664 info!(
665 "Scanning pytest internal fixtures in: {:?}",
666 pytest_internal
667 );
668 self.scan_plugin_directory(&pytest_internal);
669 }
670
671 fn extract_package_name_from_dist_info(dir_name: &str) -> Option<(String, String)> {
675 let name_version = dir_name
677 .strip_suffix(".dist-info")
678 .or_else(|| dir_name.strip_suffix(".egg-info"))?;
679
680 let name = if let Some(idx) = name_version.char_indices().position(|(i, c)| {
684 c == '-' && name_version[i + 1..].starts_with(|c: char| c.is_ascii_digit())
685 }) {
686 &name_version[..idx]
687 } else {
688 name_version
689 };
690
691 let raw = name.to_string();
692 let normalized = name.replace(['-', '.'], "_").to_lowercase();
694 Some((raw, normalized))
695 }
696
697 fn discover_editable_installs(&self, site_packages: &Path) {
699 info!("Scanning for editable installs in: {:?}", site_packages);
700
701 if !site_packages.is_dir() {
703 warn!(
704 "site-packages path is not a directory, skipping editable install scan: {:?}",
705 site_packages
706 );
707 return;
708 }
709
710 self.editable_install_roots.lock().unwrap().clear();
712
713 let pth_index = Self::build_pth_index(site_packages);
715
716 let entries = match std::fs::read_dir(site_packages) {
717 Ok(e) => e,
718 Err(_) => return,
719 };
720
721 for entry in entries.flatten() {
722 let path = entry.path();
723 let filename = path.file_name().unwrap_or_default().to_string_lossy();
724
725 if !filename.ends_with(".dist-info") {
726 continue;
727 }
728
729 let direct_url_path = path.join("direct_url.json");
730 let content = match std::fs::read_to_string(&direct_url_path) {
731 Ok(c) => c,
732 Err(_) => continue,
733 };
734
735 let json: serde_json::Value = match serde_json::from_str(&content) {
737 Ok(v) => v,
738 Err(_) => continue,
739 };
740
741 let is_editable = json
743 .get("dir_info")
744 .and_then(|d| d.get("editable"))
745 .and_then(|e| e.as_bool())
746 .unwrap_or(false);
747
748 if !is_editable {
749 continue;
750 }
751
752 let Some((raw_name, normalized_name)) =
753 Self::extract_package_name_from_dist_info(&filename)
754 else {
755 continue;
756 };
757
758 let source_root = Self::find_editable_pth_source_root(
760 &pth_index,
761 &raw_name,
762 &normalized_name,
763 site_packages,
764 );
765 let Some(source_root) = source_root else {
766 debug!(
767 "No .pth file found for editable install: {}",
768 normalized_name
769 );
770 continue;
771 };
772
773 info!(
774 "Discovered editable install: {} -> {:?}",
775 normalized_name, source_root
776 );
777 self.editable_install_roots
778 .lock()
779 .unwrap()
780 .push(super::EditableInstall {
781 package_name: normalized_name,
782 raw_package_name: raw_name,
783 source_root,
784 site_packages: site_packages.to_path_buf(),
785 });
786 }
787
788 let count = self.editable_install_roots.lock().unwrap().len();
789 info!("Discovered {} editable install(s)", count);
790 }
791
792 fn build_pth_index(site_packages: &Path) -> std::collections::HashMap<String, PathBuf> {
795 let mut index = std::collections::HashMap::new();
796 if !site_packages.is_dir() {
797 return index;
798 }
799 let entries = match std::fs::read_dir(site_packages) {
800 Ok(e) => e,
801 Err(_) => return index,
802 };
803 for entry in entries.flatten() {
804 let fname = entry.file_name();
805 let fname_str = fname.to_string_lossy();
806 if fname_str.ends_with(".pth") {
807 let stem = fname_str.strip_suffix(".pth").unwrap_or(&fname_str);
808 index.insert(stem.to_string(), entry.path());
809 }
810 }
811 index
812 }
813
814 fn find_editable_pth_source_root(
818 pth_index: &std::collections::HashMap<String, PathBuf>,
819 raw_name: &str,
820 normalized_name: &str,
821 site_packages: &Path,
822 ) -> Option<PathBuf> {
823 let mut candidates: Vec<String> = vec![
827 format!("__editable__.{}", normalized_name),
828 format!("_{}", normalized_name),
829 normalized_name.to_string(),
830 ];
831 if raw_name != normalized_name {
832 candidates.push(format!("__editable__.{}", raw_name));
833 candidates.push(format!("_{}", raw_name));
834 candidates.push(raw_name.to_string());
835 }
836
837 for (stem, pth_path) in pth_index {
839 let matches = candidates.iter().any(|c| {
840 stem == c
841 || stem.strip_prefix(c).is_some_and(|rest| {
842 rest.starts_with('-')
843 && rest[1..].starts_with(|ch: char| ch.is_ascii_digit())
844 })
845 });
846 if !matches {
847 continue;
848 }
849
850 let content = match std::fs::read_to_string(pth_path) {
852 Ok(c) => c,
853 Err(_) => continue,
854 };
855
856 for line in content.lines() {
857 let line = line.trim();
858 if line.is_empty() || line.starts_with('#') || line.starts_with("import ") {
859 continue;
860 }
861 if line.contains('\0')
864 || line.bytes().any(|b| b < 0x20 && b != b'\t')
865 || line.contains("..")
866 {
867 debug!("Skipping .pth line with invalid characters: {:?}", line);
868 continue;
869 }
870 let candidate = PathBuf::from(line);
871 let resolved = if candidate.is_absolute() {
872 candidate
873 } else {
874 site_packages.join(&candidate)
875 };
876 match resolved.canonicalize() {
879 Ok(canonical) if canonical.is_dir() => return Some(canonical),
880 Ok(canonical) => {
881 debug!(".pth path is not a directory: {:?}", canonical);
882 continue;
883 }
884 Err(_) => {
885 debug!("Could not canonicalize .pth path: {:?}", resolved);
886 continue;
887 }
888 }
889 }
890 }
891
892 None
893 }
894
895 fn resolve_entry_point_in_editable_installs(&self, module_path: &str) -> Option<PathBuf> {
897 let installs = self.editable_install_roots.lock().unwrap();
898 for install in installs.iter() {
899 if let Some(path) =
900 Self::resolve_entry_point_module_to_path(&install.source_root, module_path)
901 {
902 return Some(path);
903 }
904 }
905 None
906 }
907
908 fn scan_pytest_plugins(&self, site_packages: &Path) {
909 info!(
910 "Scanning for pytest plugins via entry points in: {:?}",
911 site_packages
912 );
913
914 self.discover_editable_installs(site_packages);
916
917 let mut plugin_count = 0;
918
919 self.scan_pytest_internal_fixtures(site_packages);
921
922 for entry in std::fs::read_dir(site_packages).into_iter().flatten() {
924 let entry = match entry {
925 Ok(e) => e,
926 Err(_) => continue,
927 };
928
929 let path = entry.path();
930 let filename = path.file_name().unwrap_or_default().to_string_lossy();
931
932 if !filename.ends_with(".dist-info") && !filename.ends_with(".egg-info") {
934 continue;
935 }
936
937 let scanned = self.load_plugin_from_entry_point(&path, site_packages);
939 if scanned > 0 {
940 plugin_count += scanned;
941 debug!("Loaded {} plugin module(s) from {}", scanned, filename);
942 }
943 }
944
945 info!(
946 "Discovered fixtures from {} pytest plugin modules",
947 plugin_count
948 );
949 }
950
951 fn scan_plugin_directory(&self, plugin_dir: &Path) {
952 for entry in WalkDir::new(plugin_dir)
954 .max_depth(3) .into_iter()
956 .filter_map(|e| e.ok())
957 {
958 let path = entry.path();
959
960 if path.extension().and_then(|s| s.to_str()) == Some("py") {
961 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
963 if filename.starts_with("test_") || filename.contains("__pycache__") {
965 continue;
966 }
967
968 debug!("Scanning plugin file: {:?}", path);
969 if let Ok(content) = std::fs::read_to_string(path) {
970 self.analyze_file(path.to_path_buf(), &content);
971 }
972 }
973 }
974 }
975 }
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981 use std::fs;
982 use tempfile::tempdir;
983
984 #[test]
985 fn test_parse_pytest11_entry_points_basic() {
986 let content = r#"
987[console_scripts]
988my-cli = my_package:main
989
990[pytest11]
991my_plugin = my_package.plugin
992another = another_pkg
993
994[other_section]
995foo = bar
996"#;
997
998 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
999 assert_eq!(entries.len(), 2);
1000 assert_eq!(entries[0].name, "my_plugin");
1001 assert_eq!(entries[0].module_path, "my_package.plugin");
1002 assert_eq!(entries[1].name, "another");
1003 assert_eq!(entries[1].module_path, "another_pkg");
1004 }
1005
1006 #[test]
1007 fn test_parse_pytest11_entry_points_empty_file() {
1008 let entries = FixtureDatabase::parse_pytest11_entry_points("");
1009 assert!(entries.is_empty());
1010 }
1011
1012 #[test]
1013 fn test_parse_pytest11_entry_points_no_pytest11_section() {
1014 let content = r#"
1015[console_scripts]
1016my-cli = my_package:main
1017"#;
1018 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1019 assert!(entries.is_empty());
1020 }
1021
1022 #[test]
1023 fn test_parse_pytest11_entry_points_with_comments() {
1024 let content = r#"
1025[pytest11]
1026# This is a comment
1027my_plugin = my_package.plugin
1028# Another comment
1029"#;
1030 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1031 assert_eq!(entries.len(), 1);
1032 assert_eq!(entries[0].name, "my_plugin");
1033 }
1034
1035 #[test]
1036 fn test_parse_pytest11_entry_points_with_whitespace() {
1037 let content = r#"
1038[pytest11]
1039 my_plugin = my_package.plugin
1040another=another_pkg
1041"#;
1042 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1043 assert_eq!(entries.len(), 2);
1044 assert_eq!(entries[0].name, "my_plugin");
1045 assert_eq!(entries[0].module_path, "my_package.plugin");
1046 assert_eq!(entries[1].name, "another");
1047 assert_eq!(entries[1].module_path, "another_pkg");
1048 }
1049
1050 #[test]
1051 fn test_parse_pytest11_entry_points_with_attr() {
1052 let content = r#"
1054[pytest11]
1055my_plugin = my_package.module:plugin_entry
1056"#;
1057 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1058 assert_eq!(entries.len(), 1);
1059 assert_eq!(entries[0].module_path, "my_package.module:plugin_entry");
1060 }
1061
1062 #[test]
1063 fn test_parse_pytest11_entry_points_multiple_sections_before_pytest11() {
1064 let content = r#"
1065[console_scripts]
1066cli = pkg:main
1067
1068[gui_scripts]
1069gui = pkg:gui_main
1070
1071[pytest11]
1072my_plugin = my_package.plugin
1073
1074[other]
1075extra = something
1076"#;
1077 let entries = FixtureDatabase::parse_pytest11_entry_points(content);
1078 assert_eq!(entries.len(), 1);
1079 assert_eq!(entries[0].name, "my_plugin");
1080 }
1081
1082 #[test]
1083 fn test_resolve_entry_point_module_to_path_package() {
1084 let temp = tempdir().unwrap();
1085 let site_packages = temp.path();
1086
1087 let pkg_dir = site_packages.join("my_plugin");
1089 fs::create_dir_all(&pkg_dir).unwrap();
1090 fs::write(pkg_dir.join("__init__.py"), "# plugin code").unwrap();
1091
1092 let result =
1094 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1095 assert!(result.is_some());
1096 assert_eq!(
1097 result.unwrap(),
1098 pkg_dir.join("__init__.py").canonicalize().unwrap()
1099 );
1100 }
1101
1102 #[test]
1103 fn test_resolve_entry_point_module_to_path_submodule() {
1104 let temp = tempdir().unwrap();
1105 let site_packages = temp.path();
1106
1107 let pkg_dir = site_packages.join("my_plugin");
1109 fs::create_dir_all(&pkg_dir).unwrap();
1110 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1111 fs::write(pkg_dir.join("plugin.py"), "# plugin code").unwrap();
1112
1113 let result =
1115 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin.plugin");
1116 assert!(result.is_some());
1117 assert_eq!(
1118 result.unwrap(),
1119 pkg_dir.join("plugin.py").canonicalize().unwrap()
1120 );
1121 }
1122
1123 #[test]
1124 fn test_resolve_entry_point_module_to_path_single_file() {
1125 let temp = tempdir().unwrap();
1126 let site_packages = temp.path();
1127
1128 fs::write(site_packages.join("my_plugin.py"), "# plugin code").unwrap();
1130
1131 let result =
1133 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "my_plugin");
1134 assert!(result.is_some());
1135 assert_eq!(
1136 result.unwrap(),
1137 site_packages.join("my_plugin.py").canonicalize().unwrap()
1138 );
1139 }
1140
1141 #[test]
1142 fn test_resolve_entry_point_module_to_path_not_found() {
1143 let temp = tempdir().unwrap();
1144 let site_packages = temp.path();
1145
1146 let result = FixtureDatabase::resolve_entry_point_module_to_path(
1148 site_packages,
1149 "nonexistent_plugin",
1150 );
1151 assert!(result.is_none());
1152 }
1153
1154 #[test]
1155 fn test_resolve_entry_point_module_strips_attr() {
1156 let temp = tempdir().unwrap();
1157 let site_packages = temp.path();
1158
1159 let pkg_dir = site_packages.join("my_plugin");
1161 fs::create_dir_all(&pkg_dir).unwrap();
1162 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1163 fs::write(pkg_dir.join("module.py"), "# plugin code").unwrap();
1164
1165 let result = FixtureDatabase::resolve_entry_point_module_to_path(
1167 site_packages,
1168 "my_plugin.module:entry_function",
1169 );
1170 assert!(result.is_some());
1171 assert_eq!(
1172 result.unwrap(),
1173 pkg_dir.join("module.py").canonicalize().unwrap()
1174 );
1175 }
1176
1177 #[test]
1178 fn test_resolve_entry_point_rejects_path_traversal() {
1179 let temp = tempdir().unwrap();
1180 let site_packages = temp.path();
1181
1182 fs::write(site_packages.join("valid.py"), "# code").unwrap();
1184
1185 let result =
1188 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "..%2Fetc%2Fpasswd");
1189 assert!(result.is_none(), "should reject traversal-like pattern");
1190
1191 let result =
1193 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "valid...secret");
1194 assert!(
1195 result.is_none(),
1196 "should reject module names with consecutive dots (empty segments)"
1197 );
1198
1199 let result =
1201 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "pkg..evil");
1202 assert!(
1203 result.is_none(),
1204 "should reject module names with consecutive dots"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_resolve_entry_point_rejects_null_bytes() {
1210 let temp = tempdir().unwrap();
1211 let site_packages = temp.path();
1212
1213 let result =
1214 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "module\0name");
1215 assert!(result.is_none(), "should reject null bytes");
1216 }
1217
1218 #[test]
1219 fn test_resolve_entry_point_rejects_empty_segments() {
1220 let temp = tempdir().unwrap();
1221 let site_packages = temp.path();
1222
1223 let result = FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "foo..bar");
1225 assert!(result.is_none(), "should reject empty path segments");
1226 }
1227
1228 #[cfg(unix)]
1229 #[test]
1230 fn test_resolve_entry_point_rejects_symlink_escape() {
1231 let temp = tempdir().unwrap();
1232 let site_packages = temp.path();
1233
1234 let outside = tempdir().unwrap();
1236 fs::write(outside.path().join("evil.py"), "# malicious").unwrap();
1237
1238 std::os::unix::fs::symlink(outside.path(), site_packages.join("escaped")).unwrap();
1240
1241 let result =
1242 FixtureDatabase::resolve_entry_point_module_to_path(site_packages, "escaped.evil");
1243 assert!(
1244 result.is_none(),
1245 "should reject paths that escape site-packages via symlink"
1246 );
1247 }
1248
1249 #[test]
1250 fn test_entry_point_plugin_discovery_integration() {
1251 let temp = tempdir().unwrap();
1253 let site_packages = temp.path();
1254
1255 let plugin_dir = site_packages.join("my_pytest_plugin");
1257 fs::create_dir_all(&plugin_dir).unwrap();
1258
1259 let plugin_content = r#"
1260import pytest
1261
1262@pytest.fixture
1263def my_dynamic_fixture():
1264 """A fixture discovered via entry points."""
1265 return "discovered via entry point"
1266
1267@pytest.fixture
1268def another_dynamic_fixture():
1269 return 42
1270"#;
1271 fs::write(plugin_dir.join("__init__.py"), plugin_content).unwrap();
1272
1273 let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1275 fs::create_dir_all(&dist_info).unwrap();
1276
1277 let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1278 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1279
1280 let db = FixtureDatabase::new();
1282 db.scan_pytest_plugins(site_packages);
1283
1284 assert!(
1285 db.definitions.contains_key("my_dynamic_fixture"),
1286 "my_dynamic_fixture should be discovered"
1287 );
1288 assert!(
1289 db.definitions.contains_key("another_dynamic_fixture"),
1290 "another_dynamic_fixture should be discovered"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_entry_point_discovery_submodule() {
1296 let temp = tempdir().unwrap();
1297 let site_packages = temp.path();
1298
1299 let plugin_dir = site_packages.join("my_pytest_plugin");
1301 fs::create_dir_all(&plugin_dir).unwrap();
1302 fs::write(plugin_dir.join("__init__.py"), "# main init").unwrap();
1303
1304 let plugin_content = r#"
1305import pytest
1306
1307@pytest.fixture
1308def submodule_fixture():
1309 return "from submodule"
1310"#;
1311 fs::write(plugin_dir.join("plugin.py"), plugin_content).unwrap();
1312
1313 let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1315 fs::create_dir_all(&dist_info).unwrap();
1316
1317 let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin.plugin\n";
1318 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1319
1320 let db = FixtureDatabase::new();
1322 db.scan_pytest_plugins(site_packages);
1323
1324 assert!(
1325 db.definitions.contains_key("submodule_fixture"),
1326 "submodule_fixture should be discovered"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_entry_point_discovery_package_scans_submodules() {
1332 let temp = tempdir().unwrap();
1333 let site_packages = temp.path();
1334
1335 let plugin_dir = site_packages.join("my_pytest_plugin");
1337 fs::create_dir_all(&plugin_dir).unwrap();
1338 fs::write(plugin_dir.join("__init__.py"), "# package init").unwrap();
1339
1340 let plugin_content = r#"
1341import pytest
1342
1343@pytest.fixture
1344def package_submodule_fixture():
1345 return "from package submodule"
1346"#;
1347 fs::write(plugin_dir.join("fixtures.py"), plugin_content).unwrap();
1348
1349 let dist_info = site_packages.join("my_pytest_plugin-1.0.0.dist-info");
1351 fs::create_dir_all(&dist_info).unwrap();
1352
1353 let entry_points = "[pytest11]\nmy_plugin = my_pytest_plugin\n";
1354 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1355
1356 let db = FixtureDatabase::new();
1358 db.scan_pytest_plugins(site_packages);
1359
1360 assert!(
1361 db.definitions.contains_key("package_submodule_fixture"),
1362 "package_submodule_fixture should be discovered"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_entry_point_discovery_no_pytest11_section() {
1368 let temp = tempdir().unwrap();
1369 let site_packages = temp.path();
1370
1371 let pkg_dir = site_packages.join("some_package");
1373 fs::create_dir_all(&pkg_dir).unwrap();
1374
1375 let pkg_content = r#"
1376import pytest
1377
1378@pytest.fixture
1379def should_not_be_found():
1380 return "this package is not a pytest plugin"
1381"#;
1382 fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1383
1384 let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1386 fs::create_dir_all(&dist_info).unwrap();
1387
1388 let entry_points = "[console_scripts]\nsome_cli = some_package:main\n";
1389 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1390
1391 let db = FixtureDatabase::new();
1393 db.scan_pytest_plugins(site_packages);
1394
1395 assert!(
1396 !db.definitions.contains_key("should_not_be_found"),
1397 "should_not_be_found should NOT be discovered (not a pytest plugin)"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_entry_point_discovery_missing_entry_points_txt() {
1403 let temp = tempdir().unwrap();
1404 let site_packages = temp.path();
1405
1406 let pkg_dir = site_packages.join("some_package");
1408 fs::create_dir_all(&pkg_dir).unwrap();
1409
1410 let pkg_content = r#"
1411import pytest
1412
1413@pytest.fixture
1414def should_not_be_found():
1415 return "no entry_points.txt"
1416"#;
1417 fs::write(pkg_dir.join("__init__.py"), pkg_content).unwrap();
1418
1419 let dist_info = site_packages.join("some_package-1.0.0.dist-info");
1421 fs::create_dir_all(&dist_info).unwrap();
1422 let db = FixtureDatabase::new();
1426 db.scan_pytest_plugins(site_packages);
1427
1428 assert!(
1429 !db.definitions.contains_key("should_not_be_found"),
1430 "should_not_be_found should NOT be discovered (no entry_points.txt)"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_entry_point_discovery_egg_info() {
1436 let temp = tempdir().unwrap();
1437 let site_packages = temp.path();
1438
1439 let pkg_dir = site_packages.join("legacy_plugin");
1441 fs::create_dir_all(&pkg_dir).unwrap();
1442 fs::write(
1443 pkg_dir.join("__init__.py"),
1444 r#"
1445import pytest
1446
1447@pytest.fixture
1448def legacy_plugin_fixture():
1449 return "from egg-info"
1450"#,
1451 )
1452 .unwrap();
1453
1454 let egg_info = site_packages.join("legacy_plugin-1.0.0.egg-info");
1456 fs::create_dir_all(&egg_info).unwrap();
1457 let entry_points = "[pytest11]\nlegacy_plugin = legacy_plugin\n";
1458 fs::write(egg_info.join("entry_points.txt"), entry_points).unwrap();
1459
1460 let db = FixtureDatabase::new();
1462 db.scan_pytest_plugins(site_packages);
1463
1464 assert!(
1465 db.definitions.contains_key("legacy_plugin_fixture"),
1466 "legacy_plugin_fixture should be discovered"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_entry_point_discovery_multiple_plugins() {
1472 let temp = tempdir().unwrap();
1473 let site_packages = temp.path();
1474
1475 let plugin1_dir = site_packages.join("plugin_one");
1477 fs::create_dir_all(&plugin1_dir).unwrap();
1478 fs::write(
1479 plugin1_dir.join("__init__.py"),
1480 r#"
1481import pytest
1482
1483@pytest.fixture
1484def fixture_from_plugin_one():
1485 return 1
1486"#,
1487 )
1488 .unwrap();
1489
1490 let dist_info1 = site_packages.join("plugin_one-1.0.0.dist-info");
1491 fs::create_dir_all(&dist_info1).unwrap();
1492 fs::write(
1493 dist_info1.join("entry_points.txt"),
1494 "[pytest11]\nplugin_one = plugin_one\n",
1495 )
1496 .unwrap();
1497
1498 let plugin2_dir = site_packages.join("plugin_two");
1500 fs::create_dir_all(&plugin2_dir).unwrap();
1501 fs::write(
1502 plugin2_dir.join("__init__.py"),
1503 r#"
1504import pytest
1505
1506@pytest.fixture
1507def fixture_from_plugin_two():
1508 return 2
1509"#,
1510 )
1511 .unwrap();
1512
1513 let dist_info2 = site_packages.join("plugin_two-2.0.0.dist-info");
1514 fs::create_dir_all(&dist_info2).unwrap();
1515 fs::write(
1516 dist_info2.join("entry_points.txt"),
1517 "[pytest11]\nplugin_two = plugin_two\n",
1518 )
1519 .unwrap();
1520
1521 let db = FixtureDatabase::new();
1523 db.scan_pytest_plugins(site_packages);
1524
1525 assert!(
1526 db.definitions.contains_key("fixture_from_plugin_one"),
1527 "fixture_from_plugin_one should be discovered"
1528 );
1529 assert!(
1530 db.definitions.contains_key("fixture_from_plugin_two"),
1531 "fixture_from_plugin_two should be discovered"
1532 );
1533 }
1534
1535 #[test]
1536 fn test_entry_point_discovery_multiple_entries_in_one_package() {
1537 let temp = tempdir().unwrap();
1538 let site_packages = temp.path();
1539
1540 let plugin_dir = site_packages.join("multi_plugin");
1542 fs::create_dir_all(&plugin_dir).unwrap();
1543 fs::write(plugin_dir.join("__init__.py"), "").unwrap();
1544
1545 fs::write(
1546 plugin_dir.join("fixtures_a.py"),
1547 r#"
1548import pytest
1549
1550@pytest.fixture
1551def fixture_a():
1552 return "A"
1553"#,
1554 )
1555 .unwrap();
1556
1557 fs::write(
1558 plugin_dir.join("fixtures_b.py"),
1559 r#"
1560import pytest
1561
1562@pytest.fixture
1563def fixture_b():
1564 return "B"
1565"#,
1566 )
1567 .unwrap();
1568
1569 let dist_info = site_packages.join("multi_plugin-1.0.0.dist-info");
1571 fs::create_dir_all(&dist_info).unwrap();
1572 fs::write(
1573 dist_info.join("entry_points.txt"),
1574 r#"[pytest11]
1575fixtures_a = multi_plugin.fixtures_a
1576fixtures_b = multi_plugin.fixtures_b
1577"#,
1578 )
1579 .unwrap();
1580
1581 let db = FixtureDatabase::new();
1583 db.scan_pytest_plugins(site_packages);
1584
1585 assert!(
1586 db.definitions.contains_key("fixture_a"),
1587 "fixture_a should be discovered"
1588 );
1589 assert!(
1590 db.definitions.contains_key("fixture_b"),
1591 "fixture_b should be discovered"
1592 );
1593 }
1594
1595 #[test]
1596 fn test_pytest_internal_fixtures_scanned() {
1597 let temp = tempdir().unwrap();
1598 let site_packages = temp.path();
1599
1600 let pytest_internal = site_packages.join("_pytest");
1602 fs::create_dir_all(&pytest_internal).unwrap();
1603
1604 let internal_fixtures = r#"
1605import pytest
1606
1607@pytest.fixture
1608def tmp_path():
1609 """Pytest's built-in tmp_path fixture."""
1610 pass
1611
1612@pytest.fixture
1613def capsys():
1614 """Pytest's built-in capsys fixture."""
1615 pass
1616"#;
1617 fs::write(pytest_internal.join("fixtures.py"), internal_fixtures).unwrap();
1618
1619 let db = FixtureDatabase::new();
1621 db.scan_pytest_plugins(site_packages);
1622
1623 assert!(
1626 db.definitions.contains_key("tmp_path"),
1627 "tmp_path should be discovered from _pytest"
1628 );
1629 assert!(
1630 db.definitions.contains_key("capsys"),
1631 "capsys should be discovered from _pytest"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_extract_package_name_from_dist_info() {
1637 assert_eq!(
1638 FixtureDatabase::extract_package_name_from_dist_info("mypackage-1.0.0.dist-info"),
1639 Some(("mypackage".to_string(), "mypackage".to_string()))
1640 );
1641 assert_eq!(
1642 FixtureDatabase::extract_package_name_from_dist_info("my-package-1.0.0.dist-info"),
1643 Some(("my-package".to_string(), "my_package".to_string()))
1644 );
1645 assert_eq!(
1646 FixtureDatabase::extract_package_name_from_dist_info("My.Package-2.3.4.dist-info"),
1647 Some(("My.Package".to_string(), "my_package".to_string()))
1648 );
1649 assert_eq!(
1650 FixtureDatabase::extract_package_name_from_dist_info("pytest_mock-3.12.0.dist-info"),
1651 Some(("pytest_mock".to_string(), "pytest_mock".to_string()))
1652 );
1653 assert_eq!(
1654 FixtureDatabase::extract_package_name_from_dist_info("mypackage-0.1.0.egg-info"),
1655 Some(("mypackage".to_string(), "mypackage".to_string()))
1656 );
1657 assert_eq!(
1659 FixtureDatabase::extract_package_name_from_dist_info("mypackage.dist-info"),
1660 Some(("mypackage".to_string(), "mypackage".to_string()))
1661 );
1662 }
1663
1664 #[test]
1665 fn test_discover_editable_installs() {
1666 let temp = tempdir().unwrap();
1667 let site_packages = temp.path();
1668
1669 let source_root = tempdir().unwrap();
1671 let pkg_dir = source_root.path().join("mypackage");
1672 fs::create_dir_all(&pkg_dir).unwrap();
1673 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1674
1675 let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
1677 fs::create_dir_all(&dist_info).unwrap();
1678
1679 let direct_url = serde_json::json!({
1680 "url": format!("file://{}", source_root.path().display()),
1681 "dir_info": {
1682 "editable": true
1683 }
1684 });
1685 fs::write(
1686 dist_info.join("direct_url.json"),
1687 serde_json::to_string(&direct_url).unwrap(),
1688 )
1689 .unwrap();
1690
1691 let pth_content = format!("{}\n", source_root.path().display());
1693 fs::write(
1694 site_packages.join("__editable__.mypackage-0.1.0.pth"),
1695 &pth_content,
1696 )
1697 .unwrap();
1698
1699 let db = FixtureDatabase::new();
1700 db.discover_editable_installs(site_packages);
1701
1702 let installs = db.editable_install_roots.lock().unwrap();
1703 assert_eq!(installs.len(), 1, "Should discover one editable install");
1704 assert_eq!(installs[0].package_name, "mypackage");
1705 assert_eq!(
1706 installs[0].source_root,
1707 source_root.path().canonicalize().unwrap()
1708 );
1709 }
1710
1711 #[test]
1712 fn test_discover_editable_installs_pth_with_dashes() {
1713 let temp = tempdir().unwrap();
1714 let site_packages = temp.path();
1715
1716 let source_root = tempdir().unwrap();
1718 let pkg_dir = source_root.path().join("my_package");
1719 fs::create_dir_all(&pkg_dir).unwrap();
1720 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1721
1722 let dist_info = site_packages.join("my-package-0.1.0.dist-info");
1724 fs::create_dir_all(&dist_info).unwrap();
1725 let direct_url = serde_json::json!({
1726 "url": format!("file://{}", source_root.path().display()),
1727 "dir_info": { "editable": true }
1728 });
1729 fs::write(
1730 dist_info.join("direct_url.json"),
1731 serde_json::to_string(&direct_url).unwrap(),
1732 )
1733 .unwrap();
1734
1735 let pth_content = format!("{}\n", source_root.path().display());
1737 fs::write(
1738 site_packages.join("__editable__.my-package-0.1.0.pth"),
1739 &pth_content,
1740 )
1741 .unwrap();
1742
1743 let db = FixtureDatabase::new();
1744 db.discover_editable_installs(site_packages);
1745
1746 let installs = db.editable_install_roots.lock().unwrap();
1747 assert_eq!(
1748 installs.len(),
1749 1,
1750 "Should discover editable install from .pth with dashes"
1751 );
1752 assert_eq!(installs[0].package_name, "my_package");
1753 assert_eq!(
1754 installs[0].source_root,
1755 source_root.path().canonicalize().unwrap()
1756 );
1757 }
1758
1759 #[test]
1760 fn test_discover_editable_installs_pth_with_dots() {
1761 let temp = tempdir().unwrap();
1762 let site_packages = temp.path();
1763
1764 let source_root = tempdir().unwrap();
1766 fs::create_dir_all(source_root.path().join("my_package")).unwrap();
1767 fs::write(source_root.path().join("my_package/__init__.py"), "").unwrap();
1768
1769 let dist_info = site_packages.join("My.Package-1.0.0.dist-info");
1771 fs::create_dir_all(&dist_info).unwrap();
1772 let direct_url = serde_json::json!({
1773 "url": format!("file://{}", source_root.path().display()),
1774 "dir_info": { "editable": true }
1775 });
1776 fs::write(
1777 dist_info.join("direct_url.json"),
1778 serde_json::to_string(&direct_url).unwrap(),
1779 )
1780 .unwrap();
1781
1782 let pth_content = format!("{}\n", source_root.path().display());
1784 fs::write(
1785 site_packages.join("__editable__.My.Package-1.0.0.pth"),
1786 &pth_content,
1787 )
1788 .unwrap();
1789
1790 let db = FixtureDatabase::new();
1791 db.discover_editable_installs(site_packages);
1792
1793 let installs = db.editable_install_roots.lock().unwrap();
1794 assert_eq!(
1795 installs.len(),
1796 1,
1797 "Should discover editable install from .pth with dots"
1798 );
1799 assert_eq!(installs[0].package_name, "my_package");
1800 assert_eq!(
1801 installs[0].source_root,
1802 source_root.path().canonicalize().unwrap()
1803 );
1804 }
1805
1806 #[test]
1807 fn test_discover_editable_installs_dedup_on_rescan() {
1808 let temp = tempdir().unwrap();
1809 let site_packages = temp.path();
1810
1811 let source_root = tempdir().unwrap();
1812 fs::create_dir_all(source_root.path().join("pkg")).unwrap();
1813 fs::write(source_root.path().join("pkg/__init__.py"), "").unwrap();
1814
1815 let dist_info = site_packages.join("pkg-0.1.0.dist-info");
1816 fs::create_dir_all(&dist_info).unwrap();
1817 let direct_url = serde_json::json!({
1818 "url": format!("file://{}", source_root.path().display()),
1819 "dir_info": { "editable": true }
1820 });
1821 fs::write(
1822 dist_info.join("direct_url.json"),
1823 serde_json::to_string(&direct_url).unwrap(),
1824 )
1825 .unwrap();
1826
1827 let pth_content = format!("{}\n", source_root.path().display());
1828 fs::write(site_packages.join("pkg.pth"), &pth_content).unwrap();
1829
1830 let db = FixtureDatabase::new();
1831
1832 db.discover_editable_installs(site_packages);
1834 db.discover_editable_installs(site_packages);
1835
1836 let installs = db.editable_install_roots.lock().unwrap();
1837 assert_eq!(
1838 installs.len(),
1839 1,
1840 "Re-scanning should not produce duplicates"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_editable_install_entry_point_resolution() {
1846 let temp = tempdir().unwrap();
1847 let site_packages = temp.path();
1848
1849 let source_root = tempdir().unwrap();
1851 let pkg_dir = source_root.path().join("mypackage");
1852 fs::create_dir_all(&pkg_dir).unwrap();
1853
1854 let plugin_content = r#"
1855import pytest
1856
1857@pytest.fixture
1858def editable_fixture():
1859 return "from editable install"
1860"#;
1861 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1862 fs::write(pkg_dir.join("plugin.py"), plugin_content).unwrap();
1863
1864 let dist_info = site_packages.join("mypackage-0.1.0.dist-info");
1866 fs::create_dir_all(&dist_info).unwrap();
1867
1868 let direct_url = serde_json::json!({
1869 "url": format!("file://{}", source_root.path().display()),
1870 "dir_info": { "editable": true }
1871 });
1872 fs::write(
1873 dist_info.join("direct_url.json"),
1874 serde_json::to_string(&direct_url).unwrap(),
1875 )
1876 .unwrap();
1877
1878 let entry_points = "[pytest11]\nmypackage = mypackage.plugin\n";
1879 fs::write(dist_info.join("entry_points.txt"), entry_points).unwrap();
1880
1881 let pth_content = format!("{}\n", source_root.path().display());
1883 fs::write(
1884 site_packages.join("__editable__.mypackage-0.1.0.pth"),
1885 &pth_content,
1886 )
1887 .unwrap();
1888
1889 let db = FixtureDatabase::new();
1890 db.scan_pytest_plugins(site_packages);
1891
1892 assert!(
1893 db.definitions.contains_key("editable_fixture"),
1894 "editable_fixture should be discovered via entry point fallback"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_discover_editable_installs_namespace_package() {
1900 let temp = tempdir().unwrap();
1901 let site_packages = temp.path();
1902
1903 let source_root = tempdir().unwrap();
1904 let pkg_dir = source_root.path().join("namespace").join("pkg");
1905 fs::create_dir_all(&pkg_dir).unwrap();
1906 fs::write(pkg_dir.join("__init__.py"), "").unwrap();
1907
1908 let dist_info = site_packages.join("namespace.pkg-1.0.0.dist-info");
1909 fs::create_dir_all(&dist_info).unwrap();
1910 let direct_url = serde_json::json!({
1911 "url": format!("file://{}", source_root.path().display()),
1912 "dir_info": { "editable": true }
1913 });
1914 fs::write(
1915 dist_info.join("direct_url.json"),
1916 serde_json::to_string(&direct_url).unwrap(),
1917 )
1918 .unwrap();
1919
1920 let pth_content = format!("{}\n", source_root.path().display());
1921 fs::write(
1922 site_packages.join("__editable__.namespace.pkg-1.0.0.pth"),
1923 &pth_content,
1924 )
1925 .unwrap();
1926
1927 let db = FixtureDatabase::new();
1928 db.discover_editable_installs(site_packages);
1929
1930 let installs = db.editable_install_roots.lock().unwrap();
1931 assert_eq!(
1932 installs.len(),
1933 1,
1934 "Should discover namespace editable install"
1935 );
1936 assert_eq!(installs[0].package_name, "namespace_pkg");
1937 assert_eq!(installs[0].raw_package_name, "namespace.pkg");
1938 assert_eq!(
1939 installs[0].source_root,
1940 source_root.path().canonicalize().unwrap()
1941 );
1942 }
1943
1944 #[test]
1945 fn test_pth_prefix_matching_no_false_positive() {
1946 let temp = tempdir().unwrap();
1948 let site_packages = temp.path();
1949
1950 let source_root_foo = tempdir().unwrap();
1951 fs::create_dir_all(source_root_foo.path()).unwrap();
1952
1953 let source_root_foobar = tempdir().unwrap();
1954 fs::create_dir_all(source_root_foobar.path()).unwrap();
1955
1956 fs::write(
1958 site_packages.join("foo-bar.pth"),
1959 format!("{}\n", source_root_foobar.path().display()),
1960 )
1961 .unwrap();
1962
1963 let pth_index = FixtureDatabase::build_pth_index(site_packages);
1964
1965 let result =
1967 FixtureDatabase::find_editable_pth_source_root(&pth_index, "foo", "foo", site_packages);
1968 assert!(
1969 result.is_none(),
1970 "foo should not match foo-bar.pth (different package)"
1971 );
1972
1973 let result = FixtureDatabase::find_editable_pth_source_root(
1975 &pth_index,
1976 "foo-bar",
1977 "foo_bar",
1978 site_packages,
1979 );
1980 assert!(result.is_some(), "foo-bar should match foo-bar.pth exactly");
1981 }
1982}