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,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
|
+
)
|