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/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Every local variable typed where it's first bound."""
|
|
3
|
+
|
|
4
|
+
from constricter.checker import (
|
|
5
|
+
COMMENT_TYPED_TARGET,
|
|
6
|
+
DEFAULT_CHECKS,
|
|
7
|
+
LEVELS,
|
|
8
|
+
NESTED_TYPE,
|
|
9
|
+
NESTING,
|
|
10
|
+
UNANNOTATED,
|
|
11
|
+
UNANNOTATED_MEMBER,
|
|
12
|
+
UNTYPED_TARGET,
|
|
13
|
+
VAGUE_TYPE,
|
|
14
|
+
Checks,
|
|
15
|
+
Coverage,
|
|
16
|
+
Level,
|
|
17
|
+
Offence,
|
|
18
|
+
annotation_coverage,
|
|
19
|
+
check_source,
|
|
20
|
+
check_tree,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.2"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"COMMENT_TYPED_TARGET",
|
|
26
|
+
"DEFAULT_CHECKS",
|
|
27
|
+
"LEVELS",
|
|
28
|
+
"NESTED_TYPE",
|
|
29
|
+
"NESTING",
|
|
30
|
+
"UNANNOTATED",
|
|
31
|
+
"UNANNOTATED_MEMBER",
|
|
32
|
+
"UNTYPED_TARGET",
|
|
33
|
+
"VAGUE_TYPE",
|
|
34
|
+
"Checks",
|
|
35
|
+
"Coverage",
|
|
36
|
+
"Level",
|
|
37
|
+
"Offence",
|
|
38
|
+
"__version__",
|
|
39
|
+
"annotation_coverage",
|
|
40
|
+
"check_source",
|
|
41
|
+
"check_tree",
|
|
42
|
+
]
|
constricter/__main__.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""What an annotation says: vague parts (LVA005), nesting depth (LVA006), and inferable values (`--fix`)."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Final, cast
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from types import EllipsisType
|
|
11
|
+
|
|
12
|
+
_VAGUE: Final = frozenset({"Any", "object"})
|
|
13
|
+
# Generics that say little without their parameters.
|
|
14
|
+
_GENERICS: Final = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"AbstractSet",
|
|
17
|
+
"AsyncGenerator",
|
|
18
|
+
"AsyncIterable",
|
|
19
|
+
"AsyncIterator",
|
|
20
|
+
"Awaitable",
|
|
21
|
+
"Callable",
|
|
22
|
+
"ChainMap",
|
|
23
|
+
"Collection",
|
|
24
|
+
"Container",
|
|
25
|
+
"Coroutine",
|
|
26
|
+
"Counter",
|
|
27
|
+
"DefaultDict",
|
|
28
|
+
"Deque",
|
|
29
|
+
"Dict",
|
|
30
|
+
"FrozenSet",
|
|
31
|
+
"Generator",
|
|
32
|
+
"ItemsView",
|
|
33
|
+
"Iterable",
|
|
34
|
+
"Iterator",
|
|
35
|
+
"KeysView",
|
|
36
|
+
"List",
|
|
37
|
+
"Mapping",
|
|
38
|
+
"Match",
|
|
39
|
+
"MutableMapping",
|
|
40
|
+
"MutableSequence",
|
|
41
|
+
"MutableSet",
|
|
42
|
+
"OrderedDict",
|
|
43
|
+
"Pattern",
|
|
44
|
+
"Reversible",
|
|
45
|
+
"Sequence",
|
|
46
|
+
"Set",
|
|
47
|
+
"Tuple",
|
|
48
|
+
"Type",
|
|
49
|
+
"ValuesView",
|
|
50
|
+
"defaultdict",
|
|
51
|
+
"deque",
|
|
52
|
+
"dict",
|
|
53
|
+
"frozenset",
|
|
54
|
+
"list",
|
|
55
|
+
"set",
|
|
56
|
+
"tuple",
|
|
57
|
+
"type",
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
# Calls that return a class or a special form, not an instance of what they're named.
|
|
61
|
+
_FACTORIES: Final = frozenset(
|
|
62
|
+
{
|
|
63
|
+
"Enum",
|
|
64
|
+
"Flag",
|
|
65
|
+
"IntEnum",
|
|
66
|
+
"IntFlag",
|
|
67
|
+
"NamedTuple",
|
|
68
|
+
"NewType",
|
|
69
|
+
"ParamSpec",
|
|
70
|
+
"StrEnum",
|
|
71
|
+
"TypeVar",
|
|
72
|
+
"TypeVarTuple",
|
|
73
|
+
"TypedDict",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
_NUMBERS: Final = (int, float, complex)
|
|
77
|
+
_TYPE_VARS: Final = frozenset({"TypeVar", "ParamSpec", "TypeVarTuple"})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parsed(annotation: ast.expr) -> ast.expr:
|
|
81
|
+
"""Unwrap a string annotation.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Its parsed expression, or the annotation itself if it isn't a string.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
88
|
+
try:
|
|
89
|
+
return ast.parse(annotation.value, mode="eval").body
|
|
90
|
+
except SyntaxError:
|
|
91
|
+
return annotation
|
|
92
|
+
return annotation
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _name(node: ast.AST) -> str:
|
|
96
|
+
name: str
|
|
97
|
+
match node:
|
|
98
|
+
case ast.Name(id=name) | ast.Attribute(attr=name):
|
|
99
|
+
return name
|
|
100
|
+
case _:
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_vague(annotation: ast.expr) -> bool:
|
|
105
|
+
"""Check an annotation for vague types.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Whether it has `Any`, `object` or a generic without its parameters in it.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
root: ast.expr = _parsed(annotation)
|
|
112
|
+
subscripted: set[int] = {id(node.value) for node in ast.walk(root) if isinstance(node, ast.Subscript)}
|
|
113
|
+
node: ast.AST
|
|
114
|
+
for node in ast.walk(root):
|
|
115
|
+
name: str = _name(node)
|
|
116
|
+
if name in _VAGUE or (name in _GENERICS and id(node) not in subscripted):
|
|
117
|
+
return True
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def depth(annotation: ast.expr) -> int:
|
|
122
|
+
"""Measure how deeply an annotation's subscripts nest.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The depth: `dict[str, list[int]]` is 2.
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
node: ast.expr = _parsed(annotation)
|
|
129
|
+
inner: ast.expr
|
|
130
|
+
parts: list[ast.expr]
|
|
131
|
+
left: ast.expr
|
|
132
|
+
right: ast.expr
|
|
133
|
+
match node:
|
|
134
|
+
case ast.Subscript(slice=inner):
|
|
135
|
+
return 1 + depth(inner)
|
|
136
|
+
case ast.Tuple(elts=parts) | ast.List(elts=parts):
|
|
137
|
+
return max((depth(part) for part in parts), default=0)
|
|
138
|
+
case ast.BinOp(left=left, right=right):
|
|
139
|
+
return max(depth(left), depth(right))
|
|
140
|
+
case _:
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def returns(tree: ast.Module) -> dict[str, str]:
|
|
145
|
+
"""Return the declared return type of each plain top-level function whose calls `--fix` can annotate.
|
|
146
|
+
|
|
147
|
+
Skipped: decorated, generic, async and redefined functions, and returns that are `None`, vague, or
|
|
148
|
+
mention a module-level `TypeVar` (a call's type then depends on its arguments).
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Each such function's name, and its return annotation as source text.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
type_vars: set[str] = set()
|
|
155
|
+
counts: dict[str, int] = {}
|
|
156
|
+
found: dict[str, str] = {}
|
|
157
|
+
stmt: ast.stmt
|
|
158
|
+
name: str
|
|
159
|
+
func: ast.expr
|
|
160
|
+
for stmt in tree.body:
|
|
161
|
+
match stmt:
|
|
162
|
+
case ast.Assign(targets=[ast.Name(id=name)], value=ast.Call(func=func)) if (
|
|
163
|
+
_name(func) in _TYPE_VARS
|
|
164
|
+
):
|
|
165
|
+
type_vars.add(name)
|
|
166
|
+
case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name):
|
|
167
|
+
counts[name] = counts.get(name, 0) + 1
|
|
168
|
+
if isinstance(stmt, ast.FunctionDef) and _plain(stmt):
|
|
169
|
+
found[name] = ast.unparse(cast("ast.expr", stmt.returns))
|
|
170
|
+
case _:
|
|
171
|
+
pass
|
|
172
|
+
return {
|
|
173
|
+
name: annotation
|
|
174
|
+
for name, annotation in found.items()
|
|
175
|
+
if counts[name] == 1 and not type_vars & set(_words(annotation))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _plain(func: ast.FunctionDef) -> bool:
|
|
180
|
+
"""Check that `func` declares a return type its calls always have.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Whether it does: not `None`, and not vague.
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
return (
|
|
187
|
+
not func.decorator_list
|
|
188
|
+
and not cast("object", getattr(func, "type_params", ())) # Python 3.12+'s `def f[T]()`
|
|
189
|
+
and func.returns is not None
|
|
190
|
+
and not (isinstance(func.returns, ast.Constant) and func.returns.value is None)
|
|
191
|
+
and not is_vague(func.returns)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _words(annotation: str) -> list[str]:
|
|
196
|
+
return [word for word in re.split(r"\W+", annotation) if word]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def inferred(value: ast.expr, calls: Mapping[str, str]) -> str | None:
|
|
200
|
+
"""Return the annotation `value` makes unambiguous, given the module's function `calls`.
|
|
201
|
+
|
|
202
|
+
A literal's type (containers too, when their elements agree), a call to a module function that
|
|
203
|
+
declares its return type, or a class it constructs.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The annotation as source text, or `None` if the value doesn't decide one.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
return _scalar(value) or _container(value, calls) or _called(value, calls)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _scalar(value: ast.expr) -> str | None:
|
|
213
|
+
constant: str | bytes | bool | int | float | complex | EllipsisType | None
|
|
214
|
+
match value:
|
|
215
|
+
case ast.Constant(value=bool() | int() | float() | complex() | str() | bytes() as constant):
|
|
216
|
+
return type(constant).__name__
|
|
217
|
+
case ast.UnaryOp(op=ast.USub() | ast.UAdd(), operand=ast.Constant(value=constant)) if isinstance(
|
|
218
|
+
constant,
|
|
219
|
+
_NUMBERS,
|
|
220
|
+
) and not isinstance(constant, bool):
|
|
221
|
+
return type(constant).__name__
|
|
222
|
+
case ast.JoinedStr():
|
|
223
|
+
return "str"
|
|
224
|
+
case _:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _container(value: ast.expr, calls: Mapping[str, str]) -> str | None:
|
|
229
|
+
elements: list[ast.expr]
|
|
230
|
+
keys: list[ast.expr | None]
|
|
231
|
+
values: list[ast.expr]
|
|
232
|
+
parts: list[str | None]
|
|
233
|
+
match value:
|
|
234
|
+
case ast.List(elts=elements) | ast.Set(elts=elements) if elements:
|
|
235
|
+
element: str | None = _uniform(elements, calls)
|
|
236
|
+
return f"{'list' if isinstance(value, ast.List) else 'set'}[{element}]" if element else None
|
|
237
|
+
case ast.Tuple(elts=elements) if elements:
|
|
238
|
+
parts = [inferred(element, calls) for element in elements]
|
|
239
|
+
return None if None in parts else f"tuple[{', '.join(str(part) for part in parts)}]"
|
|
240
|
+
case ast.Dict(keys=keys, values=values) if keys and None not in keys:
|
|
241
|
+
key: str | None = _uniform([k for k in keys if k is not None], calls)
|
|
242
|
+
item: str | None = _uniform(values, calls)
|
|
243
|
+
return f"dict[{key}, {item}]" if key and item else None
|
|
244
|
+
case _:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _uniform(elements: Sequence[ast.expr], calls: Mapping[str, str]) -> str | None:
|
|
249
|
+
"""Find the one type every element has.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
That type, or `None` if they differ or any is unknown.
|
|
253
|
+
|
|
254
|
+
"""
|
|
255
|
+
types: set[str | None] = {inferred(element, calls) for element in elements}
|
|
256
|
+
return next(iter(types)) if len(types) == 1 else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _called(value: ast.expr, calls: Mapping[str, str]) -> str | None:
|
|
260
|
+
func: ast.expr
|
|
261
|
+
match value:
|
|
262
|
+
case ast.Call(func=ast.Name() | ast.Attribute() as func) if ast.unparse(func) in calls:
|
|
263
|
+
return calls[ast.unparse(func)]
|
|
264
|
+
case ast.Call(func=ast.Name() | ast.Attribute() as func) if _constructs(_name(func)):
|
|
265
|
+
return ast.unparse(func)
|
|
266
|
+
case _:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def guessed(value: ast.expr, calls: Mapping[str, str]) -> bool:
|
|
271
|
+
"""Whether `inferred`'s annotation for `value` is a guess (`--unsafe-fixes`): it calls a class.
|
|
272
|
+
|
|
273
|
+
A capitalised call may construct a generic class (`Box(1)` is really `Box[int]`) or be a factory
|
|
274
|
+
function; literals and calls to module functions with a declared return type are certain.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Whether any call in `value` is to something other than such a module function.
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
return any(isinstance(node, ast.Call) and ast.unparse(node.func) not in calls for node in ast.walk(value))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _constructs(name: str) -> bool:
|
|
284
|
+
"""Check whether a call to `name` constructs a class, by its capitalised name.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Whether it does, and is worth annotating.
|
|
288
|
+
|
|
289
|
+
"""
|
|
290
|
+
return name[:1].isupper() and name not in _FACTORIES and name not in _GENERICS
|
constricter/baseline.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Baseline files: the offences a codebase already has, so only new ones are reported.
|
|
3
|
+
|
|
4
|
+
An entry is a file, a code and a variable name, with how many times it occurs; line numbers aren't
|
|
5
|
+
kept, so a baseline survives code moving around. Paths are relative to the baseline file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Final, TypeAlias, cast
|
|
14
|
+
|
|
15
|
+
from constricter import jsonc
|
|
16
|
+
from constricter.checker import Offence
|
|
17
|
+
|
|
18
|
+
VERSION: Final = 1
|
|
19
|
+
Entries: TypeAlias = dict[str, dict[str, int]] # file -> "CODE name" -> count
|
|
20
|
+
_Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def key(path: Path, baseline: Path) -> str:
|
|
24
|
+
"""Make `path` relative to the baseline at `baseline`.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
`path` as the baseline records it: relative to it, with `/`.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
return Path(os.path.relpath(path.resolve(), baseline.resolve().parent)).as_posix()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _entry(offence: Offence) -> str:
|
|
34
|
+
return f"{offence.code} {offence.name}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read(baseline: Path) -> Entries:
|
|
38
|
+
"""Return the entries in the baseline file `baseline`.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Each file's count of each `CODE name` entry.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: It can't be read, or isn't a baseline.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
message: str
|
|
48
|
+
document: _Json
|
|
49
|
+
try:
|
|
50
|
+
document = cast("_Json", jsonc.loads(baseline.read_bytes()))
|
|
51
|
+
except (OSError, ValueError) as error:
|
|
52
|
+
message = f"{baseline}: can't read the baseline ({error}); create it with --write-baseline"
|
|
53
|
+
raise ValueError(message) from error
|
|
54
|
+
files: dict[str, _Json]
|
|
55
|
+
entries: Entries = {}
|
|
56
|
+
match document:
|
|
57
|
+
case {"version": 1, "offences": dict() as files}:
|
|
58
|
+
path: str
|
|
59
|
+
counts: _Json
|
|
60
|
+
for path, counts in files.items():
|
|
61
|
+
if not isinstance(counts, dict) or not all(_is_count(n) for n in counts.values()):
|
|
62
|
+
break
|
|
63
|
+
entries[path] = {entry: n for entry, n in counts.items() if isinstance(n, int)}
|
|
64
|
+
else:
|
|
65
|
+
return entries
|
|
66
|
+
case _:
|
|
67
|
+
pass
|
|
68
|
+
message = f"{baseline}: not a constricter baseline (version {VERSION})"
|
|
69
|
+
raise ValueError(message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_count(value: _Json) -> bool:
|
|
73
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write(baseline: Path, found: Mapping[str, Sequence[Offence]]) -> int:
|
|
77
|
+
"""Write every offence in `found` (keyed as `key` makes them) to `baseline`.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
How many it wrote.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
files: Entries = {
|
|
84
|
+
path: dict(sorted(Counter(_entry(o) for o in offences).items()))
|
|
85
|
+
for path, offences in sorted(found.items())
|
|
86
|
+
if offences
|
|
87
|
+
}
|
|
88
|
+
_ = baseline.write_text(
|
|
89
|
+
json.dumps({"version": VERSION, "offences": files}, indent=2) + "\n",
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
newline="\n",
|
|
92
|
+
)
|
|
93
|
+
return sum(len(offences) for offences in found.values())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def remaining(offences: Sequence[Offence], counts: Mapping[str, int]) -> tuple[list[Offence], int]:
|
|
97
|
+
"""Match one file's offences against the baseline's `counts` for it.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The offences it doesn't cover, and how many it does.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
left: Counter[str] = Counter(counts)
|
|
104
|
+
kept: list[Offence] = []
|
|
105
|
+
o: Offence
|
|
106
|
+
for o in offences:
|
|
107
|
+
if left[_entry(o)] > 0:
|
|
108
|
+
left[_entry(o)] -= 1
|
|
109
|
+
else:
|
|
110
|
+
kept.append(o)
|
|
111
|
+
return kept, len(offences) - len(kept)
|