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,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,7 @@
1
+ """Слово, которым снимается любая проверка этой группы."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ MARKER: Final = "# signature-ok"
@@ -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
+ )