diff --git a/README.md b/README.md index efd62134..72dd8ac2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - [Часть 2. CRUD и корзина для интернет-магазина](src/part2/TASK.md) — нужно реализовать хранилище товаров в памяти (список кортежей), операции создания, чтения, изменения и удаления записей, аккуратную работу с ценами через `Decimal` и корзину покупателя, в которую товары переносятся со склада и обратно без потери количества. - [Часть 3. Нормализация имён и консоль администратора](src/part3/TASK.md) — нужно привести названия товаров к единому написанию и следить за их уникальностью, научить хранилище печатать себя выровненной текстовой таблицей и написать интерактивную консоль администратора, через которую можно создавать, просматривать, изменять и удалять товары. - [Часть 4. Хранилище-словарь и UUID-идентификаторы](src/part4/TASK.md) — нужно перевести хранилище со списка кортежей на словарь, выдавать товарам идентификаторы в виде UUID, завести индекс имён для быстрой проверки уникальности, научить `update_product` менять только часть полей и оформить продуктовую корзину в виде словаря. +- [Часть 5. Snowflake-идентификаторы для интернет-магазина](src/part5/TASK.md) — нужно вернуться к Snowflake из части 1 и оформить его как генератор со своим счётчиком, перевести товары с UUID на целочисленные идентификаторы и написать параметрический декоратор, который считает успешные и неуспешные вызовы операций хранилища, а консоль администратора научить печатать эту статистику командой `stats`. ## Дедлайны @@ -21,7 +22,8 @@ | [Часть 1. Генератор идентификаторов Twitter Snowflake](src/part1/TASK.md) | 25.09.2026 | дедлайн истек | | [Часть 2. CRUD и корзина для интернет-магазина](src/part2/TASK.md) | 02.10.2026 | дедлайн истек | | [Часть 3. Нормализация имён и консоль администратора](src/part3/TASK.md) | 12.10.2026 | дедлайн истек | -| [Часть 4. Хранилище-словарь и UUID-идентификаторы](src/part4/TASK.md) | 01.11.2026 | активно | +| [Часть 4. Хранилище-словарь и UUID-идентификаторы](src/part4/TASK.md) | 01.11.2026 | дедлайн истек | +| [Часть 5. Snowflake-идентификаторы для интернет-магазина](src/part5/TASK.md) | 15.11.2026 | активно | ## Критерии оценивания diff --git a/src/part5/TASK.md b/src/part5/TASK.md new file mode 100644 index 00000000..c3fa0ec2 --- /dev/null +++ b/src/part5/TASK.md @@ -0,0 +1,557 @@ +# Задача: Snowflake-идентификаторы для интернет-магазина + +## Легенда + +Наш интернет-магазин уже умеет многое: товары лежат в хранилище-словаре, есть +индекс имён, операции над хранилищем (создание, чтение, изменение и удаление +товаров), операции с корзиной (добавить товар и убрать товар) и консоль +администратора. Идентификаторы товаров при этом выдаются как UUID. Пока магазин +небольшой, этого хватает. Но по мере роста появляются новые неудобства: + +- UUID — случайная строка из 36 символов: по ней нельзя понять, какой товар + появился раньше, а ключи, которые хотя бы примерно растут со временем, + хранить и сравнивать дешевле; +- Snowflake из части 1 решает эту проблему, но `generate_snowflake_id` не + запоминала, сколько идентификаторов уже выдано в текущую миллисекунду: номер + внутри миллисекунды (`sequence_id`) приходилось передавать ей аргументом. Если + товары создаются в нескольких местах программы, каждому месту пришлось бы + вести свой счётчик, и два места легко выдали бы одинаковые идентификаторы; +- мы совсем не видим, как используется наше хранилище: какие операции + выполняются чаще всего и сколько вызовов заканчиваются неудачей. + +Первые две проблемы знакомы любой системе, где идентификаторы выдаются «на лету»: +поэтому Twitter и сделал Snowflake, а в реальных проектах генератор обычно +оформляют как отдельный объект, который сам помнит своё состояние. Третья +проблема тоже типична: такие счётчики вызовов в промышленных системах называют +метриками, и собирают их не внутри самих операций, а «снаружи», не меняя код +операций. + +В этой части мы займёмся решением этих проблем: напишем генератор Snowflake, +который сам ведёт счётчик, переведём товары на целочисленные идентификаторы и +научимся считать успешные и неуспешные вызовы операций хранилища, не меняя их +код. Результат будет использоваться в дальнейшей работе над магазином как основа +для всего, что мы построим поверх него. + +## Контекст + +Часть 5 продолжает [часть 4](../part4/TASK.md): тот же интернет-магазин, то же +хранилище-словарь, тот же индекс имён, те же CRUD-операции, те же операции +с корзиной и консоль администратора. Меняется **источник идентификаторов**. Вместо случайного UUID +(строки) товар получает целое число в формате +[Twitter Snowflake](https://en.wikipedia.org/wiki/Snowflake_ID): оно растёт со +временем создания, поэтому товары примерно упорядочены по порядку добавления. + +Модули `constants.py` и `snowflake.py` переходят из [части 1](../part1/TASK.md). +Формат идентификатора, константы, `read_current_millis` и три декодера остаются +прежними. Что меняется: + +- в `snowflake.py` функция `generate_snowflake_id(sequence_id, node_id, + epoch_ms)`, где счётчик внутри миллисекунды вёл вызывающий код, заменяется + функцией `get_snowflake_id_generator(node_id, epoch_ms)`. Она возвращает + другую функцию без аргументов, при каждом вызове которой получается новый + идентификатор. Счётчик внутри миллисекунды эта функция ведёт сама; +- `create_product` больше не вызывает функцию генерации идентификатора + напрямую: раньше она была жёстко прописана в его теле, теперь её передают + аргументом `generate_id`. Так алгоритм выдачи идентификаторов можно заменить, + не трогая код самой операции; +- идентификатор товара — `int`, а не `str`. Это касается хранилища, корзины, + консоли и всех сигнатур; +- в `crud.py` появляется функция `generate_snowflake_id()`: она выдаёт + Snowflake-идентификаторы через общий генератор модуля и подходит в качестве + аргумента `generate_id` для `create_product`. Функция `generate_product_id`, + выдававшая UUID в части 4, удаляется. + +Кроме смены идентификаторов, в части 5 появляется **учёт статистики вызовов**: + +- новый модуль `decorators.py` с параметрическим декоратором `collect_stats`, + который считает, сколько раз декорируемая функция отработала успешно, а + сколько раз неуспешно. Счётчики он складывает в общий словарь — реестр; +- четыре операции `crud.py` (`create_product`, `read_product`, `update_product`, + `delete_product`) обёрнуты этим декоратором, а их счётчики лежат в реестре + `STATS_REGISTRY`; +- в `utils.py` появляется функция `get_stats_str_representation`: она берёт + реестр `STATS_REGISTRY` и возвращает строку с текстовой таблицей в том же + формате, что и таблица товаров из части 3. В таблице одна строка на операцию: + сколько вызовов было успешных, сколько неуспешных и какую долю составляют + успешные; +- в консоли администратора появляется команда `stats`, которая выводит на экран + таблицу статистики, полученную из `get_stats_str_representation`. + +Если поведение не менялось, оно здесь не расписывается: реализуйте его так, как +в своём решении части 4, см. [описание части 4](../part4/TASK.md). Это относится к формату записи товара, индексу имён, +нормализации имён и цен и порядку шагов в `update_product`, `delete_product`, +`read_product` и в корзине. Единственное изменение в этих местах — тип +идентификатора. + +--- + +## Формат идентификатора и модуль `constants.py` + +Без изменений относительно части 1: идентификатор — положительное целое число, +меньшее `2**63`. В него упакованы три поля: время (41 бит), номер узла (10 бит) +и счётчик внутри миллисекунды (12 бит). Константы те же: `EPOCH_MS_DEFAULT`, +`NODE_ID_DEFAULT`, `TIMESTAMP_BITS`, `NODE_ID_BITS`, `SEQUENCE_ID_BITS`, +`TIMESTAMP_MS_MAX`, `NODE_ID_MAX`, `SEQUENCE_ID_MAX`, с теми же значениями и +правилом «максимумы выводятся из ширин». + +--- + +## Модуль `snowflake.py` + +Функции `read_current_millis(epoch_ms)`, `decode_timestamp_ms(snowflake_id, +epoch_ms)`, `decode_node_id(snowflake_id)` и `decode_sequence_id(snowflake_id)` +остаются такими же, как в части 1. + +Текущее время нужно получать с помощью модуля `time` из стандартной +библиотеки. + +Функция `generate_snowflake_id` из части 1 больше не нужна: вместо неё +`get_snowflake_id_generator`, описанная ниже. + +### `get_snowflake_id_generator(node_id: int = NODE_ID_DEFAULT, epoch_ms: int = EPOCH_MS_DEFAULT) -> Callable[[], int | None] | None` + +Один раз проверяет настройки и возвращает функцию без аргументов, которую +дальше называем **генератором**. Каждый вызов генератора выдаёт следующий +идентификатор. + +По сравнению с частью 1 проверка `node_id` теперь выполняется при создании +генератора, а не при выдаче идентификатора. Появилась новая проверка эпохи. +Проверка `sequence_id` превратилась в проверку «счётчик исчерпан», потому что +счётчик теперь ведёт сам генератор. + +- **Вход:** `node_id` — номер машины, от `0` до `NODE_ID_MAX` включительно; + `epoch_ms` — эпоха в миллисекундах Unix. Оба аргумента необязательные, значения + по умолчанию — `NODE_ID_DEFAULT` и `EPOCH_MS_DEFAULT`. +- **Выход:** генератор или `None`. + +Проверки при создании идут в таком порядке. Создание генератора **не выдаёт ни +одного идентификатора** и ничего не печатает, если настройки верные. + +1. `node_id` вне `[0, NODE_ID_MAX]`. Верхняя граница включается, значит + `NODE_ID_MAX` ещё допустим, а `-1` и `NODE_ID_MAX + 1` нет. Печатается + `node_id must be in [0, ], got `, возвращается `None`. +2. Эпоха лежит в будущем, то есть `read_current_millis(epoch_ms) < 0`. + Печатается `epoch_ms must not be in the future, got `, + возвращается `None`. Эпоха ровно «сейчас» допустима. + +#### Что выдаёт генератор + +Каждый вызов генератора возвращает новый идентификатор в формате Snowflake ID +(см. часть 1). Время в нём отсчитывается от `epoch_ms`, номер узла равен `node_id` +генератора. + +- Под счётчиком здесь понимается `sequence_id` — порядковый номер + идентификатора внутри одной миллисекунды (последняя часть Snowflake ID). +- Первый идентификатор в миллисекунде получает `sequence_id = 0`, каждый + следующий в той же миллисекунде на единицу больше. В новой миллисекунде + `sequence_id` снова начинается с `0`. +- В одной миллисекунде можно получить `SEQUENCE_ID_MAX + 1` разных + идентификаторов, по одному на каждое значение `sequence_id`. +- Каждый генератор ведёт свой счёт, генераторы не влияют друг на друга. + +Особые случаи при вызове, в порядке проверок. Во всех случаях печатается +сообщение и возвращается `None`: + +1. **Счётчик исчерпан.** В одной миллисекунде запросили больше, чем + `SEQUENCE_ID_MAX + 1` идентификаторов. Каждый лишний вызов печатает + `sequence_id must be in [0, ], got `, где + `` — номер, до которого дошёл бы счётчик. В следующей + миллисекунде генератор снова выдаёт идентификаторы, начиная со счётчика `0`. +2. **Время не помещается в поле.** От эпохи прошло больше `TIMESTAMP_MS_MAX` + миллисекунд. Печатается `timestamp <прошло> ms overflows ms`, + где `<прошло>` — число миллисекунд от эпохи до текущего момента. Если + прошло ровно `TIMESTAMP_MS_MAX`, идентификатор ещё выдаётся. + +#### Примеры + +Генератор создаётся один раз, дальше его только вызывают. + +> **Важно:** идентификаторы зависят от текущего времени, поэтому числа в +> примерах взяты для иллюстрации. У вас значения будут другими. Неизменны +> только закономерности: номер узла, рост значений и поведение счётчика. + +Два вызова подряд, попавшие в одну и ту же миллисекунду. Номер узла у них +одинаковый, а счётчик внутри миллисекунды растёт с нуля: + +```python +generate_id = get_snowflake_id_generator(node_id=5) + +first = generate_id() +second = generate_id() + +print(first) # 2119399534088966144 +print(second) # 2119399534088966145 + +print(decode_timestamp_ms(first, EPOCH_MS_DEFAULT)) # 1794139200123 +print(decode_timestamp_ms(second, EPOCH_MS_DEFAULT)) # 1794139200123 +print(decode_node_id(first)) # 5 +print(decode_node_id(second)) # 5 +print(decode_sequence_id(first)) # 0 +print(decode_sequence_id(second)) # 1 +``` + +Если следующий вызов пришёлся уже на следующую миллисекунду, время в +идентификаторе увеличивается, а счётчик снова начинается с нуля: + +```python +third = generate_id() + +print(third) # 2119399534093160448 +print(decode_timestamp_ms(third, EPOCH_MS_DEFAULT)) # 1794139200124 +print(decode_sequence_id(third)) # 0 +``` + +Генераторы не влияют друг на друга: у каждого свой счётчик и свой номер +узла. + +```python +generate_a = get_snowflake_id_generator(node_id=1) +generate_b = get_snowflake_id_generator(node_id=2) + +print(generate_a()) # 2119399534101532672 (узел 1, счётчик 0) +print(generate_b()) # 2119399534101536768 (узел 2, счётчик 0) +``` + +Неверные настройки отвергаются при создании, до выдачи какого-либо +идентификатора: + +```python +generator = get_snowflake_id_generator(node_id=NODE_ID_MAX + 1) +# сама функция напечатает: node_id must be in [0, 1023], got 1024 + +print(generator) # None +``` + +--- + +## Модуль `decorators.py` + +В модуле определён один декоратор, который считает успешные и неуспешные вызовы +функции. Он ничего не знает о магазине: подойдёт любая функция, которая +возвращает значение либо `None`. + +### Типы и константы + +- `SUCCESS_KEY = "success"` и `FAILS_KEY = "fails"` — имена двух счётчиков + внутри записи статистики. +- `Stats` — псевдоним типа `dict[str, int]`: счётчики одной функции. В записи + ровно два ключа, `SUCCESS_KEY` (число успешных вызовов) и `FAILS_KEY` (число + неуспешных). +- `Registry` — псевдоним типа `dict[str, Stats]`: счётчики всех функций, ключ — + имя, под которым функцию зарегистрировали (например, имя консольной команды). + +### `collect_stats(key: str, registry: Registry) -> Callable` + +Фабрика декоратора: сама `collect_stats` декоратором не является, она +возвращает декоратор, настроенный на конкретный ключ и реестр. Этот декоратор +заменяет функцию обёрткой, которая для вызывающего кода выглядит так же, как +исходная функция (принимает те же аргументы и возвращает тот же результат), но +дополнительно считает вызовы. + +```python +registry = {} + +@collect_stats("divide", registry) +def divide(a, b): + if b == 0: + print("division by zero") + return None + return a / b + +print(registry) # {'divide': {'success': 0, 'fails': 0}} + +print(divide(10, 4)) # 2.5 +print(divide(0, 5)) # 0.0 (ложный результат, но не None: считается успехом) +print(divide(1, 0)) # division by zero (печатает сама divide) + # None + +print(registry) # {'divide': {'success': 2, 'fails': 1}} +``` + +- **Вход:** `key` — ключ в реестре, под которым будут лежать счётчики этой + функции; `registry` — словарь, в который будут попадать счётчики. Он меняется на месте при + декорировании и при каждом вызове. +- **Выход:** декоратор для функции, возвращающей значение либо `None`. + +**При декорировании** в `registry[key]` кладётся новая запись +`{"success": 0, "fails": 0}`. + +**При каждом вызове** обёртка вызывает исходную функцию с теми же позиционными +и именованными аргументами и возвращает её результат без изменений: + +- если функция вернула `None`, счётчик `FAILS_KEY` растёт на единицу; +- любой другой результат растит `SUCCESS_KEY`, **в том числе «ложный»**, такой + как `0`. + +После декорирования метаинформация функции не должна измениться: например, +имя (`__name__`) и описание (`__doc__`) остаются такими же, как у исходной +функции. + +**Ключ уже занят.** Если `key` уже есть в `registry`, при декорировании +печатается `stats key is already registered` (ключ в виде `repr`, +например `stats key 'create' is already registered`), а в качестве результата +возвращается **сама исходная функция без обёртки**. Существующая запись реестра +остаётся как была. Вызовы такой функции ни в какие счётчики не попадают. + +Разные ключи ведут независимые счётчики, даже если лежат в одном реестре. + +--- + +## Модуль `crud.py` + +### Статистика операций + +- `STATS_REGISTRY: Registry` — модульный реестр счётчиков. Живёт столько же, + сколько процесс, и общий для всех хранилищ. +- `CREATE_KEY = "create"`, `READ_KEY = "read"`, `UPDATE_KEY = "update"`, + `DELETE_KEY = "delete"` — ключи записей в реестре. Они совпадают с именами + консольных команд. +- Функции `create_product`, `read_product`, `update_product` и `delete_product` + декорируются `collect_stats`: каждая со своим ключом (`CREATE_KEY`, + `READ_KEY`, `UPDATE_KEY`, `DELETE_KEY`) и общим реестром `STATS_REGISTRY`. + Поэтому сразу после импорта модуля `STATS_REGISTRY` содержит **ровно четыре + записи, по одной на операцию**, и во всех записях счётчики нулевые: + + ```python + print(STATS_REGISTRY) + # {'create': {'success': 0, 'fails': 0}, + # 'read': {'success': 0, 'fails': 0}, + # 'update': {'success': 0, 'fails': 0}, + # 'delete': {'success': 0, 'fails': 0}} + ``` + + Вызов операции считается неуспешным, когда она вернула `None`, то есть + напечатала сообщение и ничего не изменила. +- `generate_snowflake_id` декоратором **не оборачивается**: её вызовы не меняют + ни одной записи реестра. +- Функции корзины (`add_to_cart` и `remove_from_cart`) не декорируются: + отдельной статистики у корзины нет. + +### `generate_snowflake_id() -> int | None` + +Адаптер между `create_product` и результатом `get_snowflake_id_generator` из +`snowflake.py`. `create_product` ожидает функцию без аргументов, которая +возвращает идентификатор или `None`. Но `get_snowflake_id_generator` при +некорректных настройках возвращает не функцию, а `None`, и такой результат +нельзя передать в `create_product` напрямую: вызов `None` приведёт к ошибке. +Поэтому между ними нужен адаптер: `generate_snowflake_id` всегда остаётся +вызываемой функцией, и `create_product` может вызывать её одинаково, независимо +от того, получился ли у `get_snowflake_id_generator` рабочий генератор или +`None`. Для этого она обращается к **общему (глобальному) генератору модуля** +и возвращает то, что он выдал, а если значение глобального генератора — `None`, возвращает `None`. + +В модуле `crud.py` один раз, при импорте, создаётся общий генератор: результат +`get_snowflake_id_generator()` с номером узла и эпохой по умолчанию. Имя +переменной, в которой он хранится, на ваше усмотрение. Функция +`generate_snowflake_id` использует этот созданный генератор напрямую: вызывает +его сама и не создаёт других. + +- **Вход:** ничего. +- **Выход:** + - если значение общего генератора не `None`, то результат его вызова, без + изменений. Это либо идентификатор (целое число больше нуля и меньше + `2**63`), либо `None`, если генератор не смог выдать идентификатор (счётчик + исчерпан или время не помещается в поле). В последнем случае сообщение + печатает сам генератор; + - если значение общего генератора — `None`, то `None`. Сама `generate_snowflake_id` + при этом ничего не печатает: сообщение уже было напечатано при создании + генератора. + +Все вызовы идут через **один общий генератор** на модуль. Поэтому +идентификаторы из разных вызовов различаются между собой и растут. Создавать +свой генератор на каждый вызов нельзя: два вызова в одну миллисекунду дали бы +одинаковые значения. + +#### Примеры + +Как и раньше, числа зависят от времени и у вас будут другими. + +**Значение общего генератора не `None`** (так бывает при настройках по умолчанию). Номер +узла равен `NODE_ID_DEFAULT`, то есть `1`, а счётчик растёт с каждым вызовом в +пределах одной миллисекунды: + +```python +first = generate_snowflake_id() +second = generate_snowflake_id() + +print(first) # 2119399534101532672 +print(second) # 2119399534101532673 +print(decode_node_id(first)) # 1 +print(decode_sequence_id(first)) # 0 +print(decode_sequence_id(second)) # 1 +``` + +**Значение общего генератора — `None`.** Допустим, при импорте модуля настройки по +умолчанию оказались некорректными (например, `NODE_ID_DEFAULT` вышел за +пределы `[0, NODE_ID_MAX]`). Тогда при импорте один раз напечатается +`node_id must be in [0, 1023], got 1024`, а общий генератор станет `None`. +Каждый следующий вызов `generate_snowflake_id` возвращает `None` и ничего +не печатает: + +```python +print(generate_snowflake_id()) # None +print(generate_snowflake_id()) # None +``` + +В этом случае `create_product(..., generate_id=generate_snowflake_id)` тоже +вернёт `None` и не изменит ни хранилище, ни индекс имён. + +### `create_product(storage: dict[int, Product], product_names: set[str], fields: dict[str, Any], generate_id: Callable[[], int | None]) -> int | None` + +- **Вход:** хранилище, индекс имён, `fields` (все три ключа `NAME_KEY`, + `PRICE_KEY`, `QUANTITY_KEY` обязательны, как в части 4) и **функция без + аргументов `generate_id`**, которая возвращает идентификатор нового товара или + `None`, если не смогла его выдать. +- **Выход:** идентификатор, который вернул `generate_id`, либо `None`. + +Функция выполняет шаги в таком порядке. Если на каком-то шаге вернулся `None`, +хранилище и индекс не меняются: + +1. Имя нормализуется. Если оно пустое, печатается `product name must not be blank` + и возвращается `None`. +2. Если нормализованное имя уже есть в `product_names`, печатается + `product name '<имя>' is already taken` и возвращается `None`. +3. Вызывается `generate_id()`. Если вернулось `None`, `create_product` + возвращает `None` и **сама ничего не печатает**: сообщение уже напечатал + `generate_id`. +4. Иначе запись `{name: нормализованное имя, price: normalize_price(цена), + quantity: количество}` кладётся в `storage` под полученным идентификатором, + имя добавляется в `product_names`, возвращается идентификатор. + +`generate_id` вызывается один раз на каждый создаваемый товар и только после +шагов 1 и 2: если создание отклонено из-за имени, идентификатор не выдаётся. + +### `read_product`, `update_product`, `delete_product` + +Поведение, сообщения и порядок шагов — как в части 4. Меняются только сигнатуры: +идентификатор `product_id` теперь `int`, хранилище — `dict[int, Product]`. +`delete_product` возвращает `int | None`. Сообщение об отсутствующем товаре +по-прежнему содержит идентификатор: `no product with id `. + +--- + +## Модуль `cart.py` + +Поведение, порядок шагов и сообщения — как в части 4. Меняются только типы: +идентификатор товара — `int`, корзина — `cart: dict[int, int]`, хранилище — +`dict[int, Product]`. Обе функции по-прежнему возвращают количество единиц +товара в корзине после операции либо `None`, а сообщения об ошибках содержат +идентификатор товара. + +--- + +## Модуль `utils.py` + +`normalize_price`, `normalize_product_name`, `PRICE_PRECISION`, `PRICE_STEP` и +`TABLE_HEADERS` — как в части 4. Типы изменились: `get_storage_str_representation` +принимает `dict[int, Product]`. В столбец `ID` идентификатор попадает как +десятичное число. Ширина столбца считается по самому длинному значению, +включая идентификаторы Snowflake из 19 цифр, например `1288834974657000001`. + +Добавляется функционал для построения таблицы со статистикой. Эта таблица +оформляется так же, как таблица товаров: формат, выравнивание, ширина столбцов +и вид пустой таблицы описаны в [части 3](../part3/TASK.md). Заголовки столбцов — кортеж +`STATS_TABLE_HEADERS = ("command name", "success", "fails", "success rate")`. + +### `get_stats_str_representation(registry: Registry) -> str` + +- **Вход:** `registry` — реестр счётчиков (см. `decorators.py`). +- **Выход:** многострочная строка без завершающего перевода строки: строка + заголовков, разделительная строка и по одной строке на каждую запись реестра + **в порядке вставки**. Для пустого реестра остаются только первые две строки, + ширина столбцов равна ширине заголовков. + +Ячейки строки: имя команды (ключ реестра), число успешных вызовов, число +неуспешных вызовов и доля успешных вызовов. + +**Доля успешных вызовов** — это число успешных вызовов, делённое на число всех вызовов, в процентах, +ровно с двумя знаками после запятой и знаком `%`, с округлением до ближайшего +(`66.67%`, `100.00%`, `3.23%`). Команда, которую ни разу не вызывали, получает +`0.00%`, а не ошибку. + +``` +| command name | success | fails | success rate | +|--------------|---------|-------|--------------| +| create | 2 | 1 | 66.67% | +| read | 0 | 0 | 0.00% | +| delete | 5 | 0 | 100.00% | +``` + +Длинное имя команды расширяет свой столбец: + +``` +| command name | success | fails | success rate | +|-------------------------|---------|-------|--------------| +| update-everything-twice | 1 | 30 | 3.23% | +``` + +--- + +## Модуль `run_admin.py` + +Набор команд, формат ввода, константы `PROMPT` и `HELP_TEXT`, функции +`show_help` и `print_result`, сообщение `'<строка>' is not a command` — как в +части 4. Отличия: + +- `create <имя...> <цена> <количество>` передаёт в `create_product` + `generate_snowflake_id` из `crud.py`. Напечатанный идентификатор — целое + число, а его поле времени (`decode_timestamp_ms`) совпадает с моментом + создания товара с точностью до нескольких секунд. +- `` в командах `read`, `update` и `delete` теперь **приводится к `int`**, + в отличие от части 4, где оно оставалось строкой. +- Тип хранилища — `dict[int, Product]`. `main()` создаёт пустое хранилище и + пустой индекс, как в части 4. +- Новая команда `stats` (ровно одно слово в строке) печатает + `get_stats_str_representation(STATS_REGISTRY)`: статистику четырёх CRUD-команд + за всё время работы процесса. Хранилище и индекс команда не меняет. Строка + `stats` с лишними словами (например, `stats create`) командой не считается: + для неё печатается `'<строка>' is not a command`. +- В `HELP_TEXT` добавляется строка про `stats`: она идёт последней в перечне + команд, сразу после `delete`. Остальной текст справки прежний. Пример + справки целиком: + + ``` + Available commands: + help show this message + exit leave the console + show print the whole store as a table + create add a product, print its new id + read print the product with that id + update overwrite that product's fields + delete remove the product with that id + stats print call statistics of the CRUD commands + + For create and update the price and quantity are the last two words of the + line; everything before them is the product name, so it may contain spaces + (for example "Gibson SG Junior") and needs no quoting. + ``` +- Счётчики ведёт слой `crud.py`, а не консоль. Поэтому `create`, `read`, + `update` и `delete` попадают в статистику по результату вызова + соответствующей операции. Строка, чьи аргументы не разобрались (`ValueError` + или `InvalidOperation` вылетели ещё до обращения к `crud.py`), в статистику не + попадает. + +--- + +## Инварианты + +- Три поля идентификатора восстанавливаются обратно: если генератор с номером + `n` и эпохой `e` выдал идентификатор в момент `t`, то `decode_node_id` даёт + `n`, `decode_timestamp_ms(id, e)` даёт `t`, а `decode_sequence_id` даёт номер + вызова внутри этой миллисекунды, начиная с нуля. +- Идентификатор положителен и меньше `2**63`. +- Идентификаторы одного генератора на реальных часах все разные и идут по + возрастанию. +- `product_names` всегда равно `{запись["name"] for запись in storage.values()}` + (см. часть 4). Неуспешный вызов, в том числе отказ `generate_id`, не меняет ни + хранилище, ни индекс. +- После успешного `create_product` товар лежит в `storage` под идентификатором, + который вернула функция. +- Сумма «на складе + в корзине» для каждого товара сохраняется при `add_to_cart` + и `remove_from_cart` (см. часть 4). +- Каждый вызов декорированной операции добавляет ровно единицу ровно к одному + счётчику: `fails`, если вернулся `None`, иначе `success`. Результат операции + и состояние хранилища от подсчёта не зависят. +- Повторная регистрация занятого ключа не меняет ни существующую запись, ни + поведение вторично декорированной функции. +- Выдача идентификатора через `generate_snowflake_id` реестр не меняет. diff --git a/src/part5/__init__.py b/src/part5/__init__.py new file mode 100644 index 00000000..48c0fb86 --- /dev/null +++ b/src/part5/__init__.py @@ -0,0 +1 @@ +"""Homework 8: a stateful Twitter Snowflake identifier generator and its bit layout.""" diff --git a/src/part5/cart.py b/src/part5/cart.py new file mode 100644 index 00000000..a2363b3a --- /dev/null +++ b/src/part5/cart.py @@ -0,0 +1,88 @@ +"""Shopping-cart operations layered on top of the product store. + +The cart is a plain dictionary mapping each product identifier to the +number of units of that product in the cart. Moving units between the +store and the cart keeps the two sides in balance: :func:`add_to_cart` takes +units out of stock, :func:`remove_from_cart` puts them back. + +Like the CRUD layer, the failure path never raises -- the operation prints +an explanatory message to stdout and returns ``None``. +""" + +from .storage import Product + + +# TODO: при необходимости добавьте свои вспомогательные функции + + +def add_to_cart( + storage: dict[int, Product], + product_names: set[str], + cart: dict[int, int], + product_id: int, + quantity: int, +) -> int | None: + """Move ``quantity`` units of ``product_id`` from the store into ``cart``. + + The product is looked up in ``storage``. If it is missing, a message is + printed (by :func:`~src.part5.crud.read_product`) and nothing + changes. If the store holds fewer units than requested, an explanatory + message is printed and nothing changes. Otherwise the store record is + decremented by ``quantity`` and the cart gains ``quantity`` units of + ``product_id`` -- the product is added when the cart had none of it. + + Args: + storage: The product store to draw stock from; modified in place on + success. + product_names: The normalised names in ``storage``; threaded + through to :func:`~src.part5.crud.update_product`, which + keeps it in sync (a quantity-only update never renames a + product, so it is left unchanged here). + cart: The cart to add the units to; modified in place on success. + product_id: The identifier of the product to add. + quantity: Number of units to move into the cart. + + Returns: + The quantity of ``product_id`` in the cart after the addition, or + ``None`` when the product is unknown or the store cannot cover the + request. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return 0 + + +def remove_from_cart( + storage: dict[int, Product], + product_names: set[str], + cart: dict[int, int], + product_id: int, + quantity: int, +) -> int | None: + """Move ``quantity`` units of ``product_id`` from ``cart`` back to the store. + + If the cart holds no units of ``product_id``, a message is printed and + nothing changes. If it holds fewer units than requested, an explanatory + message is printed and nothing changes. Otherwise the cart loses + ``quantity`` units of the product -- the product is dropped from the + cart when its quantity reaches zero -- and the store record gains + ``quantity`` units back. + + Args: + storage: The product store to return stock to; modified in place on + success. + product_names: The normalised names in ``storage``; threaded + through to :func:`~src.part5.crud.update_product`, which + keeps it in sync (a quantity-only update never renames a + product, so it is left unchanged here). + cart: The cart to take the units from; modified in place on + success. + product_id: The identifier of the product to remove. + quantity: Number of units to move back into the store. + + Returns: + The quantity of ``product_id`` left in the cart after the removal + (zero means the product was dropped from the cart), or ``None`` when + the cart does not hold the product or holds too few units. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return 0 diff --git a/src/part5/constants.py b/src/part5/constants.py new file mode 100644 index 00000000..984671bb --- /dev/null +++ b/src/part5/constants.py @@ -0,0 +1,37 @@ +"""Bit layout and default epoch for the Twitter Snowflake ID generator. + +A Snowflake identifier is a 63-bit positive integer packed as follows, from +the most significant bit downwards: + + 1 bit unused sign bit, always zero + 41 bits milliseconds elapsed since a custom epoch + 10 bits node identifier + 12 bits per-millisecond sequence counter + +The shift and mask constants should be derived from the bit widths so that +changing a width keeps everything else consistent. +""" + +from typing import Final + + +# TODO: замените заглушки (0) на корректные значения (используйте своё решение +# части 1). + +# Twitter's original Snowflake epoch: 2010-11-04 01:42:54.657 UTC. +EPOCH_MS_DEFAULT: Final[int] = 0 + +# Node identifier used when the caller does not supply one. +NODE_ID_DEFAULT: Final[int] = 0 + +# Width of each field, in bits. +TIMESTAMP_BITS: Final[int] = 0 +NODE_ID_BITS: Final[int] = 0 +SEQUENCE_ID_BITS: Final[int] = 0 + +# Largest value each field can hold. +TIMESTAMP_MS_MAX: Final[int] = 0 +NODE_ID_MAX: Final[int] = 0 +SEQUENCE_ID_MAX: Final[int] = 0 + +# Здесь можно добавить собственные вспомогательные константы diff --git a/src/part5/crud.py b/src/part5/crud.py new file mode 100644 index 00000000..243b0528 --- /dev/null +++ b/src/part5/crud.py @@ -0,0 +1,191 @@ +"""Create/read/update/delete operations over the in-memory product store. + +Every operation takes the store -- a dictionary mapping product id to its +:data:`~src.part5.storage.Product` record -- as its first argument and +works on it in place. ``create_product``, ``update_product`` and +``delete_product`` additionally take ``product_names``, the ``set[str]`` +of normalised names currently in ``storage`` (see +:mod:`src.part5.storage`), and keep it in sync with ``storage`` on +every successful call. The failure path never raises: the operation +prints an explanatory message to stdout and returns ``None``. + +The identifier of a new product is not chosen by :func:`create_product` +itself: the caller hands in a zero-argument ``generate_id`` function that +returns the next identifier (or ``None`` when it cannot issue one), so the +identifier scheme can be swapped without touching the CRUD code. The module +supplies :func:`generate_snowflake_id`, backed by a Twitter Snowflake +generator (see :mod:`src.part5.snowflake`); the identifiers it issues +grow with creation time, so they are roughly ordered by insertion. + +Every product name that reaches the store is passed through +:func:`~src.part5.utils.normalize_product_name` first. A name that +normalises to an empty string is rejected -- the operation prints a +message and changes nothing. Names are also kept unique -- +:func:`create_product` refuses a normalised name that is already taken, +and :func:`update_product` refuses one that is already taken by another +product -- via an O(1) membership test against ``product_names`` rather +than a scan over ``storage.values()``. + +The four store operations (everything except :func:`generate_snowflake_id`) +should be decorated with :func:`~src.part5.decorators.collect_stats`, which +counts their successful calls and their failed ones (those returning +``None``) in the module-level :data:`STATS_REGISTRY`, filed under +:data:`CREATE_KEY`, :data:`READ_KEY`, :data:`UPDATE_KEY` and +:data:`DELETE_KEY`. The counters live for the lifetime of the process and +are shared by every store. +""" + +from collections.abc import Callable +from typing import Any, Final + +from .decorators import Registry +from .storage import Product + + +STATS_REGISTRY: Final[Registry] = {} + +# TODO: задайте ключи операций в реестре, см. TASK.md +CREATE_KEY: Final[str] = "" +READ_KEY: Final[str] = "" +UPDATE_KEY: Final[str] = "" +DELETE_KEY: Final[str] = "" + +# TODO: при необходимости заведите здесь общий генератор Snowflake-идентификаторов +# для generate_snowflake_id (см. TASK.md) + + +def generate_snowflake_id() -> int | None: + """Issue the next product identifier from the shared Snowflake generator. + + This is the identifier source callers pass to :func:`create_product`. + + Returns: + A positive 63-bit Snowflake integer, or ``None`` (after a message + has been printed) when no identifier can be issued right now -- see + :func:`~src.part5.snowflake.get_snowflake_id_generator` for the + cases. + """ + # TODO: реализуйте функцию + return 0 + + +# TODO: оберните операцию декоратором collect_stats +def create_product( + storage: dict[int, Product], + product_names: set[str], + fields: dict[str, Any], + generate_id: Callable[[], int | None], +) -> int | None: + """Add a new product to ``storage`` and return its new identifier. + + Args: + storage: The product store to add to; modified in place on + success. + product_names: The normalised names already in ``storage``; the + new name is added to it in place on success. + fields: A dictionary with the ``name``, ``price`` and ``quantity`` + keys (see :data:`~src.part5.storage.NAME_KEY`, + :data:`~src.part5.storage.PRICE_KEY` and + :data:`~src.part5.storage.QUANTITY_KEY`) describing the + product. ``name`` is normalised (surrounding whitespace + stripped, lower-cased) before it is stored or compared. + ``price`` is a :class:`~decimal.Decimal` amount and is rounded + to the stored money precision before it is saved. + generate_id: A zero-argument function returning the identifier for + the new product, or ``None`` if it cannot issue one (for + example :func:`generate_snowflake_id`). It is only expected to + return an identifier that is not yet a key of ``storage``. + + Returns: + The identifier returned by ``generate_id`` for the new product, or + ``None`` when ``name`` is blank once normalised, a product with + the same normalised name already exists, or ``generate_id`` + returned ``None``. In the ``None`` case ``storage`` and + ``product_names`` are left unchanged and an explanatory message is + printed (for the last case, by ``generate_id`` itself). + """ + # TODO: реализуйте функцию + return 0 + + +# TODO: оберните операцию декоратором collect_stats +def read_product(storage: dict[int, Product], product_id: int) -> Product | None: + """Return the product stored under ``product_id``. + + Args: + storage: The product store to search. + product_id: The identifier to look up. + + Returns: + The matching product record, or ``None`` when no product carries + that identifier (a message is printed in that case). + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return {} + + +# TODO: оберните операцию декоратором collect_stats +def update_product( + storage: dict[int, Product], + product_names: set[str], + product_id: int, + fields: dict[str, Any], +) -> Product | None: + """Overwrite the fields of the product stored under ``product_id``. + + The identifier itself is preserved. ``fields`` may carry any subset of + ``name``, ``price`` and ``quantity``; a key that is missing keeps the + value the product already has, so a caller can update a single field + without repeating the other two. + + Args: + storage: The product store to modify; the matching record is + updated in place on success. + product_names: The normalised names already in ``storage``; on a + successful rename the old name is removed from it and the new + one added. + product_id: The identifier of the product to change. + fields: A dictionary with any of the ``name``, ``price`` and + ``quantity`` keys (see :data:`~src.part5.storage.NAME_KEY`, + :data:`~src.part5.storage.PRICE_KEY` and + :data:`~src.part5.storage.QUANTITY_KEY`) holding the new + values. ``price`` is a :class:`~decimal.Decimal` amount and is + rounded to the stored money precision before it is saved. + Mutated in place: a ``name`` or ``price`` value present in + ``fields`` is overwritten with its normalised form. + + Returns: + The updated product record, or ``None`` when ``name`` is present + and blank once normalised, no product carries that identifier, or + the normalised name is already taken by another product. In every + ``None`` case ``storage`` and ``product_names`` are left unchanged + and an explanatory message is printed. Renaming a product to its + own current name is allowed. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return {} + + +# TODO: оберните операцию декоратором collect_stats +def delete_product( + storage: dict[int, Product], + product_names: set[str], + product_id: int, +) -> int | None: + """Remove the product stored under ``product_id`` from ``storage``. + + Args: + storage: The product store to remove from; modified in place on + success. + product_names: The normalised names already in ``storage``; the + departing product's name is removed from it in place on + success. + product_id: The identifier of the product to remove. + + Returns: + ``product_id`` when a product was removed, or ``None`` when no + product carried that identifier (``storage`` and ``product_names`` + are left unchanged and a message is printed). + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return 0 diff --git a/src/part5/decorators.py b/src/part5/decorators.py new file mode 100644 index 00000000..21a9f237 --- /dev/null +++ b/src/part5/decorators.py @@ -0,0 +1,57 @@ +"""Decorators that add bookkeeping around the part5 CRUD operations. + +The module provides :func:`collect_stats`, a parametric decorator that counts +how often a function succeeds and how often it fails. The counters are kept in +a *registry* shared with the caller -- a plain dictionary mapping a string key +to a :data:`Stats` record -- so that other code (for example the ``stats`` +command of :mod:`src.part5.run_admin`) can read them without knowing +anything about the decorated functions. + +A call counts as a failure when the decorated function returns ``None`` -- the +convention every operation in :mod:`src.part5.crud` uses to report that it +printed an explanation and changed nothing. Any other return value, including +a falsy one such as ``0``, counts as a success. +""" + +from collections.abc import Callable +from typing import Final + + +# TODO: задайте имена двух счётчиков, см. TASK.md +SUCCESS_KEY: Final[str] = "" +FAILS_KEY: Final[str] = "" + + +type Stats = dict[str, int] +type Registry = dict[str, Stats] + + +def collect_stats[**P, R]( + key: str, registry: Registry +) -> Callable[[Callable[P, R | None]], Callable[P, R | None]]: + """Build a decorator that counts successful and failed calls of a function. + + Decorating registers a fresh record ``{"success": 0, "fails": 0}`` in + ``registry`` under ``key``. Every call of the decorated function then + updates that record in place: a call that returns ``None`` adds one to the + failure counter, any other result adds one to the success counter. The + result is passed through unchanged, and a call that raises an exception + is not counted at all. + + If ``key`` is already in ``registry``, a message is printed, the existing + record is left untouched and the function is returned undecorated, so its + calls never mix into the counters of whoever registered the key first. + + Args: + key: The registry key to keep this function's counters under, for + example the name of the console command it implements. + registry: The dictionary that receives the counters; modified in + place when the function is decorated and on every later call. + + Returns: + A decorator for a function whose result is either some value or + ``None``. The decorated function has the same signature, name and + docstring as the original (see :func:`functools.wraps`). + """ + # TODO: реализуйте декоратор + return lambda function: function diff --git a/src/part5/run_admin.py b/src/part5/run_admin.py new file mode 100644 index 00000000..d2e83947 --- /dev/null +++ b/src/part5/run_admin.py @@ -0,0 +1,125 @@ +"""Interactive admin console for the part5 in-memory product store. + +Running this module starts a read-eval-print loop over a single product +store that lives in memory for the lifetime of the process. Every line +typed at the prompt is stripped of surrounding whitespace and matched, +with a ``match`` statement, against the set of known commands: + +* ``help`` -- print the list of commands and what each one does; +* ``exit`` -- leave the loop and end the program; +* ``show`` -- print the whole store as a text table; +* ``create `` -- add a product; +* ``read `` -- print one product; +* ``update `` -- overwrite a product; +* ``delete `` -- remove a product; +* ``stats`` -- print how many times each CRUD command succeeded and failed, + with its success rate, as a text table. + +For ``create`` and ``update`` the price and quantity are always the last +two words of the line; everything between the verb (and, for ``update``, +the id) and those two words is joined with single spaces into the +product name, so a multi-word name such as ``Gibson SG Junior`` needs no +quoting. + +The four CRUD commands forward to :mod:`src.part5.crud`; whatever +that call returns is printed unless it is ``None``. ``create`` hands +:func:`~src.part5.crud.generate_snowflake_id` to the CRUD layer as the +source of the new product's identifier; the ```` typed for ``read``, +``update`` and ``delete`` is converted to an integer before it is looked +up. The counters behind ``stats`` are collected by the CRUD layer itself +(:data:`~src.part5.crud.STATS_REGISTRY`); a command whose arguments do +not parse never reaches it and is not counted. A line whose first +word is not a known verb, or that carries the wrong number of arguments +for its verb, prints an "is not a command" notice. A line that names a +command correctly but whose id, price or quantity argument will not parse +prints a distinct "arguments are invalid" notice. Either way the store +is left untouched and the loop keeps running; only ``exit`` stops it. +""" + +from decimal import InvalidOperation +from typing import Final + +from .storage import Product + + +# TODO: задайте приглашение и текст справки (используйте своё решение части 4, +# не забудьте добавить команду stats, см. TASK.md) +PROMPT: Final[str] = "" +HELP_TEXT: Final[str] = "" + + +def show_help() -> None: + """Print the command reference to stdout. + + The text is the module-level :data:`HELP_TEXT` constant, printed as + is: a heading, one line per command with a short description, and a + note on how multi-word names are parsed. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + + +def print_result(result: object) -> None: + """Print a CRUD result to stdout unless it is ``None``. + + Args: + result: The value returned by a CRUD operation. ``None`` means the + operation already reported its own failure, so nothing is + printed in that case. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + + +def run_command( + storage: dict[int, Product], product_names: set[str], line: str +) -> bool: + """Parse one console line and carry out the command it names. + + Args: + storage: The product store shared across the session; mutated in + place by the ``create``, ``update`` and ``delete`` commands. + product_names: The normalised names already in ``storage``; kept + in sync by the ``create``, ``update`` and ``delete`` commands. + line: One line of console input, already stripped of surrounding + whitespace. + + Returns: + ``True`` to keep the read-eval-print loop running, or ``False`` + once the ``exit`` command has been seen. A line that is not a + known command prints a notice and still returns ``True``. + + Raises: + ValueError: If the id or quantity argument of a CRUD command does + not parse as a base-10 integer. + decimal.InvalidOperation: If the price argument of ``create`` or + ``update`` does not parse as a decimal number. + """ + # TODO: реализуйте функцию + return False + + +def main() -> None: + """Run the admin console until the ``exit`` command is entered. + + A single product store, and the set of normalised names it holds, are + created empty and kept in memory for the whole session. Each iteration + reads one line from stdin, strips its surrounding whitespace and hands + it to :func:`run_command`. A line that names a command but whose price + or quantity argument does not parse is caught here and reported + without stopping the loop; only ``exit`` ends it. + """ + storage: dict[int, Product] = {} + product_names: set[str] = set() + running = True + + while running: + line = input(PROMPT).strip() + + try: + running = run_command(storage, product_names, line) + + except (ValueError, InvalidOperation): + print(f"{line!r} names a command but its arguments are invalid") + + +if __name__ == "__main__": + main() diff --git a/src/part5/snowflake.py b/src/part5/snowflake.py new file mode 100644 index 00000000..64e28468 --- /dev/null +++ b/src/part5/snowflake.py @@ -0,0 +1,112 @@ +"""Twitter Snowflake identifier generator. + +Snowflake produces roughly time-ordered 64-bit integer identifiers without +coordination between nodes. Each identifier packs the milliseconds elapsed +since a custom epoch, a node identifier and a per-millisecond sequence counter +into a single 63-bit positive integer (see ``constants`` for the layout). + +The public entry point is :func:`get_snowflake_id_generator`. It validates the +node identifier once and returns a closure that keeps the sequence counter and +the last seen millisecond itself, so each new identifier needs only a call +without arguments. + +Each packed field can be read back on its own with :func:`decode_timestamp_ms`, +:func:`decode_node_id` and :func:`decode_sequence_id`. +""" + +from collections.abc import Callable + +from .constants import EPOCH_MS_DEFAULT, NODE_ID_DEFAULT + + +def read_current_millis(epoch_ms: int) -> int: + """Read the number of milliseconds elapsed since the custom epoch. + + Args: + epoch_ms: The custom epoch expressed as Unix milliseconds. + + Returns: + The count of whole milliseconds between ``epoch_ms`` and now. Negative + if ``epoch_ms`` lies in the future. + """ + # TODO: реализуйте функцию (используйте своё решение части 1) + return 0 + + +def decode_timestamp_ms(snowflake_id: int, epoch_ms: int = EPOCH_MS_DEFAULT) -> int: + """Read the timestamp field out of a Snowflake identifier. + + Args: + snowflake_id: An identifier produced against the same epoch. + epoch_ms: The epoch the identifier was generated against. Defaults to + the original Twitter epoch (2010-11-04 01:42:54.657 UTC). + + Returns: + The absolute Unix time in milliseconds at which the identifier was + generated. + """ + # TODO: реализуйте функцию (используйте своё решение части 1) + return 0 + + +def decode_node_id(snowflake_id: int) -> int: + """Read the node identifier field out of a Snowflake identifier. + + Args: + snowflake_id: An identifier produced by a generator from + :func:`get_snowflake_id_generator`. + + Returns: + The node identifier packed into ``snowflake_id``, in the range + ``[0, NODE_ID_MAX]``. + """ + # TODO: реализуйте функцию (используйте своё решение части 1) + return 0 + + +def decode_sequence_id(snowflake_id: int) -> int: + """Read the sequence counter field out of a Snowflake identifier. + + Args: + snowflake_id: An identifier produced by a generator from + :func:`get_snowflake_id_generator`. + + Returns: + The per-millisecond sequence counter packed into ``snowflake_id``, in + the range ``[0, SEQUENCE_ID_MAX]``. + """ + # TODO: реализуйте функцию (используйте своё решение части 1) + return 0 + + +def get_snowflake_id_generator( + node_id: int = NODE_ID_DEFAULT, + epoch_ms: int = EPOCH_MS_DEFAULT, +) -> Callable[[], int | None] | None: + """Create a stateful Snowflake identifier generator bound to one node. + + The returned closure remembers the millisecond of its previous call and the + per-millisecond sequence counter, so callers never pass them in. + + Args: + node_id: The identifier of this node, in the range ``[0, NODE_ID_MAX]``. + Optional; defaults to ``NODE_ID_DEFAULT``. + epoch_ms: The start of the epoch as Unix milliseconds. Optional; + defaults to the original Twitter epoch (2010-11-04 01:42:54.657 + UTC). + + Returns: + A zero-argument function ``generate_snowflake_id``. Each call returns a + positive 63-bit integer identifier. It returns ``None`` (after printing + an explanatory message to stdout) if more than ``SEQUENCE_ID_MAX + 1`` + identifiers are requested within one millisecond, or if the elapsed + time no longer fits in the timestamp field (roughly 69 years after + ``epoch_ms``). A failed call does not break the generator: it recovers + once the clock moves on to the next millisecond. + + ``None`` is returned instead of a generator, after printing a message + to stdout, if ``node_id`` is outside ``[0, NODE_ID_MAX]`` or + ``epoch_ms`` lies in the future. + """ + # TODO: реализуйте функцию + return lambda: 0 diff --git a/src/part5/storage.py b/src/part5/storage.py new file mode 100644 index 00000000..fa8ef3bb --- /dev/null +++ b/src/part5/storage.py @@ -0,0 +1,44 @@ +"""In-memory product store for the shop CRUD exercise. + +The store is deliberately primitive: a plain dictionary, with no +persistence and no schema enforcement. It maps a product's ``id`` -- a +Snowflake integer issued by the identifier generator handed to the create +operation in :mod:`src.part5.crud` -- to its :data:`Product` record -- +a dictionary with the keys ``name``, ``price`` and ``quantity`` -- where: + +* the ``storage`` key itself -- unique integer identifier, issued by the + generator passed to the create operation in :mod:`src.part5.crud`; + it is not duplicated inside the record; +* ``name`` -- human-readable product name, unique across the store, held + in normalised form (see + :func:`src.part5.utils.normalize_product_name`); +* ``price`` -- price of a single unit, held as a :class:`~decimal.Decimal` + amount rounded to a fixed number of decimal places (see + :func:`src.part5.utils.normalize_price`) so that money is never + subject to binary floating-point error; +* ``quantity`` -- number of units currently in stock. + +Field names are exposed as ``*_KEY`` constants so callers never hard-code +the dictionary keys. + +Every caller that mutates the store also threads along a second +structure, a ``set[str]`` of the normalised names currently in use -- +see, for example, the ``product_names`` parameter of +:func:`src.part5.crud.create_product`. Keeping that name index as a +plain set passed next to ``storage``, rather than bundling the two into +an object, lets a name-uniqueness check become an O(1) membership test +against the set instead of a linear scan over ``storage.values()``. The +set must always mirror the normalised names present in ``storage``; the +functions in :mod:`src.part5.crud` keep the two in sync on every +successful create, update and delete. +""" + +from typing import Any, Final + + +type Product = dict[str, Any] + +# TODO: задайте имена полей записи (используйте своё решение части 4) +NAME_KEY: Final = "" +PRICE_KEY: Final = "" +QUANTITY_KEY: Final = "" diff --git a/src/part5/utils.py b/src/part5/utils.py new file mode 100644 index 00000000..b2ad4dc4 --- /dev/null +++ b/src/part5/utils.py @@ -0,0 +1,120 @@ +"""Standalone helpers shared by the part5 storage and CRUD modules. + +Normalisation: + +* :func:`normalize_price` rounds a raw :class:`~decimal.Decimal` amount to + the fixed number of fractional digits (:data:`PRICE_PRECISION`) that + every stored price uses, keeping currency values free of binary + floating-point error. +* :func:`normalize_product_name` strips surrounding whitespace from a + product name, collapses every internal run of whitespace to a single + space and lower-cases it, giving every stored name a single canonical + form. + +Presentation: + +* :func:`get_storage_str_representation` renders the whole store as a + text table meant to be printed to a terminal; each column is sized to + the longest value it holds in that particular call. +* :func:`get_stats_str_representation` renders the call statistics gathered + by :func:`~src.part5.decorators.collect_stats` as a table of the same + look, with the success rate of every command as a percentage. +""" + +from decimal import Decimal +from typing import Final + +from .decorators import Registry +from .storage import Product + + +# TODO: задайте число знаков после запятой и шаг квантования (используйте своё +# решение части 4) +PRICE_PRECISION: Final[int] = 0 +PRICE_STEP: Final = Decimal(0) + + +# TODO: задайте заголовки столбцов таблицы (используйте своё решение части 4) +TABLE_HEADERS: Final[tuple[str, ...]] = () +# TODO: задайте заголовки столбцов таблицы статистики, см. TASK.md +STATS_TABLE_HEADERS: Final[tuple[str, ...]] = () + + +def normalize_price(price: Decimal) -> Decimal: + """Round a price to the precision every stored record uses. + + Args: + price: The raw price amount. + + Returns: + ``price`` quantised to :data:`PRICE_PRECISION` fractional digits, + with halves rounded up. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return Decimal(0) + + +def normalize_product_name(name: str) -> str: + """Reduce a product name to the canonical form the store keeps. + + Args: + name: The raw product name. + + Returns: + ``name`` with every leading and trailing whitespace character + removed, every internal run of whitespace (spaces, tabs and the + like) collapsed to a single space, and the rest lower-cased. For + example ``"abc def\tghi\tjkl"`` becomes ``"abc def ghi jkl"``. + The result is an empty string when ``name`` holds nothing but + whitespace. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return "" + + +# TODO: при необходимости добавьте свои вспомогательные функции + + +def get_storage_str_representation(storage: dict[int, Product]) -> str: + """Render the product store as a text table with data-sized columns. + + The table has the columns named by :data:`TABLE_HEADERS` -- id, name, + price and quantity. Each column is made exactly as wide as the longest + value it carries in this call (its header counted), so the columns + line up when the string is printed to a terminal and no value is ever + truncated. + + Args: + storage: The product store to render. + + Returns: + A multi-line string: a header row, a dashed separator row, then + one row per product in ``storage``, in insertion order. When + ``storage`` is empty only the header and separator rows are + returned, sized to the header labels. + """ + # TODO: реализуйте функцию (используйте своё решение части 4) + return "" + + +def get_stats_str_representation(registry: Registry) -> str: + """Render the call statistics as a text table with data-sized columns. + + The table has the columns named by :data:`STATS_TABLE_HEADERS` -- command + name, number of successful calls, number of failed calls and success + rate. It is laid out like the one from + :func:`get_storage_str_representation`: every column is as wide as the + longest value it carries, its header counted. + + Args: + registry: The statistics to render, a mapping of command name to the + counters kept by :func:`~src.part5.decorators.collect_stats`. + + Returns: + A multi-line string: a header row, a dashed separator row, then one + row per registry entry in insertion order. When ``registry`` is + empty only the header and separator rows are returned, sized to the + header labels. + """ + # TODO: реализуйте функцию + return ""