fix(generator): правки по двум линиям ревью — сторож состава и честные обещания

- Зачем:
  - линия постановки: тест инвариантов обещал ловить дрейф колонок, но
    переименование 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:32:14 +03:00
co-authored by Claude Opus 5
parent 2890c7f9fb
commit 1f96245c41
5 changed files with 94 additions and 26 deletions
+3 -3
View File
@@ -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` | тип устройства кодами 14, как у Метрики; у неё это строка — отступление стенда |
| 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` | высота экрана в пикселях |
+11 -7
View File
@@ -4,15 +4,19 @@
1.11.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",
@@ -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}")
+62
View File
@@ -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)
+9 -6
View File
@@ -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)