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.
Files changed (261) hide show
  1. constricter/__init__.py +55 -0
  2. constricter/__main__.py +26 -0
  3. constricter/_version.py +4 -0
  4. constricter/cli/__init__.py +2 -0
  5. constricter/cli/baseline.py +107 -0
  6. constricter/cli/collecting.py +54 -0
  7. constricter/cli/command.py +547 -0
  8. constricter/cli/config.py +322 -0
  9. constricter/cli/explain.py +104 -0
  10. constricter/cli/lsp/__init__.py +2 -0
  11. constricter/cli/lsp/connection.py +475 -0
  12. constricter/cli/lsp/edits.py +188 -0
  13. constricter/cli/lsp/guard.py +133 -0
  14. constricter/cli/lsp/hints.py +391 -0
  15. constricter/cli/lsp/protocol.py +192 -0
  16. constricter/cli/lsp/remembered.py +81 -0
  17. constricter/cli/lsp/stored.py +200 -0
  18. constricter/cli/options.py +799 -0
  19. constricter/cli/paths.py +151 -0
  20. constricter/cli/reading.py +36 -0
  21. constricter/cli/report.py +397 -0
  22. constricter/cli/rounds.py +328 -0
  23. constricter/cli/runs.py +90 -0
  24. constricter/cli/schedule.py +463 -0
  25. constricter/cli/traced.py +401 -0
  26. constricter/cli/workers.py +286 -0
  27. constricter/empty.py +66 -0
  28. constricter/fix/__init__.cp313-win32.pyd +0 -0
  29. constricter/fix/__init__.py +2 -0
  30. constricter/fix/core/__init__.cp313-win32.pyd +0 -0
  31. constricter/fix/core/__init__.py +2 -0
  32. constricter/fix/core/asked.cp313-win32.pyd +0 -0
  33. constricter/fix/core/asked.py +56 -0
  34. constricter/fix/core/fixes.cp313-win32.pyd +0 -0
  35. constricter/fix/core/fixes.py +317 -0
  36. constricter/fix/core/imports.cp313-win32.pyd +0 -0
  37. constricter/fix/core/imports.py +580 -0
  38. constricter/fix/core/inherited.cp313-win32.pyd +0 -0
  39. constricter/fix/core/inherited.py +311 -0
  40. constricter/fix/core/known.cp313-win32.pyd +0 -0
  41. constricter/fix/core/known.py +730 -0
  42. constricter/fix/core/signatures.cp313-win32.pyd +0 -0
  43. constricter/fix/core/signatures.py +132 -0
  44. constricter/fix/index/__init__.cp313-win32.pyd +0 -0
  45. constricter/fix/index/__init__.py +2 -0
  46. constricter/fix/index/awaits.cp313-win32.pyd +0 -0
  47. constricter/fix/index/awaits.py +90 -0
  48. constricter/fix/index/beyond.cp313-win32.pyd +0 -0
  49. constricter/fix/index/beyond.py +149 -0
  50. constricter/fix/index/callers.cp313-win32.pyd +0 -0
  51. constricter/fix/index/callers.py +155 -0
  52. constricter/fix/index/crossed.cp313-win32.pyd +0 -0
  53. constricter/fix/index/crossed.py +327 -0
  54. constricter/fix/index/decorated.cp313-win32.pyd +0 -0
  55. constricter/fix/index/decorated.py +140 -0
  56. constricter/fix/index/fixtures.cp313-win32.pyd +0 -0
  57. constricter/fix/index/fixtures.py +176 -0
  58. constricter/fix/index/linked.cp313-win32.pyd +0 -0
  59. constricter/fix/index/linked.py +142 -0
  60. constricter/fix/index/loose.cp313-win32.pyd +0 -0
  61. constricter/fix/index/loose.py +103 -0
  62. constricter/fix/index/managed.cp313-win32.pyd +0 -0
  63. constricter/fix/index/managed.py +63 -0
  64. constricter/fix/index/modules.cp313-win32.pyd +0 -0
  65. constricter/fix/index/modules.py +741 -0
  66. constricter/fix/index/offers.cp313-win32.pyd +0 -0
  67. constricter/fix/index/offers.py +252 -0
  68. constricter/fix/index/order.cp313-win32.pyd +0 -0
  69. constricter/fix/index/order.py +155 -0
  70. constricter/fix/index/origins.cp313-win32.pyd +0 -0
  71. constricter/fix/index/origins.py +238 -0
  72. constricter/fix/index/own_methods.cp313-win32.pyd +0 -0
  73. constricter/fix/index/own_methods.py +91 -0
  74. constricter/fix/index/own_overloads.cp313-win32.pyd +0 -0
  75. constricter/fix/index/own_overloads.py +70 -0
  76. constricter/fix/index/own_types.cp313-win32.pyd +0 -0
  77. constricter/fix/index/own_types.py +330 -0
  78. constricter/fix/index/params.cp313-win32.pyd +0 -0
  79. constricter/fix/index/params.py +81 -0
  80. constricter/fix/index/plain.cp313-win32.pyd +0 -0
  81. constricter/fix/index/plain.py +221 -0
  82. constricter/fix/index/project.cp313-win32.pyd +0 -0
  83. constricter/fix/index/project.py +797 -0
  84. constricter/fix/index/sides.cp313-win32.pyd +0 -0
  85. constricter/fix/index/sides.py +160 -0
  86. constricter/fix/index/strings.cp313-win32.pyd +0 -0
  87. constricter/fix/index/strings.py +46 -0
  88. constricter/fix/index/stubs/__init__.cp313-win32.pyd +0 -0
  89. constricter/fix/index/stubs/__init__.py +2 -0
  90. constricter/fix/index/stubs/atoms.cp313-win32.pyd +0 -0
  91. constricter/fix/index/stubs/atoms.py +425 -0
  92. constricter/fix/index/stubs/chained.cp313-win32.pyd +0 -0
  93. constricter/fix/index/stubs/chained.py +69 -0
  94. constricter/fix/index/stubs/classnames.cp313-win32.pyd +0 -0
  95. constricter/fix/index/stubs/classnames.py +124 -0
  96. constricter/fix/index/stubs/declared.cp313-win32.pyd +0 -0
  97. constricter/fix/index/stubs/declared.py +508 -0
  98. constricter/fix/index/stubs/installed.cp313-win32.pyd +0 -0
  99. constricter/fix/index/stubs/installed.py +425 -0
  100. constricter/fix/index/stubs/names.cp313-win32.pyd +0 -0
  101. constricter/fix/index/stubs/names.py +200 -0
  102. constricter/fix/index/stubs/read.cp313-win32.pyd +0 -0
  103. constricter/fix/index/stubs/read.py +70 -0
  104. constricter/fix/index/stubs/signatures.cp313-win32.pyd +0 -0
  105. constricter/fix/index/stubs/signatures.py +656 -0
  106. constricter/fix/index/stubs/stubbed.cp313-win32.pyd +0 -0
  107. constricter/fix/index/stubs/stubbed.py +208 -0
  108. constricter/fix/index/taken.cp313-win32.pyd +0 -0
  109. constricter/fix/index/taken.py +45 -0
  110. constricter/fix/index/tuples.cp313-win32.pyd +0 -0
  111. constricter/fix/index/tuples.py +112 -0
  112. constricter/fix/libraries/__init__.cp313-win32.pyd +0 -0
  113. constricter/fix/libraries/__init__.py +2 -0
  114. constricter/fix/libraries/library.cp313-win32.pyd +0 -0
  115. constricter/fix/libraries/library.py +420 -0
  116. constricter/fix/libraries/opened.cp313-win32.pyd +0 -0
  117. constricter/fix/libraries/opened.py +147 -0
  118. constricter/fix/libraries/overloads.cp313-win32.pyd +0 -0
  119. constricter/fix/libraries/overloads.py +960 -0
  120. constricter/fix/libraries/receivers.cp313-win32.pyd +0 -0
  121. constricter/fix/libraries/receivers.py +274 -0
  122. constricter/fix/libraries/stdlib.cp313-win32.pyd +0 -0
  123. constricter/fix/libraries/stdlib.py +752 -0
  124. constricter/fix/libraries/walks.cp313-win32.pyd +0 -0
  125. constricter/fix/libraries/walks.py +49 -0
  126. constricter/fix/tables/aliases.json +147 -0
  127. constricter/fix/tables/attributes.json +700 -0
  128. constricter/fix/tables/awaited.json +69 -0
  129. constricter/fix/tables/awaited_overloads.json +14 -0
  130. constricter/fix/tables/bases.json +888 -0
  131. constricter/fix/tables/classes.json +1941 -0
  132. constricter/fix/tables/elements.json +156 -0
  133. constricter/fix/tables/functions.json +58 -0
  134. constricter/fix/tables/generic_attributes.json +254 -0
  135. constricter/fix/tables/method_overloads.json +461 -0
  136. constricter/fix/tables/method_signatures.json +954 -0
  137. constricter/fix/tables/methods.json +954 -0
  138. constricter/fix/tables/overloads.json +540 -0
  139. constricter/fix/tables/platform.json +37 -0
  140. constricter/fix/tables/returns.json +1362 -0
  141. constricter/fix/tables/scalar_members.json +16 -0
  142. constricter/fix/tables/scalars.json +2707 -0
  143. constricter/fix/tables/source.json +28 -0
  144. constricter/fix/tables/subscriptable.json +77 -0
  145. constricter/fix/tables/type_parameters.json +178 -0
  146. constricter/fix/tables/variables.json +2962 -0
  147. constricter/fix/values/__init__.cp313-win32.pyd +0 -0
  148. constricter/fix/values/__init__.py +2 -0
  149. constricter/fix/values/aliased.cp313-win32.pyd +0 -0
  150. constricter/fix/values/aliased.py +195 -0
  151. constricter/fix/values/bodies.cp313-win32.pyd +0 -0
  152. constricter/fix/values/bodies.py +76 -0
  153. constricter/fix/values/callables.cp313-win32.pyd +0 -0
  154. constricter/fix/values/callables.py +323 -0
  155. constricter/fix/values/called.cp313-win32.pyd +0 -0
  156. constricter/fix/values/called.py +194 -0
  157. constricter/fix/values/classvars.cp313-win32.pyd +0 -0
  158. constricter/fix/values/classvars.py +629 -0
  159. constricter/fix/values/decided.cp313-win32.pyd +0 -0
  160. constricter/fix/values/decided.py +429 -0
  161. constricter/fix/values/displays.cp313-win32.pyd +0 -0
  162. constricter/fix/values/displays.py +101 -0
  163. constricter/fix/values/doubts.cp313-win32.pyd +0 -0
  164. constricter/fix/values/doubts.py +677 -0
  165. constricter/fix/values/entered.cp313-win32.pyd +0 -0
  166. constricter/fix/values/entered.py +332 -0
  167. constricter/fix/values/fills.cp313-win32.pyd +0 -0
  168. constricter/fix/values/fills.py +446 -0
  169. constricter/fix/values/guesses.cp313-win32.pyd +0 -0
  170. constricter/fix/values/guesses.py +376 -0
  171. constricter/fix/values/hinted.cp313-win32.pyd +0 -0
  172. constricter/fix/values/hinted.py +811 -0
  173. constricter/fix/values/inference.cp313-win32.pyd +0 -0
  174. constricter/fix/values/inference.py +698 -0
  175. constricter/fix/values/loops.cp313-win32.pyd +0 -0
  176. constricter/fix/values/loops.py +335 -0
  177. constricter/fix/values/members.cp313-win32.pyd +0 -0
  178. constricter/fix/values/members.py +578 -0
  179. constricter/fix/values/narrowed.cp313-win32.pyd +0 -0
  180. constricter/fix/values/narrowed.py +220 -0
  181. constricter/fix/values/operated.cp313-win32.pyd +0 -0
  182. constricter/fix/values/operated.py +202 -0
  183. constricter/fix/values/ordered.cp313-win32.pyd +0 -0
  184. constricter/fix/values/ordered.py +74 -0
  185. constricter/fix/values/returned.cp313-win32.pyd +0 -0
  186. constricter/fix/values/returned.py +479 -0
  187. constricter/fix/values/returns.cp313-win32.pyd +0 -0
  188. constricter/fix/values/returns.py +237 -0
  189. constricter/fix/values/shapes.cp313-win32.pyd +0 -0
  190. constricter/fix/values/shapes.py +654 -0
  191. constricter/fix/values/stored.cp313-win32.pyd +0 -0
  192. constricter/fix/values/stored.py +564 -0
  193. constricter/fix/values/targets.cp313-win32.pyd +0 -0
  194. constricter/fix/values/targets.py +419 -0
  195. constricter/fix/values/unchecked.cp313-win32.pyd +0 -0
  196. constricter/fix/values/unchecked.py +278 -0
  197. constricter/jsonc.py +50 -0
  198. constricter/noqa.py +47 -0
  199. constricter/notebook.py +115 -0
  200. constricter/offences.py +436 -0
  201. constricter/overrides.py +24 -0
  202. constricter/plugins/__init__.py +2 -0
  203. constricter/plugins/flake8.py +133 -0
  204. constricter/plugins/pylint.py +186 -0
  205. constricter/py.typed +0 -0
  206. constricter/recording.py +660 -0
  207. constricter/rules/__init__.cp313-win32.pyd +0 -0
  208. constricter/rules/__init__.py +2 -0
  209. constricter/rules/annotations.cp313-win32.pyd +0 -0
  210. constricter/rules/annotations.py +296 -0
  211. constricter/rules/binding.cp313-win32.pyd +0 -0
  212. constricter/rules/binding.py +652 -0
  213. constricter/rules/calls.cp313-win32.pyd +0 -0
  214. constricter/rules/calls.py +460 -0
  215. constricter/rules/checker.cp313-win32.pyd +0 -0
  216. constricter/rules/checker.py +536 -0
  217. constricter/rules/declarations.cp313-win32.pyd +0 -0
  218. constricter/rules/declarations.py +730 -0
  219. constricter/rules/decorators.cp313-win32.pyd +0 -0
  220. constricter/rules/decorators.py +351 -0
  221. constricter/rules/flow.cp313-win32.pyd +0 -0
  222. constricter/rules/flow.py +502 -0
  223. constricter/rules/keys.cp313-win32.pyd +0 -0
  224. constricter/rules/keys.py +79 -0
  225. constricter/rules/late.cp313-win32.pyd +0 -0
  226. constricter/rules/late.py +969 -0
  227. constricter/rules/narrowing.cp313-win32.pyd +0 -0
  228. constricter/rules/narrowing.py +98 -0
  229. constricter/rules/parsed.cp313-win32.pyd +0 -0
  230. constricter/rules/parsed.py +136 -0
  231. constricter/rules/quoted.cp313-win32.pyd +0 -0
  232. constricter/rules/quoted.py +141 -0
  233. constricter/rules/rebinding.cp313-win32.pyd +0 -0
  234. constricter/rules/rebinding.py +169 -0
  235. constricter/rules/recorded.cp313-win32.pyd +0 -0
  236. constricter/rules/recorded.py +168 -0
  237. constricter/rules/redundant.cp313-win32.pyd +0 -0
  238. constricter/rules/redundant.py +160 -0
  239. constricter/rules/scope.cp313-win32.pyd +0 -0
  240. constricter/rules/scope.py +802 -0
  241. constricter/rules/scoped.cp313-win32.pyd +0 -0
  242. constricter/rules/scoped.py +232 -0
  243. constricter/rules/scoping.cp313-win32.pyd +0 -0
  244. constricter/rules/scoping.py +346 -0
  245. constricter/rules/settings.cp313-win32.pyd +0 -0
  246. constricter/rules/settings.py +189 -0
  247. constricter/rules/syntax.cp313-win32.pyd +0 -0
  248. constricter/rules/syntax.py +472 -0
  249. constricter/rules/tables.cp313-win32.pyd +0 -0
  250. constricter/rules/tables.py +169 -0
  251. constricter/rules/walked.cp313-win32.pyd +0 -0
  252. constricter/rules/walked.py +127 -0
  253. constricter/rules/widened.cp313-win32.pyd +0 -0
  254. constricter/rules/widened.py +322 -0
  255. constricter/trace.py +374 -0
  256. e45ce06b2fdd5c43f782__mypyc.cp313-win32.pyd +0 -0
  257. python_constricter-0.4.1.dist-info/METADATA +400 -0
  258. python_constricter-0.4.1.dist-info/RECORD +261 -0
  259. python_constricter-0.4.1.dist-info/WHEEL +4 -0
  260. python_constricter-0.4.1.dist-info/entry_points.txt +5 -0
  261. 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