patch-linter 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Forbid unittest.mock.patch and pytest monkeypatch in test code."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ CODE = "PATCH001"
6
+ MESSAGE = "Patch usage in tests"
@@ -0,0 +1,208 @@
1
+ """AST checker for forbidden patch / monkeypatch usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from patch_linter import CODE, MESSAGE
11
+ from patch_linter.imports import (
12
+ MONKEYPATCH_METHODS,
13
+ ImportBindings,
14
+ collect_imports,
15
+ )
16
+ from patch_linter.models import Issue
17
+
18
+ SKIP_DIRS = frozenset({"__pycache__", ".venv", "venv", ".git", ".tox", ".mypy_cache", ".ruff_cache"})
19
+
20
+ # flake8/ruff style: ``# noqa`` or ``# noqa: PATCH001,E501``
21
+ _NOQA_RE = re.compile(
22
+ r"#\s*noqa\b(?::\s*(.*))?",
23
+ re.IGNORECASE,
24
+ )
25
+ _CODE_SPLIT_RE = re.compile(r"[,\s]+")
26
+
27
+
28
+ def line_has_noqa(line: str, code: str = CODE) -> bool:
29
+ match = _NOQA_RE.search(line)
30
+ if not match:
31
+ return False
32
+ codes = match.group(1)
33
+ if codes is None or codes.strip() == "":
34
+ return True
35
+ return code.upper() in {c.strip().upper() for c in _CODE_SPLIT_RE.split(codes) if c.strip()}
36
+
37
+
38
+ def analyze_source(
39
+ source: str,
40
+ filepath: Path,
41
+ *,
42
+ tree: ast.AST | None = None,
43
+ ) -> list[Issue]:
44
+ """Analyze Python source; return issues (empty on parse failure)."""
45
+ lines = source.splitlines()
46
+ if tree is None:
47
+ try:
48
+ tree = ast.parse(source, filename=str(filepath))
49
+ except SyntaxError as exc:
50
+ print(
51
+ f"Warning: skipping {filepath.resolve()}: syntax error: {exc}",
52
+ file=sys.stderr,
53
+ )
54
+ return []
55
+
56
+ bindings = collect_imports(tree)
57
+ visitor = PatchVisitor(bindings=bindings, filepath=filepath, lines=lines)
58
+ visitor.visit(tree)
59
+ return visitor.issues
60
+
61
+
62
+ def analyze_file(filepath: Path) -> list[Issue]:
63
+ path = filepath.resolve()
64
+ try:
65
+ content = path.read_text(encoding="utf-8")
66
+ except UnicodeDecodeError as exc:
67
+ print(f"Warning: skipping {path}: decode error: {exc}", file=sys.stderr)
68
+ return []
69
+ return analyze_source(content, path)
70
+
71
+
72
+ def iter_python_files(root: Path) -> list[Path]:
73
+ root = root.resolve()
74
+ if root.is_file():
75
+ return [root] if root.suffix == ".py" else []
76
+
77
+ files: list[Path] = []
78
+ for path in root.rglob("*.py"):
79
+ if any(part in SKIP_DIRS for part in path.parts):
80
+ continue
81
+ files.append(path)
82
+ return sorted(files)
83
+
84
+
85
+ def analyze_paths(paths: list[Path]) -> list[Issue]:
86
+ issues: list[Issue] = []
87
+ for raw in paths:
88
+ path = Path(raw)
89
+ if not path.exists():
90
+ print(f"Warning: path does not exist: {path}", file=sys.stderr)
91
+ continue
92
+ for file in iter_python_files(path):
93
+ issues.extend(analyze_file(file))
94
+ return issues
95
+
96
+
97
+ class PatchVisitor(ast.NodeVisitor):
98
+ def __init__(
99
+ self,
100
+ bindings: ImportBindings,
101
+ filepath: Path,
102
+ lines: list[str],
103
+ ) -> None:
104
+ self.bindings = bindings
105
+ self.filepath = filepath
106
+ self.lines = lines
107
+ self.issues: list[Issue] = []
108
+ # Names that refer to a MonkeyPatch fixture / instance
109
+ self._monkey_names: set[str] = {"monkeypatch"}
110
+
111
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
112
+ self._visit_function(node)
113
+
114
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
115
+ self._visit_function(node)
116
+
117
+ def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
118
+ # Pytest injects the fixture by parameter name ``monkeypatch``.
119
+ for arg in _iter_args(node.args):
120
+ if arg.arg == "monkeypatch":
121
+ self._monkey_names.add("monkeypatch")
122
+ self.generic_visit(node)
123
+
124
+ def visit_Assign(self, node: ast.Assign) -> None:
125
+ if self._is_monkeypatch_ctor(node.value):
126
+ for target in node.targets:
127
+ if isinstance(target, ast.Name):
128
+ self._monkey_names.add(target.id)
129
+ self.generic_visit(node)
130
+
131
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
132
+ if node.value is not None and self._is_monkeypatch_ctor(node.value):
133
+ if isinstance(node.target, ast.Name):
134
+ self._monkey_names.add(node.target.id)
135
+ self.generic_visit(node)
136
+
137
+ def visit_With(self, node: ast.With) -> None:
138
+ self._visit_with(node)
139
+
140
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
141
+ self._visit_with(node)
142
+
143
+ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None:
144
+ for item in node.items:
145
+ if self._is_monkeypatch_ctor(item.context_expr) and item.optional_vars is not None:
146
+ if isinstance(item.optional_vars, ast.Name):
147
+ self._monkey_names.add(item.optional_vars.id)
148
+ # ``with monkeypatch.context() as m`` — context() is itself flagged
149
+ if (
150
+ isinstance(item.context_expr, ast.Call)
151
+ and self._is_monkeypatch_method_call(item.context_expr)
152
+ and item.optional_vars is not None
153
+ and isinstance(item.optional_vars, ast.Name)
154
+ ):
155
+ self._monkey_names.add(item.optional_vars.id)
156
+ self.generic_visit(node)
157
+
158
+ def visit_Call(self, node: ast.Call) -> None:
159
+ if self.bindings.is_patch_call_func(node.func):
160
+ self._maybe_add_issue(node)
161
+ elif self._is_monkeypatch_method_call(node):
162
+ self._maybe_add_issue(node)
163
+ self.generic_visit(node)
164
+
165
+ def _is_monkeypatch_ctor(self, node: ast.AST) -> bool:
166
+ return isinstance(node, ast.Call) and self.bindings.is_monkeypatch_class(node.func)
167
+
168
+ def _is_monkeypatch_method_call(self, node: ast.Call) -> bool:
169
+ func = node.func
170
+ if not isinstance(func, ast.Attribute):
171
+ return False
172
+ if func.attr not in MONKEYPATCH_METHODS:
173
+ return False
174
+ return self._is_monkeypatch_receiver(func.value)
175
+
176
+ def _is_monkeypatch_receiver(self, node: ast.AST) -> bool:
177
+ if isinstance(node, ast.Name):
178
+ return node.id in self._monkey_names
179
+ # MonkeyPatch().setattr(...)
180
+ if isinstance(node, ast.Call) and self.bindings.is_monkeypatch_class(node.func):
181
+ return True
182
+ return False
183
+
184
+ def _maybe_add_issue(self, node: ast.AST) -> None:
185
+ line_num = getattr(node, "lineno", 1)
186
+ col = getattr(node, "col_offset", 0) + 1
187
+ code_line = self.lines[line_num - 1] if 0 < line_num <= len(self.lines) else ""
188
+ if line_has_noqa(code_line):
189
+ return
190
+ self.issues.append(
191
+ Issue(
192
+ filepath=self.filepath,
193
+ line_num=line_num,
194
+ col=col,
195
+ message=f"{CODE} {MESSAGE}",
196
+ code_line=code_line.strip(),
197
+ )
198
+ )
199
+
200
+
201
+ def _iter_args(args: ast.arguments):
202
+ yield from args.posonlyargs
203
+ yield from args.args
204
+ yield from args.kwonlyargs
205
+ if args.vararg is not None:
206
+ yield args.vararg
207
+ if args.kwarg is not None:
208
+ yield args.kwarg
patch_linter/cli.py ADDED
@@ -0,0 +1,36 @@
1
+ """Command-line entry point for patch-linter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from patch_linter.checker import analyze_paths
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> None:
13
+ parser = argparse.ArgumentParser(
14
+ description="Forbid unittest.mock.patch and pytest monkeypatch in tests",
15
+ )
16
+ parser.add_argument(
17
+ "paths",
18
+ nargs="+",
19
+ type=Path,
20
+ help="Test files or directories to analyze",
21
+ )
22
+ args = parser.parse_args(argv)
23
+
24
+ issues = analyze_paths(list(args.paths))
25
+ for issue in issues:
26
+ print(issue.format(), file=sys.stderr)
27
+
28
+ if issues:
29
+ print(f"Found {len(issues)} patch usage problem(s).", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+ print("All checks have been successful!")
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -0,0 +1,52 @@
1
+ """Flake8 plugin for patch-linter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections.abc import Generator
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from patch_linter import __version__
11
+ from patch_linter.checker import analyze_source
12
+
13
+
14
+ class PatchLinter:
15
+ """Flake8 extension that reports PATCH001 for patch / monkeypatch usage."""
16
+
17
+ name = "patch-linter"
18
+ version = __version__
19
+
20
+ def __init__(
21
+ self,
22
+ tree: ast.AST,
23
+ filename: str = "stdin",
24
+ lines: list[str] | None = None,
25
+ ) -> None:
26
+ self.tree = tree
27
+ self.filename = filename
28
+ self.lines = lines
29
+
30
+ def run(self) -> Generator[tuple[int, int, str, type[Any]], None, None]:
31
+ if self.filename in {"stdin", "-", ""}:
32
+ return
33
+
34
+ path = Path(self.filename)
35
+ source: str | None = None
36
+ if self.lines is not None:
37
+ source = "\n".join(self.lines)
38
+ if self.lines and not source.endswith("\n"):
39
+ # Preserve last line without forcing an extra blank for AST
40
+ pass
41
+ else:
42
+ try:
43
+ source = path.read_text(encoding="utf-8")
44
+ except (OSError, UnicodeDecodeError):
45
+ return
46
+
47
+ if source is None:
48
+ return
49
+
50
+ for issue in analyze_source(source, path, tree=self.tree):
51
+ # Flake8 column is 0-based; our Issue.col is 1-based for CLI links.
52
+ yield issue.line_num, max(issue.col - 1, 0), issue.message, type(self)
@@ -0,0 +1,107 @@
1
+ """Resolve import aliases for unittest.mock.patch and pytest.MonkeyPatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ ModulePath = tuple[str, ...]
8
+
9
+ PATCH_PATH: ModulePath = ("unittest", "mock", "patch")
10
+ MONKEYPATCH_CLASS_PATH: ModulePath = ("pytest", "MonkeyPatch")
11
+
12
+ PATCH_API_ATTRS = frozenset({"object", "dict", "multiple", "stopall"})
13
+
14
+ MONKEYPATCH_METHODS = frozenset(
15
+ {
16
+ "setattr",
17
+ "delattr",
18
+ "setitem",
19
+ "delitem",
20
+ "setenv",
21
+ "delenv",
22
+ "syspath_prepend",
23
+ "chdir",
24
+ "context",
25
+ "undo",
26
+ }
27
+ )
28
+
29
+
30
+ class ImportBindings:
31
+ """Maps local names to dotted import paths."""
32
+
33
+ def __init__(self) -> None:
34
+ self._names: dict[str, ModulePath] = {}
35
+
36
+ def bind(self, name: str, path: ModulePath) -> None:
37
+ self._names[name] = path
38
+
39
+ def get(self, name: str) -> ModulePath | None:
40
+ return self._names.get(name)
41
+
42
+ def resolve(self, node: ast.AST) -> ModulePath | None:
43
+ """Resolve Name / Attribute chain to a module path, if known."""
44
+ parts = _attr_parts(node)
45
+ if not parts:
46
+ return None
47
+
48
+ head = self._names.get(parts[0])
49
+ if head is None:
50
+ # Bare ``unittest.mock.patch`` without import of ``unittest`` still
51
+ # works if the full chain is written out and unittest is a real
52
+ # top-level name we treat as itself when present as Name.
53
+ if parts[0] in {"unittest", "pytest"}:
54
+ return tuple(parts)
55
+ return None
56
+ return head + tuple(parts[1:])
57
+
58
+ def is_patch_root(self, node: ast.AST) -> bool:
59
+ return self.resolve(node) == PATCH_PATH
60
+
61
+ def is_patch_call_func(self, node: ast.AST) -> bool:
62
+ """True if ``node`` is patch(...) or patch.object/dict/multiple/stopall."""
63
+ if self.is_patch_root(node):
64
+ return True
65
+ if isinstance(node, ast.Attribute) and node.attr in PATCH_API_ATTRS:
66
+ return self.is_patch_root(node.value)
67
+ return False
68
+
69
+ def is_monkeypatch_class(self, node: ast.AST) -> bool:
70
+ return self.resolve(node) == MONKEYPATCH_CLASS_PATH
71
+
72
+
73
+ def collect_imports(tree: ast.AST) -> ImportBindings:
74
+ bindings = ImportBindings()
75
+ for node in ast.walk(tree):
76
+ if isinstance(node, ast.Import):
77
+ for alias in node.names:
78
+ local = alias.asname or alias.name.split(".", 1)[0]
79
+ path = tuple(alias.name.split("."))
80
+ # ``import unittest.mock as um`` → um = (unittest, mock)
81
+ # ``import unittest`` → unittest = (unittest,)
82
+ if alias.asname:
83
+ bindings.bind(alias.asname, path)
84
+ else:
85
+ # Only bind the top-level name for ``import a.b.c``
86
+ bindings.bind(local, (path[0],))
87
+ elif isinstance(node, ast.ImportFrom):
88
+ if node.module is None:
89
+ continue
90
+ base = tuple(node.module.split("."))
91
+ for alias in node.names:
92
+ if alias.name == "*":
93
+ continue
94
+ local = alias.asname or alias.name
95
+ bindings.bind(local, base + (alias.name,))
96
+ return bindings
97
+
98
+
99
+ def _attr_parts(node: ast.AST) -> list[str] | None:
100
+ if isinstance(node, ast.Name):
101
+ return [node.id]
102
+ if isinstance(node, ast.Attribute):
103
+ parent = _attr_parts(node.value)
104
+ if parent is None:
105
+ return None
106
+ return parent + [node.attr]
107
+ return None
patch_linter/models.py ADDED
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Issue:
7
+ """A single patch-usage finding."""
8
+
9
+ filepath: Path
10
+ line_num: int
11
+ col: int
12
+ message: str
13
+ code_line: str
14
+
15
+ def format(self) -> str:
16
+ """GCC/flake8-style line that IDEs treat as a clickable link."""
17
+ path = self.filepath.resolve()
18
+ return (
19
+ f"{path}:{self.line_num}:{self.col}: {self.message}: {self.code_line}"
20
+ )
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.5
2
+ Name: patch-linter
3
+ Version: 1.0.0
4
+ Summary: Static analyzer that forbids unittest.mock.patch and pytest monkeypatch in tests
5
+ Project-URL: Homepage, https://github.com/pavelmaksimov/patch-linter
6
+ Project-URL: Bug Tracker, https://github.com/pavelmaksimov/patch-linter/issues
7
+ Author-email: Pavel Maksimov <vur21@yandex.ru>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: flake8
12
+ Requires-Dist: flake8>=7.2.0; extra == 'flake8'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Patch Linter
16
+
17
+ Static analyzer that forbids **`unittest.mock.patch`** and **pytest `monkeypatch`**
18
+ mutations in tests. Prefer real dependency injection over patching.
19
+
20
+ ## Why
21
+
22
+ Patching reaches into other modules and couples tests to implementation details.
23
+ Refactoring becomes harder, and failures become brittle. Inject dependencies
24
+ (fakes, stubs, or test doubles passed as arguments) instead.
25
+
26
+ ## What is reported
27
+
28
+ | Pattern | Example |
29
+ |---|---|
30
+ | `unittest.mock.patch` | `@patch(...)`, `with patch(...)` |
31
+ | Import aliases | `from unittest.mock import patch as p` → `p(...)` |
32
+ | Via module | `mock.patch(...)`, `unittest.mock.patch(...)` |
33
+ | Patch helpers | `patch.object` / `patch.dict` / `patch.multiple` / `patch.stopall` |
34
+ | pytest monkeypatch | `monkeypatch.setattr(...)`, `setenv`, `delattr`, … |
35
+
36
+ Not reported (by design): `mocker` (pytest-mock), bare `Mock` / `MagicMock`,
37
+ freezegun, your own functions named `patch`, HTTP `client.patch(...)`.
38
+
39
+ Code: **`PATCH001`**.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install patch-linter
45
+ # optional flake8 plugin:
46
+ pip install "patch-linter[flake8]"
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### CLI
52
+
53
+ ```bash
54
+ patch-linter tests/
55
+ patch-linter tests/unit tests/test_foo.py
56
+ ```
57
+
58
+ Output (clickable in Cursor / VS Code / PyCharm):
59
+
60
+ ```text
61
+ /abs/path/to/tests/test_foo.py:12:5: PATCH001 Patch usage in tests: @patch('module.function')
62
+ ```
63
+
64
+ Exit code `1` if problems are found.
65
+
66
+ ### Flake8
67
+
68
+ ```bash
69
+ flake8 --select=PATCH tests/
70
+ ```
71
+
72
+ ### Suppress a line
73
+
74
+ ```python
75
+ @patch("module.function") # noqa: PATCH001
76
+ def test_legacy(mock_fn):
77
+ ...
78
+ ```
79
+
80
+ Bare `# noqa` also suppresses the finding on that line.
81
+
82
+ ## Correct approaches
83
+
84
+ ```python
85
+ def process_data(repository):
86
+ return repository.get_all()
87
+
88
+
89
+ def test_process_data():
90
+ class FakeRepo:
91
+ def get_all(self):
92
+ return [1, 2, 3]
93
+
94
+ assert process_data(FakeRepo()) == [1, 2, 3]
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,11 @@
1
+ patch_linter/__init__.py,sha256=H-z98M0cMUhBOC1VKplvnWPopE8Hu1v9F99njnGc6mY,145
2
+ patch_linter/checker.py,sha256=0yObUkeCt8xSdV4MP6brjnkqCmr9m4j5nwwT_P7Wf0U,6957
3
+ patch_linter/cli.py,sha256=ZuNgre2rHgAqk3lbErxcVo_r4g6_QCoJgtBUGQzt5ss,857
4
+ patch_linter/flake8_plugin.py,sha256=BoZNvHrDFnVXMd0WSkS79Ht5MgIaEwEH0J8tnYJ_yZk,1528
5
+ patch_linter/imports.py,sha256=w8rFK9t_YA7GNljnZ996FI5oqsXmALQo4REmeZUwSmk,3530
6
+ patch_linter/models.py,sha256=rKlU343jlgJpB1y4XEoGXlW1Bc08CsyisF9UWWLk3mo,477
7
+ patch_linter-1.0.0.dist-info/METADATA,sha256=1O8KFRBHf9UUEQ2BDkxVCkreEbHr8j77OxZMvFP8fIs,2408
8
+ patch_linter-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ patch_linter-1.0.0.dist-info/entry_points.txt,sha256=CdqqTsw__yIkWAmplFrMSE-6SWk42Bxh2NtCjyRKfIQ,122
10
+ patch_linter-1.0.0.dist-info/licenses/LICENSE,sha256=tcS4tHHeWpiuqS_GdzKZMsWS-385Zjvsoz7OACq-UGQ,1071
11
+ patch_linter-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ patch-linter = patch_linter.cli:main
3
+
4
+ [flake8.extension]
5
+ PATCH = patch_linter.flake8_plugin:PatchLinter
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pavel Maksimov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.