From 51f874b1584f768bb965d4ad0f5d399639986598 Mon Sep 17 00:00:00 2001 From: Nerey Date: Mon, 21 Sep 2026 19:52:26 +0300 Subject: [PATCH 01/25] set constant values --- src/part1/constants.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/part1/constants.py b/src/part1/constants.py index 9abfbba2..2ce78cdf 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] = 2**TIMESTAMP_BITS +NODE_ID_MAX: Final[int] = 2**NODE_ID_BITS +SEQUENCE_ID_MAX: Final[int] = 2**SEQUENCE_ID_BITS # Здесь можно добавить собственные вспомогательные константы # (например, сдвиги полей при сборке идентификатора). From 358a608a8f1b00cd60f74583e82ac0c3cb424b20 Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 24 Sep 2026 18:09:18 +0300 Subject: [PATCH 02/25] write all functions and fix some problems --- src/part1/constants.py | 9 +++------ src/part1/snowflake.py | 37 +++++++++++++++++++++++++------------ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/part1/constants.py b/src/part1/constants.py index 2ce78cdf..d742fcdd 100644 --- a/src/part1/constants.py +++ b/src/part1/constants.py @@ -15,9 +15,6 @@ 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] = 1288834974657 @@ -30,9 +27,9 @@ SEQUENCE_ID_BITS: Final[int] = 12 # Largest value each field can hold. -TIMESTAMP_MS_MAX: Final[int] = 2**TIMESTAMP_BITS -NODE_ID_MAX: Final[int] = 2**NODE_ID_BITS -SEQUENCE_ID_MAX: Final[int] = 2**SEQUENCE_ID_BITS +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..5207b8be 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}]") + return None + if not (0 <= sequence_id <= SEQUENCE_ID_MAX): + print(f"sequence_id must be in [0, {SEQUENCE_ID_MAX}]") + 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 + ) From cac822730462bbee1d810c6316d7d07e85fa2100 Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 24 Sep 2026 18:14:13 +0300 Subject: [PATCH 03/25] delete last comments in constants.py --- src/part1/constants.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/part1/constants.py b/src/part1/constants.py index d742fcdd..96d0898f 100644 --- a/src/part1/constants.py +++ b/src/part1/constants.py @@ -30,6 +30,3 @@ 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 - -# Здесь можно добавить собственные вспомогательные константы -# (например, сдвиги полей при сборке идентификатора). From f19c891d24338c48485e2a0ffcae4f01f2290f45 Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 24 Sep 2026 21:41:26 +0300 Subject: [PATCH 04/25] add tests(pytest) generated by AI. Fix problems. --- src/part1/snowflake.py | 6 +- src/tests/test_snowflake.py | 327 ++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/tests/test_snowflake.py diff --git a/src/part1/snowflake.py b/src/part1/snowflake.py index 5207b8be..10daed83 100644 --- a/src/part1/snowflake.py +++ b/src/part1/snowflake.py @@ -51,7 +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. """ - return epoch_ms + snowflake_id >> (NODE_ID_BITS + SEQUENCE_ID_BITS) + return epoch_ms + (snowflake_id >> (NODE_ID_BITS + SEQUENCE_ID_BITS)) def decode_node_id(snowflake_id: int) -> int: @@ -109,10 +109,10 @@ def generate_snowflake_id( cases an explanatory message is printed to stdout first. """ if not (0 <= node_id <= NODE_ID_MAX): - print(f"node_id must be in [0, {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}]") + print(f"sequence_id must be in [0, {SEQUENCE_ID_MAX}], received {sequence_id}") return None elapsed = read_current_millis(epoch_ms) diff --git a/src/tests/test_snowflake.py b/src/tests/test_snowflake.py new file mode 100644 index 00000000..fca254a0 --- /dev/null +++ b/src/tests/test_snowflake.py @@ -0,0 +1,327 @@ +import time +import pytest + +from part1.constants import ( + EPOCH_MS_DEFAULT, NODE_ID_DEFAULT, + TIMESTAMP_BITS, NODE_ID_BITS, SEQUENCE_ID_BITS, + TIMESTAMP_MS_MAX, NODE_ID_MAX, SEQUENCE_ID_MAX, +) +from part1 import snowflake as sf + + +# ============================================================ +# CONSTANTS — границы и согласованность +# ============================================================ + +class TestConstants: + def test_bit_widths(self): + assert (TIMESTAMP_BITS, NODE_ID_BITS, SEQUENCE_ID_BITS) == (41, 10, 12) + + def test_total_width_is_63(self): + # 1 (sign) + 41 + 10 + 12 = 64, знаковый не используется -> 63 + assert TIMESTAMP_BITS + NODE_ID_BITS + SEQUENCE_ID_BITS == 63 + + def test_max_are_derived(self): + 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): + assert EPOCH_MS_DEFAULT == 1288834974657 + assert NODE_ID_DEFAULT == 1 + + +# ============================================================ +# read_current_millis — крайние случаи +# ============================================================ + +class TestReadCurrentMillis: + def test_is_in_milliseconds(self): + 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): + # при epoch=0 функция возвращает Unix-время в мс + assert abs(sf.read_current_millis(0) - int(time.time() * 1000)) < 5 + + def test_future_epoch_negative(self): + # epoch_ms в будущем -> результат отрицательный + future = int(time.time() * 1000) + 10_000_000 + assert sf.read_current_millis(future) < 0 + + def test_default_epoch_yields_positive(self): + # с 2010 года точно прошло > 0 мс + assert sf.read_current_millis(EPOCH_MS_DEFAULT) > 0 + + def test_tiny_negative_epoch(self): + # отрицательная эпоха не должна падать + assert sf.read_current_millis(-1) > 0 + + +# ============================================================ +# decode_* — границы полей, «мусор на входе» +# ============================================================ + +class TestDecodeNodeId: + def test_zero(self): + assert sf.decode_node_id(0) == 0 + + def test_max(self): + # sequence=0, node=NODE_ID_MAX + sid = NODE_ID_MAX << SEQUENCE_ID_BITS + assert sf.decode_node_id(sid) == NODE_ID_MAX + + def test_isolated_from_sequence(self): + # sequence не влияет на node + 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): + # timestamp не влияет на node + 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): + assert sf.decode_sequence_id(0) == 0 + + def test_max(self): + assert sf.decode_sequence_id(SEQUENCE_ID_MAX) == SEQUENCE_ID_MAX + + def test_only_low_12_bits(self): + # всё, что выше 12 бит, игнорируется + assert sf.decode_sequence_id(0xFFFF_FFFF) == SEQUENCE_ID_MAX + + def test_isolated_from_node_and_timestamp(self): + sid = (999 << 22) | (7 << 12) | 42 + assert sf.decode_sequence_id(sid) == 42 + + +class TestDecodeTimestampMs: + def test_zero_with_epoch(self): + # id без timestamp -> возвращаем ровно эпоху + assert sf.decode_timestamp_ms(0, epoch_ms=EPOCH_MS_DEFAULT) == EPOCH_MS_DEFAULT + + def test_zero_with_zero_epoch(self): + assert sf.decode_timestamp_ms(0, epoch_ms=0) == 0 + + def test_max_timestamp_roundtrip(self): + 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): + # node и sequence не должны влиять на timestamp + 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): + 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): + sid = sf.generate_snowflake_id(0, node_id=0) + assert sid is not None + assert 0 < sid < 2**63 + + def test_max_valid(self): + 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): + # прогоняем все граничные комбинации + 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): + # при одной и той же мс id с большим sequence больше + # (пока время не перескочило — обычно успевает) + a = sf.generate_snowflake_id(1, node_id=5) + b = sf.generate_snowflake_id(2, node_id=5) + # если мс не сменилась, b > a; если сменилась — b всё равно > a + # т.к. elapsed только растёт, а sequence мал + assert b >= a + + +class TestGenerateInvalidNodeId: + def test_negative(self, capsys): + assert sf.generate_snowflake_id(0, node_id=-1) is None + out = capsys.readouterr().out + assert "node_id must be in" in out + + def test_above_max(self, capsys): + 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): + 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): + # node невалиден И sequence невалиден -> жалуемся на node + 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 + + +class TestGenerateInvalidSequenceId: + def test_negative(self, capsys): + 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): + 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): + assert sf.generate_snowflake_id(10**9, node_id=1) is None + assert "sequence_id must be in" in capsys.readouterr().out + + +class TestGenerateOverflow: + def test_negative_epoch_overflows(self, capsys): + # epoch=-1 -> elapsed ~ 1.7e12 + 1 > 2^41-1? 2^41-1 ≈ 2.199e12 + # сейчас ~1.76e12, ещё влезает. Уводим эпоху глубоко в прошлое: + sf.generate_snowflake_id(0, node_id=1, epoch_ms=-10**12) + out = capsys.readouterr().out + assert "overflows" in out + + def test_exactly_at_max_ok(self): + # elapsed ровно = TIMESTAMP_MS_MAX -> НЕ переполнение + # подбираем epoch так, чтобы elapsed == TIMESTAMP_MS_MAX + 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 мс — допускаем оба исхода + if sid is not None: + assert sid > 0 + + def test_one_past_max_overflows(self, capsys): + 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): + # невалидный node + переполнение -> сначала node + 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 + + def test_future_epoch_no_overflow_but_valid(self): + # эпоха в будущем -> elapsed отрицательный, это НЕ overflow + future = int(time.time() * 1000) + 10_000 + sid = sf.generate_snowflake_id(0, node_id=1, epoch_ms=future) + # по заданию не описано поведение для отрицательного elapsed + # но текущая реализация не падает + assert sid is None or isinstance(sid, int) + + +# ============================================================ +# 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, sequence_id): + 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): + before = int(time.time() * 1000) + sid = sf.generate_snowflake_id(0, node_id=1, epoch_ms=EPOCH_MS_DEFAULT) + after = int(time.time() * 1000) + ts = sf.decode_timestamp_ms(sid, epoch_ms=EPOCH_MS_DEFAULT) + assert before - 1 <= ts <= after + 1 + + def test_custom_epoch_roundtrip(self): + 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) + 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): + # id, сгенерированный позже, не меньше предыдущего + ids = [sf.generate_snowflake_id(i, node_id=1) for i in range(10)] + assert all(x is not None for x in ids) + # монотонность по времени + sequence (в пределах одной мс — строго растёт) + for a, b in zip(ids, ids[1:]): + assert b >= a + + def test_fields_do_not_overlap(self): + # изменение node не меняет sequence и timestamp + sid_a = sf.generate_snowflake_id(100, node_id=1) + sid_b = sf.generate_snowflake_id(100, node_id=2) + assert sf.decode_sequence_id(sid_a) == sf.decode_sequence_id(sid_b) == 100 + assert sf.decode_node_id(sid_a) != sf.decode_node_id(sid_b) + + +# ============================================================ +# «Мусор на входе» — int-подобные значения +# ============================================================ + +class TestGarbageInputs: + def test_bool_is_int_subclass(self): + # True == 1, False == 0 — не должно падать + assert sf.generate_snowflake_id(True, node_id=False) is not None + + def test_big_negative_node(self, capsys): + assert sf.generate_snowflake_id(0, node_id=-10**18) is None + + def test_big_positive_sequence(self, capsys): + 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): + 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): + 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): + sf.generate_snowflake_id(0, node_id=1, epoch_ms=-10**12) + out = capsys.readouterr().out + assert "overflows" in out + + def test_no_print_on_success(self, capsys): + sf.generate_snowflake_id(0, node_id=1) + assert capsys.readouterr().out == "" \ No newline at end of file From 0ff10e7c9c0c5e9dae64c56a7e058c4e9ea62661 Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 24 Sep 2026 22:31:45 +0300 Subject: [PATCH 05/25] add pytest in uv.lock and bash-script to fix problems with formatting --- fix_and_test.sh | 21 ++++ pyproject.toml | 11 ++ src/tests/test_snowflake.py | 240 +++++++++++++++++++----------------- uv.lock | 65 +++++++++- 4 files changed, 221 insertions(+), 116 deletions(-) create mode 100755 fix_and_test.sh diff --git a/fix_and_test.sh b/fix_and_test.sh new file mode 100755 index 00000000..5eff8839 --- /dev/null +++ b/fix_and_test.sh @@ -0,0 +1,21 @@ +#!/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 + +echo "==> Pytest" +uv run pytest src/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/tests/test_snowflake.py b/src/tests/test_snowflake.py index fca254a0..509e6c94 100644 --- a/src/tests/test_snowflake.py +++ b/src/tests/test_snowflake.py @@ -1,327 +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_DEFAULT, - TIMESTAMP_BITS, NODE_ID_BITS, SEQUENCE_ID_BITS, - TIMESTAMP_MS_MAX, NODE_ID_MAX, SEQUENCE_ID_MAX, + EPOCH_MS_DEFAULT, + NODE_ID_BITS, + NODE_ID_DEFAULT, + NODE_ID_MAX, + SEQUENCE_ID_BITS, + SEQUENCE_ID_MAX, + TIMESTAMP_BITS, + TIMESTAMP_MS_MAX, ) -from part1 import snowflake as sf # ============================================================ # CONSTANTS — границы и согласованность # ============================================================ + class TestConstants: - def test_bit_widths(self): + 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): - # 1 (sign) + 41 + 10 + 12 = 64, знаковый не используется -> 63 + 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): + 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 + assert NODE_ID_MAX == 2**10 - 1 == 1023 + assert SEQUENCE_ID_MAX == 2**12 - 1 == 4095 - def test_epoch_and_default(self): + def test_epoch_and_default(self) -> None: assert EPOCH_MS_DEFAULT == 1288834974657 assert NODE_ID_DEFAULT == 1 # ============================================================ -# read_current_millis — крайние случаи +# read_current_millis # ============================================================ + class TestReadCurrentMillis: - def test_is_in_milliseconds(self): + 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): - # при epoch=0 функция возвращает Unix-время в мс + 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): - # epoch_ms в будущем -> результат отрицательный + 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): - # с 2010 года точно прошло > 0 мс + def test_default_epoch_yields_positive(self) -> None: assert sf.read_current_millis(EPOCH_MS_DEFAULT) > 0 - def test_tiny_negative_epoch(self): - # отрицательная эпоха не должна падать + def test_tiny_negative_epoch(self) -> None: assert sf.read_current_millis(-1) > 0 # ============================================================ -# decode_* — границы полей, «мусор на входе» +# decode_* # ============================================================ + class TestDecodeNodeId: - def test_zero(self): + def test_zero(self) -> None: assert sf.decode_node_id(0) == 0 - def test_max(self): - # sequence=0, node=NODE_ID_MAX + 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): - # sequence не влияет на node + 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): - # timestamp не влияет на node - sid = (12345 << (NODE_ID_BITS + SEQUENCE_ID_BITS)) \ - | (5 << SEQUENCE_ID_BITS) \ - | 3 + 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): + def test_zero(self) -> None: assert sf.decode_sequence_id(0) == 0 - def test_max(self): + def test_max(self) -> None: assert sf.decode_sequence_id(SEQUENCE_ID_MAX) == SEQUENCE_ID_MAX - def test_only_low_12_bits(self): - # всё, что выше 12 бит, игнорируется + 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): + 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): - # id без timestamp -> возвращаем ровно эпоху + 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): + def test_zero_with_zero_epoch(self) -> None: assert sf.decode_timestamp_ms(0, epoch_ms=0) == 0 - def test_max_timestamp_roundtrip(self): + 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): - # node и sequence не должны влиять на timestamp + 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): + 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 — границы диапазонов +# generate_snowflake_id — валидные границы # ============================================================ + class TestGenerateValidRanges: - def test_min_valid(self): + 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): + 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): - # прогоняем все граничные комбинации + 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): - # при одной и той же мс id с большим sequence больше - # (пока время не перескочило — обычно успевает) + 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) - # если мс не сменилась, b > a; если сменилась — b всё равно > a - # т.к. elapsed только растёт, а sequence мал + 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): + def test_negative(self, capsys: pytest.CaptureFixture[str]) -> None: assert sf.generate_snowflake_id(0, node_id=-1) is None - out = capsys.readouterr().out - assert "node_id must be in" in out + assert "node_id must be in" in capsys.readouterr().out - def test_above_max(self, capsys): + 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): + 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): - # node невалиден И sequence невалиден -> жалуемся на node + 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): + 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): + 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): + 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): - # epoch=-1 -> elapsed ~ 1.7e12 + 1 > 2^41-1? 2^41-1 ≈ 2.199e12 - # сейчас ~1.76e12, ещё влезает. Уводим эпоху глубоко в прошлое: - sf.generate_snowflake_id(0, node_id=1, epoch_ms=-10**12) - out = capsys.readouterr().out - assert "overflows" in out + 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): - # elapsed ровно = TIMESTAMP_MS_MAX -> НЕ переполнение - # подбираем epoch так, чтобы elapsed == TIMESTAMP_MS_MAX + 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 мс — допускаем оба исхода + # Гонка в 1 мс допустима: либо валид, либо overflow -> None if sid is not None: assert sid > 0 - def test_one_past_max_overflows(self, capsys): + 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 # заведомо больше + 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): - # невалидный node + переполнение -> сначала node - sf.generate_snowflake_id(0, node_id=-1, epoch_ms=-10**12) + 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 - def test_future_epoch_no_overflow_but_valid(self): - # эпоха в будущем -> elapsed отрицательный, это НЕ overflow - future = int(time.time() * 1000) + 10_000 - sid = sf.generate_snowflake_id(0, node_id=1, epoch_ms=future) - # по заданию не описано поведение для отрицательного elapsed - # но текущая реализация не падает - assert sid is None or isinstance(sid, int) - # ============================================================ # 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, sequence_id): + 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): + 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): + 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): - # id, сгенерированный позже, не меньше предыдущего - ids = [sf.generate_snowflake_id(i, node_id=1) for i in range(10)] - assert all(x is not None for x in ids) - # монотонность по времени + sequence (в пределах одной мс — строго растёт) - for a, b in zip(ids, ids[1:]): + 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): - # изменение node не меняет sequence и timestamp + 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 sf.decode_sequence_id(sid_a) == sf.decode_sequence_id(sid_b) == 100 + 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) # ============================================================ -# «Мусор на входе» — int-подобные значения +# «Мусор на входе» # ============================================================ + class TestGarbageInputs: - def test_bool_is_int_subclass(self): - # True == 1, False == 0 — не должно падать + 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): - assert sf.generate_snowflake_id(0, node_id=-10**18) is 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): + 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) +# Формат stdout-сообщений (TASK.md) # ============================================================ + class TestErrorMessages: - def test_node_message_contains_range(self, capsys): + 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): + 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): - sf.generate_snowflake_id(0, node_id=1, epoch_ms=-10**12) - out = capsys.readouterr().out - assert "overflows" 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): + def test_no_print_on_success(self, capsys: pytest.CaptureFixture[str]) -> None: sf.generate_snowflake_id(0, node_id=1) - assert capsys.readouterr().out == "" \ No newline at end of file + assert capsys.readouterr().out == "" 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" From e999d95b3f0738ad9c923d05f4336f711ad03cef Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 24 Sep 2026 22:55:51 +0300 Subject: [PATCH 06/25] add format ruff fix in script --- fix_and_test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/fix_and_test.sh b/fix_and_test.sh index 5eff8839..a5f48eaa 100755 --- a/fix_and_test.sh +++ b/fix_and_test.sh @@ -14,6 +14,7 @@ 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/tests From f1d8bbb023e74b8601c0e2ed6190fd7cffb91589 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sat, 26 Sep 2026 12:18:27 +0300 Subject: [PATCH 07/25] move test files --- fix_and_test.sh | 2 +- src/{ => part1}/tests/test_snowflake.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{ => part1}/tests/test_snowflake.py (100%) diff --git a/fix_and_test.sh b/fix_and_test.sh index a5f48eaa..4751cec6 100755 --- a/fix_and_test.sh +++ b/fix_and_test.sh @@ -17,6 +17,6 @@ uv run ruff check --fix src uv run ruff format src echo "==> Pytest" -uv run pytest src/tests +uv run pytest src/part1/tests echo "All done." \ No newline at end of file diff --git a/src/tests/test_snowflake.py b/src/part1/tests/test_snowflake.py similarity index 100% rename from src/tests/test_snowflake.py rename to src/part1/tests/test_snowflake.py From 97f246db61161929b4701497ee880a854c7cc4d8 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sat, 26 Sep 2026 12:18:39 +0300 Subject: [PATCH 08/25] add constants --- src/part2/storage.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 From 5923e235932dcaff18648fd935d2fc5a3192edc3 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sat, 26 Sep 2026 12:35:04 +0300 Subject: [PATCH 09/25] add AI generated tests --- fix_and_test.sh | 2 +- src/part2/tests/test_all.py | 459 ++++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/part2/tests/test_all.py diff --git a/fix_and_test.sh b/fix_and_test.sh index 4751cec6..fce2b413 100755 --- a/fix_and_test.sh +++ b/fix_and_test.sh @@ -17,6 +17,6 @@ uv run ruff check --fix src uv run ruff format src echo "==> Pytest" -uv run pytest src/part1/tests +uv run pytest src/part2/tests echo "All done." \ No newline at end of file diff --git a/src/part2/tests/test_all.py b/src/part2/tests/test_all.py new file mode 100644 index 00000000..3f33d4b8 --- /dev/null +++ b/src/part2/tests/test_all.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +from decimal import Decimal + +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 + + +def product( + product_id: int, + name: str, + price: str = "10.00", + quantity: int = 10, +) -> Product: + return ( + product_id, + name, + normalize_price(Decimal(price)), + quantity, + ) + + +# ============================================================ +# storage — константы +# ============================================================ + + +class TestStorageConstants: + def test_indexes(self) -> None: + assert PRODUCT_ID_INDEX == 0 + assert NAME_INDEX == 1 + assert PRICE_INDEX == 2 + assert QUANTITY_INDEX == 3 + + def test_first_product_id(self) -> None: + assert PRODUCT_ID_MIN == 1 + + +# ============================================================ +# utils — точность цены +# ============================================================ + + +class TestPriceConstants: + def test_precision(self) -> None: + assert PRICE_PRECISION == 2 + + def test_step_is_derived(self) -> None: + assert PRICE_STEP == Decimal(1).scaleb(-PRICE_PRECISION) + assert PRICE_STEP == Decimal("0.01") + + +class TestNormalizePrice: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("12.999", "13.00"), + ("2.3451", "2.35"), + ("2.3449", "2.34"), + ("5", "5.00"), + ("0.005", "0.01"), + ("-2.345", "-2.35"), + ], + ) + def test_rounding(self, raw: str, expected: str) -> None: + result = normalize_price(Decimal(raw)) + assert result == Decimal(expected) + assert result.as_tuple().exponent == -PRICE_PRECISION + + def test_always_has_fixed_precision(self) -> None: + result = normalize_price(Decimal(1)) + assert result == Decimal("1.00") + assert result.as_tuple().exponent == -PRICE_PRECISION + + +# ============================================================ +# crud — generate_product_id +# ============================================================ + + +class TestGenerateProductId: + def test_empty_storage(self) -> None: + assert generate_product_id([]) == PRODUCT_ID_MIN + + def test_max_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 + + +# ============================================================ +# crud — create_product +# ============================================================ + + +class TestCreateProduct: + def test_success(self) -> None: + storage: list[Product] = [] + product_id = create_product( + storage, + ("apple", Decimal("10.999"), 7), + ) + + assert product_id == PRODUCT_ID_MIN + assert storage == [ + (PRODUCT_ID_MIN, "apple", Decimal("11.00"), 7), + ] + assert read_product(storage, product_id) == storage[0] + + def test_duplicate_name( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", quantity=1)] + before = list(storage) + + result = create_product( + storage, + ("apple", Decimal(5), 1), + ) + + 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(self) -> None: + record = product(1, "apple", quantity=3) + storage = [record] + + assert read_product(storage, 1) == record + + def test_missing( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage: list[Product] = [] + + assert read_product(storage, 42) is None + + out = capsys.readouterr().out + assert "42" in out + assert "no product" in out + + +# ============================================================ +# crud — update_product +# ============================================================ + + +class TestUpdateProduct: + def test_success(self) -> None: + storage = [product(1, "apple", "10", 5)] + + updated = update_product( + storage, + 1, + ("pear", Decimal("12.345"), 9), + ) + + assert updated == (1, "pear", Decimal("12.35"), 9) + assert storage == [updated] + + def test_missing( + 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_success( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", "10", 5)] + + assert delete_product(storage, 1) == 1 + assert storage == [] + assert read_product(storage, 1) is None + + out = capsys.readouterr().out + assert "1" in out + assert "no product" in out + + def test_missing( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", "10", 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(self) -> None: + storage = [product(1, "apple", "10", 5)] + cart: list[CartLine] = [] + + line = add_to_cart(storage, cart, 1, 3) + + assert line == (1, 3) + assert cart == [(1, 3)] + assert cart[0][LINE_PRODUCT_ID_INDEX] == 1 + assert cart[0][LINE_QUANTITY_INDEX] == 3 + assert storage[0][QUANTITY_INDEX] == 2 + assert storage[0][PRICE_INDEX] == Decimal("10.00") + assert storage[0][NAME_INDEX] == "apple" + + def test_existing_line(self) -> None: + storage = [product(1, "apple", "10", 5)] + cart: list[CartLine] = [(1, 1)] + + line = add_to_cart(storage, cart, 1, 2) + + assert line == (1, 3) + assert cart == [(1, 3)] + assert storage[0][QUANTITY_INDEX] == 3 + + def test_all_stock_is_allowed(self) -> None: + storage = [product(1, "apple", "10", 3)] + cart: list[CartLine] = [] + + line = add_to_cart(storage, cart, 1, 3) + + assert line == (1, 3) + assert storage[0][QUANTITY_INDEX] == 0 + + def test_missing_product( + 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( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", "10", 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(self) -> None: + storage = [product(1, "apple", "10", 2)] + cart: list[CartLine] = [(1, 5)] + + line = remove_from_cart(storage, cart, 1, 3) + + assert line == (1, 2) + assert cart == [(1, 2)] + assert storage[0][QUANTITY_INDEX] == 5 + + def test_full_remove_deletes_line(self) -> None: + storage = [product(1, "apple", "10", 2)] + cart: list[CartLine] = [(1, 4)] + + line = remove_from_cart(storage, cart, 1, 4) + + assert line == (1, 0) + assert cart == [] + assert storage[0][QUANTITY_INDEX] == 6 + + def test_not_in_cart( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", "10", 2)] + cart: list[CartLine] = [] + before = list(storage) + + assert remove_from_cart(storage, cart, 1, 1) is None + assert storage == before + assert cart == [] + + out = capsys.readouterr().out + assert "1" in out + assert "not in the cart" in out + + def test_not_enough_in_cart( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = [product(1, "apple", "10", 2)] + cart: list[CartLine] = [(1, 1)] + before = list(storage) + + assert remove_from_cart(storage, cart, 1, 2) is None + assert storage == before + assert cart == [(1, 1)] + + out = capsys.readouterr().out + assert "cart holds only 1" in out + assert "cannot remove 2" in out + + def test_product_missing_in_storage( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage: list[Product] = [] + cart: list[CartLine] = [(42, 3)] + before = list(cart) + + assert remove_from_cart(storage, cart, 42, 1) is None + assert storage == [] + assert cart == before + + out = capsys.readouterr().out + assert "42" in out + assert "no product" in out + + +# ============================================================ +# Инварианты +# ============================================================ + + +class TestInvariants: + def test_create_then_read(self) -> None: + storage: list[Product] = [] + product_id = create_product( + storage, + ("apple", Decimal("10.999"), 5), + ) + + assert product_id is not None + assert read_product(storage, product_id) == ( + product_id, + "apple", + normalize_price(Decimal("10.999")), + 5, + ) + + def test_delete_then_read_is_none(self) -> None: + storage = [product(1, "apple", "10", 5)] + + assert delete_product(storage, 1) == 1 + assert read_product(storage, 1) is None + + def test_add_then_remove_restores_state(self) -> None: + storage = [product(1, "apple", "10", 5)] + cart: list[CartLine] = [] + initial_storage = list(storage) + initial_cart = list(cart) + + assert add_to_cart(storage, cart, 1, 3) == (1, 3) + assert remove_from_cart(storage, cart, 1, 3) == (1, 0) + + assert storage == initial_storage + assert cart == initial_cart + + def test_quantity_conserved_on_add(self) -> None: + storage = [product(1, "apple", "10", 5)] + cart: list[CartLine] = [] + total_before = storage[0][QUANTITY_INDEX] + + add_to_cart(storage, cart, 1, 3) + + total_after = storage[0][QUANTITY_INDEX] + cart[0][LINE_QUANTITY_INDEX] + assert total_after == total_before + + def test_quantity_conserved_on_remove(self) -> None: + storage = [product(1, "apple", "10", 2)] + cart: list[CartLine] = [(1, 5)] + total_before = storage[0][QUANTITY_INDEX] + cart[0][LINE_QUANTITY_INDEX] + + remove_from_cart(storage, cart, 1, 3) + + total_after = storage[0][QUANTITY_INDEX] + cart[0][LINE_QUANTITY_INDEX] + assert total_after == total_before From 1b1f9223c626eb117581ec352d16385d06eacd11 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sat, 26 Sep 2026 12:43:15 +0300 Subject: [PATCH 10/25] Implement utils.py --- src/part2/utils.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/part2/utils.py b/src/part2/utils.py index ec0348d3..d848125d 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) From 8966ac4f9a344ed4f3e1f197fa6bb68008bbbff7 Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 1 Oct 2026 18:22:46 +0300 Subject: [PATCH 11/25] write remaining functions --- src/part2/crud.py | 64 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/part2/crud.py b/src/part2/crud.py index d026cbcb..06f38c0f 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,18 @@ 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) + return new_record[PRODUCT_ID_INDEX] def read_product(storage: list[Product], product_id: int) -> Product | None: @@ -72,8 +92,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 +122,21 @@ 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) + + return new_record def delete_product(storage: list[Product], product_id: int) -> int | None: @@ -116,5 +152,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 From ebb1f5d3a30be0431cdb801ee08787917415947f Mon Sep 17 00:00:00 2001 From: Nerey Date: Thu, 1 Oct 2026 18:22:54 +0300 Subject: [PATCH 12/25] write all functions in cart --- src/part2/cart.py | 83 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 9 deletions(-) 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 From a1b55666b740fa754930f7214e9f7257bd7c8e59 Mon Sep 17 00:00:00 2001 From: Nerey Date: Fri, 2 Oct 2026 16:04:28 +0300 Subject: [PATCH 13/25] update tests to support any tuple indices --- src/part2/crud.py | 2 + src/part2/tests/test_all.py | 464 ++++++++++++++++++++++++++---------- 2 files changed, 342 insertions(+), 124 deletions(-) diff --git a/src/part2/crud.py b/src/part2/crud.py index 06f38c0f..8c51c49f 100644 --- a/src/part2/crud.py +++ b/src/part2/crud.py @@ -77,6 +77,7 @@ def create_product( } new_record = dict_to_product(dict_record) storage.append(new_record) + storage.sort() return new_record[PRODUCT_ID_INDEX] @@ -135,6 +136,7 @@ def update_product( new_record = dict_to_product(dict_record) storage.remove(record) storage.append(new_record) + storage.sort() return new_record diff --git a/src/part2/tests/test_all.py b/src/part2/tests/test_all.py index 3f33d4b8..5d4013bc 100644 --- a/src/part2/tests/test_all.py +++ b/src/part2/tests/test_all.py @@ -1,6 +1,7 @@ from __future__ import annotations -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, cast import pytest @@ -29,18 +30,65 @@ 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: - return ( - product_id, - name, - normalize_price(Decimal(price)), - quantity, - ) + """ + 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] # ============================================================ @@ -49,50 +97,91 @@ def product( class TestStorageConstants: - def test_indexes(self) -> None: - assert PRODUCT_ID_INDEX == 0 - assert NAME_INDEX == 1 - assert PRICE_INDEX == 2 - assert QUANTITY_INDEX == 3 + def test_first_product_id_is_defined_by_constant(self) -> None: + storage: list[Product] = [] + + product_id_created = create_product( + storage, + ("apple", Decimal(10), 1), + ) - def test_first_product_id(self) -> None: - assert PRODUCT_ID_MIN == 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 — точность цены +# utils — PRICE_STEP / PRICE_PRECISION # ============================================================ class TestPriceConstants: - def test_precision(self) -> None: - assert PRICE_PRECISION == 2 - - def test_step_is_derived(self) -> None: + def test_step_is_derived_from_precision(self) -> None: assert PRICE_STEP == Decimal(1).scaleb(-PRICE_PRECISION) - assert PRICE_STEP == Decimal("0.01") 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"), - ("5", "5.00"), ("0.005", "0.01"), ("-2.345", "-2.35"), ], ) - def test_rounding(self, raw: str, expected: str) -> None: + 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 - def test_always_has_fixed_precision(self) -> None: - result = normalize_price(Decimal(1)) - assert result == Decimal("1.00") + assert result == Decimal(expected) assert result.as_tuple().exponent == -PRICE_PRECISION @@ -102,17 +191,28 @@ def test_always_has_fixed_precision(self) -> None: class TestGenerateProductId: - def test_empty_storage(self) -> None: + def test_empty_storage_returns_minimum(self) -> None: assert generate_product_id([]) == PRODUCT_ID_MIN - def test_max_plus_one(self) -> None: + 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 @@ -120,20 +220,31 @@ def test_max_plus_one(self) -> None: class TestCreateProduct: - def test_success(self) -> None: + def test_success_adds_normalized_product(self) -> None: storage: list[Product] = [] - product_id = create_product( + + created_id = create_product( storage, ("apple", Decimal("10.999"), 7), ) - assert product_id == PRODUCT_ID_MIN - assert storage == [ - (PRODUCT_ID_MIN, "apple", Decimal("11.00"), 7), - ] - assert read_product(storage, product_id) == storage[0] + 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_duplicate_name( + 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: @@ -142,7 +253,7 @@ def test_duplicate_name( result = create_product( storage, - ("apple", Decimal(5), 1), + ("apple", Decimal(5), 10), ) assert result is None @@ -159,19 +270,21 @@ def test_duplicate_name( class TestReadProduct: - def test_existing(self) -> None: + 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( + def test_missing_product_returns_none_without_mutation( self, capsys: pytest.CaptureFixture[str], ) -> None: - storage: list[Product] = [] + 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 @@ -184,19 +297,41 @@ def test_missing( class TestUpdateProduct: - def test_success(self) -> None: - storage = [product(1, "apple", "10", 5)] + def test_success_changes_name_price_and_quantity_but_keeps_id(self) -> None: + storage = [product(7, "apple", "10", 5)] updated = update_product( storage, - 1, + 7, ("pear", Decimal("12.345"), 9), ) - assert updated == (1, "pear", Decimal("12.35"), 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_missing( + 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: @@ -223,25 +358,31 @@ def test_missing( class TestDeleteProduct: - def test_success( + 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", "10", 5)] + storage = [product(1, "apple", quantity=5)] assert delete_product(storage, 1) == 1 - assert storage == [] - assert read_product(storage, 1) is None + capsys.readouterr() - out = capsys.readouterr().out - assert "1" in out - assert "no product" in out + assert read_product(storage, 1) is None + assert "no product" in capsys.readouterr().out - def test_missing( + def test_missing_product_does_not_change_storage( self, capsys: pytest.CaptureFixture[str], ) -> None: - storage = [product(1, "apple", "10", 5)] + storage = [product(1, "apple", quantity=5)] before = list(storage) assert delete_product(storage, 42) is None @@ -258,40 +399,59 @@ def test_missing( class TestAddToCart: - def test_new_line(self) -> None: + 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 == (1, 3) - assert cart == [(1, 3)] - assert cart[0][LINE_PRODUCT_ID_INDEX] == 1 - assert cart[0][LINE_QUANTITY_INDEX] == 3 - assert storage[0][QUANTITY_INDEX] == 2 - assert storage[0][PRICE_INDEX] == Decimal("10.00") - assert storage[0][NAME_INDEX] == "apple" + 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(self) -> None: - storage = [product(1, "apple", "10", 5)] - cart: list[CartLine] = [(1, 1)] + 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 == (1, 3) - assert cart == [(1, 3)] - assert storage[0][QUANTITY_INDEX] == 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]) == 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", "10", 3)] + storage = [product(1, "apple", quantity=3)] cart: list[CartLine] = [] line = add_to_cart(storage, cart, 1, 3) - assert line == (1, 3) - assert storage[0][QUANTITY_INDEX] == 0 + assert line is not None + assert line_quantity(line) == 3 + assert product_quantity(storage[0]) == 0 - def test_missing_product( + def test_missing_product_returns_none_without_changes( self, capsys: pytest.CaptureFixture[str], ) -> None: @@ -306,11 +466,11 @@ def test_missing_product( assert "42" in out assert "no product" in out - def test_not_enough_stock( + def test_not_enough_stock_returns_none_without_changes( self, capsys: pytest.CaptureFixture[str], ) -> None: - storage = [product(1, "apple", "10", 2)] + storage = [product(1, "apple", quantity=2)] cart: list[CartLine] = [] before = list(storage) @@ -330,69 +490,92 @@ def test_not_enough_stock( class TestRemoveFromCart: - def test_partial_remove(self) -> None: + def test_partial_remove_moves_quantity_back_to_stock(self) -> None: storage = [product(1, "apple", "10", 2)] - cart: list[CartLine] = [(1, 5)] + cart = [cart_line(1, 5)] line = remove_from_cart(storage, cart, 1, 3) - assert line == (1, 2) - assert cart == [(1, 2)] - assert storage[0][QUANTITY_INDEX] == 5 + 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_deletes_line(self) -> None: + def test_full_remove_returns_zero_and_deletes_line(self) -> None: storage = [product(1, "apple", "10", 2)] - cart: list[CartLine] = [(1, 4)] + cart = [cart_line(1, 4)] line = remove_from_cart(storage, cart, 1, 4) - assert line == (1, 0) + assert line is not None + assert line_product_id(line) == 1 + assert line_quantity(line) == 0 assert cart == [] - assert storage[0][QUANTITY_INDEX] == 6 + assert product_quantity(storage[0]) == 6 - def test_not_in_cart( + 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", "10", 2)] + storage = [product(1, "apple", quantity=2)] cart: list[CartLine] = [] - before = list(storage) + before_storage = list(storage) + before_cart = list(cart) assert remove_from_cart(storage, cart, 1, 1) is None - assert storage == before - assert cart == [] + 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( + def test_not_enough_in_cart_returns_none_without_changes( self, capsys: pytest.CaptureFixture[str], ) -> None: - storage = [product(1, "apple", "10", 2)] - cart: list[CartLine] = [(1, 1)] - before = list(storage) + 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 - assert cart == [(1, 1)] + 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( + def test_product_missing_in_storage_returns_none_without_changes( self, capsys: pytest.CaptureFixture[str], ) -> None: storage: list[Product] = [] - cart: list[CartLine] = [(42, 3)] - before = list(cart) + cart = [cart_line(42, 3)] + before_cart = list(cart) assert remove_from_cart(storage, cart, 42, 1) is None assert storage == [] - assert cart == before + assert cart == before_cart out = capsys.readouterr().out assert "42" in out @@ -400,60 +583,93 @@ def test_product_missing_in_storage( # ============================================================ -# Инварианты +# Invariants # ============================================================ class TestInvariants: def test_create_then_read(self) -> None: storage: list[Product] = [] - product_id = create_product( + + created_id = create_product( storage, ("apple", Decimal("10.999"), 5), ) - assert product_id is not None - assert read_product(storage, product_id) == ( - product_id, - "apple", - normalize_price(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) -> None: - storage = [product(1, "apple", "10", 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_state(self) -> None: + 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) - assert add_to_cart(storage, cart, 1, 3) == (1, 3) - assert remove_from_cart(storage, cart, 1, 3) == (1, 0) + 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_conserved_on_add(self) -> None: - storage = [product(1, "apple", "10", 5)] + def test_quantity_is_conserved_on_add(self) -> None: + storage = [product(1, "apple", quantity=5)] cart: list[CartLine] = [] - total_before = storage[0][QUANTITY_INDEX] + total_before = product_quantity(storage[0]) - add_to_cart(storage, cart, 1, 3) + line = add_to_cart(storage, cart, 1, 3) - total_after = storage[0][QUANTITY_INDEX] + cart[0][LINE_QUANTITY_INDEX] + assert line is not None + total_after = product_quantity(storage[0]) + line_quantity(line) assert total_after == total_before - def test_quantity_conserved_on_remove(self) -> None: - storage = [product(1, "apple", "10", 2)] - cart: list[CartLine] = [(1, 5)] - total_before = storage[0][QUANTITY_INDEX] + cart[0][LINE_QUANTITY_INDEX] + 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]) - remove_from_cart(storage, cart, 1, 3) + line = remove_from_cart(storage, cart, 1, 3) - total_after = storage[0][QUANTITY_INDEX] + cart[0][LINE_QUANTITY_INDEX] + 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")) From 236b80c3d9f7b72d98dcee2ac0a733937c3baa2c Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 11 Oct 2026 14:47:51 +0300 Subject: [PATCH 14/25] add AI tests --- fix_and_test.sh | 2 +- src/part3/tests/test_part3.py | 624 ++++++++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 src/part3/tests/test_part3.py diff --git a/fix_and_test.sh b/fix_and_test.sh index fce2b413..4f19caf4 100755 --- a/fix_and_test.sh +++ b/fix_and_test.sh @@ -17,6 +17,6 @@ uv run ruff check --fix src uv run ruff format src echo "==> Pytest" -uv run pytest src/part2/tests +uv run pytest src/part3/tests echo "All done." \ No newline at end of file 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] From 0d257887fe85f32b19b7e7d86fc692e0dbe47a75 Mon Sep 17 00:00:00 2001 From: Nerey Date: Mon, 12 Oct 2026 14:49:38 +0300 Subject: [PATCH 15/25] rename test names --- src/part1/tests/{test_snowflake.py => test_part1.py} | 0 src/part2/tests/{test_all.py => test_part2.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/part1/tests/{test_snowflake.py => test_part1.py} (100%) rename src/part2/tests/{test_all.py => test_part2.py} (100%) diff --git a/src/part1/tests/test_snowflake.py b/src/part1/tests/test_part1.py similarity index 100% rename from src/part1/tests/test_snowflake.py rename to src/part1/tests/test_part1.py diff --git a/src/part2/tests/test_all.py b/src/part2/tests/test_part2.py similarity index 100% rename from src/part2/tests/test_all.py rename to src/part2/tests/test_part2.py From 7b6ad2170bc4e2acfa39c190fac5fd35d82f5284 Mon Sep 17 00:00:00 2001 From: Nerey Date: Mon, 12 Oct 2026 14:50:09 +0300 Subject: [PATCH 16/25] complete TODO functions in crud, storage, utils --- src/part3/crud.py | 79 ++++++++++++++++++++++++++++++++++++++------ src/part3/storage.py | 15 ++++----- src/part3/utils.py | 48 +++++++++++++++------------ 3 files changed, 101 insertions(+), 41 deletions(-) diff --git a/src/part3/crud.py b/src/part3/crud.py index 68811d7e..8d9ba82c 100644 --- a/src/part3/crud.py +++ b/src/part3/crud.py @@ -19,11 +19,14 @@ """ 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 @@ -40,9 +43,14 @@ 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( storage: list[Product], fields: tuple[str, Decimal, int] @@ -64,8 +72,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 +106,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 +139,31 @@ 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]: + 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 +179,10 @@ 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/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/utils.py b/src/part3/utils.py index 318ded0b..45f05f7e 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,24 @@ 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 + ) + ]) From 45c1250c4c09a6c11af16aeaf44d3a5a3d554693 Mon Sep 17 00:00:00 2001 From: Nerey Date: Mon, 12 Oct 2026 16:19:44 +0300 Subject: [PATCH 17/25] add other TODO functions+refactor(Complete task 3) --- src/part3/crud.py | 17 +++++++----- src/part3/run_admin.py | 63 ++++++++++++++++++++++++++++++++++-------- src/part3/utils.py | 32 +++++++++++---------- 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/src/part3/crud.py b/src/part3/crud.py index 8d9ba82c..5b22c9ec 100644 --- a/src/part3/crud.py +++ b/src/part3/crud.py @@ -29,7 +29,7 @@ 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: @@ -49,9 +49,11 @@ def generate_product_id(storage: list[Product]) -> int: 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( storage: list[Product], fields: tuple[str, Decimal, int] ) -> int | None: @@ -73,14 +75,14 @@ def create_product( is left unchanged and an explanatory message is printed. """ 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, @@ -88,7 +90,7 @@ def create_product( QUANTITY_INDEX: fields[2], } new_record = dict_to_product(dict_record) - + storage.append(new_record) storage.sort() return new_record[PRODUCT_ID_INDEX] @@ -148,10 +150,12 @@ def update_product( if not record: return None - if normalized_name in [x[NAME_INDEX] for x in storage]: + 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, @@ -185,4 +189,3 @@ def delete_product(storage: list[Product], product_id: int) -> int | 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/utils.py b/src/part3/utils.py index 45f05f7e..59e4be3a 100644 --- a/src/part3/utils.py +++ b/src/part3/utils.py @@ -80,22 +80,26 @@ def get_storage_str_representation(storage: list[Product]) -> str: """ width = { - header : max([len(header), *(len(str(product[inx])) for product in storage)]) + 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 + 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 + ), + ] + ) From 43b8434cbfdb92cefdbd4f844e5ca0486b43aca2 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 15:18:27 +0300 Subject: [PATCH 18/25] utils.py --- src/part4/utils.py | 54 +++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/part4/utils.py b/src/part4/utils.py index d2a940ea..af54c36d 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, @@ -29,18 +29,10 @@ ) -# Number of fractional digits every stored price is rounded to. -# 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 +45,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, rounding=ROUND_HALF_UP) def normalize_product_name(name: str) -> str: @@ -71,11 +62,7 @@ def normalize_product_name(name: str) -> str: The result is an empty string when ``name`` holds nothing but whitespace. """ - # TODO: реализуйте функцию (используйте своё решение части 3) - return "" - - -# TODO: при необходимости добавьте свои вспомогательные функции + return " ".join(x for x in name.strip().lower().split()) def get_storage_str_representation(storage: dict[str, Product]) -> str: @@ -96,5 +83,28 @@ 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 "" + + 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 + ), + ] + ) \ No newline at end of file From 0d7b76280878164e0f0c88a3ce3fa9b2ef7cbbca Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 15:47:17 +0300 Subject: [PATCH 19/25] AI generated tests + refactor + storage.py --- fix_and_test.sh | 2 +- src/part4/storage.py | 8 +- src/part4/tests/test_part4.py | 526 ++++++++++++++++++++++++++++++++++ src/part4/utils.py | 6 +- 4 files changed, 533 insertions(+), 9 deletions(-) create mode 100644 src/part4/tests/test_part4.py diff --git a/fix_and_test.sh b/fix_and_test.sh index 4f19caf4..7bb86ad4 100755 --- a/fix_and_test.sh +++ b/fix_and_test.sh @@ -17,6 +17,6 @@ uv run ruff check --fix src uv run ruff format src echo "==> Pytest" -uv run pytest src/part3/tests +uv run pytest src/part4/tests echo "All done." \ No newline at end of file diff --git a/src/part4/storage.py b/src/part4/storage.py index 9acedf07..01afb14f 100644 --- a/src/part4/storage.py +++ b/src/part4/storage.py @@ -38,8 +38,6 @@ type Product = dict[str, Any] -# 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/tests/test_part4.py b/src/part4/tests/test_part4.py new file mode 100644 index 00000000..a60b2bc5 --- /dev/null +++ b/src/part4/tests/test_part4.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +import uuid +from decimal import Decimal + +import pytest + +from part4.cart import add_to_cart, remove_from_cart +from part4.crud import ( + create_product, + delete_product, + generate_product_id, + read_product, + update_product, +) +from part4.run_admin import PROMPT, main, run_command +from part4.storage import NAME_KEY, PRICE_KEY, QUANTITY_KEY, Product +from part4.utils import get_storage_str_representation, normalize_price + + +# ============================================================ +# Helpers +# ============================================================ + + +ID_1 = "3f2c9a4e-8b1d-4c7a-9e55-0d6a1b7c2f10" +ID_2 = "8c7d6e5f-4a3b-4c2d-9e1f-0a1b2c3d4e5f" + + +def product(name: str, price: str = "10.00", quantity: int = 10) -> Product: + return { + NAME_KEY: name, + PRICE_KEY: normalize_price(Decimal(price)), + QUANTITY_KEY: quantity, + } + + +def assert_name_index(storage: dict[str, Product], names: set[str]) -> None: + assert names == {item[NAME_KEY] for item in storage.values()} + + +# ============================================================ +# storage and UUID generation +# ============================================================ + + +class TestStorage: + def test_product_is_a_dictionary_with_only_named_fields(self) -> None: + record = product("hammer", "12.34", 5) + + assert record == { + NAME_KEY: "hammer", + PRICE_KEY: Decimal("12.34"), + QUANTITY_KEY: 5, + } + assert set(record) == {"name", "price", "quantity"} + + def test_old_tuple_index_constants_are_gone(self) -> None: + import part4.storage as storage_module + + for constant in ( + "PRODUCT_ID_INDEX", + "NAME_INDEX", + "PRICE_INDEX", + "QUANTITY_INDEX", + "PRODUCT_ID_MIN", + ): + assert not hasattr(storage_module, constant) + + +class TestGenerateProductId: + def test_returns_a_string_containing_a_version_four_uuid(self) -> None: + product_id = generate_product_id() + + assert isinstance(product_id, str) + assert len(product_id) == 36 + assert uuid.UUID(product_id).version == 4 + + def test_consecutive_calls_return_different_ids(self) -> None: + assert generate_product_id() != generate_product_id() + + +# ============================================================ +# CRUD +# ============================================================ + + +class TestCreateProduct: + def test_creates_normalized_record_and_updates_name_index(self) -> None: + storage: dict[str, Product] = {} + names: set[str] = set() + data = { + NAME_KEY: " Cordless Drill ", + PRICE_KEY: Decimal("89.999"), + QUANTITY_KEY: 12, + } + + product_id = create_product(storage, names, data) + + assert product_id is not None + assert uuid.UUID(product_id).version == 4 + assert storage == { + product_id: { + NAME_KEY: "cordless drill", + PRICE_KEY: Decimal("90.00"), + QUANTITY_KEY: 12, + } + } + assert names == {"cordless drill"} + assert set(storage[product_id]) == {NAME_KEY, PRICE_KEY, QUANTITY_KEY} + + @pytest.mark.parametrize( + ("name", "message"), + [ + (" \t\n ", "product name must not be blank\n"), + (" CORDLESS DRILL ", "product name 'cordless drill' is already taken\n"), + ], + ) + def test_rejects_blank_or_duplicate_name_without_mutation( + self, + name: str, + message: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = {ID_1: product("cordless drill")} + names = {"cordless drill"} + before_storage = {key: value.copy() for key, value in storage.items()} + before_names = names.copy() + + result = create_product( + storage, + names, + {NAME_KEY: name, PRICE_KEY: Decimal(20), QUANTITY_KEY: 3}, + ) + + assert result is None + assert storage == before_storage + assert names == before_names + assert capsys.readouterr().out == message + + +class TestReadProduct: + def test_returns_the_record_stored_under_the_id(self) -> None: + record = product("hammer") + storage = {ID_1: record} + + assert read_product(storage, ID_1) is record + + def test_missing_id_prints_error_and_returns_none( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + assert read_product({}, "missing-id") is None + assert capsys.readouterr().out == "no product with id missing-id\n" + + +class TestUpdateProduct: + @pytest.mark.parametrize( + ("changes", "expected"), + [ + ( + {QUANTITY_KEY: 4}, + {NAME_KEY: "hammer", PRICE_KEY: Decimal("10.00"), QUANTITY_KEY: 4}, + ), + ( + {PRICE_KEY: Decimal("12.345")}, + {NAME_KEY: "hammer", PRICE_KEY: Decimal("12.35"), QUANTITY_KEY: 10}, + ), + ( + {NAME_KEY: " RUBBER MALLET "}, + { + NAME_KEY: "rubber mallet", + PRICE_KEY: Decimal("10.00"), + QUANTITY_KEY: 10, + }, + ), + ], + ) + def test_partial_update_preserves_omitted_fields_and_record_identity( + self, + changes: dict[str, object], + expected: dict[str, object], + ) -> None: + record = product("hammer") + storage = {ID_1: record} + names = {"hammer"} + + updated = update_product(storage, names, ID_1, changes) + + assert updated is record + assert storage[ID_1] is record + assert record == expected + assert_name_index(storage, names) + + def test_rename_removes_old_name_and_adds_new_name(self) -> None: + storage = {ID_1: product("hammer"), ID_2: product("saw")} + names = {"hammer", "saw"} + update_fields = {NAME_KEY: " CLAW HAMMER ", PRICE_KEY: Decimal("4.567")} + + updated = update_product(storage, names, ID_1, update_fields) + + assert updated is storage[ID_1] + assert updated == { + NAME_KEY: "claw hammer", + PRICE_KEY: Decimal("4.57"), + QUANTITY_KEY: 10, + } + assert update_fields[NAME_KEY] == "claw hammer" + assert update_fields[PRICE_KEY] == Decimal("4.57") + assert names == {"claw hammer", "saw"} + assert_name_index(storage, names) + + def test_same_normalized_name_is_not_a_duplicate(self) -> None: + storage = {ID_1: product("cordless drill")} + names = {"cordless drill"} + + result = update_product(storage, names, ID_1, {NAME_KEY: " CORDLESS DRILL "}) + + assert result is storage[ID_1] + assert names == {"cordless drill"} + + @pytest.mark.parametrize( + ("product_id", "changes", "message"), + [ + (ID_1, {NAME_KEY: " "}, "product name must not be blank\n"), + (ID_1, {NAME_KEY: "saw"}, "product name 'saw' is already taken\n"), + ("missing-id", {NAME_KEY: " "}, "no product with id missing-id\n"), + ], + ) + def test_failed_update_does_not_change_storage_or_index( + self, + product_id: str, + changes: dict[str, object], + message: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = {ID_1: product("hammer"), ID_2: product("saw")} + names = {"hammer", "saw"} + before_storage = {key: value.copy() for key, value in storage.items()} + + assert update_product(storage, names, product_id, changes) is None + + assert storage == before_storage + assert names == {"hammer", "saw"} + assert capsys.readouterr().out == message + + def test_empty_update_is_a_no_op(self) -> None: + record = product("hammer") + storage = {ID_1: record} + names = {"hammer"} + + assert update_product(storage, names, ID_1, {}) is record + assert storage[ID_1] is record + assert names == {"hammer"} + + +class TestDeleteProduct: + def test_deletes_record_and_removes_its_name_from_index(self) -> None: + storage = {ID_1: product("hammer"), ID_2: product("saw")} + names = {"hammer", "saw"} + + assert delete_product(storage, names, ID_1) == ID_1 + assert ID_1 not in storage + assert names == {"saw"} + assert_name_index(storage, names) + + def test_missing_id_changes_nothing( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = {ID_1: product("hammer")} + names = {"hammer"} + + assert delete_product(storage, names, "missing-id") is None + + assert storage == {ID_1: product("hammer")} + assert names == {"hammer"} + assert capsys.readouterr().out == "no product with id missing-id\n" + + +# ============================================================ +# Table rendering for dictionary storage +# ============================================================ + + +class TestStorageTable: + def test_uses_uuid_keys_and_keeps_dictionary_insertion_order(self) -> None: + storage = { + ID_2: product("second", "2.50", 2), + ID_1: product("first", "1.25", 1), + } + + table = get_storage_str_representation(storage) + + assert table.index(ID_2) < table.index(ID_1) + assert "second" in table and "first" in table + assert "2.50" in table and "1.25" in table + assert not table.endswith("\n") + + def test_empty_dictionary_has_the_expected_empty_table(self) -> None: + assert get_storage_str_representation({}) == ( + "| ID | name | price | quantity |\n|----|------|-------|----------|" + ) + + +# ============================================================ +# Cart dictionary +# ============================================================ + + +class TestAddToCart: + def test_adds_to_existing_quantity_and_decreases_stock(self) -> None: + record = product("hammer", quantity=8) + storage = {ID_1: record} + names = {"hammer"} + cart = {ID_1: 2} + + result = add_to_cart(storage, names, cart, ID_1, 3) + + assert result == 5 + assert cart == {ID_1: 5} + assert record[QUANTITY_KEY] == 5 + assert record[NAME_KEY] == "hammer" + assert record[PRICE_KEY] == Decimal("10.00") + assert names == {"hammer"} + + def test_exact_stock_is_allowed(self) -> None: + storage = {ID_1: product("hammer", quantity=3)} + cart: dict[str, int] = {} + names = {"hammer"} + + assert add_to_cart(storage, names, cart, ID_1, 3) == 3 + assert storage[ID_1][QUANTITY_KEY] == 0 + assert cart == {ID_1: 3} + assert ID_1 in storage + + @pytest.mark.parametrize( + ("product_id", "quantity", "message"), + [ + ("missing-id", 1, "no product with id missing-id\n"), + ( + ID_1, + 4, + f"not enough stock for product {ID_1}: 3 available, 4 requested\n", + ), + ], + ) + def test_failed_add_does_not_change_stock_or_cart( + self, + product_id: str, + quantity: int, + message: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + storage = {ID_1: product("hammer", quantity=3)} + names = {"hammer"} + cart = {ID_2: 2} + before_storage = {key: value.copy() for key, value in storage.items()} + + assert add_to_cart(storage, names, cart, product_id, quantity) is None + + assert storage == before_storage + assert cart == {ID_2: 2} + assert names == {"hammer"} + assert capsys.readouterr().out == message + + +class TestRemoveFromCart: + def test_partial_remove_returns_items_to_stock(self) -> None: + record = product("hammer", quantity=2) + storage = {ID_1: record} + names = {"hammer"} + cart = {ID_1: 5} + + assert remove_from_cart(storage, names, cart, ID_1, 3) == 2 + assert record[QUANTITY_KEY] == 5 + assert cart == {ID_1: 2} + assert names == {"hammer"} + + def test_removing_all_units_deletes_cart_key(self) -> None: + storage = {ID_1: product("hammer", quantity=2)} + names = {"hammer"} + cart = {ID_1: 4} + + assert remove_from_cart(storage, names, cart, ID_1, 4) == 0 + assert cart == {} + assert storage[ID_1][QUANTITY_KEY] == 6 + + @pytest.mark.parametrize( + ("storage", "cart", "product_id", "quantity", "message"), + [ + ({}, {}, ID_1, 1, f"product {ID_1} is not in the cart\n"), + ( + {}, + {ID_1: 1}, + ID_1, + 2, + f"cart holds only 1 unit(s) of product {ID_1}, cannot remove 2\n", + ), + ({}, {ID_1: 2}, ID_1, 1, f"no product with id {ID_1}\n"), + ], + ) + def test_failed_remove_does_not_change_storage_or_cart( + self, + storage: dict[str, Product], + cart: dict[str, int], + product_id: str, + quantity: int, + message: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + names: set[str] = set() + before_cart = cart.copy() + + assert remove_from_cart(storage, names, cart, product_id, quantity) is None + + assert storage == {} + assert cart == before_cart + assert capsys.readouterr().out == message + + +class TestCartInvariants: + def test_add_then_remove_restores_storage_and_cart(self) -> None: + storage = {ID_1: product("hammer", "12.34", 7)} + names = {"hammer"} + cart: dict[str, int] = {} + before = {key: value.copy() for key, value in storage.items()} + + assert add_to_cart(storage, names, cart, ID_1, 4) == 4 + assert remove_from_cart(storage, names, cart, ID_1, 4) == 0 + + assert storage == before + assert cart == {} + assert names == {"hammer"} + + +# ============================================================ +# Admin console — changed signatures and string UUIDs +# ============================================================ + + +class TestRunCommand: + def test_create_update_delete_keep_name_index_in_sync( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + import part4.crud as crud_module + + monkeypatch.setattr(crud_module, "generate_product_id", lambda: ID_1) + storage: dict[str, Product] = {} + names: set[str] = set() + + assert run_command(storage, names, "create Cordless Drill 89.99 12") is True + assert capsys.readouterr().out == f"{ID_1}\n" + assert names == {"cordless drill"} + + assert run_command(storage, names, f"update {ID_1} Claw Hammer 10 5") is True + assert storage[ID_1][NAME_KEY] == "claw hammer" + assert names == {"claw hammer"} + capsys.readouterr() + + assert run_command(storage, names, f"delete {ID_1}") is True + assert storage == {} + assert names == set() + assert capsys.readouterr().out == f"{ID_1}\n" + + def test_read_passes_uuid_argument_as_a_string( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = {ID_1: product("hammer")} + names = {"hammer"} + + assert run_command(storage, names, "read not-a-uuid") is True + + assert capsys.readouterr().out == "no product with id not-a-uuid\n" + + def test_invalid_argument_count_is_rejected( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + storage = {ID_1: product("hammer")} + names = {"hammer"} + + assert run_command(storage, names, "read one two") is True + + assert capsys.readouterr().out == "'read one two' is not a command\n" + assert storage == {ID_1: product("hammer")} + assert names == {"hammer"} + + +class TestAdminMain: + def test_main_keeps_storage_and_name_index_for_the_session( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + commands = iter(["create Cordless Drill 89.99 12", "show", "exit"]) + prompts: list[str] = [] + + def fake_input(prompt: str = "") -> str: + prompts.append(prompt) + return next(commands) + + import part4.crud as crud_module + + monkeypatch.setattr("builtins.input", fake_input) + monkeypatch.setattr(crud_module, "generate_product_id", lambda: ID_1) + + main() + + output = capsys.readouterr().out + assert output.startswith(f"{ID_1}\n") + assert ID_1 in output and "cordless drill" in output + assert prompts == [PROMPT, PROMPT, PROMPT] + + def test_main_reports_invalid_numeric_arguments_and_continues( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + commands = iter(["create hammer not-a-price 4", "exit"]) + + monkeypatch.setattr("builtins.input", lambda _prompt="": next(commands)) + main() + + assert capsys.readouterr().out == ( + "'create hammer not-a-price 4' names a command but its arguments are invalid\n" + ) diff --git a/src/part4/utils.py b/src/part4/utils.py index af54c36d..ae09874b 100644 --- a/src/part4/utils.py +++ b/src/part4/utils.py @@ -32,7 +32,7 @@ PRICE_PRECISION: Final[int] = 2 PRICE_STEP: Final = Decimal(1).scaleb(-PRICE_PRECISION) -TABLE_HEADERS: Final[tuple[str, ...]] = ("ID", "name", "price", "quantity") +TABLE_HEADERS: Final[tuple[str, ...]] = ("ID", NAME_KEY, PRICE_KEY, QUANTITY_KEY) def normalize_price(price: Decimal) -> Decimal: @@ -83,7 +83,7 @@ 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. """ - + width = { header: max([len(header), *(len(str(product[inx])) for product in storage)]) for inx, header in enumerate(TABLE_HEADERS) @@ -107,4 +107,4 @@ def _row(cells: list[str]) -> str: for product in storage ), ] - ) \ No newline at end of file + ) From d31b59a1d480594bd36bb3bd020cd2b7c2ba2dac Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 16:30:39 +0300 Subject: [PATCH 20/25] fix utils.py --- src/part4/utils.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/part4/utils.py b/src/part4/utils.py index ae09874b..e7158eaf 100644 --- a/src/part4/utils.py +++ b/src/part4/utils.py @@ -84,10 +84,11 @@ def get_storage_str_representation(storage: dict[str, Product]) -> str: returned, sized to the header labels. """ - width = { - header: max([len(header), *(len(str(product[inx])) for product in storage)]) - for inx, header in enumerate(TABLE_HEADERS) - } + width = {header : len(header) for header in TABLE_HEADERS} + for key, product in storage.items(): + width[TABLE_HEADERS[0]] = max(width[TABLE_HEADERS[0]], len(key)) + for header in TABLE_HEADERS[1:]: + width[header] = max(width[header], len(str(product[header]))) def _row(cells: list[str]) -> str: return ( @@ -98,13 +99,19 @@ def _row(cells: list[str]) -> str: + "|" ) + separator = ( + "".join(f"|{'-' * (width[header] + 2)}" for header in TABLE_HEADERS) + "|" + ) + return "\n".join( [ _row(list(TABLE_HEADERS)), - "".join(f"|{'-' * (width[header] + 2)}" for header in TABLE_HEADERS) + "|", + + separator, + *( - _row([str(product[inx]) for inx in range(len(TABLE_HEADERS))]) - for product in storage + _row([key] + [x for x in product.values()]) + for key, product in storage.items() ), ] ) From 1777e9b03b495006f8c0e65d76fd7cd6d2ee7ac8 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 22:53:13 +0300 Subject: [PATCH 21/25] cart.py and crud.py + refactor utils.py --- src/part4/cart.py | 61 ++++++++++++++++++++++++++++++----- src/part4/crud.py | 79 ++++++++++++++++++++++++++++++++++++++-------- src/part4/utils.py | 12 +++---- 3 files changed, 124 insertions(+), 28 deletions(-) diff --git a/src/part4/cart.py b/src/part4/cart.py index aed9bb72..6a2e5259 100644 --- a/src/part4/cart.py +++ b/src/part4/cart.py @@ -9,10 +9,11 @@ an explanatory message to stdout and returns ``None``. """ -from .storage import Product - - -# TODO: при необходимости добавьте свои вспомогательные функции +from .crud import read_product, update_product +from .storage import ( + QUANTITY_KEY, + Product, +) def add_to_cart( @@ -46,8 +47,24 @@ def add_to_cart( ``None`` when the product is unknown or the store cannot cover the request. """ - # TODO: реализуйте функцию - return 0 + product = read_product(storage, product_id) + + if not product: + return None + + if product[QUANTITY_KEY] < quantity: + print( + f"not enough stock for product {product_id}: " + f"{product[QUANTITY_KEY]} available, {quantity} requested" + ) + return None + + new_quantity = product[QUANTITY_KEY] - quantity + update_product(storage, product_names, product_id, {QUANTITY_KEY: new_quantity}) + + cart[product_id] = cart.get(product_id, 0) + quantity + + return cart[product_id] def remove_from_cart( @@ -83,5 +100,33 @@ 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 + + cart_quantity = cart.get(product_id) + if cart_quantity is None: + print(f"product {product_id} is not in the cart") + return None + + if cart_quantity < quantity: + print( + f"cart holds only {cart_quantity} unit(s) of product {product_id}, " + f"cannot remove {quantity}" + ) + return None + + product = read_product(storage, product_id) + if product is None: + return None + + new_quantity = cart[product_id] - quantity + cart[product_id] = new_quantity + update_product( + storage, + product_names, + product_id, + {QUANTITY_KEY: product[QUANTITY_KEY] + quantity}, + ) + + if new_quantity == 0: + cart.pop(product_id) + + return new_quantity diff --git a/src/part4/crud.py b/src/part4/crud.py index 471eff00..c26674ac 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,7 @@ def generate_product_id() -> str: Returns: A freshly generated UUID, stringified. """ - # TODO: реализуйте функцию - return "" + return str(uuid.uuid4()) def create_product( @@ -73,8 +72,25 @@ def create_product( and ``product_names`` are left unchanged and an explanatory message is printed. """ - # TODO: реализуйте функцию - return "" + normalized_name = normalize_product_name(fields[NAME_KEY]) + + if not normalized_name: + print("product name must not be blank") + return None + + if normalized_name in product_names: + print(f"product name '{normalized_name}' is already taken") + return None + + product_id = generate_product_id() + storage[product_id] = { + NAME_KEY: normalized_name, + PRICE_KEY: normalize_price(fields[PRICE_KEY]), + QUANTITY_KEY: fields[QUANTITY_KEY], + } + product_names.add(normalized_name) + + return product_id def read_product(storage: dict[str, Product], product_id: str) -> Product | None: @@ -88,8 +104,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 {} + record = storage.get(product_id) + if not record: + print(f"no product with id {product_id}") + return record def update_product( @@ -129,8 +147,38 @@ def update_product( and an explanatory message is printed. Renaming a product to its own current name is allowed. """ - # TODO: реализуйте функцию - return {} + record = read_product(storage, product_id) + if not record: + return None + + new_record: Product = {} + for key in [NAME_KEY, PRICE_KEY, QUANTITY_KEY]: + new_record[key] = record[key] if fields.get(key) is None else fields.get(key) + new_record[NAME_KEY] = normalize_product_name(new_record[NAME_KEY]) + new_record[PRICE_KEY] = normalize_price(new_record[PRICE_KEY]) + + if not new_record[NAME_KEY]: + print("product name must not be blank") + return None + if ( + new_record[NAME_KEY] != record[NAME_KEY] + and new_record[NAME_KEY] in product_names + ): + print(f"product name '{new_record[NAME_KEY]}' is already taken") + return None + + if record[NAME_KEY] != new_record[NAME_KEY]: + product_names.remove(record[NAME_KEY]) + product_names.add(new_record[NAME_KEY]) + + record.update(new_record) + + if NAME_KEY in fields: + fields[NAME_KEY] = new_record[NAME_KEY] + if PRICE_KEY in fields: + fields[PRICE_KEY] = new_record[PRICE_KEY] + + return record def delete_product( @@ -153,5 +201,10 @@ def delete_product( product carried that identifier (``storage`` and ``product_names`` are left unchanged and a message is printed). """ - # TODO: реализуйте функцию - return "" + record = read_product(storage, product_id) + if not record: + return None + + storage.pop(product_id) + product_names.remove(record[NAME_KEY]) + return product_id diff --git a/src/part4/utils.py b/src/part4/utils.py index e7158eaf..804911f1 100644 --- a/src/part4/utils.py +++ b/src/part4/utils.py @@ -62,7 +62,7 @@ def normalize_product_name(name: str) -> str: The result is an empty string when ``name`` holds nothing but whitespace. """ - return " ".join(x for x in name.strip().lower().split()) + return " ".join(name.lower().split()) def get_storage_str_representation(storage: dict[str, Product]) -> str: @@ -84,13 +84,13 @@ def get_storage_str_representation(storage: dict[str, Product]) -> str: returned, sized to the header labels. """ - width = {header : len(header) for header in TABLE_HEADERS} + width = {header: len(header) for header in TABLE_HEADERS} for key, product in storage.items(): width[TABLE_HEADERS[0]] = max(width[TABLE_HEADERS[0]], len(key)) for header in TABLE_HEADERS[1:]: width[header] = max(width[header], len(str(product[header]))) - def _row(cells: list[str]) -> str: + def _row(cells) -> str: return ( "".join( f"| {cell:<{width[header]}} " @@ -102,15 +102,13 @@ def _row(cells: list[str]) -> str: separator = ( "".join(f"|{'-' * (width[header] + 2)}" for header in TABLE_HEADERS) + "|" ) - + return "\n".join( [ _row(list(TABLE_HEADERS)), - separator, - *( - _row([key] + [x for x in product.values()]) + _row([key] + [product[h] for h in TABLE_HEADERS[1:]]) for key, product in storage.items() ), ] From 901f79b3ec289d1d59a66e0f448a3dd83848fea1 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 23:55:43 +0300 Subject: [PATCH 22/25] run_admin.py --- src/part4/run_admin.py | 82 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/src/part4/run_admin.py b/src/part4/run_admin.py index 2247b29b..27d571e1 100644 --- a/src/part4/run_admin.py +++ b/src/part4/run_admin.py @@ -28,22 +28,34 @@ is left untouched and the loop keeps running; only ``exit`` stops it. """ -from decimal import Decimal, InvalidOperation # noqa: F401 -from typing import Final +from decimal import Decimal, InvalidOperation +from typing import Any, 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 - - -# TODO: задайте приглашение и текст справки (используйте своё решение части 3) -PROMPT: Final[str] = "" -HELP_TEXT: Final[str] = "" +from .storage import NAME_KEY, PRICE_KEY, QUANTITY_KEY, Product +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: @@ -53,7 +65,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 +76,16 @@ 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 compress_in_dict(name: str, price: Decimal, quantity: int) -> dict[str, Any]: + return { + NAME_KEY: name, + PRICE_KEY: price, + QUANTITY_KEY: quantity, + } def run_command( @@ -91,8 +112,41 @@ 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 ["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, + product_names, + compress_in_dict( + " ".join(name_parts), Decimal(price), int(quantity) + ), + ) + ) + case ["read", id]: + print_result(read_product(storage, id)) + case ["update", id, *name_parts, price, quantity] if name_parts: + print_result( + update_product( + storage, + product_names, + id, + compress_in_dict( + " ".join(name_parts), Decimal(price), int(quantity) + ), + ) + ) + case ["delete", id]: + print_result(delete_product(storage, product_names, id)) + case _: + print(f"'{line}' is not a command") + return True def main() -> None: From ca652f572ae27ce23030f4c02cb2dfabaaa43bf7 Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 23:55:51 +0300 Subject: [PATCH 23/25] fixes + refactor --- src/part4/cart.py | 2 +- src/part4/crud.py | 40 +++++++++++++++++++++------------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/part4/cart.py b/src/part4/cart.py index 6a2e5259..42464450 100644 --- a/src/part4/cart.py +++ b/src/part4/cart.py @@ -49,7 +49,7 @@ def add_to_cart( """ product = read_product(storage, product_id) - if not product: + if product is None: return None if product[QUANTITY_KEY] < quantity: diff --git a/src/part4/crud.py b/src/part4/crud.py index c26674ac..890b1938 100644 --- a/src/part4/crud.py +++ b/src/part4/crud.py @@ -44,6 +44,20 @@ def generate_product_id() -> str: return str(uuid.uuid4()) +def _validate_name( + name: str, + product_names: set[str], + current_name: str | None = None, +) -> bool: + if not name: + print("product name must not be blank") + return False + if name != current_name and name in product_names: + print(f"product name '{name}' is already taken") + return False + return True + + def create_product( storage: dict[str, Product], product_names: set[str], @@ -74,12 +88,7 @@ def create_product( """ normalized_name = normalize_product_name(fields[NAME_KEY]) - if not normalized_name: - print("product name must not be blank") - return None - - if normalized_name in product_names: - print(f"product name '{normalized_name}' is already taken") + if not _validate_name(normalized_name, product_names): return None product_id = generate_product_id() @@ -105,7 +114,7 @@ def read_product(storage: dict[str, Product], product_id: str) -> Product | None that identifier (a message is printed in that case). """ record = storage.get(product_id) - if not record: + if record is None: print(f"no product with id {product_id}") return record @@ -148,23 +157,16 @@ def update_product( own current name is allowed. """ record = read_product(storage, product_id) - if not record: + if record is None: return None new_record: Product = {} for key in [NAME_KEY, PRICE_KEY, QUANTITY_KEY]: - new_record[key] = record[key] if fields.get(key) is None else fields.get(key) + new_record[key] = fields[key] if key in fields else record[key] new_record[NAME_KEY] = normalize_product_name(new_record[NAME_KEY]) new_record[PRICE_KEY] = normalize_price(new_record[PRICE_KEY]) - if not new_record[NAME_KEY]: - print("product name must not be blank") - return None - if ( - new_record[NAME_KEY] != record[NAME_KEY] - and new_record[NAME_KEY] in product_names - ): - print(f"product name '{new_record[NAME_KEY]}' is already taken") + if not _validate_name(new_record[NAME_KEY], product_names, record[NAME_KEY]): return None if record[NAME_KEY] != new_record[NAME_KEY]: @@ -202,9 +204,9 @@ def delete_product( are left unchanged and a message is printed). """ record = read_product(storage, product_id) - if not record: + if record is None: return None storage.pop(product_id) - product_names.remove(record[NAME_KEY]) + product_names.discard(record[NAME_KEY]) return product_id From d73ac42b5f3ef009acff445786b3f337ee82135a Mon Sep 17 00:00:00 2001 From: Nerey Date: Sun, 1 Nov 2026 23:58:54 +0300 Subject: [PATCH 24/25] mypy fix --- src/part4/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/part4/utils.py b/src/part4/utils.py index 804911f1..f9cf585b 100644 --- a/src/part4/utils.py +++ b/src/part4/utils.py @@ -19,7 +19,7 @@ """ from decimal import ROUND_HALF_UP, Decimal -from typing import Final +from typing import Any, Final from .storage import ( NAME_KEY, @@ -90,7 +90,7 @@ def get_storage_str_representation(storage: dict[str, Product]) -> str: for header in TABLE_HEADERS[1:]: width[header] = max(width[header], len(str(product[header]))) - def _row(cells) -> str: + def _row(cells: list[Any]) -> str: return ( "".join( f"| {cell:<{width[header]}} " From 0b59ed802d0fb9e0dbc67a419a2493656df667ce Mon Sep 17 00:00:00 2001 From: Nerey Date: Mon, 2 Nov 2026 00:03:49 +0300 Subject: [PATCH 25/25] add mypy check in script --- fix_and_test.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fix_and_test.sh b/fix_and_test.sh index 7bb86ad4..49938e01 100755 --- a/fix_and_test.sh +++ b/fix_and_test.sh @@ -19,4 +19,7 @@ uv run ruff format src echo "==> Pytest" uv run pytest src/part4/tests +echo "==> MyPy" +uv run mypy --strict src + echo "All done." \ No newline at end of file