1use 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
39pub use crate::violation::Violation;
42
43pub 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
77struct 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 fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
100 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 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 if is_patch && patches_constant(&node) {
158 self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
159 }
160 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 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 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
200fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
202 arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
203}
204
205fn 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
219fn 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
233fn 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
243const 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
246fn 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
258fn 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
268const ENVIRON_MUTATION_MSG: &str =
270 "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
271
272fn 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
282fn is_os_environ_subscript(expr: &Expr) -> bool {
285 matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
286}
287
288fn 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
298fn is_environ_mutator(method: &str) -> bool {
300 matches!(
301 method,
302 "update" | "pop" | "setdefault" | "clear" | "popitem"
303 )
304}
305
306fn 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
316fn 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
333fn 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}