Skip to main content

testing_conventions/
lint.rs

1//! Integration-test lints (issue #19; rules #48–#52) — the `integration lint`
2//! command.
3//!
4//! A *lint* here is a deterministic style/mechanism check on test code, as
5//! opposed to the structural `colocated-test` / `coverage` rules. This module hosts
6//! the mocking mechanism & style lints; more lints will join them under the
7//! same command.
8//!
9//! Detection is AST-based: each Python test file is parsed with
10//! `rustpython_parser` and the tree is walked with a [`Visitor`].
11//!
12//! Implemented lints:
13//! - **`no-monkeypatch`** (#49): a test/fixture function that declares the
14//!   `monkeypatch` parameter (pytest's fixture). Patch with `unittest.mock`
15//!   wrapped in a `pytest.fixture` instead.
16//! - **`no-inline-patch`** (#50): a `patch(...)` / `patch.object(...)` /
17//!   `patch.dict(...)` call inside a test body — the `with patch(...)` form or a
18//!   bare call. Patches belong in a `pytest.fixture`; a patch *inside* a fixture
19//!   is allowed.
20//! - **`no-environ-mutation`** (#51): direct mutation of `os.environ` —
21//!   `os.environ[...] = …`, `del os.environ[...]`, or a mutating method
22//!   (`update` / `pop` / `setdefault` / `clear` / `popitem`). Set env via
23//!   `patch.dict(os.environ, {...})` instead.
24//! - **`no-constant-patch`** (#52): patching a module-global UPPER_CASE constant,
25//!   e.g. `patch("pkg.config.CACHE_DIR", …)`. Inject config explicitly. Waivable
26//!   per file via the config `exempt` list.
27
28use std::path::{Path, PathBuf};
29
30use anyhow::{anyhow, Context, Result};
31use rustpython_ast::Visitor;
32use rustpython_parser::ast::{
33    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
34    StmtAugAssign, StmtDelete, StmtFunctionDef, WithItem,
35};
36use rustpython_parser::text_size::{TextRange, TextSize};
37use rustpython_parser::Parse;
38
39// `Violation` is shared with the Rust `isolation` lint; it lives in `violation`
40// and is re-exported here so `testing_conventions::lint::Violation` still resolves.
41pub use crate::violation::Violation;
42
43/// Scan the Python test files under `root` and return every lint violation,
44/// sorted by `(file, line)` for deterministic output.
45///
46/// A *Python test file* is `*_test.py`, the legacy `test_*.py`, or
47/// `conftest.py` (where fixtures live). Each is parsed and walked. A file that
48/// cannot be read or parsed is an error.
49pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
50    let root = root.as_ref();
51    let mut files = Vec::new();
52    collect_python_test_files(root, &mut files)?;
53    files.sort();
54
55    let mut violations = Vec::new();
56    for file in &files {
57        let source = std::fs::read_to_string(file)
58            .with_context(|| format!("reading test file `{}`", file.display()))?;
59        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
60            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
61        let mut visitor = LintVisitor {
62            file,
63            source: &source,
64            fixture_depth: 0,
65            violations: Vec::new(),
66        };
67        for stmt in suite {
68            visitor.visit_stmt(stmt);
69        }
70        violations.append(&mut visitor.violations);
71    }
72
73    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
74    Ok(violations)
75}
76
77/// Walks one parsed test file, collecting lint violations. Tracks how deep we
78/// are inside `@pytest.fixture` functions so `no-inline-patch` can allow patches
79/// there while flagging them in test bodies.
80struct LintVisitor<'a> {
81    file: &'a Path,
82    source: &'a str,
83    fixture_depth: usize,
84    violations: Vec<Violation>,
85}
86
87impl LintVisitor<'_> {
88    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
89        self.violations.push(Violation {
90            file: self.file.to_path_buf(),
91            line: line_of(self.source, range.start()),
92            rule,
93            message: message.to_string(),
94        });
95    }
96
97    /// Shared entry for both function kinds: run the parameter lint, then return
98    /// whether this function is a fixture (so the caller bumps `fixture_depth`).
99    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
100        // `no-monkeypatch` (#49): the `monkeypatch` parameter is the signal.
101        let takes_monkeypatch = args
102            .posonlyargs
103            .iter()
104            .chain(&args.args)
105            .chain(&args.kwonlyargs)
106            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
107            || arg_named(&args.vararg, "monkeypatch")
108            || arg_named(&args.kwarg, "monkeypatch");
109        if takes_monkeypatch {
110            self.report(
111                range,
112                "no-monkeypatch",
113                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
114            );
115        }
116
117        decorators.iter().any(is_fixture_decorator)
118    }
119}
120
121impl Visitor for LintVisitor<'_> {
122    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
123        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
124        if is_fixture {
125            self.fixture_depth += 1;
126        }
127        self.generic_visit_stmt_function_def(node);
128        if is_fixture {
129            self.fixture_depth -= 1;
130        }
131    }
132
133    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
134        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
135        if is_fixture {
136            self.fixture_depth += 1;
137        }
138        self.generic_visit_stmt_async_function_def(node);
139        if is_fixture {
140            self.fixture_depth -= 1;
141        }
142    }
143
144    fn visit_expr_call(&mut self, node: ExprCall) {
145        let is_patch = is_patch_call(&node);
146        // `no-inline-patch` (#50): a patch(...) call outside any fixture is a
147        // patch in a test body. Inside a fixture it is the right place.
148        if is_patch && self.fixture_depth == 0 {
149            self.report(
150                node.range,
151                "no-inline-patch",
152                "patch is called inline in a test body; move it into a `pytest.fixture`",
153            );
154        }
155        // `no-constant-patch` (#52): patching a module-global UPPER_CASE constant.
156        // Fires regardless of fixture — config constants are usually patched in one.
157        if is_patch && patches_constant(&node) {
158            self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
159        }
160        // `no-environ-mutation` (#51): `os.environ.update(...)` and friends.
161        if is_environ_mutation_call(&node) {
162            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
163        }
164        self.generic_visit_expr_call(node);
165    }
166
167    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
168    // context expression is never walked unless we descend into it here.
169    fn visit_withitem(&mut self, node: WithItem) {
170        self.visit_expr(node.context_expr);
171        if let Some(optional_vars) = node.optional_vars {
172            self.visit_expr(*optional_vars);
173        }
174    }
175
176    // `no-environ-mutation` (#51): `os.environ[...] = …`, augmented assignment,
177    // and `del os.environ[...]`.
178    fn visit_stmt_assign(&mut self, node: StmtAssign) {
179        if node.targets.iter().any(is_os_environ_subscript) {
180            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
181        }
182        self.generic_visit_stmt_assign(node);
183    }
184
185    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
186        if is_os_environ_subscript(node.target.as_ref()) {
187            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
188        }
189        self.generic_visit_stmt_aug_assign(node);
190    }
191
192    fn visit_stmt_delete(&mut self, node: StmtDelete) {
193        if node.targets.iter().any(is_os_environ_subscript) {
194            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
195        }
196        self.generic_visit_stmt_delete(node);
197    }
198}
199
200/// `true` when a `*args` / `**kwargs` arg is named `name`.
201fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
202    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
203}
204
205/// `true` for an `@pytest.fixture` / `@fixture` decorator, with or without a
206/// call (`@pytest.fixture(autouse=True)`).
207fn is_fixture_decorator(decorator: &Expr) -> bool {
208    let target = match decorator {
209        Expr::Call(call) => call.func.as_ref(),
210        other => other,
211    };
212    match target {
213        Expr::Name(name) => name.id.as_str() == "fixture",
214        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
215        _ => false,
216    }
217}
218
219/// `true` when a call is `patch(...)`, `patch.object(...)`, `patch.dict(...)`, or
220/// the same reached through a module (`mock.patch(...)`, `unittest.mock.patch`).
221fn is_patch_call(call: &ExprCall) -> bool {
222    match call.func.as_ref() {
223        Expr::Name(name) => name.id.as_str() == "patch",
224        Expr::Attribute(attr) => {
225            let name = attr.attr.as_str();
226            name == "patch"
227                || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
228        }
229        _ => false,
230    }
231}
232
233/// `true` when an attribute's base resolves to `patch` — the receiver of
234/// `patch.object` / `patch.dict`.
235fn attr_base_is_patch(expr: &Expr) -> bool {
236    match expr {
237        Expr::Name(name) => name.id.as_str() == "patch",
238        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
239        _ => false,
240    }
241}
242
243/// Message for the `no-constant-patch` lint.
244const 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)";
245
246/// `true` when a `patch(...)` call's first string argument names a module-global
247/// UPPER_CASE constant, e.g. `patch("pkg.config.CACHE_DIR", …)`.
248fn patches_constant(call: &ExprCall) -> bool {
249    let Some(Expr::Constant(constant)) = call.args.first() else {
250        return false;
251    };
252    let Constant::Str(target) = &constant.value else {
253        return false;
254    };
255    target.rsplit('.').next().is_some_and(is_upper_constant)
256}
257
258/// `true` for an ALL-CAPS constant name — letters uppercase, digits and
259/// underscores allowed, at least one letter (`CACHE_DIR`, `DEBUG`, `MAX_SIZE`).
260fn is_upper_constant(name: &str) -> bool {
261    !name.is_empty()
262        && name
263            .chars()
264            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
265        && name.chars().any(|c| c.is_ascii_uppercase())
266}
267
268/// Message for the `no-environ-mutation` lint.
269const ENVIRON_MUTATION_MSG: &str =
270    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
271
272/// `true` for the expression `os.environ`.
273fn is_os_environ(expr: &Expr) -> bool {
274    matches!(
275        expr,
276        Expr::Attribute(attr)
277            if attr.attr.as_str() == "environ"
278                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
279    )
280}
281
282/// `true` for `os.environ[...]` — a subscript of `os.environ`, the form used as
283/// an assignment or `del` target.
284fn is_os_environ_subscript(expr: &Expr) -> bool {
285    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
286}
287
288/// `true` for a mutating method call on `os.environ` (`os.environ.update(...)`
289/// and friends).
290fn is_environ_mutation_call(call: &ExprCall) -> bool {
291    matches!(
292        call.func.as_ref(),
293        Expr::Attribute(attr)
294            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
295    )
296}
297
298/// `true` for a `dict` method that mutates in place.
299fn is_environ_mutator(method: &str) -> bool {
300    matches!(
301        method,
302        "update" | "pop" | "setdefault" | "clear" | "popitem"
303    )
304}
305
306/// The 1-based line containing byte `offset` in `source`.
307fn line_of(source: &str, offset: TextSize) -> usize {
308    let offset = (u32::from(offset) as usize).min(source.len());
309    source.as_bytes()[..offset]
310        .iter()
311        .filter(|&&byte| byte == b'\n')
312        .count()
313        + 1
314}
315
316/// Recursively collect every Python test file under `dir` into `out`.
317fn collect_python_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
318    let entries =
319        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
320    for entry in entries {
321        let path = entry
322            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
323            .path();
324        if path.is_dir() {
325            collect_python_test_files(&path, out)?;
326        } else if is_python_test_file(&path) {
327            out.push(path);
328        }
329    }
330    Ok(())
331}
332
333/// `true` for a file the lints scan: `*_test.py`, legacy `test_*.py`, or
334/// `conftest.py`.
335fn is_python_test_file(path: &Path) -> bool {
336    let name = path
337        .file_name()
338        .and_then(|n| n.to_str())
339        .unwrap_or_default();
340    name == "conftest.py"
341        || name.ends_with("_test.py")
342        || (name.starts_with("test_") && name.ends_with(".py"))
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn recognizes_python_test_files() {
351        assert!(is_python_test_file(Path::new("widget_test.py")));
352        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
353        assert!(is_python_test_file(Path::new("test_widget.py")));
354        assert!(is_python_test_file(Path::new("conftest.py")));
355    }
356
357    #[test]
358    fn ignores_non_test_files() {
359        assert!(!is_python_test_file(Path::new("widget.py")));
360        assert!(!is_python_test_file(Path::new("conftest.pyi")));
361        assert!(!is_python_test_file(Path::new("README.md")));
362        assert!(!is_python_test_file(Path::new("testing.py")));
363    }
364
365    #[test]
366    fn line_of_counts_newlines() {
367        let src = "a\nb\nc\n";
368        assert_eq!(line_of(src, TextSize::from(0)), 1);
369        assert_eq!(line_of(src, TextSize::from(2)), 2);
370        assert_eq!(line_of(src, TextSize::from(4)), 3);
371    }
372
373    #[test]
374    fn recognizes_environ_mutators() {
375        assert!(is_environ_mutator("update"));
376        assert!(is_environ_mutator("pop"));
377        assert!(is_environ_mutator("clear"));
378        assert!(!is_environ_mutator("get"));
379        assert!(!is_environ_mutator("keys"));
380    }
381
382    #[test]
383    fn recognizes_upper_constants() {
384        assert!(is_upper_constant("CACHE_DIR"));
385        assert!(is_upper_constant("DEBUG"));
386        assert!(is_upper_constant("MAX_2"));
387        assert!(!is_upper_constant("cache_dir"));
388        assert!(!is_upper_constant("CacheDir"));
389        assert!(!is_upper_constant("fetch"));
390        assert!(!is_upper_constant(""));
391        assert!(!is_upper_constant("_"));
392        assert!(!is_upper_constant("123"));
393    }
394}