python-constricter 0.4.1__cp313-cp313-win32.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- constricter/__init__.py +55 -0
- constricter/__main__.py +26 -0
- constricter/_version.py +4 -0
- constricter/cli/__init__.py +2 -0
- constricter/cli/baseline.py +107 -0
- constricter/cli/collecting.py +54 -0
- constricter/cli/command.py +547 -0
- constricter/cli/config.py +322 -0
- constricter/cli/explain.py +104 -0
- constricter/cli/lsp/__init__.py +2 -0
- constricter/cli/lsp/connection.py +475 -0
- constricter/cli/lsp/edits.py +188 -0
- constricter/cli/lsp/guard.py +133 -0
- constricter/cli/lsp/hints.py +391 -0
- constricter/cli/lsp/protocol.py +192 -0
- constricter/cli/lsp/remembered.py +81 -0
- constricter/cli/lsp/stored.py +200 -0
- constricter/cli/options.py +799 -0
- constricter/cli/paths.py +151 -0
- constricter/cli/reading.py +36 -0
- constricter/cli/report.py +397 -0
- constricter/cli/rounds.py +328 -0
- constricter/cli/runs.py +90 -0
- constricter/cli/schedule.py +463 -0
- constricter/cli/traced.py +401 -0
- constricter/cli/workers.py +286 -0
- constricter/empty.py +66 -0
- constricter/fix/__init__.cp313-win32.pyd +0 -0
- constricter/fix/__init__.py +2 -0
- constricter/fix/core/__init__.cp313-win32.pyd +0 -0
- constricter/fix/core/__init__.py +2 -0
- constricter/fix/core/asked.cp313-win32.pyd +0 -0
- constricter/fix/core/asked.py +56 -0
- constricter/fix/core/fixes.cp313-win32.pyd +0 -0
- constricter/fix/core/fixes.py +317 -0
- constricter/fix/core/imports.cp313-win32.pyd +0 -0
- constricter/fix/core/imports.py +580 -0
- constricter/fix/core/inherited.cp313-win32.pyd +0 -0
- constricter/fix/core/inherited.py +311 -0
- constricter/fix/core/known.cp313-win32.pyd +0 -0
- constricter/fix/core/known.py +730 -0
- constricter/fix/core/signatures.cp313-win32.pyd +0 -0
- constricter/fix/core/signatures.py +132 -0
- constricter/fix/index/__init__.cp313-win32.pyd +0 -0
- constricter/fix/index/__init__.py +2 -0
- constricter/fix/index/awaits.cp313-win32.pyd +0 -0
- constricter/fix/index/awaits.py +90 -0
- constricter/fix/index/beyond.cp313-win32.pyd +0 -0
- constricter/fix/index/beyond.py +149 -0
- constricter/fix/index/callers.cp313-win32.pyd +0 -0
- constricter/fix/index/callers.py +155 -0
- constricter/fix/index/crossed.cp313-win32.pyd +0 -0
- constricter/fix/index/crossed.py +327 -0
- constricter/fix/index/decorated.cp313-win32.pyd +0 -0
- constricter/fix/index/decorated.py +140 -0
- constricter/fix/index/fixtures.cp313-win32.pyd +0 -0
- constricter/fix/index/fixtures.py +176 -0
- constricter/fix/index/linked.cp313-win32.pyd +0 -0
- constricter/fix/index/linked.py +142 -0
- constricter/fix/index/loose.cp313-win32.pyd +0 -0
- constricter/fix/index/loose.py +103 -0
- constricter/fix/index/managed.cp313-win32.pyd +0 -0
- constricter/fix/index/managed.py +63 -0
- constricter/fix/index/modules.cp313-win32.pyd +0 -0
- constricter/fix/index/modules.py +741 -0
- constricter/fix/index/offers.cp313-win32.pyd +0 -0
- constricter/fix/index/offers.py +252 -0
- constricter/fix/index/order.cp313-win32.pyd +0 -0
- constricter/fix/index/order.py +155 -0
- constricter/fix/index/origins.cp313-win32.pyd +0 -0
- constricter/fix/index/origins.py +238 -0
- constricter/fix/index/own_methods.cp313-win32.pyd +0 -0
- constricter/fix/index/own_methods.py +91 -0
- constricter/fix/index/own_overloads.cp313-win32.pyd +0 -0
- constricter/fix/index/own_overloads.py +70 -0
- constricter/fix/index/own_types.cp313-win32.pyd +0 -0
- constricter/fix/index/own_types.py +330 -0
- constricter/fix/index/params.cp313-win32.pyd +0 -0
- constricter/fix/index/params.py +81 -0
- constricter/fix/index/plain.cp313-win32.pyd +0 -0
- constricter/fix/index/plain.py +221 -0
- constricter/fix/index/project.cp313-win32.pyd +0 -0
- constricter/fix/index/project.py +797 -0
- constricter/fix/index/sides.cp313-win32.pyd +0 -0
- constricter/fix/index/sides.py +160 -0
- constricter/fix/index/strings.cp313-win32.pyd +0 -0
- constricter/fix/index/strings.py +46 -0
- constricter/fix/index/stubs/__init__.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/__init__.py +2 -0
- constricter/fix/index/stubs/atoms.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/atoms.py +425 -0
- constricter/fix/index/stubs/chained.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/chained.py +69 -0
- constricter/fix/index/stubs/classnames.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/classnames.py +124 -0
- constricter/fix/index/stubs/declared.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/declared.py +508 -0
- constricter/fix/index/stubs/installed.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/installed.py +425 -0
- constricter/fix/index/stubs/names.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/names.py +200 -0
- constricter/fix/index/stubs/read.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/read.py +70 -0
- constricter/fix/index/stubs/signatures.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/signatures.py +656 -0
- constricter/fix/index/stubs/stubbed.cp313-win32.pyd +0 -0
- constricter/fix/index/stubs/stubbed.py +208 -0
- constricter/fix/index/taken.cp313-win32.pyd +0 -0
- constricter/fix/index/taken.py +45 -0
- constricter/fix/index/tuples.cp313-win32.pyd +0 -0
- constricter/fix/index/tuples.py +112 -0
- constricter/fix/libraries/__init__.cp313-win32.pyd +0 -0
- constricter/fix/libraries/__init__.py +2 -0
- constricter/fix/libraries/library.cp313-win32.pyd +0 -0
- constricter/fix/libraries/library.py +420 -0
- constricter/fix/libraries/opened.cp313-win32.pyd +0 -0
- constricter/fix/libraries/opened.py +147 -0
- constricter/fix/libraries/overloads.cp313-win32.pyd +0 -0
- constricter/fix/libraries/overloads.py +960 -0
- constricter/fix/libraries/receivers.cp313-win32.pyd +0 -0
- constricter/fix/libraries/receivers.py +274 -0
- constricter/fix/libraries/stdlib.cp313-win32.pyd +0 -0
- constricter/fix/libraries/stdlib.py +752 -0
- constricter/fix/libraries/walks.cp313-win32.pyd +0 -0
- constricter/fix/libraries/walks.py +49 -0
- constricter/fix/tables/aliases.json +147 -0
- constricter/fix/tables/attributes.json +700 -0
- constricter/fix/tables/awaited.json +69 -0
- constricter/fix/tables/awaited_overloads.json +14 -0
- constricter/fix/tables/bases.json +888 -0
- constricter/fix/tables/classes.json +1941 -0
- constricter/fix/tables/elements.json +156 -0
- constricter/fix/tables/functions.json +58 -0
- constricter/fix/tables/generic_attributes.json +254 -0
- constricter/fix/tables/method_overloads.json +461 -0
- constricter/fix/tables/method_signatures.json +954 -0
- constricter/fix/tables/methods.json +954 -0
- constricter/fix/tables/overloads.json +540 -0
- constricter/fix/tables/platform.json +37 -0
- constricter/fix/tables/returns.json +1362 -0
- constricter/fix/tables/scalar_members.json +16 -0
- constricter/fix/tables/scalars.json +2707 -0
- constricter/fix/tables/source.json +28 -0
- constricter/fix/tables/subscriptable.json +77 -0
- constricter/fix/tables/type_parameters.json +178 -0
- constricter/fix/tables/variables.json +2962 -0
- constricter/fix/values/__init__.cp313-win32.pyd +0 -0
- constricter/fix/values/__init__.py +2 -0
- constricter/fix/values/aliased.cp313-win32.pyd +0 -0
- constricter/fix/values/aliased.py +195 -0
- constricter/fix/values/bodies.cp313-win32.pyd +0 -0
- constricter/fix/values/bodies.py +76 -0
- constricter/fix/values/callables.cp313-win32.pyd +0 -0
- constricter/fix/values/callables.py +323 -0
- constricter/fix/values/called.cp313-win32.pyd +0 -0
- constricter/fix/values/called.py +194 -0
- constricter/fix/values/classvars.cp313-win32.pyd +0 -0
- constricter/fix/values/classvars.py +629 -0
- constricter/fix/values/decided.cp313-win32.pyd +0 -0
- constricter/fix/values/decided.py +429 -0
- constricter/fix/values/displays.cp313-win32.pyd +0 -0
- constricter/fix/values/displays.py +101 -0
- constricter/fix/values/doubts.cp313-win32.pyd +0 -0
- constricter/fix/values/doubts.py +677 -0
- constricter/fix/values/entered.cp313-win32.pyd +0 -0
- constricter/fix/values/entered.py +332 -0
- constricter/fix/values/fills.cp313-win32.pyd +0 -0
- constricter/fix/values/fills.py +446 -0
- constricter/fix/values/guesses.cp313-win32.pyd +0 -0
- constricter/fix/values/guesses.py +376 -0
- constricter/fix/values/hinted.cp313-win32.pyd +0 -0
- constricter/fix/values/hinted.py +811 -0
- constricter/fix/values/inference.cp313-win32.pyd +0 -0
- constricter/fix/values/inference.py +698 -0
- constricter/fix/values/loops.cp313-win32.pyd +0 -0
- constricter/fix/values/loops.py +335 -0
- constricter/fix/values/members.cp313-win32.pyd +0 -0
- constricter/fix/values/members.py +578 -0
- constricter/fix/values/narrowed.cp313-win32.pyd +0 -0
- constricter/fix/values/narrowed.py +220 -0
- constricter/fix/values/operated.cp313-win32.pyd +0 -0
- constricter/fix/values/operated.py +202 -0
- constricter/fix/values/ordered.cp313-win32.pyd +0 -0
- constricter/fix/values/ordered.py +74 -0
- constricter/fix/values/returned.cp313-win32.pyd +0 -0
- constricter/fix/values/returned.py +479 -0
- constricter/fix/values/returns.cp313-win32.pyd +0 -0
- constricter/fix/values/returns.py +237 -0
- constricter/fix/values/shapes.cp313-win32.pyd +0 -0
- constricter/fix/values/shapes.py +654 -0
- constricter/fix/values/stored.cp313-win32.pyd +0 -0
- constricter/fix/values/stored.py +564 -0
- constricter/fix/values/targets.cp313-win32.pyd +0 -0
- constricter/fix/values/targets.py +419 -0
- constricter/fix/values/unchecked.cp313-win32.pyd +0 -0
- constricter/fix/values/unchecked.py +278 -0
- constricter/jsonc.py +50 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +115 -0
- constricter/offences.py +436 -0
- constricter/overrides.py +24 -0
- constricter/plugins/__init__.py +2 -0
- constricter/plugins/flake8.py +133 -0
- constricter/plugins/pylint.py +186 -0
- constricter/py.typed +0 -0
- constricter/recording.py +660 -0
- constricter/rules/__init__.cp313-win32.pyd +0 -0
- constricter/rules/__init__.py +2 -0
- constricter/rules/annotations.cp313-win32.pyd +0 -0
- constricter/rules/annotations.py +296 -0
- constricter/rules/binding.cp313-win32.pyd +0 -0
- constricter/rules/binding.py +652 -0
- constricter/rules/calls.cp313-win32.pyd +0 -0
- constricter/rules/calls.py +460 -0
- constricter/rules/checker.cp313-win32.pyd +0 -0
- constricter/rules/checker.py +536 -0
- constricter/rules/declarations.cp313-win32.pyd +0 -0
- constricter/rules/declarations.py +730 -0
- constricter/rules/decorators.cp313-win32.pyd +0 -0
- constricter/rules/decorators.py +351 -0
- constricter/rules/flow.cp313-win32.pyd +0 -0
- constricter/rules/flow.py +502 -0
- constricter/rules/keys.cp313-win32.pyd +0 -0
- constricter/rules/keys.py +79 -0
- constricter/rules/late.cp313-win32.pyd +0 -0
- constricter/rules/late.py +969 -0
- constricter/rules/narrowing.cp313-win32.pyd +0 -0
- constricter/rules/narrowing.py +98 -0
- constricter/rules/parsed.cp313-win32.pyd +0 -0
- constricter/rules/parsed.py +136 -0
- constricter/rules/quoted.cp313-win32.pyd +0 -0
- constricter/rules/quoted.py +141 -0
- constricter/rules/rebinding.cp313-win32.pyd +0 -0
- constricter/rules/rebinding.py +169 -0
- constricter/rules/recorded.cp313-win32.pyd +0 -0
- constricter/rules/recorded.py +168 -0
- constricter/rules/redundant.cp313-win32.pyd +0 -0
- constricter/rules/redundant.py +160 -0
- constricter/rules/scope.cp313-win32.pyd +0 -0
- constricter/rules/scope.py +802 -0
- constricter/rules/scoped.cp313-win32.pyd +0 -0
- constricter/rules/scoped.py +232 -0
- constricter/rules/scoping.cp313-win32.pyd +0 -0
- constricter/rules/scoping.py +346 -0
- constricter/rules/settings.cp313-win32.pyd +0 -0
- constricter/rules/settings.py +189 -0
- constricter/rules/syntax.cp313-win32.pyd +0 -0
- constricter/rules/syntax.py +472 -0
- constricter/rules/tables.cp313-win32.pyd +0 -0
- constricter/rules/tables.py +169 -0
- constricter/rules/walked.cp313-win32.pyd +0 -0
- constricter/rules/walked.py +127 -0
- constricter/rules/widened.cp313-win32.pyd +0 -0
- constricter/rules/widened.py +322 -0
- constricter/trace.py +374 -0
- e45ce06b2fdd5c43f782__mypyc.cp313-win32.pyd +0 -0
- python_constricter-0.4.1.dist-info/METADATA +400 -0
- python_constricter-0.4.1.dist-info/RECORD +261 -0
- python_constricter-0.4.1.dist-info/WHEEL +4 -0
- python_constricter-0.4.1.dist-info/entry_points.txt +5 -0
- python_constricter-0.4.1.dist-info/licenses/LICENSE.md +21 -0
constricter/cli/paths.py
ADDED
|
@@ -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]}"
|