Skip to content

Latest commit

 

History

History
486 lines (377 loc) · 28.1 KB

TASK.md

File metadata and controls

486 lines (377 loc) · 28.1 KB

Задача: нормализация имён, печать хранилища таблицей и админ-консоль

Легенда

CRUD-функции из части 2 уже умеют создавать, читать, изменять и удалять товары, но пользоваться ими можно только из кода: чтобы добавить в каталог одну позицию или посмотреть, что лежит на складе, приходится писать скрипт. Для администратора магазина это неудобно, а при первых же реальных данных вылезают новые проблемы:

  • один и тот же товар заводят несколько раз под разными написаниями — "Cordless Drill", "cordless drill", " cordless drill " — и в каталоге появляются дубликаты, которые невозможно найти по имени;
  • содержимое хранилища выводится как «сырой» список кортежей, в котором трудно что-либо разобрать, особенно когда товаров становится больше нескольких;
  • любое действие над каталогом требует запуска отдельного кода, хотя администратору нужен простой интерактивный инструмент.

Чтобы решить эти задачи, мы научим хранилище приводить имена товаров к единому виду и следить за их уникальностью, отрисовывать себя выровненной текстовой таблицей и построим над ним консоль администратора — маленькую программу, в которой команды вводятся строками, а результат сразу печатается на экране.

Контекст

Часть 3 продолжает часть 2 — тот же интернет-магазин, то же хранилище товаров (список кортежей Product в оперативной памяти) и те же CRUD-операции над ним. Корзина (cart.py) в этой части не используется. То, что в этой части не меняется, берите из своего решения части 2. Новых возможностей три:

  • имя товара приводится к канонической форме перед сохранением и сравнением;
  • хранилище можно отрисовать выровненной текстовой таблицей;
  • появляется интерактивная консоль администратора над этим хранилищем.

Модули:

  • storage.py — используйте своё решение части 2 (форма записи Product, константы *_INDEX, PRODUCT_ID_MIN);
  • utils.py — к существующему округлению цен (PRICE_PRECISION, PRICE_STEP, normalize_price — используйте своё решение части 2) добавляются нормализация имени и отрисовка таблицы;
  • crud.py — generate_product_id, read_product, delete_product — используйте свои решения части 2; create_product и update_product дорабатываются (см. ниже);
  • run_admin.py — новый модуль, консоль администратора.

Всё, что не изменилось по сравнению с частью 2, здесь не описывается — реализуйте это так, как вы делали в своём решении части 2 (см. описание части 2).

Важно. Тексты, которые функции печатают в 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.

Примеры. Каждый пример — отдельный фрагмент скрипта, который начинается с пустого хранилища. В комментариях указано, что функция возвращает или печатает.

Успешное создание: имя нормализуется, цена округляется, идентификаторы выдаются по порядку:

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)]

Имя занято (сравнение идёт по нормализованной форме), хранилище не меняется:

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)]

Имя из одних пробелов, хранилище не меняется:

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 <id>), возвращается None, хранилище не меняется.
  4. Если нормализованное имя уже занято другим товаром (запись с таким же нормализованным именем и другим product_id) — печатается сообщение, содержащее это имя (product name '<имя>' is already taken), возвращается None, хранилище не меняется. Переименование товара в его же текущее имя (и обновление только цены/количества) конфликтом не считается.
  5. Иначе запись заменяется на (product_id, нормализованное имя, normalize_price(цена), количество); идентификатор сохраняется прежним. Возвращается изменённая запись.

Примеры. Каждый пример — отдельный фрагмент скрипта, который начинается с хранилища из двух товаров:

storage: list[Product] = [
    (1, "cordless drill", Decimal("90.00"), 12),
    (2, "claw hammer", Decimal("9.90"), 40),
]

Успешное изменение: имя нормализуется, цена округляется, идентификатор сохраняется:

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)]

Обновление товара под его же именем (даже в другом написании) конфликтом не считается — меняются только цена и количество:

result = update_product(storage, 1, ("CORDLESS  Drill", Decimal("85"), 10))

print(result)   # (1, 'cordless drill', Decimal('85.00'), 10)

Имя уже занято другим товаром, хранилище не меняется:

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)]

Товара с таким идентификатором нет, а пустое имя отвергается раньше, чем выполняется поиск по идентификатору:

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 <name...> <price> <quantity>        add a product, print its new id
      read <id>                                  print the product with that id
      update <id> <name...> <price> <quantity>   overwrite that product's fields
      delete <id>                                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 <id> напечатать один товар
update <id> <имя...> <цена> <количество> перезаписать поля товара; <имя...> — одно или больше слов
delete <id> удалить товар

<имя...> может состоять из произвольного количества слов (но не меньше одного), разделённых пробелами; слова склеиваются через один пробел в имя товара. Все остальные параметры команд (<id>, <цена>, <количество>) — ровно по одному слову. Поэтому create и update без слов имени (create 89.90 12) командой не считаются.

Разобранные аргументы приводятся к типам: <id> и <количество> — через 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 у вас не установлен, см. инструкцию; вместо него можно использовать обычный 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 все строки имеют одинаковую длину, а «столбики» | стоят друг под другом.