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,502 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Value flow: every type a name is bound to over its lifetime in a scope, against its declared type.
|
|
3
|
+
|
|
4
|
+
Three rules come of it (`check_tree` reports them): a declared type every value fits a strictly
|
|
5
|
+
narrower one of (LVA008), a value that doesn't fit the declared type at all (LVA009), and a declared
|
|
6
|
+
union member no value ever uses (LVA010).
|
|
7
|
+
|
|
8
|
+
It's flow-insensitive: the order and branches bindings happen in don't matter, only the set of
|
|
9
|
+
values a name is ever bound to. A binding whose value `--fix` can't infer with certainty is
|
|
10
|
+
*unknown*, and one unknown binding (or a write from a nested function) stops the narrowing claims
|
|
11
|
+
(LVA008, LVA010), since the name may hold something else; it never stops LVA009, which only needs
|
|
12
|
+
the one value it reports. The checker also stops them for every name in a module or class body,
|
|
13
|
+
which other code can rebind out of sight.
|
|
14
|
+
|
|
15
|
+
Types are compared through a `Hierarchy` of which named types are narrower than which: `bool` is
|
|
16
|
+
narrower than `int`, `int` than `float` and `float` than `complex` (the numeric tower), and a class
|
|
17
|
+
defined in the module is narrower than each base it names. Any other type is only as narrow as
|
|
18
|
+
itself; a parameterised generic (`list[int]`) is compared by its text.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterable, Mapping
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
from typing import Final, TypeAlias
|
|
28
|
+
|
|
29
|
+
from constricter.rules.walked import classes as defined_classes
|
|
30
|
+
|
|
31
|
+
# Each named type's directly wider types.
|
|
32
|
+
Parents: TypeAlias = Mapping[str, frozenset[str]]
|
|
33
|
+
# One project-declared type and the types it's narrower than (`Checks.narrower`, `parse_narrower`).
|
|
34
|
+
Narrower: TypeAlias = tuple[str, tuple[str, ...]]
|
|
35
|
+
DEFAULT_PARENTS: Final[Parents] = {
|
|
36
|
+
"bool": frozenset({"int"}),
|
|
37
|
+
"int": frozenset({"float"}),
|
|
38
|
+
"float": frozenset({"complex"}),
|
|
39
|
+
}
|
|
40
|
+
# Annotations every value fits: nothing is learnt from comparing a value with one (LVA005's domain).
|
|
41
|
+
_OBJECT: Final = "object"
|
|
42
|
+
_ANYTHING: Final = frozenset({"Any", _OBJECT})
|
|
43
|
+
_NONE: Final = "None"
|
|
44
|
+
_OPTIONAL: Final = "Optional"
|
|
45
|
+
_UNION: Final = "Union"
|
|
46
|
+
_COMPLEX: Final = "complex"
|
|
47
|
+
_TEXT: Final = frozenset({"str", "bytes"})
|
|
48
|
+
# A tuple of a fixed length, as text: `tuple[int, str]`, not `tuple[int, ...]`.
|
|
49
|
+
_FIXED_TUPLE: Final = re.compile(r"(?:tuple|Tuple)\[(?!.*,\s*\.\.\.\]$).*\]")
|
|
50
|
+
# `typing`'s capitalised aliases, compared as the builtins they stand for.
|
|
51
|
+
_ALIASES: Final = {
|
|
52
|
+
"Dict": "dict",
|
|
53
|
+
"FrozenSet": "frozenset",
|
|
54
|
+
"List": "list",
|
|
55
|
+
"Set": "set",
|
|
56
|
+
"Tuple": "tuple",
|
|
57
|
+
"Type": "type",
|
|
58
|
+
}
|
|
59
|
+
_TYPING_MODULES: Final = frozenset({"typing", "typing_extensions"})
|
|
60
|
+
# Qualifiers around the type that holds the values (`ClassVar[int]`, `Final[int]`); bare, they say
|
|
61
|
+
# nothing (`x: Final = 3`'s type is inferred).
|
|
62
|
+
_QUALIFIERS: Final = frozenset({"ClassVar", "Final"})
|
|
63
|
+
_ANNOTATED: Final = "Annotated"
|
|
64
|
+
# Types whose every narrower type is known: nothing outside this list subclasses one as far as the
|
|
65
|
+
# rules are concerned (a `bool` is the one narrower builtin, and the hierarchy knows it).
|
|
66
|
+
_CLOSED: Final = frozenset({"None", "bool", "bytearray", "bytes", "complex", "float", "int", "str"})
|
|
67
|
+
# Builtin generics, compared by the container alone: a display's element types follow its context
|
|
68
|
+
# (`flags: tuple[str, ...] = ("-q",)`, `rows: list[Base] = [child]`), so they decide nothing.
|
|
69
|
+
_CLOSED_GENERICS: Final = frozenset({"dict", "frozenset", "list", "set", "tuple", "type"})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Kind(StrEnum):
|
|
73
|
+
"""What a `Finding` says, as the code that reports it."""
|
|
74
|
+
|
|
75
|
+
NARROWABLE = "LVA008" # every value fits a strictly narrower type than the declared one
|
|
76
|
+
CONFLICT = "LVA009" # a value doesn't fit the declared type
|
|
77
|
+
UNUSED_MEMBER = "LVA010" # a declared union member no value uses
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, order=True)
|
|
81
|
+
class Finding:
|
|
82
|
+
"""One value-flow result; `col` is 0-based, `detail` is the type it's about, as text."""
|
|
83
|
+
|
|
84
|
+
line: int
|
|
85
|
+
col: int
|
|
86
|
+
name: str
|
|
87
|
+
kind: Kind
|
|
88
|
+
detail: str
|
|
89
|
+
# LVA008's and LVA010's: the annotation it could be instead, and where the declared one is on
|
|
90
|
+
# its line (start and end columns; `None` where it isn't all on the name's line).
|
|
91
|
+
rewrite: str = field(default="", compare=False)
|
|
92
|
+
span: tuple[int, int] | None = field(default=None, compare=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Binding:
|
|
97
|
+
"""One binding of a name: where, and its value's type as text (`None`: unknown).
|
|
98
|
+
|
|
99
|
+
`guess`: when the type isn't certain, `--fix`'s guess at it, and what that rests on (`FIX_KINDS`).
|
|
100
|
+
Value flow never compares a guess; `--fix` offers `T | None` from one, as a guess too.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
at: tuple[int, int]
|
|
104
|
+
value: str | None
|
|
105
|
+
guess: tuple[str, frozenset[str]] | None = field(default=None, compare=False)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Lifetime:
|
|
110
|
+
"""Every binding of one name in one scope, and its declared type (its first annotation)."""
|
|
111
|
+
|
|
112
|
+
declared: str | None = None
|
|
113
|
+
declared_at: tuple[int, int] = (0, 0)
|
|
114
|
+
declared_span: tuple[int, int] | None = None # the annotation's columns, if it's all on one line
|
|
115
|
+
bindings: list[Binding] = field(default_factory=list[Binding])
|
|
116
|
+
escaped: bool = False # written somewhere this scope can't see (`global`, `nonlocal`, ...)
|
|
117
|
+
|
|
118
|
+
def declare(self, annotation: str, at: tuple[int, int], span: tuple[int, int] | None = None) -> None:
|
|
119
|
+
"""Record an annotation, and its columns if it's all on the name's line; only the first counts."""
|
|
120
|
+
if self.declared is None:
|
|
121
|
+
self.declared = annotation
|
|
122
|
+
self.declared_at = at
|
|
123
|
+
self.declared_span = span
|
|
124
|
+
|
|
125
|
+
def bind(
|
|
126
|
+
self,
|
|
127
|
+
at: tuple[int, int],
|
|
128
|
+
value: str | None,
|
|
129
|
+
guess: tuple[str, frozenset[str]] | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Record a binding, with its value's type if known (or `--fix`'s guess at it, if not)."""
|
|
132
|
+
self.bindings.append(Binding(at, value, guess))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Hierarchy:
|
|
136
|
+
"""Which named types are narrower than which; see the module docstring."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, parents: Mapping[str, Iterable[str]], classes: Iterable[str] = ()) -> None:
|
|
139
|
+
"""Take each named type's directly wider types (`DEFAULT_PARENTS`' shape).
|
|
140
|
+
|
|
141
|
+
`classes` are the other types whose whole ancestry `parents` holds (see `closed`).
|
|
142
|
+
"""
|
|
143
|
+
self.parents: dict[str, frozenset[str]] = {name: frozenset(wider) for name, wider in parents.items()}
|
|
144
|
+
self.classes: frozenset[str] = frozenset(classes)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def for_module(
|
|
148
|
+
cls,
|
|
149
|
+
tree: ast.Module,
|
|
150
|
+
narrower: Parents | None = None,
|
|
151
|
+
same: Iterable[frozenset[str]] = (),
|
|
152
|
+
) -> "Hierarchy":
|
|
153
|
+
"""Build the default hierarchy, plus each class the module defines under the bases it names.
|
|
154
|
+
|
|
155
|
+
A project's own `narrower` entries (each type's wider types) replace whatever the defaults
|
|
156
|
+
or the module say for that type (`int = []` stops `int` fitting `float`); every type they
|
|
157
|
+
name counts as `closed`, since the project vouches for its ancestry. `same`: each group of
|
|
158
|
+
ways the module spells one type (`C`, `m.C`), each of which fits the others.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The hierarchy.
|
|
162
|
+
|
|
163
|
+
"""
|
|
164
|
+
own: Parents = narrower or {}
|
|
165
|
+
parents: dict[str, frozenset[str]] = dict(DEFAULT_PARENTS)
|
|
166
|
+
defined: dict[str, list[ast.expr]] = {}
|
|
167
|
+
node: ast.AST
|
|
168
|
+
bases: frozenset[str]
|
|
169
|
+
for node in defined_classes(tree):
|
|
170
|
+
defined[node.name] = node.bases
|
|
171
|
+
if bases := frozenset(b.id for b in node.bases if isinstance(b, ast.Name)):
|
|
172
|
+
parents[node.name] = bases
|
|
173
|
+
group: frozenset[str]
|
|
174
|
+
for group in same:
|
|
175
|
+
parents.update((name, parents.get(name, frozenset()) | (group - {name})) for name in group)
|
|
176
|
+
parents.update(own)
|
|
177
|
+
vouched: frozenset[str] = frozenset(own).union(*own.values())
|
|
178
|
+
return cls(parents, vouched | {name for name in defined if _visible(name, defined, frozenset())})
|
|
179
|
+
|
|
180
|
+
def wider(self, atom: str) -> frozenset[str]:
|
|
181
|
+
"""Find every type `atom` is narrower than, directly or not.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Them, not including `atom` itself.
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
return self._wider(atom, frozenset({atom}))
|
|
188
|
+
|
|
189
|
+
def _wider(self, atom: str, seen: frozenset[str]) -> frozenset[str]:
|
|
190
|
+
"""Find the types `atom` is narrower than that aren't in `seen` (which a cycle leads back to).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Them.
|
|
194
|
+
|
|
195
|
+
"""
|
|
196
|
+
direct: frozenset[str] = self.parents.get(atom, frozenset()) - seen
|
|
197
|
+
return direct.union(*(self._wider(parent, seen | direct) for parent in direct))
|
|
198
|
+
|
|
199
|
+
def closed(self, atom: str) -> bool:
|
|
200
|
+
"""Check whether every type narrower than `atom`, and every wider one, is known.
|
|
201
|
+
|
|
202
|
+
A builtin scalar, a builtin generic (by its text), or a class the module defines whose bases
|
|
203
|
+
are all such classes; not an imported class (its bases, and subclasses, are out of sight), a
|
|
204
|
+
protocol's structural matches, or an alias. Only a closed type's mismatch is certain.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Whether it's closed.
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
return atom in _CLOSED or atom in self.classes or _container(atom) in _CLOSED_GENERICS
|
|
211
|
+
|
|
212
|
+
def fits(self, atom: str, other: str) -> bool:
|
|
213
|
+
"""Check whether a value of type `atom` fits `other`.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Whether they're the same type, or `atom` is narrower.
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
return (
|
|
220
|
+
atom == other
|
|
221
|
+
or other in _ANYTHING
|
|
222
|
+
or other in self.wider(atom)
|
|
223
|
+
or (_container(atom) in _CLOSED_GENERICS and _container(atom) == _container(other))
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def fits_all(self, value: frozenset[str], declared: frozenset[str]) -> bool:
|
|
227
|
+
"""Check whether a value of the union `value` fits the union `declared`.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Whether every member of `value` fits some member of `declared`.
|
|
231
|
+
|
|
232
|
+
"""
|
|
233
|
+
return all(any(self.fits(atom, member) for member in declared) for atom in value)
|
|
234
|
+
|
|
235
|
+
def simplified(self, atoms: Iterable[str]) -> frozenset[str]:
|
|
236
|
+
"""Drop every member of a union that's narrower than another member of it.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The union's widest members: `int | bool | str` is `int | str`.
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
pool: frozenset[str] = frozenset(atoms)
|
|
243
|
+
return frozenset(a for a in pool if not any(self.fits(a, b) and not self.fits(b, a) for b in pool))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def parse_narrower(text: str) -> tuple[Narrower, ...]:
|
|
247
|
+
"""Read a type hierarchy as the plugins' option takes it: `B=A, C=A, C=D, int=`.
|
|
248
|
+
|
|
249
|
+
Each `narrower=wider` entry adds a wider type; `narrower=` alone says it has none.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Each narrower type and its wider types, in the order first named.
|
|
253
|
+
|
|
254
|
+
"""
|
|
255
|
+
parents: dict[str, list[str]] = {}
|
|
256
|
+
entry: str
|
|
257
|
+
narrower: str
|
|
258
|
+
wider: str
|
|
259
|
+
for entry in text.replace(",", " ").split():
|
|
260
|
+
narrower, _, wider = entry.partition("=")
|
|
261
|
+
parents.setdefault(narrower, []).extend([wider] if wider else [])
|
|
262
|
+
return tuple((name, tuple(wider)) for name, wider in parents.items())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _container(atom: str) -> str:
|
|
266
|
+
"""Name a generic's container (`list` for `list[int]`), or return a plain type as it is.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
The name.
|
|
270
|
+
|
|
271
|
+
"""
|
|
272
|
+
return atom.partition("[")[0]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _visible(name: str, defined: Mapping[str, list[ast.expr]], seen: frozenset[str]) -> bool:
|
|
276
|
+
"""Check whether a module class's whole ancestry is in the module (or `object`).
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Whether each base is a plain name of `object` or of such a class; `seen` ends a cycle.
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
return name not in seen and all(
|
|
283
|
+
isinstance(base, ast.Name)
|
|
284
|
+
and (base.id == _OBJECT or (base.id in defined and _visible(base.id, defined, seen | {name})))
|
|
285
|
+
for base in defined[name]
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@lru_cache(maxsize=4096) # value flow asks of the same few annotations again and again
|
|
290
|
+
def members(annotation: str) -> frozenset[str] | None:
|
|
291
|
+
"""Split an annotation into its union's members, normalised as text.
|
|
292
|
+
|
|
293
|
+
`X | Y`, `Optional[X]` and `Union[X, Y]` all split; `None` is `"None"`; `typing`'s capitalised
|
|
294
|
+
aliases (`List[int]`) read as the builtins (`list[int]`); a string annotation is parsed first;
|
|
295
|
+
`ClassVar[X]`, `Final[X]` and `Annotated[X, ...]` are `X`'s.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The members, or `None` if the annotation can't be read (or is a bare `Final` or `ClassVar`).
|
|
299
|
+
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
root: ast.expr | None = _unwrapped(ast.parse(annotation, mode="eval").body)
|
|
303
|
+
except SyntaxError:
|
|
304
|
+
return None
|
|
305
|
+
return None if root is None else _members(root)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _unwrapped(node: ast.expr) -> ast.expr | None:
|
|
309
|
+
"""Strip the qualifiers around an annotation's type.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The type, or `None` for a bare qualifier, which names none.
|
|
313
|
+
|
|
314
|
+
"""
|
|
315
|
+
head: ast.expr
|
|
316
|
+
inner: ast.expr
|
|
317
|
+
match node:
|
|
318
|
+
case ast.Subscript(value=head, slice=inner) if _head(head) in _QUALIFIERS:
|
|
319
|
+
return _unwrapped(inner)
|
|
320
|
+
case ast.Subscript(value=head, slice=ast.Tuple(elts=[inner, *_])) if _head(head) == _ANNOTATED:
|
|
321
|
+
return _unwrapped(inner)
|
|
322
|
+
case _ if _head(node) in _QUALIFIERS:
|
|
323
|
+
return None
|
|
324
|
+
case _:
|
|
325
|
+
return node
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _members(node: ast.expr) -> frozenset[str] | None:
|
|
329
|
+
left: ast.expr
|
|
330
|
+
right: ast.expr
|
|
331
|
+
head: ast.expr
|
|
332
|
+
inner: ast.expr
|
|
333
|
+
parts: list[ast.expr]
|
|
334
|
+
text: str
|
|
335
|
+
match node:
|
|
336
|
+
case ast.BinOp(left=left, op=ast.BitOr(), right=right):
|
|
337
|
+
return _union(_members(left), _members(right))
|
|
338
|
+
case ast.Subscript(value=head, slice=inner) if _head(head) == _OPTIONAL:
|
|
339
|
+
return _union(_members(inner), frozenset({_NONE}))
|
|
340
|
+
case ast.Subscript(value=head, slice=inner) if _head(head) == _UNION:
|
|
341
|
+
parts = inner.elts if isinstance(inner, ast.Tuple) else [inner]
|
|
342
|
+
found: frozenset[str] | None = frozenset()
|
|
343
|
+
for inner in parts:
|
|
344
|
+
found = _union(found, _members(inner))
|
|
345
|
+
return found
|
|
346
|
+
case ast.Constant(value=None):
|
|
347
|
+
return frozenset({_NONE})
|
|
348
|
+
case ast.Constant(value=str() as text):
|
|
349
|
+
return members(text)
|
|
350
|
+
case _:
|
|
351
|
+
return frozenset({_atom(node)})
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _union(left: frozenset[str] | None, right: frozenset[str] | None) -> frozenset[str] | None:
|
|
355
|
+
return None if left is None or right is None else left | right
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _head(node: ast.expr) -> str:
|
|
359
|
+
"""Name a subscript's head, as `typing`'s name for it if it's one (`typing.Optional`, `Optional`).
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Its name, or `""` if it's neither a name nor a `typing` attribute.
|
|
363
|
+
|
|
364
|
+
"""
|
|
365
|
+
name: str
|
|
366
|
+
module: str
|
|
367
|
+
match node:
|
|
368
|
+
case ast.Name(id=name):
|
|
369
|
+
return name
|
|
370
|
+
case ast.Attribute(value=ast.Name(id=module), attr=name) if module in _TYPING_MODULES:
|
|
371
|
+
return name
|
|
372
|
+
case _:
|
|
373
|
+
return ""
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _atom(node: ast.expr) -> str:
|
|
377
|
+
"""Normalise one union member as text: a `typing` alias reads as its builtin.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
The text.
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
inner: ast.expr
|
|
384
|
+
head: ast.expr
|
|
385
|
+
match node:
|
|
386
|
+
case ast.Subscript(value=head, slice=inner) if _head(head) in _ALIASES:
|
|
387
|
+
return ast.unparse(ast.Subscript(ast.Name(_ALIASES[_head(head)]), inner))
|
|
388
|
+
case _ if _head(node) in _ALIASES:
|
|
389
|
+
return _ALIASES[_head(node)]
|
|
390
|
+
case ast.Attribute() if _head(node):
|
|
391
|
+
return _head(node)
|
|
392
|
+
case _:
|
|
393
|
+
return ast.unparse(node)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def augmented(op: ast.operator, operand: str | None, own: str | None = None) -> str | None:
|
|
397
|
+
"""Infer the type an augmented assignment (`x op= operand`) can bind, besides `x`'s own.
|
|
398
|
+
|
|
399
|
+
`/` gives a `float` for real numbers (a `complex` for one); `+`, `-`, `*`, `//` and `%` give a
|
|
400
|
+
type both sides fit in, which the operand's own type stands for, since `x`'s is already among
|
|
401
|
+
its values. Any other operator (`**` can turn an `int` into a `float`; bit operators, `@`, ...)
|
|
402
|
+
is unknown. Text (`own`, `x`'s certain type, a `str` or `bytes`) stays text by `+`, `*` and `%`,
|
|
403
|
+
whatever the operand: any other would raise. A tuple of some length added or multiplied is a
|
|
404
|
+
longer one, unknown.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
The type as text, or `None` if unknown.
|
|
408
|
+
|
|
409
|
+
"""
|
|
410
|
+
if own in _TEXT and isinstance(op, ast.Add | ast.Mult | ast.Mod):
|
|
411
|
+
return own
|
|
412
|
+
if operand is not None and _FIXED_TUPLE.fullmatch(operand):
|
|
413
|
+
return None
|
|
414
|
+
match op:
|
|
415
|
+
case ast.Div():
|
|
416
|
+
if operand == _COMPLEX:
|
|
417
|
+
return operand
|
|
418
|
+
return "float" if operand in DEFAULT_PARENTS else None
|
|
419
|
+
case ast.Add() | ast.Sub() | ast.Mult() | ast.FloorDiv() | ast.Mod():
|
|
420
|
+
return operand
|
|
421
|
+
case _:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def findings(name: str, lifetime: Lifetime, hierarchy: Hierarchy) -> list[Finding]:
|
|
426
|
+
"""Compare every value `name` is bound to with its declared type.
|
|
427
|
+
|
|
428
|
+
Only types whose whole ancestry is known (`Hierarchy.closed`) are compared: an imported class
|
|
429
|
+
may be a subclass of anything, so neither its values nor its annotation decide a finding.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
A `CONFLICT` for each value that doesn't fit; else, only if every value is known and nothing
|
|
433
|
+
outside the scope writes it, an `UNUSED_MEMBER` for each declared union member no value fits
|
|
434
|
+
and a `NARROWABLE` if every value fits a strictly narrower type than what's left.
|
|
435
|
+
|
|
436
|
+
"""
|
|
437
|
+
declared: frozenset[str] | None = members(lifetime.declared) if lifetime.declared else None
|
|
438
|
+
if not declared or declared & _ANYTHING:
|
|
439
|
+
return []
|
|
440
|
+
found: list[Finding] = []
|
|
441
|
+
values: list[frozenset[str]] = []
|
|
442
|
+
binding: Binding
|
|
443
|
+
value: frozenset[str] | None
|
|
444
|
+
certain: bool = all(hierarchy.closed(member) for member in declared)
|
|
445
|
+
for binding in lifetime.bindings:
|
|
446
|
+
if (value := members(binding.value) if binding.value else None) is None:
|
|
447
|
+
continue
|
|
448
|
+
values.append(value)
|
|
449
|
+
if (
|
|
450
|
+
certain
|
|
451
|
+
and all(hierarchy.closed(atom) for atom in value)
|
|
452
|
+
and not hierarchy.fits_all(value, declared)
|
|
453
|
+
):
|
|
454
|
+
found.append(Finding(*binding.at, name, Kind.CONFLICT, binding.value or ""))
|
|
455
|
+
# Every value known, nothing outside the scope writes the name, and every type compared closed.
|
|
456
|
+
complete: bool = bool(values) and len(values) == len(lifetime.bindings) and not lifetime.escaped
|
|
457
|
+
if found or not complete or not certain or not all(hierarchy.closed(a) for v in values for a in v):
|
|
458
|
+
return found
|
|
459
|
+
return found + _narrowing(
|
|
460
|
+
name,
|
|
461
|
+
lifetime,
|
|
462
|
+
declared,
|
|
463
|
+
frozenset[str]().union(*values),
|
|
464
|
+
hierarchy,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _narrowing(
|
|
469
|
+
name: str,
|
|
470
|
+
lifetime: Lifetime,
|
|
471
|
+
declared: frozenset[str],
|
|
472
|
+
values: frozenset[str],
|
|
473
|
+
hierarchy: Hierarchy,
|
|
474
|
+
) -> list[Finding]:
|
|
475
|
+
"""Find the declared union members no value uses, then whether what's left could narrow.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The `UNUSED_MEMBER` and `NARROWABLE` findings.
|
|
479
|
+
|
|
480
|
+
"""
|
|
481
|
+
at: tuple[int, int] = lifetime.declared_at
|
|
482
|
+
span: tuple[int, int] | None = lifetime.declared_span
|
|
483
|
+
used: frozenset[str] = frozenset(m for m in declared if any(hierarchy.fits(v, m) for v in values))
|
|
484
|
+
found: list[Finding] = [
|
|
485
|
+
Finding(*at, name, Kind.UNUSED_MEMBER, member, _render(used), span)
|
|
486
|
+
for member in sorted(declared - used)
|
|
487
|
+
]
|
|
488
|
+
narrowest: frozenset[str] = hierarchy.simplified(values)
|
|
489
|
+
if not hierarchy.fits_all(used, narrowest): # every value fits `used`: no `CONFLICT` got here
|
|
490
|
+
rendered: str = _render(narrowest)
|
|
491
|
+
found.append(Finding(*at, name, Kind.NARROWABLE, rendered, rendered, span))
|
|
492
|
+
return found
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _render(atoms: frozenset[str]) -> str:
|
|
496
|
+
"""Write a union as annotation text: its members sorted, `None` last.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
The text.
|
|
500
|
+
|
|
501
|
+
"""
|
|
502
|
+
return " | ".join(sorted(atoms, key=lambda atom: (atom == _NONE, atom)))
|
|
Binary file
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""A `TypedDict`'s keys, held as a class's attributes are: each one's type, and which may be missing."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from constricter.rules.quoted import written
|
|
8
|
+
|
|
9
|
+
_REQUIRED: Final = "Required"
|
|
10
|
+
_NOT_REQUIRED: Final = "NotRequired"
|
|
11
|
+
_QUALIFIERS: Final = frozenset({_REQUIRED, _NOT_REQUIRED, "ReadOnly"}) # around a key's type
|
|
12
|
+
_TOTAL: Final = "total"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def key(name: str) -> str:
|
|
16
|
+
"""Spell a `TypedDict`'s key as `declarations.classes` holds it: apart from any attribute's name.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
It.
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
return f"[{name}]"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def optional(name: str) -> str:
|
|
26
|
+
"""Spell what says a `TypedDict`'s key may be missing: held beside `key`'s, with the same type.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
It.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
return f"{key(name)}?"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def keys(node: ast.ClassDef) -> dict[str, str]:
|
|
36
|
+
"""Read a `TypedDict` class's own keys.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Each one's type by its `key`, less `Required` and its like; and by its `optional` too, for one
|
|
40
|
+
under `NotRequired`, or in a class with `total=False` and not under `Required`.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
total: bool = not any(
|
|
44
|
+
keyword.arg == _TOTAL and isinstance(keyword.value, ast.Constant) and keyword.value.value is False
|
|
45
|
+
for keyword in node.keywords
|
|
46
|
+
)
|
|
47
|
+
found: dict[str, str] = {}
|
|
48
|
+
annotated: list[tuple[str, ast.expr]] = [
|
|
49
|
+
(stmt.target.id, stmt.annotation)
|
|
50
|
+
for stmt in node.body
|
|
51
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name)
|
|
52
|
+
]
|
|
53
|
+
name: str
|
|
54
|
+
annotation: ast.expr
|
|
55
|
+
for name, annotation in annotated:
|
|
56
|
+
qualifiers: set[str] = set()
|
|
57
|
+
found[key(name)] = written(_unqualified(annotation, qualifiers))
|
|
58
|
+
if _NOT_REQUIRED in qualifiers or not (total or _REQUIRED in qualifiers):
|
|
59
|
+
found[optional(name)] = found[key(name)]
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unqualified(annotation: ast.expr, qualifiers: set[str]) -> ast.expr:
|
|
64
|
+
"""Take the qualifiers off a key's annotation, adding each to `qualifiers`.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Its type.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
name: str
|
|
71
|
+
inner: ast.expr
|
|
72
|
+
match annotation:
|
|
73
|
+
case ast.Subscript(value=ast.Name(id=name) | ast.Attribute(attr=name), slice=inner) if (
|
|
74
|
+
name in _QUALIFIERS
|
|
75
|
+
):
|
|
76
|
+
qualifiers.add(name)
|
|
77
|
+
return _unqualified(inner, qualifiers)
|
|
78
|
+
case _:
|
|
79
|
+
return annotation
|
|
Binary file
|