1use std::collections::hash_map::Entry;
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{anyhow, Context, Result};
10use rustpython_ast::Visitor;
11use rustpython_parser::ast::{
12 self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
13 StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
14};
15use rustpython_parser::text_size::{TextRange, TextSize};
16use rustpython_parser::Parse;
17
18pub use crate::violation::Violation;
20
21pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
25 let root = root.as_ref();
26 let manifest = first_party_manifest(root);
28 let mut files = Vec::new();
29 collect_python_files(root, &mut files, is_python_test_file)?;
30 files.sort();
31
32 let mut violations = Vec::new();
33 for file in &files {
34 let source = std::fs::read_to_string(file)
35 .with_context(|| format!("reading test file `{}`", file.display()))?;
36 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
37 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
38 let mut visitor = LintVisitor {
39 file,
40 source: &source,
41 fixture_depth: 0,
42 first_party: manifest.as_ref().map(|(name, _)| name.as_str()),
43 source_root: manifest.as_ref().map(|(_, dir)| dir.as_path()),
44 imports: HashMap::new(),
45 declared_modules: HashSet::new(),
46 violations: Vec::new(),
47 };
48 for stmt in suite {
49 visitor.visit_stmt(stmt);
50 }
51 violations.append(&mut visitor.violations);
52 }
53
54 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
55 Ok(violations)
56}
57
58const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
59 a suite lives in `tests/integration/` or `tests/e2e/`";
60
61pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
65 let tests = package_root.join("tests");
66 let mut violations = Vec::new();
67 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
68 for tier in &tiers {
69 if tier.is_dir() {
70 violations.extend(find_violations(tier)?);
71 }
72 }
73 if tests.is_dir() {
74 let mut strays = Vec::new();
75 collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
76 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
77 for file in strays {
78 violations.push(Violation {
79 file,
80 line: 1,
81 rule: "unknown-tier",
82 message: UNKNOWN_TIER_MSG.to_string(),
83 });
84 }
85 }
86 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
87 Ok(violations)
88}
89
90pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
94 let root = root.as_ref();
95 let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") else {
98 return Ok(Vec::new());
99 };
100 let Some(first_party) = first_party_package(root) else {
101 return Ok(Vec::new());
102 };
103 let mut files = Vec::new();
104 collect_python_files(root, &mut files, is_python_unit_test_file)?;
105 files.retain(|file| !file.starts_with(&tests));
107 files.sort();
108
109 let mut violations = Vec::new();
110 for file in &files {
111 let source = std::fs::read_to_string(file)
112 .with_context(|| format!("reading test file `{}`", file.display()))?;
113 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
114 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
115 let base = unit_under_test_base(file);
116 let mut visitor = UnitIsolationVisitor {
117 source: &source,
118 first_party: &first_party,
119 base: &base,
120 type_checking_depth: 0,
121 imports: Vec::new(),
122 patch_targets: Vec::new(),
123 };
124 for stmt in suite {
125 visitor.visit_stmt(stmt);
126 }
127 for import in &visitor.imports {
128 if import.is_uut || import.is_mocked(&visitor.patch_targets) {
129 continue;
130 }
131 violations.push(Violation {
132 file: file.to_path_buf(),
133 line: import.line,
134 rule: "unmocked-collaborator",
135 message: format!(
136 "unit test imports `{}` without mocking it — a unit test isolates the \
137 unit under test, so mock every collaborator (patch it by string in a \
138 fixture)",
139 import.display
140 ),
141 });
142 }
143 }
144
145 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
146 Ok(violations)
147}
148
149struct ImportRecord {
151 display: String,
153 line: usize,
154 is_uut: bool,
155 symbols: Vec<String>,
157 source: Option<String>,
160 module: Option<String>,
162}
163
164impl ImportRecord {
165 fn is_mocked(&self, patch_targets: &[String]) -> bool {
169 if let Some(module) = &self.module {
170 let prefix = format!("{module}.");
171 return patch_targets
172 .iter()
173 .any(|target| target == module || target.starts_with(&prefix));
174 }
175 if self.symbols.is_empty() {
176 return false;
177 }
178 self.symbols.iter().all(|symbol| {
179 patch_targets
180 .iter()
181 .any(|target| self.symbol_is_mocked(target, symbol))
182 })
183 }
184
185 fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
188 let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
189 return false;
190 };
191 match &self.source {
192 Some(source) => module == source,
193 None => true,
194 }
195 }
196}
197
198struct UnitIsolationVisitor<'a> {
202 source: &'a str,
203 first_party: &'a str,
204 base: &'a str,
205 type_checking_depth: usize,
206 imports: Vec<ImportRecord>,
207 patch_targets: Vec<String>,
208}
209
210impl Visitor for UnitIsolationVisitor<'_> {
211 fn visit_stmt_import(&mut self, node: StmtImport) {
212 if self.type_checking_depth == 0 {
213 let line = line_of(self.source, node.range.start());
214 for alias in &node.names {
215 let module = alias.name.as_str();
216 if is_checked_import(import_head(module), self.first_party) {
217 self.imports.push(ImportRecord {
218 display: module.to_string(),
219 line,
220 is_uut: last_segment(module) == self.base,
221 symbols: Vec::new(),
222 source: None,
223 module: Some(module.to_string()),
224 });
225 }
226 }
227 }
228 self.generic_visit_stmt_import(node);
229 }
230
231 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
232 if self.type_checking_depth == 0 {
233 let level = relative_level(&node);
234 let module = node.module.as_ref().map(|m| m.as_str());
235 let should_check = level > 0
237 || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
238 if should_check {
239 let line = line_of(self.source, node.range.start());
240 let dots = ".".repeat(level);
241 match module {
242 Some(module) => self.imports.push(ImportRecord {
244 display: format!("{dots}{module}"),
245 line,
246 is_uut: last_segment(module) == self.base,
247 symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
248 source: (level == 0).then(|| module.to_string()),
249 module: None,
250 }),
251 None => {
253 let barrel_sut = self.base == "__init__" && level == 1;
256 for alias in &node.names {
257 let name = alias.name.as_str();
258 self.imports.push(ImportRecord {
259 display: format!("{dots}{name}"),
260 line,
261 is_uut: barrel_sut || name == self.base,
262 symbols: vec![name.to_string()],
263 source: None,
264 module: None,
265 });
266 }
267 }
268 }
269 }
270 }
271 self.generic_visit_stmt_import_from(node);
272 }
273
274 fn visit_expr_call(&mut self, node: ExprCall) {
275 if is_patch_call(&node) {
276 if let Some(target) = patch_string_target(&node) {
277 self.patch_targets.push(target.to_string());
278 }
279 }
280 self.generic_visit_expr_call(node);
281 }
282
283 fn visit_stmt_if(&mut self, node: StmtIf) {
284 if is_type_checking(node.test.as_ref()) {
286 self.type_checking_depth += 1;
287 for stmt in node.body {
288 self.visit_stmt(stmt);
289 }
290 self.type_checking_depth -= 1;
291 for stmt in node.orelse {
292 self.visit_stmt(stmt);
293 }
294 } else {
295 self.generic_visit_stmt_if(node);
296 }
297 }
298}
299
300fn import_head(module: &str) -> &str {
302 module.split('.').next().unwrap_or(module)
303}
304
305fn is_checked_import(head: &str, first_party: &str) -> bool {
308 if head == first_party {
309 return true;
310 }
311 if TEST_FRAMEWORK.contains(&head) {
312 return false;
313 }
314 if EFFECTFUL_STDLIB.contains(&head) {
315 return true;
316 }
317 if STDLIB_MODULES.contains(&head) {
318 return false;
319 }
320 true }
322
323const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
325
326const EFFECTFUL_STDLIB: &[&str] = &[
330 "asynchat",
331 "asyncore",
332 "ctypes",
333 "curses",
334 "dbm",
335 "fcntl",
336 "ftplib",
337 "imaplib",
338 "mmap",
339 "msvcrt",
340 "multiprocessing",
341 "nis",
342 "nntplib",
343 "ossaudiodev",
344 "poplib",
345 "pty",
346 "random",
347 "secrets",
348 "select",
349 "selectors",
350 "signal",
351 "smtpd",
352 "smtplib",
353 "socket",
354 "socketserver",
355 "spwd",
356 "sqlite3",
357 "ssl",
358 "subprocess",
359 "syslog",
360 "telnetlib",
361 "termios",
362 "tty",
363 "webbrowser",
364 "winreg",
365 "winsound",
366];
367
368const STDLIB_MODULES: &[&str] = &[
371 "__future__",
372 "_abc",
373 "_aix_support",
374 "_ast",
375 "_asyncio",
376 "_bisect",
377 "_blake2",
378 "_bz2",
379 "_codecs",
380 "_codecs_cn",
381 "_codecs_hk",
382 "_codecs_iso2022",
383 "_codecs_jp",
384 "_codecs_kr",
385 "_codecs_tw",
386 "_collections",
387 "_collections_abc",
388 "_compat_pickle",
389 "_compression",
390 "_contextvars",
391 "_crypt",
392 "_csv",
393 "_ctypes",
394 "_curses",
395 "_curses_panel",
396 "_datetime",
397 "_dbm",
398 "_decimal",
399 "_elementtree",
400 "_frozen_importlib",
401 "_frozen_importlib_external",
402 "_functools",
403 "_gdbm",
404 "_hashlib",
405 "_heapq",
406 "_imp",
407 "_io",
408 "_json",
409 "_locale",
410 "_lsprof",
411 "_lzma",
412 "_markupbase",
413 "_md5",
414 "_msi",
415 "_multibytecodec",
416 "_multiprocessing",
417 "_opcode",
418 "_operator",
419 "_osx_support",
420 "_overlapped",
421 "_pickle",
422 "_posixshmem",
423 "_posixsubprocess",
424 "_py_abc",
425 "_pydatetime",
426 "_pydecimal",
427 "_pyio",
428 "_pylong",
429 "_queue",
430 "_random",
431 "_scproxy",
432 "_sha1",
433 "_sha2",
434 "_sha3",
435 "_signal",
436 "_sitebuiltins",
437 "_socket",
438 "_sqlite3",
439 "_sre",
440 "_ssl",
441 "_stat",
442 "_statistics",
443 "_string",
444 "_strptime",
445 "_struct",
446 "_symtable",
447 "_thread",
448 "_threading_local",
449 "_tkinter",
450 "_tokenize",
451 "_tracemalloc",
452 "_typing",
453 "_uuid",
454 "_warnings",
455 "_weakref",
456 "_weakrefset",
457 "_winapi",
458 "_zoneinfo",
459 "abc",
460 "aifc",
461 "antigravity",
462 "argparse",
463 "array",
464 "ast",
465 "asynchat",
466 "asyncio",
467 "asyncore",
468 "atexit",
469 "audioop",
470 "base64",
471 "bdb",
472 "binascii",
473 "bisect",
474 "builtins",
475 "bz2",
476 "cProfile",
477 "calendar",
478 "cgi",
479 "cgitb",
480 "chunk",
481 "cmath",
482 "cmd",
483 "code",
484 "codecs",
485 "codeop",
486 "collections",
487 "colorsys",
488 "compileall",
489 "concurrent",
490 "configparser",
491 "contextlib",
492 "contextvars",
493 "copy",
494 "copyreg",
495 "crypt",
496 "csv",
497 "ctypes",
498 "curses",
499 "dataclasses",
500 "datetime",
501 "dbm",
502 "decimal",
503 "difflib",
504 "dis",
505 "distutils",
506 "doctest",
507 "email",
508 "encodings",
509 "ensurepip",
510 "enum",
511 "errno",
512 "faulthandler",
513 "fcntl",
514 "filecmp",
515 "fileinput",
516 "fnmatch",
517 "fractions",
518 "ftplib",
519 "functools",
520 "gc",
521 "genericpath",
522 "getopt",
523 "getpass",
524 "gettext",
525 "glob",
526 "graphlib",
527 "grp",
528 "gzip",
529 "hashlib",
530 "heapq",
531 "hmac",
532 "html",
533 "http",
534 "idlelib",
535 "imaplib",
536 "imghdr",
537 "imp",
538 "importlib",
539 "inspect",
540 "io",
541 "ipaddress",
542 "itertools",
543 "json",
544 "keyword",
545 "lib2to3",
546 "linecache",
547 "locale",
548 "logging",
549 "lzma",
550 "mailbox",
551 "mailcap",
552 "marshal",
553 "math",
554 "mimetypes",
555 "mmap",
556 "modulefinder",
557 "msilib",
558 "msvcrt",
559 "multiprocessing",
560 "netrc",
561 "nis",
562 "nntplib",
563 "nt",
564 "ntpath",
565 "nturl2path",
566 "numbers",
567 "opcode",
568 "operator",
569 "optparse",
570 "os",
571 "ossaudiodev",
572 "pathlib",
573 "pdb",
574 "pickle",
575 "pickletools",
576 "pipes",
577 "pkgutil",
578 "platform",
579 "plistlib",
580 "poplib",
581 "posix",
582 "posixpath",
583 "pprint",
584 "profile",
585 "pstats",
586 "pty",
587 "pwd",
588 "py_compile",
589 "pyclbr",
590 "pydoc",
591 "pydoc_data",
592 "pyexpat",
593 "queue",
594 "quopri",
595 "random",
596 "re",
597 "readline",
598 "reprlib",
599 "resource",
600 "rlcompleter",
601 "runpy",
602 "sched",
603 "secrets",
604 "select",
605 "selectors",
606 "shelve",
607 "shlex",
608 "shutil",
609 "signal",
610 "site",
611 "smtpd",
612 "smtplib",
613 "sndhdr",
614 "socket",
615 "socketserver",
616 "spwd",
617 "sqlite3",
618 "sre_compile",
619 "sre_constants",
620 "sre_parse",
621 "ssl",
622 "stat",
623 "statistics",
624 "string",
625 "stringprep",
626 "struct",
627 "subprocess",
628 "sunau",
629 "symtable",
630 "sys",
631 "sysconfig",
632 "syslog",
633 "tabnanny",
634 "tarfile",
635 "telnetlib",
636 "tempfile",
637 "termios",
638 "textwrap",
639 "this",
640 "threading",
641 "time",
642 "timeit",
643 "tkinter",
644 "token",
645 "tokenize",
646 "tomllib",
647 "trace",
648 "traceback",
649 "tracemalloc",
650 "tty",
651 "turtle",
652 "turtledemo",
653 "types",
654 "typing",
655 "unicodedata",
656 "unittest",
657 "urllib",
658 "uu",
659 "uuid",
660 "venv",
661 "warnings",
662 "wave",
663 "weakref",
664 "webbrowser",
665 "winreg",
666 "winsound",
667 "wsgiref",
668 "xdrlib",
669 "xml",
670 "xmlrpc",
671 "zipapp",
672 "zipfile",
673 "zipimport",
674 "zlib",
675 "zoneinfo",
676];
677
678fn last_segment(module: &str) -> &str {
680 module.rsplit('.').next().unwrap_or(module)
681}
682
683fn relative_level(node: &StmtImportFrom) -> usize {
685 node.level.map_or(0, |level| level.to_usize())
686}
687
688fn is_type_checking(test: &Expr) -> bool {
690 match test {
691 Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
692 Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
693 _ => false,
694 }
695}
696
697fn unit_under_test_base(file: &Path) -> String {
700 let name = file
701 .file_name()
702 .and_then(|n| n.to_str())
703 .unwrap_or_default();
704 let stem = name.strip_suffix(".py").unwrap_or(name);
705 stem.strip_suffix("_test").unwrap_or(stem).to_string()
706}
707
708struct LintVisitor<'a> {
711 file: &'a Path,
712 source: &'a str,
713 fixture_depth: usize,
714 first_party: Option<&'a str>,
716 source_root: Option<&'a Path>,
718 imports: HashMap<String, String>,
720 declared_modules: HashSet<String>,
722 violations: Vec<Violation>,
723}
724
725impl LintVisitor<'_> {
726 fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
727 self.violations.push(Violation {
728 file: self.file.to_path_buf(),
729 line: line_of(self.source, range.start()),
730 rule,
731 message: message.to_string(),
732 });
733 }
734
735 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
737 let takes_monkeypatch = args
738 .posonlyargs
739 .iter()
740 .chain(&args.args)
741 .chain(&args.kwonlyargs)
742 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
743 || arg_named(&args.vararg, "monkeypatch")
744 || arg_named(&args.kwarg, "monkeypatch");
745 if takes_monkeypatch {
746 self.report(
747 range,
748 "no-monkeypatch",
749 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
750 );
751 }
752
753 decorators.iter().any(is_fixture_decorator)
754 }
755
756 fn declare_module(&mut self, module: &str) {
757 let mut path = String::new();
758 for segment in module.split('.') {
759 if !path.is_empty() {
760 path.push('.');
761 }
762 path.push_str(segment);
763 self.declared_modules.insert(path.clone());
764 }
765 }
766
767 fn resolve_ctx(&self) -> ResolveCtx<'_> {
768 ResolveCtx {
769 imports: &self.imports,
770 declared_modules: &self.declared_modules,
771 first_party: self.first_party,
772 source_root: self.source_root,
773 }
774 }
775}
776
777impl Visitor for LintVisitor<'_> {
778 fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
779 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
780 if is_fixture {
781 self.fixture_depth += 1;
782 }
783 self.generic_visit_stmt_function_def(node);
784 if is_fixture {
785 self.fixture_depth -= 1;
786 }
787 }
788
789 fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
790 let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
791 if is_fixture {
792 self.fixture_depth += 1;
793 }
794 self.generic_visit_stmt_async_function_def(node);
795 if is_fixture {
796 self.fixture_depth -= 1;
797 }
798 }
799
800 fn visit_expr_call(&mut self, node: ExprCall) {
801 if is_patch_call(&node) && self.fixture_depth == 0 {
803 self.report(
804 node.range,
805 "no-inline-patch",
806 "patch is called inline in a test body; move it into a `pytest.fixture`",
807 );
808 }
809 if let Some(target) = patch_target(&node, &self.resolve_ctx()) {
812 if patches_constant(&target) {
813 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
814 }
815 if let Some(pkg) = self.first_party {
816 if patches_first_party(&target, pkg) {
817 self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
818 }
819 }
820 }
821 if is_environ_mutation_call(&node) {
822 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
823 }
824 self.generic_visit_expr_call(node);
825 }
826
827 fn visit_stmt_import(&mut self, node: StmtImport) {
828 for alias in &node.names {
829 self.declare_module(alias.name.as_str());
830 match &alias.asname {
831 Some(asname) => {
833 self.imports
834 .insert(asname.to_string(), alias.name.to_string());
835 }
836 None => {
837 let head = import_head(alias.name.as_str());
838 self.imports.insert(head.to_string(), head.to_string());
839 }
840 }
841 }
842 self.generic_visit_stmt_import(node);
843 }
844
845 fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
846 if relative_level(&node) == 0 {
848 if let Some(module) = &node.module {
849 self.declare_module(module.as_str());
850 for alias in &node.names {
851 let bound = alias.asname.as_ref().unwrap_or(&alias.name);
852 self.imports
853 .insert(bound.to_string(), format!("{module}.{}", alias.name));
854 }
855 }
856 }
857 self.generic_visit_stmt_import_from(node);
858 }
859
860 fn visit_withitem(&mut self, node: WithItem) {
863 self.visit_expr(node.context_expr);
864 if let Some(optional_vars) = node.optional_vars {
865 self.visit_expr(*optional_vars);
866 }
867 }
868
869 fn visit_stmt_assign(&mut self, node: StmtAssign) {
870 if node.targets.iter().any(is_os_environ_subscript) {
871 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
872 }
873 self.generic_visit_stmt_assign(node);
874 }
875
876 fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
877 if is_os_environ_subscript(node.target.as_ref()) {
878 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
879 }
880 self.generic_visit_stmt_aug_assign(node);
881 }
882
883 fn visit_stmt_delete(&mut self, node: StmtDelete) {
884 if node.targets.iter().any(is_os_environ_subscript) {
885 self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
886 }
887 self.generic_visit_stmt_delete(node);
888 }
889}
890
891fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
893 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
894}
895
896fn is_fixture_decorator(decorator: &Expr) -> bool {
898 let target = match decorator {
899 Expr::Call(call) => call.func.as_ref(),
900 other => other,
901 };
902 match target {
903 Expr::Name(name) => name.id.as_str() == "fixture",
904 Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
905 _ => false,
906 }
907}
908
909enum PatchForm {
911 Target,
913 Object,
915 Dict,
917}
918
919fn patch_form(call: &ExprCall) -> Option<PatchForm> {
922 match call.func.as_ref() {
923 Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
924 Expr::Attribute(attr) => match attr.attr.as_str() {
925 "patch" => Some(PatchForm::Target),
926 "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
927 "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
928 _ => None,
929 },
930 _ => None,
931 }
932}
933
934fn is_patch_call(call: &ExprCall) -> bool {
936 patch_form(call).is_some()
937}
938
939fn attr_base_is_patch(expr: &Expr) -> bool {
941 match expr {
942 Expr::Name(name) => name.id.as_str() == "patch",
943 Expr::Attribute(attr) => attr.attr.as_str() == "patch",
944 _ => false,
945 }
946}
947
948const 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)";
949
950const 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";
951
952fn patch_string_target(call: &ExprCall) -> Option<&str> {
955 string_arg(call, 0)
956}
957
958fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
960 if let Some(Expr::Constant(constant)) = call.args.get(index) {
961 if let Constant::Str(value) = &constant.value {
962 return Some(value.as_str());
963 }
964 }
965 None
966}
967
968fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
971 match expr {
972 Expr::Name(name) => Some(vec![name.id.as_str()]),
973 Expr::Attribute(attr) => {
974 let mut segments = attr_chain_segments(attr.value.as_ref())?;
975 segments.push(attr.attr.as_str());
976 Some(segments)
977 }
978 _ => None,
979 }
980}
981
982struct ResolveCtx<'a> {
984 imports: &'a HashMap<String, String>,
986 declared_modules: &'a HashSet<String>,
988 first_party: Option<&'a str>,
989 source_root: Option<&'a Path>,
990}
991
992fn resolve_object_target(expr: &Expr, ctx: &ResolveCtx) -> Option<String> {
996 let segments = attr_chain_segments(expr)?;
997 let (head, rest) = segments.split_first()?;
998 let mut target = ctx.imports.get(*head)?.clone();
999 for (index, segment) in rest.iter().enumerate() {
1000 let candidate = format!("{target}.{segment}");
1001 if ctx.declared_modules.contains(&candidate) {
1002 target = candidate;
1003 continue;
1004 }
1005 let first_party_root = ctx
1006 .first_party
1007 .zip(ctx.source_root)
1008 .filter(|(pkg, _)| target.split('.').next() == Some(*pkg));
1009 let Some((_, root)) = first_party_root else {
1010 return Some(append_segments(target, &rest[index..]));
1011 };
1012 target = match module_attribute(root, &target, segment)? {
1013 Attr::Module(absolute) => absolute,
1014 Attr::Defined if index == rest.len() - 1 => candidate,
1015 Attr::Defined => return None,
1016 };
1017 }
1018 Some(target)
1019}
1020
1021fn append_segments(mut target: String, segments: &[&str]) -> String {
1022 for segment in segments {
1023 target.push('.');
1024 target.push_str(segment);
1025 }
1026 target
1027}
1028
1029enum Attr {
1031 Module(String),
1033 Defined,
1035}
1036
1037fn module_attribute(root: &Path, module: &str, name: &str) -> Option<Attr> {
1040 let file = locate_module(root, module)?;
1041 let source = std::fs::read_to_string(&file.path).ok()?;
1042 let scope = module_scope(&source, &file.package)?;
1043 match scope.bindings.get(name) {
1044 Some(Binding::Module(absolute)) => Some(Attr::Module(absolute.clone())),
1045 Some(Binding::Defined) => Some(Attr::Defined),
1046 Some(Binding::Opaque) => None,
1047 None if scope.has_star_import => None,
1048 None => {
1049 let submodule = format!("{module}.{name}");
1050 locate_module(root, &submodule).map(|_| Attr::Module(submodule))
1051 }
1052 }
1053}
1054
1055struct ModuleFile {
1057 path: PathBuf,
1058 package: Vec<String>,
1059}
1060
1061fn locate_module(root: &Path, module: &str) -> Option<ModuleFile> {
1063 let segments: Vec<String> = module.split('.').map(str::to_owned).collect();
1064 let rel: PathBuf = segments.iter().collect();
1065 for base in [root.to_path_buf(), root.join("src")] {
1066 let file = base.join(&rel).with_extension("py");
1067 if file.is_file() {
1068 let package = segments[..segments.len() - 1].to_vec();
1069 return Some(ModuleFile {
1070 path: file,
1071 package,
1072 });
1073 }
1074 let init = base.join(&rel).join("__init__.py");
1075 if init.is_file() {
1076 return Some(ModuleFile {
1077 path: init,
1078 package: segments,
1079 });
1080 }
1081 }
1082 None
1083}
1084
1085#[derive(Debug, PartialEq)]
1087enum Binding {
1088 Module(String),
1090 Defined,
1092 Opaque,
1094}
1095
1096struct ModuleScope {
1097 bindings: HashMap<String, Binding>,
1098 has_star_import: bool,
1099}
1100
1101impl ModuleScope {
1102 fn bind(&mut self, name: String, binding: Binding) {
1103 match self.bindings.entry(name) {
1104 Entry::Occupied(mut entry) => {
1105 if *entry.get() != binding {
1106 entry.insert(Binding::Opaque);
1107 }
1108 }
1109 Entry::Vacant(entry) => {
1110 entry.insert(binding);
1111 }
1112 }
1113 }
1114
1115 fn bind_import_from(&mut self, node: &StmtImportFrom, package: &[String]) {
1116 let base = match relative_level(node) {
1117 0 => node.module.as_ref().map(|module| module.to_string()),
1118 level if level <= package.len() => {
1120 let parent = package[..package.len() + 1 - level].join(".");
1121 Some(match &node.module {
1122 Some(module) => format!("{parent}.{module}"),
1123 None => parent,
1124 })
1125 }
1126 _ => None,
1127 };
1128 for alias in &node.names {
1129 if alias.name.as_str() == "*" {
1130 self.has_star_import = true;
1131 continue;
1132 }
1133 let bound = alias.asname.as_ref().unwrap_or(&alias.name).to_string();
1134 let binding = match &base {
1135 Some(base) => Binding::Module(format!("{base}.{}", alias.name)),
1136 None => Binding::Opaque,
1137 };
1138 self.bind(bound, binding);
1139 }
1140 }
1141
1142 fn bind_assign_target(&mut self, target: &Expr, literal: bool) {
1143 match target {
1144 Expr::Name(name) => {
1145 let binding = if literal {
1146 Binding::Defined
1147 } else {
1148 Binding::Opaque
1149 };
1150 self.bind(name.id.to_string(), binding);
1151 }
1152 Expr::Tuple(tuple) => {
1153 for elt in &tuple.elts {
1154 self.bind_assign_target(elt, false);
1155 }
1156 }
1157 Expr::List(list) => {
1158 for elt in &list.elts {
1159 self.bind_assign_target(elt, false);
1160 }
1161 }
1162 _ => {}
1163 }
1164 }
1165}
1166
1167fn module_scope(source: &str, package: &[String]) -> Option<ModuleScope> {
1170 let suite = ast::Suite::parse(source, "module.py").ok()?;
1171 let mut scope = ModuleScope {
1172 bindings: HashMap::new(),
1173 has_star_import: false,
1174 };
1175 for stmt in &suite {
1176 match stmt {
1177 ast::Stmt::Import(node) => {
1178 for alias in &node.names {
1179 match &alias.asname {
1180 Some(asname) => {
1181 scope.bind(asname.to_string(), Binding::Module(alias.name.to_string()));
1182 }
1183 None => {
1184 let head = import_head(alias.name.as_str());
1185 scope.bind(head.to_string(), Binding::Module(head.to_string()));
1186 }
1187 }
1188 }
1189 }
1190 ast::Stmt::ImportFrom(node) => scope.bind_import_from(node, package),
1191 ast::Stmt::FunctionDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1192 ast::Stmt::AsyncFunctionDef(node) => {
1193 scope.bind(node.name.to_string(), Binding::Defined);
1194 }
1195 ast::Stmt::ClassDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1196 ast::Stmt::Assign(node) => {
1197 let literal = is_literal(&node.value);
1198 for target in &node.targets {
1199 scope.bind_assign_target(target, literal);
1200 }
1201 }
1202 ast::Stmt::AnnAssign(node) => {
1203 if let Some(value) = &node.value {
1204 scope.bind_assign_target(&node.target, is_literal(value));
1205 }
1206 }
1207 _ => {}
1208 }
1209 }
1210 Some(scope)
1211}
1212
1213fn is_literal(expr: &Expr) -> bool {
1215 match expr {
1216 Expr::Constant(_) => true,
1217 Expr::UnaryOp(op) => is_literal(&op.operand),
1218 Expr::Dict(dict) => {
1219 dict.keys.iter().flatten().all(is_literal) && dict.values.iter().all(is_literal)
1220 }
1221 Expr::List(list) => list.elts.iter().all(is_literal),
1222 Expr::Tuple(tuple) => tuple.elts.iter().all(is_literal),
1223 Expr::Set(set) => set.elts.iter().all(is_literal),
1224 _ => false,
1225 }
1226}
1227
1228fn patch_target(call: &ExprCall, ctx: &ResolveCtx) -> Option<String> {
1232 match patch_form(call)? {
1233 PatchForm::Target => patch_string_target(call).map(str::to_owned),
1234 PatchForm::Dict => patch_string_target(call)
1235 .map(str::to_owned)
1236 .or_else(|| resolve_object_target(call.args.first()?, ctx)),
1237 PatchForm::Object => {
1238 let base = resolve_object_target(call.args.first()?, ctx)?;
1239 Some(match string_arg(call, 1) {
1240 Some(attr) => format!("{base}.{attr}"),
1241 None => base,
1242 })
1243 }
1244 }
1245}
1246
1247fn patches_constant(target: &str) -> bool {
1249 target.rsplit('.').next().is_some_and(is_upper_constant)
1250}
1251
1252fn patches_first_party(target: &str, pkg: &str) -> bool {
1254 target
1255 .split('.')
1256 .next()
1257 .is_some_and(|head| !head.is_empty() && head == pkg)
1258}
1259
1260fn is_upper_constant(name: &str) -> bool {
1262 !name.is_empty()
1263 && name
1264 .chars()
1265 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1266 && name.chars().any(|c| c.is_ascii_uppercase())
1267}
1268
1269const ENVIRON_MUTATION_MSG: &str =
1270 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1271
1272fn is_os_environ(expr: &Expr) -> bool {
1274 matches!(
1275 expr,
1276 Expr::Attribute(attr)
1277 if attr.attr.as_str() == "environ"
1278 && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1279 )
1280}
1281
1282fn is_os_environ_subscript(expr: &Expr) -> bool {
1284 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1285}
1286
1287fn is_environ_mutation_call(call: &ExprCall) -> bool {
1289 matches!(
1290 call.func.as_ref(),
1291 Expr::Attribute(attr)
1292 if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1293 )
1294}
1295
1296fn is_environ_mutator(method: &str) -> bool {
1298 matches!(
1299 method,
1300 "update" | "pop" | "setdefault" | "clear" | "popitem"
1301 )
1302}
1303
1304fn line_of(source: &str, offset: TextSize) -> usize {
1306 let offset = (u32::from(offset) as usize).min(source.len());
1307 source.as_bytes()[..offset]
1308 .iter()
1309 .filter(|&&byte| byte == b'\n')
1310 .count()
1311 + 1
1312}
1313
1314fn first_party_package(root: &Path) -> Option<String> {
1318 first_party_manifest(root).map(|(name, _)| name)
1319}
1320
1321fn first_party_manifest(root: &Path) -> Option<(String, PathBuf)> {
1323 for dir in root.ancestors() {
1324 let candidate = dir.join("pyproject.toml");
1325 if candidate.is_file() {
1326 return read_project_name(&candidate)
1327 .map(|name| (normalize_dist_name(&name), dir.to_path_buf()));
1328 }
1329 if dir.join(".git").exists() {
1330 break;
1331 }
1332 }
1333 None
1334}
1335
1336fn read_project_name(path: &Path) -> Option<String> {
1338 let contents = std::fs::read_to_string(path).ok()?;
1339 let value: toml::Value = toml::from_str(&contents).ok()?;
1340 value
1341 .get("project")?
1342 .get("name")?
1343 .as_str()
1344 .map(str::to_owned)
1345}
1346
1347fn normalize_dist_name(name: &str) -> String {
1350 name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1351}
1352
1353fn collect_python_files(
1354 dir: &Path,
1355 out: &mut Vec<PathBuf>,
1356 is_match: fn(&Path) -> bool,
1357) -> Result<()> {
1358 let entries =
1359 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1360 for entry in entries {
1361 let path = entry
1362 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1363 .path();
1364 if path.is_dir() {
1365 collect_python_files(&path, out, is_match)?;
1366 } else if is_match(&path) {
1367 out.push(path);
1368 }
1369 }
1370 Ok(())
1371}
1372
1373fn is_python_test_file(path: &Path) -> bool {
1376 let name = path
1377 .file_name()
1378 .and_then(|n| n.to_str())
1379 .unwrap_or_default();
1380 name == "conftest.py" || name.ends_with("_test.py")
1381}
1382
1383fn is_python_unit_test_file(path: &Path) -> bool {
1386 let name = path
1387 .file_name()
1388 .and_then(|n| n.to_str())
1389 .unwrap_or_default();
1390 name.ends_with("_test.py")
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395 use super::*;
1396 use std::sync::atomic::{AtomicU64, Ordering};
1397
1398 struct TempDir(PathBuf);
1400
1401 impl TempDir {
1402 fn new() -> Self {
1403 static COUNTER: AtomicU64 = AtomicU64::new(0);
1404 let dir = std::env::temp_dir().join(format!(
1405 "tc-lint-{}-{}",
1406 std::process::id(),
1407 COUNTER.fetch_add(1, Ordering::Relaxed),
1408 ));
1409 std::fs::create_dir_all(&dir).unwrap();
1410 TempDir(dir)
1411 }
1412
1413 fn write(&self, name: &str, contents: &str) {
1414 let path = self.0.join(name);
1415 if let Some(parent) = path.parent() {
1416 std::fs::create_dir_all(parent).unwrap();
1417 }
1418 std::fs::write(path, contents).unwrap();
1419 }
1420 }
1421
1422 impl Drop for TempDir {
1423 fn drop(&mut self) {
1424 let _ = std::fs::remove_dir_all(&self.0);
1425 }
1426 }
1427
1428 #[test]
1429 fn normalize_dist_name_maps_to_import_name() {
1430 assert_eq!(normalize_dist_name("My-Project"), "my_project");
1431 assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1432 assert_eq!(normalize_dist_name(" myproject "), "myproject");
1433 assert_eq!(normalize_dist_name("myproject"), "myproject");
1434 }
1435
1436 fn parse_call(src: &str) -> ExprCall {
1438 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1439 let stmt = suite.into_iter().next().expect("one statement");
1440 (*stmt.expect_expr_stmt().value).expect_call_expr()
1441 }
1442
1443 fn naive_ctx<'a>(
1445 imports: &'a HashMap<String, String>,
1446 declared: &'a HashSet<String>,
1447 ) -> ResolveCtx<'a> {
1448 ResolveCtx {
1449 imports,
1450 declared_modules: declared,
1451 first_party: None,
1452 source_root: None,
1453 }
1454 }
1455
1456 #[test]
1457 fn patch_target_only_reads_string_literals_for_the_string_form() {
1458 let imports = HashMap::new();
1459 let declared = HashSet::new();
1460 let ctx = naive_ctx(&imports, &declared);
1461 let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1462 assert_eq!(
1463 patch_target(&str_call, &ctx).as_deref(),
1464 Some("pkg.mod.attr")
1465 );
1466 let name_call = parse_call("patch(target)\n");
1468 assert_eq!(patch_target(&name_call, &ctx), None);
1469 let int_call = parse_call("patch(42)\n");
1470 assert_eq!(patch_target(&int_call, &ctx), None);
1471 let empty_call = parse_call("patch()\n");
1472 assert_eq!(patch_target(&empty_call, &ctx), None);
1473 }
1474
1475 fn object_form_imports() -> HashMap<String, String> {
1477 HashMap::from([
1478 ("ledger".to_string(), "myproject.ledger".to_string()),
1479 ("myproject".to_string(), "myproject".to_string()),
1480 ("cfg".to_string(), "myproject.cfg".to_string()),
1481 ])
1482 }
1483
1484 #[test]
1485 fn patch_target_resolves_object_forms_through_imports() {
1486 let imports = object_form_imports();
1487 let declared = HashSet::new();
1488 let ctx = naive_ctx(&imports, &declared);
1489 let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1490 assert_eq!(
1491 patch_target(&imported_name, &ctx).as_deref(),
1492 Some("myproject.ledger.record")
1493 );
1494 let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1495 assert_eq!(
1496 patch_target(&dotted_module, &ctx).as_deref(),
1497 Some("myproject.ledger.record")
1498 );
1499 let name_attr = parse_call("patch.object(ledger, attr)\n");
1501 assert_eq!(
1502 patch_target(&name_attr, &ctx).as_deref(),
1503 Some("myproject.ledger")
1504 );
1505 let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1506 assert_eq!(
1507 patch_target(&dict_object, &ctx).as_deref(),
1508 Some("myproject.cfg.SETTINGS")
1509 );
1510 let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1511 assert_eq!(
1512 patch_target(&dict_string, &ctx).as_deref(),
1513 Some("pkg.cfg.FLAGS")
1514 );
1515 }
1516
1517 #[test]
1518 fn patch_target_declines_a_base_bound_by_no_import() {
1519 let imports = object_form_imports();
1520 let declared = HashSet::new();
1521 let ctx = naive_ctx(&imports, &declared);
1522 let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1523 assert_eq!(patch_target(&call_base, &ctx), None);
1524 let unbound_name = parse_call("patch.object(client, \"send\")\n");
1525 assert_eq!(patch_target(&unbound_name, &ctx), None);
1526 let empty = parse_call("patch.object()\n");
1527 assert_eq!(patch_target(&empty, &ctx), None);
1528 }
1529
1530 fn object_patch_tree(base: &str, attr: &str) -> TempDir {
1533 let tree = TempDir::new();
1534 tree.write(
1535 "pyproject.toml",
1536 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1537 );
1538 tree.write(
1539 "tests/integration/spy_test.py",
1540 &format!(
1541 "from unittest.mock import patch\n\
1542 import pytest\n\
1543 from myproject import async_mod\n\
1544 @pytest.fixture\n\
1545 def spy():\n\
1546 \x20 with patch.object({base}, \"{attr}\") as s:\n\
1547 \x20 yield s\n"
1548 ),
1549 );
1550 tree
1551 }
1552
1553 fn first_party_patch_count(tree: &TempDir) -> usize {
1554 find_violations(&tree.0)
1555 .expect("walking a readable tree should succeed")
1556 .iter()
1557 .filter(|v| v.rule == "no-first-party-patch")
1558 .count()
1559 }
1560
1561 #[test]
1562 fn object_form_stdlib_reached_through_first_party_is_not_flagged() {
1563 let tree = object_patch_tree("async_mod.asyncio", "to_thread");
1564 tree.write("myproject/async_mod.py", "import asyncio\n");
1565 assert_eq!(first_party_patch_count(&tree), 0);
1566 }
1567
1568 #[test]
1569 fn object_form_unnamed_module_attribute_declines_to_fire() {
1570 let missing_source = object_patch_tree("async_mod.transport", "send");
1571 assert_eq!(first_party_patch_count(&missing_source), 0);
1572 let dynamic = object_patch_tree("async_mod.transport", "send");
1573 dynamic.write("myproject/async_mod.py", "transport = build()\n");
1574 assert_eq!(first_party_patch_count(&dynamic), 0);
1575 }
1576
1577 #[test]
1578 fn object_form_first_party_module_attribute_still_fires() {
1579 let tree = object_patch_tree("async_mod.helper", "run");
1580 tree.write("myproject/async_mod.py", "from . import helper\n");
1581 assert_eq!(first_party_patch_count(&tree), 1);
1582 }
1583
1584 #[test]
1585 fn object_form_resolves_in_a_src_layout() {
1586 let tree = object_patch_tree("async_mod.helper", "run");
1587 tree.write("src/myproject/async_mod.py", "from . import helper\n");
1588 assert_eq!(first_party_patch_count(&tree), 1);
1589 }
1590
1591 #[test]
1592 fn object_form_star_import_over_an_unbound_name_declines() {
1593 let tree = object_patch_tree("async_mod.walk", "call");
1594 tree.write("myproject/async_mod.py", "from os import *\n");
1595 assert_eq!(first_party_patch_count(&tree), 0);
1596 }
1597
1598 #[test]
1599 fn object_form_conflicting_binding_declines() {
1600 let tree = object_patch_tree("async_mod.helper", "run");
1601 tree.write(
1602 "myproject/async_mod.py",
1603 "from . import helper\nhelper = None\n",
1604 );
1605 assert_eq!(first_party_patch_count(&tree), 0);
1606 }
1607
1608 #[test]
1609 fn object_form_defined_name_mid_chain_declines() {
1610 let tree = object_patch_tree("async_mod.Client.send", "retry");
1611 tree.write("myproject/async_mod.py", "class Client:\n pass\n");
1612 assert_eq!(first_party_patch_count(&tree), 0);
1613 }
1614
1615 #[test]
1616 fn object_form_package_attribute_resolves_through_init() {
1617 let tree = TempDir::new();
1618 tree.write(
1619 "pyproject.toml",
1620 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1621 );
1622 tree.write("myproject/sub/__init__.py", "from . import leaf\n");
1623 tree.write("myproject/sub/leaf.py", "def run():\n pass\n");
1624 tree.write(
1625 "tests/integration/spy_test.py",
1626 "from unittest.mock import patch\n\
1627 import pytest\n\
1628 from myproject import sub\n\
1629 @pytest.fixture\n\
1630 def spy():\n\
1631 \x20 with patch.object(sub.leaf, \"run\") as s:\n\
1632 \x20 yield s\n",
1633 );
1634 assert_eq!(first_party_patch_count(&tree), 1);
1635 }
1636
1637 #[test]
1638 fn object_form_unbound_name_falls_back_to_the_submodule_file() {
1639 let tree = TempDir::new();
1640 tree.write(
1641 "pyproject.toml",
1642 "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1643 );
1644 tree.write("myproject/sub/__init__.py", "");
1645 tree.write("myproject/sub/leaf.py", "def run():\n pass\n");
1646 tree.write(
1647 "tests/integration/spy_test.py",
1648 "from unittest.mock import patch\n\
1649 import pytest\n\
1650 from myproject import sub\n\
1651 @pytest.fixture\n\
1652 def spy():\n\
1653 \x20 with patch.object(sub.leaf, \"run\") as s:\n\
1654 \x20 yield s\n",
1655 );
1656 assert_eq!(first_party_patch_count(&tree), 1);
1657 }
1658
1659 #[test]
1660 fn resolve_appends_naively_past_a_third_party_head() {
1661 let tree = TempDir::new();
1662 let imports = HashMap::from([("requests".to_string(), "requests".to_string())]);
1663 let declared = HashSet::new();
1664 let ctx = ResolveCtx {
1665 imports: &imports,
1666 declared_modules: &declared,
1667 first_party: Some("myproject"),
1668 source_root: Some(&tree.0),
1669 };
1670 let call = parse_call("patch.object(requests.utils, \"default_headers\")\n");
1671 assert_eq!(
1672 patch_target(&call, &ctx).as_deref(),
1673 Some("requests.utils.default_headers")
1674 );
1675 }
1676
1677 #[test]
1678 fn locate_module_tries_flat_and_src_layouts() {
1679 let tree = TempDir::new();
1680 tree.write("myproject/flat.py", "");
1681 tree.write("myproject/pkg/__init__.py", "");
1682 tree.write("src/myproject/nested.py", "");
1683 let flat = locate_module(&tree.0, "myproject.flat").expect("flat module");
1684 assert_eq!(flat.path, tree.0.join("myproject/flat.py"));
1685 assert_eq!(flat.package, vec!["myproject".to_string()]);
1686 let pkg = locate_module(&tree.0, "myproject.pkg").expect("package module");
1687 assert_eq!(pkg.path, tree.0.join("myproject/pkg/__init__.py"));
1688 assert_eq!(
1689 pkg.package,
1690 vec!["myproject".to_string(), "pkg".to_string()]
1691 );
1692 let nested = locate_module(&tree.0, "myproject.nested").expect("src module");
1693 assert_eq!(nested.path, tree.0.join("src/myproject/nested.py"));
1694 assert!(locate_module(&tree.0, "myproject.absent").is_none());
1695 }
1696
1697 fn scope_of(source: &str) -> ModuleScope {
1698 module_scope(source, &["myproject".to_string()]).expect("source should parse")
1699 }
1700
1701 #[test]
1702 fn module_scope_classifies_import_bindings() {
1703 let scope = scope_of(
1704 "import asyncio\n\
1705 import myproject.util as util\n\
1706 from . import helper\n\
1707 from .sub import leaf\n\
1708 from myproject.vendor import client as vc\n",
1709 );
1710 assert_eq!(
1711 scope.bindings.get("asyncio"),
1712 Some(&Binding::Module("asyncio".to_string()))
1713 );
1714 assert_eq!(
1715 scope.bindings.get("util"),
1716 Some(&Binding::Module("myproject.util".to_string()))
1717 );
1718 assert_eq!(
1719 scope.bindings.get("helper"),
1720 Some(&Binding::Module("myproject.helper".to_string()))
1721 );
1722 assert_eq!(
1723 scope.bindings.get("leaf"),
1724 Some(&Binding::Module("myproject.sub.leaf".to_string()))
1725 );
1726 assert_eq!(
1727 scope.bindings.get("vc"),
1728 Some(&Binding::Module("myproject.vendor.client".to_string()))
1729 );
1730 }
1731
1732 #[test]
1733 fn module_scope_classifies_definitions_and_assignments() {
1734 let scope = scope_of(
1735 "def run():\n pass\n\
1736 async def poll():\n pass\n\
1737 class Client:\n pass\n\
1738 LIMITS = [1, 2]\n\
1739 OFFSET = -1\n\
1740 registry = {\"on\": True}\n\
1741 PAIR: tuple = (1, 2)\n\
1742 transport = build()\n\
1743 alias = registry\n\
1744 a, b = make()\n",
1745 );
1746 for name in [
1747 "run", "poll", "Client", "LIMITS", "OFFSET", "registry", "PAIR",
1748 ] {
1749 assert_eq!(scope.bindings.get(name), Some(&Binding::Defined), "{name}");
1750 }
1751 for name in ["transport", "alias", "a", "b"] {
1752 assert_eq!(scope.bindings.get(name), Some(&Binding::Opaque), "{name}");
1753 }
1754 }
1755
1756 #[test]
1757 fn module_scope_marks_conflicts_opaque_and_dedupes_repeats() {
1758 let scope = scope_of("import asyncio\nimport asyncio\nfrom . import helper\nhelper = 1\n");
1759 assert_eq!(
1760 scope.bindings.get("asyncio"),
1761 Some(&Binding::Module("asyncio".to_string()))
1762 );
1763 assert_eq!(scope.bindings.get("helper"), Some(&Binding::Opaque));
1764 }
1765
1766 #[test]
1767 fn module_scope_flags_star_imports_and_rejects_deep_relatives() {
1768 let scope = scope_of("from os import *\nfrom .. import escape\n");
1769 assert!(scope.has_star_import);
1770 assert_eq!(scope.bindings.get("escape"), Some(&Binding::Opaque));
1771 }
1772
1773 #[test]
1774 fn module_scope_rejects_unparsable_source() {
1775 assert!(module_scope("def (\n", &[]).is_none());
1776 }
1777
1778 fn collect_bindings(src: &str) -> (HashMap<String, String>, HashSet<String>) {
1780 let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1781 let mut visitor = LintVisitor {
1782 file: Path::new("t.py"),
1783 source: src,
1784 fixture_depth: 0,
1785 first_party: None,
1786 source_root: None,
1787 imports: HashMap::new(),
1788 declared_modules: HashSet::new(),
1789 violations: Vec::new(),
1790 };
1791 for stmt in suite {
1792 visitor.visit_stmt(stmt);
1793 }
1794 (visitor.imports, visitor.declared_modules)
1795 }
1796
1797 fn collect_imports(src: &str) -> HashMap<String, String> {
1798 collect_bindings(src).0
1799 }
1800
1801 #[test]
1802 fn lint_visitor_binds_imports_to_their_modules() {
1803 let imports = collect_imports(
1804 "import myproject.ledger\n\
1805 import myproject.config as cfg\n\
1806 from myproject import ledger\n\
1807 from myproject import charge as ch\n\
1808 from . import rel\n",
1809 );
1810 assert_eq!(
1811 imports.get("myproject").map(String::as_str),
1812 Some("myproject")
1813 );
1814 assert_eq!(
1815 imports.get("cfg").map(String::as_str),
1816 Some("myproject.config")
1817 );
1818 assert_eq!(
1819 imports.get("ledger").map(String::as_str),
1820 Some("myproject.ledger")
1821 );
1822 assert_eq!(
1823 imports.get("ch").map(String::as_str),
1824 Some("myproject.charge")
1825 );
1826 assert_eq!(imports.get("rel"), None);
1827 }
1828
1829 #[test]
1830 fn lint_visitor_declares_every_import_prefix() {
1831 let (_, declared) = collect_bindings(
1832 "import myproject.sub.ledger\n\
1833 from myproject.api import charge\n\
1834 from . import rel\n",
1835 );
1836 for module in [
1837 "myproject",
1838 "myproject.sub",
1839 "myproject.sub.ledger",
1840 "myproject.api",
1841 ] {
1842 assert!(declared.contains(module), "{module}");
1843 }
1844 assert!(!declared.contains("myproject.api.charge"));
1845 assert!(!declared.contains("rel"));
1846 }
1847
1848 fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1850 ImportRecord {
1851 display: source.unwrap_or(".rel").to_string(),
1852 line: 1,
1853 is_uut: false,
1854 symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1855 source: source.map(str::to_string),
1856 module: None,
1857 }
1858 }
1859
1860 fn targets(list: &[&str]) -> Vec<String> {
1861 list.iter().map(|s| (*s).to_string()).collect()
1862 }
1863
1864 #[test]
1865 fn is_mocked_requires_every_symbol_at_the_import_module() {
1866 let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1867 assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1869 assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1870 }
1871
1872 #[test]
1873 fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1874 let rec = from_import(Some("pkg.ledger"), &["record"]);
1875 assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1877 let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1878 assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1879 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1880 }
1881
1882 #[test]
1883 fn is_mocked_relative_import_accepts_a_last_segment_match() {
1884 let rec = from_import(None, &["record"]);
1886 assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1887 assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1888 }
1889
1890 #[test]
1891 fn is_mocked_module_import_matches_a_patch_reaching_in() {
1892 let rec = ImportRecord {
1893 display: "pkg.db".to_string(),
1894 line: 1,
1895 is_uut: false,
1896 symbols: Vec::new(),
1897 source: None,
1898 module: Some("pkg.db".to_string()),
1899 };
1900 assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1901 assert!(rec.is_mocked(&targets(&["pkg.db"])));
1902 assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1903 let empty = from_import(Some("pkg.mod"), &[]);
1904 assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1905 }
1906
1907 #[test]
1908 fn patches_first_party_matches_head_segment() {
1909 assert!(patches_first_party("myproject.ledger.record", "myproject"));
1910 assert!(patches_first_party("myproject", "myproject"));
1911 assert!(!patches_first_party("requests.get", "myproject"));
1912 assert!(!patches_first_party("myproject_extra.x", "myproject"));
1913 assert!(!patches_first_party("", "myproject"));
1914 assert!(!patches_first_party(".leading", "myproject"));
1915 }
1916
1917 #[test]
1918 fn first_party_package_reads_pyproject_name() {
1919 let tree = TempDir::new();
1920 tree.write(
1921 "pyproject.toml",
1922 "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1923 );
1924 assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1925 }
1926
1927 #[test]
1928 fn first_party_package_is_none_without_a_project_name() {
1929 let tree = TempDir::new();
1930 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1931 tree.write(".git", "");
1932 assert_eq!(first_party_package(&tree.0), None);
1933 }
1934
1935 #[test]
1936 fn first_party_package_is_none_when_absent() {
1937 let tree = TempDir::new();
1938 assert_eq!(first_party_package(&tree.0), None);
1939 }
1940
1941 fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1943 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1944 let mut visitor = UnitIsolationVisitor {
1945 source,
1946 first_party,
1947 base,
1948 type_checking_depth: 0,
1949 imports: Vec::new(),
1950 patch_targets: Vec::new(),
1951 };
1952 for stmt in suite {
1953 visitor.visit_stmt(stmt);
1954 }
1955 visitor
1956 .imports
1957 .iter()
1958 .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1959 .map(|i| i.display.clone())
1960 .collect()
1961 }
1962
1963 #[test]
1964 fn import_head_and_last_segment() {
1965 assert_eq!(import_head("myproject.db.conn"), "myproject");
1966 assert_eq!(import_head("requests"), "requests");
1967 assert_eq!(last_segment("myproject.db.conn"), "conn");
1968 assert_eq!(last_segment("widget"), "widget");
1969 }
1970
1971 #[test]
1972 fn unit_under_test_base_strips_test_suffix() {
1973 assert_eq!(
1974 unit_under_test_base(Path::new("pkg/widget_test.py")),
1975 "widget"
1976 );
1977 assert_eq!(
1979 unit_under_test_base(Path::new("test_widget.py")),
1980 "test_widget"
1981 );
1982 assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1983 }
1984
1985 #[test]
1986 fn recognizes_python_unit_test_files() {
1987 assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1988 assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1989 assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1990 assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1991 assert!(!is_python_unit_test_file(Path::new("widget.py")));
1992 }
1993
1994 #[test]
1995 fn visitor_flags_first_party_and_external_collaborators() {
1996 let found = unmocked(
1998 "widget",
1999 "myproject",
2000 "from myproject.widget import build\n\
2001 from myproject.ledger import record\n\
2002 import requests\n",
2003 );
2004 assert_eq!(
2005 found,
2006 vec!["myproject.ledger".to_string(), "requests".to_string()]
2007 );
2008 }
2009
2010 #[test]
2011 fn visitor_clears_a_mocked_collaborator() {
2012 let found = unmocked(
2013 "widget",
2014 "myproject",
2015 "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
2016 );
2017 assert!(found.is_empty(), "got: {found:?}");
2018 }
2019
2020 #[test]
2021 fn visitor_flags_a_wrong_module_patch() {
2022 let found = unmocked(
2025 "widget",
2026 "myproject",
2027 "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
2028 );
2029 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2030 }
2031
2032 #[test]
2033 fn visitor_flags_a_partly_mocked_multi_symbol_import() {
2034 let found = unmocked(
2036 "widget",
2037 "myproject",
2038 "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
2039 );
2040 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2041 let both = unmocked(
2042 "widget",
2043 "myproject",
2044 "from myproject.ledger import record, erase\n\
2045 patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
2046 );
2047 assert!(both.is_empty(), "got: {both:?}");
2048 }
2049
2050 #[test]
2051 fn visitor_handles_module_and_relative_imports() {
2052 assert_eq!(
2053 unmocked("widget", "myproject", "import myproject.db\n"),
2054 vec!["myproject.db".to_string()]
2055 );
2056 assert!(unmocked(
2057 "widget",
2058 "myproject",
2059 "import myproject.db\npatch(\"myproject.db.connect\")\n"
2060 )
2061 .is_empty());
2062 assert_eq!(
2063 unmocked("widget", "myproject", "from .ledger import record\n"),
2064 vec![".ledger".to_string()]
2065 );
2066 assert_eq!(
2067 unmocked(
2068 "widget",
2069 "myproject",
2070 "from . import ledger\nfrom . import widget\n"
2071 ),
2072 vec![".ledger".to_string()]
2073 );
2074 }
2075
2076 #[test]
2077 fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
2078 assert!(unmocked(
2080 "__init__",
2081 "myproject",
2082 "from . import Thing, __all__, __version__\n"
2083 )
2084 .is_empty());
2085 assert_eq!(
2087 unmocked("__init__", "myproject", "from .core import Thing\n"),
2088 vec![".core".to_string()]
2089 );
2090 assert_eq!(
2092 unmocked("__init__", "myproject", "from .. import sibling\n"),
2093 vec!["..sibling".to_string()]
2094 );
2095 assert_eq!(
2097 unmocked("widget", "myproject", "from . import ledger\n"),
2098 vec![".ledger".to_string()]
2099 );
2100 }
2101
2102 #[test]
2103 fn visitor_skips_type_checking_imports() {
2104 let found = unmocked(
2106 "widget",
2107 "myproject",
2108 "if TYPE_CHECKING:\n from myproject.models import Widget\nelse:\n from myproject.ledger import record\n",
2109 );
2110 assert_eq!(found, vec!["myproject.ledger".to_string()]);
2111 }
2112
2113 #[test]
2114 fn is_checked_import_classifies_origins() {
2115 assert!(is_checked_import("myproject", "myproject")); assert!(!is_checked_import("pytest", "myproject")); assert!(!is_checked_import("_pytest", "myproject"));
2118 assert!(is_checked_import("subprocess", "myproject")); assert!(is_checked_import("socket", "myproject"));
2120 assert!(!is_checked_import("json", "myproject")); assert!(!is_checked_import("dataclasses", "myproject"));
2122 assert!(is_checked_import("requests", "myproject")); assert!(is_checked_import("stripe", "myproject"));
2124 assert!(!is_checked_import("os", "myproject"));
2126 assert!(!is_checked_import("pathlib", "myproject"));
2127 assert!(!is_checked_import("datetime", "myproject"));
2128 }
2129
2130 #[test]
2131 fn is_checked_import_classifies_private_stdlib_as_stdlib() {
2132 assert!(!is_checked_import("__future__", "myproject"));
2133 assert!(!is_checked_import("_thread", "myproject"));
2134 assert!(!is_checked_import("_socket", "myproject"));
2135 assert!(!is_checked_import("_ast", "myproject"));
2136 assert!(!is_checked_import("_collections_abc", "myproject"));
2137 assert!(is_checked_import("_stripe", "myproject")); }
2139
2140 #[test]
2141 fn visitor_flags_external_collaborators() {
2142 let found = unmocked(
2143 "widget",
2144 "myproject",
2145 "import requests\nimport subprocess\nimport json\nimport pytest\n",
2146 );
2147 assert_eq!(found.len(), 2, "got: {found:?}");
2148 assert!(found.contains(&"requests".to_string()));
2149 assert!(found.contains(&"subprocess".to_string()));
2150 }
2151
2152 #[test]
2153 fn visitor_type_checking_variants_and_plain_if() {
2154 assert!(unmocked(
2156 "widget",
2157 "myproject",
2158 "if typing.TYPE_CHECKING:\n from myproject.models import W\n import myproject.db\n"
2159 )
2160 .is_empty());
2161 assert_eq!(
2163 unmocked(
2164 "widget",
2165 "myproject",
2166 "if ready == 1:\n from myproject.ledger import record\n"
2167 ),
2168 vec!["myproject.ledger".to_string()]
2169 );
2170 }
2171
2172 #[test]
2173 fn find_unit_isolation_without_pyproject_reports_nothing() {
2174 let tree = TempDir::new();
2175 tree.write("widget_test.py", "from myproject.ledger import record\n");
2176 tree.write(".git", "");
2177 assert!(find_unit_isolation_violations(&tree.0)
2178 .expect("a readable tree should succeed")
2179 .is_empty());
2180 }
2181
2182 #[test]
2183 fn find_unit_isolation_walks_subdirs_and_flags() {
2184 let tree = TempDir::new();
2185 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2186 tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
2187 let found =
2188 find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
2189 assert_eq!(found.len(), 1, "got: {found:?}");
2190 assert_eq!(found[0].rule, "unmocked-collaborator");
2191 assert!(found[0].message.contains("myproject.ledger"));
2192 }
2193
2194 #[test]
2195 fn recognizes_python_test_files() {
2196 assert!(is_python_test_file(Path::new("widget_test.py")));
2197 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
2198 assert!(is_python_test_file(Path::new("conftest.py")));
2199 assert!(!is_python_test_file(Path::new("test_widget.py")));
2200 }
2201
2202 #[test]
2203 fn ignores_non_test_files() {
2204 assert!(!is_python_test_file(Path::new("widget.py")));
2205 assert!(!is_python_test_file(Path::new("conftest.pyi")));
2206 assert!(!is_python_test_file(Path::new("README.md")));
2207 assert!(!is_python_test_file(Path::new("testing.py")));
2208 }
2209
2210 #[test]
2211 fn line_of_counts_newlines() {
2212 let src = "a\nb\nc\n";
2213 assert_eq!(line_of(src, TextSize::from(0)), 1);
2214 assert_eq!(line_of(src, TextSize::from(2)), 2);
2215 assert_eq!(line_of(src, TextSize::from(4)), 3);
2216 }
2217
2218 #[test]
2219 fn recognizes_environ_mutators() {
2220 assert!(is_environ_mutator("update"));
2221 assert!(is_environ_mutator("pop"));
2222 assert!(is_environ_mutator("clear"));
2223 assert!(!is_environ_mutator("get"));
2224 assert!(!is_environ_mutator("keys"));
2225 }
2226
2227 fn lint_rules(source: &str) -> Vec<&'static str> {
2229 let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
2230 let mut visitor = LintVisitor {
2231 file: Path::new("t.py"),
2232 source,
2233 fixture_depth: 0,
2234 first_party: Some("myproject"),
2235 source_root: None,
2236 imports: HashMap::new(),
2237 declared_modules: HashSet::new(),
2238 violations: Vec::new(),
2239 };
2240 for stmt in suite {
2241 visitor.visit_stmt(stmt);
2242 }
2243 visitor.violations.iter().map(|v| v.rule).collect()
2244 }
2245
2246 #[test]
2247 fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
2248 assert!(
2249 lint_rules("@pytest.fixture\nasync def client():\n patch(\"pkg.mod.attr\")\n")
2250 .is_empty()
2251 );
2252 assert_eq!(
2253 lint_rules("async def widget_test():\n patch(\"pkg.mod.attr\")\n"),
2254 vec!["no-inline-patch"]
2255 );
2256 assert_eq!(
2257 lint_rules("async def widget_test(monkeypatch):\n pass\n"),
2258 vec!["no-monkeypatch"]
2259 );
2260 }
2261
2262 #[test]
2263 fn an_augmented_assignment_to_environ_is_a_mutation() {
2264 assert_eq!(
2265 lint_rules("def widget_test():\n os.environ[\"PATH\"] += \":/x\"\n"),
2266 vec!["no-environ-mutation"]
2267 );
2268 assert!(lint_rules("def widget_test():\n total += 1\n").is_empty());
2269 }
2270
2271 #[test]
2272 fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
2273 assert!(
2274 lint_rules("@fixture\ndef client():\n patch(\"pkg.mod.attr\")\n").is_empty(),
2275 "a bare `@fixture` shelters the patch"
2276 );
2277 assert_eq!(
2278 lint_rules("@registry[\"fixture\"]\ndef client():\n patch(\"pkg.mod.attr\")\n"),
2279 vec!["no-inline-patch"],
2280 "a subscripted decorator is not a fixture"
2281 );
2282 }
2283
2284 #[test]
2285 fn patch_object_is_recognized_only_through_a_patch_receiver() {
2286 assert_eq!(
2287 lint_rules("def widget_test():\n mock.patch.object(svc, \"send\")\n"),
2288 vec!["no-inline-patch"]
2289 );
2290 assert!(
2291 lint_rules("def widget_test():\n helpers[0].object(svc, \"send\")\n").is_empty(),
2292 "a subscripted receiver is not `patch`"
2293 );
2294 assert!(
2295 lint_rules("def widget_test():\n helpers[0](\"pkg.mod.attr\")\n").is_empty(),
2296 "a subscripted callee is not a patch call"
2297 );
2298 }
2299
2300 #[test]
2301 fn find_suite_without_a_tests_directory_reports_nothing() {
2302 let tree = TempDir::new();
2303 tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2304 assert!(find_suite_violations(&tree.0)
2305 .expect("a readable tree should succeed")
2306 .is_empty());
2307 }
2308
2309 #[test]
2310 fn find_unit_isolation_without_a_project_name_reports_nothing() {
2311 let tree = TempDir::new();
2312 tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
2313 tree.write("widget_test.py", "from myproject.ledger import record\n");
2314 assert!(find_unit_isolation_violations(&tree.0)
2315 .expect("a readable tree should succeed")
2316 .is_empty());
2317 }
2318
2319 #[test]
2320 fn recognizes_upper_constants() {
2321 assert!(is_upper_constant("CACHE_DIR"));
2322 assert!(is_upper_constant("DEBUG"));
2323 assert!(is_upper_constant("MAX_2"));
2324 assert!(!is_upper_constant("cache_dir"));
2325 assert!(!is_upper_constant("CacheDir"));
2326 assert!(!is_upper_constant("fetch"));
2327 assert!(!is_upper_constant(""));
2328 assert!(!is_upper_constant("_"));
2329 assert!(!is_upper_constant("123"));
2330 }
2331}