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,13 @@
|
|
|
1
|
+
"""Места вызова.
|
|
2
|
+
|
|
3
|
+
Есть функции, у которых законных мест вызова ровно столько, сколько их
|
|
4
|
+
перечислено: конверсия денег, например. Правило держит этот список.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from py_checks.checks.calls._confined_functions import (
|
|
8
|
+
ConfinedFunctions,
|
|
9
|
+
ConfinedFunctionsSettings,
|
|
10
|
+
)
|
|
11
|
+
from py_checks.checks.calls._marker import MARKER
|
|
12
|
+
|
|
13
|
+
__all__ = ["MARKER", "ConfinedFunctions", "ConfinedFunctionsSettings"]
|
|
@@ -0,0 +1,102 @@
|
|
|
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 place
|
|
9
|
+
from py_checks.checks._names import matches
|
|
10
|
+
from py_checks.checks.calls._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.core import ParsedFile
|
|
18
|
+
|
|
19
|
+
CODE: Final = "confined-functions"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfinedFunctionsSettings(CheckSettings):
|
|
23
|
+
calls: dict[str, tuple[str, ...]] = {} # noqa: RUF012 — pydantic копирует значение сам
|
|
24
|
+
home: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfinedFunctions:
|
|
28
|
+
"""Падает, если названная функция позвана не оттуда, откуда ей можно.
|
|
29
|
+
|
|
30
|
+
Написано ради конверсии денег. Сервис считает в одной валюте, и гарантия
|
|
31
|
+
за этой фразой — не имя типа: это то, что у конверсии одна реализация и
|
|
32
|
+
места её вызова можно перечислить. Где угодно ещё конверсия — это сумма в
|
|
33
|
+
чьей-то валюте посреди расчёта, и ошибка, которую она даёт, — число,
|
|
34
|
+
верное ровно до того дня, когда встретятся две валюты.
|
|
35
|
+
|
|
36
|
+
Место — кусок пути, а не файл: край — это место в замысле, и файл, который
|
|
37
|
+
разделили надвое, краем быть не перестал.
|
|
38
|
+
|
|
39
|
+
`home` — модуль, где функция объявлена: там она написана, а не позвана, и
|
|
40
|
+
правило его не трогает.
|
|
41
|
+
|
|
42
|
+
Настройки: `calls`, `home`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
code: ClassVar[str] = CODE
|
|
46
|
+
Settings: ClassVar[type[CheckSettings]] = ConfinedFunctionsSettings
|
|
47
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
48
|
+
marker: ClassVar[str] = MARKER
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def run(
|
|
52
|
+
cls,
|
|
53
|
+
*,
|
|
54
|
+
file: ParsedFile,
|
|
55
|
+
settings: CheckSettings,
|
|
56
|
+
) -> Iterator[Violation]:
|
|
57
|
+
limits = settings_as(
|
|
58
|
+
settings=settings,
|
|
59
|
+
model=ConfinedFunctionsSettings,
|
|
60
|
+
code=CODE,
|
|
61
|
+
)
|
|
62
|
+
where = place(file=file)
|
|
63
|
+
if where is None or not limits.calls:
|
|
64
|
+
return
|
|
65
|
+
if limits.home is not None and where.holds(path=limits.home):
|
|
66
|
+
return
|
|
67
|
+
for node in ast.walk(file.tree):
|
|
68
|
+
if not isinstance(node, ast.Call):
|
|
69
|
+
continue
|
|
70
|
+
called = ast.unparse(node.func)
|
|
71
|
+
allowed = cls._allowed(
|
|
72
|
+
called=called,
|
|
73
|
+
calls=limits.calls,
|
|
74
|
+
)
|
|
75
|
+
if allowed is None or where.anywhere(
|
|
76
|
+
zones=allowed,
|
|
77
|
+
):
|
|
78
|
+
continue
|
|
79
|
+
yield Violation.from_node(
|
|
80
|
+
node=node,
|
|
81
|
+
path=file.path,
|
|
82
|
+
code=CODE,
|
|
83
|
+
message=f"{called}() зовут не отсюда; её места — {', '.join(allowed)}",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _allowed(
|
|
88
|
+
*,
|
|
89
|
+
called: str,
|
|
90
|
+
calls: dict[str, tuple[str, ...]],
|
|
91
|
+
) -> tuple[str, ...] | None:
|
|
92
|
+
return next(
|
|
93
|
+
(
|
|
94
|
+
zones
|
|
95
|
+
for pattern, zones in calls.items()
|
|
96
|
+
if matches(
|
|
97
|
+
called=called,
|
|
98
|
+
pattern=pattern,
|
|
99
|
+
)
|
|
100
|
+
),
|
|
101
|
+
None,
|
|
102
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""База данных.
|
|
2
|
+
|
|
3
|
+
Граница транзакции, материал колонки, форма запроса. Готового тут почти нет:
|
|
4
|
+
`pytest-alembic` отвечает на откат миграции, `alembic check` — на расхождение
|
|
5
|
+
моделей и миграций, но звать его приходится самим: ему нужна живая база,
|
|
6
|
+
поэтому `schema-drift` объявлен `ENVIRONMENT`. Остальное — соглашения проекта.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.database._bound_checks import BoundChecks, BoundChecksSettings
|
|
10
|
+
from py_checks.checks.database._confined_calls import (
|
|
11
|
+
Confined,
|
|
12
|
+
ConfinedCalls,
|
|
13
|
+
ConfinedCallsSettings,
|
|
14
|
+
)
|
|
15
|
+
from py_checks.checks.database._marker import MARKER
|
|
16
|
+
from py_checks.checks.database._model_boundary import ModelBoundary, ModelBoundarySettings
|
|
17
|
+
from py_checks.checks.database._model_columns import ModelColumns, ModelColumnsSettings
|
|
18
|
+
from py_checks.checks.database._raw_sql import RawSql, RawSqlSettings
|
|
19
|
+
from py_checks.checks.database._schema_drift import SchemaDrift, SchemaDriftSettings
|
|
20
|
+
from py_checks.checks.database._statement_keys import StatementKeys, StatementKeysSettings
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MARKER",
|
|
24
|
+
"BoundChecks",
|
|
25
|
+
"BoundChecksSettings",
|
|
26
|
+
"Confined",
|
|
27
|
+
"ConfinedCalls",
|
|
28
|
+
"ConfinedCallsSettings",
|
|
29
|
+
"ModelBoundary",
|
|
30
|
+
"ModelBoundarySettings",
|
|
31
|
+
"ModelColumns",
|
|
32
|
+
"ModelColumnsSettings",
|
|
33
|
+
"RawSql",
|
|
34
|
+
"RawSqlSettings",
|
|
35
|
+
"SchemaDrift",
|
|
36
|
+
"SchemaDriftSettings",
|
|
37
|
+
"StatementKeys",
|
|
38
|
+
"StatementKeysSettings",
|
|
39
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
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 = "bound-checks"
|
|
20
|
+
|
|
21
|
+
MAPPED: Final = "Mapped"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BoundChecksSettings(ZonedSettings):
|
|
25
|
+
primitives: tuple[str, ...] = ()
|
|
26
|
+
call: str = "bound_check"
|
|
27
|
+
column: str = "column"
|
|
28
|
+
primitive: str = "primitive"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BoundChecks:
|
|
32
|
+
"""Падает, если ограниченная колонка не повторила своё ограничение как CHECK.
|
|
33
|
+
|
|
34
|
+
Колонка, объявленная `Mapped[PositiveDecimal]`, обещает дважды. pyright
|
|
35
|
+
держит каждую строку, СОБРАННУЮ здесь, значениями, которые тип пропустил;
|
|
36
|
+
`bound_check(column=..., primitive=PositiveDecimal)` в `__table_args__`
|
|
37
|
+
держит каждую строку, записанную любым другим способом — бэкфилл, сессия
|
|
38
|
+
psql, второй сервис в следующем году. Правило связывает две половины:
|
|
39
|
+
аннотация без CHECK — это база, доверяющая коду, которого она не видела.
|
|
40
|
+
|
|
41
|
+
Проверяется наличие, а не эквивалентность, и потому ему можно верить: SQL
|
|
42
|
+
генерируется из того же `BOUND`, которым отказывает тип, так что второго
|
|
43
|
+
выражения для сравнения просто нет — есть вызов, который могли забыть.
|
|
44
|
+
Отдельно отвергается `primitive=`, называющий не тот тип, что в аннотации:
|
|
45
|
+
это единственный способ протащить расхождение обратно.
|
|
46
|
+
|
|
47
|
+
Список ограниченных типов проектный: библиотека не может знать, что у
|
|
48
|
+
этого сервиса деньги — `PositiveDecimal`, а доля — `MarginFraction`. Без
|
|
49
|
+
списка правило молчит.
|
|
50
|
+
|
|
51
|
+
Настройки: `zones`, `primitives`, `call`, `column`, `primitive`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
code: ClassVar[str] = CODE
|
|
55
|
+
Settings: ClassVar[type[CheckSettings]] = BoundChecksSettings
|
|
56
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
57
|
+
marker: ClassVar[str] = MARKER
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def run(
|
|
61
|
+
cls,
|
|
62
|
+
*,
|
|
63
|
+
file: ParsedFile,
|
|
64
|
+
settings: CheckSettings,
|
|
65
|
+
) -> Iterator[Violation]:
|
|
66
|
+
limits = settings_as(
|
|
67
|
+
settings=settings,
|
|
68
|
+
model=BoundChecksSettings,
|
|
69
|
+
code=CODE,
|
|
70
|
+
)
|
|
71
|
+
where = zoned(
|
|
72
|
+
file=file,
|
|
73
|
+
zones=limits.zones,
|
|
74
|
+
)
|
|
75
|
+
if where is None:
|
|
76
|
+
return
|
|
77
|
+
for node in file.tree.body:
|
|
78
|
+
if isinstance(node, ast.ClassDef):
|
|
79
|
+
yield from cls._columns(
|
|
80
|
+
file=file,
|
|
81
|
+
node=node,
|
|
82
|
+
limits=limits,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def _columns(
|
|
87
|
+
cls,
|
|
88
|
+
*,
|
|
89
|
+
file: ParsedFile,
|
|
90
|
+
node: ast.ClassDef,
|
|
91
|
+
limits: BoundChecksSettings,
|
|
92
|
+
) -> Iterator[Violation]:
|
|
93
|
+
declared = cls._declared(
|
|
94
|
+
node=node,
|
|
95
|
+
limits=limits,
|
|
96
|
+
)
|
|
97
|
+
for statement in node.body:
|
|
98
|
+
if not isinstance(statement, ast.AnnAssign):
|
|
99
|
+
continue
|
|
100
|
+
if not isinstance(statement.target, ast.Name):
|
|
101
|
+
continue
|
|
102
|
+
bounded = cls._bounded(node=statement.annotation)
|
|
103
|
+
if bounded is None or bounded not in limits.primitives:
|
|
104
|
+
continue
|
|
105
|
+
field = statement.target.id
|
|
106
|
+
said = declared.get(field)
|
|
107
|
+
if said == bounded:
|
|
108
|
+
continue
|
|
109
|
+
yield Violation.from_node(
|
|
110
|
+
node=statement,
|
|
111
|
+
path=file.path,
|
|
112
|
+
code=CODE,
|
|
113
|
+
message=cls._message(
|
|
114
|
+
field=field,
|
|
115
|
+
bounded=bounded,
|
|
116
|
+
said=said,
|
|
117
|
+
limits=limits,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _message(
|
|
123
|
+
*,
|
|
124
|
+
field: str,
|
|
125
|
+
bounded: str,
|
|
126
|
+
said: str | None,
|
|
127
|
+
limits: BoundChecksSettings,
|
|
128
|
+
) -> str:
|
|
129
|
+
if said is None:
|
|
130
|
+
return (
|
|
131
|
+
f"{field} объявлено как {bounded}, но CHECK не несёт; добавь "
|
|
132
|
+
f"{limits.call}({limits.column}={field}, {limits.primitive}={bounded}) "
|
|
133
|
+
f"в __table_args__"
|
|
134
|
+
)
|
|
135
|
+
return (
|
|
136
|
+
f"{field} объявлено как {bounded}, а его {limits.call} называет {said}; "
|
|
137
|
+
f"аннотация и CHECK читают одну границу"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def _declared(
|
|
142
|
+
cls,
|
|
143
|
+
*,
|
|
144
|
+
node: ast.ClassDef,
|
|
145
|
+
limits: BoundChecksSettings,
|
|
146
|
+
) -> dict[str, str]:
|
|
147
|
+
"""Колонка — тип, по каждому вызову в теле класса."""
|
|
148
|
+
found: dict[str, str] = {}
|
|
149
|
+
for child in ast.walk(node):
|
|
150
|
+
if not isinstance(child, ast.Call) or name(node=child.func) != limits.call:
|
|
151
|
+
continue
|
|
152
|
+
column = cls._argument(
|
|
153
|
+
node=child,
|
|
154
|
+
named=limits.column,
|
|
155
|
+
)
|
|
156
|
+
primitive = cls._argument(
|
|
157
|
+
node=child,
|
|
158
|
+
named=limits.primitive,
|
|
159
|
+
)
|
|
160
|
+
if column is not None and primitive is not None:
|
|
161
|
+
found[column] = primitive
|
|
162
|
+
return found
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _argument(
|
|
166
|
+
*,
|
|
167
|
+
node: ast.Call,
|
|
168
|
+
named: str,
|
|
169
|
+
) -> str | None:
|
|
170
|
+
for keyword in node.keywords:
|
|
171
|
+
if keyword.arg == named and isinstance(keyword.value, ast.Name):
|
|
172
|
+
return keyword.value.id
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _bounded(*, node: ast.expr) -> str | None:
|
|
177
|
+
"""X из `Mapped[X]` или `Mapped[X | None]`, если это простое имя."""
|
|
178
|
+
if not isinstance(node, ast.Subscript) or name(node=node.value) != MAPPED:
|
|
179
|
+
return None
|
|
180
|
+
match node.slice:
|
|
181
|
+
case ast.Name(id=inside):
|
|
182
|
+
return inside
|
|
183
|
+
case ast.BinOp(left=ast.Name(id=inside), op=ast.BitOr()):
|
|
184
|
+
return inside
|
|
185
|
+
case _:
|
|
186
|
+
return None
|
|
@@ -0,0 +1,115 @@
|
|
|
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, place
|
|
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.checks._location import Place
|
|
17
|
+
from py_checks.core import ParsedFile
|
|
18
|
+
|
|
19
|
+
CODE: Final = "confined-calls"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Confined(ZonedSettings):
|
|
23
|
+
"""Имена методов, зона, где они запрещены, и модуль, который ими владеет.
|
|
24
|
+
|
|
25
|
+
`owner` — имя модуля без расширения. Правило его не касается: там вызов и
|
|
26
|
+
должен стоять, потому и владелец.
|
|
27
|
+
|
|
28
|
+
`outside` — куски зоны, где правило молчит. Имя метода — всё, что видно по
|
|
29
|
+
одному файлу, и край брокера тому пример: `commit()` у консьюмера
|
|
30
|
+
подтверждает смещение, а не транзакцию базы.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
methods: tuple[str, ...]
|
|
34
|
+
outside: tuple[str, ...] = ()
|
|
35
|
+
owner: str | None = None
|
|
36
|
+
said: str = "этим владеет другой модуль"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfinedCallsSettings(CheckSettings):
|
|
40
|
+
rules: tuple[Confined, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfinedCalls:
|
|
44
|
+
"""Падает, если названный метод позвали не там, где ему место.
|
|
45
|
+
|
|
46
|
+
Написано ради границы транзакции. Ставка — это одна транзакция: списать
|
|
47
|
+
деньги, записать ставку, записать событие, которое расскажет об этом
|
|
48
|
+
остальной платформе. Репозиторий, коммитящий в середине, превращает её в
|
|
49
|
+
три, и сальдо перестаёт сходиться со ставками. `begin` запрещён рядом с
|
|
50
|
+
`commit` и `rollback` по той же причине с другого конца: вызывающий уже
|
|
51
|
+
открыл транзакцию, а вторая внутри либо падает, либо тихо делает вложенную.
|
|
52
|
+
|
|
53
|
+
Имя метода — всё, что видно по одному файлу: чей это объект, сказал бы
|
|
54
|
+
только вывод типов. Поэтому правило и сужено зоной — там, где `commit()`
|
|
55
|
+
может быть только у сессии.
|
|
56
|
+
|
|
57
|
+
Настройка: `rules`.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
code: ClassVar[str] = CODE
|
|
61
|
+
Settings: ClassVar[type[CheckSettings]] = ConfinedCallsSettings
|
|
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
|
+
rules = settings_as(
|
|
73
|
+
settings=settings,
|
|
74
|
+
model=ConfinedCallsSettings,
|
|
75
|
+
code=CODE,
|
|
76
|
+
).rules
|
|
77
|
+
where = place(file=file)
|
|
78
|
+
if where is None:
|
|
79
|
+
return
|
|
80
|
+
listed = [
|
|
81
|
+
rule
|
|
82
|
+
for rule in rules
|
|
83
|
+
if cls._covers(
|
|
84
|
+
rule=rule,
|
|
85
|
+
where=where,
|
|
86
|
+
file=file,
|
|
87
|
+
)
|
|
88
|
+
]
|
|
89
|
+
if not listed:
|
|
90
|
+
return
|
|
91
|
+
for node in ast.walk(file.tree):
|
|
92
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
93
|
+
continue
|
|
94
|
+
rule = next((one for one in listed if node.func.attr in one.methods), None)
|
|
95
|
+
if rule is None:
|
|
96
|
+
continue
|
|
97
|
+
yield Violation.from_node(
|
|
98
|
+
node=node,
|
|
99
|
+
path=file.path,
|
|
100
|
+
code=CODE,
|
|
101
|
+
message=f"{ast.unparse(node.func)}: {rule.said}",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _covers(
|
|
106
|
+
*,
|
|
107
|
+
rule: Confined,
|
|
108
|
+
where: Place,
|
|
109
|
+
file: ParsedFile,
|
|
110
|
+
) -> bool:
|
|
111
|
+
if rule.owner is not None and file.path.stem == rule.owner:
|
|
112
|
+
return False
|
|
113
|
+
if where.anywhere(zones=rule.outside):
|
|
114
|
+
return False
|
|
115
|
+
return where.anywhere(zones=rule.zones)
|