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,44 @@
|
|
|
1
|
+
"""Сужение настроек до модели конкретной проверки."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from py_checks.config import CheckSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SettingsMismatchError(Exception):
|
|
12
|
+
"""Проверке отдали не её настройки."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
code: str,
|
|
18
|
+
expected: type[object],
|
|
19
|
+
got: type[object],
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(f"{code}: ожидались {expected.__name__}, пришли {got.__name__}")
|
|
22
|
+
self.code = code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def settings_as[S: CheckSettings](
|
|
26
|
+
*,
|
|
27
|
+
settings: CheckSettings,
|
|
28
|
+
model: type[S],
|
|
29
|
+
code: str,
|
|
30
|
+
) -> S:
|
|
31
|
+
"""Те же настройки, но уже своего типа.
|
|
32
|
+
|
|
33
|
+
Ядро отдаёт проверке общий `CheckSettings`: иначе протокол пришлось бы
|
|
34
|
+
параметризовать типом настроек, и реестр перестал бы складываться в один
|
|
35
|
+
словарь. Сужение здесь — одна строка в начале правила, зато дальше поля
|
|
36
|
+
видит и редактор, и pyright.
|
|
37
|
+
"""
|
|
38
|
+
if not isinstance(settings, model):
|
|
39
|
+
raise SettingsMismatchError(
|
|
40
|
+
code=code,
|
|
41
|
+
expected=model,
|
|
42
|
+
got=type(settings),
|
|
43
|
+
)
|
|
44
|
+
return settings
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Разобранный файл, который получают проверки."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import libcst
|
|
9
|
+
|
|
10
|
+
from py_checks.core._errors import ParseError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ParsedFile:
|
|
17
|
+
"""Файл, прочитанный один раз и разобранный не больше одного раза.
|
|
18
|
+
|
|
19
|
+
Дерево строится лениво и запоминается: проверок на файл много, а разбор
|
|
20
|
+
один. Файлам, которым дерево не нужно (длина модуля, например), платить за
|
|
21
|
+
него не приходится.
|
|
22
|
+
|
|
23
|
+
Деревьев два, и оба ленивые. `tree` — обычный `ast`: быстрый, его хватает,
|
|
24
|
+
когда правило только смотрит. `module` — дерево `libcst`, в котором есть
|
|
25
|
+
комментарии, кавычки и пробелы: такое дерево можно переписать и отдать
|
|
26
|
+
обратно текстом, ничего чужого не потеряв. Правило берёт то, что ему нужно,
|
|
27
|
+
и за второй разбор платит только оно.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_lines", "_module", "_text", "_tree", "path", "source")
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
path: Path,
|
|
36
|
+
text: str,
|
|
37
|
+
source: Path | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.path = path
|
|
40
|
+
# Корень исходников проекта: по нему правила, которые говорят о месте
|
|
41
|
+
# («ORM живёт в `infra/database`»), считают адрес файла. Угадывать его
|
|
42
|
+
# по `__init__.py` нельзя — папка без него встречается и внутри пакета.
|
|
43
|
+
self.source = source
|
|
44
|
+
self._text = text
|
|
45
|
+
self._tree: ast.Module | None = None
|
|
46
|
+
self._module: libcst.Module | None = None
|
|
47
|
+
self._lines: tuple[str, ...] | None = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_path(
|
|
51
|
+
cls,
|
|
52
|
+
*,
|
|
53
|
+
path: Path,
|
|
54
|
+
source: Path | None = None,
|
|
55
|
+
) -> ParsedFile:
|
|
56
|
+
return cls(
|
|
57
|
+
path=path,
|
|
58
|
+
text=path.read_text(encoding="utf-8"),
|
|
59
|
+
source=source,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def text(self) -> str:
|
|
64
|
+
return self._text
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def lines(self) -> tuple[str, ...]:
|
|
68
|
+
if self._lines is None:
|
|
69
|
+
self._lines = tuple(self._text.splitlines())
|
|
70
|
+
return self._lines
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def tree(self) -> ast.Module:
|
|
74
|
+
if self._tree is None:
|
|
75
|
+
try:
|
|
76
|
+
self._tree = ast.parse(self._text, filename=str(self.path))
|
|
77
|
+
except SyntaxError as error:
|
|
78
|
+
raise ParseError(
|
|
79
|
+
path=self.path,
|
|
80
|
+
error=error,
|
|
81
|
+
) from error
|
|
82
|
+
return self._tree
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def module(self) -> libcst.Module:
|
|
86
|
+
if self._module is None:
|
|
87
|
+
try:
|
|
88
|
+
self._module = libcst.parse_module(self._text)
|
|
89
|
+
except libcst.ParserSyntaxError as error:
|
|
90
|
+
raise ParseError(
|
|
91
|
+
path=self.path,
|
|
92
|
+
error=SyntaxError(error.message),
|
|
93
|
+
) from error
|
|
94
|
+
return self._module
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Нарушение и то, как оно выглядит в выводе."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import ast
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from py_checks.core._edit import Edit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Violation:
|
|
18
|
+
"""Одно нарушение: где, чем и почему.
|
|
19
|
+
|
|
20
|
+
Строка и колонка нумеруются с единицы, как их показывает редактор. У `ast`
|
|
21
|
+
колонка начинается с нуля, поэтому узлы дерева превращаются в нарушение
|
|
22
|
+
через `from_node`, а не вручную.
|
|
23
|
+
|
|
24
|
+
`end_line` нужен только тем нарушениям, которые занимают несколько строк:
|
|
25
|
+
по нему ядро ищет маркер во всей подписи, а не в одной её первой строке.
|
|
26
|
+
|
|
27
|
+
`edit` есть у нарушения, которое правило умеет исправить. Правку несёт само
|
|
28
|
+
нарушение, а не отдельный проход: тот, кто нашёл место, знает о нём больше
|
|
29
|
+
всех, и второй раз разбирать файл ради починки незачем.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
line: int
|
|
34
|
+
column: int
|
|
35
|
+
code: str
|
|
36
|
+
message: str
|
|
37
|
+
end_line: int | None = None
|
|
38
|
+
edit: Edit | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_node(
|
|
42
|
+
cls,
|
|
43
|
+
*,
|
|
44
|
+
node: ast.AST,
|
|
45
|
+
path: Path,
|
|
46
|
+
code: str,
|
|
47
|
+
message: str,
|
|
48
|
+
end_line: int | None = None,
|
|
49
|
+
edit: Edit | None = None,
|
|
50
|
+
) -> Violation:
|
|
51
|
+
line = getattr(node, "lineno", 1)
|
|
52
|
+
column = getattr(node, "col_offset", 0)
|
|
53
|
+
return cls(
|
|
54
|
+
path=path,
|
|
55
|
+
line=line,
|
|
56
|
+
column=column + 1,
|
|
57
|
+
code=code,
|
|
58
|
+
message=message,
|
|
59
|
+
end_line=end_line,
|
|
60
|
+
edit=edit,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def render(self, *, root: Path | None = None) -> str:
|
|
64
|
+
"""`путь:строка:колонка: код: сообщение`.
|
|
65
|
+
|
|
66
|
+
Формат выбран не ради красоты: по нему строку понимают редактор, `grep`
|
|
67
|
+
и CI, и по ней можно перейти к месту одним щелчком.
|
|
68
|
+
"""
|
|
69
|
+
path = self.path
|
|
70
|
+
if root is not None:
|
|
71
|
+
with suppress(ValueError):
|
|
72
|
+
path = path.relative_to(root)
|
|
73
|
+
return f"{path}:{self.line}:{self.column}: {self.code}: {self.message}"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Пример файла окружения, собранный из классов настроек проекта.
|
|
2
|
+
|
|
3
|
+
Имя переменной знает поле — оно объявляет его `validation_alias`, и правило
|
|
4
|
+
`config-fields` за этим следит. Значит, список переменных выводится из тех же
|
|
5
|
+
классов, что их читают, и файл, который его перечисляет, нет смысла вести
|
|
6
|
+
рукой: он расходится молча, а замечают это, когда переменной не оказалось на
|
|
7
|
+
проде.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from py_checks.environment._constants import FILE, SECTION
|
|
11
|
+
from py_checks.environment._render import render
|
|
12
|
+
from py_checks.environment._settings import Example, example
|
|
13
|
+
|
|
14
|
+
__all__ = ["FILE", "SECTION", "Example", "example", "render"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Сборка `.env.example` из классов настроек проекта."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from textwrap import wrap
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, SecretStr
|
|
14
|
+
from pydantic_core import PydanticUndefined
|
|
15
|
+
|
|
16
|
+
from py_checks.config import ConfigError, prefix
|
|
17
|
+
from py_checks.environment._constants import SECTION
|
|
18
|
+
from py_checks.environment._settings import example
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pydantic.fields import FieldInfo
|
|
22
|
+
|
|
23
|
+
from py_checks.config import Config
|
|
24
|
+
|
|
25
|
+
HEADER: Final = """\
|
|
26
|
+
# Переменные окружения, которые читает сервис. Файл собирает `py-checks sync`
|
|
27
|
+
# из классов настроек, перечисленных в [{section}] — править его нечего,
|
|
28
|
+
# следующий sync перезапишет. Значение по умолчанию здесь для того, чтобы его
|
|
29
|
+
# было видно, а не потому, что переменную обязательно задавать.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
SEPARATOR: Final = ":"
|
|
33
|
+
|
|
34
|
+
# Ширина комментария: та же, по которой переносят текст в самих докстрингах.
|
|
35
|
+
WIDTH: Final = 77
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render(
|
|
39
|
+
*,
|
|
40
|
+
root: Path,
|
|
41
|
+
config: Config,
|
|
42
|
+
) -> tuple[Path, str] | None:
|
|
43
|
+
"""Путь и текст файла; `None`, если проект не объявил ни одного класса."""
|
|
44
|
+
declared = example(config=config)
|
|
45
|
+
if not declared.settings:
|
|
46
|
+
return None
|
|
47
|
+
blocks = [
|
|
48
|
+
block
|
|
49
|
+
for name in declared.settings
|
|
50
|
+
for block in _blocks(
|
|
51
|
+
model=_imported(
|
|
52
|
+
path=name,
|
|
53
|
+
root=root,
|
|
54
|
+
src=config.src,
|
|
55
|
+
),
|
|
56
|
+
seen=set(),
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
return root / declared.path, "\n".join(
|
|
60
|
+
[
|
|
61
|
+
HEADER.format(section=f"{prefix(source=config.origin)}{SECTION}"),
|
|
62
|
+
*blocks,
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _imported(
|
|
68
|
+
*,
|
|
69
|
+
path: str,
|
|
70
|
+
root: Path,
|
|
71
|
+
src: Path,
|
|
72
|
+
) -> type[BaseModel]:
|
|
73
|
+
"""Класс настроек по записи `модуль:Класс`.
|
|
74
|
+
|
|
75
|
+
Импорт, а не чтение исходника: имя переменной — значение атрибута поля, и
|
|
76
|
+
собрано оно вызовом (`AliasChoices(...)`, имя, посчитанное при создании
|
|
77
|
+
класса). Прочитать его текстом значит выполнить этот вызов самому.
|
|
78
|
+
"""
|
|
79
|
+
module, _, attribute = path.partition(SEPARATOR)
|
|
80
|
+
if not attribute:
|
|
81
|
+
message = f"[{SECTION}]: {path!r} — нужна запись вида `модуль:Класс`"
|
|
82
|
+
raise ConfigError(message)
|
|
83
|
+
_reachable(
|
|
84
|
+
root=root,
|
|
85
|
+
src=src,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
found = getattr(importlib.import_module(module), attribute)
|
|
89
|
+
except (ImportError, AttributeError) as error:
|
|
90
|
+
raise ConfigError(f"[{SECTION}]: {path!r} не импортируется: {error}") from error
|
|
91
|
+
if not (isinstance(found, type) and issubclass(found, BaseModel)):
|
|
92
|
+
message = f"[{SECTION}]: {path!r} — не модель pydantic"
|
|
93
|
+
raise ConfigError(message)
|
|
94
|
+
return found
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _reachable(
|
|
98
|
+
*,
|
|
99
|
+
root: Path,
|
|
100
|
+
src: Path,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Дать импорту найти пакет проекта, даже если проект не установлен."""
|
|
103
|
+
for directory in (root / src, root):
|
|
104
|
+
name = str(directory)
|
|
105
|
+
if directory.is_dir() and name not in sys.path:
|
|
106
|
+
sys.path.insert(0, name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _blocks(
|
|
110
|
+
*,
|
|
111
|
+
model: type[BaseModel],
|
|
112
|
+
seen: set[type[BaseModel]],
|
|
113
|
+
) -> list[str]:
|
|
114
|
+
"""Класс и вложенные в него секции, каждая своим куском.
|
|
115
|
+
|
|
116
|
+
Проекту хватает назвать корневой класс: секции он и так перечислил — в
|
|
117
|
+
собственных полях, — и повторять их список в настройках значит завести
|
|
118
|
+
второй, который разойдётся с первым.
|
|
119
|
+
"""
|
|
120
|
+
if model in seen:
|
|
121
|
+
return []
|
|
122
|
+
seen.add(model)
|
|
123
|
+
lines = [f"# --- {_origin(model=model)} ---", *_commented(text=_said(model=model))]
|
|
124
|
+
nested: list[str] = []
|
|
125
|
+
for field in model.model_fields.values():
|
|
126
|
+
section = _section(field=field)
|
|
127
|
+
if section is not None:
|
|
128
|
+
nested.extend(
|
|
129
|
+
_blocks(
|
|
130
|
+
model=section,
|
|
131
|
+
seen=seen,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
135
|
+
name = _variable(field=field)
|
|
136
|
+
if name is None:
|
|
137
|
+
continue
|
|
138
|
+
lines.extend(_commented(text=field.description))
|
|
139
|
+
lines.append(f"{name}={_value(field=field)}")
|
|
140
|
+
# Корень, у которого своих переменных нет, в файл не едет: заголовок с
|
|
141
|
+
# докстрингом и пустотой под ним ничего не сообщает.
|
|
142
|
+
own = [] if len(lines) == 1 or not _variables(lines=lines) else ["\n".join(lines) + "\n"]
|
|
143
|
+
return own + nested
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _variables(*, lines: list[str]) -> bool:
|
|
147
|
+
"""Есть ли в куске хоть одна переменная, а не одни комментарии."""
|
|
148
|
+
return any(not line.startswith("#") for line in lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _origin(*, model: type[BaseModel]) -> str:
|
|
152
|
+
"""Как класс записывают в настройках: `модуль:Класс`."""
|
|
153
|
+
return f"{model.__module__}{SEPARATOR}{model.__qualname__}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _section(*, field: FieldInfo) -> type[BaseModel] | None:
|
|
157
|
+
"""Вложенная секция настроек, если поле — она.
|
|
158
|
+
|
|
159
|
+
Секция узнаётся по типу поля: `default_factory` бывает и у обычного
|
|
160
|
+
значения, а модель в аннотации — это ровно «здесь начинается ещё одна
|
|
161
|
+
группа переменных».
|
|
162
|
+
"""
|
|
163
|
+
annotation = field.annotation
|
|
164
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
165
|
+
return annotation
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _said(*, model: type[BaseModel]) -> str | None:
|
|
170
|
+
"""Первый абзац докстринга класса: чем эта секция занимается.
|
|
171
|
+
|
|
172
|
+
Абзац, а не строка: докстринг переносят по ширине файла, и первая строка
|
|
173
|
+
обрывается на середине фразы.
|
|
174
|
+
"""
|
|
175
|
+
if model.__doc__ is None:
|
|
176
|
+
return None
|
|
177
|
+
paragraph = model.__doc__.strip().split("\n\n", maxsplit=1)[0]
|
|
178
|
+
return " ".join(paragraph.split()) or None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _commented(*, text: str | None) -> list[str]:
|
|
182
|
+
"""Текст как комментарий, разложенный по ширине строки."""
|
|
183
|
+
if not text:
|
|
184
|
+
return []
|
|
185
|
+
return [f"# {line}" for line in wrap(text, width=WIDTH)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _variable(*, field: FieldInfo) -> str | None:
|
|
189
|
+
"""Имя переменной, которую читает поле; `None` — если поле не переменная.
|
|
190
|
+
|
|
191
|
+
Поле, собранное фабрикой, — вложенная секция: переменные читают её
|
|
192
|
+
собственные поля, а у неё самой их нет.
|
|
193
|
+
"""
|
|
194
|
+
if field.default_factory is not None:
|
|
195
|
+
return None
|
|
196
|
+
alias = field.validation_alias
|
|
197
|
+
if isinstance(alias, str):
|
|
198
|
+
return alias
|
|
199
|
+
choices = getattr(alias, "choices", ())
|
|
200
|
+
named = [choice for choice in choices if isinstance(choice, str)]
|
|
201
|
+
return named[0] if named else None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _value(*, field: FieldInfo) -> str:
|
|
205
|
+
"""Значение по умолчанию так, как его пишут в файле окружения."""
|
|
206
|
+
default = field.default
|
|
207
|
+
if default is PydanticUndefined or default is None:
|
|
208
|
+
return ""
|
|
209
|
+
return _written(value=default)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _written(*, value: Any) -> str:
|
|
213
|
+
if isinstance(value, SecretStr):
|
|
214
|
+
return value.get_secret_value()
|
|
215
|
+
if isinstance(value, bool):
|
|
216
|
+
return "true" if value else "false"
|
|
217
|
+
if isinstance(value, enum.Enum):
|
|
218
|
+
return _written(value=value.value)
|
|
219
|
+
if isinstance(value, (str, int, float, Path)):
|
|
220
|
+
return str(value)
|
|
221
|
+
# Составное значение pydantic-settings читает как JSON, а не как строку:
|
|
222
|
+
# список, записанный через запятую, он в поле не превратит.
|
|
223
|
+
return json.dumps(
|
|
224
|
+
value,
|
|
225
|
+
default=str,
|
|
226
|
+
ensure_ascii=False,
|
|
227
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
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, prefix
|
|
10
|
+
from py_checks.environment._constants import FILE, SECTION
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from py_checks.config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Example(CheckSettings):
|
|
17
|
+
"""Секция `[tool.py-checks.env-example]`.
|
|
18
|
+
|
|
19
|
+
`settings` — классы настроек, каждый как `модуль:Класс`. Перечисляются
|
|
20
|
+
именно секции, а не один корневой класс: корень собирает их фабриками, и
|
|
21
|
+
его собственные поля — это секции, а не переменные. Пустой список значит
|
|
22
|
+
«ничего не собирать»: проект без настроек из окружения — обычное дело.
|
|
23
|
+
|
|
24
|
+
`path` — куда писать; по умолчанию `.env.example` в корне.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
settings: tuple[str, ...] = ()
|
|
28
|
+
path: str = FILE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def example(*, config: Config) -> Example:
|
|
32
|
+
try:
|
|
33
|
+
return Example.model_validate(config.section(code=SECTION))
|
|
34
|
+
except ValidationError as error:
|
|
35
|
+
raise ConfigError(f"[{prefix(source=config.origin)}{SECTION}]: {error}") from error
|
py_checks/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Файлы, которые библиотека собирает для проекта.
|
|
2
|
+
|
|
3
|
+
Сегодня их два: контракты импортов для import-linter и `.env.example`.
|
|
4
|
+
Настройки ruff, pyright и остальных инструментов библиотека не трогает: их
|
|
5
|
+
приносит шаблон, и дальше они принадлежат проекту, который правит их как
|
|
6
|
+
считает нужным.
|
|
7
|
+
|
|
8
|
+
Собирается то, что нельзя написать один раз: слой, которого нет на диске,
|
|
9
|
+
ломает весь прогон import-linter, а раскладка за жизнь проекта меняется;
|
|
10
|
+
список переменных живёт в полях классов настроек, и файл, который ведут рядом
|
|
11
|
+
руками, расходится с ними молча.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from py_checks.sync._sync import planned, stale, write
|
|
15
|
+
|
|
16
|
+
__all__ = ["planned", "stale", "write"]
|
py_checks/sync/_sync.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Конфиги, которые собирает библиотека."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from py_checks.config import load
|
|
8
|
+
from py_checks.contracts import FILE, render
|
|
9
|
+
from py_checks.environment import render as environment
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def planned(*, root: Path) -> dict[Path, str]:
|
|
16
|
+
"""Что библиотека собирает для этого проекта.
|
|
17
|
+
|
|
18
|
+
Настройки ruff, pyright и прочих инструментов сюда не входят: их приносит
|
|
19
|
+
шаблон, и дальше это файлы проекта. Собирается то, что обязано совпадать с
|
|
20
|
+
кодом и расходится молча: контракты импортов — с раскладкой на диске (слой,
|
|
21
|
+
которого нет, роняет весь прогон import-linter), `.env.example` — с полями
|
|
22
|
+
классов настроек (переменная, которой нет в файле, обнаруживается на проде).
|
|
23
|
+
"""
|
|
24
|
+
config = load(root=root)
|
|
25
|
+
built: dict[Path, str] = {}
|
|
26
|
+
contracts = render(
|
|
27
|
+
root=root,
|
|
28
|
+
config=config,
|
|
29
|
+
)
|
|
30
|
+
if contracts is not None:
|
|
31
|
+
built[root / FILE] = contracts
|
|
32
|
+
variables = environment(
|
|
33
|
+
root=root,
|
|
34
|
+
config=config,
|
|
35
|
+
)
|
|
36
|
+
if variables is not None:
|
|
37
|
+
path, text = variables
|
|
38
|
+
built[path] = text
|
|
39
|
+
return built
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def stale(*, root: Path) -> list[Path]:
|
|
43
|
+
"""Файлы, которые разошлись с тем, что собралось бы сейчас."""
|
|
44
|
+
return [path for path, text in planned(root=root).items() if _read(path=path) != text]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write(*, root: Path) -> list[Path]:
|
|
48
|
+
"""Собрать заново; вернуть то, что изменилось."""
|
|
49
|
+
changed: list[Path] = []
|
|
50
|
+
for path, text in planned(root=root).items():
|
|
51
|
+
if _read(path=path) != text:
|
|
52
|
+
path.write_text(text, encoding="utf-8")
|
|
53
|
+
changed.append(path)
|
|
54
|
+
return changed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read(*, path: Path) -> str | None:
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
return None
|
|
60
|
+
return path.read_text(encoding="utf-8")
|