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,28 @@
|
|
|
1
|
+
"""Команда `explain`: подробности об одной проверке."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from py_checks.cli.commands._summary import docstring
|
|
11
|
+
from py_checks.core import get
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def explain( # check-ok: keyword-only-arguments: подпись команды разбирает typer
|
|
15
|
+
code: Annotated[str, typer.Argument(help="код проверки")],
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Показать, что проверка требует и какие у неё настройки."""
|
|
18
|
+
check = get(code=code)
|
|
19
|
+
console = Console()
|
|
20
|
+
console.print(docstring(check=check), markup=False)
|
|
21
|
+
console.print("\nнастройки:", markup=False)
|
|
22
|
+
for name, field in check.Settings.model_fields.items():
|
|
23
|
+
key = field.alias or name
|
|
24
|
+
console.print(f" {key} = {field.get_default(call_default_factory=True)!r}", markup=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(*, app: typer.Typer) -> None:
|
|
28
|
+
app.command("explain")(explain)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Команда `list`: какие проверки есть."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from py_checks.cli.commands._summary import summary
|
|
12
|
+
from py_checks.config import find_root, load
|
|
13
|
+
from py_checks.core import Scope, available
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from py_checks.config import Config
|
|
19
|
+
from py_checks.core import Check
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_checks() -> None:
|
|
23
|
+
"""Показать все проверки: код, состояние и одну строку описания."""
|
|
24
|
+
config = load(root=find_root(start=Path.cwd()))
|
|
25
|
+
table = Table(
|
|
26
|
+
box=None,
|
|
27
|
+
pad_edge=False,
|
|
28
|
+
)
|
|
29
|
+
table.add_column("код")
|
|
30
|
+
table.add_column("состояние")
|
|
31
|
+
table.add_column("что делает")
|
|
32
|
+
for code, check in sorted(available().listed.items()):
|
|
33
|
+
table.add_row(
|
|
34
|
+
code,
|
|
35
|
+
_state(
|
|
36
|
+
check=check,
|
|
37
|
+
config=config,
|
|
38
|
+
),
|
|
39
|
+
summary(check=check),
|
|
40
|
+
)
|
|
41
|
+
Console().print(table)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _state(
|
|
45
|
+
*,
|
|
46
|
+
check: Check,
|
|
47
|
+
config: Config,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Включено, выключено — или включено, но не в обычном прогоне.
|
|
50
|
+
|
|
51
|
+
Правилу, которому нужна живая среда, место в CI, поэтому вместо «вкл» в
|
|
52
|
+
таблице стоит то, чем его зовут.
|
|
53
|
+
"""
|
|
54
|
+
if not config.enabled(code=check.code):
|
|
55
|
+
return "выкл"
|
|
56
|
+
if check.scope is Scope.ENVIRONMENT:
|
|
57
|
+
return "--all"
|
|
58
|
+
return "вкл"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def register(*, app: typer.Typer) -> None:
|
|
62
|
+
app.command("list")(list_checks)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Команда `run`: прогнать проверки."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from py_checks.config import Config, find_root, load
|
|
11
|
+
from py_checks.core import (
|
|
12
|
+
Checks,
|
|
13
|
+
Scope,
|
|
14
|
+
UnknownCheckError,
|
|
15
|
+
Violation,
|
|
16
|
+
available,
|
|
17
|
+
fix,
|
|
18
|
+
get,
|
|
19
|
+
python_files,
|
|
20
|
+
reformat,
|
|
21
|
+
report,
|
|
22
|
+
survey,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
|
|
28
|
+
from py_checks.core import Check
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run( # check-ok: keyword-only-arguments: подпись команды разбирает typer
|
|
32
|
+
paths: Annotated[
|
|
33
|
+
list[Path] | None,
|
|
34
|
+
typer.Argument(help="файлы или папки; без них — весь `src` проекта"),
|
|
35
|
+
] = None,
|
|
36
|
+
select: Annotated[
|
|
37
|
+
list[str] | None,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--select",
|
|
40
|
+
"-s",
|
|
41
|
+
help="коды проверок через запятую; без них — все включённые",
|
|
42
|
+
),
|
|
43
|
+
] = None,
|
|
44
|
+
autofix: Annotated[
|
|
45
|
+
bool,
|
|
46
|
+
typer.Option("--fix", help="исправить то, что правится само"),
|
|
47
|
+
] = False,
|
|
48
|
+
everything: Annotated[
|
|
49
|
+
bool,
|
|
50
|
+
typer.Option("--all", help="вместе с правилами, которым нужна живая среда"),
|
|
51
|
+
] = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Проверить файлы и вернуть код выхода: 0 — чисто, 1 — есть нарушения."""
|
|
54
|
+
root = find_root(start=Path.cwd())
|
|
55
|
+
config = load(root=root)
|
|
56
|
+
chosen = _chosen(
|
|
57
|
+
select=select,
|
|
58
|
+
config=config,
|
|
59
|
+
paths=bool(paths),
|
|
60
|
+
everything=everything,
|
|
61
|
+
)
|
|
62
|
+
# Обход дерева нужен только файловым правилам: прогон одного правила про
|
|
63
|
+
# проект не должен читать список из тысячи файлов, чтобы никому его не дать.
|
|
64
|
+
files = (
|
|
65
|
+
python_files(
|
|
66
|
+
paths=paths or [],
|
|
67
|
+
root=root,
|
|
68
|
+
default=root / config.src,
|
|
69
|
+
exclude=config.excluded,
|
|
70
|
+
)
|
|
71
|
+
if chosen.files
|
|
72
|
+
else []
|
|
73
|
+
)
|
|
74
|
+
violations = survey(
|
|
75
|
+
chosen=chosen,
|
|
76
|
+
files=files,
|
|
77
|
+
config=config,
|
|
78
|
+
root=root,
|
|
79
|
+
)
|
|
80
|
+
if autofix:
|
|
81
|
+
violations = _fixed(violations=violations)
|
|
82
|
+
raise typer.Exit(
|
|
83
|
+
report(
|
|
84
|
+
violations=violations,
|
|
85
|
+
root=root,
|
|
86
|
+
checked=len(files),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _fixed(*, violations: list[Violation]) -> list[Violation]:
|
|
92
|
+
"""Наложить правки и вернуть то, что осталось человеку."""
|
|
93
|
+
changed, left = fix(violations=violations)
|
|
94
|
+
reformat(paths=changed)
|
|
95
|
+
return left
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _chosen(
|
|
99
|
+
*,
|
|
100
|
+
select: list[str] | None,
|
|
101
|
+
config: Config,
|
|
102
|
+
paths: bool,
|
|
103
|
+
everything: bool,
|
|
104
|
+
) -> Checks:
|
|
105
|
+
"""Выбранные проверки, а без выбора — все, кроме отключённых в конфиге.
|
|
106
|
+
|
|
107
|
+
Явный `--select` сильнее всего остального: если проверку позвали по имени,
|
|
108
|
+
значит её хотят запустить именно сейчас — и несмотря на `ignore`, и
|
|
109
|
+
несмотря на то, что ей нужна база.
|
|
110
|
+
"""
|
|
111
|
+
listed = available()
|
|
112
|
+
if select:
|
|
113
|
+
return listed.only(codes=_codes(select=select))
|
|
114
|
+
return listed.only(
|
|
115
|
+
codes={
|
|
116
|
+
code
|
|
117
|
+
for code, check in listed.listed.items()
|
|
118
|
+
if config.enabled(code=code)
|
|
119
|
+
and _wanted(
|
|
120
|
+
check=check,
|
|
121
|
+
paths=paths,
|
|
122
|
+
everything=everything,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _codes(*, select: Sequence[str]) -> set[str]:
|
|
129
|
+
"""Коды из `--select`: и повторённый флаг, и список через запятую.
|
|
130
|
+
|
|
131
|
+
Набор правил пишут в одну строку — `-s raw-sql,statement-keys`, — потому
|
|
132
|
+
что так его и держат в голове: не по одному флагу на правило, а списком.
|
|
133
|
+
Повторённый флаг остаётся рабочим, оба способа дают одно и то же.
|
|
134
|
+
|
|
135
|
+
Опечатка в коде — ошибка разбора аргумента, а не падение: имя проверки
|
|
136
|
+
приходит из командной строки, и отвечать на него следом трассировкой
|
|
137
|
+
значит показывать внутренности там, где ошибся человек.
|
|
138
|
+
"""
|
|
139
|
+
named = (code.strip() for value in select for code in value.split(","))
|
|
140
|
+
try:
|
|
141
|
+
return {get(code=code).code for code in named if code}
|
|
142
|
+
except UnknownCheckError as error:
|
|
143
|
+
raise typer.BadParameter(str(error), param_hint="--select") from error
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _wanted(
|
|
147
|
+
*,
|
|
148
|
+
check: Check,
|
|
149
|
+
paths: bool,
|
|
150
|
+
everything: bool,
|
|
151
|
+
) -> bool:
|
|
152
|
+
"""Входит ли правило в прогон, которому не назвали имён.
|
|
153
|
+
|
|
154
|
+
Названные пути правил про проект не касаются: прогон по одному файлу
|
|
155
|
+
проверяет этот файл, а не проект вокруг него. Правилу, которому нужна
|
|
156
|
+
живая среда, место в CI, а не в хуке на коммит, — его зовут `--all` или по
|
|
157
|
+
имени.
|
|
158
|
+
"""
|
|
159
|
+
if paths and check.scope is not Scope.FILE:
|
|
160
|
+
return False
|
|
161
|
+
if check.scope is Scope.ENVIRONMENT:
|
|
162
|
+
return everything
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def register(*, app: typer.Typer) -> None:
|
|
167
|
+
app.command("run")(run)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Описание проверки, взятое из её докстринга."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect as introspect
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from py_checks.core import Check
|
|
10
|
+
|
|
11
|
+
NO_DOC: Final = "нет описания"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def docstring(*, check: Check) -> str:
|
|
15
|
+
return introspect.getdoc(type(check)) or NO_DOC
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def summary(*, check: Check) -> str:
|
|
19
|
+
"""Первая строка докстринга: правило одной фразой."""
|
|
20
|
+
return docstring(check=check).splitlines()[0]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Команда `sync`: собрать конфиги, которые зависят от раскладки проекта."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from py_checks.config import find_root
|
|
12
|
+
from py_checks.core import EXIT_OK, EXIT_VIOLATION
|
|
13
|
+
from py_checks.sync import stale, write
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sync( # check-ok: keyword-only-arguments: подпись команды разбирает typer
|
|
20
|
+
check: Annotated[
|
|
21
|
+
bool,
|
|
22
|
+
typer.Option("--check", help="ничего не писать, только сказать, что устарело"),
|
|
23
|
+
] = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Собрать файлы, которые выводятся из кода: контракты импортов и `.env.example`."""
|
|
26
|
+
root = find_root(start=Path.cwd())
|
|
27
|
+
console = Console(
|
|
28
|
+
stderr=True,
|
|
29
|
+
soft_wrap=True,
|
|
30
|
+
)
|
|
31
|
+
if check:
|
|
32
|
+
raise typer.Exit(
|
|
33
|
+
_report(
|
|
34
|
+
stale=stale(root=root),
|
|
35
|
+
console=console,
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
for path in write(root=root):
|
|
39
|
+
_say(
|
|
40
|
+
text=f"собран {path.relative_to(root)}",
|
|
41
|
+
console=console,
|
|
42
|
+
)
|
|
43
|
+
raise typer.Exit(EXIT_OK)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _report(
|
|
47
|
+
*,
|
|
48
|
+
stale: Sequence[Path],
|
|
49
|
+
console: Console,
|
|
50
|
+
) -> int:
|
|
51
|
+
"""Собранный файл отстал от того, что объявлено в настройках или лежит на диске."""
|
|
52
|
+
for path in stale:
|
|
53
|
+
_say(
|
|
54
|
+
text=f"{path}: устарел, запусти `py-checks sync`",
|
|
55
|
+
console=console,
|
|
56
|
+
)
|
|
57
|
+
if stale:
|
|
58
|
+
return EXIT_VIOLATION
|
|
59
|
+
_say(
|
|
60
|
+
text="ok: собранные файлы совпадают с кодом и настройками",
|
|
61
|
+
console=console,
|
|
62
|
+
)
|
|
63
|
+
return EXIT_OK
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _say(
|
|
67
|
+
*,
|
|
68
|
+
text: str,
|
|
69
|
+
console: Console,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Печатать как есть: в строке бывают пути и секции, разметка тут лишняя."""
|
|
72
|
+
console.print(
|
|
73
|
+
text,
|
|
74
|
+
markup=False,
|
|
75
|
+
highlight=False,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register(*, app: typer.Typer) -> None:
|
|
80
|
+
app.command("sync")(sync)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Настройки из `pyproject.toml` проекта.
|
|
2
|
+
|
|
3
|
+
Настройки живут либо в своём файле — `py-checks.toml` или `pychecks.toml`,
|
|
4
|
+
с точкой в начале или без, — либо секцией `[tool.py-checks]` в
|
|
5
|
+
`pyproject.toml`. В своём
|
|
6
|
+
файле приставки нет: весь файл и есть эта секция. Внутри — по секции на
|
|
7
|
+
проверку, и каждая описана моделью pydantic, поэтому опечатка в названии
|
|
8
|
+
настройки падает сразу и с понятным текстом.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from py_checks.config._base import CheckSettings
|
|
12
|
+
from py_checks.config._config import Config, prefix
|
|
13
|
+
from py_checks.config._constants import DEFAULT_EXCLUDE, PYPROJECT, SECTION, STANDALONE
|
|
14
|
+
from py_checks.config._errors import ConfigError
|
|
15
|
+
from py_checks.config._loader import find_root, load
|
|
16
|
+
from py_checks.config._toml import TomlTable, TomlValue
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_EXCLUDE",
|
|
20
|
+
"PYPROJECT",
|
|
21
|
+
"SECTION",
|
|
22
|
+
"STANDALONE",
|
|
23
|
+
"CheckSettings",
|
|
24
|
+
"Config",
|
|
25
|
+
"ConfigError",
|
|
26
|
+
"TomlTable",
|
|
27
|
+
"TomlValue",
|
|
28
|
+
"find_root",
|
|
29
|
+
"load",
|
|
30
|
+
"prefix",
|
|
31
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Базовая модель настроек проверки."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _to_kebab(name: str) -> str: # check-ok: keyword-only-arguments: pydantic зовёт по позиции
|
|
9
|
+
return name.replace("_", "-")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CheckSettings(BaseModel):
|
|
13
|
+
"""Настройки одной проверки из её секции в `pyproject.toml`.
|
|
14
|
+
|
|
15
|
+
В файле ключи пишутся через дефис (`max-lines`), в коде — через
|
|
16
|
+
подчёркивание. `extra="forbid"` нужен, чтобы опечатка падала сразу: молча
|
|
17
|
+
проигнорированная настройка — это проверка, которая работает не так, как
|
|
18
|
+
написано в конфиге, и никто об этом не знает.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(
|
|
22
|
+
alias_generator=_to_kebab,
|
|
23
|
+
populate_by_name=True,
|
|
24
|
+
extra="forbid",
|
|
25
|
+
frozen=True,
|
|
26
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Общие настройки проекта."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, ValidationError
|
|
8
|
+
|
|
9
|
+
from py_checks.config._base import CheckSettings
|
|
10
|
+
from py_checks.config._constants import DEFAULT_EXCLUDE, PYPROJECT, SECTION
|
|
11
|
+
from py_checks.config._errors import ConfigError
|
|
12
|
+
from py_checks.config._toml import TomlTable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def prefix(*, source: Path | None) -> str:
|
|
16
|
+
"""Как называется секция проверки в том файле, откуда пришли настройки.
|
|
17
|
+
|
|
18
|
+
В `pyproject.toml` инструменты живут под своей приставкой, потому что файл
|
|
19
|
+
общий; в своём файле приставки нет — весь файл принадлежит одному
|
|
20
|
+
инструменту. Сообщение об ошибке обязано звать секцию так, как её и правда
|
|
21
|
+
зовут в этом файле: иначе оно посылает читателя не туда.
|
|
22
|
+
"""
|
|
23
|
+
if source is None or source.name == PYPROJECT:
|
|
24
|
+
return f"tool.{SECTION}."
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Config(CheckSettings):
|
|
29
|
+
"""Где искать код и что не проверять.
|
|
30
|
+
|
|
31
|
+
Настройки самих проверок сюда не попадают: они лежат в своих секциях и
|
|
32
|
+
разбираются моделью той проверки, которой принадлежат. Ядро держит их
|
|
33
|
+
нетронутыми в `checks` и отдаёт владельцу через `settings_for`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
src: Path = Path("src")
|
|
37
|
+
exclude: tuple[str, ...] = DEFAULT_EXCLUDE
|
|
38
|
+
extend_exclude: tuple[str, ...] = ()
|
|
39
|
+
ignore: tuple[str, ...] = ()
|
|
40
|
+
checks: dict[str, TomlTable] = Field(
|
|
41
|
+
default_factory=dict,
|
|
42
|
+
exclude=True,
|
|
43
|
+
)
|
|
44
|
+
# Файл, из которого настройки прочитаны: он же и место, куда сообщение об
|
|
45
|
+
# ошибке отправляет читателя.
|
|
46
|
+
origin: Path | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
exclude=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def excluded(self) -> tuple[str, ...]:
|
|
53
|
+
"""Что не проверяем: список по умолчанию плюс добавленный проектом.
|
|
54
|
+
|
|
55
|
+
`exclude` задаёт весь список целиком, `extend-exclude` добавляет к нему:
|
|
56
|
+
так проект добавляет свою папку, не переписывая `.venv` и остальное.
|
|
57
|
+
"""
|
|
58
|
+
return self.exclude + self.extend_exclude
|
|
59
|
+
|
|
60
|
+
def section(self, *, code: str) -> TomlTable:
|
|
61
|
+
return self.checks.get(code, {})
|
|
62
|
+
|
|
63
|
+
def settings_for(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
code: str,
|
|
67
|
+
model: type[CheckSettings],
|
|
68
|
+
) -> CheckSettings:
|
|
69
|
+
"""Настройки проверки: её секция, проверенная её же моделью."""
|
|
70
|
+
try:
|
|
71
|
+
return model.model_validate(self.section(code=code))
|
|
72
|
+
except ValidationError as error:
|
|
73
|
+
raise ConfigError(f"[{prefix(source=self.origin)}{code}]: {error}") from error
|
|
74
|
+
|
|
75
|
+
def enabled(self, *, code: str) -> bool:
|
|
76
|
+
return code not in self.ignore
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Имена и значения по умолчанию, общие для всего чтения настроек."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
PYPROJECT: Final = "pyproject.toml"
|
|
8
|
+
|
|
9
|
+
SECTION: Final = "py-checks"
|
|
10
|
+
|
|
11
|
+
# Свой файл настроек — как у ruff и mypy: назван именем инструмента, с точкой
|
|
12
|
+
# в начале и без. В нём приставки `[tool.py-checks]` нет: весь файл и есть
|
|
13
|
+
# эта секция, а `[<код>]` в нём — секция проверки.
|
|
14
|
+
#
|
|
15
|
+
# Имён четыре: полное и короткое, каждое с точкой и без. Угадывать, как проект
|
|
16
|
+
# назовёт свой файл, дешевле, чем отказывать ему за не ту букву, — а лежать
|
|
17
|
+
# сразу двум файлам всё равно запрещено.
|
|
18
|
+
STANDALONE: Final[tuple[str, ...]] = (
|
|
19
|
+
".py-checks.toml",
|
|
20
|
+
"py-checks.toml",
|
|
21
|
+
".pychecks.toml",
|
|
22
|
+
"pychecks.toml",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_EXCLUDE: Final[tuple[str, ...]] = (
|
|
26
|
+
".venv/*",
|
|
27
|
+
"build/*",
|
|
28
|
+
"dist/*",
|
|
29
|
+
"**/__pycache__/*",
|
|
30
|
+
"**/migrations/versions/*",
|
|
31
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Ошибки чтения настроек."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigError(Exception):
|
|
7
|
+
"""Конфиг есть, но прочитать его нельзя.
|
|
8
|
+
|
|
9
|
+
Одна ошибка на все случаи: битый TOML, неизвестный ключ, неверное значение.
|
|
10
|
+
Для того, кто запустил проверку, это одно и то же событие — конфиг надо
|
|
11
|
+
починить, — а подробности несёт текст.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Чтение настроек: из своего файла проекта или из `pyproject.toml`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from py_checks.config._config import Config, prefix
|
|
11
|
+
from py_checks.config._constants import PYPROJECT, SECTION, STANDALONE
|
|
12
|
+
from py_checks.config._errors import ConfigError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from py_checks.config._toml import TomlTable, TomlValue
|
|
18
|
+
|
|
19
|
+
# Все файлы, по которым узнаётся корень проекта. `pyproject.toml` последний:
|
|
20
|
+
# он у проекта есть почти всегда, а свой файл настроек лежит рядом с ним.
|
|
21
|
+
ANCHORS: Final[tuple[str, ...]] = (*STANDALONE, PYPROJECT)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_root(*, start: Path) -> Path:
|
|
25
|
+
"""Ближайшая папка вверх по дереву, где лежит манифест или свой файл настроек.
|
|
26
|
+
|
|
27
|
+
Именно она считается корнем проекта: пути в конфиге и в выводе даются
|
|
28
|
+
относительно неё, чтобы строка нарушения не зависела от того, откуда
|
|
29
|
+
запустили проверку.
|
|
30
|
+
"""
|
|
31
|
+
for directory in (start, *start.parents):
|
|
32
|
+
if any((directory / name).is_file() for name in ANCHORS):
|
|
33
|
+
return directory
|
|
34
|
+
return start
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(*, root: Path) -> Config:
|
|
38
|
+
"""Настройки проекта; если их нигде нет — значения по умолчанию.
|
|
39
|
+
|
|
40
|
+
Настройки живут либо в своём файле — `py-checks.toml` или
|
|
41
|
+
`pychecks.toml`, с точкой в начале или без, — либо секцией
|
|
42
|
+
`[tool.py-checks]` в `pyproject.toml`. В своём файле приставки нет:
|
|
43
|
+
весь файл и есть эта секция.
|
|
44
|
+
|
|
45
|
+
Двух мест разом не бывает: это не слияние, а вопрос без ответа, и лучше
|
|
46
|
+
спросить его вслух, чем молча прочитать одно и забыть про другое.
|
|
47
|
+
"""
|
|
48
|
+
found = _found(root=root)
|
|
49
|
+
if not found:
|
|
50
|
+
return Config()
|
|
51
|
+
if len(found) > 1:
|
|
52
|
+
raise ConfigError(
|
|
53
|
+
"настройки лежат в нескольких местах: "
|
|
54
|
+
+ ", ".join(source.name for source, _ in found)
|
|
55
|
+
+ "; оставь одно, иначе неизвестно, какое из них читают"
|
|
56
|
+
)
|
|
57
|
+
source, section = found[0]
|
|
58
|
+
return _build(
|
|
59
|
+
section=section,
|
|
60
|
+
source=source,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _found(*, root: Path) -> list[tuple[Path, TomlTable]]:
|
|
65
|
+
"""Файлы, в которых настройки этого инструмента действительно есть.
|
|
66
|
+
|
|
67
|
+
`pyproject.toml` без секции файлом настроек не считается: он лежит у
|
|
68
|
+
каждого проекта, и молчаливое присутствие — не выбор автора.
|
|
69
|
+
"""
|
|
70
|
+
found: list[tuple[Path, TomlTable]] = []
|
|
71
|
+
for name in STANDALONE:
|
|
72
|
+
path = root / name
|
|
73
|
+
if path.is_file():
|
|
74
|
+
found.append((path, _document(path=path)))
|
|
75
|
+
pyproject = root / PYPROJECT
|
|
76
|
+
if pyproject.is_file() and (section := _section(document=_document(path=pyproject))):
|
|
77
|
+
found.append((pyproject, section))
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _document(*, path: Path) -> TomlTable:
|
|
82
|
+
try:
|
|
83
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
84
|
+
except tomllib.TOMLDecodeError as error:
|
|
85
|
+
raise ConfigError(f"{path}: {error}") from error
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _section(*, document: TomlTable) -> TomlTable:
|
|
89
|
+
tool = document.get("tool")
|
|
90
|
+
section = tool.get(SECTION) if isinstance(tool, dict) else None
|
|
91
|
+
return section if isinstance(section, dict) else {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build(
|
|
95
|
+
*,
|
|
96
|
+
section: TomlTable,
|
|
97
|
+
source: Path,
|
|
98
|
+
) -> Config:
|
|
99
|
+
own, checks = _split(section=section)
|
|
100
|
+
named = prefix(source=source)
|
|
101
|
+
# В своём файле секции нет — называть в сообщении нечего, кроме файла.
|
|
102
|
+
where = f" [{named.rstrip('.')}]" if named else ""
|
|
103
|
+
try:
|
|
104
|
+
return Config.model_validate({**own, "checks": checks, "origin": source})
|
|
105
|
+
except ValidationError as error:
|
|
106
|
+
raise ConfigError(f"{source}{where}: {error}") from error
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _split(*, section: TomlTable) -> tuple[TomlTable, dict[str, TomlTable]]:
|
|
110
|
+
"""Свои ключи отдельно, вложенные таблицы проверок отдельно."""
|
|
111
|
+
own: TomlTable = {}
|
|
112
|
+
checks: dict[str, TomlTable] = {}
|
|
113
|
+
for key, value in section.items():
|
|
114
|
+
_place(
|
|
115
|
+
key=key,
|
|
116
|
+
value=value,
|
|
117
|
+
own=own,
|
|
118
|
+
checks=checks,
|
|
119
|
+
)
|
|
120
|
+
return own, checks
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _place(
|
|
124
|
+
*,
|
|
125
|
+
key: str,
|
|
126
|
+
value: TomlValue,
|
|
127
|
+
own: TomlTable,
|
|
128
|
+
checks: dict[str, TomlTable],
|
|
129
|
+
) -> None:
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
checks[key] = value
|
|
132
|
+
else:
|
|
133
|
+
own[key] = value
|