feat(generator): каркас проекта и контракт схемы события
- Зачем:
- этап 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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), "порядок групп разошёлся с их объявлением"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user