python-checks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. py_checks/__init__.py +2 -0
  2. py_checks/checks/__init__.py +20 -0
  3. py_checks/checks/_kind.py +184 -0
  4. py_checks/checks/_location.py +172 -0
  5. py_checks/checks/_names.py +86 -0
  6. py_checks/checks/api/__init__.py +13 -0
  7. py_checks/checks/api/_endpoint_declarations.py +204 -0
  8. py_checks/checks/api/_marker.py +5 -0
  9. py_checks/checks/calls/__init__.py +13 -0
  10. py_checks/checks/calls/_confined_functions.py +102 -0
  11. py_checks/checks/calls/_marker.py +5 -0
  12. py_checks/checks/database/__init__.py +39 -0
  13. py_checks/checks/database/_bound_checks.py +186 -0
  14. py_checks/checks/database/_confined_calls.py +115 -0
  15. py_checks/checks/database/_marker.py +5 -0
  16. py_checks/checks/database/_model_boundary.py +236 -0
  17. py_checks/checks/database/_model_columns.py +241 -0
  18. py_checks/checks/database/_raw_sql.py +108 -0
  19. py_checks/checks/database/_schema_drift.py +271 -0
  20. py_checks/checks/database/_statement_keys.py +169 -0
  21. py_checks/checks/effects/__init__.py +19 -0
  22. py_checks/checks/effects/_determinism.py +105 -0
  23. py_checks/checks/effects/_log_events.py +120 -0
  24. py_checks/checks/effects/_marker.py +5 -0
  25. py_checks/checks/hygiene/__init__.py +14 -0
  26. py_checks/checks/hygiene/_dependency_bounds.py +185 -0
  27. py_checks/checks/hygiene/_marker.py +5 -0
  28. py_checks/checks/imports/__init__.py +25 -0
  29. py_checks/checks/imports/_confined.py +93 -0
  30. py_checks/checks/imports/_marker.py +7 -0
  31. py_checks/checks/imports/_sealed.py +100 -0
  32. py_checks/checks/imports/_statements.py +52 -0
  33. py_checks/checks/placement/__init__.py +38 -0
  34. py_checks/checks/placement/_class_modules.py +106 -0
  35. py_checks/checks/placement/_class_placement.py +129 -0
  36. py_checks/checks/placement/_marker.py +7 -0
  37. py_checks/checks/placement/_operation_shape.py +387 -0
  38. py_checks/checks/placement/_required_class.py +179 -0
  39. py_checks/checks/signatures/__init__.py +33 -0
  40. py_checks/checks/signatures/_function_length.py +90 -0
  41. py_checks/checks/signatures/_functions.py +92 -0
  42. py_checks/checks/signatures/_keyword_only.py +148 -0
  43. py_checks/checks/signatures/_marker.py +7 -0
  44. py_checks/checks/signatures/_module_length.py +64 -0
  45. py_checks/checks/signatures/_nesting.py +156 -0
  46. py_checks/checks/signatures/_signature_layout.py +231 -0
  47. py_checks/checks/types/__init__.py +36 -0
  48. py_checks/checks/types/_annotation_shapes.py +127 -0
  49. py_checks/checks/types/_config_fields.py +236 -0
  50. py_checks/checks/types/_confined_types.py +117 -0
  51. py_checks/checks/types/_constant_annotations.py +128 -0
  52. py_checks/checks/types/_frozen_dataclasses.py +112 -0
  53. py_checks/checks/types/_marker.py +5 -0
  54. py_checks/cli/__init__.py +10 -0
  55. py_checks/cli/_app.py +21 -0
  56. py_checks/cli/_protocols.py +19 -0
  57. py_checks/cli/commands/__init__.py +23 -0
  58. py_checks/cli/commands/_explain.py +28 -0
  59. py_checks/cli/commands/_list.py +62 -0
  60. py_checks/cli/commands/_run.py +167 -0
  61. py_checks/cli/commands/_summary.py +20 -0
  62. py_checks/cli/commands/_sync.py +80 -0
  63. py_checks/config/__init__.py +31 -0
  64. py_checks/config/_base.py +26 -0
  65. py_checks/config/_config.py +76 -0
  66. py_checks/config/_constants.py +31 -0
  67. py_checks/config/_errors.py +12 -0
  68. py_checks/config/_loader.py +133 -0
  69. py_checks/config/_toml.py +24 -0
  70. py_checks/contracts/__init__.py +26 -0
  71. py_checks/contracts/_constants.py +18 -0
  72. py_checks/contracts/_layout.py +63 -0
  73. py_checks/contracts/_render.py +217 -0
  74. py_checks/contracts/_settings.py +37 -0
  75. py_checks/core/__init__.py +56 -0
  76. py_checks/core/_constants.py +15 -0
  77. py_checks/core/_discovery.py +57 -0
  78. py_checks/core/_edit.py +92 -0
  79. py_checks/core/_errors.py +35 -0
  80. py_checks/core/_fixer.py +55 -0
  81. py_checks/core/_format.py +30 -0
  82. py_checks/core/_markers.py +220 -0
  83. py_checks/core/_protocols.py +88 -0
  84. py_checks/core/_registry.py +77 -0
  85. py_checks/core/_report.py +45 -0
  86. py_checks/core/_runner.py +178 -0
  87. py_checks/core/_settings.py +44 -0
  88. py_checks/core/_source.py +94 -0
  89. py_checks/core/_violation.py +73 -0
  90. py_checks/environment/__init__.py +14 -0
  91. py_checks/environment/_constants.py +9 -0
  92. py_checks/environment/_render.py +227 -0
  93. py_checks/environment/_settings.py +35 -0
  94. py_checks/py.typed +0 -0
  95. py_checks/sync/__init__.py +16 -0
  96. py_checks/sync/_sync.py +60 -0
  97. python_checks-0.1.0.dist-info/METADATA +327 -0
  98. python_checks-0.1.0.dist-info/RECORD +101 -0
  99. python_checks-0.1.0.dist-info/WHEEL +4 -0
  100. python_checks-0.1.0.dist-info/entry_points.txt +33 -0
  101. python_checks-0.1.0.dist-info/licenses/LICENSE +21 -0
py_checks/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from py-checks!"
@@ -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