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