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,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).
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Генератор кликстрима учебного стенда.
|
||||
|
||||
Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках
|
||||
выгрузки. Из него выводятся сам генератор, его валидация и «описание
|
||||
выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию.
|
||||
"""
|
||||
@@ -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 внутри колонки",
|
||||
),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Generated
+141
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user