Линтер знает язык, но не знает ваш проект. Он не скажет, что ORM-модель уехала
в сценарий, что колонка Numeric осталась без CHECK, что datetime.now()
позвали в домене, а не в порте. Это не ошибки языка — это нарушенные
соглашения, и до сих пор их ловило ревью: глазами, у каждого свои, каждый раз
заново.
py-checks — движок для таких соглашений. Библиотека везёт правила, проект
везёт свою архитектуру: имена слоёв, список запечатанных зон, где живёт ORM,
чем ограничена колонка. Без таблиц проекта правила молчат — библиотека не
догадывается за вас, как называется ваш домен.
src/app/modules/cashout/application/offer.py:34:9: determinism: uuid4() не детерминирован;
идентификатор выдают на краю и передают внутрь
src/app/infra/database/models/bet.py:51:5: model-columns: stake — Numeric без ограничений;
деньги описывают Numeric(18, 4)
src/app/presentation/api/v1/routers/bets.py:22:1: endpoint-declarations: POST /bets
не назвал response_model
Зачем
- Соглашение перестаёт быть устным. Правило записано один раз, с причиной, и проверяется на каждом коммите — а не вспоминается на ревью тем, кто его помнит.
- Правило универсально, таблица — ваша. Одно правило «этот вызов живёт только здесь» закрывает и границу транзакции, и запрет
floatв домене. Библиотека не содержит ни одного имени вашего проекта. - Отказ объясняет себя. Сообщение говорит, что не так и чем это заменить, а не «нарушение правила №14».
- Исключение стоит одной строки, но требует причины.
# check-ok: raw-sql: проба живости, формы ORM нет— пометка без причины сама становится нарушением. - Чужую работу мы не делаем. Что умеют ruff, pyright и import-linter — остаётся за ними; что и почему туда отдано, записано в
docs/service.md.
Установка
uv add --dev python-checks
Ставится как python-checks, зовётся py-checks: на PyPI живёт сосед по
имени, а команда, секция настроек и пакет остались прежними.
Нужен Python 3.14+. Зависимости: libcst, pydantic, pydantic-settings, rich, typer.
За минуту
Положите рядом с pyproject.toml файл py-checks.toml:
src = "src"
[module-length]
max-lines = 300
# Правила, зависящие от места, работают только в названных зонах.
[model-columns]
zones = ["infra/database/models"]
types = { Numeric = "деньги описывают Numeric(18, 4)" }
[determinism]
zones = ["modules/*/domain", "modules/*/application"]
sources = { "datetime.now" = "часы берут портом", "uuid4" = "идентификатор выдают на краю" }
и запустите:
py-checks run # проверить `src`
py-checks run --fix # и починить то, что чинится само
py-checks list # какие правила есть и что включено
py-checks explain determinism # что правило требует и какие у него настройки
Настройки можно держать и секцией [tool.py-checks] в pyproject.toml — но
одно из двух: два места разом библиотека считает ошибкой, а не слиянием.
Что проверяется
Двадцать семь правил в девяти группах:
| Группа | О чём |
|---|---|
imports |
какой пакет где разрешён, какая зона запечатана |
placement |
что лежит в этой директории и как устроена операция |
signatures |
длина модуля, глубина вложенности, форма подписи |
types |
границы у полей, форма аннотаций, неизменяемость |
database |
граница модели, материал колонки, форма запроса, схема |
effects |
часы, случайность и имя события в логе |
api |
чем маршрут отвечает и что обязан объявить |
calls |
функция, у которой есть список мест, откуда её зовут |
hygiene |
потолок у зависимости |
Все двадцать семь
| Код | Что падает | Вид |
|---|---|---|
confined-imports |
пакет импортируется вне отведённых ему мест | файл |
sealed-imports |
запечатанная зона импортирует чужой пакет | файл |
class-modules |
в модуле лежит то, чего его директория не допускает | файл |
class-placement |
класс лежит не там, где лежат классы его вида | файл |
operation-shape |
операция устроена не как операция | файл |
required-class |
модуль не объявил класс, ради которого лежит в этой директории | файл |
keyword-only-arguments |
подпись записана не полностью | файл |
function-length |
функция длиннее лимита | файл |
module-length |
модуль длиннее лимита | файл |
nesting |
управляющие конструкции вложены глубже предела | файл |
signature-layout |
список из двух и более элементов записан в одну строку | файл |
annotation-shapes |
форма названа так, что поля в ней безымянные | файл |
config-fields |
поле настроек ничем не ограничено | файл |
confined-types |
поле в этой части дерева объявлено запрещённым здесь типом | файл |
constant-annotations |
константа не сказала типом, что она константа | файл |
frozen-dataclasses |
dataclass в зоне объявлен без нужных аргументов | файл |
bound-checks |
ограниченная колонка не повторила своё ограничение как CHECK | файл |
confined-calls |
названный метод позвали не там, где ему место | файл |
model-boundary |
ORM-модель объявлена, собрана или отдана не там | файл |
model-columns |
колонка собрана не из того материала | файл |
raw-sql |
SQL написан строкой там, где хватило бы выражения | файл |
statement-keys |
запрос называет колонку строкой или ходит в базу в цикле | файл |
schema-drift |
модели и миграции описывают уже разные схемы | среда |
determinism |
код сам читает часы, случайность или новый идентификатор | файл |
log-events |
событие в логе названо чем-то кроме члена перечисления | файл |
endpoint-declarations |
маршрут не сказал, чем он отвечает | файл |
confined-functions |
названная функция позвана не оттуда, откуда ей можно | файл |
dependency-bounds |
зависимость может уехать на версию, которую никто не запускал | проект |
Каждое правило объясняет себя целиком — py-checks explain <код> печатает
докстринг с причиной и список настроек. Заготовка таблиц для типового сервиса
лежит в docs/service.md.
Три вида правил
Что правилу дают на суд, оно объявляет само — полем scope:
- файл — разобранный исходник; таких большинство;
- проект — корень: манифест, согласие файлов репозитория между собой;
- среда — то же, но нужна живая база или долгий прогон. В обычный прогон такое не входит:
py-checks run --allили по имени, место ему в CI.
Пометки
Снять правило со строки можно, но придётся объяснить зачем:
text("SELECT 1") # db-ok: raw-sql: проба живости, формы ORM нет
Каноническая форма — # check-ok: <код>: <причина>, она снимает ровно одно
правило. У каждой группы есть своё короткое слово (# db-ok, # type-ok,
# signature-ok, …): человек помнит группу, а не двадцать семь кодов.
Пометок в строке может стоять несколько — подпись в столбик собирает их на последней строке:
) -> object: # signature-ok: так зовёт pydantic # type-ok: сырой ввод
Пометка без кода, с опечаткой в коде или без причины — сама нарушение. Молча неработающая пометка выглядит как отключённая проверка, а на деле проверка работает и просто её не видит.
pre-commit
- repo: https://github.com/Armontex/py-checks
rev: v0.1.0
hooks:
- id: py-checks
args: [--fix] # починить то, что чинится само
- id: py-checks-sync # контракты и `.env.example` собраны заново
Хук один, а не по хуку на правило: что включено, решает конфиг проекта. Набор
правил зовут args: [--select, "<код>,<код>"]; повторённый флаг делает то же
самое.
Ставить его нужно до ruff-format: автофикс ставит символы, а не колонки,
и раскладывает подпись форматтер проекта.
Всё остальное — ruff, pyright, import-linter, commitizen — проект объявляет сам: у каждого есть свой хук, написанный его же авторами.
Контракты импортов
Слои проекта описываются один раз, а .importlinter под них собирается:
[contracts.layers]
domain = ["domain"]
application = ["application", "domain"]
presentation = ["presentation", "application"]
py-checks sync # собрать
py-checks sync --check # упасть, если файл отстал
Собирается он не только из таблицы, но и из того, что лежит на диске: слой, которого нет, в контракт не попадает — иначе import-linter упал бы на первом же несуществующем модуле. Гоняет граф по собранному файлу хук самого import-linter.
Пример окружения
Имя переменной знает поле настроек — оно объявляет его validation_alias, и
за этим следит config-fields. Значит, .env.example выводится из тех же
классов, что переменные и читают, и вести его рядом руками незачем: расходится
он молча, а замечают это, когда переменной не оказалось на проде.
[env-example]
settings = ["myservice.config.settings:Settings"]
Тот же py-checks sync — файл собирается вместе с контрактами. Достаточно
корневого класса: свои секции он уже перечислил собственными полями, и
повторять их список в настройках значит завести второй, который с первым
разойдётся. Поле-секция переменной не становится — у него своего имени в
окружении нет. В файл едут имя переменной, значение по умолчанию, первый абзац
докстринга класса и description поля, если оно у него есть, — так объяснение
живёт рядом с полем, а не в файле, который его переживёт.
Шапку файла можно написать свою — header = "# Generated by py-checks.",
вместе с #. Нужна она проекту, который пишет комментарии на другом языке;
пустая оставляет шапку библиотеки.
Классы импортируются, а не читаются текстом: имя переменной — значение атрибута, собранное вызовом, и прочитать его исходником значит выполнить этот вызов самому.
Своё правило
Правило — это класс с четырьмя полями и run, объявленный через entry points.
Форкать библиотеку не нужно:
# myproject_checks/_no_print.py
import ast
from collections.abc import Iterator
from typing import ClassVar, Final
from py_checks.config import CheckSettings
from py_checks.core import ParsedFile, Scope, Violation
CODE: Final = "no-print"
class NoPrint:
"""Падает, если в исходнике остался `print`."""
code: ClassVar[str] = CODE
Settings: ClassVar[type[CheckSettings]] = CheckSettings
scope: ClassVar[Scope] = Scope.FILE
marker: ClassVar[str] = "# my-ok"
@classmethod
def run(cls, *, file: ParsedFile, settings: CheckSettings) -> Iterator[Violation]:
for node in ast.walk(file.tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "print":
yield Violation.from_node(
node=node,
path=file.path,
code=CODE,
message="print в исходнике; событие пишут логом",
)
[project.entry-points."py_checks.checks"]
no-print = "myproject_checks:NoPrint"
Дальше оно ведёт себя как родное: попадает в list, в explain, слушается
ignore и снимается пометкой.
Команды
| Команда | |
|---|---|
py-checks run [пути] |
прогнать проверки; 0 — чисто, 1 — есть нарушения |
py-checks run --fix |
наложить правки, которые однозначны |
py-checks run --select <код>,<код> |
только названные правила, несмотря на ignore |
py-checks run --all |
вместе с правилами, которым нужна живая среда |
py-checks list |
все правила: код, состояние, строка описания |
py-checks explain <код> |
что правило требует и какие у него настройки |
py-checks sync [--check] |
собрать контракты импортов и .env.example |
Разработка
uv sync
uv run pre-commit install
uv run pytest -q
Проверка описывается не тестом, а папками с примерами: tests/checks/<код>/
с ok/ и bad/ внутри, снимок вывода сверяет syrupy. У правил про проект —
tests/projects/<код>__<вариант>/. Библиотека проверяет сама себя: правило,
которое не выдерживает свой же репозиторий, до чужого доезжать не должно.
Соглашения репозитория — в AGENTS.md.
Лицензия
MIT — © 2026 Armontex.
Release files for python-checks 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| python_checks-0.2.0.tar.gz | 86.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| python_checks-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 234.6 kB
Release files / python_checks-0.2.0.tar.gz
| Download URL | python_checks-0.2.0.tar.gz |
|---|---|
| Size | 86.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fb572b5be687ca2862c110f4a45e6bbeb2c3aa3bf41941b4693c9469174cb41b
|
|
BLAKE2b-256 checksum How to use checksums |
0dea5e25f7c9e93f776abb3ff3c3d7f9e441d3ecce327a83365335d0dd6b181c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / python_checks-0.2.0-py3-none-any.whl
| Download URL | python_checks-0.2.0-py3-none-any.whl |
|---|---|
| Size | 148.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8eff7fc36f23e00a59448a9d5865e5c08ccd153352700c70d711c90ad12276d0
|
|
BLAKE2b-256 checksum How to use checksums |
29c6c0cdd7bd351966b3b8e8d48bf6ea7e305de179a06b7b8269caf6d073f781
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log