ml-lints 0.12.1__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.
- ml_lints/__init__.py +4 -0
- ml_lints/analyzers/__init__.py +0 -0
- ml_lints/analyzers/forbidden_types.py +136 -0
- ml_lints/analyzers/newtype_casts.py +344 -0
- ml_lints/analyzers/newtype_index.py +326 -0
- ml_lints/cli.py +512 -0
- ml_lints/noqa.py +27 -0
- ml_lints/rules/__init__.py +136 -0
- ml_lints/rules/_newtype_cast_base.py +103 -0
- ml_lints/rules/_template.py +59 -0
- ml_lints/rules/ml100_bare_dict.py +72 -0
- ml_lints/rules/ml101_bare_tuple.py +71 -0
- ml_lints/rules/ml102_dict_of_primitives.py +87 -0
- ml_lints/rules/ml103_fixed_tuple.py +71 -0
- ml_lints/rules/ml104_variable_tuple.py +67 -0
- ml_lints/rules/ml105_newtype_forbidden.py +74 -0
- ml_lints/rules/ml106_bare_mapping.py +72 -0
- ml_lints/rules/ml107_mapping_of_primitives.py +69 -0
- ml_lints/rules/ml108_newtype_self_cast.py +71 -0
- ml_lints/rules/ml109_newtype_cross_cast.py +70 -0
- ml_lints/rules/ml110_variable_tuple_param.py +88 -0
- ml_lints/rules/ml200_frozen_dataclass.py +75 -0
- ml_lints/rules/ml201_wrapper_class.py +79 -0
- ml_lints/rules/ml202_dict_spread_constructor.py +103 -0
- ml_lints/rules/ml300_inner_class.py +81 -0
- ml_lints/rules/ml400_untrusted_data.py +200 -0
- ml_lints/rules/ml500_australian_english.py +322 -0
- ml_lints/rules/ml501_hacky_pluralisation.py +124 -0
- ml_lints/rules/ml600.py +120 -0
- ml_lints/rules/spelling_map.json +1768 -0
- ml_lints/runner.py +95 -0
- ml_lints/violation.py +45 -0
- ml_lints-0.12.1.dist-info/METADATA +7 -0
- ml_lints-0.12.1.dist-info/RECORD +36 -0
- ml_lints-0.12.1.dist-info/WHEEL +4 -0
- ml_lints-0.12.1.dist-info/entry_points.txt +2 -0
ml_lints/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""ForbiddenTypeAnalyzer — pure analysis of type annotations for disallowed shapes.
|
|
2
|
+
|
|
3
|
+
Consumers use findings differently:
|
|
4
|
+
- ML100-104: each rule reports findings matching its own code.
|
|
5
|
+
- ML105, ML201: use `bool(findings)` as a yes/no signal that an annotation is forbidden.
|
|
6
|
+
- ML110: filters for ML104 findings in parameter annotations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ml_lints.violation import RuleCode
|
|
15
|
+
|
|
16
|
+
_DICT_NAMES: frozenset[str] = frozenset({"dict", "Dict"})
|
|
17
|
+
_MAPPING_NAMES: frozenset[str] = frozenset({"Mapping", "MutableMapping"})
|
|
18
|
+
_TUPLE_NAMES: frozenset[str] = frozenset({"tuple", "Tuple"})
|
|
19
|
+
_PRIMITIVE_NAMES: frozenset[str] = frozenset({"str", "int", "float", "bool", "bytes", "Any", "None"})
|
|
20
|
+
_KEY_VALUE_SUBSCRIPT_LEN = 2 # dict[K, V] / Mapping[K, V] subscript is always a 2-tuple
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ForbiddenTypeFinding:
|
|
25
|
+
"""A single forbidden-type finding in a type annotation (no path/message — pure analysis)."""
|
|
26
|
+
|
|
27
|
+
code: RuleCode
|
|
28
|
+
line: int
|
|
29
|
+
col: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ForbiddenTypeAnalyzer:
|
|
33
|
+
"""Recursively inspect a type annotation AST node and collect forbidden-type findings.
|
|
34
|
+
|
|
35
|
+
Instantiate once per annotation, call `analyze(node)`, then read `.findings`.
|
|
36
|
+
The analyzer does NOT emit Violations — that is the caller's responsibility.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.findings: list[ForbiddenTypeFinding] = []
|
|
41
|
+
|
|
42
|
+
def analyze(self, node: ast.AST) -> None:
|
|
43
|
+
if isinstance(node, ast.Name):
|
|
44
|
+
self._check_name(node)
|
|
45
|
+
elif isinstance(node, ast.Attribute):
|
|
46
|
+
self._check_attribute(node)
|
|
47
|
+
elif isinstance(node, ast.Subscript):
|
|
48
|
+
self._check_subscript(node)
|
|
49
|
+
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
50
|
+
self.analyze(node.left)
|
|
51
|
+
self.analyze(node.right)
|
|
52
|
+
elif isinstance(node, ast.Tuple):
|
|
53
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML101, node.lineno, node.col_offset + 1))
|
|
54
|
+
for elt in node.elts:
|
|
55
|
+
self.analyze(elt)
|
|
56
|
+
|
|
57
|
+
def _check_name(self, node: ast.Name) -> None:
|
|
58
|
+
if node.id in _DICT_NAMES:
|
|
59
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML100, node.lineno, node.col_offset + 1))
|
|
60
|
+
elif node.id in _MAPPING_NAMES:
|
|
61
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML106, node.lineno, node.col_offset + 1))
|
|
62
|
+
elif node.id in _TUPLE_NAMES:
|
|
63
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML101, node.lineno, node.col_offset + 1))
|
|
64
|
+
|
|
65
|
+
def _check_attribute(self, node: ast.Attribute) -> None:
|
|
66
|
+
if node.attr in _DICT_NAMES:
|
|
67
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML100, node.lineno, node.col_offset + 1))
|
|
68
|
+
elif node.attr in _MAPPING_NAMES:
|
|
69
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML106, node.lineno, node.col_offset + 1))
|
|
70
|
+
elif node.attr in _TUPLE_NAMES:
|
|
71
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML101, node.lineno, node.col_offset + 1))
|
|
72
|
+
|
|
73
|
+
def _check_subscript(self, node: ast.Subscript) -> None:
|
|
74
|
+
head = node.value
|
|
75
|
+
head_name = ""
|
|
76
|
+
if isinstance(head, ast.Name):
|
|
77
|
+
head_name = head.id
|
|
78
|
+
elif isinstance(head, ast.Attribute):
|
|
79
|
+
head_name = head.attr
|
|
80
|
+
|
|
81
|
+
if head_name in _DICT_NAMES:
|
|
82
|
+
self._check_dict_subscript(node)
|
|
83
|
+
elif head_name in _MAPPING_NAMES:
|
|
84
|
+
self._check_mapping_subscript(node)
|
|
85
|
+
elif head_name in _TUPLE_NAMES:
|
|
86
|
+
self._check_tuple_subscript(node)
|
|
87
|
+
elif isinstance(node.slice, ast.Tuple):
|
|
88
|
+
for elt in node.slice.elts:
|
|
89
|
+
self.analyze(elt)
|
|
90
|
+
else:
|
|
91
|
+
self.analyze(node.slice)
|
|
92
|
+
|
|
93
|
+
def _check_dict_subscript(self, node: ast.Subscript) -> None:
|
|
94
|
+
if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != _KEY_VALUE_SUBSCRIPT_LEN:
|
|
95
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML100, node.lineno, node.col_offset + 1))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
k, v = node.slice.elts
|
|
99
|
+
if _is_primitive(k) and _is_primitive(v):
|
|
100
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML102, node.lineno, node.col_offset + 1))
|
|
101
|
+
|
|
102
|
+
self.analyze(k)
|
|
103
|
+
self.analyze(v)
|
|
104
|
+
|
|
105
|
+
def _check_mapping_subscript(self, node: ast.Subscript) -> None:
|
|
106
|
+
if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != _KEY_VALUE_SUBSCRIPT_LEN:
|
|
107
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML106, node.lineno, node.col_offset + 1))
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
k, v = node.slice.elts
|
|
111
|
+
if _is_primitive(k) and _is_primitive(v):
|
|
112
|
+
self.findings.append(ForbiddenTypeFinding(RuleCode.ML107, node.lineno, node.col_offset + 1))
|
|
113
|
+
|
|
114
|
+
self.analyze(k)
|
|
115
|
+
self.analyze(v)
|
|
116
|
+
|
|
117
|
+
def _check_tuple_subscript(self, node: ast.Subscript) -> None:
|
|
118
|
+
elts = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice]
|
|
119
|
+
is_variable = any(isinstance(elt, ast.Constant) and elt.value is Ellipsis for elt in elts)
|
|
120
|
+
|
|
121
|
+
code = RuleCode.ML104 if is_variable else RuleCode.ML103
|
|
122
|
+
self.findings.append(ForbiddenTypeFinding(code, node.lineno, node.col_offset + 1))
|
|
123
|
+
|
|
124
|
+
for elt in elts:
|
|
125
|
+
if not (isinstance(elt, ast.Constant) and elt.value is Ellipsis):
|
|
126
|
+
self.analyze(elt)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_primitive(node: ast.AST) -> bool:
|
|
130
|
+
if isinstance(node, ast.Name):
|
|
131
|
+
return node.id in _PRIMITIVE_NAMES
|
|
132
|
+
if isinstance(node, ast.Attribute):
|
|
133
|
+
return node.attr in _PRIMITIVE_NAMES
|
|
134
|
+
if isinstance(node, ast.Constant):
|
|
135
|
+
return node.value is None or node.value is Ellipsis
|
|
136
|
+
return False
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Shared analyzer for NewType cast hygiene rules (ML108, ML109).
|
|
2
|
+
|
|
3
|
+
Tracks the static type of names visible at a call site and decides whether
|
|
4
|
+
`T(x)` is a redundant cast. The analyzer is intentionally conservative:
|
|
5
|
+
when the static type of the argument cannot be resolved, the call is left
|
|
6
|
+
alone and the consuming rule emits no violation.
|
|
7
|
+
|
|
8
|
+
Internally, the analyzer keeps a stack of scopes (one per function or
|
|
9
|
+
comprehension layer). Each scope maps a local name to a `ResolvedType`
|
|
10
|
+
that records what the name statically refers to: a NewType identity, a
|
|
11
|
+
project class identity, an iterable whose elements are one of those, or
|
|
12
|
+
nothing recognised. Resolution happens eagerly when a name is bound — at
|
|
13
|
+
that moment we know which module's namespace the annotation belongs to.
|
|
14
|
+
Looking the name up later is then a simple scope walk with no further
|
|
15
|
+
namespace bookkeeping.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import itertools
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum, auto
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from ml_lints.analyzers.newtype_index import BuiltinBase
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ml_lints.analyzers.newtype_index import NewTypeId, NewTypeIndex
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_WIDENING_BUILTINS = frozenset({"str", "int", "float", "bool", "bytes", "bytearray", "complex"})
|
|
33
|
+
|
|
34
|
+
# Containers whose generic single parameter is the iteration element.
|
|
35
|
+
_SINGLE_PARAM_ITERABLES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"list",
|
|
38
|
+
"List",
|
|
39
|
+
"set",
|
|
40
|
+
"Set",
|
|
41
|
+
"frozenset",
|
|
42
|
+
"FrozenSet",
|
|
43
|
+
"Iterable",
|
|
44
|
+
"Iterator",
|
|
45
|
+
"AsyncIterable",
|
|
46
|
+
"AsyncIterator",
|
|
47
|
+
"Sequence",
|
|
48
|
+
"MutableSequence",
|
|
49
|
+
"Collection",
|
|
50
|
+
"Reversible",
|
|
51
|
+
"Container",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Containers whose first generic parameter is the iteration element (mappings).
|
|
56
|
+
_MAPPING_ITERABLES = frozenset({"dict", "Dict", "Mapping", "MutableMapping"})
|
|
57
|
+
|
|
58
|
+
# Containers requiring tuple[T, ...] form (homogeneous variadic tuple).
|
|
59
|
+
_TUPLE_NAMES = frozenset({"tuple", "Tuple"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_homogeneous_tuple_element(slice_expr: ast.expr) -> ast.expr | None:
|
|
63
|
+
"""Return T from a `tuple[T, ...]` slice expression; None for fixed-arity tuples."""
|
|
64
|
+
min_homogeneous_tuple_arity = 2
|
|
65
|
+
if not isinstance(slice_expr, ast.Tuple) or len(slice_expr.elts) != min_homogeneous_tuple_arity:
|
|
66
|
+
return None
|
|
67
|
+
second = slice_expr.elts[1]
|
|
68
|
+
if isinstance(second, ast.Constant) and second.value is Ellipsis:
|
|
69
|
+
return slice_expr.elts[0]
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CastKind(Enum):
|
|
74
|
+
"""How a `T(x)` call relates to the static type of its argument."""
|
|
75
|
+
|
|
76
|
+
SELF = auto() # T(x) where x is statically of type T (no-op)
|
|
77
|
+
CROSS_SAME_BASE = auto() # T(x) where x is statically of type U, U != T, same base
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class CastFinding:
|
|
82
|
+
"""Result of classifying a `T(x)` call."""
|
|
83
|
+
|
|
84
|
+
kind: CastKind
|
|
85
|
+
constructor: NewTypeId
|
|
86
|
+
arg_newtype: NewTypeId
|
|
87
|
+
line: int
|
|
88
|
+
col: int
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ResolvedType:
|
|
93
|
+
"""A name's statically-known type, resolved in the namespace it was bound under.
|
|
94
|
+
|
|
95
|
+
Exactly one of the four fields is populated when the resolution succeeded;
|
|
96
|
+
the all-None instance represents an unresolved type (which the analyzer
|
|
97
|
+
treats as "we don't know — don't flag anything").
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
newtype: NewTypeId | None = None
|
|
101
|
+
class_id: tuple[str, str] | None = None
|
|
102
|
+
iter_elem_newtype: NewTypeId | None = None
|
|
103
|
+
iter_elem_class: tuple[str, str] | None = None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_empty(self) -> bool:
|
|
107
|
+
return (
|
|
108
|
+
self.newtype is None
|
|
109
|
+
and self.class_id is None
|
|
110
|
+
and self.iter_elem_newtype is None
|
|
111
|
+
and self.iter_elem_class is None
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_EMPTY = ResolvedType()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NewTypeCastAnalyzer:
|
|
119
|
+
"""Scope-aware classifier for NewType cast calls within a single file."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, module_path: str, index: NewTypeIndex) -> None:
|
|
122
|
+
self._module = module_path
|
|
123
|
+
self._index = index
|
|
124
|
+
# Stack of {name: ResolvedType}. Bottom layer is module scope.
|
|
125
|
+
self._scopes: list[dict[str, ResolvedType]] = [{}]
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Scope tracking — driven by the rule's AST hooks
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
132
|
+
scope: dict[str, ResolvedType] = {}
|
|
133
|
+
all_args = itertools.chain(
|
|
134
|
+
node.args.posonlyargs,
|
|
135
|
+
node.args.args,
|
|
136
|
+
node.args.kwonlyargs,
|
|
137
|
+
)
|
|
138
|
+
for arg in all_args:
|
|
139
|
+
self._bind_arg(arg, scope)
|
|
140
|
+
if node.args.vararg is not None:
|
|
141
|
+
self._bind_arg(node.args.vararg, scope)
|
|
142
|
+
if node.args.kwarg is not None:
|
|
143
|
+
self._bind_arg(node.args.kwarg, scope)
|
|
144
|
+
self._scopes.append(scope)
|
|
145
|
+
|
|
146
|
+
def leave_function(self, _node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
147
|
+
self._scopes.pop()
|
|
148
|
+
|
|
149
|
+
def record_ann_assign(self, node: ast.AnnAssign) -> None:
|
|
150
|
+
if not isinstance(node.target, ast.Name):
|
|
151
|
+
return
|
|
152
|
+
resolved = self._resolve_in_module(self._module, node.annotation)
|
|
153
|
+
if not resolved.is_empty:
|
|
154
|
+
self._scopes[-1][node.target.id] = resolved
|
|
155
|
+
|
|
156
|
+
def enter_for(self, node: ast.For | ast.AsyncFor) -> None:
|
|
157
|
+
scope: dict[str, ResolvedType] = {}
|
|
158
|
+
self._bind_iterable_target(node.iter, node.target, scope)
|
|
159
|
+
self._scopes.append(scope)
|
|
160
|
+
|
|
161
|
+
def leave_for(self, _node: ast.For | ast.AsyncFor) -> None:
|
|
162
|
+
self._scopes.pop()
|
|
163
|
+
|
|
164
|
+
def enter_comprehension(self, node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp) -> None:
|
|
165
|
+
scope: dict[str, ResolvedType] = {}
|
|
166
|
+
for gen in node.generators:
|
|
167
|
+
self._bind_iterable_target(gen.iter, gen.target, scope)
|
|
168
|
+
self._scopes.append(scope)
|
|
169
|
+
|
|
170
|
+
def leave_comprehension(
|
|
171
|
+
self,
|
|
172
|
+
_node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._scopes.pop()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Call classification
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def classify_call(self, node: ast.Call) -> CastFinding | None:
|
|
181
|
+
"""Return a CastFinding if `node` is a redundant NewType cast.
|
|
182
|
+
|
|
183
|
+
Returns None when:
|
|
184
|
+
- the callee is not a known NewType,
|
|
185
|
+
- the call uses keyword args or doesn't have exactly one positional arg,
|
|
186
|
+
- the argument is a plain literal or an explicit widening call,
|
|
187
|
+
- the argument's static type cannot be determined,
|
|
188
|
+
- the constructor and argument NewTypes have different bases.
|
|
189
|
+
"""
|
|
190
|
+
constructor = self._resolve_call_target(node)
|
|
191
|
+
if constructor is None or node.keywords or len(node.args) != 1:
|
|
192
|
+
return None
|
|
193
|
+
arg = node.args[0]
|
|
194
|
+
if self._is_literal(arg) or self._is_widening_call(arg):
|
|
195
|
+
return None
|
|
196
|
+
arg_resolved = self._resolve_expression(arg)
|
|
197
|
+
arg_identity = arg_resolved.newtype
|
|
198
|
+
if arg_identity is None:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if arg_identity == constructor:
|
|
202
|
+
kind = CastKind.SELF
|
|
203
|
+
else:
|
|
204
|
+
ctor_base = self._index.canonical_base(constructor)
|
|
205
|
+
arg_base = self._index.canonical_base(arg_identity)
|
|
206
|
+
if ctor_base == BuiltinBase.UNKNOWN or ctor_base != arg_base:
|
|
207
|
+
return None
|
|
208
|
+
kind = CastKind.CROSS_SAME_BASE
|
|
209
|
+
return CastFinding(kind, constructor, arg_identity, node.lineno, node.col_offset + 1)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Binding helpers
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _bind_arg(self, arg: ast.arg, scope: dict[str, ResolvedType]) -> None:
|
|
216
|
+
if arg.annotation is None:
|
|
217
|
+
return
|
|
218
|
+
resolved = self._resolve_in_module(self._module, arg.annotation)
|
|
219
|
+
if not resolved.is_empty:
|
|
220
|
+
scope[arg.arg] = resolved
|
|
221
|
+
|
|
222
|
+
def _bind_iterable_target(
|
|
223
|
+
self,
|
|
224
|
+
iter_expr: ast.expr,
|
|
225
|
+
target: ast.expr,
|
|
226
|
+
scope: dict[str, ResolvedType],
|
|
227
|
+
) -> None:
|
|
228
|
+
if not isinstance(target, ast.Name):
|
|
229
|
+
return
|
|
230
|
+
container = self._resolve_expression(iter_expr)
|
|
231
|
+
element = ResolvedType(
|
|
232
|
+
newtype=container.iter_elem_newtype,
|
|
233
|
+
class_id=container.iter_elem_class,
|
|
234
|
+
)
|
|
235
|
+
if not element.is_empty:
|
|
236
|
+
scope[target.id] = element
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Resolution: expression → ResolvedType
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _resolve_expression(self, expr: ast.expr) -> ResolvedType:
|
|
243
|
+
if isinstance(expr, ast.Name):
|
|
244
|
+
return self._lookup_name(expr.id)
|
|
245
|
+
if isinstance(expr, ast.Attribute):
|
|
246
|
+
return self._resolve_attribute(expr)
|
|
247
|
+
if isinstance(expr, ast.Call):
|
|
248
|
+
return self._resolve_call(expr)
|
|
249
|
+
return _EMPTY
|
|
250
|
+
|
|
251
|
+
def _lookup_name(self, name: str) -> ResolvedType:
|
|
252
|
+
for scope in reversed(self._scopes):
|
|
253
|
+
if name in scope:
|
|
254
|
+
return scope[name]
|
|
255
|
+
return _EMPTY
|
|
256
|
+
|
|
257
|
+
def _resolve_attribute(self, node: ast.Attribute) -> ResolvedType:
|
|
258
|
+
owner = self._resolve_expression(node.value)
|
|
259
|
+
if owner.class_id is None:
|
|
260
|
+
return _EMPTY
|
|
261
|
+
class_module, class_name = owner.class_id
|
|
262
|
+
annotation = self._index.get_class_field_annotation(class_module, class_name, node.attr)
|
|
263
|
+
if annotation is None:
|
|
264
|
+
return _EMPTY
|
|
265
|
+
return self._resolve_in_module(class_module, annotation)
|
|
266
|
+
|
|
267
|
+
def _resolve_call(self, node: ast.Call) -> ResolvedType:
|
|
268
|
+
if not isinstance(node.func, ast.Name):
|
|
269
|
+
return _EMPTY
|
|
270
|
+
target = self._index.find_function_module(self._module, node.func.id)
|
|
271
|
+
if target is None:
|
|
272
|
+
return _EMPTY
|
|
273
|
+
defining_module, original_name = target
|
|
274
|
+
annotation = self._index.get_function_return_annotation(defining_module, original_name)
|
|
275
|
+
if annotation is None:
|
|
276
|
+
return _EMPTY
|
|
277
|
+
return self._resolve_in_module(defining_module, annotation)
|
|
278
|
+
|
|
279
|
+
def _resolve_call_target(self, node: ast.Call) -> NewTypeId | None:
|
|
280
|
+
func = node.func
|
|
281
|
+
if isinstance(func, ast.Name):
|
|
282
|
+
return self._index.resolve_local_name(self._module, func.id)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Resolution: annotation expression in some module's namespace
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def _resolve_in_module(self, namespace_module: str, annotation: ast.expr) -> ResolvedType:
|
|
290
|
+
"""Resolve an annotation expression in the namespace of `namespace_module`.
|
|
291
|
+
|
|
292
|
+
Recognises three forms:
|
|
293
|
+
* a bare Name that is a project NewType,
|
|
294
|
+
* a bare Name that is a project class,
|
|
295
|
+
* a Subscript over a recognised iterable container whose element is one of
|
|
296
|
+
the two above.
|
|
297
|
+
"""
|
|
298
|
+
if isinstance(annotation, ast.Name):
|
|
299
|
+
return self._resolve_name_in_module(namespace_module, annotation.id)
|
|
300
|
+
if isinstance(annotation, ast.Subscript):
|
|
301
|
+
element_annotation = self._extract_element_annotation(annotation)
|
|
302
|
+
if element_annotation is None:
|
|
303
|
+
return _EMPTY
|
|
304
|
+
inner = self._resolve_in_module(namespace_module, element_annotation)
|
|
305
|
+
if inner.newtype is not None:
|
|
306
|
+
return ResolvedType(iter_elem_newtype=inner.newtype)
|
|
307
|
+
if inner.class_id is not None:
|
|
308
|
+
return ResolvedType(iter_elem_class=inner.class_id)
|
|
309
|
+
return _EMPTY
|
|
310
|
+
return _EMPTY
|
|
311
|
+
|
|
312
|
+
def _resolve_name_in_module(self, namespace_module: str, name: str) -> ResolvedType:
|
|
313
|
+
newtype = self._index.resolve_local_name(namespace_module, name)
|
|
314
|
+
if newtype is not None:
|
|
315
|
+
return ResolvedType(newtype=newtype)
|
|
316
|
+
class_id = self._index.find_class_module(namespace_module, name)
|
|
317
|
+
if class_id is not None:
|
|
318
|
+
return ResolvedType(class_id=class_id)
|
|
319
|
+
return _EMPTY
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _extract_element_annotation(annotation: ast.Subscript) -> ast.expr | None:
|
|
323
|
+
if not isinstance(annotation.value, ast.Name):
|
|
324
|
+
return None
|
|
325
|
+
container = annotation.value.id
|
|
326
|
+
slice_expr = annotation.slice
|
|
327
|
+
if container in _SINGLE_PARAM_ITERABLES:
|
|
328
|
+
return slice_expr
|
|
329
|
+
if container in _MAPPING_ITERABLES:
|
|
330
|
+
return slice_expr.elts[0] if isinstance(slice_expr, ast.Tuple) and slice_expr.elts else None
|
|
331
|
+
if container in _TUPLE_NAMES:
|
|
332
|
+
return _extract_homogeneous_tuple_element(slice_expr)
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _is_literal(expr: ast.expr) -> bool:
|
|
337
|
+
return isinstance(expr, ast.Constant)
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _is_widening_call(expr: ast.expr) -> bool:
|
|
341
|
+
if not isinstance(expr, ast.Call):
|
|
342
|
+
return False
|
|
343
|
+
func = expr.func
|
|
344
|
+
return isinstance(func, ast.Name) and func.id in _WIDENING_BUILTINS
|