1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, Context, Result};
9use rustpython_ast::Visitor;
10use rustpython_parser::ast::{
11 self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
12 StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
13};
14use rustpython_parser::text_size::{TextRange, TextSize};
15use rustpython_parser::Parse;
16
17pub use crate::violation::Violation;
19
20pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
24 let root = root.as_ref();
25 let first_party = first_party_package(root);
27 let mut files = Vec::new();
28 collect_python_files(root, &mut files, is_python_test_file)?;
29 files.sort();
30
31 let mut violations = Vec::new();
32 for file in &files {
33 let source = std::fs::read_to_string(file)
34 .with_context(|| format!("reading test file `{}`", file.display()))?;
35 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
36 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
37 let mut visitor = LintVisitor {
38 file,
39 source: &source,
40 fixture_depth: 0,
41 first_party: first_party.as_deref(),
42 imports: HashMap::new(),
43 violations: Vec::new(),
44 };
45 for stmt in suite {
46 visitor.visit_stmt(stmt);
47 }
48 violations.append(&mut visitor.violations);
49 }
50
51 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
52 Ok(violations)
53}
54
55const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
56 a suite lives in `tests/integration/` or `tests/e2e/`";
57
58pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
62 let tests = package_root.join("tests");
63 let mut violations = Vec::new();
64 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
65 for tier in &tiers {
66 if tier.is_dir() {
67 violations.extend(find_violations(tier)?);
68 }
69 }
70 if tests.is_dir() {
71 let mut strays = Vec::new();
72 collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
73 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
74 for file in strays {
75 violations.push(Violation {
76 file,
77 line: 1,
78 rule: "unknown-tier",
79 message: UNKNOWN_TIER_MSG.to_string(),
80 });
81 }
82 }
83 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
84 Ok(violations)
85}
86
87pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
91 let root = root.as_ref();
92 let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") else {
95 return Ok(Vec::new());
96 };
97 let Some(first_party) = first_party_package(root) else {
98 return Ok(Vec::new());
99 };
100 let mut files = Vec::new();
101 collect_python_files(root, &mut files, is_python_unit_test_file)?;
102 files.retain(|file| !file.starts_with(&tests));
104 files.sort();
105
106 let mut violations = Vec::new();
107 for file in &files {
108 let source = std::fs::read_to_string(file)
109 .with_context(|| format!("reading test file `{}`", file.display()))?;
110 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
111 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
112 let base = unit_under_test_base(file);
113 let mut visitor = UnitIsolationVisitor {
114 source: &source,
115 first_party: &first_party,
116 base: &base,
117 type_checking_depth: 0,
118 imports: Vec::new(),
119 patch_targets: Vec::new(),
120 };
121 for stmt in suite {
122 visitor.visit_stmt(stmt);
123 }
124 for import in &visitor.imports {
125 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
126 continue;
127 }
128 violations.push(Violation {
129 file: file.to_path_buf(),
130 line: import.line,
131 rule: "unmocked-collaborator",
132 message: format!(
133 "unit test imports `{}` without mocking it — a unit test isolates the \
134 unit under test, so mock every collaborator (patch it by string in a \
135 fixture)",
136 import.display
137 ),
138 });
139 }
140 }
141
142 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
143 Ok(violations)
144}
145
146struct ImportRecord {
148 display: String,
150 line: usize,
151 is_uut: bool,
152 symbols: Vec<String>,
154 source: Option<String>,
157 module: Option<String>,
159}
160
161impl ImportRecord {
162 fn is_mocked(&self, patch_targets: &[String]) -> bool {
166 if let Some(module) = &self.module {
167 let prefix = format!("{module}.");
168 return patch_targets
169 .iter()
170 .any(|target| target == module || target.starts_with(&prefix));
171 }
172 if self.symbols.is_empty() {
173 return false;
174 }
175 self.symbols.iter().all(|symbol| {
176 patch_targets
177 .iter()
178 .any(|target| self.symbol_is_mocked(target, symbol))
179 })
180 }
181
182 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
185 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
186 return false;
187 };
188 match &self.source {
189 Some(source) => module == source,
190 None => true,
191 }
192 }
193}
194
195struct UnitIsolationVisitor<'a> {
199 source: &'a str,
200 first_party: &'a str,
201 base: &'a str,
202 type_checking_depth: usize,
203 imports: Vec<ImportRecord>,
204 patch_targets: Vec<String>,
205}
206
207impl Visitor for UnitIsolationVisitor<'_> {
208 fn visit_stmt_import(&mut self, node: StmtImport) {
209 if self.type_checking_depth == 0 {
210 let line = line_of(self.source, node.range.start());
211 for alias in &node.names {
212 let module = alias.name.as_str();
213 if is_checked_import(import_head(module), self.first_party) {
214 self.imports.push(ImportRecord {
215 display: module.to_string(),
216 line,
217 is_uut: last_segment(module) == self.base,
218 symbols: Vec::new(),
219 source: None,
220 module: Some(module.to_string()),
221 });
222 }
223 }
224 }
225 self.generic_visit_stmt_import(node);
226 }
227
228 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
229 if self.type_checking_depth == 0 {
230 let level = relative_level(&node);
231 let module = node.module.as_ref().map(|m| m.as_str());
232 let should_check = level > 0
234 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
235 if should_check {
236 let line = line_of(self.source, node.range.start());
237 let dots = ".".repeat(level);
238 match module {
239 Some(module) => self.imports.push(ImportRecord {
241 display: format!("{dots}{module}"),
242 line,
243 is_uut: last_segment(module) == self.base,
244 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
245 source: (level == 0).then(|| module.to_string()),
246 module: None,
247 }),
248 None => {
250 let barrel_sut = self.base == "__init__" && level == 1;
253 for alias in &node.names {
254 let name = alias.name.as_str();
255 self.imports.push(ImportRecord {
256 display: format!("{dots}{name}"),
257 line,
258 is_uut: barrel_sut || name == self.base,
259 symbols: vec![name.to_string()],
260 source: None,
261 module: None,
262 });
263 }
264 }
265 }
266 }
267 }
268 self.generic_visit_stmt_import_from(node);
269 }
270
271 fn visit_expr_call(&mut self, node: ExprCall) {
272 if is_patch_call(&node) {
273 if let Some(target) = patch_string_target(&node) {
274 self.patch_targets.push(target.to_string());
275 }
276 }
277 self.generic_visit_expr_call(node);
278 }
279
280 fn visit_stmt_if(&mut self, node: StmtIf) {
281 if is_type_checking(node.test.as_ref()) {
283 self.type_checking_depth += 1;
284 for stmt in node.body {
285 self.visit_stmt(stmt);
286 }
287 self.type_checking_depth -= 1;
288 for stmt in node.orelse {
289 self.visit_stmt(stmt);
290 }
291 } else {
292 self.generic_visit_stmt_if(node);
293 }
294 }
295}
296
297fn import_head(module: &str) -> &str {
299 module.split('.').next().unwrap_or(module)
300}
301
302fn is_checked_import(head: &str, first_party: &str) -> bool {
305 if head == first_party {
306 return true;
307 }
308 if TEST_FRAMEWORK.contains(&head) {
309 return false;
310 }
311 if EFFECTFUL_STDLIB.contains(&head) {
312 return true;
313 }
314 if STDLIB_MODULES.contains(&head) {
315 return false;
316 }
317 true }
319
320const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
322
323const EFFECTFUL_STDLIB: &[&str] = &[
327 "asynchat",
328 "asyncore",
329 "ctypes",
330 "curses",
331 "dbm",
332 "fcntl",
333 "ftplib",
334 "imaplib",
335 "mmap",
336 "msvcrt",
337 "multiprocessing",
338 "nis",
339 "nntplib",
340 "ossaudiodev",
341 "poplib",
342 "pty",
343 "random",
344 "secrets",
345 "select",
346 "selectors",
347 "signal",
348 "smtpd",
349 "smtplib",
350 "socket",
351 "socketserver",
352 "spwd",
353 "sqlite3",
354 "ssl",
355 "subprocess",
356 "syslog",
357 "telnetlib",
358 "termios",
359 "tty",
360 "webbrowser",
361 "winreg",
362 "winsound",
363];
364
365const STDLIB_MODULES: &[&str] = &[
368 "__future__",
369 "_abc",
370 "_aix_support",
371 "_ast",
372 "_asyncio",
373 "_bisect",
374 "_blake2",
375 "_bz2",
376 "_codecs",
377 "_codecs_cn",
378 "_codecs_hk",
379 "_codecs_iso2022",
380 "_codecs_jp",
381 "_codecs_kr",
382 "_codecs_tw",
383 "_collections",
384 "_collections_abc",
385 "_compat_pickle",
386 "_compression",
387 "_contextvars",
388 "_crypt",
389 "_csv",
390 "_ctypes",
391 "_curses",
392 "_curses_panel",
393 "_datetime",
394 "_dbm",
395 "_decimal",
396 "_elementtree",
397 "_frozen_importlib",
398 "_frozen_importlib_external",
399 "_functools",
400 "_gdbm",
401 "_hashlib",
402 "_heapq",
403 "_imp",
404 "_io",
405 "_json",
406 "_locale",
407 "_lsprof",
408 "_lzma",
409 "_markupbase",
410 "_md5",
411 "_msi",
412 "_multibytecodec",
413 "_multiprocessing",
414 "_opcode",
415 "_operator",
416 "_osx_support",
417 "_overlapped",
418 "_pickle",
419 "_posixshmem",
420 "_posixsubprocess",
421 "_py_abc",
422 "_pydatetime",
423 "_pydecimal",
424 "_pyio",
425 "_pylong",
426 "_queue",
427 "_random",
428 "_scproxy",
429 "_sha1",
430 "_sha2",
431 "_sha3",
432 "_signal",
433 "_sitebuiltins",
434 "_socket",
435 "_sqlite3",
436 "_sre",
437 "_ssl",
438 "_stat",
439 "_statistics",
440 "_string",
441 "_strptime",
442 "_struct",
443 "_symtable",
444 "_thread",
445 "_threading_local",
446 "_tkinter",
447 "_tokenize",
448 "_tracemalloc",
449 "_typing",
450 "_uuid",
451 "_warnings",
452 "_weakref",
453 "_weakrefset",
454 "_winapi",
455 "_zoneinfo",
456 "abc",
457 "aifc",
458 "antigravity",
459 "argparse",
460 "array",
461 "ast",
462 "asynchat",
463 "asyncio",
464 "asyncore",
465 "atexit",
466 "audioop",
467 "base64",
468 "bdb",
469 "binascii",
470 "bisect",
471 "builtins",
472 "bz2",
473 "cProfile",
474 "calendar",
475 "cgi",
476 "cgitb",
477 "chunk",
478 "cmath",
479 "cmd",
480 "code",
481 "codecs",
482 "codeop",
483 "collections",
484 "colorsys",
485 "compileall",
486 "concurrent",
487 "configparser",
488 "contextlib",
489 "contextvars",
490 "copy",
491 "copyreg",
492 "crypt",
493 "csv",
494 "ctypes",
495 "curses",
496 "dataclasses",
497 "datetime",
498 "dbm",
499 "decimal",
500 "difflib",
501 "dis",
502 "distutils",
503 "doctest",
504 "email",
505 "encodings",
506 "ensurepip",
507 "enum",
508 "errno",
509 "faulthandler",
510 "fcntl",
511 "filecmp",
512 "fileinput",
513 "fnmatch",
514 "fractions",
515 "ftplib",
516 "functools",
517 "gc",
518 "genericpath",
519 "getopt",
520 "getpass",
521 "gettext",
522 "glob",
523 "graphlib",
524 "grp",
525 "gzip",
526 "hashlib",
527 "heapq",
528 "hmac",
529 "html",
530 "http",
531 "idlelib",
532 "imaplib",
533 "imghdr",
534 "imp",
535 "importlib",
536 "inspect",
537 "io",
538 "ipaddress",
539 "itertools",
540 "json",
541 "keyword",
542 "lib2to3",
543 "linecache",
544 "locale",
545 "logging",
546 "lzma",
547 "mailbox",
548 "mailcap",
549 "marshal",
550 "math",
551 "mimetypes",
552 "mmap",
553 "modulefinder",
554 "msilib",
555 "msvcrt",
556 "multiprocessing",
557 "netrc",
558 "nis",
559 "nntplib",
560 "nt",
561 "ntpath",
562 "nturl2path",
563 "numbers",
564 "opcode",
565 "operator",
566 "optparse",
567 "os",
568 "ossaudiodev",
569 "pathlib",
570 "pdb",
571 "pickle",
572 "pickletools",
573 "pipes",
574 "pkgutil",
575 "platform",
576 "plistlib",
577 "poplib",
578 "posix",
579 "posixpath",
580 "pprint",
581 "profile",
582 "pstats",
583 "pty",
584 "pwd",
585 "py_compile",
586 "pyclbr",
587 "pydoc",
588 "pydoc_data",
589 "pyexpat",
590 "queue",
591 "quopri",
592 "random",
593 "re",
594 "readline",
595 "reprlib",
596 "resource",
597 "rlcompleter",
598 "runpy",
599 "sched",
600 "secrets",
601 "select",
602 "selectors",
603 "shelve",
604 "shlex",
605 "shutil",
606 "signal",
607 "site",
608 "smtpd",
609 "smtplib",
610 "sndhdr",
611 "socket",
612 "socketserver",
613 "spwd",
614 "sqlite3",
615 "sre_compile",
616 "sre_constants",
617 "sre_parse",
618 "ssl",
619 "stat",
620 "statistics",
621 "string",
622 "stringprep",
623 "struct",
624 "subprocess",
625 "sunau",
626 "symtable",
627 "sys",
628 "sysconfig",
629 "syslog",
630 "tabnanny",
631 "tarfile",
632 "telnetlib",
633 "tempfile",
634 "termios",
635 "textwrap",
636 "this",
637 "threading",
638 "time",
639 "timeit",
640 "tkinter",
641 "token",
642 "tokenize",
643 "tomllib",
644 "trace",
645 "traceback",
646 "tracemalloc",
647 "tty",
648 "turtle",
649 "turtledemo",
650 "types",
651 "typing",
652 "unicodedata",
653 "unittest",
654 "urllib",
655 "uu",
656 "uuid",
657 "venv",
658 "warnings",
659 "wave",
660 "weakref",
661 "webbrowser",
662 "winreg",
663 "winsound",
664 "wsgiref",
665 "xdrlib",
666 "xml",
667 "xmlrpc",
668 "zipapp",
669 "zipfile",
670 "zipimport",
671 "zlib",
672 "zoneinfo",
673];
674
675fn last_segment(module: &str) -> &str {
677 module.rsplit('.').next().unwrap_or(module)
678}
679
680fn relative_level(node: &StmtImportFrom) -> usize {
682 node.level.map_or(0, |level| level.to_usize())
683}
684
685fn is_type_checking(test: &Expr) -> bool {
687 match test {
688 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
689 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
690 _ => false,
691 }
692}
693
694fn unit_under_test_base(file: &Path) -> String {
697 let name = file
698 .file_name()
699 .and_then(|n| n.to_str())
700 .unwrap_or_default();
701 let stem = name.strip_suffix(".py").unwrap_or(name);
702 stem.strip_suffix("_test").unwrap_or(stem).to_string()
703}
704
705struct LintVisitor<'a> {
708 file: &'a Path,
709 source: &'a str,
710 fixture_depth: usize,
711 first_party: Option<&'a str>,
713 imports: HashMap<String, String>,
715 violations: Vec<Violation>,
716}
717
718impl LintVisitor<'_> {
719 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
720 self.violations.push(Violation {
721 file: self.file.to_path_buf(),
722 line: line_of(self.source, range.start()),
723 rule,
724 message: message.to_string(),
725 });
726 }
727
728 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
730 let takes_monkeypatch = args
731 .posonlyargs
732 .iter()
733 .chain(&args.args)
734 .chain(&args.kwonlyargs)
735 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
736 || arg_named(&args.vararg, "monkeypatch")
737 || arg_named(&args.kwarg, "monkeypatch");
738 if takes_monkeypatch {
739 self.report(
740 range,
741 "no-monkeypatch",
742 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
743 );
744 }
745
746 decorators.iter().any(is_fixture_decorator)
747 }
748}
749
750impl Visitor for LintVisitor<'_> {
751 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
752 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
753 if is_fixture {
754 self.fixture_depth += 1;
755 }
756 self.generic_visit_stmt_function_def(node);
757 if is_fixture {
758 self.fixture_depth -= 1;
759 }
760 }
761
762 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
763 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
764 if is_fixture {
765 self.fixture_depth += 1;
766 }
767 self.generic_visit_stmt_async_function_def(node);
768 if is_fixture {
769 self.fixture_depth -= 1;
770 }
771 }
772
773 fn visit_expr_call(&mut self, node: ExprCall) {
774 if is_patch_call(&node) && self.fixture_depth == 0 {
776 self.report(
777 node.range,
778 "no-inline-patch",
779 "patch is called inline in a test body; move it into a `pytest.fixture`",
780 );
781 }
782 if let Some(target) = patch_target(&node, &self.imports) {
785 if patches_constant(&target) {
786 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
787 }
788 if let Some(pkg) = self.first_party {
789 if patches_first_party(&target, pkg) {
790 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
791 }
792 }
793 }
794 if is_environ_mutation_call(&node) {
795 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
796 }
797 self.generic_visit_expr_call(node);
798 }
799
800 fn visit_stmt_import(&mut self, node: StmtImport) {
801 for alias in &node.names {
802 match &alias.asname {
803 Some(asname) => {
805 self.imports
806 .insert(asname.to_string(), alias.name.to_string());
807 }
808 None => {
809 let head = import_head(alias.name.as_str());
810 self.imports.insert(head.to_string(), head.to_string());
811 }
812 }
813 }
814 self.generic_visit_stmt_import(node);
815 }
816
817 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
818 if relative_level(&node) == 0 {
820 if let Some(module) = &node.module {
821 for alias in &node.names {
822 let bound = alias.asname.as_ref().unwrap_or(&alias.name);
823 self.imports
824 .insert(bound.to_string(), format!("{module}.{}", alias.name));
825 }
826 }
827 }
828 self.generic_visit_stmt_import_from(node);
829 }
830
831 fn visit_withitem(&mut self, node: WithItem) {
834 self.visit_expr(node.context_expr);
835 if let Some(optional_vars) = node.optional_vars {
836 self.visit_expr(*optional_vars);
837 }
838 }
839
840 fn visit_stmt_assign(&mut self, node: StmtAssign) {
841 if node.targets.iter().any(is_os_environ_subscript) {
842 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
843 }
844 self.generic_visit_stmt_assign(node);
845 }
846
847 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
848 if is_os_environ_subscript(node.target.as_ref()) {
849 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
850 }
851 self.generic_visit_stmt_aug_assign(node);
852 }
853
854 fn visit_stmt_delete(&mut self, node: StmtDelete) {
855 if node.targets.iter().any(is_os_environ_subscript) {
856 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
857 }
858 self.generic_visit_stmt_delete(node);
859 }
860}
861
862fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
864 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
865}
866
867fn is_fixture_decorator(decorator: &Expr) -> bool {
869 let target = match decorator {
870 Expr::Call(call) => call.func.as_ref(),
871 other => other,
872 };
873 match target {
874 Expr::Name(name) => name.id.as_str() == "fixture",
875 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
876 _ => false,
877 }
878}
879
880enum PatchForm {
882 Target,
884 Object,
886 Dict,
888}
889
890fn patch_form(call: &ExprCall) -> Option<PatchForm> {
893 match call.func.as_ref() {
894 Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
895 Expr::Attribute(attr) => match attr.attr.as_str() {
896 "patch" => Some(PatchForm::Target),
897 "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
898 "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
899 _ => None,
900 },
901 _ => None,
902 }
903}
904
905fn is_patch_call(call: &ExprCall) -> bool {
907 patch_form(call).is_some()
908}
909
910fn attr_base_is_patch(expr: &Expr) -> bool {
912 match expr {
913 Expr::Name(name) => name.id.as_str() == "patch",
914 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
915 _ => false,
916 }
917}
918
919const 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)";
920
921const 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";
922
923fn patch_string_target(call: &ExprCall) -> Option<&str> {
926 string_arg(call, 0)
927}
928
929fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
931 if let Some(Expr::Constant(constant)) = call.args.get(index) {
932 if let Constant::Str(value) = &constant.value {
933 return Some(value.as_str());
934 }
935 }
936 None
937}
938
939fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
942 match expr {
943 Expr::Name(name) => Some(vec![name.id.as_str()]),
944 Expr::Attribute(attr) => {
945 let mut segments = attr_chain_segments(attr.value.as_ref())?;
946 segments.push(attr.attr.as_str());
947 Some(segments)
948 }
949 _ => None,
950 }
951}
952
953fn resolve_object_target(expr: &Expr, imports: &HashMap<String, String>) -> Option<String> {
957 let segments = attr_chain_segments(expr)?;
958 let (head, rest) = segments.split_first()?;
959 let mut target = imports.get(*head)?.clone();
960 for segment in rest {
961 target.push('.');
962 target.push_str(segment);
963 }
964 Some(target)
965}
966
967fn patch_target(call: &ExprCall, imports: &HashMap<String, String>) -> Option<String> {
971 match patch_form(call)? {
972 PatchForm::Target => patch_string_target(call).map(str::to_owned),
973 PatchForm::Dict => patch_string_target(call)
974 .map(str::to_owned)
975 .or_else(|| resolve_object_target(call.args.first()?, imports)),
976 PatchForm::Object => {
977 let base = resolve_object_target(call.args.first()?, imports)?;
978 Some(match string_arg(call, 1) {
979 Some(attr) => format!("{base}.{attr}"),
980 None => base,
981 })
982 }
983 }
984}
985
986fn patches_constant(target: &str) -> bool {
988 target.rsplit('.').next().is_some_and(is_upper_constant)
989}
990
991fn patches_first_party(target: &str, pkg: &str) -> bool {
993 target
994 .split('.')
995 .next()
996 .is_some_and(|head| !head.is_empty() && head == pkg)
997}
998
999fn is_upper_constant(name: &str) -> bool {
1001 !name.is_empty()
1002 && name
1003 .chars()
1004 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1005 && name.chars().any(|c| c.is_ascii_uppercase())
1006}
1007
1008const ENVIRON_MUTATION_MSG: &str =
1009 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1010
1011fn is_os_environ(expr: &Expr) -> bool {
1013 matches!(
1014 expr,
1015 Expr::Attribute(attr)
1016 if attr.attr.as_str() == "environ"
1017 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1018 )
1019}
1020
1021fn is_os_environ_subscript(expr: &Expr) -> bool {
1023 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1024}
1025
1026fn is_environ_mutation_call(call: &ExprCall) -> bool {
1028 matches!(
1029 call.func.as_ref(),
1030 Expr::Attribute(attr)
1031 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1032 )
1033}
1034
1035fn is_environ_mutator(method: &str) -> bool {
1037 matches!(
1038 method,
1039 "update" | "pop" | "setdefault" | "clear" | "popitem"
1040 )
1041}
1042
1043fn line_of(source: &str, offset: TextSize) -> usize {
1045 let offset = (u32::from(offset) as usize).min(source.len());
1046 source.as_bytes()[..offset]
1047 .iter()
1048 .filter(|&&byte| byte == b'\n')
1049 .count()
1050 + 1
1051}
1052
1053fn first_party_package(root: &Path) -> Option<String> {
1057 for dir in root.ancestors() {
1058 let candidate = dir.join("pyproject.toml");
1059 if candidate.is_file() {
1060 return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
1061 }
1062 if dir.join(".git").exists() {
1063 break;
1064 }
1065 }
1066 None
1067}
1068
1069fn read_project_name(path: &Path) -> Option<String> {
1071 let contents = std::fs::read_to_string(path).ok()?;
1072 let value: toml::Value = toml::from_str(&contents).ok()?;
1073 value
1074 .get("project")?
1075 .get("name")?
1076 .as_str()
1077 .map(str::to_owned)
1078}
1079
1080fn normalize_dist_name(name: &str) -> String {
1083 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1084}
1085
1086fn collect_python_files(
1087 dir: &Path,
1088 out: &mut Vec<PathBuf>,
1089 is_match: fn(&Path) -> bool,
1090) -> Result<()> {
1091 let entries =
1092 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1093 for entry in entries {
1094 let path = entry
1095 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1096 .path();
1097 if path.is_dir() {
1098 collect_python_files(&path, out, is_match)?;
1099 } else if is_match(&path) {
1100 out.push(path);
1101 }
1102 }
1103 Ok(())
1104}
1105
1106fn is_python_test_file(path: &Path) -> bool {
1109 let name = path
1110 .file_name()
1111 .and_then(|n| n.to_str())
1112 .unwrap_or_default();
1113 name == "conftest.py" || name.ends_with("_test.py")
1114}
1115
1116fn is_python_unit_test_file(path: &Path) -> bool {
1119 let name = path
1120 .file_name()
1121 .and_then(|n| n.to_str())
1122 .unwrap_or_default();
1123 name.ends_with("_test.py")
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129 use std::sync::atomic::{AtomicU64, Ordering};
1130
1131 struct TempDir(PathBuf);
1133
1134 impl TempDir {
1135 fn new() -> Self {
1136 static COUNTER: AtomicU64 = AtomicU64::new(0);
1137 let dir = std::env::temp_dir().join(format!(
1138 "tc-lint-{}-{}",
1139 std::process::id(),
1140 COUNTER.fetch_add(1, Ordering::Relaxed),
1141 ));
1142 std::fs::create_dir_all(&dir).unwrap();
1143 TempDir(dir)
1144 }
1145
1146 fn write(&self, name: &str, contents: &str) {
1147 let path = self.0.join(name);
1148 if let Some(parent) = path.parent() {
1149 std::fs::create_dir_all(parent).unwrap();
1150 }
1151 std::fs::write(path, contents).unwrap();
1152 }
1153 }
1154
1155 impl Drop for TempDir {
1156 fn drop(&mut self) {
1157 let _ = std::fs::remove_dir_all(&self.0);
1158 }
1159 }
1160
1161 #[test]
1162 fn normalize_dist_name_maps_to_import_name() {
1163 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1164 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1165 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1166 assert_eq!(normalize_dist_name("myproject"), "myproject");
1167 }
1168
1169 fn parse_call(src: &str) -> ExprCall {
1171 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1172 let stmt = suite.into_iter().next().expect("one statement");
1173 (*stmt.expect_expr_stmt().value).expect_call_expr()
1174 }
1175
1176 #[test]
1177 fn patch_target_only_reads_string_literals_for_the_string_form() {
1178 let imports = HashMap::new();
1179 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1180 assert_eq!(
1181 patch_target(&str_call, &imports).as_deref(),
1182 Some("pkg.mod.attr")
1183 );
1184 let name_call = parse_call("patch(target)\n");
1186 assert_eq!(patch_target(&name_call, &imports), None);
1187 let int_call = parse_call("patch(42)\n");
1188 assert_eq!(patch_target(&int_call, &imports), None);
1189 let empty_call = parse_call("patch()\n");
1190 assert_eq!(patch_target(&empty_call, &imports), None);
1191 }
1192
1193 fn object_form_imports() -> HashMap<String, String> {
1195 HashMap::from([
1196 ("ledger".to_string(), "myproject.ledger".to_string()),
1197 ("myproject".to_string(), "myproject".to_string()),
1198 ("cfg".to_string(), "myproject.cfg".to_string()),
1199 ])
1200 }
1201
1202 #[test]
1203 fn patch_target_resolves_object_forms_through_imports() {
1204 let imports = object_form_imports();
1205 let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1206 assert_eq!(
1207 patch_target(&imported_name, &imports).as_deref(),
1208 Some("myproject.ledger.record")
1209 );
1210 let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1211 assert_eq!(
1212 patch_target(&dotted_module, &imports).as_deref(),
1213 Some("myproject.ledger.record")
1214 );
1215 let name_attr = parse_call("patch.object(ledger, attr)\n");
1217 assert_eq!(
1218 patch_target(&name_attr, &imports).as_deref(),
1219 Some("myproject.ledger")
1220 );
1221 let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1222 assert_eq!(
1223 patch_target(&dict_object, &imports).as_deref(),
1224 Some("myproject.cfg.SETTINGS")
1225 );
1226 let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1227 assert_eq!(
1228 patch_target(&dict_string, &imports).as_deref(),
1229 Some("pkg.cfg.FLAGS")
1230 );
1231 }
1232
1233 #[test]
1234 fn patch_target_declines_a_base_bound_by_no_import() {
1235 let imports = object_form_imports();
1236 let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1237 assert_eq!(patch_target(&call_base, &imports), None);
1238 let unbound_name = parse_call("patch.object(client, \"send\")\n");
1239 assert_eq!(patch_target(&unbound_name, &imports), None);
1240 let empty = parse_call("patch.object()\n");
1241 assert_eq!(patch_target(&empty, &imports), None);
1242 }
1243
1244 fn collect_imports(src: &str) -> HashMap<String, String> {
1246 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1247 let mut visitor = LintVisitor {
1248 file: Path::new("t.py"),
1249 source: src,
1250 fixture_depth: 0,
1251 first_party: None,
1252 imports: HashMap::new(),
1253 violations: Vec::new(),
1254 };
1255 for stmt in suite {
1256 visitor.visit_stmt(stmt);
1257 }
1258 visitor.imports
1259 }
1260
1261 #[test]
1262 fn lint_visitor_binds_imports_to_their_modules() {
1263 let imports = collect_imports(
1264 "import myproject.ledger\n\
1265 import myproject.config as cfg\n\
1266 from myproject import ledger\n\
1267 from myproject import charge as ch\n\
1268 from . import rel\n",
1269 );
1270 assert_eq!(
1271 imports.get("myproject").map(String::as_str),
1272 Some("myproject")
1273 );
1274 assert_eq!(
1275 imports.get("cfg").map(String::as_str),
1276 Some("myproject.config")
1277 );
1278 assert_eq!(
1279 imports.get("ledger").map(String::as_str),
1280 Some("myproject.ledger")
1281 );
1282 assert_eq!(
1283 imports.get("ch").map(String::as_str),
1284 Some("myproject.charge")
1285 );
1286 assert_eq!(imports.get("rel"), None);
1287 }
1288
1289 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1291 ImportRecord {
1292 display: source.unwrap_or(".rel").to_string(),
1293 line: 1,
1294 is_uut: false,
1295 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1296 source: source.map(str::to_string),
1297 module: None,
1298 }
1299 }
1300
1301 fn targets(list: &[&str]) -> Vec<String> {
1302 list.iter().map(|s| (*s).to_string()).collect()
1303 }
1304
1305 #[test]
1306 fn is_mocked_requires_every_symbol_at_the_import_module() {
1307 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1308 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1310 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1311 }
1312
1313 #[test]
1314 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1315 let rec = from_import(Some("pkg.ledger"), &["record"]);
1316 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1318 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1319 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1320 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1321 }
1322
1323 #[test]
1324 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1325 let rec = from_import(None, &["record"]);
1327 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1328 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1329 }
1330
1331 #[test]
1332 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1333 let rec = ImportRecord {
1334 display: "pkg.db".to_string(),
1335 line: 1,
1336 is_uut: false,
1337 symbols: Vec::new(),
1338 source: None,
1339 module: Some("pkg.db".to_string()),
1340 };
1341 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1342 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1343 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1344 let empty = from_import(Some("pkg.mod"), &[]);
1345 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1346 }
1347
1348 #[test]
1349 fn patches_first_party_matches_head_segment() {
1350 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1351 assert!(patches_first_party("myproject", "myproject"));
1352 assert!(!patches_first_party("requests.get", "myproject"));
1353 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1354 assert!(!patches_first_party("", "myproject"));
1355 assert!(!patches_first_party(".leading", "myproject"));
1356 }
1357
1358 #[test]
1359 fn first_party_package_reads_pyproject_name() {
1360 let tree = TempDir::new();
1361 tree.write(
1362 "pyproject.toml",
1363 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1364 );
1365 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1366 }
1367
1368 #[test]
1369 fn first_party_package_is_none_without_a_project_name() {
1370 let tree = TempDir::new();
1371 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1372 tree.write(".git", "");
1373 assert_eq!(first_party_package(&tree.0), None);
1374 }
1375
1376 #[test]
1377 fn first_party_package_is_none_when_absent() {
1378 let tree = TempDir::new();
1379 assert_eq!(first_party_package(&tree.0), None);
1380 }
1381
1382 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1384 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1385 let mut visitor = UnitIsolationVisitor {
1386 source,
1387 first_party,
1388 base,
1389 type_checking_depth: 0,
1390 imports: Vec::new(),
1391 patch_targets: Vec::new(),
1392 };
1393 for stmt in suite {
1394 visitor.visit_stmt(stmt);
1395 }
1396 visitor
1397 .imports
1398 .iter()
1399 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1400 .map(|i| i.display.clone())
1401 .collect()
1402 }
1403
1404 #[test]
1405 fn import_head_and_last_segment() {
1406 assert_eq!(import_head("myproject.db.conn"), "myproject");
1407 assert_eq!(import_head("requests"), "requests");
1408 assert_eq!(last_segment("myproject.db.conn"), "conn");
1409 assert_eq!(last_segment("widget"), "widget");
1410 }
1411
1412 #[test]
1413 fn unit_under_test_base_strips_test_suffix() {
1414 assert_eq!(
1415 unit_under_test_base(Path::new("pkg/widget_test.py")),
1416 "widget"
1417 );
1418 assert_eq!(
1420 unit_under_test_base(Path::new("test_widget.py")),
1421 "test_widget"
1422 );
1423 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1424 }
1425
1426 #[test]
1427 fn recognizes_python_unit_test_files() {
1428 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1429 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1430 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1431 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1432 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1433 }
1434
1435 #[test]
1436 fn visitor_flags_first_party_and_external_collaborators() {
1437 let found = unmocked(
1439 "widget",
1440 "myproject",
1441 "from myproject.widget import build\n\
1442 from myproject.ledger import record\n\
1443 import requests\n",
1444 );
1445 assert_eq!(
1446 found,
1447 vec!["myproject.ledger".to_string(), "requests".to_string()]
1448 );
1449 }
1450
1451 #[test]
1452 fn visitor_clears_a_mocked_collaborator() {
1453 let found = unmocked(
1454 "widget",
1455 "myproject",
1456 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1457 );
1458 assert!(found.is_empty(), "got: {found:?}");
1459 }
1460
1461 #[test]
1462 fn visitor_flags_a_wrong_module_patch() {
1463 let found = unmocked(
1466 "widget",
1467 "myproject",
1468 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1469 );
1470 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1471 }
1472
1473 #[test]
1474 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1475 let found = unmocked(
1477 "widget",
1478 "myproject",
1479 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1480 );
1481 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1482 let both = unmocked(
1483 "widget",
1484 "myproject",
1485 "from myproject.ledger import record, erase\n\
1486 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1487 );
1488 assert!(both.is_empty(), "got: {both:?}");
1489 }
1490
1491 #[test]
1492 fn visitor_handles_module_and_relative_imports() {
1493 assert_eq!(
1494 unmocked("widget", "myproject", "import myproject.db\n"),
1495 vec!["myproject.db".to_string()]
1496 );
1497 assert!(unmocked(
1498 "widget",
1499 "myproject",
1500 "import myproject.db\npatch(\"myproject.db.connect\")\n"
1501 )
1502 .is_empty());
1503 assert_eq!(
1504 unmocked("widget", "myproject", "from .ledger import record\n"),
1505 vec![".ledger".to_string()]
1506 );
1507 assert_eq!(
1508 unmocked(
1509 "widget",
1510 "myproject",
1511 "from . import ledger\nfrom . import widget\n"
1512 ),
1513 vec![".ledger".to_string()]
1514 );
1515 }
1516
1517 #[test]
1518 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1519 assert!(unmocked(
1521 "__init__",
1522 "myproject",
1523 "from . import Thing, __all__, __version__\n"
1524 )
1525 .is_empty());
1526 assert_eq!(
1528 unmocked("__init__", "myproject", "from .core import Thing\n"),
1529 vec![".core".to_string()]
1530 );
1531 assert_eq!(
1533 unmocked("__init__", "myproject", "from .. import sibling\n"),
1534 vec!["..sibling".to_string()]
1535 );
1536 assert_eq!(
1538 unmocked("widget", "myproject", "from . import ledger\n"),
1539 vec![".ledger".to_string()]
1540 );
1541 }
1542
1543 #[test]
1544 fn visitor_skips_type_checking_imports() {
1545 let found = unmocked(
1547 "widget",
1548 "myproject",
1549 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
1550 );
1551 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1552 }
1553
1554 #[test]
1555 fn is_checked_import_classifies_origins() {
1556 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
1559 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
1561 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
1563 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
1565 assert!(!is_checked_import("os", "myproject"));
1567 assert!(!is_checked_import("pathlib", "myproject"));
1568 assert!(!is_checked_import("datetime", "myproject"));
1569 }
1570
1571 #[test]
1572 fn is_checked_import_classifies_private_stdlib_as_stdlib() {
1573 assert!(!is_checked_import("__future__", "myproject"));
1574 assert!(!is_checked_import("_thread", "myproject"));
1575 assert!(!is_checked_import("_socket", "myproject"));
1576 assert!(!is_checked_import("_ast", "myproject"));
1577 assert!(!is_checked_import("_collections_abc", "myproject"));
1578 assert!(is_checked_import("_stripe", "myproject")); }
1580
1581 #[test]
1582 fn visitor_flags_external_collaborators() {
1583 let found = unmocked(
1584 "widget",
1585 "myproject",
1586 "import requests\nimport subprocess\nimport json\nimport pytest\n",
1587 );
1588 assert_eq!(found.len(), 2, "got: {found:?}");
1589 assert!(found.contains(&"requests".to_string()));
1590 assert!(found.contains(&"subprocess".to_string()));
1591 }
1592
1593 #[test]
1594 fn visitor_type_checking_variants_and_plain_if() {
1595 assert!(unmocked(
1597 "widget",
1598 "myproject",
1599 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
1600 )
1601 .is_empty());
1602 assert_eq!(
1604 unmocked(
1605 "widget",
1606 "myproject",
1607 "if ready == 1:\n from myproject.ledger import record\n"
1608 ),
1609 vec!["myproject.ledger".to_string()]
1610 );
1611 }
1612
1613 #[test]
1614 fn find_unit_isolation_without_pyproject_reports_nothing() {
1615 let tree = TempDir::new();
1616 tree.write("widget_test.py", "from myproject.ledger import record\n");
1617 tree.write(".git", "");
1618 assert!(find_unit_isolation_violations(&tree.0)
1619 .expect("a readable tree should succeed")
1620 .is_empty());
1621 }
1622
1623 #[test]
1624 fn find_unit_isolation_walks_subdirs_and_flags() {
1625 let tree = TempDir::new();
1626 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1627 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1628 let found =
1629 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1630 assert_eq!(found.len(), 1, "got: {found:?}");
1631 assert_eq!(found[0].rule, "unmocked-collaborator");
1632 assert!(found[0].message.contains("myproject.ledger"));
1633 }
1634
1635 #[test]
1636 fn recognizes_python_test_files() {
1637 assert!(is_python_test_file(Path::new("widget_test.py")));
1638 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1639 assert!(is_python_test_file(Path::new("conftest.py")));
1640 assert!(!is_python_test_file(Path::new("test_widget.py")));
1641 }
1642
1643 #[test]
1644 fn ignores_non_test_files() {
1645 assert!(!is_python_test_file(Path::new("widget.py")));
1646 assert!(!is_python_test_file(Path::new("conftest.pyi")));
1647 assert!(!is_python_test_file(Path::new("README.md")));
1648 assert!(!is_python_test_file(Path::new("testing.py")));
1649 }
1650
1651 #[test]
1652 fn line_of_counts_newlines() {
1653 let src = "a\nb\nc\n";
1654 assert_eq!(line_of(src, TextSize::from(0)), 1);
1655 assert_eq!(line_of(src, TextSize::from(2)), 2);
1656 assert_eq!(line_of(src, TextSize::from(4)), 3);
1657 }
1658
1659 #[test]
1660 fn recognizes_environ_mutators() {
1661 assert!(is_environ_mutator("update"));
1662 assert!(is_environ_mutator("pop"));
1663 assert!(is_environ_mutator("clear"));
1664 assert!(!is_environ_mutator("get"));
1665 assert!(!is_environ_mutator("keys"));
1666 }
1667
1668 fn lint_rules(source: &str) -> Vec<&'static str> {
1670 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1671 let mut visitor = LintVisitor {
1672 file: Path::new("t.py"),
1673 source,
1674 fixture_depth: 0,
1675 first_party: Some("myproject"),
1676 imports: HashMap::new(),
1677 violations: Vec::new(),
1678 };
1679 for stmt in suite {
1680 visitor.visit_stmt(stmt);
1681 }
1682 visitor.violations.iter().map(|v| v.rule).collect()
1683 }
1684
1685 #[test]
1686 fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
1687 assert!(
1688 lint_rules("@pytest.fixture\nasync def client():\n patch(\"pkg.mod.attr\")\n")
1689 .is_empty()
1690 );
1691 assert_eq!(
1692 lint_rules("async def widget_test():\n patch(\"pkg.mod.attr\")\n"),
1693 vec!["no-inline-patch"]
1694 );
1695 assert_eq!(
1696 lint_rules("async def widget_test(monkeypatch):\n pass\n"),
1697 vec!["no-monkeypatch"]
1698 );
1699 }
1700
1701 #[test]
1702 fn an_augmented_assignment_to_environ_is_a_mutation() {
1703 assert_eq!(
1704 lint_rules("def widget_test():\n os.environ[\"PATH\"] += \":/x\"\n"),
1705 vec!["no-environ-mutation"]
1706 );
1707 assert!(lint_rules("def widget_test():\n total += 1\n").is_empty());
1708 }
1709
1710 #[test]
1711 fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
1712 assert!(
1713 lint_rules("@fixture\ndef client():\n patch(\"pkg.mod.attr\")\n").is_empty(),
1714 "a bare `@fixture` shelters the patch"
1715 );
1716 assert_eq!(
1717 lint_rules("@registry[\"fixture\"]\ndef client():\n patch(\"pkg.mod.attr\")\n"),
1718 vec!["no-inline-patch"],
1719 "a subscripted decorator is not a fixture"
1720 );
1721 }
1722
1723 #[test]
1724 fn patch_object_is_recognized_only_through_a_patch_receiver() {
1725 assert_eq!(
1726 lint_rules("def widget_test():\n mock.patch.object(svc, \"send\")\n"),
1727 vec!["no-inline-patch"]
1728 );
1729 assert!(
1730 lint_rules("def widget_test():\n helpers[0].object(svc, \"send\")\n").is_empty(),
1731 "a subscripted receiver is not `patch`"
1732 );
1733 assert!(
1734 lint_rules("def widget_test():\n helpers[0](\"pkg.mod.attr\")\n").is_empty(),
1735 "a subscripted callee is not a patch call"
1736 );
1737 }
1738
1739 #[test]
1740 fn find_suite_without_a_tests_directory_reports_nothing() {
1741 let tree = TempDir::new();
1742 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1743 assert!(find_suite_violations(&tree.0)
1744 .expect("a readable tree should succeed")
1745 .is_empty());
1746 }
1747
1748 #[test]
1749 fn find_unit_isolation_without_a_project_name_reports_nothing() {
1750 let tree = TempDir::new();
1751 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1752 tree.write("widget_test.py", "from myproject.ledger import record\n");
1753 assert!(find_unit_isolation_violations(&tree.0)
1754 .expect("a readable tree should succeed")
1755 .is_empty());
1756 }
1757
1758 #[test]
1759 fn recognizes_upper_constants() {
1760 assert!(is_upper_constant("CACHE_DIR"));
1761 assert!(is_upper_constant("DEBUG"));
1762 assert!(is_upper_constant("MAX_2"));
1763 assert!(!is_upper_constant("cache_dir"));
1764 assert!(!is_upper_constant("CacheDir"));
1765 assert!(!is_upper_constant("fetch"));
1766 assert!(!is_upper_constant(""));
1767 assert!(!is_upper_constant("_"));
1768 assert!(!is_upper_constant("123"));
1769 }
1770}