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,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