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.
Files changed (36) hide show
  1. ml_lints/__init__.py +4 -0
  2. ml_lints/analyzers/__init__.py +0 -0
  3. ml_lints/analyzers/forbidden_types.py +136 -0
  4. ml_lints/analyzers/newtype_casts.py +344 -0
  5. ml_lints/analyzers/newtype_index.py +326 -0
  6. ml_lints/cli.py +512 -0
  7. ml_lints/noqa.py +27 -0
  8. ml_lints/rules/__init__.py +136 -0
  9. ml_lints/rules/_newtype_cast_base.py +103 -0
  10. ml_lints/rules/_template.py +59 -0
  11. ml_lints/rules/ml100_bare_dict.py +72 -0
  12. ml_lints/rules/ml101_bare_tuple.py +71 -0
  13. ml_lints/rules/ml102_dict_of_primitives.py +87 -0
  14. ml_lints/rules/ml103_fixed_tuple.py +71 -0
  15. ml_lints/rules/ml104_variable_tuple.py +67 -0
  16. ml_lints/rules/ml105_newtype_forbidden.py +74 -0
  17. ml_lints/rules/ml106_bare_mapping.py +72 -0
  18. ml_lints/rules/ml107_mapping_of_primitives.py +69 -0
  19. ml_lints/rules/ml108_newtype_self_cast.py +71 -0
  20. ml_lints/rules/ml109_newtype_cross_cast.py +70 -0
  21. ml_lints/rules/ml110_variable_tuple_param.py +88 -0
  22. ml_lints/rules/ml200_frozen_dataclass.py +75 -0
  23. ml_lints/rules/ml201_wrapper_class.py +79 -0
  24. ml_lints/rules/ml202_dict_spread_constructor.py +103 -0
  25. ml_lints/rules/ml300_inner_class.py +81 -0
  26. ml_lints/rules/ml400_untrusted_data.py +200 -0
  27. ml_lints/rules/ml500_australian_english.py +322 -0
  28. ml_lints/rules/ml501_hacky_pluralisation.py +124 -0
  29. ml_lints/rules/ml600.py +120 -0
  30. ml_lints/rules/spelling_map.json +1768 -0
  31. ml_lints/runner.py +95 -0
  32. ml_lints/violation.py +45 -0
  33. ml_lints-0.12.1.dist-info/METADATA +7 -0
  34. ml_lints-0.12.1.dist-info/RECORD +36 -0
  35. ml_lints-0.12.1.dist-info/WHEEL +4 -0
  36. ml_lints-0.12.1.dist-info/entry_points.txt +2 -0
ml_lints/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ml_lints.runner import check_file
2
+ from ml_lints.violation import Violation
3
+
4
+ __all__ = ["Violation", "check_file"]
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