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,24 @@
|
|
|
1
|
+
"""Типы значений, какими их отдаёт `tomllib`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
|
|
7
|
+
type TomlValue = (
|
|
8
|
+
bool
|
|
9
|
+
| int
|
|
10
|
+
| float
|
|
11
|
+
| str
|
|
12
|
+
| datetime.datetime
|
|
13
|
+
| datetime.date
|
|
14
|
+
| datetime.time
|
|
15
|
+
| list[TomlValue]
|
|
16
|
+
| TomlTable
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type TomlTable = dict[str, TomlValue]
|
|
20
|
+
"""Таблица TOML: секция конфига до того, как её разобрала модель.
|
|
21
|
+
|
|
22
|
+
Не `dict[str, Any]`: `Any` отключает проверку типов у всех, кто такую таблицу
|
|
23
|
+
получит, а здесь заранее известно, что значения бывают ровно этих видов.
|
|
24
|
+
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Контракты импортов для import-linter.
|
|
2
|
+
|
|
3
|
+
Как называются слои и кому что можно — знает проект, а не библиотека: в
|
|
4
|
+
сервисе это `domain` и `presentation`, в утилите — `core` и `cli`, и придумать
|
|
5
|
+
за них нельзя. Проект описывает это в `[tool.py-checks.contracts]`, а
|
|
6
|
+
`py-checks sync` собирает контракты под его раскладку: слои, которых на
|
|
7
|
+
диске нет, в файл не попадают, иначе import-linter упал бы на первом же
|
|
8
|
+
несуществующем модуле.
|
|
9
|
+
|
|
10
|
+
Собранный файл править нечего — перезапишет следующий sync; менять нужно
|
|
11
|
+
секцию.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from py_checks.contracts._constants import FILE, MIGRATIONS, MODULES, SECTION
|
|
15
|
+
from py_checks.contracts._render import render
|
|
16
|
+
from py_checks.contracts._settings import Contracts, contracts
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"FILE",
|
|
20
|
+
"MIGRATIONS",
|
|
21
|
+
"MODULES",
|
|
22
|
+
"SECTION",
|
|
23
|
+
"Contracts",
|
|
24
|
+
"contracts",
|
|
25
|
+
"render",
|
|
26
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Имена, из которых собираются контракты импортов."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
FILE: Final = ".importlinter"
|
|
8
|
+
|
|
9
|
+
# Пакет с модулями приложения: слои живут и в нём, и рядом с ним.
|
|
10
|
+
MODULES: Final = "modules"
|
|
11
|
+
|
|
12
|
+
MIGRATIONS: Final = "migrations"
|
|
13
|
+
|
|
14
|
+
# Проверяется только история: `env.py` рядом — не миграция, а запускающий её
|
|
15
|
+
# код, и метаданные моделей он импортирует по своей работе.
|
|
16
|
+
VERSIONS: Final = "versions"
|
|
17
|
+
|
|
18
|
+
SECTION: Final = "contracts"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Где в проекте лежат слои.
|
|
2
|
+
|
|
3
|
+
Контракт с несуществующим модулем валит весь прогон import-linter, поэтому
|
|
4
|
+
генератор сначала смотрит, что на диске есть. Подстановка (`pkg.modules.*.domain`)
|
|
5
|
+
ничего не ломает, когда не находит ничего, — её можно писать всегда.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from py_checks.contracts._constants import MIGRATIONS, MODULES, VERSIONS
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def package(
|
|
19
|
+
*,
|
|
20
|
+
root: Path,
|
|
21
|
+
src: Path,
|
|
22
|
+
) -> str | None:
|
|
23
|
+
"""Корневой пакет проекта: единственный пакет внутри `src`."""
|
|
24
|
+
source = root / src
|
|
25
|
+
if not source.is_dir():
|
|
26
|
+
return None
|
|
27
|
+
found = [
|
|
28
|
+
directory.name
|
|
29
|
+
for directory in sorted(source.iterdir())
|
|
30
|
+
if directory.is_dir() and (directory / "__init__.py").is_file()
|
|
31
|
+
]
|
|
32
|
+
if len(found) != 1:
|
|
33
|
+
return None
|
|
34
|
+
return found[0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def expressions(
|
|
38
|
+
*,
|
|
39
|
+
root: Path,
|
|
40
|
+
src: Path,
|
|
41
|
+
package: str,
|
|
42
|
+
layer: str,
|
|
43
|
+
) -> tuple[str, ...]:
|
|
44
|
+
"""Как назвать слой в контракте: сам по себе, внутри модулей, или никак."""
|
|
45
|
+
found: list[str] = []
|
|
46
|
+
if (root / src / package / layer).is_dir():
|
|
47
|
+
found.append(f"{package}.{layer}")
|
|
48
|
+
if (root / src / package / MODULES).is_dir():
|
|
49
|
+
found.append(f"{package}.{MODULES}.*.{layer}")
|
|
50
|
+
return tuple(found)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def modules(
|
|
54
|
+
*,
|
|
55
|
+
root: Path,
|
|
56
|
+
src: Path,
|
|
57
|
+
package: str,
|
|
58
|
+
) -> bool:
|
|
59
|
+
return (root / src / package / MODULES).is_dir()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def migrations(*, root: Path) -> bool:
|
|
63
|
+
return (root / MIGRATIONS / VERSIONS).is_dir()
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Сборка файла контрактов для import-linter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
from py_checks.config import prefix
|
|
8
|
+
from py_checks.contracts._constants import MIGRATIONS, MODULES, SECTION, VERSIONS
|
|
9
|
+
from py_checks.contracts._layout import expressions, migrations, modules, package
|
|
10
|
+
from py_checks.contracts._settings import contracts
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from py_checks.config import Config
|
|
17
|
+
|
|
18
|
+
HEADER: Final = """\
|
|
19
|
+
# Контракты импортов. Файл собирает `py-checks sync` из того, какие слои
|
|
20
|
+
# есть на диске, и из секции [{section}] — править его нечего, следующий sync
|
|
21
|
+
# перезапишет. Менять нужно секцию.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render(
|
|
26
|
+
*,
|
|
27
|
+
root: Path,
|
|
28
|
+
config: Config,
|
|
29
|
+
) -> str | None:
|
|
30
|
+
"""Файл контрактов; `None`, если проверять нечего.
|
|
31
|
+
|
|
32
|
+
Нечего — это либо проект, который не объявил ни одного слоя, либо
|
|
33
|
+
раскладка, в которой объявленных слоёв нет на диске.
|
|
34
|
+
"""
|
|
35
|
+
name = package(
|
|
36
|
+
root=root,
|
|
37
|
+
src=config.src,
|
|
38
|
+
)
|
|
39
|
+
if name is None:
|
|
40
|
+
return None
|
|
41
|
+
blocks = _contracts(
|
|
42
|
+
root=root,
|
|
43
|
+
config=config,
|
|
44
|
+
package=name,
|
|
45
|
+
)
|
|
46
|
+
if not blocks:
|
|
47
|
+
return None
|
|
48
|
+
return "\n".join(
|
|
49
|
+
[
|
|
50
|
+
# Секция зовётся по-разному в манифесте и в своём файле настроек:
|
|
51
|
+
# написать одно имя значит послать читателя не туда в половине
|
|
52
|
+
# проектов.
|
|
53
|
+
HEADER.format(section=f"{prefix(source=config.origin)}{SECTION}"),
|
|
54
|
+
_roots(
|
|
55
|
+
root=root,
|
|
56
|
+
package=name,
|
|
57
|
+
),
|
|
58
|
+
*blocks,
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _roots(
|
|
64
|
+
*,
|
|
65
|
+
root: Path,
|
|
66
|
+
package: str,
|
|
67
|
+
) -> str:
|
|
68
|
+
packages = [package, MIGRATIONS] if migrations(root=root) else [package]
|
|
69
|
+
return _block(
|
|
70
|
+
head="[importlinter]",
|
|
71
|
+
scalars={},
|
|
72
|
+
lists={"root_packages": packages},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _contracts(
|
|
77
|
+
*,
|
|
78
|
+
root: Path,
|
|
79
|
+
config: Config,
|
|
80
|
+
package: str,
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
declared = contracts(config=config)
|
|
83
|
+
table = declared.layers
|
|
84
|
+
known = frozenset(table) | frozenset(declared.composition_root)
|
|
85
|
+
blocks = [
|
|
86
|
+
block
|
|
87
|
+
for layer in sorted(table)
|
|
88
|
+
if (
|
|
89
|
+
block := _layer(
|
|
90
|
+
root=root,
|
|
91
|
+
config=config,
|
|
92
|
+
package=package,
|
|
93
|
+
layer=layer,
|
|
94
|
+
forbidden=known - frozenset(table[layer]),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
blocks.extend(
|
|
99
|
+
_independence(
|
|
100
|
+
root=root,
|
|
101
|
+
config=config,
|
|
102
|
+
package=package,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
blocks.extend(
|
|
106
|
+
_migrations(
|
|
107
|
+
root=root,
|
|
108
|
+
package=package,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return blocks
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _layer(
|
|
115
|
+
*,
|
|
116
|
+
root: Path,
|
|
117
|
+
config: Config,
|
|
118
|
+
package: str,
|
|
119
|
+
layer: str,
|
|
120
|
+
forbidden: Iterable[str],
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Контракт «этому слою нельзя вот это»."""
|
|
123
|
+
sources = expressions(
|
|
124
|
+
root=root,
|
|
125
|
+
src=config.src,
|
|
126
|
+
package=package,
|
|
127
|
+
layer=layer,
|
|
128
|
+
)
|
|
129
|
+
targets = [
|
|
130
|
+
expression
|
|
131
|
+
for other in sorted(forbidden)
|
|
132
|
+
for expression in expressions(
|
|
133
|
+
root=root,
|
|
134
|
+
src=config.src,
|
|
135
|
+
package=package,
|
|
136
|
+
layer=other,
|
|
137
|
+
)
|
|
138
|
+
]
|
|
139
|
+
if not sources or not targets:
|
|
140
|
+
return None
|
|
141
|
+
return _block(
|
|
142
|
+
head=f"[importlinter:contract:layer-{layer}]",
|
|
143
|
+
scalars={
|
|
144
|
+
"name": f"{layer} не импортирует чужое",
|
|
145
|
+
"type": "forbidden",
|
|
146
|
+
# Только прямые импорты. Непрямую цепочку тут проверять нечего:
|
|
147
|
+
# `presentation` зовёт `application`, а `application` знает
|
|
148
|
+
# `domain` — по таблице это и есть правильная работа, и запрет
|
|
149
|
+
# непрямых связей запретил бы её же.
|
|
150
|
+
"allow_indirect_imports": "True",
|
|
151
|
+
},
|
|
152
|
+
lists={"source_modules": list(sources), "forbidden_modules": targets},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _independence(
|
|
157
|
+
*,
|
|
158
|
+
root: Path,
|
|
159
|
+
config: Config,
|
|
160
|
+
package: str,
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
"""Модули друг о друге не знают: соседа зовут через порт, а не по имени."""
|
|
163
|
+
if not modules(
|
|
164
|
+
root=root,
|
|
165
|
+
src=config.src,
|
|
166
|
+
package=package,
|
|
167
|
+
):
|
|
168
|
+
return []
|
|
169
|
+
return [
|
|
170
|
+
_block(
|
|
171
|
+
head="[importlinter:contract:modules]",
|
|
172
|
+
scalars={"name": "модули независимы", "type": "independence"},
|
|
173
|
+
lists={"modules": [f"{package}.{MODULES}.*"]},
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _migrations(
|
|
179
|
+
*,
|
|
180
|
+
root: Path,
|
|
181
|
+
package: str,
|
|
182
|
+
) -> list[str]:
|
|
183
|
+
"""Миграция описывает схему, а не зовёт приложение: код уедет, схема останется.
|
|
184
|
+
|
|
185
|
+
Смотрим только на `versions`: `env.py` — не история, а то, что её запускает,
|
|
186
|
+
и метаданные моделей он импортирует по своей работе.
|
|
187
|
+
"""
|
|
188
|
+
if not migrations(root=root):
|
|
189
|
+
return []
|
|
190
|
+
return [
|
|
191
|
+
_block(
|
|
192
|
+
head=f"[importlinter:contract:{MIGRATIONS}]",
|
|
193
|
+
scalars={"name": "миграции не знают приложение", "type": "forbidden"},
|
|
194
|
+
lists={
|
|
195
|
+
"source_modules": [f"{MIGRATIONS}.{VERSIONS}"],
|
|
196
|
+
"forbidden_modules": [package],
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _block(
|
|
203
|
+
*,
|
|
204
|
+
head: str,
|
|
205
|
+
scalars: Mapping[str, str],
|
|
206
|
+
lists: Mapping[str, Sequence[str]],
|
|
207
|
+
) -> str:
|
|
208
|
+
"""Один раздел ini.
|
|
209
|
+
|
|
210
|
+
Списки пишутся в столбик даже из одного значения: import-linter разбирает
|
|
211
|
+
поле-список, написанное в строку, посимвольно — и ищет пакет `p`.
|
|
212
|
+
"""
|
|
213
|
+
lines = [head, *(f"{key} = {value}" for key, value in scalars.items())]
|
|
214
|
+
for key, values in lists.items():
|
|
215
|
+
lines.append(f"{key} =")
|
|
216
|
+
lines.extend(f" {value}" for value in values)
|
|
217
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Слои проекта: что он объявил о себе сам."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from py_checks.config import CheckSettings, ConfigError
|
|
10
|
+
from py_checks.contracts._constants import SECTION
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from py_checks.config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Contracts(CheckSettings):
|
|
17
|
+
"""Секция `[tool.py-checks.contracts]`.
|
|
18
|
+
|
|
19
|
+
`layers` — слой и то, что ему разрешено импортировать. Всё, чего в таблице
|
|
20
|
+
нет, ограничений не имеет: библиотека не знает, как называются слои в этом
|
|
21
|
+
проекте, и не догадывается за него.
|
|
22
|
+
|
|
23
|
+
`composition-root` — слои, которым можно всё: они связывают остальные между
|
|
24
|
+
собой, и это вся их работа. Перечислять их отдельно нужно затем, чтобы они
|
|
25
|
+
попали в запреты остальных: слой, о котором таблица не знает, ничьим
|
|
26
|
+
запретом не становится.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
composition_root: tuple[str, ...] = ()
|
|
30
|
+
layers: dict[str, tuple[str, ...]] = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def contracts(*, config: Config) -> Contracts:
|
|
34
|
+
try:
|
|
35
|
+
return Contracts.model_validate(config.section(code=SECTION))
|
|
36
|
+
except ValidationError as error:
|
|
37
|
+
raise ConfigError(f"[tool.py-checks.{SECTION}]: {error}") from error
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Общая часть всех проверок.
|
|
2
|
+
|
|
3
|
+
Поиск файлов, разбор исходника один раз на файл, описание нарушения, реестр
|
|
4
|
+
проверок и вывод. Правило знает только своё условие, всё остальное берёт
|
|
5
|
+
отсюда.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from py_checks.core._constants import EXIT_OK, EXIT_VIOLATION, GROUP
|
|
9
|
+
from py_checks.core._discovery import python_files
|
|
10
|
+
from py_checks.core._edit import Edit, apply, column
|
|
11
|
+
from py_checks.core._errors import ParseError, UnknownCheckError
|
|
12
|
+
from py_checks.core._fixer import fix
|
|
13
|
+
from py_checks.core._format import reformat
|
|
14
|
+
from py_checks.core._markers import MARKER, Marker, complaints, read, surviving
|
|
15
|
+
from py_checks.core._protocols import Check, FileCheck, ProjectCheck, Scope
|
|
16
|
+
from py_checks.core._registry import Checks, available, get
|
|
17
|
+
from py_checks.core._report import report
|
|
18
|
+
from py_checks.core._runner import SYNTAX, examine, inspect, survey
|
|
19
|
+
from py_checks.core._settings import SettingsMismatchError, settings_as
|
|
20
|
+
from py_checks.core._source import ParsedFile
|
|
21
|
+
from py_checks.core._violation import Violation
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"EXIT_OK",
|
|
25
|
+
"EXIT_VIOLATION",
|
|
26
|
+
"GROUP",
|
|
27
|
+
"MARKER",
|
|
28
|
+
"SYNTAX",
|
|
29
|
+
"Check",
|
|
30
|
+
"Checks",
|
|
31
|
+
"Edit",
|
|
32
|
+
"FileCheck",
|
|
33
|
+
"ProjectCheck",
|
|
34
|
+
"Marker",
|
|
35
|
+
"ParseError",
|
|
36
|
+
"ParsedFile",
|
|
37
|
+
"Scope",
|
|
38
|
+
"SettingsMismatchError",
|
|
39
|
+
"UnknownCheckError",
|
|
40
|
+
"Violation",
|
|
41
|
+
"apply",
|
|
42
|
+
"available",
|
|
43
|
+
"column",
|
|
44
|
+
"complaints",
|
|
45
|
+
"fix",
|
|
46
|
+
"examine",
|
|
47
|
+
"get",
|
|
48
|
+
"inspect",
|
|
49
|
+
"python_files",
|
|
50
|
+
"read",
|
|
51
|
+
"reformat",
|
|
52
|
+
"surviving",
|
|
53
|
+
"settings_as",
|
|
54
|
+
"report",
|
|
55
|
+
"survey",
|
|
56
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Имена и коды, общие для ядра."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
# Группа entry points, в которой объявляются проверки: своя проверка живёт в
|
|
8
|
+
# отдельном пакете и не требует форка библиотеки. Группа одна на все виды
|
|
9
|
+
# правил — что правилу дают, файл или корень проекта, оно говорит само.
|
|
10
|
+
GROUP: Final = "py_checks.checks"
|
|
11
|
+
|
|
12
|
+
# Что видит оболочка. Голая единица была бы кодом, смысл которого знает только
|
|
13
|
+
# вызывающий; pre-commit по ней останавливает коммит.
|
|
14
|
+
EXIT_OK: Final = 0
|
|
15
|
+
EXIT_VIOLATION: Final = 1
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Какие файлы проверяем."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def python_files(
|
|
14
|
+
*,
|
|
15
|
+
paths: Sequence[Path],
|
|
16
|
+
root: Path,
|
|
17
|
+
default: Path,
|
|
18
|
+
exclude: Iterable[str] = (),
|
|
19
|
+
) -> list[Path]:
|
|
20
|
+
"""Все `.py` из переданных путей, без исключённых.
|
|
21
|
+
|
|
22
|
+
pre-commit передаёт список изменённых файлов, поэтому путь может быть и
|
|
23
|
+
файлом, и директорией. Когда путей нет — запуск руками, — берём `default`
|
|
24
|
+
(обычно `src`), а не весь репозиторий: иначе в выборку попадут `.venv` и
|
|
25
|
+
прочее чужое.
|
|
26
|
+
"""
|
|
27
|
+
roots = list(paths) if paths else [default]
|
|
28
|
+
patterns = tuple(exclude)
|
|
29
|
+
found: set[Path] = set()
|
|
30
|
+
for entry in roots:
|
|
31
|
+
found.update(_walk(entry=entry))
|
|
32
|
+
return sorted(
|
|
33
|
+
path
|
|
34
|
+
for path in found
|
|
35
|
+
if not _excluded(
|
|
36
|
+
path=path,
|
|
37
|
+
root=root,
|
|
38
|
+
patterns=patterns,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _walk(*, entry: Path) -> Iterable[Path]:
|
|
44
|
+
if entry.is_dir():
|
|
45
|
+
return entry.rglob("*.py")
|
|
46
|
+
return [entry] if entry.suffix == ".py" else []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _excluded(
|
|
50
|
+
*,
|
|
51
|
+
path: Path,
|
|
52
|
+
root: Path,
|
|
53
|
+
patterns: tuple[str, ...],
|
|
54
|
+
) -> bool:
|
|
55
|
+
relative = path.relative_to(root) if path.is_relative_to(root) else path
|
|
56
|
+
as_posix = relative.as_posix()
|
|
57
|
+
return any(fnmatch(as_posix, pattern) for pattern in patterns)
|
py_checks/core/_edit.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Правка исходника и то, как она накладывается."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from itertools import accumulate
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Edit:
|
|
15
|
+
"""Замена куска исходника, строки и колонки — как у нарушения, с единицы.
|
|
16
|
+
|
|
17
|
+
Вставка — это замена пустого места: начало и конец совпадают. Правка
|
|
18
|
+
описывает только тот кусок, который меняет, поэтому всё остальное в файле —
|
|
19
|
+
комментарии, переносы, чужое форматирование — остаётся нетронутым.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
line: int
|
|
23
|
+
column: int
|
|
24
|
+
end_line: int
|
|
25
|
+
end_column: int
|
|
26
|
+
text: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def column(
|
|
30
|
+
*,
|
|
31
|
+
line: str,
|
|
32
|
+
offset: int,
|
|
33
|
+
) -> int:
|
|
34
|
+
"""Колонка правки по смещению из дерева, с единицы.
|
|
35
|
+
|
|
36
|
+
`ast` считает `col_offset` в БАЙТАХ utf-8, а правка индексирует строку
|
|
37
|
+
символами. Совпадает это ровно до первого не-ascii символа в строке: одно
|
|
38
|
+
русское слово в литерале раньше по строке — и запятая встаёт не туда.
|
|
39
|
+
"""
|
|
40
|
+
return len(line.encode("utf-8")[:offset].decode("utf-8", errors="ignore")) + 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def apply(
|
|
44
|
+
*,
|
|
45
|
+
text: str,
|
|
46
|
+
edits: Sequence[Edit],
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Исходник со всеми правками.
|
|
49
|
+
|
|
50
|
+
Накладываются с конца файла к началу: тогда позиции ещё не наложенных
|
|
51
|
+
правок остаются верными и пересчитывать их не нужно. Правки, залезающие на
|
|
52
|
+
уже наложенную, пропускаются — две проверки, спорящие за один кусок текста,
|
|
53
|
+
должны разойтись в разных прогонах, а не перезаписывать друг друга.
|
|
54
|
+
"""
|
|
55
|
+
starts = _starts(text=text)
|
|
56
|
+
applied = len(text)
|
|
57
|
+
result = text
|
|
58
|
+
for edit in sorted(
|
|
59
|
+
edits,
|
|
60
|
+
key=lambda edit: (edit.line, edit.column),
|
|
61
|
+
reverse=True,
|
|
62
|
+
):
|
|
63
|
+
start = _offset(
|
|
64
|
+
starts=starts,
|
|
65
|
+
line=edit.line,
|
|
66
|
+
column=edit.column,
|
|
67
|
+
)
|
|
68
|
+
end = _offset(
|
|
69
|
+
starts=starts,
|
|
70
|
+
line=edit.end_line,
|
|
71
|
+
column=edit.end_column,
|
|
72
|
+
)
|
|
73
|
+
if end > applied:
|
|
74
|
+
continue
|
|
75
|
+
result = result[:start] + edit.text + result[end:]
|
|
76
|
+
applied = start
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _starts(*, text: str) -> tuple[int, ...]:
|
|
81
|
+
"""Смещение начала каждой строки от начала файла."""
|
|
82
|
+
lengths = (len(line) for line in text.splitlines(keepends=True))
|
|
83
|
+
return (0, *accumulate(lengths))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _offset(
|
|
87
|
+
*,
|
|
88
|
+
starts: Sequence[int],
|
|
89
|
+
line: int,
|
|
90
|
+
column: int,
|
|
91
|
+
) -> int:
|
|
92
|
+
return starts[min(line, len(starts)) - 1] + column - 1
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Ошибки ядра."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ParseError(Exception):
|
|
12
|
+
"""Файл не разбирается: синтаксис сломан."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
path: Path,
|
|
18
|
+
error: SyntaxError,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(f"{path}: {error.msg}")
|
|
21
|
+
self.path = path
|
|
22
|
+
self.error = error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnknownCheckError(Exception):
|
|
26
|
+
"""Такой проверки нет."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
code: str,
|
|
32
|
+
known: tuple[str, ...],
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(f"неизвестная проверка {code!r}; есть: {', '.join(known)}")
|
|
35
|
+
self.code = code
|