1use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, Context, Result};
8use rustpython_ast::Visitor;
9use rustpython_parser::ast::{
10 self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
11 StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
12};
13use rustpython_parser::text_size::{TextRange, TextSize};
14use rustpython_parser::Parse;
15
16pub use crate::violation::Violation;
18
19pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
23 let root = root.as_ref();
24 let first_party = first_party_package(root);
26 let mut files = Vec::new();
27 collect_python_files(root, &mut files, is_python_test_file)?;
28 files.sort();
29
30 let mut violations = Vec::new();
31 for file in &files {
32 let source = std::fs::read_to_string(file)
33 .with_context(|| format!("reading test file `{}`", file.display()))?;
34 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
35 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
36 let mut visitor = LintVisitor {
37 file,
38 source: &source,
39 fixture_depth: 0,
40 first_party: first_party.as_deref(),
41 violations: Vec::new(),
42 };
43 for stmt in suite {
44 visitor.visit_stmt(stmt);
45 }
46 violations.append(&mut visitor.violations);
47 }
48
49 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
50 Ok(violations)
51}
52
53const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
54 a suite lives in `tests/integration/` or `tests/e2e/`";
55
56pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
60 let tests = package_root.join("tests");
61 let mut violations = Vec::new();
62 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
63 for tier in &tiers {
64 if tier.is_dir() {
65 violations.extend(find_violations(tier)?);
66 }
67 }
68 if tests.is_dir() {
69 let mut strays = Vec::new();
70 collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
71 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
72 for file in strays {
73 violations.push(Violation {
74 file,
75 line: 1,
76 rule: "unknown-tier",
77 message: UNKNOWN_TIER_MSG.to_string(),
78 });
79 }
80 }
81 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
82 Ok(violations)
83}
84
85pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
89 let root = root.as_ref();
90 let Some(first_party) = first_party_package(root) else {
91 return Ok(Vec::new());
92 };
93 let mut files = Vec::new();
94 collect_python_files(root, &mut files, is_python_unit_test_file)?;
95 if let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") {
97 files.retain(|file| !file.starts_with(&tests));
98 }
99 files.sort();
100
101 let mut violations = Vec::new();
102 for file in &files {
103 let source = std::fs::read_to_string(file)
104 .with_context(|| format!("reading test file `{}`", file.display()))?;
105 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
106 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
107 let base = unit_under_test_base(file);
108 let mut visitor = UnitIsolationVisitor {
109 source: &source,
110 first_party: &first_party,
111 base: &base,
112 type_checking_depth: 0,
113 imports: Vec::new(),
114 patch_targets: Vec::new(),
115 };
116 for stmt in suite {
117 visitor.visit_stmt(stmt);
118 }
119 for import in &visitor.imports {
120 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
121 continue;
122 }
123 violations.push(Violation {
124 file: file.to_path_buf(),
125 line: import.line,
126 rule: "unmocked-collaborator",
127 message: format!(
128 "unit test imports `{}` without mocking it — a unit test isolates the \
129 unit under test, so mock every collaborator (patch it by string in a \
130 fixture)",
131 import.display
132 ),
133 });
134 }
135 }
136
137 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
138 Ok(violations)
139}
140
141struct ImportRecord {
143 display: String,
145 line: usize,
146 is_uut: bool,
147 symbols: Vec<String>,
149 source: Option<String>,
152 module: Option<String>,
154}
155
156impl ImportRecord {
157 fn is_mocked(&self, patch_targets: &[String]) -> bool {
161 if let Some(module) = &self.module {
162 let prefix = format!("{module}.");
163 return patch_targets
164 .iter()
165 .any(|target| target == module || target.starts_with(&prefix));
166 }
167 if self.symbols.is_empty() {
168 return false;
169 }
170 self.symbols.iter().all(|symbol| {
171 patch_targets
172 .iter()
173 .any(|target| self.symbol_is_mocked(target, symbol))
174 })
175 }
176
177 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
180 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
181 return false;
182 };
183 match &self.source {
184 Some(source) => module == source,
185 None => true,
186 }
187 }
188}
189
190struct UnitIsolationVisitor<'a> {
194 source: &'a str,
195 first_party: &'a str,
196 base: &'a str,
197 type_checking_depth: usize,
198 imports: Vec<ImportRecord>,
199 patch_targets: Vec<String>,
200}
201
202impl Visitor for UnitIsolationVisitor<'_> {
203 fn visit_stmt_import(&mut self, node: StmtImport) {
204 if self.type_checking_depth == 0 {
205 let line = line_of(self.source, node.range.start());
206 for alias in &node.names {
207 let module = alias.name.as_str();
208 if is_checked_import(import_head(module), self.first_party) {
209 self.imports.push(ImportRecord {
210 display: module.to_string(),
211 line,
212 is_uut: last_segment(module) == self.base,
213 symbols: Vec::new(),
214 source: None,
215 module: Some(module.to_string()),
216 });
217 }
218 }
219 }
220 self.generic_visit_stmt_import(node);
221 }
222
223 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
224 if self.type_checking_depth == 0 {
225 let level = relative_level(&node);
226 let module = node.module.as_ref().map(|m| m.as_str());
227 let should_check = level > 0
229 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
230 if should_check {
231 let line = line_of(self.source, node.range.start());
232 let dots = ".".repeat(level);
233 match module {
234 Some(module) => self.imports.push(ImportRecord {
236 display: format!("{dots}{module}"),
237 line,
238 is_uut: last_segment(module) == self.base,
239 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
240 source: (level == 0).then(|| module.to_string()),
241 module: None,
242 }),
243 None => {
245 let barrel_sut = self.base == "__init__" && level == 1;
248 for alias in &node.names {
249 let name = alias.name.as_str();
250 self.imports.push(ImportRecord {
251 display: format!("{dots}{name}"),
252 line,
253 is_uut: barrel_sut || name == self.base,
254 symbols: vec![name.to_string()],
255 source: None,
256 module: None,
257 });
258 }
259 }
260 }
261 }
262 }
263 self.generic_visit_stmt_import_from(node);
264 }
265
266 fn visit_expr_call(&mut self, node: ExprCall) {
267 if is_patch_call(&node) {
268 if let Some(target) = patch_string_target(&node) {
269 self.patch_targets.push(target.to_string());
270 }
271 }
272 self.generic_visit_expr_call(node);
273 }
274
275 fn visit_stmt_if(&mut self, node: StmtIf) {
276 if is_type_checking(node.test.as_ref()) {
278 self.type_checking_depth += 1;
279 for stmt in node.body {
280 self.visit_stmt(stmt);
281 }
282 self.type_checking_depth -= 1;
283 for stmt in node.orelse {
284 self.visit_stmt(stmt);
285 }
286 } else {
287 self.generic_visit_stmt_if(node);
288 }
289 }
290}
291
292fn import_head(module: &str) -> &str {
294 module.split('.').next().unwrap_or(module)
295}
296
297fn is_checked_import(head: &str, first_party: &str) -> bool {
300 if head == first_party {
301 return true;
302 }
303 if TEST_FRAMEWORK.contains(&head) {
304 return false;
305 }
306 if EFFECTFUL_STDLIB.contains(&head) {
307 return true;
308 }
309 if STDLIB_MODULES.contains(&head) {
310 return false;
311 }
312 true }
314
315const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
317
318const EFFECTFUL_STDLIB: &[&str] = &[
322 "asynchat",
323 "asyncore",
324 "ctypes",
325 "curses",
326 "dbm",
327 "fcntl",
328 "ftplib",
329 "imaplib",
330 "mmap",
331 "msvcrt",
332 "multiprocessing",
333 "nis",
334 "nntplib",
335 "ossaudiodev",
336 "poplib",
337 "pty",
338 "random",
339 "secrets",
340 "select",
341 "selectors",
342 "signal",
343 "smtpd",
344 "smtplib",
345 "socket",
346 "socketserver",
347 "spwd",
348 "sqlite3",
349 "ssl",
350 "subprocess",
351 "syslog",
352 "telnetlib",
353 "termios",
354 "tty",
355 "webbrowser",
356 "winreg",
357 "winsound",
358];
359
360const STDLIB_MODULES: &[&str] = &[
363 "abc",
364 "aifc",
365 "antigravity",
366 "argparse",
367 "array",
368 "ast",
369 "asynchat",
370 "asyncio",
371 "asyncore",
372 "atexit",
373 "audioop",
374 "base64",
375 "bdb",
376 "binascii",
377 "bisect",
378 "builtins",
379 "bz2",
380 "cProfile",
381 "calendar",
382 "cgi",
383 "cgitb",
384 "chunk",
385 "cmath",
386 "cmd",
387 "code",
388 "codecs",
389 "codeop",
390 "collections",
391 "colorsys",
392 "compileall",
393 "concurrent",
394 "configparser",
395 "contextlib",
396 "contextvars",
397 "copy",
398 "copyreg",
399 "crypt",
400 "csv",
401 "ctypes",
402 "curses",
403 "dataclasses",
404 "datetime",
405 "dbm",
406 "decimal",
407 "difflib",
408 "dis",
409 "distutils",
410 "doctest",
411 "email",
412 "encodings",
413 "ensurepip",
414 "enum",
415 "errno",
416 "faulthandler",
417 "fcntl",
418 "filecmp",
419 "fileinput",
420 "fnmatch",
421 "fractions",
422 "ftplib",
423 "functools",
424 "gc",
425 "genericpath",
426 "getopt",
427 "getpass",
428 "gettext",
429 "glob",
430 "graphlib",
431 "grp",
432 "gzip",
433 "hashlib",
434 "heapq",
435 "hmac",
436 "html",
437 "http",
438 "idlelib",
439 "imaplib",
440 "imghdr",
441 "imp",
442 "importlib",
443 "inspect",
444 "io",
445 "ipaddress",
446 "itertools",
447 "json",
448 "keyword",
449 "lib2to3",
450 "linecache",
451 "locale",
452 "logging",
453 "lzma",
454 "mailbox",
455 "mailcap",
456 "marshal",
457 "math",
458 "mimetypes",
459 "mmap",
460 "modulefinder",
461 "msilib",
462 "msvcrt",
463 "multiprocessing",
464 "netrc",
465 "nis",
466 "nntplib",
467 "nt",
468 "ntpath",
469 "nturl2path",
470 "numbers",
471 "opcode",
472 "operator",
473 "optparse",
474 "os",
475 "ossaudiodev",
476 "pathlib",
477 "pdb",
478 "pickle",
479 "pickletools",
480 "pipes",
481 "pkgutil",
482 "platform",
483 "plistlib",
484 "poplib",
485 "posix",
486 "posixpath",
487 "pprint",
488 "profile",
489 "pstats",
490 "pty",
491 "pwd",
492 "py_compile",
493 "pyclbr",
494 "pydoc",
495 "pydoc_data",
496 "pyexpat",
497 "queue",
498 "quopri",
499 "random",
500 "re",
501 "readline",
502 "reprlib",
503 "resource",
504 "rlcompleter",
505 "runpy",
506 "sched",
507 "secrets",
508 "select",
509 "selectors",
510 "shelve",
511 "shlex",
512 "shutil",
513 "signal",
514 "site",
515 "smtpd",
516 "smtplib",
517 "sndhdr",
518 "socket",
519 "socketserver",
520 "spwd",
521 "sqlite3",
522 "sre_compile",
523 "sre_constants",
524 "sre_parse",
525 "ssl",
526 "stat",
527 "statistics",
528 "string",
529 "stringprep",
530 "struct",
531 "subprocess",
532 "sunau",
533 "symtable",
534 "sys",
535 "sysconfig",
536 "syslog",
537 "tabnanny",
538 "tarfile",
539 "telnetlib",
540 "tempfile",
541 "termios",
542 "textwrap",
543 "this",
544 "threading",
545 "time",
546 "timeit",
547 "tkinter",
548 "token",
549 "tokenize",
550 "tomllib",
551 "trace",
552 "traceback",
553 "tracemalloc",
554 "tty",
555 "turtle",
556 "turtledemo",
557 "types",
558 "typing",
559 "unicodedata",
560 "unittest",
561 "urllib",
562 "uu",
563 "uuid",
564 "venv",
565 "warnings",
566 "wave",
567 "weakref",
568 "webbrowser",
569 "winreg",
570 "winsound",
571 "wsgiref",
572 "xdrlib",
573 "xml",
574 "xmlrpc",
575 "zipapp",
576 "zipfile",
577 "zipimport",
578 "zlib",
579 "zoneinfo",
580];
581
582fn last_segment(module: &str) -> &str {
584 module.rsplit('.').next().unwrap_or(module)
585}
586
587fn relative_level(node: &StmtImportFrom) -> usize {
589 node.level.map_or(0, |level| level.to_usize())
590}
591
592fn is_type_checking(test: &Expr) -> bool {
594 match test {
595 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
596 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
597 _ => false,
598 }
599}
600
601fn unit_under_test_base(file: &Path) -> String {
604 let name = file
605 .file_name()
606 .and_then(|n| n.to_str())
607 .unwrap_or_default();
608 let stem = name.strip_suffix(".py").unwrap_or(name);
609 stem.strip_suffix("_test").unwrap_or(stem).to_string()
610}
611
612struct LintVisitor<'a> {
615 file: &'a Path,
616 source: &'a str,
617 fixture_depth: usize,
618 first_party: Option<&'a str>,
620 violations: Vec<Violation>,
621}
622
623impl LintVisitor<'_> {
624 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
625 self.violations.push(Violation {
626 file: self.file.to_path_buf(),
627 line: line_of(self.source, range.start()),
628 rule,
629 message: message.to_string(),
630 });
631 }
632
633 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
635 let takes_monkeypatch = args
636 .posonlyargs
637 .iter()
638 .chain(&args.args)
639 .chain(&args.kwonlyargs)
640 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
641 || arg_named(&args.vararg, "monkeypatch")
642 || arg_named(&args.kwarg, "monkeypatch");
643 if takes_monkeypatch {
644 self.report(
645 range,
646 "no-monkeypatch",
647 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
648 );
649 }
650
651 decorators.iter().any(is_fixture_decorator)
652 }
653}
654
655impl Visitor for LintVisitor<'_> {
656 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
657 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
658 if is_fixture {
659 self.fixture_depth += 1;
660 }
661 self.generic_visit_stmt_function_def(node);
662 if is_fixture {
663 self.fixture_depth -= 1;
664 }
665 }
666
667 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
668 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
669 if is_fixture {
670 self.fixture_depth += 1;
671 }
672 self.generic_visit_stmt_async_function_def(node);
673 if is_fixture {
674 self.fixture_depth -= 1;
675 }
676 }
677
678 fn visit_expr_call(&mut self, node: ExprCall) {
679 let is_patch = is_patch_call(&node);
680 if is_patch && self.fixture_depth == 0 {
682 self.report(
683 node.range,
684 "no-inline-patch",
685 "patch is called inline in a test body; move it into a `pytest.fixture`",
686 );
687 }
688 if is_patch && patches_constant(&node) {
690 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
691 }
692 if is_patch {
694 if let Some(pkg) = self.first_party {
695 if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
696 {
697 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
698 }
699 }
700 }
701 if is_environ_mutation_call(&node) {
702 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
703 }
704 self.generic_visit_expr_call(node);
705 }
706
707 fn visit_withitem(&mut self, node: WithItem) {
710 self.visit_expr(node.context_expr);
711 if let Some(optional_vars) = node.optional_vars {
712 self.visit_expr(*optional_vars);
713 }
714 }
715
716 fn visit_stmt_assign(&mut self, node: StmtAssign) {
717 if node.targets.iter().any(is_os_environ_subscript) {
718 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
719 }
720 self.generic_visit_stmt_assign(node);
721 }
722
723 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
724 if is_os_environ_subscript(node.target.as_ref()) {
725 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
726 }
727 self.generic_visit_stmt_aug_assign(node);
728 }
729
730 fn visit_stmt_delete(&mut self, node: StmtDelete) {
731 if node.targets.iter().any(is_os_environ_subscript) {
732 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
733 }
734 self.generic_visit_stmt_delete(node);
735 }
736}
737
738fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
740 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
741}
742
743fn is_fixture_decorator(decorator: &Expr) -> bool {
745 let target = match decorator {
746 Expr::Call(call) => call.func.as_ref(),
747 other => other,
748 };
749 match target {
750 Expr::Name(name) => name.id.as_str() == "fixture",
751 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
752 _ => false,
753 }
754}
755
756fn is_patch_call(call: &ExprCall) -> bool {
759 match call.func.as_ref() {
760 Expr::Name(name) => name.id.as_str() == "patch",
761 Expr::Attribute(attr) => {
762 let name = attr.attr.as_str();
763 name == "patch"
764 || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
765 }
766 _ => false,
767 }
768}
769
770fn attr_base_is_patch(expr: &Expr) -> bool {
772 match expr {
773 Expr::Name(name) => name.id.as_str() == "patch",
774 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
775 _ => false,
776 }
777}
778
779const 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)";
780
781const 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";
782
783fn patch_string_target(call: &ExprCall) -> Option<&str> {
786 if let Some(Expr::Constant(constant)) = call.args.first() {
787 if let Constant::Str(target) = &constant.value {
788 return Some(target.as_str());
789 }
790 }
791 None
792}
793
794fn patches_constant(call: &ExprCall) -> bool {
796 patch_string_target(call)
797 .and_then(|target| target.rsplit('.').next())
798 .is_some_and(is_upper_constant)
799}
800
801fn patches_first_party(target: &str, pkg: &str) -> bool {
803 target
804 .split('.')
805 .next()
806 .is_some_and(|head| !head.is_empty() && head == pkg)
807}
808
809fn is_upper_constant(name: &str) -> bool {
811 !name.is_empty()
812 && name
813 .chars()
814 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
815 && name.chars().any(|c| c.is_ascii_uppercase())
816}
817
818const ENVIRON_MUTATION_MSG: &str =
819 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
820
821fn is_os_environ(expr: &Expr) -> bool {
823 matches!(
824 expr,
825 Expr::Attribute(attr)
826 if attr.attr.as_str() == "environ"
827 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
828 )
829}
830
831fn is_os_environ_subscript(expr: &Expr) -> bool {
833 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
834}
835
836fn is_environ_mutation_call(call: &ExprCall) -> bool {
838 matches!(
839 call.func.as_ref(),
840 Expr::Attribute(attr)
841 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
842 )
843}
844
845fn is_environ_mutator(method: &str) -> bool {
847 matches!(
848 method,
849 "update" | "pop" | "setdefault" | "clear" | "popitem"
850 )
851}
852
853fn line_of(source: &str, offset: TextSize) -> usize {
855 let offset = (u32::from(offset) as usize).min(source.len());
856 source.as_bytes()[..offset]
857 .iter()
858 .filter(|&&byte| byte == b'\n')
859 .count()
860 + 1
861}
862
863fn first_party_package(root: &Path) -> Option<String> {
867 for dir in root.ancestors() {
868 let candidate = dir.join("pyproject.toml");
869 if candidate.is_file() {
870 return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
871 }
872 if dir.join(".git").exists() {
873 break;
874 }
875 }
876 None
877}
878
879fn read_project_name(path: &Path) -> Option<String> {
881 let contents = std::fs::read_to_string(path).ok()?;
882 let value: toml::Value = toml::from_str(&contents).ok()?;
883 value
884 .get("project")?
885 .get("name")?
886 .as_str()
887 .map(str::to_owned)
888}
889
890fn normalize_dist_name(name: &str) -> String {
893 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
894}
895
896fn collect_python_files(
897 dir: &Path,
898 out: &mut Vec<PathBuf>,
899 is_match: fn(&Path) -> bool,
900) -> Result<()> {
901 let entries =
902 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
903 for entry in entries {
904 let path = entry
905 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
906 .path();
907 if path.is_dir() {
908 collect_python_files(&path, out, is_match)?;
909 } else if is_match(&path) {
910 out.push(path);
911 }
912 }
913 Ok(())
914}
915
916fn is_python_test_file(path: &Path) -> bool {
919 let name = path
920 .file_name()
921 .and_then(|n| n.to_str())
922 .unwrap_or_default();
923 name == "conftest.py" || name.ends_with("_test.py")
924}
925
926fn is_python_unit_test_file(path: &Path) -> bool {
929 let name = path
930 .file_name()
931 .and_then(|n| n.to_str())
932 .unwrap_or_default();
933 name.ends_with("_test.py")
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use std::sync::atomic::{AtomicU64, Ordering};
940
941 struct TempDir(PathBuf);
943
944 impl TempDir {
945 fn new() -> Self {
946 static COUNTER: AtomicU64 = AtomicU64::new(0);
947 let dir = std::env::temp_dir().join(format!(
948 "tc-lint-{}-{}",
949 std::process::id(),
950 COUNTER.fetch_add(1, Ordering::Relaxed),
951 ));
952 std::fs::create_dir_all(&dir).unwrap();
953 TempDir(dir)
954 }
955
956 fn write(&self, name: &str, contents: &str) {
957 let path = self.0.join(name);
958 if let Some(parent) = path.parent() {
959 std::fs::create_dir_all(parent).unwrap();
960 }
961 std::fs::write(path, contents).unwrap();
962 }
963 }
964
965 impl Drop for TempDir {
966 fn drop(&mut self) {
967 let _ = std::fs::remove_dir_all(&self.0);
968 }
969 }
970
971 #[test]
972 fn normalize_dist_name_maps_to_import_name() {
973 assert_eq!(normalize_dist_name("My-Project"), "my_project");
974 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
975 assert_eq!(normalize_dist_name(" myproject "), "myproject");
976 assert_eq!(normalize_dist_name("myproject"), "myproject");
977 }
978
979 fn parse_call(src: &str) -> ExprCall {
981 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
982 match suite.into_iter().next().expect("one statement") {
983 ast::Stmt::Expr(stmt) => match *stmt.value {
984 Expr::Call(call) => call,
985 other => panic!("expected a call, got {other:?}"),
986 },
987 other => panic!("expected an expression statement, got {other:?}"),
988 }
989 }
990
991 #[test]
992 fn patch_string_target_only_reads_string_literals() {
993 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
994 assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
995 let int_call = parse_call("patch(42)\n");
996 assert_eq!(patch_string_target(&int_call), None);
997 let name_call = parse_call("patch(target)\n");
998 assert_eq!(patch_string_target(&name_call), None);
999 let empty_call = parse_call("patch()\n");
1000 assert_eq!(patch_string_target(&empty_call), None);
1001 }
1002
1003 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1005 ImportRecord {
1006 display: source.unwrap_or(".rel").to_string(),
1007 line: 1,
1008 is_uut: false,
1009 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1010 source: source.map(str::to_string),
1011 module: None,
1012 }
1013 }
1014
1015 fn targets(list: &[&str]) -> Vec<String> {
1016 list.iter().map(|s| (*s).to_string()).collect()
1017 }
1018
1019 #[test]
1020 fn is_mocked_requires_every_symbol_at_the_import_module() {
1021 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1022 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1024 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1025 }
1026
1027 #[test]
1028 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1029 let rec = from_import(Some("pkg.ledger"), &["record"]);
1030 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1032 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1033 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1034 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1035 }
1036
1037 #[test]
1038 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1039 let rec = from_import(None, &["record"]);
1041 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1042 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1043 }
1044
1045 #[test]
1046 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1047 let rec = ImportRecord {
1048 display: "pkg.db".to_string(),
1049 line: 1,
1050 is_uut: false,
1051 symbols: Vec::new(),
1052 source: None,
1053 module: Some("pkg.db".to_string()),
1054 };
1055 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1056 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1057 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1058 let empty = from_import(Some("pkg.mod"), &[]);
1059 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1060 }
1061
1062 #[test]
1063 fn patches_first_party_matches_head_segment() {
1064 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1065 assert!(patches_first_party("myproject", "myproject"));
1066 assert!(!patches_first_party("requests.get", "myproject"));
1067 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1068 assert!(!patches_first_party("", "myproject"));
1069 assert!(!patches_first_party(".leading", "myproject"));
1070 }
1071
1072 #[test]
1073 fn first_party_package_reads_pyproject_name() {
1074 let tree = TempDir::new();
1075 tree.write(
1076 "pyproject.toml",
1077 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1078 );
1079 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1080 }
1081
1082 #[test]
1083 fn first_party_package_is_none_without_a_project_name() {
1084 let tree = TempDir::new();
1085 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1086 tree.write(".git", "");
1087 assert_eq!(first_party_package(&tree.0), None);
1088 }
1089
1090 #[test]
1091 fn first_party_package_is_none_when_absent() {
1092 let tree = TempDir::new();
1093 assert_eq!(first_party_package(&tree.0), None);
1094 }
1095
1096 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1098 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1099 let mut visitor = UnitIsolationVisitor {
1100 source,
1101 first_party,
1102 base,
1103 type_checking_depth: 0,
1104 imports: Vec::new(),
1105 patch_targets: Vec::new(),
1106 };
1107 for stmt in suite {
1108 visitor.visit_stmt(stmt);
1109 }
1110 visitor
1111 .imports
1112 .iter()
1113 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1114 .map(|i| i.display.clone())
1115 .collect()
1116 }
1117
1118 #[test]
1119 fn import_head_and_last_segment() {
1120 assert_eq!(import_head("myproject.db.conn"), "myproject");
1121 assert_eq!(import_head("requests"), "requests");
1122 assert_eq!(last_segment("myproject.db.conn"), "conn");
1123 assert_eq!(last_segment("widget"), "widget");
1124 }
1125
1126 #[test]
1127 fn unit_under_test_base_strips_test_suffix() {
1128 assert_eq!(
1129 unit_under_test_base(Path::new("pkg/widget_test.py")),
1130 "widget"
1131 );
1132 assert_eq!(
1134 unit_under_test_base(Path::new("test_widget.py")),
1135 "test_widget"
1136 );
1137 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1138 }
1139
1140 #[test]
1141 fn recognizes_python_unit_test_files() {
1142 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1143 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1144 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1145 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1146 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1147 }
1148
1149 #[test]
1150 fn visitor_flags_first_party_and_external_collaborators() {
1151 let found = unmocked(
1153 "widget",
1154 "myproject",
1155 "from myproject.widget import build\n\
1156 from myproject.ledger import record\n\
1157 import requests\n",
1158 );
1159 assert_eq!(
1160 found,
1161 vec!["myproject.ledger".to_string(), "requests".to_string()]
1162 );
1163 }
1164
1165 #[test]
1166 fn visitor_clears_a_mocked_collaborator() {
1167 let found = unmocked(
1168 "widget",
1169 "myproject",
1170 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1171 );
1172 assert!(found.is_empty(), "got: {found:?}");
1173 }
1174
1175 #[test]
1176 fn visitor_flags_a_wrong_module_patch() {
1177 let found = unmocked(
1180 "widget",
1181 "myproject",
1182 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1183 );
1184 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1185 }
1186
1187 #[test]
1188 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1189 let found = unmocked(
1191 "widget",
1192 "myproject",
1193 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1194 );
1195 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1196 let both = unmocked(
1197 "widget",
1198 "myproject",
1199 "from myproject.ledger import record, erase\n\
1200 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1201 );
1202 assert!(both.is_empty(), "got: {both:?}");
1203 }
1204
1205 #[test]
1206 fn visitor_handles_module_and_relative_imports() {
1207 assert_eq!(
1208 unmocked("widget", "myproject", "import myproject.db\n"),
1209 vec!["myproject.db".to_string()]
1210 );
1211 assert!(unmocked(
1212 "widget",
1213 "myproject",
1214 "import myproject.db\npatch(\"myproject.db.connect\")\n"
1215 )
1216 .is_empty());
1217 assert_eq!(
1218 unmocked("widget", "myproject", "from .ledger import record\n"),
1219 vec![".ledger".to_string()]
1220 );
1221 assert_eq!(
1222 unmocked(
1223 "widget",
1224 "myproject",
1225 "from . import ledger\nfrom . import widget\n"
1226 ),
1227 vec![".ledger".to_string()]
1228 );
1229 }
1230
1231 #[test]
1232 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1233 assert!(unmocked(
1235 "__init__",
1236 "myproject",
1237 "from . import Thing, __all__, __version__\n"
1238 )
1239 .is_empty());
1240 assert_eq!(
1242 unmocked("__init__", "myproject", "from .core import Thing\n"),
1243 vec![".core".to_string()]
1244 );
1245 assert_eq!(
1247 unmocked("__init__", "myproject", "from .. import sibling\n"),
1248 vec!["..sibling".to_string()]
1249 );
1250 assert_eq!(
1252 unmocked("widget", "myproject", "from . import ledger\n"),
1253 vec![".ledger".to_string()]
1254 );
1255 }
1256
1257 #[test]
1258 fn visitor_skips_type_checking_imports() {
1259 let found = unmocked(
1261 "widget",
1262 "myproject",
1263 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
1264 );
1265 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1266 }
1267
1268 #[test]
1269 fn is_checked_import_classifies_origins() {
1270 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
1273 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
1275 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
1277 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
1279 assert!(!is_checked_import("os", "myproject"));
1281 assert!(!is_checked_import("pathlib", "myproject"));
1282 assert!(!is_checked_import("datetime", "myproject"));
1283 }
1284
1285 #[test]
1286 fn visitor_flags_external_collaborators() {
1287 let found = unmocked(
1288 "widget",
1289 "myproject",
1290 "import requests\nimport subprocess\nimport json\nimport pytest\n",
1291 );
1292 assert_eq!(found.len(), 2, "got: {found:?}");
1293 assert!(found.contains(&"requests".to_string()));
1294 assert!(found.contains(&"subprocess".to_string()));
1295 }
1296
1297 #[test]
1298 fn visitor_type_checking_variants_and_plain_if() {
1299 assert!(unmocked(
1301 "widget",
1302 "myproject",
1303 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
1304 )
1305 .is_empty());
1306 assert_eq!(
1308 unmocked(
1309 "widget",
1310 "myproject",
1311 "if ready == 1:\n from myproject.ledger import record\n"
1312 ),
1313 vec!["myproject.ledger".to_string()]
1314 );
1315 }
1316
1317 #[test]
1318 fn find_unit_isolation_without_pyproject_reports_nothing() {
1319 let tree = TempDir::new();
1320 tree.write("widget_test.py", "from myproject.ledger import record\n");
1321 tree.write(".git", "");
1322 assert!(find_unit_isolation_violations(&tree.0)
1323 .expect("a readable tree should succeed")
1324 .is_empty());
1325 }
1326
1327 #[test]
1328 fn find_unit_isolation_walks_subdirs_and_flags() {
1329 let tree = TempDir::new();
1330 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1331 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1332 let found =
1333 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1334 assert_eq!(found.len(), 1, "got: {found:?}");
1335 assert_eq!(found[0].rule, "unmocked-collaborator");
1336 assert!(found[0].message.contains("myproject.ledger"));
1337 }
1338
1339 #[test]
1340 fn recognizes_python_test_files() {
1341 assert!(is_python_test_file(Path::new("widget_test.py")));
1342 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1343 assert!(is_python_test_file(Path::new("conftest.py")));
1344 assert!(!is_python_test_file(Path::new("test_widget.py")));
1345 }
1346
1347 #[test]
1348 fn ignores_non_test_files() {
1349 assert!(!is_python_test_file(Path::new("widget.py")));
1350 assert!(!is_python_test_file(Path::new("conftest.pyi")));
1351 assert!(!is_python_test_file(Path::new("README.md")));
1352 assert!(!is_python_test_file(Path::new("testing.py")));
1353 }
1354
1355 #[test]
1356 fn line_of_counts_newlines() {
1357 let src = "a\nb\nc\n";
1358 assert_eq!(line_of(src, TextSize::from(0)), 1);
1359 assert_eq!(line_of(src, TextSize::from(2)), 2);
1360 assert_eq!(line_of(src, TextSize::from(4)), 3);
1361 }
1362
1363 #[test]
1364 fn recognizes_environ_mutators() {
1365 assert!(is_environ_mutator("update"));
1366 assert!(is_environ_mutator("pop"));
1367 assert!(is_environ_mutator("clear"));
1368 assert!(!is_environ_mutator("get"));
1369 assert!(!is_environ_mutator("keys"));
1370 }
1371
1372 #[test]
1373 fn recognizes_upper_constants() {
1374 assert!(is_upper_constant("CACHE_DIR"));
1375 assert!(is_upper_constant("DEBUG"));
1376 assert!(is_upper_constant("MAX_2"));
1377 assert!(!is_upper_constant("cache_dir"));
1378 assert!(!is_upper_constant("CacheDir"));
1379 assert!(!is_upper_constant("fetch"));
1380 assert!(!is_upper_constant(""));
1381 assert!(!is_upper_constant("_"));
1382 assert!(!is_upper_constant("123"));
1383 }
1384}