python-constricter 0.2.2__py3-none-any.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 +42 -0
- constricter/__main__.py +9 -0
- constricter/annotations.py +290 -0
- constricter/baseline.py +111 -0
- constricter/checker.py +651 -0
- constricter/cli.py +791 -0
- constricter/config.py +177 -0
- constricter/explain.py +61 -0
- constricter/fixes.py +22 -0
- constricter/flake8_plugin.py +90 -0
- constricter/jsonc.py +31 -0
- constricter/noqa.py +47 -0
- constricter/notebook.py +111 -0
- constricter/project.py +229 -0
- constricter/py.typed +0 -0
- constricter/pylint_plugin.py +127 -0
- constricter/report.py +252 -0
- python_constricter-0.2.2.dist-info/METADATA +456 -0
- python_constricter-0.2.2.dist-info/RECORD +22 -0
- python_constricter-0.2.2.dist-info/WHEEL +4 -0
- python_constricter-0.2.2.dist-info/entry_points.txt +6 -0
- python_constricter-0.2.2.dist-info/licenses/LICENSE.md +21 -0
constricter/project.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Cross-module `--fix`: the return types of the functions other checked files define.
|
|
3
|
+
|
|
4
|
+
`index` reads every file once for its module name, its top-level functions' declared return types
|
|
5
|
+
(as `annotations.returns` picks them), and what each top-level name refers to. `calls` then gives a
|
|
6
|
+
file the return type of each function it imports (`from m import f`, `import m as a` then `a.f()`),
|
|
7
|
+
but only where every name in that type means the same thing in the file as where it was written:
|
|
8
|
+
otherwise the fix would name something undefined, or something else.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import builtins
|
|
13
|
+
import itertools
|
|
14
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Final, NamedTuple, TypeAlias
|
|
17
|
+
|
|
18
|
+
from constricter.annotations import returns
|
|
19
|
+
|
|
20
|
+
_BUILTINS: Final = frozenset(dir(builtins))
|
|
21
|
+
_PACKAGE: Final = "__init__"
|
|
22
|
+
_SUFFIX: Final = ".py"
|
|
23
|
+
_HOPS: Final = 5 # how many re-exports (`from .util import f` in an `__init__`) to follow
|
|
24
|
+
# What a name refers to: a module and an attribute of it (`None`: the module itself).
|
|
25
|
+
Origin: TypeAlias = tuple[str, str | None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Module(NamedTuple):
|
|
29
|
+
"""What one file offers and uses: its name, functions' return types, and names' origins."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
returns: dict[str, str]
|
|
33
|
+
names: dict[str, Origin]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def module_name(path: Path) -> str:
|
|
37
|
+
"""Name `path`'s module: its package folders (those with an `__init__.py`), then it.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The dotted module name.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
packages: list[Path] = list(
|
|
44
|
+
itertools.takewhile(
|
|
45
|
+
lambda folder: (folder / f"{_PACKAGE}{_SUFFIX}").is_file(),
|
|
46
|
+
path.resolve().parents,
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
return ".".join(
|
|
50
|
+
[*(folder.name for folder in reversed(packages)), *([] if path.stem == _PACKAGE else [path.stem])],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _absolute(name: str, module: str | None, level: int, *, is_package: bool) -> str:
|
|
55
|
+
"""Resolve `from <.level><module> import ...` in module `name`.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The absolute module name.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
if not level:
|
|
62
|
+
return module or ""
|
|
63
|
+
package: list[str] = name.split(".") if is_package else name.split(".")[:-1]
|
|
64
|
+
base: list[str] = package[: len(package) - (level - 1)] if level > 1 else package
|
|
65
|
+
return ".".join([*base, *([module] if module else [])])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _names(tree: ast.Module, name: str, *, is_package: bool) -> dict[str, Origin]:
|
|
69
|
+
"""Map module `name`'s top-level names (the last binding wins).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
What each refers to.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
names: dict[str, Origin] = {}
|
|
76
|
+
stmt: ast.stmt
|
|
77
|
+
alias: ast.alias
|
|
78
|
+
module: str | None
|
|
79
|
+
level: int
|
|
80
|
+
for stmt in tree.body:
|
|
81
|
+
match stmt:
|
|
82
|
+
case ast.Import():
|
|
83
|
+
for alias in stmt.names:
|
|
84
|
+
if alias.asname:
|
|
85
|
+
names[alias.asname] = (alias.name, None)
|
|
86
|
+
else: # `import a.b` binds `a`
|
|
87
|
+
names[alias.name.split(".")[0]] = (alias.name.split(".")[0], None)
|
|
88
|
+
case ast.ImportFrom(module=module, level=level):
|
|
89
|
+
for alias in stmt.names:
|
|
90
|
+
names[alias.asname or alias.name] = (
|
|
91
|
+
_absolute(name, module, level, is_package=is_package),
|
|
92
|
+
alias.name,
|
|
93
|
+
)
|
|
94
|
+
case _:
|
|
95
|
+
names.update((bound, (name, bound)) for bound in _bound(stmt))
|
|
96
|
+
return names
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bound(stmt: ast.stmt) -> Iterator[str]:
|
|
100
|
+
"""Walk a top-level statement other than an import.
|
|
101
|
+
|
|
102
|
+
Yields:
|
|
103
|
+
Each name it binds.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
node: ast.AST
|
|
107
|
+
match stmt:
|
|
108
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
|
|
109
|
+
yield stmt.name
|
|
110
|
+
case ast.Assign() | ast.AnnAssign() | ast.AugAssign():
|
|
111
|
+
targets: list[ast.expr] = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
|
|
112
|
+
for node in (n for target in targets for n in ast.walk(target)):
|
|
113
|
+
if isinstance(node, ast.Name):
|
|
114
|
+
yield node.id
|
|
115
|
+
case _:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def index(paths: Sequence[Path]) -> dict[str, Module]:
|
|
120
|
+
"""Read each `.py` file in `paths` (one that can't be read or parsed is left out).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Each module's name, mapped to what it offers and uses.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
modules: dict[str, Module] = {}
|
|
127
|
+
path: Path
|
|
128
|
+
for path in paths:
|
|
129
|
+
if path.suffix != _SUFFIX or not path.is_file():
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
tree: ast.Module = ast.parse(path.read_bytes(), str(path))
|
|
133
|
+
except (OSError, SyntaxError, ValueError):
|
|
134
|
+
continue
|
|
135
|
+
name: str = module_name(path)
|
|
136
|
+
modules[name] = Module(name, returns(tree), _names(tree, name, is_package=path.stem == _PACKAGE))
|
|
137
|
+
return modules
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _origin(module: Module, name: str) -> Origin | None:
|
|
141
|
+
if name in module.names:
|
|
142
|
+
return module.names[name]
|
|
143
|
+
return ("builtins", name) if name in _BUILTINS else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _roots(annotation: str) -> set[str]:
|
|
147
|
+
"""Find the names an annotation (maybe a string one) starts from.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The names: `m.Row` gives `m`.
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
tree: ast.expr = ast.parse(annotation, mode="eval").body
|
|
154
|
+
if isinstance(tree, ast.Constant) and isinstance(tree.value, str):
|
|
155
|
+
tree = ast.parse(tree.value, mode="eval").body
|
|
156
|
+
return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _function(modules: Mapping[str, Module], origin: Origin, hops: int = _HOPS) -> tuple[Module, str] | None:
|
|
160
|
+
"""Follow `origin` (through re-exports) to the module that defines it.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
That module and the function's name, or `None`.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
module: Module | None = modules.get(origin[0])
|
|
167
|
+
attribute: str | None = origin[1]
|
|
168
|
+
if module is None or attribute is None or not hops:
|
|
169
|
+
return None
|
|
170
|
+
if attribute in module.returns:
|
|
171
|
+
return module, attribute
|
|
172
|
+
onward: Origin | None = module.names.get(attribute)
|
|
173
|
+
return _function(modules, onward, hops - 1) if onward and onward[0] != module.name else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def calls(modules: Mapping[str, Module], path: Path) -> dict[str, str]:
|
|
177
|
+
"""Return, for the file at `path`, the return type of each function it imports whose type it can name.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Each call's name as written (`helper`, `u.helper`, `pkg.util.helper`), mapped to its type; nothing
|
|
181
|
+
for a file `modules` doesn't have (a notebook, standard input).
|
|
182
|
+
|
|
183
|
+
"""
|
|
184
|
+
name: str = module_name(path)
|
|
185
|
+
target: Module | None
|
|
186
|
+
if path.suffix != _SUFFIX or (target := modules.get(name)) is None:
|
|
187
|
+
return {}
|
|
188
|
+
found: dict[str, str] = {}
|
|
189
|
+
local: str
|
|
190
|
+
origin: Origin
|
|
191
|
+
for local, origin in target.names.items():
|
|
192
|
+
if origin[1] is not None and origin[0] != name:
|
|
193
|
+
_add(found, modules, target, local, origin)
|
|
194
|
+
elif origin[1] is None: # a module: `u.f()`, or `pkg.util.f()` after `import pkg.util`
|
|
195
|
+
other: Module
|
|
196
|
+
for other in modules.values():
|
|
197
|
+
if other.name == origin[0] or other.name.startswith(f"{origin[0]}."):
|
|
198
|
+
prefix: str = local + other.name.removeprefix(origin[0])
|
|
199
|
+
function: str
|
|
200
|
+
for function in other.returns:
|
|
201
|
+
_add(found, modules, target, f"{prefix}.{function}", (other.name, function))
|
|
202
|
+
return found
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _add(
|
|
206
|
+
found: dict[str, str],
|
|
207
|
+
modules: Mapping[str, Module],
|
|
208
|
+
target: Module,
|
|
209
|
+
key: str,
|
|
210
|
+
origin: Origin,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Record `key`'s return type in `found` if every name in it means the same in `target`."""
|
|
213
|
+
defined: tuple[Module, str] | None
|
|
214
|
+
if (defined := _function(modules, origin)) is None:
|
|
215
|
+
return
|
|
216
|
+
annotation: str = defined[0].returns[defined[1]]
|
|
217
|
+
if all(_same(target, defined[0], root) for root in _roots(annotation)):
|
|
218
|
+
found[key] = annotation
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _same(target: Module, defined: Module, name: str) -> bool:
|
|
222
|
+
"""Compare what `name` refers to in both modules.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Whether it's something, and the same thing.
|
|
226
|
+
|
|
227
|
+
"""
|
|
228
|
+
origin: Origin | None = _origin(target, name)
|
|
229
|
+
return origin is not None and origin == _origin(defined, name)
|
constricter/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The rules as a pylint plugin (C9101-C9106); reports the codes the level makes errors."""
|
|
3
|
+
|
|
4
|
+
from typing import IO, TYPE_CHECKING, NamedTuple, cast, final
|
|
5
|
+
|
|
6
|
+
from astroid import nodes
|
|
7
|
+
from pylint.checkers import BaseRawFileChecker
|
|
8
|
+
from pylint.lint import PyLinter
|
|
9
|
+
from pylint.typing import Options
|
|
10
|
+
|
|
11
|
+
from constricter.checker import (
|
|
12
|
+
COMMENT_TYPED_TARGET,
|
|
13
|
+
LEVELS,
|
|
14
|
+
MESSAGES,
|
|
15
|
+
NESTED_TYPE,
|
|
16
|
+
NESTING,
|
|
17
|
+
UNANNOTATED,
|
|
18
|
+
UNANNOTATED_MEMBER,
|
|
19
|
+
UNTYPED_TARGET,
|
|
20
|
+
VAGUE_TYPE,
|
|
21
|
+
Checks,
|
|
22
|
+
Level,
|
|
23
|
+
Offence,
|
|
24
|
+
check_source,
|
|
25
|
+
)
|
|
26
|
+
from constricter.noqa import lines, unsuppressed
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from typing_extensions import override # `typing.override` is 3.12+
|
|
30
|
+
else:
|
|
31
|
+
|
|
32
|
+
def override(func: object) -> object:
|
|
33
|
+
"""Mark an override (for type checkers only).
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
`func`, unchanged.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
return func
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Message(NamedTuple):
|
|
43
|
+
"""A code's pylint message id and symbol."""
|
|
44
|
+
|
|
45
|
+
msg_id: str
|
|
46
|
+
symbol: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
SYMBOLS: dict[str, Message] = {
|
|
50
|
+
UNANNOTATED: Message("C9101", "unannotated-local-variable"),
|
|
51
|
+
UNTYPED_TARGET: Message("C9102", "untyped-for-or-match-variable"),
|
|
52
|
+
COMMENT_TYPED_TARGET: Message("C9103", "comment-typed-for-variable"),
|
|
53
|
+
UNANNOTATED_MEMBER: Message("C9104", "unannotated-module-or-class-variable"),
|
|
54
|
+
VAGUE_TYPE: Message("C9105", "vague-annotation"),
|
|
55
|
+
NESTED_TYPE: Message("C9106", "deeply-nested-annotation"),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@final
|
|
60
|
+
class ConstricterChecker(BaseRawFileChecker):
|
|
61
|
+
"""pylint's checker, run once per module on its raw source."""
|
|
62
|
+
|
|
63
|
+
name: str = "constricter"
|
|
64
|
+
options: Options = (
|
|
65
|
+
(
|
|
66
|
+
"constricter-level",
|
|
67
|
+
{
|
|
68
|
+
"default": "strict",
|
|
69
|
+
"type": "choice",
|
|
70
|
+
"choices": list(LEVELS),
|
|
71
|
+
"metavar": "<level>",
|
|
72
|
+
"help": "Which codes are reported.",
|
|
73
|
+
},
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"constricter-type-comments",
|
|
77
|
+
{"default": False, "type": "yn", "metavar": "<y or n>", "help": "Count `# type:` comments."},
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"constricter-all-scopes",
|
|
81
|
+
{"default": False, "type": "yn", "metavar": "<y or n>", "help": "Check module and class bodies."},
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
"constricter-nesting",
|
|
85
|
+
{
|
|
86
|
+
"default": NESTING,
|
|
87
|
+
"type": "int",
|
|
88
|
+
"metavar": "<n>",
|
|
89
|
+
"help": "Report an annotation nested this deep.",
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def __init__(self, linter: PyLinter) -> None:
|
|
95
|
+
"""Register the messages with `linter`."""
|
|
96
|
+
super().__init__(linter)
|
|
97
|
+
self.msgs = {
|
|
98
|
+
message.msg_id: (MESSAGES[code].format(name="%r"), message.symbol, f"See constricter's {code}.")
|
|
99
|
+
for code, message in SYMBOLS.items()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
def process_module(self, node: nodes.Module) -> None:
|
|
104
|
+
"""Report the module's error-level offences."""
|
|
105
|
+
opened: IO[bytes] | None
|
|
106
|
+
if (opened := node.stream()) is None: # no file behind the module
|
|
107
|
+
return
|
|
108
|
+
stream: IO[bytes]
|
|
109
|
+
with opened as stream:
|
|
110
|
+
source: bytes = stream.read()
|
|
111
|
+
level: Level = LEVELS[cast("str", self.linter.config.constricter_level)]
|
|
112
|
+
checks: Checks = Checks(
|
|
113
|
+
type_comments=cast("bool", self.linter.config.constricter_type_comments),
|
|
114
|
+
all_scopes=cast("bool", self.linter.config.constricter_all_scopes),
|
|
115
|
+
nesting=cast("int", self.linter.config.constricter_nesting),
|
|
116
|
+
)
|
|
117
|
+
text: str = source.decode("utf-8")
|
|
118
|
+
offences: list[Offence] = check_source(text, node.file or "<unknown>", checks)
|
|
119
|
+
o: Offence
|
|
120
|
+
for o in unsuppressed(offences, lines(text)): # suppression comments, as the CLI reads them
|
|
121
|
+
if o.is_error(level):
|
|
122
|
+
self.add_message(SYMBOLS[o.code].symbol, line=o.line, col_offset=o.col, args=(o.name,))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def register(linter: PyLinter) -> None:
|
|
126
|
+
"""Register the checker (pylint's plugin hook)."""
|
|
127
|
+
linter.register_checker(ConstricterChecker(linter))
|
constricter/report.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
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.checker import MESSAGES, Level, Offence
|
|
15
|
+
|
|
16
|
+
_URL: Final = "https://github.com/ivylikethevine/python-constricter"
|
|
17
|
+
_Json: TypeAlias = "str | int | bool | list[_Json] | dict[str, _Json]"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Format(StrEnum):
|
|
21
|
+
"""The `--format` choices."""
|
|
22
|
+
|
|
23
|
+
TEXT = "text"
|
|
24
|
+
JSON = "json"
|
|
25
|
+
GITHUB = "github"
|
|
26
|
+
SARIF = "sarif"
|
|
27
|
+
GITLAB = "gitlab"
|
|
28
|
+
JUNIT = "junit"
|
|
29
|
+
RDJSON = "rdjson"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Result(NamedTuple):
|
|
33
|
+
"""One reported offence, in its file, at the level that applies to that file."""
|
|
34
|
+
|
|
35
|
+
path: Path
|
|
36
|
+
offence: Offence
|
|
37
|
+
level: Level
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def severity(self) -> str:
|
|
41
|
+
"""`error` or `warning`, at the result's level."""
|
|
42
|
+
return "error" if self.offence.is_error(self.level) else "warning"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def message(self) -> str:
|
|
46
|
+
"""The offence's message; in a notebook, with its cell and line first."""
|
|
47
|
+
o: Offence = self.offence
|
|
48
|
+
return o.message if o.cell is None else f"cell {o.cell}, line {o.line}: {o.message}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _github_escape(text: str, *, prop: bool = False) -> str:
|
|
52
|
+
text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
53
|
+
return text.replace(":", "%3A").replace(",", "%2C") if prop else text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _where(result: Result) -> dict[str, _Json]:
|
|
57
|
+
"""Locate `result` for SARIF.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Its physical location; a notebook's is the file, as its cells have no file lines.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
where: dict[str, _Json] = {"artifactLocation": {"uri": result.path.as_posix()}}
|
|
64
|
+
if result.offence.cell is None:
|
|
65
|
+
where["region"] = {"startLine": result.offence.line, "startColumn": result.offence.col + 1}
|
|
66
|
+
return where
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sarif(results: Sequence[Result]) -> dict[str, _Json]:
|
|
70
|
+
rules: list[_Json] = [
|
|
71
|
+
{"id": code, "shortDescription": {"text": message.format(name="`name`")}}
|
|
72
|
+
for code, message in MESSAGES.items()
|
|
73
|
+
]
|
|
74
|
+
findings: list[_Json] = [
|
|
75
|
+
{
|
|
76
|
+
"ruleId": r.offence.code,
|
|
77
|
+
"level": r.severity,
|
|
78
|
+
"message": {"text": r.message},
|
|
79
|
+
"locations": [{"physicalLocation": _where(r)}],
|
|
80
|
+
}
|
|
81
|
+
for r in results
|
|
82
|
+
]
|
|
83
|
+
driver: dict[str, _Json] = {
|
|
84
|
+
"name": "constricter",
|
|
85
|
+
"version": __version__,
|
|
86
|
+
"informationUri": _URL,
|
|
87
|
+
"rules": rules,
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
"version": "2.1.0",
|
|
91
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
92
|
+
"runs": [{"tool": {"driver": driver}, "results": findings}],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _text(results: Sequence[Result]) -> Iterator[str]:
|
|
97
|
+
r: Result
|
|
98
|
+
for r in results:
|
|
99
|
+
cell: str = "" if r.offence.cell is None else f"cell {r.offence.cell}:"
|
|
100
|
+
where: str = f"{r.path}:{cell}{r.offence.line}:{r.offence.col + 1}"
|
|
101
|
+
yield f"{where}: {r.severity}: {r.offence.code} {r.offence.message}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _json(results: Sequence[Result]) -> Iterator[str]:
|
|
105
|
+
yield json.dumps(
|
|
106
|
+
[
|
|
107
|
+
{
|
|
108
|
+
"path": str(r.path),
|
|
109
|
+
"line": r.offence.line,
|
|
110
|
+
"column": r.offence.col + 1,
|
|
111
|
+
"code": r.offence.code,
|
|
112
|
+
"severity": r.severity,
|
|
113
|
+
"message": r.offence.message,
|
|
114
|
+
"cell": r.offence.cell,
|
|
115
|
+
}
|
|
116
|
+
for r in results
|
|
117
|
+
],
|
|
118
|
+
indent=2,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _github(results: Sequence[Result]) -> Iterator[str]:
|
|
123
|
+
r: Result
|
|
124
|
+
for r in results:
|
|
125
|
+
location: str = f"file={_github_escape(str(r.path), prop=True)}"
|
|
126
|
+
if r.offence.cell is None: # a notebook's cells have no file lines to point at
|
|
127
|
+
location += f",line={r.offence.line},col={r.offence.col + 1}"
|
|
128
|
+
yield f"::{r.severity} {location},title={r.offence.code}::{_github_escape(r.message)}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gitlab(results: Sequence[Result]) -> Iterator[str]:
|
|
132
|
+
"""Render GitLab's Code Quality report (Code Climate JSON).
|
|
133
|
+
|
|
134
|
+
Yields:
|
|
135
|
+
Its lines; a fingerprint survives lines moving.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
seen: Counter[tuple[Path, str, str]] = Counter()
|
|
139
|
+
issues: list[_Json] = []
|
|
140
|
+
r: Result
|
|
141
|
+
for r in results:
|
|
142
|
+
key: tuple[Path, str, str] = (r.path, r.offence.code, r.offence.name)
|
|
143
|
+
seen[key] += 1
|
|
144
|
+
fingerprint: str = hashlib.sha256(
|
|
145
|
+
f"{r.path.as_posix()}:{key[1]}:{key[2]}:{seen[key]}".encode(),
|
|
146
|
+
).hexdigest()
|
|
147
|
+
issues.append(
|
|
148
|
+
{
|
|
149
|
+
"description": f"{r.offence.code} {r.message}",
|
|
150
|
+
"check_name": r.offence.code,
|
|
151
|
+
"fingerprint": fingerprint,
|
|
152
|
+
"severity": "major" if r.offence.is_error(r.level) else "minor",
|
|
153
|
+
"location": {"path": r.path.as_posix(), "lines": {"begin": r.offence.line}},
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
yield json.dumps(issues, indent=2)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _junit(results: Sequence[Result]) -> Iterator[str]:
|
|
160
|
+
"""Render a JUnit XML report.
|
|
161
|
+
|
|
162
|
+
Yields:
|
|
163
|
+
Its lines: a test suite per file with offences, a failed test case per offence.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
by_file: dict[Path, list[Result]] = {}
|
|
167
|
+
r: Result
|
|
168
|
+
for r in results:
|
|
169
|
+
by_file.setdefault(r.path, []).append(r)
|
|
170
|
+
yield "<?xml version='1.0' encoding='utf-8'?>"
|
|
171
|
+
yield f'<testsuites name="constricter" tests="{len(results)}" failures="{len(results)}">'
|
|
172
|
+
path: Path
|
|
173
|
+
found: list[Result]
|
|
174
|
+
for path, found in by_file.items():
|
|
175
|
+
yield f' <testsuite name={_quoted(str(path))} tests="{len(found)}" failures="{len(found)}">'
|
|
176
|
+
for r in found:
|
|
177
|
+
name: str = _quoted(f"{r.offence.code} {r.offence.name}")
|
|
178
|
+
where: str = escape(f"{path}:{r.offence.line}:{r.offence.col + 1}")
|
|
179
|
+
yield f" <testcase name={name} classname={_quoted(str(path))}>"
|
|
180
|
+
yield f" <failure message={_quoted(r.message)} type={_quoted(r.severity)}>{where}</failure>"
|
|
181
|
+
yield " </testcase>"
|
|
182
|
+
yield " </testsuite>"
|
|
183
|
+
yield "</testsuites>"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _quoted(text: str) -> str:
|
|
187
|
+
"""Quote `text` for XML.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
It as a double-quoted attribute value.
|
|
191
|
+
|
|
192
|
+
"""
|
|
193
|
+
return f'"{escape(text, quote=True)}"'
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _rdjson(results: Sequence[Result]) -> Iterator[str]:
|
|
197
|
+
"""Render reviewdog's Diagnostic JSON; a certain fix is a suggestion.
|
|
198
|
+
|
|
199
|
+
Yields:
|
|
200
|
+
Its lines (columns count UTF-8 bytes).
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
diagnostics: list[_Json] = []
|
|
204
|
+
r: Result
|
|
205
|
+
for r in results:
|
|
206
|
+
o: Offence = r.offence
|
|
207
|
+
start: dict[str, _Json] = {"line": o.line, "column": o.col + 1}
|
|
208
|
+
diagnostic: dict[str, _Json] = {
|
|
209
|
+
"message": r.message,
|
|
210
|
+
"location": {"path": r.path.as_posix(), "range": {"start": start}},
|
|
211
|
+
"severity": r.severity.upper(),
|
|
212
|
+
"code": {"value": o.code},
|
|
213
|
+
}
|
|
214
|
+
if o.fix and not o.unsafe and o.cell is None:
|
|
215
|
+
end: dict[str, _Json] = {"line": o.line, "column": o.col + len(o.name.encode()) + 1}
|
|
216
|
+
diagnostic["suggestions"] = [{"range": {"start": end, "end": end}, "text": f": {o.fix}"}]
|
|
217
|
+
diagnostics.append(diagnostic)
|
|
218
|
+
yield json.dumps({"source": {"name": "constricter", "url": _URL}, "diagnostics": diagnostics}, indent=2)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_RENDERERS: dict[Format, Callable[[Sequence[Result]], Iterator[str]]] = {
|
|
222
|
+
Format.TEXT: _text,
|
|
223
|
+
Format.JSON: _json,
|
|
224
|
+
Format.GITHUB: _github,
|
|
225
|
+
Format.SARIF: lambda results: iter([json.dumps(_sarif(results), indent=2)]),
|
|
226
|
+
Format.GITLAB: _gitlab,
|
|
227
|
+
Format.JUNIT: _junit,
|
|
228
|
+
Format.RDJSON: _rdjson,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render(fmt: Format, results: Sequence[Result]) -> Iterator[str]:
|
|
233
|
+
"""Render `results` in format `fmt`.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The output lines.
|
|
237
|
+
|
|
238
|
+
"""
|
|
239
|
+
return _RENDERERS[fmt](results)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def statistics(results: Sequence[Result]) -> Iterator[str]:
|
|
243
|
+
"""Count the results per code and severity.
|
|
244
|
+
|
|
245
|
+
Yields:
|
|
246
|
+
A line each: how many, the code, and the severity.
|
|
247
|
+
|
|
248
|
+
"""
|
|
249
|
+
key: tuple[str, str]
|
|
250
|
+
count: int
|
|
251
|
+
for key, count in Counter((r.offence.code, r.severity) for r in results).most_common():
|
|
252
|
+
yield f"{count:>5} {key[0]} {key[1]}"
|