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
@@ -0,0 +1,156 @@
1
+ """Глубина вложенности управляющих конструкций."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING, ClassVar, Final, Self
7
+
8
+ from pydantic import model_validator
9
+
10
+ from py_checks.checks.signatures._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 = "nesting"
20
+
21
+ # Виды, о которых правило умеет говорить. `with` в списке есть, но держать его
22
+ # в таблице проекту незачем: вложенный `with` ловит ruff `SIM117`, с автофиксом
23
+ # и с готовым ответом — «сделай один `with a, b:`».
24
+ KINDS: Final[dict[str, tuple[type[ast.stmt], ...]]] = {
25
+ "try": (ast.Try, ast.TryStar),
26
+ "with": (ast.With, ast.AsyncWith),
27
+ "if": (ast.If,),
28
+ "for": (ast.For, ast.AsyncFor),
29
+ "while": (ast.While,),
30
+ "match": (ast.Match,),
31
+ }
32
+
33
+ # Списки инструкций, которые узел держит в себе.
34
+ BRANCHES: Final[tuple[str, ...]] = ("body", "orelse", "finalbody")
35
+
36
+
37
+ class NestingSettings(CheckSettings):
38
+ limits: dict[str, int] = {}
39
+
40
+ @model_validator(mode="after")
41
+ def _known(self) -> Self:
42
+ unknown = sorted(set(self.limits) - set(KINDS))
43
+ if unknown:
44
+ message = f"неизвестные конструкции: {', '.join(unknown)}"
45
+ raise ValueError(message)
46
+ if any(limit < 1 for limit in self.limits.values()):
47
+ message = "предел вложенности — целое от единицы"
48
+ raise ValueError(message)
49
+ return self
50
+
51
+
52
+ class Nesting:
53
+ """Падает, если управляющие конструкции вложены глубже предела.
54
+
55
+ Глубина — это место, где логику перестают читать и начинают расшифровывать.
56
+ Предел у каждого вида свой, потому что стоят они разного: второй `try`
57
+ внутри первого прячет, какая строка бросила, а второй уровень `if` — это
58
+ обычная развилка, и лишним становится третий.
59
+
60
+ `elif` — ветка, а не уровень, и уровнем не считается. Написанный
61
+ развёрнуто `else:` с `if` внутри — считается: это и есть лишний отступ.
62
+
63
+ Вложенный `with` в таблицу лучше не класть: его ловит ruff `SIM117`, с
64
+ автофиксом и с ответом на месте.
65
+
66
+ Настройка: `limits`.
67
+ """
68
+
69
+ code: ClassVar[str] = CODE
70
+ Settings: ClassVar[type[CheckSettings]] = NestingSettings
71
+ scope: ClassVar[Scope] = Scope.FILE
72
+ marker: ClassVar[str] = MARKER
73
+
74
+ @classmethod
75
+ def run(
76
+ cls,
77
+ *,
78
+ file: ParsedFile,
79
+ settings: CheckSettings,
80
+ ) -> Iterator[Violation]:
81
+ limits = settings_as(
82
+ settings=settings,
83
+ model=NestingSettings,
84
+ code=CODE,
85
+ ).limits
86
+ if not limits:
87
+ return
88
+ depths = dict.fromkeys(limits, 0)
89
+ for node in file.tree.body:
90
+ yield from cls._visit(
91
+ file=file,
92
+ node=node,
93
+ depths=depths,
94
+ limits=limits,
95
+ )
96
+
97
+ @classmethod
98
+ def _visit(
99
+ cls,
100
+ *,
101
+ file: ParsedFile,
102
+ node: ast.stmt,
103
+ depths: dict[str, int],
104
+ limits: dict[str, int],
105
+ ) -> Iterator[Violation]:
106
+ kind = cls._kind(
107
+ node=node,
108
+ limits=limits,
109
+ )
110
+ if kind is not None:
111
+ depth = depths[kind] + 1
112
+ if depth > limits[kind]:
113
+ yield Violation.from_node(
114
+ node=node,
115
+ path=file.path,
116
+ code=CODE,
117
+ message=f"{kind} вложен на {depth}, предел {limits[kind]}",
118
+ )
119
+ depths = {**depths, kind: depth}
120
+ for child in cls._children(node=node):
121
+ inner = depths
122
+ if isinstance(node, ast.If) and cls._elif(
123
+ node=node,
124
+ child=child,
125
+ ):
126
+ inner = {**depths, "if": depths["if"] - 1}
127
+ yield from cls._visit(
128
+ file=file,
129
+ node=child,
130
+ depths=inner,
131
+ limits=limits,
132
+ )
133
+
134
+ @staticmethod
135
+ def _kind(
136
+ *,
137
+ node: ast.stmt,
138
+ limits: dict[str, int],
139
+ ) -> str | None:
140
+ return next((kind for kind in limits if isinstance(node, KINDS[kind])), None)
141
+
142
+ @staticmethod
143
+ def _children(*, node: ast.stmt) -> Iterator[ast.stmt]:
144
+ for field in BRANCHES:
145
+ yield from getattr(node, field, [])
146
+ for handler in getattr(node, "handlers", []):
147
+ yield from handler.body
148
+
149
+ @staticmethod
150
+ def _elif(
151
+ *,
152
+ node: ast.If,
153
+ child: ast.stmt,
154
+ ) -> bool:
155
+ """`elif` стоит в той же колонке, что его `if`; написанный `else: if` — нет."""
156
+ return isinstance(child, ast.If) and child.col_offset == node.col_offset
@@ -0,0 +1,231 @@
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.signatures._functions import definitions, receiver
9
+ from py_checks.checks.signatures._marker import MARKER
10
+ from py_checks.config import CheckSettings
11
+ from py_checks.core import Edit, Scope, Violation, column, settings_as
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator, Sequence
15
+
16
+ from py_checks.checks.signatures._functions import Definition, Function
17
+ from py_checks.core import ParsedFile
18
+
19
+ CODE: Final = "signature-layout"
20
+
21
+ # Двух элементов достаточно: один в строке читается как одно слово, а два уже
22
+ # приходится разбирать.
23
+ ENOUGH: Final = 2
24
+
25
+
26
+ class SignatureLayoutSettings(CheckSettings):
27
+ calls: bool = True
28
+
29
+
30
+ class SignatureLayout:
31
+ """Падает, если список из двух и более элементов записан в одну строку.
32
+
33
+ Обе половины вызова: подпись, которая объявляет параметры, и место, которое
34
+ их передаёт. В столбике правка одного аргумента трогает одну строку и
35
+ говорит ровно это; тот же список в строку сдвигает всё, что стоит после
36
+ правки, и ревью читает его целиком, чтобы найти изменение. В вызове это
37
+ важнее, чем в подписи: там стоят выражения, а не имена.
38
+
39
+ `self` и `cls` не в счёт — их передаёт интерпретатор.
40
+
41
+ В вызове правило срабатывает от двух и более ИМЕНОВАННЫХ аргументов, и это
42
+ вся граница между своим кодом и чужим: у нас каждая функция keyword-only,
43
+ поэтому вызов нашей функции — сплошь имена и под правило попадает, а
44
+ `isinstance(node, ast.Call)` и `range(1, 10)` — чужая позиционная подпись, и
45
+ её не трогают. Как только сработало, в столбик идут все аргументы,
46
+ позиционные тоже: наполовину развёрнутый вызов правилу ни к чему.
47
+
48
+ Декоратор — единственное исключение: `@dataclass(frozen=True, slots=True)`
49
+ это метка, а не список, который читают ради смысла. Те же слова на каждом
50
+ dataclass сервиса, переставлять там нечего.
51
+
52
+ `--fix` дописывает висячую запятую и зовёт `ruff format`: форматтер держит
53
+ список в столбик, когда запятая стоит, но сам её никогда не ставит.
54
+
55
+ Настройка: `calls` — судить ли места вызова. Половина правила про вызовы
56
+ дороже половины про подписи: в сервисе, который писали без неё, она трогает
57
+ почти каждый файл, и выключить её на время переезда честнее, чем выключить
58
+ правило целиком.
59
+ """
60
+
61
+ code: ClassVar[str] = CODE
62
+ Settings: ClassVar[type[CheckSettings]] = SignatureLayoutSettings
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
+ calls = settings_as(
74
+ settings=settings,
75
+ model=SignatureLayoutSettings,
76
+ code=CODE,
77
+ ).calls
78
+ for definition in definitions(node=file.tree):
79
+ yield from cls._signature(
80
+ file=file,
81
+ definition=definition,
82
+ )
83
+ if not calls:
84
+ return
85
+ marks = cls._decorators(tree=file.tree)
86
+ for node in ast.walk(file.tree):
87
+ if isinstance(node, ast.Call) and id(node) not in marks:
88
+ yield from cls._call(
89
+ file=file,
90
+ node=node,
91
+ )
92
+
93
+ @classmethod
94
+ def _signature(
95
+ cls,
96
+ *,
97
+ file: ParsedFile,
98
+ definition: Definition,
99
+ ) -> Iterator[Violation]:
100
+ node = definition.node
101
+ listed = cls._parameters(definition=definition)
102
+ defaults = cls._defaults(node=node)
103
+ if len(listed) < ENOUGH or cls._columned(
104
+ listed=listed,
105
+ after=node.lineno,
106
+ ):
107
+ return
108
+ yield Violation.from_node(
109
+ node=node,
110
+ path=file.path,
111
+ code=CODE,
112
+ message=(
113
+ f"{definition.name}: параметров {len(listed)} в одну строку; по одному на строку"
114
+ ),
115
+ edit=cls._comma(
116
+ file=file,
117
+ ends=[*cls._ends(nodes=listed), *cls._ends(nodes=defaults)],
118
+ ),
119
+ )
120
+
121
+ @classmethod
122
+ def _call(
123
+ cls,
124
+ *,
125
+ file: ParsedFile,
126
+ node: ast.Call,
127
+ ) -> Iterator[Violation]:
128
+ listed: list[ast.expr | ast.keyword] = [*node.args, *node.keywords]
129
+ if len(node.keywords) < ENOUGH:
130
+ return
131
+ after = node.func.end_lineno or node.func.lineno
132
+ if cls._columned(
133
+ listed=listed,
134
+ after=after,
135
+ ):
136
+ return
137
+ yield Violation.from_node(
138
+ node=node,
139
+ path=file.path,
140
+ code=CODE,
141
+ message=(
142
+ f"{cls._called(node=node)}: аргументов {len(listed)} в одну строку; "
143
+ f"по одному на строку"
144
+ ),
145
+ edit=cls._comma(
146
+ file=file,
147
+ ends=cls._ends(nodes=listed),
148
+ ),
149
+ )
150
+
151
+ @staticmethod
152
+ def _columned(
153
+ *,
154
+ listed: Sequence[ast.expr | ast.keyword | ast.arg],
155
+ after: int,
156
+ ) -> bool:
157
+ """По одному на строку, и ни одного на той строке, где список открылся."""
158
+ lines = {element.lineno for element in listed}
159
+ return len(lines) == len(listed) and min(lines) > after
160
+
161
+ @staticmethod
162
+ def _parameters(*, definition: Definition) -> list[ast.arg]:
163
+ """Параметры, которые заполняет вызывающий, в порядке записи."""
164
+ arguments = definition.node.args
165
+ listed = [
166
+ *arguments.posonlyargs,
167
+ *arguments.args,
168
+ *([arguments.vararg] if arguments.vararg else []),
169
+ *arguments.kwonlyargs,
170
+ *([arguments.kwarg] if arguments.kwarg else []),
171
+ ]
172
+ return listed[receiver(definition=definition) :]
173
+
174
+ @staticmethod
175
+ def _defaults(*, node: Function) -> list[ast.expr]:
176
+ """Значения по умолчанию: запятая ставится после них, а не после имени."""
177
+ return [one for one in (*node.args.defaults, *node.args.kw_defaults) if one is not None]
178
+
179
+ @staticmethod
180
+ def _ends(*, nodes: Sequence[ast.expr | ast.keyword | ast.arg]) -> list[tuple[int, int]]:
181
+ """Где кончается каждый элемент списка."""
182
+ return [
183
+ (node.end_lineno, node.end_col_offset)
184
+ for node in nodes
185
+ if node.end_lineno is not None and node.end_col_offset is not None
186
+ ]
187
+
188
+ @staticmethod
189
+ def _comma(
190
+ *,
191
+ file: ParsedFile,
192
+ ends: list[tuple[int, int]],
193
+ ) -> Edit | None:
194
+ """Правка: висячая запятая после последнего элемента списка.
195
+
196
+ Последний — по концу, а не по порядку записи: у параметра со значением
197
+ по умолчанию запятая ставится после значения, а не после имени.
198
+
199
+ Дальше раскладка — забота форматтера: `ruff format` разворачивает
200
+ список в столбик, как только запятая стоит.
201
+ """
202
+ if not ends:
203
+ return None
204
+ line, offset = max(ends)
205
+ at = column(
206
+ line=file.lines[line - 1],
207
+ offset=offset,
208
+ )
209
+ return Edit(
210
+ line=line,
211
+ column=at,
212
+ end_line=line,
213
+ end_column=at,
214
+ text=",",
215
+ )
216
+
217
+ @staticmethod
218
+ def _called(*, node: ast.Call) -> str:
219
+ """Как вызов записан: `self._policy`, `price`, `Model.build`."""
220
+ return ast.unparse(node.func)
221
+
222
+ @staticmethod
223
+ def _decorators(*, tree: ast.Module) -> frozenset[int]:
224
+ """Вызовы, которые на самом деле декораторы, — по тождеству узла."""
225
+ return frozenset(
226
+ id(decorator)
227
+ for node in ast.walk(tree)
228
+ if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef)
229
+ for decorator in node.decorator_list
230
+ if isinstance(decorator, ast.Call)
231
+ )
@@ -0,0 +1,36 @@
1
+ """Типы и аннотации.
2
+
3
+ Чем объявлено значение и что об этом видно из подписи. Готовых правил тут
4
+ почти нет: `typing.Literal` закрывается строкой `banned-api` в ruff, голый
5
+ дженерик — строгим режимом pyright, остальное — соглашения проекта.
6
+ """
7
+
8
+ from py_checks.checks.types._annotation_shapes import (
9
+ AnnotationShapes,
10
+ AnnotationShapesSettings,
11
+ )
12
+ from py_checks.checks.types._config_fields import ConfigFields, ConfigFieldsSettings
13
+ from py_checks.checks.types._confined_types import ConfinedTypes, ConfinedTypesSettings
14
+ from py_checks.checks.types._constant_annotations import (
15
+ ConstantAnnotations,
16
+ ConstantAnnotationsSettings,
17
+ )
18
+ from py_checks.checks.types._frozen_dataclasses import (
19
+ FrozenDataclasses,
20
+ FrozenDataclassesSettings,
21
+ )
22
+ from py_checks.checks.types._marker import MARKER
23
+
24
+ __all__ = [
25
+ "MARKER",
26
+ "AnnotationShapes",
27
+ "AnnotationShapesSettings",
28
+ "ConfigFields",
29
+ "ConfigFieldsSettings",
30
+ "ConfinedTypes",
31
+ "ConfinedTypesSettings",
32
+ "ConstantAnnotations",
33
+ "ConstantAnnotationsSettings",
34
+ "FrozenDataclasses",
35
+ "FrozenDataclassesSettings",
36
+ ]
@@ -0,0 +1,127 @@
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._names import name
9
+ from py_checks.checks.types._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 = "annotation-shapes"
19
+
20
+ MAPPING: Final = "dict"
21
+ TUPLE: Final = "tuple"
22
+
23
+
24
+ class AnnotationShapesSettings(CheckSettings):
25
+ keys: tuple[str, ...] = ("str",)
26
+ tuples: bool = True
27
+
28
+
29
+ class AnnotationShapes:
30
+ """Падает, если форма названа так, что поля в ней безымянные.
31
+
32
+ Словарь со строковым ключом читается как набор именованных полей, а
33
+ `TypedDict` или dataclass говорят, каких именно. Ключи бывают и настоящими
34
+ данными — мешок заголовков, носитель контекста трассировки, — и тогда
35
+ строка помечается: `# type-ok: annotation-shapes: своя форма у propagator`.
36
+
37
+ Кортеж фиксированной длины — тот, у которого последний аргумент не `...`, —
38
+ называет поля позициями: `row[2]` не говорит ничего и молча переживает
39
+ перестановку. Имена дают dataclass или `NamedTuple`, если это обязано
40
+ остаться кортежем.
41
+
42
+ Судится каждое место, где форма написана, а не только аннотации: словарь,
43
+ собранный внутри функции, — та же неописанная форма одним вызовом позже, и
44
+ обычно именно оттуда аннотация и взялась.
45
+
46
+ Настройки: `keys` — какие ключи читаются как имена полей, `tuples` —
47
+ судить ли кортежи фиксированной длины.
48
+ """
49
+
50
+ code: ClassVar[str] = CODE
51
+ Settings: ClassVar[type[CheckSettings]] = AnnotationShapesSettings
52
+ scope: ClassVar[Scope] = Scope.FILE
53
+ marker: ClassVar[str] = MARKER
54
+
55
+ @classmethod
56
+ def run(
57
+ cls,
58
+ *,
59
+ file: ParsedFile,
60
+ settings: CheckSettings,
61
+ ) -> Iterator[Violation]:
62
+ limits = settings_as(
63
+ settings=settings,
64
+ model=AnnotationShapesSettings,
65
+ code=CODE,
66
+ )
67
+ for node in ast.walk(file.tree):
68
+ if not isinstance(node, ast.Subscript):
69
+ continue
70
+ written = name(node=node.value)
71
+ arguments = cls._arguments(node=node)
72
+ if written == MAPPING and cls._keyed(
73
+ arguments=arguments,
74
+ keys=limits.keys,
75
+ ):
76
+ yield cls._says(
77
+ file=file,
78
+ node=node,
79
+ message=(
80
+ f"{ast.unparse(node)} читается как набор именованных полей; "
81
+ f"какие именно — скажет TypedDict или dataclass"
82
+ ),
83
+ )
84
+ if written == TUPLE and limits.tuples and cls._fixed(arguments=arguments):
85
+ yield cls._says(
86
+ file=file,
87
+ node=node,
88
+ message=(
89
+ f"{ast.unparse(node)} называет поля позициями; имена даст dataclass, "
90
+ f"а NamedTuple — если это обязано остаться кортежем"
91
+ ),
92
+ )
93
+
94
+ @staticmethod
95
+ def _keyed(
96
+ *,
97
+ arguments: list[ast.expr],
98
+ keys: tuple[str, ...],
99
+ ) -> bool:
100
+ return bool(arguments) and name(node=arguments[0]) in keys
101
+
102
+ @staticmethod
103
+ def _fixed(*, arguments: list[ast.expr]) -> bool:
104
+ """Кортеж, у которого длина написана: последний аргумент не `...`."""
105
+ if not arguments:
106
+ return False
107
+ last = arguments[-1]
108
+ return not (isinstance(last, ast.Constant) and last.value is Ellipsis)
109
+
110
+ @staticmethod
111
+ def _arguments(*, node: ast.Subscript) -> list[ast.expr]:
112
+ inside = node.slice
113
+ return list(inside.elts) if isinstance(inside, ast.Tuple) else [inside]
114
+
115
+ @staticmethod
116
+ def _says(
117
+ *,
118
+ file: ParsedFile,
119
+ node: ast.Subscript,
120
+ message: str,
121
+ ) -> Violation:
122
+ return Violation.from_node(
123
+ node=node,
124
+ path=file.path,
125
+ code=CODE,
126
+ message=message,
127
+ )