Skip to main content

testing_conventions/
lint.rs

1//! The Python mocking mechanism and style lints behind `integration lint`, plus the Python
2//! arm of `unit lint`. Each test file is parsed with `rustpython_parser` and walked with a
3//! [`Visitor`]; the rules themselves are documented under `docs/reference/checks/`.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, Context, Result};
8use rustpython_ast::Visitor;
9use rustpython_parser::ast::{
10    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
11    StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
12};
13use rustpython_parser::text_size::{TextRange, TextSize};
14use rustpython_parser::Parse;
15
16// Re-exported so `testing_conventions::lint::Violation` still resolves.
17pub use crate::violation::Violation;
18
19/// Every lint violation in the Python test files under `root`, sorted by `(file, line)`. A
20/// *Python test file* is `*_test.py` or `conftest.py`, where fixtures live; a legacy
21/// `test_*.py` is ordinary source. A file that cannot be read or parsed is an error.
22pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
23    let root = root.as_ref();
24    // Resolved once for the whole tree; `None` means `no-first-party-patch` flags nothing.
25    let first_party = first_party_package(root);
26    let mut files = Vec::new();
27    collect_python_files(root, &mut files, is_python_test_file)?;
28    files.sort();
29
30    let mut violations = Vec::new();
31    for file in &files {
32        let source = std::fs::read_to_string(file)
33            .with_context(|| format!("reading test file `{}`", file.display()))?;
34        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
35            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
36        let mut visitor = LintVisitor {
37            file,
38            source: &source,
39            fixture_depth: 0,
40            first_party: first_party.as_deref(),
41            violations: Vec::new(),
42        };
43        for stmt in suite {
44            visitor.visit_stmt(stmt);
45        }
46        violations.append(&mut visitor.violations);
47    }
48
49    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
50    Ok(violations)
51}
52
53const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
54     a suite lives in `tests/integration/` or `tests/e2e/`";
55
56/// Every lint violation in `package_root`'s suite tiers, sorted by `(file, line)`.
57/// `tests/integration/` and `tests/e2e/` both run first-party code for real; a `*_test.py`
58/// under `tests/` outside them is `unknown-tier` rather than silently unscanned.
59pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
60    let tests = package_root.join("tests");
61    let mut violations = Vec::new();
62    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
63    for tier in &tiers {
64        if tier.is_dir() {
65            violations.extend(find_violations(tier)?);
66        }
67    }
68    if tests.is_dir() {
69        let mut strays = Vec::new();
70        collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
71        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
72        for file in strays {
73            violations.push(Violation {
74                file,
75                line: 1,
76                rule: "unknown-tier",
77                message: UNKNOWN_TIER_MSG.to_string(),
78            });
79        }
80    }
81    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
82    Ok(violations)
83}
84
85/// Every `unmocked-collaborator` violation under `root` — a collaborator a `*_test.py`
86/// imports without mocking it — sorted by `(file, line)`. First-party is the dist's own
87/// package ([`first_party_package`]); a tree that declares none reports nothing.
88pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
89    let root = root.as_ref();
90    let Some(first_party) = first_party_package(root) else {
91        return Ok(Vec::new());
92    };
93    let mut files = Vec::new();
94    collect_python_files(root, &mut files, is_python_unit_test_file)?;
95    // The suite tiers run first-party code for real, so their files are never unit subjects.
96    if let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") {
97        files.retain(|file| !file.starts_with(&tests));
98    }
99    files.sort();
100
101    let mut violations = Vec::new();
102    for file in &files {
103        let source = std::fs::read_to_string(file)
104            .with_context(|| format!("reading test file `{}`", file.display()))?;
105        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
106            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
107        let base = unit_under_test_base(file);
108        let mut visitor = UnitIsolationVisitor {
109            source: &source,
110            first_party: &first_party,
111            base: &base,
112            type_checking_depth: 0,
113            imports: Vec::new(),
114            patch_targets: Vec::new(),
115        };
116        for stmt in suite {
117            visitor.visit_stmt(stmt);
118        }
119        for import in &visitor.imports {
120            if import.is_uut || import.is_mocked(&visitor.patch_targets) {
121                continue;
122            }
123            violations.push(Violation {
124                file: file.to_path_buf(),
125                line: import.line,
126                rule: "unmocked-collaborator",
127                message: format!(
128                    "unit test imports `{}` without mocking it — a unit test isolates the \
129                     unit under test, so mock every collaborator (patch it by string in a \
130                     fixture)",
131                    import.display
132                ),
133            });
134        }
135    }
136
137    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
138    Ok(violations)
139}
140
141/// One import seen in a unit test, with what it takes to decide whether it is mocked.
142struct ImportRecord {
143    /// The module path to name in the message (`myproject.ledger`, `.ledger`).
144    display: String,
145    line: usize,
146    is_uut: bool,
147    /// For `from X import a, b` — the bound symbols, each of which must be mocked.
148    symbols: Vec<String>,
149    /// For an **absolute** `from X import a, b` — the source module `X`, which a mocking
150    /// patch must name. `None` for a relative `from`-import, which has no module to compare.
151    source: Option<String>,
152    /// For `import X.Y` — the module path (a patch reaching into it counts as a mock).
153    module: Option<String>,
154}
155
156impl ImportRecord {
157    /// `true` when some `patch("…")` target mocks this import: a plain `import X.Y` by any
158    /// patch reaching into `X.Y`, a `from X import a, b` only when **every** bound symbol
159    /// is patched at `X` itself.
160    fn is_mocked(&self, patch_targets: &[String]) -> bool {
161        if let Some(module) = &self.module {
162            let prefix = format!("{module}.");
163            return patch_targets
164                .iter()
165                .any(|target| target == module || target.starts_with(&prefix));
166        }
167        if self.symbols.is_empty() {
168            return false;
169        }
170        self.symbols.iter().all(|symbol| {
171            patch_targets
172                .iter()
173                .any(|target| self.symbol_is_mocked(target, symbol))
174        })
175    }
176
177    /// `true` when `target`'s last dotted segment is `symbol` and — for an absolute import
178    /// — its module path is the import's own [`source`](Self::source).
179    fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
180        let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
181            return false;
182        };
183        match &self.source {
184            Some(source) => module == source,
185            None => true,
186        }
187    }
188}
189
190/// Walks one unit test, collecting its imports and every `patch("…")` string target so
191/// [`find_unit_isolation_violations`] can pair them. An `if TYPE_CHECKING:` import is erased
192/// at runtime and skipped.
193struct UnitIsolationVisitor<'a> {
194    source: &'a str,
195    first_party: &'a str,
196    base: &'a str,
197    type_checking_depth: usize,
198    imports: Vec<ImportRecord>,
199    patch_targets: Vec<String>,
200}
201
202impl Visitor for UnitIsolationVisitor<'_> {
203    fn visit_stmt_import(&mut self, node: StmtImport) {
204        if self.type_checking_depth == 0 {
205            let line = line_of(self.source, node.range.start());
206            for alias in &node.names {
207                let module = alias.name.as_str();
208                if is_checked_import(import_head(module), self.first_party) {
209                    self.imports.push(ImportRecord {
210                        display: module.to_string(),
211                        line,
212                        is_uut: last_segment(module) == self.base,
213                        symbols: Vec::new(),
214                        source: None,
215                        module: Some(module.to_string()),
216                    });
217                }
218            }
219        }
220        self.generic_visit_stmt_import(node);
221    }
222
223    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
224        if self.type_checking_depth == 0 {
225            let level = relative_level(&node);
226            let module = node.module.as_ref().map(|m| m.as_str());
227            // A relative import is first-party; an absolute one is judged by its head.
228            let should_check = level > 0
229                || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
230            if should_check {
231                let line = line_of(self.source, node.range.start());
232                let dots = ".".repeat(level);
233                match module {
234                    // `from <module> import a, b` — the bound symbols are the collaborators.
235                    Some(module) => self.imports.push(ImportRecord {
236                        display: format!("{dots}{module}"),
237                        line,
238                        is_uut: last_segment(module) == self.base,
239                        symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
240                        source: (level == 0).then(|| module.to_string()),
241                        module: None,
242                    }),
243                    // `from . import sub` — each name is a submodule.
244                    None => {
245                        // In `__init___test.py` a bare `from . import …` names the
246                        // package's own re-export surface — the unit under test itself.
247                        let barrel_sut = self.base == "__init__" && level == 1;
248                        for alias in &node.names {
249                            let name = alias.name.as_str();
250                            self.imports.push(ImportRecord {
251                                display: format!("{dots}{name}"),
252                                line,
253                                is_uut: barrel_sut || name == self.base,
254                                symbols: vec![name.to_string()],
255                                source: None,
256                                module: None,
257                            });
258                        }
259                    }
260                }
261            }
262        }
263        self.generic_visit_stmt_import_from(node);
264    }
265
266    fn visit_expr_call(&mut self, node: ExprCall) {
267        if is_patch_call(&node) {
268            if let Some(target) = patch_string_target(&node) {
269                self.patch_targets.push(target.to_string());
270            }
271        }
272        self.generic_visit_expr_call(node);
273    }
274
275    fn visit_stmt_if(&mut self, node: StmtIf) {
276        // An `if TYPE_CHECKING:` body is type-only; its runtime `else` is still walked.
277        if is_type_checking(node.test.as_ref()) {
278            self.type_checking_depth += 1;
279            for stmt in node.body {
280                self.visit_stmt(stmt);
281            }
282            self.type_checking_depth -= 1;
283            for stmt in node.orelse {
284                self.visit_stmt(stmt);
285            }
286        } else {
287            self.generic_visit_stmt_if(node);
288        }
289    }
290}
291
292/// The leading dotted segment of a module path (`myproject.db` → `myproject`).
293fn import_head(module: &str) -> &str {
294    module.split('.').next().unwrap_or(module)
295}
296
297/// `true` when an import head names a checked collaborator — the dist package, a third-party
298/// package, or effectful stdlib. The test framework and pure stdlib are not collaborators.
299fn is_checked_import(head: &str, first_party: &str) -> bool {
300    if head == first_party {
301        return true;
302    }
303    if TEST_FRAMEWORK.contains(&head) {
304        return false;
305    }
306    if EFFECTFUL_STDLIB.contains(&head) {
307        return true;
308    }
309    if STDLIB_MODULES.contains(&head) {
310        return false;
311    }
312    true // an unrecognized head is a third-party package
313}
314
315/// The test harness, never a collaborator. `unittest` is stdlib; these are the rest.
316const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
317
318/// Standard-library modules that are **effectful at the head**. Dual-nature heads (`os`,
319/// `pathlib`, `datetime`, `time`, `io`, `logging`, `threading`) are excluded: a pure use
320/// can't be told from an effectful one at the import, so the patch convention catches those.
321const EFFECTFUL_STDLIB: &[&str] = &[
322    "asynchat",
323    "asyncore",
324    "ctypes",
325    "curses",
326    "dbm",
327    "fcntl",
328    "ftplib",
329    "imaplib",
330    "mmap",
331    "msvcrt",
332    "multiprocessing",
333    "nis",
334    "nntplib",
335    "ossaudiodev",
336    "poplib",
337    "pty",
338    "random",
339    "secrets",
340    "select",
341    "selectors",
342    "signal",
343    "smtpd",
344    "smtplib",
345    "socket",
346    "socketserver",
347    "spwd",
348    "sqlite3",
349    "ssl",
350    "subprocess",
351    "syslog",
352    "telnetlib",
353    "termios",
354    "tty",
355    "webbrowser",
356    "winreg",
357    "winsound",
358];
359
360/// Python's `sys.stdlib_module_names`, which tells pure stdlib from a third-party package.
361/// The [`EFFECTFUL_STDLIB`] subset is what is actually flagged.
362const STDLIB_MODULES: &[&str] = &[
363    "abc",
364    "aifc",
365    "antigravity",
366    "argparse",
367    "array",
368    "ast",
369    "asynchat",
370    "asyncio",
371    "asyncore",
372    "atexit",
373    "audioop",
374    "base64",
375    "bdb",
376    "binascii",
377    "bisect",
378    "builtins",
379    "bz2",
380    "cProfile",
381    "calendar",
382    "cgi",
383    "cgitb",
384    "chunk",
385    "cmath",
386    "cmd",
387    "code",
388    "codecs",
389    "codeop",
390    "collections",
391    "colorsys",
392    "compileall",
393    "concurrent",
394    "configparser",
395    "contextlib",
396    "contextvars",
397    "copy",
398    "copyreg",
399    "crypt",
400    "csv",
401    "ctypes",
402    "curses",
403    "dataclasses",
404    "datetime",
405    "dbm",
406    "decimal",
407    "difflib",
408    "dis",
409    "distutils",
410    "doctest",
411    "email",
412    "encodings",
413    "ensurepip",
414    "enum",
415    "errno",
416    "faulthandler",
417    "fcntl",
418    "filecmp",
419    "fileinput",
420    "fnmatch",
421    "fractions",
422    "ftplib",
423    "functools",
424    "gc",
425    "genericpath",
426    "getopt",
427    "getpass",
428    "gettext",
429    "glob",
430    "graphlib",
431    "grp",
432    "gzip",
433    "hashlib",
434    "heapq",
435    "hmac",
436    "html",
437    "http",
438    "idlelib",
439    "imaplib",
440    "imghdr",
441    "imp",
442    "importlib",
443    "inspect",
444    "io",
445    "ipaddress",
446    "itertools",
447    "json",
448    "keyword",
449    "lib2to3",
450    "linecache",
451    "locale",
452    "logging",
453    "lzma",
454    "mailbox",
455    "mailcap",
456    "marshal",
457    "math",
458    "mimetypes",
459    "mmap",
460    "modulefinder",
461    "msilib",
462    "msvcrt",
463    "multiprocessing",
464    "netrc",
465    "nis",
466    "nntplib",
467    "nt",
468    "ntpath",
469    "nturl2path",
470    "numbers",
471    "opcode",
472    "operator",
473    "optparse",
474    "os",
475    "ossaudiodev",
476    "pathlib",
477    "pdb",
478    "pickle",
479    "pickletools",
480    "pipes",
481    "pkgutil",
482    "platform",
483    "plistlib",
484    "poplib",
485    "posix",
486    "posixpath",
487    "pprint",
488    "profile",
489    "pstats",
490    "pty",
491    "pwd",
492    "py_compile",
493    "pyclbr",
494    "pydoc",
495    "pydoc_data",
496    "pyexpat",
497    "queue",
498    "quopri",
499    "random",
500    "re",
501    "readline",
502    "reprlib",
503    "resource",
504    "rlcompleter",
505    "runpy",
506    "sched",
507    "secrets",
508    "select",
509    "selectors",
510    "shelve",
511    "shlex",
512    "shutil",
513    "signal",
514    "site",
515    "smtpd",
516    "smtplib",
517    "sndhdr",
518    "socket",
519    "socketserver",
520    "spwd",
521    "sqlite3",
522    "sre_compile",
523    "sre_constants",
524    "sre_parse",
525    "ssl",
526    "stat",
527    "statistics",
528    "string",
529    "stringprep",
530    "struct",
531    "subprocess",
532    "sunau",
533    "symtable",
534    "sys",
535    "sysconfig",
536    "syslog",
537    "tabnanny",
538    "tarfile",
539    "telnetlib",
540    "tempfile",
541    "termios",
542    "textwrap",
543    "this",
544    "threading",
545    "time",
546    "timeit",
547    "tkinter",
548    "token",
549    "tokenize",
550    "tomllib",
551    "trace",
552    "traceback",
553    "tracemalloc",
554    "tty",
555    "turtle",
556    "turtledemo",
557    "types",
558    "typing",
559    "unicodedata",
560    "unittest",
561    "urllib",
562    "uu",
563    "uuid",
564    "venv",
565    "warnings",
566    "wave",
567    "weakref",
568    "webbrowser",
569    "winreg",
570    "winsound",
571    "wsgiref",
572    "xdrlib",
573    "xml",
574    "xmlrpc",
575    "zipapp",
576    "zipfile",
577    "zipimport",
578    "zlib",
579    "zoneinfo",
580];
581
582/// The trailing dotted segment of a module path (`myproject.db` → `db`).
583fn last_segment(module: &str) -> &str {
584    module.rsplit('.').next().unwrap_or(module)
585}
586
587/// The number of leading dots on a `from`-import: `from ..pkg import x` → 2, absolute → 0.
588fn relative_level(node: &StmtImportFrom) -> usize {
589    node.level.map_or(0, |level| level.to_usize())
590}
591
592/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING`, the guard over type-only imports.
593fn is_type_checking(test: &Expr) -> bool {
594    match test {
595        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
596        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
597        _ => false,
598    }
599}
600
601/// The unit-under-test base name for a test file: `widget_test.py` → `widget`. Only
602/// `*_test.py` reaches here, so stripping the `_test` suffix is all it takes.
603fn unit_under_test_base(file: &Path) -> String {
604    let name = file
605        .file_name()
606        .and_then(|n| n.to_str())
607        .unwrap_or_default();
608    let stem = name.strip_suffix(".py").unwrap_or(name);
609    stem.strip_suffix("_test").unwrap_or(stem).to_string()
610}
611
612/// Walks one test file, collecting lint violations. `fixture_depth` tracks `@pytest.fixture`
613/// nesting, so `no-inline-patch` allows a patch there and flags one in a test body.
614struct LintVisitor<'a> {
615    file: &'a Path,
616    source: &'a str,
617    fixture_depth: usize,
618    /// The dist's own top-level package, or `None` when undiscoverable.
619    first_party: Option<&'a str>,
620    violations: Vec<Violation>,
621}
622
623impl LintVisitor<'_> {
624    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
625        self.violations.push(Violation {
626            file: self.file.to_path_buf(),
627            line: line_of(self.source, range.start()),
628            rule,
629            message: message.to_string(),
630        });
631    }
632
633    /// Run the parameter lint, and return whether this function is a fixture.
634    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
635        let takes_monkeypatch = args
636            .posonlyargs
637            .iter()
638            .chain(&args.args)
639            .chain(&args.kwonlyargs)
640            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
641            || arg_named(&args.vararg, "monkeypatch")
642            || arg_named(&args.kwarg, "monkeypatch");
643        if takes_monkeypatch {
644            self.report(
645                range,
646                "no-monkeypatch",
647                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
648            );
649        }
650
651        decorators.iter().any(is_fixture_decorator)
652    }
653}
654
655impl Visitor for LintVisitor<'_> {
656    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
657        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
658        if is_fixture {
659            self.fixture_depth += 1;
660        }
661        self.generic_visit_stmt_function_def(node);
662        if is_fixture {
663            self.fixture_depth -= 1;
664        }
665    }
666
667    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
668        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
669        if is_fixture {
670            self.fixture_depth += 1;
671        }
672        self.generic_visit_stmt_async_function_def(node);
673        if is_fixture {
674            self.fixture_depth -= 1;
675        }
676    }
677
678    fn visit_expr_call(&mut self, node: ExprCall) {
679        let is_patch = is_patch_call(&node);
680        // A fixture is the right place for a patch; a test body is not.
681        if is_patch && self.fixture_depth == 0 {
682            self.report(
683                node.range,
684                "no-inline-patch",
685                "patch is called inline in a test body; move it into a `pytest.fixture`",
686            );
687        }
688        // Fires regardless of fixture depth — a config constant is usually patched in one.
689        if is_patch && patches_constant(&node) {
690            self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
691        }
692        // Fires regardless of fixture depth, and only when the dist's package is known.
693        if is_patch {
694            if let Some(pkg) = self.first_party {
695                if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
696                {
697                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
698                }
699            }
700        }
701        if is_environ_mutation_call(&node) {
702            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
703        }
704        self.generic_visit_expr_call(node);
705    }
706
707    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
708    // context expression is never walked unless we descend into it here.
709    fn visit_withitem(&mut self, node: WithItem) {
710        self.visit_expr(node.context_expr);
711        if let Some(optional_vars) = node.optional_vars {
712            self.visit_expr(*optional_vars);
713        }
714    }
715
716    fn visit_stmt_assign(&mut self, node: StmtAssign) {
717        if node.targets.iter().any(is_os_environ_subscript) {
718            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
719        }
720        self.generic_visit_stmt_assign(node);
721    }
722
723    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
724        if is_os_environ_subscript(node.target.as_ref()) {
725            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
726        }
727        self.generic_visit_stmt_aug_assign(node);
728    }
729
730    fn visit_stmt_delete(&mut self, node: StmtDelete) {
731        if node.targets.iter().any(is_os_environ_subscript) {
732            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
733        }
734        self.generic_visit_stmt_delete(node);
735    }
736}
737
738/// `true` when a `*args` / `**kwargs` arg is named `name`.
739fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
740    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
741}
742
743/// `true` for an `@pytest.fixture` / `@fixture` decorator, called or bare.
744fn is_fixture_decorator(decorator: &Expr) -> bool {
745    let target = match decorator {
746        Expr::Call(call) => call.func.as_ref(),
747        other => other,
748    };
749    match target {
750        Expr::Name(name) => name.id.as_str() == "fixture",
751        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
752        _ => false,
753    }
754}
755
756/// `true` for `patch(...)` / `patch.object(...)` / `patch.dict(...)`, plain or reached
757/// through a module (`mock.patch(...)`, `unittest.mock.patch`).
758fn is_patch_call(call: &ExprCall) -> bool {
759    match call.func.as_ref() {
760        Expr::Name(name) => name.id.as_str() == "patch",
761        Expr::Attribute(attr) => {
762            let name = attr.attr.as_str();
763            name == "patch"
764                || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
765        }
766        _ => false,
767    }
768}
769
770/// `true` when an attribute's base resolves to `patch` — a `patch.object` receiver.
771fn attr_base_is_patch(expr: &Expr) -> bool {
772    match expr {
773        Expr::Name(name) => name.id.as_str() == "patch",
774        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
775        _ => false,
776    }
777}
778
779const 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)";
780
781const 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";
782
783/// The string-literal first argument of a `patch(...)` call, the dotted target. `None` for
784/// a non-literal argument, which can't be classified deterministically.
785fn patch_string_target(call: &ExprCall) -> Option<&str> {
786    if let Some(Expr::Constant(constant)) = call.args.first() {
787        if let Constant::Str(target) = &constant.value {
788            return Some(target.as_str());
789        }
790    }
791    None
792}
793
794/// `true` when a `patch(...)` target names an UPPER_CASE constant (`"pkg.cfg.CACHE_DIR"`).
795fn patches_constant(call: &ExprCall) -> bool {
796    patch_string_target(call)
797        .and_then(|target| target.rsplit('.').next())
798        .is_some_and(is_upper_constant)
799}
800
801/// `true` when patch `target`'s head segment names the first-party package `pkg`.
802fn patches_first_party(target: &str, pkg: &str) -> bool {
803    target
804        .split('.')
805        .next()
806        .is_some_and(|head| !head.is_empty() && head == pkg)
807}
808
809/// `true` for an ALL-CAPS name: uppercase letters, digits, underscores, one letter minimum.
810fn is_upper_constant(name: &str) -> bool {
811    !name.is_empty()
812        && name
813            .chars()
814            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
815        && name.chars().any(|c| c.is_ascii_uppercase())
816}
817
818const ENVIRON_MUTATION_MSG: &str =
819    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
820
821/// `true` for the expression `os.environ`.
822fn is_os_environ(expr: &Expr) -> bool {
823    matches!(
824        expr,
825        Expr::Attribute(attr)
826            if attr.attr.as_str() == "environ"
827                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
828    )
829}
830
831/// `true` for `os.environ[...]`, the form used as an assignment or `del` target.
832fn is_os_environ_subscript(expr: &Expr) -> bool {
833    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
834}
835
836/// `true` for a mutating method call on `os.environ`, like `os.environ.update(...)`.
837fn is_environ_mutation_call(call: &ExprCall) -> bool {
838    matches!(
839        call.func.as_ref(),
840        Expr::Attribute(attr)
841            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
842    )
843}
844
845/// `true` for a `dict` method that mutates in place.
846fn is_environ_mutator(method: &str) -> bool {
847    matches!(
848        method,
849        "update" | "pop" | "setdefault" | "clear" | "popitem"
850    )
851}
852
853/// The 1-based line containing byte `offset` in `source`.
854fn line_of(source: &str, offset: TextSize) -> usize {
855    let offset = (u32::from(offset) as usize).min(source.len());
856    source.as_bytes()[..offset]
857        .iter()
858        .filter(|&&byte| byte == b'\n')
859        .count()
860        + 1
861}
862
863/// The dist's own top-level import package: the nearest `pyproject.toml`'s `[project].name`,
864/// [normalized](normalize_dist_name). The walk up stops at a `.git` boundary so it can't
865/// escape into an unrelated project, and `None` means nothing is flagged rather than guessed.
866fn first_party_package(root: &Path) -> Option<String> {
867    for dir in root.ancestors() {
868        let candidate = dir.join("pyproject.toml");
869        if candidate.is_file() {
870            return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
871        }
872        if dir.join(".git").exists() {
873            break;
874        }
875    }
876    None
877}
878
879/// `[project].name` from a `pyproject.toml`, if present and a string.
880fn read_project_name(path: &Path) -> Option<String> {
881    let contents = std::fs::read_to_string(path).ok()?;
882    let value: toml::Value = toml::from_str(&contents).ok()?;
883    value
884        .get("project")?
885        .get("name")?
886        .as_str()
887        .map(str::to_owned)
888}
889
890/// A distribution name as its import package name, PEP 503-flavoured: `My-Project` →
891/// `my_project`.
892fn normalize_dist_name(name: &str) -> String {
893    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
894}
895
896fn collect_python_files(
897    dir: &Path,
898    out: &mut Vec<PathBuf>,
899    is_match: fn(&Path) -> bool,
900) -> Result<()> {
901    let entries =
902        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
903    for entry in entries {
904        let path = entry
905            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
906            .path();
907        if path.is_dir() {
908            collect_python_files(&path, out, is_match)?;
909        } else if is_match(&path) {
910            out.push(path);
911        }
912    }
913    Ok(())
914}
915
916/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`. A legacy
917/// `test_*.py` is ordinary source.
918fn is_python_test_file(path: &Path) -> bool {
919    let name = path
920        .file_name()
921        .and_then(|n| n.to_str())
922        .unwrap_or_default();
923    name == "conftest.py" || name.ends_with("_test.py")
924}
925
926/// `true` for a colocated unit test: `*_test.py`. A legacy `test_*.py` is ordinary source,
927/// and `conftest.py` holds fixtures rather than a unit.
928fn is_python_unit_test_file(path: &Path) -> bool {
929    let name = path
930        .file_name()
931        .and_then(|n| n.to_str())
932        .unwrap_or_default();
933    name.ends_with("_test.py")
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use std::sync::atomic::{AtomicU64, Ordering};
940
941    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
942    struct TempDir(PathBuf);
943
944    impl TempDir {
945        fn new() -> Self {
946            static COUNTER: AtomicU64 = AtomicU64::new(0);
947            let dir = std::env::temp_dir().join(format!(
948                "tc-lint-{}-{}",
949                std::process::id(),
950                COUNTER.fetch_add(1, Ordering::Relaxed),
951            ));
952            std::fs::create_dir_all(&dir).unwrap();
953            TempDir(dir)
954        }
955
956        fn write(&self, name: &str, contents: &str) {
957            let path = self.0.join(name);
958            if let Some(parent) = path.parent() {
959                std::fs::create_dir_all(parent).unwrap();
960            }
961            std::fs::write(path, contents).unwrap();
962        }
963    }
964
965    impl Drop for TempDir {
966        fn drop(&mut self) {
967            let _ = std::fs::remove_dir_all(&self.0);
968        }
969    }
970
971    #[test]
972    fn normalize_dist_name_maps_to_import_name() {
973        assert_eq!(normalize_dist_name("My-Project"), "my_project");
974        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
975        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
976        assert_eq!(normalize_dist_name("myproject"), "myproject");
977    }
978
979    /// Parse `src` (a single expression statement) and return its call.
980    fn parse_call(src: &str) -> ExprCall {
981        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
982        match suite.into_iter().next().expect("one statement") {
983            ast::Stmt::Expr(stmt) => match *stmt.value {
984                Expr::Call(call) => call,
985                other => panic!("expected a call, got {other:?}"),
986            },
987            other => panic!("expected an expression statement, got {other:?}"),
988        }
989    }
990
991    #[test]
992    fn patch_string_target_only_reads_string_literals() {
993        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
994        assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
995        let int_call = parse_call("patch(42)\n");
996        assert_eq!(patch_string_target(&int_call), None);
997        let name_call = parse_call("patch(target)\n");
998        assert_eq!(patch_string_target(&name_call), None);
999        let empty_call = parse_call("patch()\n");
1000        assert_eq!(patch_string_target(&empty_call), None);
1001    }
1002
1003    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1004    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1005        ImportRecord {
1006            display: source.unwrap_or(".rel").to_string(),
1007            line: 1,
1008            is_uut: false,
1009            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1010            source: source.map(str::to_string),
1011            module: None,
1012        }
1013    }
1014
1015    fn targets(list: &[&str]) -> Vec<String> {
1016        list.iter().map(|s| (*s).to_string()).collect()
1017    }
1018
1019    #[test]
1020    fn is_mocked_requires_every_symbol_at_the_import_module() {
1021        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1022        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked.
1023        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1024        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1025    }
1026
1027    #[test]
1028    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1029        let rec = from_import(Some("pkg.ledger"), &["record"]);
1030        // Same last segment, different module → not mocked.
1031        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1032        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1033        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1034        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1035    }
1036
1037    #[test]
1038    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1039        // A relative import has no module to compare, so a last-segment match is accepted.
1040        let rec = from_import(None, &["record"]);
1041        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1042        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1043    }
1044
1045    #[test]
1046    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1047        let rec = ImportRecord {
1048            display: "pkg.db".to_string(),
1049            line: 1,
1050            is_uut: false,
1051            symbols: Vec::new(),
1052            source: None,
1053            module: Some("pkg.db".to_string()),
1054        };
1055        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1056        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1057        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1058        let empty = from_import(Some("pkg.mod"), &[]);
1059        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1060    }
1061
1062    #[test]
1063    fn patches_first_party_matches_head_segment() {
1064        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1065        assert!(patches_first_party("myproject", "myproject"));
1066        assert!(!patches_first_party("requests.get", "myproject"));
1067        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1068        assert!(!patches_first_party("", "myproject"));
1069        assert!(!patches_first_party(".leading", "myproject"));
1070    }
1071
1072    #[test]
1073    fn first_party_package_reads_pyproject_name() {
1074        let tree = TempDir::new();
1075        tree.write(
1076            "pyproject.toml",
1077            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1078        );
1079        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1080    }
1081
1082    #[test]
1083    fn first_party_package_is_none_without_a_project_name() {
1084        let tree = TempDir::new();
1085        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1086        tree.write(".git", "");
1087        assert_eq!(first_party_package(&tree.0), None);
1088    }
1089
1090    #[test]
1091    fn first_party_package_is_none_when_absent() {
1092        let tree = TempDir::new();
1093        assert_eq!(first_party_package(&tree.0), None);
1094    }
1095
1096    /// The displays of the imports `source` leaves un-mocked.
1097    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1098        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1099        let mut visitor = UnitIsolationVisitor {
1100            source,
1101            first_party,
1102            base,
1103            type_checking_depth: 0,
1104            imports: Vec::new(),
1105            patch_targets: Vec::new(),
1106        };
1107        for stmt in suite {
1108            visitor.visit_stmt(stmt);
1109        }
1110        visitor
1111            .imports
1112            .iter()
1113            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1114            .map(|i| i.display.clone())
1115            .collect()
1116    }
1117
1118    #[test]
1119    fn import_head_and_last_segment() {
1120        assert_eq!(import_head("myproject.db.conn"), "myproject");
1121        assert_eq!(import_head("requests"), "requests");
1122        assert_eq!(last_segment("myproject.db.conn"), "conn");
1123        assert_eq!(last_segment("widget"), "widget");
1124    }
1125
1126    #[test]
1127    fn unit_under_test_base_strips_test_suffix() {
1128        assert_eq!(
1129            unit_under_test_base(Path::new("pkg/widget_test.py")),
1130            "widget"
1131        );
1132        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its prefix.
1133        assert_eq!(
1134            unit_under_test_base(Path::new("test_widget.py")),
1135            "test_widget"
1136        );
1137        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1138    }
1139
1140    #[test]
1141    fn recognizes_python_unit_test_files() {
1142        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1143        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1144        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1145        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1146        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1147    }
1148
1149    #[test]
1150    fn visitor_flags_first_party_and_external_collaborators() {
1151        // The UUT is left alone; the first-party and third-party imports are flagged.
1152        let found = unmocked(
1153            "widget",
1154            "myproject",
1155            "from myproject.widget import build\n\
1156             from myproject.ledger import record\n\
1157             import requests\n",
1158        );
1159        assert_eq!(
1160            found,
1161            vec!["myproject.ledger".to_string(), "requests".to_string()]
1162        );
1163    }
1164
1165    #[test]
1166    fn visitor_clears_a_mocked_collaborator() {
1167        let found = unmocked(
1168            "widget",
1169            "myproject",
1170            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1171        );
1172        assert!(found.is_empty(), "got: {found:?}");
1173    }
1174
1175    #[test]
1176    fn visitor_flags_a_wrong_module_patch() {
1177        // A patch sharing only the last segment names a different module, so `record`
1178        // stays an un-mocked collaborator.
1179        let found = unmocked(
1180            "widget",
1181            "myproject",
1182            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1183        );
1184        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1185    }
1186
1187    #[test]
1188    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1189        // Patching only `record` leaves the sibling `erase` a real collaborator.
1190        let found = unmocked(
1191            "widget",
1192            "myproject",
1193            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1194        );
1195        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1196        let both = unmocked(
1197            "widget",
1198            "myproject",
1199            "from myproject.ledger import record, erase\n\
1200             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1201        );
1202        assert!(both.is_empty(), "got: {both:?}");
1203    }
1204
1205    #[test]
1206    fn visitor_handles_module_and_relative_imports() {
1207        assert_eq!(
1208            unmocked("widget", "myproject", "import myproject.db\n"),
1209            vec!["myproject.db".to_string()]
1210        );
1211        assert!(unmocked(
1212            "widget",
1213            "myproject",
1214            "import myproject.db\npatch(\"myproject.db.connect\")\n"
1215        )
1216        .is_empty());
1217        assert_eq!(
1218            unmocked("widget", "myproject", "from .ledger import record\n"),
1219            vec![".ledger".to_string()]
1220        );
1221        assert_eq!(
1222            unmocked(
1223                "widget",
1224                "myproject",
1225                "from . import ledger\nfrom . import widget\n"
1226            ),
1227            vec![".ledger".to_string()]
1228        );
1229    }
1230
1231    #[test]
1232    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1233        // A bare `from . import …` names the package's own re-export surface, the SUT.
1234        assert!(unmocked(
1235            "__init__",
1236            "myproject",
1237            "from . import Thing, __all__, __version__\n"
1238        )
1239        .is_empty());
1240        // Reaching around the barrel into a sibling module is still a collaborator.
1241        assert_eq!(
1242            unmocked("__init__", "myproject", "from .core import Thing\n"),
1243            vec![".core".to_string()]
1244        );
1245        // `from .. import x` resolves to the parent package, not the SUT file.
1246        assert_eq!(
1247            unmocked("__init__", "myproject", "from .. import sibling\n"),
1248            vec!["..sibling".to_string()]
1249        );
1250        // The barrel shortcut is scoped to the `__init__` base.
1251        assert_eq!(
1252            unmocked("widget", "myproject", "from . import ledger\n"),
1253            vec![".ledger".to_string()]
1254        );
1255    }
1256
1257    #[test]
1258    fn visitor_skips_type_checking_imports() {
1259        // A TYPE_CHECKING import is type-only; the runtime `else` import is still seen.
1260        let found = unmocked(
1261            "widget",
1262            "myproject",
1263            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
1264        );
1265        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1266    }
1267
1268    #[test]
1269    fn is_checked_import_classifies_origins() {
1270        assert!(is_checked_import("myproject", "myproject")); // first-party
1271        assert!(!is_checked_import("pytest", "myproject")); // test framework
1272        assert!(!is_checked_import("_pytest", "myproject"));
1273        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
1274        assert!(is_checked_import("socket", "myproject"));
1275        assert!(!is_checked_import("json", "myproject")); // pure stdlib
1276        assert!(!is_checked_import("dataclasses", "myproject"));
1277        assert!(is_checked_import("requests", "myproject")); // third-party
1278        assert!(is_checked_import("stripe", "myproject"));
1279        // A dual-nature head stays pure — the patch convention catches it, not the import.
1280        assert!(!is_checked_import("os", "myproject"));
1281        assert!(!is_checked_import("pathlib", "myproject"));
1282        assert!(!is_checked_import("datetime", "myproject"));
1283    }
1284
1285    #[test]
1286    fn visitor_flags_external_collaborators() {
1287        let found = unmocked(
1288            "widget",
1289            "myproject",
1290            "import requests\nimport subprocess\nimport json\nimport pytest\n",
1291        );
1292        assert_eq!(found.len(), 2, "got: {found:?}");
1293        assert!(found.contains(&"requests".to_string()));
1294        assert!(found.contains(&"subprocess".to_string()));
1295    }
1296
1297    #[test]
1298    fn visitor_type_checking_variants_and_plain_if() {
1299        // The attribute form guards type-only imports too.
1300        assert!(unmocked(
1301            "widget",
1302            "myproject",
1303            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
1304        )
1305        .is_empty());
1306        // A plain `if` is walked normally; its import is still a collaborator.
1307        assert_eq!(
1308            unmocked(
1309                "widget",
1310                "myproject",
1311                "if ready == 1:\n    from myproject.ledger import record\n"
1312            ),
1313            vec!["myproject.ledger".to_string()]
1314        );
1315    }
1316
1317    #[test]
1318    fn find_unit_isolation_without_pyproject_reports_nothing() {
1319        let tree = TempDir::new();
1320        tree.write("widget_test.py", "from myproject.ledger import record\n");
1321        tree.write(".git", "");
1322        assert!(find_unit_isolation_violations(&tree.0)
1323            .expect("a readable tree should succeed")
1324            .is_empty());
1325    }
1326
1327    #[test]
1328    fn find_unit_isolation_walks_subdirs_and_flags() {
1329        let tree = TempDir::new();
1330        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1331        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1332        let found =
1333            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1334        assert_eq!(found.len(), 1, "got: {found:?}");
1335        assert_eq!(found[0].rule, "unmocked-collaborator");
1336        assert!(found[0].message.contains("myproject.ledger"));
1337    }
1338
1339    #[test]
1340    fn recognizes_python_test_files() {
1341        assert!(is_python_test_file(Path::new("widget_test.py")));
1342        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1343        assert!(is_python_test_file(Path::new("conftest.py")));
1344        assert!(!is_python_test_file(Path::new("test_widget.py")));
1345    }
1346
1347    #[test]
1348    fn ignores_non_test_files() {
1349        assert!(!is_python_test_file(Path::new("widget.py")));
1350        assert!(!is_python_test_file(Path::new("conftest.pyi")));
1351        assert!(!is_python_test_file(Path::new("README.md")));
1352        assert!(!is_python_test_file(Path::new("testing.py")));
1353    }
1354
1355    #[test]
1356    fn line_of_counts_newlines() {
1357        let src = "a\nb\nc\n";
1358        assert_eq!(line_of(src, TextSize::from(0)), 1);
1359        assert_eq!(line_of(src, TextSize::from(2)), 2);
1360        assert_eq!(line_of(src, TextSize::from(4)), 3);
1361    }
1362
1363    #[test]
1364    fn recognizes_environ_mutators() {
1365        assert!(is_environ_mutator("update"));
1366        assert!(is_environ_mutator("pop"));
1367        assert!(is_environ_mutator("clear"));
1368        assert!(!is_environ_mutator("get"));
1369        assert!(!is_environ_mutator("keys"));
1370    }
1371
1372    #[test]
1373    fn recognizes_upper_constants() {
1374        assert!(is_upper_constant("CACHE_DIR"));
1375        assert!(is_upper_constant("DEBUG"));
1376        assert!(is_upper_constant("MAX_2"));
1377        assert!(!is_upper_constant("cache_dir"));
1378        assert!(!is_upper_constant("CacheDir"));
1379        assert!(!is_upper_constant("fetch"));
1380        assert!(!is_upper_constant(""));
1381        assert!(!is_upper_constant("_"));
1382        assert!(!is_upper_constant("123"));
1383    }
1384}