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,14 @@
|
|
|
1
|
+
"""Гигиена репозитория.
|
|
2
|
+
|
|
3
|
+
Зависимость объявляет потолок: без него версию выбирает решатель, а не
|
|
4
|
+
человек. Остальное этой группы закрывается не проверками — `.env.example`
|
|
5
|
+
генерируется из моделей настроек, а `CLAUDE.md` это симлинк на `AGENTS.md`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from py_checks.checks.hygiene._dependency_bounds import (
|
|
9
|
+
DependencyBounds,
|
|
10
|
+
DependencyBoundsSettings,
|
|
11
|
+
)
|
|
12
|
+
from py_checks.checks.hygiene._marker import MARKER
|
|
13
|
+
|
|
14
|
+
__all__ = ["MARKER", "DependencyBounds", "DependencyBoundsSettings"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""У зависимости есть потолок, иначе версию выбирает решатель."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Final
|
|
8
|
+
|
|
9
|
+
from py_checks.checks.hygiene._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
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CODE: Final = "dependency-bounds"
|
|
18
|
+
|
|
19
|
+
MANIFEST: Final = "pyproject.toml"
|
|
20
|
+
MARKERS: Final = ";"
|
|
21
|
+
FIRST: Final = 1
|
|
22
|
+
|
|
23
|
+
# Имя, за которым идут extras и спецификаторы. `packaging` разобрал бы это
|
|
24
|
+
# правильно и не является зависимостью хуков, а формы, которые встречаются в
|
|
25
|
+
# манифесте, узки настолько, что выражение говорит всё правило целиком.
|
|
26
|
+
REQUIREMENT: Final = re.compile(
|
|
27
|
+
r"^(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?(?P<rest>.*)$"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DependencyBoundsSettings(CheckSettings):
|
|
32
|
+
ceilings: tuple[str, ...] = ("==", "<=", "<", "~=", "===")
|
|
33
|
+
pins: tuple[str, ...] = ("rev", "tag")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DependencyBounds:
|
|
37
|
+
"""Падает, если зависимость может уехать на версию, которую никто не запускал.
|
|
38
|
+
|
|
39
|
+
Требование объявляет потолок одним из двух способов: точной версией
|
|
40
|
+
(`greenlet==3.5.5`) или парой «пол и потолок» (`pydantic>=2.13.5,<3`,
|
|
41
|
+
`structlog~=26.1` — то же самое, сказанное иначе).
|
|
42
|
+
|
|
43
|
+
Отвергается голый пол — `pre-commit>=4.6.2`. Читается он как минимум, а
|
|
44
|
+
ведёт себя как «что новее на момент, когда кто-то пересобрал лок», то есть
|
|
45
|
+
как другой сервис после каждого обновления: выходит мажор, лок двигается, и
|
|
46
|
+
изменение приезжает в том коммите, который случайно тронул зависимости.
|
|
47
|
+
Потолок делает этот приезд осознанной правкой, за которой стоит диff и
|
|
48
|
+
прогон тестов, — единственное место, где ломающее обновление вообще можно
|
|
49
|
+
прочитать.
|
|
50
|
+
|
|
51
|
+
Лок этого не заменяет: `uv.lock` фиксирует то, что стоит сегодня, и он
|
|
52
|
+
пересобирается — ограничение это то, что переживает пересборку.
|
|
53
|
+
|
|
54
|
+
Проверяются все группы: зависимость тестов решает, проходит ли набор, а
|
|
55
|
+
сборочная — существует ли колесо.
|
|
56
|
+
|
|
57
|
+
Исключение одно, и оно несёт свой собственный гвоздь: требование без
|
|
58
|
+
спецификаторов, чьё имя лежит в `[tool.uv.sources]` с `rev` или `tag`.
|
|
59
|
+
Коммит — самый тесный потолок, какой бывает.
|
|
60
|
+
|
|
61
|
+
Настройки: `ceilings`, `pins`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
code: ClassVar[str] = CODE
|
|
65
|
+
Settings: ClassVar[type[CheckSettings]] = DependencyBoundsSettings
|
|
66
|
+
scope: ClassVar[Scope] = Scope.PROJECT
|
|
67
|
+
marker: ClassVar[str] = MARKER
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def run(
|
|
71
|
+
cls,
|
|
72
|
+
*,
|
|
73
|
+
root: Path,
|
|
74
|
+
settings: CheckSettings,
|
|
75
|
+
) -> Iterator[Violation]:
|
|
76
|
+
limits = settings_as(
|
|
77
|
+
settings=settings,
|
|
78
|
+
model=DependencyBoundsSettings,
|
|
79
|
+
code=CODE,
|
|
80
|
+
)
|
|
81
|
+
path = root / MANIFEST
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return
|
|
84
|
+
text = path.read_text(encoding="utf-8")
|
|
85
|
+
manifest = tomllib.loads(text)
|
|
86
|
+
exempt = cls._pinned(
|
|
87
|
+
manifest=manifest,
|
|
88
|
+
pins=limits.pins,
|
|
89
|
+
)
|
|
90
|
+
for where, requirement in cls._requirements(manifest=manifest):
|
|
91
|
+
if cls._bounded(
|
|
92
|
+
requirement=requirement,
|
|
93
|
+
ceilings=limits.ceilings,
|
|
94
|
+
exempt=exempt,
|
|
95
|
+
):
|
|
96
|
+
continue
|
|
97
|
+
yield Violation(
|
|
98
|
+
path=path,
|
|
99
|
+
line=cls._line(
|
|
100
|
+
text=text,
|
|
101
|
+
requirement=requirement,
|
|
102
|
+
),
|
|
103
|
+
column=FIRST,
|
|
104
|
+
code=CODE,
|
|
105
|
+
message=(
|
|
106
|
+
f"{where}: у {requirement!r} нет потолка; закрепи (==) или ограничь "
|
|
107
|
+
f"(>=x,<y), иначе версию выберет решатель"
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def _bounded(
|
|
113
|
+
cls,
|
|
114
|
+
*,
|
|
115
|
+
requirement: str,
|
|
116
|
+
ceilings: tuple[str, ...],
|
|
117
|
+
exempt: frozenset[str],
|
|
118
|
+
) -> bool:
|
|
119
|
+
stated = cls._stated(requirement=requirement)
|
|
120
|
+
if stated is None:
|
|
121
|
+
return True
|
|
122
|
+
if any(ceiling in stated for ceiling in ceilings):
|
|
123
|
+
return True
|
|
124
|
+
return not stated and cls._name(requirement=requirement) in exempt
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _stated(*, requirement: str) -> str | None:
|
|
128
|
+
"""Спецификаторы требования, или None, если это не то, что мы читаем.
|
|
129
|
+
|
|
130
|
+
Маркеры отрезаются первыми: `; python_version < "3.13"` несёт свои
|
|
131
|
+
операторы сравнения и ничего не говорит о том, какая версия встанет.
|
|
132
|
+
"""
|
|
133
|
+
written = requirement.split(MARKERS, maxsplit=1)[0].strip()
|
|
134
|
+
found = REQUIREMENT.match(written)
|
|
135
|
+
return None if found is None else found.group("rest").strip()
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _name(*, requirement: str) -> str:
|
|
139
|
+
found = REQUIREMENT.match(requirement.split(MARKERS, maxsplit=1)[0].strip())
|
|
140
|
+
return "" if found is None else found.group("name").lower().replace("_", "-")
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _pinned(
|
|
144
|
+
*,
|
|
145
|
+
manifest: dict[str, Any],
|
|
146
|
+
pins: tuple[str, ...],
|
|
147
|
+
) -> frozenset[str]:
|
|
148
|
+
"""Имена, чей источник — коммит: это потолок в одну версию."""
|
|
149
|
+
sources = manifest.get("tool", {}).get("uv", {}).get("sources", {})
|
|
150
|
+
return frozenset(
|
|
151
|
+
name.lower().replace("_", "-")
|
|
152
|
+
for name, source in sources.items()
|
|
153
|
+
if isinstance(source, dict) and any(pin in source for pin in pins)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _requirements(*, manifest: dict[str, Any]) -> list[tuple[str, str]]:
|
|
158
|
+
"""Каждое требование файла вместе с группой, в которой оно написано."""
|
|
159
|
+
project = manifest.get("project", {})
|
|
160
|
+
listed: list[tuple[str, str]] = [
|
|
161
|
+
("project.dependencies", one) for one in project.get("dependencies", [])
|
|
162
|
+
]
|
|
163
|
+
for extra, group in project.get("optional-dependencies", {}).items():
|
|
164
|
+
listed += [(f"project.optional-dependencies.{extra}", one) for one in group]
|
|
165
|
+
for name, group in manifest.get("dependency-groups", {}).items():
|
|
166
|
+
# Группа может включать другую группу — это словарь, а не
|
|
167
|
+
# требование, и ограничивать в нём нечего.
|
|
168
|
+
listed += [(f"dependency-groups.{name}", one) for one in group if isinstance(one, str)]
|
|
169
|
+
listed += [
|
|
170
|
+
("build-system.requires", one)
|
|
171
|
+
for one in manifest.get("build-system", {}).get("requires", [])
|
|
172
|
+
]
|
|
173
|
+
return listed
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _line(
|
|
177
|
+
*,
|
|
178
|
+
text: str,
|
|
179
|
+
requirement: str,
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Строка, на которой требование написано: tomllib позиций не отдаёт."""
|
|
182
|
+
for number, line in enumerate(text.splitlines(), start=FIRST):
|
|
183
|
+
if requirement in line:
|
|
184
|
+
return number
|
|
185
|
+
return FIRST
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Импорты и границы.
|
|
2
|
+
|
|
3
|
+
Слои, независимость модулей и импорты в миграциях уехали в import-linter:
|
|
4
|
+
контракты для него собирает `py-checks sync`. Здесь остались два правила,
|
|
5
|
+
которые в контракты ложатся наизнанку — там пришлось бы перечислять все места,
|
|
6
|
+
где пакет запрещён, и дописывать каждое новое.
|
|
7
|
+
|
|
8
|
+
`confined-imports` смотрит со стороны пакета: где ему можно.
|
|
9
|
+
`sealed-imports` — со стороны места: что можно здесь.
|
|
10
|
+
|
|
11
|
+
Обе таблицы — проектные: имена слоёв и список фреймворков библиотека знать не
|
|
12
|
+
может. Без настроек оба правила молчат.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from py_checks.checks.imports._confined import ConfinedImports, ConfinedSettings
|
|
16
|
+
from py_checks.checks.imports._marker import MARKER
|
|
17
|
+
from py_checks.checks.imports._sealed import SealedImports, SealedSettings
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MARKER",
|
|
21
|
+
"ConfinedImports",
|
|
22
|
+
"ConfinedSettings",
|
|
23
|
+
"SealedImports",
|
|
24
|
+
"SealedSettings",
|
|
25
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Пакет живёт там, где ему место."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from py_checks.checks._location import place
|
|
8
|
+
from py_checks.checks.imports._marker import MARKER
|
|
9
|
+
from py_checks.checks.imports._statements import imports
|
|
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
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from py_checks.checks._location import Place
|
|
18
|
+
from py_checks.checks.imports._statements import Imported
|
|
19
|
+
from py_checks.core import ParsedFile
|
|
20
|
+
|
|
21
|
+
CODE: Final = "confined-imports"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfinedSettings(CheckSettings):
|
|
25
|
+
packages: dict[str, tuple[str, ...]] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfinedImports:
|
|
29
|
+
"""Падает, если пакет импортируется вне отведённых ему мест.
|
|
30
|
+
|
|
31
|
+
Фреймворк, расползшийся по всем слоям, — это фреймворк, который нельзя
|
|
32
|
+
заменить: замена превращается в правку всего сервиса. Пока ORM живёт в
|
|
33
|
+
`infra/database`, а веб-стек на краю, каждый из них меняется в одном месте.
|
|
34
|
+
|
|
35
|
+
Где чьё место, знает проект: у сервиса это `infra/database`, у утилиты
|
|
36
|
+
такого слоя нет вовсе. Список пишется в
|
|
37
|
+
`[tool.py-checks.confined-imports.packages]`; пустой список значит
|
|
38
|
+
«нигде» — так держат убранную библиотеку, чтобы она не вернулась. Пакета,
|
|
39
|
+
которого в списке нет, правило не касается.
|
|
40
|
+
|
|
41
|
+
Настройка: `packages`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
code: ClassVar[str] = CODE
|
|
45
|
+
Settings: ClassVar[type[CheckSettings]] = ConfinedSettings
|
|
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
|
+
table = settings_as(
|
|
57
|
+
settings=settings,
|
|
58
|
+
model=ConfinedSettings,
|
|
59
|
+
code=CODE,
|
|
60
|
+
).packages
|
|
61
|
+
where = place(file=file)
|
|
62
|
+
if where is None:
|
|
63
|
+
return
|
|
64
|
+
for imported in imports(tree=file.tree):
|
|
65
|
+
allowed = table.get(imported.top)
|
|
66
|
+
if allowed is None or any(where.under(prefix=path) for path in allowed):
|
|
67
|
+
continue
|
|
68
|
+
yield cls._violation(
|
|
69
|
+
imported=imported,
|
|
70
|
+
where=where,
|
|
71
|
+
allowed=allowed,
|
|
72
|
+
path=file.path,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _violation(
|
|
77
|
+
*,
|
|
78
|
+
imported: Imported,
|
|
79
|
+
where: Place,
|
|
80
|
+
allowed: tuple[str, ...],
|
|
81
|
+
path: Path,
|
|
82
|
+
) -> Violation:
|
|
83
|
+
message = (
|
|
84
|
+
f"{imported.top} в {where.where}; ему место в {', '.join(allowed)}"
|
|
85
|
+
if allowed
|
|
86
|
+
else f"{imported.top} в {where.where}; этот пакет убран, импортировать его негде"
|
|
87
|
+
)
|
|
88
|
+
return Violation.from_node(
|
|
89
|
+
node=imported.node,
|
|
90
|
+
path=path,
|
|
91
|
+
code=CODE,
|
|
92
|
+
message=message,
|
|
93
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Внутри запечатанной зоны чужих пакетов нет."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from py_checks.checks._location import place
|
|
8
|
+
from py_checks.checks.imports._marker import MARKER
|
|
9
|
+
from py_checks.checks.imports._statements import imports
|
|
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._location import Place
|
|
17
|
+
from py_checks.core import ParsedFile
|
|
18
|
+
|
|
19
|
+
CODE: Final = "sealed-imports"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SealedSettings(CheckSettings):
|
|
23
|
+
zones: tuple[str, ...] = ()
|
|
24
|
+
allow: dict[str, tuple[str, ...]] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SealedImports:
|
|
28
|
+
"""Падает, если запечатанная зона импортирует чужой пакет.
|
|
29
|
+
|
|
30
|
+
Правила и интерфейсы вокруг них не знают ничего, кроме стандартной
|
|
31
|
+
библиотеки и кода самого сервиса: DTO здесь — dataclass, а не модель
|
|
32
|
+
фреймворка. Список разрешённого белый, а не чёрный, потому что каждый новый
|
|
33
|
+
фреймворк иначе попадает внутрь молча.
|
|
34
|
+
|
|
35
|
+
Разрешения задаются по слою, а не на всю зону: слой, который руководит,
|
|
36
|
+
обычно имеет право сказать, что произошло, а слой с правилами не знает
|
|
37
|
+
ничего.
|
|
38
|
+
|
|
39
|
+
Какие зоны запечатаны, знает проект: библиотека не догадывается, что у него
|
|
40
|
+
называется `modules`. Без `zones` правило молчит.
|
|
41
|
+
|
|
42
|
+
Настройки: `zones`, `allow`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
code: ClassVar[str] = CODE
|
|
46
|
+
Settings: ClassVar[type[CheckSettings]] = SealedSettings
|
|
47
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
48
|
+
marker: ClassVar[str] = MARKER
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def run(
|
|
52
|
+
cls,
|
|
53
|
+
*,
|
|
54
|
+
file: ParsedFile,
|
|
55
|
+
settings: CheckSettings,
|
|
56
|
+
) -> Iterator[Violation]:
|
|
57
|
+
own = settings_as(
|
|
58
|
+
settings=settings,
|
|
59
|
+
model=SealedSettings,
|
|
60
|
+
code=CODE,
|
|
61
|
+
)
|
|
62
|
+
where = place(file=file)
|
|
63
|
+
if where is None or not cls._sealed(
|
|
64
|
+
where=where,
|
|
65
|
+
zones=own.zones,
|
|
66
|
+
):
|
|
67
|
+
return
|
|
68
|
+
allowed = cls._allowed(
|
|
69
|
+
where=where,
|
|
70
|
+
allow=own.allow,
|
|
71
|
+
)
|
|
72
|
+
for imported in imports(tree=file.tree):
|
|
73
|
+
if imported.stdlib or imported.top in {where.package, *allowed}:
|
|
74
|
+
continue
|
|
75
|
+
yield Violation.from_node(
|
|
76
|
+
node=imported.node,
|
|
77
|
+
path=file.path,
|
|
78
|
+
code=CODE,
|
|
79
|
+
message=(
|
|
80
|
+
f"{imported.top} в {where.where}: запечатанная зона знает "
|
|
81
|
+
"только стандартную библиотеку и код сервиса"
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _sealed(
|
|
87
|
+
*,
|
|
88
|
+
where: Place,
|
|
89
|
+
zones: tuple[str, ...],
|
|
90
|
+
) -> bool:
|
|
91
|
+
return any(part in zones for part in where.parts)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _allowed(
|
|
95
|
+
*,
|
|
96
|
+
where: Place,
|
|
97
|
+
allow: dict[str, tuple[str, ...]],
|
|
98
|
+
) -> frozenset[str]:
|
|
99
|
+
"""Что можно этому слою: зона у файла одна, а слой внутри неё — свой."""
|
|
100
|
+
return frozenset(package for part in where.parts for package in allow.get(part, ()))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Импорты файла в том виде, в каком о них говорят правила."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Final
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
STDLIB: Final[frozenset[str]] = frozenset(sys.stdlib_module_names)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Imported:
|
|
18
|
+
"""Один импорт: узел, полное имя и пакет, которому оно принадлежит."""
|
|
19
|
+
|
|
20
|
+
node: ast.stmt
|
|
21
|
+
module: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def top(self) -> str:
|
|
25
|
+
return self.module.split(".", maxsplit=1)[0]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def stdlib(self) -> bool:
|
|
29
|
+
return self.top in STDLIB
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def imports(*, tree: ast.Module) -> Iterator[Imported]:
|
|
33
|
+
"""Все импорты модуля, кроме относительных.
|
|
34
|
+
|
|
35
|
+
Относительный импорт — это всегда сосед по пакету, то есть код самого
|
|
36
|
+
проекта: для правил про чужие пакеты он ничего не значит.
|
|
37
|
+
"""
|
|
38
|
+
for node in ast.walk(tree):
|
|
39
|
+
match node:
|
|
40
|
+
case ast.Import(names=names):
|
|
41
|
+
for name in names:
|
|
42
|
+
yield Imported(
|
|
43
|
+
node=node,
|
|
44
|
+
module=name.name,
|
|
45
|
+
)
|
|
46
|
+
case ast.ImportFrom(module=str(module), level=0):
|
|
47
|
+
yield Imported(
|
|
48
|
+
node=node,
|
|
49
|
+
module=module,
|
|
50
|
+
)
|
|
51
|
+
case _:
|
|
52
|
+
continue
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Размещение и форма модуля.
|
|
2
|
+
|
|
3
|
+
Где лежит класс, что допускает директория, какой класс модуль обязан объявить
|
|
4
|
+
первым. Соглашения, привязанные к раскладке директорий: готовых инструментов
|
|
5
|
+
под них нет — семейство ArchUnit для Python занято импортами, а движки образцов
|
|
6
|
+
(Semgrep, ast-grep) умеют запрещать, но не разрешать.
|
|
7
|
+
|
|
8
|
+
Таблицы проектные: имена `use_cases`, `dto`, `schemas` библиотека знать не
|
|
9
|
+
может. Без них правила молчат.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from py_checks.checks.placement._class_modules import ClassModules, ClassModulesSettings
|
|
13
|
+
from py_checks.checks.placement._class_placement import (
|
|
14
|
+
ClassPlacement,
|
|
15
|
+
ClassPlacementSettings,
|
|
16
|
+
Rule,
|
|
17
|
+
)
|
|
18
|
+
from py_checks.checks.placement._marker import MARKER
|
|
19
|
+
from py_checks.checks.placement._operation_shape import (
|
|
20
|
+
Operation,
|
|
21
|
+
OperationShape,
|
|
22
|
+
OperationShapeSettings,
|
|
23
|
+
)
|
|
24
|
+
from py_checks.checks.placement._required_class import RequiredClass, RequiredClassSettings
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MARKER",
|
|
28
|
+
"ClassModules",
|
|
29
|
+
"ClassModulesSettings",
|
|
30
|
+
"ClassPlacement",
|
|
31
|
+
"ClassPlacementSettings",
|
|
32
|
+
"Operation",
|
|
33
|
+
"OperationShape",
|
|
34
|
+
"OperationShapeSettings",
|
|
35
|
+
"RequiredClass",
|
|
36
|
+
"RequiredClassSettings",
|
|
37
|
+
"Rule",
|
|
38
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
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._location import Place
|
|
17
|
+
from py_checks.core import ParsedFile
|
|
18
|
+
|
|
19
|
+
CODE: Final = "class-modules"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClassModulesSettings(CheckSettings):
|
|
23
|
+
policies: dict[str, tuple[Kind, ...]] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClassModules:
|
|
27
|
+
"""Падает, если в модуле лежит то, чего его директория не допускает.
|
|
28
|
+
|
|
29
|
+
Директория называет, что в ней живёт, и рядом не садится ничего другого.
|
|
30
|
+
Хелпер, заехавший в модуль use case, — либо часть класса, и тогда он
|
|
31
|
+
статический метод внутри, либо общий, и тогда ему место там, где лежит
|
|
32
|
+
остальное общее. Перечисление, забредшее в `dto/`, — та же история.
|
|
33
|
+
|
|
34
|
+
Импорты, константы, блоки `if TYPE_CHECKING` и докстринг разрешены везде:
|
|
35
|
+
правило про то, что модуль объявляет, а не про то, чем он пользуется.
|
|
36
|
+
|
|
37
|
+
Ключ политики — путь, а не имя директории, и это важно: `application/
|
|
38
|
+
services` держит класс-оркестратор, а `domain/services` — функции, правила,
|
|
39
|
+
сравнивающие два факта. Одно слово, два разных зверя. Директории, которой
|
|
40
|
+
в таблице нет, правило не касается.
|
|
41
|
+
|
|
42
|
+
Настройка: `policies`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
code: ClassVar[str] = CODE
|
|
46
|
+
Settings: ClassVar[type[CheckSettings]] = ClassModulesSettings
|
|
47
|
+
scope: ClassVar[Scope] = Scope.FILE
|
|
48
|
+
marker: ClassVar[str] = MARKER
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def run(
|
|
52
|
+
cls,
|
|
53
|
+
*,
|
|
54
|
+
file: ParsedFile,
|
|
55
|
+
settings: CheckSettings,
|
|
56
|
+
) -> Iterator[Violation]:
|
|
57
|
+
policies = settings_as(
|
|
58
|
+
settings=settings,
|
|
59
|
+
model=ClassModulesSettings,
|
|
60
|
+
code=CODE,
|
|
61
|
+
).policies
|
|
62
|
+
where = place(file=file)
|
|
63
|
+
if where is None:
|
|
64
|
+
return
|
|
65
|
+
policy = cls._policy(
|
|
66
|
+
where=where,
|
|
67
|
+
policies=policies,
|
|
68
|
+
)
|
|
69
|
+
if policy is None:
|
|
70
|
+
return
|
|
71
|
+
directory, allowed = policy
|
|
72
|
+
for declared in declarations(tree=file.tree):
|
|
73
|
+
# Вид не виден — судить не о чем: это класс с базой из другого модуля.
|
|
74
|
+
if declared.kind is None or declared.kind in allowed:
|
|
75
|
+
continue
|
|
76
|
+
yield Violation.from_node(
|
|
77
|
+
node=declared.node,
|
|
78
|
+
path=file.path,
|
|
79
|
+
code=CODE,
|
|
80
|
+
message=(
|
|
81
|
+
f"{declared.name} — {declared.kind.said}; в {directory} держат "
|
|
82
|
+
f"{', '.join(sorted(one.said for one in allowed))}"
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _policy(
|
|
88
|
+
*,
|
|
89
|
+
where: Place,
|
|
90
|
+
policies: dict[str, tuple[Kind, ...]],
|
|
91
|
+
) -> tuple[str, tuple[Kind, ...]] | None:
|
|
92
|
+
"""Политика самой внутренней из совпавших директорий.
|
|
93
|
+
|
|
94
|
+
Побеждает самая глубокая: `modules/betslip/application/services`
|
|
95
|
+
важнее, чем `application`. При равной глубине — более длинный ключ:
|
|
96
|
+
путь говорит о месте больше, чем одно имя.
|
|
97
|
+
"""
|
|
98
|
+
matched = [
|
|
99
|
+
(depth, directory, allowed)
|
|
100
|
+
for directory, allowed in policies.items()
|
|
101
|
+
if (depth := where.within(directory=directory)) is not None
|
|
102
|
+
]
|
|
103
|
+
if not matched:
|
|
104
|
+
return None
|
|
105
|
+
deepest = max(matched, key=lambda policy: (policy[0], len(policy[1])))
|
|
106
|
+
return deepest[1], deepest[2]
|