python-constricter 0.4.1__cp311-cp311-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__.cp311-win32.pyd +0 -0
  29. constricter/fix/__init__.py +2 -0
  30. constricter/fix/core/__init__.cp311-win32.pyd +0 -0
  31. constricter/fix/core/__init__.py +2 -0
  32. constricter/fix/core/asked.cp311-win32.pyd +0 -0
  33. constricter/fix/core/asked.py +56 -0
  34. constricter/fix/core/fixes.cp311-win32.pyd +0 -0
  35. constricter/fix/core/fixes.py +317 -0
  36. constricter/fix/core/imports.cp311-win32.pyd +0 -0
  37. constricter/fix/core/imports.py +580 -0
  38. constricter/fix/core/inherited.cp311-win32.pyd +0 -0
  39. constricter/fix/core/inherited.py +311 -0
  40. constricter/fix/core/known.cp311-win32.pyd +0 -0
  41. constricter/fix/core/known.py +730 -0
  42. constricter/fix/core/signatures.cp311-win32.pyd +0 -0
  43. constricter/fix/core/signatures.py +132 -0
  44. constricter/fix/index/__init__.cp311-win32.pyd +0 -0
  45. constricter/fix/index/__init__.py +2 -0
  46. constricter/fix/index/awaits.cp311-win32.pyd +0 -0
  47. constricter/fix/index/awaits.py +90 -0
  48. constricter/fix/index/beyond.cp311-win32.pyd +0 -0
  49. constricter/fix/index/beyond.py +149 -0
  50. constricter/fix/index/callers.cp311-win32.pyd +0 -0
  51. constricter/fix/index/callers.py +155 -0
  52. constricter/fix/index/crossed.cp311-win32.pyd +0 -0
  53. constricter/fix/index/crossed.py +327 -0
  54. constricter/fix/index/decorated.cp311-win32.pyd +0 -0
  55. constricter/fix/index/decorated.py +140 -0
  56. constricter/fix/index/fixtures.cp311-win32.pyd +0 -0
  57. constricter/fix/index/fixtures.py +176 -0
  58. constricter/fix/index/linked.cp311-win32.pyd +0 -0
  59. constricter/fix/index/linked.py +142 -0
  60. constricter/fix/index/loose.cp311-win32.pyd +0 -0
  61. constricter/fix/index/loose.py +103 -0
  62. constricter/fix/index/managed.cp311-win32.pyd +0 -0
  63. constricter/fix/index/managed.py +63 -0
  64. constricter/fix/index/modules.cp311-win32.pyd +0 -0
  65. constricter/fix/index/modules.py +741 -0
  66. constricter/fix/index/offers.cp311-win32.pyd +0 -0
  67. constricter/fix/index/offers.py +252 -0
  68. constricter/fix/index/order.cp311-win32.pyd +0 -0
  69. constricter/fix/index/order.py +155 -0
  70. constricter/fix/index/origins.cp311-win32.pyd +0 -0
  71. constricter/fix/index/origins.py +238 -0
  72. constricter/fix/index/own_methods.cp311-win32.pyd +0 -0
  73. constricter/fix/index/own_methods.py +91 -0
  74. constricter/fix/index/own_overloads.cp311-win32.pyd +0 -0
  75. constricter/fix/index/own_overloads.py +70 -0
  76. constricter/fix/index/own_types.cp311-win32.pyd +0 -0
  77. constricter/fix/index/own_types.py +330 -0
  78. constricter/fix/index/params.cp311-win32.pyd +0 -0
  79. constricter/fix/index/params.py +81 -0
  80. constricter/fix/index/plain.cp311-win32.pyd +0 -0
  81. constricter/fix/index/plain.py +221 -0
  82. constricter/fix/index/project.cp311-win32.pyd +0 -0
  83. constricter/fix/index/project.py +797 -0
  84. constricter/fix/index/sides.cp311-win32.pyd +0 -0
  85. constricter/fix/index/sides.py +160 -0
  86. constricter/fix/index/strings.cp311-win32.pyd +0 -0
  87. constricter/fix/index/strings.py +46 -0
  88. constricter/fix/index/stubs/__init__.cp311-win32.pyd +0 -0
  89. constricter/fix/index/stubs/__init__.py +2 -0
  90. constricter/fix/index/stubs/atoms.cp311-win32.pyd +0 -0
  91. constricter/fix/index/stubs/atoms.py +425 -0
  92. constricter/fix/index/stubs/chained.cp311-win32.pyd +0 -0
  93. constricter/fix/index/stubs/chained.py +69 -0
  94. constricter/fix/index/stubs/classnames.cp311-win32.pyd +0 -0
  95. constricter/fix/index/stubs/classnames.py +124 -0
  96. constricter/fix/index/stubs/declared.cp311-win32.pyd +0 -0
  97. constricter/fix/index/stubs/declared.py +508 -0
  98. constricter/fix/index/stubs/installed.cp311-win32.pyd +0 -0
  99. constricter/fix/index/stubs/installed.py +425 -0
  100. constricter/fix/index/stubs/names.cp311-win32.pyd +0 -0
  101. constricter/fix/index/stubs/names.py +200 -0
  102. constricter/fix/index/stubs/read.cp311-win32.pyd +0 -0
  103. constricter/fix/index/stubs/read.py +70 -0
  104. constricter/fix/index/stubs/signatures.cp311-win32.pyd +0 -0
  105. constricter/fix/index/stubs/signatures.py +656 -0
  106. constricter/fix/index/stubs/stubbed.cp311-win32.pyd +0 -0
  107. constricter/fix/index/stubs/stubbed.py +208 -0
  108. constricter/fix/index/taken.cp311-win32.pyd +0 -0
  109. constricter/fix/index/taken.py +45 -0
  110. constricter/fix/index/tuples.cp311-win32.pyd +0 -0
  111. constricter/fix/index/tuples.py +112 -0
  112. constricter/fix/libraries/__init__.cp311-win32.pyd +0 -0
  113. constricter/fix/libraries/__init__.py +2 -0
  114. constricter/fix/libraries/library.cp311-win32.pyd +0 -0
  115. constricter/fix/libraries/library.py +420 -0
  116. constricter/fix/libraries/opened.cp311-win32.pyd +0 -0
  117. constricter/fix/libraries/opened.py +147 -0
  118. constricter/fix/libraries/overloads.cp311-win32.pyd +0 -0
  119. constricter/fix/libraries/overloads.py +960 -0
  120. constricter/fix/libraries/receivers.cp311-win32.pyd +0 -0
  121. constricter/fix/libraries/receivers.py +274 -0
  122. constricter/fix/libraries/stdlib.cp311-win32.pyd +0 -0
  123. constricter/fix/libraries/stdlib.py +752 -0
  124. constricter/fix/libraries/walks.cp311-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__.cp311-win32.pyd +0 -0
  148. constricter/fix/values/__init__.py +2 -0
  149. constricter/fix/values/aliased.cp311-win32.pyd +0 -0
  150. constricter/fix/values/aliased.py +195 -0
  151. constricter/fix/values/bodies.cp311-win32.pyd +0 -0
  152. constricter/fix/values/bodies.py +76 -0
  153. constricter/fix/values/callables.cp311-win32.pyd +0 -0
  154. constricter/fix/values/callables.py +323 -0
  155. constricter/fix/values/called.cp311-win32.pyd +0 -0
  156. constricter/fix/values/called.py +194 -0
  157. constricter/fix/values/classvars.cp311-win32.pyd +0 -0
  158. constricter/fix/values/classvars.py +629 -0
  159. constricter/fix/values/decided.cp311-win32.pyd +0 -0
  160. constricter/fix/values/decided.py +429 -0
  161. constricter/fix/values/displays.cp311-win32.pyd +0 -0
  162. constricter/fix/values/displays.py +101 -0
  163. constricter/fix/values/doubts.cp311-win32.pyd +0 -0
  164. constricter/fix/values/doubts.py +677 -0
  165. constricter/fix/values/entered.cp311-win32.pyd +0 -0
  166. constricter/fix/values/entered.py +332 -0
  167. constricter/fix/values/fills.cp311-win32.pyd +0 -0
  168. constricter/fix/values/fills.py +446 -0
  169. constricter/fix/values/guesses.cp311-win32.pyd +0 -0
  170. constricter/fix/values/guesses.py +376 -0
  171. constricter/fix/values/hinted.cp311-win32.pyd +0 -0
  172. constricter/fix/values/hinted.py +811 -0
  173. constricter/fix/values/inference.cp311-win32.pyd +0 -0
  174. constricter/fix/values/inference.py +698 -0
  175. constricter/fix/values/loops.cp311-win32.pyd +0 -0
  176. constricter/fix/values/loops.py +335 -0
  177. constricter/fix/values/members.cp311-win32.pyd +0 -0
  178. constricter/fix/values/members.py +578 -0
  179. constricter/fix/values/narrowed.cp311-win32.pyd +0 -0
  180. constricter/fix/values/narrowed.py +220 -0
  181. constricter/fix/values/operated.cp311-win32.pyd +0 -0
  182. constricter/fix/values/operated.py +202 -0
  183. constricter/fix/values/ordered.cp311-win32.pyd +0 -0
  184. constricter/fix/values/ordered.py +74 -0
  185. constricter/fix/values/returned.cp311-win32.pyd +0 -0
  186. constricter/fix/values/returned.py +479 -0
  187. constricter/fix/values/returns.cp311-win32.pyd +0 -0
  188. constricter/fix/values/returns.py +237 -0
  189. constricter/fix/values/shapes.cp311-win32.pyd +0 -0
  190. constricter/fix/values/shapes.py +654 -0
  191. constricter/fix/values/stored.cp311-win32.pyd +0 -0
  192. constricter/fix/values/stored.py +564 -0
  193. constricter/fix/values/targets.cp311-win32.pyd +0 -0
  194. constricter/fix/values/targets.py +419 -0
  195. constricter/fix/values/unchecked.cp311-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__.cp311-win32.pyd +0 -0
  208. constricter/rules/__init__.py +2 -0
  209. constricter/rules/annotations.cp311-win32.pyd +0 -0
  210. constricter/rules/annotations.py +296 -0
  211. constricter/rules/binding.cp311-win32.pyd +0 -0
  212. constricter/rules/binding.py +652 -0
  213. constricter/rules/calls.cp311-win32.pyd +0 -0
  214. constricter/rules/calls.py +460 -0
  215. constricter/rules/checker.cp311-win32.pyd +0 -0
  216. constricter/rules/checker.py +536 -0
  217. constricter/rules/declarations.cp311-win32.pyd +0 -0
  218. constricter/rules/declarations.py +730 -0
  219. constricter/rules/decorators.cp311-win32.pyd +0 -0
  220. constricter/rules/decorators.py +351 -0
  221. constricter/rules/flow.cp311-win32.pyd +0 -0
  222. constricter/rules/flow.py +502 -0
  223. constricter/rules/keys.cp311-win32.pyd +0 -0
  224. constricter/rules/keys.py +79 -0
  225. constricter/rules/late.cp311-win32.pyd +0 -0
  226. constricter/rules/late.py +969 -0
  227. constricter/rules/narrowing.cp311-win32.pyd +0 -0
  228. constricter/rules/narrowing.py +98 -0
  229. constricter/rules/parsed.cp311-win32.pyd +0 -0
  230. constricter/rules/parsed.py +136 -0
  231. constricter/rules/quoted.cp311-win32.pyd +0 -0
  232. constricter/rules/quoted.py +141 -0
  233. constricter/rules/rebinding.cp311-win32.pyd +0 -0
  234. constricter/rules/rebinding.py +169 -0
  235. constricter/rules/recorded.cp311-win32.pyd +0 -0
  236. constricter/rules/recorded.py +168 -0
  237. constricter/rules/redundant.cp311-win32.pyd +0 -0
  238. constricter/rules/redundant.py +160 -0
  239. constricter/rules/scope.cp311-win32.pyd +0 -0
  240. constricter/rules/scope.py +802 -0
  241. constricter/rules/scoped.cp311-win32.pyd +0 -0
  242. constricter/rules/scoped.py +232 -0
  243. constricter/rules/scoping.cp311-win32.pyd +0 -0
  244. constricter/rules/scoping.py +346 -0
  245. constricter/rules/settings.cp311-win32.pyd +0 -0
  246. constricter/rules/settings.py +189 -0
  247. constricter/rules/syntax.cp311-win32.pyd +0 -0
  248. constricter/rules/syntax.py +472 -0
  249. constricter/rules/tables.cp311-win32.pyd +0 -0
  250. constricter/rules/tables.py +169 -0
  251. constricter/rules/walked.cp311-win32.pyd +0 -0
  252. constricter/rules/walked.py +127 -0
  253. constricter/rules/widened.cp311-win32.pyd +0 -0
  254. constricter/rules/widened.py +322 -0
  255. constricter/trace.py +374 -0
  256. e45ce06b2fdd5c43f782__mypyc.cp311-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,151 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Which files to check: the paths given, directories walked, and what's excluded."""
3
+
4
+ import os
5
+ import shutil
6
+ import subprocess # asks git which files it tracks, or would
7
+ from collections.abc import Iterable, Iterator, Sequence
8
+ from fnmatch import fnmatch
9
+ from pathlib import Path
10
+ from typing import Final
11
+
12
+ from constricter import notebook
13
+
14
+ # Skipped in a directory walk unless `--exclude` says otherwise: hidden directories, and common
15
+ # tool/vendor ones. Merged with `exclude`'s globs (matched per directory name), not replaced by them.
16
+ _DEFAULT_EXCLUDES: Final = (".*", "__pycache__", "node_modules", "venv", "site-packages", "build", "dist")
17
+ STDIN: Final = Path("-")
18
+ _KINDS: Final = ("*.py", f"*{notebook.SUFFIX}") # the files a directory walk finds
19
+
20
+
21
+ def excluded(path: Path, patterns: Sequence[str], text: str | None = None) -> bool:
22
+ """Check `path` (or its `text`, as given) against `patterns`: the whole path, or its name.
23
+
24
+ Returns:
25
+ Whether any glob matches either.
26
+
27
+ """
28
+ text = path.as_posix() if text is None else text
29
+ return any(fnmatch(text, p) or fnmatch(path.name, p) for p in patterns)
30
+
31
+
32
+ def python_files(
33
+ paths: Sequence[Path],
34
+ exclude: Sequence[str] = (),
35
+ *,
36
+ tracked: bool = False,
37
+ ) -> Iterator[Path]:
38
+ """Find the files to check.
39
+
40
+ A directory walk skips `_DEFAULT_EXCLUDES` and `exclude`'s globs by directory name, and `exclude`
41
+ by whole path or file name; a file named directly is checked regardless. With `tracked`, a
42
+ directory in a git checkout has only what git tracks there or would (`_listed`), so nothing its
43
+ `.gitignore` files name.
44
+
45
+ Yields:
46
+ Each file given, and each `*.py` under each directory given.
47
+
48
+ """
49
+ globs: tuple[str, ...] = (*_DEFAULT_EXCLUDES, *exclude)
50
+ path: Path
51
+ found: Path
52
+ for path in paths:
53
+ if not path.is_dir():
54
+ if not excluded(path, exclude):
55
+ yield path
56
+ continue
57
+ listed: list[Path] | None = _listed(path) if tracked else None
58
+ for found in sorted(_walked(path, globs) if listed is None else _under(path, listed, globs)):
59
+ if not excluded(found, exclude):
60
+ yield found
61
+
62
+
63
+ def _listed(top: Path) -> list[Path] | None:
64
+ """Ask git for the files to check under `top`: those it tracks, and the untracked it doesn't ignore.
65
+
66
+ By its `.gitignore` files, its `info/exclude` and the user's own, as git reads them.
67
+
68
+ Returns:
69
+ Them; `None` where there's no git or `top` is in no checkout, and where git lists none: a
70
+ directory it ignores was still named, and is walked whole.
71
+
72
+ """
73
+ git: str | None
74
+ if (git := shutil.which("git")) is None:
75
+ return None
76
+ asking: list[str] = [git, "-C", str(top), "ls-files", "-z", "--cached", "--others", "--exclude-standard"]
77
+ try:
78
+ ran: subprocess.CompletedProcess[bytes] = subprocess.run(
79
+ [*asking, "--", *_KINDS],
80
+ capture_output=True,
81
+ check=False,
82
+ env=os.environ | {"GIT_OPTIONAL_LOCKS": "0"}, # reads the index: never waits to write it
83
+ )
84
+ except OSError:
85
+ return None
86
+ if ran.returncode or not ran.stdout:
87
+ return None
88
+ return [top / os.fsdecode(name) for name in ran.stdout.split(b"\0") if name]
89
+
90
+
91
+ def _under(top: Path, listed: Iterable[Path], globs: Sequence[str]) -> Iterator[Path]:
92
+ """Keep the files git listed under `top` that a walk would reach: there, in no skipped directory.
93
+
94
+ Yields:
95
+ Each (one tracked and since deleted isn't there).
96
+
97
+ """
98
+ found: Path
99
+ for found in listed:
100
+ inside: tuple[str, ...] = found.relative_to(top).parts[:-1]
101
+ if not any(fnmatch(part, glob) for part in inside for glob in globs) and found.is_file():
102
+ yield found
103
+
104
+
105
+ def _walked(top: Path, globs: Sequence[str]) -> Iterator[Path]:
106
+ """Walk `top` for the files to check, entering no directory whose name a glob of `globs` matches.
107
+
108
+ Yields:
109
+ Each `*.py` file and notebook under it, in no order.
110
+
111
+ """
112
+ directory: str
113
+ inner: list[str]
114
+ names: list[str]
115
+ for directory, inner, names in os.walk(top):
116
+ inner[:] = [name for name in inner if not any(fnmatch(name, glob) for glob in globs)]
117
+ name: str
118
+ for name in names:
119
+ if any(fnmatch(name, kind) for kind in _KINDS):
120
+ yield Path(directory, name)
121
+
122
+
123
+ def shares(paths: Sequence[Path], workers: int) -> list[list[int]]:
124
+ """Share the files among the workers, the biggest first to the least loaded: a check's time goes by size.
125
+
126
+ Returns:
127
+ Each worker's files, by their place in `paths` (no more workers than files).
128
+
129
+ """
130
+ loads: list[int] = [0] * min(workers, len(paths))
131
+ found: list[list[int]] = [[] for _ in loads]
132
+ sizes: list[int] = [_size(path) for path in paths]
133
+ index: int
134
+ for index in sorted(range(len(paths)), key=lambda at: -sizes[at]):
135
+ least: int = loads.index(min(loads))
136
+ found[least].append(index)
137
+ loads[least] += sizes[index] or 1
138
+ return [sorted(share) for share in found if share]
139
+
140
+
141
+ def _size(path: Path) -> int:
142
+ """Find a file's size, to balance the workers by.
143
+
144
+ Returns:
145
+ It, or 0 if it can't be read (checking it reports that).
146
+
147
+ """
148
+ try:
149
+ return path.stat().st_size
150
+ except OSError:
151
+ return 0
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """A file's text, in the encoding Python reads it in; `-` is standard input."""
3
+
4
+ import io
5
+ import sys
6
+ import tokenize
7
+ from pathlib import Path
8
+
9
+ from constricter import notebook
10
+ from constricter.cli.paths import STDIN
11
+
12
+
13
+ def encoding_of(path: Path, data: bytes) -> str:
14
+ """Find the encoding of `path`, whose bytes are `data`: a notebook's is UTF-8 (it's JSON).
15
+
16
+ Returns:
17
+ A module's, as Python finds it: a PEP 263 declaration (`# -*- coding: latin-1 -*-`), a BOM
18
+ (`utf-8-sig`), or UTF-8. Raises `SyntaxError` for an unknown or contradictory one.
19
+
20
+ """
21
+ return (
22
+ "utf-8" if path.suffix == notebook.SUFFIX else tokenize.detect_encoding(io.BytesIO(data).readline)[0]
23
+ )
24
+
25
+
26
+ def read_text(path: Path) -> str:
27
+ """Read `path`, in its encoding; `-` is standard input.
28
+
29
+ Returns:
30
+ Its text.
31
+
32
+ """
33
+ if path == STDIN:
34
+ return sys.stdin.read()
35
+ data: bytes = path.read_bytes()
36
+ return data.decode(encoding_of(path, data))
@@ -0,0 +1,397 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The command's output: text, JSON, GitHub workflow commands, SARIF, and statistics."""
3
+
4
+ import hashlib
5
+ import json
6
+ from collections import Counter
7
+ from collections.abc import Callable, Iterator, Sequence
8
+ from enum import StrEnum
9
+ from html import escape
10
+ from pathlib import Path
11
+ from typing import Final, NamedTuple, TypeAlias
12
+
13
+ from constricter import __version__
14
+ from constricter.cli.explain import explain
15
+ from constricter.fix.core.fixes import Replacement
16
+ from constricter.offences import MESSAGES, Level, Offence
17
+
18
+ _URL: Final = "https://github.com/ivylikethevine/python-constricter"
19
+ _Json: TypeAlias = "str | int | bool | list[_Json] | dict[str, _Json]"
20
+
21
+
22
+ class Format(StrEnum):
23
+ """The `--format` choices."""
24
+
25
+ TEXT = "text"
26
+ FULL = "full" # text, with each offence's source line and a caret under the name
27
+ JSON = "json"
28
+ GITHUB = "github"
29
+ SARIF = "sarif"
30
+ GITLAB = "gitlab"
31
+ JUNIT = "junit"
32
+ RDJSON = "rdjson"
33
+
34
+
35
+ class Result(NamedTuple):
36
+ """One reported offence, in its file, at the level that applies to that file."""
37
+
38
+ path: Path
39
+ offence: Offence
40
+ level: Level
41
+ source: str = "" # the offending line, for `--format=full`
42
+ replacements: tuple[Replacement, ...] = () # its fix as text edits, for SARIF and rdjson
43
+ fixed: bool = False # `--fix` made its fix: listed with `--show-fixes`, on the line it was on
44
+
45
+ @property
46
+ def suggestions(self) -> tuple[Replacement, ...]:
47
+ """Its fix as text edits, if it's certain (not a guess)."""
48
+ return () if self.offence.unsafe else self.replacements
49
+
50
+ @property
51
+ def severity(self) -> str:
52
+ """`error` or `warning`, at the result's level."""
53
+ return "error" if self.offence.is_error(self.level) else "warning"
54
+
55
+ @property
56
+ def message(self) -> str:
57
+ """The offence's message; in a notebook, with its cell and line first."""
58
+ o: Offence = self.offence
59
+ return o.message if o.cell is None else f"cell {o.cell}, line {o.line}: {o.message}"
60
+
61
+
62
+ def _github_escape(text: str, *, prop: bool = False) -> str:
63
+ text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
64
+ return text.replace(":", "%3A").replace(",", "%2C") if prop else text
65
+
66
+
67
+ def _where(result: Result) -> dict[str, _Json]:
68
+ """Locate `result` for SARIF.
69
+
70
+ Returns:
71
+ Its physical location; a notebook's is the file, as its cells have no file lines.
72
+
73
+ """
74
+ where: dict[str, _Json] = {"artifactLocation": {"uri": result.path.as_posix()}}
75
+ if result.offence.cell is None:
76
+ where["region"] = {"startLine": result.offence.line, "startColumn": _column(result) + 1}
77
+ return where
78
+
79
+
80
+ def _column(result: Result) -> int:
81
+ """Find `result`'s column in characters (its offence's counts UTF-8 bytes, as `ast`'s do).
82
+
83
+ Returns:
84
+ It, from 0.
85
+
86
+ """
87
+ return len(result.source.encode()[: result.offence.col].decode(errors="ignore"))
88
+
89
+
90
+ def _sarif_result(result: Result) -> dict[str, _Json]:
91
+ """Render one result for SARIF; a certain fix is a SARIF `fix`.
92
+
93
+ Returns:
94
+ The result object.
95
+
96
+ """
97
+ found: dict[str, _Json] = {
98
+ "ruleId": result.offence.code,
99
+ "level": result.severity,
100
+ "message": {"text": result.message},
101
+ "locations": [{"physicalLocation": _where(result)}],
102
+ }
103
+ edits: tuple[Replacement, ...]
104
+ if edits := result.suggestions:
105
+ change: dict[str, _Json] = {
106
+ "artifactLocation": {"uri": result.path.as_posix()},
107
+ "replacements": [
108
+ {"deletedRegion": _region(edit), "insertedContent": {"text": edit.text}} for edit in edits
109
+ ],
110
+ }
111
+ found["fixes"] = [{"description": {"text": _described(result.offence)}, "artifactChanges": [change]}]
112
+ return found
113
+
114
+
115
+ def _region(edit: Replacement) -> dict[str, _Json]:
116
+ """Place a text edit for SARIF, in characters.
117
+
118
+ Returns:
119
+ Its region.
120
+
121
+ """
122
+ start: int
123
+ end: int
124
+ start, end = edit.columns
125
+ return {"startLine": edit.line, "startColumn": start + 1, "endLine": edit.line, "endColumn": end + 1}
126
+
127
+
128
+ def _described(offence: Offence) -> str:
129
+ """Say what an offence's fix does.
130
+
131
+ Returns:
132
+ A sentence: an annotation added, or a repeated one dropped (its annotation is empty).
133
+
134
+ """
135
+ return (
136
+ f"Annotate {offence.name!r} as `{offence.fix}`"
137
+ if offence.fix
138
+ else f"Drop {offence.name!r}'s annotation"
139
+ )
140
+
141
+
142
+ def _sarif(results: Sequence[Result]) -> dict[str, _Json]:
143
+ rules: list[_Json] = [
144
+ {
145
+ "id": code,
146
+ "shortDescription": {"text": message.format(name="`name`", detail="T")},
147
+ "help": {"text": explain(code)},
148
+ "helpUri": f"{_URL}#rules",
149
+ }
150
+ for code, message in MESSAGES.items()
151
+ ]
152
+ driver: dict[str, _Json] = {
153
+ "name": "constricter",
154
+ "version": __version__,
155
+ "informationUri": _URL,
156
+ "rules": rules,
157
+ }
158
+ run: dict[str, _Json] = {
159
+ "tool": {"driver": driver},
160
+ "columnKind": "unicodeCodePoints",
161
+ "results": [_sarif_result(r) for r in results],
162
+ }
163
+ return {
164
+ "version": "2.1.0",
165
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
166
+ "runs": [run],
167
+ }
168
+
169
+
170
+ def _text(results: Sequence[Result]) -> Iterator[str]:
171
+ r: Result
172
+ for r in results:
173
+ cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
174
+ where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
175
+ yield f"{where}: {r.severity}: {r.offence.code} {r.offence.message}"
176
+
177
+
178
+ def _full(results: Sequence[Result]) -> Iterator[str]:
179
+ """Render `_text`'s lines, each followed by its source line and a caret under the name.
180
+
181
+ Yields:
182
+ The lines: a result's text line, then a gutter numbered with its line, the source, and the
183
+ caret, then a blank line.
184
+
185
+ """
186
+ r: Result
187
+ for r in results:
188
+ yield from _text([r])
189
+ source: str = r.source
190
+ # `col` counts UTF-8 bytes, as `ast` does; the caret goes under that many characters.
191
+ start: int = len(source.encode()[: r.offence.col].decode(errors="ignore"))
192
+ width: int = len(r.offence.name) if source[start:].startswith(r.offence.name) else 1
193
+ number: str = str(r.offence.line)
194
+ gutter: str = " " * len(number)
195
+ yield f"{gutter} |"
196
+ yield f"{number} | {source}"
197
+ yield f"{gutter} | {' ' * start}{'^' * width}"
198
+ yield ""
199
+
200
+
201
+ def _json(results: Sequence[Result]) -> Iterator[str]:
202
+ yield json.dumps(
203
+ [
204
+ {
205
+ "path": str(r.path),
206
+ "line": r.offence.line,
207
+ "column": r.offence.col + 1,
208
+ "code": r.offence.code,
209
+ "severity": r.severity,
210
+ "message": r.offence.message,
211
+ "cell": r.offence.cell,
212
+ "fixed": r.fixed,
213
+ "fix": None
214
+ if r.offence.fix is None
215
+ else {
216
+ "annotation": r.offence.fix,
217
+ "reason": r.offence.reason,
218
+ "unsafe": r.offence.unsafe,
219
+ "likely": r.offence.likely,
220
+ "kinds": sorted(r.offence.edit.kinds if r.offence.edit else ()),
221
+ "imports": [*r.offence.edit.imports, *r.offence.edit.guarded] if r.offence.edit else [],
222
+ },
223
+ }
224
+ for r in results
225
+ ],
226
+ indent=2,
227
+ )
228
+
229
+
230
+ def _github(results: Sequence[Result]) -> Iterator[str]:
231
+ r: Result
232
+ for r in results:
233
+ location: str = f"file={_github_escape(str(r.path), prop=True)}"
234
+ if r.offence.cell is None: # a notebook's cells have no file lines to point at
235
+ location += f",line={r.offence.line},col={r.offence.col + 1}"
236
+ yield f"::{r.severity} {location},title={r.offence.code}::{_github_escape(r.message)}"
237
+
238
+
239
+ def _gitlab(results: Sequence[Result]) -> Iterator[str]:
240
+ """Render GitLab's Code Quality report (Code Climate JSON).
241
+
242
+ Yields:
243
+ Its lines; a fingerprint survives lines moving.
244
+
245
+ """
246
+ seen: Counter[tuple[Path, str, str]] = Counter()
247
+ issues: list[_Json] = []
248
+ r: Result
249
+ for r in results:
250
+ key: tuple[Path, str, str] = (r.path, r.offence.code, r.offence.name)
251
+ seen[key] += 1
252
+ fingerprint: str = hashlib.sha256(
253
+ f"{r.path.as_posix()}:{key[1]}:{key[2]}:{seen[key]}".encode(),
254
+ ).hexdigest()
255
+ issues.append(
256
+ {
257
+ "description": f"{r.offence.code} {r.message}",
258
+ "check_name": r.offence.code,
259
+ "fingerprint": fingerprint,
260
+ "severity": "major" if r.offence.is_error(r.level) else "minor",
261
+ "location": {"path": r.path.as_posix(), "lines": {"begin": r.offence.line}},
262
+ },
263
+ )
264
+ yield json.dumps(issues, indent=2)
265
+
266
+
267
+ def _junit(results: Sequence[Result]) -> Iterator[str]:
268
+ """Render a JUnit XML report.
269
+
270
+ Yields:
271
+ Its lines: a test suite per file with offences, a failed test case per offence.
272
+
273
+ """
274
+ by_file: dict[Path, list[Result]] = {}
275
+ r: Result
276
+ for r in results:
277
+ by_file.setdefault(r.path, []).append(r)
278
+ yield "<?xml version='1.0' encoding='utf-8'?>"
279
+ yield f'<testsuites name="constricter" tests="{len(results)}" failures="{len(results)}">'
280
+ path: Path
281
+ found: list[Result]
282
+ for path, found in by_file.items():
283
+ yield f' <testsuite name={_quoted(str(path))} tests="{len(found)}" failures="{len(found)}">'
284
+ for r in found:
285
+ name: str = _quoted(f"{r.offence.code} {r.offence.name}")
286
+ where: str = escape(f"{path}:{r.offence.line}:{r.offence.col + 1}")
287
+ yield f" <testcase name={name} classname={_quoted(str(path))}>"
288
+ yield f" <failure message={_quoted(r.message)} type={_quoted(r.severity)}>{where}</failure>"
289
+ yield " </testcase>"
290
+ yield " </testsuite>"
291
+ yield "</testsuites>"
292
+
293
+
294
+ def _quoted(text: str) -> str:
295
+ """Quote `text` for XML.
296
+
297
+ Returns:
298
+ It as a double-quoted attribute value.
299
+
300
+ """
301
+ return f'"{escape(text, quote=True)}"'
302
+
303
+
304
+ def _rdjson(results: Sequence[Result]) -> Iterator[str]:
305
+ """Render reviewdog's Diagnostic JSON; a certain fix is a suggestion.
306
+
307
+ Yields:
308
+ Its lines (columns count UTF-8 bytes).
309
+
310
+ """
311
+ diagnostics: list[_Json] = []
312
+ r: Result
313
+ for r in results:
314
+ o: Offence = r.offence
315
+ start: dict[str, _Json] = {"line": o.line, "column": o.col + 1}
316
+ diagnostic: dict[str, _Json] = {
317
+ "message": r.message,
318
+ "location": {"path": r.path.as_posix(), "range": {"start": start}},
319
+ "severity": r.severity.upper(),
320
+ "code": {"value": o.code},
321
+ }
322
+ edits: tuple[Replacement, ...]
323
+ if edits := r.suggestions:
324
+ diagnostic["suggestions"] = [{"range": _byte_range(edit), "text": edit.text} for edit in edits]
325
+ diagnostics.append(diagnostic)
326
+ yield json.dumps({"source": {"name": "constricter", "url": _URL}, "diagnostics": diagnostics}, indent=2)
327
+
328
+
329
+ def _byte_range(edit: Replacement) -> dict[str, _Json]:
330
+ """Place a text edit for rdjson, whose columns count UTF-8 bytes.
331
+
332
+ Returns:
333
+ Its range.
334
+
335
+ """
336
+ columns: tuple[int, int] = edit.byte_columns
337
+ return {
338
+ "start": {"line": edit.line, "column": columns[0] + 1},
339
+ "end": {"line": edit.line, "column": columns[1] + 1},
340
+ }
341
+
342
+
343
+ _Renderer: TypeAlias = Callable[[Sequence[Result]], Iterator[str]]
344
+ _RENDERERS: dict[Format, _Renderer] = {
345
+ Format.TEXT: _text,
346
+ Format.FULL: _full,
347
+ Format.JSON: _json,
348
+ Format.GITHUB: _github,
349
+ Format.SARIF: lambda results: iter([json.dumps(_sarif(results), indent=2)]),
350
+ Format.GITLAB: _gitlab,
351
+ Format.JUNIT: _junit,
352
+ Format.RDJSON: _rdjson,
353
+ }
354
+
355
+
356
+ def render(fmt: Format, results: Sequence[Result]) -> Iterator[str]:
357
+ """Render `results` in format `fmt`.
358
+
359
+ Returns:
360
+ The output lines.
361
+
362
+ """
363
+ return _RENDERERS[fmt](results)
364
+
365
+
366
+ def fix_reasons(results: Sequence[Result]) -> Iterator[str]:
367
+ """List the annotation each fixable result would get, or got, and how its value decided it.
368
+
369
+ Yields:
370
+ A line each: where, the name (`fixed`, for a fix `--fix` made), the annotation and its reason;
371
+ a guess left unmade says it needs `--unsafe-fixes`, or `--likely` where that applies it.
372
+
373
+ """
374
+ r: Result
375
+ for r in results:
376
+ if r.offence.fix is not None:
377
+ cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
378
+ where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
379
+ flag: str = "--likely" if r.offence.likely else "--unsafe-fixes"
380
+ guess: str = f" (a guess: {flag})" if r.offence.unsafe and not r.fixed else ""
381
+ kinds: str = ", ".join(sorted(r.offence.edit.kinds if r.offence.edit else ()))
382
+ written: str = f"`{r.offence.fix}`" if r.offence.fix else "drop its annotation"
383
+ decided: str = f"{written}, from {r.offence.reason} [{kinds}]"
384
+ yield f"{where}: {'fixed' if r.fixed else 'fix'} {r.offence.name!r}: {decided}{guess}"
385
+
386
+
387
+ def statistics(results: Sequence[Result]) -> Iterator[str]:
388
+ """Count the results per code and severity.
389
+
390
+ Yields:
391
+ A line each: how many, the code, and the severity.
392
+
393
+ """
394
+ key: tuple[str, str]
395
+ count: int
396
+ for key, count in Counter((r.offence.code, r.severity) for r in results).most_common():
397
+ yield f"{count:>5} {key[0]} {key[1]}"