diff --git a/AGENTS.md b/AGENTS.md index 7be781a..b812a73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,10 @@ Airflow) и названия из кода. Если для понятия ес ## Код и данные -- Python — только через `uv`; проверка и формат — `ruff` (`make lint`). +- Python — только через `uv`; проверка и формат — `ruff`. Цель `make lint` + есть за двумя дверями и охватывает разное: в корне — код стенда, в + `generator/` — код генератора. Правишь даги или Superset — зови корневую, + правишь генератор — зови из `generator/`. - Изменения держать минимальными и в границах задания. - Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md). diff --git a/Makefile b/Makefile index 03a5c18..2f5b8a4 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,10 @@ GENERATOR_DAY ?= 0 GENERATOR_LIMIT ?= 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`. +.PHONY: up down clean ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live + +# --- Жизнь стенда --- # Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод # целиком в шапке скрипта. @@ -23,33 +26,7 @@ ps: logs: $(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: COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh @@ -59,3 +36,26 @@ check-clickhouse: check-services: COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh + +# --- Проверки, которым стенд не нужен --- + +config-test: + COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh + +# Пути названы вслух: без них ruff из корня прошёлся бы и по генератору, а у +# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне +# нет, а форматтер между версиями меняет вывод — иначе проверка однажды +# покраснела бы сама, без единой правки в репозитории. +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)") diff --git a/README.md b/README.md index fc97785..a9e8475 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,8 @@ Prometheus, Grafana и общая база Postgres для метаданных. `grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` -и `29092`. Проверкам без стенда — `make config-test`, `make lint`, -`make typecheck`, `make test` — и сборкам `make docs` и `make inventory` нужен -`uv`. +и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне, +всем целям генератора в `generator/` — нужен `uv`. Сначала скопируйте настройки стенда: @@ -140,11 +139,11 @@ ClickHouse, пока мир не доедет. Повторный `make up` за событий и хеш его байтов. Опись отвечает на единственный вопрос: тот ли это мир, что был вчера. -Спрашивают её двое. `make test` сверяет опись с тем, что собирается из кода -сегодня: правка генератора меняет мир, и опись надо пересобрать — -`make inventory`. `make check-clickhouse` сверяет с описью то, что доехало до -`ods.event`, подневно. Хеш дня можно пересчитать и руками — это обычный -`sha256sum` файла, который пишет файловый приёмник: +Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что +собирается из кода сегодня: правка генератора меняет мир, и опись надо +пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет +с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и +руками — это обычный `sha256sum` файла, который пишет файловый приёмник: ```bash uv run --project generator python -m clickstream_generator batch \ @@ -326,6 +325,6 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф - [docs/research/](docs/research/) — исследования; сейчас это формат кликстрима Яндекса, по которому строится модель события. - [docs/formats/](docs/formats/) — описания форматов источников: по ним - пишется сторона хранилища. Собираются из кода командой `make docs`, руками - не правятся. + пишется сторона хранилища. Собираются из кода командой `make docs` в + `generator/`, руками не правятся. - [AGENTS.md](AGENTS.md) — контракт работы в репозитории. diff --git a/dags/test_clickhouse.py b/dags/test_clickhouse.py index 89e3f03..da31590 100644 --- a/dags/test_clickhouse.py +++ b/dags/test_clickhouse.py @@ -75,8 +75,7 @@ def _table_engines(client, source: str) -> list[tuple[str, str]]: def _drop_tables(client) -> None: client.command( - f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} " - f"ON CLUSTER {CLUSTER} SYNC" + f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} SYNC" ) client.command( f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC" @@ -97,7 +96,7 @@ def _assert_tables_absent(client) -> None: @dag( dag_id="test_clickhouse", schedule=None, - start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), catchup=False, tags=["проверка"], doc_md=__doc__, @@ -178,9 +177,7 @@ def test_clickhouse(): """ ).result_rows if len(node_2_rows) != 1: - raise RuntimeError( - f"не удалось определить имя ноды 2: {node_2_rows}" - ) + raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}") distributed_rows = client.query( f""" SELECT _shard_num, hostName(), marker diff --git a/dags/test_kafka.py b/dags/test_kafka.py index 7f081fd..0ea5f8c 100644 --- a/dags/test_kafka.py +++ b/dags/test_kafka.py @@ -77,7 +77,7 @@ class RecordAddress(NamedTuple): @dag( dag_id="test_kafka", schedule=None, - start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), catchup=False, tags=["проверка"], doc_md=__doc__, @@ -100,9 +100,7 @@ def test_kafka(): if error is not None: delivery_errors.append(str(error)) else: - addresses.append( - RecordAddress(message.partition(), message.offset()) - ) + addresses.append(RecordAddress(message.partition(), message.offset())) producer = Producer(PRODUCER_CONFIG) # produce() не пишет, а ставит сообщение в очередь: о судьбе записи diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index 67f6cb3..97c28c3 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -29,19 +29,47 @@ ## Карта целей -Стенд нужен трём целям из семи. Цена — замер, см. «Что проверено»: `make test`, -`make smoke`, `make check-clickhouse` и `make check-services` перемерены -7 августа 2026 года на приёмке этапа 2; остальные три стоят с замера 6 августа. +Цели живут за двумя дверями. В корне — цели стенда; в `generator/` — цели +генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и +спрашивают его отдельно. -| Цель | Что утверждает | Стенд | Цена | -|---|---|---|---| -| `make lint` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | -| `make typecheck` | Типы генератора сходятся (ty) | не нужен | 0,5 с | -| `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 check-services` | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с | +Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено»: +`make test`, `make smoke`, `make check-clickhouse` и `make check-services` +перемерены 7 августа 2026 года на приёмке этапа 2, корневой `make lint` — +9 августа при его появлении; остальные стоят с замера 6 августа. + +| Цель | Откуда | Что утверждает | Стенд | Цена | +|---|---|---|---|---| +| `make config-test` | корень | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | +| `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` две, и это не опечатка.** Одноимённые цели за +разными дверями охватывают разное: корневая не видит ни одного файла +генератора, генераторная — ни одного файла стенда. Человек в корне видит +зелёное и может решить, что зелёный весь репозиторий. + +Две тонкости, которых не видно из таблицы: + +- **Версия ruff у корневой цели закреплена в самом вызове** (`uvx ruff@0.16.1`) + и равна той, что в `generator/uv.lock`. Равенство держится руками: обновили + лок — поправьте и вызов, сверять их некому. +- **Охват держат аргументы вызова, а не устройство дерева.** Python, + положенный вне двух названных в таблице путей, не проверит ни одна из дверей. + +**Корневого `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 с) и 1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той @@ -74,8 +102,9 @@ ClickHouse отвечает сразу. | PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` | | Приёмка этапа | `make up` с нуля и все три цели на стенде | -Правки генератора добавляют к этому `make lint`, `make typecheck` и -`make test`: стенд им не нужен, а `make test` из них самая дорогая. +Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test` +из каталога `generator/`: стенд им не нужен, а `make test` из них самая +дорогая. Правки дагов и Superset — корневой `make lint`. Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли @@ -192,6 +221,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.** Подъём стенда с нуля (`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index ecf0686..6ec13dd 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -2,7 +2,7 @@ Документ собран из контракта схемы генератора (`generator/src/clickstream_generator/schema.py`). Руками не править — -пересобрать: `make docs`. +пересобрать: `make docs` из каталога `generator/`. Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index b7981c3..a66e7b5 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -903,7 +903,8 @@ `KubernetesPodOperator`. Побочно это единственный вариант, при котором «генератор — отдельная и заменяемая сущность» перестаёт быть словами: его зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для - `make test`, `make lint` и `make typecheck`. Цена названа и принимается: + целей генератора — `make test`, `make lint` и `make typecheck` из каталога + `generator/`. Цена названа и принимается: чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это доступ, равный root на хосте; на учебном стенде размен допустим, но записывается уроком — в бою так не делают. Отклонено: *генератор в образе diff --git a/generator/Makefile b/generator/Makefile new file mode 100644 index 0000000..4097a78 --- /dev/null +++ b/generator/Makefile @@ -0,0 +1,23 @@ +.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 diff --git a/generator/README.md b/generator/README.md index 61250b4..6737fd5 100644 --- a/generator/README.md +++ b/generator/README.md @@ -92,12 +92,17 @@ D0 живёт предыстория, поэтому любой день соб Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`. -Из корня репозитория: +Из этого каталога — цели генератора живут здесь, а не в корне: - `make test` — тесты генератора; - `make lint` — ruff: проверка и формат; - `make typecheck` — ty: проверка типов; -- `make docs` — пересобрать описание выгрузки. +- `make docs` — пересобрать описание выгрузки; +- `make inventory` — пересобрать опись мира. + +В корне репозитория `make lint` тоже есть, но он про код стенда — даги и +Superset. Одноимённые цели за разными дверями охватывают разное: +[карта проверок](../docs/architecture/testing.md). Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом держится обещание побайтовой воспроизводимости (спека, раздел 2). diff --git a/generator/src/clickstream_generator/inventory.py b/generator/src/clickstream_generator/inventory.py index 57a58fa..0291acd 100644 --- a/generator/src/clickstream_generator/inventory.py +++ b/generator/src/clickstream_generator/inventory.py @@ -26,7 +26,7 @@ пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как именно — в README репозитория). -Собирается опись из корня репозитория целью `make inventory`, а свежесть её +Собирается опись из каталога генератора целью `make inventory`, а свежесть её сторожит тест — как и у «описания выгрузки». """ diff --git a/generator/src/clickstream_generator/schema_doc.py b/generator/src/clickstream_generator/schema_doc.py index 277dff1..befe95c 100644 --- a/generator/src/clickstream_generator/schema_doc.py +++ b/generator/src/clickstream_generator/schema_doc.py @@ -5,7 +5,7 @@ содержание берётся из контракта (`schema`), правится только там; свежесть документа сторожит тест. -Запуск — из корня репозитория целью `make docs`. +Запуск — из каталога генератора целью `make docs`. """ import argparse @@ -18,7 +18,7 @@ PREAMBLE = """# Описание выгрузки: событие кликстр Документ собран из контракта схемы генератора (`generator/src/clickstream_generator/schema.py`). Руками не править — -пересобрать: `make docs`. +пересобрать: `make docs` из каталога `generator/`. Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..eac65e3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,19 @@ +# Настройка ruff для кода стенда — дагов и Superset. У генератора своя, +# в `generator/pyproject.toml`: конфиг ruff ищется вверх по дереву от файла, +# поэтому ближний к генератору выигрывает и этот его не подменяет. +# +# Отдельный файл, а не `pyproject.toml`: пакета в корне нет и заводить его +# ради настройки линтера незачем. + +# На каком Python побежит код, ruff сам не знает: у генератора он берёт ответ +# из `requires-python`, а у стенда спрашивать некого — даги и Superset живут +# внутри образов. Стоит версия дагов: их читают и правят, и идиомы им нужны +# те, на которых они работают. Замер 9 августа 2026 года: +# `apache/airflow:3.3.0` несёт Python 3.13.14, `apache/superset:6.1.0` — 3.10.20. +# Настройке Superset от этого пока ни холодно ни жарко: модернизировать в ней +# нечего. Предложит ruff что-то не по её возрасту — заведём исключение тогда, +# с настоящим поводом. +target-version = "py313" + +[lint] +select = ["E", "F", "I", "UP", "B"]