1use std::path::{Path, PathBuf};
29
30use anyhow::{anyhow, Context, Result};
31use rustpython_ast::Visitor;
32use rustpython_parser::ast::{
33 self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
34 StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
35};
36use rustpython_parser::text_size::{TextRange, TextSize};
37use rustpython_parser::Parse;
38
39pub use crate::violation::Violation;
42
43pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
50 let root = root.as_ref();
51 let first_party = first_party_package(root);
55 let mut files = Vec::new();
56 collect_python_files(root, &mut files, is_python_test_file)?;
57 files.sort();
58
59 let mut violations = Vec::new();
60 for file in &files {
61 let source = std::fs::read_to_string(file)
62 .with_context(|| format!("reading test file `{}`", file.display()))?;
63 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
64 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
65 let mut visitor = LintVisitor {
66 file,
67 source: &source,
68 fixture_depth: 0,
69 first_party: first_party.as_deref(),
70 violations: Vec::new(),
71 };
72 for stmt in suite {
73 visitor.visit_stmt(stmt);
74 }
75 violations.append(&mut visitor.violations);
76 }
77
78 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
79 Ok(violations)
80}
81
82pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
92 let root = root.as_ref();
93 let Some(first_party) = first_party_package(root) else {
96 return Ok(Vec::new());
97 };
98 let mut files = Vec::new();
99 collect_python_files(root, &mut files, is_python_unit_test_file)?;
100 files.sort();
101
102 let mut violations = Vec::new();
103 for file in &files {
104 let source = std::fs::read_to_string(file)
105 .with_context(|| format!("reading test file `{}`", file.display()))?;
106 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
107 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
108 let base = unit_under_test_base(file);
109 let mut visitor = UnitIsolationVisitor {
110 source: &source,
111 first_party: &first_party,
112 base: &base,
113 type_checking_depth: 0,
114 imports: Vec::new(),
115 patch_targets: Vec::new(),
116 };
117 for stmt in suite {
118 visitor.visit_stmt(stmt);
119 }
120 for import in &visitor.imports {
123 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
124 continue;
125 }
126 violations.push(Violation {
127 file: file.to_path_buf(),
128 line: import.line,
129 rule: "unmocked-collaborator",
130 message: format!(
131 "unit test imports `{}` without mocking it — a unit test isolates the \
132 unit under test, so mock every collaborator (patch it by string in a \
133 fixture)",
134 import.display
135 ),
136 });
137 }
138 }
139
140 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
141 Ok(violations)
142}
143
144struct ImportRecord {
147 display: String,
149 line: usize,
150 is_uut: bool,
152 symbols: Vec<String>,
155 source: Option<String>,
160 module: Option<String>,
162}
163
164impl ImportRecord {
165 fn is_mocked(&self, patch_targets: &[String]) -> bool {
177 if let Some(module) = &self.module {
178 let prefix = format!("{module}.");
179 return patch_targets
180 .iter()
181 .any(|target| target == module || target.starts_with(&prefix));
182 }
183 if self.symbols.is_empty() {
184 return false;
185 }
186 self.symbols.iter().all(|symbol| {
187 patch_targets
188 .iter()
189 .any(|target| self.symbol_is_mocked(target, symbol))
190 })
191 }
192
193 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
197 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
198 return false;
199 };
200 match &self.source {
201 Some(source) => module == source,
202 None => true,
203 }
204 }
205}
206
207struct UnitIsolationVisitor<'a> {
212 source: &'a str,
213 first_party: &'a str,
214 base: &'a str,
215 type_checking_depth: usize,
216 imports: Vec<ImportRecord>,
217 patch_targets: Vec<String>,
218}
219
220impl Visitor for UnitIsolationVisitor<'_> {
221 fn visit_stmt_import(&mut self, node: StmtImport) {
222 if self.type_checking_depth == 0 {
223 let line = line_of(self.source, node.range.start());
224 for alias in &node.names {
225 let module = alias.name.as_str();
226 if is_checked_import(import_head(module), self.first_party) {
227 self.imports.push(ImportRecord {
228 display: module.to_string(),
229 line,
230 is_uut: last_segment(module) == self.base,
231 symbols: Vec::new(),
232 source: None,
233 module: Some(module.to_string()),
234 });
235 }
236 }
237 }
238 self.generic_visit_stmt_import(node);
239 }
240
241 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
242 if self.type_checking_depth == 0 {
243 let level = relative_level(&node);
244 let module = node.module.as_ref().map(|m| m.as_str());
245 let should_check = level > 0
248 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
249 if should_check {
250 let line = line_of(self.source, node.range.start());
251 let dots = ".".repeat(level);
252 match module {
253 Some(module) => self.imports.push(ImportRecord {
258 display: format!("{dots}{module}"),
259 line,
260 is_uut: last_segment(module) == self.base,
261 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
262 source: (level == 0).then(|| module.to_string()),
263 module: None,
264 }),
265 None => {
267 let barrel_sut = self.base == "__init__" && level == 1;
277 for alias in &node.names {
278 let name = alias.name.as_str();
279 self.imports.push(ImportRecord {
280 display: format!("{dots}{name}"),
281 line,
282 is_uut: barrel_sut || name == self.base,
283 symbols: vec![name.to_string()],
284 source: None,
285 module: None,
286 });
287 }
288 }
289 }
290 }
291 }
292 self.generic_visit_stmt_import_from(node);
293 }
294
295 fn visit_expr_call(&mut self, node: ExprCall) {
296 if is_patch_call(&node) {
297 if let Some(target) = patch_string_target(&node) {
298 self.patch_targets.push(target.to_string());
299 }
300 }
301 self.generic_visit_expr_call(node);
302 }
303
304 fn visit_stmt_if(&mut self, node: StmtIf) {
305 if is_type_checking(node.test.as_ref()) {
308 self.type_checking_depth += 1;
309 for stmt in node.body {
310 self.visit_stmt(stmt);
311 }
312 self.type_checking_depth -= 1;
313 for stmt in node.orelse {
314 self.visit_stmt(stmt);
315 }
316 } else {
317 self.generic_visit_stmt_if(node);
318 }
319 }
320}
321
322fn import_head(module: &str) -> &str {
324 module.split('.').next().unwrap_or(module)
325}
326
327fn is_checked_import(head: &str, first_party: &str) -> bool {
332 if head == first_party {
333 return true; }
335 if TEST_FRAMEWORK.contains(&head) {
336 return false; }
338 if EFFECTFUL_STDLIB.contains(&head) {
339 return true; }
341 if STDLIB_MODULES.contains(&head) {
342 return false; }
344 true }
346
347const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
350
351const EFFECTFUL_STDLIB: &[&str] = &[
359 "asynchat",
360 "asyncore",
361 "ctypes",
362 "curses",
363 "dbm",
364 "fcntl",
365 "ftplib",
366 "imaplib",
367 "mmap",
368 "msvcrt",
369 "multiprocessing",
370 "nis",
371 "nntplib",
372 "ossaudiodev",
373 "poplib",
374 "pty",
375 "random",
376 "secrets",
377 "select",
378 "selectors",
379 "signal",
380 "smtpd",
381 "smtplib",
382 "socket",
383 "socketserver",
384 "spwd",
385 "sqlite3",
386 "ssl",
387 "subprocess",
388 "syslog",
389 "telnetlib",
390 "termios",
391 "tty",
392 "webbrowser",
393 "winreg",
394 "winsound",
395];
396
397const STDLIB_MODULES: &[&str] = &[
401 "abc",
402 "aifc",
403 "antigravity",
404 "argparse",
405 "array",
406 "ast",
407 "asynchat",
408 "asyncio",
409 "asyncore",
410 "atexit",
411 "audioop",
412 "base64",
413 "bdb",
414 "binascii",
415 "bisect",
416 "builtins",
417 "bz2",
418 "cProfile",
419 "calendar",
420 "cgi",
421 "cgitb",
422 "chunk",
423 "cmath",
424 "cmd",
425 "code",
426 "codecs",
427 "codeop",
428 "collections",
429 "colorsys",
430 "compileall",
431 "concurrent",
432 "configparser",
433 "contextlib",
434 "contextvars",
435 "copy",
436 "copyreg",
437 "crypt",
438 "csv",
439 "ctypes",
440 "curses",
441 "dataclasses",
442 "datetime",
443 "dbm",
444 "decimal",
445 "difflib",
446 "dis",
447 "distutils",
448 "doctest",
449 "email",
450 "encodings",
451 "ensurepip",
452 "enum",
453 "errno",
454 "faulthandler",
455 "fcntl",
456 "filecmp",
457 "fileinput",
458 "fnmatch",
459 "fractions",
460 "ftplib",
461 "functools",
462 "gc",
463 "genericpath",
464 "getopt",
465 "getpass",
466 "gettext",
467 "glob",
468 "graphlib",
469 "grp",
470 "gzip",
471 "hashlib",
472 "heapq",
473 "hmac",
474 "html",
475 "http",
476 "idlelib",
477 "imaplib",
478 "imghdr",
479 "imp",
480 "importlib",
481 "inspect",
482 "io",
483 "ipaddress",
484 "itertools",
485 "json",
486 "keyword",
487 "lib2to3",
488 "linecache",
489 "locale",
490 "logging",
491 "lzma",
492 "mailbox",
493 "mailcap",
494 "marshal",
495 "math",
496 "mimetypes",
497 "mmap",
498 "modulefinder",
499 "msilib",
500 "msvcrt",
501 "multiprocessing",
502 "netrc",
503 "nis",
504 "nntplib",
505 "nt",
506 "ntpath",
507 "nturl2path",
508 "numbers",
509 "opcode",
510 "operator",
511 "optparse",
512 "os",
513 "ossaudiodev",
514 "pathlib",
515 "pdb",
516 "pickle",
517 "pickletools",
518 "pipes",
519 "pkgutil",
520 "platform",
521 "plistlib",
522 "poplib",
523 "posix",
524 "posixpath",
525 "pprint",
526 "profile",
527 "pstats",
528 "pty",
529 "pwd",
530 "py_compile",
531 "pyclbr",
532 "pydoc",
533 "pydoc_data",
534 "pyexpat",
535 "queue",
536 "quopri",
537 "random",
538 "re",
539 "readline",
540 "reprlib",
541 "resource",
542 "rlcompleter",
543 "runpy",
544 "sched",
545 "secrets",
546 "select",
547 "selectors",
548 "shelve",
549 "shlex",
550 "shutil",
551 "signal",
552 "site",
553 "smtpd",
554 "smtplib",
555 "sndhdr",
556 "socket",
557 "socketserver",
558 "spwd",
559 "sqlite3",
560 "sre_compile",
561 "sre_constants",
562 "sre_parse",
563 "ssl",
564 "stat",
565 "statistics",
566 "string",
567 "stringprep",
568 "struct",
569 "subprocess",
570 "sunau",
571 "symtable",
572 "sys",
573 "sysconfig",
574 "syslog",
575 "tabnanny",
576 "tarfile",
577 "telnetlib",
578 "tempfile",
579 "termios",
580 "textwrap",
581 "this",
582 "threading",
583 "time",
584 "timeit",
585 "tkinter",
586 "token",
587 "tokenize",
588 "tomllib",
589 "trace",
590 "traceback",
591 "tracemalloc",
592 "tty",
593 "turtle",
594 "turtledemo",
595 "types",
596 "typing",
597 "unicodedata",
598 "unittest",
599 "urllib",
600 "uu",
601 "uuid",
602 "venv",
603 "warnings",
604 "wave",
605 "weakref",
606 "webbrowser",
607 "winreg",
608 "winsound",
609 "wsgiref",
610 "xdrlib",
611 "xml",
612 "xmlrpc",
613 "zipapp",
614 "zipfile",
615 "zipimport",
616 "zlib",
617 "zoneinfo",
618];
619
620fn last_segment(module: &str) -> &str {
622 module.rsplit('.').next().unwrap_or(module)
623}
624
625fn relative_level(node: &StmtImportFrom) -> usize {
628 node.level.map_or(0, |level| level.to_usize())
629}
630
631fn is_type_checking(test: &Expr) -> bool {
634 match test {
635 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
636 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
637 _ => false,
638 }
639}
640
641fn unit_under_test_base(file: &Path) -> String {
645 let name = file
646 .file_name()
647 .and_then(|n| n.to_str())
648 .unwrap_or_default();
649 let stem = name.strip_suffix(".py").unwrap_or(name);
650 stem.strip_suffix("_test").unwrap_or(stem).to_string()
651}
652
653struct LintVisitor<'a> {
657 file: &'a Path,
658 source: &'a str,
659 fixture_depth: usize,
660 first_party: Option<&'a str>,
662 violations: Vec<Violation>,
663}
664
665impl LintVisitor<'_> {
666 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
667 self.violations.push(Violation {
668 file: self.file.to_path_buf(),
669 line: line_of(self.source, range.start()),
670 rule,
671 message: message.to_string(),
672 });
673 }
674
675 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
678 let takes_monkeypatch = args
680 .posonlyargs
681 .iter()
682 .chain(&args.args)
683 .chain(&args.kwonlyargs)
684 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
685 || arg_named(&args.vararg, "monkeypatch")
686 || arg_named(&args.kwarg, "monkeypatch");
687 if takes_monkeypatch {
688 self.report(
689 range,
690 "no-monkeypatch",
691 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
692 );
693 }
694
695 decorators.iter().any(is_fixture_decorator)
696 }
697}
698
699impl Visitor for LintVisitor<'_> {
700 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
701 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
702 if is_fixture {
703 self.fixture_depth += 1;
704 }
705 self.generic_visit_stmt_function_def(node);
706 if is_fixture {
707 self.fixture_depth -= 1;
708 }
709 }
710
711 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
712 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
713 if is_fixture {
714 self.fixture_depth += 1;
715 }
716 self.generic_visit_stmt_async_function_def(node);
717 if is_fixture {
718 self.fixture_depth -= 1;
719 }
720 }
721
722 fn visit_expr_call(&mut self, node: ExprCall) {
723 let is_patch = is_patch_call(&node);
724 if is_patch && self.fixture_depth == 0 {
727 self.report(
728 node.range,
729 "no-inline-patch",
730 "patch is called inline in a test body; move it into a `pytest.fixture`",
731 );
732 }
733 if is_patch && patches_constant(&node) {
736 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
737 }
738 if is_patch {
744 if let Some(pkg) = self.first_party {
745 if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
746 {
747 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
748 }
749 }
750 }
751 if is_environ_mutation_call(&node) {
753 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
754 }
755 self.generic_visit_expr_call(node);
756 }
757
758 fn visit_withitem(&mut self, node: WithItem) {
761 self.visit_expr(node.context_expr);
762 if let Some(optional_vars) = node.optional_vars {
763 self.visit_expr(*optional_vars);
764 }
765 }
766
767 fn visit_stmt_assign(&mut self, node: StmtAssign) {
770 if node.targets.iter().any(is_os_environ_subscript) {
771 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
772 }
773 self.generic_visit_stmt_assign(node);
774 }
775
776 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
777 if is_os_environ_subscript(node.target.as_ref()) {
778 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
779 }
780 self.generic_visit_stmt_aug_assign(node);
781 }
782
783 fn visit_stmt_delete(&mut self, node: StmtDelete) {
784 if node.targets.iter().any(is_os_environ_subscript) {
785 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
786 }
787 self.generic_visit_stmt_delete(node);
788 }
789}
790
791fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
793 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
794}
795
796fn is_fixture_decorator(decorator: &Expr) -> bool {
799 let target = match decorator {
800 Expr::Call(call) => call.func.as_ref(),
801 other => other,
802 };
803 match target {
804 Expr::Name(name) => name.id.as_str() == "fixture",
805 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
806 _ => false,
807 }
808}
809
810fn is_patch_call(call: &ExprCall) -> bool {
813 match call.func.as_ref() {
814 Expr::Name(name) => name.id.as_str() == "patch",
815 Expr::Attribute(attr) => {
816 let name = attr.attr.as_str();
817 name == "patch"
818 || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
819 }
820 _ => false,
821 }
822}
823
824fn attr_base_is_patch(expr: &Expr) -> bool {
827 match expr {
828 Expr::Name(name) => name.id.as_str() == "patch",
829 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
830 _ => false,
831 }
832}
833
834const CONSTANT_PATCH_MSG: &str = "patches a module-global config constant; inject config explicitly (a consumer that did `from pkg import CONSTANT` snapshots the value at import time and ignores the patch)";
836
837const FIRST_PARTY_PATCH_MSG: &str = "patches a first-party target; an integration test must run first-party code for real — only third-party packages and effectful stdlib may be patched";
839
840fn patch_string_target(call: &ExprCall) -> Option<&str> {
844 if let Some(Expr::Constant(constant)) = call.args.first() {
845 if let Constant::Str(target) = &constant.value {
846 return Some(target.as_str());
847 }
848 }
849 None
850}
851
852fn patches_constant(call: &ExprCall) -> bool {
855 patch_string_target(call)
856 .and_then(|target| target.rsplit('.').next())
857 .is_some_and(is_upper_constant)
858}
859
860fn patches_first_party(target: &str, pkg: &str) -> bool {
863 target
864 .split('.')
865 .next()
866 .is_some_and(|head| !head.is_empty() && head == pkg)
867}
868
869fn is_upper_constant(name: &str) -> bool {
872 !name.is_empty()
873 && name
874 .chars()
875 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
876 && name.chars().any(|c| c.is_ascii_uppercase())
877}
878
879const ENVIRON_MUTATION_MSG: &str =
881 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
882
883fn is_os_environ(expr: &Expr) -> bool {
885 matches!(
886 expr,
887 Expr::Attribute(attr)
888 if attr.attr.as_str() == "environ"
889 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
890 )
891}
892
893fn is_os_environ_subscript(expr: &Expr) -> bool {
896 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
897}
898
899fn is_environ_mutation_call(call: &ExprCall) -> bool {
902 matches!(
903 call.func.as_ref(),
904 Expr::Attribute(attr)
905 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
906 )
907}
908
909fn is_environ_mutator(method: &str) -> bool {
911 matches!(
912 method,
913 "update" | "pop" | "setdefault" | "clear" | "popitem"
914 )
915}
916
917fn line_of(source: &str, offset: TextSize) -> usize {
919 let offset = (u32::from(offset) as usize).min(source.len());
920 source.as_bytes()[..offset]
921 .iter()
922 .filter(|&&byte| byte == b'\n')
923 .count()
924 + 1
925}
926
927fn first_party_package(root: &Path) -> Option<String> {
936 for dir in root.ancestors() {
937 let candidate = dir.join("pyproject.toml");
938 if candidate.is_file() {
939 return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
940 }
941 if dir.join(".git").exists() {
942 break;
943 }
944 }
945 None
946}
947
948fn read_project_name(path: &Path) -> Option<String> {
950 let contents = std::fs::read_to_string(path).ok()?;
951 let value: toml::Value = toml::from_str(&contents).ok()?;
952 value
953 .get("project")?
954 .get("name")?
955 .as_str()
956 .map(str::to_owned)
957}
958
959fn normalize_dist_name(name: &str) -> String {
962 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
963}
964
965fn collect_python_files(
967 dir: &Path,
968 out: &mut Vec<PathBuf>,
969 is_match: fn(&Path) -> bool,
970) -> Result<()> {
971 let entries =
972 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
973 for entry in entries {
974 let path = entry
975 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
976 .path();
977 if path.is_dir() {
978 collect_python_files(&path, out, is_match)?;
979 } else if is_match(&path) {
980 out.push(path);
981 }
982 }
983 Ok(())
984}
985
986fn is_python_test_file(path: &Path) -> bool {
990 let name = path
991 .file_name()
992 .and_then(|n| n.to_str())
993 .unwrap_or_default();
994 name == "conftest.py" || name.ends_with("_test.py")
995}
996
997fn is_python_unit_test_file(path: &Path) -> bool {
1001 let name = path
1002 .file_name()
1003 .and_then(|n| n.to_str())
1004 .unwrap_or_default();
1005 name.ends_with("_test.py")
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011 use std::sync::atomic::{AtomicU64, Ordering};
1012
1013 struct TempDir(PathBuf);
1015
1016 impl TempDir {
1017 fn new() -> Self {
1018 static COUNTER: AtomicU64 = AtomicU64::new(0);
1019 let dir = std::env::temp_dir().join(format!(
1020 "tc-lint-{}-{}",
1021 std::process::id(),
1022 COUNTER.fetch_add(1, Ordering::Relaxed),
1023 ));
1024 std::fs::create_dir_all(&dir).unwrap();
1025 TempDir(dir)
1026 }
1027
1028 fn write(&self, name: &str, contents: &str) {
1029 let path = self.0.join(name);
1030 if let Some(parent) = path.parent() {
1031 std::fs::create_dir_all(parent).unwrap();
1032 }
1033 std::fs::write(path, contents).unwrap();
1034 }
1035 }
1036
1037 impl Drop for TempDir {
1038 fn drop(&mut self) {
1039 let _ = std::fs::remove_dir_all(&self.0);
1040 }
1041 }
1042
1043 #[test]
1044 fn normalize_dist_name_maps_to_import_name() {
1045 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1046 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1047 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1048 assert_eq!(normalize_dist_name("myproject"), "myproject");
1049 }
1050
1051 fn parse_call(src: &str) -> ExprCall {
1053 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1054 match suite.into_iter().next().expect("one statement") {
1055 ast::Stmt::Expr(stmt) => match *stmt.value {
1056 Expr::Call(call) => call,
1057 other => panic!("expected a call, got {other:?}"),
1058 },
1059 other => panic!("expected an expression statement, got {other:?}"),
1060 }
1061 }
1062
1063 #[test]
1064 fn patch_string_target_only_reads_string_literals() {
1065 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1066 assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
1067 let int_call = parse_call("patch(42)\n");
1070 assert_eq!(patch_string_target(&int_call), None);
1071 let name_call = parse_call("patch(target)\n");
1072 assert_eq!(patch_string_target(&name_call), None);
1073 let empty_call = parse_call("patch()\n");
1074 assert_eq!(patch_string_target(&empty_call), None);
1075 }
1076
1077 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1079 ImportRecord {
1080 display: source.unwrap_or(".rel").to_string(),
1081 line: 1,
1082 is_uut: false,
1083 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1084 source: source.map(str::to_string),
1085 module: None,
1086 }
1087 }
1088
1089 fn targets(list: &[&str]) -> Vec<String> {
1090 list.iter().map(|s| (*s).to_string()).collect()
1091 }
1092
1093 #[test]
1094 fn is_mocked_requires_every_symbol_at_the_import_module() {
1095 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1096 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1098 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1100 }
1101
1102 #[test]
1103 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1104 let rec = from_import(Some("pkg.ledger"), &["record"]);
1105 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1107 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1109 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1110 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1112 }
1113
1114 #[test]
1115 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1116 let rec = from_import(None, &["record"]);
1119 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1120 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1121 }
1122
1123 #[test]
1124 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1125 let rec = ImportRecord {
1126 display: "pkg.db".to_string(),
1127 line: 1,
1128 is_uut: false,
1129 symbols: Vec::new(),
1130 source: None,
1131 module: Some("pkg.db".to_string()),
1132 };
1133 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1134 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1135 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1136 let empty = from_import(Some("pkg.mod"), &[]);
1138 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1139 }
1140
1141 #[test]
1142 fn patches_first_party_matches_head_segment() {
1143 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1144 assert!(patches_first_party("myproject", "myproject"));
1145 assert!(!patches_first_party("requests.get", "myproject"));
1146 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1147 assert!(!patches_first_party("", "myproject"));
1148 assert!(!patches_first_party(".leading", "myproject"));
1149 }
1150
1151 #[test]
1152 fn first_party_package_reads_pyproject_name() {
1153 let tree = TempDir::new();
1154 tree.write(
1155 "pyproject.toml",
1156 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1157 );
1158 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1160 }
1161
1162 #[test]
1163 fn first_party_package_is_none_without_a_project_name() {
1164 let tree = TempDir::new();
1165 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1167 tree.write(".git", "");
1168 assert_eq!(first_party_package(&tree.0), None);
1169 }
1170
1171 #[test]
1172 fn first_party_package_is_none_when_absent() {
1173 let tree = TempDir::new();
1175 assert_eq!(first_party_package(&tree.0), None);
1176 }
1177
1178 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1181 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1182 let mut visitor = UnitIsolationVisitor {
1183 source,
1184 first_party,
1185 base,
1186 type_checking_depth: 0,
1187 imports: Vec::new(),
1188 patch_targets: Vec::new(),
1189 };
1190 for stmt in suite {
1191 visitor.visit_stmt(stmt);
1192 }
1193 visitor
1194 .imports
1195 .iter()
1196 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1197 .map(|i| i.display.clone())
1198 .collect()
1199 }
1200
1201 #[test]
1202 fn import_head_and_last_segment() {
1203 assert_eq!(import_head("myproject.db.conn"), "myproject");
1204 assert_eq!(import_head("requests"), "requests");
1205 assert_eq!(last_segment("myproject.db.conn"), "conn");
1206 assert_eq!(last_segment("widget"), "widget");
1207 }
1208
1209 #[test]
1210 fn unit_under_test_base_strips_test_suffix() {
1211 assert_eq!(
1212 unit_under_test_base(Path::new("pkg/widget_test.py")),
1213 "widget"
1214 );
1215 assert_eq!(
1218 unit_under_test_base(Path::new("test_widget.py")),
1219 "test_widget"
1220 );
1221 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1222 }
1223
1224 #[test]
1225 fn recognizes_python_unit_test_files() {
1226 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1227 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1228 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1230 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1232 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1233 }
1234
1235 #[test]
1236 fn visitor_flags_first_party_and_external_collaborators() {
1237 let found = unmocked(
1240 "widget",
1241 "myproject",
1242 "from myproject.widget import build\n\
1243 from myproject.ledger import record\n\
1244 import requests\n",
1245 );
1246 assert_eq!(
1247 found,
1248 vec!["myproject.ledger".to_string(), "requests".to_string()]
1249 );
1250 }
1251
1252 #[test]
1253 fn visitor_clears_a_mocked_collaborator() {
1254 let found = unmocked(
1256 "widget",
1257 "myproject",
1258 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1259 );
1260 assert!(found.is_empty(), "got: {found:?}");
1261 }
1262
1263 #[test]
1264 fn visitor_flags_a_wrong_module_patch() {
1265 let found = unmocked(
1268 "widget",
1269 "myproject",
1270 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1271 );
1272 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1273 }
1274
1275 #[test]
1276 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1277 let found = unmocked(
1280 "widget",
1281 "myproject",
1282 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1283 );
1284 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1285 let both = unmocked(
1287 "widget",
1288 "myproject",
1289 "from myproject.ledger import record, erase\n\
1290 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1291 );
1292 assert!(both.is_empty(), "got: {both:?}");
1293 }
1294
1295 #[test]
1296 fn visitor_handles_module_and_relative_imports() {
1297 assert_eq!(
1299 unmocked("widget", "myproject", "import myproject.db\n"),
1300 vec!["myproject.db".to_string()]
1301 );
1302 assert!(unmocked(
1304 "widget",
1305 "myproject",
1306 "import myproject.db\npatch(\"myproject.db.connect\")\n"
1307 )
1308 .is_empty());
1309 assert_eq!(
1311 unmocked("widget", "myproject", "from .ledger import record\n"),
1312 vec![".ledger".to_string()]
1313 );
1314 assert_eq!(
1315 unmocked(
1316 "widget",
1317 "myproject",
1318 "from . import ledger\nfrom . import widget\n"
1319 ),
1320 vec![".ledger".to_string()]
1321 );
1322 }
1323
1324 #[test]
1325 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1326 assert!(unmocked(
1330 "__init__",
1331 "myproject",
1332 "from . import Thing, __all__, __version__\n"
1333 )
1334 .is_empty());
1335 assert_eq!(
1338 unmocked("__init__", "myproject", "from .core import Thing\n"),
1339 vec![".core".to_string()]
1340 );
1341 assert_eq!(
1344 unmocked("__init__", "myproject", "from .. import sibling\n"),
1345 vec!["..sibling".to_string()]
1346 );
1347 assert_eq!(
1350 unmocked("widget", "myproject", "from . import ledger\n"),
1351 vec![".ledger".to_string()]
1352 );
1353 }
1354
1355 #[test]
1356 fn visitor_skips_type_checking_imports() {
1357 let found = unmocked(
1360 "widget",
1361 "myproject",
1362 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
1363 );
1364 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1365 }
1366
1367 #[test]
1368 fn is_checked_import_classifies_origins() {
1369 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
1372 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
1374 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
1376 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
1378 assert!(!is_checked_import("os", "myproject"));
1380 assert!(!is_checked_import("pathlib", "myproject"));
1381 assert!(!is_checked_import("datetime", "myproject"));
1382 }
1383
1384 #[test]
1385 fn visitor_flags_external_collaborators() {
1386 let found = unmocked(
1388 "widget",
1389 "myproject",
1390 "import requests\nimport subprocess\nimport json\nimport pytest\n",
1391 );
1392 assert_eq!(found.len(), 2, "got: {found:?}");
1393 assert!(found.contains(&"requests".to_string()));
1394 assert!(found.contains(&"subprocess".to_string()));
1395 }
1396
1397 #[test]
1398 fn visitor_type_checking_variants_and_plain_if() {
1399 assert!(unmocked(
1402 "widget",
1403 "myproject",
1404 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
1405 )
1406 .is_empty());
1407 assert_eq!(
1410 unmocked(
1411 "widget",
1412 "myproject",
1413 "if ready == 1:\n from myproject.ledger import record\n"
1414 ),
1415 vec!["myproject.ledger".to_string()]
1416 );
1417 }
1418
1419 #[test]
1420 fn find_unit_isolation_without_pyproject_reports_nothing() {
1421 let tree = TempDir::new();
1423 tree.write("widget_test.py", "from myproject.ledger import record\n");
1424 tree.write(".git", "");
1425 assert!(find_unit_isolation_violations(&tree.0)
1426 .expect("a readable tree should succeed")
1427 .is_empty());
1428 }
1429
1430 #[test]
1431 fn find_unit_isolation_walks_subdirs_and_flags() {
1432 let tree = TempDir::new();
1433 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1434 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1436 let found =
1437 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1438 assert_eq!(found.len(), 1, "got: {found:?}");
1439 assert_eq!(found[0].rule, "unmocked-collaborator");
1440 assert!(found[0].message.contains("myproject.ledger"));
1441 }
1442
1443 #[test]
1444 fn recognizes_python_test_files() {
1445 assert!(is_python_test_file(Path::new("widget_test.py")));
1446 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1447 assert!(is_python_test_file(Path::new("conftest.py")));
1448 assert!(!is_python_test_file(Path::new("test_widget.py")));
1450 }
1451
1452 #[test]
1453 fn ignores_non_test_files() {
1454 assert!(!is_python_test_file(Path::new("widget.py")));
1455 assert!(!is_python_test_file(Path::new("conftest.pyi")));
1456 assert!(!is_python_test_file(Path::new("README.md")));
1457 assert!(!is_python_test_file(Path::new("testing.py")));
1458 }
1459
1460 #[test]
1461 fn line_of_counts_newlines() {
1462 let src = "a\nb\nc\n";
1463 assert_eq!(line_of(src, TextSize::from(0)), 1);
1464 assert_eq!(line_of(src, TextSize::from(2)), 2);
1465 assert_eq!(line_of(src, TextSize::from(4)), 3);
1466 }
1467
1468 #[test]
1469 fn recognizes_environ_mutators() {
1470 assert!(is_environ_mutator("update"));
1471 assert!(is_environ_mutator("pop"));
1472 assert!(is_environ_mutator("clear"));
1473 assert!(!is_environ_mutator("get"));
1474 assert!(!is_environ_mutator("keys"));
1475 }
1476
1477 #[test]
1478 fn recognizes_upper_constants() {
1479 assert!(is_upper_constant("CACHE_DIR"));
1480 assert!(is_upper_constant("DEBUG"));
1481 assert!(is_upper_constant("MAX_2"));
1482 assert!(!is_upper_constant("cache_dir"));
1483 assert!(!is_upper_constant("CacheDir"));
1484 assert!(!is_upper_constant("fetch"));
1485 assert!(!is_upper_constant(""));
1486 assert!(!is_upper_constant("_"));
1487 assert!(!is_upper_constant("123"));
1488 }
1489}