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