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