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