python-constricter 0.2.2__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.
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Every local variable typed where it's first bound."""
3
+
4
+ from constricter.checker import (
5
+ COMMENT_TYPED_TARGET,
6
+ DEFAULT_CHECKS,
7
+ LEVELS,
8
+ NESTED_TYPE,
9
+ NESTING,
10
+ UNANNOTATED,
11
+ UNANNOTATED_MEMBER,
12
+ UNTYPED_TARGET,
13
+ VAGUE_TYPE,
14
+ Checks,
15
+ Coverage,
16
+ Level,
17
+ Offence,
18
+ annotation_coverage,
19
+ check_source,
20
+ check_tree,
21
+ )
22
+
23
+ __version__ = "0.2.2"
24
+ __all__ = [
25
+ "COMMENT_TYPED_TARGET",
26
+ "DEFAULT_CHECKS",
27
+ "LEVELS",
28
+ "NESTED_TYPE",
29
+ "NESTING",
30
+ "UNANNOTATED",
31
+ "UNANNOTATED_MEMBER",
32
+ "UNTYPED_TARGET",
33
+ "VAGUE_TYPE",
34
+ "Checks",
35
+ "Coverage",
36
+ "Level",
37
+ "Offence",
38
+ "__version__",
39
+ "annotation_coverage",
40
+ "check_source",
41
+ "check_tree",
42
+ ]
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`python -m constricter`."""
3
+
4
+ import sys
5
+
6
+ from constricter.cli import main
7
+
8
+ if __name__ == "__main__": # not when `--jobs` workers import it
9
+ sys.exit(main())
@@ -0,0 +1,290 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """What an annotation says: vague parts (LVA005), nesting depth (LVA006), and inferable values (`--fix`)."""
3
+
4
+ import ast
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import TYPE_CHECKING, Final, cast
8
+
9
+ if TYPE_CHECKING:
10
+ from types import EllipsisType
11
+
12
+ _VAGUE: Final = frozenset({"Any", "object"})
13
+ # Generics that say little without their parameters.
14
+ _GENERICS: Final = frozenset(
15
+ {
16
+ "AbstractSet",
17
+ "AsyncGenerator",
18
+ "AsyncIterable",
19
+ "AsyncIterator",
20
+ "Awaitable",
21
+ "Callable",
22
+ "ChainMap",
23
+ "Collection",
24
+ "Container",
25
+ "Coroutine",
26
+ "Counter",
27
+ "DefaultDict",
28
+ "Deque",
29
+ "Dict",
30
+ "FrozenSet",
31
+ "Generator",
32
+ "ItemsView",
33
+ "Iterable",
34
+ "Iterator",
35
+ "KeysView",
36
+ "List",
37
+ "Mapping",
38
+ "Match",
39
+ "MutableMapping",
40
+ "MutableSequence",
41
+ "MutableSet",
42
+ "OrderedDict",
43
+ "Pattern",
44
+ "Reversible",
45
+ "Sequence",
46
+ "Set",
47
+ "Tuple",
48
+ "Type",
49
+ "ValuesView",
50
+ "defaultdict",
51
+ "deque",
52
+ "dict",
53
+ "frozenset",
54
+ "list",
55
+ "set",
56
+ "tuple",
57
+ "type",
58
+ },
59
+ )
60
+ # Calls that return a class or a special form, not an instance of what they're named.
61
+ _FACTORIES: Final = frozenset(
62
+ {
63
+ "Enum",
64
+ "Flag",
65
+ "IntEnum",
66
+ "IntFlag",
67
+ "NamedTuple",
68
+ "NewType",
69
+ "ParamSpec",
70
+ "StrEnum",
71
+ "TypeVar",
72
+ "TypeVarTuple",
73
+ "TypedDict",
74
+ },
75
+ )
76
+ _NUMBERS: Final = (int, float, complex)
77
+ _TYPE_VARS: Final = frozenset({"TypeVar", "ParamSpec", "TypeVarTuple"})
78
+
79
+
80
+ def _parsed(annotation: ast.expr) -> ast.expr:
81
+ """Unwrap a string annotation.
82
+
83
+ Returns:
84
+ Its parsed expression, or the annotation itself if it isn't a string.
85
+
86
+ """
87
+ if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
88
+ try:
89
+ return ast.parse(annotation.value, mode="eval").body
90
+ except SyntaxError:
91
+ return annotation
92
+ return annotation
93
+
94
+
95
+ def _name(node: ast.AST) -> str:
96
+ name: str
97
+ match node:
98
+ case ast.Name(id=name) | ast.Attribute(attr=name):
99
+ return name
100
+ case _:
101
+ return ""
102
+
103
+
104
+ def is_vague(annotation: ast.expr) -> bool:
105
+ """Check an annotation for vague types.
106
+
107
+ Returns:
108
+ Whether it has `Any`, `object` or a generic without its parameters in it.
109
+
110
+ """
111
+ root: ast.expr = _parsed(annotation)
112
+ subscripted: set[int] = {id(node.value) for node in ast.walk(root) if isinstance(node, ast.Subscript)}
113
+ node: ast.AST
114
+ for node in ast.walk(root):
115
+ name: str = _name(node)
116
+ if name in _VAGUE or (name in _GENERICS and id(node) not in subscripted):
117
+ return True
118
+ return False
119
+
120
+
121
+ def depth(annotation: ast.expr) -> int:
122
+ """Measure how deeply an annotation's subscripts nest.
123
+
124
+ Returns:
125
+ The depth: `dict[str, list[int]]` is 2.
126
+
127
+ """
128
+ node: ast.expr = _parsed(annotation)
129
+ inner: ast.expr
130
+ parts: list[ast.expr]
131
+ left: ast.expr
132
+ right: ast.expr
133
+ match node:
134
+ case ast.Subscript(slice=inner):
135
+ return 1 + depth(inner)
136
+ case ast.Tuple(elts=parts) | ast.List(elts=parts):
137
+ return max((depth(part) for part in parts), default=0)
138
+ case ast.BinOp(left=left, right=right):
139
+ return max(depth(left), depth(right))
140
+ case _:
141
+ return 0
142
+
143
+
144
+ def returns(tree: ast.Module) -> dict[str, str]:
145
+ """Return the declared return type of each plain top-level function whose calls `--fix` can annotate.
146
+
147
+ Skipped: decorated, generic, async and redefined functions, and returns that are `None`, vague, or
148
+ mention a module-level `TypeVar` (a call's type then depends on its arguments).
149
+
150
+ Returns:
151
+ Each such function's name, and its return annotation as source text.
152
+
153
+ """
154
+ type_vars: set[str] = set()
155
+ counts: dict[str, int] = {}
156
+ found: dict[str, str] = {}
157
+ stmt: ast.stmt
158
+ name: str
159
+ func: ast.expr
160
+ for stmt in tree.body:
161
+ match stmt:
162
+ case ast.Assign(targets=[ast.Name(id=name)], value=ast.Call(func=func)) if (
163
+ _name(func) in _TYPE_VARS
164
+ ):
165
+ type_vars.add(name)
166
+ case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name):
167
+ counts[name] = counts.get(name, 0) + 1
168
+ if isinstance(stmt, ast.FunctionDef) and _plain(stmt):
169
+ found[name] = ast.unparse(cast("ast.expr", stmt.returns))
170
+ case _:
171
+ pass
172
+ return {
173
+ name: annotation
174
+ for name, annotation in found.items()
175
+ if counts[name] == 1 and not type_vars & set(_words(annotation))
176
+ }
177
+
178
+
179
+ def _plain(func: ast.FunctionDef) -> bool:
180
+ """Check that `func` declares a return type its calls always have.
181
+
182
+ Returns:
183
+ Whether it does: not `None`, and not vague.
184
+
185
+ """
186
+ return (
187
+ not func.decorator_list
188
+ and not cast("object", getattr(func, "type_params", ())) # Python 3.12+'s `def f[T]()`
189
+ and func.returns is not None
190
+ and not (isinstance(func.returns, ast.Constant) and func.returns.value is None)
191
+ and not is_vague(func.returns)
192
+ )
193
+
194
+
195
+ def _words(annotation: str) -> list[str]:
196
+ return [word for word in re.split(r"\W+", annotation) if word]
197
+
198
+
199
+ def inferred(value: ast.expr, calls: Mapping[str, str]) -> str | None:
200
+ """Return the annotation `value` makes unambiguous, given the module's function `calls`.
201
+
202
+ 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.
204
+
205
+ Returns:
206
+ The annotation as source text, or `None` if the value doesn't decide one.
207
+
208
+ """
209
+ return _scalar(value) or _container(value, calls) or _called(value, calls)
210
+
211
+
212
+ def _scalar(value: ast.expr) -> str | None:
213
+ constant: str | bytes | bool | int | float | complex | EllipsisType | None
214
+ match value:
215
+ case ast.Constant(value=bool() | int() | float() | complex() | str() | bytes() as constant):
216
+ return type(constant).__name__
217
+ case ast.UnaryOp(op=ast.USub() | ast.UAdd(), operand=ast.Constant(value=constant)) if isinstance(
218
+ constant,
219
+ _NUMBERS,
220
+ ) and not isinstance(constant, bool):
221
+ return type(constant).__name__
222
+ case ast.JoinedStr():
223
+ return "str"
224
+ case _:
225
+ return None
226
+
227
+
228
+ def _container(value: ast.expr, calls: Mapping[str, str]) -> str | None:
229
+ elements: list[ast.expr]
230
+ keys: list[ast.expr | None]
231
+ values: list[ast.expr]
232
+ parts: list[str | None]
233
+ match value:
234
+ case ast.List(elts=elements) | ast.Set(elts=elements) if elements:
235
+ element: str | None = _uniform(elements, calls)
236
+ return f"{'list' if isinstance(value, ast.List) else 'set'}[{element}]" if element else None
237
+ case ast.Tuple(elts=elements) if elements:
238
+ parts = [inferred(element, calls) for element in elements]
239
+ return None if None in parts else f"tuple[{', '.join(str(part) for part in parts)}]"
240
+ 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)
243
+ return f"dict[{key}, {item}]" if key and item else None
244
+ case _:
245
+ return None
246
+
247
+
248
+ def _uniform(elements: Sequence[ast.expr], calls: Mapping[str, str]) -> str | None:
249
+ """Find the one type every element has.
250
+
251
+ Returns:
252
+ That type, or `None` if they differ or any is unknown.
253
+
254
+ """
255
+ types: set[str | None] = {inferred(element, calls) for element in elements}
256
+ return next(iter(types)) if len(types) == 1 else None
257
+
258
+
259
+ def _called(value: ast.expr, calls: Mapping[str, str]) -> str | None:
260
+ func: ast.expr
261
+ match value:
262
+ case ast.Call(func=ast.Name() | ast.Attribute() as func) if ast.unparse(func) in calls:
263
+ return calls[ast.unparse(func)]
264
+ case ast.Call(func=ast.Name() | ast.Attribute() as func) if _constructs(_name(func)):
265
+ return ast.unparse(func)
266
+ case _:
267
+ return None
268
+
269
+
270
+ def guessed(value: ast.expr, calls: Mapping[str, str]) -> bool:
271
+ """Whether `inferred`'s annotation for `value` is a guess (`--unsafe-fixes`): it calls a class.
272
+
273
+ 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.
275
+
276
+ Returns:
277
+ Whether any call in `value` is to something other than such a module function.
278
+
279
+ """
280
+ return any(isinstance(node, ast.Call) and ast.unparse(node.func) not in calls for node in ast.walk(value))
281
+
282
+
283
+ def _constructs(name: str) -> bool:
284
+ """Check whether a call to `name` constructs a class, by its capitalised name.
285
+
286
+ Returns:
287
+ Whether it does, and is worth annotating.
288
+
289
+ """
290
+ return name[:1].isupper() and name not in _FACTORIES and name not in _GENERICS
@@ -0,0 +1,111 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Baseline files: the offences a codebase already has, so only new ones are reported.
3
+
4
+ An entry is a file, a code and a variable name, with how many times it occurs; line numbers aren't
5
+ kept, so a baseline survives code moving around. Paths are relative to the baseline file.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from collections import Counter
11
+ from collections.abc import Mapping, Sequence
12
+ from pathlib import Path
13
+ from typing import Final, TypeAlias, cast
14
+
15
+ from constricter import jsonc
16
+ from constricter.checker import Offence
17
+
18
+ VERSION: Final = 1
19
+ Entries: TypeAlias = dict[str, dict[str, int]] # file -> "CODE name" -> count
20
+ _Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None"
21
+
22
+
23
+ def key(path: Path, baseline: Path) -> str:
24
+ """Make `path` relative to the baseline at `baseline`.
25
+
26
+ Returns:
27
+ `path` as the baseline records it: relative to it, with `/`.
28
+
29
+ """
30
+ return Path(os.path.relpath(path.resolve(), baseline.resolve().parent)).as_posix()
31
+
32
+
33
+ def _entry(offence: Offence) -> str:
34
+ return f"{offence.code} {offence.name}"
35
+
36
+
37
+ def read(baseline: Path) -> Entries:
38
+ """Return the entries in the baseline file `baseline`.
39
+
40
+ Returns:
41
+ Each file's count of each `CODE name` entry.
42
+
43
+ Raises:
44
+ ValueError: It can't be read, or isn't a baseline.
45
+
46
+ """
47
+ message: str
48
+ document: _Json
49
+ try:
50
+ document = cast("_Json", jsonc.loads(baseline.read_bytes()))
51
+ except (OSError, ValueError) as error:
52
+ message = f"{baseline}: can't read the baseline ({error}); create it with --write-baseline"
53
+ raise ValueError(message) from error
54
+ files: dict[str, _Json]
55
+ entries: Entries = {}
56
+ match document:
57
+ case {"version": 1, "offences": dict() as files}:
58
+ path: str
59
+ counts: _Json
60
+ for path, counts in files.items():
61
+ if not isinstance(counts, dict) or not all(_is_count(n) for n in counts.values()):
62
+ break
63
+ entries[path] = {entry: n for entry, n in counts.items() if isinstance(n, int)}
64
+ else:
65
+ return entries
66
+ case _:
67
+ pass
68
+ message = f"{baseline}: not a constricter baseline (version {VERSION})"
69
+ raise ValueError(message)
70
+
71
+
72
+ def _is_count(value: _Json) -> bool:
73
+ return isinstance(value, int) and not isinstance(value, bool)
74
+
75
+
76
+ def write(baseline: Path, found: Mapping[str, Sequence[Offence]]) -> int:
77
+ """Write every offence in `found` (keyed as `key` makes them) to `baseline`.
78
+
79
+ Returns:
80
+ How many it wrote.
81
+
82
+ """
83
+ files: Entries = {
84
+ path: dict(sorted(Counter(_entry(o) for o in offences).items()))
85
+ for path, offences in sorted(found.items())
86
+ if offences
87
+ }
88
+ _ = baseline.write_text(
89
+ json.dumps({"version": VERSION, "offences": files}, indent=2) + "\n",
90
+ encoding="utf-8",
91
+ newline="\n",
92
+ )
93
+ return sum(len(offences) for offences in found.values())
94
+
95
+
96
+ def remaining(offences: Sequence[Offence], counts: Mapping[str, int]) -> tuple[list[Offence], int]:
97
+ """Match one file's offences against the baseline's `counts` for it.
98
+
99
+ Returns:
100
+ The offences it doesn't cover, and how many it does.
101
+
102
+ """
103
+ left: Counter[str] = Counter(counts)
104
+ kept: list[Offence] = []
105
+ o: Offence
106
+ for o in offences:
107
+ if left[_entry(o)] > 0:
108
+ left[_entry(o)] -= 1
109
+ else:
110
+ kept.append(o)
111
+ return kept, len(offences) - len(kept)