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,5 @@
1
+ """Слово группы для пометок: `# api-ok: <код>: <причина>`."""
2
+
3
+ from typing import Final
4
+
5
+ MARKER: Final = "# api-ok"
@@ -0,0 +1,13 @@
1
+ """Места вызова.
2
+
3
+ Есть функции, у которых законных мест вызова ровно столько, сколько их
4
+ перечислено: конверсия денег, например. Правило держит этот список.
5
+ """
6
+
7
+ from py_checks.checks.calls._confined_functions import (
8
+ ConfinedFunctions,
9
+ ConfinedFunctionsSettings,
10
+ )
11
+ from py_checks.checks.calls._marker import MARKER
12
+
13
+ __all__ = ["MARKER", "ConfinedFunctions", "ConfinedFunctionsSettings"]
@@ -0,0 +1,102 @@
1
+ """У некоторых функций законных мест вызова ровно столько, сколько перечислено."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING, ClassVar, Final
7
+
8
+ from py_checks.checks._location import place
9
+ from py_checks.checks._names import matches
10
+ from py_checks.checks.calls._marker import MARKER
11
+ from py_checks.config import CheckSettings
12
+ from py_checks.core import Scope, Violation, settings_as
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterator
16
+
17
+ from py_checks.core import ParsedFile
18
+
19
+ CODE: Final = "confined-functions"
20
+
21
+
22
+ class ConfinedFunctionsSettings(CheckSettings):
23
+ calls: dict[str, tuple[str, ...]] = {} # noqa: RUF012 — pydantic копирует значение сам
24
+ home: str | None = None
25
+
26
+
27
+ class ConfinedFunctions:
28
+ """Падает, если названная функция позвана не оттуда, откуда ей можно.
29
+
30
+ Написано ради конверсии денег. Сервис считает в одной валюте, и гарантия
31
+ за этой фразой — не имя типа: это то, что у конверсии одна реализация и
32
+ места её вызова можно перечислить. Где угодно ещё конверсия — это сумма в
33
+ чьей-то валюте посреди расчёта, и ошибка, которую она даёт, — число,
34
+ верное ровно до того дня, когда встретятся две валюты.
35
+
36
+ Место — кусок пути, а не файл: край — это место в замысле, и файл, который
37
+ разделили надвое, краем быть не перестал.
38
+
39
+ `home` — модуль, где функция объявлена: там она написана, а не позвана, и
40
+ правило его не трогает.
41
+
42
+ Настройки: `calls`, `home`.
43
+ """
44
+
45
+ code: ClassVar[str] = CODE
46
+ Settings: ClassVar[type[CheckSettings]] = ConfinedFunctionsSettings
47
+ scope: ClassVar[Scope] = Scope.FILE
48
+ marker: ClassVar[str] = MARKER
49
+
50
+ @classmethod
51
+ def run(
52
+ cls,
53
+ *,
54
+ file: ParsedFile,
55
+ settings: CheckSettings,
56
+ ) -> Iterator[Violation]:
57
+ limits = settings_as(
58
+ settings=settings,
59
+ model=ConfinedFunctionsSettings,
60
+ code=CODE,
61
+ )
62
+ where = place(file=file)
63
+ if where is None or not limits.calls:
64
+ return
65
+ if limits.home is not None and where.holds(path=limits.home):
66
+ return
67
+ for node in ast.walk(file.tree):
68
+ if not isinstance(node, ast.Call):
69
+ continue
70
+ called = ast.unparse(node.func)
71
+ allowed = cls._allowed(
72
+ called=called,
73
+ calls=limits.calls,
74
+ )
75
+ if allowed is None or where.anywhere(
76
+ zones=allowed,
77
+ ):
78
+ continue
79
+ yield Violation.from_node(
80
+ node=node,
81
+ path=file.path,
82
+ code=CODE,
83
+ message=f"{called}() зовут не отсюда; её места — {', '.join(allowed)}",
84
+ )
85
+
86
+ @staticmethod
87
+ def _allowed(
88
+ *,
89
+ called: str,
90
+ calls: dict[str, tuple[str, ...]],
91
+ ) -> tuple[str, ...] | None:
92
+ return next(
93
+ (
94
+ zones
95
+ for pattern, zones in calls.items()
96
+ if matches(
97
+ called=called,
98
+ pattern=pattern,
99
+ )
100
+ ),
101
+ None,
102
+ )
@@ -0,0 +1,5 @@
1
+ """Слово группы для пометок: `# call-ok: <код>: <причина>`."""
2
+
3
+ from typing import Final
4
+
5
+ MARKER: Final = "# call-ok"
@@ -0,0 +1,39 @@
1
+ """База данных.
2
+
3
+ Граница транзакции, материал колонки, форма запроса. Готового тут почти нет:
4
+ `pytest-alembic` отвечает на откат миграции, `alembic check` — на расхождение
5
+ моделей и миграций, но звать его приходится самим: ему нужна живая база,
6
+ поэтому `schema-drift` объявлен `ENVIRONMENT`. Остальное — соглашения проекта.
7
+ """
8
+
9
+ from py_checks.checks.database._bound_checks import BoundChecks, BoundChecksSettings
10
+ from py_checks.checks.database._confined_calls import (
11
+ Confined,
12
+ ConfinedCalls,
13
+ ConfinedCallsSettings,
14
+ )
15
+ from py_checks.checks.database._marker import MARKER
16
+ from py_checks.checks.database._model_boundary import ModelBoundary, ModelBoundarySettings
17
+ from py_checks.checks.database._model_columns import ModelColumns, ModelColumnsSettings
18
+ from py_checks.checks.database._raw_sql import RawSql, RawSqlSettings
19
+ from py_checks.checks.database._schema_drift import SchemaDrift, SchemaDriftSettings
20
+ from py_checks.checks.database._statement_keys import StatementKeys, StatementKeysSettings
21
+
22
+ __all__ = [
23
+ "MARKER",
24
+ "BoundChecks",
25
+ "BoundChecksSettings",
26
+ "Confined",
27
+ "ConfinedCalls",
28
+ "ConfinedCallsSettings",
29
+ "ModelBoundary",
30
+ "ModelBoundarySettings",
31
+ "ModelColumns",
32
+ "ModelColumnsSettings",
33
+ "RawSql",
34
+ "RawSqlSettings",
35
+ "SchemaDrift",
36
+ "SchemaDriftSettings",
37
+ "StatementKeys",
38
+ "StatementKeysSettings",
39
+ ]
@@ -0,0 +1,186 @@
1
+ """Ограниченная колонка повторяет своё ограничение в базе."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING, ClassVar, Final
7
+
8
+ from py_checks.checks._location import ZonedSettings, zoned
9
+ from py_checks.checks._names import name
10
+ from py_checks.checks.database._marker import MARKER
11
+ from py_checks.core import Scope, Violation, settings_as
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator
15
+
16
+ from py_checks.config import CheckSettings
17
+ from py_checks.core import ParsedFile
18
+
19
+ CODE: Final = "bound-checks"
20
+
21
+ MAPPED: Final = "Mapped"
22
+
23
+
24
+ class BoundChecksSettings(ZonedSettings):
25
+ primitives: tuple[str, ...] = ()
26
+ call: str = "bound_check"
27
+ column: str = "column"
28
+ primitive: str = "primitive"
29
+
30
+
31
+ class BoundChecks:
32
+ """Падает, если ограниченная колонка не повторила своё ограничение как CHECK.
33
+
34
+ Колонка, объявленная `Mapped[PositiveDecimal]`, обещает дважды. pyright
35
+ держит каждую строку, СОБРАННУЮ здесь, значениями, которые тип пропустил;
36
+ `bound_check(column=..., primitive=PositiveDecimal)` в `__table_args__`
37
+ держит каждую строку, записанную любым другим способом — бэкфилл, сессия
38
+ psql, второй сервис в следующем году. Правило связывает две половины:
39
+ аннотация без CHECK — это база, доверяющая коду, которого она не видела.
40
+
41
+ Проверяется наличие, а не эквивалентность, и потому ему можно верить: SQL
42
+ генерируется из того же `BOUND`, которым отказывает тип, так что второго
43
+ выражения для сравнения просто нет — есть вызов, который могли забыть.
44
+ Отдельно отвергается `primitive=`, называющий не тот тип, что в аннотации:
45
+ это единственный способ протащить расхождение обратно.
46
+
47
+ Список ограниченных типов проектный: библиотека не может знать, что у
48
+ этого сервиса деньги — `PositiveDecimal`, а доля — `MarginFraction`. Без
49
+ списка правило молчит.
50
+
51
+ Настройки: `zones`, `primitives`, `call`, `column`, `primitive`.
52
+ """
53
+
54
+ code: ClassVar[str] = CODE
55
+ Settings: ClassVar[type[CheckSettings]] = BoundChecksSettings
56
+ scope: ClassVar[Scope] = Scope.FILE
57
+ marker: ClassVar[str] = MARKER
58
+
59
+ @classmethod
60
+ def run(
61
+ cls,
62
+ *,
63
+ file: ParsedFile,
64
+ settings: CheckSettings,
65
+ ) -> Iterator[Violation]:
66
+ limits = settings_as(
67
+ settings=settings,
68
+ model=BoundChecksSettings,
69
+ code=CODE,
70
+ )
71
+ where = zoned(
72
+ file=file,
73
+ zones=limits.zones,
74
+ )
75
+ if where is None:
76
+ return
77
+ for node in file.tree.body:
78
+ if isinstance(node, ast.ClassDef):
79
+ yield from cls._columns(
80
+ file=file,
81
+ node=node,
82
+ limits=limits,
83
+ )
84
+
85
+ @classmethod
86
+ def _columns(
87
+ cls,
88
+ *,
89
+ file: ParsedFile,
90
+ node: ast.ClassDef,
91
+ limits: BoundChecksSettings,
92
+ ) -> Iterator[Violation]:
93
+ declared = cls._declared(
94
+ node=node,
95
+ limits=limits,
96
+ )
97
+ for statement in node.body:
98
+ if not isinstance(statement, ast.AnnAssign):
99
+ continue
100
+ if not isinstance(statement.target, ast.Name):
101
+ continue
102
+ bounded = cls._bounded(node=statement.annotation)
103
+ if bounded is None or bounded not in limits.primitives:
104
+ continue
105
+ field = statement.target.id
106
+ said = declared.get(field)
107
+ if said == bounded:
108
+ continue
109
+ yield Violation.from_node(
110
+ node=statement,
111
+ path=file.path,
112
+ code=CODE,
113
+ message=cls._message(
114
+ field=field,
115
+ bounded=bounded,
116
+ said=said,
117
+ limits=limits,
118
+ ),
119
+ )
120
+
121
+ @staticmethod
122
+ def _message(
123
+ *,
124
+ field: str,
125
+ bounded: str,
126
+ said: str | None,
127
+ limits: BoundChecksSettings,
128
+ ) -> str:
129
+ if said is None:
130
+ return (
131
+ f"{field} объявлено как {bounded}, но CHECK не несёт; добавь "
132
+ f"{limits.call}({limits.column}={field}, {limits.primitive}={bounded}) "
133
+ f"в __table_args__"
134
+ )
135
+ return (
136
+ f"{field} объявлено как {bounded}, а его {limits.call} называет {said}; "
137
+ f"аннотация и CHECK читают одну границу"
138
+ )
139
+
140
+ @classmethod
141
+ def _declared(
142
+ cls,
143
+ *,
144
+ node: ast.ClassDef,
145
+ limits: BoundChecksSettings,
146
+ ) -> dict[str, str]:
147
+ """Колонка — тип, по каждому вызову в теле класса."""
148
+ found: dict[str, str] = {}
149
+ for child in ast.walk(node):
150
+ if not isinstance(child, ast.Call) or name(node=child.func) != limits.call:
151
+ continue
152
+ column = cls._argument(
153
+ node=child,
154
+ named=limits.column,
155
+ )
156
+ primitive = cls._argument(
157
+ node=child,
158
+ named=limits.primitive,
159
+ )
160
+ if column is not None and primitive is not None:
161
+ found[column] = primitive
162
+ return found
163
+
164
+ @staticmethod
165
+ def _argument(
166
+ *,
167
+ node: ast.Call,
168
+ named: str,
169
+ ) -> str | None:
170
+ for keyword in node.keywords:
171
+ if keyword.arg == named and isinstance(keyword.value, ast.Name):
172
+ return keyword.value.id
173
+ return None
174
+
175
+ @staticmethod
176
+ def _bounded(*, node: ast.expr) -> str | None:
177
+ """X из `Mapped[X]` или `Mapped[X | None]`, если это простое имя."""
178
+ if not isinstance(node, ast.Subscript) or name(node=node.value) != MAPPED:
179
+ return None
180
+ match node.slice:
181
+ case ast.Name(id=inside):
182
+ return inside
183
+ case ast.BinOp(left=ast.Name(id=inside), op=ast.BitOr()):
184
+ return inside
185
+ case _:
186
+ return None
@@ -0,0 +1,115 @@
1
+ """Вызов, который принадлежит одному модулю, и больше никому."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import TYPE_CHECKING, ClassVar, Final
7
+
8
+ from py_checks.checks._location import ZonedSettings, place
9
+ from py_checks.checks.database._marker import MARKER
10
+ from py_checks.config import CheckSettings
11
+ from py_checks.core import Scope, Violation, settings_as
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator
15
+
16
+ from py_checks.checks._location import Place
17
+ from py_checks.core import ParsedFile
18
+
19
+ CODE: Final = "confined-calls"
20
+
21
+
22
+ class Confined(ZonedSettings):
23
+ """Имена методов, зона, где они запрещены, и модуль, который ими владеет.
24
+
25
+ `owner` — имя модуля без расширения. Правило его не касается: там вызов и
26
+ должен стоять, потому и владелец.
27
+
28
+ `outside` — куски зоны, где правило молчит. Имя метода — всё, что видно по
29
+ одному файлу, и край брокера тому пример: `commit()` у консьюмера
30
+ подтверждает смещение, а не транзакцию базы.
31
+ """
32
+
33
+ methods: tuple[str, ...]
34
+ outside: tuple[str, ...] = ()
35
+ owner: str | None = None
36
+ said: str = "этим владеет другой модуль"
37
+
38
+
39
+ class ConfinedCallsSettings(CheckSettings):
40
+ rules: tuple[Confined, ...] = ()
41
+
42
+
43
+ class ConfinedCalls:
44
+ """Падает, если названный метод позвали не там, где ему место.
45
+
46
+ Написано ради границы транзакции. Ставка — это одна транзакция: списать
47
+ деньги, записать ставку, записать событие, которое расскажет об этом
48
+ остальной платформе. Репозиторий, коммитящий в середине, превращает её в
49
+ три, и сальдо перестаёт сходиться со ставками. `begin` запрещён рядом с
50
+ `commit` и `rollback` по той же причине с другого конца: вызывающий уже
51
+ открыл транзакцию, а вторая внутри либо падает, либо тихо делает вложенную.
52
+
53
+ Имя метода — всё, что видно по одному файлу: чей это объект, сказал бы
54
+ только вывод типов. Поэтому правило и сужено зоной — там, где `commit()`
55
+ может быть только у сессии.
56
+
57
+ Настройка: `rules`.
58
+ """
59
+
60
+ code: ClassVar[str] = CODE
61
+ Settings: ClassVar[type[CheckSettings]] = ConfinedCallsSettings
62
+ scope: ClassVar[Scope] = Scope.FILE
63
+ marker: ClassVar[str] = MARKER
64
+
65
+ @classmethod
66
+ def run(
67
+ cls,
68
+ *,
69
+ file: ParsedFile,
70
+ settings: CheckSettings,
71
+ ) -> Iterator[Violation]:
72
+ rules = settings_as(
73
+ settings=settings,
74
+ model=ConfinedCallsSettings,
75
+ code=CODE,
76
+ ).rules
77
+ where = place(file=file)
78
+ if where is None:
79
+ return
80
+ listed = [
81
+ rule
82
+ for rule in rules
83
+ if cls._covers(
84
+ rule=rule,
85
+ where=where,
86
+ file=file,
87
+ )
88
+ ]
89
+ if not listed:
90
+ return
91
+ for node in ast.walk(file.tree):
92
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
93
+ continue
94
+ rule = next((one for one in listed if node.func.attr in one.methods), None)
95
+ if rule is None:
96
+ continue
97
+ yield Violation.from_node(
98
+ node=node,
99
+ path=file.path,
100
+ code=CODE,
101
+ message=f"{ast.unparse(node.func)}: {rule.said}",
102
+ )
103
+
104
+ @staticmethod
105
+ def _covers(
106
+ *,
107
+ rule: Confined,
108
+ where: Place,
109
+ file: ParsedFile,
110
+ ) -> bool:
111
+ if rule.owner is not None and file.path.stem == rule.owner:
112
+ return False
113
+ if where.anywhere(zones=rule.outside):
114
+ return False
115
+ return where.anywhere(zones=rule.zones)
@@ -0,0 +1,5 @@
1
+ """Слово группы для пометок: `# db-ok: <код>: <причина>`."""
2
+
3
+ from typing import Final
4
+
5
+ MARKER: Final = "# db-ok"