1use std::collections::hash_map::Entry;
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{anyhow, Context, Result};
10use rustpython_ast::Visitor;
11use rustpython_parser::ast::{
12 self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
13 StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
14};
15use rustpython_parser::text_size::{TextRange, TextSize};
16use rustpython_parser::Parse;
17
18pub use crate::violation::Violation;
20
21pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
25 let root = root.as_ref();
26 let manifest = first_party_manifest(root);
28 let mut files = Vec::new();
29 collect_python_files(root, &mut files, is_python_test_file)?;
30 files.sort();
31
32 let mut violations = Vec::new();
33 for file in &files {
34 let source = std::fs::read_to_string(file)
35 .with_context(|| format!("reading test file `{}`", file.display()))?;
36 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
37 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
38 let mut visitor = LintVisitor {
39 file,
40 source: &source,
41 fixture_depth: 0,
42 first_party: manifest.as_ref().map(|(name, _)| name.as_str()),
43 source_root: manifest.as_ref().map(|(_, dir)| dir.as_path()),
44 imports: HashMap::new(),
45 declared_modules: HashSet::new(),
46 violations: Vec::new(),
47 };
48 for stmt in suite {
49 visitor.visit_stmt(stmt);
50 }
51 violations.append(&mut visitor.violations);
52 }
53
54 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
55 Ok(violations)
56}
57
58const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
59 a suite lives in `tests/integration/` or `tests/e2e/`";
60
61pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
65 let tests = package_root.join("tests");
66 let mut violations = Vec::new();
67 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
68 for tier in &tiers {
69 if tier.is_dir() {
70 violations.extend(find_violations(tier)?);
71 }
72 }
73 if tests.is_dir() {
74 let mut strays = Vec::new();
75 collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
76 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
77 for file in strays {
78 violations.push(Violation {
79 file,
80 line: 1,
81 rule: "unknown-tier",
82 message: UNKNOWN_TIER_MSG.to_string(),
83 });
84 }
85 }
86 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
87 Ok(violations)
88}
89
90pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
94 let root = root.as_ref();
95 let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") else {
98 return Ok(Vec::new());
99 };
100 let Some(first_party) = first_party_package(root) else {
101 return Ok(Vec::new());
102 };
103 let mut files = Vec::new();
104 collect_python_files(root, &mut files, is_python_unit_test_file)?;
105 files.retain(|file| !file.starts_with(&tests));
107 files.sort();
108
109 let mut violations = Vec::new();
110 for file in &files {
111 let source = std::fs::read_to_string(file)
112 .with_context(|| format!("reading test file `{}`", file.display()))?;
113 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
114 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
115 let base = unit_under_test_base(file);
116 let mut visitor = UnitIsolationVisitor {
117 source: &source,
118 first_party: &first_party,
119 base: &base,
120 type_checking_depth: 0,
121 imports: Vec::new(),
122 patch_targets: Vec::new(),
123 };
124 for stmt in suite {
125 visitor.visit_stmt(stmt);
126 }
127 for import in &visitor.imports {
128 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
129 continue;
130 }
131 violations.push(Violation {
132 file: file.to_path_buf(),
133 line: import.line,
134 rule: "unmocked-collaborator",
135 message: format!(
136 "unit test imports `{}` without mocking it — a unit test isolates the \
137 unit under test, so mock every collaborator (patch it by string in a \
138 fixture)",
139 import.display
140 ),
141 });
142 }
143 }
144
145 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
146 Ok(violations)
147}
148
149struct ImportRecord {
151 display: String,
153 line: usize,
154 is_uut: bool,
155 symbols: Vec<String>,
157 source: Option<String>,
160 module: Option<String>,
162}
163
164impl ImportRecord {
165 fn is_mocked(&self, patch_targets: &[String]) -> bool {
169 if let Some(module) = &self.module {
170 let prefix = format!("{module}.");
171 return patch_targets
172 .iter()
173 .any(|target| target == module || target.starts_with(&prefix));
174 }
175 if self.symbols.is_empty() {
176 return false;
177 }
178 self.symbols.iter().all(|symbol| {
179 patch_targets
180 .iter()
181 .any(|target| self.symbol_is_mocked(target, symbol))
182 })
183 }
184
185 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
188 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
189 return false;
190 };
191 match &self.source {
192 Some(source) => module == source,
193 None => true,
194 }
195 }
196}
197
198struct UnitIsolationVisitor<'a> {
202 source: &'a str,
203 first_party: &'a str,
204 base: &'a str,
205 type_checking_depth: usize,
206 imports: Vec<ImportRecord>,
207 patch_targets: Vec<String>,
208}
209
210impl Visitor for UnitIsolationVisitor<'_> {
211 fn visit_stmt_import(&mut self, node: StmtImport) {
212 if self.type_checking_depth == 0 {
213 let line = line_of(self.source, node.range.start());
214 for alias in &node.names {
215 let module = alias.name.as_str();
216 if is_checked_import(import_head(module), self.first_party) {
217 self.imports.push(ImportRecord {
218 display: module.to_string(),
219 line,
220 is_uut: last_segment(module) == self.base,
221 symbols: Vec::new(),
222 source: None,
223 module: Some(module.to_string()),
224 });
225 }
226 }
227 }
228 self.generic_visit_stmt_import(node);
229 }
230
231 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
232 if self.type_checking_depth == 0 {
233 let level = relative_level(&node);
234 let module = node.module.as_ref().map(|m| m.as_str());
235 let should_check = level > 0
237 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
238 if should_check {
239 let line = line_of(self.source, node.range.start());
240 let dots = ".".repeat(level);
241 match module {
242 Some(module) => self.imports.push(ImportRecord {
244 display: format!("{dots}{module}"),
245 line,
246 is_uut: last_segment(module) == self.base,
247 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
248 source: (level == 0).then(|| module.to_string()),
249 module: None,
250 }),
251 None => {
253 let barrel_sut = self.base == "__init__" && level == 1;
256 for alias in &node.names {
257 let name = alias.name.as_str();
258 self.imports.push(ImportRecord {
259 display: format!("{dots}{name}"),
260 line,
261 is_uut: barrel_sut || name == self.base,
262 symbols: vec![name.to_string()],
263 source: None,
264 module: None,
265 });
266 }
267 }
268 }
269 }
270 }
271 self.generic_visit_stmt_import_from(node);
272 }
273
274 fn visit_expr_call(&mut self, node: ExprCall) {
275 if is_patch_call(&node) {
276 if let Some(target) = patch_string_target(&node) {
277 self.patch_targets.push(target.to_string());
278 }
279 }
280 self.generic_visit_expr_call(node);
281 }
282
283 fn visit_stmt_if(&mut self, node: StmtIf) {
284 if is_type_checking(node.test.as_ref()) {
286 self.type_checking_depth += 1;
287 for stmt in node.body {
288 self.visit_stmt(stmt);
289 }
290 self.type_checking_depth -= 1;
291 for stmt in node.orelse {
292 self.visit_stmt(stmt);
293 }
294 } else {
295 self.generic_visit_stmt_if(node);
296 }
297 }
298}
299
300fn import_head(module: &str) -> &str {
302 module.split('.').next().unwrap_or(module)
303}
304
305fn is_checked_import(head: &str, first_party: &str) -> bool {
308 if head == first_party {
309 return true;
310 }
311 if TEST_FRAMEWORK.contains(&head) {
312 return false;
313 }
314 if EFFECTFUL_STDLIB.contains(&head) {
315 return true;
316 }
317 if STDLIB_MODULES.contains(&head) {
318 return false;
319 }
320 true }
322
323const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
325
326const EFFECTFUL_STDLIB: &[&str] = &[
330 "asynchat",
331 "asyncore",
332 "ctypes",
333 "curses",
334 "dbm",
335 "fcntl",
336 "ftplib",
337 "imaplib",
338 "mmap",
339 "msvcrt",
340 "multiprocessing",
341 "nis",
342 "nntplib",
343 "ossaudiodev",
344 "poplib",
345 "pty",
346 "random",
347 "secrets",
348 "select",
349 "selectors",
350 "signal",
351 "smtpd",
352 "smtplib",
353 "socket",
354 "socketserver",
355 "spwd",
356 "sqlite3",
357 "ssl",
358 "subprocess",
359 "syslog",
360 "telnetlib",
361 "termios",
362 "tty",
363 "webbrowser",
364 "winreg",
365 "winsound",
366];
367
368const STDLIB_MODULES: &[&str] = &[
371 "__future__",
372 "_abc",
373 "_aix_support",
374 "_ast",
375 "_asyncio",
376 "_bisect",
377 "_blake2",
378 "_bz2",
379 "_codecs",
380 "_codecs_cn",
381 "_codecs_hk",
382 "_codecs_iso2022",
383 "_codecs_jp",
384 "_codecs_kr",
385 "_codecs_tw",
386 "_collections",
387 "_collections_abc",
388 "_compat_pickle",
389 "_compression",
390 "_contextvars",
391 "_crypt",
392 "_csv",
393 "_ctypes",
394 "_curses",
395 "_curses_panel",
396 "_datetime",
397 "_dbm",
398 "_decimal",
399 "_elementtree",
400 "_frozen_importlib",
401 "_frozen_importlib_external",
402 "_functools",
403 "_gdbm",
404 "_hashlib",
405 "_heapq",
406 "_imp",
407 "_io",
408 "_json",
409 "_locale",
410 "_lsprof",
411 "_lzma",
412 "_markupbase",
413 "_md5",
414 "_msi",
415 "_multibytecodec",
416 "_multiprocessing",
417 "_opcode",
418 "_operator",
419 "_osx_support",
420 "_overlapped",
421 "_pickle",
422 "_posixshmem",
423 "_posixsubprocess",
424 "_py_abc",
425 "_pydatetime",
426 "_pydecimal",
427 "_pyio",
428 "_pylong",
429 "_queue",
430 "_random",
431 "_scproxy",
432 "_sha1",
433 "_sha2",
434 "_sha3",
435 "_signal",
436 "_sitebuiltins",
437 "_socket",
438 "_sqlite3",
439 "_sre",
440 "_ssl",
441 "_stat",
442 "_statistics",
443 "_string",
444 "_strptime",
445 "_struct",
446 "_symtable",
447 "_thread",
448 "_threading_local",
449 "_tkinter",
450 "_tokenize",
451 "_tracemalloc",
452 "_typing",
453 "_uuid",
454 "_warnings",
455 "_weakref",
456 "_weakrefset",
457 "_winapi",
458 "_zoneinfo",
459 "abc",
460 "aifc",
461 "antigravity",
462 "argparse",
463 "array",
464 "ast",
465 "asynchat",
466 "asyncio",
467 "asyncore",
468 "atexit",
469 "audioop",
470 "base64",
471 "bdb",
472 "binascii",
473 "bisect",
474 "builtins",
475 "bz2",
476 "cProfile",
477 "calendar",
478 "cgi",
479 "cgitb",
480 "chunk",
481 "cmath",
482 "cmd",
483 "code",
484 "codecs",
485 "codeop",
486 "collections",
487 "colorsys",
488 "compileall",
489 "concurrent",
490 "configparser",
491 "contextlib",
492 "contextvars",
493 "copy",
494 "copyreg",
495 "crypt",
496 "csv",
497 "ctypes",
498 "curses",
499 "dataclasses",
500 "datetime",
501 "dbm",
502 "decimal",
503 "difflib",
504 "dis",
505 "distutils",
506 "doctest",
507 "email",
508 "encodings",
509 "ensurepip",
510 "enum",
511 "errno",
512 "faulthandler",
513 "fcntl",
514 "filecmp",
515 "fileinput",
516 "fnmatch",
517 "fractions",
518 "ftplib",
519 "functools",
520 "gc",
521 "genericpath",
522 "getopt",
523 "getpass",
524 "gettext",
525 "glob",
526 "graphlib",
527 "grp",
528 "gzip",
529 "hashlib",
530 "heapq",
531 "hmac",
532 "html",
533 "http",
534 "idlelib",
535 "imaplib",
536 "imghdr",
537 "imp",
538 "importlib",
539 "inspect",
540 "io",
541 "ipaddress",
542 "itertools",
543 "json",
544 "keyword",
545 "lib2to3",
546 "linecache",
547 "locale",
548 "logging",
549 "lzma",
550 "mailbox",
551 "mailcap",
552 "marshal",
553 "math",
554 "mimetypes",
555 "mmap",
556 "modulefinder",
557 "msilib",
558 "msvcrt",
559 "multiprocessing",
560 "netrc",
561 "nis",
562 "nntplib",
563 "nt",
564 "ntpath",
565 "nturl2path",
566 "numbers",
567 "opcode",
568 "operator",
569 "optparse",
570 "os",
571 "ossaudiodev",
572 "pathlib",
573 "pdb",
574 "pickle",
575 "pickletools",
576 "pipes",
577 "pkgutil",
578 "platform",
579 "plistlib",
580 "poplib",
581 "posix",
582 "posixpath",
583 "pprint",
584 "profile",
585 "pstats",
586 "pty",
587 "pwd",
588 "py_compile",
589 "pyclbr",
590 "pydoc",
591 "pydoc_data",
592 "pyexpat",
593 "queue",
594 "quopri",
595 "random",
596 "re",
597 "readline",
598 "reprlib",
599 "resource",
600 "rlcompleter",
601 "runpy",
602 "sched",
603 "secrets",
604 "select",
605 "selectors",
606 "shelve",
607 "shlex",
608 "shutil",
609 "signal",
610 "site",
611 "smtpd",
612 "smtplib",
613 "sndhdr",
614 "socket",
615 "socketserver",
616 "spwd",
617 "sqlite3",
618 "sre_compile",
619 "sre_constants",
620 "sre_parse",
621 "ssl",
622 "stat",
623 "statistics",
624 "string",
625 "stringprep",
626 "struct",
627 "subprocess",
628 "sunau",
629 "symtable",
630 "sys",
631 "sysconfig",
632 "syslog",
633 "tabnanny",
634 "tarfile",
635 "telnetlib",
636 "tempfile",
637 "termios",
638 "textwrap",
639 "this",
640 "threading",
641 "time",
642 "timeit",
643 "tkinter",
644 "token",
645 "tokenize",
646 "tomllib",
647 "trace",
648 "traceback",
649 "tracemalloc",
650 "tty",
651 "turtle",
652 "turtledemo",
653 "types",
654 "typing",
655 "unicodedata",
656 "unittest",
657 "urllib",
658 "uu",
659 "uuid",
660 "venv",
661 "warnings",
662 "wave",
663 "weakref",
664 "webbrowser",
665 "winreg",
666 "winsound",
667 "wsgiref",
668 "xdrlib",
669 "xml",
670 "xmlrpc",
671 "zipapp",
672 "zipfile",
673 "zipimport",
674 "zlib",
675 "zoneinfo",
676];
677
678fn last_segment(module: &str) -> &str {
680 module.rsplit('.').next().unwrap_or(module)
681}
682
683fn relative_level(node: &StmtImportFrom) -> usize {
685 node.level.map_or(0, |level| level.to_usize())
686}
687
688fn is_type_checking(test: &Expr) -> bool {
690 match test {
691 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
692 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
693 _ => false,
694 }
695}
696
697fn unit_under_test_base(file: &Path) -> String {
700 let name = file
701 .file_name()
702 .and_then(|n| n.to_str())
703 .unwrap_or_default();
704 let stem = name.strip_suffix(".py").unwrap_or(name);
705 stem.strip_suffix("_test").unwrap_or(stem).to_string()
706}
707
708struct LintVisitor<'a> {
711 file: &'a Path,
712 source: &'a str,
713 fixture_depth: usize,
714 first_party: Option<&'a str>,
716 source_root: Option<&'a Path>,
718 imports: HashMap<String, String>,
720 declared_modules: HashSet<String>,
722 violations: Vec<Violation>,
723}
724
725impl LintVisitor<'_> {
726 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
727 self.violations.push(Violation {
728 file: self.file.to_path_buf(),
729 line: line_of(self.source, range.start()),
730 rule,
731 message: message.to_string(),
732 });
733 }
734
735 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
737 let takes_monkeypatch = args
738 .posonlyargs
739 .iter()
740 .chain(&args.args)
741 .chain(&args.kwonlyargs)
742 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
743 || arg_named(&args.vararg, "monkeypatch")
744 || arg_named(&args.kwarg, "monkeypatch");
745 if takes_monkeypatch {
746 self.report(
747 range,
748 "no-monkeypatch",
749 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
750 );
751 }
752
753 decorators.iter().any(is_fixture_decorator)
754 }
755
756 fn declare_module(&mut self, module: &str) {
757 let mut path = String::new();
758 for segment in module.split('.') {
759 if !path.is_empty() {
760 path.push('.');
761 }
762 path.push_str(segment);
763 self.declared_modules.insert(path.clone());
764 }
765 }
766
767 fn resolve_ctx(&self) -> ResolveCtx<'_> {
768 ResolveCtx {
769 imports: &self.imports,
770 declared_modules: &self.declared_modules,
771 first_party: self.first_party,
772 source_root: self.source_root,
773 }
774 }
775}
776
777impl Visitor for LintVisitor<'_> {
778 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
779 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
780 if is_fixture {
781 self.fixture_depth += 1;
782 }
783 self.generic_visit_stmt_function_def(node);
784 if is_fixture {
785 self.fixture_depth -= 1;
786 }
787 }
788
789 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
790 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
791 if is_fixture {
792 self.fixture_depth += 1;
793 }
794 self.generic_visit_stmt_async_function_def(node);
795 if is_fixture {
796 self.fixture_depth -= 1;
797 }
798 }
799
800 fn visit_expr_call(&mut self, node: ExprCall) {
801 if is_patch_call(&node) && self.fixture_depth == 0 {
803 self.report(
804 node.range,
805 "no-inline-patch",
806 "patch is called inline in a test body; move it into a `pytest.fixture`",
807 );
808 }
809 if let Some(target) = patch_target(&node, &self.resolve_ctx()) {
812 if patches_constant(&target) {
813 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
814 }
815 if let Some(pkg) = self.first_party {
816 if patches_first_party(&target, pkg) {
817 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
818 }
819 }
820 }
821 if is_environ_mutation_call(&node) {
822 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
823 }
824 self.generic_visit_expr_call(node);
825 }
826
827 fn visit_stmt_import(&mut self, node: StmtImport) {
828 for alias in &node.names {
829 self.declare_module(alias.name.as_str());
830 match &alias.asname {
831 Some(asname) => {
833 self.imports
834 .insert(asname.to_string(), alias.name.to_string());
835 }
836 None => {
837 let head = import_head(alias.name.as_str());
838 self.imports.insert(head.to_string(), head.to_string());
839 }
840 }
841 }
842 self.generic_visit_stmt_import(node);
843 }
844
845 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
846 if let (0, Some(module)) = (relative_level(&node), &node.module) {
848 self.declare_module(module.as_str());
849 for alias in &node.names {
850 let bound = alias.asname.as_ref().unwrap_or(&alias.name);
851 self.imports
852 .insert(bound.to_string(), format!("{module}.{}", alias.name));
853 }
854 }
855 self.generic_visit_stmt_import_from(node);
856 }
857
858 fn visit_withitem(&mut self, node: WithItem) {
861 self.visit_expr(node.context_expr);
862 if let Some(optional_vars) = node.optional_vars {
863 self.visit_expr(*optional_vars);
864 }
865 }
866
867 fn visit_stmt_assign(&mut self, node: StmtAssign) {
868 if node.targets.iter().any(is_os_environ_subscript) {
869 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
870 }
871 self.generic_visit_stmt_assign(node);
872 }
873
874 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
875 if is_os_environ_subscript(node.target.as_ref()) {
876 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
877 }
878 self.generic_visit_stmt_aug_assign(node);
879 }
880
881 fn visit_stmt_delete(&mut self, node: StmtDelete) {
882 if node.targets.iter().any(is_os_environ_subscript) {
883 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
884 }
885 self.generic_visit_stmt_delete(node);
886 }
887}
888
889fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
891 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
892}
893
894fn is_fixture_decorator(decorator: &Expr) -> bool {
896 let target = match decorator {
897 Expr::Call(call) => call.func.as_ref(),
898 other => other,
899 };
900 match target {
901 Expr::Name(name) => name.id.as_str() == "fixture",
902 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
903 _ => false,
904 }
905}
906
907enum PatchForm {
909 Target,
911 Object,
913 Dict,
915}
916
917fn patch_form(call: &ExprCall) -> Option<PatchForm> {
920 match call.func.as_ref() {
921 Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
922 Expr::Attribute(attr) => match attr.attr.as_str() {
923 "patch" => Some(PatchForm::Target),
924 "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
925 "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
926 _ => None,
927 },
928 _ => None,
929 }
930}
931
932fn is_patch_call(call: &ExprCall) -> bool {
934 patch_form(call).is_some()
935}
936
937fn attr_base_is_patch(expr: &Expr) -> bool {
939 match expr {
940 Expr::Name(name) => name.id.as_str() == "patch",
941 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
942 _ => false,
943 }
944}
945
946const 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)";
947
948const 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";
949
950fn patch_string_target(call: &ExprCall) -> Option<&str> {
953 string_arg(call, 0)
954}
955
956fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
958 if let Some(Expr::Constant(constant)) = call.args.get(index) {
959 if let Constant::Str(value) = &constant.value {
960 return Some(value.as_str());
961 }
962 }
963 None
964}
965
966fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
969 match expr {
970 Expr::Name(name) => Some(vec![name.id.as_str()]),
971 Expr::Attribute(attr) => {
972 let mut segments = attr_chain_segments(attr.value.as_ref())?;
973 segments.push(attr.attr.as_str());
974 Some(segments)
975 }
976 _ => None,
977 }
978}
979
980struct ResolveCtx<'a> {
982 imports: &'a HashMap<String, String>,
984 declared_modules: &'a HashSet<String>,
986 first_party: Option<&'a str>,
987 source_root: Option<&'a Path>,
988}
989
990fn resolve_object_target(expr: &Expr, ctx: &ResolveCtx) -> Option<String> {
994 let segments = attr_chain_segments(expr)?;
995 let (head, rest) = segments.split_first()?;
996 let mut target = ctx.imports.get(*head)?.clone();
997 for (index, segment) in rest.iter().enumerate() {
998 let candidate = format!("{target}.{segment}");
999 if ctx.declared_modules.contains(&candidate) {
1000 target = candidate;
1001 continue;
1002 }
1003 let first_party_root = ctx
1004 .first_party
1005 .zip(ctx.source_root)
1006 .filter(|(pkg, _)| target.split('.').next() == Some(*pkg));
1007 let Some((_, root)) = first_party_root else {
1008 return Some(append_segments(target, &rest[index..]));
1009 };
1010 target = match module_attribute(root, &target, segment)? {
1011 Attr::Module(absolute) => absolute,
1012 Attr::Defined if index == rest.len() - 1 => candidate,
1013 Attr::Defined => return None,
1014 };
1015 }
1016 Some(target)
1017}
1018
1019fn append_segments(mut target: String, segments: &[&str]) -> String {
1020 for segment in segments {
1021 target.push('.');
1022 target.push_str(segment);
1023 }
1024 target
1025}
1026
1027enum Attr {
1029 Module(String),
1031 Defined,
1033}
1034
1035fn module_attribute(root: &Path, module: &str, name: &str) -> Option<Attr> {
1038 let file = locate_module(root, module)?;
1039 let source = std::fs::read_to_string(&file.path).ok()?;
1040 let scope = module_scope(&source, &file.package)?;
1041 match scope.bindings.get(name) {
1042 Some(Binding::Module(absolute)) => Some(Attr::Module(absolute.clone())),
1043 Some(Binding::Defined) => Some(Attr::Defined),
1044 Some(Binding::Opaque) => None,
1045 None if scope.has_star_import => None,
1046 None => {
1047 let submodule = format!("{module}.{name}");
1048 locate_module(root, &submodule).map(|_| Attr::Module(submodule))
1049 }
1050 }
1051}
1052
1053struct ModuleFile {
1055 path: PathBuf,
1056 package: Vec<String>,
1057}
1058
1059fn locate_module(root: &Path, module: &str) -> Option<ModuleFile> {
1061 let segments: Vec<String> = module.split('.').map(str::to_owned).collect();
1062 let rel: PathBuf = segments.iter().collect();
1063 for base in [root.to_path_buf(), root.join("src")] {
1064 let file = base.join(&rel).with_extension("py");
1065 if file.is_file() {
1066 let package = segments[..segments.len() - 1].to_vec();
1067 return Some(ModuleFile {
1068 path: file,
1069 package,
1070 });
1071 }
1072 let init = base.join(&rel).join("__init__.py");
1073 if init.is_file() {
1074 return Some(ModuleFile {
1075 path: init,
1076 package: segments,
1077 });
1078 }
1079 }
1080 None
1081}
1082
1083#[derive(Debug, PartialEq)]
1085enum Binding {
1086 Module(String),
1088 Defined,
1090 Opaque,
1092}
1093
1094struct ModuleScope {
1095 bindings: HashMap<String, Binding>,
1096 has_star_import: bool,
1097}
1098
1099impl ModuleScope {
1100 fn bind(&mut self, name: String, binding: Binding) {
1101 match self.bindings.entry(name) {
1102 Entry::Occupied(mut entry) => {
1103 if *entry.get() != binding {
1104 entry.insert(Binding::Opaque);
1105 }
1106 }
1107 Entry::Vacant(entry) => {
1108 entry.insert(binding);
1109 }
1110 }
1111 }
1112
1113 fn bind_import_from(&mut self, node: &StmtImportFrom, package: &[String]) {
1114 let base = match relative_level(node) {
1115 0 => node.module.as_ref().map(|module| module.to_string()),
1116 level if level <= package.len() => {
1118 let parent = package[..package.len() + 1 - level].join(".");
1119 Some(match &node.module {
1120 Some(module) => format!("{parent}.{module}"),
1121 None => parent,
1122 })
1123 }
1124 _ => None,
1125 };
1126 for alias in &node.names {
1127 if alias.name.as_str() == "*" {
1128 self.has_star_import = true;
1129 continue;
1130 }
1131 let bound = alias.asname.as_ref().unwrap_or(&alias.name).to_string();
1132 let binding = match &base {
1133 Some(base) => Binding::Module(format!("{base}.{}", alias.name)),
1134 None => Binding::Opaque,
1135 };
1136 self.bind(bound, binding);
1137 }
1138 }
1139
1140 fn bind_assign_target(&mut self, target: &Expr, literal: bool) {
1141 match target {
1142 Expr::Name(name) => {
1143 let binding = if literal {
1144 Binding::Defined
1145 } else {
1146 Binding::Opaque
1147 };
1148 self.bind(name.id.to_string(), binding);
1149 }
1150 Expr::Tuple(tuple) => {
1151 for elt in &tuple.elts {
1152 self.bind_assign_target(elt, false);
1153 }
1154 }
1155 Expr::List(list) => {
1156 for elt in &list.elts {
1157 self.bind_assign_target(elt, false);
1158 }
1159 }
1160 _ => {}
1161 }
1162 }
1163
1164 fn bind_stmt(&mut self, stmt: &ast::Stmt, package: &[String]) {
1167 match stmt {
1168 ast::Stmt::Import(node) => {
1169 for alias in &node.names {
1170 match &alias.asname {
1171 Some(asname) => {
1172 self.bind(asname.to_string(), Binding::Module(alias.name.to_string()));
1173 }
1174 None => {
1175 let head = import_head(alias.name.as_str());
1176 self.bind(head.to_string(), Binding::Module(head.to_string()));
1177 }
1178 }
1179 }
1180 }
1181 ast::Stmt::ImportFrom(node) => self.bind_import_from(node, package),
1182 ast::Stmt::FunctionDef(node) => self.bind(node.name.to_string(), Binding::Defined),
1183 ast::Stmt::AsyncFunctionDef(node) => {
1184 self.bind(node.name.to_string(), Binding::Defined);
1185 }
1186 ast::Stmt::ClassDef(node) => self.bind(node.name.to_string(), Binding::Defined),
1187 ast::Stmt::Assign(node) => {
1188 let literal = is_literal(&node.value);
1189 for target in &node.targets {
1190 self.bind_assign_target(target, literal);
1191 }
1192 }
1193 ast::Stmt::AnnAssign(node) => {
1194 if let Some(value) = &node.value {
1195 self.bind_assign_target(&node.target, is_literal(value));
1196 }
1197 }
1198 _ => {}
1199 }
1200 }
1201}
1202
1203fn module_scope(source: &str, package: &[String]) -> Option<ModuleScope> {
1206 let suite = ast::Suite::parse(source, "module.py").ok()?;
1207 let mut scope = ModuleScope {
1208 bindings: HashMap::new(),
1209 has_star_import: false,
1210 };
1211 for stmt in &suite {
1212 scope.bind_stmt(stmt, package);
1213 }
1214 Some(scope)
1215}
1216
1217fn is_literal(expr: &Expr) -> bool {
1219 match expr {
1220 Expr::Constant(_) => true,
1221 Expr::UnaryOp(op) => is_literal(&op.operand),
1222 Expr::Dict(dict) => {
1223 dict.keys.iter().flatten().all(is_literal) && dict.values.iter().all(is_literal)
1224 }
1225 Expr::List(list) => list.elts.iter().all(is_literal),
1226 Expr::Tuple(tuple) => tuple.elts.iter().all(is_literal),
1227 Expr::Set(set) => set.elts.iter().all(is_literal),
1228 _ => false,
1229 }
1230}
1231
1232fn patch_target(call: &ExprCall, ctx: &ResolveCtx) -> Option<String> {
1236 match patch_form(call)? {
1237 PatchForm::Target => patch_string_target(call).map(str::to_owned),
1238 PatchForm::Dict => patch_string_target(call)
1239 .map(str::to_owned)
1240 .or_else(|| resolve_object_target(call.args.first()?, ctx)),
1241 PatchForm::Object => {
1242 let base = resolve_object_target(call.args.first()?, ctx)?;
1243 Some(match string_arg(call, 1) {
1244 Some(attr) => format!("{base}.{attr}"),
1245 None => base,
1246 })
1247 }
1248 }
1249}
1250
1251fn patches_constant(target: &str) -> bool {
1253 target.rsplit('.').next().is_some_and(is_upper_constant)
1254}
1255
1256fn patches_first_party(target: &str, pkg: &str) -> bool {
1258 target
1259 .split('.')
1260 .next()
1261 .is_some_and(|head| !head.is_empty() && head == pkg)
1262}
1263
1264fn is_upper_constant(name: &str) -> bool {
1266 !name.is_empty()
1267 && name
1268 .chars()
1269 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1270 && name.chars().any(|c| c.is_ascii_uppercase())
1271}
1272
1273const ENVIRON_MUTATION_MSG: &str =
1274 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1275
1276fn is_os_environ(expr: &Expr) -> bool {
1278 matches!(
1279 expr,
1280 Expr::Attribute(attr)
1281 if attr.attr.as_str() == "environ"
1282 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1283 )
1284}
1285
1286fn is_os_environ_subscript(expr: &Expr) -> bool {
1288 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1289}
1290
1291fn is_environ_mutation_call(call: &ExprCall) -> bool {
1293 matches!(
1294 call.func.as_ref(),
1295 Expr::Attribute(attr)
1296 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1297 )
1298}
1299
1300fn is_environ_mutator(method: &str) -> bool {
1302 matches!(
1303 method,
1304 "update" | "pop" | "setdefault" | "clear" | "popitem"
1305 )
1306}
1307
1308fn line_of(source: &str, offset: TextSize) -> usize {
1310 let offset = (u32::from(offset) as usize).min(source.len());
1311 source.as_bytes()[..offset]
1312 .iter()
1313 .filter(|&&byte| byte == b'\n')
1314 .count()
1315 + 1
1316}
1317
1318fn first_party_package(root: &Path) -> Option<String> {
1322 first_party_manifest(root).map(|(name, _)| name)
1323}
1324
1325fn first_party_manifest(root: &Path) -> Option<(String, PathBuf)> {
1327 for dir in root.ancestors() {
1328 let candidate = dir.join("pyproject.toml");
1329 if candidate.is_file() {
1330 return read_project_name(&candidate)
1331 .map(|name| (normalize_dist_name(&name), dir.to_path_buf()));
1332 }
1333 if dir.join(".git").exists() {
1334 break;
1335 }
1336 }
1337 None
1338}
1339
1340fn read_project_name(path: &Path) -> Option<String> {
1342 let contents = std::fs::read_to_string(path).ok()?;
1343 let value: toml::Value = toml::from_str(&contents).ok()?;
1344 value
1345 .get("project")?
1346 .get("name")?
1347 .as_str()
1348 .map(str::to_owned)
1349}
1350
1351fn normalize_dist_name(name: &str) -> String {
1354 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1355}
1356
1357fn collect_python_files(
1358 dir: &Path,
1359 out: &mut Vec<PathBuf>,
1360 is_match: fn(&Path) -> bool,
1361) -> Result<()> {
1362 let entries =
1363 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1364 for entry in entries {
1365 let path = crate::walk::dir_entry(entry, dir)?.path();
1366 if path.is_dir() {
1367 collect_python_files(&path, out, is_match)?;
1368 } else if is_match(&path) {
1369 out.push(path);
1370 }
1371 }
1372 Ok(())
1373}
1374
1375fn is_python_test_file(path: &Path) -> bool {
1378 let name = path
1379 .file_name()
1380 .and_then(|n| n.to_str())
1381 .unwrap_or_default();
1382 name == "conftest.py" || name.ends_with("_test.py")
1383}
1384
1385fn is_python_unit_test_file(path: &Path) -> bool {
1388 let name = path
1389 .file_name()
1390 .and_then(|n| n.to_str())
1391 .unwrap_or_default();
1392 name.ends_with("_test.py")
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397 use super::*;
1398 use std::sync::atomic::{AtomicU64, Ordering};
1399
1400 struct TempDir(PathBuf);
1402
1403 impl TempDir {
1404 fn new() -> Self {
1405 static COUNTER: AtomicU64 = AtomicU64::new(0);
1406 let dir = std::env::temp_dir().join(format!(
1407 "tc-lint-{}-{}",
1408 std::process::id(),
1409 COUNTER.fetch_add(1, Ordering::Relaxed),
1410 ));
1411 std::fs::create_dir_all(&dir).unwrap();
1412 TempDir(dir)
1413 }
1414
1415 fn write(&self, name: &str, contents: &str) {
1416 let path = self.0.join(name);
1417 if let Some(parent) = path.parent() {
1418 std::fs::create_dir_all(parent).unwrap();
1419 }
1420 std::fs::write(path, contents).unwrap();
1421 }
1422 }
1423
1424 impl Drop for TempDir {
1425 fn drop(&mut self) {
1426 let _ = std::fs::remove_dir_all(&self.0);
1427 }
1428 }
1429
1430 #[test]
1431 fn normalize_dist_name_maps_to_import_name() {
1432 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1433 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1434 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1435 assert_eq!(normalize_dist_name("myproject"), "myproject");
1436 }
1437
1438 fn parse_call(src: &str) -> ExprCall {
1440 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1441 let stmt = suite.into_iter().next().expect("one statement");
1442 (*stmt.expect_expr_stmt().value).expect_call_expr()
1443 }
1444
1445 fn naive_ctx<'a>(
1447 imports: &'a HashMap<String, String>,
1448 declared: &'a HashSet<String>,
1449 ) -> ResolveCtx<'a> {
1450 ResolveCtx {
1451 imports,
1452 declared_modules: declared,
1453 first_party: None,
1454 source_root: None,
1455 }
1456 }
1457
1458 #[test]
1459 fn patch_target_only_reads_string_literals_for_the_string_form() {
1460 let imports = HashMap::new();
1461 let declared = HashSet::new();
1462 let ctx = naive_ctx(&imports, &declared);
1463 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1464 assert_eq!(
1465 patch_target(&str_call, &ctx).as_deref(),
1466 Some("pkg.mod.attr")
1467 );
1468 let name_call = parse_call("patch(target)\n");
1470 assert_eq!(patch_target(&name_call, &ctx), None);
1471 let int_call = parse_call("patch(42)\n");
1472 assert_eq!(patch_target(&int_call, &ctx), None);
1473 let empty_call = parse_call("patch()\n");
1474 assert_eq!(patch_target(&empty_call, &ctx), None);
1475 }
1476
1477 fn object_form_imports() -> HashMap<String, String> {
1479 HashMap::from([
1480 ("ledger".to_string(), "myproject.ledger".to_string()),
1481 ("myproject".to_string(), "myproject".to_string()),
1482 ("cfg".to_string(), "myproject.cfg".to_string()),
1483 ])
1484 }
1485
1486 #[test]
1487 fn patch_target_resolves_object_forms_through_imports() {
1488 let imports = object_form_imports();
1489 let declared = HashSet::new();
1490 let ctx = naive_ctx(&imports, &declared);
1491 let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1492 assert_eq!(
1493 patch_target(&imported_name, &ctx).as_deref(),
1494 Some("myproject.ledger.record")
1495 );
1496 let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1497 assert_eq!(
1498 patch_target(&dotted_module, &ctx).as_deref(),
1499 Some("myproject.ledger.record")
1500 );
1501 let name_attr = parse_call("patch.object(ledger, attr)\n");
1503 assert_eq!(
1504 patch_target(&name_attr, &ctx).as_deref(),
1505 Some("myproject.ledger")
1506 );
1507 let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1508 assert_eq!(
1509 patch_target(&dict_object, &ctx).as_deref(),
1510 Some("myproject.cfg.SETTINGS")
1511 );
1512 let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1513 assert_eq!(
1514 patch_target(&dict_string, &ctx).as_deref(),
1515 Some("pkg.cfg.FLAGS")
1516 );
1517 }
1518
1519 #[test]
1520 fn patch_target_declines_a_base_bound_by_no_import() {
1521 let imports = object_form_imports();
1522 let declared = HashSet::new();
1523 let ctx = naive_ctx(&imports, &declared);
1524 let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1525 assert_eq!(patch_target(&call_base, &ctx), None);
1526 let unbound_name = parse_call("patch.object(client, \"send\")\n");
1527 assert_eq!(patch_target(&unbound_name, &ctx), None);
1528 let empty = parse_call("patch.object()\n");
1529 assert_eq!(patch_target(&empty, &ctx), None);
1530 }
1531
1532 fn object_patch_tree(base: &str, attr: &str) -> TempDir {
1535 let tree = TempDir::new();
1536 tree.write(
1537 "pyproject.toml",
1538 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1539 );
1540 tree.write(
1541 "tests/integration/spy_test.py",
1542 &format!(
1543 "from unittest.mock import patch\n\
1544 import pytest\n\
1545 from myproject import async_mod\n\
1546 @pytest.fixture\n\
1547 def spy():\n\
1548 \x20 with patch.object({base}, \"{attr}\") as s:\n\
1549 \x20 yield s\n"
1550 ),
1551 );
1552 tree
1553 }
1554
1555 fn first_party_patch_count(tree: &TempDir) -> usize {
1556 find_violations(&tree.0)
1557 .expect("walking a readable tree should succeed")
1558 .iter()
1559 .filter(|v| v.rule == "no-first-party-patch")
1560 .count()
1561 }
1562
1563 #[test]
1564 fn object_form_stdlib_reached_through_first_party_is_not_flagged() {
1565 let tree = object_patch_tree("async_mod.asyncio", "to_thread");
1566 tree.write("myproject/async_mod.py", "import asyncio\n");
1567 assert_eq!(first_party_patch_count(&tree), 0);
1568 }
1569
1570 #[test]
1571 fn object_form_unnamed_module_attribute_declines_to_fire() {
1572 let missing_source = object_patch_tree("async_mod.transport", "send");
1573 assert_eq!(first_party_patch_count(&missing_source), 0);
1574 let dynamic = object_patch_tree("async_mod.transport", "send");
1575 dynamic.write("myproject/async_mod.py", "transport = build()\n");
1576 assert_eq!(first_party_patch_count(&dynamic), 0);
1577 }
1578
1579 #[test]
1580 fn object_form_first_party_module_attribute_still_fires() {
1581 let tree = object_patch_tree("async_mod.helper", "run");
1582 tree.write("myproject/async_mod.py", "from . import helper\n");
1583 assert_eq!(first_party_patch_count(&tree), 1);
1584 }
1585
1586 #[test]
1587 fn object_form_resolves_in_a_src_layout() {
1588 let tree = object_patch_tree("async_mod.helper", "run");
1589 tree.write("src/myproject/async_mod.py", "from . import helper\n");
1590 assert_eq!(first_party_patch_count(&tree), 1);
1591 }
1592
1593 #[test]
1594 fn object_form_star_import_over_an_unbound_name_declines() {
1595 let tree = object_patch_tree("async_mod.walk", "call");
1596 tree.write("myproject/async_mod.py", "from os import *\n");
1597 assert_eq!(first_party_patch_count(&tree), 0);
1598 }
1599
1600 #[test]
1601 fn object_form_conflicting_binding_declines() {
1602 let tree = object_patch_tree("async_mod.helper", "run");
1603 tree.write(
1604 "myproject/async_mod.py",
1605 "from . import helper\nhelper = None\n",
1606 );
1607 assert_eq!(first_party_patch_count(&tree), 0);
1608 }
1609
1610 #[test]
1611 fn object_form_defined_name_mid_chain_declines() {
1612 let tree = object_patch_tree("async_mod.Client.send", "retry");
1613 tree.write("myproject/async_mod.py", "class Client:\n pass\n");
1614 assert_eq!(first_party_patch_count(&tree), 0);
1615 }
1616
1617 #[test]
1618 fn object_form_package_attribute_resolves_through_init() {
1619 let tree = TempDir::new();
1620 tree.write(
1621 "pyproject.toml",
1622 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1623 );
1624 tree.write("myproject/sub/__init__.py", "from . import leaf\n");
1625 tree.write("myproject/sub/leaf.py", "def run():\n pass\n");
1626 tree.write(
1627 "tests/integration/spy_test.py",
1628 "from unittest.mock import patch\n\
1629 import pytest\n\
1630 from myproject import sub\n\
1631 @pytest.fixture\n\
1632 def spy():\n\
1633 \x20 with patch.object(sub.leaf, \"run\") as s:\n\
1634 \x20 yield s\n",
1635 );
1636 assert_eq!(first_party_patch_count(&tree), 1);
1637 }
1638
1639 #[test]
1640 fn object_form_unbound_name_falls_back_to_the_submodule_file() {
1641 let tree = TempDir::new();
1642 tree.write(
1643 "pyproject.toml",
1644 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1645 );
1646 tree.write("myproject/sub/__init__.py", "");
1647 tree.write("myproject/sub/leaf.py", "def run():\n pass\n");
1648 tree.write(
1649 "tests/integration/spy_test.py",
1650 "from unittest.mock import patch\n\
1651 import pytest\n\
1652 from myproject import sub\n\
1653 @pytest.fixture\n\
1654 def spy():\n\
1655 \x20 with patch.object(sub.leaf, \"run\") as s:\n\
1656 \x20 yield s\n",
1657 );
1658 assert_eq!(first_party_patch_count(&tree), 1);
1659 }
1660
1661 #[test]
1662 fn resolve_appends_naively_past_a_third_party_head() {
1663 let tree = TempDir::new();
1664 let imports = HashMap::from([("requests".to_string(), "requests".to_string())]);
1665 let declared = HashSet::new();
1666 let ctx = ResolveCtx {
1667 imports: &imports,
1668 declared_modules: &declared,
1669 first_party: Some("myproject"),
1670 source_root: Some(&tree.0),
1671 };
1672 let call = parse_call("patch.object(requests.utils, \"default_headers\")\n");
1673 assert_eq!(
1674 patch_target(&call, &ctx).as_deref(),
1675 Some("requests.utils.default_headers")
1676 );
1677 }
1678
1679 #[test]
1680 fn locate_module_tries_flat_and_src_layouts() {
1681 let tree = TempDir::new();
1682 tree.write("myproject/flat.py", "");
1683 tree.write("myproject/pkg/__init__.py", "");
1684 tree.write("src/myproject/nested.py", "");
1685 let flat = locate_module(&tree.0, "myproject.flat").expect("flat module");
1686 assert_eq!(flat.path, tree.0.join("myproject/flat.py"));
1687 assert_eq!(flat.package, vec!["myproject".to_string()]);
1688 let pkg = locate_module(&tree.0, "myproject.pkg").expect("package module");
1689 assert_eq!(pkg.path, tree.0.join("myproject/pkg/__init__.py"));
1690 assert_eq!(
1691 pkg.package,
1692 vec!["myproject".to_string(), "pkg".to_string()]
1693 );
1694 let nested = locate_module(&tree.0, "myproject.nested").expect("src module");
1695 assert_eq!(nested.path, tree.0.join("src/myproject/nested.py"));
1696 assert!(locate_module(&tree.0, "myproject.absent").is_none());
1697 }
1698
1699 fn scope_of(source: &str) -> ModuleScope {
1700 module_scope(source, &["myproject".to_string()]).expect("source should parse")
1701 }
1702
1703 #[test]
1704 fn module_scope_classifies_import_bindings() {
1705 let scope = scope_of(
1706 "import asyncio\n\
1707 import myproject.util as util\n\
1708 from . import helper\n\
1709 from .sub import leaf\n\
1710 from myproject.vendor import client as vc\n",
1711 );
1712 assert_eq!(
1713 scope.bindings.get("asyncio"),
1714 Some(&Binding::Module("asyncio".to_string()))
1715 );
1716 assert_eq!(
1717 scope.bindings.get("util"),
1718 Some(&Binding::Module("myproject.util".to_string()))
1719 );
1720 assert_eq!(
1721 scope.bindings.get("helper"),
1722 Some(&Binding::Module("myproject.helper".to_string()))
1723 );
1724 assert_eq!(
1725 scope.bindings.get("leaf"),
1726 Some(&Binding::Module("myproject.sub.leaf".to_string()))
1727 );
1728 assert_eq!(
1729 scope.bindings.get("vc"),
1730 Some(&Binding::Module("myproject.vendor.client".to_string()))
1731 );
1732 }
1733
1734 #[test]
1735 fn module_scope_classifies_definitions_and_assignments() {
1736 let scope = scope_of(
1737 "def run():\n pass\n\
1738 async def poll():\n pass\n\
1739 class Client:\n pass\n\
1740 LIMITS = [1, 2]\n\
1741 OFFSET = -1\n\
1742 registry = {\"on\": True}\n\
1743 PAIR: tuple = (1, 2)\n\
1744 transport = build()\n\
1745 alias = registry\n\
1746 a, b = make()\n",
1747 );
1748 for name in [
1749 "run", "poll", "Client", "LIMITS", "OFFSET", "registry", "PAIR",
1750 ] {
1751 assert_eq!(scope.bindings.get(name), Some(&Binding::Defined), "{name}");
1752 }
1753 for name in ["transport", "alias", "a", "b"] {
1754 assert_eq!(scope.bindings.get(name), Some(&Binding::Opaque), "{name}");
1755 }
1756 }
1757
1758 #[test]
1759 fn module_scope_marks_conflicts_opaque_and_dedupes_repeats() {
1760 let scope = scope_of("import asyncio\nimport asyncio\nfrom . import helper\nhelper = 1\n");
1761 assert_eq!(
1762 scope.bindings.get("asyncio"),
1763 Some(&Binding::Module("asyncio".to_string()))
1764 );
1765 assert_eq!(scope.bindings.get("helper"), Some(&Binding::Opaque));
1766 }
1767
1768 #[test]
1769 fn module_scope_flags_star_imports_and_rejects_deep_relatives() {
1770 let scope = scope_of("from os import *\nfrom .. import escape\n");
1771 assert!(scope.has_star_import);
1772 assert_eq!(scope.bindings.get("escape"), Some(&Binding::Opaque));
1773 }
1774
1775 #[test]
1776 fn module_scope_rejects_unparsable_source() {
1777 assert!(module_scope("def (\n", &[]).is_none());
1778 }
1779
1780 #[test]
1781 fn module_scope_binds_list_unpack_targets_as_opaque() {
1782 let scope = scope_of("[c, d] = make()\n");
1783 assert_eq!(scope.bindings.get("c"), Some(&Binding::Opaque));
1784 assert_eq!(scope.bindings.get("d"), Some(&Binding::Opaque));
1785 }
1786
1787 #[test]
1788 fn module_scope_skips_an_attribute_target() {
1789 let scope = scope_of("obj.attr = 1\n");
1790 assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1791 }
1792
1793 #[test]
1794 fn module_scope_ignores_non_binding_statements() {
1795 let scope = scope_of("print(1)\n");
1796 assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1797 }
1798
1799 #[test]
1800 fn module_scope_treats_a_set_literal_as_defined() {
1801 let scope = scope_of("NAMES = {1, 2}\n");
1802 assert_eq!(scope.bindings.get("NAMES"), Some(&Binding::Defined));
1803 }
1804
1805 fn collect_bindings(src: &str) -> (HashMap<String, String>, HashSet<String>) {
1807 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1808 let mut visitor = LintVisitor {
1809 file: Path::new("t.py"),
1810 source: src,
1811 fixture_depth: 0,
1812 first_party: None,
1813 source_root: None,
1814 imports: HashMap::new(),
1815 declared_modules: HashSet::new(),
1816 violations: Vec::new(),
1817 };
1818 for stmt in suite {
1819 visitor.visit_stmt(stmt);
1820 }
1821 (visitor.imports, visitor.declared_modules)
1822 }
1823
1824 fn collect_imports(src: &str) -> HashMap<String, String> {
1825 collect_bindings(src).0
1826 }
1827
1828 #[test]
1829 fn lint_visitor_binds_imports_to_their_modules() {
1830 let imports = collect_imports(
1831 "import myproject.ledger\n\
1832 import myproject.config as cfg\n\
1833 from myproject import ledger\n\
1834 from myproject import charge as ch\n\
1835 from . import rel\n",
1836 );
1837 assert_eq!(
1838 imports.get("myproject").map(String::as_str),
1839 Some("myproject")
1840 );
1841 assert_eq!(
1842 imports.get("cfg").map(String::as_str),
1843 Some("myproject.config")
1844 );
1845 assert_eq!(
1846 imports.get("ledger").map(String::as_str),
1847 Some("myproject.ledger")
1848 );
1849 assert_eq!(
1850 imports.get("ch").map(String::as_str),
1851 Some("myproject.charge")
1852 );
1853 assert_eq!(imports.get("rel"), None);
1854 }
1855
1856 #[test]
1857 fn lint_visitor_declares_every_import_prefix() {
1858 let (_, declared) = collect_bindings(
1859 "import myproject.sub.ledger\n\
1860 from myproject.api import charge\n\
1861 from . import rel\n",
1862 );
1863 for module in [
1864 "myproject",
1865 "myproject.sub",
1866 "myproject.sub.ledger",
1867 "myproject.api",
1868 ] {
1869 assert!(declared.contains(module), "{module}");
1870 }
1871 assert!(!declared.contains("myproject.api.charge"));
1872 assert!(!declared.contains("rel"));
1873 }
1874
1875 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1877 ImportRecord {
1878 display: source.unwrap_or(".rel").to_string(),
1879 line: 1,
1880 is_uut: false,
1881 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1882 source: source.map(str::to_string),
1883 module: None,
1884 }
1885 }
1886
1887 fn targets(list: &[&str]) -> Vec<String> {
1888 list.iter().map(|s| (*s).to_string()).collect()
1889 }
1890
1891 #[test]
1892 fn is_mocked_requires_every_symbol_at_the_import_module() {
1893 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1894 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1896 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1897 }
1898
1899 #[test]
1900 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1901 let rec = from_import(Some("pkg.ledger"), &["record"]);
1902 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1904 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1905 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1906 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1907 }
1908
1909 #[test]
1910 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1911 let rec = from_import(None, &["record"]);
1913 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1914 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1915 }
1916
1917 #[test]
1918 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1919 let rec = ImportRecord {
1920 display: "pkg.db".to_string(),
1921 line: 1,
1922 is_uut: false,
1923 symbols: Vec::new(),
1924 source: None,
1925 module: Some("pkg.db".to_string()),
1926 };
1927 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1928 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1929 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1930 let empty = from_import(Some("pkg.mod"), &[]);
1931 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1932 }
1933
1934 #[test]
1935 fn patches_first_party_matches_head_segment() {
1936 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1937 assert!(patches_first_party("myproject", "myproject"));
1938 assert!(!patches_first_party("requests.get", "myproject"));
1939 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1940 assert!(!patches_first_party("", "myproject"));
1941 assert!(!patches_first_party(".leading", "myproject"));
1942 }
1943
1944 #[test]
1945 fn first_party_package_reads_pyproject_name() {
1946 let tree = TempDir::new();
1947 tree.write(
1948 "pyproject.toml",
1949 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1950 );
1951 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1952 }
1953
1954 #[test]
1955 fn first_party_package_is_none_without_a_project_name() {
1956 let tree = TempDir::new();
1957 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1958 tree.write(".git", "");
1959 assert_eq!(first_party_package(&tree.0), None);
1960 }
1961
1962 #[test]
1963 fn first_party_package_is_none_when_absent() {
1964 let tree = TempDir::new();
1965 assert_eq!(first_party_package(&tree.0), None);
1966 }
1967
1968 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1970 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1971 let mut visitor = UnitIsolationVisitor {
1972 source,
1973 first_party,
1974 base,
1975 type_checking_depth: 0,
1976 imports: Vec::new(),
1977 patch_targets: Vec::new(),
1978 };
1979 for stmt in suite {
1980 visitor.visit_stmt(stmt);
1981 }
1982 visitor
1983 .imports
1984 .iter()
1985 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1986 .map(|i| i.display.clone())
1987 .collect()
1988 }
1989
1990 #[test]
1991 fn import_head_and_last_segment() {
1992 assert_eq!(import_head("myproject.db.conn"), "myproject");
1993 assert_eq!(import_head("requests"), "requests");
1994 assert_eq!(last_segment("myproject.db.conn"), "conn");
1995 assert_eq!(last_segment("widget"), "widget");
1996 }
1997
1998 #[test]
1999 fn unit_under_test_base_strips_test_suffix() {
2000 assert_eq!(
2001 unit_under_test_base(Path::new("pkg/widget_test.py")),
2002 "widget"
2003 );
2004 assert_eq!(
2006 unit_under_test_base(Path::new("test_widget.py")),
2007 "test_widget"
2008 );
2009 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
2010 }
2011
2012 #[test]
2013 fn recognizes_python_unit_test_files() {
2014 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
2015 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
2016 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
2017 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
2018 assert!(!is_python_unit_test_file(Path::new("widget.py")));
2019 }
2020
2021 #[test]
2022 fn visitor_flags_first_party_and_external_collaborators() {
2023 let found = unmocked(
2025 "widget",
2026 "myproject",
2027 "from myproject.widget import build\n\
2028 from myproject.ledger import record\n\
2029 import requests\n",
2030 );
2031 assert_eq!(
2032 found,
2033 vec!["myproject.ledger".to_string(), "requests".to_string()]
2034 );
2035 }
2036
2037 #[test]
2038 fn visitor_clears_a_mocked_collaborator() {
2039 let found = unmocked(
2040 "widget",
2041 "myproject",
2042 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
2043 );
2044 assert!(found.is_empty(), "got: {found:?}");
2045 }
2046
2047 #[test]
2048 fn visitor_flags_a_wrong_module_patch() {
2049 let found = unmocked(
2052 "widget",
2053 "myproject",
2054 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
2055 );
2056 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2057 }
2058
2059 #[test]
2060 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
2061 let found = unmocked(
2063 "widget",
2064 "myproject",
2065 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
2066 );
2067 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2068 let both = unmocked(
2069 "widget",
2070 "myproject",
2071 "from myproject.ledger import record, erase\n\
2072 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
2073 );
2074 assert!(both.is_empty(), "got: {both:?}");
2075 }
2076
2077 #[test]
2078 fn visitor_handles_module_and_relative_imports() {
2079 assert_eq!(
2080 unmocked("widget", "myproject", "import myproject.db\n"),
2081 vec!["myproject.db".to_string()]
2082 );
2083 assert!(unmocked(
2084 "widget",
2085 "myproject",
2086 "import myproject.db\npatch(\"myproject.db.connect\")\n"
2087 )
2088 .is_empty());
2089 assert_eq!(
2090 unmocked("widget", "myproject", "from .ledger import record\n"),
2091 vec![".ledger".to_string()]
2092 );
2093 assert_eq!(
2094 unmocked(
2095 "widget",
2096 "myproject",
2097 "from . import ledger\nfrom . import widget\n"
2098 ),
2099 vec![".ledger".to_string()]
2100 );
2101 }
2102
2103 #[test]
2104 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
2105 assert!(unmocked(
2107 "__init__",
2108 "myproject",
2109 "from . import Thing, __all__, __version__\n"
2110 )
2111 .is_empty());
2112 assert_eq!(
2114 unmocked("__init__", "myproject", "from .core import Thing\n"),
2115 vec![".core".to_string()]
2116 );
2117 assert_eq!(
2119 unmocked("__init__", "myproject", "from .. import sibling\n"),
2120 vec!["..sibling".to_string()]
2121 );
2122 assert_eq!(
2124 unmocked("widget", "myproject", "from . import ledger\n"),
2125 vec![".ledger".to_string()]
2126 );
2127 }
2128
2129 #[test]
2130 fn visitor_skips_type_checking_imports() {
2131 let found = unmocked(
2133 "widget",
2134 "myproject",
2135 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
2136 );
2137 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2138 }
2139
2140 #[test]
2141 fn is_checked_import_classifies_origins() {
2142 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
2145 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
2147 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
2149 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
2151 assert!(!is_checked_import("os", "myproject"));
2153 assert!(!is_checked_import("pathlib", "myproject"));
2154 assert!(!is_checked_import("datetime", "myproject"));
2155 }
2156
2157 #[test]
2158 fn is_checked_import_classifies_private_stdlib_as_stdlib() {
2159 assert!(!is_checked_import("__future__", "myproject"));
2160 assert!(!is_checked_import("_thread", "myproject"));
2161 assert!(!is_checked_import("_socket", "myproject"));
2162 assert!(!is_checked_import("_ast", "myproject"));
2163 assert!(!is_checked_import("_collections_abc", "myproject"));
2164 assert!(is_checked_import("_stripe", "myproject")); }
2166
2167 #[test]
2168 fn visitor_flags_external_collaborators() {
2169 let found = unmocked(
2170 "widget",
2171 "myproject",
2172 "import requests\nimport subprocess\nimport json\nimport pytest\n",
2173 );
2174 assert_eq!(found.len(), 2, "got: {found:?}");
2175 assert!(found.contains(&"requests".to_string()));
2176 assert!(found.contains(&"subprocess".to_string()));
2177 }
2178
2179 #[test]
2180 fn visitor_type_checking_variants_and_plain_if() {
2181 assert!(unmocked(
2183 "widget",
2184 "myproject",
2185 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
2186 )
2187 .is_empty());
2188 assert_eq!(
2190 unmocked(
2191 "widget",
2192 "myproject",
2193 "if ready == 1:\n from myproject.ledger import record\n"
2194 ),
2195 vec!["myproject.ledger".to_string()]
2196 );
2197 }
2198
2199 #[test]
2200 fn find_unit_isolation_without_pyproject_reports_nothing() {
2201 let tree = TempDir::new();
2202 tree.write("widget_test.py", "from myproject.ledger import record\n");
2203 tree.write(".git", "");
2204 assert!(find_unit_isolation_violations(&tree.0)
2205 .expect("a readable tree should succeed")
2206 .is_empty());
2207 }
2208
2209 #[test]
2210 fn find_unit_isolation_walks_subdirs_and_flags() {
2211 let tree = TempDir::new();
2212 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2213 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
2214 let found =
2215 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
2216 assert_eq!(found.len(), 1, "got: {found:?}");
2217 assert_eq!(found[0].rule, "unmocked-collaborator");
2218 assert!(found[0].message.contains("myproject.ledger"));
2219 }
2220
2221 #[test]
2222 fn recognizes_python_test_files() {
2223 assert!(is_python_test_file(Path::new("widget_test.py")));
2224 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
2225 assert!(is_python_test_file(Path::new("conftest.py")));
2226 assert!(!is_python_test_file(Path::new("test_widget.py")));
2227 }
2228
2229 #[test]
2230 fn ignores_non_test_files() {
2231 assert!(!is_python_test_file(Path::new("widget.py")));
2232 assert!(!is_python_test_file(Path::new("conftest.pyi")));
2233 assert!(!is_python_test_file(Path::new("README.md")));
2234 assert!(!is_python_test_file(Path::new("testing.py")));
2235 }
2236
2237 #[test]
2238 fn line_of_counts_newlines() {
2239 let src = "a\nb\nc\n";
2240 assert_eq!(line_of(src, TextSize::from(0)), 1);
2241 assert_eq!(line_of(src, TextSize::from(2)), 2);
2242 assert_eq!(line_of(src, TextSize::from(4)), 3);
2243 }
2244
2245 #[test]
2246 fn recognizes_environ_mutators() {
2247 assert!(is_environ_mutator("update"));
2248 assert!(is_environ_mutator("pop"));
2249 assert!(is_environ_mutator("clear"));
2250 assert!(!is_environ_mutator("get"));
2251 assert!(!is_environ_mutator("keys"));
2252 }
2253
2254 fn lint_rules(source: &str) -> Vec<&'static str> {
2256 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
2257 let mut visitor = LintVisitor {
2258 file: Path::new("t.py"),
2259 source,
2260 fixture_depth: 0,
2261 first_party: Some("myproject"),
2262 source_root: None,
2263 imports: HashMap::new(),
2264 declared_modules: HashSet::new(),
2265 violations: Vec::new(),
2266 };
2267 for stmt in suite {
2268 visitor.visit_stmt(stmt);
2269 }
2270 visitor.violations.iter().map(|v| v.rule).collect()
2271 }
2272
2273 #[test]
2274 fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
2275 assert!(
2276 lint_rules("@pytest.fixture\nasync def client():\n patch(\"pkg.mod.attr\")\n")
2277 .is_empty()
2278 );
2279 assert_eq!(
2280 lint_rules("async def widget_test():\n patch(\"pkg.mod.attr\")\n"),
2281 vec!["no-inline-patch"]
2282 );
2283 assert_eq!(
2284 lint_rules("async def widget_test(monkeypatch):\n pass\n"),
2285 vec!["no-monkeypatch"]
2286 );
2287 }
2288
2289 #[test]
2290 fn an_augmented_assignment_to_environ_is_a_mutation() {
2291 assert_eq!(
2292 lint_rules("def widget_test():\n os.environ[\"PATH\"] += \":/x\"\n"),
2293 vec!["no-environ-mutation"]
2294 );
2295 assert!(lint_rules("def widget_test():\n total += 1\n").is_empty());
2296 }
2297
2298 #[test]
2299 fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
2300 assert!(
2301 lint_rules("@fixture\ndef client():\n patch(\"pkg.mod.attr\")\n").is_empty(),
2302 "a bare `@fixture` shelters the patch"
2303 );
2304 assert_eq!(
2305 lint_rules("@registry[\"fixture\"]\ndef client():\n patch(\"pkg.mod.attr\")\n"),
2306 vec!["no-inline-patch"],
2307 "a subscripted decorator is not a fixture"
2308 );
2309 }
2310
2311 #[test]
2312 fn patch_object_is_recognized_only_through_a_patch_receiver() {
2313 assert_eq!(
2314 lint_rules("def widget_test():\n mock.patch.object(svc, \"send\")\n"),
2315 vec!["no-inline-patch"]
2316 );
2317 assert!(
2318 lint_rules("def widget_test():\n helpers[0].object(svc, \"send\")\n").is_empty(),
2319 "a subscripted receiver is not `patch`"
2320 );
2321 assert!(
2322 lint_rules("def widget_test():\n helpers[0](\"pkg.mod.attr\")\n").is_empty(),
2323 "a subscripted callee is not a patch call"
2324 );
2325 }
2326
2327 #[test]
2328 fn find_suite_without_a_tests_directory_reports_nothing() {
2329 let tree = TempDir::new();
2330 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2331 assert!(find_suite_violations(&tree.0)
2332 .expect("a readable tree should succeed")
2333 .is_empty());
2334 }
2335
2336 #[test]
2337 fn find_unit_isolation_without_a_project_name_reports_nothing() {
2338 let tree = TempDir::new();
2339 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
2340 tree.write("widget_test.py", "from myproject.ledger import record\n");
2341 assert!(find_unit_isolation_violations(&tree.0)
2342 .expect("a readable tree should succeed")
2343 .is_empty());
2344 }
2345
2346 #[test]
2347 fn recognizes_upper_constants() {
2348 assert!(is_upper_constant("CACHE_DIR"));
2349 assert!(is_upper_constant("DEBUG"));
2350 assert!(is_upper_constant("MAX_2"));
2351 assert!(!is_upper_constant("cache_dir"));
2352 assert!(!is_upper_constant("CacheDir"));
2353 assert!(!is_upper_constant("fetch"));
2354 assert!(!is_upper_constant(""));
2355 assert!(!is_upper_constant("_"));
2356 assert!(!is_upper_constant("123"));
2357 }
2358
2359 #[test]
2360 fn an_unreadable_test_file_names_the_file() {
2361 let tree = TempDir::new();
2362 std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2363 let err = find_violations(&tree.0).unwrap_err();
2364 assert!(
2365 format!("{err:#}").contains("reading test file"),
2366 "got: {err:#}"
2367 );
2368 }
2369
2370 #[test]
2371 fn an_unparsable_test_file_names_the_file() {
2372 let tree = TempDir::new();
2373 tree.write("widget_test.py", "def broken(:\n");
2374 let err = find_violations(&tree.0).unwrap_err();
2375 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2376 }
2377
2378 #[test]
2379 fn a_missing_root_is_an_error() {
2380 let err = find_violations(Path::new("/nonexistent-tc-lint")).unwrap_err();
2381 assert!(
2382 format!("{err:#}").contains("reading directory"),
2383 "got: {err:#}"
2384 );
2385 }
2386
2387 fn unit_isolation_tree() -> TempDir {
2388 let tree = TempDir::new();
2389 tree.write(
2390 "pyproject.toml",
2391 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
2392 );
2393 tree
2394 }
2395
2396 #[test]
2397 fn an_unreadable_unit_test_file_names_the_file() {
2398 let tree = unit_isolation_tree();
2399 std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2400 let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2401 assert!(
2402 format!("{err:#}").contains("reading test file"),
2403 "got: {err:#}"
2404 );
2405 }
2406
2407 #[test]
2408 fn an_unparsable_unit_test_file_names_the_file() {
2409 let tree = unit_isolation_tree();
2410 tree.write("widget_test.py", "def broken(:\n");
2411 let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2412 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2413 }
2414
2415 #[test]
2416 fn the_unit_under_test_import_is_never_a_collaborator() {
2417 let tree = unit_isolation_tree();
2418 tree.write("widget_test.py", "from myproject.widget import build\n");
2419 let violations = find_unit_isolation_violations(&tree.0).unwrap();
2420 assert!(violations.is_empty(), "got {violations:?}");
2421 }
2422
2423 #[test]
2424 fn a_starred_monkeypatch_parameter_is_flagged() {
2425 let tree = TempDir::new();
2426 tree.write(
2427 "widget_test.py",
2428 "def test_widget(*monkeypatch):\n pass\n",
2429 );
2430 let violations = find_violations(&tree.0).unwrap();
2431 assert_eq!(violations.len(), 1, "got {violations:?}");
2432 assert_eq!(violations[0].rule, "no-monkeypatch");
2433 }
2434
2435 #[test]
2436 fn a_double_starred_monkeypatch_parameter_is_flagged() {
2437 let tree = TempDir::new();
2438 tree.write(
2439 "widget_test.py",
2440 "def test_widget(**monkeypatch):\n pass\n",
2441 );
2442 let violations = find_violations(&tree.0).unwrap();
2443 assert_eq!(violations.len(), 1, "got {violations:?}");
2444 assert_eq!(violations[0].rule, "no-monkeypatch");
2445 }
2446
2447 fn parse_expr(src: &str) -> Expr {
2449 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
2450 let stmt = suite.into_iter().next().expect("one statement");
2451 *stmt.expect_expr_stmt().value
2452 }
2453
2454 #[test]
2455 fn a_called_fixture_decorator_is_recognized() {
2456 assert!(is_fixture_decorator(&parse_expr("pytest.fixture()\n")));
2457 assert!(is_fixture_decorator(&parse_expr("fixture()\n")));
2458 assert!(!is_fixture_decorator(&parse_expr("staticmethod()\n")));
2459 }
2460
2461 #[test]
2462 fn object_target_walks_declared_submodules() {
2463 let imports = HashMap::from([("myproject".to_string(), "myproject".to_string())]);
2464 let declared = HashSet::from(["myproject.sub".to_string()]);
2465 let ctx = naive_ctx(&imports, &declared);
2466 let call = parse_call("patch.object(myproject.sub.helper, \"run\")\n");
2467 assert_eq!(
2468 patch_target(&call, &ctx).as_deref(),
2469 Some("myproject.sub.helper.run")
2470 );
2471 }
2472
2473 #[test]
2474 fn a_stdlib_from_import_is_not_a_collaborator() {
2475 let found = unmocked("widget", "myproject", "from os import path\n");
2476 assert!(found.is_empty(), "got {found:?}");
2477 }
2478}