python-checks 0.1.0__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.
- py_checks/__init__.py +2 -0
- py_checks/checks/__init__.py +20 -0
- py_checks/checks/_kind.py +184 -0
- py_checks/checks/_location.py +172 -0
- py_checks/checks/_names.py +86 -0
- py_checks/checks/api/__init__.py +13 -0
- py_checks/checks/api/_endpoint_declarations.py +204 -0
- py_checks/checks/api/_marker.py +5 -0
- py_checks/checks/calls/__init__.py +13 -0
- py_checks/checks/calls/_confined_functions.py +102 -0
- py_checks/checks/calls/_marker.py +5 -0
- py_checks/checks/database/__init__.py +39 -0
- py_checks/checks/database/_bound_checks.py +186 -0
- py_checks/checks/database/_confined_calls.py +115 -0
- py_checks/checks/database/_marker.py +5 -0
- py_checks/checks/database/_model_boundary.py +236 -0
- py_checks/checks/database/_model_columns.py +241 -0
- py_checks/checks/database/_raw_sql.py +108 -0
- py_checks/checks/database/_schema_drift.py +271 -0
- py_checks/checks/database/_statement_keys.py +169 -0
- py_checks/checks/effects/__init__.py +19 -0
- py_checks/checks/effects/_determinism.py +105 -0
- py_checks/checks/effects/_log_events.py +120 -0
- py_checks/checks/effects/_marker.py +5 -0
- py_checks/checks/hygiene/__init__.py +14 -0
- py_checks/checks/hygiene/_dependency_bounds.py +185 -0
- py_checks/checks/hygiene/_marker.py +5 -0
- py_checks/checks/imports/__init__.py +25 -0
- py_checks/checks/imports/_confined.py +93 -0
- py_checks/checks/imports/_marker.py +7 -0
- py_checks/checks/imports/_sealed.py +100 -0
- py_checks/checks/imports/_statements.py +52 -0
- py_checks/checks/placement/__init__.py +38 -0
- py_checks/checks/placement/_class_modules.py +106 -0
- py_checks/checks/placement/_class_placement.py +129 -0
- py_checks/checks/placement/_marker.py +7 -0
- py_checks/checks/placement/_operation_shape.py +387 -0
- py_checks/checks/placement/_required_class.py +179 -0
- py_checks/checks/signatures/__init__.py +33 -0
- py_checks/checks/signatures/_function_length.py +90 -0
- py_checks/checks/signatures/_functions.py +92 -0
- py_checks/checks/signatures/_keyword_only.py +148 -0
- py_checks/checks/signatures/_marker.py +7 -0
- py_checks/checks/signatures/_module_length.py +64 -0
- py_checks/checks/signatures/_nesting.py +156 -0
- py_checks/checks/signatures/_signature_layout.py +231 -0
- py_checks/checks/types/__init__.py +36 -0
- py_checks/checks/types/_annotation_shapes.py +127 -0
- py_checks/checks/types/_config_fields.py +236 -0
- py_checks/checks/types/_confined_types.py +117 -0
- py_checks/checks/types/_constant_annotations.py +128 -0
- py_checks/checks/types/_frozen_dataclasses.py +112 -0
- py_checks/checks/types/_marker.py +5 -0
- py_checks/cli/__init__.py +10 -0
- py_checks/cli/_app.py +21 -0
- py_checks/cli/_protocols.py +19 -0
- py_checks/cli/commands/__init__.py +23 -0
- py_checks/cli/commands/_explain.py +28 -0
- py_checks/cli/commands/_list.py +62 -0
- py_checks/cli/commands/_run.py +167 -0
- py_checks/cli/commands/_summary.py +20 -0
- py_checks/cli/commands/_sync.py +80 -0
- py_checks/config/__init__.py +31 -0
- py_checks/config/_base.py +26 -0
- py_checks/config/_config.py +76 -0
- py_checks/config/_constants.py +31 -0
- py_checks/config/_errors.py +12 -0
- py_checks/config/_loader.py +133 -0
- py_checks/config/_toml.py +24 -0
- py_checks/contracts/__init__.py +26 -0
- py_checks/contracts/_constants.py +18 -0
- py_checks/contracts/_layout.py +63 -0
- py_checks/contracts/_render.py +217 -0
- py_checks/contracts/_settings.py +37 -0
- py_checks/core/__init__.py +56 -0
- py_checks/core/_constants.py +15 -0
- py_checks/core/_discovery.py +57 -0
- py_checks/core/_edit.py +92 -0
- py_checks/core/_errors.py +35 -0
- py_checks/core/_fixer.py +55 -0
- py_checks/core/_format.py +30 -0
- py_checks/core/_markers.py +220 -0
- py_checks/core/_protocols.py +88 -0
- py_checks/core/_registry.py +77 -0
- py_checks/core/_report.py +45 -0
- py_checks/core/_runner.py +178 -0
- py_checks/core/_settings.py +44 -0
- py_checks/core/_source.py +94 -0
- py_checks/core/_violation.py +73 -0
- py_checks/environment/__init__.py +14 -0
- py_checks/environment/_constants.py +9 -0
- py_checks/environment/_render.py +227 -0
- py_checks/environment/_settings.py +35 -0
- py_checks/py.typed +0 -0
- py_checks/sync/__init__.py +16 -0
- py_checks/sync/_sync.py +60 -0
- python_checks-0.1.0.dist-info/METADATA +327 -0
- python_checks-0.1.0.dist-info/RECORD +101 -0
- python_checks-0.1.0.dist-info/WHEEL +4 -0
- python_checks-0.1.0.dist-info/entry_points.txt +33 -0
- python_checks-0.1.0.dist-info/licenses/LICENSE +21 -0
py_checks/core/_fixer.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Наложение правок на файлы."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from py_checks.core._edit import apply
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from py_checks.core._violation import Violation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fix(*, violations: Sequence[Violation]) -> tuple[list[Path], list[Violation]]:
|
|
18
|
+
"""Исправить, что умеем; вернуть изменённые файлы и то, что осталось.
|
|
19
|
+
|
|
20
|
+
Нарушение без правки — не провал автофикса, а честный ответ: `*args`
|
|
21
|
+
исправить нельзя, имена аргументов придумывает автор. Такие нарушения
|
|
22
|
+
возвращаются и попадают в отчёт как обычно.
|
|
23
|
+
"""
|
|
24
|
+
grouped: dict[Path, list[Violation]] = defaultdict(list)
|
|
25
|
+
for violation in violations:
|
|
26
|
+
grouped[violation.path].append(violation)
|
|
27
|
+
changed: list[Path] = []
|
|
28
|
+
left: list[Violation] = []
|
|
29
|
+
for path, found in grouped.items():
|
|
30
|
+
left.extend(violation for violation in found if violation.edit is None)
|
|
31
|
+
if _rewrite(
|
|
32
|
+
path=path,
|
|
33
|
+
found=found,
|
|
34
|
+
):
|
|
35
|
+
changed.append(path)
|
|
36
|
+
return changed, left
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rewrite(
|
|
40
|
+
*,
|
|
41
|
+
path: Path,
|
|
42
|
+
found: Sequence[Violation],
|
|
43
|
+
) -> bool:
|
|
44
|
+
edits = [violation.edit for violation in found if violation.edit is not None]
|
|
45
|
+
if not edits:
|
|
46
|
+
return False
|
|
47
|
+
text = path.read_text(encoding="utf-8")
|
|
48
|
+
fixed = apply(
|
|
49
|
+
text=text,
|
|
50
|
+
edits=edits,
|
|
51
|
+
)
|
|
52
|
+
if fixed == text:
|
|
53
|
+
return False
|
|
54
|
+
path.write_text(fixed, encoding="utf-8")
|
|
55
|
+
return True
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Форматирование файлов, которые правил автофикс."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
FORMATTER: Final = "ruff"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reformat(*, paths: Sequence[Path]) -> None:
|
|
17
|
+
"""Пройтись форматтером по изменённым файлам.
|
|
18
|
+
|
|
19
|
+
Правка ставит символы, а не колонки: после вставки `*` подпись может стать
|
|
20
|
+
длиннее лимита строки. Раскладывать её руками — работа форматтера, он в
|
|
21
|
+
проекте всё равно есть. Если его нет, файл остаётся исправленным, просто
|
|
22
|
+
неотформатированным.
|
|
23
|
+
"""
|
|
24
|
+
formatter = shutil.which(FORMATTER)
|
|
25
|
+
if formatter is None or not paths:
|
|
26
|
+
return
|
|
27
|
+
subprocess.run( # noqa: S603 - команда своя, пути берутся из найденных файлов
|
|
28
|
+
[formatter, "format", "--quiet", *(str(path) for path in paths)],
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Маркер, которым строка снимается с проверки.
|
|
2
|
+
|
|
3
|
+
Форма одна на все правила: `# check-ok: <код>[, <код>]: <причина>`. Правило про
|
|
4
|
+
маркеры не знает ничего — нарушения снимает ядро, поэтому и синтаксис, и
|
|
5
|
+
требование причины у всех проверок одинаковые.
|
|
6
|
+
|
|
7
|
+
Код обязателен: маркер снимает названное правило, а не всё подряд. Причина
|
|
8
|
+
обязательна по той же причине, по которой её требует `# noqa` в ревью: через
|
|
9
|
+
полгода никто не помнит, чья это библиотека диктует подпись.
|
|
10
|
+
|
|
11
|
+
У группы правил есть своё короткое слово — `# signature-ok` на весь пакет
|
|
12
|
+
`signatures`. Оно снимает любую проверку группы: человек помнит группу («это
|
|
13
|
+
про подписи»), а не сорок кодов. Канонический `# check-ok: <код>` снимает ровно
|
|
14
|
+
одно правило и работает всегда.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING, Final
|
|
22
|
+
|
|
23
|
+
from py_checks.core._violation import Violation
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Collection, Iterator, Mapping, Sequence
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from py_checks.core._source import ParsedFile
|
|
30
|
+
|
|
31
|
+
MARKER: Final = "# check-ok:"
|
|
32
|
+
|
|
33
|
+
CODE: Final = "check-ok"
|
|
34
|
+
|
|
35
|
+
SHAPE: Final = "`# check-ok: <код>: <причина>`"
|
|
36
|
+
|
|
37
|
+
# Код правила выглядит так и не иначе. Проверка нужна не ради строгости: это
|
|
38
|
+
# же слово стоит в документации и в сообщениях об ошибках, и такая строка не
|
|
39
|
+
# должна читаться как пометка. Всё, что на код не похоже, — просто текст.
|
|
40
|
+
NAME: Final = re.compile(r"[a-z][a-z0-9_-]*")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Marker:
|
|
45
|
+
"""Что написано в маркере: какие правила он снимает и почему."""
|
|
46
|
+
|
|
47
|
+
codes: frozenset[str]
|
|
48
|
+
reason: str
|
|
49
|
+
column: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read(
|
|
53
|
+
*,
|
|
54
|
+
line: str,
|
|
55
|
+
aliases: Mapping[str, frozenset[str]],
|
|
56
|
+
) -> Marker | None:
|
|
57
|
+
"""Маркер из строки, если он там есть.
|
|
58
|
+
|
|
59
|
+
Разбор нарочно не падает на кривой записи: маркер без кода или без причины
|
|
60
|
+
читается и попадает в `complaints`, иначе о нём никто бы не узнал.
|
|
61
|
+
"""
|
|
62
|
+
if MARKER in line:
|
|
63
|
+
codes, _, reason = line.split(MARKER, maxsplit=1)[1].partition(":")
|
|
64
|
+
if not _named(text=codes):
|
|
65
|
+
return None
|
|
66
|
+
return Marker(
|
|
67
|
+
codes=frozenset(_codes(text=codes)),
|
|
68
|
+
reason=reason.strip(),
|
|
69
|
+
column=line.index(MARKER) + 1,
|
|
70
|
+
)
|
|
71
|
+
for text, codes in aliases.items():
|
|
72
|
+
if text in line:
|
|
73
|
+
return Marker(
|
|
74
|
+
codes=codes,
|
|
75
|
+
reason=line.split(text, maxsplit=1)[1].removeprefix(":").strip(),
|
|
76
|
+
column=line.index(text) + 1,
|
|
77
|
+
)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def surviving(
|
|
82
|
+
*,
|
|
83
|
+
violations: Sequence[Violation],
|
|
84
|
+
file: ParsedFile,
|
|
85
|
+
aliases: Mapping[str, frozenset[str]],
|
|
86
|
+
) -> list[Violation]:
|
|
87
|
+
"""Нарушения, которые никто не снял маркером."""
|
|
88
|
+
return [
|
|
89
|
+
violation
|
|
90
|
+
for violation in violations
|
|
91
|
+
if not _covered(
|
|
92
|
+
violation=violation,
|
|
93
|
+
file=file,
|
|
94
|
+
aliases=aliases,
|
|
95
|
+
)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def complaints(
|
|
100
|
+
*,
|
|
101
|
+
file: ParsedFile,
|
|
102
|
+
aliases: Mapping[str, frozenset[str]],
|
|
103
|
+
known: Collection[str],
|
|
104
|
+
) -> Iterator[Violation]:
|
|
105
|
+
"""Маркер, который ничего не снимает, — молча неработающий маркер.
|
|
106
|
+
|
|
107
|
+
Опечатка в коде правила выглядит как отключённая проверка, а на деле
|
|
108
|
+
проверка работает и просто не видит пометки. Поэтому такой маркер — сам
|
|
109
|
+
нарушение.
|
|
110
|
+
"""
|
|
111
|
+
for number, line in enumerate(file.lines, start=1):
|
|
112
|
+
marker = read(
|
|
113
|
+
line=line,
|
|
114
|
+
aliases=aliases,
|
|
115
|
+
)
|
|
116
|
+
if marker is None:
|
|
117
|
+
continue
|
|
118
|
+
yield from _wrong(
|
|
119
|
+
marker=marker,
|
|
120
|
+
path=file.path,
|
|
121
|
+
line=number,
|
|
122
|
+
known=known,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _named(*, text: str) -> bool:
|
|
127
|
+
"""Похоже ли перечисленное на коды правил.
|
|
128
|
+
|
|
129
|
+
Пустое место после маркера — тоже пометка, только без кода: о ней скажет
|
|
130
|
+
`complaints`. А вот `# check-ok: <код>` из документации пометкой не
|
|
131
|
+
считается, иначе библиотека ловила бы собственный текст.
|
|
132
|
+
"""
|
|
133
|
+
names = list(_codes(text=text))
|
|
134
|
+
return not names or all(NAME.fullmatch(name) for name in names)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _codes(*, text: str) -> Iterator[str]:
|
|
138
|
+
for code in text.split(","):
|
|
139
|
+
if stripped := code.strip():
|
|
140
|
+
yield stripped
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _covered(
|
|
144
|
+
*,
|
|
145
|
+
violation: Violation,
|
|
146
|
+
file: ParsedFile,
|
|
147
|
+
aliases: Mapping[str, frozenset[str]],
|
|
148
|
+
) -> bool:
|
|
149
|
+
for line in _span(
|
|
150
|
+
violation=violation,
|
|
151
|
+
file=file,
|
|
152
|
+
):
|
|
153
|
+
marker = read(
|
|
154
|
+
line=line,
|
|
155
|
+
aliases=aliases,
|
|
156
|
+
)
|
|
157
|
+
if marker is not None and violation.code in marker.codes:
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _span(
|
|
163
|
+
*,
|
|
164
|
+
violation: Violation,
|
|
165
|
+
file: ParsedFile,
|
|
166
|
+
) -> tuple[str, ...]:
|
|
167
|
+
"""Строки, в которых ищем маркер.
|
|
168
|
+
|
|
169
|
+
Нарушение указывает на первую строку того, что нашло, а пометке место в
|
|
170
|
+
конце: подпись в столбик несёт её на последней строке. Поэтому проверка,
|
|
171
|
+
занимающая несколько строк, говорит `end_line`, и маркер ищется во всех.
|
|
172
|
+
"""
|
|
173
|
+
last = max(violation.end_line or violation.line, violation.line)
|
|
174
|
+
return file.lines[violation.line - 1 : last]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _wrong(
|
|
178
|
+
*,
|
|
179
|
+
marker: Marker,
|
|
180
|
+
path: Path,
|
|
181
|
+
line: int,
|
|
182
|
+
known: Collection[str],
|
|
183
|
+
) -> Iterator[Violation]:
|
|
184
|
+
if not marker.codes:
|
|
185
|
+
yield _complaint(
|
|
186
|
+
path=path,
|
|
187
|
+
line=line,
|
|
188
|
+
column=marker.column,
|
|
189
|
+
message=f"маркеру нужен код проверки: {SHAPE}",
|
|
190
|
+
)
|
|
191
|
+
for code in sorted(marker.codes.difference(known)):
|
|
192
|
+
yield _complaint(
|
|
193
|
+
path=path,
|
|
194
|
+
line=line,
|
|
195
|
+
column=marker.column,
|
|
196
|
+
message=f"нет проверки `{code}`, маркер ничего не снимает",
|
|
197
|
+
)
|
|
198
|
+
if not marker.reason:
|
|
199
|
+
yield _complaint(
|
|
200
|
+
path=path,
|
|
201
|
+
line=line,
|
|
202
|
+
column=marker.column,
|
|
203
|
+
message=f"маркеру нужна причина: {SHAPE}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _complaint(
|
|
208
|
+
*,
|
|
209
|
+
path: Path,
|
|
210
|
+
line: int,
|
|
211
|
+
column: int,
|
|
212
|
+
message: str,
|
|
213
|
+
) -> Violation:
|
|
214
|
+
return Violation(
|
|
215
|
+
path=path,
|
|
216
|
+
line=line,
|
|
217
|
+
column=column,
|
|
218
|
+
code=CODE,
|
|
219
|
+
message=message,
|
|
220
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Каким библиотека видит правило."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from py_checks.config import CheckSettings
|
|
13
|
+
from py_checks.core._source import ParsedFile
|
|
14
|
+
from py_checks.core._violation import Violation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Scope(StrEnum):
|
|
18
|
+
"""Что правилу дают на суд.
|
|
19
|
+
|
|
20
|
+
Вид объявляет само правило, а не группа entry points: так автор чужого
|
|
21
|
+
пакета пишет одну запись, а `list` и `explain` видят все правила разом, не
|
|
22
|
+
складывая два реестра в один.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Один файл, разобранный ядром: таких правил большинство.
|
|
26
|
+
FILE = "file"
|
|
27
|
+
|
|
28
|
+
# Корень проекта: манифест, согласие двух файлов репозитория между собой.
|
|
29
|
+
PROJECT = "project"
|
|
30
|
+
|
|
31
|
+
# То же, что `PROJECT`, но правилу нужна живая среда — база, сеть, долгий
|
|
32
|
+
# прогон. В обычный прогон такое не входит: его зовут по имени или в CI,
|
|
33
|
+
# иначе хук на коммит начинает ждать базу.
|
|
34
|
+
ENVIRONMENT = "environment"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class FileCheck(Protocol):
|
|
39
|
+
"""Правило, которому хватает одного файла.
|
|
40
|
+
|
|
41
|
+
Всё остальное — поиск файлов, разбор, настройки, вывод — делает ядро.
|
|
42
|
+
Проверка знает только своё условие и возвращает нарушения, ничего не
|
|
43
|
+
печатая: иначе формат вывода расползётся по сорока правилам.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
code: ClassVar[str]
|
|
47
|
+
Settings: ClassVar[type[CheckSettings]]
|
|
48
|
+
scope: ClassVar[Scope]
|
|
49
|
+
|
|
50
|
+
# Слово группы, к которой правило принадлежит: `# signature-ok` снимает
|
|
51
|
+
# любую проверку из `signatures`. Пишется один раз на пакет, потому что
|
|
52
|
+
# человек помнит группу («это про подписи»), а не сорок кодов. Канонический
|
|
53
|
+
# `# check-ok: <код>` работает всегда и снимает ровно одно правило.
|
|
54
|
+
marker: ClassVar[str]
|
|
55
|
+
|
|
56
|
+
def run(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
file: ParsedFile,
|
|
60
|
+
settings: CheckSettings,
|
|
61
|
+
) -> Iterator[Violation]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@runtime_checkable
|
|
65
|
+
class ProjectCheck(Protocol):
|
|
66
|
+
"""Правило, которому одного файла мало.
|
|
67
|
+
|
|
68
|
+
Манифест зависимостей, согласие двух файлов репозитория между собой — то,
|
|
69
|
+
что живёт не в исходнике, а в проекте. Такое правило вызывается один раз за
|
|
70
|
+
прогон и само решает, что ему прочитать; ядро даёт ему корень и настройки.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
code: ClassVar[str]
|
|
74
|
+
Settings: ClassVar[type[CheckSettings]]
|
|
75
|
+
scope: ClassVar[Scope]
|
|
76
|
+
marker: ClassVar[str]
|
|
77
|
+
|
|
78
|
+
def run(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
root: Path,
|
|
82
|
+
settings: CheckSettings,
|
|
83
|
+
) -> Iterator[Violation]: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Правило — это одно из двух: судящее файл или судящее проект. Там, где важно
|
|
87
|
+
# лишь то, что у него есть код и описание (список, объяснение), годится любое.
|
|
88
|
+
type Check = FileCheck | ProjectCheck
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Какие проверки существуют и как их находят."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib.metadata import entry_points
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from py_checks.core._constants import GROUP
|
|
11
|
+
from py_checks.core._errors import UnknownCheckError
|
|
12
|
+
from py_checks.core._protocols import Scope
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Collection
|
|
16
|
+
|
|
17
|
+
from py_checks.core._protocols import Check, FileCheck, ProjectCheck
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Checks:
|
|
22
|
+
"""Правила, разобранные по тому, что им дают: файл или корень проекта.
|
|
23
|
+
|
|
24
|
+
Один и тот же вид описывает и всё, что установлено, и то, что выбрали на
|
|
25
|
+
этот прогон, — поэтому выбор не превращается в развилку у каждого, кто его
|
|
26
|
+
получает: прогон, список и объяснение говорят об одном и том же наборе.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
files: dict[str, FileCheck]
|
|
30
|
+
project: dict[str, ProjectCheck]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def listed(self) -> dict[str, Check]:
|
|
34
|
+
"""Все правила по коду, независимо от вида."""
|
|
35
|
+
return {**self.files, **self.project}
|
|
36
|
+
|
|
37
|
+
def only(self, *, codes: Collection[str]) -> Checks:
|
|
38
|
+
"""Тот же набор, суженный до названных кодов."""
|
|
39
|
+
return Checks(
|
|
40
|
+
files={code: check for code, check in self.files.items() if code in codes},
|
|
41
|
+
project={code: check for code, check in self.project.items() if code in codes},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cache
|
|
46
|
+
def available() -> Checks:
|
|
47
|
+
"""Все проверки, объявленные через entry points.
|
|
48
|
+
|
|
49
|
+
Так проект или команда добавляет своё правило: ставит рядом свой пакет с
|
|
50
|
+
записью в этой же группе, а библиотеку форкать не нужно. Вид правила —
|
|
51
|
+
файл ему дают или корень проекта — объявляет оно само.
|
|
52
|
+
|
|
53
|
+
Читается один раз: загрузка означает импорт каждого объявленного модуля, а
|
|
54
|
+
спрашивают реестр и прогон, и выбор, и объяснение.
|
|
55
|
+
"""
|
|
56
|
+
files: dict[str, FileCheck] = {}
|
|
57
|
+
project: dict[str, ProjectCheck] = {}
|
|
58
|
+
for entry in sorted(entry_points(group=GROUP), key=lambda entry: entry.name):
|
|
59
|
+
check = entry.load()()
|
|
60
|
+
if check.scope is Scope.FILE:
|
|
61
|
+
files[check.code] = check
|
|
62
|
+
else:
|
|
63
|
+
project[check.code] = check
|
|
64
|
+
return Checks(
|
|
65
|
+
files=files,
|
|
66
|
+
project=project,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get(*, code: str) -> Check:
|
|
71
|
+
checks = available().listed
|
|
72
|
+
if code not in checks:
|
|
73
|
+
raise UnknownCheckError(
|
|
74
|
+
code=code,
|
|
75
|
+
known=tuple(sorted(checks)),
|
|
76
|
+
)
|
|
77
|
+
return checks[code]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Вывод нарушений."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from py_checks.core._constants import EXIT_OK, EXIT_VIOLATION
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from py_checks.core._violation import Violation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def report(
|
|
19
|
+
*,
|
|
20
|
+
violations: Sequence[Violation],
|
|
21
|
+
root: Path,
|
|
22
|
+
checked: int,
|
|
23
|
+
console: Console | None = None,
|
|
24
|
+
) -> int:
|
|
25
|
+
"""Печатает нарушения и возвращает код выхода.
|
|
26
|
+
|
|
27
|
+
Нарушения идут в stderr обычными строками: их читают редактор и CI, и
|
|
28
|
+
подсветка не должна мешать разбирать строку. Цвета `rich` отключает сам,
|
|
29
|
+
когда вывод идёт не в терминал, — а под pre-commit это всегда так.
|
|
30
|
+
"""
|
|
31
|
+
console = console or Console(
|
|
32
|
+
stderr=True,
|
|
33
|
+
soft_wrap=True,
|
|
34
|
+
)
|
|
35
|
+
for violation in violations:
|
|
36
|
+
console.print(
|
|
37
|
+
violation.render(root=root),
|
|
38
|
+
markup=False,
|
|
39
|
+
highlight=False,
|
|
40
|
+
)
|
|
41
|
+
if violations:
|
|
42
|
+
console.print(f"\n{len(violations)} нарушени(й) в {checked} файл(ах)", markup=False)
|
|
43
|
+
return EXIT_VIOLATION
|
|
44
|
+
console.print(f"ok: проверено файлов — {checked}", markup=False)
|
|
45
|
+
return EXIT_OK
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Прогон проверок по файлам."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from py_checks.core import _registry
|
|
9
|
+
from py_checks.core._errors import ParseError
|
|
10
|
+
from py_checks.core._markers import complaints, surviving
|
|
11
|
+
from py_checks.core._source import ParsedFile
|
|
12
|
+
from py_checks.core._violation import Violation
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from py_checks.config import CheckSettings, Config
|
|
19
|
+
from py_checks.core._protocols import Check, FileCheck, ProjectCheck
|
|
20
|
+
from py_checks.core._registry import Checks
|
|
21
|
+
|
|
22
|
+
SYNTAX: Final = "syntax"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def survey(
|
|
26
|
+
*,
|
|
27
|
+
chosen: Checks,
|
|
28
|
+
files: Sequence[Path],
|
|
29
|
+
config: Config,
|
|
30
|
+
root: Path,
|
|
31
|
+
) -> list[Violation]:
|
|
32
|
+
"""Все нарушения выбранных правил: файловых — по файлам, проектных — по корню.
|
|
33
|
+
|
|
34
|
+
Разделение сделано один раз здесь, потому что вид правила виден только по
|
|
35
|
+
тому, что ему дают: у судящего файл и у судящего проект разные `run`, и
|
|
36
|
+
складывать их в один цикл нечестно.
|
|
37
|
+
"""
|
|
38
|
+
return [
|
|
39
|
+
*inspect(
|
|
40
|
+
files=files,
|
|
41
|
+
checks=list(chosen.files.values()),
|
|
42
|
+
config=config,
|
|
43
|
+
root=root,
|
|
44
|
+
),
|
|
45
|
+
*examine(
|
|
46
|
+
checks=list(chosen.project.values()),
|
|
47
|
+
config=config,
|
|
48
|
+
root=root,
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def inspect(
|
|
54
|
+
*,
|
|
55
|
+
files: Sequence[Path],
|
|
56
|
+
checks: Sequence[FileCheck],
|
|
57
|
+
config: Config,
|
|
58
|
+
root: Path | None = None,
|
|
59
|
+
) -> list[Violation]:
|
|
60
|
+
"""Все нарушения по всем файлам.
|
|
61
|
+
|
|
62
|
+
Внешний цикл по файлам, а не по проверкам: файл читается и разбирается один
|
|
63
|
+
раз, а проверок на него много.
|
|
64
|
+
"""
|
|
65
|
+
settings = {
|
|
66
|
+
check.code: config.settings_for(
|
|
67
|
+
code=check.code,
|
|
68
|
+
model=check.Settings,
|
|
69
|
+
)
|
|
70
|
+
for check in checks
|
|
71
|
+
}
|
|
72
|
+
registered = _registry.available().listed
|
|
73
|
+
aliases = _aliases(registered=registered)
|
|
74
|
+
source = root / config.src if root is not None else None
|
|
75
|
+
violations: list[Violation] = []
|
|
76
|
+
for path in files:
|
|
77
|
+
violations.extend(
|
|
78
|
+
_inspect_file(
|
|
79
|
+
path=path,
|
|
80
|
+
checks=checks,
|
|
81
|
+
settings=settings,
|
|
82
|
+
source=source,
|
|
83
|
+
aliases=aliases,
|
|
84
|
+
known=frozenset(registered),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return violations
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def examine(
|
|
91
|
+
*,
|
|
92
|
+
checks: Sequence[ProjectCheck],
|
|
93
|
+
config: Config,
|
|
94
|
+
root: Path,
|
|
95
|
+
) -> list[Violation]:
|
|
96
|
+
"""Нарушения правил, которым нужен проект целиком.
|
|
97
|
+
|
|
98
|
+
Каждое зовётся один раз: что прочитать — манифест, пару файлов, дерево, —
|
|
99
|
+
решает оно само.
|
|
100
|
+
"""
|
|
101
|
+
return [
|
|
102
|
+
violation
|
|
103
|
+
for check in checks
|
|
104
|
+
for violation in check.run(
|
|
105
|
+
root=root,
|
|
106
|
+
settings=config.settings_for(
|
|
107
|
+
code=check.code,
|
|
108
|
+
model=check.Settings,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _aliases(*, registered: Mapping[str, Check]) -> dict[str, frozenset[str]]:
|
|
115
|
+
"""Слово группы и все правила, которые оно снимает.
|
|
116
|
+
|
|
117
|
+
Слово у группы одно на всех, поэтому `# signature-ok` снимает любую
|
|
118
|
+
проверку из `signatures`: человек помнит группу, а не сорок кодов. Когда
|
|
119
|
+
нужно снять ровно одно правило, для этого есть `# check-ok: <код>`.
|
|
120
|
+
"""
|
|
121
|
+
groups: dict[str, set[str]] = defaultdict(set)
|
|
122
|
+
for code, check in registered.items():
|
|
123
|
+
groups[check.marker].add(code)
|
|
124
|
+
return {marker: frozenset(codes) for marker, codes in groups.items()}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _inspect_file(
|
|
128
|
+
*,
|
|
129
|
+
path: Path,
|
|
130
|
+
checks: Sequence[FileCheck],
|
|
131
|
+
settings: Mapping[str, CheckSettings],
|
|
132
|
+
source: Path | None,
|
|
133
|
+
aliases: Mapping[str, frozenset[str]],
|
|
134
|
+
known: Collection[str],
|
|
135
|
+
) -> list[Violation]:
|
|
136
|
+
file = ParsedFile.from_path(
|
|
137
|
+
path=path,
|
|
138
|
+
source=source,
|
|
139
|
+
)
|
|
140
|
+
found: list[Violation] = []
|
|
141
|
+
for check in checks:
|
|
142
|
+
try:
|
|
143
|
+
found.extend(
|
|
144
|
+
check.run(
|
|
145
|
+
file=file,
|
|
146
|
+
settings=settings[check.code],
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
except ParseError as error:
|
|
150
|
+
return [_broken(error=error)]
|
|
151
|
+
kept = surviving(
|
|
152
|
+
violations=found,
|
|
153
|
+
file=file,
|
|
154
|
+
aliases=aliases,
|
|
155
|
+
)
|
|
156
|
+
return [
|
|
157
|
+
*kept,
|
|
158
|
+
*complaints(
|
|
159
|
+
file=file,
|
|
160
|
+
aliases=aliases,
|
|
161
|
+
known=known,
|
|
162
|
+
),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _broken(*, error: ParseError) -> Violation:
|
|
167
|
+
"""Сломанный файл — это одно нарушение, а не падение всего прогона.
|
|
168
|
+
|
|
169
|
+
Иначе один файл с недописанным синтаксисом прячет нарушения во всех
|
|
170
|
+
остальных.
|
|
171
|
+
"""
|
|
172
|
+
return Violation(
|
|
173
|
+
path=error.path,
|
|
174
|
+
line=error.error.lineno or 1,
|
|
175
|
+
column=error.error.offset or 1,
|
|
176
|
+
code=SYNTAX,
|
|
177
|
+
message=error.error.msg,
|
|
178
|
+
)
|