diff --git a/fix_and_test.sh b/fix_and_test.sh new file mode 100755 index 00000000..4f19caf4 --- /dev/null +++ b/fix_and_test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Auto-fix imports/format, then run tests. +set -euo pipefail + +cd "$(dirname "$0")" + +if ! command -v uv >/dev/null 2>&1; then + echo "Error: uv is not installed. Install it: https://docs.astral.sh/uv/getting-started/installation/" >&2 + exit 1 +fi + +echo "==> Install dependencies" +uv sync --locked + +echo "==> Ruff fix (imports, lint)" +uv run ruff check --fix src +uv run ruff format src + +echo "==> Pytest" +uv run pytest src/part3/tests + +echo "All done." \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 06fbe913..a8793dc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [] dev = [ "mypy==2.3.1", "ruff==0.16.8", + "pytest==9.1.1", ] [tool.ruff] @@ -33,3 +34,13 @@ avoid-escape = true [tool.ruff.lint.isort] lines-after-imports = 2 split-on-trailing-comma = true + +[tool.mypy] +strict = true +files = ["src"] +mypy_path = "src" +explicit_package_bases = true + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["src/tests"] \ No newline at end of file diff --git a/src/part1/constants.py b/src/part1/constants.py index 9abfbba2..96d0898f 100644 --- a/src/part1/constants.py +++ b/src/part1/constants.py @@ -15,24 +15,18 @@ from typing import Final -# TODO: замените заглушки (0) на корректные значения, см. TASK.md. -# Все константы, зависящие от ёмкостей *_BITS, должны вычисляться из них. - # Twitter's original Snowflake epoch: 2010-11-04 01:42:54.657 UTC. -EPOCH_MS_DEFAULT: Final[int] = 0 +EPOCH_MS_DEFAULT: Final[int] = 1288834974657 # Node identifier used when the caller does not supply one. -NODE_ID_DEFAULT: Final[int] = 0 +NODE_ID_DEFAULT: Final[int] = 1 # Width of each field, in bits. -TIMESTAMP_BITS: Final[int] = 0 -NODE_ID_BITS: Final[int] = 0 -SEQUENCE_ID_BITS: Final[int] = 0 +TIMESTAMP_BITS: Final[int] = 41 +NODE_ID_BITS: Final[int] = 10 +SEQUENCE_ID_BITS: Final[int] = 12 # Largest value each field can hold. -TIMESTAMP_MS_MAX: Final[int] = 0 -NODE_ID_MAX: Final[int] = 0 -SEQUENCE_ID_MAX: Final[int] = 0 - -# Здесь можно добавить собственные вспомогательные константы -# (например, сдвиги полей при сборке идентификатора). +TIMESTAMP_MS_MAX: Final[int] = 2**TIMESTAMP_BITS - 1 +NODE_ID_MAX: Final[int] = 2**NODE_ID_BITS - 1 +SEQUENCE_ID_MAX: Final[int] = 2**SEQUENCE_ID_BITS - 1 diff --git a/src/part1/snowflake.py b/src/part1/snowflake.py index d3abf050..10daed83 100644 --- a/src/part1/snowflake.py +++ b/src/part1/snowflake.py @@ -13,12 +13,14 @@ :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, ) @@ -34,8 +36,7 @@ def read_current_millis(epoch_ms: int) -> int: The count of whole milliseconds between ``epoch_ms`` and now. May be negative if ``epoch_ms`` lies in the future. """ - # TODO: реализуйте функцию - return 0 + return int(time.time() * 1000) - epoch_ms def decode_timestamp_ms(snowflake_id: int, epoch_ms: int = EPOCH_MS_DEFAULT) -> int: @@ -50,8 +51,7 @@ def decode_timestamp_ms(snowflake_id: int, epoch_ms: int = EPOCH_MS_DEFAULT) -> The absolute Unix time in milliseconds at which the identifier was generated. """ - # TODO: реализуйте функцию - return 0 + return epoch_ms + (snowflake_id >> (NODE_ID_BITS + SEQUENCE_ID_BITS)) def decode_node_id(snowflake_id: int) -> int: @@ -64,8 +64,7 @@ def decode_node_id(snowflake_id: int) -> int: The node identifier packed into ``snowflake_id``, in the range ``[0, NODE_ID_MAX]``. """ - # TODO: реализуйте функцию - return 0 + return (snowflake_id >> SEQUENCE_ID_BITS) & ((1 << NODE_ID_BITS) - 1) def decode_sequence_id(snowflake_id: int) -> int: @@ -78,8 +77,7 @@ def decode_sequence_id(snowflake_id: int) -> int: The per-millisecond sequence counter packed into ``snowflake_id``, in the range ``[0, SEQUENCE_ID_MAX]``. """ - # TODO: реализуйте функцию - return 0 + return (snowflake_id) & ((1 << SEQUENCE_ID_BITS) - 1) def generate_snowflake_id( @@ -110,5 +108,20 @@ def generate_snowflake_id( 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): + print(f"node_id must be in [0, {NODE_ID_MAX}], received {node_id}") + return None + if not (0 <= sequence_id <= SEQUENCE_ID_MAX): + print(f"sequence_id must be in [0, {SEQUENCE_ID_MAX}], received {sequence_id}") + return None + + elapsed = read_current_millis(epoch_ms) + if elapsed > TIMESTAMP_MS_MAX: + print("overflows") + return None + + return ( + (elapsed << (NODE_ID_BITS + SEQUENCE_ID_BITS)) + | (node_id << SEQUENCE_ID_BITS) + | sequence_id + ) diff --git a/src/part1/tests/test_part1.py b/src/part1/tests/test_part1.py new file mode 100644 index 00000000..509e6c94 --- /dev/null +++ b/src/part1/tests/test_part1.py @@ -0,0 +1,337 @@ +import time +from itertools import pairwise + +import pytest + +from part1 import snowflake as sf +from part1.constants import ( + EPOCH_MS_DEFAULT, + NODE_ID_BITS, + NODE_ID_DEFAULT, + NODE_ID_MAX, + SEQUENCE_ID_BITS, + SEQUENCE_ID_MAX, + TIMESTAMP_BITS, + TIMESTAMP_MS_MAX, +) + + +# ============================================================ +# CONSTANTS — границы и согласованность +# ============================================================ + + +class TestConstants: + def test_bit_widths(self) -> None: + assert (TIMESTAMP_BITS, NODE_ID_BITS, SEQUENCE_ID_BITS) == (41, 10, 12) + + def test_total_width_is_63(self) -> None: + # 1 (sign) + 41 + 10 + 12 = 64; знаковый разряд не используется -> 63 + assert TIMESTAMP_BITS + NODE_ID_BITS + SEQUENCE_ID_BITS == 63 + + def test_max_are_derived(self) -> None: + assert TIMESTAMP_MS_MAX == 2**41 - 1 + assert NODE_ID_MAX == 2**10 - 1 == 1023 + assert SEQUENCE_ID_MAX == 2**12 - 1 == 4095 + + def test_epoch_and_default(self) -> None: + assert EPOCH_MS_DEFAULT == 1288834974657 + assert NODE_ID_DEFAULT == 1 + + +# ============================================================ +# read_current_millis +# ============================================================ + + +class TestReadCurrentMillis: + def test_is_in_milliseconds(self) -> None: + before = int(time.time() * 1000) + got = sf.read_current_millis(0) + after = int(time.time() * 1000) + assert before <= got <= after + + def test_epoch_zero_equals_unix_ms(self) -> None: + assert abs(sf.read_current_millis(0) - int(time.time() * 1000)) < 5 + + def test_future_epoch_negative(self) -> None: + future = int(time.time() * 1000) + 10_000_000 + assert sf.read_current_millis(future) < 0 + + def test_default_epoch_yields_positive(self) -> None: + assert sf.read_current_millis(EPOCH_MS_DEFAULT) > 0 + + def test_tiny_negative_epoch(self) -> None: + assert sf.read_current_millis(-1) > 0 + + +# ============================================================ +# decode_* +# ============================================================ + + +class TestDecodeNodeId: + def test_zero(self) -> None: + assert sf.decode_node_id(0) == 0 + + def test_max(self) -> None: + sid = NODE_ID_MAX << SEQUENCE_ID_BITS + assert sf.decode_node_id(sid) == NODE_ID_MAX + + def test_isolated_from_sequence(self) -> None: + sid = (NODE_ID_MAX << SEQUENCE_ID_BITS) | SEQUENCE_ID_MAX + assert sf.decode_node_id(sid) == NODE_ID_MAX + + def test_isolated_from_timestamp(self) -> None: + sid = (12345 << (NODE_ID_BITS + SEQUENCE_ID_BITS)) | (5 << SEQUENCE_ID_BITS) | 3 + assert sf.decode_node_id(sid) == 5 + + +class TestDecodeSequenceId: + def test_zero(self) -> None: + assert sf.decode_sequence_id(0) == 0 + + def test_max(self) -> None: + assert sf.decode_sequence_id(SEQUENCE_ID_MAX) == SEQUENCE_ID_MAX + + def test_only_low_12_bits(self) -> None: + assert sf.decode_sequence_id(0xFFFF_FFFF) == SEQUENCE_ID_MAX + + def test_isolated_from_node_and_timestamp(self) -> None: + sid = (999 << 22) | (7 << 12) | 42 + assert sf.decode_sequence_id(sid) == 42 + + +class TestDecodeTimestampMs: + def test_zero_with_epoch(self) -> None: + assert sf.decode_timestamp_ms(0, epoch_ms=EPOCH_MS_DEFAULT) == EPOCH_MS_DEFAULT + + def test_zero_with_zero_epoch(self) -> None: + assert sf.decode_timestamp_ms(0, epoch_ms=0) == 0 + + def test_max_timestamp_roundtrip(self) -> None: + sid = TIMESTAMP_MS_MAX << (NODE_ID_BITS + SEQUENCE_ID_BITS) + assert sf.decode_timestamp_ms(sid, epoch_ms=0) == TIMESTAMP_MS_MAX + + def test_isolated_from_lower_fields(self) -> None: + ts = 123_456_789 + sid = (ts << 22) | (NODE_ID_MAX << 12) | SEQUENCE_ID_MAX + assert sf.decode_timestamp_ms(sid, epoch_ms=0) == ts + + def test_default_epoch_is_used(self) -> None: + ts = 1000 + sid = ts << 22 + assert sf.decode_timestamp_ms(sid) == ts + EPOCH_MS_DEFAULT + + +# ============================================================ +# generate_snowflake_id — валидные границы +# ============================================================ + + +class TestGenerateValidRanges: + def test_min_valid(self) -> None: + sid = sf.generate_snowflake_id(0, node_id=0) + assert sid is not None + assert 0 < sid < 2**63 + + def test_max_valid(self) -> None: + sid = sf.generate_snowflake_id(SEQUENCE_ID_MAX, node_id=NODE_ID_MAX) + assert sid is not None + assert 0 < sid < 2**63 + assert sf.decode_node_id(sid) == NODE_ID_MAX + assert sf.decode_sequence_id(sid) == SEQUENCE_ID_MAX + + def test_always_positive_63bit(self) -> None: + for n in (0, 1, NODE_ID_MAX): + for s in (0, 1, SEQUENCE_ID_MAX): + sid = sf.generate_snowflake_id(s, node_id=n) + assert sid is not None + assert 0 < sid < 2**63, f"n={n}, s={s}, sid={sid}" + + def test_monotonic_within_same_ms(self) -> None: + a = sf.generate_snowflake_id(1, node_id=5) + b = sf.generate_snowflake_id(2, node_id=5) + assert a is not None + assert b is not None + assert b >= a + + +# ============================================================ +# generate_snowflake_id — невалидный node_id +# ============================================================ + + +class TestGenerateInvalidNodeId: + def test_negative(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(0, node_id=-1) is None + assert "node_id must be in" in capsys.readouterr().out + + def test_above_max(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(0, node_id=NODE_ID_MAX + 1) is None + assert "node_id must be in" in capsys.readouterr().out + + def test_way_above(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(0, node_id=10**9) is None + assert "node_id must be in" in capsys.readouterr().out + + def test_node_checked_before_sequence( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + sf.generate_snowflake_id(-1, node_id=-1) + out = capsys.readouterr().out + assert "node_id must be in" in out + assert "sequence_id must be in" not in out + + +# ============================================================ +# generate_snowflake_id — невалидный sequence_id +# ============================================================ + + +class TestGenerateInvalidSequenceId: + def test_negative(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(-1, node_id=1) is None + assert "sequence_id must be in" in capsys.readouterr().out + + def test_above_max(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(SEQUENCE_ID_MAX + 1, node_id=1) is None + assert "sequence_id must be in" in capsys.readouterr().out + + def test_huge(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(10**9, node_id=1) is None + assert "sequence_id must be in" in capsys.readouterr().out + + +# ============================================================ +# generate_snowflake_id — переполнение +# ============================================================ + + +class TestGenerateOverflow: + def test_negative_epoch_overflows(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(0, node_id=1, epoch_ms=-(10**12)) is None + assert "overflows" in capsys.readouterr().out + + def test_exactly_at_max_ok(self) -> None: + now_ms = int(time.time() * 1000) + epoch = now_ms - TIMESTAMP_MS_MAX + sid = sf.generate_snowflake_id(0, node_id=1, epoch_ms=epoch) + # Гонка в 1 мс допустима: либо валид, либо overflow -> None + if sid is not None: + assert sid > 0 + + def test_one_past_max_overflows(self, capsys: pytest.CaptureFixture[str]) -> None: + now_ms = int(time.time() * 1000) + epoch = now_ms - TIMESTAMP_MS_MAX - 100 + assert sf.generate_snowflake_id(0, node_id=1, epoch_ms=epoch) is None + assert "overflows" in capsys.readouterr().out + + def test_overflow_checked_after_both_ids( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + sf.generate_snowflake_id(0, node_id=-1, epoch_ms=-(10**12)) + out = capsys.readouterr().out + assert "node_id must be in" in out + assert "overflows" not in out + + +# ============================================================ +# Roundtrip — главное свойство +# ============================================================ + + +class TestRoundtrip: + @pytest.mark.parametrize("node_id", [0, 1, 5, NODE_ID_MAX]) + @pytest.mark.parametrize("sequence_id", [0, 1, 42, SEQUENCE_ID_MAX]) + def test_node_and_sequence(self, node_id: int, sequence_id: int) -> None: + sid = sf.generate_snowflake_id(sequence_id, node_id=node_id) + assert sid is not None + assert sf.decode_node_id(sid) == node_id + assert sf.decode_sequence_id(sid) == sequence_id + + def test_timestamp_is_absolute_now(self) -> None: + before = int(time.time() * 1000) + sid = sf.generate_snowflake_id(0, node_id=1, epoch_ms=EPOCH_MS_DEFAULT) + after = int(time.time() * 1000) + assert sid is not None + ts = sf.decode_timestamp_ms(sid, epoch_ms=EPOCH_MS_DEFAULT) + assert before - 1 <= ts <= after + 1 + + def test_custom_epoch_roundtrip(self) -> None: + epoch = 1_700_000_000_000 + before = int(time.time() * 1000) + sid = sf.generate_snowflake_id(7, node_id=3, epoch_ms=epoch) + after = int(time.time() * 1000) + assert sid is not None + ts = sf.decode_timestamp_ms(sid, epoch_ms=epoch) + assert before - 1 <= ts <= after + 1 + assert sf.decode_node_id(sid) == 3 + assert sf.decode_sequence_id(sid) == 7 + + def test_ordering_by_timestamp(self) -> None: + ids: list[int] = [] + for i in range(10): + sid = sf.generate_snowflake_id(i, node_id=1) + assert sid is not None + ids.append(sid) + for a, b in pairwise(ids): + assert b >= a + + def test_fields_do_not_overlap(self) -> None: + sid_a = sf.generate_snowflake_id(100, node_id=1) + sid_b = sf.generate_snowflake_id(100, node_id=2) + assert sid_a is not None + assert sid_b is not None + assert sf.decode_sequence_id(sid_a) == 100 + assert sf.decode_sequence_id(sid_b) == 100 + assert sf.decode_node_id(sid_a) != sf.decode_node_id(sid_b) + + +# ============================================================ +# «Мусор на входе» +# ============================================================ + + +class TestGarbageInputs: + def test_bool_is_int_subclass(self) -> None: + assert sf.generate_snowflake_id(True, node_id=False) is not None + + def test_big_negative_node(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(0, node_id=-(10**18)) is None + + def test_big_positive_sequence(self, capsys: pytest.CaptureFixture[str]) -> None: + assert sf.generate_snowflake_id(10**18, node_id=1) is None + + +# ============================================================ +# Формат stdout-сообщений (TASK.md) +# ============================================================ + + +class TestErrorMessages: + def test_node_message_contains_range( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + sf.generate_snowflake_id(0, node_id=-1) + out = capsys.readouterr().out + assert str(NODE_ID_MAX) in out + assert "-1" in out + + def test_sequence_message_contains_range( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + sf.generate_snowflake_id(-1, node_id=1) + out = capsys.readouterr().out + assert str(SEQUENCE_ID_MAX) in out + assert "-1" in out + + def test_overflow_message_contains_word( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + sf.generate_snowflake_id(0, node_id=1, epoch_ms=-(10**12)) + assert "overflows" in capsys.readouterr().out + + def test_no_print_on_success(self, capsys: pytest.CaptureFixture[str]) -> None: + sf.generate_snowflake_id(0, node_id=1) + assert capsys.readouterr().out == "" diff --git a/src/part2/cart.py b/src/part2/cart.py index 063f9aea..83a76b78 100644 --- a/src/part2/cart.py +++ b/src/part2/cart.py @@ -9,10 +9,10 @@ an explanatory message to stdout and returns ``None``. """ -from typing import Final +from typing import Any, Final -from .crud import read_product, update_product # noqa: F401 -from .storage import ( # noqa: F401 +from .crud import read_product, update_product +from .storage import ( NAME_INDEX, PRICE_INDEX, QUANTITY_INDEX, @@ -22,9 +22,12 @@ type CartLine = tuple[int, int] -# TODO: задайте позиции полей внутри кортежа CartLine LINE_PRODUCT_ID_INDEX: Final = 0 -LINE_QUANTITY_INDEX: Final = 0 +LINE_QUANTITY_INDEX: Final = 1 + + +def dict_to_cart_line(line: dict[int, Any]) -> CartLine: + return tuple(x[1] for x in sorted(line.items())) def add_to_cart( @@ -53,8 +56,39 @@ def add_to_cart( 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 not product: + return None + + if product[QUANTITY_INDEX] < quantity: + print( + f"not enough stock for product {product_id}: \ + {product[QUANTITY_INDEX]} available, {quantity} requested" + ) + return None + + new_quantity = product[QUANTITY_INDEX] - quantity + update_product( + storage, product_id, (product[NAME_INDEX], product[PRICE_INDEX], new_quantity) + ) + + line = find_cart_line(cart, product_id) + dict_line = { + LINE_PRODUCT_ID_INDEX: product_id, + LINE_QUANTITY_INDEX: quantity + (line[LINE_QUANTITY_INDEX] if line else 0), + } + new_line = dict_to_cart_line(dict_line) + + if line: + cart.remove(line) + cart.append(new_line) + + return new_line + + +def find_cart_line(cart: list[CartLine], product_id: int) -> CartLine | None: + line = [x for x in cart if x[LINE_PRODUCT_ID_INDEX] == product_id] + return line[0] if line else None def remove_from_cart( @@ -85,5 +119,36 @@ def remove_from_cart( 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 not line: + print(f"product {product_id} is not in the cart") + return None + + if line[LINE_QUANTITY_INDEX] < quantity: + print( + f"cart holds only {line[LINE_QUANTITY_INDEX]} unit(s) \ + of product {product_id}, cannot remove {quantity}" + ) + return None + + product = read_product(storage, product_id) + if not product: + return None + + new_quantity = product[QUANTITY_INDEX] + quantity + update_product( + storage, product_id, (product[NAME_INDEX], product[PRICE_INDEX], new_quantity) + ) + + dict_line = { + LINE_PRODUCT_ID_INDEX: product_id, + LINE_QUANTITY_INDEX: line[LINE_QUANTITY_INDEX] - quantity, + } + new_line = dict_to_cart_line(dict_line) + + if line: + cart.remove(line) + if new_line[LINE_QUANTITY_INDEX] != 0: + cart.append(new_line) + + return new_line diff --git a/src/part2/crud.py b/src/part2/crud.py index d026cbcb..8c51c49f 100644 --- a/src/part2/crud.py +++ b/src/part2/crud.py @@ -13,14 +13,21 @@ """ from decimal import Decimal +from typing import Any -from .storage import ( # noqa: F401 +from .storage import ( NAME_INDEX, + PRICE_INDEX, PRODUCT_ID_INDEX, PRODUCT_ID_MIN, + QUANTITY_INDEX, Product, ) -from .utils import normalize_price # noqa: F401 +from .utils import normalize_price + + +def dict_to_product(record: dict[int, Any]) -> Product: + return tuple(x[1] for x in sorted(record.items())) def generate_product_id(storage: list[Product]) -> int: @@ -34,8 +41,11 @@ def generate_product_id(storage: list[Product]) -> int: :data:`~src.part2.storage.PRODUCT_ID_MIN` when ``storage`` is empty. """ - # TODO: реализуйте функцию - return 0 + return ( + PRODUCT_ID_MIN + if not storage + else max(storage, key=lambda x: x[PRODUCT_ID_INDEX])[PRODUCT_ID_INDEX] + 1 + ) def create_product( @@ -56,8 +66,19 @@ def create_product( ``storage`` is left unchanged and a message naming the clashing name is printed. """ - # TODO: реализуйте функцию - return 0 + if fields[0] in [x[NAME_INDEX] for x in storage]: + print(f"product name {fields[0]} is already taken") + return None + dict_record = { + PRODUCT_ID_INDEX: generate_product_id(storage), + NAME_INDEX: fields[0], + PRICE_INDEX: normalize_price(fields[1]), + QUANTITY_INDEX: fields[2], + } + new_record = dict_to_product(dict_record) + storage.append(new_record) + storage.sort() + return new_record[PRODUCT_ID_INDEX] def read_product(storage: list[Product], product_id: int) -> Product | None: @@ -72,8 +93,11 @@ def read_product(storage: list[Product], product_id: int) -> Product | None: ``None`` when no product carries that identifier (a message is printed in that case). """ - # TODO: реализуйте функцию - return (0, "", Decimal(0), 0) + record = [x for x in storage if x[PRODUCT_ID_INDEX] == product_id] + if not record: + print(f"no product with id {product_id}") + return None + return record[0] def update_product( @@ -99,8 +123,22 @@ def update_product( ``None`` when no product carries that identifier (``storage`` is left unchanged and a message is printed). """ - # TODO: реализуйте функцию - return (0, "", Decimal(0), 0) + record = read_product(storage, product_id) + if not record: + return None + + dict_record = { + PRODUCT_ID_INDEX: record[PRODUCT_ID_INDEX], + NAME_INDEX: fields[0], + PRICE_INDEX: normalize_price(fields[1]), + QUANTITY_INDEX: fields[2], + } + new_record = dict_to_product(dict_record) + storage.remove(record) + storage.append(new_record) + storage.sort() + + return new_record def delete_product(storage: list[Product], product_id: int) -> int | None: @@ -116,5 +154,9 @@ def delete_product(storage: list[Product], product_id: int) -> int | None: product carried that identifier (``storage`` is left unchanged and a message is printed). """ - # TODO: реализуйте функцию - return 0 + record = read_product(storage, product_id) + if not record: + return None + + storage.remove(record) + return 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/tests/test_part2.py b/src/part2/tests/test_part2.py new file mode 100644 index 00000000..5d4013bc --- /dev/null +++ b/src/part2/tests/test_part2.py @@ -0,0 +1,675 @@ +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, cast + +import pytest + +from part2.cart import ( + LINE_PRODUCT_ID_INDEX, + LINE_QUANTITY_INDEX, + CartLine, + add_to_cart, + remove_from_cart, +) +from part2.crud import ( + create_product, + delete_product, + generate_product_id, + read_product, + update_product, +) +from part2.storage import ( + NAME_INDEX, + PRICE_INDEX, + PRODUCT_ID_INDEX, + PRODUCT_ID_MIN, + QUANTITY_INDEX, + Product, +) +from part2.utils import PRICE_PRECISION, PRICE_STEP, normalize_price + + +# ============================================================ +# Helpers +# ============================================================ + + +def product( + product_id: int, + name: str, + price: str = "10.00", + quantity: int = 10, +) -> Product: + """ + Build a Product through the public index constants. + + The tests deliberately do not assume that product fields live at + positions 0, 1, 2, 3. If the tuple layout is reordered together with + the *_INDEX constants, the tests continue to work. + """ + fields: list[Any] = [None] * 4 + fields[PRODUCT_ID_INDEX] = product_id + fields[NAME_INDEX] = name + fields[PRICE_INDEX] = normalize_price(Decimal(price)) + fields[QUANTITY_INDEX] = quantity + return cast(Product, tuple(fields)) + + +def cart_line(product_id: int, quantity: int) -> CartLine: + """ + Build a CartLine through the public index constants instead of hardcoding + its tuple order. + """ + fields: list[Any] = [None] * 2 + fields[LINE_PRODUCT_ID_INDEX] = product_id + fields[LINE_QUANTITY_INDEX] = quantity + return cast(CartLine, tuple(fields)) + + +def product_id(record: Product) -> int: + return record[PRODUCT_ID_INDEX] + + +def product_name(record: Product) -> str: + return record[NAME_INDEX] + + +def product_price(record: Product) -> Decimal: + return record[PRICE_INDEX] + + +def product_quantity(record: Product) -> int: + return record[QUANTITY_INDEX] + + +def line_product_id(line: CartLine) -> int: + return line[LINE_PRODUCT_ID_INDEX] + + +def line_quantity(line: CartLine) -> int: + return line[LINE_QUANTITY_INDEX] + + +# ============================================================ +# storage — константы +# ============================================================ + + +class TestStorageConstants: + def test_first_product_id_is_defined_by_constant(self) -> None: + storage: list[Product] = [] + + product_id_created = create_product( + storage, + ("apple", Decimal(10), 1), + ) + + assert product_id_created == PRODUCT_ID_MIN + + def test_product_helper_respects_index_constants(self) -> None: + record = product(7, "apple", "12.34", 5) + + assert product_id(record) == 7 + assert product_name(record) == "apple" + assert product_price(record) == Decimal("12.34") + assert product_quantity(record) == 5 + + +# ============================================================ +# utils — PRICE_STEP / PRICE_PRECISION +# ============================================================ + + +class TestPriceConstants: + def test_step_is_derived_from_precision(self) -> None: + assert PRICE_STEP == Decimal(1).scaleb(-PRICE_PRECISION) + + +class TestNormalizePrice: + def test_rounds_above_half_up(self) -> None: + raw = Decimal(10) + PRICE_STEP * Decimal("1.5") + expected = Decimal(10) + PRICE_STEP * Decimal(2) + + result = normalize_price(raw) + + assert result == expected + assert result.as_tuple().exponent == -PRICE_PRECISION + + def test_rounds_below_half_down(self) -> None: + raw = Decimal(10) + PRICE_STEP * Decimal("1.4") + expected = Decimal(10) + PRICE_STEP + + result = normalize_price(raw) + + assert result == expected + assert result.as_tuple().exponent == -PRICE_PRECISION + + def test_exact_half_uses_round_half_up(self) -> None: + raw = Decimal(10) + PRICE_STEP / Decimal(2) + expected = Decimal(10) + PRICE_STEP + + result = normalize_price(raw) + + assert result == expected + + def test_negative_exact_half_rounds_away_from_zero(self) -> None: + raw = Decimal(-10) - PRICE_STEP / Decimal(2) + expected = Decimal(-10) - PRICE_STEP + + assert normalize_price(raw) == expected + + def test_integer_gets_fixed_precision(self) -> None: + result = normalize_price(Decimal(5)) + + assert result == Decimal(5).quantize( + PRICE_STEP, + rounding=ROUND_HALF_UP, + ) + assert result.as_tuple().exponent == -PRICE_PRECISION + + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("12.999", "13.00"), + ("2.3451", "2.35"), + ("2.3449", "2.34"), + ("0.005", "0.01"), + ("-2.345", "-2.35"), + ], + ) + def test_examples_from_task(self, raw: str, expected: str) -> None: + result = normalize_price(Decimal(raw)) + + assert result == Decimal(expected) + assert result.as_tuple().exponent == -PRICE_PRECISION + + +# ============================================================ +# crud — generate_product_id +# ============================================================ + + +class TestGenerateProductId: + def test_empty_storage_returns_minimum(self) -> None: + assert generate_product_id([]) == PRODUCT_ID_MIN + + def test_returns_maximum_id_plus_one(self) -> None: + storage = [ + product(1, "a", quantity=1), + product(5, "b", quantity=1), + product(3, "c", quantity=1), + ] + + assert generate_product_id(storage) == 6 + + def test_does_not_reuse_gap_after_delete(self) -> None: + storage = [ + product(1, "a"), + product(2, "b"), + product(3, "c"), + ] + + assert delete_product(storage, 2) == 2 + assert generate_product_id(storage) == 4 + + +# ============================================================ +# crud — create_product +# ============================================================ + + +class TestCreateProduct: + def test_success_adds_normalized_product(self) -> None: + storage: list[Product] = [] + + created_id = create_product( + storage, + ("apple", Decimal("10.999"), 7), + ) + + assert created_id == PRODUCT_ID_MIN + assert len(storage) == 1 + + saved = storage[0] + assert product_id(saved) == created_id + assert product_name(saved) == "apple" + assert product_price(saved) == normalize_price(Decimal("10.999")) + assert product_quantity(saved) == 7 + + def test_successful_create_is_readable(self) -> None: + storage: list[Product] = [] + created_id = create_product(storage, ("apple", Decimal(10), 4)) + + assert created_id is not None + assert read_product(storage, created_id) == storage[0] + + def test_duplicate_name_does_not_change_storage( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=1)] + before = list(storage) + + result = create_product( + storage, + ("apple", Decimal(5), 10), + ) + + assert result is None + assert storage == before + + out = capsys.readouterr().out + assert "apple" in out + assert "already taken" in out + + +# ============================================================ +# crud — read_product +# ============================================================ + + +class TestReadProduct: + def test_existing_product_is_returned(self) -> None: + record = product(1, "apple", quantity=3) + storage = [record] + + assert read_product(storage, 1) == record + + def test_missing_product_returns_none_without_mutation( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=3)] + before = list(storage) + + assert read_product(storage, 42) is None + assert storage == before + + out = capsys.readouterr().out + assert "42" in out + assert "no product" in out + + +# ============================================================ +# crud — update_product +# ============================================================ + + +class TestUpdateProduct: + def test_success_changes_name_price_and_quantity_but_keeps_id(self) -> None: + storage = [product(7, "apple", "10", 5)] + + updated = update_product( + storage, + 7, + ("pear", Decimal("12.345"), 9), + ) + + assert updated is not None + assert product_id(updated) == 7 + assert product_name(updated) == "pear" + assert product_price(updated) == normalize_price(Decimal("12.345")) + assert product_quantity(updated) == 9 + assert storage == [updated] + + def test_update_does_not_require_unique_name(self) -> None: + storage = [ + product(1, "apple", quantity=5), + product(2, "pear", quantity=6), + ] + + updated = update_product( + storage, + 1, + ("pear", Decimal("7.777"), 8), + ) + + assert updated is not None + assert product_name(updated) == "pear" + assert product_quantity(updated) == 8 + assert product_price(updated) == normalize_price(Decimal("7.777")) + assert len(storage) == 2 + + def test_missing_product_does_not_change_storage( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", "10", 5)] + before = list(storage) + + result = update_product( + storage, + 99, + ("pear", Decimal(1), 2), + ) + + assert result is None + assert storage == before + + out = capsys.readouterr().out + assert "99" in out + assert "no product" in out + + +# ============================================================ +# crud — delete_product +# ============================================================ + + +class TestDeleteProduct: + def test_existing_product_is_removed_and_id_returned(self) -> None: + first = product(1, "apple", quantity=5) + second = product(2, "pear", quantity=6) + storage = [first, second] + + assert delete_product(storage, 1) == 1 + assert storage == [second] + + def test_deleted_product_is_no_longer_readable( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=5)] + + assert delete_product(storage, 1) == 1 + capsys.readouterr() + + assert read_product(storage, 1) is None + assert "no product" in capsys.readouterr().out + + def test_missing_product_does_not_change_storage( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=5)] + before = list(storage) + + assert delete_product(storage, 42) is None + assert storage == before + + out = capsys.readouterr().out + assert "42" in out + assert "no product" in out + + +# ============================================================ +# cart — add_to_cart +# ============================================================ + + +class TestAddToCart: + def test_new_line_is_created_and_stock_decreases(self) -> None: + storage = [product(1, "apple", "10", 5)] + cart: list[CartLine] = [] + + line = add_to_cart(storage, cart, 1, 3) + + assert line is not None + assert line_product_id(line) == 1 + assert line_quantity(line) == 3 + assert cart == [line] + assert product_quantity(storage[0]) == 2 + assert product_name(storage[0]) == "apple" + assert product_price(storage[0]) == Decimal("10.00") + + def test_existing_line_is_incremented(self) -> None: + storage = [product(1, "apple", quantity=5)] + cart = [cart_line(1, 1)] + + line = add_to_cart(storage, cart, 1, 2) + + assert line is not None + assert line_product_id(line) == 1 + assert line_quantity(line) == 3 + assert cart == [line] + assert product_quantity(storage[0]) == 3 + + def test_add_updates_only_matching_cart_line(self) -> None: + storage = [ + product(1, "apple", quantity=5), + product(2, "pear", quantity=7), + ] + cart = [cart_line(2, 4), cart_line(1, 1)] + + line = add_to_cart(storage, cart, 1, 2) + + assert line is not None + assert line_product_id(line) == 1 + assert line_quantity(line) == 3 + assert cart == [cart_line(2, 4), cart_line(1, 3)] + assert product_quantity(storage[0]) == 3 + assert product_quantity(storage[1]) == 7 + + def test_all_stock_is_allowed(self) -> None: + storage = [product(1, "apple", quantity=3)] + cart: list[CartLine] = [] + + line = add_to_cart(storage, cart, 1, 3) + + assert line is not None + assert line_quantity(line) == 3 + assert product_quantity(storage[0]) == 0 + + def test_missing_product_returns_none_without_changes( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage: list[Product] = [] + cart: list[CartLine] = [] + + assert add_to_cart(storage, cart, 42, 1) is None + assert storage == [] + assert cart == [] + + out = capsys.readouterr().out + assert "42" in out + assert "no product" in out + + def test_not_enough_stock_returns_none_without_changes( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=2)] + cart: list[CartLine] = [] + before = list(storage) + + assert add_to_cart(storage, cart, 1, 3) is None + assert storage == before + assert cart == [] + + out = capsys.readouterr().out + assert "not enough stock" in out + assert "2 available" in out + assert "3 requested" in out + + +# ============================================================ +# cart — remove_from_cart +# ============================================================ + + +class TestRemoveFromCart: + def test_partial_remove_moves_quantity_back_to_stock(self) -> None: + storage = [product(1, "apple", "10", 2)] + cart = [cart_line(1, 5)] + + line = remove_from_cart(storage, cart, 1, 3) + + assert line is not None + assert line_product_id(line) == 1 + assert line_quantity(line) == 2 + assert cart == [line] + assert product_quantity(storage[0]) == 5 + assert product_name(storage[0]) == "apple" + assert product_price(storage[0]) == Decimal("10.00") + + def test_full_remove_returns_zero_and_deletes_line(self) -> None: + storage = [product(1, "apple", "10", 2)] + cart = [cart_line(1, 4)] + + line = remove_from_cart(storage, cart, 1, 4) + + assert line is not None + assert line_product_id(line) == 1 + assert line_quantity(line) == 0 + assert cart == [] + assert product_quantity(storage[0]) == 6 + + def test_removes_matching_line_even_when_it_is_not_first(self) -> None: + storage = [ + product(1, "apple", quantity=2), + product(2, "pear", quantity=4), + ] + cart = [cart_line(2, 1), cart_line(1, 5)] + + line = remove_from_cart(storage, cart, 1, 2) + + assert line is not None + assert line == cart_line(1, 3) + assert cart == [cart_line(2, 1), cart_line(1, 3)] + assert product_quantity(storage[0]) == 4 + assert product_quantity(storage[1]) == 4 + + def test_not_in_cart_returns_none_without_changes( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=2)] + cart: list[CartLine] = [] + before_storage = list(storage) + before_cart = list(cart) + + assert remove_from_cart(storage, cart, 1, 1) is None + assert storage == before_storage + assert cart == before_cart + + out = capsys.readouterr().out + assert "1" in out + assert "not in the cart" in out + + def test_not_enough_in_cart_returns_none_without_changes( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=2)] + cart = [cart_line(1, 1)] + before_storage = list(storage) + before_cart = list(cart) + + assert remove_from_cart(storage, cart, 1, 2) is None + assert storage == before_storage + assert cart == before_cart + + out = capsys.readouterr().out + assert "cart holds only 1" in out + assert "cannot remove 2" in out + + def test_product_missing_in_storage_returns_none_without_changes( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage: list[Product] = [] + cart = [cart_line(42, 3)] + before_cart = list(cart) + + assert remove_from_cart(storage, cart, 42, 1) is None + assert storage == [] + assert cart == before_cart + + out = capsys.readouterr().out + assert "42" in out + assert "no product" in out + + +# ============================================================ +# Invariants +# ============================================================ + + +class TestInvariants: + def test_create_then_read(self) -> None: + storage: list[Product] = [] + + created_id = create_product( + storage, + ("apple", Decimal("10.999"), 5), + ) + + assert created_id is not None + record = read_product(storage, created_id) + assert record is not None + assert product_id(record) == created_id + assert product_name(record) == "apple" + assert product_price(record) == normalize_price(Decimal("10.999")) + assert product_quantity(record) == 5 + + def test_delete_then_read_is_none( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=5)] + + assert delete_product(storage, 1) == 1 + capsys.readouterr() + assert read_product(storage, 1) is None + assert "no product" in capsys.readouterr().out + + def test_add_then_remove_restores_exact_state(self) -> None: + storage = [product(1, "apple", "10", 5)] + cart: list[CartLine] = [] + initial_storage = list(storage) + initial_cart = list(cart) + + added = add_to_cart(storage, cart, 1, 3) + removed = remove_from_cart(storage, cart, 1, 3) + + assert added is not None + assert removed is not None + assert line_quantity(added) == 3 + assert line_quantity(removed) == 0 + assert storage == initial_storage + assert cart == initial_cart + + def test_quantity_is_conserved_on_add(self) -> None: + storage = [product(1, "apple", quantity=5)] + cart: list[CartLine] = [] + total_before = product_quantity(storage[0]) + + line = add_to_cart(storage, cart, 1, 3) + + assert line is not None + total_after = product_quantity(storage[0]) + line_quantity(line) + assert total_after == total_before + + def test_quantity_is_conserved_on_remove(self) -> None: + storage = [product(1, "apple", quantity=2)] + cart = [cart_line(1, 5)] + total_before = product_quantity(storage[0]) + line_quantity(cart[0]) + + line = remove_from_cart(storage, cart, 1, 3) + + assert line is not None + total_after = product_quantity(storage[0]) + line_quantity(line) + assert total_after == total_before + + def test_normalized_price_is_preserved_through_create_and_update(self) -> None: + storage: list[Product] = [] + created_id = create_product( + storage, + ("apple", Decimal("2.3451"), 5), + ) + + assert created_id is not None + created = read_product(storage, created_id) + assert created is not None + assert product_price(created) == normalize_price(Decimal("2.3451")) + + updated = update_product( + storage, + created_id, + ("apple", Decimal("7.7777"), 8), + ) + + assert updated is not None + assert product_price(updated) == normalize_price(Decimal("7.7777")) diff --git a/src/part2/utils.py b/src/part2/utils.py index 11bd7a76..6fbf19f7 100644 --- a/src/part2/utils.py +++ b/src/part2/utils.py @@ -6,13 +6,12 @@ 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: @@ -25,5 +24,4 @@ def normalize_price(price: Decimal) -> Decimal: ``price`` quantised to :data:`PRICE_PRECISION` fractional digits, with halves rounded up. """ - # TODO: реализуйте функцию - return Decimal(0) + return price.quantize(PRICE_STEP, rounding=ROUND_HALF_UP) diff --git a/src/part3/crud.py b/src/part3/crud.py index 68811d7e..5b22c9ec 100644 --- a/src/part3/crud.py +++ b/src/part3/crud.py @@ -19,14 +19,17 @@ """ from decimal import Decimal +from typing import Any -from .storage import ( # noqa: F401 +from .storage import ( NAME_INDEX, + PRICE_INDEX, PRODUCT_ID_INDEX, PRODUCT_ID_MIN, + QUANTITY_INDEX, 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: @@ -40,8 +43,15 @@ def generate_product_id(storage: list[Product]) -> int: :data:`~src.part3.storage.PRODUCT_ID_MIN` when ``storage`` is empty. """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return 0 + return ( + PRODUCT_ID_MIN + if not storage + else max(storage, key=lambda x: x[PRODUCT_ID_INDEX])[PRODUCT_ID_INDEX] + 1 + ) + + +def dict_to_product(record: dict[int, Any]) -> Product: + return tuple(x[1] for x in sorted(record.items())) def create_product( @@ -64,8 +74,26 @@ def create_product( normalised name already exists. In the ``None`` case ``storage`` is left unchanged and an explanatory message is printed. """ - # TODO: реализуйте функцию - return 0 + normalized_name = normalize_product_name(fields[0]) + + if normalized_name in [x[NAME_INDEX] for x in storage]: + print(f"product name '{normalized_name}' is already taken") + return None + if not normalized_name: + print("product name must not be blank") + return None + + dict_record = { + PRODUCT_ID_INDEX: generate_product_id(storage), + NAME_INDEX: normalized_name, + PRICE_INDEX: normalize_price(fields[1]), + QUANTITY_INDEX: fields[2], + } + new_record = dict_to_product(dict_record) + + storage.append(new_record) + storage.sort() + return new_record[PRODUCT_ID_INDEX] def read_product(storage: list[Product], product_id: int) -> Product | None: @@ -80,8 +108,11 @@ def read_product(storage: list[Product], product_id: int) -> Product | None: ``None`` when no product carries that identifier (a message is printed in that case). """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return (0, "", Decimal(0), 0) + record = [x for x in storage if x[PRODUCT_ID_INDEX] == product_id] + if not record: + print(f"no product with id {product_id}") + return None + return record[0] def update_product( @@ -110,8 +141,33 @@ def update_product( unchanged and an explanatory message is printed. Renaming a product to its own current name is allowed. """ - # TODO: реализуйте функцию - return (0, "", Decimal(0), 0) + normalized_name = normalize_product_name(fields[0]) + if not normalized_name: + print("product name must not be blank") + return None + + record = read_product(storage, product_id) + if not record: + return None + + if normalized_name in [ + x[NAME_INDEX] for x in storage if x[PRODUCT_ID_INDEX] != product_id + ]: + print(f"product name '{normalized_name}' is already taken") + return None + + dict_record = { + PRODUCT_ID_INDEX: record[PRODUCT_ID_INDEX], + NAME_INDEX: normalized_name, + PRICE_INDEX: normalize_price(fields[1]), + QUANTITY_INDEX: fields[2], + } + new_record = dict_to_product(dict_record) + storage.remove(record) + storage.append(new_record) + storage.sort() + + return new_record def delete_product(storage: list[Product], product_id: int) -> int | None: @@ -127,5 +183,9 @@ def delete_product(storage: list[Product], product_id: int) -> int | None: product carried that identifier (``storage`` is left unchanged and a message is printed). """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return 0 + record = read_product(storage, product_id) + if not record: + return None + + storage.remove(record) + return product_id diff --git a/src/part3/run_admin.py b/src/part3/run_admin.py index c5feeb0b..1813b765 100644 --- a/src/part3/run_admin.py +++ b/src/part3/run_admin.py @@ -29,22 +29,34 @@ 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 - - -# TODO: задайте приглашение и текст справки -PROMPT: Final[str] = "" -HELP_TEXT: Final[str] = "" +from .utils import get_storage_str_representation + + +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: @@ -54,7 +66,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: реализуйте функцию + print(HELP_TEXT) def print_result(result: object) -> None: @@ -65,7 +77,8 @@ def print_result(result: object) -> None: operation already reported its own failure, so nothing is printed in that case. """ - # TODO: реализуйте функцию + if result: + print(result) def run_command(storage: list[Product], line: str) -> bool: @@ -88,8 +101,34 @@ def run_command(storage: list[Product], line: str) -> bool: decimal.InvalidOperation: If the price argument of ``create`` or ``update`` does not parse as a decimal number. """ - # TODO: реализуйте функцию - return False + match line.split(): + case ["exit"]: + return False + case ["show"]: + print(get_storage_str_representation(storage)) + case ["help"]: + show_help() + case ["create", *name_parts, price, quantity] if name_parts: + print_result( + create_product( + storage, (" ".join(name_parts), Decimal(price), int(quantity)) + ) + ) + case ["read", id]: + print_result(read_product(storage, int(id))) + case ["update", id, *name_parts, price, quantity] if name_parts: + print_result( + update_product( + storage, + int(id), + (" ".join(name_parts), Decimal(price), int(quantity)), + ) + ) + case ["delete", id]: + print_result(delete_product(storage, int(id))) + case _: + print(f"'{line}' is not a command") + return True def main() -> None: diff --git a/src/part3/storage.py b/src/part3/storage.py index c5e34be5..e4bceff1 100644 --- a/src/part3/storage.py +++ b/src/part3/storage.py @@ -6,11 +6,11 @@ where: * ``product_id`` -- unique positive integer key, assigned by the create - operation in :mod:`src.part3.crud`; + operation in :mod:`src.part2.crud`; * ``name`` -- human-readable product name, unique across the store; * ``price`` -- price of a single unit, held as a :class:`~decimal.Decimal` amount rounded to a fixed number of decimal places (see - :func:`src.part3.utils.normalize_price`) so that money is never + :func:`src.part2.utils.normalize_price`) so that money is never subject to binary floating-point error; * ``quantity`` -- number of units currently in stock. @@ -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/test_part3.py b/src/part3/tests/test_part3.py new file mode 100644 index 00000000..96956fec --- /dev/null +++ b/src/part3/tests/test_part3.py @@ -0,0 +1,624 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any, cast + +import pytest + +from part3.crud import create_product, delete_product, read_product, update_product +from part3.run_admin import ( + HELP_TEXT, + PROMPT, + main, + print_result, + run_command, + show_help, +) +from part3.storage import ( + NAME_INDEX, + PRICE_INDEX, + PRODUCT_ID_INDEX, + PRODUCT_ID_MIN, + QUANTITY_INDEX, + Product, +) +from part3.utils import ( + TABLE_HEADERS, + get_storage_str_representation, + normalize_price, + normalize_product_name, +) + + +# ============================================================ +# Helpers +# ============================================================ + + +def product( + product_id: int, + name: str, + price: str = "10.00", + quantity: int = 10, +) -> Product: + """Build a Product using the public index constants, not fixed positions.""" + fields: list[Any] = [None] * 4 + fields[PRODUCT_ID_INDEX] = product_id + fields[NAME_INDEX] = name + fields[PRICE_INDEX] = normalize_price(Decimal(price)) + fields[QUANTITY_INDEX] = quantity + return cast(Product, tuple(fields)) + + +def product_id(record: Product) -> int: + return record[PRODUCT_ID_INDEX] + + +def product_name(record: Product) -> str: + return record[NAME_INDEX] + + +def product_price(record: Product) -> Decimal: + return record[PRICE_INDEX] + + +def product_quantity(record: Product) -> int: + return record[QUANTITY_INDEX] + + +EXPECTED_HELP_TEXT = """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.""" + + +# ============================================================ +# utils — TABLE_HEADERS and normalize_product_name +# ============================================================ + + +class TestTableHeaders: + def test_table_headers_have_the_required_order(self) -> None: + assert TABLE_HEADERS == ("ID", "name", "price", "quantity") + + +class TestNormalizeProductName: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + (" Cordless Drill ", "cordless drill"), + ("abc def\tghi\tjkl", "abc def ghi jkl"), + (" \t\n ", ""), + ("claw hammer", "claw hammer"), + (" RUBBER\t\tMALLET ", "rubber mallet"), + ("one\ntwo\r\nthree", "one two three"), + ("\v A\fB ", "a b"), + ("123 ABC", "123 abc"), + ("", ""), + ], + ) + def test_normalizes_name(self, raw: str, expected: str) -> None: + assert normalize_product_name(raw) == expected + + def test_normalization_is_idempotent(self) -> None: + normalized = normalize_product_name(" Cordless\t DRILL \n") + assert normalize_product_name(normalized) == normalized + + +# ============================================================ +# utils — get_storage_str_representation +# ============================================================ + + +class TestGetStorageStringRepresentation: + def test_empty_storage_has_only_header_and_separator(self) -> None: + assert get_storage_str_representation([]) == ( + "| ID | name | price | quantity |\n|----|------|-------|----------|" + ) + + def test_simple_storage_matches_exact_table(self) -> None: + storage = [ + product(1, "saw", "9.90", 5), + product(2, "axe", "12.00", 8), + ] + + assert get_storage_str_representation(storage) == ( + "| ID | name | price | quantity |\n" + "|----|------|-------|----------|\n" + "| 1 | saw | 9.90 | 5 |\n" + "| 2 | axe | 12.00 | 8 |" + ) + + def test_column_widths_expand_to_fit_values(self) -> None: + storage = [ + product(10, "compressor", "1299.99", 3), + product(2, "nail", "2.50", 500), + ] + + assert get_storage_str_representation(storage) == ( + "| ID | name | price | quantity |\n" + "|----|------------|---------|----------|\n" + "| 10 | compressor | 1299.99 | 3 |\n" + "| 2 | nail | 2.50 | 500 |" + ) + + def test_table_preserves_storage_order(self) -> None: + storage = [ + product(8, "last alphabetically", "1.00", 1), + product(2, "first alphabetically", "2.00", 2), + ] + + table = get_storage_str_representation(storage) + assert table.index("last alphabetically") < table.index("first alphabetically") + + def test_all_rows_have_the_same_width_and_aligned_separators(self) -> None: + storage = [ + product(100, "a much longer product name", "123456.78", 1000), + product(2, "x", "0.01", 1), + product(30, "medium name", "10.00", 99999), + ] + lines = get_storage_str_representation(storage).split("\n") + + assert len({len(line) for line in lines}) == 1 + bar_positions = [ + [i for i, char in enumerate(line) if char == "|"] for line in lines + ] + assert all(positions == bar_positions[0] for positions in bar_positions) + + def test_table_has_no_trailing_newline(self) -> None: + table = get_storage_str_representation([product(1, "saw")]) + assert not table.endswith("\n") + + def test_data_values_are_not_reordered_or_truncated(self) -> None: + storage = [product(17, "A very long name", "12345.67", 123456)] + table = get_storage_str_representation(storage) + + assert "A very long name" in table + assert "12345.67" in table + assert "123456" in table + assert table.index("17") < table.index("A very long name") + + +# ============================================================ +# crud — create_product with normalized names +# ============================================================ + + +class TestCreateProductPart3: + def test_success_normalizes_name_and_price(self) -> None: + storage: list[Product] = [] + + created_id = create_product( + storage, + (" Cordless Drill ", Decimal("89.999"), 12), + ) + + assert created_id == PRODUCT_ID_MIN + assert len(storage) == 1 + saved = storage[0] + assert product_id(saved) == created_id + assert product_name(saved) == "cordless drill" + assert product_price(saved) == Decimal("90.00") + assert product_quantity(saved) == 12 + + def test_successive_products_receive_successive_ids(self) -> None: + storage: list[Product] = [] + first_id = create_product(storage, ("Cordless Drill", Decimal(10), 1)) + second_id = create_product(storage, ("Claw Hammer", Decimal(20), 2)) + + assert first_id == PRODUCT_ID_MIN + assert second_id == PRODUCT_ID_MIN + 1 + assert [product_id(item) for item in storage] == [first_id, second_id] + + def test_duplicate_after_normalization_is_rejected_without_mutation( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill", "50.00", 3)] + before = list(storage) + + result = create_product( + storage, + (" CORDLESS DRILL ", Decimal(75), 1), + ) + + assert result is None + assert storage == before + assert ( + capsys.readouterr().out + == "product name 'cordless drill' is already taken\n" + ) + + @pytest.mark.parametrize("blank_name", ["", " ", "\t \n"]) + def test_blank_name_is_rejected_without_mutation( + self, blank_name: str, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "hammer")] + before = list(storage) + + result = create_product(storage, (blank_name, Decimal(10), 3)) + + assert result is None + assert storage == before + assert capsys.readouterr().out == "product name must not be blank\n" + + def test_successfully_created_product_can_be_read(self) -> None: + storage: list[Product] = [] + created_id = create_product( + storage, (" RUBBER MALLET ", Decimal("7.505"), 25) + ) + + assert created_id is not None + saved = read_product(storage, created_id) + assert saved is not None + assert product_name(saved) == "rubber mallet" + assert product_price(saved) == Decimal("7.51") + assert product_quantity(saved) == 25 + + +# ============================================================ +# crud — update_product with normalized names +# ============================================================ + + +class TestUpdateProductPart3: + def test_success_normalizes_name_and_price_and_keeps_id(self) -> None: + storage = [ + product(1, "cordless drill", "90.00", 12), + product(2, "claw hammer", "9.90", 40), + ] + + updated = update_product( + storage, + 2, + (" Rubber Mallet ", Decimal("7.505"), 25), + ) + + assert updated is not None + assert product_id(updated) == 2 + assert product_name(updated) == "rubber mallet" + assert product_price(updated) == Decimal("7.51") + assert product_quantity(updated) == 25 + assert storage == [product(1, "cordless drill", "90.00", 12), updated] + + def test_update_to_same_name_in_different_form_is_allowed(self) -> None: + storage = [product(1, "cordless drill", "90.00", 12)] + + updated = update_product( + storage, + 1, + (" CORDLESS Drill ", Decimal(85), 10), + ) + + assert updated is not None + assert product_id(updated) == 1 + assert product_name(updated) == "cordless drill" + assert product_price(updated) == Decimal("85.00") + assert product_quantity(updated) == 10 + assert storage == [updated] + + def test_duplicate_name_owned_by_another_product_is_rejected( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [ + product(1, "cordless drill", "90.00", 12), + product(2, "claw hammer", "9.90", 40), + ] + before = list(storage) + + result = update_product( + storage, + 1, + (" CLAW HAMMER ", Decimal(85), 10), + ) + + assert result is None + assert storage == before + assert ( + capsys.readouterr().out == "product name 'claw hammer' is already taken\n" + ) + + @pytest.mark.parametrize("blank_name", ["", " ", "\t\n"]) + def test_blank_name_is_rejected_without_mutation( + self, blank_name: str, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill", "90.00", 12)] + before = list(storage) + + result = update_product(storage, 1, (blank_name, Decimal(85), 10)) + + assert result is None + assert storage == before + assert capsys.readouterr().out == "product name must not be blank\n" + + def test_blank_name_is_checked_before_missing_product_id( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill")] + + result = update_product(storage, 99, (" ", Decimal(20), 5)) + + assert result is None + assert capsys.readouterr().out == "product name must not be blank\n" + assert storage == [product(1, "cordless drill")] + + def test_missing_product_id_does_not_change_storage( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill", "90.00", 12)] + before = list(storage) + + result = update_product(storage, 99, ("saw", Decimal(20), 5)) + + assert result is None + assert storage == before + assert capsys.readouterr().out == "no product with id 99\n" + + def test_normalized_name_can_be_reused_after_old_product_is_deleted( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [ + product(PRODUCT_ID_MIN, "cordless drill"), + product(PRODUCT_ID_MIN + 1, "claw hammer"), + product(PRODUCT_ID_MIN + 2, "saw"), + ] + assert delete_product(storage, PRODUCT_ID_MIN + 1) == PRODUCT_ID_MIN + 1 + capsys.readouterr() + + created_id = create_product(storage, (" CLAW HAMMER ", Decimal(11), 3)) + + assert created_id == PRODUCT_ID_MIN + 3 + assert product_name(storage[-1]) == "claw hammer" + + +# ============================================================ +# run_admin — show_help and print_result +# ============================================================ + + +class TestAdminOutputHelpers: + def test_help_text_matches_the_task(self) -> None: + assert HELP_TEXT == EXPECTED_HELP_TEXT + + def test_show_help_prints_the_help_text( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + show_help() + assert capsys.readouterr().out == EXPECTED_HELP_TEXT + "\n" + + @pytest.mark.parametrize("result", [1, "created", (1, "hammer")]) + def test_print_result_prints_non_none_values( + self, result: object, capsys: pytest.CaptureFixture[str] + ) -> None: + print_result(result) + assert capsys.readouterr().out == f"{result}\n" + + def test_print_result_does_not_print_none( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + print_result(None) + assert capsys.readouterr().out == "" + + +# ============================================================ +# run_admin — run_command +# ============================================================ + + +class TestRunCommand: + def test_help_prints_exact_help_text_and_continues( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + assert run_command([], "help") is True + assert capsys.readouterr().out == EXPECTED_HELP_TEXT + "\n" + + def test_exit_returns_false_without_output( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + assert run_command([], "exit") is False + assert capsys.readouterr().out == "" + + def test_show_prints_empty_storage_table( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + assert run_command([], "show") is True + assert capsys.readouterr().out == ( + "| ID | name | price | quantity |\n|----|------|-------|----------|\n" + ) + + def test_create_parses_multiword_name_and_prints_id( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage: list[Product] = [] + + assert run_command(storage, "create Cordless Drill 89.999 12") is True + + assert capsys.readouterr().out == f"{PRODUCT_ID_MIN}\n" + assert len(storage) == 1 + assert product_id(storage[0]) == PRODUCT_ID_MIN + assert product_name(storage[0]) == "cordless drill" + assert product_price(storage[0]) == Decimal("90.00") + assert product_quantity(storage[0]) == 12 + + def test_create_duplicate_does_not_print_none( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill")] + + assert run_command(storage, "create CORDLESS DRILL 20 4") is True + + assert ( + capsys.readouterr().out + == "product name 'cordless drill' is already taken\n" + ) + assert storage == [product(1, "cordless drill")] + + def test_read_prints_the_product(self, capsys: pytest.CaptureFixture[str]) -> None: + storage = [product(7, "hammer", "9.90", 3)] + + assert run_command(storage, "read 7") is True + + assert capsys.readouterr().out == "(7, 'hammer', Decimal('9.90'), 3)\n" + + def test_read_missing_product_prints_only_error( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(7, "hammer")] + + assert run_command(storage, "read 99") is True + + assert capsys.readouterr().out == "no product with id 99\n" + + def test_update_parses_multiword_name_and_prints_updated_product( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill", "90.00", 12)] + + assert run_command(storage, "update 1 Rubber Mallet 7.505 25") is True + + assert capsys.readouterr().out == "(1, 'rubber mallet', Decimal('7.51'), 25)\n" + assert storage == [product(1, "rubber mallet", "7.51", 25)] + + def test_update_duplicate_name_does_not_change_storage( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "cordless drill"), product(2, "claw hammer")] + before = list(storage) + + assert run_command(storage, "update 1 Claw Hammer 20 5") is True + + assert ( + capsys.readouterr().out == "product name 'claw hammer' is already taken\n" + ) + assert storage == before + + def test_delete_removes_product_and_prints_id( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = [product(1, "hammer"), product(2, "saw")] + + assert run_command(storage, "delete 1") is True + + assert capsys.readouterr().out == "1\n" + assert storage == [product(2, "saw")] + + @pytest.mark.parametrize( + "line", + [ + "frobnicate", + "read 1 2 3", + "delete", + "show extra", + "help now", + "exit now", + "create 89.90 12", + "update 1 89.90 12", + "", + ], + ) + def test_unknown_command_or_wrong_argument_count_is_rejected( + self, + line: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "hammer")] + before = list(storage) + + assert run_command(storage, line) is True + + assert capsys.readouterr().out == f"'{line}' is not a command\n" + assert storage == before + + def test_invalid_integer_argument_propagates_value_error(self) -> None: + with pytest.raises(ValueError): + run_command([], "read not-an-id") + + def test_invalid_decimal_argument_propagates_invalid_operation(self) -> None: + with pytest.raises(InvalidOperation): + run_command([], "create hammer not-a-price 4") + + def test_invalid_update_id_propagates_value_error(self) -> None: + with pytest.raises(ValueError): + run_command([], "update not-an-id hammer 10 4") + + +# ============================================================ +# run_admin — main loop and invalid arguments +# ============================================================ + + +class TestAdminMain: + def test_main_keeps_one_storage_for_the_whole_session( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + commands = iter( + [ + "create Cordless Drill 89.99 12", + "create Claw Hammer 9.9 40", + "show", + "exit", + ] + ) + prompts: list[str] = [] + + def fake_input(prompt: str = "") -> str: + prompts.append(prompt) + return next(commands) + + monkeypatch.setattr("builtins.input", fake_input) + main() + + output = capsys.readouterr().out + expected_storage = [ + product(PRODUCT_ID_MIN, "cordless drill", "89.99", 12), + product(PRODUCT_ID_MIN + 1, "claw hammer", "9.90", 40), + ] + expected_table = get_storage_str_representation(expected_storage) + assert output == (f"{PRODUCT_ID_MIN}\n{PRODUCT_ID_MIN + 1}\n{expected_table}\n") + assert prompts == [PROMPT, PROMPT, PROMPT, PROMPT] + + @pytest.mark.parametrize( + ("bad_command", "expected_error"), + [ + ( + "read not-an-id", + "'read not-an-id' names a command but its arguments are invalid\n", + ), + ( + "create hammer not-a-price 4", + "'create hammer not-a-price 4' names a command but its arguments are invalid\n", + ), + ( + "update bad-id hammer 10 4", + "'update bad-id hammer 10 4' names a command but its arguments are invalid\n", + ), + ], + ) + def test_main_handles_invalid_arguments_and_continues( + self, + bad_command: str, + expected_error: str, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + commands = iter([bad_command, "exit"]) + prompts: list[str] = [] + + def fake_input(prompt: str = "") -> str: + prompts.append(prompt) + return next(commands) + + monkeypatch.setattr("builtins.input", fake_input) + main() + + assert capsys.readouterr().out == expected_error + assert prompts == [PROMPT, PROMPT] diff --git a/src/part3/utils.py b/src/part3/utils.py index 318ded0b..59e4be3a 100644 --- a/src/part3/utils.py +++ b/src/part3/utils.py @@ -18,24 +18,16 @@ 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") def normalize_price(price: Decimal) -> Decimal: @@ -48,8 +40,7 @@ def normalize_price(price: Decimal) -> Decimal: ``price`` quantised to :data:`PRICE_PRECISION` fractional digits, with halves rounded up. """ - # TODO: реализуйте функцию (используйте своё решение части 2) - return Decimal(0) + return price.quantize(PRICE_STEP, rounding=ROUND_HALF_UP) def normalize_product_name(name: str) -> str: @@ -66,11 +57,7 @@ def normalize_product_name(name: str) -> str: The result is an empty string when ``name`` holds nothing but whitespace. """ - # TODO: реализуйте функцию - return "" - - -# TODO: при необходимости добавьте свои вспомогательные функции + return " ".join(x for x in name.strip().lower().split()) def get_storage_str_representation(storage: list[Product]) -> str: @@ -91,5 +78,28 @@ def get_storage_str_representation(storage: list[Product]) -> str: is empty only the header and separator rows are returned, sized to the header labels. """ - # TODO: реализуйте функцию - return "" + + width = { + header: max([len(header), *(len(str(product[inx])) for product in storage)]) + for inx, header in enumerate(TABLE_HEADERS) + } + + def _row(cells: list[str]) -> str: + return ( + "".join( + f"| {cell:<{width[header]}} " + for header, cell in zip(TABLE_HEADERS, cells) + ) + + "|" + ) + + return "\n".join( + [ + _row(list(TABLE_HEADERS)), + "".join(f"|{'-' * (width[header] + 2)}" for header in TABLE_HEADERS) + "|", + *( + _row([str(product[inx]) for inx in range(len(TABLE_HEADERS))]) + for product in storage + ), + ] + ) diff --git a/uv.lock b/uv.lock index 2a56c481..f0810da0 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.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[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"