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,236 @@
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._location import ZonedSettings, zoned
9
+ from py_checks.checks._names import walked
10
+ from py_checks.checks.types._marker import MARKER
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.config import CheckSettings
17
+ from py_checks.core import ParsedFile
18
+
19
+ CODE: Final = "config-fields"
20
+
21
+ # Классы, которые держат словарь или интерфейс, а не поля настроек.
22
+ NOT_SETTINGS: Final[frozenset[str]] = frozenset(
23
+ {"Enum", "StrEnum", "IntEnum", "IntFlag", "Flag", "Protocol", "TypedDict"},
24
+ )
25
+
26
+ # Аннотация, которая говорит, как значение хранится, и ничего — какие значения
27
+ # допустимы. Всё остальное — имя, а имя это место, где правило может жить.
28
+ BOUNDS: Final[dict[str, tuple[str, ...]]] = {
29
+ "int": ("ge", "gt", "le", "lt"),
30
+ "float": ("ge", "gt", "le", "lt"),
31
+ # `max_length` тут отсутствует намеренно: потолок говорит, какой длины
32
+ # значение может быть, а не что оно вообще есть, — а речь именно о пустой
33
+ # строке, которой оборачивается неустановленная переменная.
34
+ "str": ("min_length", "pattern"),
35
+ }
36
+
37
+ CLASS_VAR: Final = "ClassVar"
38
+
39
+ # Поле, собранное фабрикой, — это вложенная секция, а не значение: переменной у
40
+ # него нет, её читают поля внутри.
41
+ FACTORY: Final = "default_factory"
42
+
43
+
44
+ class ConfigFieldsSettings(ZonedSettings):
45
+ factory: str = "Field"
46
+ alias: str | None = None
47
+ bounds: dict[str, tuple[str, ...]] = BOUNDS
48
+
49
+
50
+ class ConfigFields:
51
+ """Падает, если поле настроек ничем не ограничено.
52
+
53
+ Значение приходит текстом из окружения, которое никто не ревьюит, поэтому
54
+ обе половины объявления обязательны.
55
+
56
+ Поле объявляется через `Field(...)`: там живут псевдоним переменной,
57
+ значение по умолчанию и ограничения, а голое `name: str = "x"` молча
58
+ роняет все три.
59
+
60
+ Поле с голым числом называет границу — `ge`, `gt`, `le`, `lt` — или
61
+ аннотируется типом, который её несёт. Без этого `POSTGRES_POOL_SIZE=0` и
62
+ пул на пятьсот принимаются здесь и падают где-то там, где в трейсбеке
63
+ настроек уже не видно.
64
+
65
+ Голая строка — та же дыра с более тихим отказом: неустановленная переменная
66
+ приходит пустой строкой, и пустой адрес брокера, DSN или имя топика
67
+ принимаются как настройка. Поле называет `min_length` или `pattern`, либо
68
+ несёт тип, который это делает.
69
+
70
+ Поле называет переменную, из которой читается (`alias`, у pydantic это
71
+ `validation_alias`). Без неё имя переменной знает один pydantic — он
72
+ выводит его из имени поля и приставки, — и ни `.env.example`, собранный из
73
+ этих же классов, ни человек, ищущий, откуда берётся значение, назвать её не
74
+ могут. Поле, собранное `default_factory`, — исключение: это вложенная
75
+ секция, а не значение, и переменные читают её собственные поля.
76
+
77
+ `ClassVar` — не поле настроек, а константа рядом с ними.
78
+
79
+ Настройки: `zones`, `factory`, `alias`, `bounds`.
80
+ """
81
+
82
+ code: ClassVar[str] = CODE
83
+ Settings: ClassVar[type[CheckSettings]] = ConfigFieldsSettings
84
+ scope: ClassVar[Scope] = Scope.FILE
85
+ marker: ClassVar[str] = MARKER
86
+
87
+ @classmethod
88
+ def run(
89
+ cls,
90
+ *,
91
+ file: ParsedFile,
92
+ settings: CheckSettings,
93
+ ) -> Iterator[Violation]:
94
+ limits = settings_as(
95
+ settings=settings,
96
+ model=ConfigFieldsSettings,
97
+ code=CODE,
98
+ )
99
+ where = zoned(
100
+ file=file,
101
+ zones=limits.zones,
102
+ )
103
+ if where is None:
104
+ return
105
+ for node in ast.walk(file.tree):
106
+ if isinstance(node, ast.ClassDef) and cls._settings(node=node):
107
+ yield from cls._fields(
108
+ file=file,
109
+ node=node,
110
+ limits=limits,
111
+ )
112
+
113
+ @classmethod
114
+ def _fields(
115
+ cls,
116
+ *,
117
+ file: ParsedFile,
118
+ node: ast.ClassDef,
119
+ limits: ConfigFieldsSettings,
120
+ ) -> Iterator[Violation]:
121
+ for statement in node.body:
122
+ if not isinstance(statement, ast.AnnAssign):
123
+ continue
124
+ if not isinstance(statement.target, ast.Name):
125
+ continue
126
+ named = frozenset(walked(node=statement.annotation))
127
+ if CLASS_VAR in named:
128
+ continue
129
+ reason = cls._reason(
130
+ statement=statement,
131
+ named=named,
132
+ limits=limits,
133
+ )
134
+ if reason is None:
135
+ continue
136
+ yield Violation.from_node(
137
+ node=statement,
138
+ path=file.path,
139
+ code=CODE,
140
+ message=f"{node.name}.{statement.target.id} {reason}",
141
+ )
142
+
143
+ @classmethod
144
+ def _reason(
145
+ cls,
146
+ *,
147
+ statement: ast.AnnAssign,
148
+ named: frozenset[str],
149
+ limits: ConfigFieldsSettings,
150
+ ) -> str | None:
151
+ """Чем поле не закрыто, или `None`, если закрыто."""
152
+ if not cls._declared(
153
+ node=statement.value,
154
+ factory=limits.factory,
155
+ ):
156
+ if statement.value is None:
157
+ return (
158
+ f"объявлено без значения; поле настроек объявляют через {limits.factory}(...)"
159
+ )
160
+ return f"объявлено не через {limits.factory}(...)"
161
+ if (
162
+ limits.alias is not None
163
+ and not cls._states(
164
+ node=statement.value,
165
+ wanted=(FACTORY,),
166
+ )
167
+ and not cls._states(
168
+ node=statement.value,
169
+ wanted=(limits.alias,),
170
+ )
171
+ ):
172
+ return (
173
+ f"не называет {limits.alias}=; без него имя переменной знает "
174
+ f"один pydantic, а `.env.example` собирается из этих же полей"
175
+ )
176
+ wanted = cls._wanted(
177
+ named=named,
178
+ bounds=limits.bounds,
179
+ )
180
+ if wanted and not cls._states(
181
+ node=statement.value,
182
+ wanted=wanted,
183
+ ):
184
+ return (
185
+ f"ничем не ограничено; назови одно из {', '.join(wanted)} "
186
+ f"или аннотируй типом, который несёт это правило"
187
+ )
188
+ return None
189
+
190
+ @staticmethod
191
+ def _wanted(
192
+ *,
193
+ named: frozenset[str],
194
+ bounds: dict[str, tuple[str, ...]],
195
+ ) -> tuple[str, ...]:
196
+ """Какие ограничения задолжала аннотация.
197
+
198
+ `str | None` — это строка, а `int | None` — число: объединение говорит
199
+ о том, есть ли значение, а не о том, какие значения допустимы. Имя, не
200
+ попавшее в таблицу, — уже правило: ограничение живёт в нём.
201
+ """
202
+ if not named or not named <= frozenset(bounds):
203
+ return ()
204
+ listed = [one for name in sorted(named) for one in bounds[name]]
205
+ return tuple(dict.fromkeys(listed))
206
+
207
+ @staticmethod
208
+ def _declared(
209
+ *,
210
+ node: ast.expr | None,
211
+ factory: str,
212
+ ) -> bool:
213
+ match node:
214
+ case ast.Call(func=ast.Name(id=name) | ast.Attribute(attr=name)):
215
+ return name == factory
216
+ case _:
217
+ return False
218
+
219
+ @staticmethod
220
+ def _states(
221
+ *,
222
+ node: ast.expr | None,
223
+ wanted: tuple[str, ...],
224
+ ) -> bool:
225
+ if not isinstance(node, ast.Call):
226
+ return False
227
+ return any(keyword.arg in wanted for keyword in node.keywords)
228
+
229
+ @staticmethod
230
+ def _settings(*, node: ast.ClassDef) -> bool:
231
+ """Класс полей настроек, а не словарь и не интерфейс рядом с ними."""
232
+ bases = {
233
+ base.attr if isinstance(base, ast.Attribute) else getattr(base, "id", "")
234
+ for base in node.bases
235
+ }
236
+ return not bases & NOT_SETTINGS
@@ -0,0 +1,117 @@
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._location import place
9
+ from py_checks.checks._names import walked
10
+ from py_checks.checks.types._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._location import Place
18
+ from py_checks.core import ParsedFile
19
+
20
+ CODE: Final = "confined-types"
21
+
22
+ # Имя, которое говорит, что значение принадлежит классу, а не экземпляру:
23
+ # такое поле — не состояние, переходящее границу, и правило его не касается.
24
+ ASIDE: Final[frozenset[str]] = frozenset({"ClassVar", "Final"})
25
+
26
+
27
+ class ConfinedTypesSettings(CheckSettings):
28
+ zones: dict[str, tuple[str, ...]] = {}
29
+
30
+
31
+ class ConfinedTypes:
32
+ """Падает, если поле в этой части дерева объявлено запрещённым здесь типом.
33
+
34
+ Одно правило на два случая, которые раньше писались по отдельности.
35
+ `float` в домене: двоичная плавающая точка не держит цену, а ошибка
36
+ округления в хранимом состоянии — это деньги, которые перестают сходиться.
37
+ Голые `str`, `int`, `Decimal` там, где живут контракты: `int` говорит, что
38
+ версия может быть −10000, `str` — что тег может быть пустым, `Decimal` —
39
+ что коэффициент может быть отрицательным или NaN. Ничего из этого про дело
40
+ не верно, а тип — последнее место, где это можно сказать один раз, а не
41
+ перепроверять глазами.
42
+
43
+ Какие типы где запрещены — дело проекта: в одном сервисе деньги считают
44
+ везде, в другом `float` в отчёте законен. Без таблицы правило молчит.
45
+
46
+ Аннотация просматривается насквозь: `tuple[str, ...]` — та же голая строка
47
+ этажом ниже. Судятся поля класса; `ClassVar` и `Final` — не поля: они
48
+ принадлежат классу, а не экземпляру.
49
+
50
+ Настройка: `zones` — путь (можно с `*`: `modules/*/domain`) и список имён.
51
+ """
52
+
53
+ code: ClassVar[str] = CODE
54
+ Settings: ClassVar[type[CheckSettings]] = ConfinedTypesSettings
55
+ scope: ClassVar[Scope] = Scope.FILE
56
+ marker: ClassVar[str] = MARKER
57
+
58
+ @classmethod
59
+ def run(
60
+ cls,
61
+ *,
62
+ file: ParsedFile,
63
+ settings: CheckSettings,
64
+ ) -> Iterator[Violation]:
65
+ zones = settings_as(
66
+ settings=settings,
67
+ model=ConfinedTypesSettings,
68
+ code=CODE,
69
+ ).zones
70
+ where = place(file=file)
71
+ if where is None:
72
+ return
73
+ forbidden = cls._forbidden(
74
+ where=where,
75
+ zones=zones,
76
+ )
77
+ if not forbidden:
78
+ return
79
+ for name, statement in cls._fields(tree=file.tree):
80
+ found = frozenset(walked(node=statement.annotation)) & forbidden
81
+ if not found:
82
+ continue
83
+ yield Violation.from_node(
84
+ node=statement,
85
+ path=file.path,
86
+ code=CODE,
87
+ message=(
88
+ f"{name} объявлено через {', '.join(sorted(found))}; "
89
+ f"здесь тип называет, что значение может держать"
90
+ ),
91
+ )
92
+
93
+ @staticmethod
94
+ def _forbidden(
95
+ *,
96
+ where: Place,
97
+ zones: dict[str, tuple[str, ...]],
98
+ ) -> frozenset[str]:
99
+ """Всё, что запрещено в этом месте: зоны складываются, а не спорят."""
100
+ return frozenset(
101
+ name for zone, names in zones.items() if where.holds(path=zone) for name in names
102
+ )
103
+
104
+ @staticmethod
105
+ def _fields(*, tree: ast.Module) -> Iterator[tuple[str, ast.AnnAssign]]:
106
+ """Поля классов модуля под именами вида `Класс.поле`."""
107
+ for node in ast.walk(tree):
108
+ if not isinstance(node, ast.ClassDef):
109
+ continue
110
+ for statement in node.body:
111
+ if not isinstance(statement, ast.AnnAssign):
112
+ continue
113
+ if not isinstance(statement.target, ast.Name):
114
+ continue
115
+ if frozenset(walked(node=statement.annotation)) & ASIDE:
116
+ continue
117
+ yield f"{node.name}.{statement.target.id}", statement
@@ -0,0 +1,128 @@
1
+ """Имя, написанное как константа, обещает неизменность — и говорит это типом."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from typing import TYPE_CHECKING, ClassVar, Final
8
+
9
+ from py_checks.checks._kind import ENUMS
10
+ from py_checks.checks.types._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 = "constant-annotations"
20
+
21
+ FINAL: Final = "Final"
22
+ CLASS_VAR: Final = "ClassVar"
23
+
24
+ # Имя — это и есть обещание: строчная привязка на уровне модуля объявляет
25
+ # переменную и говорит об этом прямо, и правилу до неё дела нет.
26
+ NAME: Final = re.compile(r"^[A-Z][A-Z0-9_]*$")
27
+
28
+
29
+ class ConstantAnnotationsSettings(CheckSettings):
30
+ module: str = FINAL
31
+ inside_class: str = CLASS_VAR
32
+
33
+
34
+ class ConstantAnnotations:
35
+ """Падает, если константа не сказала типом, что она константа.
36
+
37
+ Имя `UPPER_SNAKE` — обещание, `Final` — то, что делает обещание
38
+ проверяемым: без него имя читается как константа, а ведёт себя как
39
+ переменная, и любой импортировавший модуль волен её перепривязать.
40
+
41
+ В теле класса слово другое, и по причине. `Final` там означает, что
42
+ атрибут нельзя переопределить вообще (PEP 591), а ограниченные примитивы
43
+ построены ровно на переопределении: `PositiveDecimal.BOUND` заменяет
44
+ `BOUND`, объявленный базой. `ClassVar` говорит «принадлежит классу, а не
45
+ экземпляру» и переопределение оставляет открытым — это верно про оба.
46
+
47
+ Перечисления не трогаются: член — это словарь, а не константа рядом с ним.
48
+
49
+ Настройки: `module`, `inside-class` — какими словами это говорится.
50
+ """
51
+
52
+ code: ClassVar[str] = CODE
53
+ Settings: ClassVar[type[CheckSettings]] = ConstantAnnotationsSettings
54
+ scope: ClassVar[Scope] = Scope.FILE
55
+ marker: ClassVar[str] = MARKER
56
+
57
+ @classmethod
58
+ def run(
59
+ cls,
60
+ *,
61
+ file: ParsedFile,
62
+ settings: CheckSettings,
63
+ ) -> Iterator[Violation]:
64
+ limits = settings_as(
65
+ settings=settings,
66
+ model=ConstantAnnotationsSettings,
67
+ code=CODE,
68
+ )
69
+ yield from cls._constants(
70
+ file=file,
71
+ body=file.tree.body,
72
+ wanted=limits.module,
73
+ )
74
+ for node in ast.walk(file.tree):
75
+ if isinstance(node, ast.ClassDef) and not cls._enum(node=node):
76
+ yield from cls._constants(
77
+ file=file,
78
+ body=node.body,
79
+ wanted=limits.inside_class,
80
+ )
81
+
82
+ @classmethod
83
+ def _constants(
84
+ cls,
85
+ *,
86
+ file: ParsedFile,
87
+ body: list[ast.stmt],
88
+ wanted: str,
89
+ ) -> Iterator[Violation]:
90
+ for statement in body:
91
+ name = cls._constant(node=statement)
92
+ if name is None:
93
+ continue
94
+ yield Violation.from_node(
95
+ node=statement,
96
+ path=file.path,
97
+ code=CODE,
98
+ message=f"{name} названо константой, но не объявлено через {wanted}",
99
+ )
100
+
101
+ @classmethod
102
+ def _constant(cls, *, node: ast.stmt) -> str | None:
103
+ """Имя константы, которой не хватает слова; иначе `None`."""
104
+ match node:
105
+ case ast.Assign(targets=[ast.Name(id=name)]) if NAME.match(name):
106
+ return name
107
+ case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation) if NAME.match(name):
108
+ return None if cls._says(node=annotation) else name
109
+ case _:
110
+ return None
111
+
112
+ @staticmethod
113
+ def _says(*, node: ast.expr) -> bool:
114
+ """Сказано ли в аннотации то самое слово — голым или с параметром."""
115
+ outer = node.value if isinstance(node, ast.Subscript) else node
116
+ match outer:
117
+ case ast.Name(id=name) | ast.Attribute(attr=name):
118
+ return name in (FINAL, CLASS_VAR)
119
+ case _:
120
+ return False
121
+
122
+ @staticmethod
123
+ def _enum(*, node: ast.ClassDef) -> bool:
124
+ bases = {
125
+ base.attr if isinstance(base, ast.Attribute) else getattr(base, "id", "")
126
+ for base in node.bases
127
+ }
128
+ return bool(bases & ENUMS)
@@ -0,0 +1,112 @@
1
+ """dataclass объявлен так, чтобы значение оставалось значением."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING, ClassVar, Final
7
+
8
+ from py_checks.checks._location import ZonedSettings, zoned
9
+ from py_checks.checks._names import name
10
+ from py_checks.checks.types._marker import MARKER
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.config import CheckSettings
17
+ from py_checks.core import ParsedFile
18
+
19
+ CODE: Final = "frozen-dataclasses"
20
+
21
+ DECORATOR: Final = "dataclass"
22
+
23
+
24
+ class FrozenDataclassesSettings(ZonedSettings):
25
+ options: tuple[str, ...] = ("frozen", "slots", "kw_only")
26
+
27
+
28
+ class FrozenDataclasses:
29
+ """Падает, если dataclass в зоне объявлен без нужных аргументов.
30
+
31
+ Объект дела — это значение: собрали один раз и не меняли, поэтому
32
+ существующий объект не может исподтишка съехать в недопустимое состояние.
33
+ `frozen` это покупает, `slots` не даёт опечатке завести атрибут, которого
34
+ никто не объявлял, а `kw_only` — перепутать местами два поля одного типа:
35
+ у значения из четырёх строк порядок помнит только автор.
36
+
37
+ @dataclass(frozen=True, slots=True, kw_only=True)
38
+ class Price: ...
39
+
40
+ Зона проектная: держать значения неизменяемыми имеет смысл там, где живут
41
+ правила, а не в конфиге и не в проводке. Без зон правило молчит.
42
+
43
+ Настройки: `zones`, `options`.
44
+ """
45
+
46
+ code: ClassVar[str] = CODE
47
+ Settings: ClassVar[type[CheckSettings]] = FrozenDataclassesSettings
48
+ scope: ClassVar[Scope] = Scope.FILE
49
+ marker: ClassVar[str] = MARKER
50
+
51
+ @classmethod
52
+ def run(
53
+ cls,
54
+ *,
55
+ file: ParsedFile,
56
+ settings: CheckSettings,
57
+ ) -> Iterator[Violation]:
58
+ limits = settings_as(
59
+ settings=settings,
60
+ model=FrozenDataclassesSettings,
61
+ code=CODE,
62
+ )
63
+ where = zoned(
64
+ file=file,
65
+ zones=limits.zones,
66
+ )
67
+ if where is None:
68
+ return
69
+ for node in ast.walk(file.tree):
70
+ if not isinstance(node, ast.ClassDef):
71
+ continue
72
+ decorator = cls._decorator(node=node)
73
+ if decorator is None:
74
+ continue
75
+ missing = tuple(
76
+ option for option in limits.options if option not in cls._enabled(node=decorator)
77
+ )
78
+ if not missing:
79
+ continue
80
+ yield Violation.from_node(
81
+ node=node,
82
+ path=file.path,
83
+ code=CODE,
84
+ message=(
85
+ f"dataclass {node.name} объявлен без "
86
+ f"{', '.join(f'{option}=True' for option in missing)}"
87
+ ),
88
+ )
89
+
90
+ @staticmethod
91
+ def _decorator(*, node: ast.ClassDef) -> ast.expr | None:
92
+ """Декоратор `@dataclass`, с аргументами или без."""
93
+ for item in node.decorator_list:
94
+ called = item.func if isinstance(item, ast.Call) else item
95
+ if name(node=called) == DECORATOR:
96
+ return item
97
+ return None
98
+
99
+ @classmethod
100
+ def _enabled(cls, *, node: ast.expr) -> frozenset[str]:
101
+ """Аргументы декоратора, выставленные в `True`."""
102
+ if not isinstance(node, ast.Call):
103
+ return frozenset()
104
+ return frozenset(
105
+ keyword.arg
106
+ for keyword in node.keywords
107
+ if keyword.arg is not None and cls._true(node=keyword.value)
108
+ )
109
+
110
+ @staticmethod
111
+ def _true(*, node: ast.expr) -> bool:
112
+ return isinstance(node, ast.Constant) and node.value is True
@@ -0,0 +1,5 @@
1
+ """Слово группы для пометок: `# type-ok: <код>: <причина>`."""
2
+
3
+ from typing import Final
4
+
5
+ MARKER: Final = "# type-ok"
@@ -0,0 +1,10 @@
1
+ """Командная строка.
2
+
3
+ Точка входа, через которую проверки вызывает pre-commit, и команды для ручного
4
+ запуска: прогнать проверки, показать список, объяснить правило.
5
+ """
6
+
7
+ from py_checks.cli._app import app, main
8
+ from py_checks.cli._protocols import Registrar
9
+
10
+ __all__ = ["Registrar", "app", "main"]
py_checks/cli/_app.py ADDED
@@ -0,0 +1,21 @@
1
+ """Приложение командной строки."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from py_checks.cli.commands import REGISTRARS
8
+
9
+ app = typer.Typer(
10
+ no_args_is_help=True,
11
+ add_completion=False,
12
+ help="Проверки архитектурных соглашений проекта.",
13
+ )
14
+
15
+ for register in REGISTRARS:
16
+ register(app=app)
17
+
18
+
19
+ def main() -> None:
20
+ """Точка входа консольной команды `py-checks`."""
21
+ app()
@@ -0,0 +1,19 @@
1
+ """Каким приложение видит команду."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ import typer
9
+
10
+
11
+ class Registrar(Protocol):
12
+ """Функция, которая вешает команду на приложение.
13
+
14
+ Только так `_app` и знает о командах: имя, опции и справка остаются в
15
+ модуле команды, а приложение получает готовую регистрацию. Подпись одна и
16
+ та же и для `app.command(...)`, и для `app.add_typer(...)`.
17
+ """
18
+
19
+ def __call__(self, *, app: typer.Typer) -> None: ...
@@ -0,0 +1,23 @@
1
+ """Команды, по модулю на команду.
2
+
3
+ Модуль команды сам знает своё имя и свои опции: `_app` только собирает
4
+ приложение из их `register`. Когда у команды появятся подкоманды, её `register`
5
+ вызовет `add_typer`, и собирающий код это не заметит.
6
+ """
7
+
8
+ from typing import Final
9
+
10
+ from py_checks.cli._protocols import Registrar
11
+ from py_checks.cli.commands._explain import register as register_explain
12
+ from py_checks.cli.commands._list import register as register_list
13
+ from py_checks.cli.commands._run import register as register_run
14
+ from py_checks.cli.commands._sync import register as register_sync
15
+
16
+ REGISTRARS: Final[tuple[Registrar, ...]] = (
17
+ register_run,
18
+ register_list,
19
+ register_explain,
20
+ register_sync,
21
+ )
22
+
23
+ __all__ = ["REGISTRARS", "Registrar"]