willitbite 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- willitbite/__init__.py +3 -0
- willitbite/callsites.py +187 -0
- willitbite/cli.py +189 -0
- willitbite/escape.py +215 -0
- willitbite/mutation.py +268 -0
- willitbite/ruffrun.py +102 -0
- willitbite/verdict.py +50 -0
- willitbite-0.1.0.dist-info/METADATA +202 -0
- willitbite-0.1.0.dist-info/RECORD +12 -0
- willitbite-0.1.0.dist-info/WHEEL +4 -0
- willitbite-0.1.0.dist-info/entry_points.txt +2 -0
- willitbite-0.1.0.dist-info/licenses/LICENSE +21 -0
willitbite/__init__.py
ADDED
willitbite/callsites.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Who calls this, and do any of them leave the argument out?
|
|
2
|
+
|
|
3
|
+
A function that mutates its own mutable default is wrong. That is a property of
|
|
4
|
+
the function and it does not depend on anybody else. Whether the wrongness can
|
|
5
|
+
reach you is a property of the program, and it turns on one thing: some caller
|
|
6
|
+
has to omit the argument. If every call site passes an explicit value, the
|
|
7
|
+
shared default is never the object being mutated and the bug sits there waiting
|
|
8
|
+
for a caller who does not yet exist.
|
|
9
|
+
|
|
10
|
+
Both facts are worth reporting, and they are different facts. So this produces a
|
|
11
|
+
second verdict of its own and the first one stays visible underneath it.
|
|
12
|
+
|
|
13
|
+
**Matching is by name, and it is deliberately generous.** Resolving ``x.send()``
|
|
14
|
+
to a definition properly needs type inference, which this does not have. So a
|
|
15
|
+
call is counted whenever the called name matches, wherever it appears. That
|
|
16
|
+
over-matches: an unrelated ``send`` in another module is counted too. The
|
|
17
|
+
over-matching is in the safe direction, because an extra caller can only ever
|
|
18
|
+
add an omission and an omission is the answer that keeps the warning.
|
|
19
|
+
|
|
20
|
+
**Everything unresolvable stays unresolved.** A ``**kwargs`` splat might be
|
|
21
|
+
carrying the argument, a ``*args`` splat might be filling the position, and a
|
|
22
|
+
function with no visible caller at all might be a public entry point that half
|
|
23
|
+
the internet calls. None of those is evidence of safety, so each of them leaves
|
|
24
|
+
the verdict where it was. The only thing that downgrades a warning here is a
|
|
25
|
+
complete set of call sites that every one of them supplies the argument.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import os
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
#: Directories that are never the project's own source. Scanning them wastes
|
|
34
|
+
#: time and, worse, a vendored copy of the same function invents call sites the
|
|
35
|
+
#: project does not have.
|
|
36
|
+
SKIP_DIRS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
".git",
|
|
39
|
+
".hg",
|
|
40
|
+
".svn",
|
|
41
|
+
".tox",
|
|
42
|
+
".nox",
|
|
43
|
+
".venv",
|
|
44
|
+
"venv",
|
|
45
|
+
"env",
|
|
46
|
+
".mypy_cache",
|
|
47
|
+
".pytest_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
"__pycache__",
|
|
50
|
+
"node_modules",
|
|
51
|
+
"site-packages",
|
|
52
|
+
"build",
|
|
53
|
+
"dist",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Call:
|
|
60
|
+
"""One call, remembered well enough to answer questions about its arguments."""
|
|
61
|
+
|
|
62
|
+
filename: str
|
|
63
|
+
line: int
|
|
64
|
+
node: ast.Call
|
|
65
|
+
attribute: bool #: written as ``x.name(...)`` rather than ``name(...)``
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def python_files(root):
|
|
69
|
+
"""Every ``.py`` file under ``root``, skipping the directories above."""
|
|
70
|
+
root = str(root)
|
|
71
|
+
if os.path.isfile(root):
|
|
72
|
+
yield root
|
|
73
|
+
return
|
|
74
|
+
for folder, subfolders, names in os.walk(root):
|
|
75
|
+
subfolders[:] = [d for d in subfolders if d not in SKIP_DIRS]
|
|
76
|
+
for name in names:
|
|
77
|
+
if name.endswith(".py"):
|
|
78
|
+
yield os.path.join(folder, name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def index(root):
|
|
82
|
+
"""Map every called name under ``root`` to the calls that use it.
|
|
83
|
+
|
|
84
|
+
A file that cannot be read or parsed is skipped rather than fatal. The index
|
|
85
|
+
is a source of evidence, and missing evidence is already handled: it leaves
|
|
86
|
+
warnings where they are.
|
|
87
|
+
"""
|
|
88
|
+
found = defaultdict(list)
|
|
89
|
+
for filename in python_files(root):
|
|
90
|
+
# Closed explicitly rather than left to the collector: this runs once
|
|
91
|
+
# per file across a whole source tree, and a few thousand open handles
|
|
92
|
+
# is its own kind of failure.
|
|
93
|
+
try:
|
|
94
|
+
with open(filename, encoding="utf-8") as handle:
|
|
95
|
+
tree = ast.parse(handle.read())
|
|
96
|
+
except (OSError, SyntaxError, ValueError, UnicodeDecodeError):
|
|
97
|
+
continue
|
|
98
|
+
for node in ast.walk(tree):
|
|
99
|
+
if not isinstance(node, ast.Call):
|
|
100
|
+
continue
|
|
101
|
+
callee = node.func
|
|
102
|
+
if isinstance(callee, ast.Name):
|
|
103
|
+
found[callee.id].append(Call(filename, node.lineno, node, False))
|
|
104
|
+
elif isinstance(callee, ast.Attribute):
|
|
105
|
+
found[callee.attr].append(Call(filename, node.lineno, node, True))
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def position(fn, name):
|
|
110
|
+
"""Where ``name`` sits in the signature, or None if it is keyword-only."""
|
|
111
|
+
positional = fn.args.posonlyargs + fn.args.args
|
|
112
|
+
for i, arg in enumerate(positional):
|
|
113
|
+
if arg.arg == name:
|
|
114
|
+
return i
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def binds_self(fn):
|
|
119
|
+
"""Does a bound call to this function consume the first parameter?
|
|
120
|
+
|
|
121
|
+
Reading the decorator list is enough here: ``staticmethod`` takes no
|
|
122
|
+
receiver, and ``classmethod`` takes one exactly as an instance method does.
|
|
123
|
+
A module-level function whose first parameter is called ``self`` would be
|
|
124
|
+
misread, which is rare enough to be worth the simplicity, and the misreading
|
|
125
|
+
lands on the conservative side.
|
|
126
|
+
"""
|
|
127
|
+
first = (fn.args.posonlyargs + fn.args.args)[:1]
|
|
128
|
+
if not first or first[0].arg not in ("self", "cls"):
|
|
129
|
+
return False
|
|
130
|
+
for decorator in fn.decorator_list:
|
|
131
|
+
label = decorator.id if isinstance(decorator, ast.Name) else getattr(decorator, "attr", "")
|
|
132
|
+
if label == "staticmethod":
|
|
133
|
+
return False
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def supplies(call, name, index_in_signature, drop_self):
|
|
138
|
+
"""Does this call site pass the parameter? True, False, or None for unknown."""
|
|
139
|
+
for keyword in call.node.keywords:
|
|
140
|
+
if keyword.arg == name:
|
|
141
|
+
return True
|
|
142
|
+
if any(keyword.arg is None for keyword in call.node.keywords):
|
|
143
|
+
return None # a **splat could be carrying it
|
|
144
|
+
if index_in_signature is None:
|
|
145
|
+
return False # keyword-only, and no keyword of that name was given
|
|
146
|
+
if any(isinstance(arg, ast.Starred) for arg in call.node.args):
|
|
147
|
+
return None # a *splat could be filling the position
|
|
148
|
+
wanted = index_in_signature - 1 if (drop_self and call.attribute) else index_in_signature
|
|
149
|
+
if wanted < 0:
|
|
150
|
+
return None
|
|
151
|
+
return len(call.node.args) > wanted
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class Reach:
|
|
156
|
+
"""What the call sites collectively say about one parameter."""
|
|
157
|
+
|
|
158
|
+
supplying: int
|
|
159
|
+
omitting: tuple
|
|
160
|
+
unknown: tuple
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def total(self):
|
|
164
|
+
return self.supplying + len(self.omitting) + len(self.unknown)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def unreachable(self):
|
|
168
|
+
"""True only when every call site was resolved and all of them supply it."""
|
|
169
|
+
return self.total > 0 and not self.omitting and not self.unknown
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def reach(fn, name, calls):
|
|
173
|
+
"""Read the call sites of ``fn`` for what they do with ``name``."""
|
|
174
|
+
sites = calls.get(fn.name, ())
|
|
175
|
+
where = position(fn, name)
|
|
176
|
+
drop_self = binds_self(fn)
|
|
177
|
+
|
|
178
|
+
supplying, omitting, unknown = 0, [], []
|
|
179
|
+
for site in sites:
|
|
180
|
+
answer = supplies(site, name, where, drop_self)
|
|
181
|
+
if answer is True:
|
|
182
|
+
supplying += 1
|
|
183
|
+
elif answer is False:
|
|
184
|
+
omitting.append(site)
|
|
185
|
+
else:
|
|
186
|
+
unknown.append(site)
|
|
187
|
+
return Reach(supplying, tuple(omitting), tuple(unknown))
|
willitbite/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The command line: run ruff, decide every warning, report what can bite."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
|
|
10
|
+
from . import __version__, callsites, escape, mutation, ruffrun
|
|
11
|
+
from .verdict import BITES, CALLEE, LATENT, ORDER, SAFE, Verdict
|
|
12
|
+
|
|
13
|
+
ANALYSERS = {"B023": escape.analyse, "B006": mutation.analyse}
|
|
14
|
+
|
|
15
|
+
RULE_TITLES = {
|
|
16
|
+
"B023": "closures capturing a loop variable",
|
|
17
|
+
"B006": "mutable default arguments",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decide(findings, root=None):
|
|
22
|
+
"""Attach a verdict to every finding, parsing each file once.
|
|
23
|
+
|
|
24
|
+
The call-site pass is a second look, not a first one. Reading every Python
|
|
25
|
+
file under ``root`` costs real time on a large tree, and it can only ever
|
|
26
|
+
change a B006 answer that already came back BITES, which on the codebases
|
|
27
|
+
this was built against was one warning in a hundred and thirty-three. So
|
|
28
|
+
the index is built only if there is something for it to decide, and a run
|
|
29
|
+
that finds nothing biting never pays for it at all.
|
|
30
|
+
"""
|
|
31
|
+
by_file = defaultdict(list)
|
|
32
|
+
for finding in findings:
|
|
33
|
+
by_file[finding["filename"]].append(finding)
|
|
34
|
+
|
|
35
|
+
trees = {}
|
|
36
|
+
decided = []
|
|
37
|
+
for filename, group in by_file.items():
|
|
38
|
+
try:
|
|
39
|
+
source = io.open(filename, encoding="utf-8").read()
|
|
40
|
+
tree = ast.parse(source)
|
|
41
|
+
except (OSError, SyntaxError) as exc:
|
|
42
|
+
for finding in group:
|
|
43
|
+
decided.append({**finding, "verdict": Verdict(CALLEE, f"unreadable: {exc}")})
|
|
44
|
+
continue
|
|
45
|
+
trees[filename] = tree
|
|
46
|
+
parents = escape.parent_map(tree)
|
|
47
|
+
for finding in group:
|
|
48
|
+
analyse = ANALYSERS.get(finding["code"])
|
|
49
|
+
if analyse is None:
|
|
50
|
+
continue
|
|
51
|
+
decided.append(
|
|
52
|
+
{**finding, "verdict": analyse(tree, finding["line"], parents)}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if root is not None:
|
|
56
|
+
pending = [
|
|
57
|
+
row
|
|
58
|
+
for row in decided
|
|
59
|
+
if row["code"] == "B006" and row["verdict"].kind == BITES
|
|
60
|
+
]
|
|
61
|
+
if pending:
|
|
62
|
+
calls = callsites.index(root)
|
|
63
|
+
for row in pending:
|
|
64
|
+
tree = trees.get(row["filename"])
|
|
65
|
+
if tree is None:
|
|
66
|
+
continue
|
|
67
|
+
row["verdict"] = mutation.analyse(tree, row["line"], calls=calls)
|
|
68
|
+
return decided
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _counts(rows):
|
|
72
|
+
tally = defaultdict(int)
|
|
73
|
+
for row in rows:
|
|
74
|
+
tally[row["verdict"].kind] += 1
|
|
75
|
+
return tally
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render(decided, show_all=False):
|
|
79
|
+
"""Human-readable report. Returns the lines."""
|
|
80
|
+
lines = []
|
|
81
|
+
by_rule = defaultdict(list)
|
|
82
|
+
for row in decided:
|
|
83
|
+
by_rule[row["code"]].append(row)
|
|
84
|
+
|
|
85
|
+
total_bites = 0
|
|
86
|
+
total_latent = 0
|
|
87
|
+
for code in sorted(by_rule):
|
|
88
|
+
rows = by_rule[code]
|
|
89
|
+
tally = _counts(rows)
|
|
90
|
+
total_bites += tally[BITES]
|
|
91
|
+
total_latent += tally[LATENT]
|
|
92
|
+
lines.append(f"{code} {RULE_TITLES.get(code, '')}")
|
|
93
|
+
# The latent column is omitted when it is empty rather than printed as a
|
|
94
|
+
# permanent zero, since only B006 can produce one.
|
|
95
|
+
latent = f"{tally[LATENT]} latent " if tally[LATENT] else ""
|
|
96
|
+
lines.append(
|
|
97
|
+
f" {len(rows)} warning(s) {tally[BITES]} can bite {latent}"
|
|
98
|
+
f"{tally[CALLEE]} depend on a callee {tally[SAFE]} safe"
|
|
99
|
+
)
|
|
100
|
+
wanted = ORDER if show_all else (BITES, LATENT, CALLEE)
|
|
101
|
+
for kind in wanted:
|
|
102
|
+
for row in sorted(rows, key=lambda r: (r["filename"], r["line"])):
|
|
103
|
+
if row["verdict"].kind != kind:
|
|
104
|
+
continue
|
|
105
|
+
lines.append(f" [{kind}] {row['filename']}:{row['line']}")
|
|
106
|
+
lines.append(f" {row['verdict'].reason}")
|
|
107
|
+
lines.append("")
|
|
108
|
+
|
|
109
|
+
tail = f", {total_latent} latent" if total_latent else ""
|
|
110
|
+
if not decided:
|
|
111
|
+
lines.append("No B006 or B023 warnings found.")
|
|
112
|
+
elif total_bites == 0:
|
|
113
|
+
lines.append(
|
|
114
|
+
f"Nothing here can bite today. {len(decided)} warning(s), "
|
|
115
|
+
f"0 reachable defects{tail}."
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
lines.append(
|
|
119
|
+
f"{total_bites} of {len(decided)} warning(s) can actually bite{tail}."
|
|
120
|
+
)
|
|
121
|
+
return lines
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def as_json(decided):
|
|
125
|
+
return json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"warnings": len(decided),
|
|
128
|
+
"bites": sum(1 for r in decided if r["verdict"].kind == BITES),
|
|
129
|
+
"latent": sum(1 for r in decided if r["verdict"].kind == LATENT),
|
|
130
|
+
"results": [
|
|
131
|
+
{
|
|
132
|
+
"code": r["code"],
|
|
133
|
+
"filename": r["filename"],
|
|
134
|
+
"line": r["line"],
|
|
135
|
+
"verdict": r["verdict"].kind,
|
|
136
|
+
"reason": r["verdict"].reason,
|
|
137
|
+
}
|
|
138
|
+
for r in decided
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
indent=2,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv=None):
|
|
146
|
+
parser = argparse.ArgumentParser(
|
|
147
|
+
prog="willitbite",
|
|
148
|
+
description=(
|
|
149
|
+
"Decide which of ruff's loop-closure and mutable-default warnings "
|
|
150
|
+
"can actually reach you at runtime."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument("path", nargs="?", default=".", help="file or directory to inspect")
|
|
154
|
+
parser.add_argument("--version", action="version", version=f"willitbite {__version__}")
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--json", dest="json_in", metavar="FILE",
|
|
157
|
+
help="read ruff's JSON output from FILE instead of running ruff",
|
|
158
|
+
)
|
|
159
|
+
parser.add_argument("--json-out", action="store_true", help="print results as JSON")
|
|
160
|
+
parser.add_argument("--all", action="store_true", help="list the safe warnings too")
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--exit-zero", action="store_true",
|
|
163
|
+
help="always exit 0, even when something can bite",
|
|
164
|
+
)
|
|
165
|
+
args = parser.parse_args(argv)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
if args.json_in:
|
|
169
|
+
findings = ruffrun.parse(io.open(args.json_in, encoding="utf-8").read())
|
|
170
|
+
else:
|
|
171
|
+
findings = ruffrun.run(args.path)
|
|
172
|
+
except (ruffrun.RuffMissing, RuntimeError, OSError) as exc:
|
|
173
|
+
print(str(exc), file=sys.stderr)
|
|
174
|
+
return 2
|
|
175
|
+
|
|
176
|
+
decided = decide(findings, root=args.path)
|
|
177
|
+
|
|
178
|
+
if args.json_out:
|
|
179
|
+
print(as_json(decided))
|
|
180
|
+
else:
|
|
181
|
+
print("\n".join(render(decided, show_all=args.all)))
|
|
182
|
+
|
|
183
|
+
if args.exit_zero:
|
|
184
|
+
return 0
|
|
185
|
+
return 1 if any(r["verdict"].kind == BITES for r in decided) else 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
raise SystemExit(main())
|
willitbite/escape.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""B023: does a closure that captures a loop variable outlive its iteration?
|
|
2
|
+
|
|
3
|
+
Ruff flags every closure inside a loop that reads the loop variable. That is a
|
|
4
|
+
description of a shape, not of a bug. The bug is late binding: if the closure is
|
|
5
|
+
still callable after the loop moves on, every copy of it sees the loop
|
|
6
|
+
variable's final value. If the closure is built and consumed inside the same
|
|
7
|
+
iteration, it sees the value it was written next to.
|
|
8
|
+
|
|
9
|
+
So the question here is never "does it capture". Ruff answered that. It is
|
|
10
|
+
"can this closure still be called once the loop variable has changed".
|
|
11
|
+
|
|
12
|
+
For a nested ``def NAME``, the evidence is what the loop body does with NAME:
|
|
13
|
+
|
|
14
|
+
every reference is ``NAME(...)`` -> SAFE, it runs in its own iteration
|
|
15
|
+
a bare reference that is stored -> BITES
|
|
16
|
+
a bare reference passed to ``f(NAME)`` -> CALLEE, depends on f
|
|
17
|
+
|
|
18
|
+
For a ``lambda``, the evidence is the construct that owns it: an argument to
|
|
19
|
+
``sorted`` runs now; ``.append`` and ``functools.partial`` keep it for later.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
|
|
24
|
+
from .verdict import BITES, CALLEE, SAFE, Verdict
|
|
25
|
+
|
|
26
|
+
LOOPS = (ast.For, ast.AsyncFor, ast.While)
|
|
27
|
+
|
|
28
|
+
#: Builtins that call the closure and drop it before the expression finishes.
|
|
29
|
+
CONSUMING = frozenset(
|
|
30
|
+
{"sorted", "min", "max", "sum", "any", "all", "sort", "nlargest", "nsmallest"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: Builtins that return a lazy iterator holding the closure. Safe only if the
|
|
34
|
+
#: result is consumed in the same iteration, which is a separate question.
|
|
35
|
+
LAZY = frozenset({"map", "filter", "groupby"})
|
|
36
|
+
|
|
37
|
+
#: Attribute calls whose whole purpose is to keep the callable for later.
|
|
38
|
+
KEEPING_ATTRS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"append",
|
|
41
|
+
"add",
|
|
42
|
+
"insert",
|
|
43
|
+
"extend",
|
|
44
|
+
"submit",
|
|
45
|
+
"apply_async",
|
|
46
|
+
"put",
|
|
47
|
+
"add_done_callback",
|
|
48
|
+
"connect",
|
|
49
|
+
"register",
|
|
50
|
+
"subscribe",
|
|
51
|
+
"schedule",
|
|
52
|
+
"create_task",
|
|
53
|
+
"run_in_executor",
|
|
54
|
+
"call_later",
|
|
55
|
+
"call_soon",
|
|
56
|
+
"setdefault",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
#: Constructors and helpers that store the callable in the object they return.
|
|
61
|
+
KEEPING_CALLS = frozenset(
|
|
62
|
+
{"partial", "Thread", "Timer", "Process", "create_task", "ensure_future"}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parent_map(tree):
|
|
67
|
+
"""Map every node to its parent. ast exposes children only."""
|
|
68
|
+
parents = {}
|
|
69
|
+
for node in ast.walk(tree):
|
|
70
|
+
for child in ast.iter_child_nodes(node):
|
|
71
|
+
parents[child] = node
|
|
72
|
+
return parents
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def innermost_closure(tree, line):
|
|
76
|
+
"""The lambda or def enclosing ``line``, innermost when several do.
|
|
77
|
+
|
|
78
|
+
Ruff points at the captured name, not at the closure, so the closure has to
|
|
79
|
+
be found by span. The one that starts latest is the innermost.
|
|
80
|
+
"""
|
|
81
|
+
holding = [
|
|
82
|
+
n
|
|
83
|
+
for n in ast.walk(tree)
|
|
84
|
+
if isinstance(n, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef))
|
|
85
|
+
and n.lineno <= line <= getattr(n, "end_lineno", n.lineno)
|
|
86
|
+
]
|
|
87
|
+
return max(holding, key=lambda n: n.lineno) if holding else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def enclosing_loop(node, parents):
|
|
91
|
+
cur = node
|
|
92
|
+
while cur in parents:
|
|
93
|
+
cur = parents[cur]
|
|
94
|
+
if isinstance(cur, LOOPS):
|
|
95
|
+
return cur
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _callee_name(call):
|
|
100
|
+
fn = call.func
|
|
101
|
+
if isinstance(fn, ast.Name):
|
|
102
|
+
return fn.id
|
|
103
|
+
if isinstance(fn, ast.Attribute):
|
|
104
|
+
return fn.attr
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def classify_def(fn, loop, parents):
|
|
109
|
+
"""A nested def: what does the loop body do with its name?"""
|
|
110
|
+
if loop is None:
|
|
111
|
+
return Verdict(CALLEE, f"`{fn.name}` is not inside a loop in this file")
|
|
112
|
+
|
|
113
|
+
name = fn.name
|
|
114
|
+
called = 0
|
|
115
|
+
stored = []
|
|
116
|
+
passed = []
|
|
117
|
+
for node in ast.walk(loop):
|
|
118
|
+
if not isinstance(node, ast.Name) or node.id != name:
|
|
119
|
+
continue
|
|
120
|
+
if not isinstance(node.ctx, ast.Load):
|
|
121
|
+
continue
|
|
122
|
+
up = parents.get(node)
|
|
123
|
+
if isinstance(up, ast.Call) and up.func is node:
|
|
124
|
+
called += 1
|
|
125
|
+
elif isinstance(up, ast.Call):
|
|
126
|
+
passed.append(_callee_name(up) or "a call")
|
|
127
|
+
else:
|
|
128
|
+
stored.append(type(up).__name__)
|
|
129
|
+
|
|
130
|
+
if stored:
|
|
131
|
+
where = ", ".join(sorted(set(stored)))
|
|
132
|
+
return Verdict(
|
|
133
|
+
BITES,
|
|
134
|
+
f"`{name}` is stored rather than called ({where}), so it can run "
|
|
135
|
+
f"after the loop variable has changed",
|
|
136
|
+
)
|
|
137
|
+
keepers = sorted({p for p in passed if p in KEEPING_ATTRS or p in KEEPING_CALLS})
|
|
138
|
+
if keepers:
|
|
139
|
+
who = ", ".join(keepers)
|
|
140
|
+
return Verdict(
|
|
141
|
+
BITES,
|
|
142
|
+
f"`{name}` is stored rather than called: it is handed to {who}(), "
|
|
143
|
+
f"which keeps it past the end of the iteration",
|
|
144
|
+
)
|
|
145
|
+
if passed:
|
|
146
|
+
runners = {p for p in passed if p in CONSUMING}
|
|
147
|
+
rest = sorted(set(passed) - runners)
|
|
148
|
+
if not rest:
|
|
149
|
+
who = ", ".join(sorted(runners))
|
|
150
|
+
return Verdict(
|
|
151
|
+
SAFE, f"`{name}` is only ever an argument to {who}(), which runs it now"
|
|
152
|
+
)
|
|
153
|
+
who = ", ".join(rest)
|
|
154
|
+
return Verdict(
|
|
155
|
+
CALLEE,
|
|
156
|
+
f"`{name}` is handed to {who}(), so it bites only if {who} keeps it "
|
|
157
|
+
f"instead of calling it",
|
|
158
|
+
)
|
|
159
|
+
if called:
|
|
160
|
+
return Verdict(
|
|
161
|
+
SAFE,
|
|
162
|
+
f"`{name}` is only ever called directly, {called} time(s) in the "
|
|
163
|
+
f"same iteration",
|
|
164
|
+
)
|
|
165
|
+
return Verdict(
|
|
166
|
+
BITES,
|
|
167
|
+
f"`{name}` is never used inside the loop, so whatever holds it outlives "
|
|
168
|
+
f"the iteration",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def classify_lambda(node, parents):
|
|
173
|
+
"""A lambda: which construct owns it?"""
|
|
174
|
+
cur = node
|
|
175
|
+
while cur in parents:
|
|
176
|
+
up = parents[cur]
|
|
177
|
+
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.NamedExpr)):
|
|
178
|
+
return Verdict(BITES, "the lambda is bound to a name, so it is called later")
|
|
179
|
+
if isinstance(up, (ast.Dict, ast.List, ast.Set, ast.Tuple)):
|
|
180
|
+
return Verdict(BITES, "the lambda is stored in a container")
|
|
181
|
+
if isinstance(up, ast.Return):
|
|
182
|
+
return Verdict(BITES, "the lambda is returned out of the function")
|
|
183
|
+
if isinstance(up, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
184
|
+
return Verdict(BITES, "the lambda is collected by a comprehension")
|
|
185
|
+
if isinstance(up, ast.Call):
|
|
186
|
+
name = _callee_name(up)
|
|
187
|
+
if name in KEEPING_ATTRS or name in KEEPING_CALLS:
|
|
188
|
+
return Verdict(BITES, f"the lambda is handed to {name}(), which keeps it")
|
|
189
|
+
if name in CONSUMING:
|
|
190
|
+
return Verdict(
|
|
191
|
+
SAFE, f"the lambda is an argument to {name}(), which runs it now"
|
|
192
|
+
)
|
|
193
|
+
if name in LAZY:
|
|
194
|
+
return Verdict(
|
|
195
|
+
CALLEE,
|
|
196
|
+
f"the lambda is an argument to {name}(), which is lazy, so it is "
|
|
197
|
+
f"safe only if the result is consumed in this iteration",
|
|
198
|
+
)
|
|
199
|
+
return Verdict(
|
|
200
|
+
CALLEE,
|
|
201
|
+
f"the lambda is handed to {name}(), so it bites only if {name} keeps it",
|
|
202
|
+
)
|
|
203
|
+
cur = up
|
|
204
|
+
return Verdict(CALLEE, "the owning construct could not be identified")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def analyse(tree, line, parents=None):
|
|
208
|
+
"""Decide one B023 warning reported at ``line``."""
|
|
209
|
+
parents = parent_map(tree) if parents is None else parents
|
|
210
|
+
closure = innermost_closure(tree, line)
|
|
211
|
+
if closure is None:
|
|
212
|
+
return Verdict(CALLEE, "no closure found at this line")
|
|
213
|
+
if isinstance(closure, ast.Lambda):
|
|
214
|
+
return classify_lambda(closure, parents)
|
|
215
|
+
return classify_def(closure, enclosing_loop(closure, parents), parents)
|
willitbite/mutation.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""B006: is a mutable default argument ever actually mutated?
|
|
2
|
+
|
|
3
|
+
A mutable default is evaluated once, at definition time, and shared by every
|
|
4
|
+
call that omits the argument. That is only a bug when the function changes it,
|
|
5
|
+
because the change is then visible to the next caller. A default that is only
|
|
6
|
+
read behaves exactly like the immutable one the author probably imagined.
|
|
7
|
+
|
|
8
|
+
So the decisive question is mutation, and there is one trap in asking it. A
|
|
9
|
+
great many functions open with ``items = items or []`` or ``items = list(items)``,
|
|
10
|
+
which rebinds the name to a fresh object before anything is appended. The shared
|
|
11
|
+
default is never touched. Mutation that happens after a rebind is mutation of
|
|
12
|
+
the new object, so position matters and a plain "does the name appear on the
|
|
13
|
+
left of an append" is not enough.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
|
|
18
|
+
from .callsites import reach
|
|
19
|
+
from .verdict import BITES, CALLEE, LATENT, SAFE, Verdict
|
|
20
|
+
|
|
21
|
+
#: Methods that change the receiver in place.
|
|
22
|
+
MUTATORS = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"append",
|
|
25
|
+
"extend",
|
|
26
|
+
"insert",
|
|
27
|
+
"add",
|
|
28
|
+
"update",
|
|
29
|
+
"setdefault",
|
|
30
|
+
"pop",
|
|
31
|
+
"popitem",
|
|
32
|
+
"remove",
|
|
33
|
+
"clear",
|
|
34
|
+
"sort",
|
|
35
|
+
"discard",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: Calls that cannot change what they are given: they either only read it, or
|
|
40
|
+
#: they build a new object from it. Passing the default to one of these is not
|
|
41
|
+
#: an escape, so they must be excluded or every read looks like a risk.
|
|
42
|
+
NON_MUTATING = frozenset(
|
|
43
|
+
{
|
|
44
|
+
# build a copy
|
|
45
|
+
"list", "dict", "set", "tuple", "frozenset", "copy", "deepcopy", "sorted",
|
|
46
|
+
# read only
|
|
47
|
+
"len", "any", "all", "sum", "min", "max", "enumerate", "iter", "reversed",
|
|
48
|
+
"bool", "str", "repr", "print", "isinstance", "join", "format", "next",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: Factories that produce a mutable object, so ``x=dict()`` is as shared as ``x={}``.
|
|
53
|
+
MUTABLE_FACTORIES = frozenset(
|
|
54
|
+
{"list", "dict", "set", "defaultdict", "Counter", "OrderedDict", "deque"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mutable_defaults(fn):
|
|
59
|
+
"""Parameter names whose default is a mutable object.
|
|
60
|
+
|
|
61
|
+
Ruff reports the offending default's position, but recovering the parameter
|
|
62
|
+
name from that is fiddlier than reading the signature directly, and the
|
|
63
|
+
signature is what a reader checks the verdict against.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
args = fn.args
|
|
69
|
+
names = []
|
|
70
|
+
|
|
71
|
+
positional = args.posonlyargs + args.args
|
|
72
|
+
if args.defaults:
|
|
73
|
+
paired = zip(positional[len(positional) - len(args.defaults) :], args.defaults)
|
|
74
|
+
for arg, default in paired:
|
|
75
|
+
if _is_mutable(default):
|
|
76
|
+
names.append(arg.arg)
|
|
77
|
+
|
|
78
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
|
|
79
|
+
if default is not None and _is_mutable(default):
|
|
80
|
+
names.append(arg.arg)
|
|
81
|
+
|
|
82
|
+
return names
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_mutable(node):
|
|
86
|
+
if isinstance(node, (ast.List, ast.Dict, ast.Set)):
|
|
87
|
+
return True
|
|
88
|
+
if isinstance(node, ast.Call):
|
|
89
|
+
fn = node.func
|
|
90
|
+
name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "")
|
|
91
|
+
return name in MUTABLE_FACTORIES
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _rebind_line(fn, name):
|
|
96
|
+
"""First line where ``name`` is assigned, or None.
|
|
97
|
+
|
|
98
|
+
After a rebind the name refers to a new object, so anything done to it from
|
|
99
|
+
that point on cannot reach the shared default.
|
|
100
|
+
"""
|
|
101
|
+
augmented = {
|
|
102
|
+
node.target
|
|
103
|
+
for node in ast.walk(fn)
|
|
104
|
+
if isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name)
|
|
105
|
+
}
|
|
106
|
+
lines = [
|
|
107
|
+
node.lineno
|
|
108
|
+
for node in ast.walk(fn)
|
|
109
|
+
if isinstance(node, ast.Name)
|
|
110
|
+
and node.id == name
|
|
111
|
+
and isinstance(node.ctx, ast.Store)
|
|
112
|
+
# ``items += [1]`` on a list calls __iadd__, which extends in place. It
|
|
113
|
+
# is a Store node but it does not produce a new object, so counting it
|
|
114
|
+
# as a rebind would hide the very mutation it performs.
|
|
115
|
+
and node not in augmented
|
|
116
|
+
]
|
|
117
|
+
return min(lines) if lines else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _mutations(fn, name):
|
|
121
|
+
"""Every in-place change to ``name``, as (line, description)."""
|
|
122
|
+
found = []
|
|
123
|
+
for node in ast.walk(fn):
|
|
124
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
125
|
+
receiver = node.func.value
|
|
126
|
+
if isinstance(receiver, ast.Name) and receiver.id == name:
|
|
127
|
+
if node.func.attr in MUTATORS:
|
|
128
|
+
found.append((node.lineno, f"{name}.{node.func.attr}()"))
|
|
129
|
+
elif isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name):
|
|
130
|
+
if node.value.id == name and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
131
|
+
verb = "assigned" if isinstance(node.ctx, ast.Store) else "deleted"
|
|
132
|
+
found.append((node.lineno, f"{name}[...] {verb}"))
|
|
133
|
+
elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
|
|
134
|
+
if node.target.id == name:
|
|
135
|
+
found.append((node.lineno, f"{name} += ..."))
|
|
136
|
+
return found
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _escapes_unchanged(fn, name):
|
|
140
|
+
"""Is the default handed to something that could keep or mutate it?
|
|
141
|
+
|
|
142
|
+
A default that is only read inside the function can still be mutated a frame
|
|
143
|
+
down if it is passed on by reference. Calls that provably cannot change it
|
|
144
|
+
are excluded, or every ``len(items)`` would look like a risk.
|
|
145
|
+
"""
|
|
146
|
+
for node in ast.walk(fn):
|
|
147
|
+
if not isinstance(node, ast.Call):
|
|
148
|
+
continue
|
|
149
|
+
callee = node.func
|
|
150
|
+
callee_name = (
|
|
151
|
+
callee.id if isinstance(callee, ast.Name) else getattr(callee, "attr", None)
|
|
152
|
+
)
|
|
153
|
+
if callee_name in NON_MUTATING:
|
|
154
|
+
continue
|
|
155
|
+
for arg in node.args:
|
|
156
|
+
if isinstance(arg, ast.Name) and arg.id == name:
|
|
157
|
+
return callee_name or "a call"
|
|
158
|
+
for kw in node.keywords:
|
|
159
|
+
if isinstance(kw.value, ast.Name) and kw.value.id == name:
|
|
160
|
+
return callee_name or "a call"
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _reaching_mutations(fn, name):
|
|
165
|
+
"""The mutations that touch the shared default, and the rebind line if any."""
|
|
166
|
+
rebound = _rebind_line(fn, name)
|
|
167
|
+
mutations = _mutations(fn, name)
|
|
168
|
+
return [m for m in mutations if rebound is None or m[0] < rebound], rebound, mutations
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _defect_clause(name, reaching):
|
|
172
|
+
"""The half of the reason that describes the defect itself.
|
|
173
|
+
|
|
174
|
+
Shared by BITES and LATENT so the two answers never drift into describing
|
|
175
|
+
the same defect differently. Only what follows this clause changes.
|
|
176
|
+
"""
|
|
177
|
+
where = ", ".join(f"{what} on line {line}" for line, what in sorted(reaching))
|
|
178
|
+
return f"`{name}` is mutated before it is rebound ({where})"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def analyse_param(fn, name):
|
|
182
|
+
"""Decide one parameter of one function, from its body alone."""
|
|
183
|
+
reaching, rebound, mutations = _reaching_mutations(fn, name)
|
|
184
|
+
|
|
185
|
+
if reaching:
|
|
186
|
+
return Verdict(
|
|
187
|
+
BITES,
|
|
188
|
+
f"{_defect_clause(name, reaching)}, so every call that omits it sees "
|
|
189
|
+
f"the previous call's changes",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if rebound is not None:
|
|
193
|
+
detail = " before any mutation" if mutations else ""
|
|
194
|
+
return Verdict(
|
|
195
|
+
SAFE,
|
|
196
|
+
f"`{name}` is rebound on line {rebound}{detail}, so the shared default "
|
|
197
|
+
f"is never touched",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
escapee = _escapes_unchanged(fn, name)
|
|
201
|
+
if escapee:
|
|
202
|
+
return Verdict(
|
|
203
|
+
CALLEE,
|
|
204
|
+
f"`{name}` is never mutated here but is passed to {escapee}(), which "
|
|
205
|
+
f"could mutate it a frame down",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return Verdict(SAFE, f"`{name}` is never mutated, so sharing it is harmless")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _with_call_sites(fn, name, verdict, calls):
|
|
212
|
+
"""Ask the callers whether the defect in ``fn`` can currently fire.
|
|
213
|
+
|
|
214
|
+
Only a complete answer changes anything. If every call site was resolved and
|
|
215
|
+
every one of them passes the argument, the shared default is never the
|
|
216
|
+
object being mutated and the defect is waiting rather than happening. Any
|
|
217
|
+
other shape leaves the verdict alone: an unresolved splat, a caller that
|
|
218
|
+
omits it, or no caller at all are each a reason to keep looking, not a
|
|
219
|
+
reason to clear it.
|
|
220
|
+
"""
|
|
221
|
+
if calls is None:
|
|
222
|
+
return verdict
|
|
223
|
+
|
|
224
|
+
found = reach(fn, name, calls)
|
|
225
|
+
reaching, _, _ = _reaching_mutations(fn, name)
|
|
226
|
+
clause = _defect_clause(name, reaching)
|
|
227
|
+
|
|
228
|
+
if found.total == 0:
|
|
229
|
+
return Verdict(
|
|
230
|
+
BITES,
|
|
231
|
+
f"{clause}, and no call site of `{fn.name}()` was found under this path, "
|
|
232
|
+
f"so nothing here shows that callers pass it",
|
|
233
|
+
)
|
|
234
|
+
if found.unreachable:
|
|
235
|
+
plural = "" if found.total == 1 else "s"
|
|
236
|
+
return Verdict(
|
|
237
|
+
LATENT,
|
|
238
|
+
f"{clause}, but all {found.total} call site{plural} of `{fn.name}()` pass "
|
|
239
|
+
f"it explicitly, so the shared default is never the one being changed",
|
|
240
|
+
)
|
|
241
|
+
return verdict
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def analyse(tree, line, parents=None, calls=None):
|
|
245
|
+
"""Decide one B006 warning reported at ``line``.
|
|
246
|
+
|
|
247
|
+
``calls`` is the optional call index from :mod:`willitbite.callsites`.
|
|
248
|
+
Without it the answer is about the function; with it the answer is about
|
|
249
|
+
the program, which is a strictly narrower and more useful claim.
|
|
250
|
+
"""
|
|
251
|
+
from .escape import innermost_closure
|
|
252
|
+
|
|
253
|
+
fn = innermost_closure(tree, line)
|
|
254
|
+
if fn is None or isinstance(fn, ast.Lambda):
|
|
255
|
+
return Verdict(CALLEE, "no function definition found at this line")
|
|
256
|
+
|
|
257
|
+
names = mutable_defaults(fn)
|
|
258
|
+
if not names:
|
|
259
|
+
return Verdict(CALLEE, f"no mutable default found in the signature of `{fn.name}`")
|
|
260
|
+
|
|
261
|
+
decided = [(n, analyse_param(fn, n)) for n in names]
|
|
262
|
+
for name, verdict in decided:
|
|
263
|
+
if verdict.kind == BITES:
|
|
264
|
+
return _with_call_sites(fn, name, verdict, calls)
|
|
265
|
+
for _, verdict in decided:
|
|
266
|
+
if verdict.kind == CALLEE:
|
|
267
|
+
return verdict
|
|
268
|
+
return decided[0][1]
|
willitbite/ruffrun.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Get the warnings from ruff, or from a file ruff already wrote.
|
|
2
|
+
|
|
3
|
+
This does not reimplement the rules. Ruff decides what is a candidate and does
|
|
4
|
+
it faster and more correctly than a hand-rolled matcher would; the value here is
|
|
5
|
+
entirely in what happens to the list afterwards. Two consequences follow.
|
|
6
|
+
|
|
7
|
+
Ruff is invoked with ``--isolated`` so the answer does not depend on whether the
|
|
8
|
+
project being inspected happens to enable these rules. A team that has not
|
|
9
|
+
adopted B006 and B023 yet is exactly the team that wants this, and their config
|
|
10
|
+
would otherwise return nothing.
|
|
11
|
+
|
|
12
|
+
Ruff is not a dependency. It is looked up as a subprocess and its absence is
|
|
13
|
+
reported as the ordinary situation it is, with the ``--json`` route available
|
|
14
|
+
for anyone who would rather run their own ruff and pipe the result in.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
#: The rules this tool can decide. Adding one means adding an analyser.
|
|
23
|
+
SUPPORTED = ("B006", "B023")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuffMissing(RuntimeError):
|
|
27
|
+
"""ruff is not on PATH."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ruff_available():
|
|
31
|
+
return shutil.which("ruff") is not None or _module_available()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _module_available():
|
|
35
|
+
try:
|
|
36
|
+
subprocess.run(
|
|
37
|
+
[sys.executable, "-m", "ruff", "--version"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
check=True,
|
|
40
|
+
)
|
|
41
|
+
return True
|
|
42
|
+
except (subprocess.CalledProcessError, OSError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ruff_command():
|
|
47
|
+
if shutil.which("ruff"):
|
|
48
|
+
return ["ruff"]
|
|
49
|
+
if _module_available():
|
|
50
|
+
return [sys.executable, "-m", "ruff"]
|
|
51
|
+
raise RuffMissing(
|
|
52
|
+
"ruff was not found. Install it with `pip install ruff`, or run ruff "
|
|
53
|
+
"yourself and pass its output with --json."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(path, rules=SUPPORTED):
|
|
58
|
+
"""Run ruff over ``path`` and return its findings for ``rules``."""
|
|
59
|
+
command = _ruff_command() + [
|
|
60
|
+
"check",
|
|
61
|
+
# The project's own configuration is deliberately ignored: the people
|
|
62
|
+
# who need this are the ones who have not enabled these rules yet.
|
|
63
|
+
"--isolated",
|
|
64
|
+
"--select",
|
|
65
|
+
",".join(rules),
|
|
66
|
+
"--output-format",
|
|
67
|
+
"json",
|
|
68
|
+
str(path),
|
|
69
|
+
]
|
|
70
|
+
# A non-zero exit only means ruff found something, which is the normal case
|
|
71
|
+
# here, so the return code is not checked. A real failure shows up as
|
|
72
|
+
# unparseable output and is reported as that.
|
|
73
|
+
finished = subprocess.run(command, capture_output=True, text=True)
|
|
74
|
+
if not finished.stdout.strip():
|
|
75
|
+
if finished.returncode not in (0, 1):
|
|
76
|
+
raise RuntimeError(finished.stderr.strip() or "ruff failed")
|
|
77
|
+
return []
|
|
78
|
+
return parse(finished.stdout)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse(text):
|
|
82
|
+
"""Parse ruff's JSON output into the shape the analysers want."""
|
|
83
|
+
try:
|
|
84
|
+
raw = json.loads(text)
|
|
85
|
+
except json.JSONDecodeError as exc:
|
|
86
|
+
raise RuntimeError(f"could not read ruff output as JSON: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
findings = []
|
|
89
|
+
for item in raw:
|
|
90
|
+
code = item.get("code")
|
|
91
|
+
if code not in SUPPORTED:
|
|
92
|
+
continue
|
|
93
|
+
location = item.get("location") or {}
|
|
94
|
+
findings.append(
|
|
95
|
+
{
|
|
96
|
+
"code": code,
|
|
97
|
+
"filename": item.get("filename", ""),
|
|
98
|
+
"line": location.get("row", 0),
|
|
99
|
+
"message": item.get("message", ""),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return findings
|
willitbite/verdict.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The answers this tool gives, and why there are more than two.
|
|
2
|
+
|
|
3
|
+
A linter has two states, warned and silent. Reachability needs more, because
|
|
4
|
+
twice over the honest answer is neither.
|
|
5
|
+
|
|
6
|
+
CALLEE exists because deciding whether a closure escapes sometimes depends on a
|
|
7
|
+
function this tool cannot see into. LATENT exists because a function can be
|
|
8
|
+
genuinely wrong and still be unreachable: a mutable default that the function
|
|
9
|
+
mutates is a defect, but it only fires when some caller omits the argument, and
|
|
10
|
+
sometimes no caller does. Collapsing either case into "safe" or "bites" would be
|
|
11
|
+
a guess presented as an answer, so each gets its own verdict and names the thing
|
|
12
|
+
the reader has to look at.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
BITES = "BITES"
|
|
18
|
+
LATENT = "LATENT"
|
|
19
|
+
SAFE = "SAFE"
|
|
20
|
+
CALLEE = "CALLEE"
|
|
21
|
+
|
|
22
|
+
#: Report order: the reader wants the actionable ones first.
|
|
23
|
+
ORDER = (BITES, LATENT, CALLEE, SAFE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Verdict:
|
|
28
|
+
"""One warning, decided.
|
|
29
|
+
|
|
30
|
+
kind: BITES, LATENT, CALLEE or SAFE.
|
|
31
|
+
reason: a sentence a reader can check against the source without rerunning
|
|
32
|
+
anything. Never a restatement of the rule.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
kind: str
|
|
36
|
+
reason: str
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def actionable(self) -> bool:
|
|
40
|
+
"""Can this reach you as the code stands today?
|
|
41
|
+
|
|
42
|
+
LATENT is deliberately excluded. It is a real defect and it is reported
|
|
43
|
+
as one, but nothing calls it in a way that triggers it, so failing a
|
|
44
|
+
build on it would fail every build until somebody rewrote code that
|
|
45
|
+
currently works.
|
|
46
|
+
"""
|
|
47
|
+
return self.kind == BITES
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.kind}: {self.reason}"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: willitbite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tell which of ruff's loop-closure and mutable-default warnings can actually reach you at runtime
|
|
5
|
+
Project-URL: Homepage, https://github.com/muhzuhaib/willitbite
|
|
6
|
+
Project-URL: Issues, https://github.com/muhzuhaib/willitbite/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/muhzuhaib/willitbite/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Muhammad Zuhaib Zahid
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Muhammad Zuhaib Zahid
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: cli,linting,python,ruff,static-analysis,triage
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# willitbite
|
|
47
|
+
|
|
48
|
+
[](https://github.com/muhzuhaib/willitbite/actions/workflows/ci.yml)
|
|
49
|
+
[](https://pypi.org/project/willitbite/)
|
|
50
|
+
|
|
51
|
+
Ruff will tell you that 99 closures in your codebase capture a loop variable. It will not tell you
|
|
52
|
+
that none of them can actually bite you.
|
|
53
|
+
|
|
54
|
+
`willitbite` takes the two rules that produce most of the noise when a project first adopts ruff and
|
|
55
|
+
asks the question the linter cannot: **can this one reach you at runtime?**
|
|
56
|
+
|
|
57
|
+
Here it is on [ragflow](https://github.com/infiniflow/ragflow), 1,259 files of Python, at commit
|
|
58
|
+
`0c28d59`:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
$ willitbite .
|
|
62
|
+
B006 mutable default arguments
|
|
63
|
+
34 warning(s) 0 can bite 1 latent 13 depend on a callee 20 safe
|
|
64
|
+
[LATENT] agent/component/message.py:159
|
|
65
|
+
`kwargs` is mutated before it is rebound (kwargs[...] assigned on line
|
|
66
|
+
181), but all 2 call sites of `get_kwargs()` pass it explicitly, so the
|
|
67
|
+
shared default is never the one being changed
|
|
68
|
+
|
|
69
|
+
B023 closures capturing a loop variable
|
|
70
|
+
99 warning(s) 0 can bite 15 depend on a callee 84 safe
|
|
71
|
+
|
|
72
|
+
Nothing here can bite today. 133 warning(s), 0 reachable defects, 1 latent.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
133 warnings, ten seconds, and 29 of them are worth a person's time: the one latent
|
|
76
|
+
defect and the 28 that turn on a function the tool cannot see into. The other 104 need nobody.
|
|
77
|
+
|
|
78
|
+
## Why this exists
|
|
79
|
+
|
|
80
|
+
Both rules describe a shape, and the shape is not the bug.
|
|
81
|
+
|
|
82
|
+
**B023** flags every closure inside a loop that reads the loop variable. The bug it is looking for is
|
|
83
|
+
late binding: if the closure is still callable after the loop moves on, every copy sees the loop
|
|
84
|
+
variable's final value. A closure that is built and called inside the same iteration sees the value
|
|
85
|
+
it was written next to, which is what the author meant.
|
|
86
|
+
|
|
87
|
+
**B006** flags every mutable default argument. A mutable default is evaluated once and shared by
|
|
88
|
+
every call that omits it, but that only matters if the function changes it. A default that is only
|
|
89
|
+
read behaves exactly like the immutable one the author probably had in mind.
|
|
90
|
+
|
|
91
|
+
So a team switching these rules on faces a few hundred warnings, most of which are fine, with no way
|
|
92
|
+
to tell which is which except by reading all of them. That is the job this does.
|
|
93
|
+
|
|
94
|
+
## Install and run
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
pip install willitbite
|
|
98
|
+
willitbite ./src
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Python 3.10 or newer. To work from a checkout instead:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
git clone https://github.com/muhzuhaib/willitbite
|
|
105
|
+
pip install ./willitbite
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Ruff is called as a subprocess if it is on your PATH. If you would rather run your own:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
ruff check --select B006,B023 --output-format json ./src > warnings.json
|
|
112
|
+
willitbite --json warnings.json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Flag | Effect |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `--all` | list the safe warnings too, not just the actionable ones |
|
|
118
|
+
| `--json-out` | print results as JSON |
|
|
119
|
+
| `--exit-zero` | always exit 0, for a first run that should not fail a build |
|
|
120
|
+
|
|
121
|
+
Exit code is 1 when something can bite, 0 when nothing can, 2 when the tool could not run.
|
|
122
|
+
|
|
123
|
+
## Design decisions
|
|
124
|
+
|
|
125
|
+
**A linter has two states, warned and silent. This has four.** Two of the four exist because the
|
|
126
|
+
honest answer is sometimes neither of the other two.
|
|
127
|
+
|
|
128
|
+
`CALLEE` is for a closure handed to `run_with_retry(...)`, which is safe if that helper calls it and
|
|
129
|
+
dangerous if it stores it. Guessing safe would clear a real defect. Guessing unsafe would raise a
|
|
130
|
+
false alarm on every codebase this was built against. So it names the function you have to look at
|
|
131
|
+
and stops there.
|
|
132
|
+
|
|
133
|
+
`LATENT` is for a function that is genuinely wrong and that nothing currently calls in the way that
|
|
134
|
+
would hurt. It has its own section below.
|
|
135
|
+
|
|
136
|
+
**Ruff finds the candidates; this decides them.** Reimplementing the rules would be slower, less
|
|
137
|
+
correct, and would drift from ruff's behaviour. Ruff is invoked with `--isolated` on purpose, so the
|
|
138
|
+
answer does not depend on whether the project has enabled these rules: a team that has not adopted
|
|
139
|
+
them yet is exactly the team that wants this.
|
|
140
|
+
|
|
141
|
+
**Ruff is not a dependency.** A tool that reports on your linter should not pin a version of it.
|
|
142
|
+
|
|
143
|
+
**Every verdict carries a reason you can check without rerunning anything.** The reason names the
|
|
144
|
+
identifier and the line, never restates the rule. A verdict you have to take on trust is worth about
|
|
145
|
+
as much as the warning it replaced.
|
|
146
|
+
|
|
147
|
+
## Latent defects: real, but nothing calls them that way
|
|
148
|
+
|
|
149
|
+
A function that mutates its own mutable default is wrong on its own terms. Whether the wrongness can
|
|
150
|
+
reach you is a separate question, and it is answered in the callers: the shared default is only ever
|
|
151
|
+
the object being mutated when somebody omits the argument.
|
|
152
|
+
|
|
153
|
+
So B006 warnings that survive the first pass get a second one, across every `.py` file under the
|
|
154
|
+
path you gave. If some caller omits the argument, the verdict stays `BITES`. If every caller passes
|
|
155
|
+
it explicitly, the verdict becomes `LATENT`: still a defect, still reported, but nothing in the tree
|
|
156
|
+
triggers it today.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
B006 mutable default arguments
|
|
160
|
+
2 warning(s) 1 can bite 1 latent 0 depend on a callee 0 safe
|
|
161
|
+
[BITES] live.py:1
|
|
162
|
+
`cache` is mutated before it is rebound (cache[...] assigned on line 2),
|
|
163
|
+
so every call that omits it sees the previous call's changes
|
|
164
|
+
[LATENT] lib.py:1
|
|
165
|
+
`items` is mutated before it is rebound (items.append() on line 2), but all
|
|
166
|
+
2 call sites of `collect()` pass it explicitly, so the shared default is
|
|
167
|
+
never the one being changed
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Those two functions have the same shape. Only their callers differ.
|
|
171
|
+
|
|
172
|
+
**A latent defect does not fail the build.** The exit code answers "can this bite today", and this
|
|
173
|
+
one cannot, so failing on it would fail every build until somebody rewrote code that currently
|
|
174
|
+
works. It is in the report because it will bite the first caller who leaves the argument out.
|
|
175
|
+
|
|
176
|
+
## Known boundary: which callers get matched
|
|
177
|
+
|
|
178
|
+
Call sites are matched **by name**. Resolving `x.send()` to a definition properly needs type
|
|
179
|
+
inference, which this does not do, so a call counts whenever the called name matches, wherever it
|
|
180
|
+
appears. That over-matches, and the over-matching is deliberate: an unrelated `send` elsewhere can
|
|
181
|
+
only add an omission, and an omission is the answer that keeps the warning.
|
|
182
|
+
|
|
183
|
+
Three things count as no evidence at all, and each of them leaves a warning at `BITES`:
|
|
184
|
+
|
|
185
|
+
- a `**kwargs` splat at the call site, which might be carrying the argument
|
|
186
|
+
- a `*args` splat, which might be filling the position
|
|
187
|
+
- **no caller anywhere**, which usually means a public entry point called from outside the tree you
|
|
188
|
+
scanned, and is the case most likely to bite a stranger
|
|
189
|
+
|
|
190
|
+
The index is only built when something came back `BITES`, so a run that finds nothing reachable
|
|
191
|
+
never pays for the scan.
|
|
192
|
+
|
|
193
|
+
## Where this came from
|
|
194
|
+
|
|
195
|
+
It was written while triaging 138 ruff warnings across two large open-source Python codebases by
|
|
196
|
+
hand. All 99 loop-closure warnings turned out to be false alarms, as did every mutable-default
|
|
197
|
+
warning that was actually reachable. Reading them one at a time to learn that took most of a day,
|
|
198
|
+
which seemed like a poor way to spend the next one.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
willitbite/__init__.py,sha256=FUpJ5MNR6noX2iHBhedkhyY9nVUQpvGv_PrKJc8NWkw,91
|
|
2
|
+
willitbite/callsites.py,sha256=lMhP8WryZ_-VEW2PZ_UVDwXPrJMLGYfbPnCHNUrkS3k,6905
|
|
3
|
+
willitbite/cli.py,sha256=i7zcjFtCKQsRK8rcELpXK2jMQ74Af7KmyiBpONoAdwY,6424
|
|
4
|
+
willitbite/escape.py,sha256=z2pApKQafp--FblwzgSyEnNWl7sON3r6Mg9ONWA0nLU,7578
|
|
5
|
+
willitbite/mutation.py,sha256=RRboicC-T5m_gAcFKEi09o-biC1E2kvMYtMs3PJOyYk,9818
|
|
6
|
+
willitbite/ruffrun.py,sha256=jNzk6DVkhw7TcNmdLnvPeg5GQeA_ycn9dWf-ceygR3o,3322
|
|
7
|
+
willitbite/verdict.py,sha256=zjEnrwSa--SLcq3rnOlholue92R03uIFun3RFB9Dvcw,1667
|
|
8
|
+
willitbite-0.1.0.dist-info/METADATA,sha256=AMCHzdI17OTMZjqZkiUVMnPiRpOGdzymN-f_SmHEqtw,9487
|
|
9
|
+
willitbite-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
willitbite-0.1.0.dist-info/entry_points.txt,sha256=W0qIMfC-7u2cL8lG6wel2vQheL7kCUdr7T-h3bB1Xm4,51
|
|
11
|
+
willitbite-0.1.0.dist-info/licenses/LICENSE,sha256=18n8-_Lz8Si5JYibyhHrPuKT21C1_-muoU4gmhOWouc,1078
|
|
12
|
+
willitbite-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Zuhaib Zahid
|
|
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.
|