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
py_checks/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Реализации правил, по пакету на группу.
|
|
2
|
+
|
|
3
|
+
Внутри группы — по модулю на проверку. Правила, уехавшие в import-linter,
|
|
4
|
+
ruff и alembic, сюда не переносятся; что и куда ушло, записано в
|
|
5
|
+
`docs/current.md`.
|
|
6
|
+
|
|
7
|
+
Проверка — это один класс, и всё, чем она разбирает дерево, лежит внутри него:
|
|
8
|
+
помощники не расползаются по модулю, и видно, чьи они. Лист берёт
|
|
9
|
+
`@staticmethod`, помощник, который зовёт другого помощника, — `@classmethod`,
|
|
10
|
+
чтобы звать через `cls`, а не по имени класса.
|
|
11
|
+
|
|
12
|
+
Внутри класса живёт то, что правилу и принадлежит. Слово языка правилу не
|
|
13
|
+
принадлежит: «как этот узел зовут» — это `_names.py`, «где лежит этот файл» —
|
|
14
|
+
`_location.py`, «что тут объявлено» — `_kind.py`. Шесть одинаковых `_name` по
|
|
15
|
+
классам — это не шесть помощников, а один, забытый в шести местах.
|
|
16
|
+
|
|
17
|
+
Слово, которым правило снимают с кода, объявлено один раз на группу — в
|
|
18
|
+
`_marker.py` пакета. Оно снимает любую проверку группы; чтобы снять ровно
|
|
19
|
+
одну, есть `# check-ok: <код>: <причина>`.
|
|
20
|
+
"""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Что объявлено в модуле: класс, порт, dataclass, алиас.
|
|
2
|
+
|
|
3
|
+
Правила размещения говорят о видах объявлений, а не о синтаксисе: «в `dto/`
|
|
4
|
+
лежат dataclass-ы», «в `ports/` — протоколы». Вид узнаётся один раз здесь, и
|
|
5
|
+
этим же знанием пользуются все правила группы.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import TYPE_CHECKING, Final
|
|
14
|
+
|
|
15
|
+
from py_checks.checks._names import name, names
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
ABSTRACT: Final[frozenset[str]] = frozenset({"Protocol", "ABC"})
|
|
21
|
+
ABSTRACT_METACLASS: Final = "ABCMeta"
|
|
22
|
+
MODEL: Final = "BaseModel"
|
|
23
|
+
DATACLASS: Final = "dataclass"
|
|
24
|
+
ENUMS: Final[frozenset[str]] = frozenset(
|
|
25
|
+
{"Enum", "StrEnum", "IntEnum", "Flag", "IntFlag", "ReprEnum"}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ERROR_BASES: Final[frozenset[str]] = frozenset({"Exception", "BaseException"})
|
|
29
|
+
|
|
30
|
+
# Исключение проекта наследуется от своего же корня (`class NotFound(DomainError)`),
|
|
31
|
+
# а не от `Exception`, — но имя корня кончается так же, и по нему вид узнаётся,
|
|
32
|
+
# не читая чужой модуль.
|
|
33
|
+
ERROR_SUFFIXES: Final[tuple[str, ...]] = ("Error", "Exception")
|
|
34
|
+
|
|
35
|
+
# Присваивание, которым объявляют имя для типа, а не значение.
|
|
36
|
+
ALIAS_ANNOTATION: Final = "TypeAlias"
|
|
37
|
+
ALIAS_FACTORIES: Final[frozenset[str]] = frozenset(
|
|
38
|
+
{"TypeVar", "NewType", "ParamSpec", "TypeAliasType"},
|
|
39
|
+
)
|
|
40
|
+
ALIAS_CONSTRUCTORS: Final[frozenset[str]] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"dict",
|
|
43
|
+
"list",
|
|
44
|
+
"set",
|
|
45
|
+
"tuple",
|
|
46
|
+
"frozenset",
|
|
47
|
+
"type",
|
|
48
|
+
"Union",
|
|
49
|
+
"Optional",
|
|
50
|
+
"Literal",
|
|
51
|
+
"Callable",
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Kind(StrEnum):
|
|
57
|
+
"""Виды объявлений, о которых говорят правила размещения."""
|
|
58
|
+
|
|
59
|
+
CLASS = "class"
|
|
60
|
+
PORT = "port"
|
|
61
|
+
DATACLASS = "dataclass"
|
|
62
|
+
MODEL = "model"
|
|
63
|
+
ALIAS = "alias"
|
|
64
|
+
ENUM = "enum"
|
|
65
|
+
ERROR = "error"
|
|
66
|
+
FUNCTION = "function"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def said(self) -> str:
|
|
70
|
+
"""Как вид называется в сообщении: `str.title` занят самим `str`."""
|
|
71
|
+
return NAMES[self]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
NAMES: Final[dict[Kind, str]] = {
|
|
75
|
+
Kind.CLASS: "класс",
|
|
76
|
+
Kind.PORT: "порт",
|
|
77
|
+
Kind.DATACLASS: "dataclass",
|
|
78
|
+
Kind.MODEL: "модель",
|
|
79
|
+
Kind.ALIAS: "алиас",
|
|
80
|
+
Kind.ENUM: "перечисление",
|
|
81
|
+
Kind.ERROR: "исключение",
|
|
82
|
+
Kind.FUNCTION: "функция",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class Declaration:
|
|
88
|
+
"""Объявление верхнего уровня: имя, вид и узел.
|
|
89
|
+
|
|
90
|
+
`kind` пуст, когда вид по одному файлу не виден: класс с базой из другого
|
|
91
|
+
модуля. Имя у такого всё равно есть, и правило, которое смотрит на суффикс,
|
|
92
|
+
им пользуется.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
kind: Kind | None
|
|
97
|
+
node: ast.stmt
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def declarations(*, tree: ast.Module) -> Iterator[Declaration]:
|
|
101
|
+
"""Всё, что модуль объявляет.
|
|
102
|
+
|
|
103
|
+
Импорты, константы, блоки `if TYPE_CHECKING` и докстринг сюда не попадают:
|
|
104
|
+
они разрешены везде, и правилам размещения о них говорить нечего.
|
|
105
|
+
"""
|
|
106
|
+
for node in tree.body:
|
|
107
|
+
match node:
|
|
108
|
+
case ast.ClassDef(name=name):
|
|
109
|
+
yield Declaration(
|
|
110
|
+
name=name,
|
|
111
|
+
kind=_class(node=node),
|
|
112
|
+
node=node,
|
|
113
|
+
)
|
|
114
|
+
case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name):
|
|
115
|
+
yield Declaration(
|
|
116
|
+
name=name,
|
|
117
|
+
kind=Kind.FUNCTION,
|
|
118
|
+
node=node,
|
|
119
|
+
)
|
|
120
|
+
case ast.AnnAssign(target=ast.Name(id=name)) | ast.Assign(targets=[ast.Name(id=name)]):
|
|
121
|
+
if _alias(node=node):
|
|
122
|
+
yield Declaration(
|
|
123
|
+
name=name,
|
|
124
|
+
kind=Kind.ALIAS,
|
|
125
|
+
node=node,
|
|
126
|
+
)
|
|
127
|
+
case _:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _class(*, node: ast.ClassDef) -> Kind | None:
|
|
132
|
+
"""Вид класса; `None`, если по одному файлу его не видно.
|
|
133
|
+
|
|
134
|
+
База, объявленная в другом модуле, — это вид, которого отсюда не видно:
|
|
135
|
+
`class CodeMismatchResponse(ErrorResponse)` — pydantic-модель, но узнать
|
|
136
|
+
это можно только прочитав тот модуль. Про такой класс правило молчит:
|
|
137
|
+
заблудившийся хелпер, ради которого оно написано, базы обычно не имеет.
|
|
138
|
+
"""
|
|
139
|
+
bases = frozenset(names(nodes=node.bases))
|
|
140
|
+
if bases & ABSTRACT or _metaclass(node=node):
|
|
141
|
+
return Kind.PORT
|
|
142
|
+
if MODEL in bases:
|
|
143
|
+
return Kind.MODEL
|
|
144
|
+
if bases & ENUMS:
|
|
145
|
+
return Kind.ENUM
|
|
146
|
+
if DATACLASS in frozenset(names(nodes=node.decorator_list)):
|
|
147
|
+
return Kind.DATACLASS
|
|
148
|
+
if _error(bases=bases):
|
|
149
|
+
return Kind.ERROR
|
|
150
|
+
if bases:
|
|
151
|
+
return None
|
|
152
|
+
return Kind.CLASS
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _error(*, bases: frozenset[str]) -> bool:
|
|
156
|
+
return bool(bases & ERROR_BASES) or any(base.endswith(ERROR_SUFFIXES) for base in bases)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _metaclass(*, node: ast.ClassDef) -> bool:
|
|
160
|
+
return any(
|
|
161
|
+
keyword.arg == "metaclass" and name(node=keyword.value) == ABSTRACT_METACLASS
|
|
162
|
+
for keyword in node.keywords
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _alias(*, node: ast.AnnAssign | ast.Assign) -> bool:
|
|
167
|
+
"""Присваивание, объявляющее имя для типа.
|
|
168
|
+
|
|
169
|
+
Три вида: с аннотацией `TypeAlias`, вызов фабрики вроде `NewType`, и голое
|
|
170
|
+
`Row = dict[str, int]` — имя для формы, а не значение.
|
|
171
|
+
"""
|
|
172
|
+
if isinstance(node, ast.AnnAssign) and name(node=node.annotation) == ALIAS_ANNOTATION:
|
|
173
|
+
return True
|
|
174
|
+
if node.value is None:
|
|
175
|
+
return False
|
|
176
|
+
match node.value:
|
|
177
|
+
case ast.Call(func=function):
|
|
178
|
+
return name(node=function) in ALIAS_FACTORIES
|
|
179
|
+
case ast.Subscript(value=value):
|
|
180
|
+
return name(node=value) in ALIAS_CONSTRUCTORS
|
|
181
|
+
case ast.BinOp(op=ast.BitOr()):
|
|
182
|
+
return True
|
|
183
|
+
case _:
|
|
184
|
+
return False
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Где файл лежит внутри своего пакета.
|
|
2
|
+
|
|
3
|
+
Правило про импорты говорит о месте: «sqlalchemy живёт в `infra/database`».
|
|
4
|
+
Место считается от корня исходников, который известен ядру из настроек.
|
|
5
|
+
Угадывать его по `__init__.py` нельзя: папка без него встречается и посреди
|
|
6
|
+
пакета — в одном из сервисов так лежит половина репозиториев.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Final
|
|
13
|
+
|
|
14
|
+
from py_checks.config import CheckSettings
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from py_checks.core import ParsedFile
|
|
21
|
+
|
|
22
|
+
INIT: Final = "__init__"
|
|
23
|
+
|
|
24
|
+
SEPARATOR: Final = "/"
|
|
25
|
+
|
|
26
|
+
# Один любой кусок пути: `modules/*/domain` — домен любого модуля.
|
|
27
|
+
ANY: Final = "*"
|
|
28
|
+
|
|
29
|
+
# Пакет и хотя бы один шаг внутри него: файл, лежащий прямо в корне исходников,
|
|
30
|
+
# ни в каком пакете не находится, и говорить о его месте нечего.
|
|
31
|
+
INSIDE: Final = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Place:
|
|
36
|
+
"""Корневой пакет файла и его адрес внутри этого пакета."""
|
|
37
|
+
|
|
38
|
+
package: str
|
|
39
|
+
parts: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def where(self) -> str:
|
|
43
|
+
return "/".join(self.parts)
|
|
44
|
+
|
|
45
|
+
def under(self, *, prefix: str) -> bool:
|
|
46
|
+
"""Лежит ли файл под этим путём; пустой путь не разрешает ничего."""
|
|
47
|
+
if not prefix:
|
|
48
|
+
return False
|
|
49
|
+
wanted = tuple(prefix.split(SEPARATOR))
|
|
50
|
+
return self.parts[: len(wanted)] == wanted
|
|
51
|
+
|
|
52
|
+
def anywhere(self, *, zones: Iterable[str]) -> bool:
|
|
53
|
+
"""Лежит ли файл хоть в одной из этих зон.
|
|
54
|
+
|
|
55
|
+
Зоны складываются, а не спорят: правило работает там, где его о том
|
|
56
|
+
попросили, и молчит везде остальном.
|
|
57
|
+
"""
|
|
58
|
+
return any(self.holds(path=zone) for zone in zones)
|
|
59
|
+
|
|
60
|
+
def holds(self, *, path: str) -> bool:
|
|
61
|
+
"""Идут ли эти куски адреса подряд где угодно внутри него.
|
|
62
|
+
|
|
63
|
+
Адрес включает имя модуля, поэтому `exceptions` подходит и как
|
|
64
|
+
директория, и как файл `exceptions.py`: для словаря отказов это одно и
|
|
65
|
+
то же место.
|
|
66
|
+
"""
|
|
67
|
+
return (
|
|
68
|
+
_run(
|
|
69
|
+
parts=self.parts,
|
|
70
|
+
wanted=path,
|
|
71
|
+
)
|
|
72
|
+
is not None
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def within(self, *, directory: str) -> int | None:
|
|
76
|
+
"""Где кончается самое глубокое вхождение этих директорий, или `None`.
|
|
77
|
+
|
|
78
|
+
Имя файла в счёт не идёт: речь о директории, а не о модуле. Конец, а не
|
|
79
|
+
начало, потому что сравнивать вложенность двух ключей разной длины
|
|
80
|
+
можно только по тому, где они кончаются, — глубже тот, кто кончается
|
|
81
|
+
позже.
|
|
82
|
+
"""
|
|
83
|
+
return _run(
|
|
84
|
+
parts=self.parts[:-1],
|
|
85
|
+
wanted=directory,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ZonedSettings(CheckSettings):
|
|
90
|
+
"""Настройки правила, которое работает не везде.
|
|
91
|
+
|
|
92
|
+
`zones` — места, где правило судит; пустой список означает, что правило
|
|
93
|
+
молчит. Молчит, а не судит всюду: соглашение про репозитории неверно для
|
|
94
|
+
сценариев, и правило, которому забыли назвать зону, лучше не скажет ничего,
|
|
95
|
+
чем скажет неправду по всему дереву.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
zones: tuple[str, ...] = ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def zoned(
|
|
102
|
+
*,
|
|
103
|
+
file: ParsedFile,
|
|
104
|
+
zones: Iterable[str],
|
|
105
|
+
) -> Place | None:
|
|
106
|
+
"""Адрес файла, если он в одной из зон; иначе `None` — правилу тут нечего сказать."""
|
|
107
|
+
where = place(file=file)
|
|
108
|
+
if where is None or not where.anywhere(zones=zones):
|
|
109
|
+
return None
|
|
110
|
+
return where
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def place(*, file: ParsedFile) -> Place | None:
|
|
114
|
+
"""Адрес файла; `None`, если корень исходников неизвестен или файл вне его."""
|
|
115
|
+
if file.source is None:
|
|
116
|
+
return None
|
|
117
|
+
relative = _relative(
|
|
118
|
+
path=file.path,
|
|
119
|
+
source=file.source,
|
|
120
|
+
)
|
|
121
|
+
if relative is None or len(relative) < INSIDE:
|
|
122
|
+
return None
|
|
123
|
+
parts = relative[1:]
|
|
124
|
+
if parts[-1] == INIT:
|
|
125
|
+
parts = parts[:-1]
|
|
126
|
+
return Place(
|
|
127
|
+
package=relative[0],
|
|
128
|
+
parts=parts,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _relative(
|
|
133
|
+
*,
|
|
134
|
+
path: Path,
|
|
135
|
+
source: Path,
|
|
136
|
+
) -> tuple[str, ...] | None:
|
|
137
|
+
try:
|
|
138
|
+
inside = path.resolve().relative_to(source.resolve())
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None
|
|
141
|
+
return inside.with_suffix("").parts
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run(
|
|
145
|
+
*,
|
|
146
|
+
parts: tuple[str, ...],
|
|
147
|
+
wanted: str,
|
|
148
|
+
) -> int | None:
|
|
149
|
+
"""Конец последнего вхождения подряд идущих кусков пути.
|
|
150
|
+
|
|
151
|
+
`*` подходит любому одному куску: `modules/*/domain` — это домен любого
|
|
152
|
+
модуля, и перечислять модули по именам не нужно.
|
|
153
|
+
"""
|
|
154
|
+
needle = tuple(wanted.split(SEPARATOR))
|
|
155
|
+
span = len(needle)
|
|
156
|
+
ends = [
|
|
157
|
+
start + span
|
|
158
|
+
for start in range(len(parts) - span + 1)
|
|
159
|
+
if _same(
|
|
160
|
+
found=parts[start : start + span],
|
|
161
|
+
needle=needle,
|
|
162
|
+
)
|
|
163
|
+
]
|
|
164
|
+
return max(ends) if ends else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _same(
|
|
168
|
+
*,
|
|
169
|
+
found: tuple[str, ...],
|
|
170
|
+
needle: tuple[str, ...],
|
|
171
|
+
) -> bool:
|
|
172
|
+
return all(wanted in (ANY, part) for part, wanted in zip(found, needle, strict=True))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Имена: как их читают в дереве и как сверяют с записанным в настройках.
|
|
2
|
+
|
|
3
|
+
Правила говорят именами: «база называется `Base`», «эта колонка объявлена
|
|
4
|
+
`mapped_column`», «`datetime.now` берут портом». Спросить у узла, как его
|
|
5
|
+
зовут, — не дело каждого правила: способ один на язык, и живёт он здесь.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from typing import TYPE_CHECKING, Final
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterable, Iterator
|
|
15
|
+
|
|
16
|
+
DOT: Final = "."
|
|
17
|
+
ANY: Final = "*"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def name(*, node: ast.expr) -> str:
|
|
21
|
+
"""Как это зовут; пустая строка, если именем это не назвать.
|
|
22
|
+
|
|
23
|
+
Строковая аннотация (`-> "Order"`) — это имя, написанное буквами:
|
|
24
|
+
отличать её от обычной незачем, автор имел в виду то же самое. А вот вызов
|
|
25
|
+
и подписанное выражение именем не считаются: `tuple(...)[:1]` — это срез
|
|
26
|
+
списка, и правило про формы типов не должно принять его за объявление.
|
|
27
|
+
Развернуть их просит тот, кто читает базы и декораторы, — `names`.
|
|
28
|
+
"""
|
|
29
|
+
match node:
|
|
30
|
+
case ast.Name(id=found) | ast.Attribute(attr=found) | ast.Constant(value=str() as found):
|
|
31
|
+
return found
|
|
32
|
+
case _:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def names(*, nodes: Iterable[ast.expr]) -> Iterator[str]:
|
|
37
|
+
"""Имена перечисленного: баз класса, декораторов.
|
|
38
|
+
|
|
39
|
+
Здесь имя и правда стоит за вызовом и за подстановкой: `@dataclass(frozen=True)`
|
|
40
|
+
— это `dataclass`, `Generic[T]` — это `Generic`.
|
|
41
|
+
"""
|
|
42
|
+
for node in nodes:
|
|
43
|
+
if found := name(node=_head(node=node)):
|
|
44
|
+
yield found
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _head(*, node: ast.expr) -> ast.expr:
|
|
48
|
+
"""Из чего сделано выражение: с кого начали, прежде чем звать и подставлять."""
|
|
49
|
+
match node:
|
|
50
|
+
case ast.Call(func=inner) | ast.Subscript(value=inner):
|
|
51
|
+
return _head(node=inner)
|
|
52
|
+
case _:
|
|
53
|
+
return node
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def walked(*, node: ast.expr) -> Iterator[str]:
|
|
57
|
+
"""Все имена внутри выражения, на любой глубине и в порядке написания.
|
|
58
|
+
|
|
59
|
+
`dict[str, Order | None]` — это `dict`, `str`, `Order`: правило про
|
|
60
|
+
аннотации смотрит на то, что в ней названо, а не на её форму. Имя, взятое
|
|
61
|
+
в кавычки, — такое же имя: ссылка вперёд написана строкой не по смыслу, а
|
|
62
|
+
потому что в этом месте класса ещё нет.
|
|
63
|
+
"""
|
|
64
|
+
for child in ast.walk(node):
|
|
65
|
+
if isinstance(child, ast.Name | ast.Attribute | ast.Constant) and (
|
|
66
|
+
found := name(node=child)
|
|
67
|
+
):
|
|
68
|
+
yield found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def matches(
|
|
72
|
+
*,
|
|
73
|
+
called: str,
|
|
74
|
+
pattern: str,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""Хвост имени: `datetime.now` — это и `datetime.datetime.now`.
|
|
77
|
+
|
|
78
|
+
`random.*` подходит любому вызову модуля целиком: важен не последний
|
|
79
|
+
кусок, а то, у кого его взяли.
|
|
80
|
+
"""
|
|
81
|
+
parts = called.split(DOT)
|
|
82
|
+
wanted = pattern.split(DOT)
|
|
83
|
+
if wanted[-1] == ANY:
|
|
84
|
+
head = wanted[:-1]
|
|
85
|
+
return len(parts) > len(head) and parts[-len(head) - 1 : -1] == head
|
|
86
|
+
return len(parts) >= len(wanted) and parts[-len(wanted) :] == wanted
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""API и события.
|
|
2
|
+
|
|
3
|
+
Маршрут объявляет в декораторе всё, чем он будет описан в схеме: механизм у
|
|
4
|
+
фреймворка есть, требования писать — нет.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from py_checks.checks.api._endpoint_declarations import (
|
|
8
|
+
EndpointDeclarations,
|
|
9
|
+
EndpointDeclarationsSettings,
|
|
10
|
+
)
|
|
11
|
+
from py_checks.checks.api._marker import MARKER
|
|
12
|
+
|
|
13
|
+
__all__ = ["MARKER", "EndpointDeclarations", "EndpointDeclarationsSettings"]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Маршрут объявляет в декораторе всё, чем он будет описан в схеме."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.api._marker import MARKER
|
|
10
|
+
from py_checks.config import CheckSettings
|
|
11
|
+
from py_checks.core import Scope, Violation, settings_as
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
from py_checks.core import ParsedFile
|
|
17
|
+
|
|
18
|
+
CODE: Final = "endpoint-declarations"
|
|
19
|
+
|
|
20
|
+
PATH: Final = "path"
|
|
21
|
+
STATUS: Final = "status_code"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EndpointDeclarationsSettings(CheckSettings):
|
|
25
|
+
methods: tuple[str, ...] = ()
|
|
26
|
+
required: tuple[str, ...] = ()
|
|
27
|
+
body: str | None = None
|
|
28
|
+
bodiless: tuple[int, ...] = ()
|
|
29
|
+
exempt: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EndpointDeclarations:
|
|
33
|
+
"""Падает, если маршрут не сказал, чем он отвечает.
|
|
34
|
+
|
|
35
|
+
Декоратор маршрута — это контракт. Кто читает сгенерированную схему —
|
|
36
|
+
соседний сервис, человек, пишущий клиент, — читает только то, что объявил
|
|
37
|
+
декоратор, и поле, которого там нет, для него не существует, что бы ни
|
|
38
|
+
возвращало тело функции.
|
|
39
|
+
|
|
40
|
+
Механизм у фреймворка есть, требования писать — нет. Маршрут без `summary`
|
|
41
|
+
попадёт в схему с именем функции вместо описания, а с пустым `responses` —
|
|
42
|
+
с обещанием, что отказов у него не бывает: успех из подписи выводится,
|
|
43
|
+
отказы — нет, и ничто в ней не говорит, что этот маршрут отвечает 409.
|
|
44
|
+
|
|
45
|
+
Путь пишется словом `path=`: позиционный первый аргумент — единственное в
|
|
46
|
+
декораторе, чей смысл зависит от позиции.
|
|
47
|
+
|
|
48
|
+
Тело объявляется отдельно от прочего и не требуется там, где его не
|
|
49
|
+
бывает: 204, 205 и 304 — это статусы без тела, и модель ответа рядом с
|
|
50
|
+
ними обещает то, что протокол запрещает. Статус, записанный не числом и не
|
|
51
|
+
членом `HTTPStatus`, читается как неизвестный, а неизвестный считается
|
|
52
|
+
имеющим тело: проверка, сработавшая зря, снимается пометкой, а
|
|
53
|
+
промолчавшая — это контракт, которого никто не хватится.
|
|
54
|
+
|
|
55
|
+
Маршрут, выведенный из схемы (`include_in_schema=False`), правило не
|
|
56
|
+
трогает: схема — это то, что оно защищает, а такого маршрута в ней нет.
|
|
57
|
+
|
|
58
|
+
Настройки: `methods`, `required`, `body`, `bodiless`, `exempt`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
code: ClassVar[str] = CODE
|
|
62
|
+
Settings: ClassVar[type[CheckSettings]] = EndpointDeclarationsSettings
|
|
63
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
64
|
+
marker: ClassVar[str] = MARKER
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def run(
|
|
68
|
+
cls,
|
|
69
|
+
*,
|
|
70
|
+
file: ParsedFile,
|
|
71
|
+
settings: CheckSettings,
|
|
72
|
+
) -> Iterator[Violation]:
|
|
73
|
+
limits = settings_as(
|
|
74
|
+
settings=settings,
|
|
75
|
+
model=EndpointDeclarationsSettings,
|
|
76
|
+
code=CODE,
|
|
77
|
+
)
|
|
78
|
+
if not limits.methods:
|
|
79
|
+
return
|
|
80
|
+
for node in ast.walk(file.tree):
|
|
81
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
82
|
+
continue
|
|
83
|
+
for decorator in node.decorator_list:
|
|
84
|
+
if not isinstance(decorator, ast.Call):
|
|
85
|
+
continue
|
|
86
|
+
if not cls._route(
|
|
87
|
+
node=decorator,
|
|
88
|
+
methods=limits.methods,
|
|
89
|
+
):
|
|
90
|
+
continue
|
|
91
|
+
yield from cls._judged(
|
|
92
|
+
route=decorator,
|
|
93
|
+
file=file,
|
|
94
|
+
limits=limits,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def _judged(
|
|
99
|
+
cls,
|
|
100
|
+
*,
|
|
101
|
+
route: ast.Call,
|
|
102
|
+
file: ParsedFile,
|
|
103
|
+
limits: EndpointDeclarationsSettings,
|
|
104
|
+
) -> Iterator[Violation]:
|
|
105
|
+
if cls._unpublished(
|
|
106
|
+
route=route,
|
|
107
|
+
exempt=limits.exempt,
|
|
108
|
+
):
|
|
109
|
+
return
|
|
110
|
+
named = cls._name(route=route)
|
|
111
|
+
declared = {keyword.arg for keyword in route.keywords}
|
|
112
|
+
if route.args:
|
|
113
|
+
yield cls._violation(
|
|
114
|
+
route=route,
|
|
115
|
+
file=file,
|
|
116
|
+
message=f"{named} передаёт путь позиционно; напиши {PATH}=",
|
|
117
|
+
)
|
|
118
|
+
for wanted in limits.required:
|
|
119
|
+
if wanted in declared or (wanted == PATH and route.args):
|
|
120
|
+
continue
|
|
121
|
+
yield cls._violation(
|
|
122
|
+
route=route,
|
|
123
|
+
file=file,
|
|
124
|
+
message=f"{named} не объявляет {wanted}=",
|
|
125
|
+
)
|
|
126
|
+
if limits.body is None or limits.body in declared:
|
|
127
|
+
return
|
|
128
|
+
if cls._status(route=route) in limits.bodiless:
|
|
129
|
+
return
|
|
130
|
+
yield cls._violation(
|
|
131
|
+
route=route,
|
|
132
|
+
file=file,
|
|
133
|
+
message=(
|
|
134
|
+
f"{named} не объявляет {limits.body}= и отвечает телом; "
|
|
135
|
+
f"без него отвечают {', '.join(str(status) for status in limits.bodiless)}"
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _violation(
|
|
141
|
+
*,
|
|
142
|
+
route: ast.Call,
|
|
143
|
+
file: ParsedFile,
|
|
144
|
+
message: str,
|
|
145
|
+
) -> Violation:
|
|
146
|
+
return Violation.from_node(
|
|
147
|
+
node=route,
|
|
148
|
+
path=file.path,
|
|
149
|
+
code=CODE,
|
|
150
|
+
message=message,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _route(
|
|
155
|
+
*,
|
|
156
|
+
node: ast.Call,
|
|
157
|
+
methods: tuple[str, ...],
|
|
158
|
+
) -> bool:
|
|
159
|
+
"""Декоратор вида `<что-то>.<метод>(...)`, а не `@app.middleware(...)`."""
|
|
160
|
+
return isinstance(node.func, ast.Attribute) and node.func.attr in methods
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _unpublished(
|
|
164
|
+
*,
|
|
165
|
+
route: ast.Call,
|
|
166
|
+
exempt: str | None,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Маршрут сказал, что в схеме его нет."""
|
|
169
|
+
return exempt is not None and any(
|
|
170
|
+
keyword.arg == exempt
|
|
171
|
+
and isinstance(keyword.value, ast.Constant)
|
|
172
|
+
and keyword.value.value is False
|
|
173
|
+
for keyword in route.keywords
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _name(*, route: ast.Call) -> str:
|
|
178
|
+
"""`POST '/tickets'` — как бы путь ни был записан.
|
|
179
|
+
|
|
180
|
+
Путь читается и из слова, и из первого аргумента: сообщение обязано
|
|
181
|
+
назвать маршрут и в том файле, где слова как раз нет, — «GET не
|
|
182
|
+
объявляет summary» в модуле с шестью GET не называет ничего.
|
|
183
|
+
"""
|
|
184
|
+
attribute = route.func
|
|
185
|
+
method = attribute.attr.upper() if isinstance(attribute, ast.Attribute) else ""
|
|
186
|
+
written = [keyword.value for keyword in route.keywords if keyword.arg == PATH]
|
|
187
|
+
path = route.args[:1] + written
|
|
188
|
+
return f"{method} {ast.unparse(path[0])}" if path else method
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _status(*, route: ast.Call) -> int | None:
|
|
192
|
+
"""Статус, если он записан так, что его видно по файлу."""
|
|
193
|
+
for keyword in route.keywords:
|
|
194
|
+
if keyword.arg != STATUS:
|
|
195
|
+
continue
|
|
196
|
+
match keyword.value:
|
|
197
|
+
case ast.Constant(value=int() as status):
|
|
198
|
+
return status
|
|
199
|
+
case ast.Attribute(value=ast.Name(id="HTTPStatus"), attr=name):
|
|
200
|
+
found = getattr(HTTPStatus, name, None)
|
|
201
|
+
return None if found is None else int(found)
|
|
202
|
+
case _:
|
|
203
|
+
return None
|
|
204
|
+
return None
|