From 1f96245c419a219e5d4e97a3a14cdf1a6332652e Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 22:32:14 +0300 Subject: [PATCH] =?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)