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.
Files changed (101) hide show
  1. py_checks/__init__.py +2 -0
  2. py_checks/checks/__init__.py +20 -0
  3. py_checks/checks/_kind.py +184 -0
  4. py_checks/checks/_location.py +172 -0
  5. py_checks/checks/_names.py +86 -0
  6. py_checks/checks/api/__init__.py +13 -0
  7. py_checks/checks/api/_endpoint_declarations.py +204 -0
  8. py_checks/checks/api/_marker.py +5 -0
  9. py_checks/checks/calls/__init__.py +13 -0
  10. py_checks/checks/calls/_confined_functions.py +102 -0
  11. py_checks/checks/calls/_marker.py +5 -0
  12. py_checks/checks/database/__init__.py +39 -0
  13. py_checks/checks/database/_bound_checks.py +186 -0
  14. py_checks/checks/database/_confined_calls.py +115 -0
  15. py_checks/checks/database/_marker.py +5 -0
  16. py_checks/checks/database/_model_boundary.py +236 -0
  17. py_checks/checks/database/_model_columns.py +241 -0
  18. py_checks/checks/database/_raw_sql.py +108 -0
  19. py_checks/checks/database/_schema_drift.py +271 -0
  20. py_checks/checks/database/_statement_keys.py +169 -0
  21. py_checks/checks/effects/__init__.py +19 -0
  22. py_checks/checks/effects/_determinism.py +105 -0
  23. py_checks/checks/effects/_log_events.py +120 -0
  24. py_checks/checks/effects/_marker.py +5 -0
  25. py_checks/checks/hygiene/__init__.py +14 -0
  26. py_checks/checks/hygiene/_dependency_bounds.py +185 -0
  27. py_checks/checks/hygiene/_marker.py +5 -0
  28. py_checks/checks/imports/__init__.py +25 -0
  29. py_checks/checks/imports/_confined.py +93 -0
  30. py_checks/checks/imports/_marker.py +7 -0
  31. py_checks/checks/imports/_sealed.py +100 -0
  32. py_checks/checks/imports/_statements.py +52 -0
  33. py_checks/checks/placement/__init__.py +38 -0
  34. py_checks/checks/placement/_class_modules.py +106 -0
  35. py_checks/checks/placement/_class_placement.py +129 -0
  36. py_checks/checks/placement/_marker.py +7 -0
  37. py_checks/checks/placement/_operation_shape.py +387 -0
  38. py_checks/checks/placement/_required_class.py +179 -0
  39. py_checks/checks/signatures/__init__.py +33 -0
  40. py_checks/checks/signatures/_function_length.py +90 -0
  41. py_checks/checks/signatures/_functions.py +92 -0
  42. py_checks/checks/signatures/_keyword_only.py +148 -0
  43. py_checks/checks/signatures/_marker.py +7 -0
  44. py_checks/checks/signatures/_module_length.py +64 -0
  45. py_checks/checks/signatures/_nesting.py +156 -0
  46. py_checks/checks/signatures/_signature_layout.py +231 -0
  47. py_checks/checks/types/__init__.py +36 -0
  48. py_checks/checks/types/_annotation_shapes.py +127 -0
  49. py_checks/checks/types/_config_fields.py +236 -0
  50. py_checks/checks/types/_confined_types.py +117 -0
  51. py_checks/checks/types/_constant_annotations.py +128 -0
  52. py_checks/checks/types/_frozen_dataclasses.py +112 -0
  53. py_checks/checks/types/_marker.py +5 -0
  54. py_checks/cli/__init__.py +10 -0
  55. py_checks/cli/_app.py +21 -0
  56. py_checks/cli/_protocols.py +19 -0
  57. py_checks/cli/commands/__init__.py +23 -0
  58. py_checks/cli/commands/_explain.py +28 -0
  59. py_checks/cli/commands/_list.py +62 -0
  60. py_checks/cli/commands/_run.py +167 -0
  61. py_checks/cli/commands/_summary.py +20 -0
  62. py_checks/cli/commands/_sync.py +80 -0
  63. py_checks/config/__init__.py +31 -0
  64. py_checks/config/_base.py +26 -0
  65. py_checks/config/_config.py +76 -0
  66. py_checks/config/_constants.py +31 -0
  67. py_checks/config/_errors.py +12 -0
  68. py_checks/config/_loader.py +133 -0
  69. py_checks/config/_toml.py +24 -0
  70. py_checks/contracts/__init__.py +26 -0
  71. py_checks/contracts/_constants.py +18 -0
  72. py_checks/contracts/_layout.py +63 -0
  73. py_checks/contracts/_render.py +217 -0
  74. py_checks/contracts/_settings.py +37 -0
  75. py_checks/core/__init__.py +56 -0
  76. py_checks/core/_constants.py +15 -0
  77. py_checks/core/_discovery.py +57 -0
  78. py_checks/core/_edit.py +92 -0
  79. py_checks/core/_errors.py +35 -0
  80. py_checks/core/_fixer.py +55 -0
  81. py_checks/core/_format.py +30 -0
  82. py_checks/core/_markers.py +220 -0
  83. py_checks/core/_protocols.py +88 -0
  84. py_checks/core/_registry.py +77 -0
  85. py_checks/core/_report.py +45 -0
  86. py_checks/core/_runner.py +178 -0
  87. py_checks/core/_settings.py +44 -0
  88. py_checks/core/_source.py +94 -0
  89. py_checks/core/_violation.py +73 -0
  90. py_checks/environment/__init__.py +14 -0
  91. py_checks/environment/_constants.py +9 -0
  92. py_checks/environment/_render.py +227 -0
  93. py_checks/environment/_settings.py +35 -0
  94. py_checks/py.typed +0 -0
  95. py_checks/sync/__init__.py +16 -0
  96. py_checks/sync/_sync.py +60 -0
  97. python_checks-0.1.0.dist-info/METADATA +327 -0
  98. python_checks-0.1.0.dist-info/RECORD +101 -0
  99. python_checks-0.1.0.dist-info/WHEEL +4 -0
  100. python_checks-0.1.0.dist-info/entry_points.txt +33 -0
  101. python_checks-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ )