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,179 @@
|
|
|
1
|
+
"""Модуль объявляет тот класс, ради которого его директория существует."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from py_checks.checks._kind import Kind, declarations
|
|
8
|
+
from py_checks.checks._location import place
|
|
9
|
+
from py_checks.checks.placement._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.checks._kind import Declaration
|
|
17
|
+
from py_checks.checks._location import Place
|
|
18
|
+
from py_checks.core import ParsedFile
|
|
19
|
+
|
|
20
|
+
CODE: Final = "required-class"
|
|
21
|
+
|
|
22
|
+
# Вид объявления, которому позволено стоять выше требуемого класса. Алиас и
|
|
23
|
+
# перечисление — словарь, а не второй предмет разговора: тело класса
|
|
24
|
+
# выполняется в момент объявления, поэтому имена, которые требуемый класс
|
|
25
|
+
# называет у себя внутри, ниже него написать нельзя.
|
|
26
|
+
VOCABULARY: Final[frozenset[Kind]] = frozenset({Kind.ALIAS, Kind.ENUM})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RequiredClassSettings(CheckSettings):
|
|
30
|
+
suffixes: dict[str, str] = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RequiredClass:
|
|
34
|
+
"""Падает, если модуль не объявил класс, ради которого лежит в этой директории.
|
|
35
|
+
|
|
36
|
+
Файл в `use_cases` существует ради сценария, файл в `repositories` — ради
|
|
37
|
+
репозитория, файл в `config` — ради группы настроек. Модуль, который
|
|
38
|
+
объявил что-то другое, либо назван не так, либо лежит не там.
|
|
39
|
+
|
|
40
|
+
Класс идёт первым и идёт один. Первым — потому что читатель, открывший
|
|
41
|
+
`repositories/order.py`, ищет репозиторий, а хелпер перед ним читается как
|
|
42
|
+
что-то более важное. Один — потому что имя файла и есть то, как читатель
|
|
43
|
+
находит класс: три сценария в одном модуле отвечают на вопрос «где
|
|
44
|
+
`ResolveLimitsUseCase`» словами «прочти все три».
|
|
45
|
+
|
|
46
|
+
Выше требуемого класса разрешены константы, алиасы и перечисления: имя
|
|
47
|
+
читают там, где им пользуются, а значение вторым предметом разговора не
|
|
48
|
+
становится.
|
|
49
|
+
|
|
50
|
+
`__init__.py` ничего не объявляет, а переэкспортирует; пустой модуль ещё
|
|
51
|
+
ничего не обещал; модуль с подчёркиванием (`_base.py`) держит машинерию
|
|
52
|
+
своей директории, а не один из её классов. Эти трое правилу не подсудны.
|
|
53
|
+
|
|
54
|
+
Настройка: `suffixes`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
code: ClassVar[str] = CODE
|
|
58
|
+
Settings: ClassVar[type[CheckSettings]] = RequiredClassSettings
|
|
59
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
60
|
+
marker: ClassVar[str] = MARKER
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def run(
|
|
64
|
+
cls,
|
|
65
|
+
*,
|
|
66
|
+
file: ParsedFile,
|
|
67
|
+
settings: CheckSettings,
|
|
68
|
+
) -> Iterator[Violation]:
|
|
69
|
+
suffixes = settings_as(
|
|
70
|
+
settings=settings,
|
|
71
|
+
model=RequiredClassSettings,
|
|
72
|
+
code=CODE,
|
|
73
|
+
).suffixes
|
|
74
|
+
where = place(file=file)
|
|
75
|
+
if where is None or file.path.stem.startswith("_"):
|
|
76
|
+
return
|
|
77
|
+
suffix = cls._suffix(
|
|
78
|
+
where=where,
|
|
79
|
+
suffixes=suffixes,
|
|
80
|
+
)
|
|
81
|
+
if suffix is None:
|
|
82
|
+
return
|
|
83
|
+
declared = list(declarations(tree=file.tree))
|
|
84
|
+
if not declared:
|
|
85
|
+
return
|
|
86
|
+
yield from cls._violations(
|
|
87
|
+
file=file,
|
|
88
|
+
declared=declared,
|
|
89
|
+
suffix=suffix,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def _violations(
|
|
94
|
+
cls,
|
|
95
|
+
*,
|
|
96
|
+
file: ParsedFile,
|
|
97
|
+
declared: list[Declaration],
|
|
98
|
+
suffix: str,
|
|
99
|
+
) -> Iterator[Violation]:
|
|
100
|
+
required = [one for one in declared if one.name.endswith(suffix)]
|
|
101
|
+
if not required:
|
|
102
|
+
yield cls._says(
|
|
103
|
+
file=file,
|
|
104
|
+
node=declared[0],
|
|
105
|
+
message=(
|
|
106
|
+
f"модуль объявляет {cls._listed(declared=declared)}, "
|
|
107
|
+
f"а в этой директории объявляют класс ...{suffix}"
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
if len(required) > 1:
|
|
112
|
+
yield cls._says(
|
|
113
|
+
file=file,
|
|
114
|
+
node=required[1],
|
|
115
|
+
message=(
|
|
116
|
+
f"в модуле {cls._listed(declared=required)} — "
|
|
117
|
+
f"один ...{suffix} на модуль, и модуль назван его именем"
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
ahead = cls._ahead(
|
|
122
|
+
declared=declared,
|
|
123
|
+
suffix=suffix,
|
|
124
|
+
)
|
|
125
|
+
if ahead is not None:
|
|
126
|
+
yield cls._says(
|
|
127
|
+
file=file,
|
|
128
|
+
node=ahead,
|
|
129
|
+
message=(
|
|
130
|
+
f"{ahead.name} объявлен выше ...{suffix}, ради которого "
|
|
131
|
+
f"существует модуль; хелперам место ниже"
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _ahead(
|
|
137
|
+
*,
|
|
138
|
+
declared: list[Declaration],
|
|
139
|
+
suffix: str,
|
|
140
|
+
) -> Declaration | None:
|
|
141
|
+
"""Первое объявление, вставшее выше требуемого класса."""
|
|
142
|
+
for one in declared:
|
|
143
|
+
if one.name.endswith(suffix):
|
|
144
|
+
return None
|
|
145
|
+
if one.kind not in VOCABULARY:
|
|
146
|
+
return one
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _suffix(
|
|
151
|
+
*,
|
|
152
|
+
where: Place,
|
|
153
|
+
suffixes: dict[str, str],
|
|
154
|
+
) -> str | None:
|
|
155
|
+
"""Суффикс самой внутренней из совпавших директорий."""
|
|
156
|
+
matched = [
|
|
157
|
+
(depth, len(directory), suffix)
|
|
158
|
+
for directory, suffix in suffixes.items()
|
|
159
|
+
if (depth := where.within(directory=directory)) is not None
|
|
160
|
+
]
|
|
161
|
+
return max(matched)[2] if matched else None
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _says(
|
|
165
|
+
*,
|
|
166
|
+
file: ParsedFile,
|
|
167
|
+
node: Declaration,
|
|
168
|
+
message: str,
|
|
169
|
+
) -> Violation:
|
|
170
|
+
return Violation.from_node(
|
|
171
|
+
node=node.node,
|
|
172
|
+
path=file.path,
|
|
173
|
+
code=CODE,
|
|
174
|
+
message=message,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _listed(*, declared: list[Declaration]) -> str:
|
|
179
|
+
return ", ".join(one.name for one in declared)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Сигнатуры и тела.
|
|
2
|
+
|
|
3
|
+
Длина функции и модуля, глубина вложенности, требование писать сигнатуру
|
|
4
|
+
полностью и раскладывать её по столбцу. Число аргументов судит
|
|
5
|
+
`operation-shape` — там, где известно, какой класс операция и какая у неё
|
|
6
|
+
дверь; вложенный `with` отдан ruff, правилу `SIM117` с автофиксом.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.signatures._function_length import (
|
|
10
|
+
FunctionLength,
|
|
11
|
+
FunctionLengthSettings,
|
|
12
|
+
)
|
|
13
|
+
from py_checks.checks.signatures._keyword_only import KeywordOnlyArguments
|
|
14
|
+
from py_checks.checks.signatures._marker import MARKER
|
|
15
|
+
from py_checks.checks.signatures._module_length import ModuleLength, ModuleLengthSettings
|
|
16
|
+
from py_checks.checks.signatures._nesting import Nesting, NestingSettings
|
|
17
|
+
from py_checks.checks.signatures._signature_layout import (
|
|
18
|
+
SignatureLayout,
|
|
19
|
+
SignatureLayoutSettings,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MARKER",
|
|
24
|
+
"FunctionLength",
|
|
25
|
+
"FunctionLengthSettings",
|
|
26
|
+
"KeywordOnlyArguments",
|
|
27
|
+
"ModuleLength",
|
|
28
|
+
"ModuleLengthSettings",
|
|
29
|
+
"Nesting",
|
|
30
|
+
"NestingSettings",
|
|
31
|
+
"SignatureLayout",
|
|
32
|
+
"SignatureLayoutSettings",
|
|
33
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Длина функции."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.signatures._functions import definitions, signature_end
|
|
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.checks.signatures._functions import Definition
|
|
18
|
+
from py_checks.core import ParsedFile
|
|
19
|
+
|
|
20
|
+
CODE: Final = "function-length"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FunctionLengthSettings(CheckSettings):
|
|
24
|
+
max_lines: int = Field(
|
|
25
|
+
default=50,
|
|
26
|
+
gt=0,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FunctionLength:
|
|
31
|
+
"""Падает, если функция длиннее лимита.
|
|
32
|
+
|
|
33
|
+
Функция за пределом прячет внутри себя вторую. Считается тело как
|
|
34
|
+
написано — пустые строки и комментарии тоже держат в голове, — а подпись и
|
|
35
|
+
декораторы нет: они описывают функцию, а не работу, которую она делает.
|
|
36
|
+
|
|
37
|
+
Похожее правило есть у ruff, `PLR0915`, но оно считает инструкции, а не
|
|
38
|
+
строки: замер на четырёх сервисах дал ноль срабатываний при пределе в
|
|
39
|
+
полсотни, а самая длинная функция там — 91 строка и две инструкции.
|
|
40
|
+
|
|
41
|
+
Настройка: `max-lines`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
code: ClassVar[str] = CODE
|
|
45
|
+
Settings: ClassVar[type[CheckSettings]] = FunctionLengthSettings
|
|
46
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
47
|
+
marker: ClassVar[str] = MARKER
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def run(
|
|
51
|
+
cls,
|
|
52
|
+
*,
|
|
53
|
+
file: ParsedFile,
|
|
54
|
+
settings: CheckSettings,
|
|
55
|
+
) -> Iterator[Violation]:
|
|
56
|
+
limits = settings_as(
|
|
57
|
+
settings=settings,
|
|
58
|
+
model=FunctionLengthSettings,
|
|
59
|
+
code=CODE,
|
|
60
|
+
)
|
|
61
|
+
for definition in definitions(node=file.tree):
|
|
62
|
+
length = cls._length(definition=definition)
|
|
63
|
+
if length <= limits.max_lines:
|
|
64
|
+
continue
|
|
65
|
+
yield Violation.from_node(
|
|
66
|
+
node=definition.node,
|
|
67
|
+
path=file.path,
|
|
68
|
+
code=CODE,
|
|
69
|
+
# Пометке место в конце подписи: на строке `def` она не всегда
|
|
70
|
+
# помещается, а подпись, разложенная по столбцу, кончается
|
|
71
|
+
# совсем не там, куда указывает нарушение.
|
|
72
|
+
end_line=signature_end(node=definition.node),
|
|
73
|
+
message=(
|
|
74
|
+
f"{definition.name}: строк {length}, предел {limits.max_lines}; "
|
|
75
|
+
"вынеси часть в отдельную функцию"
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _length(*, definition: Definition) -> int:
|
|
81
|
+
"""Строки тела: от первой инструкции до последней строки функции.
|
|
82
|
+
|
|
83
|
+
Подпись не считается: разложенная по столбцу, она добавила бы функции
|
|
84
|
+
десяток строк, которых в ней никто не читает как работу.
|
|
85
|
+
"""
|
|
86
|
+
node = definition.node
|
|
87
|
+
end = node.end_lineno
|
|
88
|
+
if end is None:
|
|
89
|
+
return 0
|
|
90
|
+
return end - node.body[0].lineno + 1
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Функции модуля и то, что о них знает только дерево."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
# Единственный способ для метода не получить первый аргумент от интерпретатора.
|
|
13
|
+
STATIC: Final = "staticmethod"
|
|
14
|
+
|
|
15
|
+
type Function = ast.FunctionDef | ast.AsyncFunctionDef
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Definition:
|
|
20
|
+
"""Функция, её имя вида `Класс.метод` и то, метод ли она.
|
|
21
|
+
|
|
22
|
+
Первый аргумент метода передаёт интерпретатор, и автор подписи тут ни при
|
|
23
|
+
чём — но узнаётся это по месту, а не по имени. `self` в обычной функции или
|
|
24
|
+
в `@staticmethod` — обычный аргумент.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
node: Function
|
|
29
|
+
method: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def definitions(
|
|
33
|
+
*,
|
|
34
|
+
node: ast.AST,
|
|
35
|
+
prefix: str = "",
|
|
36
|
+
method: bool = False,
|
|
37
|
+
) -> Iterator[Definition]:
|
|
38
|
+
"""Все функции дерева под именами вида `Класс.метод` или `внешняя.вложенная`.
|
|
39
|
+
|
|
40
|
+
Заодно запоминается, тело какого узла мы разбираем: функция в теле класса —
|
|
41
|
+
метод, а функция внутри метода — уже нет, и первый аргумент ей никто не
|
|
42
|
+
передаёт.
|
|
43
|
+
"""
|
|
44
|
+
for child in ast.iter_child_nodes(node):
|
|
45
|
+
match child:
|
|
46
|
+
case ast.ClassDef(name=name):
|
|
47
|
+
yield from definitions(
|
|
48
|
+
node=child,
|
|
49
|
+
prefix=f"{prefix}{name}.",
|
|
50
|
+
method=True,
|
|
51
|
+
)
|
|
52
|
+
case ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name):
|
|
53
|
+
yield Definition(
|
|
54
|
+
name=f"{prefix}{name}",
|
|
55
|
+
node=child,
|
|
56
|
+
method=method,
|
|
57
|
+
)
|
|
58
|
+
yield from definitions(
|
|
59
|
+
node=child,
|
|
60
|
+
prefix=f"{prefix}{name}.",
|
|
61
|
+
method=False,
|
|
62
|
+
)
|
|
63
|
+
case _:
|
|
64
|
+
yield from definitions(
|
|
65
|
+
node=child,
|
|
66
|
+
prefix=prefix,
|
|
67
|
+
method=method,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def receiver(*, definition: Definition) -> int:
|
|
72
|
+
"""Сколько первых аргументов передаёт интерпретатор: один у метода, иначе ноль."""
|
|
73
|
+
if not definition.method or static(node=definition.node):
|
|
74
|
+
return 0
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def static(*, node: Function) -> bool:
|
|
79
|
+
return STATIC in {name(node=item) for item in node.decorator_list}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def name(*, node: ast.expr) -> str:
|
|
83
|
+
match node:
|
|
84
|
+
case ast.Name(id=found) | ast.Attribute(attr=found):
|
|
85
|
+
return found
|
|
86
|
+
case _:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def signature_end(*, node: Function) -> int:
|
|
91
|
+
"""Последняя строка подписи: на ней стоит пометка, если подпись в столбик."""
|
|
92
|
+
return max(node.body[0].lineno - 1, node.lineno)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Все аргументы передаются по имени."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from py_checks.checks.signatures._functions import (
|
|
8
|
+
Definition,
|
|
9
|
+
definitions,
|
|
10
|
+
receiver,
|
|
11
|
+
signature_end,
|
|
12
|
+
)
|
|
13
|
+
from py_checks.checks.signatures._marker import MARKER
|
|
14
|
+
from py_checks.config import CheckSettings
|
|
15
|
+
from py_checks.core import Edit, Scope, Violation, column
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
from py_checks.checks.signatures._functions import Function
|
|
21
|
+
from py_checks.core import ParsedFile
|
|
22
|
+
|
|
23
|
+
CODE: Final = "keyword-only-arguments"
|
|
24
|
+
|
|
25
|
+
# Дандеры, которые зовёт наш собственный код: их вызов — такой же вызов, как
|
|
26
|
+
# любой другой. Остальные дандеры зовёт интерпретатор, и подпись у них не наша.
|
|
27
|
+
OWN_DUNDERS: Final[frozenset[str]] = frozenset({"__init__", "__new__", "__call__"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KeywordOnlyArguments:
|
|
31
|
+
"""Падает, если подпись записана не полностью.
|
|
32
|
+
|
|
33
|
+
Каждый аргумент передаётся по имени, поэтому место вызова читается как
|
|
34
|
+
документация, а аргументы можно менять местами, не ломая вызовы:
|
|
35
|
+
|
|
36
|
+
def price(*, market: Market, stake: Money) -> Money: ...
|
|
37
|
+
|
|
38
|
+
`*args` и `**kwargs` запрещены по той же причине: сборщик принимает что
|
|
39
|
+
угодно, проверять типы там нечего, а место вызова ничего не объясняет. Их
|
|
40
|
+
`--fix` не трогает: имена аргументов вместо звёздочек придумывает автор.
|
|
41
|
+
|
|
42
|
+
Обратный вызов или обёртка, чью подпись диктует библиотека, помечается в
|
|
43
|
+
подписи: `def f(a): ... # check-ok: keyword-only-arguments: sqlalchemy`.
|
|
44
|
+
Слово группы `# signature-ok` библиотека тоже понимает.
|
|
45
|
+
|
|
46
|
+
Настроек нет.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
code: ClassVar[str] = CODE
|
|
50
|
+
Settings: ClassVar[type[CheckSettings]] = CheckSettings
|
|
51
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
52
|
+
marker: ClassVar[str] = MARKER
|
|
53
|
+
|
|
54
|
+
def run(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
file: ParsedFile,
|
|
58
|
+
settings: CheckSettings,
|
|
59
|
+
) -> Iterator[Violation]:
|
|
60
|
+
_ = settings
|
|
61
|
+
for definition in definitions(node=file.tree):
|
|
62
|
+
if self._interpreter_dunder(name=definition.name):
|
|
63
|
+
continue
|
|
64
|
+
yield from self._violations(
|
|
65
|
+
definition=definition,
|
|
66
|
+
file=file,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def _violations(
|
|
71
|
+
cls,
|
|
72
|
+
*,
|
|
73
|
+
definition: Definition,
|
|
74
|
+
file: ParsedFile,
|
|
75
|
+
) -> Iterator[Violation]:
|
|
76
|
+
node, name = definition.node, definition.name
|
|
77
|
+
end_line = signature_end(node=node)
|
|
78
|
+
if positional := cls._positional(definition=definition):
|
|
79
|
+
yield Violation.from_node(
|
|
80
|
+
node=node,
|
|
81
|
+
path=file.path,
|
|
82
|
+
code=CODE,
|
|
83
|
+
message=(
|
|
84
|
+
f"{name} принимает {', '.join(positional)} по позиции; поставь `*` перед ними"
|
|
85
|
+
),
|
|
86
|
+
end_line=end_line,
|
|
87
|
+
edit=cls._star(
|
|
88
|
+
definition=definition,
|
|
89
|
+
file=file,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
if collected := cls._collectors(node=node):
|
|
93
|
+
yield Violation.from_node(
|
|
94
|
+
node=node,
|
|
95
|
+
path=file.path,
|
|
96
|
+
code=CODE,
|
|
97
|
+
message=f"{name} принимает {', '.join(collected)}; перечисли аргументы по имени",
|
|
98
|
+
end_line=end_line,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _interpreter_dunder(*, name: str) -> bool:
|
|
103
|
+
own = name.rsplit(".", maxsplit=1)[-1]
|
|
104
|
+
if own in OWN_DUNDERS:
|
|
105
|
+
return False
|
|
106
|
+
return own.startswith("__") and own.endswith("__")
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _positional(*, definition: Definition) -> tuple[str, ...]:
|
|
110
|
+
"""Аргументы, которые вызывающий может передать по позиции."""
|
|
111
|
+
skip = receiver(definition=definition)
|
|
112
|
+
arguments = [*definition.node.args.posonlyargs, *definition.node.args.args]
|
|
113
|
+
return tuple(argument.arg for argument in arguments[skip:])
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _collectors(*, node: Function) -> tuple[str, ...]:
|
|
117
|
+
"""`*args` и `**kwargs` в том виде, в каком их видит вызывающий."""
|
|
118
|
+
stars = ((node.args.vararg, "*"), (node.args.kwarg, "**"))
|
|
119
|
+
return tuple(f"{star}{argument.arg}" for argument, star in stars if argument)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _star(
|
|
123
|
+
*,
|
|
124
|
+
definition: Definition,
|
|
125
|
+
file: ParsedFile,
|
|
126
|
+
) -> Edit | None:
|
|
127
|
+
"""Правка: `*` перед первым аргументом, который сейчас идёт по позиции.
|
|
128
|
+
|
|
129
|
+
Не для всех случаев. При `*args` вторая звезда в подписи не встанет, а
|
|
130
|
+
при `/` аргументы позиционны по требованию автора, и снимать его
|
|
131
|
+
требование автофиксу не по чину.
|
|
132
|
+
"""
|
|
133
|
+
node = definition.node
|
|
134
|
+
if node.args.vararg is not None or node.args.posonlyargs:
|
|
135
|
+
return None
|
|
136
|
+
first = node.args.args[receiver(definition=definition)]
|
|
137
|
+
line = first.lineno
|
|
138
|
+
at = column(
|
|
139
|
+
line=file.lines[line - 1],
|
|
140
|
+
offset=first.col_offset,
|
|
141
|
+
)
|
|
142
|
+
return Edit(
|
|
143
|
+
line=line,
|
|
144
|
+
column=at,
|
|
145
|
+
end_line=line,
|
|
146
|
+
end_column=at,
|
|
147
|
+
text="*, ",
|
|
148
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Длина модуля."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.signatures._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 = "module-length"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModuleLengthSettings(CheckSettings):
|
|
22
|
+
max_lines: int = Field(
|
|
23
|
+
default=600,
|
|
24
|
+
gt=0,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModuleLength:
|
|
29
|
+
"""Падает, если модуль длиннее лимита.
|
|
30
|
+
|
|
31
|
+
Длинный модуль — это обычно несколько модулей, которые не разъехались
|
|
32
|
+
вовремя. Считаются все строки файла, включая пустые и комментарии: правило
|
|
33
|
+
про размер файла, который приходится держать в голове, а не про плотность
|
|
34
|
+
кода в нём.
|
|
35
|
+
|
|
36
|
+
Настройка: `max-lines`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
code: ClassVar[str] = CODE
|
|
40
|
+
Settings: ClassVar[type[CheckSettings]] = ModuleLengthSettings
|
|
41
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
42
|
+
marker: ClassVar[str] = MARKER
|
|
43
|
+
|
|
44
|
+
def run(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
file: ParsedFile,
|
|
48
|
+
settings: CheckSettings,
|
|
49
|
+
) -> Iterator[Violation]:
|
|
50
|
+
limits = settings_as(
|
|
51
|
+
settings=settings,
|
|
52
|
+
model=ModuleLengthSettings,
|
|
53
|
+
code=CODE,
|
|
54
|
+
)
|
|
55
|
+
length = len(file.lines)
|
|
56
|
+
if length <= limits.max_lines:
|
|
57
|
+
return
|
|
58
|
+
yield Violation(
|
|
59
|
+
path=file.path,
|
|
60
|
+
line=limits.max_lines + 1,
|
|
61
|
+
column=1,
|
|
62
|
+
code=CODE,
|
|
63
|
+
message=f"{length} строк, предел {limits.max_lines}",
|
|
64
|
+
)
|