python-constricter 0.2.2__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.
- constricter/__init__.py +42 -0
- constricter/__main__.py +9 -0
- constricter/annotations.py +290 -0
- constricter/baseline.py +111 -0
- constricter/checker.py +651 -0
- constricter/cli.py +791 -0
- constricter/config.py +177 -0
- constricter/explain.py +61 -0
- constricter/fixes.py +22 -0
- constricter/flake8_plugin.py +90 -0
- constricter/jsonc.py +31 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +111 -0
- constricter/project.py +229 -0
- constricter/py.typed +0 -0
- constricter/pylint_plugin.py +127 -0
- constricter/report.py +252 -0
- python_constricter-0.2.2.dist-info/METADATA +456 -0
- python_constricter-0.2.2.dist-info/RECORD +22 -0
- python_constricter-0.2.2.dist-info/WHEEL +4 -0
- python_constricter-0.2.2.dist-info/entry_points.txt +6 -0
- python_constricter-0.2.2.dist-info/licenses/LICENSE.md +21 -0
constricter/checker.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The rules: every local variable is typed where it's first bound (see README)."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Final, NamedTuple, TypeAlias, cast
|
|
10
|
+
|
|
11
|
+
from constricter.annotations import depth, guessed, inferred, is_vague, returns
|
|
12
|
+
|
|
13
|
+
UNANNOTATED: Final = "LVA001"
|
|
14
|
+
UNTYPED_TARGET: Final = "LVA002"
|
|
15
|
+
COMMENT_TYPED_TARGET: Final = "LVA003"
|
|
16
|
+
UNANNOTATED_MEMBER: Final = "LVA004"
|
|
17
|
+
VAGUE_TYPE: Final = "LVA005"
|
|
18
|
+
NESTED_TYPE: Final = "LVA006"
|
|
19
|
+
MESSAGES: dict[str, str] = {
|
|
20
|
+
UNANNOTATED: "local variable {name} is not annotated where it's first bound",
|
|
21
|
+
UNTYPED_TARGET: "for/match variable {name} is untyped; declare it before the statement",
|
|
22
|
+
COMMENT_TYPED_TARGET: "for variable {name} is typed only by a type comment; declare it before the loop",
|
|
23
|
+
UNANNOTATED_MEMBER: "module or class variable {name} is not annotated where it's first bound",
|
|
24
|
+
VAGUE_TYPE: "the annotation of {name} is vague: Any, object, or a generic without its parameters",
|
|
25
|
+
NESTED_TYPE: "the annotation of {name} nests too deeply; name a part of it with a `type` alias",
|
|
26
|
+
}
|
|
27
|
+
NESTING: Final = 5 # LVA006's default depth
|
|
28
|
+
|
|
29
|
+
_FunctionDef: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef
|
|
30
|
+
_FUNCTION_DEFS: tuple[type[ast.FunctionDef], type[ast.AsyncFunctionDef]] = (
|
|
31
|
+
ast.FunctionDef,
|
|
32
|
+
ast.AsyncFunctionDef,
|
|
33
|
+
)
|
|
34
|
+
# The node class of `type X = ...` statements, by name: Python 3.11's `ast` has no `TypeAlias`.
|
|
35
|
+
_TYPE_ALIAS: Final = "TypeAlias"
|
|
36
|
+
_FUTURE: Final = "__future__"
|
|
37
|
+
# `from __future__` features only code that also runs on Python 2 imports: its type comments count.
|
|
38
|
+
_PYTHON2_FUTURES: frozenset[str] = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"nested_scopes",
|
|
41
|
+
"generators",
|
|
42
|
+
"division",
|
|
43
|
+
"absolute_import",
|
|
44
|
+
"with_statement",
|
|
45
|
+
"print_function",
|
|
46
|
+
"unicode_literals",
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Level(IntEnum):
|
|
52
|
+
"""How strict: each level makes one more code an error rather than a warning."""
|
|
53
|
+
|
|
54
|
+
RELAXED = 0
|
|
55
|
+
STRICT = 1
|
|
56
|
+
CONSTRICT = 2
|
|
57
|
+
SUFFOCATE = 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Each level by name and by number, as the options take it.
|
|
61
|
+
LEVELS: dict[str, Level] = {key: level for level in Level for key in (level.name.lower(), str(level.value))}
|
|
62
|
+
_ERROR_FROM: dict[str, Level] = {
|
|
63
|
+
UNANNOTATED: Level.STRICT,
|
|
64
|
+
UNTYPED_TARGET: Level.CONSTRICT,
|
|
65
|
+
COMMENT_TYPED_TARGET: Level.SUFFOCATE,
|
|
66
|
+
UNANNOTATED_MEMBER: Level.STRICT,
|
|
67
|
+
VAGUE_TYPE: Level.SUFFOCATE,
|
|
68
|
+
NESTED_TYPE: Level.SUFFOCATE,
|
|
69
|
+
}
|
|
70
|
+
# Codes reported only from a level up (the rest are reported at every level).
|
|
71
|
+
_REPORTED_FROM: dict[str, Level] = {VAGUE_TYPE: Level.STRICT, NESTED_TYPE: Level.STRICT}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class _Settings:
|
|
76
|
+
"""One module's options, and its source lines (to place a `**rest` capture)."""
|
|
77
|
+
|
|
78
|
+
type_comments: bool
|
|
79
|
+
all_scopes: bool
|
|
80
|
+
nesting: int
|
|
81
|
+
lines: Sequence[str]
|
|
82
|
+
calls: dict[str, str] # each module function's return type, for `--fix`
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, order=True)
|
|
86
|
+
class Offence:
|
|
87
|
+
"""One untyped first binding; `col` is 0-based."""
|
|
88
|
+
|
|
89
|
+
line: int
|
|
90
|
+
col: int
|
|
91
|
+
name: str
|
|
92
|
+
code: str = UNANNOTATED
|
|
93
|
+
# The annotation `--fix` would add, where the value makes it unambiguous.
|
|
94
|
+
fix: str | None = field(default=None, compare=False)
|
|
95
|
+
# In a notebook, the cell (from 1); `line` is then the line in that cell.
|
|
96
|
+
cell: int | None = field(default=None, compare=False)
|
|
97
|
+
# Whether `fix` is a guess, applied only with `--unsafe-fixes`.
|
|
98
|
+
unsafe: bool = field(default=False, compare=False)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def message(self) -> str:
|
|
102
|
+
"""The report text."""
|
|
103
|
+
return MESSAGES[self.code].format(name=repr(self.name))
|
|
104
|
+
|
|
105
|
+
def is_error(self, level: Level) -> bool:
|
|
106
|
+
"""Check this offence's severity at `level`.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Whether it's an error rather than a warning.
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
return level >= _ERROR_FROM[self.code]
|
|
113
|
+
|
|
114
|
+
def is_reported(self, level: Level) -> bool:
|
|
115
|
+
"""Check whether `level` reports this offence.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Whether it does at all.
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
return level >= _REPORTED_FROM.get(self.code, Level.RELAXED)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Checks(NamedTuple):
|
|
125
|
+
"""What to check, beyond the defaults.
|
|
126
|
+
|
|
127
|
+
With `type_comments`, `x = 1 # type: int` counts as annotated; with `all_scopes`, module and
|
|
128
|
+
class bodies are checked too (LVA004); an annotation nested `nesting` deep is LVA006.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
type_comments: bool = False
|
|
132
|
+
all_scopes: bool = False
|
|
133
|
+
nesting: int = NESTING
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
DEFAULT_CHECKS: Final = Checks()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_source(
|
|
140
|
+
source: str | bytes,
|
|
141
|
+
filename: str = "<unknown>",
|
|
142
|
+
checks: Checks = DEFAULT_CHECKS,
|
|
143
|
+
*,
|
|
144
|
+
calls: Mapping[str, str] | None = None,
|
|
145
|
+
) -> list[Offence]:
|
|
146
|
+
"""Return the offences in `source`, sorted. Raises `SyntaxError`.
|
|
147
|
+
|
|
148
|
+
`calls` adds the return types of functions other modules define, for `--fix` (see `project.calls`).
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Every offence; `# noqa` comments are the caller's to apply.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
tree: ast.Module = _parse(source, filename)
|
|
155
|
+
text: str = source.decode("utf-8") if isinstance(source, bytes) else source
|
|
156
|
+
return check_tree(tree, checks, lines=text.splitlines(), calls=calls)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse(source: str | bytes, filename: str) -> ast.Module:
|
|
160
|
+
"""Parse `source` with its `# type:` comments; without them if one is misplaced.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The module. Raises `SyntaxError`.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
return ast.parse(source, filename, type_comments=True)
|
|
168
|
+
except SyntaxError: # a misplaced `# type:` comment, or a real error raised again here
|
|
169
|
+
return ast.parse(source, filename)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_tree(
|
|
173
|
+
tree: ast.Module,
|
|
174
|
+
checks: Checks = DEFAULT_CHECKS,
|
|
175
|
+
*,
|
|
176
|
+
lines: Sequence[str] = (),
|
|
177
|
+
calls: Mapping[str, str] | None = None,
|
|
178
|
+
) -> list[Offence]:
|
|
179
|
+
"""Return the offences in a parsed module, sorted.
|
|
180
|
+
|
|
181
|
+
`# type:` comments are seen only if it was parsed with `type_comments=True`; they count for `=`
|
|
182
|
+
and `with` too in a module written to run on Python 2. With its source `lines`, a `**rest`
|
|
183
|
+
capture is reported at its name rather than at its pattern's start.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Every offence, in source order.
|
|
187
|
+
|
|
188
|
+
"""
|
|
189
|
+
settings: _Settings = _Settings(
|
|
190
|
+
checks.type_comments or _python2_compatible(tree),
|
|
191
|
+
checks.all_scopes,
|
|
192
|
+
checks.nesting,
|
|
193
|
+
lines,
|
|
194
|
+
{**(calls or {}), **returns(tree)},
|
|
195
|
+
)
|
|
196
|
+
return sorted(o for scope in _scopes(tree, settings) for o in scope.reported())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Coverage(NamedTuple):
|
|
200
|
+
"""How many of a module's typeable first bindings are typed, of how many."""
|
|
201
|
+
|
|
202
|
+
typed: int
|
|
203
|
+
total: int
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def percent(self) -> float:
|
|
207
|
+
"""The typed share, as a percentage (100 when there's nothing to type)."""
|
|
208
|
+
return 100 * self.typed / self.total if self.total else 100.0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# The codes that mean a binding has no type at all (LVA003's type comment is a type).
|
|
212
|
+
_UNTYPED: Final = frozenset({UNANNOTATED, UNTYPED_TARGET, UNANNOTATED_MEMBER})
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def annotation_coverage(source: str, checks: Checks = DEFAULT_CHECKS) -> Coverage:
|
|
216
|
+
"""Count the first bindings in `source` the rules cover, and how many are typed.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The counts; `# noqa` comments don't make a binding typed. Raises `SyntaxError`.
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
tree: ast.Module = _parse(source, "<unknown>")
|
|
223
|
+
settings: _Settings = _Settings(
|
|
224
|
+
checks.type_comments or _python2_compatible(tree),
|
|
225
|
+
checks.all_scopes,
|
|
226
|
+
checks.nesting,
|
|
227
|
+
source.splitlines(),
|
|
228
|
+
{},
|
|
229
|
+
)
|
|
230
|
+
scopes: list[_Scope] = _scopes(tree, settings)
|
|
231
|
+
total: int = sum(len(scope.bound()) for scope in scopes)
|
|
232
|
+
untyped: int = sum(o.code in _UNTYPED for scope in scopes for o in scope.reported())
|
|
233
|
+
return Coverage(total - untyped, total)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _scopes(tree: ast.Module, settings: _Settings) -> list["_Scope"]:
|
|
237
|
+
"""Collect the scopes to check.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Every function's scope, and with `all_scopes` every module and class body's.
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
functions: list[_FunctionDef] = []
|
|
244
|
+
_collect_functions(tree.body, functions)
|
|
245
|
+
scopes: list[_Scope] = _function_scopes(functions, settings)
|
|
246
|
+
if settings.all_scopes:
|
|
247
|
+
scopes += _body_scopes(tree, settings)
|
|
248
|
+
return scopes
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _python2_compatible(tree: ast.Module) -> bool:
|
|
252
|
+
"""Check for a `from __future__` import only Python 2 needs.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Whether it marks the module as written for Python 2.
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
return any(
|
|
259
|
+
isinstance(stmt, ast.ImportFrom)
|
|
260
|
+
and stmt.module == _FUTURE
|
|
261
|
+
and any(alias.name in _PYTHON2_FUTURES for alias in stmt.names)
|
|
262
|
+
for stmt in tree.body
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _body_scopes(tree: ast.Module, settings: _Settings) -> list["_Scope"]:
|
|
267
|
+
"""Collect the module and class bodies (LVA004).
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Their scopes, but for an enum's.
|
|
271
|
+
|
|
272
|
+
"""
|
|
273
|
+
classes: list[list[ast.stmt]] = [
|
|
274
|
+
node.body for node in ast.walk(tree) if isinstance(node, ast.ClassDef) and not _is_enum(node)
|
|
275
|
+
]
|
|
276
|
+
scopes: list[_Scope] = []
|
|
277
|
+
body: list[ast.stmt]
|
|
278
|
+
for body in (tree.body, *classes):
|
|
279
|
+
# A class body is never fixed: annotating a dataclass's variable makes it a field.
|
|
280
|
+
scope: _Scope = _Scope({"_"}, [], settings, unannotated=UNANNOTATED_MEMBER, fixable=body is tree.body)
|
|
281
|
+
stmt: ast.stmt
|
|
282
|
+
for stmt in body:
|
|
283
|
+
_visit(scope, stmt)
|
|
284
|
+
scopes.append(scope)
|
|
285
|
+
return scopes
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_enum(node: ast.ClassDef) -> bool:
|
|
289
|
+
"""Check whether a base is an enum: enum members mustn't be annotated.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Whether its name ends in `Enum` or `Flag`.
|
|
293
|
+
|
|
294
|
+
"""
|
|
295
|
+
base: ast.expr
|
|
296
|
+
name: str
|
|
297
|
+
for base in node.bases:
|
|
298
|
+
match base:
|
|
299
|
+
case ast.Name(id=name) | ast.Attribute(attr=name) if name.endswith(("Enum", "Flag")):
|
|
300
|
+
return True
|
|
301
|
+
case _:
|
|
302
|
+
pass
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _collect_functions(body: list[ast.stmt], into: list[_FunctionDef]) -> None:
|
|
307
|
+
"""Collect functions in a module or class body, through compound statements and classes."""
|
|
308
|
+
stmt: ast.stmt
|
|
309
|
+
for stmt in body:
|
|
310
|
+
if isinstance(stmt, _FUNCTION_DEFS):
|
|
311
|
+
into.append(stmt)
|
|
312
|
+
elif isinstance(stmt, ast.ClassDef):
|
|
313
|
+
_collect_functions(stmt.body, into)
|
|
314
|
+
else:
|
|
315
|
+
_collect_functions(_child_statements(stmt), into)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _child_statements(stmt: ast.stmt) -> list[ast.stmt]:
|
|
319
|
+
"""Collect the statements nested directly in `stmt`.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Them, in source order.
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
children: list[ast.stmt] = []
|
|
326
|
+
handler: ast.ExceptHandler
|
|
327
|
+
case: ast.match_case
|
|
328
|
+
match stmt:
|
|
329
|
+
case ast.If() | ast.For() | ast.AsyncFor() | ast.While():
|
|
330
|
+
children += stmt.body + stmt.orelse
|
|
331
|
+
case ast.With() | ast.AsyncWith():
|
|
332
|
+
children += stmt.body
|
|
333
|
+
case ast.Try() | ast.TryStar():
|
|
334
|
+
children += stmt.body
|
|
335
|
+
for handler in stmt.handlers:
|
|
336
|
+
children += handler.body
|
|
337
|
+
children += stmt.orelse + stmt.finalbody
|
|
338
|
+
case ast.Match():
|
|
339
|
+
for case in stmt.cases:
|
|
340
|
+
children += case.body
|
|
341
|
+
case _:
|
|
342
|
+
pass
|
|
343
|
+
return children
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _expressions(stmt: ast.stmt) -> Iterator[ast.AST]:
|
|
347
|
+
"""Walk the parts of `stmt` that aren't statements.
|
|
348
|
+
|
|
349
|
+
Yields:
|
|
350
|
+
Each, as where a `:=` can bind.
|
|
351
|
+
|
|
352
|
+
"""
|
|
353
|
+
child: ast.AST
|
|
354
|
+
for child in ast.iter_child_nodes(stmt):
|
|
355
|
+
if isinstance(child, ast.match_case | ast.ExceptHandler):
|
|
356
|
+
yield from (part for part in ast.iter_child_nodes(child) if not isinstance(part, ast.stmt))
|
|
357
|
+
elif not isinstance(child, ast.stmt):
|
|
358
|
+
yield child
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _names(target: ast.expr) -> Iterator[ast.Name]:
|
|
362
|
+
"""Walk an assignment target.
|
|
363
|
+
|
|
364
|
+
Yields:
|
|
365
|
+
Each plain name it binds.
|
|
366
|
+
|
|
367
|
+
"""
|
|
368
|
+
elements: list[ast.expr]
|
|
369
|
+
element: ast.expr
|
|
370
|
+
value: ast.expr
|
|
371
|
+
match target:
|
|
372
|
+
case ast.Name():
|
|
373
|
+
yield target
|
|
374
|
+
case ast.Tuple(elts=elements) | ast.List(elts=elements):
|
|
375
|
+
for element in elements:
|
|
376
|
+
yield from _names(element)
|
|
377
|
+
case ast.Starred(value=value):
|
|
378
|
+
yield from _names(value)
|
|
379
|
+
case _:
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _captures(pattern: ast.pattern, lines: Sequence[str]) -> Iterator[tuple[str, tuple[int, int]]]:
|
|
384
|
+
"""Walk a `case` pattern.
|
|
385
|
+
|
|
386
|
+
Yields:
|
|
387
|
+
Each name it captures, with where it's bound.
|
|
388
|
+
|
|
389
|
+
"""
|
|
390
|
+
node: ast.AST
|
|
391
|
+
name: str
|
|
392
|
+
for node in ast.walk(pattern):
|
|
393
|
+
match node:
|
|
394
|
+
case ast.MatchAs(name=str() as name) | ast.MatchStar(name=str() as name):
|
|
395
|
+
yield name, _at(node)
|
|
396
|
+
case ast.MatchMapping(rest=str() as name):
|
|
397
|
+
yield name, _rest_at(node, name, lines)
|
|
398
|
+
case _:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _at(node: ast.expr | ast.pattern) -> tuple[int, int]:
|
|
403
|
+
return node.lineno, node.col_offset
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _rest_at(node: ast.MatchMapping, name: str, lines: Sequence[str]) -> tuple[int, int]:
|
|
407
|
+
"""Find `**name` in a mapping pattern's source.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Its position (as `ast` gives it, a byte column), or else the pattern's start.
|
|
411
|
+
|
|
412
|
+
"""
|
|
413
|
+
rest: re.Pattern[bytes] = re.compile(rb"\*\*\s*(" + re.escape(name.encode()) + rb")\b")
|
|
414
|
+
number: int
|
|
415
|
+
found: re.Match[bytes] | None
|
|
416
|
+
for number in range(node.lineno, min(node.end_lineno or node.lineno, len(lines)) + 1):
|
|
417
|
+
if found := rest.search(lines[number - 1].encode(), node.col_offset if number == node.lineno else 0):
|
|
418
|
+
return number, found.start(1)
|
|
419
|
+
return _at(node)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class _Scope:
|
|
423
|
+
"""One function body: names bound so far and offences found."""
|
|
424
|
+
|
|
425
|
+
def __init__(
|
|
426
|
+
self,
|
|
427
|
+
declared: set[str],
|
|
428
|
+
nested: list[_FunctionDef],
|
|
429
|
+
settings: _Settings,
|
|
430
|
+
*,
|
|
431
|
+
unannotated: str = UNANNOTATED,
|
|
432
|
+
fixable: bool = True,
|
|
433
|
+
) -> None:
|
|
434
|
+
self.declared: set[str] = declared
|
|
435
|
+
self.nested: list[_FunctionDef] = nested
|
|
436
|
+
self.settings: _Settings = settings
|
|
437
|
+
self.unannotated_code: str = unannotated
|
|
438
|
+
self.fixable: bool = fixable
|
|
439
|
+
self.offences: list[Offence] = []
|
|
440
|
+
self.first: list[str] = [] # each first binding the rules cover, typed or not
|
|
441
|
+
|
|
442
|
+
def bind(
|
|
443
|
+
self,
|
|
444
|
+
name: str,
|
|
445
|
+
at: tuple[int, int],
|
|
446
|
+
code: str | None,
|
|
447
|
+
fix: str | None = None,
|
|
448
|
+
*,
|
|
449
|
+
unsafe: bool = False,
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Bind `name`; unless it's already bound, report `code` at `(line, col)` (`None`: typed)."""
|
|
452
|
+
if name not in self.declared:
|
|
453
|
+
self.declared.add(name)
|
|
454
|
+
self.first.append(name)
|
|
455
|
+
if code is not None:
|
|
456
|
+
self.offences.append(Offence(*at, name, code, fix if self.fixable else None, unsafe=unsafe))
|
|
457
|
+
|
|
458
|
+
def declare(self, name: str) -> None:
|
|
459
|
+
"""Bind `name` by an annotation (`name: T`, `name: T = ...`): a typed first binding."""
|
|
460
|
+
if name not in self.declared:
|
|
461
|
+
self.declared.add(name)
|
|
462
|
+
self.first.append(name)
|
|
463
|
+
|
|
464
|
+
def _covered(self, name: str) -> bool:
|
|
465
|
+
"""Check whether the rules cover `name` here.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Whether they do; a module or class body's dunder names are exempt.
|
|
469
|
+
|
|
470
|
+
"""
|
|
471
|
+
return self.unannotated_code != UNANNOTATED_MEMBER or not (
|
|
472
|
+
name.startswith("__") and name.endswith("__")
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def reported(self) -> list[Offence]:
|
|
476
|
+
"""Filter the offences found.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
All but those for exempt names.
|
|
480
|
+
|
|
481
|
+
"""
|
|
482
|
+
return [o for o in self.offences if self._covered(o.name)]
|
|
483
|
+
|
|
484
|
+
def bound(self) -> list[str]:
|
|
485
|
+
"""List the first bindings the rules cover.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Their names, typed or not.
|
|
489
|
+
|
|
490
|
+
"""
|
|
491
|
+
return [name for name in self.first if self._covered(name)]
|
|
492
|
+
|
|
493
|
+
def annotation(self, name: str, annotation: ast.expr) -> None:
|
|
494
|
+
"""Report an annotation that's vague (LVA005) or nests too deeply (LVA006)."""
|
|
495
|
+
if is_vague(annotation):
|
|
496
|
+
self.offences.append(Offence(*_at(annotation), name, VAGUE_TYPE))
|
|
497
|
+
if depth(annotation) >= self.settings.nesting:
|
|
498
|
+
self.offences.append(Offence(*_at(annotation), name, NESTED_TYPE))
|
|
499
|
+
|
|
500
|
+
def walrus(self, node: ast.AST) -> None:
|
|
501
|
+
"""Bind `:=` targets in an expression, comprehensions included, lambdas excluded."""
|
|
502
|
+
in_lambda: set[int] = {
|
|
503
|
+
id(inner)
|
|
504
|
+
for outer in ast.walk(node)
|
|
505
|
+
if isinstance(outer, ast.Lambda)
|
|
506
|
+
for inner in ast.walk(outer)
|
|
507
|
+
}
|
|
508
|
+
current: ast.AST
|
|
509
|
+
for current in ast.walk(node):
|
|
510
|
+
if isinstance(current, ast.NamedExpr) and id(current) not in in_lambda:
|
|
511
|
+
self.bind(current.target.id, _at(current.target), self.unannotated_code)
|
|
512
|
+
|
|
513
|
+
def unannotated(self, type_comment: str | None) -> str | None:
|
|
514
|
+
"""Decide the code for an `=` or `with` binding.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
The code, or `None` if a counted type comment types it.
|
|
518
|
+
|
|
519
|
+
"""
|
|
520
|
+
return None if type_comment is not None and self.settings.type_comments else self.unannotated_code
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _function_scopes(functions: list[_FunctionDef], settings: _Settings) -> list["_Scope"]:
|
|
524
|
+
"""Check `functions` and every function defined inside them.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Their scopes.
|
|
528
|
+
|
|
529
|
+
"""
|
|
530
|
+
scopes: list[_Scope] = []
|
|
531
|
+
func: _FunctionDef
|
|
532
|
+
for func in functions:
|
|
533
|
+
nested: list[_FunctionDef] = []
|
|
534
|
+
scopes.append(_function_scope(func, nested, settings))
|
|
535
|
+
scopes += _function_scopes(nested, settings)
|
|
536
|
+
return scopes
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _function_scope(func: _FunctionDef, functions: list[_FunctionDef], settings: _Settings) -> "_Scope":
|
|
540
|
+
"""Check one function; functions defined in it are collected into `functions`.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Its scope.
|
|
544
|
+
|
|
545
|
+
"""
|
|
546
|
+
args: ast.arguments = func.args
|
|
547
|
+
params: set[str] = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)}
|
|
548
|
+
params.update(extra.arg for extra in (args.vararg, args.kwarg) if extra is not None)
|
|
549
|
+
# `_` is a discard.
|
|
550
|
+
scope: _Scope = _Scope(params | {"_"}, functions, settings)
|
|
551
|
+
stmt: ast.stmt
|
|
552
|
+
for stmt in func.body:
|
|
553
|
+
_visit(scope, stmt)
|
|
554
|
+
return scope
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _visit(scope: _Scope, stmt: ast.stmt) -> None:
|
|
558
|
+
"""Bind the names `stmt` binds, as Python would, then visit its nested statements."""
|
|
559
|
+
part: ast.AST
|
|
560
|
+
for part in _expressions(stmt):
|
|
561
|
+
scope.walrus(part)
|
|
562
|
+
_declare(scope, stmt)
|
|
563
|
+
_bind(scope, stmt)
|
|
564
|
+
child: ast.stmt
|
|
565
|
+
for child in _child_statements(stmt):
|
|
566
|
+
_visit(scope, child)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _declare(scope: _Scope, stmt: ast.stmt) -> None:
|
|
570
|
+
"""Bind the names `stmt` binds that need no annotation, or carry their own."""
|
|
571
|
+
aliases: list[ast.alias]
|
|
572
|
+
names: list[str]
|
|
573
|
+
name: str
|
|
574
|
+
annotation: ast.expr
|
|
575
|
+
handlers: list[ast.ExceptHandler]
|
|
576
|
+
match stmt:
|
|
577
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
|
578
|
+
scope.declared.add(stmt.name)
|
|
579
|
+
scope.nested.append(stmt) # its body is its own scope
|
|
580
|
+
case ast.ClassDef():
|
|
581
|
+
scope.declared.add(stmt.name)
|
|
582
|
+
_collect_functions(stmt.body, scope.nested) # methods of a class defined in a function
|
|
583
|
+
case ast.Import(names=aliases) | ast.ImportFrom(names=aliases):
|
|
584
|
+
scope.declared.update((alias.asname or alias.name).split(".")[0] for alias in aliases)
|
|
585
|
+
case ast.Global(names=names) | ast.Nonlocal(names=names):
|
|
586
|
+
scope.declared.update(names)
|
|
587
|
+
case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation):
|
|
588
|
+
scope.declare(name)
|
|
589
|
+
scope.annotation(name, annotation)
|
|
590
|
+
case _ if type(stmt).__name__ == _TYPE_ALIAS:
|
|
591
|
+
alias: ast.expr = cast("ast.expr", next(ast.iter_child_nodes(stmt))) # its first field, the name
|
|
592
|
+
scope.declared.update(name.id for name in _names(alias))
|
|
593
|
+
case ast.Try(handlers=handlers) | ast.TryStar(handlers=handlers):
|
|
594
|
+
scope.declared.update(handler.name for handler in handlers if handler.name)
|
|
595
|
+
case _:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _bind(scope: _Scope, stmt: ast.stmt) -> None:
|
|
600
|
+
"""Bind the names `stmt` binds that need typing, reporting the untyped ones."""
|
|
601
|
+
targets: list[ast.expr]
|
|
602
|
+
target: ast.expr
|
|
603
|
+
items: list[ast.withitem]
|
|
604
|
+
comment: str | None
|
|
605
|
+
name: str
|
|
606
|
+
single: ast.Name
|
|
607
|
+
value: ast.expr
|
|
608
|
+
cases: list[ast.match_case]
|
|
609
|
+
match stmt:
|
|
610
|
+
case ast.Assign(targets=[ast.Name(id=name) as single], value=value, type_comment=comment):
|
|
611
|
+
calls: dict[str, str] = scope.settings.calls
|
|
612
|
+
scope.bind(
|
|
613
|
+
name,
|
|
614
|
+
_at(single),
|
|
615
|
+
scope.unannotated(comment),
|
|
616
|
+
inferred(value, calls),
|
|
617
|
+
unsafe=guessed(value, calls),
|
|
618
|
+
)
|
|
619
|
+
case ast.Assign(targets=targets, type_comment=comment):
|
|
620
|
+
_bind_targets(scope, targets, scope.unannotated(comment))
|
|
621
|
+
case ast.With(items=items, type_comment=comment) | ast.AsyncWith(items=items, type_comment=comment):
|
|
622
|
+
_bind_targets(
|
|
623
|
+
scope,
|
|
624
|
+
[i.optional_vars for i in items if i.optional_vars],
|
|
625
|
+
scope.unannotated(comment),
|
|
626
|
+
)
|
|
627
|
+
case ast.For(target=target, type_comment=comment) | ast.AsyncFor(target=target, type_comment=comment):
|
|
628
|
+
_bind_targets(scope, [target], UNTYPED_TARGET if comment is None else COMMENT_TYPED_TARGET)
|
|
629
|
+
case ast.Match(cases=cases):
|
|
630
|
+
_bind_captures(scope, cases)
|
|
631
|
+
case _:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _bind_targets(scope: _Scope, targets: list[ast.expr], code: str | None) -> None:
|
|
636
|
+
"""Bind every name in `targets`, reporting `code` for each first binding."""
|
|
637
|
+
target: ast.expr
|
|
638
|
+
name: ast.Name
|
|
639
|
+
for target in targets:
|
|
640
|
+
for name in _names(target):
|
|
641
|
+
scope.bind(name.id, _at(name), code)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _bind_captures(scope: _Scope, cases: list[ast.match_case]) -> None:
|
|
645
|
+
"""Bind every name the `case` patterns capture: LVA002 unless declared first."""
|
|
646
|
+
case: ast.match_case
|
|
647
|
+
name: str
|
|
648
|
+
at: tuple[int, int]
|
|
649
|
+
for case in cases:
|
|
650
|
+
for name, at in _captures(case.pattern, scope.settings.lines):
|
|
651
|
+
scope.bind(name, at, UNTYPED_TARGET)
|