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.
@@ -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,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
@@ -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
+ ]