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,401 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`--infer-from`: the types a traced run saw (`python -m constricter.trace`), as a type checker's hints are.
3
+
4
+ A trace holds, for each file by its SHA-256, the types each binding of its functions' locals held
5
+ (see `constricter.recording`). `hints` turns a file's into `Hints`, which `--fix` judges as it does a
6
+ checker's (`constricter.fix.values.hinted`): each a guess, of kind `traced`. Only for a local no
7
+ annotation types: one an assignment or a `for` loop binds to a value rooted at a parameter no
8
+ checker types (`row = rows[0].load()`, `for row in rows:`; see `_unannotated`), or at an attribute
9
+ of `self` its class stores nothing else in (see `_undeclared`). Anything else a type
10
+ checker may type wider than the run saw (an `X | None` that was never `None`, a base class, a
11
+ `TypedDict`), which the narrower annotation would make an error. A name bound more than once has
12
+ what all its bindings held, where each is such a binding the run reached. Up to three types seen
13
+ are their union, `None` last.
14
+
15
+ A class is shown bare (one defined in another as `Outer.Inner`), with the import that names it for
16
+ type checking alone (`Offered`), unless the file defines it: `constricter.fix.index.offers` then
17
+ takes only a class the index knows. One of a private module of the standard library has no hint.
18
+ """
19
+
20
+ import ast
21
+ import hashlib
22
+ import json
23
+ import re
24
+ import sys
25
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
26
+ from functools import cache
27
+ from pathlib import Path
28
+ from typing import Final, NamedTuple, TypeAlias, cast
29
+
30
+ from constricter import recording
31
+ from constricter.fix.core.known import Hints, Offered
32
+ from constricter.fix.values.hinted import TRACED
33
+
34
+ _LABEL: Final = "a traced run"
35
+ _SUFFIX: Final = ".py"
36
+ _MEMBERS: Final = 3 # how many types seen make a union
37
+ _CLASS: Final = re.compile(rf"([\w.]+){recording.SEPARATOR}([\w.]+)")
38
+ _STDLIB: Final = sys.stdlib_module_names
39
+ _SELF: Final = "self"
40
+ _TYPED: Final = frozenset({_SELF, "cls"}) # the parameters a class types, unannotated
41
+ _Function: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef
42
+ # Each binding's spellings, by its name, by its line.
43
+ _Bindings: TypeAlias = Mapping[int, Mapping[str, tuple[str, ...]]]
44
+
45
+
46
+ class Trace(NamedTuple):
47
+ """A trace file: each file's bindings by its SHA-256, and the file each module named in it is."""
48
+
49
+ files: Mapping[str, _Bindings]
50
+ modules: Mapping[str, str]
51
+
52
+
53
+ @cache
54
+ def load(path: Path) -> Trace:
55
+ """Read the trace file at `path`, once.
56
+
57
+ Returns:
58
+ It.
59
+
60
+ Raises:
61
+ ValueError: It can't be read, or isn't a trace this version reads.
62
+
63
+ """
64
+ try:
65
+ return _read(_table(cast("object", json.loads(path.read_text(encoding="utf-8")))))
66
+ except (OSError, ValueError) as error:
67
+ message: str = f"{path}: {error}"
68
+ raise ValueError(message) from error
69
+
70
+
71
+ def _read(found: Mapping[str, object]) -> Trace:
72
+ """Read a trace file's contents.
73
+
74
+ Returns:
75
+ The trace, without what isn't as the recorder writes it.
76
+
77
+ Raises:
78
+ ValueError: It isn't a trace this version reads.
79
+
80
+ """
81
+ if found.get("version") != recording.VERSION:
82
+ message: str = f"not a trace `python -m constricter.trace` version {recording.VERSION} wrote"
83
+ raise ValueError(message)
84
+ return Trace(
85
+ {
86
+ digest: {
87
+ int(line): {
88
+ name: tuple(cast("list[str]", types))
89
+ for name, types in _table(held).items()
90
+ if isinstance(types, list)
91
+ }
92
+ for line, held in _table(_table(entry).get("bindings")).items()
93
+ if line.isdecimal()
94
+ }
95
+ for digest, entry in _table(found.get("files")).items()
96
+ },
97
+ {name: digest for name, digest in _table(found.get("modules")).items() if isinstance(digest, str)},
98
+ )
99
+
100
+
101
+ def _table(value: object) -> dict[str, object]:
102
+ return cast("dict[str, object]", value) if isinstance(value, dict) else {}
103
+
104
+
105
+ def merged(
106
+ hinted: Mapping[Path, tuple[Hints, ...]],
107
+ found: Trace,
108
+ paths: Iterable[Path],
109
+ ) -> dict[Path, tuple[Hints, ...]]:
110
+ """Add the trace's hints for each of `paths` after the type checkers' (`hinted`).
111
+
112
+ Returns:
113
+ Each file's hints.
114
+
115
+ """
116
+ every: dict[Path, tuple[Hints, ...]] = dict(hinted)
117
+ path: Path
118
+ for path in paths:
119
+ try:
120
+ traced: Hints | None = hints(found, path.read_bytes()) if path.suffix == _SUFFIX else None
121
+ except OSError: # checking it reports that
122
+ continue
123
+ if traced is not None:
124
+ every[path] = (*every.get(path, ()), traced)
125
+ return every
126
+
127
+
128
+ def hints(found: Trace, source: bytes) -> Hints | None:
129
+ """Make the hints a trace has for a file's `source`.
130
+
131
+ Returns:
132
+ Them; `None` for a file the trace doesn't have as it is, or has nothing for.
133
+
134
+ """
135
+ digest: str = hashlib.sha256(source).hexdigest()
136
+ bindings: _Bindings = found.files.get(digest, {})
137
+ try:
138
+ tree: ast.Module | None = ast.parse(source) if bindings else None
139
+ except (SyntaxError, ValueError):
140
+ tree = None
141
+ types: dict[tuple[int, int], str] = {}
142
+ offered: dict[tuple[int, int], Offered] = {}
143
+ stored: dict[int, frozenset[str]] = {} if tree is None else _undeclared(tree)
144
+ node: ast.AST
145
+ for node in ast.walk(tree) if tree is not None else ():
146
+ targets: list[ast.Name]
147
+ for targets in (
148
+ _untyped(node, stored.get(id(node), frozenset())).values() if isinstance(node, _Function) else ()
149
+ ):
150
+ # A binding the run never reached may hold anything.
151
+ seen: list[tuple[str, ...]] = [
152
+ bindings.get(target.lineno, {}).get(target.id, ()) for target in targets
153
+ ]
154
+ typed: Offered | None = (
155
+ _typed(sorted({spelling for each in seen for spelling in each}), found.modules, digest)
156
+ if all(seen)
157
+ else None
158
+ )
159
+ if typed is not None:
160
+ types.update({(target.lineno, target.end_col_offset or 0): typed.text for target in targets})
161
+ if typed is not None and typed.imports:
162
+ offered.update({(target.lineno, target.end_col_offset or 0): typed for target in targets})
163
+ return Hints(_LABEL, types, offered, TRACED) if types else None
164
+
165
+
166
+ def _typed(spellings: Sequence[str], modules: Mapping[str, str], digest: str) -> Offered | None:
167
+ """Join a local's `spellings` into the annotation they make, with the imports its classes need.
168
+
169
+ `modules`, `digest`: see `Trace.modules`, and the file's own SHA-256, whose classes need none.
170
+
171
+ Returns:
172
+ It, or `None`: nothing seen, something that couldn't be spelled, more than `_MEMBERS` types,
173
+ two classes of one name, or one of a private module of the standard library.
174
+
175
+ """
176
+ if not spellings or recording.UNKNOWN in spellings or len(spellings) > _MEMBERS:
177
+ return None
178
+ origins: dict[str, str] = {}
179
+ spelling: str
180
+ for spelling in spellings:
181
+ module: str
182
+ name: str
183
+ for module, name in cast("list[tuple[str, str]]", _CLASS.findall(spelling)):
184
+ private: bool = module.partition(".")[0] in _STDLIB and any(
185
+ part.startswith("_") for part in module.split(".")
186
+ )
187
+ # A class defined in another is imported by the outer one's name.
188
+ if private or origins.setdefault(name.partition(".")[0], module) != module:
189
+ return None
190
+ return Offered(
191
+ " | ".join(
192
+ _CLASS.sub(r"\2", spelling)
193
+ for spelling in sorted(spellings, key=lambda text: (text == recording.NONE, text))
194
+ ),
195
+ tuple(
196
+ f"from {module} import {name}"
197
+ for name, module in sorted(origins.items())
198
+ if modules.get(module) != digest
199
+ ),
200
+ )
201
+
202
+
203
+ def _undeclared(tree: ast.Module) -> dict[int, frozenset[str]]:
204
+ """Find the attributes of `self` no type checker types, in each method of a class with no base.
205
+
206
+ Those the class's methods store only by a plain `self.x = value`, each value rooted at a
207
+ parameter its method leaves unannotated, and the class's body doesn't bind: a checker types
208
+ one by what's stored in it, so has no type for it.
209
+
210
+ Returns:
211
+ Them, by each such method's identity.
212
+
213
+ """
214
+ found: dict[int, frozenset[str]] = {}
215
+ node: ast.AST
216
+ for node in ast.walk(tree):
217
+ if not isinstance(node, ast.ClassDef) or node.bases or node.keywords:
218
+ continue
219
+ methods: list[_Function] = [
220
+ method
221
+ for method in node.body
222
+ if isinstance(method, _Function) and [arg.arg for arg in _params(method)][:1] == [_SELF]
223
+ ]
224
+ bound: set[str] = {
225
+ each.name if isinstance(each, ast.ClassDef | _Function) else each.id
226
+ for statement in node.body
227
+ for each in (statement, *_own(statement))
228
+ if isinstance(each, ast.ClassDef | _Function)
229
+ or (isinstance(each, ast.Name) and isinstance(each.ctx, ast.Store))
230
+ }
231
+ plain: dict[str, bool] = {} # whether each attribute stored is only ever stored so
232
+ method: _Function
233
+ for method in methods:
234
+ rooted: set[str | None] = _unannotated(method)
235
+ taken: set[int] = {
236
+ id(statement.targets[0])
237
+ for statement in ast.walk(method)
238
+ if isinstance(statement, ast.Assign)
239
+ and len(statement.targets) == 1
240
+ and _root(statement.value)[0] in rooted
241
+ }
242
+ plain.update(
243
+ {
244
+ store.attr: plain.get(store.attr, True) and id(store) in taken
245
+ for store in ast.walk(method)
246
+ if isinstance(store, ast.Attribute)
247
+ and isinstance(store.ctx, ast.Store | ast.Del)
248
+ and isinstance(store.value, ast.Name)
249
+ and store.value.id == _SELF
250
+ },
251
+ )
252
+ attributes: frozenset[str] = frozenset(attr for attr, only in plain.items() if only) - bound
253
+ found.update({id(method): attributes for method in methods})
254
+ return found
255
+
256
+
257
+ def _params(function: _Function) -> list[ast.arg]:
258
+ """List a function's parameters, in order.
259
+
260
+ Returns:
261
+ Them.
262
+
263
+ """
264
+ args: ast.arguments = function.args
265
+ return [
266
+ arg
267
+ for arg in (*args.posonlyargs, *args.args, args.vararg, *args.kwonlyargs, args.kwarg)
268
+ if arg is not None
269
+ ]
270
+
271
+
272
+ def _unannotated(function: _Function) -> set[str | None]:
273
+ """Name the parameters of a function no type checker has a type for.
274
+
275
+ The unannotated ones (not `self` or `cls`, which its class types) it gives no default but
276
+ `None` (a checker types one by its default), doesn't bind again, and doesn't test by a call
277
+ (`isinstance(rows, list)`, a `TypeGuard`'s) or match, which narrows one.
278
+
279
+ Returns:
280
+ Them.
281
+
282
+ """
283
+ args: ast.arguments = function.args
284
+ last: list[ast.arg] = [*args.posonlyargs, *args.args][::-1][: len(args.defaults)][::-1]
285
+ defaults: list[tuple[ast.arg, ast.expr | None]] = [
286
+ *zip(last, args.defaults, strict=True),
287
+ *zip(args.kwonlyargs, args.kw_defaults, strict=True),
288
+ ]
289
+ typed: set[str] = {
290
+ arg.arg
291
+ for arg, default in defaults
292
+ if default is not None and not (isinstance(default, ast.Constant) and default.value is None)
293
+ }
294
+ node: ast.AST
295
+ for node in _own(function):
296
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
297
+ typed.add(node.id)
298
+ elif isinstance(node, ast.Match) and isinstance(node.subject, ast.Name):
299
+ typed.add(node.subject.id)
300
+ elif isinstance(node, ast.If | ast.While | ast.IfExp | ast.Assert):
301
+ typed.update(
302
+ passed.id
303
+ for call in ast.walk(node.test)
304
+ if isinstance(call, ast.Call)
305
+ for passed in (*call.args, *(keyword.value for keyword in call.keywords))
306
+ if isinstance(passed, ast.Name)
307
+ )
308
+ return {arg.arg for arg in _params(function) if arg.annotation is None and arg.arg not in _TYPED | typed}
309
+
310
+
311
+ def _untyped(function: _Function, stored: frozenset[str]) -> dict[str, list[ast.Name]]:
312
+ """Find the locals `function` binds only to values no annotation types.
313
+
314
+ Each by a plain assignment or as a `for` loop's target, to a value rooted at a parameter it
315
+ leaves unannotated (`rows[0].load()`, `make(n)`; not `self` or `cls`, which its class types),
316
+ or at one of the attributes of `self` its class has `stored` so (`self.rows[0]`): a type checker
317
+ has no type for it either, so none to set against what the run saw. Not one rooted at such a
318
+ local, which a checker types by its new annotation.
319
+
320
+ Returns:
321
+ Each such name's bindings.
322
+
323
+ """
324
+ others: set[str] = {arg.arg for arg in _params(function)}
325
+ rooted: set[str | None] = _unannotated(function)
326
+ stores: list[ast.Name] = []
327
+ node: ast.AST
328
+ for node in _own(function):
329
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
330
+ stores.append(node)
331
+ elif isinstance(node, ast.Global | ast.Nonlocal):
332
+ others.update(node.names)
333
+ elif isinstance(node, ast.alias):
334
+ others.add((node.asname or node.name).partition(".")[0])
335
+ elif isinstance(node, ast.ExceptHandler | ast.MatchAs | ast.MatchStar) and node.name is not None:
336
+ others.add(node.name)
337
+ elif isinstance(node, ast.MatchMapping) and node.rest is not None:
338
+ others.add(node.rest)
339
+ targets: list[ast.Name] = [
340
+ target
341
+ for target, value in _assigned(function)
342
+ if _root(value)[0] in rooted or (_root(value)[0] == _SELF and _root(value)[1] in stored)
343
+ ]
344
+ others.update(store.id for store in stores if all(store is not target for target in targets))
345
+ found: dict[str, list[ast.Name]] = {}
346
+ target: ast.Name
347
+ for target in targets:
348
+ if target.id not in others:
349
+ found.setdefault(target.id, []).append(target)
350
+ return found
351
+
352
+
353
+ def _assigned(function: _Function) -> Iterator[tuple[ast.Name, ast.expr]]:
354
+ """Find what binds one name to one value in a function's own scope, in order.
355
+
356
+ Yields:
357
+ Each plain assignment's name and value, and each `for` loop's target and what it loops over.
358
+
359
+ """
360
+ node: ast.AST
361
+ for node in _own(function):
362
+ target: ast.expr | None = None
363
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
364
+ target = node.targets[0]
365
+ elif isinstance(node, ast.For | ast.AsyncFor):
366
+ target = node.target
367
+ if isinstance(node, ast.Assign | ast.For | ast.AsyncFor) and isinstance(target, ast.Name):
368
+ yield target, node.value if isinstance(node, ast.Assign) else node.iter
369
+
370
+
371
+ def _root(value: ast.expr, attr: str | None = None) -> tuple[str | None, str | None]:
372
+ """Find the name a value is taken from: `rows` in `rows[0].load()`, `make` in `make(n)`.
373
+
374
+ `attr`: the attribute `value` is read for.
375
+
376
+ Returns:
377
+ It (`None` for a value that's computed some other way), and the attribute read of the name
378
+ itself, if one is: `rows` in `self.rows[0].load()`.
379
+
380
+ """
381
+ if isinstance(value, ast.Attribute):
382
+ return _root(value.value, value.attr)
383
+ if isinstance(value, ast.Subscript | ast.Await):
384
+ return _root(value.value)
385
+ if isinstance(value, ast.Call):
386
+ return _root(value.func)
387
+ return (value.id if isinstance(value, ast.Name) else None, attr)
388
+
389
+
390
+ def _own(node: ast.AST) -> Iterator[ast.AST]:
391
+ """Walk what a function's own scope holds: not what a function, class or comprehension inside it does.
392
+
393
+ Yields:
394
+ Each node under `node`, a nested scope's own node included.
395
+
396
+ """
397
+ child: ast.AST
398
+ for child in ast.iter_child_nodes(node):
399
+ yield child
400
+ if not isinstance(child, recording.SCOPES):
401
+ yield from _own(child)
@@ -0,0 +1,286 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Worker processes for `--jobs`: each indexes its own share of the files, then checks it."""
3
+
4
+ import contextlib
5
+ import copy
6
+ import enum
7
+ import gc
8
+ import importlib
9
+ import mmap
10
+ import tempfile
11
+ from collections.abc import Callable, Collection, Generator, Mapping, Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, BinaryIO, Final, Self, TypeAlias, cast
15
+
16
+ from constricter.cli import collecting
17
+ from constricter.cli.paths import shares
18
+ from constricter.cli.runs import FileRun
19
+ from constricter.fix.core.known import Hints, Outside, Returns
20
+ from constricter.fix.index import project
21
+ from constricter.rules import parsed
22
+
23
+ if TYPE_CHECKING: # slow to import, and only needed for many files
24
+ from concurrent.futures import Future, ProcessPoolExecutor
25
+ from multiprocessing.context import DefaultContext
26
+
27
+ # A worker's answers, one per file of its share: what it read (to index), and what it found.
28
+ _Reading: TypeAlias = "Future[list[project.Module | None]]"
29
+ _FIRST_COMPLETED: Final = "FIRST_COMPLETED" # `concurrent.futures.FIRST_COMPLETED`
30
+ _Check: TypeAlias = Callable[[Path, Outside], FileRun]
31
+ File: TypeAlias = tuple[Path, tuple[Hints, ...]]
32
+
33
+
34
+ class Again(enum.Enum):
35
+ """Which of a worker's files asked about again it checks: those whose last check knew otherwise."""
36
+
37
+ NO = "no" # each, whatever it was last checked knowing
38
+ RETURNED = "returned" # each whose imports return something else now
39
+ OUTSIDE = "outside" # each that knows anything else from outside now (its text as it was)
40
+
41
+
42
+ # A worker's files to check: each with its hints; what checked modules' functions return since it
43
+ # was last told; and which of them it checks.
44
+ Asked: TypeAlias = tuple[Sequence[File], Mapping[str, Returns], Again]
45
+ Answering: TypeAlias = "Future[list[FileRun | None]]" # a worker's answer for the files it was asked
46
+ _Wait: TypeAlias = Callable[..., tuple[set[object], set[object]]] # `concurrent.futures.wait`: done, and not
47
+ _FORKSERVER: Final = "forkserver" # Python 3.14's start method on Linux
48
+ _PRELOADED: Final = ["constricter.cli.command"] # what a fork server imports before forking a worker
49
+ _TOLD: Final = "told.pickle" # in the workers' directory: what they're all told, once (see `Workers.tell`)
50
+ Told: TypeAlias = bytes | mmap.mmap # what `Workers.tell` gives each worker: a file's contents, mapped
51
+
52
+
53
+ class Workers:
54
+ """Worker processes, each with its own share of the files, which it indexes and then checks.
55
+
56
+ The trees a worker parsed to index its files are still there to check (`parsed.keep`), within its
57
+ part of the budget: sharing the files out anew for the check (a pool's way) would parse them again.
58
+ """
59
+
60
+ def __init__(self, paths: Sequence[Path], jobs: int) -> None:
61
+ """Share `paths` out among up to `jobs` workers (see `paths.shares`)."""
62
+ self.paths: Sequence[Path] = paths
63
+ self.shares: list[list[int]] = shares(paths, jobs)
64
+ self.stack: contextlib.ExitStack = contextlib.ExitStack()
65
+ self.pools: list[ProcessPoolExecutor] = [] # started by `__enter__`
66
+ self.told: Path = Path() # in a directory of their own, made by `__enter__`
67
+
68
+ def __enter__(self) -> Self:
69
+ """Start the workers, one process each, each with its part of the budget.
70
+
71
+ Returns:
72
+ Them.
73
+
74
+ """
75
+ _preload()
76
+ # Before the pools: they stop first, as the stack unwinds, and the directory goes after.
77
+ self.told = Path(self.stack.enter_context(tempfile.TemporaryDirectory(prefix="constricter-"))) / _TOLD
78
+ self.pools = [
79
+ self.stack.enter_context(
80
+ cast(
81
+ "type[ProcessPoolExecutor]",
82
+ importlib.import_module("concurrent.futures").ProcessPoolExecutor,
83
+ )(
84
+ max_workers=1,
85
+ initializer=_started,
86
+ initargs=(len(self.shares),),
87
+ ),
88
+ )
89
+ for _ in self.shares
90
+ ]
91
+ return self
92
+
93
+ def __exit__(self, *_details: object) -> None:
94
+ """Stop the workers."""
95
+ self.stack.close()
96
+
97
+ def over(self, paths: Sequence[Path]) -> "Workers":
98
+ """Ask these workers about `paths` alone, some of their files: each has what it had of them.
99
+
100
+ Returns:
101
+ Them, each with its share of `paths`.
102
+
103
+ """
104
+ place: dict[Path, int] = {path: at for at, path in enumerate(paths)}
105
+ some: Workers = copy.copy(self)
106
+ some.paths = paths
107
+ some.shares = [
108
+ [place[self.paths[at]] for at in share if self.paths[at] in place] for share in self.shares
109
+ ]
110
+ return some
111
+
112
+ def index(self) -> project.Index:
113
+ """Index every file, each worker its share.
114
+
115
+ Returns:
116
+ The index, as one process would build it.
117
+
118
+ """
119
+ return project.indexed(self.read())
120
+
121
+ def read(self) -> list[project.Module | None]:
122
+ """Read every file, each worker its share, keeping its tree to check (see `project.read`).
123
+
124
+ Returns:
125
+ Each file's module, in their order, as one process reads them.
126
+
127
+ """
128
+ read: list[_Reading] = [
129
+ pool.submit(_read_share, [self.paths[at] for at in share])
130
+ for pool, share in zip(self.pools, self.shares, strict=True)
131
+ ]
132
+ found: dict[int, project.Module | None] = {}
133
+ share: list[int]
134
+ future: _Reading
135
+ for share, future in zip(self.shares, read, strict=True):
136
+ found.update(zip(share, future.result(), strict=True))
137
+ return [found[at] for at in range(len(self.paths))]
138
+
139
+ def tell(self, function: Callable[[Told], None], told: bytes) -> None:
140
+ """Run `function(told)` in every worker, and wait for them all: what each is to know from then on.
141
+
142
+ `told` is written once, to a file each worker maps (`_mapped`): a pool would pickle it again
143
+ on its way down each worker's pipe, every copy held at once.
144
+ """
145
+ _ = self.told.write_bytes(told)
146
+ telling: list[Future[None]] = [pool.submit(_mapped, function, self.told) for pool in self.pools]
147
+ each: Future[None]
148
+ for each in telling:
149
+ each.result()
150
+
151
+ def ask(
152
+ self,
153
+ worker: int,
154
+ function: Callable[[_Check, Asked], list[FileRun | None]],
155
+ check: _Check,
156
+ asked: Asked,
157
+ ) -> Answering:
158
+ """Start `function(check, asked)` in one worker: its files to check, and what it's to know for them.
159
+
160
+ Returns:
161
+ The answer it will give.
162
+
163
+ """
164
+ return self.pools[worker].submit(function, check, asked)
165
+
166
+
167
+ @dataclass
168
+ class _Kept:
169
+ """A run's workers, started for its first round and asked again in each later one."""
170
+
171
+ stack: contextlib.ExitStack | None = None
172
+ workers: Workers | None = None
173
+
174
+
175
+ _KEPT: Final = _Kept()
176
+
177
+
178
+ @contextlib.contextmanager
179
+ def kept() -> Generator[None]:
180
+ """Keep the workers `started` starts until the block ends: a run's rounds share them.
181
+
182
+ A file's second check is then by the process that has its tree and how its first check ended,
183
+ where new processes would parse it and check it whole.
184
+ """
185
+ stack: contextlib.ExitStack
186
+ with contextlib.ExitStack() as stack:
187
+ _KEPT.stack = stack
188
+ try:
189
+ yield
190
+ finally:
191
+ _KEPT.stack = _KEPT.workers = None
192
+
193
+
194
+ def running() -> bool:
195
+ """Check whether the run's workers (see `kept`) have started: its later rounds are theirs to check.
196
+
197
+ Returns:
198
+ Whether they have.
199
+
200
+ """
201
+ return _KEPT.workers is not None
202
+
203
+
204
+ def started(paths: Sequence[Path], jobs: int) -> Workers:
205
+ """Start workers for `paths`, or ask the run's (see `kept`) about these of their files.
206
+
207
+ Returns:
208
+ The workers.
209
+
210
+ """
211
+ if _KEPT.workers is not None:
212
+ return _KEPT.workers.over(paths)
213
+ _KEPT.workers = (_KEPT.stack or contextlib.ExitStack()).enter_context(Workers(paths, jobs))
214
+ return _KEPT.workers
215
+
216
+
217
+ def first_done(pending: Collection[Answering]) -> set[Answering]:
218
+ """Wait for any of `pending` to finish.
219
+
220
+ Returns:
221
+ Those that have.
222
+
223
+ """
224
+ wait: _Wait = cast("_Wait", importlib.import_module("concurrent.futures").wait)
225
+ return cast("set[Answering]", wait(pending, return_when=_FIRST_COMPLETED)[0])
226
+
227
+
228
+ def _preload() -> None:
229
+ """Have a fork server import constricter, its tables read, before it forks a worker.
230
+
231
+ Its own default imports a script's `__main__` alone: under `python -m constricter` each worker
232
+ would import constricter itself as it starts. Once the server runs, this changes nothing; a
233
+ forked worker has the parent's imports, and a spawned one imports its own whatever this says.
234
+ """
235
+ get_context: Callable[[], DefaultContext] = cast(
236
+ "Callable[[], DefaultContext]",
237
+ importlib.import_module("multiprocessing").get_context,
238
+ )
239
+ context: DefaultContext = get_context()
240
+ if context.get_start_method() == _FORKSERVER:
241
+ context.set_forkserver_preload(_PRELOADED)
242
+
243
+
244
+ def _mapped(function: Callable[[Told], None], told: Path) -> None:
245
+ """Run `function` on the contents of the file `told`, mapped, in a worker (see `Workers.tell`)."""
246
+ file: BinaryIO
247
+ mapped: mmap.mmap
248
+ with told.open("rb") as file, mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ) as mapped:
249
+ function(mapped)
250
+
251
+
252
+ def _started(processes: int) -> None:
253
+ """Start a worker: its share of the kept trees' budget, and the collector off (see `collecting`)."""
254
+ parsed.budget(processes)
255
+ gc.disable()
256
+
257
+
258
+ def _read_share(paths: Sequence[Path]) -> list[project.Module | None]:
259
+ """Index a worker's share of the files (see `project.read`).
260
+
261
+ Returns:
262
+ Each file's module, or `None`.
263
+
264
+ """
265
+ found: list[project.Module | None] = [project.read(path) for path in paths]
266
+ collecting.indexed()
267
+ return found
268
+
269
+
270
+ def check_share(
271
+ check: Callable[[Path, Outside], FileRun],
272
+ files: Sequence[tuple[Path, Outside]],
273
+ ) -> list[FileRun]:
274
+ """Check a share of the files (a worker's, or all of them in one process).
275
+
276
+ Returns:
277
+ What each found.
278
+
279
+ """
280
+ found: list[FileRun] = []
281
+ path: Path
282
+ outside: Outside
283
+ for path, outside in files:
284
+ found.append(check(path, outside))
285
+ collecting.sweep()
286
+ return found