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