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,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