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 или возвращают как строку (сообщения об ошибках, таблица хранилища, ответы консоли), в тестах проверяются дословно: лишний пробел, другой регистр, пропущенная точка или перевод строки считаются ошибкой. Берите тексты из описания и примеров как есть.
Кортеж заголовков столбцов таблицы, слева направо:
("ID", "name", "price", "quantity"). Аннотирован typing.Final. Ширина
столбцов здесь не фиксируется — она измеряется по данным в момент вызова
get_storage_str_representation.
Приводит сырое имя товара к единственному написанию, которое хранит склад.
- Вход:
name— произвольная строка. - Выход: та же строка без начальных и конечных пробельных символов; каждая внутренняя последовательность пробельных символов (пробелы, табуляции и т. п.) заменяется на один пробел; всё остальное переведено в нижний регистр. Если в строке нет ничего, кроме пробельных символов, — возвращается пустая строка.
Примеры: " Cordless Drill " → "cordless drill",
"abc def\tghi\tjkl" → "abc def ghi jkl", " \t\n " → "",
"claw hammer" → "claw hammer" (уже канонично — не меняется).
Отрисовывает всё хранилище одной мультистрокой — выровненной таблицей, пригодной для печати в терминал.
«Выровненная» значит, что при печати моноширинным шрифтом все строки таблицы
имеют одинаковую длину, а разделители | стоят строго друг под другом:
значения в каждом столбце начинаются с одной и той же позиции, а ширина столбца
подбирается под самое длинное из его значений. Сравните: так выглядит вывод
хранилища, если просто напечатать значения через пробел:
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", завершающего перевода строки нет):- строка заголовков — значения из
TABLE_HEADERS; - разделительная строка из дефисов;
- по одной строке на каждый товар из
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 |
Остальные функции модуля (generate_product_id, read_product,
delete_product) реализуйте как в своём решении части 2. Изменяются только две
функции, описанные ниже; их сигнатуры прежние.
Порядок действий (при первой же неудаче хранилище не меняется, печатается
сообщение, возвращается None):
- Имя из
fieldsприводится черезnormalize_product_name. Дальше везде — и при сравнении, и при сохранении — используется нормализованное имя. - Если нормализованное имя пустое — печатается сообщение
product name must not be blank, возвращаетсяNone. - Если в хранилище уже есть запись с таким же нормализованным именем —
печатается сообщение, содержащее это имя
(
product name '<имя>' is already taken), возвращаетсяNone. Сравнение идёт по канонической форме, поэтому" Cordless Drill "(иной регистр, лишние пробелы по краям и между словами) конфликтует с уже сохранённым"cordless drill". - Иначе — как в части 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
Порядок действий:
- Имя из
fieldsприводится черезnormalize_product_name. - Если нормализованное имя пустое — печатается сообщение
product name must not be blank, возвращаетсяNone, хранилище не меняется. Эта проверка выполняется до поиска записи по идентификатору. - Если записи с
product_idнет — печатается сообщение с идентификатором (no product with id <id>), возвращаетсяNone, хранилище не меняется. - Если нормализованное имя уже занято другим товаром (запись с таким
же нормализованным именем и другим
product_id) — печатается сообщение, содержащее это имя (product name '<имя>' is already taken), возвращаетсяNone, хранилище не меняется. Переименование товара в его же текущее имя (и обновление только цены/количества) конфликтом не считается. - Иначе запись заменяется на
(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Интерактивная консоль администратора над одним хранилищем товаров, которое
живёт в памяти всё время работы процесса. Цикл «прочитать строку — выполнить —
напечатать результат» продолжается, пока не введена команда 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.
Печатает HELP_TEXT в stdout как есть.
- Вход:
result— значение, вернувшееся из CRUD-операции. - Поведение: если
resultнеNone— печатаетresultв stdout; еслиNone— не печатает ничего (операция уже сообщила о своей неудаче сама).
Разбирает одну строку консоли и выполняет названную ею команду.
- Вход:
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.
Запускает цикл до команды 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все строки имеют одинаковую длину, а «столбики»|стоят друг под другом.