Metadata-Version: 2.4
Name: python-checks
Version: 0.2.0
Summary: Проверки архитектурных соглашений проекта
Author: Armontex
Author-email: Armontex <windle1337@gmail.com>
License-Expression: MIT
License-File: LICENSE
Requires-Dist: libcst>=1.9,<2
Requires-Dist: pydantic>=2.13.5,<3
Requires-Dist: pydantic-settings>=2.15.0,<3
Requires-Dist: rich>=14.0.0,<16.0.0
Requires-Dist: typer>=0.27.2,<0.28
Requires-Python: >=3.14
Description-Content-Type: text/markdown

<div align="center">

<img src="docs/logo.svg" width="112" alt="py-checks">

# py-checks

**Архитектурные соглашения проекта, проверяемые как код.**

[![ci](https://github.com/Armontex/py-checks/actions/workflows/ci.yml/badge.svg)](https://github.com/Armontex/py-checks/actions/workflows/ci.yml)
[![python](https://img.shields.io/badge/python-3.14%2B-3776AB)](https://www.python.org/)
[![правил](https://img.shields.io/badge/checks-27-2ea043)](#что-проверяется)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-FAB040)](#pre-commit)
[![ruff](https://img.shields.io/badge/linted%20with-ruff-261230)](https://docs.astral.sh/ruff/)
[![pyright](https://img.shields.io/badge/types-pyright%20strict-1f6feb)](https://microsoft.github.io/pyright/)
[![license](https://img.shields.io/badge/license-MIT-750014)](LICENSE)

</div>

---

Линтер знает язык, но не знает ваш проект. Он не скажет, что 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`](docs/service.md).

## Установка

```bash
uv add --dev python-checks
```

Ставится как `python-checks`, зовётся `py-checks`: на PyPI живёт сосед по
имени, а команда, секция настроек и пакет остались прежними.

Нужен Python 3.14+. Зависимости: `libcst`, `pydantic`, `pydantic-settings`, `rich`, `typer`.

## За минуту

Положите рядом с `pyproject.toml` файл `py-checks.toml`:

```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" = "идентификатор выдают на краю" }
```

и запустите:

```bash
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` | потолок у зависимости |

<details>
<summary>Все двадцать семь</summary>

| Код | Что падает | Вид |
|---|---|:-:|
| `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` | зависимость может уехать на версию, которую никто не запускал | проект |

</details>

Каждое правило объясняет себя целиком — `py-checks explain <код>` печатает
докстринг с причиной и список настроек. Заготовка таблиц для типового сервиса
лежит в [`docs/service.md`](docs/service.md).

### Три вида правил

Что правилу дают на суд, оно объявляет само — полем `scope`:

- **файл** — разобранный исходник; таких большинство;
- **проект** — корень: манифест, согласие файлов репозитория между собой;
- **среда** — то же, но нужна живая база или долгий прогон. В обычный прогон такое не входит: `py-checks run --all` или по имени, место ему в CI.

## Пометки

Снять правило со строки можно, но придётся объяснить зачем:

```python
text("SELECT 1")  # db-ok: raw-sql: проба живости, формы ORM нет
```

Каноническая форма — `# check-ok: <код>: <причина>`, она снимает ровно одно
правило. У каждой группы есть своё короткое слово (`# db-ok`, `# type-ok`,
`# signature-ok`, …): человек помнит группу, а не двадцать семь кодов.

Пометок в строке может стоять несколько — подпись в столбик собирает их на
последней строке:

```python
    ) -> object:  # signature-ok: так зовёт pydantic  # type-ok: сырой ввод
```

Пометка без кода, с опечаткой в коде или без причины — сама нарушение. Молча
неработающая пометка выглядит как отключённая проверка, а на деле проверка
работает и просто её не видит.

## pre-commit

```yaml
- 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` под них собирается:

```toml
[contracts.layers]
domain = ["domain"]
application = ["application", "domain"]
presentation = ["presentation", "application"]
```

```bash
py-checks sync           # собрать
py-checks sync --check   # упасть, если файл отстал
```

Собирается он не только из таблицы, но и из того, что лежит на диске: слой,
которого нет, в контракт не попадает — иначе import-linter упал бы на первом же
несуществующем модуле. Гоняет граф по собранному файлу хук самого import-linter.

## Пример окружения

Имя переменной знает поле настроек — оно объявляет его `validation_alias`, и
за этим следит `config-fields`. Значит, `.env.example` выводится из тех же
классов, что переменные и читают, и вести его рядом руками незачем: расходится
он молча, а замечают это, когда переменной не оказалось на проде.

```toml
[env-example]
settings = ["myservice.config.settings:Settings"]
```

Тот же `py-checks sync` — файл собирается вместе с контрактами. Достаточно
корневого класса: свои секции он уже перечислил собственными полями, и
повторять их список в настройках значит завести второй, который с первым
разойдётся. Поле-секция переменной не становится — у него своего имени в
окружении нет. В файл едут имя переменной, значение по умолчанию, первый абзац
докстринга класса и `description` поля, если оно у него есть, — так объяснение
живёт рядом с полем, а не в файле, который его переживёт.

Шапку файла можно написать свою — `header = "# Generated by py-checks."`,
вместе с `#`. Нужна она проекту, который пишет комментарии на другом языке;
пустая оставляет шапку библиотеки.

Классы импортируются, а не читаются текстом: имя переменной — значение
атрибута, собранное вызовом, и прочитать его исходником значит выполнить этот
вызов самому.

## Своё правило

Правило — это класс с четырьмя полями и `run`, объявленный через entry points.
Форкать библиотеку не нужно:

```python
# 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 в исходнике; событие пишут логом",
                )
```

```toml
[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` |

## Разработка

```bash
uv sync
uv run pre-commit install
uv run pytest -q
```

Проверка описывается не тестом, а папками с примерами: `tests/checks/<код>/`
с `ok/` и `bad/` внутри, снимок вывода сверяет syrupy. У правил про проект —
`tests/projects/<код>__<вариант>/`. Библиотека проверяет сама себя: правило,
которое не выдерживает свой же репозиторий, до чужого доезжать не должно.

Соглашения репозитория — в [`AGENTS.md`](AGENTS.md).

## Лицензия

[MIT](LICENSE) — © 2026 Armontex.
