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,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,9 @@
1
+ """Имена, о которых знают все модули пакета."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ SECTION: Final = "env-example"
8
+
9
+ FILE: Final = ".env.example"
@@ -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"]
@@ -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")