Repository navigation
Expand file tree
/
Copy pathdead_code_detection.py
More file actions
executable file
·277 lines (233 loc) · 9.54 KB
/
Copy pathdead_code_detection.py
File metadata and controls
executable file
·277 lines (233 loc) · 9.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
#!/usr/bin/python3
"""Hook to detect dead/unused code using vulture.
On top of vulture's static analysis this hook adds two capabilities:
* **Dynamic-import awareness** (default-on): names reached only through
``importlib.import_module``, ``__import__``, ``getattr``/``setattr``/``hasattr``,
``globals()``/``vars()``/``locals()`` subscripts or ``entry_points`` are collected
from the AST and filtered out of vulture's report, killing false positives.
Disable with ``--no-dynamic-imports``.
* **Test-only detection** (``--detect-test-only``): symbols defined in production
code but referenced *only* from test files are flagged separately. Fails the hook
by default; ``--warn-only`` downgrades these findings to a report.
"""
from __future__ import annotations
import ast
import fnmatch
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any
_ATTR_NAME_ARG_COUNT = 2 # getattr/setattr/hasattr(obj, name, ...)
DEFAULT_TEST_PATTERNS = ('tests/', 'test_*.py', '*_test.py', 'conftest.py')
def _iter_python_files(paths: Iterable[str]) -> list[str]:
"""Expand the given paths into a de-duplicated, ordered list of .py files."""
files: list[str] = []
for raw in paths:
path = Path(raw)
if path.is_dir():
files.extend(str(f) for f in sorted(path.rglob('*.py')))
elif path.suffix == '.py':
files.append(str(path))
return list(dict.fromkeys(files))
def _const_str(node: ast.expr) -> str | None:
"""Return the string value of a literal node, or None if it is not a str literal."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _add_name(names: set[str], value: str | None) -> None:
"""Add a dynamic reference and its last dotted segment to the name set."""
if value:
names.add(value)
names.add(value.rsplit('.', 1)[-1])
def _handle_call(node: ast.Call, names: set[str]) -> None:
"""Collect dynamically referenced names from a function/method call."""
func = node.func
if isinstance(func, ast.Name):
fname = func.id
elif isinstance(func, ast.Attribute):
fname = func.attr
else:
return
if fname in {'import_module', '__import__'}:
if node.args:
_add_name(names, _const_str(node.args[0]))
elif fname in {'getattr', 'setattr', 'hasattr'}:
if len(node.args) >= _ATTR_NAME_ARG_COUNT:
_add_name(names, _const_str(node.args[1]))
elif fname == 'entry_points':
for arg in node.args:
_add_name(names, _const_str(arg))
for kw in node.keywords:
_add_name(names, _const_str(kw.value))
def _handle_subscript(node: ast.Subscript, names: set[str]) -> None:
"""Collect names from globals()/vars()/locals() string subscripts."""
value = node.value
if not isinstance(value, ast.Call):
return
func = value.func
if isinstance(func, ast.Name) and func.id in {'globals', 'vars', 'locals'}:
_add_name(names, _const_str(node.slice))
def _extract_dynamic_names(tree: ast.AST) -> set[str]:
"""Walk an AST and return every dynamically referenced name."""
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
_handle_call(node, names)
elif isinstance(node, ast.Subscript):
_handle_subscript(node, names)
return names
def collect_dynamic_names(paths: Iterable[str]) -> set[str]:
"""Scan the given paths for names referenced via dynamic imports/attribute access."""
names: set[str] = set()
for file in _iter_python_files(paths):
try:
source = Path(file).read_text(encoding='utf-8')
tree = ast.parse(source)
except (OSError, SyntaxError, ValueError):
continue
names |= _extract_dynamic_names(tree)
return names
def is_test_file(path: str, patterns: Sequence[str] = DEFAULT_TEST_PATTERNS) -> bool:
"""Return True if path matches any test pattern (directory fragment or basename glob)."""
parsed = Path(path)
parts = parsed.as_posix().split('/')
for pattern in patterns:
if pattern.endswith('/'):
if pattern.rstrip('/') in parts:
return True
elif fnmatch.fnmatch(parsed.name, pattern):
return True
return False
def _item_key(item: Any) -> tuple[str, str, int]:
"""Stable identity for a vulture unused-code item."""
return (str(item.filename), str(item.name), int(item.first_lineno))
def _run_vulture(
vulture_mod: Any,
paths: list[str],
exclude: list[str],
min_confidence: int,
) -> list[Any]:
"""Run a fresh vulture scan over paths and return the unused-code items."""
scanner = vulture_mod.Vulture()
scanner.scavenge(paths, exclude=exclude or [])
return list(scanner.get_unused_code(min_confidence=min_confidence))
def _filter_dynamic(items: list[Any], dynamic_names: set[str]) -> list[Any]:
"""Drop items whose name is referenced dynamically elsewhere."""
if not dynamic_names:
return items
return [item for item in items if item.name not in dynamic_names]
def _build_parser(tools_instance: Any) -> None:
"""Configure the argument parser for this hook."""
tools_instance.set_params(
help_msg='detect dead/unused code using vulture',
arguments=[
(
'--min-confidence',
{
'type': int,
'default': 80,
'help': 'Minimum confidence percentage for unused code reports (default: 80)',
},
),
(
'--exclude',
{
'nargs': '*',
'default': [],
'metavar': 'PATTERN',
'help': 'Glob patterns of paths to exclude (e.g. tests/ migrations/)',
},
),
(
'--whitelist',
{
'nargs': '*',
'default': [],
'metavar': 'FILE',
'help': 'Vulture whitelist Python files listing used names to suppress false positives',
},
),
(
'--no-dynamic-imports',
{
'action': 'store_true',
'help': 'Disable dynamic-import awareness (report names reached via importlib/getattr/...)',
},
),
(
'--detect-test-only',
{
'action': 'store_true',
'help': 'Also flag production symbols referenced only from test files',
},
),
(
'--test-pattern',
{
'nargs': '*',
'default': list(DEFAULT_TEST_PATTERNS),
'metavar': 'PATTERN',
'help': 'File patterns marking test files (default: tests/ test_*.py *_test.py conftest.py)',
},
),
(
'--warn-only',
{
'action': 'store_true',
'help': 'Report test-only findings without failing the hook',
},
),
],
)
def _report_dead(items: list[Any]) -> None:
"""Print regular dead-code findings."""
for item in items:
print(
f'[{item.filename}:{item.first_lineno}] unused {item.typ}: {item.name}',
) # print-detection: disable
def _report_test_only(items: list[Any]) -> None:
"""Print test-only findings."""
for item in items:
print(
f'[{item.filename}:{item.first_lineno}] test-only {item.typ}: {item.name} (used only in tests)',
) # print-detection: disable
def main(argv: Sequence[str] | None = None) -> int:
"""Run vulture to detect unused code and return 1 if any is found."""
try:
import vulture as _vulture
except ImportError:
print(
'vulture is required: pip install vulture (or add it to additional_dependencies)',
) # print-detection: disable
return 1
from pre_commit_hooks.tools.pre_commit_tools import PreCommitTools
tools_instance = PreCommitTools()
_build_parser(tools_instance)
args, _ = tools_instance.get_args(argv=argv)
whitelist = list(args.whitelist)
filenames = [str(p) for p in args.filenames]
dynamic_names: set[str] = set() if args.no_dynamic_imports else collect_dynamic_names(whitelist + filenames)
if args.detect_test_only:
py_files = _iter_python_files(filenames)
prod_files = [f for f in py_files if not is_test_file(f, args.test_pattern)]
unused_full = _filter_dynamic(
_run_vulture(_vulture, whitelist + py_files, args.exclude, args.min_confidence),
dynamic_names,
)
unused_prod = _filter_dynamic(
_run_vulture(_vulture, whitelist + prod_files, args.exclude, args.min_confidence),
dynamic_names,
)
full_keys = {_item_key(i) for i in unused_full}
test_only = [i for i in unused_prod if _item_key(i) not in full_keys]
_report_dead(unused_full)
_report_test_only(test_only)
failed = bool(unused_full) or (bool(test_only) and not args.warn_only)
return int(failed)
unused = _filter_dynamic(
_run_vulture(_vulture, whitelist + filenames, args.exclude, args.min_confidence),
dynamic_names,
)
_report_dead(unused)
return int(bool(unused))
if __name__ == '__main__':
raise SystemExit(main())