python-constricter 0.2.2__py3-none-any.whl → 0.2.3__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 +3 -1
- constricter/annotations.py +386 -23
- constricter/baseline.py +1 -5
- constricter/checker.py +220 -47
- constricter/cli.py +122 -56
- constricter/config.py +19 -9
- constricter/explain.py +7 -0
- constricter/fixes.py +7 -1
- constricter/jsonc.py +22 -3
- constricter/notebook.py +7 -2
- constricter/project.py +35 -10
- constricter/pylint_plugin.py +5 -2
- {python_constricter-0.2.2.dist-info → python_constricter-0.2.3.dist-info}/METADATA +176 -26
- python_constricter-0.2.3.dist-info/RECORD +22 -0
- python_constricter-0.2.2.dist-info/RECORD +0 -22
- {python_constricter-0.2.2.dist-info → python_constricter-0.2.3.dist-info}/WHEEL +0 -0
- {python_constricter-0.2.2.dist-info → python_constricter-0.2.3.dist-info}/entry_points.txt +0 -0
- {python_constricter-0.2.2.dist-info → python_constricter-0.2.3.dist-info}/licenses/LICENSE.md +0 -0
constricter/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ from constricter.checker import (
|
|
|
7
7
|
LEVELS,
|
|
8
8
|
NESTED_TYPE,
|
|
9
9
|
NESTING,
|
|
10
|
+
REDUNDANT_TYPE,
|
|
10
11
|
UNANNOTATED,
|
|
11
12
|
UNANNOTATED_MEMBER,
|
|
12
13
|
UNTYPED_TARGET,
|
|
@@ -20,13 +21,14 @@ from constricter.checker import (
|
|
|
20
21
|
check_tree,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
|
-
__version__ = "0.2.
|
|
24
|
+
__version__ = "0.2.3"
|
|
24
25
|
__all__ = [
|
|
25
26
|
"COMMENT_TYPED_TARGET",
|
|
26
27
|
"DEFAULT_CHECKS",
|
|
27
28
|
"LEVELS",
|
|
28
29
|
"NESTED_TYPE",
|
|
29
30
|
"NESTING",
|
|
31
|
+
"REDUNDANT_TYPE",
|
|
30
32
|
"UNANNOTATED",
|
|
31
33
|
"UNANNOTATED_MEMBER",
|
|
32
34
|
"UNTYPED_TARGET",
|
constricter/annotations.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import ast
|
|
5
5
|
import re
|
|
6
|
-
from collections.abc import Mapping, Sequence
|
|
6
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
7
|
+
from functools import lru_cache
|
|
7
8
|
from typing import TYPE_CHECKING, Final, cast
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
@@ -75,11 +76,226 @@ _FACTORIES: Final = frozenset(
|
|
|
75
76
|
)
|
|
76
77
|
_NUMBERS: Final = (int, float, complex)
|
|
77
78
|
_TYPE_VARS: Final = frozenset({"TypeVar", "ParamSpec", "TypeVarTuple"})
|
|
79
|
+
# Builtins whose return type is fixed by the language, whatever their argument: safe to infer, not
|
|
80
|
+
# a guess (unlike a capitalised call, which could really be a generic class or a factory function).
|
|
81
|
+
_BUILTIN_RETURNS: Final = {
|
|
82
|
+
"bool": "bool",
|
|
83
|
+
"bytes": "bytes",
|
|
84
|
+
"callable": "bool",
|
|
85
|
+
"chr": "str",
|
|
86
|
+
"complex": "complex",
|
|
87
|
+
"float": "float",
|
|
88
|
+
"hasattr": "bool",
|
|
89
|
+
"hash": "int",
|
|
90
|
+
"id": "int",
|
|
91
|
+
"int": "int",
|
|
92
|
+
"isinstance": "bool",
|
|
93
|
+
"issubclass": "bool",
|
|
94
|
+
"len": "int",
|
|
95
|
+
"ord": "int",
|
|
96
|
+
"repr": "str",
|
|
97
|
+
"str": "str",
|
|
98
|
+
}
|
|
99
|
+
# Modules `_FACTORIES`' names are imported from (so an aliased or re-exported import is still found).
|
|
100
|
+
_FACTORY_MODULES: Final = frozenset({"enum", "typing", "typing_extensions"})
|
|
101
|
+
# `str`/`bytes` methods whose return type is fixed by the language, whatever their arguments: safe
|
|
102
|
+
# to infer for a call on an already-typed local, not a guess.
|
|
103
|
+
_STR_METHODS: Final = {
|
|
104
|
+
"capitalize": "str",
|
|
105
|
+
"casefold": "str",
|
|
106
|
+
"center": "str",
|
|
107
|
+
"count": "int",
|
|
108
|
+
"encode": "bytes",
|
|
109
|
+
"endswith": "bool",
|
|
110
|
+
"expandtabs": "str",
|
|
111
|
+
"find": "int",
|
|
112
|
+
"format": "str",
|
|
113
|
+
"format_map": "str",
|
|
114
|
+
"index": "int",
|
|
115
|
+
"isalnum": "bool",
|
|
116
|
+
"isalpha": "bool",
|
|
117
|
+
"isascii": "bool",
|
|
118
|
+
"isdecimal": "bool",
|
|
119
|
+
"isdigit": "bool",
|
|
120
|
+
"isidentifier": "bool",
|
|
121
|
+
"islower": "bool",
|
|
122
|
+
"isnumeric": "bool",
|
|
123
|
+
"isprintable": "bool",
|
|
124
|
+
"isspace": "bool",
|
|
125
|
+
"istitle": "bool",
|
|
126
|
+
"isupper": "bool",
|
|
127
|
+
"join": "str",
|
|
128
|
+
"ljust": "str",
|
|
129
|
+
"lower": "str",
|
|
130
|
+
"lstrip": "str",
|
|
131
|
+
"removeprefix": "str",
|
|
132
|
+
"removesuffix": "str",
|
|
133
|
+
"replace": "str",
|
|
134
|
+
"rfind": "int",
|
|
135
|
+
"rindex": "int",
|
|
136
|
+
"rjust": "str",
|
|
137
|
+
"rsplit": "list[str]",
|
|
138
|
+
"rstrip": "str",
|
|
139
|
+
"split": "list[str]",
|
|
140
|
+
"splitlines": "list[str]",
|
|
141
|
+
"startswith": "bool",
|
|
142
|
+
"strip": "str",
|
|
143
|
+
"swapcase": "str",
|
|
144
|
+
"title": "str",
|
|
145
|
+
"translate": "str",
|
|
146
|
+
"upper": "str",
|
|
147
|
+
"zfill": "str",
|
|
148
|
+
}
|
|
149
|
+
_BYTES_METHODS: Final = {
|
|
150
|
+
"capitalize": "bytes",
|
|
151
|
+
"center": "bytes",
|
|
152
|
+
"count": "int",
|
|
153
|
+
"decode": "str",
|
|
154
|
+
"endswith": "bool",
|
|
155
|
+
"expandtabs": "bytes",
|
|
156
|
+
"find": "int",
|
|
157
|
+
"hex": "str",
|
|
158
|
+
"index": "int",
|
|
159
|
+
"isalnum": "bool",
|
|
160
|
+
"isalpha": "bool",
|
|
161
|
+
"isascii": "bool",
|
|
162
|
+
"isdigit": "bool",
|
|
163
|
+
"islower": "bool",
|
|
164
|
+
"isspace": "bool",
|
|
165
|
+
"istitle": "bool",
|
|
166
|
+
"isupper": "bool",
|
|
167
|
+
"join": "bytes",
|
|
168
|
+
"ljust": "bytes",
|
|
169
|
+
"lower": "bytes",
|
|
170
|
+
"lstrip": "bytes",
|
|
171
|
+
"removeprefix": "bytes",
|
|
172
|
+
"removesuffix": "bytes",
|
|
173
|
+
"replace": "bytes",
|
|
174
|
+
"rfind": "int",
|
|
175
|
+
"rindex": "int",
|
|
176
|
+
"rjust": "bytes",
|
|
177
|
+
"rsplit": "list[bytes]",
|
|
178
|
+
"rstrip": "bytes",
|
|
179
|
+
"split": "list[bytes]",
|
|
180
|
+
"splitlines": "list[bytes]",
|
|
181
|
+
"startswith": "bool",
|
|
182
|
+
"strip": "bytes",
|
|
183
|
+
"swapcase": "bytes",
|
|
184
|
+
"title": "bytes",
|
|
185
|
+
"translate": "bytes",
|
|
186
|
+
"upper": "bytes",
|
|
187
|
+
"zfill": "bytes",
|
|
188
|
+
}
|
|
189
|
+
_METHOD_RETURNS: Final = {"str": _STR_METHODS, "bytes": _BYTES_METHODS}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _method_return(receiver: str, method: str) -> str | None:
|
|
193
|
+
"""Look up `method`'s return type on a receiver whose own type is `receiver`, as text.
|
|
78
194
|
|
|
195
|
+
Returns:
|
|
196
|
+
The annotation as source text, or `None` if `receiver` isn't `str`/`bytes`, or `method` isn't
|
|
197
|
+
one of `_METHOD_RETURNS`'.
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
return _METHOD_RETURNS.get(receiver, {}).get(method)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def imported_from(tree: ast.Module, modules: frozenset[str]) -> frozenset[str]:
|
|
204
|
+
"""Find the top-level names this module imports (`from module import name`) from one of `modules`.
|
|
205
|
+
|
|
206
|
+
Only absolute imports are resolved; a relative one (`from . import x`) names no module here.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Those names, as they're bound here (their alias, if importing gave them one).
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
names: set[str] = set()
|
|
213
|
+
stmt: ast.stmt
|
|
214
|
+
module: str
|
|
215
|
+
for stmt in tree.body:
|
|
216
|
+
match stmt:
|
|
217
|
+
case ast.ImportFrom(module=str() as module, level=0) if module in modules:
|
|
218
|
+
names.update(alias.asname or alias.name for alias in stmt.names)
|
|
219
|
+
case _:
|
|
220
|
+
pass
|
|
221
|
+
return frozenset(names)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def factories(tree: ast.Module) -> frozenset[str]:
|
|
225
|
+
"""Find names this module imports that are known to build a class or special form.
|
|
226
|
+
|
|
227
|
+
Not an instance of what they're named (`Enum`, `NamedTuple`, `TypeVar`, ... from `enum`,
|
|
228
|
+
`typing` or `typing_extensions`), however they're aliased.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Those names, as they're bound here.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
return imported_from(tree, _FACTORY_MODULES)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def classes(tree: ast.Module) -> dict[str, dict[str, str]]:
|
|
238
|
+
"""Map each class defined in the module to its annotated attributes.
|
|
239
|
+
|
|
240
|
+
A class-body annotation (`class C: x: int`) and a `self.x: int = ...` annotated assignment
|
|
241
|
+
anywhere in one of its methods both count; a name that names more than one class in the module
|
|
242
|
+
(however unlikely) gets the last one's attributes.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Each class's name, mapped to its attributes' names and annotation text.
|
|
246
|
+
|
|
247
|
+
"""
|
|
248
|
+
found: dict[str, dict[str, str]] = {}
|
|
249
|
+
node: ast.AST
|
|
250
|
+
for node in ast.walk(tree):
|
|
251
|
+
if isinstance(node, ast.ClassDef):
|
|
252
|
+
found[node.name] = _attributes(node)
|
|
253
|
+
return found
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _attributes(node: ast.ClassDef) -> dict[str, str]:
|
|
257
|
+
attrs: dict[str, str] = {}
|
|
258
|
+
stmt: ast.stmt
|
|
259
|
+
name: str
|
|
260
|
+
annotation: ast.expr
|
|
261
|
+
for stmt in node.body:
|
|
262
|
+
match stmt:
|
|
263
|
+
case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation):
|
|
264
|
+
attrs[name] = ast.unparse(annotation)
|
|
265
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
|
266
|
+
attrs.update(_self_attributes(stmt))
|
|
267
|
+
case _:
|
|
268
|
+
pass
|
|
269
|
+
return attrs
|
|
79
270
|
|
|
271
|
+
|
|
272
|
+
def _self_attributes(func: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[tuple[str, str]]:
|
|
273
|
+
"""Find `self.attr: T = ...` annotated assignments anywhere in a method's body.
|
|
274
|
+
|
|
275
|
+
Yields:
|
|
276
|
+
Each attribute's name and annotation text.
|
|
277
|
+
|
|
278
|
+
"""
|
|
279
|
+
node: ast.AST
|
|
280
|
+
name: str
|
|
281
|
+
annotation: ast.expr
|
|
282
|
+
for node in ast.walk(func):
|
|
283
|
+
match node:
|
|
284
|
+
case ast.AnnAssign(
|
|
285
|
+
target=ast.Attribute(value=ast.Name(id="self"), attr=name),
|
|
286
|
+
annotation=annotation,
|
|
287
|
+
):
|
|
288
|
+
yield name, ast.unparse(annotation)
|
|
289
|
+
case _:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@lru_cache(maxsize=256)
|
|
80
294
|
def _parsed(annotation: ast.expr) -> ast.expr:
|
|
81
295
|
"""Unwrap a string annotation.
|
|
82
296
|
|
|
297
|
+
`is_vague` and `depth` both call this on the same annotation; cached so it's parsed once.
|
|
298
|
+
|
|
83
299
|
Returns:
|
|
84
300
|
Its parsed expression, or the annotation itself if it isn't a string.
|
|
85
301
|
|
|
@@ -92,7 +308,13 @@ def _parsed(annotation: ast.expr) -> ast.expr:
|
|
|
92
308
|
return annotation
|
|
93
309
|
|
|
94
310
|
|
|
95
|
-
def
|
|
311
|
+
def node_name(node: ast.AST) -> str:
|
|
312
|
+
"""Read a `Name`'s or `Attribute`'s simple name.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
It, or `""` if `node` is neither.
|
|
316
|
+
|
|
317
|
+
"""
|
|
96
318
|
name: str
|
|
97
319
|
match node:
|
|
98
320
|
case ast.Name(id=name) | ast.Attribute(attr=name):
|
|
@@ -112,7 +334,7 @@ def is_vague(annotation: ast.expr) -> bool:
|
|
|
112
334
|
subscripted: set[int] = {id(node.value) for node in ast.walk(root) if isinstance(node, ast.Subscript)}
|
|
113
335
|
node: ast.AST
|
|
114
336
|
for node in ast.walk(root):
|
|
115
|
-
name: str =
|
|
337
|
+
name: str = node_name(node)
|
|
116
338
|
if name in _VAGUE or (name in _GENERICS and id(node) not in subscripted):
|
|
117
339
|
return True
|
|
118
340
|
return False
|
|
@@ -160,7 +382,7 @@ def returns(tree: ast.Module) -> dict[str, str]:
|
|
|
160
382
|
for stmt in tree.body:
|
|
161
383
|
match stmt:
|
|
162
384
|
case ast.Assign(targets=[ast.Name(id=name)], value=ast.Call(func=func)) if (
|
|
163
|
-
|
|
385
|
+
node_name(func) in _TYPE_VARS
|
|
164
386
|
):
|
|
165
387
|
type_vars.add(name)
|
|
166
388
|
case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name):
|
|
@@ -196,17 +418,56 @@ def _words(annotation: str) -> list[str]:
|
|
|
196
418
|
return [word for word in re.split(r"\W+", annotation) if word]
|
|
197
419
|
|
|
198
420
|
|
|
199
|
-
def inferred(
|
|
421
|
+
def inferred(
|
|
422
|
+
value: ast.expr,
|
|
423
|
+
calls: Mapping[str, str],
|
|
424
|
+
known_factories: frozenset[str],
|
|
425
|
+
declared: Mapping[str, str],
|
|
426
|
+
known_classes: Mapping[str, Mapping[str, str]],
|
|
427
|
+
) -> str | None:
|
|
200
428
|
"""Return the annotation `value` makes unambiguous, given the module's function `calls`.
|
|
201
429
|
|
|
202
430
|
A literal's type (containers too, when their elements agree), a call to a module function that
|
|
203
|
-
declares its return type,
|
|
431
|
+
declares its return type, a class it constructs, or (`declared`) another local this scope
|
|
432
|
+
already gave a type: a plain copy, a subscript of a known container, or (`known_classes`, see
|
|
433
|
+
`classes`) an attribute of a class defined in this module. `known_factories` (see `factories`)
|
|
434
|
+
are calls that build a class or special form rather than an instance of it, so they're never
|
|
435
|
+
guessed to construct one.
|
|
204
436
|
|
|
205
437
|
Returns:
|
|
206
438
|
The annotation as source text, or `None` if the value doesn't decide one.
|
|
207
439
|
|
|
208
440
|
"""
|
|
209
|
-
|
|
441
|
+
if isinstance(value, ast.Name) and value.id in declared:
|
|
442
|
+
return declared[value.id]
|
|
443
|
+
found: str | None
|
|
444
|
+
if (
|
|
445
|
+
isinstance(value, ast.Subscript)
|
|
446
|
+
and isinstance(value.value, ast.Name)
|
|
447
|
+
and value.value.id in declared
|
|
448
|
+
and (found := _subscripted(declared[value.value.id], value)) is not None
|
|
449
|
+
):
|
|
450
|
+
return found
|
|
451
|
+
if (
|
|
452
|
+
isinstance(value, ast.Attribute)
|
|
453
|
+
and isinstance(value.value, ast.Name)
|
|
454
|
+
and value.value.id in declared
|
|
455
|
+
and (found := known_classes.get(declared[value.value.id], {}).get(value.attr)) is not None
|
|
456
|
+
):
|
|
457
|
+
return found
|
|
458
|
+
if (
|
|
459
|
+
isinstance(value, ast.Call)
|
|
460
|
+
and isinstance(value.func, ast.Attribute)
|
|
461
|
+
and isinstance(value.func.value, ast.Name)
|
|
462
|
+
and value.func.value.id in declared
|
|
463
|
+
and (found := _method_return(declared[value.func.value.id], value.func.attr)) is not None
|
|
464
|
+
):
|
|
465
|
+
return found
|
|
466
|
+
return (
|
|
467
|
+
_scalar(value)
|
|
468
|
+
or _container(value, calls, known_factories, declared, known_classes)
|
|
469
|
+
or _called(value, calls, known_factories)
|
|
470
|
+
)
|
|
210
471
|
|
|
211
472
|
|
|
212
473
|
def _scalar(value: ast.expr) -> str | None:
|
|
@@ -219,72 +480,174 @@ def _scalar(value: ast.expr) -> str | None:
|
|
|
219
480
|
_NUMBERS,
|
|
220
481
|
) and not isinstance(constant, bool):
|
|
221
482
|
return type(constant).__name__
|
|
483
|
+
case ast.UnaryOp(op=ast.Not()): # `not x` always yields a real `bool`, unlike a comparison
|
|
484
|
+
return "bool"
|
|
222
485
|
case ast.JoinedStr():
|
|
223
486
|
return "str"
|
|
224
487
|
case _:
|
|
225
488
|
return None
|
|
226
489
|
|
|
227
490
|
|
|
228
|
-
def _container(
|
|
491
|
+
def _container(
|
|
492
|
+
value: ast.expr,
|
|
493
|
+
calls: Mapping[str, str],
|
|
494
|
+
known_factories: frozenset[str],
|
|
495
|
+
declared: Mapping[str, str],
|
|
496
|
+
known_classes: Mapping[str, Mapping[str, str]],
|
|
497
|
+
) -> str | None:
|
|
229
498
|
elements: list[ast.expr]
|
|
230
499
|
keys: list[ast.expr | None]
|
|
231
500
|
values: list[ast.expr]
|
|
232
501
|
parts: list[str | None]
|
|
233
502
|
match value:
|
|
234
503
|
case ast.List(elts=elements) | ast.Set(elts=elements) if elements:
|
|
235
|
-
element: str | None = _uniform(elements, calls)
|
|
504
|
+
element: str | None = _uniform(elements, calls, known_factories, declared, known_classes)
|
|
236
505
|
return f"{'list' if isinstance(value, ast.List) else 'set'}[{element}]" if element else None
|
|
237
506
|
case ast.Tuple(elts=elements) if elements:
|
|
238
|
-
parts = [
|
|
507
|
+
parts = [
|
|
508
|
+
inferred(element, calls, known_factories, declared, known_classes) for element in elements
|
|
509
|
+
]
|
|
239
510
|
return None if None in parts else f"tuple[{', '.join(str(part) for part in parts)}]"
|
|
240
511
|
case ast.Dict(keys=keys, values=values) if keys and None not in keys:
|
|
241
|
-
|
|
242
|
-
|
|
512
|
+
present: list[ast.expr] = [k for k in keys if k is not None]
|
|
513
|
+
key: str | None = _uniform(present, calls, known_factories, declared, known_classes)
|
|
514
|
+
item: str | None = _uniform(values, calls, known_factories, declared, known_classes)
|
|
243
515
|
return f"dict[{key}, {item}]" if key and item else None
|
|
244
516
|
case _:
|
|
245
517
|
return None
|
|
246
518
|
|
|
247
519
|
|
|
248
|
-
def _uniform(
|
|
520
|
+
def _uniform(
|
|
521
|
+
elements: Sequence[ast.expr],
|
|
522
|
+
calls: Mapping[str, str],
|
|
523
|
+
known_factories: frozenset[str],
|
|
524
|
+
declared: Mapping[str, str],
|
|
525
|
+
known_classes: Mapping[str, Mapping[str, str]],
|
|
526
|
+
) -> str | None:
|
|
249
527
|
"""Find the one type every element has.
|
|
250
528
|
|
|
251
529
|
Returns:
|
|
252
530
|
That type, or `None` if they differ or any is unknown.
|
|
253
531
|
|
|
254
532
|
"""
|
|
255
|
-
types: set[str | None] = {
|
|
533
|
+
types: set[str | None] = {
|
|
534
|
+
inferred(element, calls, known_factories, declared, known_classes) for element in elements
|
|
535
|
+
}
|
|
256
536
|
return next(iter(types)) if len(types) == 1 else None
|
|
257
537
|
|
|
258
538
|
|
|
259
|
-
def
|
|
539
|
+
def _subscripted(container: str, node: ast.Subscript) -> str | None:
|
|
540
|
+
"""Infer `container[...]`'s type, given `container`'s own type as text.
|
|
541
|
+
|
|
542
|
+
A slice (`x[1:2]`) of a `list`, `str` or `bytes` is the same type as `container` itself; a plain
|
|
543
|
+
index into one is its element type, as is any index into a `dict` (its value type) or a
|
|
544
|
+
homogeneous `tuple[T, ...]`. A fixed-length `tuple[T1, T2]`'s element only varies with the index,
|
|
545
|
+
which isn't worth resolving.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
The annotation as source text, or `None` if the subscript doesn't decide one.
|
|
549
|
+
|
|
550
|
+
"""
|
|
551
|
+
# `container` is always `ast.unparse`'s own output (an annotation, or an earlier `inferred`),
|
|
552
|
+
# never user text, so it's always valid Python to parse back.
|
|
553
|
+
root: ast.expr = ast.parse(container, mode="eval").body
|
|
554
|
+
sliced: bool = isinstance(node.slice, ast.Slice)
|
|
555
|
+
element: ast.expr
|
|
556
|
+
last: ast.expr
|
|
557
|
+
match root:
|
|
558
|
+
case ast.Name(id="str" | "bytes"):
|
|
559
|
+
return container
|
|
560
|
+
case ast.Subscript(value=ast.Name(id="list" | "List"), slice=element):
|
|
561
|
+
return container if sliced else ast.unparse(element)
|
|
562
|
+
case ast.Subscript(
|
|
563
|
+
value=ast.Name(id="dict" | "Dict"),
|
|
564
|
+
slice=ast.Tuple(elts=[_, element]),
|
|
565
|
+
) if not sliced:
|
|
566
|
+
return ast.unparse(element)
|
|
567
|
+
case ast.Subscript(
|
|
568
|
+
value=ast.Name(id="tuple" | "Tuple"),
|
|
569
|
+
slice=ast.Tuple(elts=[element, last]),
|
|
570
|
+
) if not sliced and isinstance(last, ast.Constant) and last.value is Ellipsis:
|
|
571
|
+
return ast.unparse(element)
|
|
572
|
+
case _:
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _called(value: ast.expr, calls: Mapping[str, str], known_factories: frozenset[str]) -> str | None:
|
|
260
577
|
func: ast.expr
|
|
578
|
+
name: str
|
|
261
579
|
match value:
|
|
262
580
|
case ast.Call(func=ast.Name() | ast.Attribute() as func) if ast.unparse(func) in calls:
|
|
263
581
|
return calls[ast.unparse(func)]
|
|
264
|
-
case ast.Call(func=ast.Name()
|
|
582
|
+
case ast.Call(func=ast.Name(id=name)) if name in _BUILTIN_RETURNS:
|
|
583
|
+
return _BUILTIN_RETURNS[name]
|
|
584
|
+
case ast.Call(func=ast.Name() | ast.Attribute() as func) if _constructs(
|
|
585
|
+
node_name(func),
|
|
586
|
+
known_factories,
|
|
587
|
+
):
|
|
265
588
|
return ast.unparse(func)
|
|
266
589
|
case _:
|
|
267
590
|
return None
|
|
268
591
|
|
|
269
592
|
|
|
270
|
-
def guessed(
|
|
593
|
+
def guessed(
|
|
594
|
+
value: ast.expr,
|
|
595
|
+
calls: Mapping[str, str],
|
|
596
|
+
guesses: frozenset[str],
|
|
597
|
+
declared: Mapping[str, str],
|
|
598
|
+
) -> bool:
|
|
271
599
|
"""Whether `inferred`'s annotation for `value` is a guess (`--unsafe-fixes`): it calls a class.
|
|
272
600
|
|
|
273
601
|
A capitalised call may construct a generic class (`Box(1)` is really `Box[int]`) or be a factory
|
|
274
|
-
function; literals
|
|
602
|
+
function; literals, calls to a module function, a fixed-return builtin (`len`, `isinstance`,
|
|
603
|
+
...) or a fixed-return `str`/`bytes` method (`_method_return`) on an already-typed local, and
|
|
604
|
+
another local this scope already typed, are certain. Copying a local `inferred` itself only
|
|
605
|
+
guessed (`guesses`) is no more certain than the guess it copies.
|
|
275
606
|
|
|
276
607
|
Returns:
|
|
277
|
-
Whether any call in `value` is to something other than such a
|
|
608
|
+
Whether any call in `value` is to something other than such a certain callee, or any name in
|
|
609
|
+
it copies such a guess.
|
|
278
610
|
|
|
279
611
|
"""
|
|
280
|
-
return any(
|
|
612
|
+
return any(_is_guess(node, calls, guesses, declared) for node in ast.walk(value))
|
|
281
613
|
|
|
282
614
|
|
|
283
|
-
def
|
|
615
|
+
def _is_guess(
|
|
616
|
+
node: ast.AST,
|
|
617
|
+
calls: Mapping[str, str],
|
|
618
|
+
guesses: frozenset[str],
|
|
619
|
+
declared: Mapping[str, str],
|
|
620
|
+
) -> bool:
|
|
621
|
+
name: str
|
|
622
|
+
func: ast.expr
|
|
623
|
+
receiver: str
|
|
624
|
+
method: str
|
|
625
|
+
match node:
|
|
626
|
+
case ast.Call(func=ast.Name(id=name)) if name in _BUILTIN_RETURNS:
|
|
627
|
+
return False
|
|
628
|
+
case ast.Call(func=ast.Attribute(value=ast.Name(id=receiver), attr=method)) if (
|
|
629
|
+
receiver in declared and _method_return(declared[receiver], method) is not None
|
|
630
|
+
):
|
|
631
|
+
return False
|
|
632
|
+
case ast.Call(func=func):
|
|
633
|
+
return ast.unparse(func) not in calls
|
|
634
|
+
case ast.Name(id=name):
|
|
635
|
+
return name in guesses
|
|
636
|
+
case _:
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _constructs(name: str, known_factories: frozenset[str]) -> bool:
|
|
284
641
|
"""Check whether a call to `name` constructs a class, by its capitalised name.
|
|
285
642
|
|
|
286
643
|
Returns:
|
|
287
|
-
Whether it does, and is worth annotating
|
|
644
|
+
Whether it does, and is worth annotating: `name` isn't a known factory, by import
|
|
645
|
+
(`known_factories`) or by its bare name (`_FACTORIES`, for one imported some other way).
|
|
288
646
|
|
|
289
647
|
"""
|
|
290
|
-
return
|
|
648
|
+
return (
|
|
649
|
+
name[:1].isupper()
|
|
650
|
+
and name not in known_factories
|
|
651
|
+
and name not in _FACTORIES
|
|
652
|
+
and name not in _GENERICS
|
|
653
|
+
)
|
constricter/baseline.py
CHANGED
|
@@ -58,7 +58,7 @@ def read(baseline: Path) -> Entries:
|
|
|
58
58
|
path: str
|
|
59
59
|
counts: _Json
|
|
60
60
|
for path, counts in files.items():
|
|
61
|
-
if not isinstance(counts, dict) or not all(
|
|
61
|
+
if not isinstance(counts, dict) or not all(jsonc.is_int(n) for n in counts.values()):
|
|
62
62
|
break
|
|
63
63
|
entries[path] = {entry: n for entry, n in counts.items() if isinstance(n, int)}
|
|
64
64
|
else:
|
|
@@ -69,10 +69,6 @@ def read(baseline: Path) -> Entries:
|
|
|
69
69
|
raise ValueError(message)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def _is_count(value: _Json) -> bool:
|
|
73
|
-
return isinstance(value, int) and not isinstance(value, bool)
|
|
74
|
-
|
|
75
|
-
|
|
76
72
|
def write(baseline: Path, found: Mapping[str, Sequence[Offence]]) -> int:
|
|
77
73
|
"""Write every offence in `found` (keyed as `key` makes them) to `baseline`.
|
|
78
74
|
|