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(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") else {
95 return Ok(Vec::new());
96 };
97 let Some(first_party) = first_party_package(root) else {
98 return Ok(Vec::new());
99 };
100 let mut files = Vec::new();
101 collect_python_files(root, &mut files, is_python_unit_test_file)?;
102 files.retain(|file| !file.starts_with(&tests));
104 files.sort();
105
106 let mut violations = Vec::new();
107 for file in &files {
108 let source = std::fs::read_to_string(file)
109 .with_context(|| format!("reading test file `{}`", file.display()))?;
110 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
111 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
112 let base = unit_under_test_base(file);
113 let mut visitor = UnitIsolationVisitor {
114 source: &source,
115 first_party: &first_party,
116 base: &base,
117 type_checking_depth: 0,
118 imports: Vec::new(),
119 patch_targets: Vec::new(),
120 };
121 for stmt in suite {
122 visitor.visit_stmt(stmt);
123 }
124 for import in &visitor.imports {
125 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
126 continue;
127 }
128 violations.push(Violation {
129 file: file.to_path_buf(),
130 line: import.line,
131 rule: "unmocked-collaborator",
132 message: format!(
133 "unit test imports `{}` without mocking it — a unit test isolates the \
134 unit under test, so mock every collaborator (patch it by string in a \
135 fixture)",
136 import.display
137 ),
138 });
139 }
140 }
141
142 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
143 Ok(violations)
144}
145
146struct ImportRecord {
148 display: String,
150 line: usize,
151 is_uut: bool,
152 symbols: Vec<String>,
154 source: Option<String>,
157 module: Option<String>,
159}
160
161impl ImportRecord {
162 fn is_mocked(&self, patch_targets: &[String]) -> bool {
166 if let Some(module) = &self.module {
167 let prefix = format!("{module}.");
168 return patch_targets
169 .iter()
170 .any(|target| target == module || target.starts_with(&prefix));
171 }
172 if self.symbols.is_empty() {
173 return false;
174 }
175 self.symbols.iter().all(|symbol| {
176 patch_targets
177 .iter()
178 .any(|target| self.symbol_is_mocked(target, symbol))
179 })
180 }
181
182 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
185 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
186 return false;
187 };
188 match &self.source {
189 Some(source) => module == source,
190 None => true,
191 }
192 }
193}
194
195struct UnitIsolationVisitor<'a> {
199 source: &'a str,
200 first_party: &'a str,
201 base: &'a str,
202 type_checking_depth: usize,
203 imports: Vec<ImportRecord>,
204 patch_targets: Vec<String>,
205}
206
207impl Visitor for UnitIsolationVisitor<'_> {
208 fn visit_stmt_import(&mut self, node: StmtImport) {
209 if self.type_checking_depth == 0 {
210 let line = line_of(self.source, node.range.start());
211 for alias in &node.names {
212 let module = alias.name.as_str();
213 if is_checked_import(import_head(module), self.first_party) {
214 self.imports.push(ImportRecord {
215 display: module.to_string(),
216 line,
217 is_uut: last_segment(module) == self.base,
218 symbols: Vec::new(),
219 source: None,
220 module: Some(module.to_string()),
221 });
222 }
223 }
224 }
225 self.generic_visit_stmt_import(node);
226 }
227
228 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
229 if self.type_checking_depth == 0 {
230 let level = relative_level(&node);
231 let module = node.module.as_ref().map(|m| m.as_str());
232 let should_check = level > 0
234 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
235 if should_check {
236 let line = line_of(self.source, node.range.start());
237 let dots = ".".repeat(level);
238 match module {
239 Some(module) => self.imports.push(ImportRecord {
241 display: format!("{dots}{module}"),
242 line,
243 is_uut: last_segment(module) == self.base,
244 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
245 source: (level == 0).then(|| module.to_string()),
246 module: None,
247 }),
248 None => {
250 let barrel_sut = self.base == "__init__" && level == 1;
253 for alias in &node.names {
254 let name = alias.name.as_str();
255 self.imports.push(ImportRecord {
256 display: format!("{dots}{name}"),
257 line,
258 is_uut: barrel_sut || name == self.base,
259 symbols: vec![name.to_string()],
260 source: None,
261 module: None,
262 });
263 }
264 }
265 }
266 }
267 }
268 self.generic_visit_stmt_import_from(node);
269 }
270
271 fn visit_expr_call(&mut self, node: ExprCall) {
272 if is_patch_call(&node) {
273 if let Some(target) = patch_string_target(&node) {
274 self.patch_targets.push(target.to_string());
275 }
276 }
277 self.generic_visit_expr_call(node);
278 }
279
280 fn visit_stmt_if(&mut self, node: StmtIf) {
281 if is_type_checking(node.test.as_ref()) {
283 self.type_checking_depth += 1;
284 for stmt in node.body {
285 self.visit_stmt(stmt);
286 }
287 self.type_checking_depth -= 1;
288 for stmt in node.orelse {
289 self.visit_stmt(stmt);
290 }
291 } else {
292 self.generic_visit_stmt_if(node);
293 }
294 }
295}
296
297fn import_head(module: &str) -> &str {
299 module.split('.').next().unwrap_or(module)
300}
301
302fn is_checked_import(head: &str, first_party: &str) -> bool {
305 if head == first_party {
306 return true;
307 }
308 if TEST_FRAMEWORK.contains(&head) {
309 return false;
310 }
311 if EFFECTFUL_STDLIB.contains(&head) {
312 return true;
313 }
314 if STDLIB_MODULES.contains(&head) {
315 return false;
316 }
317 true }
319
320const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
322
323const EFFECTFUL_STDLIB: &[&str] = &[
327 "asynchat",
328 "asyncore",
329 "ctypes",
330 "curses",
331 "dbm",
332 "fcntl",
333 "ftplib",
334 "imaplib",
335 "mmap",
336 "msvcrt",
337 "multiprocessing",
338 "nis",
339 "nntplib",
340 "ossaudiodev",
341 "poplib",
342 "pty",
343 "random",
344 "secrets",
345 "select",
346 "selectors",
347 "signal",
348 "smtpd",
349 "smtplib",
350 "socket",
351 "socketserver",
352 "spwd",
353 "sqlite3",
354 "ssl",
355 "subprocess",
356 "syslog",
357 "telnetlib",
358 "termios",
359 "tty",
360 "webbrowser",
361 "winreg",
362 "winsound",
363];
364
365const STDLIB_MODULES: &[&str] = &[
368 "abc",
369 "aifc",
370 "antigravity",
371 "argparse",
372 "array",
373 "ast",
374 "asynchat",
375 "asyncio",
376 "asyncore",
377 "atexit",
378 "audioop",
379 "base64",
380 "bdb",
381 "binascii",
382 "bisect",
383 "builtins",
384 "bz2",
385 "cProfile",
386 "calendar",
387 "cgi",
388 "cgitb",
389 "chunk",
390 "cmath",
391 "cmd",
392 "code",
393 "codecs",
394 "codeop",
395 "collections",
396 "colorsys",
397 "compileall",
398 "concurrent",
399 "configparser",
400 "contextlib",
401 "contextvars",
402 "copy",
403 "copyreg",
404 "crypt",
405 "csv",
406 "ctypes",
407 "curses",
408 "dataclasses",
409 "datetime",
410 "dbm",
411 "decimal",
412 "difflib",
413 "dis",
414 "distutils",
415 "doctest",
416 "email",
417 "encodings",
418 "ensurepip",
419 "enum",
420 "errno",
421 "faulthandler",
422 "fcntl",
423 "filecmp",
424 "fileinput",
425 "fnmatch",
426 "fractions",
427 "ftplib",
428 "functools",
429 "gc",
430 "genericpath",
431 "getopt",
432 "getpass",
433 "gettext",
434 "glob",
435 "graphlib",
436 "grp",
437 "gzip",
438 "hashlib",
439 "heapq",
440 "hmac",
441 "html",
442 "http",
443 "idlelib",
444 "imaplib",
445 "imghdr",
446 "imp",
447 "importlib",
448 "inspect",
449 "io",
450 "ipaddress",
451 "itertools",
452 "json",
453 "keyword",
454 "lib2to3",
455 "linecache",
456 "locale",
457 "logging",
458 "lzma",
459 "mailbox",
460 "mailcap",
461 "marshal",
462 "math",
463 "mimetypes",
464 "mmap",
465 "modulefinder",
466 "msilib",
467 "msvcrt",
468 "multiprocessing",
469 "netrc",
470 "nis",
471 "nntplib",
472 "nt",
473 "ntpath",
474 "nturl2path",
475 "numbers",
476 "opcode",
477 "operator",
478 "optparse",
479 "os",
480 "ossaudiodev",
481 "pathlib",
482 "pdb",
483 "pickle",
484 "pickletools",
485 "pipes",
486 "pkgutil",
487 "platform",
488 "plistlib",
489 "poplib",
490 "posix",
491 "posixpath",
492 "pprint",
493 "profile",
494 "pstats",
495 "pty",
496 "pwd",
497 "py_compile",
498 "pyclbr",
499 "pydoc",
500 "pydoc_data",
501 "pyexpat",
502 "queue",
503 "quopri",
504 "random",
505 "re",
506 "readline",
507 "reprlib",
508 "resource",
509 "rlcompleter",
510 "runpy",
511 "sched",
512 "secrets",
513 "select",
514 "selectors",
515 "shelve",
516 "shlex",
517 "shutil",
518 "signal",
519 "site",
520 "smtpd",
521 "smtplib",
522 "sndhdr",
523 "socket",
524 "socketserver",
525 "spwd",
526 "sqlite3",
527 "sre_compile",
528 "sre_constants",
529 "sre_parse",
530 "ssl",
531 "stat",
532 "statistics",
533 "string",
534 "stringprep",
535 "struct",
536 "subprocess",
537 "sunau",
538 "symtable",
539 "sys",
540 "sysconfig",
541 "syslog",
542 "tabnanny",
543 "tarfile",
544 "telnetlib",
545 "tempfile",
546 "termios",
547 "textwrap",
548 "this",
549 "threading",
550 "time",
551 "timeit",
552 "tkinter",
553 "token",
554 "tokenize",
555 "tomllib",
556 "trace",
557 "traceback",
558 "tracemalloc",
559 "tty",
560 "turtle",
561 "turtledemo",
562 "types",
563 "typing",
564 "unicodedata",
565 "unittest",
566 "urllib",
567 "uu",
568 "uuid",
569 "venv",
570 "warnings",
571 "wave",
572 "weakref",
573 "webbrowser",
574 "winreg",
575 "winsound",
576 "wsgiref",
577 "xdrlib",
578 "xml",
579 "xmlrpc",
580 "zipapp",
581 "zipfile",
582 "zipimport",
583 "zlib",
584 "zoneinfo",
585];
586
587fn last_segment(module: &str) -> &str {
589 module.rsplit('.').next().unwrap_or(module)
590}
591
592fn relative_level(node: &StmtImportFrom) -> usize {
594 node.level.map_or(0, |level| level.to_usize())
595}
596
597fn is_type_checking(test: &Expr) -> bool {
599 match test {
600 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
601 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
602 _ => false,
603 }
604}
605
606fn unit_under_test_base(file: &Path) -> String {
609 let name = file
610 .file_name()
611 .and_then(|n| n.to_str())
612 .unwrap_or_default();
613 let stem = name.strip_suffix(".py").unwrap_or(name);
614 stem.strip_suffix("_test").unwrap_or(stem).to_string()
615}
616
617struct LintVisitor<'a> {
620 file: &'a Path,
621 source: &'a str,
622 fixture_depth: usize,
623 first_party: Option<&'a str>,
625 imports: HashMap<String, String>,
627 violations: Vec<Violation>,
628}
629
630impl LintVisitor<'_> {
631 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
632 self.violations.push(Violation {
633 file: self.file.to_path_buf(),
634 line: line_of(self.source, range.start()),
635 rule,
636 message: message.to_string(),
637 });
638 }
639
640 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
642 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 if is_patch_call(&node) && self.fixture_depth == 0 {
688 self.report(
689 node.range,
690 "no-inline-patch",
691 "patch is called inline in a test body; move it into a `pytest.fixture`",
692 );
693 }
694 if let Some(target) = patch_target(&node, &self.imports) {
697 if patches_constant(&target) {
698 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
699 }
700 if let Some(pkg) = self.first_party {
701 if patches_first_party(&target, pkg) {
702 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
703 }
704 }
705 }
706 if is_environ_mutation_call(&node) {
707 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
708 }
709 self.generic_visit_expr_call(node);
710 }
711
712 fn visit_stmt_import(&mut self, node: StmtImport) {
713 for alias in &node.names {
714 match &alias.asname {
715 Some(asname) => {
717 self.imports
718 .insert(asname.to_string(), alias.name.to_string());
719 }
720 None => {
721 let head = import_head(alias.name.as_str());
722 self.imports.insert(head.to_string(), head.to_string());
723 }
724 }
725 }
726 self.generic_visit_stmt_import(node);
727 }
728
729 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
730 if relative_level(&node) == 0 {
732 if let Some(module) = &node.module {
733 for alias in &node.names {
734 let bound = alias.asname.as_ref().unwrap_or(&alias.name);
735 self.imports
736 .insert(bound.to_string(), format!("{module}.{}", alias.name));
737 }
738 }
739 }
740 self.generic_visit_stmt_import_from(node);
741 }
742
743 fn visit_withitem(&mut self, node: WithItem) {
746 self.visit_expr(node.context_expr);
747 if let Some(optional_vars) = node.optional_vars {
748 self.visit_expr(*optional_vars);
749 }
750 }
751
752 fn visit_stmt_assign(&mut self, node: StmtAssign) {
753 if node.targets.iter().any(is_os_environ_subscript) {
754 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
755 }
756 self.generic_visit_stmt_assign(node);
757 }
758
759 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
760 if is_os_environ_subscript(node.target.as_ref()) {
761 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
762 }
763 self.generic_visit_stmt_aug_assign(node);
764 }
765
766 fn visit_stmt_delete(&mut self, node: StmtDelete) {
767 if node.targets.iter().any(is_os_environ_subscript) {
768 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
769 }
770 self.generic_visit_stmt_delete(node);
771 }
772}
773
774fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
776 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
777}
778
779fn is_fixture_decorator(decorator: &Expr) -> bool {
781 let target = match decorator {
782 Expr::Call(call) => call.func.as_ref(),
783 other => other,
784 };
785 match target {
786 Expr::Name(name) => name.id.as_str() == "fixture",
787 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
788 _ => false,
789 }
790}
791
792enum PatchForm {
794 Target,
796 Object,
798 Dict,
800}
801
802fn patch_form(call: &ExprCall) -> Option<PatchForm> {
805 match call.func.as_ref() {
806 Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
807 Expr::Attribute(attr) => match attr.attr.as_str() {
808 "patch" => Some(PatchForm::Target),
809 "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
810 "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
811 _ => None,
812 },
813 _ => None,
814 }
815}
816
817fn is_patch_call(call: &ExprCall) -> bool {
819 patch_form(call).is_some()
820}
821
822fn attr_base_is_patch(expr: &Expr) -> bool {
824 match expr {
825 Expr::Name(name) => name.id.as_str() == "patch",
826 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
827 _ => false,
828 }
829}
830
831const 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)";
832
833const 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";
834
835fn patch_string_target(call: &ExprCall) -> Option<&str> {
838 string_arg(call, 0)
839}
840
841fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
843 if let Some(Expr::Constant(constant)) = call.args.get(index) {
844 if let Constant::Str(value) = &constant.value {
845 return Some(value.as_str());
846 }
847 }
848 None
849}
850
851fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
854 match expr {
855 Expr::Name(name) => Some(vec![name.id.as_str()]),
856 Expr::Attribute(attr) => {
857 let mut segments = attr_chain_segments(attr.value.as_ref())?;
858 segments.push(attr.attr.as_str());
859 Some(segments)
860 }
861 _ => None,
862 }
863}
864
865fn resolve_object_target(expr: &Expr, imports: &HashMap<String, String>) -> Option<String> {
869 let segments = attr_chain_segments(expr)?;
870 let (head, rest) = segments.split_first()?;
871 let mut target = imports.get(*head)?.clone();
872 for segment in rest {
873 target.push('.');
874 target.push_str(segment);
875 }
876 Some(target)
877}
878
879fn patch_target(call: &ExprCall, imports: &HashMap<String, String>) -> Option<String> {
883 match patch_form(call)? {
884 PatchForm::Target => patch_string_target(call).map(str::to_owned),
885 PatchForm::Dict => patch_string_target(call)
886 .map(str::to_owned)
887 .or_else(|| resolve_object_target(call.args.first()?, imports)),
888 PatchForm::Object => {
889 let base = resolve_object_target(call.args.first()?, imports)?;
890 Some(match string_arg(call, 1) {
891 Some(attr) => format!("{base}.{attr}"),
892 None => base,
893 })
894 }
895 }
896}
897
898fn patches_constant(target: &str) -> bool {
900 target.rsplit('.').next().is_some_and(is_upper_constant)
901}
902
903fn patches_first_party(target: &str, pkg: &str) -> bool {
905 target
906 .split('.')
907 .next()
908 .is_some_and(|head| !head.is_empty() && head == pkg)
909}
910
911fn is_upper_constant(name: &str) -> bool {
913 !name.is_empty()
914 && name
915 .chars()
916 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
917 && name.chars().any(|c| c.is_ascii_uppercase())
918}
919
920const ENVIRON_MUTATION_MSG: &str =
921 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
922
923fn is_os_environ(expr: &Expr) -> bool {
925 matches!(
926 expr,
927 Expr::Attribute(attr)
928 if attr.attr.as_str() == "environ"
929 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
930 )
931}
932
933fn is_os_environ_subscript(expr: &Expr) -> bool {
935 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
936}
937
938fn is_environ_mutation_call(call: &ExprCall) -> bool {
940 matches!(
941 call.func.as_ref(),
942 Expr::Attribute(attr)
943 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
944 )
945}
946
947fn is_environ_mutator(method: &str) -> bool {
949 matches!(
950 method,
951 "update" | "pop" | "setdefault" | "clear" | "popitem"
952 )
953}
954
955fn line_of(source: &str, offset: TextSize) -> usize {
957 let offset = (u32::from(offset) as usize).min(source.len());
958 source.as_bytes()[..offset]
959 .iter()
960 .filter(|&&byte| byte == b'\n')
961 .count()
962 + 1
963}
964
965fn first_party_package(root: &Path) -> Option<String> {
969 for dir in root.ancestors() {
970 let candidate = dir.join("pyproject.toml");
971 if candidate.is_file() {
972 return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
973 }
974 if dir.join(".git").exists() {
975 break;
976 }
977 }
978 None
979}
980
981fn read_project_name(path: &Path) -> Option<String> {
983 let contents = std::fs::read_to_string(path).ok()?;
984 let value: toml::Value = toml::from_str(&contents).ok()?;
985 value
986 .get("project")?
987 .get("name")?
988 .as_str()
989 .map(str::to_owned)
990}
991
992fn normalize_dist_name(name: &str) -> String {
995 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
996}
997
998fn collect_python_files(
999 dir: &Path,
1000 out: &mut Vec<PathBuf>,
1001 is_match: fn(&Path) -> bool,
1002) -> Result<()> {
1003 let entries =
1004 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1005 for entry in entries {
1006 let path = entry
1007 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1008 .path();
1009 if path.is_dir() {
1010 collect_python_files(&path, out, is_match)?;
1011 } else if is_match(&path) {
1012 out.push(path);
1013 }
1014 }
1015 Ok(())
1016}
1017
1018fn is_python_test_file(path: &Path) -> bool {
1021 let name = path
1022 .file_name()
1023 .and_then(|n| n.to_str())
1024 .unwrap_or_default();
1025 name == "conftest.py" || name.ends_with("_test.py")
1026}
1027
1028fn is_python_unit_test_file(path: &Path) -> bool {
1031 let name = path
1032 .file_name()
1033 .and_then(|n| n.to_str())
1034 .unwrap_or_default();
1035 name.ends_with("_test.py")
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use super::*;
1041 use std::sync::atomic::{AtomicU64, Ordering};
1042
1043 struct TempDir(PathBuf);
1045
1046 impl TempDir {
1047 fn new() -> Self {
1048 static COUNTER: AtomicU64 = AtomicU64::new(0);
1049 let dir = std::env::temp_dir().join(format!(
1050 "tc-lint-{}-{}",
1051 std::process::id(),
1052 COUNTER.fetch_add(1, Ordering::Relaxed),
1053 ));
1054 std::fs::create_dir_all(&dir).unwrap();
1055 TempDir(dir)
1056 }
1057
1058 fn write(&self, name: &str, contents: &str) {
1059 let path = self.0.join(name);
1060 if let Some(parent) = path.parent() {
1061 std::fs::create_dir_all(parent).unwrap();
1062 }
1063 std::fs::write(path, contents).unwrap();
1064 }
1065 }
1066
1067 impl Drop for TempDir {
1068 fn drop(&mut self) {
1069 let _ = std::fs::remove_dir_all(&self.0);
1070 }
1071 }
1072
1073 #[test]
1074 fn normalize_dist_name_maps_to_import_name() {
1075 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1076 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1077 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1078 assert_eq!(normalize_dist_name("myproject"), "myproject");
1079 }
1080
1081 fn parse_call(src: &str) -> ExprCall {
1083 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1084 let stmt = suite.into_iter().next().expect("one statement");
1085 (*stmt.expect_expr_stmt().value).expect_call_expr()
1086 }
1087
1088 #[test]
1089 fn patch_target_only_reads_string_literals_for_the_string_form() {
1090 let imports = HashMap::new();
1091 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1092 assert_eq!(
1093 patch_target(&str_call, &imports).as_deref(),
1094 Some("pkg.mod.attr")
1095 );
1096 let name_call = parse_call("patch(target)\n");
1098 assert_eq!(patch_target(&name_call, &imports), None);
1099 let int_call = parse_call("patch(42)\n");
1100 assert_eq!(patch_target(&int_call, &imports), None);
1101 let empty_call = parse_call("patch()\n");
1102 assert_eq!(patch_target(&empty_call, &imports), None);
1103 }
1104
1105 fn object_form_imports() -> HashMap<String, String> {
1107 HashMap::from([
1108 ("ledger".to_string(), "myproject.ledger".to_string()),
1109 ("myproject".to_string(), "myproject".to_string()),
1110 ("cfg".to_string(), "myproject.cfg".to_string()),
1111 ])
1112 }
1113
1114 #[test]
1115 fn patch_target_resolves_object_forms_through_imports() {
1116 let imports = object_form_imports();
1117 let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1118 assert_eq!(
1119 patch_target(&imported_name, &imports).as_deref(),
1120 Some("myproject.ledger.record")
1121 );
1122 let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1123 assert_eq!(
1124 patch_target(&dotted_module, &imports).as_deref(),
1125 Some("myproject.ledger.record")
1126 );
1127 let name_attr = parse_call("patch.object(ledger, attr)\n");
1129 assert_eq!(
1130 patch_target(&name_attr, &imports).as_deref(),
1131 Some("myproject.ledger")
1132 );
1133 let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1134 assert_eq!(
1135 patch_target(&dict_object, &imports).as_deref(),
1136 Some("myproject.cfg.SETTINGS")
1137 );
1138 let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1139 assert_eq!(
1140 patch_target(&dict_string, &imports).as_deref(),
1141 Some("pkg.cfg.FLAGS")
1142 );
1143 }
1144
1145 #[test]
1146 fn patch_target_declines_a_base_bound_by_no_import() {
1147 let imports = object_form_imports();
1148 let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1149 assert_eq!(patch_target(&call_base, &imports), None);
1150 let unbound_name = parse_call("patch.object(client, \"send\")\n");
1151 assert_eq!(patch_target(&unbound_name, &imports), None);
1152 let empty = parse_call("patch.object()\n");
1153 assert_eq!(patch_target(&empty, &imports), None);
1154 }
1155
1156 fn collect_imports(src: &str) -> HashMap<String, String> {
1158 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1159 let mut visitor = LintVisitor {
1160 file: Path::new("t.py"),
1161 source: src,
1162 fixture_depth: 0,
1163 first_party: None,
1164 imports: HashMap::new(),
1165 violations: Vec::new(),
1166 };
1167 for stmt in suite {
1168 visitor.visit_stmt(stmt);
1169 }
1170 visitor.imports
1171 }
1172
1173 #[test]
1174 fn lint_visitor_binds_imports_to_their_modules() {
1175 let imports = collect_imports(
1176 "import myproject.ledger\n\
1177 import myproject.config as cfg\n\
1178 from myproject import ledger\n\
1179 from myproject import charge as ch\n\
1180 from . import rel\n",
1181 );
1182 assert_eq!(
1183 imports.get("myproject").map(String::as_str),
1184 Some("myproject")
1185 );
1186 assert_eq!(
1187 imports.get("cfg").map(String::as_str),
1188 Some("myproject.config")
1189 );
1190 assert_eq!(
1191 imports.get("ledger").map(String::as_str),
1192 Some("myproject.ledger")
1193 );
1194 assert_eq!(
1195 imports.get("ch").map(String::as_str),
1196 Some("myproject.charge")
1197 );
1198 assert_eq!(imports.get("rel"), None);
1199 }
1200
1201 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1203 ImportRecord {
1204 display: source.unwrap_or(".rel").to_string(),
1205 line: 1,
1206 is_uut: false,
1207 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1208 source: source.map(str::to_string),
1209 module: None,
1210 }
1211 }
1212
1213 fn targets(list: &[&str]) -> Vec<String> {
1214 list.iter().map(|s| (*s).to_string()).collect()
1215 }
1216
1217 #[test]
1218 fn is_mocked_requires_every_symbol_at_the_import_module() {
1219 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1220 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1222 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1223 }
1224
1225 #[test]
1226 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1227 let rec = from_import(Some("pkg.ledger"), &["record"]);
1228 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1230 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1231 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1232 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1233 }
1234
1235 #[test]
1236 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1237 let rec = from_import(None, &["record"]);
1239 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1240 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1241 }
1242
1243 #[test]
1244 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1245 let rec = ImportRecord {
1246 display: "pkg.db".to_string(),
1247 line: 1,
1248 is_uut: false,
1249 symbols: Vec::new(),
1250 source: None,
1251 module: Some("pkg.db".to_string()),
1252 };
1253 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1254 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1255 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1256 let empty = from_import(Some("pkg.mod"), &[]);
1257 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1258 }
1259
1260 #[test]
1261 fn patches_first_party_matches_head_segment() {
1262 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1263 assert!(patches_first_party("myproject", "myproject"));
1264 assert!(!patches_first_party("requests.get", "myproject"));
1265 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1266 assert!(!patches_first_party("", "myproject"));
1267 assert!(!patches_first_party(".leading", "myproject"));
1268 }
1269
1270 #[test]
1271 fn first_party_package_reads_pyproject_name() {
1272 let tree = TempDir::new();
1273 tree.write(
1274 "pyproject.toml",
1275 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1276 );
1277 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1278 }
1279
1280 #[test]
1281 fn first_party_package_is_none_without_a_project_name() {
1282 let tree = TempDir::new();
1283 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1284 tree.write(".git", "");
1285 assert_eq!(first_party_package(&tree.0), None);
1286 }
1287
1288 #[test]
1289 fn first_party_package_is_none_when_absent() {
1290 let tree = TempDir::new();
1291 assert_eq!(first_party_package(&tree.0), None);
1292 }
1293
1294 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1296 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1297 let mut visitor = UnitIsolationVisitor {
1298 source,
1299 first_party,
1300 base,
1301 type_checking_depth: 0,
1302 imports: Vec::new(),
1303 patch_targets: Vec::new(),
1304 };
1305 for stmt in suite {
1306 visitor.visit_stmt(stmt);
1307 }
1308 visitor
1309 .imports
1310 .iter()
1311 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1312 .map(|i| i.display.clone())
1313 .collect()
1314 }
1315
1316 #[test]
1317 fn import_head_and_last_segment() {
1318 assert_eq!(import_head("myproject.db.conn"), "myproject");
1319 assert_eq!(import_head("requests"), "requests");
1320 assert_eq!(last_segment("myproject.db.conn"), "conn");
1321 assert_eq!(last_segment("widget"), "widget");
1322 }
1323
1324 #[test]
1325 fn unit_under_test_base_strips_test_suffix() {
1326 assert_eq!(
1327 unit_under_test_base(Path::new("pkg/widget_test.py")),
1328 "widget"
1329 );
1330 assert_eq!(
1332 unit_under_test_base(Path::new("test_widget.py")),
1333 "test_widget"
1334 );
1335 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1336 }
1337
1338 #[test]
1339 fn recognizes_python_unit_test_files() {
1340 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1341 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1342 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1343 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1344 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1345 }
1346
1347 #[test]
1348 fn visitor_flags_first_party_and_external_collaborators() {
1349 let found = unmocked(
1351 "widget",
1352 "myproject",
1353 "from myproject.widget import build\n\
1354 from myproject.ledger import record\n\
1355 import requests\n",
1356 );
1357 assert_eq!(
1358 found,
1359 vec!["myproject.ledger".to_string(), "requests".to_string()]
1360 );
1361 }
1362
1363 #[test]
1364 fn visitor_clears_a_mocked_collaborator() {
1365 let found = unmocked(
1366 "widget",
1367 "myproject",
1368 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1369 );
1370 assert!(found.is_empty(), "got: {found:?}");
1371 }
1372
1373 #[test]
1374 fn visitor_flags_a_wrong_module_patch() {
1375 let found = unmocked(
1378 "widget",
1379 "myproject",
1380 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1381 );
1382 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1383 }
1384
1385 #[test]
1386 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1387 let found = unmocked(
1389 "widget",
1390 "myproject",
1391 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1392 );
1393 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1394 let both = unmocked(
1395 "widget",
1396 "myproject",
1397 "from myproject.ledger import record, erase\n\
1398 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1399 );
1400 assert!(both.is_empty(), "got: {both:?}");
1401 }
1402
1403 #[test]
1404 fn visitor_handles_module_and_relative_imports() {
1405 assert_eq!(
1406 unmocked("widget", "myproject", "import myproject.db\n"),
1407 vec!["myproject.db".to_string()]
1408 );
1409 assert!(unmocked(
1410 "widget",
1411 "myproject",
1412 "import myproject.db\npatch(\"myproject.db.connect\")\n"
1413 )
1414 .is_empty());
1415 assert_eq!(
1416 unmocked("widget", "myproject", "from .ledger import record\n"),
1417 vec![".ledger".to_string()]
1418 );
1419 assert_eq!(
1420 unmocked(
1421 "widget",
1422 "myproject",
1423 "from . import ledger\nfrom . import widget\n"
1424 ),
1425 vec![".ledger".to_string()]
1426 );
1427 }
1428
1429 #[test]
1430 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1431 assert!(unmocked(
1433 "__init__",
1434 "myproject",
1435 "from . import Thing, __all__, __version__\n"
1436 )
1437 .is_empty());
1438 assert_eq!(
1440 unmocked("__init__", "myproject", "from .core import Thing\n"),
1441 vec![".core".to_string()]
1442 );
1443 assert_eq!(
1445 unmocked("__init__", "myproject", "from .. import sibling\n"),
1446 vec!["..sibling".to_string()]
1447 );
1448 assert_eq!(
1450 unmocked("widget", "myproject", "from . import ledger\n"),
1451 vec![".ledger".to_string()]
1452 );
1453 }
1454
1455 #[test]
1456 fn visitor_skips_type_checking_imports() {
1457 let found = unmocked(
1459 "widget",
1460 "myproject",
1461 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
1462 );
1463 assert_eq!(found, vec!["myproject.ledger".to_string()]);
1464 }
1465
1466 #[test]
1467 fn is_checked_import_classifies_origins() {
1468 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
1471 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
1473 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
1475 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
1477 assert!(!is_checked_import("os", "myproject"));
1479 assert!(!is_checked_import("pathlib", "myproject"));
1480 assert!(!is_checked_import("datetime", "myproject"));
1481 }
1482
1483 #[test]
1484 fn visitor_flags_external_collaborators() {
1485 let found = unmocked(
1486 "widget",
1487 "myproject",
1488 "import requests\nimport subprocess\nimport json\nimport pytest\n",
1489 );
1490 assert_eq!(found.len(), 2, "got: {found:?}");
1491 assert!(found.contains(&"requests".to_string()));
1492 assert!(found.contains(&"subprocess".to_string()));
1493 }
1494
1495 #[test]
1496 fn visitor_type_checking_variants_and_plain_if() {
1497 assert!(unmocked(
1499 "widget",
1500 "myproject",
1501 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
1502 )
1503 .is_empty());
1504 assert_eq!(
1506 unmocked(
1507 "widget",
1508 "myproject",
1509 "if ready == 1:\n from myproject.ledger import record\n"
1510 ),
1511 vec!["myproject.ledger".to_string()]
1512 );
1513 }
1514
1515 #[test]
1516 fn find_unit_isolation_without_pyproject_reports_nothing() {
1517 let tree = TempDir::new();
1518 tree.write("widget_test.py", "from myproject.ledger import record\n");
1519 tree.write(".git", "");
1520 assert!(find_unit_isolation_violations(&tree.0)
1521 .expect("a readable tree should succeed")
1522 .is_empty());
1523 }
1524
1525 #[test]
1526 fn find_unit_isolation_walks_subdirs_and_flags() {
1527 let tree = TempDir::new();
1528 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1529 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1530 let found =
1531 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1532 assert_eq!(found.len(), 1, "got: {found:?}");
1533 assert_eq!(found[0].rule, "unmocked-collaborator");
1534 assert!(found[0].message.contains("myproject.ledger"));
1535 }
1536
1537 #[test]
1538 fn recognizes_python_test_files() {
1539 assert!(is_python_test_file(Path::new("widget_test.py")));
1540 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1541 assert!(is_python_test_file(Path::new("conftest.py")));
1542 assert!(!is_python_test_file(Path::new("test_widget.py")));
1543 }
1544
1545 #[test]
1546 fn ignores_non_test_files() {
1547 assert!(!is_python_test_file(Path::new("widget.py")));
1548 assert!(!is_python_test_file(Path::new("conftest.pyi")));
1549 assert!(!is_python_test_file(Path::new("README.md")));
1550 assert!(!is_python_test_file(Path::new("testing.py")));
1551 }
1552
1553 #[test]
1554 fn line_of_counts_newlines() {
1555 let src = "a\nb\nc\n";
1556 assert_eq!(line_of(src, TextSize::from(0)), 1);
1557 assert_eq!(line_of(src, TextSize::from(2)), 2);
1558 assert_eq!(line_of(src, TextSize::from(4)), 3);
1559 }
1560
1561 #[test]
1562 fn recognizes_environ_mutators() {
1563 assert!(is_environ_mutator("update"));
1564 assert!(is_environ_mutator("pop"));
1565 assert!(is_environ_mutator("clear"));
1566 assert!(!is_environ_mutator("get"));
1567 assert!(!is_environ_mutator("keys"));
1568 }
1569
1570 fn lint_rules(source: &str) -> Vec<&'static str> {
1572 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1573 let mut visitor = LintVisitor {
1574 file: Path::new("t.py"),
1575 source,
1576 fixture_depth: 0,
1577 first_party: Some("myproject"),
1578 imports: HashMap::new(),
1579 violations: Vec::new(),
1580 };
1581 for stmt in suite {
1582 visitor.visit_stmt(stmt);
1583 }
1584 visitor.violations.iter().map(|v| v.rule).collect()
1585 }
1586
1587 #[test]
1588 fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
1589 assert!(
1590 lint_rules("@pytest.fixture\nasync def client():\n patch(\"pkg.mod.attr\")\n")
1591 .is_empty()
1592 );
1593 assert_eq!(
1594 lint_rules("async def widget_test():\n patch(\"pkg.mod.attr\")\n"),
1595 vec!["no-inline-patch"]
1596 );
1597 assert_eq!(
1598 lint_rules("async def widget_test(monkeypatch):\n pass\n"),
1599 vec!["no-monkeypatch"]
1600 );
1601 }
1602
1603 #[test]
1604 fn an_augmented_assignment_to_environ_is_a_mutation() {
1605 assert_eq!(
1606 lint_rules("def widget_test():\n os.environ[\"PATH\"] += \":/x\"\n"),
1607 vec!["no-environ-mutation"]
1608 );
1609 assert!(lint_rules("def widget_test():\n total += 1\n").is_empty());
1610 }
1611
1612 #[test]
1613 fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
1614 assert!(
1615 lint_rules("@fixture\ndef client():\n patch(\"pkg.mod.attr\")\n").is_empty(),
1616 "a bare `@fixture` shelters the patch"
1617 );
1618 assert_eq!(
1619 lint_rules("@registry[\"fixture\"]\ndef client():\n patch(\"pkg.mod.attr\")\n"),
1620 vec!["no-inline-patch"],
1621 "a subscripted decorator is not a fixture"
1622 );
1623 }
1624
1625 #[test]
1626 fn patch_object_is_recognized_only_through_a_patch_receiver() {
1627 assert_eq!(
1628 lint_rules("def widget_test():\n mock.patch.object(svc, \"send\")\n"),
1629 vec!["no-inline-patch"]
1630 );
1631 assert!(
1632 lint_rules("def widget_test():\n helpers[0].object(svc, \"send\")\n").is_empty(),
1633 "a subscripted receiver is not `patch`"
1634 );
1635 assert!(
1636 lint_rules("def widget_test():\n helpers[0](\"pkg.mod.attr\")\n").is_empty(),
1637 "a subscripted callee is not a patch call"
1638 );
1639 }
1640
1641 #[test]
1642 fn find_suite_without_a_tests_directory_reports_nothing() {
1643 let tree = TempDir::new();
1644 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1645 assert!(find_suite_violations(&tree.0)
1646 .expect("a readable tree should succeed")
1647 .is_empty());
1648 }
1649
1650 #[test]
1651 fn find_unit_isolation_without_a_project_name_reports_nothing() {
1652 let tree = TempDir::new();
1653 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1654 tree.write("widget_test.py", "from myproject.ledger import record\n");
1655 assert!(find_unit_isolation_violations(&tree.0)
1656 .expect("a readable tree should succeed")
1657 .is_empty());
1658 }
1659
1660 #[test]
1661 fn recognizes_upper_constants() {
1662 assert!(is_upper_constant("CACHE_DIR"));
1663 assert!(is_upper_constant("DEBUG"));
1664 assert!(is_upper_constant("MAX_2"));
1665 assert!(!is_upper_constant("cache_dir"));
1666 assert!(!is_upper_constant("CacheDir"));
1667 assert!(!is_upper_constant("fetch"));
1668 assert!(!is_upper_constant(""));
1669 assert!(!is_upper_constant("_"));
1670 assert!(!is_upper_constant("123"));
1671 }
1672}