testing_conventions/
lint.rs1use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Context, Result};
20use rustpython_parser::ast::{self, Arguments, Stmt};
21use rustpython_parser::text_size::{TextRange, TextSize};
22use rustpython_parser::Parse;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Violation {
27 pub file: PathBuf,
29 pub line: usize,
31 pub rule: &'static str,
33 pub message: String,
35}
36
37pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
46 let root = root.as_ref();
47 let mut files = Vec::new();
48 collect_python_test_files(root, &mut files)?;
49 files.sort();
50
51 let mut violations = Vec::new();
52 for file in &files {
53 let source = std::fs::read_to_string(file)
54 .with_context(|| format!("reading test file `{}`", file.display()))?;
55 let suite = ast::Suite::parse(&source, &file.to_string_lossy())
56 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
57 check_suite(&suite, file, &source, &mut violations);
58 }
59
60 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
61 Ok(violations)
62}
63
64fn check_suite(stmts: &[Stmt], file: &Path, source: &str, out: &mut Vec<Violation>) {
68 for stmt in stmts {
69 match stmt {
70 Stmt::FunctionDef(f) => {
71 check_arguments(&f.args, f.range, file, source, out);
72 check_suite(&f.body, file, source, out);
73 }
74 Stmt::AsyncFunctionDef(f) => {
75 check_arguments(&f.args, f.range, file, source, out);
76 check_suite(&f.body, file, source, out);
77 }
78 Stmt::ClassDef(c) => check_suite(&c.body, file, source, out),
79 _ => {}
80 }
81 }
82}
83
84fn check_arguments(
88 args: &Arguments,
89 range: TextRange,
90 file: &Path,
91 source: &str,
92 out: &mut Vec<Violation>,
93) {
94 let takes_monkeypatch = args
95 .posonlyargs
96 .iter()
97 .chain(args.args.iter())
98 .chain(args.kwonlyargs.iter())
99 .any(|arg| arg.def.arg.as_str() == "monkeypatch")
100 || vararg_is_monkeypatch(&args.vararg)
101 || vararg_is_monkeypatch(&args.kwarg);
102
103 if takes_monkeypatch {
104 out.push(Violation {
105 file: file.to_path_buf(),
106 line: line_of(source, range.start()),
107 rule: "no-monkeypatch",
108 message:
109 "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead"
110 .to_string(),
111 });
112 }
113}
114
115fn vararg_is_monkeypatch(arg: &Option<Box<ast::Arg>>) -> bool {
117 arg.as_ref()
118 .is_some_and(|arg| arg.arg.as_str() == "monkeypatch")
119}
120
121fn line_of(source: &str, offset: TextSize) -> usize {
123 let offset = (u32::from(offset) as usize).min(source.len());
124 source.as_bytes()[..offset]
125 .iter()
126 .filter(|&&byte| byte == b'\n')
127 .count()
128 + 1
129}
130
131fn collect_python_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
133 let entries =
134 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
135 for entry in entries {
136 let path = entry
137 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
138 .path();
139 if path.is_dir() {
140 collect_python_test_files(&path, out)?;
141 } else if is_python_test_file(&path) {
142 out.push(path);
143 }
144 }
145 Ok(())
146}
147
148fn is_python_test_file(path: &Path) -> bool {
151 let name = path
152 .file_name()
153 .and_then(|n| n.to_str())
154 .unwrap_or_default();
155 name == "conftest.py"
156 || name.ends_with("_test.py")
157 || (name.starts_with("test_") && name.ends_with(".py"))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn recognizes_python_test_files() {
166 assert!(is_python_test_file(Path::new("widget_test.py")));
167 assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
168 assert!(is_python_test_file(Path::new("test_widget.py")));
169 assert!(is_python_test_file(Path::new("conftest.py")));
170 }
171
172 #[test]
173 fn ignores_non_test_files() {
174 assert!(!is_python_test_file(Path::new("widget.py")));
175 assert!(!is_python_test_file(Path::new("conftest.pyi")));
176 assert!(!is_python_test_file(Path::new("README.md")));
177 assert!(!is_python_test_file(Path::new("testing.py")));
178 }
179
180 #[test]
181 fn line_of_counts_newlines() {
182 let src = "a\nb\nc\n";
183 assert_eq!(line_of(src, TextSize::from(0)), 1);
184 assert_eq!(line_of(src, TextSize::from(2)), 2);
185 assert_eq!(line_of(src, TextSize::from(4)), 3);
186 }
187}