diff --git a/pyproject.toml b/pyproject.toml index 06fbe913..342512a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [] [dependency-groups] dev = [ "mypy==2.3.1", + "pytest>=9.1.1", "ruff==0.16.8", ] diff --git a/src/part1/constants.py b/src/part1/constants.py index 9abfbba2..974ccda9 100644 --- a/src/part1/constants.py +++ b/src/part1/constants.py @@ -19,20 +19,20 @@ # Все константы, зависящие от ёмкостей *_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] = (1 << TIMESTAMP_BITS) - 1 +NODE_ID_MAX: Final[int] = (1 << NODE_ID_BITS) - 1 +SEQUENCE_ID_MAX: Final[int] = (1 << SEQUENCE_ID_BITS) - 1 # Здесь можно добавить собственные вспомогательные константы # (например, сдвиги полей при сборке идентификатора). diff --git a/src/part1/snowflake.py b/src/part1/snowflake.py index d3abf050..d2a87fb8 100644 --- a/src/part1/snowflake.py +++ b/src/part1/snowflake.py @@ -13,73 +13,39 @@ :func:`decode_node_id` and :func:`decode_sequence_id`. """ -import time # noqa: F401 +import time -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_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 + current_time_ms = int(time.time() * 1000) + timestamp_ms = current_time_ms - epoch_ms + return timestamp_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 + timestamp_ms = snowflake_id >> (SEQUENCE_ID_BITS + NODE_ID_BITS) + current_time_ms = timestamp_ms + epoch_ms + return current_time_ms 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 + node_id = (snowflake_id & (NODE_ID_MAX << SEQUENCE_ID_BITS)) >> SEQUENCE_ID_BITS + return node_id 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 + sequence_id = snowflake_id & (SEQUENCE_ID_MAX) + return sequence_id def generate_snowflake_id( @@ -87,28 +53,17 @@ 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 + + if not (0 <= node_id <= NODE_ID_MAX): + return print(f"node_id ({node_id}) must be in [0, {NODE_ID_MAX}]") + if not (0 <= sequence_id <= SEQUENCE_ID_MAX): + return print(f"sequence_id ({sequence_id}) must be in [0, {SEQUENCE_ID_MAX}]") + timestamp_ms = read_current_millis(epoch_ms) + if timestamp_ms > TIMESTAMP_MS_MAX: + return print("overflows") + + timestamp_ms_snowflake_id = timestamp_ms << (SEQUENCE_ID_BITS + NODE_ID_BITS) + node_snowflake_id = node_id << SEQUENCE_ID_BITS + sequence_snowflake_id = sequence_id + snowflake_id = timestamp_ms_snowflake_id + node_snowflake_id + sequence_snowflake_id + return snowflake_id diff --git a/src/part2/cart.py b/src/part2/cart.py index 063f9aea..d0dc094d 100644 --- a/src/part2/cart.py +++ b/src/part2/cart.py @@ -22,9 +22,18 @@ type CartLine = tuple[int, int] -# TODO: задайте позиции полей внутри кортежа CartLine LINE_PRODUCT_ID_INDEX: Final = 0 -LINE_QUANTITY_INDEX: Final = 0 +LINE_QUANTITY_INDEX: Final = 1 + + +def find_cart_line( + cart: list[CartLine], + product_id: int, +) -> CartLine | None: + for line in cart: + if line[LINE_PRODUCT_ID_INDEX] == product_id: + return line + return None def add_to_cart( @@ -33,28 +42,24 @@ def add_to_cart( product_id: int, quantity: int, ) -> CartLine | None: - """Move ``quantity`` units of ``product_id`` from the store into ``cart``. - - The product is looked up in ``storage``. If it is missing, a message is - printed (by :func:`~src.part2.crud.read_product`) and nothing - changes. If the store holds fewer units than requested, an explanatory - message is printed and nothing changes. Otherwise the store record is - decremented by ``quantity`` and the cart line for ``product_id`` gains - ``quantity`` units -- a new line is created when the cart had none. - - Args: - storage: The product store to draw stock from; modified in place on - success. - cart: The cart to add the units to; modified in place on success. - product_id: The identifier of the product to add. - quantity: Number of units to move into the cart. - - Returns: - The cart line for ``product_id`` after the addition, or ``None`` - when the product is unknown or the store cannot cover the request. - """ - # TODO: реализуйте функцию - return (0, 0) + product = read_product(storage, product_id) + if product is None: + return None + if product[QUANTITY_INDEX] < quantity: + return print( + f"not enough stock for product {product_id}: {product[QUANTITY_INDEX]} available, {quantity} requested" + ) + id, name, price, stock_quantity = product + updated_product = (id, name, price, stock_quantity - quantity) + storage[storage.index(product)] = updated_product + for line in cart: + if line[LINE_PRODUCT_ID_INDEX] == product_id: + updated_line = (product_id, line[LINE_QUANTITY_INDEX] + quantity) + cart[cart.index(line)] = updated_line + return updated_line + new_line = (product_id, quantity) + cart.append(new_line) + return new_line def remove_from_cart( @@ -63,27 +68,23 @@ def remove_from_cart( product_id: int, quantity: int, ) -> CartLine | None: - """Move ``quantity`` units of ``product_id`` from ``cart`` back to the store. - - The cart line for ``product_id`` is looked up. If there is none, a - message is printed and nothing changes. If the line holds fewer units - than requested, an explanatory message is printed and nothing changes. - Otherwise the cart line loses ``quantity`` units -- the line is dropped - when it reaches zero -- and the store record gains ``quantity`` units - back. - - Args: - storage: The product store to return stock to; modified in place on - success. - cart: The cart to take the units from; modified in place on - success. - product_id: The identifier of the product to remove. - quantity: Number of units to move back into the store. - - Returns: - The cart line for ``product_id`` after the removal (a quantity of - zero means the line was dropped), or ``None`` when the cart has no - line for the product or holds too few units. - """ - # TODO: реализуйте функцию - return (0, 0) + line = find_cart_line(cart, product_id) + if line == None: + return print(f"product {product_id} is not in the cart") + if line[LINE_QUANTITY_INDEX] < quantity: + return print( + f"cart holds only {line[LINE_QUANTITY_INDEX]} unit(s) of product {product_id}, cannot remove {quantity}" + ) + product = read_product(storage, product_id) + if product is None: + return None + id, name, price, storage_quantity = product + updated_product = (id, name, price, storage_quantity + quantity) + storage[storage.index(product)] = updated_product + id, cart_quantity = line + updated_line = (id, cart_quantity - quantity) + if cart_quantity - quantity == 0: + cart.remove(line) + else: + cart[cart.index(line)] = updated_line + return updated_line diff --git a/src/part2/crud.py b/src/part2/crud.py index d026cbcb..b7ec22be 100644 --- a/src/part2/crud.py +++ b/src/part2/crud.py @@ -14,66 +14,39 @@ from decimal import Decimal -from .storage import ( # noqa: F401 +from .storage import ( NAME_INDEX, PRODUCT_ID_INDEX, PRODUCT_ID_MIN, Product, ) -from .utils import normalize_price # noqa: F401 +from .utils import normalize_price def generate_product_id(storage: list[Product]) -> int: - """Choose the identifier for the next product added to ``storage``. - - Args: - storage: The product store to inspect. - - Returns: - One past the greatest identifier currently held in ``storage``, or - :data:`~src.part2.storage.PRODUCT_ID_MIN` when ``storage`` is - empty. - """ - # TODO: реализуйте функцию - return 0 + if len(storage) == 0: + 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: - """Append a new product to ``storage`` and return its new identifier. - - Args: - storage: The product store to append to; modified in place on - success. - fields: A ``(name, price, quantity)`` tuple describing the product. - ``price`` is a :class:`~decimal.Decimal` amount and is rounded - to the stored money precision before it is saved. - - Returns: - The identifier generated for the new product, or ``None`` when a - product with the same name already exists. In the ``None`` case - ``storage`` is left unchanged and a message naming the clashing - name is printed. - """ - # TODO: реализуйте функцию - return 0 + name, price, quantity = fields + if name in [product[NAME_INDEX] for product in storage]: + return print(f"product name {name} is already taken") + new_id = generate_product_id(storage) + price = normalize_price(price) + product = (new_id, name, price, quantity) + storage.append(product) + return new_id def read_product(storage: list[Product], product_id: int) -> Product | None: - """Return the product stored under ``product_id``. - - Args: - storage: The product store to search. - product_id: The identifier to look up. - - Returns: - The matching ``(product_id, name, price, quantity)`` record, or - ``None`` when no product carries that identifier (a message is - printed in that case). - """ - # TODO: реализуйте функцию - return (0, "", Decimal(0), 0) + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + return product + return print(f"no product with id {product_id}") def update_product( @@ -81,40 +54,19 @@ def update_product( product_id: int, fields: tuple[str, Decimal, int], ) -> Product | None: - """Overwrite the fields of the product stored under ``product_id``. - - The identifier itself is preserved; only ``name``, ``price`` and - ``quantity`` are replaced. - - Args: - storage: The product store to modify; the matching record is - replaced in place on success. - product_id: The identifier of the product to change. - fields: A ``(name, price, quantity)`` tuple with the new values. - ``price`` is a :class:`~decimal.Decimal` amount and is rounded - to the stored money precision before it is saved. - - Returns: - The updated ``(product_id, name, price, quantity)`` record, or - ``None`` when no product carries that identifier (``storage`` is - left unchanged and a message is printed). - """ - # TODO: реализуйте функцию - return (0, "", Decimal(0), 0) + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + name, price, quantity = fields + price = normalize_price(price) + updated_product = (product_id, name, price, quantity) + storage[storage.index(product)] = updated_product + return updated_product + return print(f"no product with id {product_id}") def delete_product(storage: list[Product], product_id: int) -> int | None: - """Remove the product stored under ``product_id`` from ``storage``. - - Args: - storage: The product store to remove from; modified in place on - success. - product_id: The identifier of the product to remove. - - Returns: - ``product_id`` when a product was removed, or ``None`` when no - product carried that identifier (``storage`` is left unchanged and - a message is printed). - """ - # TODO: реализуйте функцию - return 0 + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + storage.remove(product) + return product_id + return print(f"no product with id {product_id}") diff --git a/src/part2/storage.py b/src/part2/storage.py index b63d5fc7..e4bceff1 100644 --- a/src/part2/storage.py +++ b/src/part2/storage.py @@ -24,11 +24,9 @@ type Product = tuple[int, str, Decimal, int] -# TODO: задайте позиции полей внутри кортежа Product PRODUCT_ID_INDEX: Final = 0 -NAME_INDEX: Final = 0 -PRICE_INDEX: Final = 0 -QUANTITY_INDEX: Final = 0 +NAME_INDEX: Final = 1 +PRICE_INDEX: Final = 2 +QUANTITY_INDEX: Final = 3 -# TODO: задайте идентификатор первого товара в пустом хранилище -PRODUCT_ID_MIN: Final[int] = 0 +PRODUCT_ID_MIN: Final[int] = 1 diff --git a/src/part2/utils.py b/src/part2/utils.py index 11bd7a76..413f9564 100644 --- a/src/part2/utils.py +++ b/src/part2/utils.py @@ -6,24 +6,13 @@ 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 -# TODO: задайте число знаков после запятой и шаг квантования -PRICE_PRECISION: Final[int] = 0 -PRICE_STEP: Final = Decimal(0) +PRICE_PRECISION: Final[int] = 2 +PRICE_STEP: Final = Decimal(1).scaleb(-PRICE_PRECISION) def normalize_price(price: Decimal) -> Decimal: - """Round a price to the precision every stored record uses. - - Args: - price: The raw price amount. - - Returns: - ``price`` quantised to :data:`PRICE_PRECISION` fractional digits, - with halves rounded up. - """ - # TODO: реализуйте функцию - return Decimal(0) + return price.quantize(PRICE_STEP, ROUND_HALF_UP) diff --git a/src/part3/crud.py b/src/part3/crud.py index 68811d7e..2933816c 100644 --- a/src/part3/crud.py +++ b/src/part3/crud.py @@ -20,68 +20,42 @@ from decimal import Decimal -from .storage import ( # noqa: F401 +from .storage import ( NAME_INDEX, PRODUCT_ID_INDEX, PRODUCT_ID_MIN, Product, ) -from .utils import normalize_price, normalize_product_name # noqa: F401 +from .utils import normalize_price, normalize_product_name def generate_product_id(storage: list[Product]) -> int: - """Choose the identifier for the next product added to ``storage``. - - Args: - storage: The product store to inspect. - - Returns: - One past the greatest identifier currently held in ``storage``, or - :data:`~src.part3.storage.PRODUCT_ID_MIN` when ``storage`` is - empty. - """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return 0 + if len(storage) == 0: + 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: - """Append a new product to ``storage`` and return its new identifier. - - Args: - storage: The product store to append to; modified in place on - success. - fields: A ``(name, price, quantity)`` tuple describing the product. - ``name`` is normalised (surrounding whitespace stripped, - lower-cased) before it is stored or compared. ``price`` is a - :class:`~decimal.Decimal` amount and is rounded to the stored - money precision before it is saved. - - Returns: - The identifier generated for the new product, or ``None`` when - ``name`` is blank once normalised or a product with the same - normalised name already exists. In the ``None`` case ``storage`` - is left unchanged and an explanatory message is printed. - """ - # TODO: реализуйте функцию - return 0 + name, price, quantity = fields + name = normalize_product_name(name) + if name == "": + return print("product name must not be blank") + if name in [product[NAME_INDEX] for product in storage]: + return print(f"product name '{name}' is already taken") + new_id = generate_product_id(storage) + price = normalize_price(price) + product = (new_id, name, price, quantity) + storage.append(product) + return new_id def read_product(storage: list[Product], product_id: int) -> Product | None: - """Return the product stored under ``product_id``. - - Args: - storage: The product store to search. - product_id: The identifier to look up. - - Returns: - The matching ``(product_id, name, price, quantity)`` record, or - ``None`` when no product carries that identifier (a message is - printed in that case). - """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return (0, "", Decimal(0), 0) + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + return product + return print(f"no product with id {product_id}") def update_product( @@ -89,43 +63,33 @@ def update_product( product_id: int, fields: tuple[str, Decimal, int], ) -> Product | None: - """Overwrite the fields of the product stored under ``product_id``. - - The identifier itself is preserved; only ``name``, ``price`` and - ``quantity`` are replaced. - - Args: - storage: The product store to modify; the matching record is - replaced in place on success. - product_id: The identifier of the product to change. - fields: A ``(name, price, quantity)`` tuple with the new values. - ``price`` is a :class:`~decimal.Decimal` amount and is rounded - to the stored money precision before it is saved. - - Returns: - The updated ``(product_id, name, price, quantity)`` record, or - ``None`` when ``name`` is blank once normalised, no product - carries that identifier, or the normalised name is already taken - by another product. In every ``None`` case ``storage`` is left - unchanged and an explanatory message is printed. Renaming a - product to its own current name is allowed. - """ - # TODO: реализуйте функцию - return (0, "", Decimal(0), 0) + + name, price, quantity = fields + name = normalize_product_name(name) + if name == "": + return print("product name must not be blank") + + product = read_product(storage, product_id) + if product is None: + return None + + for other_product in storage: + if ( + other_product[NAME_INDEX] == name + and other_product[PRODUCT_ID_INDEX] != product_id + ): + return print(f"product name '{name}' is already taken") + + price = normalize_price(price) + updated_product = (product_id, name, price, quantity) + storage[storage.index(product)] = updated_product + + return updated_product def delete_product(storage: list[Product], product_id: int) -> int | None: - """Remove the product stored under ``product_id`` from ``storage``. - - Args: - storage: The product store to remove from; modified in place on - success. - product_id: The identifier of the product to remove. - - Returns: - ``product_id`` when a product was removed, or ``None`` when no - product carried that identifier (``storage`` is left unchanged and - a message is printed). - """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return 0 + for product in storage: + if product[PRODUCT_ID_INDEX] == product_id: + storage.remove(product) + return product_id + return print(f"no product with id {product_id}") diff --git a/src/part3/run_admin.py b/src/part3/run_admin.py index c5feeb0b..d1f1a741 100644 --- a/src/part3/run_admin.py +++ b/src/part3/run_admin.py @@ -29,79 +29,84 @@ 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 Product -from .utils import get_storage_str_representation # noqa: F401 +from .utils import get_storage_str_representation -# TODO: задайте приглашение и текст справки -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: - """Print the command reference to stdout. - The text is the module-level :data:`HELP_TEXT` constant, printed as - is: a heading, one line per command with a short description, and a - note on how multi-word names are parsed. - """ - # TODO: реализуйте функцию +def show_help() -> None: + print(HELP_TEXT) def print_result(result: object) -> None: - """Print a CRUD result to stdout unless it is ``None``. - - Args: - result: The value returned by a CRUD operation. ``None`` means the - operation already reported its own failure, so nothing is - printed in that case. - """ - # TODO: реализуйте функцию + if result is not None: + print(result) def run_command(storage: list[Product], line: str) -> bool: - """Parse one console line and carry out the command it names. + match line.split(): + case ["help"]: + show_help() + + case ["exit"]: + return False - Args: - storage: The product store shared across the session; mutated in - place by the ``create``, ``update`` and ``delete`` commands. - line: One line of console input, already stripped of surrounding - whitespace. + case ["show"]: + print(get_storage_str_representation(storage)) - Returns: - ``True`` to keep the read-eval-print loop running, or ``False`` - once the ``exit`` command has been seen. A line that is not a - known command prints a notice and still returns ``True``. + case ["read", product_id]: + print_result(read_product(storage, int(product_id))) - Raises: - ValueError: If an id or quantity argument of a CRUD command does - not parse as a base-10 integer. - decimal.InvalidOperation: If the price argument of ``create`` or - ``update`` does not parse as a decimal number. - """ - # TODO: реализуйте функцию - return False + case ["delete", product_id]: + print_result(delete_product(storage, int(product_id))) + + case ["create", *name_parts, price, quantity] if name_parts: + name = " ".join(name_parts) + price_decimal = Decimal(price) + quantity_int = int(quantity) + print_result(create_product(storage, (name, price_decimal, quantity_int))) + + case ["update", product_id, *name_parts, price, quantity] if name_parts: + product_id_int = int(product_id) + name = " ".join(name_parts) + price_decimal = Decimal(price) + quantity_int = int(quantity) + print_result( + update_product( + storage, product_id_int, (name, price_decimal, quantity_int) + ) + ) + + case _: + print(f"'{line}' is not a command") + return True def main() -> None: - """Run the admin console until the ``exit`` command is entered. - - A single product store is created empty and kept in memory for the - whole session. Each iteration reads one line from stdin, strips its - surrounding whitespace and hands it to :func:`run_command`. A line - that names a command but whose id, price or quantity argument does - not parse is caught here and reported without stopping the loop; only - ``exit`` ends it. - """ + storage: list[Product] = [] running = True diff --git a/src/part3/storage.py b/src/part3/storage.py index c5e34be5..c516645b 100644 --- a/src/part3/storage.py +++ b/src/part3/storage.py @@ -24,12 +24,9 @@ type Product = tuple[int, str, Decimal, int] -# TODO: задайте позиции полей внутри кортежа Product (используйте своё решение -# части 2) PRODUCT_ID_INDEX: Final = 0 -NAME_INDEX: Final = 0 -PRICE_INDEX: Final = 0 -QUANTITY_INDEX: Final = 0 +NAME_INDEX: Final = 1 +PRICE_INDEX: Final = 2 +QUANTITY_INDEX: Final = 3 -# TODO: задайте идентификатор первого товара в пустом хранилище -PRODUCT_ID_MIN: Final[int] = 0 +PRODUCT_ID_MIN: Final[int] = 1 diff --git a/src/part3/tests/TESTS.md b/src/part3/tests/TESTS.md new file mode 100644 index 00000000..93dfe8dd --- /dev/null +++ b/src/part3/tests/TESTS.md @@ -0,0 +1,62 @@ +# Тесты Part 3 + +## Описание тестов + +### Базовые функции +- **test_normalize_product_name** — нормализация имён товаров (приведение к нижнему регистру, удаление лишних пробелов) +- **test_constants** — проверка значений констант (промпт и заголовки таблицы) + +### Представление данных +- **test_get_storage_str_representation** — форматирование хранилища в таблицу (проверка выравнивания, разделители, инварианты) + +### CRUD операции +- **test_create_product** — создание товара: валидация имён, проверка дубликатов, генерация ID +- **test_update_product** — обновление товара: валидация, проверка конфликтов имён, обработка отсутствующих ID + +### Команды +- **test_run_command_help_exit** — команды help и exit +- **test_run_command_create** — команда create (нормализация имени, округление цены) +- **test_run_command_show** — команда show (вывод таблицы) +- **test_run_command_read** — команда read (чтение товара по ID) +- **test_run_command_update** — команда update (обновление товара) +- **test_run_command_delete** — команда delete (удаление товара) +- **test_run_command_not_a_command** — обработка неверных команд и аргументов +- **test_run_command_invalid_arguments** — парсинг некорректных значений (ID, цена, количество) + +### Интеграционные тесты +- **test_show_help** — вывод справки +- **test_print_result** — форматирование результатов команд +- **test_main_session** — полный сеанс работы администратора +- **test_main_catches_invalid_arguments** — обработка ошибок парсинга в main + +## Запуск + +### Windows +```cmd +# Из корня проекта +python -m pytest src/part3/tests/test_general.py -v + +# Или конкретный тест +python -m pytest src/part3/tests/test_general.py::test_normalize_product_name -v +``` + +### macOS / Linux (Ubuntu, Fedora) +```bash +# Из корня проекта +python -m pytest src/part3/tests/test_general.py -v + +# Или конкретный тест +python -m pytest src/part3/tests/test_general.py::test_normalize_product_name -v + +# С покрытием кода +python -m pytest src/part3/tests/test_general.py --cov=src/part3 --cov-report=term-missing +``` + +### Без запуска всех +```bash +# Только быстрые тесты +python -m pytest src/part3/tests/test_general.py -v -k "not main" + +# Прерваться на первой ошибке +python -m pytest src/part3/tests/test_general.py -x +``` diff --git a/src/part3/tests/__init__.py b/src/part3/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/part3/tests/test_general.py b/src/part3/tests/test_general.py new file mode 100644 index 00000000..9e9a30e2 --- /dev/null +++ b/src/part3/tests/test_general.py @@ -0,0 +1,377 @@ +from decimal import Decimal, InvalidOperation + +import pytest + +from ..crud import create_product, update_product +from ..run_admin import HELP_TEXT, PROMPT, main, print_result, run_command, show_help +from ..storage import Product +from ..utils import ( + TABLE_HEADERS, + get_storage_str_representation, + normalize_product_name, +) + + +def test_normalize_product_name() -> None: + assert normalize_product_name(" Cordless Drill ") == "cordless drill" + assert normalize_product_name("abc def\tghi\tjkl") == "abc def ghi jkl" + assert normalize_product_name(" \t\n ") == "" + assert normalize_product_name("claw hammer") == "claw hammer" + assert normalize_product_name("") == "" + assert normalize_product_name("Cordless\tDrill\n ") == "cordless drill" + + +def test_constants() -> None: + assert PROMPT == "admin> " + assert TABLE_HEADERS == ("ID", "name", "price", "quantity") + + +def test_get_storage_str_representation() -> None: + # пустое хранилище: только заголовки и разделитель + assert ( + get_storage_str_representation([]) + == """| ID | name | price | quantity | +|----|------|-------|----------|""" + ) + + test1_str = """| ID | name | price | quantity | +|----|------|-------|----------| +| 1 | saw | 9.90 | 5 | +| 2 | axe | 12.00 | 8 |""" + assert ( + get_storage_str_representation( + [(1, "saw", Decimal("9.90"), 5), (2, "axe", Decimal("12.00"), 8)] + ) + == test1_str + ) + + test2_str = """| ID | name | price | quantity | +|----|------------|---------|----------| +| 10 | compressor | 1299.99 | 3 | +| 2 | nail | 2.50 | 500 |""" + assert ( + get_storage_str_representation( + [ + (10, "compressor", Decimal("1299.99"), 3), + (2, "nail", Decimal("2.50"), 500), + ] + ) + == test2_str + ) + + # инварианты: все строки одной длины, | на краях каждой строки + lines = test2_str.split("\n") + assert len({len(line) for line in lines}) == 1 + assert all(line.startswith("|") and line.endswith("|") for line in lines) + + +def test_create_product(capsys: pytest.CaptureFixture[str]) -> None: + taken: list[Product] = [ + (1, "saw", Decimal("9.90"), 5), + (2, "axe", Decimal("12.00"), 8), + ] + + # пустое имя: пустая строка или только пробельные символы + for name in ("", " ", " \n \t "): + assert create_product(taken, (name, Decimal("1.00"), 1)) is None + assert capsys.readouterr().out == "product name must not be blank\n" + + # имя занято, сравнение идёт по нормализованной форме + assert create_product(taken, ("saw", Decimal("1.00"), 1)) is None + assert capsys.readouterr().out == "product name 'saw' is already taken\n" + assert create_product(taken, (" SAw ", Decimal("1.00"), 1)) is None + assert capsys.readouterr().out == "product name 'saw' is already taken\n" + assert taken == [ + (1, "saw", Decimal("9.90"), 5), + (2, "axe", Decimal("12.00"), 8), + ] + + # успех: имя и цена нормализуются, id на один больше наибольшего + assert create_product(taken, ("sword", Decimal("1.00"), 1)) == 3 + assert taken == [ + (1, "saw", Decimal("9.90"), 5), + (2, "axe", Decimal("12.00"), 8), + (3, "sword", Decimal("1.00"), 1), + ] + + # id считается как max + 1, а не len + 1 + gappy: list[Product] = [ + (1, "saw", Decimal("9.90"), 5), + (3, "axe", Decimal("12.00"), 8), + ] + assert create_product(gappy, ("sword", Decimal("1.00"), 1)) == 4 + + # примеры из TASK.md + storage1: list[Product] = [] + first_id = create_product(storage1, (" Cordless Drill ", Decimal("89.999"), 12)) + second_id = create_product(storage1, ("Claw Hammer", Decimal("9.9"), 40)) + assert first_id == 1 + assert second_id == 2 + assert storage1 == [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + storage2: list[Product] = [] + assert create_product(storage2, ("cordless drill", Decimal(50), 3)) == 1 + result = create_product(storage2, (" CORDLESS DRILL ", Decimal(75), 1)) + assert result is None + assert capsys.readouterr().out == "product name 'cordless drill' is already taken\n" + assert storage2 == [(1, "cordless drill", Decimal("50.00"), 3)] + + storage3: list[Product] = [] + result = create_product(storage3, (" ", Decimal(10), 5)) + assert result is None + assert capsys.readouterr().out == "product name must not be blank\n" + assert storage3 == [] + + +def test_update_product(capsys: pytest.CaptureFixture[str]) -> None: + storage1: list[Product] = [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + # успех: имя и цена нормализуются, идентификатор сохраняется + result = update_product(storage1, 2, (" Rubber Mallet ", Decimal("7.505"), 25)) + assert result == (2, "rubber mallet", Decimal("7.51"), 25) + assert storage1 == [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "rubber mallet", Decimal("7.51"), 25), + ] + + storage2: list[Product] = [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + # обновление под собственным именем (в другом написании) — не конфликт + result = update_product(storage2, 1, ("CORDLESS Drill", Decimal(85), 10)) + assert result == (1, "cordless drill", Decimal("85.00"), 10) + + storage3: list[Product] = [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + # имя занято другим товаром + result = update_product(storage3, 1, (" Claw Hammer ", Decimal(85), 10)) + assert result is None + assert capsys.readouterr().out == "product name 'claw hammer' is already taken\n" + assert storage3 == [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + storage4: list[Product] = [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + # товара с таким id нет + result = update_product(storage4, 99, ("saw", Decimal(20), 5)) + assert result is None + assert capsys.readouterr().out == "no product with id 99\n" + + # пустое имя отвергается до поиска по идентификатору + result = update_product(storage4, 99, (" ", Decimal(20), 5)) + assert result is None + assert capsys.readouterr().out == "product name must not be blank\n" + assert storage4 == [ + (1, "cordless drill", Decimal("90.00"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + +def test_show_help(capsys: pytest.CaptureFixture[str]) -> None: + show_help() + assert capsys.readouterr().out == HELP_TEXT + "\n" + + +def test_print_result(capsys: pytest.CaptureFixture[str]) -> None: + print_result(None) + assert capsys.readouterr().out == "" + + print_result(42) + assert capsys.readouterr().out == "42\n" + + product: Product = (1, "saw", Decimal("9.90"), 5) + print_result(product) + assert capsys.readouterr().out == "(1, 'saw', Decimal('9.90'), 5)\n" + + +def sample_storage() -> list[Product]: + """A fresh store with two products, for the run_command tests.""" + return [ + (1, "cordless drill", Decimal("89.99"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + +def test_run_command_help_exit(capsys: pytest.CaptureFixture[str]) -> None: + storage: list[Product] = [] + + assert run_command(storage, "help") is True + assert capsys.readouterr().out == HELP_TEXT + "\n" + + assert run_command(storage, "exit") is False + assert capsys.readouterr().out == "" + assert storage == [] + + +def test_run_command_create(capsys: pytest.CaptureFixture[str]) -> None: + storage: list[Product] = [] + + # имя нормализуется, цена округляется, печатается новый id + assert run_command(storage, "create Cordless Drill 89.99 12") is True + assert capsys.readouterr().out == "1\n" + + assert run_command(storage, "create claw hammer 9.9 40") is True + assert capsys.readouterr().out == "2\n" + assert storage == [ + (1, "cordless drill", Decimal("89.99"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + +def test_run_command_show(capsys: pytest.CaptureFixture[str]) -> None: + storage: list[Product] = [] + + assert run_command(storage, "show") is True + assert capsys.readouterr().out == ( + "| ID | name | price | quantity |\n|----|------|-------|----------|\n" + ) + + run_command(storage, "create Cordless Drill 89.99 12") + run_command(storage, "create claw hammer 9.9 40") + capsys.readouterr() + + assert run_command(storage, "show") is True + assert capsys.readouterr().out == ( + "| ID | name | price | quantity |\n" + "|----|----------------|-------|----------|\n" + "| 1 | cordless drill | 89.99 | 12 |\n" + "| 2 | claw hammer | 9.90 | 40 |\n" + ) + + +def test_run_command_read(capsys: pytest.CaptureFixture[str]) -> None: + storage = sample_storage() + + assert run_command(storage, "read 1") is True + assert capsys.readouterr().out == "(1, 'cordless drill', Decimal('89.99'), 12)\n" + + assert run_command(storage, "read 5") is True + assert capsys.readouterr().out == "no product with id 5\n" + assert storage == [ + (1, "cordless drill", Decimal("89.99"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + +def test_run_command_update(capsys: pytest.CaptureFixture[str]) -> None: + storage = sample_storage() + + assert run_command(storage, "update 2 mallet 7.50 30") is True + assert capsys.readouterr().out == "(2, 'mallet', Decimal('7.50'), 30)\n" + assert storage == [ + (1, "cordless drill", Decimal("89.99"), 12), + (2, "mallet", Decimal("7.50"), 30), + ] + + +def test_run_command_delete(capsys: pytest.CaptureFixture[str]) -> None: + storage = sample_storage() + + assert run_command(storage, "delete 2") is True + assert capsys.readouterr().out == "2\n" + + assert run_command(storage, "delete 2") is True + assert capsys.readouterr().out == "no product with id 2\n" + assert storage == [(1, "cordless drill", Decimal("89.99"), 12)] + + +def test_run_command_not_a_command(capsys: pytest.CaptureFixture[str]) -> None: + storage = sample_storage() + + # неизвестная команда, неверное число аргументов и create/update без + # слов имени: одно и то же сообщение, хранилище не меняется + for line in ( + "frobnicate 1 2", + "read 1 2 3", + "read", + "delete", + "help me", + "exit now", + "show extra", + "create 89.90 12", + "update 1 89.90 12", + ): + assert run_command(storage, line) is True + assert capsys.readouterr().out == f"'{line}' is not a command\n" + assert storage == [ + (1, "cordless drill", Decimal("89.99"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + +# помогла написать нейронка +def test_run_command_invalid_arguments(capsys: pytest.CaptureFixture[str]) -> None: + storage = sample_storage() + + # id/цена/количество не разбираются: исключение (ловится в main), + # хранилище и stdout не меняются + for line, exc_type in ( + ("read abc", ValueError), + ("delete abc", ValueError), + ("update abc claw hammer 1.00 1", ValueError), + ("create foo 1.00 abc", ValueError), + ("create foo abc 5", InvalidOperation), + ("update 1 foo abc 2", InvalidOperation), + ): + with pytest.raises(exc_type): + run_command(storage, line) + assert capsys.readouterr().out == "" + assert storage == [ + (1, "cordless drill", Decimal("89.99"), 12), + (2, "claw hammer", Decimal("9.90"), 40), + ] + + +# помогла написать нейронка +def test_main_session( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lines = iter( + [ + "create Cordless Drill 89.99 12", + "create claw hammer 9.9 40", + "show", + "read 5", + "exit", + ] + ) + monkeypatch.setattr("builtins.input", lambda prompt="": next(lines)) + main() + assert capsys.readouterr().out == ( + "1\n" + "2\n" + "| ID | name | price | quantity |\n" + "|----|----------------|-------|----------|\n" + "| 1 | cordless drill | 89.99 | 12 |\n" + "| 2 | claw hammer | 9.90 | 40 |\n" + "no product with id 5\n" + ) + + +# Взял погонять у товарища +def test_main_catches_invalid_arguments( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lines = iter(["create foo abc 5", "read x", "exit"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(lines)) + main() + assert capsys.readouterr().out == ( + "'create foo abc 5' names a command but its arguments are invalid\n" + "'read x' names a command but its arguments are invalid\n" + ) diff --git a/src/part3/utils.py b/src/part3/utils.py index 318ded0b..7073828d 100644 --- a/src/part3/utils.py +++ b/src/part3/utils.py @@ -18,78 +18,57 @@ 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 -# Number of fractional digits every stored price is rounded to. -# Quantisation step derived from PRICE_PRECISION, e.g. Decimal("0.01"). -# TODO: задайте число знаков после запятой и шаг квантования (используйте своё -# решение части 2) -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: задайте заголовки столбцов таблицы -TABLE_HEADERS: Final[tuple[str, ...]] = () +TABLE_HEADERS: Final[tuple[str, ...]] = ("ID", "name", "price", "quantity") +storage: list[tuple[int, str, Decimal, int]] = [] -def normalize_price(price: Decimal) -> Decimal: - """Round a price to the precision every stored record uses. - - Args: - price: The raw price amount. - Returns: - ``price`` quantised to :data:`PRICE_PRECISION` fractional digits, - with halves rounded up. - """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return Decimal(0) +def normalize_price(price: Decimal) -> Decimal: + return price.quantize(PRICE_STEP, ROUND_HALF_UP) def normalize_product_name(name: str) -> str: - """Reduce a product name to the canonical form the store keeps. - - Args: - name: The raw product name. - - Returns: - ``name`` with every leading and trailing whitespace character - removed, every internal run of whitespace (spaces, tabs and the - like) collapsed to a single space, and the rest lower-cased. For - example ``"abc def\tghi\tjkl"`` becomes ``"abc def ghi jkl"``. - The result is an empty string when ``name`` holds nothing but - whitespace. - """ - # TODO: реализуйте функцию - return "" - - -# TODO: при необходимости добавьте свои вспомогательные функции + return " ".join(name.split()).lower() def get_storage_str_representation(storage: list[Product]) -> str: - """Render the product store as a text table with data-sized columns. - - The table has the columns named by :data:`TABLE_HEADERS` -- id, name, - price and quantity. Each column is made exactly as wide as the longest - value it carries in this call (its header counted), so the columns - line up when the string is printed to a terminal and no value is ever - truncated. - - Args: - storage: The product store to render. - - Returns: - A multi-line string: a header row, a dashed separator row, then - one row per product in ``storage`` in list order. When ``storage`` - is empty only the header and separator rows are returned, sized to - the header labels. - """ - # TODO: реализуйте функцию - return "" + column_width = [len(header) for header in TABLE_HEADERS] + for product in storage: + for i, value in enumerate(product): + column_width[i] = max(column_width[i], len(str(value))) + header_row = ( + "|" + + "|".join( + f" {header.ljust(width)} " + for header, width in zip(TABLE_HEADERS, column_width) + ) + + "|" + ) + delimiter_row = "|" + "|".join("-" * (width + 2) for width in column_width) + "|" + + table_rows = [header_row, delimiter_row] + + for product in storage: + row = ( + "|" + + "|".join( + f" {str(value).ljust(width)} " + for value, width in zip(product, column_width) + ) + + "|" + ) + table_rows.append(row) + + return "\n".join(table_rows) diff --git a/src/part4/cart.py b/src/part4/cart.py index aed9bb72..fc4b0bfc 100644 --- a/src/part4/cart.py +++ b/src/part4/cart.py @@ -9,7 +9,7 @@ an explanatory message to stdout and returns ``None``. """ -from .storage import Product +from .storage import QUANTITY_KEY, Product # TODO: при необходимости добавьте свои вспомогательные функции @@ -46,8 +46,20 @@ def add_to_cart( ``None`` when the product is unknown or the store cannot cover the request. """ - # TODO: реализуйте функцию - return 0 + + if not (product := storage.get(product_id)): + return print(f"no product with id {product_id}") + + if product[QUANTITY_KEY] < quantity: + return print( + f"not enough stock for product {product_id}: " + f"{product[QUANTITY_KEY]} available, {quantity} requested" + ) + + product[QUANTITY_KEY] -= quantity + + cart[product_id] = cart.get(product_id, 0) + quantity + return cart[product_id] def remove_from_cart( @@ -83,5 +95,23 @@ 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 product_id not in cart: + return print(f"product {product_id} is not in cart") + + if cart[product_id] < quantity: + return print( + f"cart holds only {cart[product_id]}" + f" units of product {product_id}, cannot remove {quantity}" + ) + + if not (product := storage.get(product_id)): + return print(f"no product with id {product_id}") + + product[QUANTITY_KEY] += quantity + + cart[product_id] -= quantity + if cart[product_id] == 0: + del cart[product_id] + return 0 + return cart[product_id] diff --git a/src/part4/crud.py b/src/part4/crud.py index 471eff00..8c7a271e 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,8 @@ def generate_product_id() -> str: Returns: A freshly generated UUID, stringified. """ - # TODO: реализуйте функцию - return "" + + return str(uuid.uuid4()) def create_product( @@ -73,8 +73,25 @@ def create_product( and ``product_names`` are left unchanged and an explanatory message is printed. """ - # TODO: реализуйте функцию - return "" + new_name = normalize_product_name(fields.get(NAME_KEY, "")) + + if not new_name: + return print("product name must not be blank") + + if new_name in product_names: + return print(f"product name '{new_name}' is already taken") + + new_id = generate_product_id() + new_price = normalize_price(fields.get(PRICE_KEY, 0)) + storage[new_id] = { + NAME_KEY: new_name, + PRICE_KEY: new_price, + QUANTITY_KEY: fields.get(QUANTITY_KEY, 0), + } + + product_names.add(new_name) + + return new_id def read_product(storage: dict[str, Product], product_id: str) -> Product | None: @@ -88,8 +105,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 product := storage.get(product_id): + return product + return print(f"no product with id {product_id}") def update_product( @@ -129,8 +148,27 @@ def update_product( and an explanatory message is printed. Renaming a product to its own current name is allowed. """ - # TODO: реализуйте функцию - return {} + if not (old_product := storage.get(product_id)): + return print(f"no product with id {product_id}") + + if NAME_KEY in fields: + new_name = fields.get(NAME_KEY, "") + new_name = normalize_product_name(new_name) + fields[NAME_KEY] = new_name + if not new_name: + return print("product name must not be blank") + if new_name != old_product[NAME_KEY] and new_name in product_names: + return print(f"product name '{new_name}' is already taken") + if new_name != old_product[NAME_KEY]: + product_names.remove(old_product[NAME_KEY]) + product_names.add(new_name) + + if PRICE_KEY in fields: + fields[PRICE_KEY] = normalize_price(fields[PRICE_KEY]) + + old_product.update(fields) + + return old_product def delete_product( @@ -153,5 +191,8 @@ def delete_product( product carried that identifier (``storage`` and ``product_names`` are left unchanged and a message is printed). """ - # TODO: реализуйте функцию - return "" + if product := storage.get(product_id): + product_names.remove(product[NAME_KEY]) + del storage[product_id] + return product_id + return print(f"no product with id {product_id}") diff --git a/src/part4/run_admin.py b/src/part4/run_admin.py index 2247b29b..2bb08fb2 100644 --- a/src/part4/run_admin.py +++ b/src/part4/run_admin.py @@ -28,22 +28,32 @@ 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 +63,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 +74,8 @@ def print_result(result: object) -> None: operation already reported its own failure, so nothing is printed in that case. """ - # TODO: реализуйте функцию (используйте своё решение части 3) + if result is not None: + print(result) def run_command( @@ -91,8 +102,50 @@ 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 ["read", product_id]: + print_result(read_product(storage, product_id)) + + case ["delete", product_id]: + print_result(delete_product(storage, product_names, product_id)) + + case ["create", *name_parts, price, quantity] if name_parts: + name = " ".join(name_parts) + price_decimal = Decimal(price) + quantity_int = int(quantity) + + fields = { + NAME_KEY: name, + PRICE_KEY: price_decimal, + QUANTITY_KEY: quantity_int, + } + + print_result(create_product(storage, product_names, fields)) + + case ["update", product_id, *name_parts, price, quantity] if name_parts: + name = " ".join(name_parts) + price_decimal = Decimal(price) + quantity_int = int(quantity) + fields = { + NAME_KEY: name, + PRICE_KEY: price_decimal, + QUANTITY_KEY: quantity_int, + } + print_result(update_product(storage, product_names, product_id, fields)) + + 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..a25f6253 100644 --- a/src/part4/storage.py +++ b/src/part4/storage.py @@ -40,6 +40,6 @@ # 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..d33b6798 100644 --- a/src/part4/utils.py +++ b/src/part4/utils.py @@ -18,10 +18,10 @@ 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 ( # noqa: F401 +from .storage import ( NAME_KEY, PRICE_KEY, QUANTITY_KEY, @@ -33,14 +33,14 @@ # 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", "price", "quantity") def normalize_price(price: Decimal) -> Decimal: @@ -53,8 +53,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: @@ -72,10 +71,7 @@ def normalize_product_name(name: str) -> str: whitespace. """ # TODO: реализуйте функцию (используйте своё решение части 3) - return "" - - -# TODO: при необходимости добавьте свои вспомогательные функции + return " ".join(name.split()).lower() def get_storage_str_representation(storage: dict[str, Product]) -> str: @@ -96,5 +92,36 @@ 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 = [] + for product_id, product in storage.items(): + rows.append( + ( + product_id, + product[NAME_KEY], + str(product[PRICE_KEY]), + str(product[QUANTITY_KEY]), + ) + ) + columns = tuple(zip(TABLE_HEADERS, *rows)) + + column_width = [max(map(len, column)) for column in columns] + + header = ( + "| " + + " | ".join( + f"{value:<{width}}" for value, width in zip(TABLE_HEADERS, column_width) + ) + + " | " + ) + + separator = "|" + "|".join("-" * (width + 2) for width in column_width) + "|" + + formatted_rows = [ + "| " + + " | ".join(f"{value:<{width}}" for value, width in zip(row, column_width)) + + " |" + for row in rows + ] + + return "\n".join([header, separator, *formatted_rows]) diff --git a/test b/test new file mode 100644 index 00000000..fedebcc1 --- /dev/null +++ b/test @@ -0,0 +1,5 @@ +x = 0b1101 +y = bin(x) +print(x) +print(bin(x)) +print(int(y)) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 2a56c481..b51f5004 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -70,6 +70,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -234,6 +252,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -251,6 +278,7 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -259,9 +287,44 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = "==2.3.1" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = "==0.16.8" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "ruff" version = "0.16.8"