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,129 @@
|
|
|
1
|
+
"""Класс лежит там, где лежат классы его вида."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import model_validator
|
|
8
|
+
|
|
9
|
+
from py_checks.checks._kind import Kind, declarations
|
|
10
|
+
from py_checks.checks._location import place
|
|
11
|
+
from py_checks.checks.placement._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.checks._kind import Declaration
|
|
19
|
+
from py_checks.checks._location import Place
|
|
20
|
+
from py_checks.core import ParsedFile
|
|
21
|
+
|
|
22
|
+
CODE: Final = "class-placement"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Rule(CheckSettings):
|
|
26
|
+
"""Кому куда: вид объявления или суффикс имени — и где ему место.
|
|
27
|
+
|
|
28
|
+
`area` сужает правило до части дерева. Без него `dataclass` пришлось бы
|
|
29
|
+
держать в `dto/` по всему сервису, а доменный value object — тоже
|
|
30
|
+
dataclass, и живёт он в домене.
|
|
31
|
+
|
|
32
|
+
`inside` перечисляет равноправные адреса: `errors` и `exceptions` — это
|
|
33
|
+
директория и модуль, и словарь отказов законно лежит в любом из них.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
kind: Kind | None = None
|
|
37
|
+
suffix: str | None = None
|
|
38
|
+
inside: tuple[str, ...]
|
|
39
|
+
area: str | None = None
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="after")
|
|
42
|
+
def _one_subject(self) -> Self:
|
|
43
|
+
if (self.kind is None) == (self.suffix is None):
|
|
44
|
+
message = "правилу нужен ровно один признак: `kind` или `suffix`"
|
|
45
|
+
raise ValueError(message)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def about(self, *, declared: Declaration) -> bool:
|
|
49
|
+
if self.suffix is not None:
|
|
50
|
+
return declared.name.endswith(self.suffix)
|
|
51
|
+
return declared.kind is self.kind
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def said(self) -> str:
|
|
55
|
+
if self.suffix is not None:
|
|
56
|
+
return f"кончается на {self.suffix}"
|
|
57
|
+
return f"— {self.kind.said}" if self.kind is not None else ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ClassPlacementSettings(CheckSettings):
|
|
61
|
+
rules: tuple[Rule, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ClassPlacement:
|
|
65
|
+
"""Падает, если класс лежит не там, где лежат классы его вида.
|
|
66
|
+
|
|
67
|
+
Директория называет вид, и читатель находит порт, не открывая файла.
|
|
68
|
+
Правила проверяются по порядку, первое подошедшее отвечает за объявление:
|
|
69
|
+
исключение — это исключение, даже если его имя кончается на `Service`.
|
|
70
|
+
|
|
71
|
+
Область (`area`) — половина смысла: `dataclass` обязан лежать в `dto/`
|
|
72
|
+
только внутри `application`, потому что доменный value object — тоже
|
|
73
|
+
dataclass, и живёт он в домене.
|
|
74
|
+
|
|
75
|
+
Настройка: `rules`.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
code: ClassVar[str] = CODE
|
|
79
|
+
Settings: ClassVar[type[CheckSettings]] = ClassPlacementSettings
|
|
80
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
81
|
+
marker: ClassVar[str] = MARKER
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def run(
|
|
85
|
+
cls,
|
|
86
|
+
*,
|
|
87
|
+
file: ParsedFile,
|
|
88
|
+
settings: CheckSettings,
|
|
89
|
+
) -> Iterator[Violation]:
|
|
90
|
+
rules = settings_as(
|
|
91
|
+
settings=settings,
|
|
92
|
+
model=ClassPlacementSettings,
|
|
93
|
+
code=CODE,
|
|
94
|
+
).rules
|
|
95
|
+
where = place(file=file)
|
|
96
|
+
if where is None:
|
|
97
|
+
return
|
|
98
|
+
for declared in declarations(tree=file.tree):
|
|
99
|
+
rule = cls._rule(
|
|
100
|
+
declared=declared,
|
|
101
|
+
where=where,
|
|
102
|
+
rules=rules,
|
|
103
|
+
)
|
|
104
|
+
if rule is None:
|
|
105
|
+
continue
|
|
106
|
+
yield Violation.from_node(
|
|
107
|
+
node=declared.node,
|
|
108
|
+
path=file.path,
|
|
109
|
+
code=CODE,
|
|
110
|
+
message=(f"{declared.name} {rule.said}; ему место в {', '.join(rule.inside)}"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _rule(
|
|
115
|
+
*,
|
|
116
|
+
declared: Declaration,
|
|
117
|
+
where: Place,
|
|
118
|
+
rules: tuple[Rule, ...],
|
|
119
|
+
) -> Rule | None:
|
|
120
|
+
"""Первое правило, которое про это объявление и которое нарушено."""
|
|
121
|
+
for rule in rules:
|
|
122
|
+
if not rule.about(declared=declared):
|
|
123
|
+
continue
|
|
124
|
+
if rule.area is not None and not where.holds(path=rule.area):
|
|
125
|
+
continue
|
|
126
|
+
if any(where.holds(path=address) for address in rule.inside):
|
|
127
|
+
return None
|
|
128
|
+
return rule
|
|
129
|
+
return None
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Операция — один класс, одна дверь и ничего рядом."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from py_checks.checks._kind import Kind, declarations
|
|
11
|
+
from py_checks.checks._location import place
|
|
12
|
+
from py_checks.checks.placement._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 Iterator
|
|
18
|
+
|
|
19
|
+
from py_checks.checks._kind import Declaration
|
|
20
|
+
from py_checks.checks._location import Place
|
|
21
|
+
from py_checks.core import ParsedFile
|
|
22
|
+
|
|
23
|
+
CODE: Final = "operation-shape"
|
|
24
|
+
|
|
25
|
+
PRIVATE: Final = "_"
|
|
26
|
+
|
|
27
|
+
STATIC: Final = "staticmethod"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Operation(CheckSettings):
|
|
31
|
+
"""Директория с операциями и форма, которую там держат.
|
|
32
|
+
|
|
33
|
+
`method` — единственная публичная дверь: сценарий просят об одном деле, и
|
|
34
|
+
второй публичный метод означает вторую операцию, поделившую с первой
|
|
35
|
+
конструктор. Пусто — значит число дверей не ограничено: у сервиса модуля
|
|
36
|
+
их столько, сколько переходов у его сущности, и это тот же выбор, а не
|
|
37
|
+
поблажка.
|
|
38
|
+
|
|
39
|
+
`forbids` — имена типов, которых операция не держит: `UnitOfWork` ловится
|
|
40
|
+
и как `IPlacementUnitOfWork`, и как `UnitOfWorkFactory`, потому что
|
|
41
|
+
запрещено держать транзакцию, а не писать её имя одним конкретным образом.
|
|
42
|
+
|
|
43
|
+
`max_arguments` — сколько аргументов занимает вход. Дверь несёт то, что
|
|
44
|
+
пришло снаружи, и вход длиннее нескольких полей — вещь с именем: команда,
|
|
45
|
+
запрос, DTO. Считаются публичные методы: конструктор получает зависимости,
|
|
46
|
+
а это проводка, не вход, и в счёт он не идёт уже потому, что публичным не
|
|
47
|
+
является.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
inside: str
|
|
51
|
+
suffix: str
|
|
52
|
+
method: str | None = None
|
|
53
|
+
forbids: tuple[str, ...] = ()
|
|
54
|
+
max_arguments: int | None = Field(
|
|
55
|
+
default=None,
|
|
56
|
+
gt=0,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OperationShapeSettings(CheckSettings):
|
|
61
|
+
operations: tuple[Operation, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OperationShape:
|
|
65
|
+
"""Падает, если операция устроена не как операция.
|
|
66
|
+
|
|
67
|
+
Вход двери ограничен по числу аргументов, если предел задан: то, что
|
|
68
|
+
пришло снаружи, длиннее нескольких полей — это команда, запрос или DTO.
|
|
69
|
+
|
|
70
|
+
Рядом с операцией не стоит ничего: ни второй класс, ни функция — ни выше,
|
|
71
|
+
ни ниже. Хелпер перед предметом — абзац, который читатель пролистывает;
|
|
72
|
+
хелпер после — тот же хелпер, ничем не разделяемый: нужен операции — стал
|
|
73
|
+
приватным методом, нужен двоим — переехал туда, где лежит общее.
|
|
74
|
+
|
|
75
|
+
Константы и алиасы стоять могут: имя читают там, где им пользуются.
|
|
76
|
+
Перечисление — не может, в отличие от других директорий: словарь — это
|
|
77
|
+
класс, и операция, которой он понадобился, называет то, чем её модуль не
|
|
78
|
+
владеет.
|
|
79
|
+
|
|
80
|
+
`__init__.py`, пустой модуль и модуль с подчёркиванием правилу не подсудны.
|
|
81
|
+
Модуль, не объявивший операции вовсе, — тоже: об этом говорит
|
|
82
|
+
`required-class`, и второе мнение сообщило бы одну ошибку дважды.
|
|
83
|
+
|
|
84
|
+
Настройка: `operations`.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
code: ClassVar[str] = CODE
|
|
88
|
+
Settings: ClassVar[type[CheckSettings]] = OperationShapeSettings
|
|
89
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
90
|
+
marker: ClassVar[str] = MARKER
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def run(
|
|
94
|
+
cls,
|
|
95
|
+
*,
|
|
96
|
+
file: ParsedFile,
|
|
97
|
+
settings: CheckSettings,
|
|
98
|
+
) -> Iterator[Violation]:
|
|
99
|
+
listed = settings_as(
|
|
100
|
+
settings=settings,
|
|
101
|
+
model=OperationShapeSettings,
|
|
102
|
+
code=CODE,
|
|
103
|
+
).operations
|
|
104
|
+
where = place(file=file)
|
|
105
|
+
if where is None or file.path.stem.startswith(PRIVATE):
|
|
106
|
+
return
|
|
107
|
+
rule = cls._rule(
|
|
108
|
+
where=where,
|
|
109
|
+
listed=listed,
|
|
110
|
+
)
|
|
111
|
+
if rule is None:
|
|
112
|
+
return
|
|
113
|
+
declared = list(declarations(tree=file.tree))
|
|
114
|
+
subject = cls._subject(
|
|
115
|
+
declared=declared,
|
|
116
|
+
rule=rule,
|
|
117
|
+
)
|
|
118
|
+
found = list(
|
|
119
|
+
cls._beside(
|
|
120
|
+
file=file,
|
|
121
|
+
declared=declared,
|
|
122
|
+
subject=subject,
|
|
123
|
+
rule=rule,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
if subject is not None and isinstance(subject.node, ast.ClassDef):
|
|
127
|
+
found += [
|
|
128
|
+
*cls._door(
|
|
129
|
+
file=file,
|
|
130
|
+
subject=subject,
|
|
131
|
+
node=subject.node,
|
|
132
|
+
rule=rule,
|
|
133
|
+
),
|
|
134
|
+
*cls._input(
|
|
135
|
+
file=file,
|
|
136
|
+
subject=subject,
|
|
137
|
+
node=subject.node,
|
|
138
|
+
rule=rule,
|
|
139
|
+
),
|
|
140
|
+
*cls._held(
|
|
141
|
+
file=file,
|
|
142
|
+
subject=subject,
|
|
143
|
+
node=subject.node,
|
|
144
|
+
rule=rule,
|
|
145
|
+
),
|
|
146
|
+
]
|
|
147
|
+
yield from sorted(found, key=lambda violation: (violation.line, violation.column))
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _rule(
|
|
151
|
+
*,
|
|
152
|
+
where: Place,
|
|
153
|
+
listed: tuple[Operation, ...],
|
|
154
|
+
) -> Operation | None:
|
|
155
|
+
"""Правило этой директории: самое глубокое, а при равенстве — точное.
|
|
156
|
+
|
|
157
|
+
Директорий в `inside` у правила может быть несколько, и файл попадает
|
|
158
|
+
под оба: `use_cases` внутри `application/services` судит то, что
|
|
159
|
+
названо длиннее и лежит ближе.
|
|
160
|
+
"""
|
|
161
|
+
matched = [
|
|
162
|
+
(depth, len(one.inside), one)
|
|
163
|
+
for one in listed
|
|
164
|
+
if (depth := where.within(directory=one.inside)) is not None
|
|
165
|
+
]
|
|
166
|
+
if not matched:
|
|
167
|
+
return None
|
|
168
|
+
return max(matched, key=lambda found: found[:2])[2]
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _subject(
|
|
172
|
+
*,
|
|
173
|
+
declared: list[Declaration],
|
|
174
|
+
rule: Operation,
|
|
175
|
+
) -> Declaration | None:
|
|
176
|
+
"""Операция, ради которой существует модуль, — по имени, а не по месту.
|
|
177
|
+
|
|
178
|
+
По месту было бы неверно: класс, по ошибке вставший выше операции, —
|
|
179
|
+
то самое, о чём правило и сообщает, — оказался бы предметом, и модуль
|
|
180
|
+
услышал бы, что у его словаря нет `execute()`. Одна ошибка — одна
|
|
181
|
+
жалоба.
|
|
182
|
+
"""
|
|
183
|
+
classes = [one for one in declared if isinstance(one.node, ast.ClassDef)]
|
|
184
|
+
named = [one for one in classes if one.name.endswith(rule.suffix)]
|
|
185
|
+
return next(iter(named or classes), None)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def _beside(
|
|
189
|
+
cls,
|
|
190
|
+
*,
|
|
191
|
+
file: ParsedFile,
|
|
192
|
+
declared: list[Declaration],
|
|
193
|
+
subject: Declaration | None,
|
|
194
|
+
rule: Operation,
|
|
195
|
+
) -> Iterator[Violation]:
|
|
196
|
+
"""Всё, что встало рядом с операцией: второй класс или функция."""
|
|
197
|
+
for one in declared:
|
|
198
|
+
if one.kind is Kind.ALIAS or one is subject:
|
|
199
|
+
continue
|
|
200
|
+
if one.kind is Kind.FUNCTION:
|
|
201
|
+
yield cls._says(
|
|
202
|
+
file=file,
|
|
203
|
+
declared=one,
|
|
204
|
+
message=(
|
|
205
|
+
f"{one.name}() стоит рядом с операцией; нужный ей хелпер — "
|
|
206
|
+
f"приватный метод, нужный двоим — общий код"
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
yield cls._says(
|
|
211
|
+
file=file,
|
|
212
|
+
declared=one,
|
|
213
|
+
message=(
|
|
214
|
+
f"{one.name} стоит рядом с операцией; в {rule.inside} модуль "
|
|
215
|
+
f"объявляет один класс и больше ничего"
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def _door(
|
|
221
|
+
cls,
|
|
222
|
+
*,
|
|
223
|
+
file: ParsedFile,
|
|
224
|
+
subject: Declaration,
|
|
225
|
+
node: ast.ClassDef,
|
|
226
|
+
rule: Operation,
|
|
227
|
+
) -> Iterator[Violation]:
|
|
228
|
+
"""Единственная публичная дверь операции."""
|
|
229
|
+
if rule.method is None:
|
|
230
|
+
return
|
|
231
|
+
public = cls._public(node=node)
|
|
232
|
+
names = [method.name for method in public]
|
|
233
|
+
if names == [rule.method]:
|
|
234
|
+
return
|
|
235
|
+
if not public:
|
|
236
|
+
yield cls._says(
|
|
237
|
+
file=file,
|
|
238
|
+
declared=subject,
|
|
239
|
+
message=(
|
|
240
|
+
f"у {subject.name} нет публичного метода; об операции просят "
|
|
241
|
+
f"одним, и он называется {rule.method}()"
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
yield Violation.from_node(
|
|
246
|
+
node=public[0],
|
|
247
|
+
path=file.path,
|
|
248
|
+
code=CODE,
|
|
249
|
+
message=(
|
|
250
|
+
f"{subject.name} предлагает {', '.join(f'{name}()' for name in names)}; "
|
|
251
|
+
f"у операции один публичный метод, и это {rule.method}() — "
|
|
252
|
+
f"остальные приватны или это другой класс"
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def _input(
|
|
258
|
+
cls,
|
|
259
|
+
*,
|
|
260
|
+
file: ParsedFile,
|
|
261
|
+
subject: Declaration,
|
|
262
|
+
node: ast.ClassDef,
|
|
263
|
+
rule: Operation,
|
|
264
|
+
) -> Iterator[Violation]:
|
|
265
|
+
"""Сколько аргументов занимает вход."""
|
|
266
|
+
if rule.max_arguments is None:
|
|
267
|
+
return
|
|
268
|
+
for method in cls._public(node=node):
|
|
269
|
+
count = cls._arguments(node=method)
|
|
270
|
+
if count <= rule.max_arguments:
|
|
271
|
+
continue
|
|
272
|
+
yield Violation.from_node(
|
|
273
|
+
node=method,
|
|
274
|
+
path=file.path,
|
|
275
|
+
code=CODE,
|
|
276
|
+
message=(
|
|
277
|
+
f"{subject.name}.{method.name} — аргументов {count}, предел "
|
|
278
|
+
f"{rule.max_arguments}; передай команду, запрос или DTO"
|
|
279
|
+
),
|
|
280
|
+
# Пометка снимается с любой строки подписи.
|
|
281
|
+
end_line=max(method.body[0].lineno - 1, method.lineno),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def _arguments(cls, *, node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
286
|
+
"""Всё, что заполняет вызывающий; первый аргумент метода не в счёт.
|
|
287
|
+
|
|
288
|
+
По месту, а не по имени: `self` в `@staticmethod` — обычный аргумент.
|
|
289
|
+
"""
|
|
290
|
+
receiver = 0 if cls._static(node=node) else 1
|
|
291
|
+
named = [*node.args.posonlyargs, *node.args.args][receiver:]
|
|
292
|
+
collectors = [one for one in (node.args.vararg, node.args.kwarg) if one is not None]
|
|
293
|
+
return len(named) + len(node.args.kwonlyargs) + len(collectors)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _static(*, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
297
|
+
names = {
|
|
298
|
+
item.id if isinstance(item, ast.Name) else getattr(item, "attr", "")
|
|
299
|
+
for item in node.decorator_list
|
|
300
|
+
}
|
|
301
|
+
return STATIC in names
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def _held(
|
|
305
|
+
cls,
|
|
306
|
+
*,
|
|
307
|
+
file: ParsedFile,
|
|
308
|
+
subject: Declaration,
|
|
309
|
+
node: ast.ClassDef,
|
|
310
|
+
rule: Operation,
|
|
311
|
+
) -> Iterator[Violation]:
|
|
312
|
+
"""Тип, которого операция не держит, — в поле или в параметре."""
|
|
313
|
+
for annotation in cls._annotations(node=node):
|
|
314
|
+
held = next(
|
|
315
|
+
(
|
|
316
|
+
name
|
|
317
|
+
for name in cls._typed(node=annotation)
|
|
318
|
+
for mark in rule.forbids
|
|
319
|
+
if mark in name
|
|
320
|
+
),
|
|
321
|
+
None,
|
|
322
|
+
)
|
|
323
|
+
if held is None:
|
|
324
|
+
continue
|
|
325
|
+
yield Violation.from_node(
|
|
326
|
+
node=annotation,
|
|
327
|
+
path=file.path,
|
|
328
|
+
code=CODE,
|
|
329
|
+
message=(
|
|
330
|
+
f"{subject.name} держит {held}; операции передают то, через что "
|
|
331
|
+
f"она пишет, а транзакция остаётся вызывающему"
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _public(*, node: ast.ClassDef) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
337
|
+
"""Методы, до которых может дотянуться вызывающий, в порядке объявления.
|
|
338
|
+
|
|
339
|
+
`@property` считается: это то, что с операции читают, а предложить ей
|
|
340
|
+
нечего, кроме одного.
|
|
341
|
+
"""
|
|
342
|
+
return [
|
|
343
|
+
statement
|
|
344
|
+
for statement in node.body
|
|
345
|
+
if isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef)
|
|
346
|
+
and not statement.name.startswith(PRIVATE)
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _annotations(*, node: ast.ClassDef) -> Iterator[ast.expr]:
|
|
351
|
+
"""Всё, что класс объявил аннотацией: поля и параметры методов."""
|
|
352
|
+
for child in ast.walk(node):
|
|
353
|
+
match child:
|
|
354
|
+
case ast.AnnAssign(annotation=annotation):
|
|
355
|
+
yield annotation
|
|
356
|
+
case ast.arg(annotation=ast.expr() as annotation):
|
|
357
|
+
yield annotation
|
|
358
|
+
case _:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _typed(*, node: ast.expr) -> Iterator[str]:
|
|
363
|
+
"""Имена типов, написанные внутри аннотации."""
|
|
364
|
+
for child in ast.walk(node):
|
|
365
|
+
match child:
|
|
366
|
+
case ast.Name(id=name) | ast.Attribute(attr=name):
|
|
367
|
+
yield name
|
|
368
|
+
# Отложенная ссылка `uow: "PlacementUnitOfWork"` — та же
|
|
369
|
+
# зависимость, записанная ради проверяльщика типов.
|
|
370
|
+
case ast.Constant(value=str() as text):
|
|
371
|
+
yield text
|
|
372
|
+
case _:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _says(
|
|
377
|
+
*,
|
|
378
|
+
file: ParsedFile,
|
|
379
|
+
declared: Declaration,
|
|
380
|
+
message: str,
|
|
381
|
+
) -> Violation:
|
|
382
|
+
return Violation.from_node(
|
|
383
|
+
node=declared.node,
|
|
384
|
+
path=file.path,
|
|
385
|
+
code=CODE,
|
|
386
|
+
message=message,
|
|
387
|
+
)
|