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