python-constricter 0.4.1__cp313-cp313-win32.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 +55 -0
- constricter/__main__.py +26 -0
- constricter/_version.py +4 -0
- constricter/cli/__init__.py +2 -0
- constricter/cli/baseline.py +107 -0
- constricter/cli/collecting.py +54 -0
- constricter/cli/command.py +547 -0
- constricter/cli/config.py +322 -0
- constricter/cli/explain.py +104 -0
- constricter/cli/lsp/__init__.py +2 -0
- constricter/cli/lsp/connection.py +475 -0
- constricter/cli/lsp/edits.py +188 -0
- constricter/cli/lsp/guard.py +133 -0
- constricter/cli/lsp/hints.py +391 -0
- constricter/cli/lsp/protocol.py +192 -0
- constricter/cli/lsp/remembered.py +81 -0
- constricter/cli/lsp/stored.py +200 -0
- constricter/cli/options.py +799 -0
- constricter/cli/paths.py +151 -0
- constricter/cli/reading.py +36 -0
- constricter/cli/report.py +397 -0
- constricter/cli/rounds.py +328 -0
- constricter/cli/runs.py +90 -0
- constricter/cli/schedule.py +463 -0
- constricter/cli/traced.py +401 -0
- constricter/cli/workers.py +286 -0
- constricter/empty.py +66 -0
- constricter/fix/__init__.cp313-win32.pyd +0 -0
- constricter/fix/__init__.py +2 -0
- constricter/fix/core/__init__.cp313-win32.pyd +0 -0
- constricter/fix/core/__init__.py +2 -0
- constricter/fix/core/asked.cp313-win32.pyd +0 -0
- constricter/fix/core/asked.py +56 -0
- constricter/fix/core/fixes.cp313-win32.pyd +0 -0
- constricter/fix/core/fixes.py +317 -0
- constricter/fix/core/imports.cp313-win32.pyd +0 -0
- constricter/fix/core/imports.py +580 -0
- constricter/fix/core/inherited.cp313-win32.pyd +0 -0
- constricter/fix/core/inherited.py +311 -0
- constricter/fix/core/known.cp313-win32.pyd +0 -0
- constricter/fix/core/known.py +730 -0
- constricter/fix/core/signatures.cp313-win32.pyd +0 -0
- constricter/fix/core/signatures.py +132 -0
- constricter/fix/index/__init__.cp313-win32.pyd +0 -0
- constricter/fix/index/__init__.py +2 -0
- constricter/fix/index/awaits.cp313-win32.pyd +0 -0
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.cp313-win32.pyd +0 -0
- constricter/fix/index/beyond.py +149 -0
- constricter/fix/index/callers.cp313-win32.pyd +0 -0
- constricter/fix/index/callers.py +155 -0
- constricter/fix/index/crossed.cp313-win32.pyd +0 -0
- constricter/fix/index/crossed.py +327 -0
- constricter/fix/index/decorated.cp313-win32.pyd +0 -0
- constricter/fix/index/decorated.py +140 -0
- constricter/fix/index/fixtures.cp313-win32.pyd +0 -0
- constricter/fix/index/fixtures.py +176 -0
- constricter/fix/index/linked.cp313-win32.pyd +0 -0
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.cp313-win32.pyd +0 -0
- constricter/fix/index/loose.py +103 -0
- constricter/fix/index/managed.cp313-win32.pyd +0 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.cp313-win32.pyd +0 -0
- constricter/fix/index/modules.py +741 -0
- constricter/fix/index/offers.cp313-win32.pyd +0 -0
- constricter/fix/index/offers.py +252 -0
- constricter/fix/index/order.cp313-win32.pyd +0 -0
- constricter/fix/index/order.py +155 -0
- constricter/fix/index/origins.cp313-win32.pyd +0 -0
- constricter/fix/index/origins.py +238 -0
- constricter/fix/index/own_methods.cp313-win32.pyd +0 -0
- constricter/fix/index/own_methods.py +91 -0
- constricter/fix/index/own_overloads.cp313-win32.pyd +0 -0
- constricter/fix/index/own_overloads.py +70 -0
- constricter/fix/index/own_types.cp313-win32.pyd +0 -0
- constricter/fix/index/own_types.py +330 -0
- constricter/fix/index/params.cp313-win32.pyd +0 -0
- constricter/fix/index/params.py +81 -0
- constricter/fix/index/plain.cp313-win32.pyd +0 -0
- constricter/fix/index/plain.py +221 -0
- constricter/fix/index/project.cp313-win32.pyd +0 -0
- constricter/fix/index/project.py +797 -0
- constricter/fix/index/sides.cp313-win32.pyd +0 -0
- constricter/fix/index/sides.py +160 -0
- constricter/fix/index/strings.cp313-win32.pyd +0 -0
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubs/__init__.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/__init__.py +2 -0
- constricter/fix/index/stubs/atoms.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/atoms.py +425 -0
- constricter/fix/index/stubs/chained.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/chained.py +69 -0
- constricter/fix/index/stubs/classnames.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/classnames.py +124 -0
- constricter/fix/index/stubs/declared.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/declared.py +508 -0
- constricter/fix/index/stubs/installed.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/installed.py +425 -0
- constricter/fix/index/stubs/names.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/names.py +200 -0
- constricter/fix/index/stubs/read.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/read.py +70 -0
- constricter/fix/index/stubs/signatures.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/signatures.py +656 -0
- constricter/fix/index/stubs/stubbed.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/stubbed.py +208 -0
- constricter/fix/index/taken.cp313-win32.pyd +0 -0
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.cp313-win32.pyd +0 -0
- constricter/fix/index/tuples.py +112 -0
- constricter/fix/libraries/__init__.cp313-win32.pyd +0 -0
- constricter/fix/libraries/__init__.py +2 -0
- constricter/fix/libraries/library.cp313-win32.pyd +0 -0
- constricter/fix/libraries/library.py +420 -0
- constricter/fix/libraries/opened.cp313-win32.pyd +0 -0
- constricter/fix/libraries/opened.py +147 -0
- constricter/fix/libraries/overloads.cp313-win32.pyd +0 -0
- constricter/fix/libraries/overloads.py +960 -0
- constricter/fix/libraries/receivers.cp313-win32.pyd +0 -0
- constricter/fix/libraries/receivers.py +274 -0
- constricter/fix/libraries/stdlib.cp313-win32.pyd +0 -0
- constricter/fix/libraries/stdlib.py +752 -0
- constricter/fix/libraries/walks.cp313-win32.pyd +0 -0
- constricter/fix/libraries/walks.py +49 -0
- constricter/fix/tables/aliases.json +147 -0
- constricter/fix/tables/attributes.json +700 -0
- constricter/fix/tables/awaited.json +69 -0
- constricter/fix/tables/awaited_overloads.json +14 -0
- constricter/fix/tables/bases.json +888 -0
- constricter/fix/tables/classes.json +1941 -0
- constricter/fix/tables/elements.json +156 -0
- constricter/fix/tables/functions.json +58 -0
- constricter/fix/tables/generic_attributes.json +254 -0
- constricter/fix/tables/method_overloads.json +461 -0
- constricter/fix/tables/method_signatures.json +954 -0
- constricter/fix/tables/methods.json +954 -0
- constricter/fix/tables/overloads.json +540 -0
- constricter/fix/tables/platform.json +37 -0
- constricter/fix/tables/returns.json +1362 -0
- constricter/fix/tables/scalar_members.json +16 -0
- constricter/fix/tables/scalars.json +2707 -0
- constricter/fix/tables/source.json +28 -0
- constricter/fix/tables/subscriptable.json +77 -0
- constricter/fix/tables/type_parameters.json +178 -0
- constricter/fix/tables/variables.json +2962 -0
- constricter/fix/values/__init__.cp313-win32.pyd +0 -0
- constricter/fix/values/__init__.py +2 -0
- constricter/fix/values/aliased.cp313-win32.pyd +0 -0
- constricter/fix/values/aliased.py +195 -0
- constricter/fix/values/bodies.cp313-win32.pyd +0 -0
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.cp313-win32.pyd +0 -0
- constricter/fix/values/callables.py +323 -0
- constricter/fix/values/called.cp313-win32.pyd +0 -0
- constricter/fix/values/called.py +194 -0
- constricter/fix/values/classvars.cp313-win32.pyd +0 -0
- constricter/fix/values/classvars.py +629 -0
- constricter/fix/values/decided.cp313-win32.pyd +0 -0
- constricter/fix/values/decided.py +429 -0
- constricter/fix/values/displays.cp313-win32.pyd +0 -0
- constricter/fix/values/displays.py +101 -0
- constricter/fix/values/doubts.cp313-win32.pyd +0 -0
- constricter/fix/values/doubts.py +677 -0
- constricter/fix/values/entered.cp313-win32.pyd +0 -0
- constricter/fix/values/entered.py +332 -0
- constricter/fix/values/fills.cp313-win32.pyd +0 -0
- constricter/fix/values/fills.py +446 -0
- constricter/fix/values/guesses.cp313-win32.pyd +0 -0
- constricter/fix/values/guesses.py +376 -0
- constricter/fix/values/hinted.cp313-win32.pyd +0 -0
- constricter/fix/values/hinted.py +811 -0
- constricter/fix/values/inference.cp313-win32.pyd +0 -0
- constricter/fix/values/inference.py +698 -0
- constricter/fix/values/loops.cp313-win32.pyd +0 -0
- constricter/fix/values/loops.py +335 -0
- constricter/fix/values/members.cp313-win32.pyd +0 -0
- constricter/fix/values/members.py +578 -0
- constricter/fix/values/narrowed.cp313-win32.pyd +0 -0
- constricter/fix/values/narrowed.py +220 -0
- constricter/fix/values/operated.cp313-win32.pyd +0 -0
- constricter/fix/values/operated.py +202 -0
- constricter/fix/values/ordered.cp313-win32.pyd +0 -0
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.cp313-win32.pyd +0 -0
- constricter/fix/values/returned.py +479 -0
- constricter/fix/values/returns.cp313-win32.pyd +0 -0
- constricter/fix/values/returns.py +237 -0
- constricter/fix/values/shapes.cp313-win32.pyd +0 -0
- constricter/fix/values/shapes.py +654 -0
- constricter/fix/values/stored.cp313-win32.pyd +0 -0
- constricter/fix/values/stored.py +564 -0
- constricter/fix/values/targets.cp313-win32.pyd +0 -0
- constricter/fix/values/targets.py +419 -0
- constricter/fix/values/unchecked.cp313-win32.pyd +0 -0
- constricter/fix/values/unchecked.py +278 -0
- constricter/jsonc.py +50 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +115 -0
- constricter/offences.py +436 -0
- constricter/overrides.py +24 -0
- constricter/plugins/__init__.py +2 -0
- constricter/plugins/flake8.py +133 -0
- constricter/plugins/pylint.py +186 -0
- constricter/py.typed +0 -0
- constricter/recording.py +660 -0
- constricter/rules/__init__.cp313-win32.pyd +0 -0
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.cp313-win32.pyd +0 -0
- constricter/rules/annotations.py +296 -0
- constricter/rules/binding.cp313-win32.pyd +0 -0
- constricter/rules/binding.py +652 -0
- constricter/rules/calls.cp313-win32.pyd +0 -0
- constricter/rules/calls.py +460 -0
- constricter/rules/checker.cp313-win32.pyd +0 -0
- constricter/rules/checker.py +536 -0
- constricter/rules/declarations.cp313-win32.pyd +0 -0
- constricter/rules/declarations.py +730 -0
- constricter/rules/decorators.cp313-win32.pyd +0 -0
- constricter/rules/decorators.py +351 -0
- constricter/rules/flow.cp313-win32.pyd +0 -0
- constricter/rules/flow.py +502 -0
- constricter/rules/keys.cp313-win32.pyd +0 -0
- constricter/rules/keys.py +79 -0
- constricter/rules/late.cp313-win32.pyd +0 -0
- constricter/rules/late.py +969 -0
- constricter/rules/narrowing.cp313-win32.pyd +0 -0
- constricter/rules/narrowing.py +98 -0
- constricter/rules/parsed.cp313-win32.pyd +0 -0
- constricter/rules/parsed.py +136 -0
- constricter/rules/quoted.cp313-win32.pyd +0 -0
- constricter/rules/quoted.py +141 -0
- constricter/rules/rebinding.cp313-win32.pyd +0 -0
- constricter/rules/rebinding.py +169 -0
- constricter/rules/recorded.cp313-win32.pyd +0 -0
- constricter/rules/recorded.py +168 -0
- constricter/rules/redundant.cp313-win32.pyd +0 -0
- constricter/rules/redundant.py +160 -0
- constricter/rules/scope.cp313-win32.pyd +0 -0
- constricter/rules/scope.py +802 -0
- constricter/rules/scoped.cp313-win32.pyd +0 -0
- constricter/rules/scoped.py +232 -0
- constricter/rules/scoping.cp313-win32.pyd +0 -0
- constricter/rules/scoping.py +346 -0
- constricter/rules/settings.cp313-win32.pyd +0 -0
- constricter/rules/settings.py +189 -0
- constricter/rules/syntax.cp313-win32.pyd +0 -0
- constricter/rules/syntax.py +472 -0
- constricter/rules/tables.cp313-win32.pyd +0 -0
- constricter/rules/tables.py +169 -0
- constricter/rules/walked.cp313-win32.pyd +0 -0
- constricter/rules/walked.py +127 -0
- constricter/rules/widened.cp313-win32.pyd +0 -0
- constricter/rules/widened.py +322 -0
- constricter/trace.py +374 -0
- e45ce06b2fdd5c43f782__mypyc.cp313-win32.pyd +0 -0
- python_constricter-0.4.1.dist-info/METADATA +400 -0
- python_constricter-0.4.1.dist-info/RECORD +261 -0
- python_constricter-0.4.1.dist-info/WHEEL +4 -0
- python_constricter-0.4.1.dist-info/entry_points.txt +5 -0
- python_constricter-0.4.1.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Types split over what a loop or an unpacking binds: a container's elements, a tuple's parts."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Final, cast
|
|
10
|
+
|
|
11
|
+
from constricter.fix.core.imports import rebound_names
|
|
12
|
+
from constricter.fix.core.known import Inference
|
|
13
|
+
from constricter.rules.annotations import node_name
|
|
14
|
+
from constricter.rules.declarations import defined_type_vars
|
|
15
|
+
from constricter.rules.quoted import written
|
|
16
|
+
from constricter.rules.syntax import top_level
|
|
17
|
+
from constricter.rules.walked import classes
|
|
18
|
+
|
|
19
|
+
# Builtins that iterate over their (first) argument's elements, one to one.
|
|
20
|
+
SAME_ELEMENTS: Final = frozenset({"reversed", "sorted"})
|
|
21
|
+
DICT_VIEWS: Final = frozenset({"keys", "values", "items"})
|
|
22
|
+
# What iterating each builtin that takes no type argument gives.
|
|
23
|
+
_ELEMENTS: Final = {"str": "str", "bytes": "int", "bytearray": "int", "range": "int"}
|
|
24
|
+
# Containers whose one type parameter is their elements'.
|
|
25
|
+
# And the builtin iterators', what they yield (`zip[tuple[str, int]]`); `enumerate[T]` counts its `T`s.
|
|
26
|
+
_ONE_ELEMENT_TYPE: Final = frozenset(
|
|
27
|
+
{"list", "List", "set", "Set", "frozenset", "FrozenSet", "zip", "map", "filter", "reversed"},
|
|
28
|
+
)
|
|
29
|
+
# What yields its first type parameter, however it's named (`Iterator[T]`, `abc.Generator[T, None, None]`).
|
|
30
|
+
_YIELDING: Final = frozenset({"Iterable", "Iterator", "Generator"})
|
|
31
|
+
# Mappings of keys to values, however they're named (`dict[K, V]`, `abc.Mapping[K, V]`).
|
|
32
|
+
_MAPPINGS: Final = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"dict",
|
|
35
|
+
"Dict",
|
|
36
|
+
"Mapping",
|
|
37
|
+
"MutableMapping",
|
|
38
|
+
"OrderedDict",
|
|
39
|
+
"defaultdict",
|
|
40
|
+
"DefaultDict",
|
|
41
|
+
"MappingProxyType",
|
|
42
|
+
"ChainMap",
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
RANGE: Final = "range"
|
|
46
|
+
ENUMERATE: Final = "enumerate"
|
|
47
|
+
ZIP: Final = "zip"
|
|
48
|
+
MAP: Final = "map" # yields what its function returns, whatever it's mapped over
|
|
49
|
+
ITER: Final = "iter" # yields its one argument's elements (with a sentinel, what its callable returns)
|
|
50
|
+
FILTER: Final = "filter" # yields its second argument's elements its predicate keeps
|
|
51
|
+
ITERATORS: Final = frozenset({RANGE, ENUMERATE, ZIP, MAP, ITER, FILTER, *SAME_ELEMENTS})
|
|
52
|
+
# The keywords each of `ITERATORS` takes that don't change what it yields (`sorted`'s only its order).
|
|
53
|
+
_ITERATOR_KEYWORDS: Final = {
|
|
54
|
+
ENUMERATE: frozenset({"start"}),
|
|
55
|
+
ZIP: frozenset({"strict"}),
|
|
56
|
+
"sorted": frozenset({"key", "reverse"}),
|
|
57
|
+
}
|
|
58
|
+
# `tuple[T, ...]`'s two parts: the element type and the ellipsis.
|
|
59
|
+
_ANY_LENGTH: Final = 2
|
|
60
|
+
_NAMED_TUPLE: Final = ["NamedTuple"] # the one base of a class whose annotated variables are its fields
|
|
61
|
+
_PAIR: Final = 2 # a functional `NamedTuple`'s field: its name and type
|
|
62
|
+
_TUPLES: Final = frozenset({"tuple", "Tuple"})
|
|
63
|
+
_UNIONS: Final = frozenset({"Union", "Optional"})
|
|
64
|
+
_UNION: Final = re.compile(r"\||\b(?:Union|Optional)\[") # in a quoted alias
|
|
65
|
+
_TYPE_ALIAS: Final = "TypeAlias"
|
|
66
|
+
_NO_TUPLES: Final[Mapping[str, str]] = MappingProxyType({})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def named_tuples(tree: ast.Module) -> dict[str, str]:
|
|
70
|
+
"""Map each tuple type the module names to the tuple unpacking one gives.
|
|
71
|
+
|
|
72
|
+
A class defined once, directly under `NamedTuple` alone, with two fields or more: its fields'
|
|
73
|
+
types; or under a `NamedTuple("P", [("x", int), ...])` call alone, the call's. A type alias of a
|
|
74
|
+
tuple (`Pair: TypeAlias = tuple[int, str]`, `Pair = tuple[int, str]`, `type Pair = tuple[int,
|
|
75
|
+
str]`) at the module's top level, bound once there and naming none of its type variables: the
|
|
76
|
+
tuple.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Each one's name, and the tuple's type as text.
|
|
80
|
+
|
|
81
|
+
"""
|
|
82
|
+
found: dict[str, str] = _aliased_tuples(tree)
|
|
83
|
+
nodes: Sequence[ast.ClassDef] = classes(tree)
|
|
84
|
+
counts: Counter[str] = Counter(node.name for node in nodes)
|
|
85
|
+
node: ast.ClassDef
|
|
86
|
+
for node in nodes:
|
|
87
|
+
fields: list[str] | None = None
|
|
88
|
+
if counts[node.name] == 1 and [node_name(base) for base in node.bases] == _NAMED_TUPLE:
|
|
89
|
+
fields = [
|
|
90
|
+
written(stmt.annotation)
|
|
91
|
+
for stmt in node.body
|
|
92
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name)
|
|
93
|
+
]
|
|
94
|
+
elif counts[node.name] == 1 and len(node.bases) == 1:
|
|
95
|
+
fields = _called_fields(node.bases[0])
|
|
96
|
+
if fields is not None and len(fields) > 1:
|
|
97
|
+
found[node.name] = f"tuple[{', '.join(fields)}]"
|
|
98
|
+
return found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _called_fields(base: ast.expr) -> list[str] | None:
|
|
102
|
+
"""Read the fields of a `NamedTuple("P", [("x", int), ("y", str)])` call as a base.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Their types as written, in order; `None` for any other base, a `namedtuple(...)` call's
|
|
106
|
+
untyped fields included.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
func: ast.expr
|
|
110
|
+
elts: list[ast.expr]
|
|
111
|
+
match base:
|
|
112
|
+
case ast.Call(
|
|
113
|
+
func=func,
|
|
114
|
+
args=[ast.Constant(value=str()), ast.List(elts=elts) | ast.Tuple(elts=elts)],
|
|
115
|
+
keywords=[],
|
|
116
|
+
) if node_name(func) == _NAMED_TUPLE[0]:
|
|
117
|
+
pairs: list[list[ast.expr]] = [
|
|
118
|
+
list(elt.elts) for elt in elts if isinstance(elt, ast.Tuple) and len(elt.elts) == _PAIR
|
|
119
|
+
]
|
|
120
|
+
if len(pairs) != len(elts) or any(not isinstance(name, ast.Constant) for name, _ in pairs):
|
|
121
|
+
return None
|
|
122
|
+
return [written(annotation) for _, annotation in pairs]
|
|
123
|
+
case _:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _aliased_tuples(tree: ast.Module) -> dict[str, str]:
|
|
128
|
+
"""Find the module's type aliases of a tuple (see `named_tuples`).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Each one's name, and the tuple's type as text.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
type_vars: frozenset[str] = defined_type_vars(tree)
|
|
135
|
+
return {
|
|
136
|
+
name: written(value)
|
|
137
|
+
for name, value in _aliases(tree)
|
|
138
|
+
if isinstance(value, ast.Subscript)
|
|
139
|
+
and node_name(value.value) in _TUPLES
|
|
140
|
+
and not type_vars & {node.id for node in ast.walk(value) if isinstance(node, ast.Name)}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def aliased_unions(tree: ast.Module) -> dict[str, str]:
|
|
145
|
+
"""Find the module's type aliases of a union: `Key = int | str`, `Key: TypeAlias = Union[int, str]`.
|
|
146
|
+
|
|
147
|
+
At its top level, bound once there. A value of one is narrowed as a union is, by a test or an
|
|
148
|
+
assignment, which its alias's name alone doesn't say.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Each one's name, and the union as text.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
return {
|
|
155
|
+
name: written(value)
|
|
156
|
+
for name, value in _aliases(tree)
|
|
157
|
+
if (isinstance(value, ast.BinOp) and isinstance(value.op, ast.BitOr))
|
|
158
|
+
or (isinstance(value, ast.Subscript) and node_name(value.value) in _UNIONS)
|
|
159
|
+
or (isinstance(value, ast.Constant) and isinstance(value.value, str) and _UNION.search(value.value))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _aliases(tree: ast.Module) -> list[tuple[str, ast.expr]]:
|
|
164
|
+
"""Find what may be the module's type aliases: each name its top level binds once, as `_alias` reads it.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Each one's name and value.
|
|
168
|
+
|
|
169
|
+
"""
|
|
170
|
+
rebound: frozenset[str] = frozenset(rebound_names(tree))
|
|
171
|
+
read: Iterator[tuple[str, ast.expr] | None] = (_alias(stmt) for stmt in top_level(tree.body))
|
|
172
|
+
return [aliased for aliased in read if aliased is not None and aliased[0] not in rebound]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _alias(stmt: ast.stmt) -> tuple[str, ast.expr] | None:
|
|
176
|
+
"""Read the name and value a statement binds as a type alias, if it may be one.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Them: an assignment's to one name (annotated `TypeAlias`, or not at all), or a `type`
|
|
180
|
+
statement's; `None` for any other statement.
|
|
181
|
+
|
|
182
|
+
"""
|
|
183
|
+
name: str
|
|
184
|
+
value: ast.expr
|
|
185
|
+
annotation: ast.expr
|
|
186
|
+
match stmt:
|
|
187
|
+
case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation, value=ast.expr() as value) if (
|
|
188
|
+
node_name(annotation) == _TYPE_ALIAS
|
|
189
|
+
):
|
|
190
|
+
return name, value
|
|
191
|
+
case ast.Assign(targets=[ast.Name(id=name)], value=value):
|
|
192
|
+
return name, value
|
|
193
|
+
case _ if type(stmt).__name__ == _TYPE_ALIAS: # Python 3.12+'s `type X = ...`
|
|
194
|
+
return cast("ast.Name", getattr(stmt, "name", None)).id, cast(
|
|
195
|
+
"ast.expr",
|
|
196
|
+
getattr(stmt, "value", None),
|
|
197
|
+
)
|
|
198
|
+
case _:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def sole(argument: ast.expr) -> ast.expr:
|
|
203
|
+
"""Read a one-parameter generic's type argument, written with a trailing comma or not.
|
|
204
|
+
|
|
205
|
+
`list[int,]` (as a formatter writes a long one split over lines) subscripts `list` with the
|
|
206
|
+
tuple `(int,)`; its element is still `int`.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The type argument.
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
element: ast.expr
|
|
213
|
+
match argument:
|
|
214
|
+
case ast.Tuple(elts=[element]):
|
|
215
|
+
return element
|
|
216
|
+
case _:
|
|
217
|
+
return argument
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def element_type(container: str, reason: str, kinds: frozenset[str]) -> Inference | None:
|
|
221
|
+
"""Infer the elements of a value typed `container`.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Them, or `None` for a type whose elements aren't known from it alone.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
# `container` is always `ast.unparse`'s own output, so it's always valid Python to parse back.
|
|
228
|
+
root: ast.expr = ast.parse(container, mode="eval").body
|
|
229
|
+
name: str
|
|
230
|
+
head: ast.expr
|
|
231
|
+
item: ast.expr
|
|
232
|
+
key: ast.expr
|
|
233
|
+
others: list[ast.expr]
|
|
234
|
+
match root:
|
|
235
|
+
case ast.Name(id=name) if name in _ELEMENTS:
|
|
236
|
+
return Inference(_ELEMENTS[name], reason, kinds)
|
|
237
|
+
case ast.Subscript(value=ast.Name(id=name), slice=item) if (
|
|
238
|
+
name in _ONE_ELEMENT_TYPE or name == ENUMERATE
|
|
239
|
+
):
|
|
240
|
+
text: str = ast.unparse(sole(item))
|
|
241
|
+
return Inference(f"tuple[int, {text}]" if name == ENUMERATE else text, reason, kinds)
|
|
242
|
+
case ast.Subscript(value=head, slice=ast.Tuple(elts=[item, *_]) | item) if (
|
|
243
|
+
node_name(head) in _YIELDING
|
|
244
|
+
):
|
|
245
|
+
return Inference(ast.unparse(item), reason, kinds)
|
|
246
|
+
case ast.Subscript(value=head, slice=ast.Tuple(elts=[key, _])) if node_name(head) in _MAPPINGS:
|
|
247
|
+
return Inference(ast.unparse(key), reason, kinds)
|
|
248
|
+
case ast.Subscript(value=ast.Name(id="tuple" | "Tuple"), slice=ast.Tuple(elts=[item, *others])) if (
|
|
249
|
+
# `tuple[T, ...]`, or one whose parts agree (`("a", "b")`): whichever it is, a `T`.
|
|
250
|
+
all(_is_ellipsis(other) or ast.unparse(other) == ast.unparse(item) for other in others)
|
|
251
|
+
):
|
|
252
|
+
return Inference(ast.unparse(item), reason, kinds)
|
|
253
|
+
case _:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def dict_parts(annotation: str) -> tuple[str, str] | None:
|
|
258
|
+
"""Read a `dict[K, V]`'s key and value types, or another mapping's (`Mapping[K, V]`).
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Them, as text, or `None` for any other annotation.
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
# `annotation` is always `ast.unparse`'s own output, so it's always valid Python to parse back.
|
|
265
|
+
head: ast.expr
|
|
266
|
+
key: ast.expr
|
|
267
|
+
value: ast.expr
|
|
268
|
+
match ast.parse(annotation, mode="eval").body:
|
|
269
|
+
case ast.Subscript(value=head, slice=ast.Tuple(elts=[key, value])) if node_name(head) in _MAPPINGS:
|
|
270
|
+
return ast.unparse(key), ast.unparse(value)
|
|
271
|
+
case _:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def counted(name: str, args: list[ast.expr]) -> list[ast.expr]:
|
|
276
|
+
"""Pick the arguments whose elements one of `ITERATORS` yields.
|
|
277
|
+
|
|
278
|
+
`enumerate`'s first, `filter`'s second, the others' all.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Them.
|
|
282
|
+
|
|
283
|
+
"""
|
|
284
|
+
return args[:1] if name == ENUMERATE else args[1:] if name == FILTER else args
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _is_ellipsis(node: ast.expr) -> bool:
|
|
288
|
+
return isinstance(node, ast.Constant) and node.value is Ellipsis
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def unpacked(
|
|
292
|
+
target: ast.expr,
|
|
293
|
+
annotation: str | None,
|
|
294
|
+
tuples: Mapping[str, str] = _NO_TUPLES,
|
|
295
|
+
) -> list[tuple[ast.Name, str | None]]:
|
|
296
|
+
"""Match an unpacking target's names with the parts of a value typed `annotation`.
|
|
297
|
+
|
|
298
|
+
A plain name takes the whole type; a tuple or list of targets takes a `tuple[A, B, ...]` of the
|
|
299
|
+
same length part by part, or each an element of anything else whose elements are known (a
|
|
300
|
+
`tuple[T, ...]`'s or `list[T]`'s `T`, a `str`'s `str`). A starred name takes a `list` of what's
|
|
301
|
+
left for it, if that's of one type. A shape that doesn't match gets `None`. `tuples`: the named
|
|
302
|
+
tuples the module names (see `named_tuples`), each split as its fields' tuple.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Each name the target binds, with its type as text (or `None`).
|
|
306
|
+
|
|
307
|
+
"""
|
|
308
|
+
elements: list[ast.expr]
|
|
309
|
+
value: ast.expr
|
|
310
|
+
match target:
|
|
311
|
+
case ast.Name():
|
|
312
|
+
return [(target, annotation)]
|
|
313
|
+
case ast.Tuple(elts=elements) | ast.List(elts=elements):
|
|
314
|
+
parts: list[str | None] = _parts(tuples.get(annotation or "", annotation), elements)
|
|
315
|
+
return [
|
|
316
|
+
pair
|
|
317
|
+
for element, part in zip(elements, parts, strict=True)
|
|
318
|
+
for pair in unpacked(element, part, tuples)
|
|
319
|
+
]
|
|
320
|
+
case ast.Starred(value=value):
|
|
321
|
+
return unpacked(value, annotation, tuples)
|
|
322
|
+
case _:
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _parts(annotation: str | None, targets: Sequence[ast.expr]) -> list[str | None]:
|
|
327
|
+
"""Split a value's type over the targets it's unpacked into.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Each target's type as text, a starred one's a `list`; all `None` if `annotation` isn't a tuple
|
|
331
|
+
of that many, nor anything whose elements are known.
|
|
332
|
+
|
|
333
|
+
"""
|
|
334
|
+
unknown: list[str | None] = [None] * len(targets)
|
|
335
|
+
if annotation is None:
|
|
336
|
+
return unknown
|
|
337
|
+
star: int | None = next(
|
|
338
|
+
(at for at, target in enumerate(targets) if isinstance(target, ast.Starred)),
|
|
339
|
+
None,
|
|
340
|
+
)
|
|
341
|
+
elements: list[ast.expr]
|
|
342
|
+
match ast.parse(annotation, mode="eval").body:
|
|
343
|
+
case ast.Subscript(value=ast.Name(id="tuple" | "Tuple"), slice=ast.Tuple(elts=elements)) if not (
|
|
344
|
+
len(elements) == _ANY_LENGTH and _is_ellipsis(elements[-1])
|
|
345
|
+
):
|
|
346
|
+
return _fixed([ast.unparse(element) for element in elements], len(targets), star) or unknown
|
|
347
|
+
case _:
|
|
348
|
+
found: Inference | None
|
|
349
|
+
if (found := element_type(annotation, "", frozenset())) is None:
|
|
350
|
+
return unknown
|
|
351
|
+
each: list[str | None] = [found.annotation] * len(targets)
|
|
352
|
+
if star is not None:
|
|
353
|
+
each[star] = f"list[{found.annotation}]"
|
|
354
|
+
return each
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _fixed(parts: list[str], count: int, star: int | None) -> list[str | None] | None:
|
|
358
|
+
"""Split a fixed-length tuple's `parts` over `count` targets, the one at `star` starred (if any).
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Each target's type, the starred one's a `list` of the parts left for it where they agree
|
|
362
|
+
(else `None`); or `None` if the tuple hasn't a part for each target.
|
|
363
|
+
|
|
364
|
+
"""
|
|
365
|
+
if star is None:
|
|
366
|
+
return [*parts] if len(parts) == count else None
|
|
367
|
+
if len(parts) < count - 1:
|
|
368
|
+
return None
|
|
369
|
+
after: int = len(parts) - (count - star - 1)
|
|
370
|
+
rest: set[str] = set(parts[star:after])
|
|
371
|
+
starred: str | None = f"list[{rest.pop()}]" if len(rest) == 1 else None
|
|
372
|
+
return [*parts[:star], starred, *parts[after:]]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def iterator_call(iterable: ast.expr) -> tuple[str, list[ast.expr]] | None:
|
|
376
|
+
"""Read a call to one of `ITERATORS` whose elements its positional arguments decide.
|
|
377
|
+
|
|
378
|
+
Its keywords must be ones that don't change what it yields (`enumerate`'s `start`, `zip`'s
|
|
379
|
+
`strict`, ...), and no argument starred: `zip(*rows)` yields as many parts as `rows` has.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The iterator's name and its positional arguments, or `None` if `iterable` isn't such a call.
|
|
383
|
+
|
|
384
|
+
"""
|
|
385
|
+
name: str
|
|
386
|
+
args: list[ast.expr]
|
|
387
|
+
keywords: list[ast.keyword]
|
|
388
|
+
match iterable:
|
|
389
|
+
case ast.Call(func=ast.Name(id=name), args=args, keywords=keywords) if (
|
|
390
|
+
args
|
|
391
|
+
and name in ITERATORS
|
|
392
|
+
and not any(isinstance(arg, ast.Starred) for arg in args)
|
|
393
|
+
and all(keyword.arg in _ITERATOR_KEYWORDS.get(name, ()) for keyword in keywords)
|
|
394
|
+
):
|
|
395
|
+
return name, args
|
|
396
|
+
case _:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def iterated(iterable: ast.expr) -> list[ast.expr]:
|
|
401
|
+
"""Find the values `looped` typed a loop over `iterable` from, to judge whether it guessed.
|
|
402
|
+
|
|
403
|
+
Through `enumerate` (its first argument), `zip`, `reversed`, `sorted` and a `dict` view to what
|
|
404
|
+
they iterate; a `range()` iterates nothing typed, and a `map()` yields what its function returns.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Those values.
|
|
408
|
+
|
|
409
|
+
"""
|
|
410
|
+
call: tuple[str, list[ast.expr]] | None
|
|
411
|
+
if (call := iterator_call(iterable)) is not None:
|
|
412
|
+
return [] if call[0] in {RANGE, MAP} else [part for arg in counted(*call) for part in iterated(arg)]
|
|
413
|
+
receiver: ast.expr
|
|
414
|
+
view: str
|
|
415
|
+
match iterable:
|
|
416
|
+
case ast.Call(func=ast.Attribute(value=receiver, attr=view), args=[]) if view in DICT_VIEWS:
|
|
417
|
+
return [receiver]
|
|
418
|
+
case _:
|
|
419
|
+
return [iterable]
|
|
Binary file
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Where a guess, once declared, makes an error of what its function goes on to do with the name.
|
|
3
|
+
|
|
4
|
+
A type checker takes a call of an unannotated function for anything, and checks nothing done with
|
|
5
|
+
its value; declared, the value is held to its type. So a guess isn't offered where the function
|
|
6
|
+
then takes an attribute its class hasn't (`cfg.verbose`, of a class whose attributes are set from
|
|
7
|
+
outside it), nor a union's where it takes an attribute or an item of the name that no test narrows
|
|
8
|
+
(`opt.cb`, of an `Option | None`). Nor where it uses a comparison of the name as more than a `bool`
|
|
9
|
+
(`(when == index).any()`: the class compares element by element, whatever its stubs say), formats a
|
|
10
|
+
`bytes` into a string (`f"{raw}"`, which mypy reports), or stores the name, or an item of it, in an
|
|
11
|
+
attribute it stores something else in too (the attribute then has the name's type).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from typing import Final, NamedTuple, TypeAlias
|
|
18
|
+
|
|
19
|
+
from constricter.fix.values.narrowed import Regions, read_narrowed, tested_lines
|
|
20
|
+
from constricter.rules.syntax import FunctionDef
|
|
21
|
+
from constricter.rules.walked import classes, walk
|
|
22
|
+
|
|
23
|
+
# A use of a name: its line, and the attribute taken of it (none: an item).
|
|
24
|
+
Use: TypeAlias = tuple[int, str]
|
|
25
|
+
_DUNDER: Final = "__"
|
|
26
|
+
# What gives a class attributes its body doesn't name.
|
|
27
|
+
_DYNAMIC: Final = frozenset({"__getattr__", "__getattribute__", "setattr", "__dict__", "vars"})
|
|
28
|
+
_OBJECT: Final = "object"
|
|
29
|
+
_BYTES: Final = "bytes"
|
|
30
|
+
_FORMAT: Final = "format"
|
|
31
|
+
_BITWISE: Final = (ast.BitAnd, ast.BitOr, ast.BitXor)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=64) # asked of each of a function's guesses
|
|
35
|
+
def uses(function: FunctionDef) -> Mapping[str, tuple[Use, ...]]:
|
|
36
|
+
"""Find where a function takes an attribute or an item of a name: `x.attr`, `x[0]`.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Each such name's uses, the functions' inside it too.
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
found: dict[str, list[Use]] = {}
|
|
43
|
+
node: ast.AST
|
|
44
|
+
name: str
|
|
45
|
+
attr: str
|
|
46
|
+
for node in walk(function):
|
|
47
|
+
match node:
|
|
48
|
+
case ast.Attribute(value=ast.Name(id=name), attr=attr):
|
|
49
|
+
found.setdefault(name, []).append((node.value.lineno, attr))
|
|
50
|
+
case ast.Subscript(value=ast.Name(id=name)):
|
|
51
|
+
found.setdefault(name, []).append((node.value.lineno, ""))
|
|
52
|
+
case _:
|
|
53
|
+
pass
|
|
54
|
+
return {name: tuple(taken) for name, taken in found.items()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unnarrowed(function: FunctionDef, name: str, regions: Regions) -> bool:
|
|
58
|
+
"""Check whether a function takes an attribute or an item of `name` where no test narrows it.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Whether it does: an error, for a name declared a union.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
tested: frozenset[int] = tested_lines(function).get(name, frozenset())
|
|
65
|
+
return any(
|
|
66
|
+
line not in tested and not read_narrowed(regions, name, line, union=True)
|
|
67
|
+
for line, _ in uses(function).get(name, ())
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def misused(function: FunctionDef, name: str, annotation: str) -> bool:
|
|
72
|
+
"""Check whether a function does with `name` what its type `annotation` makes an error of.
|
|
73
|
+
|
|
74
|
+
See `_compared`, `_formatted` and `_restored`.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Whether it does.
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
found: _Misuses = _misuses(function)
|
|
81
|
+
return name in found.compared | found.restored or (annotation == _BYTES and name in found.formatted)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _Misuses(NamedTuple):
|
|
85
|
+
"""The names a function compares, formats and stores as `misused` has it."""
|
|
86
|
+
|
|
87
|
+
compared: frozenset[str]
|
|
88
|
+
formatted: frozenset[str]
|
|
89
|
+
restored: frozenset[str]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@lru_cache(maxsize=64) # asked of each of a function's guesses
|
|
93
|
+
def _misuses(function: FunctionDef) -> _Misuses:
|
|
94
|
+
compared: set[str] = set()
|
|
95
|
+
formatted: set[str] = set()
|
|
96
|
+
stored: dict[str, list[str]] = {}
|
|
97
|
+
target: ast.Attribute
|
|
98
|
+
value: ast.expr
|
|
99
|
+
node: ast.AST
|
|
100
|
+
for node in walk(function):
|
|
101
|
+
compared.update(_compared(node))
|
|
102
|
+
formatted.update(_formatted(node))
|
|
103
|
+
match node:
|
|
104
|
+
case ast.Assign(targets=[ast.Attribute() as target], value=value):
|
|
105
|
+
stored.setdefault(ast.unparse(target), []).append(_held(value))
|
|
106
|
+
case _:
|
|
107
|
+
pass
|
|
108
|
+
restored: set[str] = {name for names in stored.values() if len(set(names)) > 1 for name in names if name}
|
|
109
|
+
return _Misuses(frozenset(compared), frozenset(formatted), frozenset(restored))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _held(value: ast.expr) -> str: # the name a stored value is, or is an item of; else nothing
|
|
113
|
+
name: str
|
|
114
|
+
match value:
|
|
115
|
+
case ast.Name(id=name) | ast.Subscript(value=ast.Name(id=name)):
|
|
116
|
+
return name
|
|
117
|
+
case _:
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _compared(node: ast.AST) -> Iterator[str]:
|
|
122
|
+
"""Name what `node` compares, where it uses the comparison as more than a `bool`.
|
|
123
|
+
|
|
124
|
+
An attribute or an item of it (`(a == b).any()`), or an operand of `&`, `|`, `^` or `~`.
|
|
125
|
+
|
|
126
|
+
Yields:
|
|
127
|
+
Each name compared.
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
used: list[ast.expr] = []
|
|
131
|
+
inner: ast.expr
|
|
132
|
+
left: ast.expr
|
|
133
|
+
right: ast.expr
|
|
134
|
+
op: ast.operator
|
|
135
|
+
match node:
|
|
136
|
+
case ast.Attribute(value=ast.Compare() as inner) | ast.Subscript(value=ast.Compare() as inner):
|
|
137
|
+
used.append(inner)
|
|
138
|
+
case ast.UnaryOp(op=ast.Invert(), operand=ast.Compare() as inner):
|
|
139
|
+
used.append(inner)
|
|
140
|
+
case ast.BinOp(left=left, op=op, right=right) if isinstance(op, _BITWISE):
|
|
141
|
+
used += [left, right]
|
|
142
|
+
case _:
|
|
143
|
+
pass
|
|
144
|
+
side: ast.expr
|
|
145
|
+
for inner in used:
|
|
146
|
+
if isinstance(inner, ast.Compare):
|
|
147
|
+
for side in (inner.left, *inner.comparators):
|
|
148
|
+
if isinstance(side, ast.Name):
|
|
149
|
+
yield side.id
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _formatted(node: ast.AST) -> Iterator[str]:
|
|
153
|
+
"""Name what `node` formats into a string as `str()` would: `f"{x}"`, `"{}".format(x)`, `"%s" % x`.
|
|
154
|
+
|
|
155
|
+
Yields:
|
|
156
|
+
Each name; not one an f-string converts itself (`f"{x!r}"`).
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
given: list[ast.expr] = []
|
|
160
|
+
value: ast.expr
|
|
161
|
+
attr: str
|
|
162
|
+
args: list[ast.expr]
|
|
163
|
+
match node:
|
|
164
|
+
case ast.FormattedValue(value=value, conversion=-1):
|
|
165
|
+
given.append(value)
|
|
166
|
+
case ast.Call(func=ast.Attribute(value=ast.Constant(value=str()), attr=attr), args=args) if (
|
|
167
|
+
attr == _FORMAT
|
|
168
|
+
):
|
|
169
|
+
given += args
|
|
170
|
+
case ast.BinOp(left=ast.Constant(value=str()), op=ast.Mod(), right=ast.Tuple(elts=args)):
|
|
171
|
+
given += args
|
|
172
|
+
case ast.BinOp(left=ast.Constant(value=str()), op=ast.Mod(), right=value):
|
|
173
|
+
given.append(value)
|
|
174
|
+
case _:
|
|
175
|
+
pass
|
|
176
|
+
for value in given:
|
|
177
|
+
if isinstance(value, ast.Name):
|
|
178
|
+
yield value.id
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def lacks(tree: ast.Module, function: FunctionDef, name: str, annotation: str) -> bool:
|
|
182
|
+
"""Check whether a function takes an attribute of `name` that the class `annotation` names hasn't.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Whether it does: `annotation` is one of the module's classes whose every attribute its own
|
|
186
|
+
body, and its bases', name (see `closed`), and one the function takes isn't among them.
|
|
187
|
+
|
|
188
|
+
"""
|
|
189
|
+
known: frozenset[str] | None = closed(tree).get(annotation)
|
|
190
|
+
return known is not None and any(
|
|
191
|
+
attr and not attr.startswith(_DUNDER) and attr not in known
|
|
192
|
+
for _, attr in uses(function).get(name, ())
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@lru_cache(maxsize=4) # asked of the same module's functions
|
|
197
|
+
def closed(tree: ast.Module) -> Mapping[str, frozenset[str]]:
|
|
198
|
+
"""Find the module's classes whose attributes are all in sight, and name them.
|
|
199
|
+
|
|
200
|
+
A class defined once, undecorated, with no base but others such, that neither answers for
|
|
201
|
+
attributes it doesn't name (`__getattr__`) nor sets them by name (`setattr`). Its attributes:
|
|
202
|
+
every name, attribute and string its body has, so what it binds, its `__slots__`, and what its
|
|
203
|
+
methods store (`self.x = ...`).
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Each such class's attributes, its bases' too.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
defined: dict[str, list[ast.ClassDef]] = {}
|
|
210
|
+
node: ast.ClassDef
|
|
211
|
+
for node in classes(tree):
|
|
212
|
+
defined.setdefault(node.name, []).append(node)
|
|
213
|
+
found: dict[str, frozenset[str]] = {}
|
|
214
|
+
name: str
|
|
215
|
+
for name in defined:
|
|
216
|
+
_ = _close(name, defined, found, frozenset())
|
|
217
|
+
return found
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _close(
|
|
221
|
+
name: str,
|
|
222
|
+
defined: Mapping[str, Sequence[ast.ClassDef]],
|
|
223
|
+
found: dict[str, frozenset[str]],
|
|
224
|
+
under: frozenset[str],
|
|
225
|
+
) -> frozenset[str] | None:
|
|
226
|
+
"""Name the attributes of the class `name`, if they're all in sight (see `closed`), into `found`.
|
|
227
|
+
|
|
228
|
+
`under`: the classes whose bases are being read, which a class can't be its own base through.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Them, or `None`.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
if name in found:
|
|
235
|
+
return found[name]
|
|
236
|
+
nodes: Sequence[ast.ClassDef] = defined.get(name, ())
|
|
237
|
+
if len(nodes) != 1 or name in under or nodes[0].decorator_list or nodes[0].keywords:
|
|
238
|
+
return None
|
|
239
|
+
attributes: set[str] | None = _attributes(nodes[0])
|
|
240
|
+
base: ast.expr
|
|
241
|
+
for base in nodes[0].bases:
|
|
242
|
+
inherited: frozenset[str] | None = (
|
|
243
|
+
frozenset()
|
|
244
|
+
if isinstance(base, ast.Name) and base.id == _OBJECT
|
|
245
|
+
else _close(base.id, defined, found, under | {name})
|
|
246
|
+
if isinstance(base, ast.Name)
|
|
247
|
+
else None
|
|
248
|
+
)
|
|
249
|
+
if attributes is None or inherited is None:
|
|
250
|
+
return None
|
|
251
|
+
attributes |= inherited
|
|
252
|
+
if attributes is None:
|
|
253
|
+
return None
|
|
254
|
+
found[name] = frozenset(attributes)
|
|
255
|
+
return found[name]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _attributes(node: ast.ClassDef) -> set[str] | None:
|
|
259
|
+
"""Name what a class's own body gives its instances.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Each attribute, or `None` if something in it may give others (see `_DYNAMIC`).
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
found: set[str] = set()
|
|
266
|
+
inner: ast.AST
|
|
267
|
+
for inner in walk(node):
|
|
268
|
+
if isinstance(inner, ast.Name):
|
|
269
|
+
found.add(inner.id)
|
|
270
|
+
elif isinstance(inner, ast.Attribute):
|
|
271
|
+
found.add(inner.attr)
|
|
272
|
+
elif isinstance(inner, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
|
273
|
+
found.add(inner.name)
|
|
274
|
+
elif isinstance(inner, ast.alias):
|
|
275
|
+
found.add((inner.asname or inner.name).split(".")[0])
|
|
276
|
+
elif isinstance(inner, ast.Constant) and isinstance(inner.value, str):
|
|
277
|
+
found.add(inner.value) # one of its `__slots__`
|
|
278
|
+
return None if found & _DYNAMIC else found
|