From 979afbdefb5e1c75148b4ff065354ac7407ddbfb Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Tue, 22 Sep 2026 22:06:17 +0300 Subject: [PATCH 1/7] Solve task 1 --- src/part1/constants.py | 33 +++-------- src/part1/snowflake.py | 124 ++++++++++++----------------------------- 2 files changed, 44 insertions(+), 113 deletions(-) diff --git a/src/part1/constants.py b/src/part1/constants.py index 9abfbba2..12e23773 100644 --- a/src/part1/constants.py +++ b/src/part1/constants.py @@ -1,38 +1,21 @@ -"""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) на корректные значения, см. TASK.md. -# Все константы, зависящие от ёмкостей *_BITS, должны вычисляться из них. - # Twitter's original Snowflake epoch: 2010-11-04 01:42:54.657 UTC. -EPOCH_MS_DEFAULT: Final[int] = 0 +EPOCH_MS_DEFAULT: Final[int] = 1288834974657 # Node identifier used when the caller does not supply one. -NODE_ID_DEFAULT: Final[int] = 0 +NODE_ID_DEFAULT: Final[int] = 1 # Width of each field, in bits. -TIMESTAMP_BITS: Final[int] = 0 -NODE_ID_BITS: Final[int] = 0 -SEQUENCE_ID_BITS: Final[int] = 0 +TIMESTAMP_BITS: Final[int] = 41 +NODE_ID_BITS: Final[int] = 10 +SEQUENCE_ID_BITS: Final[int] = 12 # Largest value each field can hold. -TIMESTAMP_MS_MAX: Final[int] = 0 -NODE_ID_MAX: Final[int] = 0 -SEQUENCE_ID_MAX: Final[int] = 0 +TIMESTAMP_MS_MAX: Final[int] = 2**TIMESTAMP_BITS - 1 +NODE_ID_MAX: Final[int] = 2**NODE_ID_BITS - 1 +SEQUENCE_ID_MAX: Final[int] = 2**SEQUENCE_ID_BITS - 1 # Здесь можно добавить собственные вспомогательные константы # (например, сдвиги полей при сборке идентификатора). diff --git a/src/part1/snowflake.py b/src/part1/snowflake.py index d3abf050..1f0e0673 100644 --- a/src/part1/snowflake.py +++ b/src/part1/snowflake.py @@ -1,85 +1,34 @@ -"""Twitter Snowflake identifier generator. +import time -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:`generate_snowflake_id`. It is stateless: the -caller passes the sequence counter on every call and is responsible for -advancing it within a millisecond and resetting it when the clock ticks over. - -Each packed field can be read back on its own with :func:`decode_timestamp_ms`, -:func:`decode_node_id` and :func:`decode_sequence_id`. -""" - -import time # noqa: F401 - -from .constants import ( # noqa: F401 +from .constants import ( EPOCH_MS_DEFAULT, + NODE_ID_BITS, NODE_ID_DEFAULT, NODE_ID_MAX, + SEQUENCE_ID_BITS, SEQUENCE_ID_MAX, + TIMESTAMP_BITS, TIMESTAMP_MS_MAX, ) 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. May be - negative if ``epoch_ms`` lies in the future. - """ - # TODO: реализуйте функцию - return 0 + return int(time.time() * 1000) - epoch_ms 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: реализуйте функцию - return 0 + relative_time = ( + snowflake_id >> SEQUENCE_ID_BITS >> NODE_ID_BITS + ) & TIMESTAMP_MS_MAX + return epoch_ms + relative_time 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 :func:`generate_snowflake_id`. - - Returns: - The node identifier packed into ``snowflake_id``, in the range - ``[0, NODE_ID_MAX]``. - """ - # TODO: реализуйте функцию - return 0 + return (snowflake_id >> SEQUENCE_ID_BITS) & NODE_ID_MAX 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 :func:`generate_snowflake_id`. - - Returns: - The per-millisecond sequence counter packed into ``snowflake_id``, in - the range ``[0, SEQUENCE_ID_MAX]``. - """ - # TODO: реализуйте функцию - return 0 + return snowflake_id & SEQUENCE_ID_MAX def generate_snowflake_id( @@ -87,28 +36,27 @@ def generate_snowflake_id( node_id: int = NODE_ID_DEFAULT, epoch_ms: int = EPOCH_MS_DEFAULT, ) -> int | None: - """Build and return a Snowflake identifier for the current millisecond. - - The function is stateless: the caller passes the per-millisecond sequence - counter explicitly. It is the caller's responsibility to increment - ``sequence_id`` for identifiers minted within the same millisecond and to - reset it once the clock advances. - - Args: - sequence_id: The per-millisecond sequence counter, in the range - ``[0, SEQUENCE_ID_MAX]``. - 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: - The Snowflake identifier as a positive 63-bit integer, or ``None`` if - ``node_id`` is outside ``[0, NODE_ID_MAX]``, ``sequence_id`` is outside - ``[0, SEQUENCE_ID_MAX]``, or the elapsed time no longer fits in the - timestamp field (roughly 69 years after ``epoch_ms``). In each of those - cases an explanatory message is printed to stdout first. - """ - # TODO: реализуйте функцию - return 0 + current_millis = read_current_millis(epoch_ms) + + if node_id not in range(NODE_ID_MAX + 1): + print(f"node_id is {node_id}, but must be in [0, {NODE_ID_MAX}]") + return None + elif sequence_id not in range(SEQUENCE_ID_MAX + 1): + print(f"sequence_id is {sequence_id}, but must be in [0, {SEQUENCE_ID_MAX}]") + return None + else: + if current_millis > TIMESTAMP_MS_MAX: + print("overflows") + return None + + snowflake_id = int( + "0" + + bin(current_millis)[2:].zfill(TIMESTAMP_BITS) + + bin(node_id)[2:].zfill(NODE_ID_BITS) + + bin(sequence_id)[2:].zfill(SEQUENCE_ID_BITS), + base=2, + ) + # print(decode_node_id(snowflake_id) == node_id, + # decode_sequence_id(snowflake_id) == sequence_id, + # decode_timestamp_ms(snowflake_id, epoch_ms) == current_millis + epoch_ms) + return snowflake_id From 006a02428c85b81d40ad572f5dbf6fc07ff4cd54 Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Thu, 1 Oct 2026 20:05:51 +0300 Subject: [PATCH 2/7] Solve task 2 --- src/part2/cart.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/part2/cart.py b/src/part2/cart.py index 82305630..e2cb4801 100644 --- a/src/part2/cart.py +++ b/src/part2/cart.py @@ -45,7 +45,6 @@ def add_to_cart( product[QUANTITY_INDEX] - quantity )) - # 2. Обновляем или добавляем строку в корзину existing_line = find_cart_line(cart, product_id) if existing_line: cart.remove(existing_line) @@ -81,7 +80,6 @@ def remove_from_cart( if not product: return None - # 1. Возвращаем товар на склад update_product(storage, product_id, ( product[NAME_INDEX], product[PRICE_INDEX], From 3c6c114caef3a7293cc6882e934ea493e7308e8e Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Thu, 1 Oct 2026 20:34:55 +0300 Subject: [PATCH 3/7] Solve task 2 --- src/part2/cart.py | 30 +++++++++++++++--------------- src/part2/crud.py | 17 ++++++----------- src/part2/utils.py | 2 +- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/part2/cart.py b/src/part2/cart.py index e2cb4801..668f7360 100644 --- a/src/part2/cart.py +++ b/src/part2/cart.py @@ -31,19 +31,19 @@ def add_to_cart( product = read_product(storage, product_id) if not product: return None - + if product[QUANTITY_INDEX] < quantity: print( f"not enough stock for product {product_id}: " f"{product[QUANTITY_INDEX]} available, {quantity} requested" ) return None - - update_product(storage, product_id, ( - product[NAME_INDEX], - product[PRICE_INDEX], - product[QUANTITY_INDEX] - quantity - )) + + update_product( + storage, + product_id, + (product[NAME_INDEX], product[PRICE_INDEX], product[QUANTITY_INDEX] - quantity), + ) existing_line = find_cart_line(cart, product_id) if existing_line: @@ -67,7 +67,7 @@ def remove_from_cart( if not cartline: print(f"product {product_id} is not in the cart") return None - + if cartline[LINE_QUANTITY_INDEX] < quantity: print( f"cart holds only {cartline[LINE_QUANTITY_INDEX]} " @@ -75,16 +75,16 @@ def remove_from_cart( f"cannot remove {quantity}" ) return None - + product = read_product(storage, product_id) if not product: return None - update_product(storage, product_id, ( - product[NAME_INDEX], - product[PRICE_INDEX], - product[QUANTITY_INDEX] + quantity - )) + update_product( + storage, + product_id, + (product[NAME_INDEX], product[PRICE_INDEX], product[QUANTITY_INDEX] + quantity), + ) new_quantity = cartline[LINE_QUANTITY_INDEX] - quantity cart.remove(cartline) @@ -92,4 +92,4 @@ def remove_from_cart( return (product_id, 0) updated_line = (product_id, new_quantity) cart.append(updated_line) - return updated_line \ No newline at end of file + return updated_line diff --git a/src/part2/crud.py b/src/part2/crud.py index 158dd25d..b42bfa51 100644 --- a/src/part2/crud.py +++ b/src/part2/crud.py @@ -1,7 +1,7 @@ from decimal import Decimal -from .storage import ( - NAME_INDEX, +from .storage import ( + NAME_INDEX, PRODUCT_ID_INDEX, PRODUCT_ID_MIN, Product, @@ -23,16 +23,11 @@ def create_product( if product_name in storage_product_names: print(f"product name '{product_name}' is already taken") return None - + product_id = generate_product_id(storage) product_price = normalize_price(fields[1]) product_quantity = fields[2] - storage.append(( - product_id, - product_name, - product_price, - product_quantity - )) + storage.append((product_id, product_name, product_price, product_quantity)) return product_id @@ -55,7 +50,7 @@ def update_product( product_id, fields[0], normalize_price(fields[1]), - fields[2] + fields[2], ) storage[i] = updated_product return updated_product @@ -69,4 +64,4 @@ def delete_product(storage: list[Product], product_id: int) -> int | None: storage.remove(product) return product_id print(f"no product with id {product_id}") - return None \ No newline at end of file + return None diff --git a/src/part2/utils.py b/src/part2/utils.py index 541d69fe..98b91aa4 100644 --- a/src/part2/utils.py +++ b/src/part2/utils.py @@ -7,4 +7,4 @@ def normalize_price(price: Decimal) -> Decimal: - return price.quantize(PRICE_STEP, ROUND_HALF_UP) \ No newline at end of file + return price.quantize(PRICE_STEP, ROUND_HALF_UP) From 8b77b82c1d5fd7599837eb67e1b8cabdd75c7595 Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Sat, 10 Oct 2026 18:28:46 +0300 Subject: [PATCH 4/7] task-3 --- README.md | 98 +------- docs/grading_criteria.md | 94 ++++++++ src/part3.zip | Bin 0 -> 20067 bytes src/part3/TASK.md | 486 +++++++++++++++++++++++++++++++++++++++ src/part3/__init__.py | 0 src/part3/crud.py | 104 +++++++++ src/part3/run_admin.py | 79 +++++++ src/part3/storage.py | 12 + src/part3/utils.py | 39 ++++ 9 files changed, 822 insertions(+), 90 deletions(-) create mode 100644 docs/grading_criteria.md create mode 100644 src/part3.zip create mode 100644 src/part3/TASK.md create mode 100644 src/part3/__init__.py create mode 100644 src/part3/crud.py create mode 100644 src/part3/run_admin.py create mode 100644 src/part3/storage.py create mode 100644 src/part3/utils.py diff --git a/README.md b/README.md index 76d456ac..d810c576 100644 --- a/README.md +++ b/README.md @@ -11,98 +11,16 @@ - [Часть 1. Генератор идентификаторов Twitter Snowflake](src/part1/TASK.md) — нужно написать генератор 64-битных числовых идентификаторов, в которые упакованы метка времени, номер узла и счётчик последовательности. Такие идентификаторы уникальны и примерно упорядочены по времени, а из самого идентификатора можно узнать, когда и на каком узле он был создан. - [Часть 2. CRUD и корзина для интернет-магазина](src/part2/TASK.md) — нужно реализовать хранилище товаров в памяти (список кортежей), операции создания, чтения, изменения и удаления записей, аккуратную работу с ценами через `Decimal` и корзину покупателя, в которую товары переносятся со склада и обратно без потери количества. +- [Часть 3. Нормализация имён и консоль администратора](src/part3/TASK.md) — нужно привести названия товаров к единому написанию и следить за их уникальностью, научить хранилище печатать себя выровненной текстовой таблицей и написать интерактивную консоль администратора, через которую можно создавать, просматривать, изменять и удалять товары. -## Критерии оценивания - -Максимальный балл за каждое задание — **10**. Правильность решения проверяется закрытыми тестами. - -### Условия принятия работы - -Работа принимается к оцениванию, только если выполнены **все** условия: - -- код проходит проверки [ruff](https://docs.astral.sh/ruff/) и [MyPy](https://mypy-lang.org/) (запустить их можно [локально](manuals/local_checks.md)); -- работа сдана до дедлайна. - -Если хотя бы одно условие не выполнено, работа не принимается и оценивается в 0 баллов. - -### Как считается балл за правильность - -Тесты разбиты на функциональные блоки: каждый блок соответствует функции или классу из задания и имеет свой вес (более сложные блоки весят больше). Внутри блока тесты делятся на две группы: - -| Группа тестов | Доля веса блока | Что проверяет | -|---|---|---| -| Базовые (типичные входные данные) | 70% | Основная логика реализована верно | -| Граничные (пустой ввод, нулевые и предельные значения, повторы и т. п.) | 30% | Обработка особых случаев | - -Формулы: - -``` -балл_блока = 0.7 · доля_пройденных_базовых + 0.3 · доля_пройденных_граничных -балл_за_правильность = 10 · Σ(вес_блока · балл_блока) / Σ(вес_блока) -``` - -Дополнительные правила: - -- **Нереализованный функционал.** Блок, в котором функционал не реализован (заглушка, `pass`, `NotImplementedError`), получает 0 баллов. Остальные блоки оцениваются как обычно, дополнительного штрафа за нереализованное нет. -- **Ошибки выполнения.** Тест, в котором возникло необработанное исключение, считается проваленным. -- **Зависание.** У каждого теста есть ограничение по времени выполнения. Если решение не укладывается в него (например, из-за бесконечного цикла), тест считается проваленным. - -### Штрафы за оформление +## Дедлайны -Кроме правильности, оценивается качество кода. За нарушения ниже из балла за правильность вычитаются штрафы. Каждое нарушение штрафуется **один раз**, сколько бы раз оно ни встретилось в работе. - -У каждого нарушения есть уникальный код: `S` — серьёзные, `M` — средние, `T` — типовые. При проверке преподаватель указывает в комментарии к работе только коды (например, `S1, M2, T1`), а расшифровку и штраф можно найти в таблицах ниже. - -#### Серьёзные нарушения (−1 балл за каждое) - -| Код | Нарушение | Что имеется в виду | -|---|---|---| -| `S1` | Копипаста | Вместо вынесения повторяющейся логики в функцию, метод или цикл один и тот же (или почти тот же) код скопирован несколько раз. Мешает расширяемости: любое изменение приходится вносить во все копии | -| `S2` | Слишком длинные функции | Функция делает много разных вещей одновременно (вычисления, проверки, преобразования, форматирование в одном месте) и не читается целиком. Ориентир: более 30–40 строк или несколько несвязанных обязанностей. Такой код тяжело читать и поддерживать | -| `S3` | Магические значения | Числа и строки с неочевидным смыслом прописаны прямо в коде, а не вынесены в именованные константы. Как и копипаста, мешает расширяемости: значение приходится искать и менять по всему коду | -| `S4` | Слишком широкий перехват исключений | Голый `except:` или `except Exception:` без необходимости; молча проглоченные ошибки (`except ...: pass`). Так можно пропустить что-то важное | -| `S5` | Изменение входных данных | Функция неожиданно изменяет переданные ей аргументы, хотя из названия и описания это не следует. Неожиданное поведение, источник серьёзных ошибок | - -#### Средние нарушения (−0.5 балла за каждое) - -| Код | Нарушение | Что имеется в виду | +| Задание | Дедлайн (включительно) | Статус | |---|---|---| -| `M1` | Большая вложенность | Более трёх уровней вложенности (`if`, `for`, `while`, `try`, `with`) внутри одной функции. Лечится ранним возвратом (`return`, `continue`) и выносом логики в отдельные функции | -| `M2` | Неинформативные имена | Имена вроде `a`, `tmp`, `data2`, `x1`, а также имена, не отражающие суть, или не соответствующие PEP 8 (например, `camelCase` для функций и переменных) | -| `M3` | Избыточные глобальные переменные | Изменяемое глобальное состояние там, где достаточно параметров, возвращаемых значений или атрибутов класса | -| `M4` | Мёртвый код | Код, который нигде не используется: неиспользуемые функции, классы, переменные, константы, импорты, недостижимые ветки. Сюда же относятся крупные куски закомментированного кода «на будущее» и оставленный отладочный `print`. Затрудняет чтение, но на работу программы не влияет | - -#### Типовые нарушения хорошего тона (−0.25 балла за каждое) - -| Код | Нарушение | -|---|---| -| `T1` | `else` или `elif` после `return`, `raise`, `continue` или `break` | -| `T2` | Явное приведение к `bool` в условиях (`if bool(x):`) и сравнение с `True` / `False` (`if x == True:`) | -| `T3` | Сравнение с синглтонами и членами enum'ов через `==` / `!=` вместо `is` / `is not` | -| `T4` | Проверка пустоты коллекции через `len(x) == 0` вместо `if not x` | -| `T5` | Обход через `for i in range(len(items))`, когда индекс не нужен (вместо `for item in items` или `enumerate`) | -| `T6` | `if cond: return True else: return False` вместо `return cond` | -| `T7` | Изменяемое значение по умолчанию у аргумента (`def f(x=[])`) | -| `T8` | Ручная конкатенация строк вместо f-строк | -| `T9` | Избыточные комментарии, пересказывающие очевидный код | +| [Часть 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 | активно | -### Штраф за списывание - -Если работа подозревается в списывании, студент получает **−5 баллов**. Эти баллы не вычитаются из оценки за правильность, а **заменяют** итоговую оценку: итоговой оценкой становится −5, независимо от результата тестов. - -Студент может оспорить этот балл на занятии, доказав, что не списывал, а написал код самостоятельно (например, объяснив решение и ответив на вопросы по нему). Если подозрение снимается, работа оценивается по обычным критериям. - -### Итоговая оценка - -``` -если есть подозрение в списывании: - итоговая_оценка = −5 -иначе: - итоговая_оценка = max(0, балл_за_правильность − штрафы_за_оформление) -``` - -Итоговая оценка за домашнее задание округляется до ближайшего целого по правилам математики: дробная часть от 0.5 и выше округляется вверх, меньше 0.5 — вниз (например, 7.5 → 8, 7.4 → 7). - -### Как оспорить оценку +## Критерии оценивания -Если вы не согласны с оценкой, напишите преподавателю. Укажите, с каким блоком или с какой частью критериев вы не согласны, и приведите аргументы. Оценка пересматривается по тем же критериям, которые описаны выше. +Критерии оценивания можно узнать [тут](docs/grading_criteria.md). diff --git a/docs/grading_criteria.md b/docs/grading_criteria.md new file mode 100644 index 00000000..8b1c7890 --- /dev/null +++ b/docs/grading_criteria.md @@ -0,0 +1,94 @@ +# Критерии оценивания + +Максимальный балл за каждое задание — **10**. Правильность решения проверяется закрытыми тестами. + +## Условия принятия работы + +Работа принимается к оцениванию, только если выполнены **все** условия: + +- код проходит проверки [ruff](https://docs.astral.sh/ruff/) и [MyPy](https://mypy-lang.org/) (запустить их можно [локально](../manuals/local_checks.md)); +- работа сдана до дедлайна. + +Если хотя бы одно условие не выполнено, работа не принимается и оценивается в 0 баллов. + +## Как считается балл за правильность + +Тесты разбиты на функциональные блоки: каждый блок соответствует функции или классу из задания и имеет свой вес (более сложные блоки весят больше). Внутри блока тесты делятся на две группы: + +| Группа тестов | Доля веса блока | Что проверяет | +|---|---|---| +| Базовые (типичные входные данные) | 70% | Основная логика реализована верно | +| Граничные (пустой ввод, нулевые и предельные значения, повторы и т. п.) | 30% | Обработка особых случаев | + +Формулы: + +``` +балл_блока = 0.7 · доля_пройденных_базовых + 0.3 · доля_пройденных_граничных +балл_за_правильность = 10 · Σ(вес_блока · балл_блока) / Σ(вес_блока) +``` + +Дополнительные правила: + +- **Нереализованный функционал.** Блок, в котором функционал не реализован (заглушка, `pass`, `NotImplementedError`), получает 0 баллов. Остальные блоки оцениваются как обычно, дополнительного штрафа за нереализованное нет. +- **Ошибки выполнения.** Тест, в котором возникло необработанное исключение, считается проваленным. +- **Зависание.** У каждого теста есть ограничение по времени выполнения. Если решение не укладывается в него (например, из-за бесконечного цикла), тест считается проваленным. + +## Штрафы за оформление + +Кроме правильности, оценивается качество кода. За нарушения ниже из балла за правильность вычитаются штрафы. Каждое нарушение штрафуется **один раз**, сколько бы раз оно ни встретилось в работе. + +У каждого нарушения есть уникальный код: `S` — серьёзные, `M` — средние, `T` — типовые. При проверке преподаватель указывает в комментарии к работе только коды (например, `S1, M2, T1`), а расшифровку и штраф можно найти в таблицах ниже. + +### Серьёзные нарушения (−1 балл за каждое) + +| Код | Нарушение | Что имеется в виду | +|---|---|---| +| `S1` | Копипаста | Вместо вынесения повторяющейся логики в функцию, метод или цикл один и тот же (или почти тот же) код скопирован несколько раз. Мешает расширяемости: любое изменение приходится вносить во все копии | +| `S2` | Слишком длинные функции | Функция делает много разных вещей одновременно (вычисления, проверки, преобразования, форматирование в одном месте) и не читается целиком. Ориентир: более 30–40 строк или несколько несвязанных обязанностей. Такой код тяжело читать и поддерживать | +| `S3` | Магические значения | Числа и строки с неочевидным смыслом прописаны прямо в коде, а не вынесены в именованные константы. Как и копипаста, мешает расширяемости: значение приходится искать и менять по всему коду | +| `S4` | Слишком широкий перехват исключений | Голый `except:` или `except Exception:` без необходимости; молча проглоченные ошибки (`except ...: pass`). Так можно пропустить что-то важное | +| `S5` | Изменение входных данных | Функция неожиданно изменяет переданные ей аргументы, хотя из названия и описания это не следует. Неожиданное поведение, источник серьёзных ошибок | + +### Средние нарушения (−0.5 балла за каждое) + +| Код | Нарушение | Что имеется в виду | +|---|---|---| +| `M1` | Большая вложенность | Более трёх уровней вложенности (`if`, `for`, `while`, `try`, `with`) внутри одной функции. Лечится ранним возвратом (`return`, `continue`) и выносом логики в отдельные функции | +| `M2` | Неинформативные имена | Имена вроде `a`, `tmp`, `data2`, `x1`, а также имена, не отражающие суть, или не соответствующие PEP 8 (например, `camelCase` для функций и переменных) | +| `M3` | Избыточные глобальные переменные | Изменяемое глобальное состояние там, где достаточно параметров, возвращаемых значений или атрибутов класса | +| `M4` | Мёртвый код | Код, который нигде не используется: неиспользуемые функции, классы, переменные, константы, импорты, недостижимые ветки. Сюда же относятся крупные куски закомментированного кода «на будущее» и оставленный отладочный `print`. Затрудняет чтение, но на работу программы не влияет | + +### Типовые нарушения хорошего тона (−0.25 балла за каждое) + +| Код | Нарушение | +|---|---| +| `T1` | `else` или `elif` после `return`, `raise`, `continue` или `break` | +| `T2` | Явное приведение к `bool` в условиях (`if bool(x):`) и сравнение с `True` / `False` (`if x == True:`) | +| `T3` | Сравнение с синглтонами и членами enum'ов через `==` / `!=` вместо `is` / `is not` | +| `T4` | Проверка пустоты коллекции через `len(x) == 0` вместо `if not x` | +| `T5` | Обход через `for i in range(len(items))`, когда индекс не нужен (вместо `for item in items` или `enumerate`) | +| `T6` | `if cond: return True else: return False` вместо `return cond` | +| `T7` | Изменяемое значение по умолчанию у аргумента (`def f(x=[])`) | +| `T8` | Ручная конкатенация строк вместо f-строк | +| `T9` | Избыточные комментарии, пересказывающие очевидный код | + +## Штраф за списывание + +Если работа подозревается в списывании, студент получает **−5 баллов**. Эти баллы не вычитаются из оценки за правильность, а **заменяют** итоговую оценку: итоговой оценкой становится −5, независимо от результата тестов. + +Студент может оспорить этот балл на занятии, доказав, что не списывал, а написал код самостоятельно (например, объяснив решение и ответив на вопросы по нему). Если подозрение снимается, работа оценивается по обычным критериям. + +## Итоговая оценка + +``` +если есть подозрение в списывании: + итоговая_оценка = −5 +иначе: + итоговая_оценка = max(0, балл_за_правильность − штрафы_за_оформление) +``` + +Итоговая оценка за домашнее задание округляется до ближайшего целого по правилам математики: дробная часть от 0.5 и выше округляется вверх, меньше 0.5 — вниз (например, 7.5 → 8, 7.4 → 7). + +## Как оспорить оценку + +Если вы не согласны с оценкой, напишите преподавателю. Укажите, с каким блоком или с какой частью критериев вы не согласны, и приведите аргументы. Оценка пересматривается по тем же критериям, которые описаны выше. diff --git a/src/part3.zip b/src/part3.zip new file mode 100644 index 0000000000000000000000000000000000000000..ebe1e5e32ff7d5240e4733154cd088e38c5bf007 GIT binary patch literal 20067 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 K#o> **Важно.** Тексты, которые функции печатают в stdout или возвращают как +> строку (сообщения об ошибках, таблица хранилища, ответы консоли), в тестах +> проверяются **дословно**: лишний пробел, другой регистр, пропущенная точка или +> перевод строки считаются ошибкой. Берите тексты из описания и примеров как +> есть. + +--- + +## Модуль `utils.py` + +### Константа `TABLE_HEADERS` + +Кортеж заголовков столбцов таблицы, слева направо: +`("ID", "name", "price", "quantity")`. Аннотирован `typing.Final`. Ширина +столбцов здесь не фиксируется — она измеряется по данным в момент вызова +`get_storage_str_representation`. + +### `normalize_product_name(name: str) -> str` + +Приводит сырое имя товара к единственному написанию, которое хранит склад. + +- **Вход:** `name` — произвольная строка. +- **Выход:** та же строка без начальных и конечных пробельных символов; + каждая внутренняя последовательность пробельных символов (пробелы, + табуляции и т. п.) заменяется на один пробел; всё остальное переведено в + нижний регистр. Если в строке нет ничего, кроме пробельных символов, — + возвращается пустая строка. + +Примеры: `" Cordless Drill " → "cordless drill"`, +`"abc def\tghi\tjkl" → "abc def ghi jkl"`, `" \t\n " → ""`, +`"claw hammer" → "claw hammer"` (уже канонично — не меняется). + +### `get_storage_str_representation(storage: list[Product]) -> str` + +Отрисовывает всё хранилище одной мультистрокой — выровненной таблицей, +пригодной для печати в терминал. + +«Выровненная» значит, что при печати моноширинным шрифтом все строки таблицы +имеют одинаковую длину, а разделители `|` стоят строго друг под другом: +значения в каждом столбце начинаются с одной и той же позиции, а ширина столбца +подбирается под самое длинное из его значений. Сравните: так выглядит вывод +хранилища, если просто напечатать значения через пробел: + +``` +ID name price quantity +10 compressor 1299.99 3 +2 nail 2.50 500 +``` + +А так он выглядит после выравнивания: + +``` +| ID | name | price | quantity | +|----|------------|---------|----------| +| 10 | compressor | 1299.99 | 3 | +| 2 | nail | 2.50 | 500 | +``` + +Точные правила выравнивания приведены в разделе «Формат» ниже. + +- **Вход:** `storage` — хранилище товаров. +- **Выход:** мультистрока (строки соединены `"\n"`, завершающего + перевода строки нет): + + 1. строка заголовков — значения из `TABLE_HEADERS`; + 2. разделительная строка из дефисов; + 3. по одной строке на каждый товар из `storage`, **в том порядке, в котором + хранилище их держит**. + + Когда `storage` пусто — возвращаются только строки 1 и 2. + +**Формат.** + +- Значения ячеек товара берутся как `str(...)` от полей записи в порядке + `TABLE_HEADERS`: `str(id)`, имя как есть, `str(price)` (у `Decimal`, уже + нормализованного, это, например, `"9.90"`, `"12.00"`, `"1299.99"`), + `str(quantity)`. +- Ширина каждого столбца равна длине самого длинного значения в этом столбце, + причём метка заголовка из `TABLE_HEADERS` тоже участвует в подсчёте. Поэтому + столбец никогда не уже своего заголовка, в том числе на пустом хранилище. +- Ячейка выравнивается по левому краю и дополняется пробелами до ширины + столбца; вокруг каждой ячейки — по одному пробелу отступа. Строка данных и + строка заголовков начинаются и заканчиваются символом `|`, соседние ячейки + тоже разделяются `|`. +- В разделительной строке каждый прогон дефисов на два символа длиннее ширины + своего столбца (закрывает отступы по краям ячейки); прогоны разделены `|`, + строка начинается и заканчивается `|`. + +Пустое хранилище отрисовывается ровно так: + +``` +| ID | name | price | quantity | +|----|------|-------|----------| +``` + +Хранилище `[(1, "saw", Decimal("9.90"), 5), (2, "axe", Decimal("12.00"), 8)]`: + +``` +| ID | name | price | quantity | +|----|------|-------|----------| +| 1 | saw | 9.90 | 5 | +| 2 | axe | 12.00 | 8 | +``` + +Хранилище `[(10, "compressor", Decimal("1299.99"), 3), (2, "nail", Decimal("2.50"), 500)]` +(столбцы расширяются под самое длинное значение): + +``` +| ID | name | price | quantity | +|----|------------|---------|----------| +| 10 | compressor | 1299.99 | 3 | +| 2 | nail | 2.50 | 500 | +``` + +--- + +## Модуль `crud.py` + +Остальные функции модуля (`generate_product_id`, `read_product`, +`delete_product`) реализуйте как в своём решении части 2. Изменяются только две +функции, описанные ниже; их сигнатуры прежние. + +### `create_product(storage: list[Product], fields: tuple[str, Decimal, int]) -> int | None` + +Порядок действий (при первой же неудаче хранилище не меняется, печатается +сообщение, возвращается `None`): + +1. Имя из `fields` приводится через `normalize_product_name`. Дальше везде — + и при сравнении, и при сохранении — используется нормализованное имя. +2. Если нормализованное имя пустое — печатается сообщение + `product name must not be blank`, возвращается `None`. +3. Если в хранилище уже есть запись с таким же **нормализованным** именем — + печатается сообщение, содержащее это имя + (`product name '<имя>' is already taken`), возвращается `None`. Сравнение + идёт по канонической форме, поэтому `" Cordless Drill "` (иной регистр, + лишние пробелы по краям и между словами) конфликтует с уже сохранённым + `"cordless drill"`. +4. Иначе — как в части 2: кортеж вида `(идентификатор, нормализованное имя, + нормализованная цена, количество)` добавляется в хранилище, возвращается + идентификатор. Значение идентификатора должно быть сгенерировано + с помощью функции `generate_product_id`, цена нормализована с помощью + функции `normalize_price`. + +**Примеры.** Каждый пример — отдельный фрагмент скрипта, который начинается с +пустого хранилища. В комментариях указано, что функция возвращает или печатает. + +Успешное создание: имя нормализуется, цена округляется, идентификаторы +выдаются по порядку: + +```python +storage: list[Product] = [] + +first_id = create_product(storage, (" Cordless Drill ", Decimal("89.999"), 12)) +second_id = create_product(storage, ("Claw Hammer", Decimal("9.9"), 40)) + +print(first_id) # 1 +print(second_id) # 2 +print(storage) +# [(1, 'cordless drill', Decimal('90.00'), 12), +# (2, 'claw hammer', Decimal('9.90'), 40)] +``` + +Имя занято (сравнение идёт по нормализованной форме), хранилище не меняется: + +```python +storage: list[Product] = [] + +create_product(storage, ("cordless drill", Decimal("50"), 3)) +result = create_product(storage, (" CORDLESS DRILL ", Decimal("75"), 1)) +# печатает: product name 'cordless drill' is already taken + +print(result) # None +print(storage) # [(1, 'cordless drill', Decimal('50.00'), 3)] +``` + +Имя из одних пробелов, хранилище не меняется: + +```python +storage: list[Product] = [] + +result = create_product(storage, (" ", Decimal("10"), 5)) +# печатает: product name must not be blank + +print(result) # None +print(storage) # [] +``` + +### `update_product(storage: list[Product], product_id: int, fields: tuple[str, Decimal, int]) -> Product | None` + +Порядок действий: + +1. Имя из `fields` приводится через `normalize_product_name`. +2. Если нормализованное имя пустое — печатается сообщение + `product name must not be blank`, возвращается `None`, хранилище не + меняется. Эта проверка выполняется **до** поиска записи по идентификатору. +3. Если записи с `product_id` нет — печатается сообщение с идентификатором + (`no product with id `), возвращается `None`, хранилище не меняется. +4. Если нормализованное имя уже занято **другим** товаром (запись с таким + же нормализованным именем и другим `product_id`) — печатается сообщение, + содержащее это имя (`product name '<имя>' is already taken`), возвращается + `None`, хранилище не меняется. Переименование товара в его же текущее имя + (и обновление только цены/количества) конфликтом не считается. +5. Иначе запись заменяется на `(product_id, нормализованное имя, + normalize_price(цена), количество)`; идентификатор сохраняется прежним. + Возвращается изменённая запись. + +**Примеры.** Каждый пример — отдельный фрагмент скрипта, который начинается с +хранилища из двух товаров: + +```python +storage: list[Product] = [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), +] +``` + +Успешное изменение: имя нормализуется, цена округляется, идентификатор +сохраняется: + +```python +result = update_product(storage, 2, (" Rubber Mallet ", Decimal("7.505"), 25)) + +print(result) # (2, 'rubber mallet', Decimal('7.51'), 25) +print(storage) +# [(1, 'cordless drill', Decimal('90.00'), 12), +# (2, 'rubber mallet', Decimal('7.51'), 25)] +``` + +Обновление товара под его же именем (даже в другом написании) конфликтом не +считается — меняются только цена и количество: + +```python +result = update_product(storage, 1, ("CORDLESS Drill", Decimal("85"), 10)) + +print(result) # (1, 'cordless drill', Decimal('85.00'), 10) +``` + +Имя уже занято другим товаром, хранилище не меняется: + +```python +result = update_product(storage, 1, (" Claw Hammer ", Decimal("85"), 10)) +# печатает: product name 'claw hammer' is already taken + +print(result) # None +print(storage) +# [(1, 'cordless drill', Decimal('90.00'), 12), +# (2, 'claw hammer', Decimal('9.90'), 40)] +``` + +Товара с таким идентификатором нет, а пустое имя отвергается раньше, чем +выполняется поиск по идентификатору: + +```python +result = update_product(storage, 99, ("saw", Decimal("20"), 5)) +# печатает: no product with id 99 +print(result) # None + +result = update_product(storage, 99, (" ", Decimal("20"), 5)) +# печатает: product name must not be blank (а не no product with id 99) +print(result) # None +``` + +--- + +## Модуль `run_admin.py` (новый) + +Интерактивная консоль администратора над одним хранилищем товаров, которое +живёт в памяти всё время работы процесса. Цикл «прочитать строку — выполнить — +напечатать результат» продолжается, пока не введена команда `exit`. + +Пример сессии (после `admin> ` идёт ввод пользователя, остальное печатает +программа): + +``` +admin> create Cordless Drill 89.99 12 +1 +admin> create claw hammer 9.9 40 +2 +admin> show +| ID | name | price | quantity | +|----|----------------|-------|----------| +| 1 | cordless drill | 89.99 | 12 | +| 2 | claw hammer | 9.90 | 40 | +admin> read 5 +no product with id 5 +admin> exit +``` + +### Константы + +- `PROMPT: Final[str] = "admin> "` — приглашение, печатается перед чтением + каждой строки ввода. +- `HELP_TEXT: Final[str]` — многострочный текст-справка: перечень всех команд с + кратким описанием каждой и пояснением, что для `create` и `update` цена и + количество — это два последних слова строки, а всё между ними — имя товара + (поэтому имя может содержать пробелы и не требует кавычек). + + Пример текста справки: + + ``` + 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 + + 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. + ``` + +### Разбор команд + +Команда определяется по строке, разбитой по пробельным символам. +Распознаются следующие формы (число слов должно совпадать точно): + +| Форма ввода | Действие | +| --- | --- | +| `help` | напечатать `HELP_TEXT` | +| `exit` | завершить цикл | +| `show` | напечатать всё хранилище выравненной таблицей | +| `create <имя...> <цена> <количество>` | добавить товар; `<имя...>` — одно или больше слов | +| `read ` | напечатать один товар | +| `update <имя...> <цена> <количество>` | перезаписать поля товара; `<имя...>` — одно или больше слов | +| `delete ` | удалить товар | + +`<имя...>` может состоять из произвольного количества слов (но не меньше +одного), разделённых пробелами; слова склеиваются через один пробел в имя +товара. Все остальные параметры команд (``, `<цена>`, `<количество>`) — ровно +по одному слову. Поэтому `create` и `update` без слов имени (`create 89.90 12`) +командой не считаются. + +Разобранные аргументы приводятся к типам: `` и `<количество>` — через +`int(...)` (десятичное целое), `<цена>` — через `Decimal(...)`. Получившийся +кортеж `(имя, цена, количество)` передаётся в соответствующую функцию `crud.py`. + +### `show_help() -> None` + +Печатает `HELP_TEXT` в stdout как есть. + +### `print_result(result: object) -> None` + +- **Вход:** `result` — значение, вернувшееся из CRUD-операции. +- **Поведение:** если `result` не `None` — печатает `result` в stdout; если + `None` — не печатает ничего (операция уже сообщила о своей неудаче сама). + +### `run_command(storage: list[Product], line: str) -> bool` + +Разбирает одну строку консоли и выполняет названную ею команду. + +- **Вход:** `storage` — общее хранилище сессии (команды `create`, `update`, + `delete` меняют его на месте); `line` — одна строка ввода, уже очищенная от + окружающих пробелов. +- **Выход:** `True` — цикл нужно продолжать; `False` — введена команда `exit`. +- **Поведение по формам:** + - `help` — печатается справка; цикл продолжается; + - `exit` — цикл нужно остановить; + - `show` — печатается таблица со всем хранилищем; цикл продолжается; + - `create`, `read`, `update`, `delete` — выполняется соответствующая + CRUD-операция с разобранными аргументами, её результат выводится на экран + (если операция ничего не вернула, не выводится ничего); цикл продолжается; + - строка, которая не подошла ни под одну форму — неизвестная команда + (`frobnicate ...`) либо известная команда с неверным числом аргументов + (`read 1 2 3`) — печатается сообщение `'<строка>' is not a command`; + хранилище не меняется; вернуть `True`. + +### `main() -> None` + +Запускает цикл до команды `exit`. В шаблоне задания эта функция **уже +реализована** — писать её не нужно; ниже описано её поведение, чтобы вы понимали, +как консоль использует `run_command`. + +- Заводит одно пустое хранилище `storage` и держит его в памяти всю сессию. +- На каждой итерации: читает строку через `input(PROMPT)`, очищает её от + окружающих пробелов, передаёт в `run_command`. Значение `run_command` + становится флагом продолжения цикла. +- `ValueError` и `decimal.InvalidOperation` из `run_command` перехватываются + здесь: печатается сообщение вида + `'<строка>' names a command but its arguments are invalid`, хранилище не + меняется, цикл продолжается. +- Цикл останавливает только `exit`. + +### Как запустить консоль + +Откройте терминал **в корне репозитория** (там, где лежит `pyproject.toml`) и +выполните: + +``` +uv run python -m src.part3.run_admin +``` + +Если `uv` у вас не установлен, см. [инструкцию](../../manuals/local_checks.md); +вместо него можно использовать обычный Python 3.12 или новее: + +``` +python -m src.part3.run_admin +``` + +После запуска появится приглашение `admin> `. Введите `help`, чтобы увидеть +список команд (после того как вы реализуете `show_help()`), и `exit`, чтобы +выйти. Хранилище живёт только пока работает программа: при следующем запуске оно +снова пустое. + +Обратите внимание: + +- запускать нужно именно как модуль (`-m src.part3.run_admin`, имя модуля с + точками, без `.py`). Запуск файла напрямую (`python src/part3/run_admin.py`) + завершится ошибкой `ImportError: attempted relative import with no known + parent package`, потому что модули пакета импортируют друг друга; +- запуск из другой папки (например, из `src/part3`) тоже не сработает — модуль + `src` в этом случае не будет найден; +- если вы запускаете скрипт из PyCharm или VS Code, настройте запуск «как + модуль» (параметр `-m` / `module`) с тем же именем `src.part3.run_admin` и + рабочей папкой в корне репозитория. + +--- + +## Инварианты + +- Всякое сохранённое имя каноническое: `create_product` и `update_product` + пропускают имя через `normalize_product_name` и при сравнении, и при записи, + поэтому в хранилище не встречаются имена с окружающими пробелами, с + несколькими пробельными символами подряд внутри или с буквами верхнего + регистра. +- Имена уникальны с точностью до нормализации: два имени, совпадающие после + `normalize_product_name`, не могут оба быть в хранилище. +- `get_storage_str_representation` не теряет и не переставляет товары: тело + таблицы — это записи `storage` в исходном порядке, по строке на запись; ни + одно значение не обрезается (каждый столбец не уже самого длинного своего + значения). +- Для непустого `storage` в выводе `get_storage_str_representation` все строки + имеют одинаковую длину, а «столбики» `|` стоят друг под другом. diff --git a/src/part3/__init__.py b/src/part3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/part3/crud.py b/src/part3/crud.py new file mode 100644 index 00000000..ecf4fe80 --- /dev/null +++ b/src/part3/crud.py @@ -0,0 +1,104 @@ +from decimal import Decimal + +from .storage import ( + NAME_INDEX, + PRODUCT_ID_INDEX, + PRODUCT_ID_MIN, + Product +) +from .utils import ( + normalize_price, + normalize_product_name +) + + +def generate_product_id(storage: list[Product]) -> int: + if not storage: + return PRODUCT_ID_MIN + return max([product[PRODUCT_ID_INDEX] for product in storage]) + 1 + + +def create_product( + storage: list[Product], fields: tuple[str, Decimal, int] +) -> int | None: + product_name = normalize_product_name(fields[0]) + if not product_name: + print('product name must not be blank') + return None + storage_product_names = [product[NAME_INDEX] for product in storage] + if product_name in storage_product_names: + print(f"product name '{product_name}' is already taken") + return None + + product_id = generate_product_id(storage) + product_price = normalize_price(fields[1]) + product_quantity = fields[2] + storage.append(( + product_id, + product_name, + product_price, + product_quantity + )) + return product_id + + +def read_product(storage: list[Product], product_id: int) -> Product | None: + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + return product + print(f"no product with id {product_id}") + return None + + +def update_product( + storage: list[Product], + product_id: int, + fields: tuple[str, Decimal, int], +) -> Product | None: + product_name = normalize_product_name(fields[0]) + if not product_name: + print('product name must not be blank') + return None + + product_index = -1 + is_name_taken = False + for i, product in enumerate(storage): + if product[PRODUCT_ID_INDEX] == product_id: + product_index = i + elif product[NAME_INDEX] == product_name: + is_name_taken = True + + if product_index < 0: + print(f"no product with id {product_id}") + return None + + if is_name_taken: + print(f"product name '{product_name}' is already taken") + return None + + updated_product = ( + product_id, + product_name, + normalize_price(fields[1]), + fields[2], + ) + storage[product_index] = updated_product + return updated_product + + +def delete_product(storage: list[Product], product_id: int) -> int | None: + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + storage.remove(product) + return product_id + print(f"no product with id {product_id}") + return None + +storage: list[Product] = [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), +] + +result = update_product(storage, 1, ("CORDLESS Drill", Decimal("85"), 10)) + +print(result) # (1, 'cordless drill', Decimal('85.00'), 10) \ No newline at end of file diff --git a/src/part3/run_admin.py b/src/part3/run_admin.py new file mode 100644 index 00000000..b7437b85 --- /dev/null +++ b/src/part3/run_admin.py @@ -0,0 +1,79 @@ +from decimal import Decimal, InvalidOperation # noqa: F401 +from typing import Final + +from .crud import ( # noqa: F401 + create_product, + delete_product, + read_product, + update_product, +) +from .storage import Product +from .utils import get_storage_str_representation # noqa: F401 + + +# TODO: задайте приглашение и текст справки +PROMPT: Final[str] = "admin> " +HELP_TEXT: Final[str] = ''' + 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 + + 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. + ''' + + +def show_help() -> None: + print(HELP_TEXT) + + +def print_result(result: object) -> None: + if result: + print(result) + + +def run_command(storage: list[Product], line: str) -> bool: + match line.split(): + case ["help"]: + show_help() + case ["exit"]: + return False + case ["show"]: + print(get_storage_str_representation(storage)) + case ["create", *name, price, quantity]: + fields = (' '.join(name), Decimal(price), int(quantity)) + print_result(create_product(storage, fields)) + case ["read", product_id]: + print_result(read_product(storage, int(product_id))) + case ["update", product_id, *name, price, quantity]: + fields = (' '.join(name), Decimal(price), int(quantity)) + print_result(update_product(storage, int(product_id), fields)) + case ["delete", product_id]: + print_result(delete_product(storage, int(product_id))) + case _: + print(f"'{line}' is not a command") + return True + + +def main() -> None: + storage: list[Product] = [] + running = True + + while running: + line = input(PROMPT).strip() + + try: + running = run_command(storage, line) + + except (ValueError, InvalidOperation): + print(f"{line!r} names a command but its arguments are invalid") + + +if __name__ == "__main__": + main() diff --git a/src/part3/storage.py b/src/part3/storage.py new file mode 100644 index 00000000..81651a75 --- /dev/null +++ b/src/part3/storage.py @@ -0,0 +1,12 @@ +from decimal import Decimal +from typing import Final + + +type Product = tuple[int, str, Decimal, int] + +PRODUCT_ID_INDEX: Final = 0 +NAME_INDEX: Final = 1 +PRICE_INDEX: Final = 2 +QUANTITY_INDEX: Final = 3 + +PRODUCT_ID_MIN: Final[int] = 1 diff --git a/src/part3/utils.py b/src/part3/utils.py new file mode 100644 index 00000000..0499f57d --- /dev/null +++ b/src/part3/utils.py @@ -0,0 +1,39 @@ +from decimal import ROUND_HALF_UP, Decimal +from typing import Final + +from .storage import ( + Product +) + + +PRICE_PRECISION: Final[int] = 2 +PRICE_STEP: Final = Decimal(1).scaleb(-PRICE_PRECISION) +TABLE_HEADERS: Final = ("ID", "name", "price", "quantity") + + +def normalize_price(price: Decimal) -> Decimal: + return price.quantize(PRICE_STEP, ROUND_HALF_UP) + +def normalize_product_name(name: str) -> str: + return ' '.join(name.split()).lower() + +def get_storage_str_representation(storage: list[Product]) -> str: + col_width = [ + max( + max([len(str(product[i])) for product in storage] + [0]), # + [0] во избежание + len(TABLE_HEADERS[i]) # пустоты списка + ) + for i in range(len(TABLE_HEADERS)) + ] + rows_data = [TABLE_HEADERS] + storage + rows = [] + for row_data in rows_data: + row = '|' + for i, cell_data in enumerate(row_data): + cell = str(cell_data).ljust(col_width[i]) + row = row + ' ' + cell + ' |' + rows.append(row) + dividing_row_data = ['-' * (width + 2) for width in col_width] + dividing_row = '|' + '|'.join(dividing_row_data) + '|' + rows.insert(1, dividing_row) + return '\n'.join(rows) \ No newline at end of file From 3cf8571d742ffacab62203cf287bb552afeab120 Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Sat, 10 Oct 2026 18:39:16 +0300 Subject: [PATCH 5/7] task 3 CI checked --- src/part3/crud.py | 38 ++++++++------------------------------ src/part3/run_admin.py | 14 +++++++------- src/part3/utils.py | 24 +++++++++++++----------- 3 files changed, 28 insertions(+), 48 deletions(-) diff --git a/src/part3/crud.py b/src/part3/crud.py index ecf4fe80..ac9f29ff 100644 --- a/src/part3/crud.py +++ b/src/part3/crud.py @@ -1,15 +1,7 @@ from decimal import Decimal -from .storage import ( - NAME_INDEX, - PRODUCT_ID_INDEX, - PRODUCT_ID_MIN, - Product -) -from .utils import ( - normalize_price, - normalize_product_name -) +from .storage import NAME_INDEX, PRODUCT_ID_INDEX, PRODUCT_ID_MIN, Product +from .utils import normalize_price, normalize_product_name def generate_product_id(storage: list[Product]) -> int: @@ -23,7 +15,7 @@ def create_product( ) -> int | None: product_name = normalize_product_name(fields[0]) if not product_name: - print('product name must not be blank') + print("product name must not be blank") return None storage_product_names = [product[NAME_INDEX] for product in storage] if product_name in storage_product_names: @@ -33,12 +25,7 @@ def create_product( product_id = generate_product_id(storage) product_price = normalize_price(fields[1]) product_quantity = fields[2] - storage.append(( - product_id, - product_name, - product_price, - product_quantity - )) + storage.append((product_id, product_name, product_price, product_quantity)) return product_id @@ -57,9 +44,9 @@ def update_product( ) -> Product | None: product_name = normalize_product_name(fields[0]) if not product_name: - print('product name must not be blank') + print("product name must not be blank") return None - + product_index = -1 is_name_taken = False for i, product in enumerate(storage): @@ -71,11 +58,11 @@ def update_product( if product_index < 0: print(f"no product with id {product_id}") return None - + if is_name_taken: print(f"product name '{product_name}' is already taken") return None - + updated_product = ( product_id, product_name, @@ -93,12 +80,3 @@ def delete_product(storage: list[Product], product_id: int) -> int | None: return product_id print(f"no product with id {product_id}") return None - -storage: list[Product] = [ - (1, "cordless drill", Decimal("90.00"), 12), - (2, "claw hammer", Decimal("9.90"), 40), -] - -result = update_product(storage, 1, ("CORDLESS Drill", Decimal("85"), 10)) - -print(result) # (1, 'cordless drill', Decimal('85.00'), 10) \ No newline at end of file diff --git a/src/part3/run_admin.py b/src/part3/run_admin.py index b7437b85..f111b76b 100644 --- a/src/part3/run_admin.py +++ b/src/part3/run_admin.py @@ -1,19 +1,19 @@ -from decimal import Decimal, InvalidOperation # noqa: F401 +from decimal import Decimal, InvalidOperation from typing import Final -from .crud import ( # noqa: F401 +from .crud import ( create_product, delete_product, read_product, update_product, ) from .storage import Product -from .utils import get_storage_str_representation # noqa: F401 +from .utils import get_storage_str_representation # TODO: задайте приглашение и текст справки PROMPT: Final[str] = "admin> " -HELP_TEXT: Final[str] = ''' +HELP_TEXT: Final[str] = """ Available commands: help show this message exit leave the console @@ -26,7 +26,7 @@ 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. - ''' + """ def show_help() -> None: @@ -47,12 +47,12 @@ def run_command(storage: list[Product], line: str) -> bool: case ["show"]: print(get_storage_str_representation(storage)) case ["create", *name, price, quantity]: - fields = (' '.join(name), Decimal(price), int(quantity)) + fields = (" ".join(name), Decimal(price), int(quantity)) print_result(create_product(storage, fields)) case ["read", product_id]: print_result(read_product(storage, int(product_id))) case ["update", product_id, *name, price, quantity]: - fields = (' '.join(name), Decimal(price), int(quantity)) + fields = (" ".join(name), Decimal(price), int(quantity)) print_result(update_product(storage, int(product_id), fields)) case ["delete", product_id]: print_result(delete_product(storage, int(product_id))) diff --git a/src/part3/utils.py b/src/part3/utils.py index 0499f57d..34c529c3 100644 --- a/src/part3/utils.py +++ b/src/part3/utils.py @@ -1,9 +1,7 @@ from decimal import ROUND_HALF_UP, Decimal from typing import Final -from .storage import ( - Product -) +from .storage import Product PRICE_PRECISION: Final[int] = 2 @@ -14,26 +12,30 @@ def normalize_price(price: Decimal) -> Decimal: return price.quantize(PRICE_STEP, ROUND_HALF_UP) + def normalize_product_name(name: str) -> str: - return ' '.join(name.split()).lower() + return " ".join(name.split()).lower() + def get_storage_str_representation(storage: list[Product]) -> str: col_width = [ max( - max([len(str(product[i])) for product in storage] + [0]), # + [0] во избежание - len(TABLE_HEADERS[i]) # пустоты списка + max( + [len(str(product[i])) for product in storage] + [0] + ), # + [0] во избежание + len(TABLE_HEADERS[i]), # пустоты списка ) for i in range(len(TABLE_HEADERS)) ] rows_data = [TABLE_HEADERS] + storage rows = [] for row_data in rows_data: - row = '|' + row = "|" for i, cell_data in enumerate(row_data): cell = str(cell_data).ljust(col_width[i]) - row = row + ' ' + cell + ' |' + row = row + " " + cell + " |" rows.append(row) - dividing_row_data = ['-' * (width + 2) for width in col_width] - dividing_row = '|' + '|'.join(dividing_row_data) + '|' + dividing_row_data = ["-" * (width + 2) for width in col_width] + dividing_row = "|" + "|".join(dividing_row_data) + "|" rows.insert(1, dividing_row) - return '\n'.join(rows) \ No newline at end of file + return "\n".join(rows) From ec84a6692314f336be045c3021c94e8eaa63d610 Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Fri, 23 Oct 2026 15:27:41 +0300 Subject: [PATCH 6/7] Solve task 2 new pr --- src/part3/crud.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/part3/crud.py b/src/part3/crud.py index ac9f29ff..76736744 100644 --- a/src/part3/crud.py +++ b/src/part3/crud.py @@ -73,6 +73,7 @@ def update_product( return updated_product + def delete_product(storage: list[Product], product_id: int) -> int | None: for product in storage: if product[PRODUCT_ID_INDEX] == product_id: From 0711c20e9071a8abda7fb1ceb50301ad6540886b Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Fri, 30 Oct 2026 23:29:40 +0300 Subject: [PATCH 7/7] solve-task-4 --- src/part2/utils.py | 2 +- src/part3.zip | Bin 20067 -> 0 bytes src/part3/crud.py | 3 +- src/part3/run_admin.py | 4 +- src/part3/storage.py | 2 +- src/part3/utils.py | 4 +- src/part4.zip | Bin 0 -> 26809 bytes src/part4/cart.py | 56 ++++++++++++++++--------- src/part4/crud.py | 64 +++++++++++++++++++++++------ src/part4/run_admin.py | 90 +++++++++++++++++++++++------------------ src/part4/storage.py | 42 ++----------------- src/part4/utils.py | 75 +++++++++++++++++----------------- 12 files changed, 188 insertions(+), 154 deletions(-) delete mode 100644 src/part3.zip create mode 100644 src/part4.zip diff --git a/src/part2/utils.py b/src/part2/utils.py index 7e95b729..413f9564 100644 --- a/src/part2/utils.py +++ b/src/part2/utils.py @@ -6,7 +6,7 @@ currency values free of binary floating-point error. """ -from decimal import ROUND_HALF_UP, Decimal # noqa: F401 +from decimal import ROUND_HALF_UP, Decimal from typing import Final diff --git a/src/part3.zip b/src/part3.zip deleted file mode 100644 index ebe1e5e32ff7d5240e4733154cd088e38c5bf007..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20067 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 K#o> int | None: storage.remove(product) return product_id print(f"no product with id {product_id}") - return None \ No newline at end of file + return None diff --git a/src/part3/run_admin.py b/src/part3/run_admin.py index 6bbb4c86..6d469f6a 100644 --- a/src/part3/run_admin.py +++ b/src/part3/run_admin.py @@ -29,10 +29,10 @@ it. """ -from decimal import Decimal, InvalidOperation # noqa: F401 +from decimal import Decimal, InvalidOperation from typing import Final -from .crud import ( # noqa: F401 +from .crud import ( create_product, delete_product, read_product, diff --git a/src/part3/storage.py b/src/part3/storage.py index 208e0779..c516645b 100644 --- a/src/part3/storage.py +++ b/src/part3/storage.py @@ -29,4 +29,4 @@ PRICE_INDEX: Final = 2 QUANTITY_INDEX: Final = 3 -PRODUCT_ID_MIN: Final[int] = 1 \ No newline at end of file +PRODUCT_ID_MIN: Final[int] = 1 diff --git a/src/part3/utils.py b/src/part3/utils.py index 8f2efa80..572e13a1 100644 --- a/src/part3/utils.py +++ b/src/part3/utils.py @@ -18,7 +18,7 @@ the longest value it holds in that particular call. """ -from decimal import ROUND_HALF_UP, Decimal # noqa: F401 +from decimal import ROUND_HALF_UP, Decimal from typing import Final from .storage import Product @@ -97,4 +97,4 @@ def get_storage_str_representation(storage: list[Product]) -> str: dividing_row_data = ["-" * (width + 2) for width in col_width] dividing_row = "|" + "|".join(dividing_row_data) + "|" rows.insert(1, dividing_row) - return "\n".join(rows) \ No newline at end of file + return "\n".join(rows) diff --git a/src/part4.zip b/src/part4.zip new file mode 100644 index 0000000000000000000000000000000000000000..0f906586ea956aff5729a60b8eb015a1393faa18 GIT binary patch literal 26809 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 BGSC13 literal 0 HcmV?d00001 diff --git a/src/part4/cart.py b/src/part4/cart.py index aed9bb72..f2cda1e7 100644 --- a/src/part4/cart.py +++ b/src/part4/cart.py @@ -1,18 +1,9 @@ -"""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: при необходимости добавьте свои вспомогательные функции +from .storage import ( # noqa: F401 + NAME_KEY, + PRICE_KEY, + QUANTITY_KEY, + Product, +) def add_to_cart( @@ -46,8 +37,19 @@ def add_to_cart( ``None`` when the product is unknown or the store cannot cover the request. """ - # TODO: реализуйте функцию - return 0 + if not product_id in storage: + print(f"no product with id {product_id}") + return None + if (storage_quantity := storage[product_id][QUANTITY_KEY]) < quantity: + print( + f"not enough stock for product {product_id}: " + f"{storage_quantity} available, " + f"{quantity} requested" + ) + return None + storage[product_id][QUANTITY_KEY] -= quantity + cart[product_id] = cart.get(product_id, 0) + quantity + return cart[product_id] def remove_from_cart( @@ -83,5 +85,21 @@ def remove_from_cart( (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: реализуйте функцию - return 0 + if not product_id in cart: + print(f"product {product_id} is not in the cart") + return None + if cart[product_id] < quantity: + print( + f"cart holds only {cart[product_id]} " + f"unit(s) of product {product_id}, " + f"cannot remove {quantity}" + ) + return None + if not product_id in storage: + print(f"no product with id {product_id}") + return None + storage[product_id][QUANTITY_KEY] += quantity + cart[product_id] -= quantity + if cart[product_id] == 0: + return cart.pop(product_id) + return cart[product_id] diff --git a/src/part4/crud.py b/src/part4/crud.py index 471eff00..b5dcb9c8 100644 --- a/src/part4/crud.py +++ b/src/part4/crud.py @@ -23,16 +23,16 @@ than a scan over ``storage.values()``. """ -import uuid # noqa: F401 +import uuid from typing import Any -from .storage import ( # noqa: F401 +from .storage import ( NAME_KEY, PRICE_KEY, QUANTITY_KEY, Product, ) -from .utils import normalize_price, normalize_product_name # noqa: F401 +from .utils import normalize_price, normalize_product_name def generate_product_id() -> str: @@ -41,8 +41,7 @@ def generate_product_id() -> str: Returns: A freshly generated UUID, stringified. """ - # TODO: реализуйте функцию - return "" + return str(uuid.uuid4()) def create_product( @@ -73,8 +72,21 @@ def create_product( and ``product_names`` are left unchanged and an explanatory message is printed. """ - # TODO: реализуйте функцию - return "" + normalized_name = normalize_product_name(fields[NAME_KEY]) + if not normalized_name: + print("product name must not be blank") + return None + if normalized_name in product_names: + print(f"product name '{normalized_name}' is already taken") + return None + product_id = generate_product_id() + storage[product_id] = { + NAME_KEY: normalized_name, + PRICE_KEY: normalize_price(fields[PRICE_KEY]), + QUANTITY_KEY: fields[QUANTITY_KEY], + } + product_names.add(normalized_name) + return product_id def read_product(storage: dict[str, Product], product_id: str) -> Product | None: @@ -88,8 +100,10 @@ def read_product(storage: dict[str, Product], product_id: str) -> Product | None The matching product record, or ``None`` when no product carries that identifier (a message is printed in that case). """ - # TODO: реализуйте функцию - return {} + if not product_id in storage: + print(f"no product with id {product_id}") + return None + return storage[product_id] def update_product( @@ -129,8 +143,29 @@ def update_product( and an explanatory message is printed. Renaming a product to its own current name is allowed. """ - # TODO: реализуйте функцию - return {} + if not read_product(storage, product_id): + return None + if NAME_KEY in fields: + if not (normalized_name := normalize_product_name(fields[NAME_KEY])): + print("product name must not be blank") + return None + if normalized_name != (old_name := storage[product_id][NAME_KEY]): + if normalized_name in product_names: + print(f"product name '{normalized_name}' is already taken") + return None + product_names.remove(old_name) + product_names.add(normalized_name) + fields[NAME_KEY] = normalized_name + + if PRICE_KEY in fields: + fields[PRICE_KEY] = normalize_price(fields[PRICE_KEY]) + + storage[product_id] = { + NAME_KEY: fields.get(NAME_KEY, storage[product_id][NAME_KEY]), + PRICE_KEY: fields.get(PRICE_KEY, storage[product_id][PRICE_KEY]), + QUANTITY_KEY: fields.get(QUANTITY_KEY, storage[product_id][QUANTITY_KEY]), + } + return storage[product_id] def delete_product( @@ -153,5 +188,8 @@ def delete_product( product carried that identifier (``storage`` and ``product_names`` are left unchanged and a message is printed). """ - # TODO: реализуйте функцию - return "" + if not read_product(storage, product_id): + return None + product_names.remove(storage[product_id][NAME_KEY]) + del storage[product_id] + return product_id diff --git a/src/part4/run_admin.py b/src/part4/run_admin.py index 2247b29b..80b3a78e 100644 --- a/src/part4/run_admin.py +++ b/src/part4/run_admin.py @@ -1,49 +1,32 @@ -"""Interactive admin console for the part4 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. - -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.part4.crud`; whatever -that call returns is printed unless it is ``None``. 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 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 Decimal, InvalidOperation # noqa: F401 +from decimal import Decimal, InvalidOperation from typing import Final -from .crud import ( # noqa: F401 +from .crud import ( create_product, delete_product, read_product, update_product, ) -from .storage import NAME_KEY, PRICE_KEY, QUANTITY_KEY, Product # noqa: F401 -from .utils import get_storage_str_representation # noqa: F401 +from .storage import NAME_KEY, PRICE_KEY, QUANTITY_KEY, Product +from .utils import get_storage_str_representation # TODO: задайте приглашение и текст справки (используйте своё решение части 3) -PROMPT: Final[str] = "" -HELP_TEXT: Final[str] = "" +PROMPT: Final[str] = "admin> " +HELP_TEXT: Final[str] = """ + 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 + + 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. + """ def show_help() -> None: @@ -53,7 +36,7 @@ def show_help() -> None: is: a heading, one line per command with a short description, and a note on how multi-word names are parsed. """ - # TODO: реализуйте функцию (используйте своё решение части 3) + print(HELP_TEXT) def print_result(result: object) -> None: @@ -64,7 +47,16 @@ def print_result(result: object) -> None: operation already reported its own failure, so nothing is printed in that case. """ - # TODO: реализуйте функцию (используйте своё решение части 3) + if result: + print(result) + + +def get_fields(name: list[str], price: str, quantity: str) -> Product: + return { + NAME_KEY: " ".join(name), + PRICE_KEY: Decimal(price), + QUANTITY_KEY: int(quantity), + } def run_command( @@ -91,8 +83,26 @@ def run_command( decimal.InvalidOperation: If the price argument of ``create`` or ``update`` does not parse as a decimal number. """ - # TODO: реализуйте функцию - return False + match line.split(): + case ["help"]: + show_help() + case ["exit"]: + return False + case ["show"]: + print(get_storage_str_representation(storage)) + case ["create", *name, price, quantity]: + fields = get_fields(name, price, quantity) + print_result(create_product(storage, product_names, fields)) + case ["read", product_id]: + print_result(read_product(storage, product_id)) + case ["update", product_id, *name, price, quantity]: + fields = get_fields(name, price, quantity) + print_result(update_product(storage, product_names, product_id, fields)) + case ["delete", product_id]: + print_result(delete_product(storage, product_names, product_id)) + case _: + print(f"'{line}' is not a command") + return True def main() -> None: diff --git a/src/part4/storage.py b/src/part4/storage.py index 9acedf07..564aa4ac 100644 --- a/src/part4/storage.py +++ b/src/part4/storage.py @@ -1,45 +1,9 @@ -"""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 -UUID string assigned by the create operation in :mod:`src.part4.crud` --- to its :data:`Product` record -- a dictionary with the keys ``name``, -``price`` and ``quantity`` -- where: - -* the ``storage`` key itself -- unique string identifier, assigned by the - create operation in :mod:`src.part4.crud`; it is not duplicated - inside the record; -* ``name`` -- human-readable product name, unique across the store, held - in normalised form (see - :func:`src.part4.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.part4.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.part4.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.part4.crud` keep the two in sync on every -successful create, update and delete. -""" - from typing import Any, Final type Product = dict[str, Any] # Name of each field inside a Product record. -# TODO: задайте имена полей записи -NAME_KEY: Final = "" -PRICE_KEY: Final = "" -QUANTITY_KEY: Final = "" +NAME_KEY: Final = "name" +PRICE_KEY: Final = "price" +QUANTITY_KEY: Final = "quantity" diff --git a/src/part4/utils.py b/src/part4/utils.py index d2a940ea..087098b0 100644 --- a/src/part4/utils.py +++ b/src/part4/utils.py @@ -1,27 +1,7 @@ -"""Standalone helpers shared by the part4 storage and CRUD modules. +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Final -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. -""" - -from decimal import ROUND_HALF_UP, Decimal # noqa: F401 -from typing import Final - -from .storage import ( # noqa: F401 +from .storage import ( NAME_KEY, PRICE_KEY, QUANTITY_KEY, @@ -31,16 +11,13 @@ # Number of fractional digits every stored price is rounded to. # Quantisation step derived from PRICE_PRECISION, e.g. Decimal("0.01"). -# TODO: задайте число знаков после запятой и шаг квантования (используйте своё -# решение части 3) -PRICE_PRECISION: Final[int] = 0 -PRICE_STEP: Final = Decimal(0) +PRICE_PRECISION: Final[int] = 2 +PRICE_STEP: Final = Decimal(1).scaleb(-PRICE_PRECISION) # Column headers of the table produced by get_storage_str_representation, # left to right. The width of each column is not fixed here -- it is # measured per call from the data (see the function). -# TODO: задайте заголовки столбцов таблицы (используйте своё решение части 3) -TABLE_HEADERS: Final[tuple[str, ...]] = () +TABLE_HEADERS: Final[tuple[str, ...]] = ("ID", NAME_KEY, PRICE_KEY, QUANTITY_KEY) def normalize_price(price: Decimal) -> Decimal: @@ -53,8 +30,7 @@ def normalize_price(price: Decimal) -> Decimal: ``price`` quantised to :data:`PRICE_PRECISION` fractional digits, with halves rounded up. """ - # TODO: реализуйте функцию (используйте своё решение части 3) - return Decimal(0) + return price.quantize(PRICE_STEP, ROUND_HALF_UP) def normalize_product_name(name: str) -> str: @@ -71,11 +47,26 @@ def normalize_product_name(name: str) -> str: The result is an empty string when ``name`` holds nothing but whitespace. """ - # TODO: реализуйте функцию (используйте своё решение части 3) - return "" + return " ".join(name.split()).lower() + + +def get_columns_width(rows_data: list[tuple[str, ...]]) -> list[int]: + return [ + max( + max([len(str(row_data[i])) for row_data in rows_data] + [0]), + len(TABLE_HEADERS[i]), + ) + for i in range(len(TABLE_HEADERS)) + ] + + +def merge_in_row_data(product_id: str, fields: dict[str, Any]) -> tuple[str, ...]: + return (product_id, *map(str, fields.values())) -# TODO: при необходимости добавьте свои вспомогательные функции +def get_dividing_row(cols_width: list[int]) -> str: + dividing_row_data = ["-" * (width + 2) for width in cols_width] + return "|" + "|".join(dividing_row_data) + "|" def get_storage_str_representation(storage: dict[str, Product]) -> str: @@ -96,5 +87,17 @@ def get_storage_str_representation(storage: dict[str, Product]) -> str: ``storage`` is empty only the header and separator rows are returned, sized to the header labels. """ - # TODO: реализуйте функцию (используйте своё решение части 3) - return "" + rows_data = [TABLE_HEADERS] + list( + map(merge_in_row_data, storage.keys(), storage.values()) + ) + cols_width = get_columns_width(rows_data) + rows = [] + for row_data in rows_data: + row = "|" + for i, cell_data in enumerate(row_data): + cell = cell_data.ljust(cols_width[i]) + row += " " + cell + " |" + rows.append(row) + dividing_row = get_dividing_row(cols_width) + rows.insert(1, dividing_row) + return "\n".join(rows)