From 979afbdefb5e1c75148b4ff065354ac7407ddbfb Mon Sep 17 00:00:00 2001 From: SlavAlVla Date: Tue, 22 Sep 2026 22:06:17 +0300 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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)