python-constricter 0.4.1__cp312-cp312-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__.cp312-win32.pyd +0 -0
  29. constricter/fix/__init__.py +2 -0
  30. constricter/fix/core/__init__.cp312-win32.pyd +0 -0
  31. constricter/fix/core/__init__.py +2 -0
  32. constricter/fix/core/asked.cp312-win32.pyd +0 -0
  33. constricter/fix/core/asked.py +56 -0
  34. constricter/fix/core/fixes.cp312-win32.pyd +0 -0
  35. constricter/fix/core/fixes.py +317 -0
  36. constricter/fix/core/imports.cp312-win32.pyd +0 -0
  37. constricter/fix/core/imports.py +580 -0
  38. constricter/fix/core/inherited.cp312-win32.pyd +0 -0
  39. constricter/fix/core/inherited.py +311 -0
  40. constricter/fix/core/known.cp312-win32.pyd +0 -0
  41. constricter/fix/core/known.py +730 -0
  42. constricter/fix/core/signatures.cp312-win32.pyd +0 -0
  43. constricter/fix/core/signatures.py +132 -0
  44. constricter/fix/index/__init__.cp312-win32.pyd +0 -0
  45. constricter/fix/index/__init__.py +2 -0
  46. constricter/fix/index/awaits.cp312-win32.pyd +0 -0
  47. constricter/fix/index/awaits.py +90 -0
  48. constricter/fix/index/beyond.cp312-win32.pyd +0 -0
  49. constricter/fix/index/beyond.py +149 -0
  50. constricter/fix/index/callers.cp312-win32.pyd +0 -0
  51. constricter/fix/index/callers.py +155 -0
  52. constricter/fix/index/crossed.cp312-win32.pyd +0 -0
  53. constricter/fix/index/crossed.py +327 -0
  54. constricter/fix/index/decorated.cp312-win32.pyd +0 -0
  55. constricter/fix/index/decorated.py +140 -0
  56. constricter/fix/index/fixtures.cp312-win32.pyd +0 -0
  57. constricter/fix/index/fixtures.py +176 -0
  58. constricter/fix/index/linked.cp312-win32.pyd +0 -0
  59. constricter/fix/index/linked.py +142 -0
  60. constricter/fix/index/loose.cp312-win32.pyd +0 -0
  61. constricter/fix/index/loose.py +103 -0
  62. constricter/fix/index/managed.cp312-win32.pyd +0 -0
  63. constricter/fix/index/managed.py +63 -0
  64. constricter/fix/index/modules.cp312-win32.pyd +0 -0
  65. constricter/fix/index/modules.py +741 -0
  66. constricter/fix/index/offers.cp312-win32.pyd +0 -0
  67. constricter/fix/index/offers.py +252 -0
  68. constricter/fix/index/order.cp312-win32.pyd +0 -0
  69. constricter/fix/index/order.py +155 -0
  70. constricter/fix/index/origins.cp312-win32.pyd +0 -0
  71. constricter/fix/index/origins.py +238 -0
  72. constricter/fix/index/own_methods.cp312-win32.pyd +0 -0
  73. constricter/fix/index/own_methods.py +91 -0
  74. constricter/fix/index/own_overloads.cp312-win32.pyd +0 -0
  75. constricter/fix/index/own_overloads.py +70 -0
  76. constricter/fix/index/own_types.cp312-win32.pyd +0 -0
  77. constricter/fix/index/own_types.py +330 -0
  78. constricter/fix/index/params.cp312-win32.pyd +0 -0
  79. constricter/fix/index/params.py +81 -0
  80. constricter/fix/index/plain.cp312-win32.pyd +0 -0
  81. constricter/fix/index/plain.py +221 -0
  82. constricter/fix/index/project.cp312-win32.pyd +0 -0
  83. constricter/fix/index/project.py +797 -0
  84. constricter/fix/index/sides.cp312-win32.pyd +0 -0
  85. constricter/fix/index/sides.py +160 -0
  86. constricter/fix/index/strings.cp312-win32.pyd +0 -0
  87. constricter/fix/index/strings.py +46 -0
  88. constricter/fix/index/stubs/__init__.cp312-win32.pyd +0 -0
  89. constricter/fix/index/stubs/__init__.py +2 -0
  90. constricter/fix/index/stubs/atoms.cp312-win32.pyd +0 -0
  91. constricter/fix/index/stubs/atoms.py +425 -0
  92. constricter/fix/index/stubs/chained.cp312-win32.pyd +0 -0
  93. constricter/fix/index/stubs/chained.py +69 -0
  94. constricter/fix/index/stubs/classnames.cp312-win32.pyd +0 -0
  95. constricter/fix/index/stubs/classnames.py +124 -0
  96. constricter/fix/index/stubs/declared.cp312-win32.pyd +0 -0
  97. constricter/fix/index/stubs/declared.py +508 -0
  98. constricter/fix/index/stubs/installed.cp312-win32.pyd +0 -0
  99. constricter/fix/index/stubs/installed.py +425 -0
  100. constricter/fix/index/stubs/names.cp312-win32.pyd +0 -0
  101. constricter/fix/index/stubs/names.py +200 -0
  102. constricter/fix/index/stubs/read.cp312-win32.pyd +0 -0
  103. constricter/fix/index/stubs/read.py +70 -0
  104. constricter/fix/index/stubs/signatures.cp312-win32.pyd +0 -0
  105. constricter/fix/index/stubs/signatures.py +656 -0
  106. constricter/fix/index/stubs/stubbed.cp312-win32.pyd +0 -0
  107. constricter/fix/index/stubs/stubbed.py +208 -0
  108. constricter/fix/index/taken.cp312-win32.pyd +0 -0
  109. constricter/fix/index/taken.py +45 -0
  110. constricter/fix/index/tuples.cp312-win32.pyd +0 -0
  111. constricter/fix/index/tuples.py +112 -0
  112. constricter/fix/libraries/__init__.cp312-win32.pyd +0 -0
  113. constricter/fix/libraries/__init__.py +2 -0
  114. constricter/fix/libraries/library.cp312-win32.pyd +0 -0
  115. constricter/fix/libraries/library.py +420 -0
  116. constricter/fix/libraries/opened.cp312-win32.pyd +0 -0
  117. constricter/fix/libraries/opened.py +147 -0
  118. constricter/fix/libraries/overloads.cp312-win32.pyd +0 -0
  119. constricter/fix/libraries/overloads.py +960 -0
  120. constricter/fix/libraries/receivers.cp312-win32.pyd +0 -0
  121. constricter/fix/libraries/receivers.py +274 -0
  122. constricter/fix/libraries/stdlib.cp312-win32.pyd +0 -0
  123. constricter/fix/libraries/stdlib.py +752 -0
  124. constricter/fix/libraries/walks.cp312-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__.cp312-win32.pyd +0 -0
  148. constricter/fix/values/__init__.py +2 -0
  149. constricter/fix/values/aliased.cp312-win32.pyd +0 -0
  150. constricter/fix/values/aliased.py +195 -0
  151. constricter/fix/values/bodies.cp312-win32.pyd +0 -0
  152. constricter/fix/values/bodies.py +76 -0
  153. constricter/fix/values/callables.cp312-win32.pyd +0 -0
  154. constricter/fix/values/callables.py +323 -0
  155. constricter/fix/values/called.cp312-win32.pyd +0 -0
  156. constricter/fix/values/called.py +194 -0
  157. constricter/fix/values/classvars.cp312-win32.pyd +0 -0
  158. constricter/fix/values/classvars.py +629 -0
  159. constricter/fix/values/decided.cp312-win32.pyd +0 -0
  160. constricter/fix/values/decided.py +429 -0
  161. constricter/fix/values/displays.cp312-win32.pyd +0 -0
  162. constricter/fix/values/displays.py +101 -0
  163. constricter/fix/values/doubts.cp312-win32.pyd +0 -0
  164. constricter/fix/values/doubts.py +677 -0
  165. constricter/fix/values/entered.cp312-win32.pyd +0 -0
  166. constricter/fix/values/entered.py +332 -0
  167. constricter/fix/values/fills.cp312-win32.pyd +0 -0
  168. constricter/fix/values/fills.py +446 -0
  169. constricter/fix/values/guesses.cp312-win32.pyd +0 -0
  170. constricter/fix/values/guesses.py +376 -0
  171. constricter/fix/values/hinted.cp312-win32.pyd +0 -0
  172. constricter/fix/values/hinted.py +811 -0
  173. constricter/fix/values/inference.cp312-win32.pyd +0 -0
  174. constricter/fix/values/inference.py +698 -0
  175. constricter/fix/values/loops.cp312-win32.pyd +0 -0
  176. constricter/fix/values/loops.py +335 -0
  177. constricter/fix/values/members.cp312-win32.pyd +0 -0
  178. constricter/fix/values/members.py +578 -0
  179. constricter/fix/values/narrowed.cp312-win32.pyd +0 -0
  180. constricter/fix/values/narrowed.py +220 -0
  181. constricter/fix/values/operated.cp312-win32.pyd +0 -0
  182. constricter/fix/values/operated.py +202 -0
  183. constricter/fix/values/ordered.cp312-win32.pyd +0 -0
  184. constricter/fix/values/ordered.py +74 -0
  185. constricter/fix/values/returned.cp312-win32.pyd +0 -0
  186. constricter/fix/values/returned.py +479 -0
  187. constricter/fix/values/returns.cp312-win32.pyd +0 -0
  188. constricter/fix/values/returns.py +237 -0
  189. constricter/fix/values/shapes.cp312-win32.pyd +0 -0
  190. constricter/fix/values/shapes.py +654 -0
  191. constricter/fix/values/stored.cp312-win32.pyd +0 -0
  192. constricter/fix/values/stored.py +564 -0
  193. constricter/fix/values/targets.cp312-win32.pyd +0 -0
  194. constricter/fix/values/targets.py +419 -0
  195. constricter/fix/values/unchecked.cp312-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__.cp312-win32.pyd +0 -0
  208. constricter/rules/__init__.py +2 -0
  209. constricter/rules/annotations.cp312-win32.pyd +0 -0
  210. constricter/rules/annotations.py +296 -0
  211. constricter/rules/binding.cp312-win32.pyd +0 -0
  212. constricter/rules/binding.py +652 -0
  213. constricter/rules/calls.cp312-win32.pyd +0 -0
  214. constricter/rules/calls.py +460 -0
  215. constricter/rules/checker.cp312-win32.pyd +0 -0
  216. constricter/rules/checker.py +536 -0
  217. constricter/rules/declarations.cp312-win32.pyd +0 -0
  218. constricter/rules/declarations.py +730 -0
  219. constricter/rules/decorators.cp312-win32.pyd +0 -0
  220. constricter/rules/decorators.py +351 -0
  221. constricter/rules/flow.cp312-win32.pyd +0 -0
  222. constricter/rules/flow.py +502 -0
  223. constricter/rules/keys.cp312-win32.pyd +0 -0
  224. constricter/rules/keys.py +79 -0
  225. constricter/rules/late.cp312-win32.pyd +0 -0
  226. constricter/rules/late.py +969 -0
  227. constricter/rules/narrowing.cp312-win32.pyd +0 -0
  228. constricter/rules/narrowing.py +98 -0
  229. constricter/rules/parsed.cp312-win32.pyd +0 -0
  230. constricter/rules/parsed.py +136 -0
  231. constricter/rules/quoted.cp312-win32.pyd +0 -0
  232. constricter/rules/quoted.py +141 -0
  233. constricter/rules/rebinding.cp312-win32.pyd +0 -0
  234. constricter/rules/rebinding.py +169 -0
  235. constricter/rules/recorded.cp312-win32.pyd +0 -0
  236. constricter/rules/recorded.py +168 -0
  237. constricter/rules/redundant.cp312-win32.pyd +0 -0
  238. constricter/rules/redundant.py +160 -0
  239. constricter/rules/scope.cp312-win32.pyd +0 -0
  240. constricter/rules/scope.py +802 -0
  241. constricter/rules/scoped.cp312-win32.pyd +0 -0
  242. constricter/rules/scoped.py +232 -0
  243. constricter/rules/scoping.cp312-win32.pyd +0 -0
  244. constricter/rules/scoping.py +346 -0
  245. constricter/rules/settings.cp312-win32.pyd +0 -0
  246. constricter/rules/settings.py +189 -0
  247. constricter/rules/syntax.cp312-win32.pyd +0 -0
  248. constricter/rules/syntax.py +472 -0
  249. constricter/rules/tables.cp312-win32.pyd +0 -0
  250. constricter/rules/tables.py +169 -0
  251. constricter/rules/walked.cp312-win32.pyd +0 -0
  252. constricter/rules/walked.py +127 -0
  253. constricter/rules/widened.cp312-win32.pyd +0 -0
  254. constricter/rules/widened.py +322 -0
  255. constricter/trace.py +374 -0
  256. e45ce06b2fdd5c43f782__mypyc.cp312-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,252 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--infer-with`: what a hint may name that the file doesn't bind where its annotations run.
3
+
4
+ A hint is text, and a name in it may not be a type: basedpyright shows a value that is a module by
5
+ the module's name, and a generic class without its arguments when it doesn't know them. So a name
6
+ is taken only for a class or a type alias the index of checked files and installed packages
7
+ defines: `own`, for what the file imports under `if TYPE_CHECKING:`; `vetted`, for what a hint's
8
+ edits would import (the standard library's are `hinted`'s to judge, by its tables).
9
+ """
10
+
11
+ import ast
12
+ import re
13
+ import sys
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+ from typing import Final, NamedTuple, TypeAlias, cast
17
+
18
+ from constricter.empty import Empty
19
+ from constricter.fix.core.known import Guarded, Hints, Offered, Origin
20
+ from constricter.fix.index import project
21
+ from constricter.fix.index.modules import SUFFIX
22
+ from constricter.fix.index.origins import absolute
23
+ from constricter.fix.libraries import stdlib
24
+
25
+ _PACKAGE: Final = "__init__"
26
+ _STDLIB: Final = sys.stdlib_module_names
27
+ _Defined: TypeAlias = tuple[str, str] # where the index defines a class: its module, and its name there
28
+
29
+
30
+ class Own(NamedTuple):
31
+ """The classes and aliases a file imports for type checking alone (`guarded`), and the generic ones."""
32
+
33
+ # A plain `dict`, not a `MappingProxyType`: the CLI's worker processes are sent it, pickled.
34
+ guarded: Mapping[str, Guarded] = Empty()
35
+ generics: frozenset[str] = frozenset()
36
+
37
+
38
+ def own(catalog: project.Index, path: Path) -> Own:
39
+ """Find the classes the file at `path` imports under a top-level `if` (`if TYPE_CHECKING:`) alone.
40
+
41
+ Returns:
42
+ Those the index or the standard library's tables define; none for a file `catalog` doesn't have.
43
+
44
+ """
45
+ target: project.Module | None
46
+ if path.suffix != SUFFIX or (target := catalog.modules.get(project.module_name(path))) is None:
47
+ return Own()
48
+ found: dict[str, Guarded] = {}
49
+ generics: set[str] = set()
50
+ name: str
51
+ origin: Origin
52
+ for name, origin in target.guarded.items():
53
+ generic: bool | None = _class(catalog, origin)
54
+ if generic is not None and name not in target.names:
55
+ found[name] = Guarded(origin, None)
56
+ if generic:
57
+ generics.add(name)
58
+ return Own(found, frozenset(generics))
59
+
60
+
61
+ def vetted(catalog: project.Index, path: Path, hints: tuple[Hints, ...]) -> tuple[Hints, ...]:
62
+ """Drop the edits of the file at `path`'s hints that import what isn't a class, or a generic one bare.
63
+
64
+ Returns:
65
+ The hints, without them: such a hint is judged by what it shows alone.
66
+
67
+ """
68
+ if not any(found.offered for found in hints):
69
+ return hints
70
+ module: tuple[str, bool] = (project.module_name(path), path.stem == _PACKAGE)
71
+ target: project.Module | None = catalog.modules.get(module[0])
72
+ checking: Mapping[str, Origin] = Empty() if target is None else target.guarded
73
+ found: dict[str, _Defined | None] = {name: _defined(catalog, origin) for name, origin in checking.items()}
74
+ guarded: dict[str, _Defined] = {name: defined for name, defined in found.items() if defined is not None}
75
+ return tuple(
76
+ _shared(
77
+ catalog,
78
+ module,
79
+ found._replace(
80
+ offered={
81
+ where: offered
82
+ for where, offered in found.offered.items()
83
+ if _sound(catalog, module, offered, found.types.get(where, ""))
84
+ },
85
+ ),
86
+ guarded,
87
+ )
88
+ for found in hints
89
+ )
90
+
91
+
92
+ def _shared(
93
+ catalog: project.Index,
94
+ module: tuple[str, bool],
95
+ found: Hints,
96
+ guarded: Mapping[str, _Defined],
97
+ ) -> Hints:
98
+ """Import one class by one statement in all of a file's hints' edits, the first's, as it's written once.
99
+
100
+ A checker's edits may import a class by any module that re-exports it (`pint`'s `Unit`, then
101
+ `pint.registry`'s): the first made the second's an import of a name taken, and its fix waited
102
+ for a second pass, which reads the name as the file's. A hint whose edits import another class
103
+ by a name the file imports for type checking (`guarded`, each where it's defined) is
104
+ dropped: judged by what it shows, it would be taken for that one. Only a class the index
105
+ defines: it doesn't follow the standard library's re-exports (`typing`'s `Callable`).
106
+
107
+ Returns:
108
+ The hints so.
109
+
110
+ """
111
+ first: dict[str, tuple[_Defined, str]] = {} # each name's class, and its statement
112
+ types: dict[tuple[int, int], str] = dict(found.types)
113
+ offered: dict[tuple[int, int], Offered] = {}
114
+ where: tuple[int, int]
115
+ each: Offered
116
+ for where, each in sorted(found.offered.items()):
117
+ statements: list[str] = []
118
+ statement: str
119
+ for statement in each.imports:
120
+ name: str
121
+ origin: Origin
122
+ name, origin = _bound(module, statement)
123
+ defined: _Defined | None
124
+ if (defined := _defined(catalog, origin)) is None:
125
+ statements.append(statement)
126
+ continue
127
+ if name in guarded and guarded[name] != defined:
128
+ break
129
+ was: tuple[_Defined, str] = first.setdefault(name, (defined, statement))
130
+ statements.append(was[1] if was[0] == defined else statement)
131
+ else:
132
+ offered[where] = each._replace(imports=tuple(statements))
133
+ continue
134
+ _ = types.pop(where, None)
135
+ return found._replace(types=types, offered=offered)
136
+
137
+
138
+ def imported(path: Path, hints: tuple[Hints, ...]) -> dict[str, Guarded]:
139
+ """Find what the file at `path`'s vetted hints' edits import for type checking alone.
140
+
141
+ For `linked.same`: a class an edit imports (`DataFrame`, from `pandas.core.frame`) is a spelling
142
+ of it beside the file's own (`pd.DataFrame`) in the pass that writes the import, as in the next.
143
+
144
+ Returns:
145
+ Each name one statement binds in every edit, with it.
146
+
147
+ """
148
+ module: tuple[str, bool] = (project.module_name(path), path.stem == _PACKAGE)
149
+ found: dict[str, Guarded] = {}
150
+ clashing: set[str] = set()
151
+ statement: str
152
+ for statement in {
153
+ statement for each in hints for offered in each.offered.values() for statement in offered.imports
154
+ }:
155
+ name: str
156
+ origin: Origin
157
+ name, origin = _bound(module, statement)
158
+ if name in found:
159
+ clashing.add(name)
160
+ found[name] = Guarded(origin, statement)
161
+ return {name: guarded for name, guarded in found.items() if name not in clashing}
162
+
163
+
164
+ def _bound(module: tuple[str, bool], statement: str) -> tuple[str, Origin]:
165
+ """Read what a `from` import of a hint's edits binds, from the hinted file's `module`.
166
+
167
+ Returns:
168
+ The name it binds, and what that is.
169
+
170
+ """
171
+ node: ast.ImportFrom = cast("ast.ImportFrom", ast.parse(statement).body[0])
172
+ alias: ast.alias = node.names[0]
173
+ return alias.asname or alias.name, (
174
+ absolute(module[0], node.module, node.level, is_package=module[1]),
175
+ alias.name,
176
+ )
177
+
178
+
179
+ def _defined(catalog: project.Index, origin: Origin) -> _Defined | None:
180
+ """Find where the index defines a class or a type alias, through re-exports.
181
+
182
+ Returns:
183
+ Its module and its name there; `None` where it doesn't.
184
+
185
+ """
186
+ kind: str
187
+ for kind in (project.CLASS, project.ALIAS):
188
+ found: tuple[project.Module, str] | None
189
+ if (found := project.definition(catalog.modules, origin, kind)) is not None:
190
+ return found[0].name, found[1]
191
+ return None
192
+
193
+
194
+ def _sound(catalog: project.Index, module: tuple[str, bool], offered: Offered, shown: str) -> bool:
195
+ """Check that each import of a hint's edits names a class, and no generic one written bare.
196
+
197
+ `module`: the hinted file's module name, and whether it's a package's `__init__`; `shown`: what
198
+ the hint shows, which may write a class bare that its edit's spelling doesn't.
199
+
200
+ Returns:
201
+ Whether they do; an `import m`, which names no class, never does, nor an import from an
202
+ installed package's private module (`numpy._core`), which its next release may move.
203
+
204
+ """
205
+ statement: str
206
+ for statement in offered.imports:
207
+ try:
208
+ node: ast.stmt = ast.parse(statement).body[0]
209
+ except SyntaxError: # from a file no import can name (`v3.0.0.c.py`)
210
+ return False
211
+ if not isinstance(node, ast.ImportFrom):
212
+ return False
213
+ alias: ast.alias = node.names[0]
214
+ origin: Origin = (absolute(module[0], node.module, node.level, is_package=module[1]), alias.name)
215
+ if origin[0].partition(".")[0] in _STDLIB:
216
+ continue
217
+ lone: re.Pattern[str] = re.compile(rf"(?<![\w.]){re.escape(alias.asname or alias.name)}(?![\w\[])")
218
+ generic: bool | None = _class(catalog, origin)
219
+ bare: bool = bool(generic and (lone.search(offered.text) or lone.search(shown)))
220
+ if generic is None or bare or _private(catalog, origin[0]):
221
+ return False
222
+ return True
223
+
224
+
225
+ def _private(catalog: project.Index, name: str) -> bool:
226
+ """Check whether module `name` is an installed package's private one.
227
+
228
+ Returns:
229
+ Whether it is.
230
+
231
+ """
232
+ module: project.Module | None = catalog.modules.get(name)
233
+ return module is not None and module.installed and any(part.startswith("_") for part in name.split("."))
234
+
235
+
236
+ def _class(catalog: project.Index, origin: Origin) -> bool | None:
237
+ """Find whether `origin` is a class or a type alias: an indexed module's (through re-exports).
238
+
239
+ Or a class of the standard library's.
240
+
241
+ Returns:
242
+ Whether it's generic, missing its arguments written bare; `None` if it's neither, to anyone's
243
+ knowledge.
244
+
245
+ """
246
+ defined: tuple[project.Module, str] | None
247
+ if (defined := project.definition(catalog.modules, origin, project.CLASS)) is not None:
248
+ return defined[1] in defined[0].generics
249
+ if (defined := project.definition(catalog.modules, origin, project.ALIAS)) is not None:
250
+ return defined[0].aliases[defined[1]]
251
+ path: str = f"{origin[0]}.{origin[1]}"
252
+ return stdlib.needs_arguments(path) if stdlib.defines_class(path) else None
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The order the CLI checks files in: each after those whose unannotated functions it calls.
3
+
4
+ So their `return`s are known when it's checked (see `project.returned`); files calling each other's
5
+ functions (a cycle in the graph `linked.needs` gives) form one component.
6
+ """
7
+
8
+ from collections.abc import Iterator, Mapping, Sequence
9
+ from pathlib import Path
10
+ from typing import Final, NamedTuple, TypeAlias
11
+
12
+ from constricter.fix.index import awaits, fixtures, linked, loose, managed, project
13
+ from constricter.fix.index.project import Index
14
+
15
+ _SUFFIX: Final = ".py"
16
+ _Visit: TypeAlias = tuple[str, Iterator[str]] # a module in the graph, and its callees still to visit
17
+
18
+
19
+ class Plan(NamedTuple):
20
+ """The order to check files in (see `plan`), and the module each one's `returned` goes to."""
21
+
22
+ components: list[list[int]] # files importing each other, by their index in the paths; callees first
23
+ after: list[frozenset[int]] # the components each one's files import unannotated functions from
24
+ names: dict[int, str] # each file's module name, if it's the only file with it
25
+
26
+
27
+ def plan(catalog: Index, paths: Sequence[Path]) -> Plan:
28
+ """Order the files in `paths` for checking: each after the files whose unannotated functions it calls.
29
+
30
+ Those it imports and calls (through re-exports, or an imported module), so their `return`s are known
31
+ when it's checked (see `returned`); files calling each other's (a cycle) are one component. A
32
+ file `catalog` doesn't have, or one of several with the same module name, is one on its own.
33
+
34
+ Returns:
35
+ The components, callees first, and what each comes after; and each file's module.
36
+
37
+ """
38
+ names: list[str | None] = [
39
+ project.module_name(path) if path.suffix == _SUFFIX else None for path in paths
40
+ ]
41
+ counts: dict[str | None, int] = {}
42
+ name: str | None
43
+ for name in names:
44
+ counts[name] = counts.get(name, 0) + 1
45
+ unique: dict[str, int] = {
46
+ name: at
47
+ for at, name in enumerate(names)
48
+ if name is not None and counts[name] == 1 and name in catalog.modules
49
+ }
50
+ graph: dict[str, list[str]] = {
51
+ name: sorted(
52
+ (
53
+ linked.needs(catalog, catalog.modules[name])
54
+ | loose.needs(catalog, catalog.modules[name])
55
+ | managed.needs(catalog, catalog.modules[name])
56
+ | awaits.needs(catalog, catalog.modules[name])
57
+ | fixtures.needed(catalog, name)
58
+ )
59
+ & unique.keys(),
60
+ )
61
+ for name in sorted(unique)
62
+ }
63
+ components: list[list[int]] = []
64
+ after: list[frozenset[int]] = []
65
+ component: dict[str, int] = {}
66
+ members: frozenset[str]
67
+ for members in _Components(graph): # callees' components first
68
+ member: str
69
+ for member in members:
70
+ component[member] = len(components)
71
+ after.append(
72
+ frozenset(
73
+ component[callee] for member in members for callee in graph[member] if callee not in members
74
+ ),
75
+ )
76
+ components.append(sorted(unique[member] for member in members))
77
+ at: int
78
+ for at in sorted(set(range(len(paths))) - set(unique.values())):
79
+ components.append([at])
80
+ after.append(frozenset())
81
+ return Plan(components, after, {at: name for name, at in unique.items()})
82
+
83
+
84
+ class _Components:
85
+ """A graph's strongly connected components (Tarjan's, with a stack of its own: chains can be long)."""
86
+
87
+ def __init__(self, graph: Mapping[str, Sequence[str]]) -> None:
88
+ """Start on `graph`: each node's successors."""
89
+ self.graph: Mapping[str, Sequence[str]] = graph
90
+ self.order: dict[str, int] = {} # each node visited, and when
91
+ self.low: dict[str, int] = {} # the earliest node on the stack each node reaches
92
+ self.stack: list[str] = []
93
+ self.on_stack: set[str] = set()
94
+
95
+ def __iter__(self) -> Iterator[frozenset[str]]:
96
+ """Find the components.
97
+
98
+ Yields:
99
+ Each component, after every component it reaches.
100
+
101
+ """
102
+ start: str
103
+ for start in self.graph:
104
+ yield from self.reached(start)
105
+
106
+ def reached(self, start: str) -> Iterator[frozenset[str]]:
107
+ """Visit what `start` reaches that isn't visited yet (nothing, if it's been visited itself).
108
+
109
+ Yields:
110
+ Each component it completes.
111
+
112
+ """
113
+ if start in self.order:
114
+ return
115
+ # Each entry: a node, and its successors still to visit; `None` at the bottom ends it.
116
+ waiting: list[_Visit | None] = [None, self._visit(start)]
117
+ entry: _Visit
118
+ for entry in iter(waiting.pop, None):
119
+ node: str = entry[0]
120
+ successor: str | None
121
+ if (successor := next(entry[1], None)) is not None:
122
+ waiting.append(entry)
123
+ if successor not in self.order:
124
+ waiting.append(self._visit(successor))
125
+ elif successor in self.on_stack:
126
+ self.low[node] = min(self.low[node], self.order[successor])
127
+ continue
128
+ parent: _Visit | None
129
+ if (parent := waiting[-1]) is not None:
130
+ self.low[parent[0]] = min(self.low[parent[0]], self.low[node])
131
+ if self.low[node] == self.order[node]:
132
+ yield self._popped(node)
133
+
134
+ def _visit(self, node: str) -> _Visit:
135
+ """Put `node` on the stack.
136
+
137
+ Returns:
138
+ It, and its successors to visit.
139
+
140
+ """
141
+ self.order[node] = self.low[node] = len(self.order)
142
+ self.stack.append(node)
143
+ self.on_stack.add(node)
144
+ return node, iter(self.graph[node])
145
+
146
+ def _popped(self, node: str) -> frozenset[str]:
147
+ """Take `node`'s component off the stack: `node` and what's above it.
148
+
149
+ Returns:
150
+ The component.
151
+
152
+ """
153
+ members: list[str] = [*iter(self.stack.pop, node), node]
154
+ self.on_stack.difference_update(members)
155
+ return frozenset(members)
@@ -0,0 +1,238 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Where each of a checked module's top-level names comes from (`Origin`), for `modules.read`.
3
+
4
+ What it imports, under a top-level `if` or `try` too, and what it binds itself.
5
+ """
6
+
7
+ import ast
8
+ from collections.abc import Iterator, Mapping
9
+ from typing import Final, cast
10
+
11
+ from constricter.fix.core.known import Origin
12
+
13
+ _PYTEST: Final = "pytest"
14
+ _IMPORTORSKIP: Final = "importorskip"
15
+ _MODNAME: Final = "modname" # its parameter
16
+
17
+
18
+ def absolute(name: str, module: str | None, level: int, *, is_package: bool) -> str:
19
+ """Resolve `from <.level><module> import ...` in module `name`.
20
+
21
+ Returns:
22
+ The absolute module name.
23
+
24
+ """
25
+ if not level:
26
+ return module or ""
27
+ package: list[str] = name.split(".") if is_package else name.split(".")[:-1]
28
+ base: list[str] = package[: len(package) - (level - 1)] if level > 1 else package
29
+ return ".".join([*base, *([module] if module else [])])
30
+
31
+
32
+ def origins_of(tree: ast.Module, name: str, *, is_package: bool) -> dict[str, Origin]:
33
+ """Map module `name`'s top-level names (the last binding wins).
34
+
35
+ Returns:
36
+ What each refers to.
37
+
38
+ """
39
+ names: dict[str, Origin] = {}
40
+ stmt: ast.stmt
41
+ for stmt in tree.body:
42
+ match stmt:
43
+ case ast.Import() | ast.ImportFrom():
44
+ names.update(_imported(stmt, name, is_package=is_package))
45
+ case _:
46
+ names.update((bound, (name, bound)) for bound in _bound(stmt))
47
+ return names
48
+
49
+
50
+ def _imported(stmt: ast.Import | ast.ImportFrom, name: str, *, is_package: bool) -> dict[str, Origin]:
51
+ """Map the names one import in module `name` binds.
52
+
53
+ Returns:
54
+ What each refers to.
55
+
56
+ """
57
+ names: dict[str, Origin] = {}
58
+ alias: ast.alias
59
+ if isinstance(stmt, ast.Import):
60
+ for alias in stmt.names:
61
+ if alias.asname:
62
+ names[alias.asname] = (alias.name, None)
63
+ else: # `import a.b` binds `a`
64
+ names[alias.name.split(".")[0]] = (alias.name.split(".")[0], None)
65
+ return names
66
+ for alias in stmt.names:
67
+ names[alias.asname or alias.name] = (
68
+ absolute(name, stmt.module, stmt.level, is_package=is_package),
69
+ alias.name,
70
+ )
71
+ return names
72
+
73
+
74
+ def guarded_origins(tree: ast.Module, name: str, *, is_package: bool) -> dict[str, Origin]:
75
+ """Map the names module `name` imports under a top-level `if` or `try` (`if TYPE_CHECKING:`).
76
+
77
+ Not what it binds at run time (`origins_of`): a type variable imported only for the checker is a type
78
+ variable all the same. Not a name two of them bind to different things (one in each branch of an
79
+ `if`): which it is, another file can't say.
80
+
81
+ Returns:
82
+ What each refers to.
83
+
84
+ """
85
+ found: dict[str, Origin] = {}
86
+ either: set[str] = set()
87
+ node: ast.AST
88
+ bound: str
89
+ origin: Origin
90
+ for node in (
91
+ n for stmt in tree.body if isinstance(stmt, ast.If | ast.Try | ast.TryStar) for n in ast.walk(stmt)
92
+ ):
93
+ if isinstance(node, ast.Import | ast.ImportFrom):
94
+ for bound, origin in _imported(node, name, is_package=is_package).items():
95
+ if found.setdefault(bound, origin) != origin:
96
+ either.add(bound)
97
+ return {bound: origin for bound, origin in found.items() if bound not in either}
98
+
99
+
100
+ def skipped(tree: ast.Module, names: Mapping[str, Origin]) -> dict[str, Origin]:
101
+ """Map each name the module binds to `pytest.importorskip("mod")` to that module, as an import binds it.
102
+
103
+ Wherever it's bound, in a function too: the name is the module (`import mod as name`) when every
104
+ binding of it in the module is such a call for that one module. `names`: the module's top-level
105
+ names (`origins_of`), which say how it imports `pytest`.
106
+
107
+ Returns:
108
+ Each such name, mapped to the module's origin.
109
+
110
+ """
111
+ found: dict[str, set[str]] = {}
112
+ calls: set[int] = set()
113
+ node: ast.AST
114
+ for node in ast.walk(tree):
115
+ target: str
116
+ stored: ast.Name
117
+ call: ast.Call
118
+ module: str | None
119
+ match node:
120
+ case ast.Assign(targets=[ast.Name(id=target) as stored], value=ast.Call() as call) if (
121
+ module := _skipped_module(call, names)
122
+ ) is not None:
123
+ found.setdefault(target, set()).add(module)
124
+ calls.add(id(stored))
125
+ case _:
126
+ pass
127
+ if not found:
128
+ return {}
129
+ others: set[str] = set(_bindings(tree, calls))
130
+ stmt: ast.Import | ast.ImportFrom
131
+ for stmt in (each for each in ast.walk(tree) if isinstance(each, ast.Import | ast.ImportFrom)):
132
+ alias: ast.alias
133
+ for alias in stmt.names:
134
+ bound: str = alias.asname or alias.name.partition(".")[0]
135
+ # `import mod` (or `import mod.sub as name`) binds the module it skips for: no other binding.
136
+ same: bool = isinstance(stmt, ast.Import) and found.get(bound) == {
137
+ alias.name if alias.asname else bound,
138
+ }
139
+ if not same:
140
+ others.add(bound)
141
+ return {
142
+ target: (next(iter(modules)), None)
143
+ for target, modules in found.items()
144
+ if len(modules) == 1 and target not in others
145
+ }
146
+
147
+
148
+ def _bindings(tree: ast.Module, skipping: set[int]) -> Iterator[str]:
149
+ """Walk every name the module binds anywhere but by the `importorskip` calls (`skipping`, their targets).
150
+
151
+ Yields:
152
+ Each name: a store, a parameter, a definition or an `except`'s.
153
+
154
+ """
155
+ node: ast.AST
156
+ for node in ast.walk(tree):
157
+ match node:
158
+ case ast.Name(ctx=ast.Store() | ast.Del()) if id(node) not in skipping:
159
+ yield node.id
160
+ case ast.arg():
161
+ yield node.arg
162
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
163
+ yield node.name
164
+ case ast.ExceptHandler(name=str()) | ast.MatchAs(name=str()) | ast.MatchStar(name=str()):
165
+ yield cast("str", node.name)
166
+ case _:
167
+ pass
168
+
169
+
170
+ def _skipped_module(call: ast.Call, names: Mapping[str, Origin]) -> str | None:
171
+ """Find the module `pytest.importorskip(...)` imports: its first argument, a literal.
172
+
173
+ Returns:
174
+ Its name, or `None` for any other call, or a name that isn't a literal.
175
+
176
+ """
177
+ func: ast.expr = call.func
178
+ owner: str
179
+ attr: str
180
+ bound: str
181
+ origin: Origin | None
182
+ match func:
183
+ case ast.Attribute(value=ast.Name(id=owner), attr=attr) if attr == _IMPORTORSKIP:
184
+ origin = None if names.get(owner) != (_PYTEST, None) else (_PYTEST, _IMPORTORSKIP)
185
+ case ast.Name(id=bound):
186
+ origin = names.get(bound)
187
+ case _:
188
+ origin = None
189
+ if origin != (_PYTEST, _IMPORTORSKIP):
190
+ return None
191
+ named: list[ast.expr] = [*call.args[:1], *(k.value for k in call.keywords if k.arg == _MODNAME)]
192
+ module: str
193
+ match named:
194
+ case [ast.Constant(value=str() as module)] if all(part.isidentifier() for part in module.split(".")):
195
+ return module
196
+ case _:
197
+ return None
198
+
199
+
200
+ def starred(tree: ast.Module, name: str, *, is_package: bool) -> tuple[str, ...]:
201
+ """List the modules module `name` imports everything from (`from .main import *`), in order.
202
+
203
+ At its top level, or under a top-level `if` or `try` (pydantic's `__init__`, for type checking).
204
+
205
+ Returns:
206
+ Their absolute names.
207
+
208
+ """
209
+ nodes: Iterator[ast.AST] = (
210
+ node
211
+ for stmt in tree.body
212
+ for node in (ast.walk(stmt) if isinstance(stmt, ast.If | ast.Try | ast.TryStar) else (stmt,))
213
+ )
214
+ return tuple(
215
+ absolute(name, node.module, node.level, is_package=is_package)
216
+ for node in nodes
217
+ if isinstance(node, ast.ImportFrom) and [alias.name for alias in node.names] == ["*"]
218
+ )
219
+
220
+
221
+ def _bound(stmt: ast.stmt) -> Iterator[str]:
222
+ """Walk a top-level statement other than an import.
223
+
224
+ Yields:
225
+ Each name it binds.
226
+
227
+ """
228
+ node: ast.AST
229
+ match stmt:
230
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
231
+ yield stmt.name
232
+ case ast.Assign() | ast.AnnAssign() | ast.AugAssign():
233
+ targets: list[ast.expr] = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
234
+ for node in (n for target in targets for n in ast.walk(target)):
235
+ if isinstance(node, ast.Name):
236
+ yield node.id
237
+ case _:
238
+ pass