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,236 @@
|
|
|
1
|
+
"""ORM-модель не выходит за пределы слоя, который её понимает."""
|
|
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 place
|
|
9
|
+
from py_checks.checks._names import name, walked
|
|
10
|
+
from py_checks.checks.database._marker import MARKER
|
|
11
|
+
from py_checks.config import CheckSettings
|
|
12
|
+
from py_checks.core import Scope, Violation, settings_as
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
|
|
17
|
+
from py_checks.checks._location import Place
|
|
18
|
+
from py_checks.core import ParsedFile
|
|
19
|
+
|
|
20
|
+
CODE: Final = "model-boundary"
|
|
21
|
+
|
|
22
|
+
PRIVATE: Final = "_"
|
|
23
|
+
DOT: Final = "."
|
|
24
|
+
SEPARATOR: Final = "/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ModelBoundarySettings(CheckSettings):
|
|
28
|
+
base: str = "Base"
|
|
29
|
+
declared: tuple[str, ...] = ()
|
|
30
|
+
built: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModelBoundary:
|
|
34
|
+
"""Падает, если ORM-модель объявлена, собрана или отдана не там.
|
|
35
|
+
|
|
36
|
+
Модель — это описание таблицы, и три правила держат её описанием.
|
|
37
|
+
|
|
38
|
+
Объявляется она там, где объявляются модели: где-то ещё это таблица,
|
|
39
|
+
которую никто не ждёт по этому адресу, а autogenerate alembic видит только
|
|
40
|
+
те модели, до которых дотянулись импорты их пакета.
|
|
41
|
+
|
|
42
|
+
Собирается она только в репозиториях: собрать модель — значит записать
|
|
43
|
+
строку, и модель, собранная в другом месте, либо не делает ничего — никто
|
|
44
|
+
снаружи не держит сессию, чтобы её добавить, — либо это строка, записанная
|
|
45
|
+
слоем, у которого нет транзакции, чтобы её записать.
|
|
46
|
+
|
|
47
|
+
Публичный метод репозитория её не возвращает. Модель уносит с собой
|
|
48
|
+
сессию: обращение к атрибуту после закрытия транзакции либо падает, либо
|
|
49
|
+
лезет в базу из слоя, которому туда нельзя, а через связи оттуда достижима
|
|
50
|
+
половина схемы, и запрос уходит из кода, который ни о каком соединении не
|
|
51
|
+
просил. Репозитории возвращают DTO, идентификаторы, количества — всё, с чем
|
|
52
|
+
слой базы уже закончил.
|
|
53
|
+
|
|
54
|
+
Модель узнаётся двумя способами, и оба видны в одном файле: объявление —
|
|
55
|
+
по базе `Base`, использование — по импорту из пакета моделей. Имя ни при
|
|
56
|
+
чём: `SettingsModel` в настройках и `DeviceModel` в домене — не таблицы.
|
|
57
|
+
|
|
58
|
+
Настройки: `base`, `declared`, `built`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
code: ClassVar[str] = CODE
|
|
62
|
+
Settings: ClassVar[type[CheckSettings]] = ModelBoundarySettings
|
|
63
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
64
|
+
marker: ClassVar[str] = MARKER
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def run(
|
|
68
|
+
cls,
|
|
69
|
+
*,
|
|
70
|
+
file: ParsedFile,
|
|
71
|
+
settings: CheckSettings,
|
|
72
|
+
) -> Iterator[Violation]:
|
|
73
|
+
limits = settings_as(
|
|
74
|
+
settings=settings,
|
|
75
|
+
model=ModelBoundarySettings,
|
|
76
|
+
code=CODE,
|
|
77
|
+
)
|
|
78
|
+
where = place(file=file)
|
|
79
|
+
if where is None or not limits.declared:
|
|
80
|
+
return
|
|
81
|
+
models = cls._models(
|
|
82
|
+
file=file,
|
|
83
|
+
limits=limits,
|
|
84
|
+
)
|
|
85
|
+
found = [
|
|
86
|
+
*cls._declared(
|
|
87
|
+
file=file,
|
|
88
|
+
where=where,
|
|
89
|
+
limits=limits,
|
|
90
|
+
),
|
|
91
|
+
*cls._built(
|
|
92
|
+
file=file,
|
|
93
|
+
where=where,
|
|
94
|
+
limits=limits,
|
|
95
|
+
models=models,
|
|
96
|
+
),
|
|
97
|
+
*cls._returned(
|
|
98
|
+
file=file,
|
|
99
|
+
where=where,
|
|
100
|
+
limits=limits,
|
|
101
|
+
models=models,
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
yield from sorted(found, key=lambda violation: (violation.line, violation.column))
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def _declared(
|
|
108
|
+
cls,
|
|
109
|
+
*,
|
|
110
|
+
file: ParsedFile,
|
|
111
|
+
where: Place,
|
|
112
|
+
limits: ModelBoundarySettings,
|
|
113
|
+
) -> Iterator[Violation]:
|
|
114
|
+
"""Модель, объявленная не в доме моделей."""
|
|
115
|
+
if where.anywhere(
|
|
116
|
+
zones=limits.declared,
|
|
117
|
+
):
|
|
118
|
+
return
|
|
119
|
+
for node in ast.walk(file.tree):
|
|
120
|
+
if not isinstance(node, ast.ClassDef):
|
|
121
|
+
continue
|
|
122
|
+
if any(name(node=base) == limits.base for base in node.bases):
|
|
123
|
+
yield Violation.from_node(
|
|
124
|
+
node=node,
|
|
125
|
+
path=file.path,
|
|
126
|
+
code=CODE,
|
|
127
|
+
message=(
|
|
128
|
+
f"{node.name} объявлена вне {', '.join(limits.declared)}; "
|
|
129
|
+
f"autogenerate видит только модели их пакета"
|
|
130
|
+
),
|
|
131
|
+
end_line=node.body[0].lineno,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def _built(
|
|
136
|
+
cls,
|
|
137
|
+
*,
|
|
138
|
+
file: ParsedFile,
|
|
139
|
+
where: Place,
|
|
140
|
+
limits: ModelBoundarySettings,
|
|
141
|
+
models: frozenset[str],
|
|
142
|
+
) -> Iterator[Violation]:
|
|
143
|
+
"""Модель, собранная там, где нечем записать строку."""
|
|
144
|
+
if where.anywhere(
|
|
145
|
+
zones=limits.built + limits.declared,
|
|
146
|
+
):
|
|
147
|
+
return
|
|
148
|
+
for node in ast.walk(file.tree):
|
|
149
|
+
if not isinstance(node, ast.Call):
|
|
150
|
+
continue
|
|
151
|
+
built = name(node=node.func)
|
|
152
|
+
if built in models:
|
|
153
|
+
yield Violation.from_node(
|
|
154
|
+
node=node,
|
|
155
|
+
path=file.path,
|
|
156
|
+
code=CODE,
|
|
157
|
+
message=(
|
|
158
|
+
f"{built}(...) собирается вне {', '.join(limits.built)}; "
|
|
159
|
+
f"собрать модель — значит записать строку"
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def _returned(
|
|
165
|
+
cls,
|
|
166
|
+
*,
|
|
167
|
+
file: ParsedFile,
|
|
168
|
+
where: Place,
|
|
169
|
+
limits: ModelBoundarySettings,
|
|
170
|
+
models: frozenset[str],
|
|
171
|
+
) -> Iterator[Violation]:
|
|
172
|
+
"""Модель, отданная наружу публичным методом репозитория."""
|
|
173
|
+
if not where.anywhere(
|
|
174
|
+
zones=limits.built,
|
|
175
|
+
):
|
|
176
|
+
return
|
|
177
|
+
for node in ast.walk(file.tree):
|
|
178
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
179
|
+
continue
|
|
180
|
+
if node.name.startswith(PRIVATE) or node.returns is None:
|
|
181
|
+
continue
|
|
182
|
+
returned = cls._named(
|
|
183
|
+
node=node.returns,
|
|
184
|
+
models=models,
|
|
185
|
+
)
|
|
186
|
+
if returned is None:
|
|
187
|
+
continue
|
|
188
|
+
yield Violation.from_node(
|
|
189
|
+
node=node,
|
|
190
|
+
path=file.path,
|
|
191
|
+
code=CODE,
|
|
192
|
+
message=(
|
|
193
|
+
f"{node.name} возвращает {returned}; модель уносит с собой сессию — "
|
|
194
|
+
f"отдавай DTO, идентификатор, количество"
|
|
195
|
+
),
|
|
196
|
+
end_line=node.body[0].lineno,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _models(
|
|
201
|
+
cls,
|
|
202
|
+
*,
|
|
203
|
+
file: ParsedFile,
|
|
204
|
+
limits: ModelBoundarySettings,
|
|
205
|
+
) -> frozenset[str]:
|
|
206
|
+
"""Имена, пришедшие импортом из пакета моделей."""
|
|
207
|
+
names: set[str] = set()
|
|
208
|
+
for node in ast.walk(file.tree):
|
|
209
|
+
if not isinstance(node, ast.ImportFrom) or node.module is None:
|
|
210
|
+
continue
|
|
211
|
+
if cls._home(
|
|
212
|
+
module=node.module,
|
|
213
|
+
zones=limits.declared,
|
|
214
|
+
):
|
|
215
|
+
names.update(alias.asname or alias.name for alias in node.names)
|
|
216
|
+
return frozenset(names)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _home(
|
|
220
|
+
*,
|
|
221
|
+
module: str,
|
|
222
|
+
zones: tuple[str, ...],
|
|
223
|
+
) -> bool:
|
|
224
|
+
path = SEPARATOR.join(module.split(DOT))
|
|
225
|
+
return any(
|
|
226
|
+
f"{SEPARATOR}{zone}{SEPARATOR}" in f"{SEPARATOR}{path}{SEPARATOR}" for zone in zones
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _named(
|
|
231
|
+
*,
|
|
232
|
+
node: ast.expr,
|
|
233
|
+
models: frozenset[str],
|
|
234
|
+
) -> str | None:
|
|
235
|
+
"""Имя модели, названное где-нибудь внутри аннотации."""
|
|
236
|
+
return next((found for found in walked(node=node) if found in models), None)
|
|
@@ -0,0 +1,241 @@
|
|
|
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 = "model-columns"
|
|
20
|
+
|
|
21
|
+
MAPPED: Final = "Mapped"
|
|
22
|
+
AWARE: Final = "timezone"
|
|
23
|
+
NULLABLE: Final = "nullable"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ModelColumnsSettings(ZonedSettings):
|
|
27
|
+
factories: tuple[str, ...] = ("mapped_column", "Column")
|
|
28
|
+
types: dict[str, str] = {}
|
|
29
|
+
homes: dict[str, str] = {}
|
|
30
|
+
defaults: tuple[str, ...] = ()
|
|
31
|
+
unruled: tuple[str, ...] = ()
|
|
32
|
+
aware: tuple[str, ...] = ()
|
|
33
|
+
nullable: bool = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ModelColumns:
|
|
37
|
+
"""Падает, если колонка собрана не из того материала.
|
|
38
|
+
|
|
39
|
+
Пять правил на одну таблицу настроек.
|
|
40
|
+
|
|
41
|
+
`types` — материал, которому в колонке не место, и что писать вместо.
|
|
42
|
+
Голый `Enum` — нативный тип Postgres: каждый новый член требует `ALTER
|
|
43
|
+
TYPE`, а словари здесь чужие и расти будут. `Float` не держит цену точно, а
|
|
44
|
+
колонка — это состояние, и ошибка округления копится с каждой записью.
|
|
45
|
+
Голый `JSONB` — форма, которую никто не объявил: что положил писатель, то и
|
|
46
|
+
получит каждый читатель, а разбор, поймавший бы пропущенный ключ, случается
|
|
47
|
+
в каждом отдельно или нигде.
|
|
48
|
+
|
|
49
|
+
`homes` — модуль, которому этот материал называть можно: там живёт обёртка
|
|
50
|
+
над ним, и правило его не касается.
|
|
51
|
+
|
|
52
|
+
`defaults` — все способы, которыми колонка заполняет себя сама. Значение по
|
|
53
|
+
умолчанию — это значение, которого никто не писал: писатель пропустил
|
|
54
|
+
колонку, строка всё равно получила число, и пропуск, который на пропущенном
|
|
55
|
+
аргументе конструктора поймал бы проверяльщик типов, превращается в
|
|
56
|
+
правдоподобную строку.
|
|
57
|
+
|
|
58
|
+
`unruled` — встроенные типы в `Mapped[...]`. Такая колонка говорит, какой
|
|
59
|
+
у значения вид, и ничего — какие значения допустимы, так что правило
|
|
60
|
+
приходится помнить каждому писателю.
|
|
61
|
+
|
|
62
|
+
`aware` — типы времени, которым нужен `timezone=True`: без него колонка
|
|
63
|
+
хранит наивную метку, те самые настенные часы писателя, без подписи.
|
|
64
|
+
|
|
65
|
+
`nullable` — аннотация и ключевое слово обязаны совпадать. SQLAlchemy
|
|
66
|
+
разрешает им разойтись, и тогда аннотация лжёт: pyright рассуждает по ней,
|
|
67
|
+
база держит ключевое слово, и одно из двух неверно на каждой строке.
|
|
68
|
+
|
|
69
|
+
Настройки: `zones`, `factories`, `types`, `homes`, `defaults`, `unruled`,
|
|
70
|
+
`aware`, `nullable`.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
code: ClassVar[str] = CODE
|
|
74
|
+
Settings: ClassVar[type[CheckSettings]] = ModelColumnsSettings
|
|
75
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
76
|
+
marker: ClassVar[str] = MARKER
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def run(
|
|
80
|
+
cls,
|
|
81
|
+
*,
|
|
82
|
+
file: ParsedFile,
|
|
83
|
+
settings: CheckSettings,
|
|
84
|
+
) -> Iterator[Violation]:
|
|
85
|
+
limits = settings_as(
|
|
86
|
+
settings=settings,
|
|
87
|
+
model=ModelColumnsSettings,
|
|
88
|
+
code=CODE,
|
|
89
|
+
)
|
|
90
|
+
where = zoned(
|
|
91
|
+
file=file,
|
|
92
|
+
zones=limits.zones,
|
|
93
|
+
)
|
|
94
|
+
if where is None:
|
|
95
|
+
return
|
|
96
|
+
for node in ast.walk(file.tree):
|
|
97
|
+
if isinstance(node, ast.Call):
|
|
98
|
+
yield from cls._material(
|
|
99
|
+
file=file,
|
|
100
|
+
node=node,
|
|
101
|
+
limits=limits,
|
|
102
|
+
)
|
|
103
|
+
if isinstance(node, ast.AnnAssign):
|
|
104
|
+
yield from cls._annotation(
|
|
105
|
+
file=file,
|
|
106
|
+
node=node,
|
|
107
|
+
limits=limits,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def _material(
|
|
112
|
+
cls,
|
|
113
|
+
*,
|
|
114
|
+
file: ParsedFile,
|
|
115
|
+
node: ast.Call,
|
|
116
|
+
limits: ModelColumnsSettings,
|
|
117
|
+
) -> Iterator[Violation]:
|
|
118
|
+
"""Материал и то, чем колонка заполняет себя сама."""
|
|
119
|
+
written = name(node=node.func)
|
|
120
|
+
if written in limits.types and limits.homes.get(written) != file.path.stem:
|
|
121
|
+
yield cls._says(
|
|
122
|
+
file=file,
|
|
123
|
+
node=node,
|
|
124
|
+
message=limits.types[written],
|
|
125
|
+
)
|
|
126
|
+
if written in limits.aware and not cls._said(
|
|
127
|
+
node=node,
|
|
128
|
+
named=AWARE,
|
|
129
|
+
):
|
|
130
|
+
yield cls._says(
|
|
131
|
+
file=file,
|
|
132
|
+
node=node,
|
|
133
|
+
message=(
|
|
134
|
+
f"{written} без timezone=True хранит наивную метку времени; скажи timezone=True"
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
if written not in limits.factories:
|
|
138
|
+
return
|
|
139
|
+
for keyword in node.keywords:
|
|
140
|
+
if keyword.arg in limits.defaults:
|
|
141
|
+
yield cls._says(
|
|
142
|
+
file=file,
|
|
143
|
+
node=node,
|
|
144
|
+
message=(
|
|
145
|
+
f"{keyword.arg}= заполняет колонку за писателя, который её не написал; "
|
|
146
|
+
f"передай значение в запросе"
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def _annotation(
|
|
152
|
+
cls,
|
|
153
|
+
*,
|
|
154
|
+
file: ParsedFile,
|
|
155
|
+
node: ast.AnnAssign,
|
|
156
|
+
limits: ModelColumnsSettings,
|
|
157
|
+
) -> Iterator[Violation]:
|
|
158
|
+
"""Аннотация колонки: встроенный тип и согласие с `nullable=`."""
|
|
159
|
+
inner = cls._mapped(node=node.annotation)
|
|
160
|
+
if inner is None:
|
|
161
|
+
return
|
|
162
|
+
written, optional = inner
|
|
163
|
+
if written in limits.unruled:
|
|
164
|
+
yield Violation.from_node(
|
|
165
|
+
node=node,
|
|
166
|
+
path=file.path,
|
|
167
|
+
code=CODE,
|
|
168
|
+
message=(f"{written} говорит вид, а не правило; возьми примитив с его границей"),
|
|
169
|
+
)
|
|
170
|
+
if not limits.nullable:
|
|
171
|
+
return
|
|
172
|
+
said = cls._nullable(
|
|
173
|
+
node=node.value,
|
|
174
|
+
limits=limits,
|
|
175
|
+
)
|
|
176
|
+
if said is not None and said != optional:
|
|
177
|
+
yield Violation.from_node(
|
|
178
|
+
node=node,
|
|
179
|
+
path=file.path,
|
|
180
|
+
code=CODE,
|
|
181
|
+
message=(
|
|
182
|
+
"аннотация и nullable= расходятся; во время работы побеждает то, "
|
|
183
|
+
"чего проверяльщик типов не видит"
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _nullable(
|
|
189
|
+
*,
|
|
190
|
+
node: ast.expr | None,
|
|
191
|
+
limits: ModelColumnsSettings,
|
|
192
|
+
) -> bool | None:
|
|
193
|
+
"""Что сказано в `nullable=`, если вообще сказано."""
|
|
194
|
+
if not isinstance(node, ast.Call) or name(node=node.func) not in limits.factories:
|
|
195
|
+
return None
|
|
196
|
+
for keyword in node.keywords:
|
|
197
|
+
if keyword.arg == NULLABLE and isinstance(keyword.value, ast.Constant):
|
|
198
|
+
return bool(keyword.value.value)
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def _mapped(cls, *, node: ast.expr) -> tuple[str, bool] | None:
|
|
203
|
+
"""Имя внутри `Mapped[...]` и то, допускает ли оно `None`."""
|
|
204
|
+
if not isinstance(node, ast.Subscript) or name(node=node.value) != MAPPED:
|
|
205
|
+
return None
|
|
206
|
+
match node.slice:
|
|
207
|
+
case ast.Name(id=inside) | ast.Attribute(attr=inside):
|
|
208
|
+
return inside, False
|
|
209
|
+
case ast.BinOp(left=ast.Name(id=inside), op=ast.BitOr(), right=right):
|
|
210
|
+
return inside, cls._none(node=right)
|
|
211
|
+
case _:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _none(*, node: ast.expr) -> bool:
|
|
216
|
+
return isinstance(node, ast.Constant) and node.value is None
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _said(
|
|
220
|
+
*,
|
|
221
|
+
node: ast.Call,
|
|
222
|
+
named: str,
|
|
223
|
+
) -> bool:
|
|
224
|
+
return any(
|
|
225
|
+
keyword.arg == named and keyword.value != ast.Constant(value=False)
|
|
226
|
+
for keyword in node.keywords
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _says(
|
|
231
|
+
*,
|
|
232
|
+
file: ParsedFile,
|
|
233
|
+
node: ast.Call,
|
|
234
|
+
message: str,
|
|
235
|
+
) -> Violation:
|
|
236
|
+
return Violation.from_node(
|
|
237
|
+
node=node,
|
|
238
|
+
path=file.path,
|
|
239
|
+
code=CODE,
|
|
240
|
+
message=message,
|
|
241
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""SQL, написанный строкой там, где хватило бы выражения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
7
|
+
|
|
8
|
+
from py_checks.checks._names import name
|
|
9
|
+
from py_checks.checks.database._marker import MARKER
|
|
10
|
+
from py_checks.config import CheckSettings
|
|
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.core import ParsedFile
|
|
17
|
+
|
|
18
|
+
CODE: Final = "raw-sql"
|
|
19
|
+
|
|
20
|
+
# Вызов — и то, что пишут вместо строки. Каждый берёт SQL первым позиционным.
|
|
21
|
+
CALLS: Final[dict[str, str]] = {
|
|
22
|
+
"CheckConstraint": "выражение по колонке, например and_(margin >= NOTHING, margin < WHOLE)",
|
|
23
|
+
"text": "select()/insert(), собранный из атрибутов модели",
|
|
24
|
+
"literal_column": "сама маппед-колонка",
|
|
25
|
+
"column": "сама маппед-колонка",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RawSqlSettings(CheckSettings):
|
|
30
|
+
calls: dict[str, str] = CALLS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RawSql:
|
|
34
|
+
"""Падает, если SQL написан строкой там, где хватило бы выражения.
|
|
35
|
+
|
|
36
|
+
CHECK, записанный как `"margin >= 0 AND margin < 1"`, — это второе
|
|
37
|
+
определение правила, которое домен уже сформулировал, на языке, который в
|
|
38
|
+
репозитории никто не проверяет. Переименуй колонку — строка по-прежнему
|
|
39
|
+
компилируется; сдвинь границу — строка по-прежнему называет старое число, и
|
|
40
|
+
расхождение всплывает нарушением ограничения на строке, которая была верна
|
|
41
|
+
по всем правилам, известным коду.
|
|
42
|
+
|
|
43
|
+
Записанное выражением — `CheckConstraint(and_(margin >= NOTHING, margin <
|
|
44
|
+
WHOLE))` — оно состоит из атрибута, который pyright и так проверяет, и
|
|
45
|
+
констант, которыми сущность отказывает, так что разъехаться им негде.
|
|
46
|
+
|
|
47
|
+
То же про `text()`, `literal_column()` и `column()`: запрос, собранный
|
|
48
|
+
строкой, — запрос, который никто не проверяет, а собранный из значения,
|
|
49
|
+
пришедшего откуда угодно, — инъекция, ждущая забывчивого вызывающего.
|
|
50
|
+
|
|
51
|
+
Где выражения честно нет — `SELECT 1` для пробы живости, чтение служебной
|
|
52
|
+
таблицы alembic, — на строке пишут причину:
|
|
53
|
+
`# db-ok: raw-sql: проба живости, формы ORM нет`. Пометка снимается с любой
|
|
54
|
+
строки самого вызова и не достаёт дальше него: пометка на объемлющей
|
|
55
|
+
инструкции извиняет её, а не SQL внутри.
|
|
56
|
+
|
|
57
|
+
Миграции правилу не подсудны: миграция — это история, она может не иметь
|
|
58
|
+
права импортировать те самые константы, поэтому её SQL выписан словами и
|
|
59
|
+
заморожен в день рождения. Это `exclude` проекта, а не дело правила.
|
|
60
|
+
|
|
61
|
+
Настройка: `calls`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
code: ClassVar[str] = CODE
|
|
65
|
+
Settings: ClassVar[type[CheckSettings]] = RawSqlSettings
|
|
66
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
67
|
+
marker: ClassVar[str] = MARKER
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def run(
|
|
71
|
+
cls,
|
|
72
|
+
*,
|
|
73
|
+
file: ParsedFile,
|
|
74
|
+
settings: CheckSettings,
|
|
75
|
+
) -> Iterator[Violation]:
|
|
76
|
+
calls = settings_as(
|
|
77
|
+
settings=settings,
|
|
78
|
+
model=RawSqlSettings,
|
|
79
|
+
code=CODE,
|
|
80
|
+
).calls
|
|
81
|
+
for node in ast.walk(file.tree):
|
|
82
|
+
if not isinstance(node, ast.Call) or not node.args:
|
|
83
|
+
continue
|
|
84
|
+
written = name(node=node.func)
|
|
85
|
+
if written not in calls or not cls._sql(node=node.args[0]):
|
|
86
|
+
continue
|
|
87
|
+
yield Violation.from_node(
|
|
88
|
+
node=node,
|
|
89
|
+
path=file.path,
|
|
90
|
+
code=CODE,
|
|
91
|
+
# Пометка снимается с любой строки самого вызова.
|
|
92
|
+
end_line=node.end_lineno or node.lineno,
|
|
93
|
+
message=f"{written}(...) со строкой SQL; вместо неё — {calls[written]}",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _sql(*, node: ast.expr) -> bool:
|
|
98
|
+
"""Строковый литерал или строка, собранная из литералов."""
|
|
99
|
+
match node:
|
|
100
|
+
case ast.Constant(value=str()):
|
|
101
|
+
return True
|
|
102
|
+
case ast.JoinedStr() | ast.BinOp(op=ast.Add() | ast.Mod()):
|
|
103
|
+
return any(
|
|
104
|
+
isinstance(part, ast.Constant) and isinstance(part.value, str)
|
|
105
|
+
for part in ast.walk(node)
|
|
106
|
+
)
|
|
107
|
+
case _:
|
|
108
|
+
return False
|