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
1165fn module_scope(source: &str, package: &[String]) -> Option<ModuleScope> {
1168 let suite = ast::Suite::parse(source, "module.py").ok()?;
1169 let mut scope = ModuleScope {
1170 bindings: HashMap::new(),
1171 has_star_import: false,
1172 };
1173 for stmt in &suite {
1174 match stmt {
1175 ast::Stmt::Import(node) => {
1176 for alias in &node.names {
1177 match &alias.asname {
1178 Some(asname) => {
1179 scope.bind(asname.to_string(), Binding::Module(alias.name.to_string()));
1180 }
1181 None => {
1182 let head = import_head(alias.name.as_str());
1183 scope.bind(head.to_string(), Binding::Module(head.to_string()));
1184 }
1185 }
1186 }
1187 }
1188 ast::Stmt::ImportFrom(node) => scope.bind_import_from(node, package),
1189 ast::Stmt::FunctionDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1190 ast::Stmt::AsyncFunctionDef(node) => {
1191 scope.bind(node.name.to_string(), Binding::Defined);
1192 }
1193 ast::Stmt::ClassDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1194 ast::Stmt::Assign(node) => {
1195 let literal = is_literal(&node.value);
1196 for target in &node.targets {
1197 scope.bind_assign_target(target, literal);
1198 }
1199 }
1200 ast::Stmt::AnnAssign(node) => {
1201 if let Some(value) = &node.value {
1202 scope.bind_assign_target(&node.target, is_literal(value));
1203 }
1204 }
1205 _ => {}
1206 }
1207 }
1208 Some(scope)
1209}
1210
1211fn is_literal(expr: &Expr) -> bool {
1213 match expr {
1214 Expr::Constant(_) => true,
1215 Expr::UnaryOp(op) => is_literal(&op.operand),
1216 Expr::Dict(dict) => {
1217 dict.keys.iter().flatten().all(is_literal) && dict.values.iter().all(is_literal)
1218 }
1219 Expr::List(list) => list.elts.iter().all(is_literal),
1220 Expr::Tuple(tuple) => tuple.elts.iter().all(is_literal),
1221 Expr::Set(set) => set.elts.iter().all(is_literal),
1222 _ => false,
1223 }
1224}
1225
1226fn patch_target(call: &ExprCall, ctx: &ResolveCtx) -> Option<String> {
1230 match patch_form(call)? {
1231 PatchForm::Target => patch_string_target(call).map(str::to_owned),
1232 PatchForm::Dict => patch_string_target(call)
1233 .map(str::to_owned)
1234 .or_else(|| resolve_object_target(call.args.first()?, ctx)),
1235 PatchForm::Object => {
1236 let base = resolve_object_target(call.args.first()?, ctx)?;
1237 Some(match string_arg(call, 1) {
1238 Some(attr) => format!("{base}.{attr}"),
1239 None => base,
1240 })
1241 }
1242 }
1243}
1244
1245fn patches_constant(target: &str) -> bool {
1247 target.rsplit('.').next().is_some_and(is_upper_constant)
1248}
1249
1250fn patches_first_party(target: &str, pkg: &str) -> bool {
1252 target
1253 .split('.')
1254 .next()
1255 .is_some_and(|head| !head.is_empty() && head == pkg)
1256}
1257
1258fn is_upper_constant(name: &str) -> bool {
1260 !name.is_empty()
1261 && name
1262 .chars()
1263 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1264 && name.chars().any(|c| c.is_ascii_uppercase())
1265}
1266
1267const ENVIRON_MUTATION_MSG: &str =
1268 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1269
1270fn is_os_environ(expr: &Expr) -> bool {
1272 matches!(
1273 expr,
1274 Expr::Attribute(attr)
1275 if attr.attr.as_str() == "environ"
1276 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1277 )
1278}
1279
1280fn is_os_environ_subscript(expr: &Expr) -> bool {
1282 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1283}
1284
1285fn is_environ_mutation_call(call: &ExprCall) -> bool {
1287 matches!(
1288 call.func.as_ref(),
1289 Expr::Attribute(attr)
1290 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1291 )
1292}
1293
1294fn is_environ_mutator(method: &str) -> bool {
1296 matches!(
1297 method,
1298 "update" | "pop" | "setdefault" | "clear" | "popitem"
1299 )
1300}
1301
1302fn line_of(source: &str, offset: TextSize) -> usize {
1304 let offset = (u32::from(offset) as usize).min(source.len());
1305 source.as_bytes()[..offset]
1306 .iter()
1307 .filter(|&&byte| byte == b'\n')
1308 .count()
1309 + 1
1310}
1311
1312fn first_party_package(root: &Path) -> Option<String> {
1316 first_party_manifest(root).map(|(name, _)| name)
1317}
1318
1319fn first_party_manifest(root: &Path) -> Option<(String, PathBuf)> {
1321 for dir in root.ancestors() {
1322 let candidate = dir.join("pyproject.toml");
1323 if candidate.is_file() {
1324 return read_project_name(&candidate)
1325 .map(|name| (normalize_dist_name(&name), dir.to_path_buf()));
1326 }
1327 if dir.join(".git").exists() {
1328 break;
1329 }
1330 }
1331 None
1332}
1333
1334fn read_project_name(path: &Path) -> Option<String> {
1336 let contents = std::fs::read_to_string(path).ok()?;
1337 let value: toml::Value = toml::from_str(&contents).ok()?;
1338 value
1339 .get("project")?
1340 .get("name")?
1341 .as_str()
1342 .map(str::to_owned)
1343}
1344
1345fn normalize_dist_name(name: &str) -> String {
1348 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1349}
1350
1351fn collect_python_files(
1352 dir: &Path,
1353 out: &mut Vec<PathBuf>,
1354 is_match: fn(&Path) -> bool,
1355) -> Result<()> {
1356 let entries =
1357 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1358 for entry in entries {
1359 let path = crate::walk::dir_entry(entry, dir)?.path();
1360 if path.is_dir() {
1361 collect_python_files(&path, out, is_match)?;
1362 } else if is_match(&path) {
1363 out.push(path);
1364 }
1365 }
1366 Ok(())
1367}
1368
1369fn is_python_test_file(path: &Path) -> bool {
1372 let name = path
1373 .file_name()
1374 .and_then(|n| n.to_str())
1375 .unwrap_or_default();
1376 name == "conftest.py" || name.ends_with("_test.py")
1377}
1378
1379fn is_python_unit_test_file(path: &Path) -> bool {
1382 let name = path
1383 .file_name()
1384 .and_then(|n| n.to_str())
1385 .unwrap_or_default();
1386 name.ends_with("_test.py")
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::*;
1392 use std::sync::atomic::{AtomicU64, Ordering};
1393
1394 struct TempDir(PathBuf);
1396
1397 impl TempDir {
1398 fn new() -> Self {
1399 static COUNTER: AtomicU64 = AtomicU64::new(0);
1400 let dir = std::env::temp_dir().join(format!(
1401 "tc-lint-{}-{}",
1402 std::process::id(),
1403 COUNTER.fetch_add(1, Ordering::Relaxed),
1404 ));
1405 std::fs::create_dir_all(&dir).unwrap();
1406 TempDir(dir)
1407 }
1408
1409 fn write(&self, name: &str, contents: &str) {
1410 let path = self.0.join(name);
1411 if let Some(parent) = path.parent() {
1412 std::fs::create_dir_all(parent).unwrap();
1413 }
1414 std::fs::write(path, contents).unwrap();
1415 }
1416 }
1417
1418 impl Drop for TempDir {
1419 fn drop(&mut self) {
1420 let _ = std::fs::remove_dir_all(&self.0);
1421 }
1422 }
1423
1424 #[test]
1425 fn normalize_dist_name_maps_to_import_name() {
1426 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1427 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1428 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1429 assert_eq!(normalize_dist_name("myproject"), "myproject");
1430 }
1431
1432 fn parse_call(src: &str) -> ExprCall {
1434 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1435 let stmt = suite.into_iter().next().expect("one statement");
1436 (*stmt.expect_expr_stmt().value).expect_call_expr()
1437 }
1438
1439 fn naive_ctx<'a>(
1441 imports: &'a HashMap<String, String>,
1442 declared: &'a HashSet<String>,
1443 ) -> ResolveCtx<'a> {
1444 ResolveCtx {
1445 imports,
1446 declared_modules: declared,
1447 first_party: None,
1448 source_root: None,
1449 }
1450 }
1451
1452 #[test]
1453 fn patch_target_only_reads_string_literals_for_the_string_form() {
1454 let imports = HashMap::new();
1455 let declared = HashSet::new();
1456 let ctx = naive_ctx(&imports, &declared);
1457 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1458 assert_eq!(
1459 patch_target(&str_call, &ctx).as_deref(),
1460 Some("pkg.mod.attr")
1461 );
1462 let name_call = parse_call("patch(target)\n");
1464 assert_eq!(patch_target(&name_call, &ctx), None);
1465 let int_call = parse_call("patch(42)\n");
1466 assert_eq!(patch_target(&int_call, &ctx), None);
1467 let empty_call = parse_call("patch()\n");
1468 assert_eq!(patch_target(&empty_call, &ctx), None);
1469 }
1470
1471 fn object_form_imports() -> HashMap<String, String> {
1473 HashMap::from([
1474 ("ledger".to_string(), "myproject.ledger".to_string()),
1475 ("myproject".to_string(), "myproject".to_string()),
1476 ("cfg".to_string(), "myproject.cfg".to_string()),
1477 ])
1478 }
1479
1480 #[test]
1481 fn patch_target_resolves_object_forms_through_imports() {
1482 let imports = object_form_imports();
1483 let declared = HashSet::new();
1484 let ctx = naive_ctx(&imports, &declared);
1485 let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1486 assert_eq!(
1487 patch_target(&imported_name, &ctx).as_deref(),
1488 Some("myproject.ledger.record")
1489 );
1490 let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1491 assert_eq!(
1492 patch_target(&dotted_module, &ctx).as_deref(),
1493 Some("myproject.ledger.record")
1494 );
1495 let name_attr = parse_call("patch.object(ledger, attr)\n");
1497 assert_eq!(
1498 patch_target(&name_attr, &ctx).as_deref(),
1499 Some("myproject.ledger")
1500 );
1501 let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1502 assert_eq!(
1503 patch_target(&dict_object, &ctx).as_deref(),
1504 Some("myproject.cfg.SETTINGS")
1505 );
1506 let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1507 assert_eq!(
1508 patch_target(&dict_string, &ctx).as_deref(),
1509 Some("pkg.cfg.FLAGS")
1510 );
1511 }
1512
1513 #[test]
1514 fn patch_target_declines_a_base_bound_by_no_import() {
1515 let imports = object_form_imports();
1516 let declared = HashSet::new();
1517 let ctx = naive_ctx(&imports, &declared);
1518 let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1519 assert_eq!(patch_target(&call_base, &ctx), None);
1520 let unbound_name = parse_call("patch.object(client, \"send\")\n");
1521 assert_eq!(patch_target(&unbound_name, &ctx), None);
1522 let empty = parse_call("patch.object()\n");
1523 assert_eq!(patch_target(&empty, &ctx), None);
1524 }
1525
1526 fn object_patch_tree(base: &str, attr: &str) -> TempDir {
1529 let tree = TempDir::new();
1530 tree.write(
1531 "pyproject.toml",
1532 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1533 );
1534 tree.write(
1535 "tests/integration/spy_test.py",
1536 &format!(
1537 "from unittest.mock import patch\n\
1538 import pytest\n\
1539 from myproject import async_mod\n\
1540 @pytest.fixture\n\
1541 def spy():\n\
1542 \x20 with patch.object({base}, \"{attr}\") as s:\n\
1543 \x20 yield s\n"
1544 ),
1545 );
1546 tree
1547 }
1548
1549 fn first_party_patch_count(tree: &TempDir) -> usize {
1550 find_violations(&tree.0)
1551 .expect("walking a readable tree should succeed")
1552 .iter()
1553 .filter(|v| v.rule == "no-first-party-patch")
1554 .count()
1555 }
1556
1557 #[test]
1558 fn object_form_stdlib_reached_through_first_party_is_not_flagged() {
1559 let tree = object_patch_tree("async_mod.asyncio", "to_thread");
1560 tree.write("myproject/async_mod.py", "import asyncio\n");
1561 assert_eq!(first_party_patch_count(&tree), 0);
1562 }
1563
1564 #[test]
1565 fn object_form_unnamed_module_attribute_declines_to_fire() {
1566 let missing_source = object_patch_tree("async_mod.transport", "send");
1567 assert_eq!(first_party_patch_count(&missing_source), 0);
1568 let dynamic = object_patch_tree("async_mod.transport", "send");
1569 dynamic.write("myproject/async_mod.py", "transport = build()\n");
1570 assert_eq!(first_party_patch_count(&dynamic), 0);
1571 }
1572
1573 #[test]
1574 fn object_form_first_party_module_attribute_still_fires() {
1575 let tree = object_patch_tree("async_mod.helper", "run");
1576 tree.write("myproject/async_mod.py", "from . import helper\n");
1577 assert_eq!(first_party_patch_count(&tree), 1);
1578 }
1579
1580 #[test]
1581 fn object_form_resolves_in_a_src_layout() {
1582 let tree = object_patch_tree("async_mod.helper", "run");
1583 tree.write("src/myproject/async_mod.py", "from . import helper\n");
1584 assert_eq!(first_party_patch_count(&tree), 1);
1585 }
1586
1587 #[test]
1588 fn object_form_star_import_over_an_unbound_name_declines() {
1589 let tree = object_patch_tree("async_mod.walk", "call");
1590 tree.write("myproject/async_mod.py", "from os import *\n");
1591 assert_eq!(first_party_patch_count(&tree), 0);
1592 }
1593
1594 #[test]
1595 fn object_form_conflicting_binding_declines() {
1596 let tree = object_patch_tree("async_mod.helper", "run");
1597 tree.write(
1598 "myproject/async_mod.py",
1599 "from . import helper\nhelper = None\n",
1600 );
1601 assert_eq!(first_party_patch_count(&tree), 0);
1602 }
1603
1604 #[test]
1605 fn object_form_defined_name_mid_chain_declines() {
1606 let tree = object_patch_tree("async_mod.Client.send", "retry");
1607 tree.write("myproject/async_mod.py", "class Client:\n pass\n");
1608 assert_eq!(first_party_patch_count(&tree), 0);
1609 }
1610
1611 #[test]
1612 fn object_form_package_attribute_resolves_through_init() {
1613 let tree = TempDir::new();
1614 tree.write(
1615 "pyproject.toml",
1616 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1617 );
1618 tree.write("myproject/sub/__init__.py", "from . import leaf\n");
1619 tree.write("myproject/sub/leaf.py", "def run():\n pass\n");
1620 tree.write(
1621 "tests/integration/spy_test.py",
1622 "from unittest.mock import patch\n\
1623 import pytest\n\
1624 from myproject import sub\n\
1625 @pytest.fixture\n\
1626 def spy():\n\
1627 \x20 with patch.object(sub.leaf, \"run\") as s:\n\
1628 \x20 yield s\n",
1629 );
1630 assert_eq!(first_party_patch_count(&tree), 1);
1631 }
1632
1633 #[test]
1634 fn object_form_unbound_name_falls_back_to_the_submodule_file() {
1635 let tree = TempDir::new();
1636 tree.write(
1637 "pyproject.toml",
1638 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1639 );
1640 tree.write("myproject/sub/__init__.py", "");
1641 tree.write("myproject/sub/leaf.py", "def run():\n pass\n");
1642 tree.write(
1643 "tests/integration/spy_test.py",
1644 "from unittest.mock import patch\n\
1645 import pytest\n\
1646 from myproject import sub\n\
1647 @pytest.fixture\n\
1648 def spy():\n\
1649 \x20 with patch.object(sub.leaf, \"run\") as s:\n\
1650 \x20 yield s\n",
1651 );
1652 assert_eq!(first_party_patch_count(&tree), 1);
1653 }
1654
1655 #[test]
1656 fn resolve_appends_naively_past_a_third_party_head() {
1657 let tree = TempDir::new();
1658 let imports = HashMap::from([("requests".to_string(), "requests".to_string())]);
1659 let declared = HashSet::new();
1660 let ctx = ResolveCtx {
1661 imports: &imports,
1662 declared_modules: &declared,
1663 first_party: Some("myproject"),
1664 source_root: Some(&tree.0),
1665 };
1666 let call = parse_call("patch.object(requests.utils, \"default_headers\")\n");
1667 assert_eq!(
1668 patch_target(&call, &ctx).as_deref(),
1669 Some("requests.utils.default_headers")
1670 );
1671 }
1672
1673 #[test]
1674 fn locate_module_tries_flat_and_src_layouts() {
1675 let tree = TempDir::new();
1676 tree.write("myproject/flat.py", "");
1677 tree.write("myproject/pkg/__init__.py", "");
1678 tree.write("src/myproject/nested.py", "");
1679 let flat = locate_module(&tree.0, "myproject.flat").expect("flat module");
1680 assert_eq!(flat.path, tree.0.join("myproject/flat.py"));
1681 assert_eq!(flat.package, vec!["myproject".to_string()]);
1682 let pkg = locate_module(&tree.0, "myproject.pkg").expect("package module");
1683 assert_eq!(pkg.path, tree.0.join("myproject/pkg/__init__.py"));
1684 assert_eq!(
1685 pkg.package,
1686 vec!["myproject".to_string(), "pkg".to_string()]
1687 );
1688 let nested = locate_module(&tree.0, "myproject.nested").expect("src module");
1689 assert_eq!(nested.path, tree.0.join("src/myproject/nested.py"));
1690 assert!(locate_module(&tree.0, "myproject.absent").is_none());
1691 }
1692
1693 fn scope_of(source: &str) -> ModuleScope {
1694 module_scope(source, &["myproject".to_string()]).expect("source should parse")
1695 }
1696
1697 #[test]
1698 fn module_scope_classifies_import_bindings() {
1699 let scope = scope_of(
1700 "import asyncio\n\
1701 import myproject.util as util\n\
1702 from . import helper\n\
1703 from .sub import leaf\n\
1704 from myproject.vendor import client as vc\n",
1705 );
1706 assert_eq!(
1707 scope.bindings.get("asyncio"),
1708 Some(&Binding::Module("asyncio".to_string()))
1709 );
1710 assert_eq!(
1711 scope.bindings.get("util"),
1712 Some(&Binding::Module("myproject.util".to_string()))
1713 );
1714 assert_eq!(
1715 scope.bindings.get("helper"),
1716 Some(&Binding::Module("myproject.helper".to_string()))
1717 );
1718 assert_eq!(
1719 scope.bindings.get("leaf"),
1720 Some(&Binding::Module("myproject.sub.leaf".to_string()))
1721 );
1722 assert_eq!(
1723 scope.bindings.get("vc"),
1724 Some(&Binding::Module("myproject.vendor.client".to_string()))
1725 );
1726 }
1727
1728 #[test]
1729 fn module_scope_classifies_definitions_and_assignments() {
1730 let scope = scope_of(
1731 "def run():\n pass\n\
1732 async def poll():\n pass\n\
1733 class Client:\n pass\n\
1734 LIMITS = [1, 2]\n\
1735 OFFSET = -1\n\
1736 registry = {\"on\": True}\n\
1737 PAIR: tuple = (1, 2)\n\
1738 transport = build()\n\
1739 alias = registry\n\
1740 a, b = make()\n",
1741 );
1742 for name in [
1743 "run", "poll", "Client", "LIMITS", "OFFSET", "registry", "PAIR",
1744 ] {
1745 assert_eq!(scope.bindings.get(name), Some(&Binding::Defined), "{name}");
1746 }
1747 for name in ["transport", "alias", "a", "b"] {
1748 assert_eq!(scope.bindings.get(name), Some(&Binding::Opaque), "{name}");
1749 }
1750 }
1751
1752 #[test]
1753 fn module_scope_marks_conflicts_opaque_and_dedupes_repeats() {
1754 let scope = scope_of("import asyncio\nimport asyncio\nfrom . import helper\nhelper = 1\n");
1755 assert_eq!(
1756 scope.bindings.get("asyncio"),
1757 Some(&Binding::Module("asyncio".to_string()))
1758 );
1759 assert_eq!(scope.bindings.get("helper"), Some(&Binding::Opaque));
1760 }
1761
1762 #[test]
1763 fn module_scope_flags_star_imports_and_rejects_deep_relatives() {
1764 let scope = scope_of("from os import *\nfrom .. import escape\n");
1765 assert!(scope.has_star_import);
1766 assert_eq!(scope.bindings.get("escape"), Some(&Binding::Opaque));
1767 }
1768
1769 #[test]
1770 fn module_scope_rejects_unparsable_source() {
1771 assert!(module_scope("def (\n", &[]).is_none());
1772 }
1773
1774 #[test]
1775 fn module_scope_binds_list_unpack_targets_as_opaque() {
1776 let scope = scope_of("[c, d] = make()\n");
1777 assert_eq!(scope.bindings.get("c"), Some(&Binding::Opaque));
1778 assert_eq!(scope.bindings.get("d"), Some(&Binding::Opaque));
1779 }
1780
1781 #[test]
1782 fn module_scope_skips_an_attribute_target() {
1783 let scope = scope_of("obj.attr = 1\n");
1784 assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1785 }
1786
1787 #[test]
1788 fn module_scope_ignores_non_binding_statements() {
1789 let scope = scope_of("print(1)\n");
1790 assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1791 }
1792
1793 #[test]
1794 fn module_scope_treats_a_set_literal_as_defined() {
1795 let scope = scope_of("NAMES = {1, 2}\n");
1796 assert_eq!(scope.bindings.get("NAMES"), Some(&Binding::Defined));
1797 }
1798
1799 fn collect_bindings(src: &str) -> (HashMap<String, String>, HashSet<String>) {
1801 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1802 let mut visitor = LintVisitor {
1803 file: Path::new("t.py"),
1804 source: src,
1805 fixture_depth: 0,
1806 first_party: None,
1807 source_root: None,
1808 imports: HashMap::new(),
1809 declared_modules: HashSet::new(),
1810 violations: Vec::new(),
1811 };
1812 for stmt in suite {
1813 visitor.visit_stmt(stmt);
1814 }
1815 (visitor.imports, visitor.declared_modules)
1816 }
1817
1818 fn collect_imports(src: &str) -> HashMap<String, String> {
1819 collect_bindings(src).0
1820 }
1821
1822 #[test]
1823 fn lint_visitor_binds_imports_to_their_modules() {
1824 let imports = collect_imports(
1825 "import myproject.ledger\n\
1826 import myproject.config as cfg\n\
1827 from myproject import ledger\n\
1828 from myproject import charge as ch\n\
1829 from . import rel\n",
1830 );
1831 assert_eq!(
1832 imports.get("myproject").map(String::as_str),
1833 Some("myproject")
1834 );
1835 assert_eq!(
1836 imports.get("cfg").map(String::as_str),
1837 Some("myproject.config")
1838 );
1839 assert_eq!(
1840 imports.get("ledger").map(String::as_str),
1841 Some("myproject.ledger")
1842 );
1843 assert_eq!(
1844 imports.get("ch").map(String::as_str),
1845 Some("myproject.charge")
1846 );
1847 assert_eq!(imports.get("rel"), None);
1848 }
1849
1850 #[test]
1851 fn lint_visitor_declares_every_import_prefix() {
1852 let (_, declared) = collect_bindings(
1853 "import myproject.sub.ledger\n\
1854 from myproject.api import charge\n\
1855 from . import rel\n",
1856 );
1857 for module in [
1858 "myproject",
1859 "myproject.sub",
1860 "myproject.sub.ledger",
1861 "myproject.api",
1862 ] {
1863 assert!(declared.contains(module), "{module}");
1864 }
1865 assert!(!declared.contains("myproject.api.charge"));
1866 assert!(!declared.contains("rel"));
1867 }
1868
1869 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1871 ImportRecord {
1872 display: source.unwrap_or(".rel").to_string(),
1873 line: 1,
1874 is_uut: false,
1875 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1876 source: source.map(str::to_string),
1877 module: None,
1878 }
1879 }
1880
1881 fn targets(list: &[&str]) -> Vec<String> {
1882 list.iter().map(|s| (*s).to_string()).collect()
1883 }
1884
1885 #[test]
1886 fn is_mocked_requires_every_symbol_at_the_import_module() {
1887 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1888 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1890 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1891 }
1892
1893 #[test]
1894 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1895 let rec = from_import(Some("pkg.ledger"), &["record"]);
1896 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1898 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1899 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1900 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1901 }
1902
1903 #[test]
1904 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1905 let rec = from_import(None, &["record"]);
1907 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1908 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1909 }
1910
1911 #[test]
1912 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1913 let rec = ImportRecord {
1914 display: "pkg.db".to_string(),
1915 line: 1,
1916 is_uut: false,
1917 symbols: Vec::new(),
1918 source: None,
1919 module: Some("pkg.db".to_string()),
1920 };
1921 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1922 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1923 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1924 let empty = from_import(Some("pkg.mod"), &[]);
1925 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1926 }
1927
1928 #[test]
1929 fn patches_first_party_matches_head_segment() {
1930 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1931 assert!(patches_first_party("myproject", "myproject"));
1932 assert!(!patches_first_party("requests.get", "myproject"));
1933 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1934 assert!(!patches_first_party("", "myproject"));
1935 assert!(!patches_first_party(".leading", "myproject"));
1936 }
1937
1938 #[test]
1939 fn first_party_package_reads_pyproject_name() {
1940 let tree = TempDir::new();
1941 tree.write(
1942 "pyproject.toml",
1943 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1944 );
1945 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1946 }
1947
1948 #[test]
1949 fn first_party_package_is_none_without_a_project_name() {
1950 let tree = TempDir::new();
1951 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1952 tree.write(".git", "");
1953 assert_eq!(first_party_package(&tree.0), None);
1954 }
1955
1956 #[test]
1957 fn first_party_package_is_none_when_absent() {
1958 let tree = TempDir::new();
1959 assert_eq!(first_party_package(&tree.0), None);
1960 }
1961
1962 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1964 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1965 let mut visitor = UnitIsolationVisitor {
1966 source,
1967 first_party,
1968 base,
1969 type_checking_depth: 0,
1970 imports: Vec::new(),
1971 patch_targets: Vec::new(),
1972 };
1973 for stmt in suite {
1974 visitor.visit_stmt(stmt);
1975 }
1976 visitor
1977 .imports
1978 .iter()
1979 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1980 .map(|i| i.display.clone())
1981 .collect()
1982 }
1983
1984 #[test]
1985 fn import_head_and_last_segment() {
1986 assert_eq!(import_head("myproject.db.conn"), "myproject");
1987 assert_eq!(import_head("requests"), "requests");
1988 assert_eq!(last_segment("myproject.db.conn"), "conn");
1989 assert_eq!(last_segment("widget"), "widget");
1990 }
1991
1992 #[test]
1993 fn unit_under_test_base_strips_test_suffix() {
1994 assert_eq!(
1995 unit_under_test_base(Path::new("pkg/widget_test.py")),
1996 "widget"
1997 );
1998 assert_eq!(
2000 unit_under_test_base(Path::new("test_widget.py")),
2001 "test_widget"
2002 );
2003 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
2004 }
2005
2006 #[test]
2007 fn recognizes_python_unit_test_files() {
2008 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
2009 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
2010 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
2011 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
2012 assert!(!is_python_unit_test_file(Path::new("widget.py")));
2013 }
2014
2015 #[test]
2016 fn visitor_flags_first_party_and_external_collaborators() {
2017 let found = unmocked(
2019 "widget",
2020 "myproject",
2021 "from myproject.widget import build\n\
2022 from myproject.ledger import record\n\
2023 import requests\n",
2024 );
2025 assert_eq!(
2026 found,
2027 vec!["myproject.ledger".to_string(), "requests".to_string()]
2028 );
2029 }
2030
2031 #[test]
2032 fn visitor_clears_a_mocked_collaborator() {
2033 let found = unmocked(
2034 "widget",
2035 "myproject",
2036 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
2037 );
2038 assert!(found.is_empty(), "got: {found:?}");
2039 }
2040
2041 #[test]
2042 fn visitor_flags_a_wrong_module_patch() {
2043 let found = unmocked(
2046 "widget",
2047 "myproject",
2048 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
2049 );
2050 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2051 }
2052
2053 #[test]
2054 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
2055 let found = unmocked(
2057 "widget",
2058 "myproject",
2059 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
2060 );
2061 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2062 let both = unmocked(
2063 "widget",
2064 "myproject",
2065 "from myproject.ledger import record, erase\n\
2066 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
2067 );
2068 assert!(both.is_empty(), "got: {both:?}");
2069 }
2070
2071 #[test]
2072 fn visitor_handles_module_and_relative_imports() {
2073 assert_eq!(
2074 unmocked("widget", "myproject", "import myproject.db\n"),
2075 vec!["myproject.db".to_string()]
2076 );
2077 assert!(unmocked(
2078 "widget",
2079 "myproject",
2080 "import myproject.db\npatch(\"myproject.db.connect\")\n"
2081 )
2082 .is_empty());
2083 assert_eq!(
2084 unmocked("widget", "myproject", "from .ledger import record\n"),
2085 vec![".ledger".to_string()]
2086 );
2087 assert_eq!(
2088 unmocked(
2089 "widget",
2090 "myproject",
2091 "from . import ledger\nfrom . import widget\n"
2092 ),
2093 vec![".ledger".to_string()]
2094 );
2095 }
2096
2097 #[test]
2098 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
2099 assert!(unmocked(
2101 "__init__",
2102 "myproject",
2103 "from . import Thing, __all__, __version__\n"
2104 )
2105 .is_empty());
2106 assert_eq!(
2108 unmocked("__init__", "myproject", "from .core import Thing\n"),
2109 vec![".core".to_string()]
2110 );
2111 assert_eq!(
2113 unmocked("__init__", "myproject", "from .. import sibling\n"),
2114 vec!["..sibling".to_string()]
2115 );
2116 assert_eq!(
2118 unmocked("widget", "myproject", "from . import ledger\n"),
2119 vec![".ledger".to_string()]
2120 );
2121 }
2122
2123 #[test]
2124 fn visitor_skips_type_checking_imports() {
2125 let found = unmocked(
2127 "widget",
2128 "myproject",
2129 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
2130 );
2131 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2132 }
2133
2134 #[test]
2135 fn is_checked_import_classifies_origins() {
2136 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
2139 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
2141 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
2143 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
2145 assert!(!is_checked_import("os", "myproject"));
2147 assert!(!is_checked_import("pathlib", "myproject"));
2148 assert!(!is_checked_import("datetime", "myproject"));
2149 }
2150
2151 #[test]
2152 fn is_checked_import_classifies_private_stdlib_as_stdlib() {
2153 assert!(!is_checked_import("__future__", "myproject"));
2154 assert!(!is_checked_import("_thread", "myproject"));
2155 assert!(!is_checked_import("_socket", "myproject"));
2156 assert!(!is_checked_import("_ast", "myproject"));
2157 assert!(!is_checked_import("_collections_abc", "myproject"));
2158 assert!(is_checked_import("_stripe", "myproject")); }
2160
2161 #[test]
2162 fn visitor_flags_external_collaborators() {
2163 let found = unmocked(
2164 "widget",
2165 "myproject",
2166 "import requests\nimport subprocess\nimport json\nimport pytest\n",
2167 );
2168 assert_eq!(found.len(), 2, "got: {found:?}");
2169 assert!(found.contains(&"requests".to_string()));
2170 assert!(found.contains(&"subprocess".to_string()));
2171 }
2172
2173 #[test]
2174 fn visitor_type_checking_variants_and_plain_if() {
2175 assert!(unmocked(
2177 "widget",
2178 "myproject",
2179 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
2180 )
2181 .is_empty());
2182 assert_eq!(
2184 unmocked(
2185 "widget",
2186 "myproject",
2187 "if ready == 1:\n from myproject.ledger import record\n"
2188 ),
2189 vec!["myproject.ledger".to_string()]
2190 );
2191 }
2192
2193 #[test]
2194 fn find_unit_isolation_without_pyproject_reports_nothing() {
2195 let tree = TempDir::new();
2196 tree.write("widget_test.py", "from myproject.ledger import record\n");
2197 tree.write(".git", "");
2198 assert!(find_unit_isolation_violations(&tree.0)
2199 .expect("a readable tree should succeed")
2200 .is_empty());
2201 }
2202
2203 #[test]
2204 fn find_unit_isolation_walks_subdirs_and_flags() {
2205 let tree = TempDir::new();
2206 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2207 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
2208 let found =
2209 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
2210 assert_eq!(found.len(), 1, "got: {found:?}");
2211 assert_eq!(found[0].rule, "unmocked-collaborator");
2212 assert!(found[0].message.contains("myproject.ledger"));
2213 }
2214
2215 #[test]
2216 fn recognizes_python_test_files() {
2217 assert!(is_python_test_file(Path::new("widget_test.py")));
2218 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
2219 assert!(is_python_test_file(Path::new("conftest.py")));
2220 assert!(!is_python_test_file(Path::new("test_widget.py")));
2221 }
2222
2223 #[test]
2224 fn ignores_non_test_files() {
2225 assert!(!is_python_test_file(Path::new("widget.py")));
2226 assert!(!is_python_test_file(Path::new("conftest.pyi")));
2227 assert!(!is_python_test_file(Path::new("README.md")));
2228 assert!(!is_python_test_file(Path::new("testing.py")));
2229 }
2230
2231 #[test]
2232 fn line_of_counts_newlines() {
2233 let src = "a\nb\nc\n";
2234 assert_eq!(line_of(src, TextSize::from(0)), 1);
2235 assert_eq!(line_of(src, TextSize::from(2)), 2);
2236 assert_eq!(line_of(src, TextSize::from(4)), 3);
2237 }
2238
2239 #[test]
2240 fn recognizes_environ_mutators() {
2241 assert!(is_environ_mutator("update"));
2242 assert!(is_environ_mutator("pop"));
2243 assert!(is_environ_mutator("clear"));
2244 assert!(!is_environ_mutator("get"));
2245 assert!(!is_environ_mutator("keys"));
2246 }
2247
2248 fn lint_rules(source: &str) -> Vec<&'static str> {
2250 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
2251 let mut visitor = LintVisitor {
2252 file: Path::new("t.py"),
2253 source,
2254 fixture_depth: 0,
2255 first_party: Some("myproject"),
2256 source_root: None,
2257 imports: HashMap::new(),
2258 declared_modules: HashSet::new(),
2259 violations: Vec::new(),
2260 };
2261 for stmt in suite {
2262 visitor.visit_stmt(stmt);
2263 }
2264 visitor.violations.iter().map(|v| v.rule).collect()
2265 }
2266
2267 #[test]
2268 fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
2269 assert!(
2270 lint_rules("@pytest.fixture\nasync def client():\n patch(\"pkg.mod.attr\")\n")
2271 .is_empty()
2272 );
2273 assert_eq!(
2274 lint_rules("async def widget_test():\n patch(\"pkg.mod.attr\")\n"),
2275 vec!["no-inline-patch"]
2276 );
2277 assert_eq!(
2278 lint_rules("async def widget_test(monkeypatch):\n pass\n"),
2279 vec!["no-monkeypatch"]
2280 );
2281 }
2282
2283 #[test]
2284 fn an_augmented_assignment_to_environ_is_a_mutation() {
2285 assert_eq!(
2286 lint_rules("def widget_test():\n os.environ[\"PATH\"] += \":/x\"\n"),
2287 vec!["no-environ-mutation"]
2288 );
2289 assert!(lint_rules("def widget_test():\n total += 1\n").is_empty());
2290 }
2291
2292 #[test]
2293 fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
2294 assert!(
2295 lint_rules("@fixture\ndef client():\n patch(\"pkg.mod.attr\")\n").is_empty(),
2296 "a bare `@fixture` shelters the patch"
2297 );
2298 assert_eq!(
2299 lint_rules("@registry[\"fixture\"]\ndef client():\n patch(\"pkg.mod.attr\")\n"),
2300 vec!["no-inline-patch"],
2301 "a subscripted decorator is not a fixture"
2302 );
2303 }
2304
2305 #[test]
2306 fn patch_object_is_recognized_only_through_a_patch_receiver() {
2307 assert_eq!(
2308 lint_rules("def widget_test():\n mock.patch.object(svc, \"send\")\n"),
2309 vec!["no-inline-patch"]
2310 );
2311 assert!(
2312 lint_rules("def widget_test():\n helpers[0].object(svc, \"send\")\n").is_empty(),
2313 "a subscripted receiver is not `patch`"
2314 );
2315 assert!(
2316 lint_rules("def widget_test():\n helpers[0](\"pkg.mod.attr\")\n").is_empty(),
2317 "a subscripted callee is not a patch call"
2318 );
2319 }
2320
2321 #[test]
2322 fn find_suite_without_a_tests_directory_reports_nothing() {
2323 let tree = TempDir::new();
2324 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2325 assert!(find_suite_violations(&tree.0)
2326 .expect("a readable tree should succeed")
2327 .is_empty());
2328 }
2329
2330 #[test]
2331 fn find_unit_isolation_without_a_project_name_reports_nothing() {
2332 let tree = TempDir::new();
2333 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
2334 tree.write("widget_test.py", "from myproject.ledger import record\n");
2335 assert!(find_unit_isolation_violations(&tree.0)
2336 .expect("a readable tree should succeed")
2337 .is_empty());
2338 }
2339
2340 #[test]
2341 fn recognizes_upper_constants() {
2342 assert!(is_upper_constant("CACHE_DIR"));
2343 assert!(is_upper_constant("DEBUG"));
2344 assert!(is_upper_constant("MAX_2"));
2345 assert!(!is_upper_constant("cache_dir"));
2346 assert!(!is_upper_constant("CacheDir"));
2347 assert!(!is_upper_constant("fetch"));
2348 assert!(!is_upper_constant(""));
2349 assert!(!is_upper_constant("_"));
2350 assert!(!is_upper_constant("123"));
2351 }
2352
2353 #[test]
2354 fn an_unreadable_test_file_names_the_file() {
2355 let tree = TempDir::new();
2356 std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2357 let err = find_violations(&tree.0).unwrap_err();
2358 assert!(
2359 format!("{err:#}").contains("reading test file"),
2360 "got: {err:#}"
2361 );
2362 }
2363
2364 #[test]
2365 fn an_unparsable_test_file_names_the_file() {
2366 let tree = TempDir::new();
2367 tree.write("widget_test.py", "def broken(:\n");
2368 let err = find_violations(&tree.0).unwrap_err();
2369 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2370 }
2371
2372 #[test]
2373 fn a_missing_root_is_an_error() {
2374 let err = find_violations(Path::new("/nonexistent-tc-lint")).unwrap_err();
2375 assert!(
2376 format!("{err:#}").contains("reading directory"),
2377 "got: {err:#}"
2378 );
2379 }
2380
2381 fn unit_isolation_tree() -> TempDir {
2382 let tree = TempDir::new();
2383 tree.write(
2384 "pyproject.toml",
2385 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
2386 );
2387 tree
2388 }
2389
2390 #[test]
2391 fn an_unreadable_unit_test_file_names_the_file() {
2392 let tree = unit_isolation_tree();
2393 std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2394 let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2395 assert!(
2396 format!("{err:#}").contains("reading test file"),
2397 "got: {err:#}"
2398 );
2399 }
2400
2401 #[test]
2402 fn an_unparsable_unit_test_file_names_the_file() {
2403 let tree = unit_isolation_tree();
2404 tree.write("widget_test.py", "def broken(:\n");
2405 let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2406 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2407 }
2408
2409 #[test]
2410 fn the_unit_under_test_import_is_never_a_collaborator() {
2411 let tree = unit_isolation_tree();
2412 tree.write("widget_test.py", "from myproject.widget import build\n");
2413 let violations = find_unit_isolation_violations(&tree.0).unwrap();
2414 assert!(violations.is_empty(), "got {violations:?}");
2415 }
2416
2417 #[test]
2418 fn a_starred_monkeypatch_parameter_is_flagged() {
2419 let tree = TempDir::new();
2420 tree.write(
2421 "widget_test.py",
2422 "def test_widget(*monkeypatch):\n pass\n",
2423 );
2424 let violations = find_violations(&tree.0).unwrap();
2425 assert_eq!(violations.len(), 1, "got {violations:?}");
2426 assert_eq!(violations[0].rule, "no-monkeypatch");
2427 }
2428
2429 #[test]
2430 fn a_double_starred_monkeypatch_parameter_is_flagged() {
2431 let tree = TempDir::new();
2432 tree.write(
2433 "widget_test.py",
2434 "def test_widget(**monkeypatch):\n pass\n",
2435 );
2436 let violations = find_violations(&tree.0).unwrap();
2437 assert_eq!(violations.len(), 1, "got {violations:?}");
2438 assert_eq!(violations[0].rule, "no-monkeypatch");
2439 }
2440
2441 fn parse_expr(src: &str) -> Expr {
2443 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
2444 let stmt = suite.into_iter().next().expect("one statement");
2445 *stmt.expect_expr_stmt().value
2446 }
2447
2448 #[test]
2449 fn a_called_fixture_decorator_is_recognized() {
2450 assert!(is_fixture_decorator(&parse_expr("pytest.fixture()\n")));
2451 assert!(is_fixture_decorator(&parse_expr("fixture()\n")));
2452 assert!(!is_fixture_decorator(&parse_expr("staticmethod()\n")));
2453 }
2454
2455 #[test]
2456 fn object_target_walks_declared_submodules() {
2457 let imports = HashMap::from([("myproject".to_string(), "myproject".to_string())]);
2458 let declared = HashSet::from(["myproject.sub".to_string()]);
2459 let ctx = naive_ctx(&imports, &declared);
2460 let call = parse_call("patch.object(myproject.sub.helper, \"run\")\n");
2461 assert_eq!(
2462 patch_target(&call, &ctx).as_deref(),
2463 Some("myproject.sub.helper.run")
2464 );
2465 }
2466
2467 #[test]
2468 fn a_stdlib_from_import_is_not_a_collaborator() {
2469 let found = unmocked("widget", "myproject", "from os import path\n");
2470 assert!(found.is_empty(), "got {found:?}");
2471 }
2472}