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
82const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
85 a suite lives in `tests/integration/` or `tests/e2e/`";
86
87pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
97 let tests = package_root.join("tests");
98 let mut violations = Vec::new();
99 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
100 for tier in &tiers {
101 if tier.is_dir() {
102 violations.extend(find_violations(tier)?);
103 }
104 }
105 if tests.is_dir() {
106 let mut strays = Vec::new();
107 collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
108 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
109 for file in strays {
110 violations.push(Violation {
111 file,
112 line: 1,
113 rule: "unknown-tier",
114 message: UNKNOWN_TIER_MSG.to_string(),
115 });
116 }
117 }
118 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
119 Ok(violations)
120}
121
122pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
132 let root = root.as_ref();
133 let Some(first_party) = first_party_package(root) else {
136 return Ok(Vec::new());
137 };
138 let mut files = Vec::new();
139 collect_python_files(root, &mut files, is_python_unit_test_file)?;
140 if let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") {
143 files.retain(|file| !file.starts_with(&tests));
144 }
145 files.sort();
146
147 let mut violations = Vec::new();
148 for file in &files {
149 let source = std::fs::read_to_string(file)
150 .with_context(|| format!("reading test file `{}`", file.display()))?;
151 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
152 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
153 let base = unit_under_test_base(file);
154 let mut visitor = UnitIsolationVisitor {
155 source: &source,
156 first_party: &first_party,
157 base: &base,
158 type_checking_depth: 0,
159 imports: Vec::new(),
160 patch_targets: Vec::new(),
161 };
162 for stmt in suite {
163 visitor.visit_stmt(stmt);
164 }
165 for import in &visitor.imports {
168 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
169 continue;
170 }
171 violations.push(Violation {
172 file: file.to_path_buf(),
173 line: import.line,
174 rule: "unmocked-collaborator",
175 message: format!(
176 "unit test imports `{}` without mocking it — a unit test isolates the \
177 unit under test, so mock every collaborator (patch it by string in a \
178 fixture)",
179 import.display
180 ),
181 });
182 }
183 }
184
185 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
186 Ok(violations)
187}
188
189struct ImportRecord {
192 display: String,
194 line: usize,
195 is_uut: bool,
197 symbols: Vec<String>,
200 source: Option<String>,
205 module: Option<String>,
207}
208
209impl ImportRecord {
210 fn is_mocked(&self, patch_targets: &[String]) -> bool {
222 if let Some(module) = &self.module {
223 let prefix = format!("{module}.");
224 return patch_targets
225 .iter()
226 .any(|target| target == module || target.starts_with(&prefix));
227 }
228 if self.symbols.is_empty() {
229 return false;
230 }
231 self.symbols.iter().all(|symbol| {
232 patch_targets
233 .iter()
234 .any(|target| self.symbol_is_mocked(target, symbol))
235 })
236 }
237
238 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
242 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
243 return false;
244 };
245 match &self.source {
246 Some(source) => module == source,
247 None => true,
248 }
249 }
250}
251
252struct UnitIsolationVisitor<'a> {
257 source: &'a str,
258 first_party: &'a str,
259 base: &'a str,
260 type_checking_depth: usize,
261 imports: Vec<ImportRecord>,
262 patch_targets: Vec<String>,
263}
264
265impl Visitor for UnitIsolationVisitor<'_> {
266 fn visit_stmt_import(&mut self, node: StmtImport) {
267 if self.type_checking_depth == 0 {
268 let line = line_of(self.source, node.range.start());
269 for alias in &node.names {
270 let module = alias.name.as_str();
271 if is_checked_import(import_head(module), self.first_party) {
272 self.imports.push(ImportRecord {
273 display: module.to_string(),
274 line,
275 is_uut: last_segment(module) == self.base,
276 symbols: Vec::new(),
277 source: None,
278 module: Some(module.to_string()),
279 });
280 }
281 }
282 }
283 self.generic_visit_stmt_import(node);
284 }
285
286 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
287 if self.type_checking_depth == 0 {
288 let level = relative_level(&node);
289 let module = node.module.as_ref().map(|m| m.as_str());
290 let should_check = level > 0
293 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
294 if should_check {
295 let line = line_of(self.source, node.range.start());
296 let dots = ".".repeat(level);
297 match module {
298 Some(module) => self.imports.push(ImportRecord {
303 display: format!("{dots}{module}"),
304 line,
305 is_uut: last_segment(module) == self.base,
306 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
307 source: (level == 0).then(|| module.to_string()),
308 module: None,
309 }),
310 None => {
312 let barrel_sut = self.base == "__init__" && level == 1;
322 for alias in &node.names {
323 let name = alias.name.as_str();
324 self.imports.push(ImportRecord {
325 display: format!("{dots}{name}"),
326 line,
327 is_uut: barrel_sut || name == self.base,
328 symbols: vec![name.to_string()],
329 source: None,
330 module: None,
331 });
332 }
333 }
334 }
335 }
336 }
337 self.generic_visit_stmt_import_from(node);
338 }
339
340 fn visit_expr_call(&mut self, node: ExprCall) {
341 if is_patch_call(&node) {
342 if let Some(target) = patch_string_target(&node) {
343 self.patch_targets.push(target.to_string());
344 }
345 }
346 self.generic_visit_expr_call(node);
347 }
348
349 fn visit_stmt_if(&mut self, node: StmtIf) {
350 if is_type_checking(node.test.as_ref()) {
353 self.type_checking_depth += 1;
354 for stmt in node.body {
355 self.visit_stmt(stmt);
356 }
357 self.type_checking_depth -= 1;
358 for stmt in node.orelse {
359 self.visit_stmt(stmt);
360 }
361 } else {
362 self.generic_visit_stmt_if(node);
363 }
364 }
365}
366
367fn import_head(module: &str) -> &str {
369 module.split('.').next().unwrap_or(module)
370}
371
372fn is_checked_import(head: &str, first_party: &str) -> bool {
377 if head == first_party {
378 return true; }
380 if TEST_FRAMEWORK.contains(&head) {
381 return false; }
383 if EFFECTFUL_STDLIB.contains(&head) {
384 return true; }
386 if STDLIB_MODULES.contains(&head) {
387 return false; }
389 true }
391
392const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
395
396const EFFECTFUL_STDLIB: &[&str] = &[
404 "asynchat",
405 "asyncore",
406 "ctypes",
407 "curses",
408 "dbm",
409 "fcntl",
410 "ftplib",
411 "imaplib",
412 "mmap",
413 "msvcrt",
414 "multiprocessing",
415 "nis",
416 "nntplib",
417 "ossaudiodev",
418 "poplib",
419 "pty",
420 "random",
421 "secrets",
422 "select",
423 "selectors",
424 "signal",
425 "smtpd",
426 "smtplib",
427 "socket",
428 "socketserver",
429 "spwd",
430 "sqlite3",
431 "ssl",
432 "subprocess",
433 "syslog",
434 "telnetlib",
435 "termios",
436 "tty",
437 "webbrowser",
438 "winreg",
439 "winsound",
440];
441
442const STDLIB_MODULES: &[&str] = &[
446 "abc",
447 "aifc",
448 "antigravity",
449 "argparse",
450 "array",
451 "ast",
452 "asynchat",
453 "asyncio",
454 "asyncore",
455 "atexit",
456 "audioop",
457 "base64",
458 "bdb",
459 "binascii",
460 "bisect",
461 "builtins",
462 "bz2",
463 "cProfile",
464 "calendar",
465 "cgi",
466 "cgitb",
467 "chunk",
468 "cmath",
469 "cmd",
470 "code",
471 "codecs",
472 "codeop",
473 "collections",
474 "colorsys",
475 "compileall",
476 "concurrent",
477 "configparser",
478 "contextlib",
479 "contextvars",
480 "copy",
481 "copyreg",
482 "crypt",
483 "csv",
484 "ctypes",
485 "curses",
486 "dataclasses",
487 "datetime",
488 "dbm",
489 "decimal",
490 "difflib",
491 "dis",
492 "distutils",
493 "doctest",
494 "email",
495 "encodings",
496 "ensurepip",
497 "enum",
498 "errno",
499 "faulthandler",
500 "fcntl",
501 "filecmp",
502 "fileinput",
503 "fnmatch",
504 "fractions",
505 "ftplib",
506 "functools",
507 "gc",
508 "genericpath",
509 "getopt",
510 "getpass",
511 "gettext",
512 "glob",
513 "graphlib",
514 "grp",
515 "gzip",
516 "hashlib",
517 "heapq",
518 "hmac",
519 "html",
520 "http",
521 "idlelib",
522 "imaplib",
523 "imghdr",
524 "imp",
525 "importlib",
526 "inspect",
527 "io",
528 "ipaddress",
529 "itertools",
530 "json",
531 "keyword",
532 "lib2to3",
533 "linecache",
534 "locale",
535 "logging",
536 "lzma",
537 "mailbox",
538 "mailcap",
539 "marshal",
540 "math",
541 "mimetypes",
542 "mmap",
543 "modulefinder",
544 "msilib",
545 "msvcrt",
546 "multiprocessing",
547 "netrc",
548 "nis",
549 "nntplib",
550 "nt",
551 "ntpath",
552 "nturl2path",
553 "numbers",
554 "opcode",
555 "operator",
556 "optparse",
557 "os",
558 "ossaudiodev",
559 "pathlib",
560 "pdb",
561 "pickle",
562 "pickletools",
563 "pipes",
564 "pkgutil",
565 "platform",
566 "plistlib",
567 "poplib",
568 "posix",
569 "posixpath",
570 "pprint",
571 "profile",
572 "pstats",
573 "pty",
574 "pwd",
575 "py_compile",
576 "pyclbr",
577 "pydoc",
578 "pydoc_data",
579 "pyexpat",
580 "queue",
581 "quopri",
582 "random",
583 "re",
584 "readline",
585 "reprlib",
586 "resource",
587 "rlcompleter",
588 "runpy",
589 "sched",
590 "secrets",
591 "select",
592 "selectors",
593 "shelve",
594 "shlex",
595 "shutil",
596 "signal",
597 "site",
598 "smtpd",
599 "smtplib",
600 "sndhdr",
601 "socket",
602 "socketserver",
603 "spwd",
604 "sqlite3",
605 "sre_compile",
606 "sre_constants",
607 "sre_parse",
608 "ssl",
609 "stat",
610 "statistics",
611 "string",
612 "stringprep",
613 "struct",
614 "subprocess",
615 "sunau",
616 "symtable",
617 "sys",
618 "sysconfig",
619 "syslog",
620 "tabnanny",
621 "tarfile",
622 "telnetlib",
623 "tempfile",
624 "termios",
625 "textwrap",
626 "this",
627 "threading",
628 "time",
629 "timeit",
630 "tkinter",
631 "token",
632 "tokenize",
633 "tomllib",
634 "trace",
635 "traceback",
636 "tracemalloc",
637 "tty",
638 "turtle",
639 "turtledemo",
640 "types",
641 "typing",
642 "unicodedata",
643 "unittest",
644 "urllib",
645 "uu",
646 "uuid",
647 "venv",
648 "warnings",
649 "wave",
650 "weakref",
651 "webbrowser",
652 "winreg",
653 "winsound",
654 "wsgiref",
655 "xdrlib",
656 "xml",
657 "xmlrpc",
658 "zipapp",
659 "zipfile",
660 "zipimport",
661 "zlib",
662 "zoneinfo",
663];
664
665fn last_segment(module: &str) -> &str {
667 module.rsplit('.').next().unwrap_or(module)
668}
669
670fn relative_level(node: &StmtImportFrom) -> usize {
673 node.level.map_or(0, |level| level.to_usize())
674}
675
676fn is_type_checking(test: &Expr) -> bool {
679 match test {
680 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
681 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
682 _ => false,
683 }
684}
685
686fn unit_under_test_base(file: &Path) -> String {
690 let name = file
691 .file_name()
692 .and_then(|n| n.to_str())
693 .unwrap_or_default();
694 let stem = name.strip_suffix(".py").unwrap_or(name);
695 stem.strip_suffix("_test").unwrap_or(stem).to_string()
696}
697
698struct LintVisitor<'a> {
702 file: &'a Path,
703 source: &'a str,
704 fixture_depth: usize,
705 first_party: Option<&'a str>,
707 violations: Vec<Violation>,
708}
709
710impl LintVisitor<'_> {
711 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
712 self.violations.push(Violation {
713 file: self.file.to_path_buf(),
714 line: line_of(self.source, range.start()),
715 rule,
716 message: message.to_string(),
717 });
718 }
719
720 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
723 let takes_monkeypatch = args
725 .posonlyargs
726 .iter()
727 .chain(&args.args)
728 .chain(&args.kwonlyargs)
729 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
730 || arg_named(&args.vararg, "monkeypatch")
731 || arg_named(&args.kwarg, "monkeypatch");
732 if takes_monkeypatch {
733 self.report(
734 range,
735 "no-monkeypatch",
736 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
737 );
738 }
739
740 decorators.iter().any(is_fixture_decorator)
741 }
742}
743
744impl Visitor for LintVisitor<'_> {
745 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
746 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
747 if is_fixture {
748 self.fixture_depth += 1;
749 }
750 self.generic_visit_stmt_function_def(node);
751 if is_fixture {
752 self.fixture_depth -= 1;
753 }
754 }
755
756 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
757 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
758 if is_fixture {
759 self.fixture_depth += 1;
760 }
761 self.generic_visit_stmt_async_function_def(node);
762 if is_fixture {
763 self.fixture_depth -= 1;
764 }
765 }
766
767 fn visit_expr_call(&mut self, node: ExprCall) {
768 let is_patch = is_patch_call(&node);
769 if is_patch && self.fixture_depth == 0 {
772 self.report(
773 node.range,
774 "no-inline-patch",
775 "patch is called inline in a test body; move it into a `pytest.fixture`",
776 );
777 }
778 if is_patch && patches_constant(&node) {
781 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
782 }
783 if is_patch {
789 if let Some(pkg) = self.first_party {
790 if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
791 {
792 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
793 }
794 }
795 }
796 if is_environ_mutation_call(&node) {
798 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
799 }
800 self.generic_visit_expr_call(node);
801 }
802
803 fn visit_withitem(&mut self, node: WithItem) {
806 self.visit_expr(node.context_expr);
807 if let Some(optional_vars) = node.optional_vars {
808 self.visit_expr(*optional_vars);
809 }
810 }
811
812 fn visit_stmt_assign(&mut self, node: StmtAssign) {
815 if node.targets.iter().any(is_os_environ_subscript) {
816 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
817 }
818 self.generic_visit_stmt_assign(node);
819 }
820
821 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
822 if is_os_environ_subscript(node.target.as_ref()) {
823 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
824 }
825 self.generic_visit_stmt_aug_assign(node);
826 }
827
828 fn visit_stmt_delete(&mut self, node: StmtDelete) {
829 if node.targets.iter().any(is_os_environ_subscript) {
830 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
831 }
832 self.generic_visit_stmt_delete(node);
833 }
834}
835
836fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
838 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
839}
840
841fn is_fixture_decorator(decorator: &Expr) -> bool {
844 let target = match decorator {
845 Expr::Call(call) => call.func.as_ref(),
846 other => other,
847 };
848 match target {
849 Expr::Name(name) => name.id.as_str() == "fixture",
850 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
851 _ => false,
852 }
853}
854
855fn is_patch_call(call: &ExprCall) -> bool {
858 match call.func.as_ref() {
859 Expr::Name(name) => name.id.as_str() == "patch",
860 Expr::Attribute(attr) => {
861 let name = attr.attr.as_str();
862 name == "patch"
863 || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
864 }
865 _ => false,
866 }
867}
868
869fn attr_base_is_patch(expr: &Expr) -> bool {
872 match expr {
873 Expr::Name(name) => name.id.as_str() == "patch",
874 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
875 _ => false,
876 }
877}
878
879const 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)";
881
882const 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";
884
885fn patch_string_target(call: &ExprCall) -> Option<&str> {
889 if let Some(Expr::Constant(constant)) = call.args.first() {
890 if let Constant::Str(target) = &constant.value {
891 return Some(target.as_str());
892 }
893 }
894 None
895}
896
897fn patches_constant(call: &ExprCall) -> bool {
900 patch_string_target(call)
901 .and_then(|target| target.rsplit('.').next())
902 .is_some_and(is_upper_constant)
903}
904
905fn patches_first_party(target: &str, pkg: &str) -> bool {
908 target
909 .split('.')
910 .next()
911 .is_some_and(|head| !head.is_empty() && head == pkg)
912}
913
914fn is_upper_constant(name: &str) -> bool {
917 !name.is_empty()
918 && name
919 .chars()
920 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
921 && name.chars().any(|c| c.is_ascii_uppercase())
922}
923
924const ENVIRON_MUTATION_MSG: &str =
926 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
927
928fn is_os_environ(expr: &Expr) -> bool {
930 matches!(
931 expr,
932 Expr::Attribute(attr)
933 if attr.attr.as_str() == "environ"
934 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
935 )
936}
937
938fn is_os_environ_subscript(expr: &Expr) -> bool {
941 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
942}
943
944fn is_environ_mutation_call(call: &ExprCall) -> bool {
947 matches!(
948 call.func.as_ref(),
949 Expr::Attribute(attr)
950 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
951 )
952}
953
954fn is_environ_mutator(method: &str) -> bool {
956 matches!(
957 method,
958 "update" | "pop" | "setdefault" | "clear" | "popitem"
959 )
960}
961
962fn line_of(source: &str, offset: TextSize) -> usize {
964 let offset = (u32::from(offset) as usize).min(source.len());
965 source.as_bytes()[..offset]
966 .iter()
967 .filter(|&&byte| byte == b'\n')
968 .count()
969 + 1
970}
971
972fn first_party_package(root: &Path) -> Option<String> {
981 for dir in root.ancestors() {
982 let candidate = dir.join("pyproject.toml");
983 if candidate.is_file() {
984 return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
985 }
986 if dir.join(".git").exists() {
987 break;
988 }
989 }
990 None
991}
992
993fn read_project_name(path: &Path) -> Option<String> {
995 let contents = std::fs::read_to_string(path).ok()?;
996 let value: toml::Value = toml::from_str(&contents).ok()?;
997 value
998 .get("project")?
999 .get("name")?
1000 .as_str()
1001 .map(str::to_owned)
1002}
1003
1004fn normalize_dist_name(name: &str) -> String {
1007 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1008}
1009
1010fn collect_python_files(
1012 dir: &Path,
1013 out: &mut Vec<PathBuf>,
1014 is_match: fn(&Path) -> bool,
1015) -> Result<()> {
1016 let entries =
1017 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1018 for entry in entries {
1019 let path = entry
1020 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1021 .path();
1022 if path.is_dir() {
1023 collect_python_files(&path, out, is_match)?;
1024 } else if is_match(&path) {
1025 out.push(path);
1026 }
1027 }
1028 Ok(())
1029}
1030
1031fn is_python_test_file(path: &Path) -> bool {
1035 let name = path
1036 .file_name()
1037 .and_then(|n| n.to_str())
1038 .unwrap_or_default();
1039 name == "conftest.py" || name.ends_with("_test.py")
1040}
1041
1042fn is_python_unit_test_file(path: &Path) -> bool {
1046 let name = path
1047 .file_name()
1048 .and_then(|n| n.to_str())
1049 .unwrap_or_default();
1050 name.ends_with("_test.py")
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056 use std::sync::atomic::{AtomicU64, Ordering};
1057
1058 struct TempDir(PathBuf);
1060
1061 impl TempDir {
1062 fn new() -> Self {
1063 static COUNTER: AtomicU64 = AtomicU64::new(0);
1064 let dir = std::env::temp_dir().join(format!(
1065 "tc-lint-{}-{}",
1066 std::process::id(),
1067 COUNTER.fetch_add(1, Ordering::Relaxed),
1068 ));
1069 std::fs::create_dir_all(&dir).unwrap();
1070 TempDir(dir)
1071 }
1072
1073 fn write(&self, name: &str, contents: &str) {
1074 let path = self.0.join(name);
1075 if let Some(parent) = path.parent() {
1076 std::fs::create_dir_all(parent).unwrap();
1077 }
1078 std::fs::write(path, contents).unwrap();
1079 }
1080 }
1081
1082 impl Drop for TempDir {
1083 fn drop(&mut self) {
1084 let _ = std::fs::remove_dir_all(&self.0);
1085 }
1086 }
1087
1088 #[test]
1089 fn normalize_dist_name_maps_to_import_name() {
1090 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1091 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1092 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1093 assert_eq!(normalize_dist_name("myproject"), "myproject");
1094 }
1095
1096 fn parse_call(src: &str) -> ExprCall {
1098 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1099 match suite.into_iter().next().expect("one statement") {
1100 ast::Stmt::Expr(stmt) => match *stmt.value {
1101 Expr::Call(call) => call,
1102 other => panic!("expected a call, got {other:?}"),
1103 },
1104 other => panic!("expected an expression statement, got {other:?}"),
1105 }
1106 }
1107
1108 #[test]
1109 fn patch_string_target_only_reads_string_literals() {
1110 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1111 assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
1112 let int_call = parse_call("patch(42)\n");
1115 assert_eq!(patch_string_target(&int_call), None);
1116 let name_call = parse_call("patch(target)\n");
1117 assert_eq!(patch_string_target(&name_call), None);
1118 let empty_call = parse_call("patch()\n");
1119 assert_eq!(patch_string_target(&empty_call), None);
1120 }
1121
1122 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1124 ImportRecord {
1125 display: source.unwrap_or(".rel").to_string(),
1126 line: 1,
1127 is_uut: false,
1128 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1129 source: source.map(str::to_string),
1130 module: None,
1131 }
1132 }
1133
1134 fn targets(list: &[&str]) -> Vec<String> {
1135 list.iter().map(|s| (*s).to_string()).collect()
1136 }
1137
1138 #[test]
1139 fn is_mocked_requires_every_symbol_at_the_import_module() {
1140 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1141 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1143 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1145 }
1146
1147 #[test]
1148 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1149 let rec = from_import(Some("pkg.ledger"), &["record"]);
1150 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1152 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1154 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1155 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1157 }
1158
1159 #[test]
1160 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1161 let rec = from_import(None, &["record"]);
1164 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1165 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1166 }
1167
1168 #[test]
1169 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1170 let rec = ImportRecord {
1171 display: "pkg.db".to_string(),
1172 line: 1,
1173 is_uut: false,
1174 symbols: Vec::new(),
1175 source: None,
1176 module: Some("pkg.db".to_string()),
1177 };
1178 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1179 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1180 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1181 let empty = from_import(Some("pkg.mod"), &[]);
1183 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1184 }
1185
1186 #[test]
1187 fn patches_first_party_matches_head_segment() {
1188 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1189 assert!(patches_first_party("myproject", "myproject"));
1190 assert!(!patches_first_party("requests.get", "myproject"));
1191 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1192 assert!(!patches_first_party("", "myproject"));
1193 assert!(!patches_first_party(".leading", "myproject"));
1194 }
1195
1196 #[test]
1197 fn first_party_package_reads_pyproject_name() {
1198 let tree = TempDir::new();
1199 tree.write(
1200 "pyproject.toml",
1201 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1202 );
1203 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1205 }
1206
1207 #[test]
1208 fn first_party_package_is_none_without_a_project_name() {
1209 let tree = TempDir::new();
1210 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1212 tree.write(".git", "");
1213 assert_eq!(first_party_package(&tree.0), None);
1214 }
1215
1216 #[test]
1217 fn first_party_package_is_none_when_absent() {
1218 let tree = TempDir::new();
1220 assert_eq!(first_party_package(&tree.0), None);
1221 }
1222
1223 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1226 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1227 let mut visitor = UnitIsolationVisitor {
1228 source,
1229 first_party,
1230 base,
1231 type_checking_depth: 0,
1232 imports: Vec::new(),
1233 patch_targets: Vec::new(),
1234 };
1235 for stmt in suite {
1236 visitor.visit_stmt(stmt);
1237 }
1238 visitor
1239 .imports
1240 .iter()
1241 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1242 .map(|i| i.display.clone())
1243 .collect()
1244 }
1245
1246 #[test]
1247 fn import_head_and_last_segment() {
1248 assert_eq!(import_head("myproject.db.conn"), "myproject");
1249 assert_eq!(import_head("requests"), "requests");
1250 assert_eq!(last_segment("myproject.db.conn"), "conn");
1251 assert_eq!(last_segment("widget"), "widget");
1252 }
1253
1254 #[test]
1255 fn unit_under_test_base_strips_test_suffix() {
1256 assert_eq!(
1257 unit_under_test_base(Path::new("pkg/widget_test.py")),
1258 "widget"
1259 );
1260 assert_eq!(
1263 unit_under_test_base(Path::new("test_widget.py")),
1264 "test_widget"
1265 );
1266 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1267 }
1268
1269 #[test]
1270 fn recognizes_python_unit_test_files() {
1271 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1272 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1273 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1275 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1277 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1278 }
1279
1280 #[test]
1281 fn visitor_flags_first_party_and_external_collaborators() {
1282 let found = unmocked(
1285 "widget",
1286 "myproject",
1287 "from myproject.widget import build\n\
1288 from myproject.ledger import record\n\
1289 import requests\n",
1290 );
1291 assert_eq!(
1292 found,
1293 vec!["myproject.ledger".to_string(), "requests".to_string()]
1294 );
1295 }
1296
1297 #[test]
1298 fn visitor_clears_a_mocked_collaborator() {
1299 let found = unmocked(
1301 "widget",
1302 "myproject",
1303 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1304 );
1305 assert!(found.is_empty(), "got: {found:?}");
1306 }
1307
1308 #[test]
1309 fn visitor_flags_a_wrong_module_patch() {
1310 let found = unmocked(
1313 "widget",
1314 "myproject",
1315 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1316 );
1317 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1318 }
1319
1320 #[test]
1321 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1322 let found = unmocked(
1325 "widget",
1326 "myproject",
1327 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1328 );
1329 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1330 let both = unmocked(
1332 "widget",
1333 "myproject",
1334 "from myproject.ledger import record, erase\n\
1335 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1336 );
1337 assert!(both.is_empty(), "got: {both:?}");
1338 }
1339
1340 #[test]
1341 fn visitor_handles_module_and_relative_imports() {
1342 assert_eq!(
1344 unmocked("widget", "myproject", "import myproject.db\n"),
1345 vec!["myproject.db".to_string()]
1346 );
1347 assert!(unmocked(
1349 "widget",
1350 "myproject",
1351 "import myproject.db\npatch(\"myproject.db.connect\")\n"
1352 )
1353 .is_empty());
1354 assert_eq!(
1356 unmocked("widget", "myproject", "from .ledger import record\n"),
1357 vec![".ledger".to_string()]
1358 );
1359 assert_eq!(
1360 unmocked(
1361 "widget",
1362 "myproject",
1363 "from . import ledger\nfrom . import widget\n"
1364 ),
1365 vec![".ledger".to_string()]
1366 );
1367 }
1368
1369 #[test]
1370 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1371 assert!(unmocked(
1375 "__init__",
1376 "myproject",
1377 "from . import Thing, __all__, __version__\n"
1378 )
1379 .is_empty());
1380 assert_eq!(
1383 unmocked("__init__", "myproject", "from .core import Thing\n"),
1384 vec![".core".to_string()]
1385 );
1386 assert_eq!(
1389 unmocked("__init__", "myproject", "from .. import sibling\n"),
1390 vec!["..sibling".to_string()]
1391 );
1392 assert_eq!(
1395 unmocked("widget", "myproject", "from . import ledger\n"),
1396 vec![".ledger".to_string()]
1397 );
1398 }
1399
1400 #[test]
1401 fn visitor_skips_type_checking_imports() {
1402 let found = unmocked(
1405 "widget",
1406 "myproject",
1407 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
1408 );
1409 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1410 }
1411
1412 #[test]
1413 fn is_checked_import_classifies_origins() {
1414 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
1417 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
1419 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
1421 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
1423 assert!(!is_checked_import("os", "myproject"));
1425 assert!(!is_checked_import("pathlib", "myproject"));
1426 assert!(!is_checked_import("datetime", "myproject"));
1427 }
1428
1429 #[test]
1430 fn visitor_flags_external_collaborators() {
1431 let found = unmocked(
1433 "widget",
1434 "myproject",
1435 "import requests\nimport subprocess\nimport json\nimport pytest\n",
1436 );
1437 assert_eq!(found.len(), 2, "got: {found:?}");
1438 assert!(found.contains(&"requests".to_string()));
1439 assert!(found.contains(&"subprocess".to_string()));
1440 }
1441
1442 #[test]
1443 fn visitor_type_checking_variants_and_plain_if() {
1444 assert!(unmocked(
1447 "widget",
1448 "myproject",
1449 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
1450 )
1451 .is_empty());
1452 assert_eq!(
1455 unmocked(
1456 "widget",
1457 "myproject",
1458 "if ready == 1:\n from myproject.ledger import record\n"
1459 ),
1460 vec!["myproject.ledger".to_string()]
1461 );
1462 }
1463
1464 #[test]
1465 fn find_unit_isolation_without_pyproject_reports_nothing() {
1466 let tree = TempDir::new();
1468 tree.write("widget_test.py", "from myproject.ledger import record\n");
1469 tree.write(".git", "");
1470 assert!(find_unit_isolation_violations(&tree.0)
1471 .expect("a readable tree should succeed")
1472 .is_empty());
1473 }
1474
1475 #[test]
1476 fn find_unit_isolation_walks_subdirs_and_flags() {
1477 let tree = TempDir::new();
1478 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1479 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1481 let found =
1482 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1483 assert_eq!(found.len(), 1, "got: {found:?}");
1484 assert_eq!(found[0].rule, "unmocked-collaborator");
1485 assert!(found[0].message.contains("myproject.ledger"));
1486 }
1487
1488 #[test]
1489 fn recognizes_python_test_files() {
1490 assert!(is_python_test_file(Path::new("widget_test.py")));
1491 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1492 assert!(is_python_test_file(Path::new("conftest.py")));
1493 assert!(!is_python_test_file(Path::new("test_widget.py")));
1495 }
1496
1497 #[test]
1498 fn ignores_non_test_files() {
1499 assert!(!is_python_test_file(Path::new("widget.py")));
1500 assert!(!is_python_test_file(Path::new("conftest.pyi")));
1501 assert!(!is_python_test_file(Path::new("README.md")));
1502 assert!(!is_python_test_file(Path::new("testing.py")));
1503 }
1504
1505 #[test]
1506 fn line_of_counts_newlines() {
1507 let src = "a\nb\nc\n";
1508 assert_eq!(line_of(src, TextSize::from(0)), 1);
1509 assert_eq!(line_of(src, TextSize::from(2)), 2);
1510 assert_eq!(line_of(src, TextSize::from(4)), 3);
1511 }
1512
1513 #[test]
1514 fn recognizes_environ_mutators() {
1515 assert!(is_environ_mutator("update"));
1516 assert!(is_environ_mutator("pop"));
1517 assert!(is_environ_mutator("clear"));
1518 assert!(!is_environ_mutator("get"));
1519 assert!(!is_environ_mutator("keys"));
1520 }
1521
1522 #[test]
1523 fn recognizes_upper_constants() {
1524 assert!(is_upper_constant("CACHE_DIR"));
1525 assert!(is_upper_constant("DEBUG"));
1526 assert!(is_upper_constant("MAX_2"));
1527 assert!(!is_upper_constant("cache_dir"));
1528 assert!(!is_upper_constant("CacheDir"));
1529 assert!(!is_upper_constant("fetch"));
1530 assert!(!is_upper_constant(""));
1531 assert!(!is_upper_constant("_"));
1532 assert!(!is_upper_constant("123"));
1533 }
1534}