python-checks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_checks/__init__.py +2 -0
- py_checks/checks/__init__.py +20 -0
- py_checks/checks/_kind.py +184 -0
- py_checks/checks/_location.py +172 -0
- py_checks/checks/_names.py +86 -0
- py_checks/checks/api/__init__.py +13 -0
- py_checks/checks/api/_endpoint_declarations.py +204 -0
- py_checks/checks/api/_marker.py +5 -0
- py_checks/checks/calls/__init__.py +13 -0
- py_checks/checks/calls/_confined_functions.py +102 -0
- py_checks/checks/calls/_marker.py +5 -0
- py_checks/checks/database/__init__.py +39 -0
- py_checks/checks/database/_bound_checks.py +186 -0
- py_checks/checks/database/_confined_calls.py +115 -0
- py_checks/checks/database/_marker.py +5 -0
- py_checks/checks/database/_model_boundary.py +236 -0
- py_checks/checks/database/_model_columns.py +241 -0
- py_checks/checks/database/_raw_sql.py +108 -0
- py_checks/checks/database/_schema_drift.py +271 -0
- py_checks/checks/database/_statement_keys.py +169 -0
- py_checks/checks/effects/__init__.py +19 -0
- py_checks/checks/effects/_determinism.py +105 -0
- py_checks/checks/effects/_log_events.py +120 -0
- py_checks/checks/effects/_marker.py +5 -0
- py_checks/checks/hygiene/__init__.py +14 -0
- py_checks/checks/hygiene/_dependency_bounds.py +185 -0
- py_checks/checks/hygiene/_marker.py +5 -0
- py_checks/checks/imports/__init__.py +25 -0
- py_checks/checks/imports/_confined.py +93 -0
- py_checks/checks/imports/_marker.py +7 -0
- py_checks/checks/imports/_sealed.py +100 -0
- py_checks/checks/imports/_statements.py +52 -0
- py_checks/checks/placement/__init__.py +38 -0
- py_checks/checks/placement/_class_modules.py +106 -0
- py_checks/checks/placement/_class_placement.py +129 -0
- py_checks/checks/placement/_marker.py +7 -0
- py_checks/checks/placement/_operation_shape.py +387 -0
- py_checks/checks/placement/_required_class.py +179 -0
- py_checks/checks/signatures/__init__.py +33 -0
- py_checks/checks/signatures/_function_length.py +90 -0
- py_checks/checks/signatures/_functions.py +92 -0
- py_checks/checks/signatures/_keyword_only.py +148 -0
- py_checks/checks/signatures/_marker.py +7 -0
- py_checks/checks/signatures/_module_length.py +64 -0
- py_checks/checks/signatures/_nesting.py +156 -0
- py_checks/checks/signatures/_signature_layout.py +231 -0
- py_checks/checks/types/__init__.py +36 -0
- py_checks/checks/types/_annotation_shapes.py +127 -0
- py_checks/checks/types/_config_fields.py +236 -0
- py_checks/checks/types/_confined_types.py +117 -0
- py_checks/checks/types/_constant_annotations.py +128 -0
- py_checks/checks/types/_frozen_dataclasses.py +112 -0
- py_checks/checks/types/_marker.py +5 -0
- py_checks/cli/__init__.py +10 -0
- py_checks/cli/_app.py +21 -0
- py_checks/cli/_protocols.py +19 -0
- py_checks/cli/commands/__init__.py +23 -0
- py_checks/cli/commands/_explain.py +28 -0
- py_checks/cli/commands/_list.py +62 -0
- py_checks/cli/commands/_run.py +167 -0
- py_checks/cli/commands/_summary.py +20 -0
- py_checks/cli/commands/_sync.py +80 -0
- py_checks/config/__init__.py +31 -0
- py_checks/config/_base.py +26 -0
- py_checks/config/_config.py +76 -0
- py_checks/config/_constants.py +31 -0
- py_checks/config/_errors.py +12 -0
- py_checks/config/_loader.py +133 -0
- py_checks/config/_toml.py +24 -0
- py_checks/contracts/__init__.py +26 -0
- py_checks/contracts/_constants.py +18 -0
- py_checks/contracts/_layout.py +63 -0
- py_checks/contracts/_render.py +217 -0
- py_checks/contracts/_settings.py +37 -0
- py_checks/core/__init__.py +56 -0
- py_checks/core/_constants.py +15 -0
- py_checks/core/_discovery.py +57 -0
- py_checks/core/_edit.py +92 -0
- py_checks/core/_errors.py +35 -0
- py_checks/core/_fixer.py +55 -0
- py_checks/core/_format.py +30 -0
- py_checks/core/_markers.py +220 -0
- py_checks/core/_protocols.py +88 -0
- py_checks/core/_registry.py +77 -0
- py_checks/core/_report.py +45 -0
- py_checks/core/_runner.py +178 -0
- py_checks/core/_settings.py +44 -0
- py_checks/core/_source.py +94 -0
- py_checks/core/_violation.py +73 -0
- py_checks/environment/__init__.py +14 -0
- py_checks/environment/_constants.py +9 -0
- py_checks/environment/_render.py +227 -0
- py_checks/environment/_settings.py +35 -0
- py_checks/py.typed +0 -0
- py_checks/sync/__init__.py +16 -0
- py_checks/sync/_sync.py +60 -0
- python_checks-0.1.0.dist-info/METADATA +327 -0
- python_checks-0.1.0.dist-info/RECORD +101 -0
- python_checks-0.1.0.dist-info/WHEEL +4 -0
- python_checks-0.1.0.dist-info/entry_points.txt +33 -0
- python_checks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Поле настроек названо целиком: и как приходит, и какие значения допустимы."""
|
|
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 walked
|
|
10
|
+
from py_checks.checks.types._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 = "config-fields"
|
|
20
|
+
|
|
21
|
+
# Классы, которые держат словарь или интерфейс, а не поля настроек.
|
|
22
|
+
NOT_SETTINGS: Final[frozenset[str]] = frozenset(
|
|
23
|
+
{"Enum", "StrEnum", "IntEnum", "IntFlag", "Flag", "Protocol", "TypedDict"},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Аннотация, которая говорит, как значение хранится, и ничего — какие значения
|
|
27
|
+
# допустимы. Всё остальное — имя, а имя это место, где правило может жить.
|
|
28
|
+
BOUNDS: Final[dict[str, tuple[str, ...]]] = {
|
|
29
|
+
"int": ("ge", "gt", "le", "lt"),
|
|
30
|
+
"float": ("ge", "gt", "le", "lt"),
|
|
31
|
+
# `max_length` тут отсутствует намеренно: потолок говорит, какой длины
|
|
32
|
+
# значение может быть, а не что оно вообще есть, — а речь именно о пустой
|
|
33
|
+
# строке, которой оборачивается неустановленная переменная.
|
|
34
|
+
"str": ("min_length", "pattern"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
CLASS_VAR: Final = "ClassVar"
|
|
38
|
+
|
|
39
|
+
# Поле, собранное фабрикой, — это вложенная секция, а не значение: переменной у
|
|
40
|
+
# него нет, её читают поля внутри.
|
|
41
|
+
FACTORY: Final = "default_factory"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigFieldsSettings(ZonedSettings):
|
|
45
|
+
factory: str = "Field"
|
|
46
|
+
alias: str | None = None
|
|
47
|
+
bounds: dict[str, tuple[str, ...]] = BOUNDS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConfigFields:
|
|
51
|
+
"""Падает, если поле настроек ничем не ограничено.
|
|
52
|
+
|
|
53
|
+
Значение приходит текстом из окружения, которое никто не ревьюит, поэтому
|
|
54
|
+
обе половины объявления обязательны.
|
|
55
|
+
|
|
56
|
+
Поле объявляется через `Field(...)`: там живут псевдоним переменной,
|
|
57
|
+
значение по умолчанию и ограничения, а голое `name: str = "x"` молча
|
|
58
|
+
роняет все три.
|
|
59
|
+
|
|
60
|
+
Поле с голым числом называет границу — `ge`, `gt`, `le`, `lt` — или
|
|
61
|
+
аннотируется типом, который её несёт. Без этого `POSTGRES_POOL_SIZE=0` и
|
|
62
|
+
пул на пятьсот принимаются здесь и падают где-то там, где в трейсбеке
|
|
63
|
+
настроек уже не видно.
|
|
64
|
+
|
|
65
|
+
Голая строка — та же дыра с более тихим отказом: неустановленная переменная
|
|
66
|
+
приходит пустой строкой, и пустой адрес брокера, DSN или имя топика
|
|
67
|
+
принимаются как настройка. Поле называет `min_length` или `pattern`, либо
|
|
68
|
+
несёт тип, который это делает.
|
|
69
|
+
|
|
70
|
+
Поле называет переменную, из которой читается (`alias`, у pydantic это
|
|
71
|
+
`validation_alias`). Без неё имя переменной знает один pydantic — он
|
|
72
|
+
выводит его из имени поля и приставки, — и ни `.env.example`, собранный из
|
|
73
|
+
этих же классов, ни человек, ищущий, откуда берётся значение, назвать её не
|
|
74
|
+
могут. Поле, собранное `default_factory`, — исключение: это вложенная
|
|
75
|
+
секция, а не значение, и переменные читают её собственные поля.
|
|
76
|
+
|
|
77
|
+
`ClassVar` — не поле настроек, а константа рядом с ними.
|
|
78
|
+
|
|
79
|
+
Настройки: `zones`, `factory`, `alias`, `bounds`.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
code: ClassVar[str] = CODE
|
|
83
|
+
Settings: ClassVar[type[CheckSettings]] = ConfigFieldsSettings
|
|
84
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
85
|
+
marker: ClassVar[str] = MARKER
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def run(
|
|
89
|
+
cls,
|
|
90
|
+
*,
|
|
91
|
+
file: ParsedFile,
|
|
92
|
+
settings: CheckSettings,
|
|
93
|
+
) -> Iterator[Violation]:
|
|
94
|
+
limits = settings_as(
|
|
95
|
+
settings=settings,
|
|
96
|
+
model=ConfigFieldsSettings,
|
|
97
|
+
code=CODE,
|
|
98
|
+
)
|
|
99
|
+
where = zoned(
|
|
100
|
+
file=file,
|
|
101
|
+
zones=limits.zones,
|
|
102
|
+
)
|
|
103
|
+
if where is None:
|
|
104
|
+
return
|
|
105
|
+
for node in ast.walk(file.tree):
|
|
106
|
+
if isinstance(node, ast.ClassDef) and cls._settings(node=node):
|
|
107
|
+
yield from cls._fields(
|
|
108
|
+
file=file,
|
|
109
|
+
node=node,
|
|
110
|
+
limits=limits,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _fields(
|
|
115
|
+
cls,
|
|
116
|
+
*,
|
|
117
|
+
file: ParsedFile,
|
|
118
|
+
node: ast.ClassDef,
|
|
119
|
+
limits: ConfigFieldsSettings,
|
|
120
|
+
) -> Iterator[Violation]:
|
|
121
|
+
for statement in node.body:
|
|
122
|
+
if not isinstance(statement, ast.AnnAssign):
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(statement.target, ast.Name):
|
|
125
|
+
continue
|
|
126
|
+
named = frozenset(walked(node=statement.annotation))
|
|
127
|
+
if CLASS_VAR in named:
|
|
128
|
+
continue
|
|
129
|
+
reason = cls._reason(
|
|
130
|
+
statement=statement,
|
|
131
|
+
named=named,
|
|
132
|
+
limits=limits,
|
|
133
|
+
)
|
|
134
|
+
if reason is None:
|
|
135
|
+
continue
|
|
136
|
+
yield Violation.from_node(
|
|
137
|
+
node=statement,
|
|
138
|
+
path=file.path,
|
|
139
|
+
code=CODE,
|
|
140
|
+
message=f"{node.name}.{statement.target.id} {reason}",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _reason(
|
|
145
|
+
cls,
|
|
146
|
+
*,
|
|
147
|
+
statement: ast.AnnAssign,
|
|
148
|
+
named: frozenset[str],
|
|
149
|
+
limits: ConfigFieldsSettings,
|
|
150
|
+
) -> str | None:
|
|
151
|
+
"""Чем поле не закрыто, или `None`, если закрыто."""
|
|
152
|
+
if not cls._declared(
|
|
153
|
+
node=statement.value,
|
|
154
|
+
factory=limits.factory,
|
|
155
|
+
):
|
|
156
|
+
if statement.value is None:
|
|
157
|
+
return (
|
|
158
|
+
f"объявлено без значения; поле настроек объявляют через {limits.factory}(...)"
|
|
159
|
+
)
|
|
160
|
+
return f"объявлено не через {limits.factory}(...)"
|
|
161
|
+
if (
|
|
162
|
+
limits.alias is not None
|
|
163
|
+
and not cls._states(
|
|
164
|
+
node=statement.value,
|
|
165
|
+
wanted=(FACTORY,),
|
|
166
|
+
)
|
|
167
|
+
and not cls._states(
|
|
168
|
+
node=statement.value,
|
|
169
|
+
wanted=(limits.alias,),
|
|
170
|
+
)
|
|
171
|
+
):
|
|
172
|
+
return (
|
|
173
|
+
f"не называет {limits.alias}=; без него имя переменной знает "
|
|
174
|
+
f"один pydantic, а `.env.example` собирается из этих же полей"
|
|
175
|
+
)
|
|
176
|
+
wanted = cls._wanted(
|
|
177
|
+
named=named,
|
|
178
|
+
bounds=limits.bounds,
|
|
179
|
+
)
|
|
180
|
+
if wanted and not cls._states(
|
|
181
|
+
node=statement.value,
|
|
182
|
+
wanted=wanted,
|
|
183
|
+
):
|
|
184
|
+
return (
|
|
185
|
+
f"ничем не ограничено; назови одно из {', '.join(wanted)} "
|
|
186
|
+
f"или аннотируй типом, который несёт это правило"
|
|
187
|
+
)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _wanted(
|
|
192
|
+
*,
|
|
193
|
+
named: frozenset[str],
|
|
194
|
+
bounds: dict[str, tuple[str, ...]],
|
|
195
|
+
) -> tuple[str, ...]:
|
|
196
|
+
"""Какие ограничения задолжала аннотация.
|
|
197
|
+
|
|
198
|
+
`str | None` — это строка, а `int | None` — число: объединение говорит
|
|
199
|
+
о том, есть ли значение, а не о том, какие значения допустимы. Имя, не
|
|
200
|
+
попавшее в таблицу, — уже правило: ограничение живёт в нём.
|
|
201
|
+
"""
|
|
202
|
+
if not named or not named <= frozenset(bounds):
|
|
203
|
+
return ()
|
|
204
|
+
listed = [one for name in sorted(named) for one in bounds[name]]
|
|
205
|
+
return tuple(dict.fromkeys(listed))
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _declared(
|
|
209
|
+
*,
|
|
210
|
+
node: ast.expr | None,
|
|
211
|
+
factory: str,
|
|
212
|
+
) -> bool:
|
|
213
|
+
match node:
|
|
214
|
+
case ast.Call(func=ast.Name(id=name) | ast.Attribute(attr=name)):
|
|
215
|
+
return name == factory
|
|
216
|
+
case _:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _states(
|
|
221
|
+
*,
|
|
222
|
+
node: ast.expr | None,
|
|
223
|
+
wanted: tuple[str, ...],
|
|
224
|
+
) -> bool:
|
|
225
|
+
if not isinstance(node, ast.Call):
|
|
226
|
+
return False
|
|
227
|
+
return any(keyword.arg in wanted for keyword in node.keywords)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _settings(*, node: ast.ClassDef) -> bool:
|
|
231
|
+
"""Класс полей настроек, а не словарь и не интерфейс рядом с ними."""
|
|
232
|
+
bases = {
|
|
233
|
+
base.attr if isinstance(base, ast.Attribute) else getattr(base, "id", "")
|
|
234
|
+
for base in node.bases
|
|
235
|
+
}
|
|
236
|
+
return not bases & NOT_SETTINGS
|
|
@@ -0,0 +1,117 @@
|
|
|
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 walked
|
|
10
|
+
from py_checks.checks.types._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 = "confined-types"
|
|
21
|
+
|
|
22
|
+
# Имя, которое говорит, что значение принадлежит классу, а не экземпляру:
|
|
23
|
+
# такое поле — не состояние, переходящее границу, и правило его не касается.
|
|
24
|
+
ASIDE: Final[frozenset[str]] = frozenset({"ClassVar", "Final"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfinedTypesSettings(CheckSettings):
|
|
28
|
+
zones: dict[str, tuple[str, ...]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfinedTypes:
|
|
32
|
+
"""Падает, если поле в этой части дерева объявлено запрещённым здесь типом.
|
|
33
|
+
|
|
34
|
+
Одно правило на два случая, которые раньше писались по отдельности.
|
|
35
|
+
`float` в домене: двоичная плавающая точка не держит цену, а ошибка
|
|
36
|
+
округления в хранимом состоянии — это деньги, которые перестают сходиться.
|
|
37
|
+
Голые `str`, `int`, `Decimal` там, где живут контракты: `int` говорит, что
|
|
38
|
+
версия может быть −10000, `str` — что тег может быть пустым, `Decimal` —
|
|
39
|
+
что коэффициент может быть отрицательным или NaN. Ничего из этого про дело
|
|
40
|
+
не верно, а тип — последнее место, где это можно сказать один раз, а не
|
|
41
|
+
перепроверять глазами.
|
|
42
|
+
|
|
43
|
+
Какие типы где запрещены — дело проекта: в одном сервисе деньги считают
|
|
44
|
+
везде, в другом `float` в отчёте законен. Без таблицы правило молчит.
|
|
45
|
+
|
|
46
|
+
Аннотация просматривается насквозь: `tuple[str, ...]` — та же голая строка
|
|
47
|
+
этажом ниже. Судятся поля класса; `ClassVar` и `Final` — не поля: они
|
|
48
|
+
принадлежат классу, а не экземпляру.
|
|
49
|
+
|
|
50
|
+
Настройка: `zones` — путь (можно с `*`: `modules/*/domain`) и список имён.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
code: ClassVar[str] = CODE
|
|
54
|
+
Settings: ClassVar[type[CheckSettings]] = ConfinedTypesSettings
|
|
55
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
56
|
+
marker: ClassVar[str] = MARKER
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def run(
|
|
60
|
+
cls,
|
|
61
|
+
*,
|
|
62
|
+
file: ParsedFile,
|
|
63
|
+
settings: CheckSettings,
|
|
64
|
+
) -> Iterator[Violation]:
|
|
65
|
+
zones = settings_as(
|
|
66
|
+
settings=settings,
|
|
67
|
+
model=ConfinedTypesSettings,
|
|
68
|
+
code=CODE,
|
|
69
|
+
).zones
|
|
70
|
+
where = place(file=file)
|
|
71
|
+
if where is None:
|
|
72
|
+
return
|
|
73
|
+
forbidden = cls._forbidden(
|
|
74
|
+
where=where,
|
|
75
|
+
zones=zones,
|
|
76
|
+
)
|
|
77
|
+
if not forbidden:
|
|
78
|
+
return
|
|
79
|
+
for name, statement in cls._fields(tree=file.tree):
|
|
80
|
+
found = frozenset(walked(node=statement.annotation)) & forbidden
|
|
81
|
+
if not found:
|
|
82
|
+
continue
|
|
83
|
+
yield Violation.from_node(
|
|
84
|
+
node=statement,
|
|
85
|
+
path=file.path,
|
|
86
|
+
code=CODE,
|
|
87
|
+
message=(
|
|
88
|
+
f"{name} объявлено через {', '.join(sorted(found))}; "
|
|
89
|
+
f"здесь тип называет, что значение может держать"
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _forbidden(
|
|
95
|
+
*,
|
|
96
|
+
where: Place,
|
|
97
|
+
zones: dict[str, tuple[str, ...]],
|
|
98
|
+
) -> frozenset[str]:
|
|
99
|
+
"""Всё, что запрещено в этом месте: зоны складываются, а не спорят."""
|
|
100
|
+
return frozenset(
|
|
101
|
+
name for zone, names in zones.items() if where.holds(path=zone) for name in names
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _fields(*, tree: ast.Module) -> Iterator[tuple[str, ast.AnnAssign]]:
|
|
106
|
+
"""Поля классов модуля под именами вида `Класс.поле`."""
|
|
107
|
+
for node in ast.walk(tree):
|
|
108
|
+
if not isinstance(node, ast.ClassDef):
|
|
109
|
+
continue
|
|
110
|
+
for statement in node.body:
|
|
111
|
+
if not isinstance(statement, ast.AnnAssign):
|
|
112
|
+
continue
|
|
113
|
+
if not isinstance(statement.target, ast.Name):
|
|
114
|
+
continue
|
|
115
|
+
if frozenset(walked(node=statement.annotation)) & ASIDE:
|
|
116
|
+
continue
|
|
117
|
+
yield f"{node.name}.{statement.target.id}", statement
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Имя, написанное как константа, обещает неизменность — и говорит это типом."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
8
|
+
|
|
9
|
+
from py_checks.checks._kind import ENUMS
|
|
10
|
+
from py_checks.checks.types._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 = "constant-annotations"
|
|
20
|
+
|
|
21
|
+
FINAL: Final = "Final"
|
|
22
|
+
CLASS_VAR: Final = "ClassVar"
|
|
23
|
+
|
|
24
|
+
# Имя — это и есть обещание: строчная привязка на уровне модуля объявляет
|
|
25
|
+
# переменную и говорит об этом прямо, и правилу до неё дела нет.
|
|
26
|
+
NAME: Final = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConstantAnnotationsSettings(CheckSettings):
|
|
30
|
+
module: str = FINAL
|
|
31
|
+
inside_class: str = CLASS_VAR
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConstantAnnotations:
|
|
35
|
+
"""Падает, если константа не сказала типом, что она константа.
|
|
36
|
+
|
|
37
|
+
Имя `UPPER_SNAKE` — обещание, `Final` — то, что делает обещание
|
|
38
|
+
проверяемым: без него имя читается как константа, а ведёт себя как
|
|
39
|
+
переменная, и любой импортировавший модуль волен её перепривязать.
|
|
40
|
+
|
|
41
|
+
В теле класса слово другое, и по причине. `Final` там означает, что
|
|
42
|
+
атрибут нельзя переопределить вообще (PEP 591), а ограниченные примитивы
|
|
43
|
+
построены ровно на переопределении: `PositiveDecimal.BOUND` заменяет
|
|
44
|
+
`BOUND`, объявленный базой. `ClassVar` говорит «принадлежит классу, а не
|
|
45
|
+
экземпляру» и переопределение оставляет открытым — это верно про оба.
|
|
46
|
+
|
|
47
|
+
Перечисления не трогаются: член — это словарь, а не константа рядом с ним.
|
|
48
|
+
|
|
49
|
+
Настройки: `module`, `inside-class` — какими словами это говорится.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
code: ClassVar[str] = CODE
|
|
53
|
+
Settings: ClassVar[type[CheckSettings]] = ConstantAnnotationsSettings
|
|
54
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
55
|
+
marker: ClassVar[str] = MARKER
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def run(
|
|
59
|
+
cls,
|
|
60
|
+
*,
|
|
61
|
+
file: ParsedFile,
|
|
62
|
+
settings: CheckSettings,
|
|
63
|
+
) -> Iterator[Violation]:
|
|
64
|
+
limits = settings_as(
|
|
65
|
+
settings=settings,
|
|
66
|
+
model=ConstantAnnotationsSettings,
|
|
67
|
+
code=CODE,
|
|
68
|
+
)
|
|
69
|
+
yield from cls._constants(
|
|
70
|
+
file=file,
|
|
71
|
+
body=file.tree.body,
|
|
72
|
+
wanted=limits.module,
|
|
73
|
+
)
|
|
74
|
+
for node in ast.walk(file.tree):
|
|
75
|
+
if isinstance(node, ast.ClassDef) and not cls._enum(node=node):
|
|
76
|
+
yield from cls._constants(
|
|
77
|
+
file=file,
|
|
78
|
+
body=node.body,
|
|
79
|
+
wanted=limits.inside_class,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def _constants(
|
|
84
|
+
cls,
|
|
85
|
+
*,
|
|
86
|
+
file: ParsedFile,
|
|
87
|
+
body: list[ast.stmt],
|
|
88
|
+
wanted: str,
|
|
89
|
+
) -> Iterator[Violation]:
|
|
90
|
+
for statement in body:
|
|
91
|
+
name = cls._constant(node=statement)
|
|
92
|
+
if name is None:
|
|
93
|
+
continue
|
|
94
|
+
yield Violation.from_node(
|
|
95
|
+
node=statement,
|
|
96
|
+
path=file.path,
|
|
97
|
+
code=CODE,
|
|
98
|
+
message=f"{name} названо константой, но не объявлено через {wanted}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def _constant(cls, *, node: ast.stmt) -> str | None:
|
|
103
|
+
"""Имя константы, которой не хватает слова; иначе `None`."""
|
|
104
|
+
match node:
|
|
105
|
+
case ast.Assign(targets=[ast.Name(id=name)]) if NAME.match(name):
|
|
106
|
+
return name
|
|
107
|
+
case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation) if NAME.match(name):
|
|
108
|
+
return None if cls._says(node=annotation) else name
|
|
109
|
+
case _:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _says(*, node: ast.expr) -> bool:
|
|
114
|
+
"""Сказано ли в аннотации то самое слово — голым или с параметром."""
|
|
115
|
+
outer = node.value if isinstance(node, ast.Subscript) else node
|
|
116
|
+
match outer:
|
|
117
|
+
case ast.Name(id=name) | ast.Attribute(attr=name):
|
|
118
|
+
return name in (FINAL, CLASS_VAR)
|
|
119
|
+
case _:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _enum(*, node: ast.ClassDef) -> bool:
|
|
124
|
+
bases = {
|
|
125
|
+
base.attr if isinstance(base, ast.Attribute) else getattr(base, "id", "")
|
|
126
|
+
for base in node.bases
|
|
127
|
+
}
|
|
128
|
+
return bool(bases & ENUMS)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""dataclass объявлен так, чтобы значение оставалось значением."""
|
|
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.types._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 = "frozen-dataclasses"
|
|
20
|
+
|
|
21
|
+
DECORATOR: Final = "dataclass"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FrozenDataclassesSettings(ZonedSettings):
|
|
25
|
+
options: tuple[str, ...] = ("frozen", "slots", "kw_only")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FrozenDataclasses:
|
|
29
|
+
"""Падает, если dataclass в зоне объявлен без нужных аргументов.
|
|
30
|
+
|
|
31
|
+
Объект дела — это значение: собрали один раз и не меняли, поэтому
|
|
32
|
+
существующий объект не может исподтишка съехать в недопустимое состояние.
|
|
33
|
+
`frozen` это покупает, `slots` не даёт опечатке завести атрибут, которого
|
|
34
|
+
никто не объявлял, а `kw_only` — перепутать местами два поля одного типа:
|
|
35
|
+
у значения из четырёх строк порядок помнит только автор.
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
38
|
+
class Price: ...
|
|
39
|
+
|
|
40
|
+
Зона проектная: держать значения неизменяемыми имеет смысл там, где живут
|
|
41
|
+
правила, а не в конфиге и не в проводке. Без зон правило молчит.
|
|
42
|
+
|
|
43
|
+
Настройки: `zones`, `options`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
code: ClassVar[str] = CODE
|
|
47
|
+
Settings: ClassVar[type[CheckSettings]] = FrozenDataclassesSettings
|
|
48
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
49
|
+
marker: ClassVar[str] = MARKER
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def run(
|
|
53
|
+
cls,
|
|
54
|
+
*,
|
|
55
|
+
file: ParsedFile,
|
|
56
|
+
settings: CheckSettings,
|
|
57
|
+
) -> Iterator[Violation]:
|
|
58
|
+
limits = settings_as(
|
|
59
|
+
settings=settings,
|
|
60
|
+
model=FrozenDataclassesSettings,
|
|
61
|
+
code=CODE,
|
|
62
|
+
)
|
|
63
|
+
where = zoned(
|
|
64
|
+
file=file,
|
|
65
|
+
zones=limits.zones,
|
|
66
|
+
)
|
|
67
|
+
if where is None:
|
|
68
|
+
return
|
|
69
|
+
for node in ast.walk(file.tree):
|
|
70
|
+
if not isinstance(node, ast.ClassDef):
|
|
71
|
+
continue
|
|
72
|
+
decorator = cls._decorator(node=node)
|
|
73
|
+
if decorator is None:
|
|
74
|
+
continue
|
|
75
|
+
missing = tuple(
|
|
76
|
+
option for option in limits.options if option not in cls._enabled(node=decorator)
|
|
77
|
+
)
|
|
78
|
+
if not missing:
|
|
79
|
+
continue
|
|
80
|
+
yield Violation.from_node(
|
|
81
|
+
node=node,
|
|
82
|
+
path=file.path,
|
|
83
|
+
code=CODE,
|
|
84
|
+
message=(
|
|
85
|
+
f"dataclass {node.name} объявлен без "
|
|
86
|
+
f"{', '.join(f'{option}=True' for option in missing)}"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _decorator(*, node: ast.ClassDef) -> ast.expr | None:
|
|
92
|
+
"""Декоратор `@dataclass`, с аргументами или без."""
|
|
93
|
+
for item in node.decorator_list:
|
|
94
|
+
called = item.func if isinstance(item, ast.Call) else item
|
|
95
|
+
if name(node=called) == DECORATOR:
|
|
96
|
+
return item
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def _enabled(cls, *, node: ast.expr) -> frozenset[str]:
|
|
101
|
+
"""Аргументы декоратора, выставленные в `True`."""
|
|
102
|
+
if not isinstance(node, ast.Call):
|
|
103
|
+
return frozenset()
|
|
104
|
+
return frozenset(
|
|
105
|
+
keyword.arg
|
|
106
|
+
for keyword in node.keywords
|
|
107
|
+
if keyword.arg is not None and cls._true(node=keyword.value)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _true(*, node: ast.expr) -> bool:
|
|
112
|
+
return isinstance(node, ast.Constant) and node.value is True
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Командная строка.
|
|
2
|
+
|
|
3
|
+
Точка входа, через которую проверки вызывает pre-commit, и команды для ручного
|
|
4
|
+
запуска: прогнать проверки, показать список, объяснить правило.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from py_checks.cli._app import app, main
|
|
8
|
+
from py_checks.cli._protocols import Registrar
|
|
9
|
+
|
|
10
|
+
__all__ = ["Registrar", "app", "main"]
|
py_checks/cli/_app.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Приложение командной строки."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from py_checks.cli.commands import REGISTRARS
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
add_completion=False,
|
|
12
|
+
help="Проверки архитектурных соглашений проекта.",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
for register in REGISTRARS:
|
|
16
|
+
register(app=app)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Точка входа консольной команды `py-checks`."""
|
|
21
|
+
app()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Каким приложение видит команду."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Registrar(Protocol):
|
|
12
|
+
"""Функция, которая вешает команду на приложение.
|
|
13
|
+
|
|
14
|
+
Только так `_app` и знает о командах: имя, опции и справка остаются в
|
|
15
|
+
модуле команды, а приложение получает готовую регистрацию. Подпись одна и
|
|
16
|
+
та же и для `app.command(...)`, и для `app.add_typer(...)`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __call__(self, *, app: typer.Typer) -> None: ...
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Команды, по модулю на команду.
|
|
2
|
+
|
|
3
|
+
Модуль команды сам знает своё имя и свои опции: `_app` только собирает
|
|
4
|
+
приложение из их `register`. Когда у команды появятся подкоманды, её `register`
|
|
5
|
+
вызовет `add_typer`, и собирающий код это не заметит.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from py_checks.cli._protocols import Registrar
|
|
11
|
+
from py_checks.cli.commands._explain import register as register_explain
|
|
12
|
+
from py_checks.cli.commands._list import register as register_list
|
|
13
|
+
from py_checks.cli.commands._run import register as register_run
|
|
14
|
+
from py_checks.cli.commands._sync import register as register_sync
|
|
15
|
+
|
|
16
|
+
REGISTRARS: Final[tuple[Registrar, ...]] = (
|
|
17
|
+
register_run,
|
|
18
|
+
register_list,
|
|
19
|
+
register_explain,
|
|
20
|
+
register_sync,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["REGISTRARS", "Registrar"]
|