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 for alias in &node.names {
238 let name = alias.name.as_str();
239 self.imports.push(ImportRecord {
240 display: format!("{dots}{name}"),
241 line,
242 is_uut: name == self.base,
243 symbols: vec![name.to_string()],
244 module: None,
245 });
246 }
247 }
248 }
249 }
250 }
251 self.generic_visit_stmt_import_from(node);
252 }
253
254 fn visit_expr_call(&mut self, node: ExprCall) {
255 if is_patch_call(&node) {
256 if let Some(target) = patch_string_target(&node) {
257 self.patch_targets.push(target.to_string());
258 }
259 }
260 self.generic_visit_expr_call(node);
261 }
262
263 fn visit_stmt_if(&mut self, node: StmtIf) {
264 if is_type_checking(node.test.as_ref()) {
267 self.type_checking_depth += 1;
268 for stmt in node.body {
269 self.visit_stmt(stmt);
270 }
271 self.type_checking_depth -= 1;
272 for stmt in node.orelse {
273 self.visit_stmt(stmt);
274 }
275 } else {
276 self.generic_visit_stmt_if(node);
277 }
278 }
279}
280
281fn import_head(module: &str) -> &str {
283 module.split('.').next().unwrap_or(module)
284}
285
286fn is_checked_import(head: &str, first_party: &str) -> bool {
291 if head == first_party {
292 return true; }
294 if TEST_FRAMEWORK.contains(&head) {
295 return false; }
297 if EFFECTFUL_STDLIB.contains(&head) {
298 return true; }
300 if STDLIB_MODULES.contains(&head) {
301 return false; }
303 true }
305
306const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
309
310const EFFECTFUL_STDLIB: &[&str] = &[
318 "asynchat",
319 "asyncore",
320 "ctypes",
321 "curses",
322 "dbm",
323 "fcntl",
324 "ftplib",
325 "imaplib",
326 "mmap",
327 "msvcrt",
328 "multiprocessing",
329 "nis",
330 "nntplib",
331 "ossaudiodev",
332 "poplib",
333 "pty",
334 "random",
335 "secrets",
336 "select",
337 "selectors",
338 "signal",
339 "smtpd",
340 "smtplib",
341 "socket",
342 "socketserver",
343 "spwd",
344 "sqlite3",
345 "ssl",
346 "subprocess",
347 "syslog",
348 "telnetlib",
349 "termios",
350 "tty",
351 "webbrowser",
352 "winreg",
353 "winsound",
354];
355
356const STDLIB_MODULES: &[&str] = &[
360 "abc",
361 "aifc",
362 "antigravity",
363 "argparse",
364 "array",
365 "ast",
366 "asynchat",
367 "asyncio",
368 "asyncore",
369 "atexit",
370 "audioop",
371 "base64",
372 "bdb",
373 "binascii",
374 "bisect",
375 "builtins",
376 "bz2",
377 "cProfile",
378 "calendar",
379 "cgi",
380 "cgitb",
381 "chunk",
382 "cmath",
383 "cmd",
384 "code",
385 "codecs",
386 "codeop",
387 "collections",
388 "colorsys",
389 "compileall",
390 "concurrent",
391 "configparser",
392 "contextlib",
393 "contextvars",
394 "copy",
395 "copyreg",
396 "crypt",
397 "csv",
398 "ctypes",
399 "curses",
400 "dataclasses",
401 "datetime",
402 "dbm",
403 "decimal",
404 "difflib",
405 "dis",
406 "distutils",
407 "doctest",
408 "email",
409 "encodings",
410 "ensurepip",
411 "enum",
412 "errno",
413 "faulthandler",
414 "fcntl",
415 "filecmp",
416 "fileinput",
417 "fnmatch",
418 "fractions",
419 "ftplib",
420 "functools",
421 "gc",
422 "genericpath",
423 "getopt",
424 "getpass",
425 "gettext",
426 "glob",
427 "graphlib",
428 "grp",
429 "gzip",
430 "hashlib",
431 "heapq",
432 "hmac",
433 "html",
434 "http",
435 "idlelib",
436 "imaplib",
437 "imghdr",
438 "imp",
439 "importlib",
440 "inspect",
441 "io",
442 "ipaddress",
443 "itertools",
444 "json",
445 "keyword",
446 "lib2to3",
447 "linecache",
448 "locale",
449 "logging",
450 "lzma",
451 "mailbox",
452 "mailcap",
453 "marshal",
454 "math",
455 "mimetypes",
456 "mmap",
457 "modulefinder",
458 "msilib",
459 "msvcrt",
460 "multiprocessing",
461 "netrc",
462 "nis",
463 "nntplib",
464 "nt",
465 "ntpath",
466 "nturl2path",
467 "numbers",
468 "opcode",
469 "operator",
470 "optparse",
471 "os",
472 "ossaudiodev",
473 "pathlib",
474 "pdb",
475 "pickle",
476 "pickletools",
477 "pipes",
478 "pkgutil",
479 "platform",
480 "plistlib",
481 "poplib",
482 "posix",
483 "posixpath",
484 "pprint",
485 "profile",
486 "pstats",
487 "pty",
488 "pwd",
489 "py_compile",
490 "pyclbr",
491 "pydoc",
492 "pydoc_data",
493 "pyexpat",
494 "queue",
495 "quopri",
496 "random",
497 "re",
498 "readline",
499 "reprlib",
500 "resource",
501 "rlcompleter",
502 "runpy",
503 "sched",
504 "secrets",
505 "select",
506 "selectors",
507 "shelve",
508 "shlex",
509 "shutil",
510 "signal",
511 "site",
512 "smtpd",
513 "smtplib",
514 "sndhdr",
515 "socket",
516 "socketserver",
517 "spwd",
518 "sqlite3",
519 "sre_compile",
520 "sre_constants",
521 "sre_parse",
522 "ssl",
523 "stat",
524 "statistics",
525 "string",
526 "stringprep",
527 "struct",
528 "subprocess",
529 "sunau",
530 "symtable",
531 "sys",
532 "sysconfig",
533 "syslog",
534 "tabnanny",
535 "tarfile",
536 "telnetlib",
537 "tempfile",
538 "termios",
539 "textwrap",
540 "this",
541 "threading",
542 "time",
543 "timeit",
544 "tkinter",
545 "token",
546 "tokenize",
547 "tomllib",
548 "trace",
549 "traceback",
550 "tracemalloc",
551 "tty",
552 "turtle",
553 "turtledemo",
554 "types",
555 "typing",
556 "unicodedata",
557 "unittest",
558 "urllib",
559 "uu",
560 "uuid",
561 "venv",
562 "warnings",
563 "wave",
564 "weakref",
565 "webbrowser",
566 "winreg",
567 "winsound",
568 "wsgiref",
569 "xdrlib",
570 "xml",
571 "xmlrpc",
572 "zipapp",
573 "zipfile",
574 "zipimport",
575 "zlib",
576 "zoneinfo",
577];
578
579fn last_segment(module: &str) -> &str {
581 module.rsplit('.').next().unwrap_or(module)
582}
583
584fn relative_level(node: &StmtImportFrom) -> usize {
587 node.level.map_or(0, |level| level.to_usize())
588}
589
590fn is_type_checking(test: &Expr) -> bool {
593 match test {
594 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
595 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
596 _ => false,
597 }
598}
599
600fn 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> {
616 file: &'a Path,
617 source: &'a str,
618 fixture_depth: usize,
619 first_party: Option<&'a str>,
621 violations: Vec<Violation>,
622}
623
624impl LintVisitor<'_> {
625 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
626 self.violations.push(Violation {
627 file: self.file.to_path_buf(),
628 line: line_of(self.source, range.start()),
629 rule,
630 message: message.to_string(),
631 });
632 }
633
634 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
637 let takes_monkeypatch = args
639 .posonlyargs
640 .iter()
641 .chain(&args.args)
642 .chain(&args.kwonlyargs)
643 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
644 || arg_named(&args.vararg, "monkeypatch")
645 || arg_named(&args.kwarg, "monkeypatch");
646 if takes_monkeypatch {
647 self.report(
648 range,
649 "no-monkeypatch",
650 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
651 );
652 }
653
654 decorators.iter().any(is_fixture_decorator)
655 }
656}
657
658impl Visitor for LintVisitor<'_> {
659 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
660 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
661 if is_fixture {
662 self.fixture_depth += 1;
663 }
664 self.generic_visit_stmt_function_def(node);
665 if is_fixture {
666 self.fixture_depth -= 1;
667 }
668 }
669
670 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
671 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
672 if is_fixture {
673 self.fixture_depth += 1;
674 }
675 self.generic_visit_stmt_async_function_def(node);
676 if is_fixture {
677 self.fixture_depth -= 1;
678 }
679 }
680
681 fn visit_expr_call(&mut self, node: ExprCall) {
682 let is_patch = is_patch_call(&node);
683 if is_patch && self.fixture_depth == 0 {
686 self.report(
687 node.range,
688 "no-inline-patch",
689 "patch is called inline in a test body; move it into a `pytest.fixture`",
690 );
691 }
692 if is_patch && patches_constant(&node) {
695 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
696 }
697 if is_patch {
703 if let Some(pkg) = self.first_party {
704 if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
705 {
706 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
707 }
708 }
709 }
710 if is_environ_mutation_call(&node) {
712 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
713 }
714 self.generic_visit_expr_call(node);
715 }
716
717 fn visit_withitem(&mut self, node: WithItem) {
720 self.visit_expr(node.context_expr);
721 if let Some(optional_vars) = node.optional_vars {
722 self.visit_expr(*optional_vars);
723 }
724 }
725
726 fn visit_stmt_assign(&mut self, node: StmtAssign) {
729 if node.targets.iter().any(is_os_environ_subscript) {
730 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
731 }
732 self.generic_visit_stmt_assign(node);
733 }
734
735 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
736 if is_os_environ_subscript(node.target.as_ref()) {
737 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
738 }
739 self.generic_visit_stmt_aug_assign(node);
740 }
741
742 fn visit_stmt_delete(&mut self, node: StmtDelete) {
743 if node.targets.iter().any(is_os_environ_subscript) {
744 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
745 }
746 self.generic_visit_stmt_delete(node);
747 }
748}
749
750fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
752 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
753}
754
755fn is_fixture_decorator(decorator: &Expr) -> bool {
758 let target = match decorator {
759 Expr::Call(call) => call.func.as_ref(),
760 other => other,
761 };
762 match target {
763 Expr::Name(name) => name.id.as_str() == "fixture",
764 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
765 _ => false,
766 }
767}
768
769fn is_patch_call(call: &ExprCall) -> bool {
772 match call.func.as_ref() {
773 Expr::Name(name) => name.id.as_str() == "patch",
774 Expr::Attribute(attr) => {
775 let name = attr.attr.as_str();
776 name == "patch"
777 || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
778 }
779 _ => false,
780 }
781}
782
783fn attr_base_is_patch(expr: &Expr) -> bool {
786 match expr {
787 Expr::Name(name) => name.id.as_str() == "patch",
788 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
789 _ => false,
790 }
791}
792
793const 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)";
795
796const 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";
798
799fn patch_string_target(call: &ExprCall) -> Option<&str> {
803 if let Some(Expr::Constant(constant)) = call.args.first() {
804 if let Constant::Str(target) = &constant.value {
805 return Some(target.as_str());
806 }
807 }
808 None
809}
810
811fn patches_constant(call: &ExprCall) -> bool {
814 patch_string_target(call)
815 .and_then(|target| target.rsplit('.').next())
816 .is_some_and(is_upper_constant)
817}
818
819fn patches_first_party(target: &str, pkg: &str) -> bool {
822 target
823 .split('.')
824 .next()
825 .is_some_and(|head| !head.is_empty() && head == pkg)
826}
827
828fn is_upper_constant(name: &str) -> bool {
831 !name.is_empty()
832 && name
833 .chars()
834 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
835 && name.chars().any(|c| c.is_ascii_uppercase())
836}
837
838const ENVIRON_MUTATION_MSG: &str =
840 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
841
842fn is_os_environ(expr: &Expr) -> bool {
844 matches!(
845 expr,
846 Expr::Attribute(attr)
847 if attr.attr.as_str() == "environ"
848 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
849 )
850}
851
852fn is_os_environ_subscript(expr: &Expr) -> bool {
855 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
856}
857
858fn is_environ_mutation_call(call: &ExprCall) -> bool {
861 matches!(
862 call.func.as_ref(),
863 Expr::Attribute(attr)
864 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
865 )
866}
867
868fn is_environ_mutator(method: &str) -> bool {
870 matches!(
871 method,
872 "update" | "pop" | "setdefault" | "clear" | "popitem"
873 )
874}
875
876fn line_of(source: &str, offset: TextSize) -> usize {
878 let offset = (u32::from(offset) as usize).min(source.len());
879 source.as_bytes()[..offset]
880 .iter()
881 .filter(|&&byte| byte == b'\n')
882 .count()
883 + 1
884}
885
886fn first_party_package(root: &Path) -> Option<String> {
895 for dir in root.ancestors() {
896 let candidate = dir.join("pyproject.toml");
897 if candidate.is_file() {
898 return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
899 }
900 if dir.join(".git").exists() {
901 break;
902 }
903 }
904 None
905}
906
907fn read_project_name(path: &Path) -> Option<String> {
909 let contents = std::fs::read_to_string(path).ok()?;
910 let value: toml::Value = toml::from_str(&contents).ok()?;
911 value
912 .get("project")?
913 .get("name")?
914 .as_str()
915 .map(str::to_owned)
916}
917
918fn normalize_dist_name(name: &str) -> String {
921 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
922}
923
924fn collect_python_files(
926 dir: &Path,
927 out: &mut Vec<PathBuf>,
928 is_match: fn(&Path) -> bool,
929) -> Result<()> {
930 let entries =
931 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
932 for entry in entries {
933 let path = entry
934 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
935 .path();
936 if path.is_dir() {
937 collect_python_files(&path, out, is_match)?;
938 } else if is_match(&path) {
939 out.push(path);
940 }
941 }
942 Ok(())
943}
944
945fn is_python_test_file(path: &Path) -> bool {
949 let name = path
950 .file_name()
951 .and_then(|n| n.to_str())
952 .unwrap_or_default();
953 name == "conftest.py" || name.ends_with("_test.py")
954}
955
956fn is_python_unit_test_file(path: &Path) -> bool {
960 let name = path
961 .file_name()
962 .and_then(|n| n.to_str())
963 .unwrap_or_default();
964 name.ends_with("_test.py")
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use std::sync::atomic::{AtomicU64, Ordering};
971
972 struct TempDir(PathBuf);
974
975 impl TempDir {
976 fn new() -> Self {
977 static COUNTER: AtomicU64 = AtomicU64::new(0);
978 let dir = std::env::temp_dir().join(format!(
979 "tc-lint-{}-{}",
980 std::process::id(),
981 COUNTER.fetch_add(1, Ordering::Relaxed),
982 ));
983 std::fs::create_dir_all(&dir).unwrap();
984 TempDir(dir)
985 }
986
987 fn write(&self, name: &str, contents: &str) {
988 let path = self.0.join(name);
989 if let Some(parent) = path.parent() {
990 std::fs::create_dir_all(parent).unwrap();
991 }
992 std::fs::write(path, contents).unwrap();
993 }
994 }
995
996 impl Drop for TempDir {
997 fn drop(&mut self) {
998 let _ = std::fs::remove_dir_all(&self.0);
999 }
1000 }
1001
1002 #[test]
1003 fn normalize_dist_name_maps_to_import_name() {
1004 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1005 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1006 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1007 assert_eq!(normalize_dist_name("myproject"), "myproject");
1008 }
1009
1010 fn parse_call(src: &str) -> ExprCall {
1012 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1013 match suite.into_iter().next().expect("one statement") {
1014 ast::Stmt::Expr(stmt) => match *stmt.value {
1015 Expr::Call(call) => call,
1016 other => panic!("expected a call, got {other:?}"),
1017 },
1018 other => panic!("expected an expression statement, got {other:?}"),
1019 }
1020 }
1021
1022 #[test]
1023 fn patch_string_target_only_reads_string_literals() {
1024 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1025 assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
1026 let int_call = parse_call("patch(42)\n");
1029 assert_eq!(patch_string_target(&int_call), None);
1030 let name_call = parse_call("patch(target)\n");
1031 assert_eq!(patch_string_target(&name_call), None);
1032 let empty_call = parse_call("patch()\n");
1033 assert_eq!(patch_string_target(&empty_call), None);
1034 }
1035
1036 #[test]
1037 fn patches_first_party_matches_head_segment() {
1038 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1039 assert!(patches_first_party("myproject", "myproject"));
1040 assert!(!patches_first_party("requests.get", "myproject"));
1041 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1042 assert!(!patches_first_party("", "myproject"));
1043 assert!(!patches_first_party(".leading", "myproject"));
1044 }
1045
1046 #[test]
1047 fn first_party_package_reads_pyproject_name() {
1048 let tree = TempDir::new();
1049 tree.write(
1050 "pyproject.toml",
1051 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1052 );
1053 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1055 }
1056
1057 #[test]
1058 fn first_party_package_is_none_without_a_project_name() {
1059 let tree = TempDir::new();
1060 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1062 tree.write(".git", "");
1063 assert_eq!(first_party_package(&tree.0), None);
1064 }
1065
1066 #[test]
1067 fn first_party_package_is_none_when_absent() {
1068 let tree = TempDir::new();
1070 assert_eq!(first_party_package(&tree.0), None);
1071 }
1072
1073 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1076 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1077 let mut visitor = UnitIsolationVisitor {
1078 source,
1079 first_party,
1080 base,
1081 type_checking_depth: 0,
1082 imports: Vec::new(),
1083 patch_targets: Vec::new(),
1084 };
1085 for stmt in suite {
1086 visitor.visit_stmt(stmt);
1087 }
1088 visitor
1089 .imports
1090 .iter()
1091 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1092 .map(|i| i.display.clone())
1093 .collect()
1094 }
1095
1096 #[test]
1097 fn import_head_and_last_segment() {
1098 assert_eq!(import_head("myproject.db.conn"), "myproject");
1099 assert_eq!(import_head("requests"), "requests");
1100 assert_eq!(last_segment("myproject.db.conn"), "conn");
1101 assert_eq!(last_segment("widget"), "widget");
1102 }
1103
1104 #[test]
1105 fn unit_under_test_base_strips_test_suffix() {
1106 assert_eq!(
1107 unit_under_test_base(Path::new("pkg/widget_test.py")),
1108 "widget"
1109 );
1110 assert_eq!(
1113 unit_under_test_base(Path::new("test_widget.py")),
1114 "test_widget"
1115 );
1116 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1117 }
1118
1119 #[test]
1120 fn recognizes_python_unit_test_files() {
1121 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1122 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1123 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1125 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1127 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1128 }
1129
1130 #[test]
1131 fn is_mocked_matches_symbol_last_segment_and_module_prefix() {
1132 let symbol = ImportRecord {
1133 display: "myproject.ledger".into(),
1134 line: 1,
1135 is_uut: false,
1136 symbols: vec!["record".into()],
1137 module: None,
1138 };
1139 assert!(symbol.is_mocked(&["myproject.widget.record".into()]));
1141 assert!(symbol.is_mocked(&["myproject.ledger.record".into()]));
1142 assert!(!symbol.is_mocked(&["myproject.widget.other".into()]));
1143
1144 let module = ImportRecord {
1145 display: "myproject.db".into(),
1146 line: 1,
1147 is_uut: false,
1148 symbols: Vec::new(),
1149 module: Some("myproject.db".into()),
1150 };
1151 assert!(module.is_mocked(&["myproject.db.conn".into()])); assert!(module.is_mocked(&["myproject.db".into()])); assert!(!module.is_mocked(&["myproject.dbx.y".into()])); }
1155
1156 #[test]
1157 fn visitor_flags_first_party_and_external_collaborators() {
1158 let found = unmocked(
1161 "widget",
1162 "myproject",
1163 "from myproject.widget import build\n\
1164 from myproject.ledger import record\n\
1165 import requests\n",
1166 );
1167 assert_eq!(
1168 found,
1169 vec!["myproject.ledger".to_string(), "requests".to_string()]
1170 );
1171 }
1172
1173 #[test]
1174 fn visitor_clears_a_mocked_collaborator() {
1175 let found = unmocked(
1177 "widget",
1178 "myproject",
1179 "from myproject.ledger import record\npatch(\"myproject.widget.record\")\n",
1180 );
1181 assert!(found.is_empty(), "got: {found:?}");
1182 }
1183
1184 #[test]
1185 fn visitor_handles_module_and_relative_imports() {
1186 assert_eq!(
1188 unmocked("widget", "myproject", "import myproject.db\n"),
1189 vec!["myproject.db".to_string()]
1190 );
1191 assert!(unmocked(
1193 "widget",
1194 "myproject",
1195 "import myproject.db\npatch(\"myproject.db.connect\")\n"
1196 )
1197 .is_empty());
1198 assert_eq!(
1200 unmocked("widget", "myproject", "from .ledger import record\n"),
1201 vec![".ledger".to_string()]
1202 );
1203 assert_eq!(
1204 unmocked(
1205 "widget",
1206 "myproject",
1207 "from . import ledger\nfrom . import widget\n"
1208 ),
1209 vec![".ledger".to_string()]
1210 );
1211 }
1212
1213 #[test]
1214 fn visitor_skips_type_checking_imports() {
1215 let found = unmocked(
1218 "widget",
1219 "myproject",
1220 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
1221 );
1222 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1223 }
1224
1225 #[test]
1226 fn is_checked_import_classifies_origins() {
1227 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
1230 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
1232 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
1234 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
1236 assert!(!is_checked_import("os", "myproject"));
1238 assert!(!is_checked_import("pathlib", "myproject"));
1239 assert!(!is_checked_import("datetime", "myproject"));
1240 }
1241
1242 #[test]
1243 fn visitor_flags_external_collaborators() {
1244 let found = unmocked(
1246 "widget",
1247 "myproject",
1248 "import requests\nimport subprocess\nimport json\nimport pytest\n",
1249 );
1250 assert_eq!(found.len(), 2, "got: {found:?}");
1251 assert!(found.contains(&"requests".to_string()));
1252 assert!(found.contains(&"subprocess".to_string()));
1253 }
1254
1255 #[test]
1256 fn visitor_type_checking_variants_and_plain_if() {
1257 assert!(unmocked(
1260 "widget",
1261 "myproject",
1262 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
1263 )
1264 .is_empty());
1265 assert_eq!(
1268 unmocked(
1269 "widget",
1270 "myproject",
1271 "if ready == 1:\n from myproject.ledger import record\n"
1272 ),
1273 vec!["myproject.ledger".to_string()]
1274 );
1275 }
1276
1277 #[test]
1278 fn find_unit_isolation_without_pyproject_reports_nothing() {
1279 let tree = TempDir::new();
1281 tree.write("widget_test.py", "from myproject.ledger import record\n");
1282 tree.write(".git", "");
1283 assert!(find_unit_isolation_violations(&tree.0)
1284 .expect("a readable tree should succeed")
1285 .is_empty());
1286 }
1287
1288 #[test]
1289 fn find_unit_isolation_walks_subdirs_and_flags() {
1290 let tree = TempDir::new();
1291 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1292 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1294 let found =
1295 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1296 assert_eq!(found.len(), 1, "got: {found:?}");
1297 assert_eq!(found[0].rule, "unmocked-collaborator");
1298 assert!(found[0].message.contains("myproject.ledger"));
1299 }
1300
1301 #[test]
1302 fn recognizes_python_test_files() {
1303 assert!(is_python_test_file(Path::new("widget_test.py")));
1304 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1305 assert!(is_python_test_file(Path::new("conftest.py")));
1306 assert!(!is_python_test_file(Path::new("test_widget.py")));
1308 }
1309
1310 #[test]
1311 fn ignores_non_test_files() {
1312 assert!(!is_python_test_file(Path::new("widget.py")));
1313 assert!(!is_python_test_file(Path::new("conftest.pyi")));
1314 assert!(!is_python_test_file(Path::new("README.md")));
1315 assert!(!is_python_test_file(Path::new("testing.py")));
1316 }
1317
1318 #[test]
1319 fn line_of_counts_newlines() {
1320 let src = "a\nb\nc\n";
1321 assert_eq!(line_of(src, TextSize::from(0)), 1);
1322 assert_eq!(line_of(src, TextSize::from(2)), 2);
1323 assert_eq!(line_of(src, TextSize::from(4)), 3);
1324 }
1325
1326 #[test]
1327 fn recognizes_environ_mutators() {
1328 assert!(is_environ_mutator("update"));
1329 assert!(is_environ_mutator("pop"));
1330 assert!(is_environ_mutator("clear"));
1331 assert!(!is_environ_mutator("get"));
1332 assert!(!is_environ_mutator("keys"));
1333 }
1334
1335 #[test]
1336 fn recognizes_upper_constants() {
1337 assert!(is_upper_constant("CACHE_DIR"));
1338 assert!(is_upper_constant("DEBUG"));
1339 assert!(is_upper_constant("MAX_2"));
1340 assert!(!is_upper_constant("cache_dir"));
1341 assert!(!is_upper_constant("CacheDir"));
1342 assert!(!is_upper_constant("fetch"));
1343 assert!(!is_upper_constant(""));
1344 assert!(!is_upper_constant("_"));
1345 assert!(!is_upper_constant("123"));
1346 }
1347}