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" }, +]