From 2890c7f9fb3657a93bbd5c365e2d9580e4062314 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 22:22:14 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(generator):=20=D0=BA=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=D1=81=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - этап 2 начинается с формы: контракт схемы — источник истины и для генерации событий, и для DDL хранилища, а имена пакета и модулей задают границы всем следующим тикетам этапа. - Что: - заведён uv-проект generator/ (pyproject.toml и uv.lock в git; numpy, pytest и ruff), пакет clickstream_generator. - schema.py — контракт: чистые данные о 47 колонках выгрузки (имя Метрики, тип ClickHouse, тип numpy, имя для DDS, группа); порядок несёт сам кортеж COLUMNS, отдельного поля с номером нет намеренно. - schema_doc.py собирает из контракта описание выгрузки docs/formats/clickstream-event.md — по нему пишется сторона хранилища; документ руками не правится. - тесты: инварианты контракта (состав, уникальность, заполненность, согласие типов и порядок групп) и свежесть описания выгрузки. - цели make lint, make test и make docs; README, AGENTS.md и CONTEXT.md дополнены генератором, форматами и словарной статьёй. - Проверка: - make test (248 тестов), make lint, make config-test; - make docs, затем git diff --exit-code docs/ — пусто. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 6 +- CONTEXT.md | 6 + Makefile | 12 +- README.md | 13 +- docs/formats/clickstream-event.md | 94 ++++ generator/README.md | 28 ++ generator/pyproject.toml | 22 + .../src/clickstream_generator/__init__.py | 6 + generator/src/clickstream_generator/schema.py | 439 ++++++++++++++++++ .../src/clickstream_generator/schema_doc.py | 81 ++++ generator/tests/test_schema.py | 100 ++++ generator/tests/test_schema_doc.py | 57 +++ generator/uv.lock | 141 ++++++ 13 files changed, 1001 insertions(+), 4 deletions(-) create mode 100644 docs/formats/clickstream-event.md create mode 100644 generator/README.md create mode 100644 generator/pyproject.toml create mode 100644 generator/src/clickstream_generator/__init__.py create mode 100644 generator/src/clickstream_generator/schema.py create mode 100644 generator/src/clickstream_generator/schema_doc.py create mode 100644 generator/tests/test_schema.py create mode 100644 generator/tests/test_schema_doc.py create mode 100644 generator/uv.lock diff --git a/AGENTS.md b/AGENTS.md index bc4bf61..f39838a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Airflow) и названия из кода. Если для понятия ес ## Код и данные -- Python — только через `uv`. +- Python — только через `uv`; проверка и формат — `ruff` (`make lint`). - Изменения держать минимальными и в границах задания. - Секреты не коммитить: настройки — через `.env`, образец — `.env.example`. - При изменении инфраструктуры или DDL обновлять документацию тем же PR. @@ -118,6 +118,10 @@ Airflow) и названия из кода. Если для понятия ес (`2026-07-30-stand-v2-realism.md`). Дата фиксирует, когда документ появился, и при правках не меняется: файлы сортируются по времени, а история живёт в git. +- Имена файлов в `docs/formats/` — слаг строчными латинскими буквами через + дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие + истории, а текущее описание живого формата — и собирается из кода, а не + пишется руками. - Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата в имени не нужна. diff --git a/CONTEXT.md b/CONTEXT.md index 56eea55..a77be65 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,6 +43,12 @@ Python-модуль с описателями колонок события — Из него выводятся генератор, валидация и документация формата; хранилище строится по документации, не по модулю. +**Описание выгрузки**: +Публичная документация формата события: таблица колонок, собранная из +контракта схемы. По ней пишется сторона хранилища — как в бою по документации +источника. Правится только контракт, документ пересобирается. +_Избегать_: описание схемы, документация контракта + **Канонический сериализатор**: Единственное место, где событие превращается в байты. Фиксированный порядок ключей и строк — основа побайтовой воспроизводимости. diff --git a/Makefile b/Makefile index bd5e9ce..f643909 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ COMPOSE ?= docker compose -.PHONY: up down clean ps logs config-test smoke smoke-cluster smoke-guards +.PHONY: up down clean ps logs config-test lint test docs smoke smoke-cluster smoke-guards up: $(COMPOSE) up --detach --build --wait --wait-timeout 600 @@ -21,6 +21,16 @@ config-test: COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh ./tests/stand-smoke-static.sh +lint: + cd generator && uv run ruff check && uv run ruff format --check + +test: + cd generator && uv run pytest + +docs: + cd generator && uv run python -m clickstream_generator.schema_doc \ + ../docs/formats/clickstream-event.md + smoke: COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh diff --git a/README.md b/README.md index 15fd6e7..72250e5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ [«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md). Сейчас работают кластер ClickHouse из двух шардов, отдельный clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset, -Prometheus, Grafana и общая база Postgres для метаданных. +Prometheus, Grafana и общая база Postgres для метаданных. Начат генератор +кликстрима: в [`generator/`](generator/) заведён контракт схемы события, из +которого собрано [описание выгрузки](docs/formats/clickstream-event.md). ## Быстрый старт @@ -24,7 +26,8 @@ Prometheus, Grafana и общая база Postgres для метаданных. Полная проверка также использует `curl`, `jq`, `awk`, `grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` -и `29092`. Для статической проверки `make config-test` нужен `uv`. +и `29092`. Проверкам без стенда — `make config-test`, `make lint`, +`make test` — и сборке документации `make docs` нужен `uv`. Стенд запускается без `.env`: @@ -84,6 +87,9 @@ Superset, создаёт администратора и импортирует - `make config-test` — секунды, стенд поднимать не нужно. Видит только то, что есть в файлах, и о работоспособности не говорит ничего. Дёшево настолько, что можно гонять перед каждым коммитом. +- `make lint` и `make test` — тоже секунды и тоже без стенда, но про другой + код: ruff и тесты генератора в `generator/`. Тесты сторожат контракт схемы + события и свежесть собранного из него описания выгрузки. - `make smoke` — минута-две на поднятом стенде. Дороже, но проверяет связи между службами, а не отдельные файлы: это интеграционная проверка. - `make smoke-cluster` — около минуты. Одна связь, зато до дна: межнодовое @@ -200,4 +206,7 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф - [docs/specs/](docs/specs/) — спеки: источник истины о задуманном. - [docs/research/](docs/research/) — исследования; сейчас это формат кликстрима Яндекса, по которому строится модель события. +- [docs/formats/](docs/formats/) — описания форматов источников: по ним + пишется сторона хранилища. Собираются из кода командой `make docs`, руками + не правятся. - [AGENTS.md](AGENTS.md) — контракт работы в репозитории. diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md new file mode 100644 index 0000000..c6dc176 --- /dev/null +++ b/docs/formats/clickstream-event.md @@ -0,0 +1,94 @@ +# Описание выгрузки: событие кликстрима + +Документ собран из контракта схемы генератора +(`generator/src/clickstream_generator/schema.py`). Руками не править — +пересобрать: `make docs`. + +Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. +Многозначное лежит в параллельных массивах одной длины, плюс одно сырое +JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты +собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. + +Имена и типы колонок — стороны источника. Хранилище принимает их как есть и +нормализует у себя: своё snake_case-имя каждой колонки ждёт в столбце «Имя в +DDS». Столбец «Тип numpy» показывает, чем колонка представлена внутри +генератора; у массивов это тип элемента. Номер — место колонки в выгрузке: +порядок задан контрактом. + +Колонки группы «Ecommerce» заполнены только у торговых событий: +`add_to_cart` несёт один товар, `purchase` — состав заказа и блок +`purchase*`. У остальных событий они пусты. + +Всего колонок: 47. + +## Идентификаторы и время + +| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +|---|---|---|---|---|---| +| 1 | `WatchID` | `UInt64` | `uint64` | `watch_id` | id события — хита; держится ниже 2^53, выше числа в JSON округляются | +| 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни | +| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется | +| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один | +| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события; по ней режется партиция | +| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики | +| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах | +| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет | +| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт | + +## Страница и атрибуция + +| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +|---|---|---|---|---|---| +| 10 | `URL` | `String` | `object` | `url` | адрес страницы события | +| 11 | `Referer` | `String` | `object` | `referer` | адрес, с которого посетитель пришёл на страницу | +| 12 | `Title` | `String` | `object` | `title` | заголовок страницы | +| 13 | `UTMSource` | `String` | `object` | `utm_source` | метка utm_source: площадка перехода | +| 14 | `UTMMedium` | `String` | `object` | `utm_medium` | метка utm_medium: тип трафика | +| 15 | `UTMCampaign` | `String` | `object` | `utm_campaign` | метка utm_campaign: рекламная кампания | +| 16 | `UTMContent` | `String` | `object` | `utm_content` | метка utm_content: что различает объявления одной кампании | +| 17 | `UTMTerm` | `String` | `object` | `utm_term` | метка utm_term: ключевое слово перехода | +| 18 | `LastTrafficSource` | `String` | `object` | `last_traffic_source` | последний источник трафика: direct, organic, ad, referral | +| 19 | `HasGCLID` | `UInt8` | `uint8` | `has_gclid` | 1, если в адресе была метка Google Ads | +| 20 | `YCLID` | `UInt64` | `uint64` | `yclid` | идентификатор клика Яндекс Директа; 0 — метки не было | + +## Браузер, устройство, гео + +| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +|---|---|---|---|---|---| +| 21 | `Browser` | `String` | `object` | `browser` | браузер посетителя | +| 22 | `BrowserMajorVersion` | `UInt16` | `uint16` | `browser_major_version` | старшая версия браузера | +| 23 | `BrowserLanguage` | `String` | `object` | `browser_language` | язык браузера | +| 24 | `OperatingSystem` | `String` | `object` | `operating_system` | операционная система с версией | +| 25 | `OperatingSystemRoot` | `String` | `object` | `operating_system_root` | семейство операционной системы, без версии | +| 26 | `DeviceCategory` | `UInt8` | `uint8` | `device_category` | тип устройства кодами 1–4, как у Метрики; у неё это строка — отступление стенда | +| 27 | `MobilePhoneModel` | `String` | `object` | `mobile_phone_model` | модель телефона; на десктопе пусто | +| 28 | `ScreenWidth` | `UInt16` | `uint16` | `screen_width` | ширина экрана в пикселях | +| 29 | `ScreenHeight` | `UInt16` | `uint16` | `screen_height` | высота экрана в пикселях | +| 30 | `IPAddress` | `String` | `object` | `ip_address` | IP-адрес посетителя | +| 31 | `RegionCountry` | `String` | `object` | `region_country` | страна кодом ISO | +| 32 | `RegionCity` | `String` | `object` | `region_city` | город, название по-английски | +| 33 | `RegionCountryID` | `UInt32` | `uint32` | `region_country_id` | числовой id страны в справочнике регионов Яндекса | +| 34 | `RegionCityID` | `UInt32` | `uint32` | `region_city_id` | числовой id города в том же справочнике | + +## Массивы и параметры + +| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +|---|---|---|---|---|---| +| 35 | `GoalsReached` | `Array(UInt32)` | `uint32` | `goals_reached` | id достигнутых целей; на стенде их две — корзина и покупка | +| 36 | `ParsedParamsKey1` | `Array(String)` | `object` | `parsed_params_key1` | свои параметры сайта, один уровень — например вариант A/B-теста | + +## Ecommerce + +| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +|---|---|---|---|---|---| +| 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент | +| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; Float64, как у Метрики — на этом держится урок о расхождениях с бэкендом | +| 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа | +| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён | +| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события | +| 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров | +| 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров | +| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми | +| 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара | +| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: detail, add, remove, purchase | +| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки | diff --git a/generator/README.md b/generator/README.md new file mode 100644 index 0000000..d74b72c --- /dev/null +++ b/generator/README.md @@ -0,0 +1,28 @@ +# Генератор кликстрима + +Клиентская сторона стенда: отсюда берётся поток событий — широкое событие по +образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения — +спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md). + +Пока здесь каркас проекта и его сердце — контракт схемы события. + +## Что где лежит + +- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о + колонках выгрузки. Собственность генератора; из него выводятся сам + генератор, его валидация и описание выгрузки в доках. +- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки» + ([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md)) + из контракта. Документ руками не правят — пересобирают. +- `tests/` — инварианты контракта и свежесть описания. + +## Команды + +Из корня репозитория: + +- `make test` — тесты генератора; +- `make lint` — ruff: проверка и формат; +- `make docs` — пересобрать описание выгрузки. + +Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом +держится обещание побайтовой воспроизводимости (спека, раздел 2). diff --git a/generator/pyproject.toml b/generator/pyproject.toml new file mode 100644 index 0000000..4002d51 --- /dev/null +++ b/generator/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "clickstream-generator" +version = "0.1.0" +description = "Генератор кликстрима учебного стенда: контракт схемы события и модельный мир" +readme = "README.md" +# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости +# держится на зафиксированных версиях (спека генератора, раздел 2). +requires-python = ">=3.14,<3.15" +dependencies = ["numpy>=2"] + +[dependency-groups] +dev = ["pytest>=8", "ruff>=0.15"] + +[build-system] +requires = ["uv_build>=0.11.21,<0.12.0"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/generator/src/clickstream_generator/__init__.py b/generator/src/clickstream_generator/__init__.py new file mode 100644 index 0000000..f259a83 --- /dev/null +++ b/generator/src/clickstream_generator/__init__.py @@ -0,0 +1,6 @@ +"""Генератор кликстрима учебного стенда. + +Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках +выгрузки. Из него выводятся сам генератор, его валидация и «описание +выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию. +""" diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py new file mode 100644 index 0000000..8077021 --- /dev/null +++ b/generator/src/clickstream_generator/schema.py @@ -0,0 +1,439 @@ +"""Контракт схемы события: чистые данные о колонках выгрузки, никакой логики. + +Состав, имена и типы решены мастер-спекой «Боевой реализм стенда» (разделы +1.1–1.2) и здесь не переоткрываются — модуль записывает их машинно-читаемо. +Контракт принадлежит генератору и кормит трёх потребителей: сам генератор, +его валидацию и «описание выгрузки» в доках (`schema_doc`). Хранилище +строится по описанию, а не по модулю; границу сторожит contract-тест, +сверяющий `system.columns` поднятого стенда с этим контрактом. + +Что несёт описатель колонки: + +- `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит + его без изменений. +- `clickhouse_type` — тип в хранилище ровно в той записи, в какой его вернёт + `system.columns`. +- `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это + тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной + длины стенду ничего не дают. +- `dds_name` — наше snake_case-имя, под которым колонка появится в DDS. + Перевод механический, акроним идёт одним куском (`utm_source`, `has_gclid`, + `ip_address`); единственное исключение — `client_timezone`: «timezone» + пишем одним словом. +- `group` — раздел описания выгрузки; колонки одной группы идут подряд. +- `comment` — строка описания для менти, попадает в документ как есть. + +Порядок колонок несёт сам кортеж `COLUMNS` — он и есть порядок выгрузки, по +нему считается нумерация в документе. Отдельного поля с номером намеренно +нет: два источника порядка разъезжаются при первой же вставке колонки +в середину. +""" + +from dataclasses import dataclass +from enum import Enum + + +class ColumnGroup(Enum): + """Разделы описания выгрузки; порядок объявления — порядок в документе.""" + + IDENTIFIERS = "Идентификаторы и время" + PAGE = "Страница и атрибуция" + CLIENT = "Браузер, устройство, гео" + PARAMS = "Массивы и параметры" + ECOMMERCE = "Ecommerce" + + +@dataclass(frozen=True, slots=True) +class Column: + """Описатель одной колонки выгрузки.""" + + name: str + clickhouse_type: str + numpy_dtype: str + dds_name: str + group: ColumnGroup + comment: str + + +COLUMNS: tuple[Column, ...] = ( + Column( + name="WatchID", + clickhouse_type="UInt64", + numpy_dtype="uint64", + dds_name="watch_id", + group=ColumnGroup.IDENTIFIERS, + comment="id события — хита; держится ниже 2^53, выше числа в JSON округляются", + ), + Column( + name="VisitID", + clickhouse_type="UInt64", + numpy_dtype="uint64", + dds_name="visit_id", + group=ColumnGroup.IDENTIFIERS, + comment="id визита от генератора — эталон лабы: собери сессии сам и сравни", + ), + Column( + name="ClientID", + clickhouse_type="UInt64", + numpy_dtype="uint64", + dds_name="client_id", + group=ColumnGroup.IDENTIFIERS, + comment="анонимный id браузера — кука; по хешу от неё таблица шардируется", + ), + Column( + name="CounterID", + clickhouse_type="UInt32", + numpy_dtype="uint32", + dds_name="counter_id", + group=ColumnGroup.IDENTIFIERS, + comment="id счётчика: на стенде константа, сайт один", + ), + Column( + name="EventDate", + clickhouse_type="Date", + numpy_dtype="datetime64[D]", + dds_name="event_date", + group=ColumnGroup.IDENTIFIERS, + comment="дата события; по ней режется партиция", + ), + Column( + name="UTCEventTime", + clickhouse_type="DateTime", + numpy_dtype="datetime64[s]", + dds_name="utc_event_time", + group=ColumnGroup.IDENTIFIERS, + comment="время события в UTC — единственная метка времени, как у Метрики", + ), + Column( + name="ClientTimeZone", + clickhouse_type="Int16", + numpy_dtype="int16", + dds_name="client_timezone", + group=ColumnGroup.IDENTIFIERS, + comment="смещение часового пояса клиента от UTC, в минутах", + ), + Column( + name="EventType", + clickhouse_type="LowCardinality(String)", + numpy_dtype="object", + dds_name="event_type", + group=ColumnGroup.IDENTIFIERS, + comment="тип события: pageview, add_to_cart, purchase — добавка стенда," + " у Метрики такого поля нет", + ), + Column( + name="Sign", + clickhouse_type="Int8", + numpy_dtype="int8", + dds_name="sign", + group=ColumnGroup.IDENTIFIERS, + comment="всегда 1: колонка формата, исправлений записей генератор не шлёт", + ), + Column( + name="URL", + clickhouse_type="String", + numpy_dtype="object", + dds_name="url", + group=ColumnGroup.PAGE, + comment="адрес страницы события", + ), + Column( + name="Referer", + clickhouse_type="String", + numpy_dtype="object", + dds_name="referer", + group=ColumnGroup.PAGE, + comment="адрес, с которого посетитель пришёл на страницу", + ), + Column( + name="Title", + clickhouse_type="String", + numpy_dtype="object", + dds_name="title", + group=ColumnGroup.PAGE, + comment="заголовок страницы", + ), + Column( + name="UTMSource", + clickhouse_type="String", + numpy_dtype="object", + dds_name="utm_source", + group=ColumnGroup.PAGE, + comment="метка utm_source: площадка перехода", + ), + Column( + name="UTMMedium", + clickhouse_type="String", + numpy_dtype="object", + dds_name="utm_medium", + group=ColumnGroup.PAGE, + comment="метка utm_medium: тип трафика", + ), + Column( + name="UTMCampaign", + clickhouse_type="String", + numpy_dtype="object", + dds_name="utm_campaign", + group=ColumnGroup.PAGE, + comment="метка utm_campaign: рекламная кампания", + ), + Column( + name="UTMContent", + clickhouse_type="String", + numpy_dtype="object", + dds_name="utm_content", + group=ColumnGroup.PAGE, + comment="метка utm_content: что различает объявления одной кампании", + ), + Column( + name="UTMTerm", + clickhouse_type="String", + numpy_dtype="object", + dds_name="utm_term", + group=ColumnGroup.PAGE, + comment="метка utm_term: ключевое слово перехода", + ), + Column( + name="LastTrafficSource", + clickhouse_type="String", + numpy_dtype="object", + dds_name="last_traffic_source", + group=ColumnGroup.PAGE, + comment="последний источник трафика: direct, organic, ad, referral", + ), + Column( + name="HasGCLID", + clickhouse_type="UInt8", + numpy_dtype="uint8", + dds_name="has_gclid", + group=ColumnGroup.PAGE, + comment="1, если в адресе была метка Google Ads", + ), + Column( + name="YCLID", + clickhouse_type="UInt64", + numpy_dtype="uint64", + dds_name="yclid", + group=ColumnGroup.PAGE, + comment="идентификатор клика Яндекс Директа; 0 — метки не было", + ), + Column( + name="Browser", + clickhouse_type="String", + numpy_dtype="object", + dds_name="browser", + group=ColumnGroup.CLIENT, + comment="браузер посетителя", + ), + Column( + name="BrowserMajorVersion", + clickhouse_type="UInt16", + numpy_dtype="uint16", + dds_name="browser_major_version", + group=ColumnGroup.CLIENT, + comment="старшая версия браузера", + ), + Column( + name="BrowserLanguage", + clickhouse_type="String", + numpy_dtype="object", + dds_name="browser_language", + group=ColumnGroup.CLIENT, + comment="язык браузера", + ), + Column( + name="OperatingSystem", + clickhouse_type="String", + numpy_dtype="object", + dds_name="operating_system", + group=ColumnGroup.CLIENT, + comment="операционная система с версией", + ), + Column( + name="OperatingSystemRoot", + clickhouse_type="String", + numpy_dtype="object", + dds_name="operating_system_root", + group=ColumnGroup.CLIENT, + comment="семейство операционной системы, без версии", + ), + Column( + name="DeviceCategory", + clickhouse_type="UInt8", + numpy_dtype="uint8", + dds_name="device_category", + group=ColumnGroup.CLIENT, + comment="тип устройства кодами 1–4, как у Метрики; у неё это строка —" + " отступление стенда", + ), + Column( + name="MobilePhoneModel", + clickhouse_type="String", + numpy_dtype="object", + dds_name="mobile_phone_model", + group=ColumnGroup.CLIENT, + comment="модель телефона; на десктопе пусто", + ), + Column( + name="ScreenWidth", + clickhouse_type="UInt16", + numpy_dtype="uint16", + dds_name="screen_width", + group=ColumnGroup.CLIENT, + comment="ширина экрана в пикселях", + ), + Column( + name="ScreenHeight", + clickhouse_type="UInt16", + numpy_dtype="uint16", + dds_name="screen_height", + group=ColumnGroup.CLIENT, + comment="высота экрана в пикселях", + ), + Column( + name="IPAddress", + clickhouse_type="String", + numpy_dtype="object", + dds_name="ip_address", + group=ColumnGroup.CLIENT, + comment="IP-адрес посетителя", + ), + Column( + name="RegionCountry", + clickhouse_type="String", + numpy_dtype="object", + dds_name="region_country", + group=ColumnGroup.CLIENT, + comment="страна кодом ISO", + ), + Column( + name="RegionCity", + clickhouse_type="String", + numpy_dtype="object", + dds_name="region_city", + group=ColumnGroup.CLIENT, + comment="город, название по-английски", + ), + Column( + name="RegionCountryID", + clickhouse_type="UInt32", + numpy_dtype="uint32", + dds_name="region_country_id", + group=ColumnGroup.CLIENT, + comment="числовой id страны в справочнике регионов Яндекса", + ), + Column( + name="RegionCityID", + clickhouse_type="UInt32", + numpy_dtype="uint32", + dds_name="region_city_id", + group=ColumnGroup.CLIENT, + comment="числовой id города в том же справочнике", + ), + Column( + name="GoalsReached", + clickhouse_type="Array(UInt32)", + numpy_dtype="uint32", + dds_name="goals_reached", + group=ColumnGroup.PARAMS, + comment="id достигнутых целей; на стенде их две — корзина и покупка", + ), + Column( + name="ParsedParamsKey1", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="parsed_params_key1", + group=ColumnGroup.PARAMS, + comment="свои параметры сайта, один уровень — например вариант A/B-теста", + ), + Column( + name="purchaseID", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="purchase_id", + group=ColumnGroup.ECOMMERCE, + comment="номер заказа; у события purchase — один элемент", + ), + Column( + name="purchaseRevenue", + clickhouse_type="Array(Float64)", + numpy_dtype="float64", + dds_name="purchase_revenue", + group=ColumnGroup.ECOMMERCE, + comment="выручка заказа глазами клиента; Float64, как у Метрики —" + " на этом держится урок о расхождениях с бэкендом", + ), + Column( + name="purchaseCurrency", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="purchase_currency", + group=ColumnGroup.ECOMMERCE, + comment="валюта заказа", + ), + Column( + name="purchaseCoupon", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="purchase_coupon", + group=ColumnGroup.ECOMMERCE, + comment="купон заказа, если был применён", + ), + Column( + name="productID", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="product_id", + group=ColumnGroup.ECOMMERCE, + comment="id товаров события", + ), + Column( + name="productName", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="product_name", + group=ColumnGroup.ECOMMERCE, + comment="названия тех же товаров", + ), + Column( + name="productCategory", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="product_category", + group=ColumnGroup.ECOMMERCE, + comment="категории тех же товаров", + ), + Column( + name="productPrice", + clickhouse_type="Array(Int64)", + numpy_dtype="int64", + dds_name="product_price", + group=ColumnGroup.ECOMMERCE, + comment="цена за штуку целым числом: деньги генератор считает целыми", + ), + Column( + name="productQuantity", + clickhouse_type="Array(UInt64)", + numpy_dtype="uint64", + dds_name="product_quantity", + group=ColumnGroup.ECOMMERCE, + comment="количество штук каждого товара", + ), + Column( + name="productEventType", + clickhouse_type="Array(String)", + numpy_dtype="object", + dds_name="product_event_type", + group=ColumnGroup.ECOMMERCE, + comment="действие с товаром: detail, add, remove, purchase", + ), + Column( + name="ecommerce", + clickhouse_type="String", + numpy_dtype="object", + dds_name="ecommerce", + group=ColumnGroup.ECOMMERCE, + comment="сырой JSON события, как отдаёт Метрика — материал лабы" + " про разбор JSON внутри колонки", + ), +) diff --git a/generator/src/clickstream_generator/schema_doc.py b/generator/src/clickstream_generator/schema_doc.py new file mode 100644 index 0000000..a35621d --- /dev/null +++ b/generator/src/clickstream_generator/schema_doc.py @@ -0,0 +1,81 @@ +"""Сборка «описания выгрузки» — публичной документации формата события. + +Аналог документации Метрики: по нему пишется сторона хранилища (DDL, матвью, +витрины), поэтому документ должен читаться сам по себе, без чтения кода. Всё +содержание берётся из контракта (`schema`), правится только там; свежесть +документа сторожит тест. + +Запуск — из корня репозитория целью `make docs`. +""" + +import argparse +from collections.abc import Sequence +from itertools import groupby +from pathlib import Path + +from clickstream_generator.schema import COLUMNS, Column + +PREAMBLE = """# Описание выгрузки: событие кликстрима + +Документ собран из контракта схемы генератора +(`generator/src/clickstream_generator/schema.py`). Руками не править — +пересобрать: `make docs`. + +Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. +Многозначное лежит в параллельных массивах одной длины, плюс одно сырое +JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты +собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. + +Имена и типы колонок — стороны источника. Хранилище принимает их как есть и +нормализует у себя: своё snake_case-имя каждой колонки ждёт в столбце «Имя в +DDS». Столбец «Тип numpy» показывает, чем колонка представлена внутри +генератора; у массивов это тип элемента. Номер — место колонки в выгрузке: +порядок задан контрактом. + +Колонки группы «Ecommerce» заполнены только у торговых событий: +`add_to_cart` несёт один товар, `purchase` — состав заказа и блок +`purchase*`. У остальных событий они пусты. + +Всего колонок: {count}.""" + +TABLE_HEADER = ( + "| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий |", + "|---|---|---|---|---|---|", +) + + +def render(columns: Sequence[Column] = COLUMNS) -> str: + """Собирает документ целиком: преамбула и таблица колонок по группам.""" + lines = PREAMBLE.format(count=len(columns)).splitlines() + numbers = iter(range(1, len(columns) + 1)) + for group, columns_of_group in groupby(columns, key=lambda column: column.group): + lines += ["", f"## {group.value}", "", *TABLE_HEADER] + lines += [row(next(numbers), column) for column in columns_of_group] + return "\n".join(lines) + "\n" + + +def row(number: int, column: Column) -> str: + """Строка таблицы; номер — место колонки в порядке выгрузки.""" + cells = ( + str(number), + f"`{column.name}`", + f"`{column.clickhouse_type}`", + f"`{column.numpy_dtype}`", + f"`{column.dds_name}`", + column.comment, + ) + return "| " + " | ".join(cells) + " |" + + +def main(argv: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Собирает описание выгрузки из контракта схемы события." + ) + parser.add_argument("output", type=Path, help="путь к файлу описания") + output = parser.parse_args(argv).output + output.write_text(render(), encoding="utf-8") + print(f"Описание выгрузки собрано: {output}") + + +if __name__ == "__main__": + main() diff --git a/generator/tests/test_schema.py b/generator/tests/test_schema.py new file mode 100644 index 0000000..791b569 --- /dev/null +++ b/generator/tests/test_schema.py @@ -0,0 +1,100 @@ +"""Инварианты контракта схемы события. + +Контракт — чистые данные, поэтому проверять в нём нечего кроме связности: +состав, уникальность имён, заполненность полей, согласие типов и порядок. +Это и есть сторож границы «трекер | хранилище»: молчаливый дрейф колонок +ловится здесь, а не в DDL через неделю. +""" + +import re + +import numpy as np +import pytest + +from clickstream_generator.schema import COLUMNS, Column, ColumnGroup + +# Состав решён мастер-спекой (раздел 1.2) и в этом тикете не переоткрывается. +EXPECTED_COLUMN_COUNT = 47 + +# Соответствие «тип ClickHouse — тип numpy», записанное независимо от +# контракта: если пара в контракте разъедется, сойтись они уже не смогут. +NUMPY_BY_CLICKHOUSE_TYPE = { + "UInt8": "uint8", + "UInt16": "uint16", + "UInt32": "uint32", + "UInt64": "uint64", + "Int8": "int8", + "Int16": "int16", + "Int64": "int64", + "Float64": "float64", + "String": "object", + "LowCardinality(String)": "object", + "Date": "datetime64[D]", + "DateTime": "datetime64[s]", +} + +METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") +DDS_NAME = re.compile(r"^[a-z][a-z0-9_]*$") +ARRAY_TYPE = re.compile(r"^Array\((.+)\)$") + + +def element_type(clickhouse_type: str) -> str: + """Тип элемента: у массива — то, что внутри `Array(...)`, иначе сам тип.""" + array = ARRAY_TYPE.match(clickhouse_type) + return array.group(1) if array else clickhouse_type + + +def test_columns_are_an_immutable_sequence(): + assert isinstance(COLUMNS, tuple) + + +def test_column_count(): + assert len(COLUMNS) == EXPECTED_COLUMN_COUNT + + +def test_metrica_names_are_unique(): + names = [column.name for column in COLUMNS] + assert len(set(names)) == len(names) + + +def test_dds_names_are_unique(): + names = [column.dds_name for column in COLUMNS] + assert len(set(names)) == len(names) + + +@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) +def test_attributes_are_filled(column: Column): + assert column.name.strip() + assert column.clickhouse_type.strip() + assert column.numpy_dtype.strip() + assert column.dds_name.strip() + assert column.comment.strip() + assert isinstance(column.group, ColumnGroup) + + +@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) +def test_names_keep_their_styles(column: Column): + assert METRICA_NAME.match(column.name), "имя источника — как в выгрузке Метрики" + assert DDS_NAME.match(column.dds_name), "имя для DDS — snake_case" + + +@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) +def test_numpy_dtype_exists(column: Column): + assert np.dtype(column.numpy_dtype).name == column.numpy_dtype + + +@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) +def test_numpy_dtype_matches_clickhouse_type(column: Column): + expected = NUMPY_BY_CLICKHOUSE_TYPE.get(element_type(column.clickhouse_type)) + assert expected is not None, f"незнакомый тип ClickHouse: {column.clickhouse_type}" + assert column.numpy_dtype == expected + + +def test_groups_go_in_runs_and_in_order(): + """Группы не чередуются: каждая идёт одним куском, куски — по объявлению.""" + seen = [] + for column in COLUMNS: + if not seen or seen[-1] is not column.group: + assert column.group not in seen, f"группа {column.group.name} разорвана" + seen.append(column.group) + assert seen == list(ColumnGroup), "порядок групп разошёлся с их объявлением" diff --git a/generator/tests/test_schema_doc.py b/generator/tests/test_schema_doc.py new file mode 100644 index 0000000..3132964 --- /dev/null +++ b/generator/tests/test_schema_doc.py @@ -0,0 +1,57 @@ +"""Проверки «описания выгрузки»: свежесть документа и полнота таблицы. + +Документ собирается из контракта, значит расходиться они могут только одним +способом — контракт правили, документ не пересобрали. Ровно это здесь и +сторожится. +""" + +import re +from pathlib import Path + +import pytest + +from clickstream_generator.schema import COLUMNS, Column, ColumnGroup +from clickstream_generator.schema_doc import render + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOC_PATH = REPO_ROOT / "docs" / "formats" / "clickstream-event.md" + +TABLE_ROW = re.compile(r"^\| \d+ \|", re.MULTILINE) + + +@pytest.fixture(scope="module") +def rendered() -> str: + return render() + + +def test_doc_is_up_to_date(rendered: str): + assert DOC_PATH.exists(), f"описание выгрузки не найдено: {DOC_PATH}" + assert DOC_PATH.read_text(encoding="utf-8") == rendered, ( + "описание выгрузки отстало от контракта — пересоберите: make docs" + ) + + +def test_every_column_has_a_row(rendered: str): + assert len(TABLE_ROW.findall(rendered)) == len(COLUMNS) + + +def test_rows_are_numbered_in_contract_order(rendered: str): + numbers = [int(row.strip("| ")) for row in TABLE_ROW.findall(rendered)] + assert numbers == list(range(1, len(COLUMNS) + 1)) + + +@pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) +def test_column_is_described_in_full(column: Column, rendered: str): + cells = ( + f"`{column.name}`", + f"`{column.clickhouse_type}`", + f"`{column.numpy_dtype}`", + f"`{column.dds_name}`", + column.comment, + ) + assert "| " + " | ".join(cells) + " |" in rendered + + +@pytest.mark.parametrize("group", list(ColumnGroup), ids=lambda group: group.name) +def test_group_is_a_heading(group: ColumnGroup, rendered: str): + assert f"\n## {group.value}\n" in rendered diff --git a/generator/uv.lock b/generator/uv.lock new file mode 100644 index 0000000..6ee8a00 --- /dev/null +++ b/generator/uv.lock @@ -0,0 +1,141 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "clickstream-generator" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=2" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.15" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] -- 2.54.0 From 1f96245c419a219e5d4e97a3a14cdf1a6332652e Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 22:32:14 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix(generator):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B4=D0=B2=D1=83=D0=BC=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=B6=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B0=20=D0=B8=20=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BE=D0=B1=D0=B5=D1=89=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - линия постановки: тест инвариантов обещал ловить дрейф колонок, но переименование Referer или перенос колонки в другую группу проходили все проверки; линия стандартов: докстринг говорил о contract-тесте как о существующем и не нёс следа сверки API через Context7. - Что: - тест состава по разделу 1.2 мастер-спеки: группа, имя и тип всех 47 колонок записаны независимо от контракта, поэтому молчаливое переименование или перестановка краснеют — проверено правкой Referer → Referrer. - контракт: contract-тест переведён в будущее время со ссылкой на спеку; записана сверка записи типов ClickHouse (Context7 и запрос к узлу стенда 26.3.17.56 — параметры входят в имя типа целиком). - описание выгрузки самодостаточнее: расшифрованы коды DeviceCategory, домен LastTrafficSource честно назван неполным, «идентификатор» сведён к «id» ради одного слова на одну вещь. - schema_doc: убраны неиспользуемые параметры render и main, row → table_row; тест строки сверяет свойство, а не форму. - Проверка: - make test (249 тестов), make lint; - make docs, затем git diff --exit-code docs/ — пусто. Co-Authored-By: Claude Opus 5 --- docs/formats/clickstream-event.md | 6 +- generator/src/clickstream_generator/schema.py | 18 +++--- .../src/clickstream_generator/schema_doc.py | 19 +++--- generator/tests/test_schema.py | 62 +++++++++++++++++++ generator/tests/test_schema_doc.py | 15 +++-- 5 files changed, 94 insertions(+), 26 deletions(-) diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index c6dc176..a4ec787 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -47,9 +47,9 @@ DDS». Столбец «Тип numpy» показывает, чем колонк | 15 | `UTMCampaign` | `String` | `object` | `utm_campaign` | метка utm_campaign: рекламная кампания | | 16 | `UTMContent` | `String` | `object` | `utm_content` | метка utm_content: что различает объявления одной кампании | | 17 | `UTMTerm` | `String` | `object` | `utm_term` | метка utm_term: ключевое слово перехода | -| 18 | `LastTrafficSource` | `String` | `object` | `last_traffic_source` | последний источник трафика: direct, organic, ad, referral | +| 18 | `LastTrafficSource` | `String` | `object` | `last_traffic_source` | последний источник трафика: organic, direct, ad и подобные | | 19 | `HasGCLID` | `UInt8` | `uint8` | `has_gclid` | 1, если в адресе была метка Google Ads | -| 20 | `YCLID` | `UInt64` | `uint64` | `yclid` | идентификатор клика Яндекс Директа; 0 — метки не было | +| 20 | `YCLID` | `UInt64` | `uint64` | `yclid` | id клика Яндекс Директа; без метки — 0 | ## Браузер, устройство, гео @@ -60,7 +60,7 @@ DDS». Столбец «Тип numpy» показывает, чем колонк | 23 | `BrowserLanguage` | `String` | `object` | `browser_language` | язык браузера | | 24 | `OperatingSystem` | `String` | `object` | `operating_system` | операционная система с версией | | 25 | `OperatingSystemRoot` | `String` | `object` | `operating_system_root` | семейство операционной системы, без версии | -| 26 | `DeviceCategory` | `UInt8` | `uint8` | `device_category` | тип устройства кодами 1–4, как у Метрики; у неё это строка — отступление стенда | +| 26 | `DeviceCategory` | `UInt8` | `uint8` | `device_category` | тип устройства кодами Метрики: 1 — десктоп, 2 — телефон, 3 — планшет, 4 — телевизор; у Метрики это строка, у нас число | | 27 | `MobilePhoneModel` | `String` | `object` | `mobile_phone_model` | модель телефона; на десктопе пусто | | 28 | `ScreenWidth` | `UInt16` | `uint16` | `screen_width` | ширина экрана в пикселях | | 29 | `ScreenHeight` | `UInt16` | `uint16` | `screen_height` | высота экрана в пикселях | diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py index 8077021..eb7c2f0 100644 --- a/generator/src/clickstream_generator/schema.py +++ b/generator/src/clickstream_generator/schema.py @@ -4,15 +4,19 @@ 1.1–1.2) и здесь не переоткрываются — модуль записывает их машинно-читаемо. Контракт принадлежит генератору и кормит трёх потребителей: сам генератор, его валидацию и «описание выгрузки» в доках (`schema_doc`). Хранилище -строится по описанию, а не по модулю; границу сторожит contract-тест, -сверяющий `system.columns` поднятого стенда с этим контрактом. +строится по описанию, а не по модулю; границу будет сторожить contract-тест, +сверяющий `system.columns` поднятого стенда с этим контрактом, — он придёт +вместе с типизированным ODS (спека генератора, раздел 3). Что несёт описатель колонки: - `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит его без изменений. - `clickhouse_type` — тип в хранилище ровно в той записи, в какой его вернёт - `system.columns`. + `system.columns`: параметры входят в имя типа целиком, без сокращений + (`LowCardinality(String)`, `Array(Float64)`). Сверено 2026-08-01 — + по документации ClickHouse через Context7 и запросом к узлу стенда + (26.3.17.56); от этой записи зависит будущий contract-тест. - `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной длины стенду ничего не дают. @@ -199,7 +203,7 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="object", dds_name="last_traffic_source", group=ColumnGroup.PAGE, - comment="последний источник трафика: direct, organic, ad, referral", + comment="последний источник трафика: organic, direct, ad и подобные", ), Column( name="HasGCLID", @@ -215,7 +219,7 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="uint64", dds_name="yclid", group=ColumnGroup.PAGE, - comment="идентификатор клика Яндекс Директа; 0 — метки не было", + comment="id клика Яндекс Директа; без метки — 0", ), Column( name="Browser", @@ -263,8 +267,8 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="uint8", dds_name="device_category", group=ColumnGroup.CLIENT, - comment="тип устройства кодами 1–4, как у Метрики; у неё это строка —" - " отступление стенда", + comment="тип устройства кодами Метрики: 1 — десктоп, 2 — телефон," + " 3 — планшет, 4 — телевизор; у Метрики это строка, у нас число", ), Column( name="MobilePhoneModel", diff --git a/generator/src/clickstream_generator/schema_doc.py b/generator/src/clickstream_generator/schema_doc.py index a35621d..f843fc6 100644 --- a/generator/src/clickstream_generator/schema_doc.py +++ b/generator/src/clickstream_generator/schema_doc.py @@ -9,7 +9,6 @@ """ import argparse -from collections.abc import Sequence from itertools import groupby from pathlib import Path @@ -44,18 +43,18 @@ TABLE_HEADER = ( ) -def render(columns: Sequence[Column] = COLUMNS) -> str: +def render() -> str: """Собирает документ целиком: преамбула и таблица колонок по группам.""" - lines = PREAMBLE.format(count=len(columns)).splitlines() - numbers = iter(range(1, len(columns) + 1)) - for group, columns_of_group in groupby(columns, key=lambda column: column.group): + lines = PREAMBLE.format(count=len(COLUMNS)).splitlines() + numbers = iter(range(1, len(COLUMNS) + 1)) + for group, columns_of_group in groupby(COLUMNS, key=lambda column: column.group): lines += ["", f"## {group.value}", "", *TABLE_HEADER] - lines += [row(next(numbers), column) for column in columns_of_group] + lines += [table_row(next(numbers), column) for column in columns_of_group] return "\n".join(lines) + "\n" -def row(number: int, column: Column) -> str: - """Строка таблицы; номер — место колонки в порядке выгрузки.""" +def table_row(number: int, column: Column) -> str: + """Строка таблицы колонок; номер — место колонки в порядке выгрузки.""" cells = ( str(number), f"`{column.name}`", @@ -67,12 +66,12 @@ def row(number: int, column: Column) -> str: return "| " + " | ".join(cells) + " |" -def main(argv: Sequence[str] | None = None) -> None: +def main() -> None: parser = argparse.ArgumentParser( description="Собирает описание выгрузки из контракта схемы события." ) parser.add_argument("output", type=Path, help="путь к файлу описания") - output = parser.parse_args(argv).output + output = parser.parse_args().output output.write_text(render(), encoding="utf-8") print(f"Описание выгрузки собрано: {output}") diff --git a/generator/tests/test_schema.py b/generator/tests/test_schema.py index 791b569..afce036 100644 --- a/generator/tests/test_schema.py +++ b/generator/tests/test_schema.py @@ -16,6 +16,60 @@ from clickstream_generator.schema import COLUMNS, Column, ColumnGroup # Состав решён мастер-спекой (раздел 1.2) и в этом тикете не переоткрывается. EXPECTED_COLUMN_COUNT = 47 +# Тот же состав, переписанный с мастер-спеки отдельно от контракта: группа, +# имя, тип. Дубль намеренный — только независимая запись ловит молчаливое +# переименование колонки, подмену типа или перестановку. Правка контракта без +# правки спеки краснеет здесь, и это единственный способ узнать о ней вовремя. +MASTER_SPEC_COMPOSITION = ( + (ColumnGroup.IDENTIFIERS, "WatchID", "UInt64"), + (ColumnGroup.IDENTIFIERS, "VisitID", "UInt64"), + (ColumnGroup.IDENTIFIERS, "ClientID", "UInt64"), + (ColumnGroup.IDENTIFIERS, "CounterID", "UInt32"), + (ColumnGroup.IDENTIFIERS, "EventDate", "Date"), + (ColumnGroup.IDENTIFIERS, "UTCEventTime", "DateTime"), + (ColumnGroup.IDENTIFIERS, "ClientTimeZone", "Int16"), + (ColumnGroup.IDENTIFIERS, "EventType", "LowCardinality(String)"), + (ColumnGroup.IDENTIFIERS, "Sign", "Int8"), + (ColumnGroup.PAGE, "URL", "String"), + (ColumnGroup.PAGE, "Referer", "String"), + (ColumnGroup.PAGE, "Title", "String"), + (ColumnGroup.PAGE, "UTMSource", "String"), + (ColumnGroup.PAGE, "UTMMedium", "String"), + (ColumnGroup.PAGE, "UTMCampaign", "String"), + (ColumnGroup.PAGE, "UTMContent", "String"), + (ColumnGroup.PAGE, "UTMTerm", "String"), + (ColumnGroup.PAGE, "LastTrafficSource", "String"), + (ColumnGroup.PAGE, "HasGCLID", "UInt8"), + (ColumnGroup.PAGE, "YCLID", "UInt64"), + (ColumnGroup.CLIENT, "Browser", "String"), + (ColumnGroup.CLIENT, "BrowserMajorVersion", "UInt16"), + (ColumnGroup.CLIENT, "BrowserLanguage", "String"), + (ColumnGroup.CLIENT, "OperatingSystem", "String"), + (ColumnGroup.CLIENT, "OperatingSystemRoot", "String"), + (ColumnGroup.CLIENT, "DeviceCategory", "UInt8"), + (ColumnGroup.CLIENT, "MobilePhoneModel", "String"), + (ColumnGroup.CLIENT, "ScreenWidth", "UInt16"), + (ColumnGroup.CLIENT, "ScreenHeight", "UInt16"), + (ColumnGroup.CLIENT, "IPAddress", "String"), + (ColumnGroup.CLIENT, "RegionCountry", "String"), + (ColumnGroup.CLIENT, "RegionCity", "String"), + (ColumnGroup.CLIENT, "RegionCountryID", "UInt32"), + (ColumnGroup.CLIENT, "RegionCityID", "UInt32"), + (ColumnGroup.PARAMS, "GoalsReached", "Array(UInt32)"), + (ColumnGroup.PARAMS, "ParsedParamsKey1", "Array(String)"), + (ColumnGroup.ECOMMERCE, "purchaseID", "Array(String)"), + (ColumnGroup.ECOMMERCE, "purchaseRevenue", "Array(Float64)"), + (ColumnGroup.ECOMMERCE, "purchaseCurrency", "Array(String)"), + (ColumnGroup.ECOMMERCE, "purchaseCoupon", "Array(String)"), + (ColumnGroup.ECOMMERCE, "productID", "Array(String)"), + (ColumnGroup.ECOMMERCE, "productName", "Array(String)"), + (ColumnGroup.ECOMMERCE, "productCategory", "Array(String)"), + (ColumnGroup.ECOMMERCE, "productPrice", "Array(Int64)"), + (ColumnGroup.ECOMMERCE, "productQuantity", "Array(UInt64)"), + (ColumnGroup.ECOMMERCE, "productEventType", "Array(String)"), + (ColumnGroup.ECOMMERCE, "ecommerce", "String"), +) + # Соответствие «тип ClickHouse — тип numpy», записанное независимо от # контракта: если пара в контракте разъедется, сойтись они уже не смогут. NUMPY_BY_CLICKHOUSE_TYPE = { @@ -52,6 +106,14 @@ def test_column_count(): assert len(COLUMNS) == EXPECTED_COLUMN_COUNT +def test_composition_matches_master_spec(): + """Состав, имена, типы и порядок — те же, что в разделе 1.2 мастер-спеки.""" + composition = tuple( + (column.group, column.name, column.clickhouse_type) for column in COLUMNS + ) + assert composition == MASTER_SPEC_COMPOSITION + + def test_metrica_names_are_unique(): names = [column.name for column in COLUMNS] assert len(set(names)) == len(names) diff --git a/generator/tests/test_schema_doc.py b/generator/tests/test_schema_doc.py index 3132964..653c0db 100644 --- a/generator/tests/test_schema_doc.py +++ b/generator/tests/test_schema_doc.py @@ -42,14 +42,17 @@ def test_rows_are_numbered_in_contract_order(rendered: str): @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) def test_column_is_described_in_full(column: Column, rendered: str): - cells = ( - f"`{column.name}`", - f"`{column.clickhouse_type}`", - f"`{column.numpy_dtype}`", - f"`{column.dds_name}`", + """Колонку описывает одна строка, и в ней всё, что несёт контракт.""" + described = ( + column.name, + column.clickhouse_type, + column.numpy_dtype, + column.dds_name, column.comment, ) - assert "| " + " | ".join(cells) + " |" in rendered + assert any( + all(value in line for value in described) for line in rendered.splitlines() + ) @pytest.mark.parametrize("group", list(ColumnGroup), ids=lambda group: group.name) -- 2.54.0 From aabd339a267b08949cfb8ef0b9200fa6e2dc8bbc Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 22:44:53 +0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(generator):=20=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=D0=B0=20=D0=B8=D0=BC=D1=91=D0=BD=20DDS=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BA=D0=B0=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA,=20=D1=82=D0=BE=D1=80=D0=B3=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - слепая линия Кодекса (свежий тред, high) нашла три места, где обещание контракта не подкреплено: имена для DDS не сверялись ни с чем, порядок строк документа держался только на нумерации, а комментарий рекламировал торговые события, которые мастер-спека прямо исключила. - Что: - имена для DDS записаны независимо и сверяются целиком: они не выводятся правилом из имён Метрики, значит осмысленно неверное имя иначе молча уезжает в опубликованное описание (проверено подменой referer). - строки документа сверяются парами «номер, колонка»: рендер в другом порядке больше не проходит зелёным (проверено перевёрнутым рендером). - productEventType: detail и remove убраны из комментария — раздел 10 мастер-спеки отказался от полного словаря торговых событий Метрики; стенд шлёт add и purchase. - Проверка: - make test (250 тестов), make lint; - make docs, затем git diff --exit-code docs/ — пусто; - обе новые проверки проверены мутациями: каждая краснеет своим тестом. Co-Authored-By: Claude Opus 5 --- docs/formats/clickstream-event.md | 2 +- generator/src/clickstream_generator/schema.py | 3 +- generator/tests/test_schema.py | 59 +++++++++++++++++++ generator/tests/test_schema_doc.py | 12 ++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index a4ec787..05889c0 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -90,5 +90,5 @@ DDS». Столбец «Тип numpy» показывает, чем колонк | 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров | | 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми | | 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара | -| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: detail, add, remove, purchase | +| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём | | 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки | diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py index eb7c2f0..6c33d59 100644 --- a/generator/src/clickstream_generator/schema.py +++ b/generator/src/clickstream_generator/schema.py @@ -429,7 +429,8 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="object", dds_name="product_event_type", group=ColumnGroup.ECOMMERCE, - comment="действие с товаром: detail, add, remove, purchase", + comment="действие с товаром: стенд шлёт add и purchase, полный" + " словарь Метрики (detail, remove, impressions) не берём", ), Column( name="ecommerce", diff --git a/generator/tests/test_schema.py b/generator/tests/test_schema.py index afce036..030586c 100644 --- a/generator/tests/test_schema.py +++ b/generator/tests/test_schema.py @@ -87,6 +87,60 @@ NUMPY_BY_CLICKHOUSE_TYPE = { "DateTime": "datetime64[s]", } +# Имена для DDS — не производная от имён Метрики, а решение тикета #36: +# вывести их правилом нельзя (акронимы, «timezone» одним словом), поэтому +# сверять их не с чем, кроме такой же независимой записи. Без неё осмысленно +# неверное имя молча уезжает в опубликованное описание выгрузки. +EXPECTED_DDS_NAMES = { + "WatchID": "watch_id", + "VisitID": "visit_id", + "ClientID": "client_id", + "CounterID": "counter_id", + "EventDate": "event_date", + "UTCEventTime": "utc_event_time", + "ClientTimeZone": "client_timezone", + "EventType": "event_type", + "Sign": "sign", + "URL": "url", + "Referer": "referer", + "Title": "title", + "UTMSource": "utm_source", + "UTMMedium": "utm_medium", + "UTMCampaign": "utm_campaign", + "UTMContent": "utm_content", + "UTMTerm": "utm_term", + "LastTrafficSource": "last_traffic_source", + "HasGCLID": "has_gclid", + "YCLID": "yclid", + "Browser": "browser", + "BrowserMajorVersion": "browser_major_version", + "BrowserLanguage": "browser_language", + "OperatingSystem": "operating_system", + "OperatingSystemRoot": "operating_system_root", + "DeviceCategory": "device_category", + "MobilePhoneModel": "mobile_phone_model", + "ScreenWidth": "screen_width", + "ScreenHeight": "screen_height", + "IPAddress": "ip_address", + "RegionCountry": "region_country", + "RegionCity": "region_city", + "RegionCountryID": "region_country_id", + "RegionCityID": "region_city_id", + "GoalsReached": "goals_reached", + "ParsedParamsKey1": "parsed_params_key1", + "purchaseID": "purchase_id", + "purchaseRevenue": "purchase_revenue", + "purchaseCurrency": "purchase_currency", + "purchaseCoupon": "purchase_coupon", + "productID": "product_id", + "productName": "product_name", + "productCategory": "product_category", + "productPrice": "product_price", + "productQuantity": "product_quantity", + "productEventType": "product_event_type", + "ecommerce": "ecommerce", +} + METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") DDS_NAME = re.compile(r"^[a-z][a-z0-9_]*$") ARRAY_TYPE = re.compile(r"^Array\((.+)\)$") @@ -124,6 +178,11 @@ def test_dds_names_are_unique(): assert len(set(names)) == len(names) +def test_dds_names_are_the_ones_we_chose(): + """Переименование колонки в DDS — решение, а не правка мимоходом.""" + assert {column.name: column.dds_name for column in COLUMNS} == EXPECTED_DDS_NAMES + + @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) def test_attributes_are_filled(column: Column): assert column.name.strip() diff --git a/generator/tests/test_schema_doc.py b/generator/tests/test_schema_doc.py index 653c0db..9b154f8 100644 --- a/generator/tests/test_schema_doc.py +++ b/generator/tests/test_schema_doc.py @@ -16,7 +16,9 @@ from clickstream_generator.schema_doc import render REPO_ROOT = Path(__file__).resolve().parents[2] DOC_PATH = REPO_ROOT / "docs" / "formats" / "clickstream-event.md" -TABLE_ROW = re.compile(r"^\| \d+ \|", re.MULTILINE) +# Номер и имя колонки из строки таблицы: по ним сверяется не только состав +# документа, но и его порядок — по нему сторона хранилища выпишет колонки. +TABLE_ROW = re.compile(r"^\| (\d+) \| `([^`]+)` \|", re.MULTILINE) @pytest.fixture(scope="module") @@ -35,9 +37,11 @@ def test_every_column_has_a_row(rendered: str): assert len(TABLE_ROW.findall(rendered)) == len(COLUMNS) -def test_rows_are_numbered_in_contract_order(rendered: str): - numbers = [int(row.strip("| ")) for row in TABLE_ROW.findall(rendered)] - assert numbers == list(range(1, len(COLUMNS) + 1)) +def test_rows_follow_contract_order(rendered: str): + """Строки идут в порядке контракта, а не просто нумеруются с 1 по 47.""" + rows = [(int(number), name) for number, name in TABLE_ROW.findall(rendered)] + expected = [(number, column.name) for number, column in enumerate(COLUMNS, 1)] + assert rows == expected @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) -- 2.54.0 From 24c8dd9b9886ce4cdd59f943cf71f8e40f27ea73 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 23:01:04 +0300 Subject: [PATCH 4/5] =?UTF-8?q?refactor(generator):=20=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B2=20DDS,?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D0=BD=D1=8F=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - контракт вёл себя как хозяин чужого слоя: поле называлось dds_name, в описании стоял столбец «Имя в DDS», а два теста прибивали имена гвоздями. Спека же задала вид имени (snake_case), а не список: имена атрибутов складывает модель данных DDS, и решать это не трекеру. - Что: - поле контракта и столбец описания стали нормализованным именем: имя источника в нашем стиле. В описании и в докстринге сказано прямо, что слой DDS называет атрибуты по своей модели. - сняты оба теста на имена — копия имён DDS и конспект состава по мастер-спеке. Они не проверяли верность имени, только неизменность, а неизменность и так сторожит пересборка описания: молчаливой правки контракта не бывает, она всплывает диффом документа. - остались проверки формы: 47 колонок, уникальность, стили имён, заполненность, согласие типов numpy и ClickHouse, порядок групп. - спека генератора (раздел 3) и CONTEXT.md согласованы тем же коммитом: уточнение внесено как расхождение, найденное при исполнении. - Проверка: - make test (248 тестов), make lint, make config-test; - make docs, затем git diff --exit-code docs/ — пусто. Co-Authored-By: Claude Opus 5 --- CONTEXT.md | 6 + docs/formats/clickstream-event.md | 20 +-- docs/specs/2026-08-01-generator.md | 10 +- generator/src/clickstream_generator/schema.py | 104 ++++++------- .../src/clickstream_generator/schema_doc.py | 14 +- generator/tests/test_schema.py | 138 ++---------------- generator/tests/test_schema_doc.py | 2 +- 7 files changed, 95 insertions(+), 199 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a77be65..59b72d5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,6 +43,12 @@ Python-модуль с описателями колонок события — Из него выводятся генератор, валидация и документация формата; хранилище строится по документации, не по модулю. +**Нормализованное имя**: +Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в +контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели +данных: слой DDS складывает модель и называет атрибуты по ней. +_Избегать_: имя в DDS + **Описание выгрузки**: Публичная документация формата события: таблица колонок, собранная из контракта схемы. По ней пишется сторона хранилища — как в бою по документации diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index 05889c0..a40bd89 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -10,10 +10,12 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. Имена и типы колонок — стороны источника. Хранилище принимает их как есть и -нормализует у себя: своё snake_case-имя каждой колонки ждёт в столбце «Имя в -DDS». Столбец «Тип numpy» показывает, чем колонка представлена внутри -генератора; у массивов это тип элемента. Номер — место колонки в выгрузке: -порядок задан контрактом. +нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное +имя». Это имя источника, приведённое к snake_case, а не имя атрибута в +модели данных: слой DDS складывает свою модель и называет атрибуты по ней. +Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора; +у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан +контрактом. Колонки группы «Ecommerce» заполнены только у торговых событий: `add_to_cart` несёт один товар, `purchase` — состав заказа и блок @@ -23,7 +25,7 @@ DDS». Столбец «Тип numpy» показывает, чем колонк ## Идентификаторы и время -| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | |---|---|---|---|---|---| | 1 | `WatchID` | `UInt64` | `uint64` | `watch_id` | id события — хита; держится ниже 2^53, выше числа в JSON округляются | | 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни | @@ -37,7 +39,7 @@ DDS». Столбец «Тип numpy» показывает, чем колонк ## Страница и атрибуция -| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | |---|---|---|---|---|---| | 10 | `URL` | `String` | `object` | `url` | адрес страницы события | | 11 | `Referer` | `String` | `object` | `referer` | адрес, с которого посетитель пришёл на страницу | @@ -53,7 +55,7 @@ DDS». Столбец «Тип numpy» показывает, чем колонк ## Браузер, устройство, гео -| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | |---|---|---|---|---|---| | 21 | `Browser` | `String` | `object` | `browser` | браузер посетителя | | 22 | `BrowserMajorVersion` | `UInt16` | `uint16` | `browser_major_version` | старшая версия браузера | @@ -72,14 +74,14 @@ DDS». Столбец «Тип numpy» показывает, чем колонк ## Массивы и параметры -| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | |---|---|---|---|---|---| | 35 | `GoalsReached` | `Array(UInt32)` | `uint32` | `goals_reached` | id достигнутых целей; на стенде их две — корзина и покупка | | 36 | `ParsedParamsKey1` | `Array(String)` | `object` | `parsed_params_key1` | свои параметры сайта, один уровень — например вариант A/B-теста | ## Ecommerce -| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий | +| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | |---|---|---|---|---|---| | 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент | | 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; Float64, как у Метрики — на этом держится урок о расхождениях с бэкендом | diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 870442d..66367f6 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -156,9 +156,13 @@ валидация и публичное «описание выгрузки» в доках — рендеренная таблица колонок, аналог документации Метрики. - **Форма контракта — импортируемый python-модуль с чистыми данными**: - описатели колонок (имя Метрики, тип ClickHouse, тип numpy, snake_case-имя - для DDS, группа полей, порядок), никакой логики. Читаемость для менти - несёт рендеренная таблица в доках, не модуль. + описатели колонок (имя Метрики, тип ClickHouse, тип numpy, нормализованное + snake_case-имя, группа полей, порядок), никакой логики. Читаемость для + менти несёт рендеренная таблица в доках, не модуль. Уточнение при + исполнении (#36): нормализованное имя — имя источника, приведённое к + нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою + модель и называет атрибуты по ней; `dds.v_event` эти имена берёт (раздел 7 + мастер-спеки), но контракт их не диктует и тестами не сторожит. - **Сторона хранилища пишется по документации, не генерируется.** DDL `ods.event`, SELECT матвью, `dds.v_event`, трансформации — работа следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py index 6c33d59..93f56ef 100644 --- a/generator/src/clickstream_generator/schema.py +++ b/generator/src/clickstream_generator/schema.py @@ -20,10 +20,12 @@ - `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной длины стенду ничего не дают. -- `dds_name` — наше snake_case-имя, под которым колонка появится в DDS. - Перевод механический, акроним идёт одним куском (`utm_source`, `has_gclid`, +- `normalized_name` — то же имя в нашем стиле: snake_case, механический + перевод имени источника, акроним одним куском (`utm_source`, `has_gclid`, `ip_address`); единственное исключение — `client_timezone`: «timezone» - пишем одним словом. + пишем одним словом. Это имя источника, приведённое к нашему стилю, а не + имя атрибута в модели данных: слой DDS складывает свою модель и называет + атрибуты по ней — нормализованное имя ему отправная точка, не обязанность. - `group` — раздел описания выгрузки; колонки одной группы идут подряд. - `comment` — строка описания для менти, попадает в документ как есть. @@ -54,7 +56,7 @@ class Column: name: str clickhouse_type: str numpy_dtype: str - dds_name: str + normalized_name: str group: ColumnGroup comment: str @@ -64,7 +66,7 @@ COLUMNS: tuple[Column, ...] = ( name="WatchID", clickhouse_type="UInt64", numpy_dtype="uint64", - dds_name="watch_id", + normalized_name="watch_id", group=ColumnGroup.IDENTIFIERS, comment="id события — хита; держится ниже 2^53, выше числа в JSON округляются", ), @@ -72,7 +74,7 @@ COLUMNS: tuple[Column, ...] = ( name="VisitID", clickhouse_type="UInt64", numpy_dtype="uint64", - dds_name="visit_id", + normalized_name="visit_id", group=ColumnGroup.IDENTIFIERS, comment="id визита от генератора — эталон лабы: собери сессии сам и сравни", ), @@ -80,7 +82,7 @@ COLUMNS: tuple[Column, ...] = ( name="ClientID", clickhouse_type="UInt64", numpy_dtype="uint64", - dds_name="client_id", + normalized_name="client_id", group=ColumnGroup.IDENTIFIERS, comment="анонимный id браузера — кука; по хешу от неё таблица шардируется", ), @@ -88,7 +90,7 @@ COLUMNS: tuple[Column, ...] = ( name="CounterID", clickhouse_type="UInt32", numpy_dtype="uint32", - dds_name="counter_id", + normalized_name="counter_id", group=ColumnGroup.IDENTIFIERS, comment="id счётчика: на стенде константа, сайт один", ), @@ -96,7 +98,7 @@ COLUMNS: tuple[Column, ...] = ( name="EventDate", clickhouse_type="Date", numpy_dtype="datetime64[D]", - dds_name="event_date", + normalized_name="event_date", group=ColumnGroup.IDENTIFIERS, comment="дата события; по ней режется партиция", ), @@ -104,7 +106,7 @@ COLUMNS: tuple[Column, ...] = ( name="UTCEventTime", clickhouse_type="DateTime", numpy_dtype="datetime64[s]", - dds_name="utc_event_time", + normalized_name="utc_event_time", group=ColumnGroup.IDENTIFIERS, comment="время события в UTC — единственная метка времени, как у Метрики", ), @@ -112,7 +114,7 @@ COLUMNS: tuple[Column, ...] = ( name="ClientTimeZone", clickhouse_type="Int16", numpy_dtype="int16", - dds_name="client_timezone", + normalized_name="client_timezone", group=ColumnGroup.IDENTIFIERS, comment="смещение часового пояса клиента от UTC, в минутах", ), @@ -120,7 +122,7 @@ COLUMNS: tuple[Column, ...] = ( name="EventType", clickhouse_type="LowCardinality(String)", numpy_dtype="object", - dds_name="event_type", + normalized_name="event_type", group=ColumnGroup.IDENTIFIERS, comment="тип события: pageview, add_to_cart, purchase — добавка стенда," " у Метрики такого поля нет", @@ -129,7 +131,7 @@ COLUMNS: tuple[Column, ...] = ( name="Sign", clickhouse_type="Int8", numpy_dtype="int8", - dds_name="sign", + normalized_name="sign", group=ColumnGroup.IDENTIFIERS, comment="всегда 1: колонка формата, исправлений записей генератор не шлёт", ), @@ -137,7 +139,7 @@ COLUMNS: tuple[Column, ...] = ( name="URL", clickhouse_type="String", numpy_dtype="object", - dds_name="url", + normalized_name="url", group=ColumnGroup.PAGE, comment="адрес страницы события", ), @@ -145,7 +147,7 @@ COLUMNS: tuple[Column, ...] = ( name="Referer", clickhouse_type="String", numpy_dtype="object", - dds_name="referer", + normalized_name="referer", group=ColumnGroup.PAGE, comment="адрес, с которого посетитель пришёл на страницу", ), @@ -153,7 +155,7 @@ COLUMNS: tuple[Column, ...] = ( name="Title", clickhouse_type="String", numpy_dtype="object", - dds_name="title", + normalized_name="title", group=ColumnGroup.PAGE, comment="заголовок страницы", ), @@ -161,7 +163,7 @@ COLUMNS: tuple[Column, ...] = ( name="UTMSource", clickhouse_type="String", numpy_dtype="object", - dds_name="utm_source", + normalized_name="utm_source", group=ColumnGroup.PAGE, comment="метка utm_source: площадка перехода", ), @@ -169,7 +171,7 @@ COLUMNS: tuple[Column, ...] = ( name="UTMMedium", clickhouse_type="String", numpy_dtype="object", - dds_name="utm_medium", + normalized_name="utm_medium", group=ColumnGroup.PAGE, comment="метка utm_medium: тип трафика", ), @@ -177,7 +179,7 @@ COLUMNS: tuple[Column, ...] = ( name="UTMCampaign", clickhouse_type="String", numpy_dtype="object", - dds_name="utm_campaign", + normalized_name="utm_campaign", group=ColumnGroup.PAGE, comment="метка utm_campaign: рекламная кампания", ), @@ -185,7 +187,7 @@ COLUMNS: tuple[Column, ...] = ( name="UTMContent", clickhouse_type="String", numpy_dtype="object", - dds_name="utm_content", + normalized_name="utm_content", group=ColumnGroup.PAGE, comment="метка utm_content: что различает объявления одной кампании", ), @@ -193,7 +195,7 @@ COLUMNS: tuple[Column, ...] = ( name="UTMTerm", clickhouse_type="String", numpy_dtype="object", - dds_name="utm_term", + normalized_name="utm_term", group=ColumnGroup.PAGE, comment="метка utm_term: ключевое слово перехода", ), @@ -201,7 +203,7 @@ COLUMNS: tuple[Column, ...] = ( name="LastTrafficSource", clickhouse_type="String", numpy_dtype="object", - dds_name="last_traffic_source", + normalized_name="last_traffic_source", group=ColumnGroup.PAGE, comment="последний источник трафика: organic, direct, ad и подобные", ), @@ -209,7 +211,7 @@ COLUMNS: tuple[Column, ...] = ( name="HasGCLID", clickhouse_type="UInt8", numpy_dtype="uint8", - dds_name="has_gclid", + normalized_name="has_gclid", group=ColumnGroup.PAGE, comment="1, если в адресе была метка Google Ads", ), @@ -217,7 +219,7 @@ COLUMNS: tuple[Column, ...] = ( name="YCLID", clickhouse_type="UInt64", numpy_dtype="uint64", - dds_name="yclid", + normalized_name="yclid", group=ColumnGroup.PAGE, comment="id клика Яндекс Директа; без метки — 0", ), @@ -225,7 +227,7 @@ COLUMNS: tuple[Column, ...] = ( name="Browser", clickhouse_type="String", numpy_dtype="object", - dds_name="browser", + normalized_name="browser", group=ColumnGroup.CLIENT, comment="браузер посетителя", ), @@ -233,7 +235,7 @@ COLUMNS: tuple[Column, ...] = ( name="BrowserMajorVersion", clickhouse_type="UInt16", numpy_dtype="uint16", - dds_name="browser_major_version", + normalized_name="browser_major_version", group=ColumnGroup.CLIENT, comment="старшая версия браузера", ), @@ -241,7 +243,7 @@ COLUMNS: tuple[Column, ...] = ( name="BrowserLanguage", clickhouse_type="String", numpy_dtype="object", - dds_name="browser_language", + normalized_name="browser_language", group=ColumnGroup.CLIENT, comment="язык браузера", ), @@ -249,7 +251,7 @@ COLUMNS: tuple[Column, ...] = ( name="OperatingSystem", clickhouse_type="String", numpy_dtype="object", - dds_name="operating_system", + normalized_name="operating_system", group=ColumnGroup.CLIENT, comment="операционная система с версией", ), @@ -257,7 +259,7 @@ COLUMNS: tuple[Column, ...] = ( name="OperatingSystemRoot", clickhouse_type="String", numpy_dtype="object", - dds_name="operating_system_root", + normalized_name="operating_system_root", group=ColumnGroup.CLIENT, comment="семейство операционной системы, без версии", ), @@ -265,7 +267,7 @@ COLUMNS: tuple[Column, ...] = ( name="DeviceCategory", clickhouse_type="UInt8", numpy_dtype="uint8", - dds_name="device_category", + normalized_name="device_category", group=ColumnGroup.CLIENT, comment="тип устройства кодами Метрики: 1 — десктоп, 2 — телефон," " 3 — планшет, 4 — телевизор; у Метрики это строка, у нас число", @@ -274,7 +276,7 @@ COLUMNS: tuple[Column, ...] = ( name="MobilePhoneModel", clickhouse_type="String", numpy_dtype="object", - dds_name="mobile_phone_model", + normalized_name="mobile_phone_model", group=ColumnGroup.CLIENT, comment="модель телефона; на десктопе пусто", ), @@ -282,7 +284,7 @@ COLUMNS: tuple[Column, ...] = ( name="ScreenWidth", clickhouse_type="UInt16", numpy_dtype="uint16", - dds_name="screen_width", + normalized_name="screen_width", group=ColumnGroup.CLIENT, comment="ширина экрана в пикселях", ), @@ -290,7 +292,7 @@ COLUMNS: tuple[Column, ...] = ( name="ScreenHeight", clickhouse_type="UInt16", numpy_dtype="uint16", - dds_name="screen_height", + normalized_name="screen_height", group=ColumnGroup.CLIENT, comment="высота экрана в пикселях", ), @@ -298,7 +300,7 @@ COLUMNS: tuple[Column, ...] = ( name="IPAddress", clickhouse_type="String", numpy_dtype="object", - dds_name="ip_address", + normalized_name="ip_address", group=ColumnGroup.CLIENT, comment="IP-адрес посетителя", ), @@ -306,7 +308,7 @@ COLUMNS: tuple[Column, ...] = ( name="RegionCountry", clickhouse_type="String", numpy_dtype="object", - dds_name="region_country", + normalized_name="region_country", group=ColumnGroup.CLIENT, comment="страна кодом ISO", ), @@ -314,7 +316,7 @@ COLUMNS: tuple[Column, ...] = ( name="RegionCity", clickhouse_type="String", numpy_dtype="object", - dds_name="region_city", + normalized_name="region_city", group=ColumnGroup.CLIENT, comment="город, название по-английски", ), @@ -322,7 +324,7 @@ COLUMNS: tuple[Column, ...] = ( name="RegionCountryID", clickhouse_type="UInt32", numpy_dtype="uint32", - dds_name="region_country_id", + normalized_name="region_country_id", group=ColumnGroup.CLIENT, comment="числовой id страны в справочнике регионов Яндекса", ), @@ -330,7 +332,7 @@ COLUMNS: tuple[Column, ...] = ( name="RegionCityID", clickhouse_type="UInt32", numpy_dtype="uint32", - dds_name="region_city_id", + normalized_name="region_city_id", group=ColumnGroup.CLIENT, comment="числовой id города в том же справочнике", ), @@ -338,7 +340,7 @@ COLUMNS: tuple[Column, ...] = ( name="GoalsReached", clickhouse_type="Array(UInt32)", numpy_dtype="uint32", - dds_name="goals_reached", + normalized_name="goals_reached", group=ColumnGroup.PARAMS, comment="id достигнутых целей; на стенде их две — корзина и покупка", ), @@ -346,7 +348,7 @@ COLUMNS: tuple[Column, ...] = ( name="ParsedParamsKey1", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="parsed_params_key1", + normalized_name="parsed_params_key1", group=ColumnGroup.PARAMS, comment="свои параметры сайта, один уровень — например вариант A/B-теста", ), @@ -354,7 +356,7 @@ COLUMNS: tuple[Column, ...] = ( name="purchaseID", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="purchase_id", + normalized_name="purchase_id", group=ColumnGroup.ECOMMERCE, comment="номер заказа; у события purchase — один элемент", ), @@ -362,7 +364,7 @@ COLUMNS: tuple[Column, ...] = ( name="purchaseRevenue", clickhouse_type="Array(Float64)", numpy_dtype="float64", - dds_name="purchase_revenue", + normalized_name="purchase_revenue", group=ColumnGroup.ECOMMERCE, comment="выручка заказа глазами клиента; Float64, как у Метрики —" " на этом держится урок о расхождениях с бэкендом", @@ -371,7 +373,7 @@ COLUMNS: tuple[Column, ...] = ( name="purchaseCurrency", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="purchase_currency", + normalized_name="purchase_currency", group=ColumnGroup.ECOMMERCE, comment="валюта заказа", ), @@ -379,7 +381,7 @@ COLUMNS: tuple[Column, ...] = ( name="purchaseCoupon", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="purchase_coupon", + normalized_name="purchase_coupon", group=ColumnGroup.ECOMMERCE, comment="купон заказа, если был применён", ), @@ -387,7 +389,7 @@ COLUMNS: tuple[Column, ...] = ( name="productID", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="product_id", + normalized_name="product_id", group=ColumnGroup.ECOMMERCE, comment="id товаров события", ), @@ -395,7 +397,7 @@ COLUMNS: tuple[Column, ...] = ( name="productName", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="product_name", + normalized_name="product_name", group=ColumnGroup.ECOMMERCE, comment="названия тех же товаров", ), @@ -403,7 +405,7 @@ COLUMNS: tuple[Column, ...] = ( name="productCategory", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="product_category", + normalized_name="product_category", group=ColumnGroup.ECOMMERCE, comment="категории тех же товаров", ), @@ -411,7 +413,7 @@ COLUMNS: tuple[Column, ...] = ( name="productPrice", clickhouse_type="Array(Int64)", numpy_dtype="int64", - dds_name="product_price", + normalized_name="product_price", group=ColumnGroup.ECOMMERCE, comment="цена за штуку целым числом: деньги генератор считает целыми", ), @@ -419,7 +421,7 @@ COLUMNS: tuple[Column, ...] = ( name="productQuantity", clickhouse_type="Array(UInt64)", numpy_dtype="uint64", - dds_name="product_quantity", + normalized_name="product_quantity", group=ColumnGroup.ECOMMERCE, comment="количество штук каждого товара", ), @@ -427,7 +429,7 @@ COLUMNS: tuple[Column, ...] = ( name="productEventType", clickhouse_type="Array(String)", numpy_dtype="object", - dds_name="product_event_type", + normalized_name="product_event_type", group=ColumnGroup.ECOMMERCE, comment="действие с товаром: стенд шлёт add и purchase, полный" " словарь Метрики (detail, remove, impressions) не берём", @@ -436,7 +438,7 @@ COLUMNS: tuple[Column, ...] = ( name="ecommerce", clickhouse_type="String", numpy_dtype="object", - dds_name="ecommerce", + normalized_name="ecommerce", group=ColumnGroup.ECOMMERCE, comment="сырой JSON события, как отдаёт Метрика — материал лабы" " про разбор JSON внутри колонки", diff --git a/generator/src/clickstream_generator/schema_doc.py b/generator/src/clickstream_generator/schema_doc.py index f843fc6..888f051 100644 --- a/generator/src/clickstream_generator/schema_doc.py +++ b/generator/src/clickstream_generator/schema_doc.py @@ -26,10 +26,12 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. Имена и типы колонок — стороны источника. Хранилище принимает их как есть и -нормализует у себя: своё snake_case-имя каждой колонки ждёт в столбце «Имя в -DDS». Столбец «Тип numpy» показывает, чем колонка представлена внутри -генератора; у массивов это тип элемента. Номер — место колонки в выгрузке: -порядок задан контрактом. +нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное +имя». Это имя источника, приведённое к snake_case, а не имя атрибута в +модели данных: слой DDS складывает свою модель и называет атрибуты по ней. +Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора; +у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан +контрактом. Колонки группы «Ecommerce» заполнены только у торговых событий: `add_to_cart` несёт один товар, `purchase` — состав заказа и блок @@ -38,7 +40,7 @@ DDS». Столбец «Тип numpy» показывает, чем колонк Всего колонок: {count}.""" TABLE_HEADER = ( - "| № | Колонка | Тип ClickHouse | Тип numpy | Имя в DDS | Комментарий |", + "| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |", "|---|---|---|---|---|---|", ) @@ -60,7 +62,7 @@ def table_row(number: int, column: Column) -> str: f"`{column.name}`", f"`{column.clickhouse_type}`", f"`{column.numpy_dtype}`", - f"`{column.dds_name}`", + f"`{column.normalized_name}`", column.comment, ) return "| " + " | ".join(cells) + " |" diff --git a/generator/tests/test_schema.py b/generator/tests/test_schema.py index 030586c..7257e24 100644 --- a/generator/tests/test_schema.py +++ b/generator/tests/test_schema.py @@ -1,9 +1,10 @@ """Инварианты контракта схемы события. Контракт — чистые данные, поэтому проверять в нём нечего кроме связности: -состав, уникальность имён, заполненность полей, согласие типов и порядок. -Это и есть сторож границы «трекер | хранилище»: молчаливый дрейф колонок -ловится здесь, а не в DDL через неделю. +состав на месте, имена уникальны и в своих стилях, атрибуты заполнены, типы +согласованы, группы идут подряд. Имена колонок тесты не сторожат намеренно: +любая правка контракта проходит через пересборку описания выгрузки, а её +дифф виден в ревью лучше, чем правка внутри питона. """ import re @@ -16,60 +17,6 @@ from clickstream_generator.schema import COLUMNS, Column, ColumnGroup # Состав решён мастер-спекой (раздел 1.2) и в этом тикете не переоткрывается. EXPECTED_COLUMN_COUNT = 47 -# Тот же состав, переписанный с мастер-спеки отдельно от контракта: группа, -# имя, тип. Дубль намеренный — только независимая запись ловит молчаливое -# переименование колонки, подмену типа или перестановку. Правка контракта без -# правки спеки краснеет здесь, и это единственный способ узнать о ней вовремя. -MASTER_SPEC_COMPOSITION = ( - (ColumnGroup.IDENTIFIERS, "WatchID", "UInt64"), - (ColumnGroup.IDENTIFIERS, "VisitID", "UInt64"), - (ColumnGroup.IDENTIFIERS, "ClientID", "UInt64"), - (ColumnGroup.IDENTIFIERS, "CounterID", "UInt32"), - (ColumnGroup.IDENTIFIERS, "EventDate", "Date"), - (ColumnGroup.IDENTIFIERS, "UTCEventTime", "DateTime"), - (ColumnGroup.IDENTIFIERS, "ClientTimeZone", "Int16"), - (ColumnGroup.IDENTIFIERS, "EventType", "LowCardinality(String)"), - (ColumnGroup.IDENTIFIERS, "Sign", "Int8"), - (ColumnGroup.PAGE, "URL", "String"), - (ColumnGroup.PAGE, "Referer", "String"), - (ColumnGroup.PAGE, "Title", "String"), - (ColumnGroup.PAGE, "UTMSource", "String"), - (ColumnGroup.PAGE, "UTMMedium", "String"), - (ColumnGroup.PAGE, "UTMCampaign", "String"), - (ColumnGroup.PAGE, "UTMContent", "String"), - (ColumnGroup.PAGE, "UTMTerm", "String"), - (ColumnGroup.PAGE, "LastTrafficSource", "String"), - (ColumnGroup.PAGE, "HasGCLID", "UInt8"), - (ColumnGroup.PAGE, "YCLID", "UInt64"), - (ColumnGroup.CLIENT, "Browser", "String"), - (ColumnGroup.CLIENT, "BrowserMajorVersion", "UInt16"), - (ColumnGroup.CLIENT, "BrowserLanguage", "String"), - (ColumnGroup.CLIENT, "OperatingSystem", "String"), - (ColumnGroup.CLIENT, "OperatingSystemRoot", "String"), - (ColumnGroup.CLIENT, "DeviceCategory", "UInt8"), - (ColumnGroup.CLIENT, "MobilePhoneModel", "String"), - (ColumnGroup.CLIENT, "ScreenWidth", "UInt16"), - (ColumnGroup.CLIENT, "ScreenHeight", "UInt16"), - (ColumnGroup.CLIENT, "IPAddress", "String"), - (ColumnGroup.CLIENT, "RegionCountry", "String"), - (ColumnGroup.CLIENT, "RegionCity", "String"), - (ColumnGroup.CLIENT, "RegionCountryID", "UInt32"), - (ColumnGroup.CLIENT, "RegionCityID", "UInt32"), - (ColumnGroup.PARAMS, "GoalsReached", "Array(UInt32)"), - (ColumnGroup.PARAMS, "ParsedParamsKey1", "Array(String)"), - (ColumnGroup.ECOMMERCE, "purchaseID", "Array(String)"), - (ColumnGroup.ECOMMERCE, "purchaseRevenue", "Array(Float64)"), - (ColumnGroup.ECOMMERCE, "purchaseCurrency", "Array(String)"), - (ColumnGroup.ECOMMERCE, "purchaseCoupon", "Array(String)"), - (ColumnGroup.ECOMMERCE, "productID", "Array(String)"), - (ColumnGroup.ECOMMERCE, "productName", "Array(String)"), - (ColumnGroup.ECOMMERCE, "productCategory", "Array(String)"), - (ColumnGroup.ECOMMERCE, "productPrice", "Array(Int64)"), - (ColumnGroup.ECOMMERCE, "productQuantity", "Array(UInt64)"), - (ColumnGroup.ECOMMERCE, "productEventType", "Array(String)"), - (ColumnGroup.ECOMMERCE, "ecommerce", "String"), -) - # Соответствие «тип ClickHouse — тип numpy», записанное независимо от # контракта: если пара в контракте разъедется, сойтись они уже не смогут. NUMPY_BY_CLICKHOUSE_TYPE = { @@ -87,62 +34,8 @@ NUMPY_BY_CLICKHOUSE_TYPE = { "DateTime": "datetime64[s]", } -# Имена для DDS — не производная от имён Метрики, а решение тикета #36: -# вывести их правилом нельзя (акронимы, «timezone» одним словом), поэтому -# сверять их не с чем, кроме такой же независимой записи. Без неё осмысленно -# неверное имя молча уезжает в опубликованное описание выгрузки. -EXPECTED_DDS_NAMES = { - "WatchID": "watch_id", - "VisitID": "visit_id", - "ClientID": "client_id", - "CounterID": "counter_id", - "EventDate": "event_date", - "UTCEventTime": "utc_event_time", - "ClientTimeZone": "client_timezone", - "EventType": "event_type", - "Sign": "sign", - "URL": "url", - "Referer": "referer", - "Title": "title", - "UTMSource": "utm_source", - "UTMMedium": "utm_medium", - "UTMCampaign": "utm_campaign", - "UTMContent": "utm_content", - "UTMTerm": "utm_term", - "LastTrafficSource": "last_traffic_source", - "HasGCLID": "has_gclid", - "YCLID": "yclid", - "Browser": "browser", - "BrowserMajorVersion": "browser_major_version", - "BrowserLanguage": "browser_language", - "OperatingSystem": "operating_system", - "OperatingSystemRoot": "operating_system_root", - "DeviceCategory": "device_category", - "MobilePhoneModel": "mobile_phone_model", - "ScreenWidth": "screen_width", - "ScreenHeight": "screen_height", - "IPAddress": "ip_address", - "RegionCountry": "region_country", - "RegionCity": "region_city", - "RegionCountryID": "region_country_id", - "RegionCityID": "region_city_id", - "GoalsReached": "goals_reached", - "ParsedParamsKey1": "parsed_params_key1", - "purchaseID": "purchase_id", - "purchaseRevenue": "purchase_revenue", - "purchaseCurrency": "purchase_currency", - "purchaseCoupon": "purchase_coupon", - "productID": "product_id", - "productName": "product_name", - "productCategory": "product_category", - "productPrice": "product_price", - "productQuantity": "product_quantity", - "productEventType": "product_event_type", - "ecommerce": "ecommerce", -} - METRICA_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") -DDS_NAME = re.compile(r"^[a-z][a-z0-9_]*$") +NORMALIZED_NAME = re.compile(r"^[a-z][a-z0-9_]*$") ARRAY_TYPE = re.compile(r"^Array\((.+)\)$") @@ -160,35 +53,22 @@ def test_column_count(): assert len(COLUMNS) == EXPECTED_COLUMN_COUNT -def test_composition_matches_master_spec(): - """Состав, имена, типы и порядок — те же, что в разделе 1.2 мастер-спеки.""" - composition = tuple( - (column.group, column.name, column.clickhouse_type) for column in COLUMNS - ) - assert composition == MASTER_SPEC_COMPOSITION - - def test_metrica_names_are_unique(): names = [column.name for column in COLUMNS] assert len(set(names)) == len(names) -def test_dds_names_are_unique(): - names = [column.dds_name for column in COLUMNS] +def test_normalized_names_are_unique(): + names = [column.normalized_name for column in COLUMNS] assert len(set(names)) == len(names) -def test_dds_names_are_the_ones_we_chose(): - """Переименование колонки в DDS — решение, а не правка мимоходом.""" - assert {column.name: column.dds_name for column in COLUMNS} == EXPECTED_DDS_NAMES - - @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) def test_attributes_are_filled(column: Column): assert column.name.strip() assert column.clickhouse_type.strip() assert column.numpy_dtype.strip() - assert column.dds_name.strip() + assert column.normalized_name.strip() assert column.comment.strip() assert isinstance(column.group, ColumnGroup) @@ -196,7 +76,7 @@ def test_attributes_are_filled(column: Column): @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) def test_names_keep_their_styles(column: Column): assert METRICA_NAME.match(column.name), "имя источника — как в выгрузке Метрики" - assert DDS_NAME.match(column.dds_name), "имя для DDS — snake_case" + assert NORMALIZED_NAME.match(column.normalized_name), "наше имя — snake_case" @pytest.mark.parametrize("column", COLUMNS, ids=lambda column: column.name) diff --git a/generator/tests/test_schema_doc.py b/generator/tests/test_schema_doc.py index 9b154f8..6643070 100644 --- a/generator/tests/test_schema_doc.py +++ b/generator/tests/test_schema_doc.py @@ -51,7 +51,7 @@ def test_column_is_described_in_full(column: Column, rendered: str): column.name, column.clickhouse_type, column.numpy_dtype, - column.dds_name, + column.normalized_name, column.comment, ) assert any( -- 2.54.0 From 3ba40ffdf2ac3d0ce9655fd8b4a88fba620f3b84 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 23:15:53 +0300 Subject: [PATCH 5/5] =?UTF-8?q?docs(context):=20=C2=AB=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D0=B2=20DDS=C2=BB=20=D0=B2=D1=8B=D1=87=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D0=BE=20=D0=B8=D0=B7=20=D0=B8=D0=B7=D0=B1?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D0=B5=D0=BC=D1=8B=D1=85=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - запрет был лишним: dds.v_event по разделу 7 мастер-спеки эти имена и берёт, так что синоним не всегда ошибка. - Что: - в статье «Нормализованное имя» снята строка _Избегать_; различие с именем атрибута в модели данных осталось в теле статьи. - Проверка: - вычитка. Co-Authored-By: Claude Opus 5 --- CONTEXT.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 59b72d5..081af02 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -47,7 +47,6 @@ Python-модуль с описателями колонок события — Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели данных: слой DDS складывает модель и называет атрибуты по ней. -_Избегать_: имя в DDS **Описание выгрузки**: Публичная документация формата события: таблица колонок, собранная из -- 2.54.0