refactor(make): корень — про стенд, генератор — за своей дверью

- Зачем:
  - корневые цели смешивали два уровня: пять из шестнадцати начинались с cd generator.
  - цель, названная общерепозиторной, охватывала 31 файл Python из 34: даги и Superset не видел ни линт, ни типы.
- Что:
  - lint, typecheck, test, docs и inventory переехали в новый generator/Makefile.
  - корневой lint заведён по коду стенда — dags и infra/superset — с явными путями и закреплённой версией ruff.
  - заведён корневой ruff.toml: тот же список правил, target-version по младшему Python в образах стенда.
  - цели корня сгруппированы по использованию, осталось двенадцать.
  - два файла дагов переформатированы под новую проверку.
  - карта проверок, оба README, спека генератора и AGENTS.md приведены к двум дверям.
- Проверка:
  - make lint; make config-test — зелёные.
  - make -C generator lint; typecheck; test — зелёные, 407 тестов.
  - ruff check --show-files: из корня ровно три файла стенда, из generator/ — только его.
  - цена корневого lint замерена (0,4 с) и вписана в карту проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 21:31:07 +03:00
co-authored by Claude Opus 5
parent 368fb1a66b
commit 6a708cd5aa
13 changed files with 154 additions and 66 deletions
+3 -1
View File
@@ -77,7 +77,9 @@ Airflow) и названия из кода. Если для понятия ес
## Код и данные ## Код и данные
- Python — только через `uv`; проверка и формат — `ruff` (`make lint`). - Python — только через `uv`; проверка и формат — `ruff`. Цель `make lint`
есть за двумя дверями и охватывает разное: в корне — код стенда, в
`generator/` — код генератора. Правишь одно — зови ту.
- Изменения держать минимальными и в границах задания. - Изменения держать минимальными и в границах задания.
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук - Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md). обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
+31 -28
View File
@@ -3,7 +3,12 @@ GENERATOR_DAY ?= 0
GENERATOR_LIMIT ?= GENERATOR_LIMIT ?=
GENERATOR_SPEED ?= GENERATOR_SPEED ?=
.PHONY: up down clean ps logs generate-batch generate-live config-test lint typecheck test docs inventory smoke check-clickhouse check-services # Цели корня — про стенд. Проверки генератора живут за своей дверью, в
# `generator/Makefile`: генератор — отдельная и заменяемая сущность со своим
# `pyproject.toml`, локом и образом, и цели у него свои.
.PHONY: up down clean ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
# --- Жизнь стенда ---
# Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод # Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод
# целиком в шапке скрипта. # целиком в шапке скрипта.
@@ -23,33 +28,7 @@ ps:
logs: logs:
$(COMPOSE) logs --follow $(COMPOSE) logs --follow
generate-batch: # --- Проверки, которым нужен поднятый стенд ---
$(COMPOSE) --profile generator run --rm generator batch --day "$(GENERATOR_DAY)" \
$(if $(GENERATOR_LIMIT),--limit "$(GENERATOR_LIMIT)")
generate-live:
$(COMPOSE) --profile generator run --rm generator live --day "$(GENERATOR_DAY)" \
$(if $(GENERATOR_SPEED),--speed "$(GENERATOR_SPEED)")
config-test:
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
lint:
cd generator && uv run ruff check && uv run ruff format --check
typecheck:
cd generator && uv run ty check
test:
cd generator && uv run pytest
docs:
cd generator && uv run python -m clickstream_generator.schema_doc \
../docs/formats/clickstream-event.md
inventory:
cd generator && uv run python -m clickstream_generator.inventory \
../data/world-inventory.json
smoke: smoke:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
@@ -59,3 +38,27 @@ check-clickhouse:
check-services: check-services:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh
# --- Проверки, которым стенд не нужен ---
config-test:
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
# Пути названы вслух: без них ruff из корня прошёлся бы и по генератору, а у
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
# покраснела бы сама, без единой правки в репозитории. Держать её равной той,
# что в `generator/uv.lock`, приходится руками: сверять их некому.
lint:
uvx ruff@0.16.1 check dags infra/superset
uvx ruff@0.16.1 format --check dags infra/superset
# --- Наполнение миром ---
generate-batch:
$(COMPOSE) --profile generator run --rm generator batch --day "$(GENERATOR_DAY)" \
$(if $(GENERATOR_LIMIT),--limit "$(GENERATOR_LIMIT)")
generate-live:
$(COMPOSE) --profile generator run --rm generator live --day "$(GENERATOR_DAY)" \
$(if $(GENERATOR_SPEED),--speed "$(GENERATOR_SPEED)")
+8 -8
View File
@@ -31,9 +31,8 @@ Prometheus, Grafana и общая база Postgres для метаданных.
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им `grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
и `29092`. Проверкам без стенда — `make config-test`, `make lint`, и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
`make typecheck`, `make test` — и сборкам `make docs` и `make inventory` нужен всем целям генератора в `generator/` нужен `uv`.
`uv`.
Сначала скопируйте настройки стенда: Сначала скопируйте настройки стенда:
@@ -140,9 +139,10 @@ ClickHouse, пока мир не доедет. Повторный `make up` за
событий и хеш его байтов. Опись отвечает на единственный вопрос: тот ли это событий и хеш его байтов. Опись отвечает на единственный вопрос: тот ли это
мир, что был вчера. мир, что был вчера.
Спрашивают её двое. `make test` сверяет опись с тем, что собирается из кода Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
сегодня: правка генератора меняет мир, и опись надо пересобрать — собирается из кода сегодня: правка генератора меняет мир, и опись надо
`make inventory`. `make check-clickhouse` сверяет с описью то, что доехало до пересобрать — `make inventory` там же. `make check-clickhouse` в корне
сверяет с описью то, что доехало до
`ods.event`, подневно. Хеш дня можно пересчитать и руками — это обычный `ods.event`, подневно. Хеш дня можно пересчитать и руками — это обычный
`sha256sum` файла, который пишет файловый приёмник: `sha256sum` файла, который пишет файловый приёмник:
@@ -326,6 +326,6 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф
- [docs/research/](docs/research/) — исследования; сейчас это формат - [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события. кликстрима Яндекса, по которому строится модель события.
- [docs/formats/](docs/formats/) — описания форматов источников: по ним - [docs/formats/](docs/formats/) — описания форматов источников: по ним
пишется сторона хранилища. Собираются из кода командой `make docs`, руками пишется сторона хранилища. Собираются из кода командой `make docs` в
не правятся. `generator/`, руками не правятся.
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории. - [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
+2 -5
View File
@@ -75,8 +75,7 @@ def _table_engines(client, source: str) -> list[tuple[str, str]]:
def _drop_tables(client) -> None: def _drop_tables(client) -> None:
client.command( client.command(
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} " f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} SYNC"
f"ON CLUSTER {CLUSTER} SYNC"
) )
client.command( client.command(
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC" f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC"
@@ -178,9 +177,7 @@ def test_clickhouse():
""" """
).result_rows ).result_rows
if len(node_2_rows) != 1: if len(node_2_rows) != 1:
raise RuntimeError( raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
f"не удалось определить имя ноды 2: {node_2_rows}"
)
distributed_rows = client.query( distributed_rows = client.query(
f""" f"""
SELECT _shard_num, hostName(), marker SELECT _shard_num, hostName(), marker
+1 -3
View File
@@ -100,9 +100,7 @@ def test_kafka():
if error is not None: if error is not None:
delivery_errors.append(str(error)) delivery_errors.append(str(error))
else: else:
addresses.append( addresses.append(RecordAddress(message.partition(), message.offset()))
RecordAddress(message.partition(), message.offset())
)
producer = Producer(PRODUCER_CONFIG) producer = Producer(PRODUCER_CONFIG)
# produce() не пишет, а ставит сообщение в очередь: о судьбе записи # produce() не пишет, а ставит сообщение в очередь: о судьбе записи
+50 -14
View File
@@ -29,19 +29,45 @@
## Карта целей ## Карта целей
Стенд нужен трём целям из семи. Цена — замер, см. «Что проверено»: `make test`, Цели живут за двумя дверями. В корне — цели стенда; в `generator/` — цели
`make smoke`, `make check-clickhouse` и `make check-services` перемерены генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и
7 августа 2026 года на приёмке этапа 2; остальные три стоят с замера 6 августа. спрашивают его отдельно.
| Цель | Что утверждает | Стенд | Цена | Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено»:
|---|---|---|---| `make test`, `make smoke`, `make check-clickhouse` и `make check-services`
| `make lint` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | перемерены 7 августа 2026 года на приёмке этапа 2, корневой `make lint`
| `make typecheck` | Типы генератора сходятся (ty) | не нужен | 0,5 с | 9 августа при его появлении; остальные стоят с замера 6 августа.
| `make test` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
| `make config-test` | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | | Цель | Откуда | Что утверждает | Стенд | Цена |
| `make smoke` | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с | |---|---|---|---|---|
| `make check-clickhouse` | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, счёт событий стартового мира против описи | нужен | 8 с | | `make config-test` | корень | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make check-services` | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с | | `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, счёт событий стартового мира против описи | нужен | 8 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла
генератора, генераторная — ни одного файла стенда. Человек в корне видит
зелёное и может решить, что зелёный весь репозиторий.
Список правил у обеих один, а строгость нет: правила `UP` предлагают синтаксис
настолько новый, насколько позволяет `target-version`, — у генератора это 3.14
из `requires-python`, у стенда 3.10, младшая из версий Python в его образах.
Один и тот же файл может пройти корневую проверку и не пройти генераторную.
Довод и замер — в комментарии `ruff.toml`.
**Корневого `typecheck` нет, и это решение, а не пропуск.** Проверке типов мало
прочитать файл: чтобы понять `from airflow.sdk import dag`, ей нужен
установленный Airflow, а он живёт в образе, не на машине — 132 пакета ради двух
файлов пробников (замер 9 августа 2026 года, `uv pip compile`). Цена решения
называется честно: две настоящие находки `ty` в `dags/test_kafka.py` постоянного
сторожа не получают. Вопрос вернётся на этапе 5, когда придут настоящие даги:
тогда окружение окупится, и условие возврата — именно это, а не «стало
неудобно».
Цена самого `make up` — 2 м 50 с с нуля (`make clean` перед ним — ещё 23 с) и Цена самого `make up` — 2 м 50 с с нуля (`make clean` перед ним — ещё 23 с) и
1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той 1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той
@@ -74,8 +100,9 @@ ClickHouse отвечает сразу.
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` | | PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
| Приёмка этапа | `make up` с нуля и все три цели на стенде | | Приёмка этапа | `make up` с нуля и все три цели на стенде |
Правки генератора добавляют к этому `make lint`, `make typecheck` и Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
`make test`: стенд им не нужен, а `make test` из них самая дорогая. из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и Superset — корневой `make lint`.
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли
@@ -192,6 +219,15 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено ## Что проверено
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
от файла, и ближний выигрывает.
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля **Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: (`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. 18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
+1 -1
View File
@@ -2,7 +2,7 @@
Документ собран из контракта схемы генератора Документ собран из контракта схемы генератора
(`generator/src/clickstream_generator/schema.py`). Руками не править — (`generator/src/clickstream_generator/schema.py`). Руками не править —
пересобрать: `make docs`. пересобрать: `make docs` из каталога `generator/`.
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
+2 -1
View File
@@ -903,7 +903,8 @@
`KubernetesPodOperator`. Побочно это единственный вариант, при котором `KubernetesPodOperator`. Побочно это единственный вариант, при котором
«генератор — отдельная и заменяемая сущность» перестаёт быть словами: его «генератор — отдельная и заменяемая сущность» перестаёт быть словами: его
зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для
`make test`, `make lint` и `make typecheck`. Цена названа и принимается: целей генератора — `make test`, `make lint` и `make typecheck` из каталога
`generator/`. Цена названа и принимается:
чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это
доступ, равный root на хосте; на учебном стенде размен допустим, но доступ, равный root на хосте; на учебном стенде размен допустим, но
записывается уроком — в бою так не делают. Отклонено: *генератор в образе записывается уроком — в бою так не делают. Отклонено: *генератор в образе
+27
View File
@@ -0,0 +1,27 @@
# Цели генератора. Дверь у него своя, потому что своя и сущность: пакет с
# собственными `pyproject.toml`, `uv.lock` и образом, заменяемый целиком
# (спека генератора, раздел 1). Отсюда и короткие имена — каталог уже сказал,
# о ком речь, и суффикс `-generator` был бы вторым разом про то же.
.PHONY: lint typecheck test docs inventory
# --- Проверки ---
lint:
uv run ruff check
uv run ruff format --check
typecheck:
uv run ty check
test:
uv run pytest
# --- Сборка из кода ---
docs:
uv run python -m clickstream_generator.schema_doc \
../docs/formats/clickstream-event.md
inventory:
uv run python -m clickstream_generator.inventory \
../data/world-inventory.json
+7 -2
View File
@@ -92,12 +92,17 @@ D0 живёт предыстория, поэтому любой день соб
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`. Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`.
Из корня репозитория: Из этого каталога — цели генератора живут здесь, а не в корне:
- `make test` — тесты генератора; - `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат; - `make lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов; - `make typecheck` — ty: проверка типов;
- `make docs` — пересобрать описание выгрузки. - `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира.
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
Superset. Одноимённые цели за разными дверями охватывают разное:
[карта проверок](../docs/architecture/testing.md).
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
держится обещание побайтовой воспроизводимости (спека, раздел 2). держится обещание побайтовой воспроизводимости (спека, раздел 2).
@@ -26,7 +26,7 @@
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
именно в README репозитория). именно в README репозитория).
Собирается опись из корня репозитория целью `make inventory`, а свежесть её Собирается опись из каталога генератора целью `make inventory`, а свежесть её
сторожит тест как и у «описания выгрузки». сторожит тест как и у «описания выгрузки».
""" """
@@ -5,7 +5,7 @@
содержание берётся из контракта (`schema`), правится только там; свежесть содержание берётся из контракта (`schema`), правится только там; свежесть
документа сторожит тест. документа сторожит тест.
Запуск из корня репозитория целью `make docs`. Запуск из каталога генератора целью `make docs`.
""" """
import argparse import argparse
@@ -18,7 +18,7 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
Документ собран из контракта схемы генератора Документ собран из контракта схемы генератора
(`generator/src/clickstream_generator/schema.py`). Руками не править (`generator/src/clickstream_generator/schema.py`). Руками не править
пересобрать: `make docs`. пересобрать: `make docs` из каталога `generator/`.
Одно событие одна строка: хит по образцу облачной выгрузки Яндекс Метрики. Одно событие одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
+19
View File
@@ -0,0 +1,19 @@
# Настройка ruff для кода стенда — дагов и Superset. У генератора своя,
# в `generator/pyproject.toml`: конфиг ruff ищется вверх по дереву от файла,
# поэтому ближний к генератору выигрывает и этот его не подменяет.
#
# Здесь отдельный файл, а не `pyproject.toml`, потому что пакета в корне нет:
# корневой `pyproject.toml` завёл бы в репозитории второй проект `uv` — с
# локом и окружением, — которого стенду не нужно.
# На каком Python побежит код, ruff сам не знает: у генератора он берёт ответ
# из `requires-python`, а у стенда спрашивать некого — даги и Superset живут
# внутри образов. Замер 9 августа 2026 года: `apache/airflow:3.3.0` несёт
# Python 3.13.14, `apache/superset:6.1.0` — 3.10.20. Стоит младшая из двух,
# иначе ruff предложил бы Superset синтаксис, которого его Python не знает.
# Совпадает со значением ruff по умолчанию, и записано именно поэтому:
# сменится умолчание — проверка не поедет следом молча.
target-version = "py310"
[lint]
select = ["E", "F", "I", "UP", "B"]