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,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
+ )
@@ -0,0 +1,5 @@
1
+ """Слово группы для пометок: `# effect-ok: <код>: <причина>`."""
2
+
3
+ from typing import Final
4
+
5
+ MARKER: Final = "# effect-ok"