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 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.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",
@@ -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 _name(node: ast.AST) -> str:
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 = _name(node)
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
- _name(func) in _TYPE_VARS
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(value: ast.expr, calls: Mapping[str, str]) -> str | None:
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, or a class it constructs.
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
- return _scalar(value) or _container(value, calls) or _called(value, calls)
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(value: ast.expr, calls: Mapping[str, str]) -> str | None:
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 = [inferred(element, calls) for element in elements]
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
- key: str | None = _uniform([k for k in keys if k is not None], calls)
242
- item: str | None = _uniform(values, calls)
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(elements: Sequence[ast.expr], calls: Mapping[str, str]) -> str | None:
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] = {inferred(element, calls) for element in elements}
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 _called(value: ast.expr, calls: Mapping[str, str]) -> str | None:
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() | ast.Attribute() as func) if _constructs(_name(func)):
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(value: ast.expr, calls: Mapping[str, str]) -> bool:
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 and calls to module functions with a declared return type are certain.
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 module function.
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(isinstance(node, ast.Call) and ast.unparse(node.func) not in calls for node in ast.walk(value))
612
+ return any(_is_guess(node, calls, guesses, declared) for node in ast.walk(value))
281
613
 
282
614
 
283
- def _constructs(name: str) -> bool:
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 name[:1].isupper() and name not in _FACTORIES and name not in _GENERICS
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(_is_count(n) for n in counts.values()):
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