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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Схема, которую строят миграции, и схема, которую описывают модели."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess # noqa: S404 — правило состоит в том, чтобы позвать alembic
|
|
7
|
+
import tempfile
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
11
|
+
|
|
12
|
+
from py_checks.checks.database._marker import MARKER
|
|
13
|
+
from py_checks.config import CheckSettings
|
|
14
|
+
from py_checks.core import Scope, Violation, settings_as
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Generator, Iterator, Sequence
|
|
18
|
+
|
|
19
|
+
CODE: Final = "schema-drift"
|
|
20
|
+
|
|
21
|
+
# Чем alembic отвечает, когда дописывать нечего.
|
|
22
|
+
IN_STEP: Final = "No new upgrade operations detected"
|
|
23
|
+
|
|
24
|
+
# Так выходит удавшаяся команда; к коду выхода самой библиотеки это отношения
|
|
25
|
+
# не имеет.
|
|
26
|
+
DONE: Final = 0
|
|
27
|
+
|
|
28
|
+
DATABASE: Final = "drift.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SchemaDriftSettings(CheckSettings):
|
|
32
|
+
versions: Path = Path("migrations/versions")
|
|
33
|
+
models: str = "src/*/infra/database/models"
|
|
34
|
+
variable: str = "DATABASE_URL"
|
|
35
|
+
url: str = "sqlite+aiosqlite:///{path}"
|
|
36
|
+
alembic: tuple[str, ...] = ("alembic",)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DriftError(RuntimeError):
|
|
40
|
+
"""Правило не дошло до вердикта.
|
|
41
|
+
|
|
42
|
+
Своя ошибка, чтобы «не удалось посмотреть» никогда не читалось как
|
|
43
|
+
«смотреть не на что»: молчание правила означает, что расхождения нет.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SchemaDrift:
|
|
48
|
+
"""Падает, если модели и миграции описывают уже разные схемы.
|
|
49
|
+
|
|
50
|
+
Модель поменяли, ревизию не написали — и дальше всё зависит от того, где
|
|
51
|
+
код встретится со схемой. Тесты на своей базе, поднятой из метаданных,
|
|
52
|
+
зелены; прод поднят миграциями и не знает о колонке, которой модель уже
|
|
53
|
+
пользуется. Расхождение всплывает не в ревью, а в первом запросе после
|
|
54
|
+
выкатки.
|
|
55
|
+
|
|
56
|
+
Статикой это не видно: одна схема написана декларациями, вторая — историей
|
|
57
|
+
правок, и сравнивает их только тот, кто умеет обе выполнить. Поэтому
|
|
58
|
+
правило поднимает свою пустую базу во временной директории, накатывает её
|
|
59
|
+
до `head` и спрашивает `alembic check`, что он дописал бы сам. Своя, а не
|
|
60
|
+
база разработчика: та стоит на ревизии, на которой её оставили, — ровно то
|
|
61
|
+
состояние, которому правило и не верит.
|
|
62
|
+
|
|
63
|
+
Живая база и запуск alembic — причина, по которой правило объявлено
|
|
64
|
+
`ENVIRONMENT`: в хуке на коммит ему не место. Его зовут в CI —
|
|
65
|
+
`py-checks run --all` или `--select schema-drift`.
|
|
66
|
+
|
|
67
|
+
Две оговорки. `env.py` проекта обязан читать адрес базы из переменной
|
|
68
|
+
окружения (`variable`): если он берёт его из своих настроек, правило
|
|
69
|
+
подсунуть пустую базу не может и накатит миграции на ту, что найдёт.
|
|
70
|
+
Адрес по умолчанию — SQLite, а ему нужен установленный `aiosqlite`; где
|
|
71
|
+
его нет, в `url` пишут адрес одноразовой базы CI.
|
|
72
|
+
|
|
73
|
+
Настройки: `versions`, `models`, `variable`, `url`, `alembic`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
code: ClassVar[str] = CODE
|
|
77
|
+
Settings: ClassVar[type[CheckSettings]] = SchemaDriftSettings
|
|
78
|
+
scope: ClassVar[Scope] = Scope.ENVIRONMENT
|
|
79
|
+
marker: ClassVar[str] = MARKER
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def run(
|
|
83
|
+
cls,
|
|
84
|
+
*,
|
|
85
|
+
root: Path,
|
|
86
|
+
settings: CheckSettings,
|
|
87
|
+
) -> Iterator[Violation]:
|
|
88
|
+
limits = settings_as(
|
|
89
|
+
settings=settings,
|
|
90
|
+
model=SchemaDriftSettings,
|
|
91
|
+
code=CODE,
|
|
92
|
+
)
|
|
93
|
+
revisions = cls._revisions(
|
|
94
|
+
root=root,
|
|
95
|
+
limits=limits,
|
|
96
|
+
)
|
|
97
|
+
models = cls._models(
|
|
98
|
+
root=root,
|
|
99
|
+
limits=limits,
|
|
100
|
+
)
|
|
101
|
+
if not revisions and not models:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
found = cls._compared(
|
|
105
|
+
root=root,
|
|
106
|
+
limits=limits,
|
|
107
|
+
)
|
|
108
|
+
except DriftError as broken:
|
|
109
|
+
yield cls._violation(
|
|
110
|
+
root=root,
|
|
111
|
+
limits=limits,
|
|
112
|
+
message=str(broken),
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
if found is not None:
|
|
116
|
+
yield cls._violation(
|
|
117
|
+
root=root,
|
|
118
|
+
limits=limits,
|
|
119
|
+
message=cls._joined(
|
|
120
|
+
lines=(
|
|
121
|
+
"модели описывают схему, которую миграции не строят",
|
|
122
|
+
found,
|
|
123
|
+
"напиши ревизию: `alembic revision --autogenerate`, "
|
|
124
|
+
"потом прочитай, что она пишет",
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def _compared(
|
|
131
|
+
cls,
|
|
132
|
+
*,
|
|
133
|
+
root: Path,
|
|
134
|
+
limits: SchemaDriftSettings,
|
|
135
|
+
) -> str | None:
|
|
136
|
+
"""Что alembic дописал бы к истории, или `None`, когда дописывать нечего."""
|
|
137
|
+
with cls._database() as path:
|
|
138
|
+
url = limits.url.format(path=path)
|
|
139
|
+
cls._migrated(
|
|
140
|
+
root=root,
|
|
141
|
+
url=url,
|
|
142
|
+
limits=limits,
|
|
143
|
+
)
|
|
144
|
+
return cls._behind(
|
|
145
|
+
root=root,
|
|
146
|
+
url=url,
|
|
147
|
+
limits=limits,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _revisions(
|
|
152
|
+
*,
|
|
153
|
+
root: Path,
|
|
154
|
+
limits: SchemaDriftSettings,
|
|
155
|
+
) -> Sequence[Path]:
|
|
156
|
+
versions = root / limits.versions
|
|
157
|
+
if not versions.is_dir():
|
|
158
|
+
return ()
|
|
159
|
+
return sorted(path for path in versions.rglob("*.py") if path.name != "__init__.py")
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _models(
|
|
163
|
+
*,
|
|
164
|
+
root: Path,
|
|
165
|
+
limits: SchemaDriftSettings,
|
|
166
|
+
) -> Sequence[Path]:
|
|
167
|
+
return sorted(
|
|
168
|
+
path
|
|
169
|
+
for directory in root.glob(limits.models)
|
|
170
|
+
for path in directory.rglob("*.py")
|
|
171
|
+
if path.name != "__init__.py"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
@contextmanager
|
|
176
|
+
def _database() -> Generator[Path]:
|
|
177
|
+
try:
|
|
178
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
179
|
+
yield Path(directory) / DATABASE
|
|
180
|
+
except OSError as unwritable:
|
|
181
|
+
raise DriftError(f"негде держать базу: {unwritable}") from unwritable
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def _migrated(
|
|
185
|
+
cls,
|
|
186
|
+
*,
|
|
187
|
+
root: Path,
|
|
188
|
+
url: str,
|
|
189
|
+
limits: SchemaDriftSettings,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Поднять пустую базу до `head` или сказать, почему не вышло."""
|
|
192
|
+
upgraded = cls._finished(
|
|
193
|
+
command=(*limits.alembic, "upgrade", "head"),
|
|
194
|
+
root=root,
|
|
195
|
+
url=url,
|
|
196
|
+
limits=limits,
|
|
197
|
+
)
|
|
198
|
+
if upgraded.returncode != DONE:
|
|
199
|
+
raise DriftError(
|
|
200
|
+
cls._joined(
|
|
201
|
+
lines=(
|
|
202
|
+
f"`alembic upgrade head` вышел с {upgraded.returncode}",
|
|
203
|
+
cls._said(finished=upgraded),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def _behind(
|
|
210
|
+
cls,
|
|
211
|
+
*,
|
|
212
|
+
root: Path,
|
|
213
|
+
url: str,
|
|
214
|
+
limits: SchemaDriftSettings,
|
|
215
|
+
) -> str | None:
|
|
216
|
+
checked = cls._finished(
|
|
217
|
+
command=(*limits.alembic, "check"),
|
|
218
|
+
root=root,
|
|
219
|
+
url=url,
|
|
220
|
+
limits=limits,
|
|
221
|
+
)
|
|
222
|
+
if checked.returncode == DONE or IN_STEP in checked.stdout:
|
|
223
|
+
return None
|
|
224
|
+
return cls._said(finished=checked)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _finished(
|
|
228
|
+
*,
|
|
229
|
+
command: tuple[str, ...],
|
|
230
|
+
root: Path,
|
|
231
|
+
url: str,
|
|
232
|
+
limits: SchemaDriftSettings,
|
|
233
|
+
) -> subprocess.CompletedProcess[str]:
|
|
234
|
+
"""alembic зовётся тем же именем, что и в окружении вызвавшего.
|
|
235
|
+
|
|
236
|
+
Прогон уже идёт из окружения проекта, и второй `uv run` внутри него
|
|
237
|
+
пересобрал бы это окружение посреди проверки.
|
|
238
|
+
"""
|
|
239
|
+
return subprocess.run( # noqa: S603 — список аргументов собран здесь же
|
|
240
|
+
command,
|
|
241
|
+
cwd=root,
|
|
242
|
+
capture_output=True,
|
|
243
|
+
text=True,
|
|
244
|
+
check=False,
|
|
245
|
+
env=os.environ | {limits.variable: url},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _said(*, finished: subprocess.CompletedProcess[str]) -> str:
|
|
250
|
+
return finished.stderr.strip() or finished.stdout.strip()
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _joined(*, lines: tuple[str, ...]) -> str:
|
|
254
|
+
"""Строки сообщения без пустых: молчаливый alembic не должен добавлять пустую."""
|
|
255
|
+
return "\n".join(line for line in lines if line)
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _violation(
|
|
259
|
+
*,
|
|
260
|
+
root: Path,
|
|
261
|
+
limits: SchemaDriftSettings,
|
|
262
|
+
message: str,
|
|
263
|
+
) -> Violation:
|
|
264
|
+
"""Нарушение указывает на папку ревизий: там же и починка — новой ревизией."""
|
|
265
|
+
return Violation(
|
|
266
|
+
path=root / limits.versions,
|
|
267
|
+
line=1,
|
|
268
|
+
column=1,
|
|
269
|
+
code=CODE,
|
|
270
|
+
message=message,
|
|
271
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Запрос называет колонку атрибутом, а не строкой, и ходит в базу один раз."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
7
|
+
|
|
8
|
+
from py_checks.checks._location import ZonedSettings, zoned
|
|
9
|
+
from py_checks.checks._names import name
|
|
10
|
+
from py_checks.checks.database._marker import MARKER
|
|
11
|
+
from py_checks.core import Scope, Violation, settings_as
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
from py_checks.config import CheckSettings
|
|
17
|
+
from py_checks.core import ParsedFile
|
|
18
|
+
|
|
19
|
+
CODE: Final = "statement-keys"
|
|
20
|
+
|
|
21
|
+
SAID: Final = "называет колонку строкой; маппед-атрибут переезжает вместе с ней"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StatementKeysSettings(ZonedSettings):
|
|
25
|
+
lists: tuple[str, ...] = ("index_elements",)
|
|
26
|
+
mappings: tuple[str, ...] = ("set_",)
|
|
27
|
+
calls: tuple[str, ...] = ("from_select",)
|
|
28
|
+
loops: tuple[str, ...] = ("execute",)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StatementKeys:
|
|
32
|
+
"""Падает, если запрос называет колонку строкой или ходит в базу в цикле.
|
|
33
|
+
|
|
34
|
+
Строки собираются через модели, поэтому список колонок держит pyright:
|
|
35
|
+
пропущенная колонка — пропущенный аргумент, переименованная — неожиданное
|
|
36
|
+
ключевое слово. Строковый ключ в `index_elements=[...]`, `set_={...}` или
|
|
37
|
+
`from_select` открывает дыру заново: он ничего не совпадает во время
|
|
38
|
+
проверки и либо падает, либо молча перестаёт совпадать на той строке, что
|
|
39
|
+
выполняется.
|
|
40
|
+
|
|
41
|
+
`index_elements` и `set_` вместе — это `ON CONFLICT DO UPDATE`, то есть
|
|
42
|
+
inbox и каждый upsert. Строка, переставшая там совпадать, не поднимает
|
|
43
|
+
исключения: конфликт просто не находится, дубль вставляется второй раз, и
|
|
44
|
+
идемпотентность — то, ради чего inbox и существует, — тихо кончается.
|
|
45
|
+
|
|
46
|
+
Второе правило: `execute(...)` внутри цикла — это поход в базу на итерацию,
|
|
47
|
+
форма N+1. Сто ставок — сто поездок туда и обратно там, где хватило бы
|
|
48
|
+
одного запроса по всему множеству. Иногда цикл честен — три константы, и
|
|
49
|
+
одного запроса, говорящего то же самое, не существует, — поэтому правило
|
|
50
|
+
снимается пометкой на строке цикла или самого вызова, а не отсутствует.
|
|
51
|
+
|
|
52
|
+
Настройки: `zones`, `lists`, `mappings`, `calls`, `loops`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
code: ClassVar[str] = CODE
|
|
56
|
+
Settings: ClassVar[type[CheckSettings]] = StatementKeysSettings
|
|
57
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
58
|
+
marker: ClassVar[str] = MARKER
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def run(
|
|
62
|
+
cls,
|
|
63
|
+
*,
|
|
64
|
+
file: ParsedFile,
|
|
65
|
+
settings: CheckSettings,
|
|
66
|
+
) -> Iterator[Violation]:
|
|
67
|
+
limits = settings_as(
|
|
68
|
+
settings=settings,
|
|
69
|
+
model=StatementKeysSettings,
|
|
70
|
+
code=CODE,
|
|
71
|
+
)
|
|
72
|
+
where = zoned(
|
|
73
|
+
file=file,
|
|
74
|
+
zones=limits.zones,
|
|
75
|
+
)
|
|
76
|
+
if where is None:
|
|
77
|
+
return
|
|
78
|
+
for node in ast.walk(file.tree):
|
|
79
|
+
if isinstance(node, ast.Call):
|
|
80
|
+
yield from cls._keys(
|
|
81
|
+
file=file,
|
|
82
|
+
node=node,
|
|
83
|
+
limits=limits,
|
|
84
|
+
)
|
|
85
|
+
if isinstance(node, ast.For | ast.AsyncFor | ast.While):
|
|
86
|
+
yield from cls._loop(
|
|
87
|
+
file=file,
|
|
88
|
+
node=node,
|
|
89
|
+
limits=limits,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def _keys(
|
|
94
|
+
cls,
|
|
95
|
+
*,
|
|
96
|
+
file: ParsedFile,
|
|
97
|
+
node: ast.Call,
|
|
98
|
+
limits: StatementKeysSettings,
|
|
99
|
+
) -> Iterator[Violation]:
|
|
100
|
+
for keyword in node.keywords:
|
|
101
|
+
if keyword.arg in limits.lists:
|
|
102
|
+
yield from cls._strings(
|
|
103
|
+
file=file,
|
|
104
|
+
written=keyword.arg,
|
|
105
|
+
nodes=cls._elements(node=keyword.value),
|
|
106
|
+
)
|
|
107
|
+
if keyword.arg in limits.mappings:
|
|
108
|
+
yield from cls._strings(
|
|
109
|
+
file=file,
|
|
110
|
+
written=keyword.arg,
|
|
111
|
+
nodes=cls._keyed(node=keyword.value),
|
|
112
|
+
)
|
|
113
|
+
if name(node=node.func) in limits.calls and node.args:
|
|
114
|
+
yield from cls._strings(
|
|
115
|
+
file=file,
|
|
116
|
+
written=name(node=node.func),
|
|
117
|
+
nodes=cls._elements(node=node.args[0]),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _loop(
|
|
122
|
+
*,
|
|
123
|
+
file: ParsedFile,
|
|
124
|
+
node: ast.For | ast.AsyncFor | ast.While,
|
|
125
|
+
limits: StatementKeysSettings,
|
|
126
|
+
) -> Iterator[Violation]:
|
|
127
|
+
"""Поход в базу на каждой итерации."""
|
|
128
|
+
for child in ast.walk(node):
|
|
129
|
+
if not isinstance(child, ast.Call) or name(node=child.func) not in limits.loops:
|
|
130
|
+
continue
|
|
131
|
+
yield Violation.from_node(
|
|
132
|
+
node=node,
|
|
133
|
+
path=file.path,
|
|
134
|
+
code=CODE,
|
|
135
|
+
# Пометка снимается со строки цикла или с любой строки вызова:
|
|
136
|
+
# причина принадлежит тому месту, где автор её и пишет.
|
|
137
|
+
end_line=child.end_lineno or child.lineno,
|
|
138
|
+
message=(
|
|
139
|
+
f"{name(node=child.func)}() внутри цикла — поход в базу на итерацию; "
|
|
140
|
+
f"один запрос по всему множеству говорит то же самое"
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _strings(
|
|
146
|
+
*,
|
|
147
|
+
file: ParsedFile,
|
|
148
|
+
written: str,
|
|
149
|
+
nodes: Iterator[ast.expr],
|
|
150
|
+
) -> Iterator[Violation]:
|
|
151
|
+
for node in nodes:
|
|
152
|
+
if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
|
|
153
|
+
continue
|
|
154
|
+
yield Violation.from_node(
|
|
155
|
+
node=node,
|
|
156
|
+
path=file.path,
|
|
157
|
+
code=CODE,
|
|
158
|
+
message=f"{written}: {SAID}",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _elements(*, node: ast.expr) -> Iterator[ast.expr]:
|
|
163
|
+
if isinstance(node, ast.List | ast.Tuple | ast.Set):
|
|
164
|
+
yield from node.elts
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _keyed(*, node: ast.expr) -> Iterator[ast.expr]:
|
|
168
|
+
if isinstance(node, ast.Dict):
|
|
169
|
+
yield from (key for key in node.keys if key is not None)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Эффекты: что код берёт у мира и что он о мире говорит.
|
|
2
|
+
|
|
3
|
+
Часы, случайность и новый идентификатор берутся портом, а не глобальной
|
|
4
|
+
функцией: иначе один и тот же вход даёт разный выход, и тест либо замораживает
|
|
5
|
+
мир мокой, либо не утверждает ничего. Строка лога называет событие членом
|
|
6
|
+
перечисления: это имя читает не человек, а счётчик и алерт.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.effects._determinism import Determinism, DeterminismSettings
|
|
10
|
+
from py_checks.checks.effects._log_events import LogEvents, LogEventsSettings
|
|
11
|
+
from py_checks.checks.effects._marker import MARKER
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MARKER",
|
|
15
|
+
"Determinism",
|
|
16
|
+
"DeterminismSettings",
|
|
17
|
+
"LogEvents",
|
|
18
|
+
"LogEventsSettings",
|
|
19
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Часы, случайность и новый идентификатор берутся портом, а не глобально."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
7
|
+
|
|
8
|
+
from py_checks.checks._location import ZonedSettings, zoned
|
|
9
|
+
from py_checks.checks._names import matches
|
|
10
|
+
from py_checks.checks.effects._marker import MARKER
|
|
11
|
+
from py_checks.core import Scope, Violation, settings_as
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
from py_checks.config import CheckSettings
|
|
17
|
+
from py_checks.core import ParsedFile
|
|
18
|
+
|
|
19
|
+
CODE: Final = "determinism"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeterminismSettings(ZonedSettings):
|
|
23
|
+
sources: dict[str, str] = {} # noqa: RUF012 — pydantic копирует значение по умолчанию
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Determinism:
|
|
27
|
+
"""Падает, если код сам читает часы, случайность или новый идентификатор.
|
|
28
|
+
|
|
29
|
+
`datetime.now()`, `uuid4()` и `random.random()` делают сценарий
|
|
30
|
+
непроверяемым: один и тот же вход даёт разный выход, и тест либо
|
|
31
|
+
замораживает мир мокой, либо не утверждает ничего. Бизнес-код берёт их
|
|
32
|
+
зависимостью — `self._clock.now()`, идентификатор, выданный на краю, — и
|
|
33
|
+
вызов через порт правило не трогает: оно судит глобальные источники.
|
|
34
|
+
|
|
35
|
+
Репозитории закрыты той же зоной, и там источник пишется на SQL:
|
|
36
|
+
`func.gen_random_uuid()` внутри INSERT — то же решение этажом ниже, где его
|
|
37
|
+
ещё хуже видно. В тесте о нём нечего утверждать, слой хранения становится
|
|
38
|
+
автором идентификатора, о котором ему ничего не передавали, а среди uuid7
|
|
39
|
+
появляется uuid4 — случайный там, где все остальные упорядочены, и
|
|
40
|
+
упорядоченность — то, ради чего индекс по ним чего-то стоит.
|
|
41
|
+
|
|
42
|
+
Имя сверяется с хвостом: `datetime.now` подходит и записи
|
|
43
|
+
`datetime.datetime.now`, а `random.*` — любому вызову модуля целиком.
|
|
44
|
+
|
|
45
|
+
Настройки: `zones`, `sources`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
code: ClassVar[str] = CODE
|
|
49
|
+
Settings: ClassVar[type[CheckSettings]] = DeterminismSettings
|
|
50
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
51
|
+
marker: ClassVar[str] = MARKER
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def run(
|
|
55
|
+
cls,
|
|
56
|
+
*,
|
|
57
|
+
file: ParsedFile,
|
|
58
|
+
settings: CheckSettings,
|
|
59
|
+
) -> Iterator[Violation]:
|
|
60
|
+
limits = settings_as(
|
|
61
|
+
settings=settings,
|
|
62
|
+
model=DeterminismSettings,
|
|
63
|
+
code=CODE,
|
|
64
|
+
)
|
|
65
|
+
where = zoned(
|
|
66
|
+
file=file,
|
|
67
|
+
zones=limits.zones,
|
|
68
|
+
)
|
|
69
|
+
if where is None or not limits.sources:
|
|
70
|
+
return
|
|
71
|
+
for node in ast.walk(file.tree):
|
|
72
|
+
if not isinstance(node, ast.Call):
|
|
73
|
+
continue
|
|
74
|
+
called = ast.unparse(node.func)
|
|
75
|
+
said = cls._source(
|
|
76
|
+
called=called,
|
|
77
|
+
sources=limits.sources,
|
|
78
|
+
)
|
|
79
|
+
if said is None:
|
|
80
|
+
continue
|
|
81
|
+
yield Violation.from_node(
|
|
82
|
+
node=node,
|
|
83
|
+
path=file.path,
|
|
84
|
+
code=CODE,
|
|
85
|
+
message=f"{called}() не детерминирован; {said}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _source(
|
|
90
|
+
*,
|
|
91
|
+
called: str,
|
|
92
|
+
sources: dict[str, str],
|
|
93
|
+
) -> str | None:
|
|
94
|
+
"""Причина, по которой такой вызов запрещён, если он в таблице."""
|
|
95
|
+
return next(
|
|
96
|
+
(
|
|
97
|
+
said
|
|
98
|
+
for pattern, said in sources.items()
|
|
99
|
+
if matches(
|
|
100
|
+
called=called,
|
|
101
|
+
pattern=pattern,
|
|
102
|
+
)
|
|
103
|
+
),
|
|
104
|
+
None,
|
|
105
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Строка лога называет событие членом перечисления, а не фразой."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, Final, Self
|
|
8
|
+
|
|
9
|
+
from pydantic import model_validator
|
|
10
|
+
|
|
11
|
+
from py_checks.checks.effects._marker import MARKER
|
|
12
|
+
from py_checks.config import CheckSettings
|
|
13
|
+
from py_checks.core import Scope, Violation, settings_as
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
from py_checks.core import ParsedFile
|
|
19
|
+
|
|
20
|
+
CODE: Final = "log-events"
|
|
21
|
+
|
|
22
|
+
LEVELS: Final = ("debug", "info", "warning", "warn", "error", "exception", "critical")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LogEventsSettings(CheckSettings):
|
|
26
|
+
enum: str = "LogEvent"
|
|
27
|
+
levels: tuple[str, ...] = LEVELS
|
|
28
|
+
receiver: str = r"(^|_)log(ger)?$"
|
|
29
|
+
|
|
30
|
+
@model_validator(mode="after")
|
|
31
|
+
def _readable(self) -> Self:
|
|
32
|
+
try:
|
|
33
|
+
re.compile(self.receiver)
|
|
34
|
+
except re.error as broken:
|
|
35
|
+
message = f"receiver — регулярное выражение: {broken}"
|
|
36
|
+
raise ValueError(message) from broken
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LogEvents:
|
|
41
|
+
"""Падает, если событие в логе названо чем-то кроме члена перечисления.
|
|
42
|
+
|
|
43
|
+
Имя события читает не человек: процессор в цепочке structlog превращает
|
|
44
|
+
`consumer.message.handled` в счётчик, а алерт джойнится по этой строке.
|
|
45
|
+
Литерал, написанный на месте вызова, определения не имеет, и код, который
|
|
46
|
+
имя ИЗДАЁТ, ничем не связан с кодом, который его ловит: опечатка не ломает
|
|
47
|
+
ни одного теста, она просто перестаёт совпадать, и метрика тихо читает
|
|
48
|
+
ноль.
|
|
49
|
+
|
|
50
|
+
Правило читает ФОРМУ `LogEvent.SOMETHING` и члена не ищет: имени, которого
|
|
51
|
+
в перечислении нет, pyright откажет, а без него это `AttributeError` на
|
|
52
|
+
первом же запуске — собирать члены значило бы ловить пойманное дважды.
|
|
53
|
+
|
|
54
|
+
Чужой логгер — библиотечный или тот, чьим словарём владеет другой проект, —
|
|
55
|
+
снимается пометкой: `# effect-ok: log-events: не наш логгер`.
|
|
56
|
+
|
|
57
|
+
Настройки: `enum`, `levels`, `receiver`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
code: ClassVar[str] = CODE
|
|
61
|
+
Settings: ClassVar[type[CheckSettings]] = LogEventsSettings
|
|
62
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
63
|
+
marker: ClassVar[str] = MARKER
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def run(
|
|
67
|
+
cls,
|
|
68
|
+
*,
|
|
69
|
+
file: ParsedFile,
|
|
70
|
+
settings: CheckSettings,
|
|
71
|
+
) -> Iterator[Violation]:
|
|
72
|
+
limits = settings_as(
|
|
73
|
+
settings=settings,
|
|
74
|
+
model=LogEventsSettings,
|
|
75
|
+
code=CODE,
|
|
76
|
+
)
|
|
77
|
+
receiver = re.compile(limits.receiver)
|
|
78
|
+
for node in ast.walk(file.tree):
|
|
79
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
80
|
+
continue
|
|
81
|
+
if node.func.attr not in limits.levels or not node.args:
|
|
82
|
+
continue
|
|
83
|
+
if not receiver.search(cls._receiver(node=node.func.value)):
|
|
84
|
+
continue
|
|
85
|
+
if cls._member(
|
|
86
|
+
node=node.args[0],
|
|
87
|
+
enum=limits.enum,
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
yield Violation.from_node(
|
|
91
|
+
node=node,
|
|
92
|
+
path=file.path,
|
|
93
|
+
code=CODE,
|
|
94
|
+
message=(
|
|
95
|
+
f"{ast.unparse(node.func)}({ast.unparse(node.args[0])}...): "
|
|
96
|
+
f"имя события — член {limits.enum}, а не фраза; по нему джойнятся "
|
|
97
|
+
f"счётчик и алерт"
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _receiver(*, node: ast.expr) -> str:
|
|
103
|
+
"""Чей это метод: `logger`, `log`, `self._logger`, `_LOGGER`."""
|
|
104
|
+
match node:
|
|
105
|
+
case ast.Name(id=name) | ast.Attribute(attr=name):
|
|
106
|
+
return name
|
|
107
|
+
case _:
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _member(
|
|
112
|
+
*,
|
|
113
|
+
node: ast.expr,
|
|
114
|
+
enum: str,
|
|
115
|
+
) -> bool:
|
|
116
|
+
return (
|
|
117
|
+
isinstance(node, ast.Attribute)
|
|
118
|
+
and isinstance(node.value, ast.Name)
|
|
119
|
+
and node.value.id == enum
|
|
120
|
+
)
|