patch-linter 1.0.0__tar.gz
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.
- patch_linter-1.0.0/.gitignore +53 -0
- patch_linter-1.0.0/CHANGELOG.md +12 -0
- patch_linter-1.0.0/LICENSE +21 -0
- patch_linter-1.0.0/PKG-INFO +99 -0
- patch_linter-1.0.0/README.md +85 -0
- patch_linter-1.0.0/pyproject.toml +44 -0
- patch_linter-1.0.0/src/patch_linter/__init__.py +6 -0
- patch_linter-1.0.0/src/patch_linter/checker.py +208 -0
- patch_linter-1.0.0/src/patch_linter/cli.py +36 -0
- patch_linter-1.0.0/src/patch_linter/flake8_plugin.py +52 -0
- patch_linter-1.0.0/src/patch_linter/imports.py +107 -0
- patch_linter-1.0.0/src/patch_linter/models.py +20 -0
- patch_linter-1.0.0/tests/fixtures/bad_patches.py +17 -0
- patch_linter-1.0.0/tests/fixtures/clean_tests.py +35 -0
- patch_linter-1.0.0/tests/test_checker.py +243 -0
- patch_linter-1.0.0/tests/test_integration.py +98 -0
- patch_linter-1.0.0/uv.lock +127 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
share/python-wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
MANIFEST
|
|
23
|
+
*.manifest
|
|
24
|
+
*.spec
|
|
25
|
+
pip-log.txt
|
|
26
|
+
pip-delete-this-directory.txt
|
|
27
|
+
htmlcov/
|
|
28
|
+
.tox/
|
|
29
|
+
.nox/
|
|
30
|
+
.coverage
|
|
31
|
+
.coverage.*
|
|
32
|
+
.cache
|
|
33
|
+
nosetests.xml
|
|
34
|
+
coverage.xml
|
|
35
|
+
*.cover
|
|
36
|
+
*.py,cover
|
|
37
|
+
.hypothesis/
|
|
38
|
+
.pytest_cache/
|
|
39
|
+
cover/
|
|
40
|
+
*.mo
|
|
41
|
+
*.pot
|
|
42
|
+
*.log
|
|
43
|
+
.venv
|
|
44
|
+
env/
|
|
45
|
+
venv/
|
|
46
|
+
ENV/
|
|
47
|
+
.env
|
|
48
|
+
.mypy_cache/
|
|
49
|
+
.dmypy.json
|
|
50
|
+
dmypy.json
|
|
51
|
+
.ruff_cache/
|
|
52
|
+
.idea/
|
|
53
|
+
.vscode/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.0] - 2026-08-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Detect `unittest.mock.patch` usage in tests (decorators, context managers, `patch.object` /
|
|
8
|
+
`patch.dict` / `patch.multiple` / `patch.stopall`), including import aliases.
|
|
9
|
+
- Detect pytest `monkeypatch` mutation methods (`setattr`, `delattr`, `setitem`, …).
|
|
10
|
+
- CLI `patch-linter` with clickable `path:line:col: PATCH001 …` diagnostics.
|
|
11
|
+
- Flake8 plugin (`PATCH` / `PATCH001`).
|
|
12
|
+
- Suppress with `# noqa` or `# noqa: PATCH001`.
|
|
@@ -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.
|
|
@@ -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,85 @@
|
|
|
1
|
+
# Patch Linter
|
|
2
|
+
|
|
3
|
+
Static analyzer that forbids **`unittest.mock.patch`** and **pytest `monkeypatch`**
|
|
4
|
+
mutations in tests. Prefer real dependency injection over patching.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
Patching reaches into other modules and couples tests to implementation details.
|
|
9
|
+
Refactoring becomes harder, and failures become brittle. Inject dependencies
|
|
10
|
+
(fakes, stubs, or test doubles passed as arguments) instead.
|
|
11
|
+
|
|
12
|
+
## What is reported
|
|
13
|
+
|
|
14
|
+
| Pattern | Example |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `unittest.mock.patch` | `@patch(...)`, `with patch(...)` |
|
|
17
|
+
| Import aliases | `from unittest.mock import patch as p` → `p(...)` |
|
|
18
|
+
| Via module | `mock.patch(...)`, `unittest.mock.patch(...)` |
|
|
19
|
+
| Patch helpers | `patch.object` / `patch.dict` / `patch.multiple` / `patch.stopall` |
|
|
20
|
+
| pytest monkeypatch | `monkeypatch.setattr(...)`, `setenv`, `delattr`, … |
|
|
21
|
+
|
|
22
|
+
Not reported (by design): `mocker` (pytest-mock), bare `Mock` / `MagicMock`,
|
|
23
|
+
freezegun, your own functions named `patch`, HTTP `client.patch(...)`.
|
|
24
|
+
|
|
25
|
+
Code: **`PATCH001`**.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install patch-linter
|
|
31
|
+
# optional flake8 plugin:
|
|
32
|
+
pip install "patch-linter[flake8]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### CLI
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
patch-linter tests/
|
|
41
|
+
patch-linter tests/unit tests/test_foo.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Output (clickable in Cursor / VS Code / PyCharm):
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/abs/path/to/tests/test_foo.py:12:5: PATCH001 Patch usage in tests: @patch('module.function')
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Exit code `1` if problems are found.
|
|
51
|
+
|
|
52
|
+
### Flake8
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
flake8 --select=PATCH tests/
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Suppress a line
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
@patch("module.function") # noqa: PATCH001
|
|
62
|
+
def test_legacy(mock_fn):
|
|
63
|
+
...
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Bare `# noqa` also suppresses the finding on that line.
|
|
67
|
+
|
|
68
|
+
## Correct approaches
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
def process_data(repository):
|
|
72
|
+
return repository.get_all()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_process_data():
|
|
76
|
+
class FakeRepo:
|
|
77
|
+
def get_all(self):
|
|
78
|
+
return [1, 2, 3]
|
|
79
|
+
|
|
80
|
+
assert process_data(FakeRepo()) == [1, 2, 3]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "patch-linter"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Static analyzer that forbids unittest.mock.patch and pytest monkeypatch in tests"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Pavel Maksimov", email = "vur21@yandex.ru" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
flake8 = [
|
|
15
|
+
"flake8>=7.2.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.3.5",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
"Homepage" = "https://github.com/pavelmaksimov/patch-linter"
|
|
25
|
+
"Bug Tracker" = "https://github.com/pavelmaksimov/patch-linter/issues"
|
|
26
|
+
|
|
27
|
+
[project.entry-points."flake8.extension"]
|
|
28
|
+
PATCH = "patch_linter.flake8_plugin:PatchLinter"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
patch-linter = "patch_linter.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
indent-width = 4
|
|
40
|
+
target-version = "py311"
|
|
41
|
+
include = ["src/**/*.py", "tests/**/*.py"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.format]
|
|
44
|
+
line-ending = "lf"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,17 @@
|
|
|
1
|
+
"""Bad examples: patch-linter must report PATCH001 for each marked line."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@patch("module.function")
|
|
7
|
+
def test_decorator(mock_function):
|
|
8
|
+
mock_function.return_value = "mocked"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_context_manager():
|
|
12
|
+
with patch("module.function") as mock_function:
|
|
13
|
+
mock_function.return_value = "mocked"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_monkeypatch(monkeypatch):
|
|
17
|
+
monkeypatch.setattr("module.function", lambda: "mocked")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Clean examples: patch-linter must report nothing."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def process_data(repository):
|
|
5
|
+
return repository.get_all()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_with_injected_fake():
|
|
9
|
+
class FakeRepo:
|
|
10
|
+
def get_all(self):
|
|
11
|
+
return [1, 2, 3]
|
|
12
|
+
|
|
13
|
+
assert process_data(FakeRepo()) == [1, 2, 3]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def patch(value):
|
|
17
|
+
"""Local helper named patch — must not be flagged."""
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_local_patch_helper():
|
|
22
|
+
assert patch("ok") == "ok"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Client:
|
|
26
|
+
def patch(self, url: str) -> str:
|
|
27
|
+
return url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_client_patch_method():
|
|
31
|
+
assert Client().patch("/api") == "/api"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_monkeypatch_unused(monkeypatch):
|
|
35
|
+
assert monkeypatch is not None
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from patch_linter.checker import analyze_file, analyze_source, line_has_noqa
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _issues(tmp_path: Path, source: str):
|
|
7
|
+
path = tmp_path / "test_sample.py"
|
|
8
|
+
path.write_text(source, encoding="utf-8")
|
|
9
|
+
return analyze_file(path)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_detects_unittest_patch_decorator(tmp_path: Path):
|
|
13
|
+
source = '''
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
@patch("module.function")
|
|
17
|
+
def test_function(mock_function):
|
|
18
|
+
mock_function.return_value = "mocked"
|
|
19
|
+
'''
|
|
20
|
+
issues = _issues(tmp_path, source)
|
|
21
|
+
assert len(issues) == 1
|
|
22
|
+
assert "PATCH001" in issues[0].message
|
|
23
|
+
assert issues[0].col >= 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_detects_patch_alias(tmp_path: Path):
|
|
27
|
+
source = '''
|
|
28
|
+
from unittest.mock import patch as p
|
|
29
|
+
|
|
30
|
+
@p("module.function")
|
|
31
|
+
def test_function(mock_function):
|
|
32
|
+
pass
|
|
33
|
+
'''
|
|
34
|
+
issues = _issues(tmp_path, source)
|
|
35
|
+
assert len(issues) == 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_detects_mock_patch_via_module(tmp_path: Path):
|
|
39
|
+
source = '''
|
|
40
|
+
from unittest import mock
|
|
41
|
+
|
|
42
|
+
def test_function():
|
|
43
|
+
with mock.patch("module.function") as mocked:
|
|
44
|
+
mocked.return_value = 1
|
|
45
|
+
'''
|
|
46
|
+
issues = _issues(tmp_path, source)
|
|
47
|
+
assert len(issues) == 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_detects_unittest_mock_patch_full(tmp_path: Path):
|
|
51
|
+
source = '''
|
|
52
|
+
import unittest
|
|
53
|
+
|
|
54
|
+
def test_function():
|
|
55
|
+
with unittest.mock.patch("module.function"):
|
|
56
|
+
pass
|
|
57
|
+
'''
|
|
58
|
+
issues = _issues(tmp_path, source)
|
|
59
|
+
assert len(issues) == 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_detects_patch_object_dict_multiple_stopall(tmp_path: Path):
|
|
63
|
+
source = '''
|
|
64
|
+
from unittest.mock import patch
|
|
65
|
+
|
|
66
|
+
def test_function():
|
|
67
|
+
with patch.object(object, "x", 1):
|
|
68
|
+
pass
|
|
69
|
+
with patch.dict("os.environ", {"A": "1"}):
|
|
70
|
+
pass
|
|
71
|
+
with patch.multiple("module", a=1):
|
|
72
|
+
pass
|
|
73
|
+
patch.stopall()
|
|
74
|
+
'''
|
|
75
|
+
issues = _issues(tmp_path, source)
|
|
76
|
+
assert len(issues) == 4
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_detects_import_unittest_mock_as(tmp_path: Path):
|
|
80
|
+
source = '''
|
|
81
|
+
import unittest.mock as um
|
|
82
|
+
|
|
83
|
+
def test_function():
|
|
84
|
+
with um.patch("module.function"):
|
|
85
|
+
pass
|
|
86
|
+
'''
|
|
87
|
+
issues = _issues(tmp_path, source)
|
|
88
|
+
assert len(issues) == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_detects_monkeypatch_setattr(tmp_path: Path):
|
|
92
|
+
source = '''
|
|
93
|
+
def test_function(monkeypatch):
|
|
94
|
+
monkeypatch.setattr("module.function", lambda: "mocked")
|
|
95
|
+
'''
|
|
96
|
+
issues = _issues(tmp_path, source)
|
|
97
|
+
assert len(issues) == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_detects_monkeypatch_other_methods(tmp_path: Path):
|
|
101
|
+
source = '''
|
|
102
|
+
def test_function(monkeypatch):
|
|
103
|
+
monkeypatch.setenv("A", "1")
|
|
104
|
+
monkeypatch.delenv("A", raising=False)
|
|
105
|
+
monkeypatch.chdir("/tmp")
|
|
106
|
+
'''
|
|
107
|
+
issues = _issues(tmp_path, source)
|
|
108
|
+
assert len(issues) == 3
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_detects_monkeypatch_class_instance(tmp_path: Path):
|
|
112
|
+
source = '''
|
|
113
|
+
from pytest import MonkeyPatch
|
|
114
|
+
|
|
115
|
+
def test_function():
|
|
116
|
+
mp = MonkeyPatch()
|
|
117
|
+
mp.setattr("module.function", lambda: 1)
|
|
118
|
+
'''
|
|
119
|
+
issues = _issues(tmp_path, source)
|
|
120
|
+
assert len(issues) == 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_noqa_patch001_suppresses(tmp_path: Path):
|
|
124
|
+
source = '''
|
|
125
|
+
from unittest.mock import patch
|
|
126
|
+
|
|
127
|
+
@patch("module.function") # noqa: PATCH001
|
|
128
|
+
def test_function(mock_function):
|
|
129
|
+
pass
|
|
130
|
+
'''
|
|
131
|
+
issues = _issues(tmp_path, source)
|
|
132
|
+
assert issues == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_bare_noqa_suppresses(tmp_path: Path):
|
|
136
|
+
source = '''
|
|
137
|
+
from unittest.mock import patch
|
|
138
|
+
|
|
139
|
+
@patch("module.function") # noqa
|
|
140
|
+
def test_function(mock_function):
|
|
141
|
+
pass
|
|
142
|
+
'''
|
|
143
|
+
issues = _issues(tmp_path, source)
|
|
144
|
+
assert issues == []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_noqa_other_code_does_not_suppress(tmp_path: Path):
|
|
148
|
+
source = '''
|
|
149
|
+
from unittest.mock import patch
|
|
150
|
+
|
|
151
|
+
@patch("module.function") # noqa: E501
|
|
152
|
+
def test_function(mock_function):
|
|
153
|
+
pass
|
|
154
|
+
'''
|
|
155
|
+
issues = _issues(tmp_path, source)
|
|
156
|
+
assert len(issues) == 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_ignores_custom_patch_function(tmp_path: Path):
|
|
160
|
+
source = '''
|
|
161
|
+
def patch(x):
|
|
162
|
+
return x
|
|
163
|
+
|
|
164
|
+
def test_function():
|
|
165
|
+
patch("hello")
|
|
166
|
+
'''
|
|
167
|
+
issues = _issues(tmp_path, source)
|
|
168
|
+
assert issues == []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_ignores_client_patch(tmp_path: Path):
|
|
172
|
+
source = '''
|
|
173
|
+
class Client:
|
|
174
|
+
def patch(self, url):
|
|
175
|
+
return url
|
|
176
|
+
|
|
177
|
+
def test_function():
|
|
178
|
+
Client().patch("/api")
|
|
179
|
+
'''
|
|
180
|
+
issues = _issues(tmp_path, source)
|
|
181
|
+
assert issues == []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_ignores_mocker_patch(tmp_path: Path):
|
|
185
|
+
source = '''
|
|
186
|
+
def test_function(mocker):
|
|
187
|
+
mocker.patch("module.function")
|
|
188
|
+
'''
|
|
189
|
+
issues = _issues(tmp_path, source)
|
|
190
|
+
assert issues == []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_ignores_monkeypatch_param_without_call(tmp_path: Path):
|
|
194
|
+
source = '''
|
|
195
|
+
def test_function(monkeypatch):
|
|
196
|
+
assert monkeypatch is not None
|
|
197
|
+
'''
|
|
198
|
+
issues = _issues(tmp_path, source)
|
|
199
|
+
assert issues == []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_syntax_error_returns_empty(tmp_path: Path, capsys):
|
|
203
|
+
path = tmp_path / "bad.py"
|
|
204
|
+
path.write_text("def (\n", encoding="utf-8")
|
|
205
|
+
assert analyze_file(path) == []
|
|
206
|
+
err = capsys.readouterr().err
|
|
207
|
+
assert "syntax error" in err.lower() or "Warning" in err
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_issue_format_is_clickable(tmp_path: Path):
|
|
211
|
+
source = '''
|
|
212
|
+
from unittest.mock import patch
|
|
213
|
+
|
|
214
|
+
@patch("x")
|
|
215
|
+
def test_function(m):
|
|
216
|
+
pass
|
|
217
|
+
'''
|
|
218
|
+
issues = _issues(tmp_path, source)
|
|
219
|
+
formatted = issues[0].format()
|
|
220
|
+
assert formatted.startswith(str(issues[0].filepath.resolve()))
|
|
221
|
+
assert ":PATCH001 " in formatted.replace(" ", "") or "PATCH001" in formatted
|
|
222
|
+
# path:line:col:
|
|
223
|
+
parts = formatted.split(":", 3)
|
|
224
|
+
assert parts[1].isdigit()
|
|
225
|
+
assert parts[2].isdigit()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_line_has_noqa_helpers():
|
|
229
|
+
assert line_has_noqa("x # noqa")
|
|
230
|
+
assert line_has_noqa("x # noqa: PATCH001")
|
|
231
|
+
assert line_has_noqa("x # noqa: E501, PATCH001")
|
|
232
|
+
assert not line_has_noqa("x # noqa: E501")
|
|
233
|
+
assert not line_has_noqa("x # no comment")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_analyze_source_with_tree(tmp_path: Path):
|
|
237
|
+
import ast
|
|
238
|
+
|
|
239
|
+
source = "from unittest.mock import patch\n\n@patch('a')\ndef t(m):\n pass\n"
|
|
240
|
+
tree = ast.parse(source)
|
|
241
|
+
path = tmp_path / "t.py"
|
|
242
|
+
issues = analyze_source(source, path, tree=tree)
|
|
243
|
+
assert len(issues) == 1
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""End-to-end checks: fixtures + CLI behave as documented."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from patch_linter.checker import analyze_file, analyze_paths
|
|
13
|
+
from patch_linter.cli import main
|
|
14
|
+
from patch_linter.flake8_plugin import PatchLinter
|
|
15
|
+
|
|
16
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
17
|
+
BAD = FIXTURES / "bad_patches.py"
|
|
18
|
+
CLEAN = FIXTURES / "clean_tests.py"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_fixture_bad_patches_reports_expected_count():
|
|
22
|
+
issues = analyze_file(BAD)
|
|
23
|
+
assert len(issues) == 3
|
|
24
|
+
assert all("PATCH001" in i.message for i in issues)
|
|
25
|
+
lines = {i.line_num for i in issues}
|
|
26
|
+
# decorator, context manager, monkeypatch.setattr
|
|
27
|
+
assert lines == {6, 12, 17}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_fixture_clean_tests_reports_nothing():
|
|
31
|
+
assert analyze_file(CLEAN) == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_analyze_paths_directory_finds_only_bad():
|
|
35
|
+
issues = analyze_paths([FIXTURES])
|
|
36
|
+
bad_only = [i for i in issues if i.filepath.resolve() == BAD.resolve()]
|
|
37
|
+
clean_hits = [i for i in issues if i.filepath.resolve() == CLEAN.resolve()]
|
|
38
|
+
assert len(bad_only) == 3
|
|
39
|
+
assert clean_hits == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cli_fails_on_bad_fixture(capsys):
|
|
43
|
+
with pytest.raises(SystemExit) as exc:
|
|
44
|
+
main([str(BAD)])
|
|
45
|
+
assert exc.value.code == 1
|
|
46
|
+
err = capsys.readouterr().err
|
|
47
|
+
assert "PATCH001" in err
|
|
48
|
+
assert str(BAD.resolve()) in err
|
|
49
|
+
# clickable path:line:col:
|
|
50
|
+
assert f"{BAD.resolve()}:" in err
|
|
51
|
+
assert err.count("PATCH001") == 3
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_cli_succeeds_on_clean_fixture(capsys):
|
|
55
|
+
main([str(CLEAN)])
|
|
56
|
+
out = capsys.readouterr().out
|
|
57
|
+
assert "All checks have been successful!" in out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_cli_subprocess_matches_library():
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
[sys.executable, "-m", "patch_linter.cli", str(BAD)],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
check=False,
|
|
66
|
+
)
|
|
67
|
+
assert result.returncode == 1
|
|
68
|
+
assert "PATCH001" in result.stderr
|
|
69
|
+
assert str(BAD.resolve()) in result.stderr
|
|
70
|
+
|
|
71
|
+
ok = subprocess.run(
|
|
72
|
+
[sys.executable, "-m", "patch_linter.cli", str(CLEAN)],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
check=False,
|
|
76
|
+
)
|
|
77
|
+
assert ok.returncode == 0
|
|
78
|
+
assert "successful" in ok.stdout.lower()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_flake8_plugin_on_bad_fixture():
|
|
82
|
+
source = BAD.read_text(encoding="utf-8")
|
|
83
|
+
tree = ast.parse(source)
|
|
84
|
+
plugin = PatchLinter(tree=tree, filename=str(BAD), lines=source.splitlines())
|
|
85
|
+
findings = list(plugin.run())
|
|
86
|
+
assert len(findings) == 3
|
|
87
|
+
for line, col, message, checker in findings:
|
|
88
|
+
assert line in {6, 12, 17}
|
|
89
|
+
assert col >= 0
|
|
90
|
+
assert message.startswith("PATCH001")
|
|
91
|
+
assert checker is PatchLinter
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_flake8_plugin_on_clean_fixture():
|
|
95
|
+
source = CLEAN.read_text(encoding="utf-8")
|
|
96
|
+
tree = ast.parse(source)
|
|
97
|
+
plugin = PatchLinter(tree=tree, filename=str(CLEAN), lines=source.splitlines())
|
|
98
|
+
assert list(plugin.run()) == []
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 1
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "flake8"
|
|
16
|
+
version = "7.3.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
dependencies = [
|
|
19
|
+
{ name = "mccabe" },
|
|
20
|
+
{ name = "pycodestyle" },
|
|
21
|
+
{ name = "pyflakes" },
|
|
22
|
+
]
|
|
23
|
+
sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326 }
|
|
24
|
+
wheels = [
|
|
25
|
+
{ url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922 },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[package]]
|
|
29
|
+
name = "iniconfig"
|
|
30
|
+
version = "2.3.0"
|
|
31
|
+
source = { registry = "https://pypi.org/simple" }
|
|
32
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
|
|
33
|
+
wheels = [
|
|
34
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[[package]]
|
|
38
|
+
name = "mccabe"
|
|
39
|
+
version = "0.7.0"
|
|
40
|
+
source = { registry = "https://pypi.org/simple" }
|
|
41
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 }
|
|
42
|
+
wheels = [
|
|
43
|
+
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[[package]]
|
|
47
|
+
name = "packaging"
|
|
48
|
+
version = "26.3"
|
|
49
|
+
source = { registry = "https://pypi.org/simple" }
|
|
50
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 }
|
|
51
|
+
wheels = [
|
|
52
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[[package]]
|
|
56
|
+
name = "patch-linter"
|
|
57
|
+
version = "1.0.0"
|
|
58
|
+
source = { editable = "." }
|
|
59
|
+
|
|
60
|
+
[package.optional-dependencies]
|
|
61
|
+
flake8 = [
|
|
62
|
+
{ name = "flake8" },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[package.dev-dependencies]
|
|
66
|
+
dev = [
|
|
67
|
+
{ name = "pytest" },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[package.metadata]
|
|
71
|
+
requires-dist = [{ name = "flake8", marker = "extra == 'flake8'", specifier = ">=7.2.0" }]
|
|
72
|
+
provides-extras = ["flake8"]
|
|
73
|
+
|
|
74
|
+
[package.metadata.requires-dev]
|
|
75
|
+
dev = [{ name = "pytest", specifier = ">=8.3.5" }]
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "pluggy"
|
|
79
|
+
version = "1.6.0"
|
|
80
|
+
source = { registry = "https://pypi.org/simple" }
|
|
81
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
|
|
82
|
+
wheels = [
|
|
83
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[[package]]
|
|
87
|
+
name = "pycodestyle"
|
|
88
|
+
version = "2.14.0"
|
|
89
|
+
source = { registry = "https://pypi.org/simple" }
|
|
90
|
+
sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472 }
|
|
91
|
+
wheels = [
|
|
92
|
+
{ url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594 },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[[package]]
|
|
96
|
+
name = "pyflakes"
|
|
97
|
+
version = "3.4.0"
|
|
98
|
+
source = { registry = "https://pypi.org/simple" }
|
|
99
|
+
sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669 }
|
|
100
|
+
wheels = [
|
|
101
|
+
{ url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551 },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
[[package]]
|
|
105
|
+
name = "pygments"
|
|
106
|
+
version = "2.20.0"
|
|
107
|
+
source = { registry = "https://pypi.org/simple" }
|
|
108
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
|
|
109
|
+
wheels = [
|
|
110
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
[[package]]
|
|
114
|
+
name = "pytest"
|
|
115
|
+
version = "9.1.1"
|
|
116
|
+
source = { registry = "https://pypi.org/simple" }
|
|
117
|
+
dependencies = [
|
|
118
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
119
|
+
{ name = "iniconfig" },
|
|
120
|
+
{ name = "packaging" },
|
|
121
|
+
{ name = "pluggy" },
|
|
122
|
+
{ name = "pygments" },
|
|
123
|
+
]
|
|
124
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
|
|
125
|
+
wheels = [
|
|
126
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
|
|
127
|
+
]
|