From 1f5fc334e84ba5b7106f4e47cad946d311359a51 Mon Sep 17 00:00:00 2001 From: sawaca96 Date: Fri, 17 Feb 2023 08:22:35 +0900 Subject: [PATCH] =?UTF-8?q?1=EF=B8=8F=E2=83=A31=EF=B8=8F=E2=83=A3=20CHAPTE?= =?UTF-8?q?R=5F11=20external=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- Dockerfile | 3 +- app/allocation/adapters/db.py | 4 +- app/allocation/adapters/redis.py | 18 ++ app/allocation/adapters/repository.py | 1 - app/allocation/constants.py | 2 + app/allocation/domain/commands.py | 1 - app/allocation/domain/events.py | 9 + app/allocation/domain/models.py | 10 +- app/allocation/routers/{main.py => api.py} | 6 +- app/allocation/routers/dependencies.py | 4 +- app/allocation/routers/worker.py | 30 ++ app/allocation/service_layer/handlers.py | 26 +- app/allocation/service_layer/messagebus.py | 4 +- app/allocation/service_layer/unit_of_work.py | 5 +- app/config.py | 7 +- docker-compose.yaml | 29 +- poetry.lock | 299 ++++++++++++++++++- pyproject.toml | 3 + scripts/start-dev.sh | 2 +- scripts/start-worker.sh | 7 + tests/conftest.py | 8 +- tests/e2e/test_api.py | 2 +- tests/e2e/test_external_events.py | 74 +++++ tests/test_main.py | 2 +- tests/unit/test_handlers.py | 14 +- tests/unit/test_product.py | 21 ++ 27 files changed, 546 insertions(+), 47 deletions(-) create mode 100644 app/allocation/adapters/redis.py create mode 100644 app/allocation/constants.py rename app/allocation/routers/{main.py => api.py} (90%) create mode 100644 app/allocation/routers/worker.py create mode 100644 scripts/start-worker.sh create mode 100644 tests/e2e/test_external_events.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56789ef..9af2e43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,4 +19,4 @@ repos: rev: v0.982 hooks: - id: "mypy" - additional_dependencies: ["fastapi", "pytest"] + additional_dependencies: [fastapi, pytest, types-redis] diff --git a/Dockerfile b/Dockerfile index b21b0da..bc00152 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ ENV PYTHONHASHSEED=random \ POETRY_HOME="/opt/poetry" \ POETRY_VIRTUALENVS_CREATE=false \ WORKDIR=/code -ENV PATH="$POETRY_HOME/bin:$PATH" +ENV PATH="$POETRY_HOME/bin:$PATH" \ + PYTHONPATH=$WORKDIR:$PYTHONPATH WORKDIR $WORKDIR FROM base as poetry_installer diff --git a/app/allocation/adapters/db.py b/app/allocation/adapters/db.py index 5380c9e..f9bf320 100644 --- a/app/allocation/adapters/db.py +++ b/app/allocation/adapters/db.py @@ -5,9 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, create_async_engine from sqlalchemy.orm import sessionmaker -from app.config import get_config - -config = get_config() +from app.config import config ENGINE = create_async_engine(config.PG_DSN, echo=False) SESSION_FACTORY = async_scoped_session( diff --git a/app/allocation/adapters/redis.py b/app/allocation/adapters/redis.py new file mode 100644 index 0000000..bce8c53 --- /dev/null +++ b/app/allocation/adapters/redis.py @@ -0,0 +1,18 @@ +# types-redis의 Generic 타입 이슈로 인해 아래와 같이 사용 +# https://github.com/python/typeshed/issues/8242 + +from typing import TYPE_CHECKING + +from redis.asyncio.client import Redis as Redis_ + +from app.config import config + +if TYPE_CHECKING: + Redis = Redis_[bytes] +else: + Redis = Redis_ + + +__all__ = ["Redis"] + +redis = Redis.from_url(config.REDIS_DSN) diff --git a/app/allocation/adapters/repository.py b/app/allocation/adapters/repository.py index 23e0e03..6d0b09e 100644 --- a/app/allocation/adapters/repository.py +++ b/app/allocation/adapters/repository.py @@ -64,7 +64,6 @@ async def _get(self, sku: str) -> models.Product: selectinload(models.Product.batches).options(selectinload(models.Batch.allocations)) ) ) - # TODO: joinedload? return result.scalar_one_or_none() async def _get_by_batch_id(self, batch_id: UUID) -> models.Product: diff --git a/app/allocation/constants.py b/app/allocation/constants.py new file mode 100644 index 0000000..92af11f --- /dev/null +++ b/app/allocation/constants.py @@ -0,0 +1,2 @@ +LINE_ALLOCATED_CHANNEL = "allocation:line_allocated:v1" +BATCH_QUANTITY_CHANGED_CHANNEL = "allocation:batch_quantity_changed:v1" diff --git a/app/allocation/domain/commands.py b/app/allocation/domain/commands.py index 8d10d00..9962c33 100644 --- a/app/allocation/domain/commands.py +++ b/app/allocation/domain/commands.py @@ -23,6 +23,5 @@ class ChangeBatchQuantity(Command): @dataclass class Allocate(Command): - order_id: UUID sku: str qty: int diff --git a/app/allocation/domain/events.py b/app/allocation/domain/events.py index 332de93..72b33d1 100644 --- a/app/allocation/domain/events.py +++ b/app/allocation/domain/events.py @@ -1,10 +1,19 @@ from dataclasses import dataclass +from uuid import UUID class Event: pass +@dataclass +class Allocated(Event): + order_id: UUID + sku: str + qty: int + batch_id: UUID + + @dataclass class OutOfStock(Event): sku: str diff --git a/app/allocation/domain/models.py b/app/allocation/domain/models.py index 008e9dc..5909602 100644 --- a/app/allocation/domain/models.py +++ b/app/allocation/domain/models.py @@ -75,6 +75,14 @@ def allocate(self, line: OrderLine) -> UUID: batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) batch.allocate(line) self.version_number += 1 + self.events.append( + events.Allocated( + order_id=line.id, + sku=line.sku, + qty=line.qty, + batch_id=batch.id, + ) + ) return batch.id except StopIteration: self.events.append(events.OutOfStock(sku=line.sku)) @@ -85,7 +93,7 @@ def change_batch_quantity(self, id: UUID, qty: int) -> None: batch.qty = qty while batch.available_quantity < 0: line = batch.deallocate_one() - self.events.append(commands.Allocate(line.id, line.sku, line.qty)) + self.events.append(commands.Allocate(line.sku, line.qty)) def __hash__(self) -> int: return hash(self.sku) diff --git a/app/allocation/routers/main.py b/app/allocation/routers/api.py similarity index 90% rename from app/allocation/routers/main.py rename to app/allocation/routers/api.py index 73daf9f..04268df 100644 --- a/app/allocation/routers/main.py +++ b/app/allocation/routers/api.py @@ -3,6 +3,7 @@ from fastapi import Body, Depends, FastAPI, HTTPException +from app.allocation.adapters.orm import start_mappers from app.allocation.adapters.repository import AbstractProductRepository from app.allocation.domain import commands from app.allocation.routers.dependencies import batch_uow @@ -11,7 +12,7 @@ from app.allocation.service_layer.unit_of_work import AbstractUnitOfWork app = FastAPI() -# start_mappers() # TODO: 운영환경에서는 실행되어야 함 +start_mappers() @app.get("/") @@ -34,13 +35,12 @@ async def add_batch( @app.post("/allocate", response_model=dict[str, str], status_code=201) async def allocate( - line_id: UUID = Body(), sku: str = Body(), quantity: int = Body(), uow: AbstractUnitOfWork[AbstractProductRepository] = Depends(batch_uow), ) -> dict[str, str]: try: - cmd = commands.Allocate(line_id, sku, quantity) + cmd = commands.Allocate(sku, quantity) results = await messagebus.handle(cmd, uow) batch_id = results[0] except InvalidSku as e: diff --git a/app/allocation/routers/dependencies.py b/app/allocation/routers/dependencies.py index f1a5cd2..fe8bf68 100644 --- a/app/allocation/routers/dependencies.py +++ b/app/allocation/routers/dependencies.py @@ -7,9 +7,7 @@ from app.allocation.adapters.db import DB from app.allocation.adapters.repository import AbstractProductRepository, PGProductRepository from app.allocation.service_layer.unit_of_work import AbstractUnitOfWork, ProductUnitOfWork -from app.config import get_config - -config = get_config() +from app.config import config @functools.lru_cache diff --git a/app/allocation/routers/worker.py b/app/allocation/routers/worker.py new file mode 100644 index 0000000..6007270 --- /dev/null +++ b/app/allocation/routers/worker.py @@ -0,0 +1,30 @@ +import asyncio +from uuid import UUID + +import orjson + +from app.allocation.adapters.orm import start_mappers +from app.allocation.adapters.redis import redis +from app.allocation.constants import BATCH_QUANTITY_CHANGED_CHANNEL +from app.allocation.domain import commands +from app.allocation.service_layer import messagebus, unit_of_work + +start_mappers() + + +async def main() -> None: + pubsub = redis.pubsub(ignore_subscribe_messages=True) + await pubsub.subscribe(BATCH_QUANTITY_CHANGED_CHANNEL) + + async for m in pubsub.listen(): + channel = m["channel"].decode("utf-8") + data = orjson.loads(m["data"]) + if channel == BATCH_QUANTITY_CHANGED_CHANNEL: + await messagebus.handle( + commands.ChangeBatchQuantity(id=UUID(data["id"]), qty=data["qty"]), + uow=unit_of_work.ProductUnitOfWork(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/allocation/service_layer/handlers.py b/app/allocation/service_layer/handlers.py index 69c0dcd..1353520 100644 --- a/app/allocation/service_layer/handlers.py +++ b/app/allocation/service_layer/handlers.py @@ -1,7 +1,11 @@ -from uuid import UUID +from uuid import UUID, uuid4 + +import orjson from app.allocation.adapters import email +from app.allocation.adapters.redis import redis from app.allocation.adapters.repository import AbstractProductRepository +from app.allocation.constants import LINE_ALLOCATED_CHANNEL from app.allocation.domain import commands, events, models from app.allocation.service_layer import unit_of_work @@ -27,7 +31,7 @@ async def allocate( cmd: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork[AbstractProductRepository], ) -> UUID: - line = models.OrderLine(id=cmd.order_id, sku=cmd.sku, qty=cmd.qty) + line = models.OrderLine(id=uuid4(), sku=cmd.sku, qty=cmd.qty) async with uow: product = await uow.repo.get(line.sku) if product is None: @@ -47,7 +51,19 @@ async def change_batch_quantity( await uow.commit() -def send_out_of_stock_notification( - event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork[AbstractProductRepository] -) -> None: +def send_out_of_stock_notification(event: events.OutOfStock) -> None: email.send("stock@made.com", f"Out of stock for {event.sku}") + + +async def publish_allocated_event(event: events.Allocated) -> None: + await redis.publish( + LINE_ALLOCATED_CHANNEL, + orjson.dumps( + dict( + order_id=str(event.order_id), + sku=event.sku, + qty=event.qty, + batch_id=str(event.batch_id), + ) + ), + ) diff --git a/app/allocation/service_layer/messagebus.py b/app/allocation/service_layer/messagebus.py index 8e2a411..af7eda4 100644 --- a/app/allocation/service_layer/messagebus.py +++ b/app/allocation/service_layer/messagebus.py @@ -32,7 +32,9 @@ async def handle_event( ) -> None: try: if isinstance(event, events.OutOfStock): - handlers.send_out_of_stock_notification(event, uow) + handlers.send_out_of_stock_notification(event) + elif isinstance(event, events.Allocated): + await handlers.publish_allocated_event(event) queue.extend(uow.collect_new_events()) except Exception: return diff --git a/app/allocation/service_layer/unit_of_work.py b/app/allocation/service_layer/unit_of_work.py index 3da8c13..2c78ba0 100644 --- a/app/allocation/service_layer/unit_of_work.py +++ b/app/allocation/service_layer/unit_of_work.py @@ -11,9 +11,6 @@ ) from app.allocation.domain.commands import Command from app.allocation.domain.events import Event -from app.config import get_config - -config = get_config() Repo = TypeVar("Repo", bound=AbstractProductRepository) @@ -33,7 +30,7 @@ async def commit(self) -> None: await self._commit() def collect_new_events(self) -> Generator[Event | Command, None, None]: - for product in self.repo._seen: + for product in self.repo.seen: while product.events: yield product.events.pop(0) diff --git a/app/config.py b/app/config.py index 69fe936..60eca27 100644 --- a/app/config.py +++ b/app/config.py @@ -1,12 +1,9 @@ -from functools import lru_cache - from pydantic import BaseSettings class Config(BaseSettings): PG_DSN: str + REDIS_DSN: str -@lru_cache -def get_config() -> Config: - return Config() +config = Config() diff --git a/docker-compose.yaml b/docker-compose.yaml index 40c86a3..c1b8a50 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,7 @@ services: container_name: app depends_on: - db + - redis ports: - 8000:8000 env_file: @@ -19,6 +20,23 @@ services: - python-packages:/usr/local/lib/python3.10/site-packages - python-bin:/usr/local/bin command: /bin/bash scripts/start-dev.sh + + worker: + build: + context: . + dockerfile: Dockerfile + target: dev + restart: always + container_name: worker + depends_on: + - redis + env_file: + - ./secrets/.env + volumes: + - .:/code + - python-packages:/usr/local/lib/python3.10/site-packages + - python-bin:/usr/local/bin + command: /bin/bash scripts/start-worker.sh db: image: postgres @@ -32,10 +50,19 @@ services: POSTGRES_PASSWORD: password volumes: - db-volume:/data/postgres/:/var/lib/postgresql/data - + + redis: + image: redis + restart: always + container_name: redis + ports: + - "6379:6379" + volumes: + - redis-volume:/data/redis/:/data volumes: python-packages: python-bin: db-volume: + redis-volume: diff --git a/poetry.lock b/poetry.lock index 14f9696..d54b132 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,6 +33,18 @@ doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] trio = ["trio (>=0.16,<0.22)"] +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] + [[package]] name = "asyncpg" version = "0.27.0" @@ -201,6 +213,83 @@ files = [ {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, ] +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.3.1" @@ -240,6 +329,52 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "39.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965"}, + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4"}, + {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, + {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5caeb8188c24888c90b5108a441c106f7faa4c4c075a2bcae438c6e8ca73cef"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4789d1e3e257965e960232345002262ede4d094d1a19f4d3b52e48d4d8f3b885"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a"}, + {file = "cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] + [[package]] name = "distlib" version = "0.3.6" @@ -436,6 +571,105 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "hiredis" +version = "2.2.1" +description = "Python wrapper for hiredis" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "hiredis-2.2.1-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:998ab35070dc81806a23be5de837466a51b25e739fb1a0d5313474d5bb29c829"}, + {file = "hiredis-2.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:70db8f514ebcb6f884497c4eee21d0350bbc4102e63502411f8e100cf3b7921e"}, + {file = "hiredis-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a57a4a33a78e94618d026fc68e853d3f71fa4a1d4da7a6e828e927819b001f2d"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:209b94fa473b39e174b665186cad73206ca849cf6e822900b761e83080f67b06"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:58e51d83b42fdcc29780897641b1dcb30c0e4d3c4f6d9d71d79b2cfec99b8eb7"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:706995fb1173fab7f12110fbad00bb95dd0453336f7f0b341b4ca7b1b9ff0bc7"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:812e27a9b20db967f942306267bcd8b1369d7c171831b6f45d22d75576cd01cd"}, + {file = "hiredis-2.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69c32d54ac1f6708145c77d79af12f7448ca1025a0bf912700ad1f0be511026a"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:96745c4cdca261a50bd70c01f14c6c352a48c4d6a78e2d422040fba7919eadef"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:943631a49d7746cd413acaf0b712d030a15f02671af94c54759ba3144351f97a"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:796b616478a5c1cac83e9e10fcd803e746e5a02461bfa7767aebae8b304e2124"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:341952a311654c39433c1e0d8d31c2a0c5864b2675ed159ed264ecaa5cfb225b"}, + {file = "hiredis-2.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6fbb1a56d455602bd6c276d5c316ae245111b2dc8158355112f2d905e7471c85"}, + {file = "hiredis-2.2.1-cp310-cp310-win32.whl", hash = "sha256:14f67987e1d55b197e46729d1497019228ad8c94427bb63500e6f217aa586ca5"}, + {file = "hiredis-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:ea011b3bfa37f2746737860c1e5ba198b63c9b4764e40b042aac7bd2c258938f"}, + {file = "hiredis-2.2.1-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:103bde304d558061c4ba1d7ff94351e761da753c28883fd68964f25080152dac"}, + {file = "hiredis-2.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6ba9f425739a55e1409fda5dafad7fdda91c6dcd2b111ba93bb7b53d90737506"}, + {file = "hiredis-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb59a7535e0b8373f694ce87576c573f533438c5fbee450193333a22118f4a98"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6afbddc82bbb2c4c405d9a49a056ffe6541f8ad3160df49a80573b399f94ba3a"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a386f00800b1b043b091b93850e02814a8b398952438a9d4895bd70f5c80a821"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fec7465caac7b0a36551abb37066221cabf59f776d78fdd58ff17669942b4b41"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd590dd7858d0107c37b438aa27bbcaa0ba77c5b8eda6ebab7acff0aa89f7d7"}, + {file = "hiredis-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1523ec56d711bee863aaaf4325cef4430da3143ec388e60465f47e28818016cd"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d4f6bbe599d255a504ef789c19e23118c654d256343c1ecdf7042fb4b4d0f7fa"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d77dbc13d55c1d45d6a203da910002fffd13fa310af5e9c5994959587a192789"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b2b847ea3f9af99e02c4c58b7cc6714e105c8d73705e5ff1132e9a249391f688"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:18135ecf28fc6577e71c0f8d8eb2f31e4783020a7d455571e7e5d2793374ce20"}, + {file = "hiredis-2.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:724aed63871bc386d6f28b5f4d15490d84934709f093e021c4abb785e72db5db"}, + {file = "hiredis-2.2.1-cp311-cp311-win32.whl", hash = "sha256:497a8837984ddfbf6f5a4c034c0107f2c5aaaebeebf34e2c6ab591acffce5f12"}, + {file = "hiredis-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1776db8af168b22588ec10c3df674897b20cc6d25f093cd2724b8b26d7dac057"}, + {file = "hiredis-2.2.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:49a518b456403602775218062a4dd06bed42b26854ff1ff6784cfee2ef6fa347"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02118dc8545e2371448b9983a0041f12124eea907eb61858f2be8e7c1dfa1e43"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78f2a53149b116e0088f6eda720574f723fbc75189195aab8a7a2a591ca89cab"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e3b8f0eba6d88c2aec63e6d1e38960f8a25c01f9796d32993ffa1cfcf48744c"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38270042f40ed9e576966c603d06c984c80364b0d9ec86962a31551dae27b0cd"}, + {file = "hiredis-2.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a11250dd0521e9f729325b19ce9121df4cbb80ad3468cc21e56803e8380bc4b"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:595474e6c25f1c3c8ec67d587188e7dd47c492829b2c7c5ba1b17ee9e7e9a9ea"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8ad00a7621de8ef9ae1616cf24a53d48ad1a699b96668637559a8982d109a800"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a5e5e51faa7cd02444d4ee1eb59e316c08e974bcfa3a959cb790bc4e9bb616c5"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:0a9493bbc477436a3725e99cfcba768f416ab70ab92956e373d1a3b480b1e204"}, + {file = "hiredis-2.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:231e5836579fc75b25c6f9bb6213950ea3d39aadcfeb7f880211ca55df968342"}, + {file = "hiredis-2.2.1-cp37-cp37m-win32.whl", hash = "sha256:2ed6c948648798b440a9da74db65cdd2ad22f38cf4687f5212df369031394591"}, + {file = "hiredis-2.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c65f38418e35970d44f9b5a59533f0f60f14b9f91b712dba51092d2c74d4dcd1"}, + {file = "hiredis-2.2.1-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:2f6e80fb7cd4cc61af95ab2875801e4c36941a956c183297c3273cbfbbefa9d3"}, + {file = "hiredis-2.2.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:a54d2b3328a2305e0dfb257a4545053fdc64df0c64e0635982e191c846cc0456"}, + {file = "hiredis-2.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:33624903dfb629d6f7c17ed353b4b415211c29fd447f31e6bf03361865b97e68"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f4b92df1e69dc48411045d2117d1d27ec6b5f0dd2b6501759cea2f6c68d5618"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03c6a1f6bf2f64f40d076c997cdfcb8b3d1c9557dda6cb7bbad2c5c839921726"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af3071d33432960cba88ce4e4932b508ab3e13ce41431c2a1b2dc9a988f7627"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb3f56d371b560bf39fe45d29c24e3d819ae2399733e2c86394a34e76adab38"}, + {file = "hiredis-2.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da26970c41084a2ac337a4f075301a78cffb0e0f3df5e98c3049fc95e10725c"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d87f90064106dfd7d2cc7baeb007a8ca289ee985f4bf64bb627c50cdc34208ed"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c233199b9f4dd43e2297577e32ba5fcd0378871a47207bc424d5e5344d030a3e"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:99b5bcadd5e029234f89d244b86bc8d21093be7ac26111068bebd92a4a95dc73"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ed79f65098c4643cb6ec4530b337535f00b58ea02e25180e3df15e9cc9da58dc"}, + {file = "hiredis-2.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7fd6394779c9a3b324b65394deadb949311662f3770bd34f904b8c04328082c"}, + {file = "hiredis-2.2.1-cp38-cp38-win32.whl", hash = "sha256:bde0178e7e6c49e408b8d3a8c0ec8e69a23e8dc2ae29f87af2d74b21025385dc"}, + {file = "hiredis-2.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:6f5f469ba5ae613e4c652cdedfc723aa802329fcc2d65df1e9ab0ac0de34ad9e"}, + {file = "hiredis-2.2.1-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:e5945ef29a76ab792973bef1ffa2970d81dd22edb94dfa5d6cba48beb9f51962"}, + {file = "hiredis-2.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bad6e9a0e31678ee15ac3ef72e77c08177c86df05c37d2423ff3cded95131e51"}, + {file = "hiredis-2.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e57dfcd72f036cce9eab77bc533a932444459f7e54d96a555d25acf2501048be"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3afc76a012b907895e679d1e6bcc6394845d0cc91b75264711f8caf53d7b0f37"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a99c0d50d1a31be285c83301eff4b911dca16aac1c3fe1875c7d6f517a1e9fc4"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8849bc74473778c10377f82cf9a534e240734e2f9a92c181ef6d51b4e3d3eb2"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e199868fe78c2d175bbb7b88f5daf2eae4a643a62f03f8d6736f9832f04f88b"}, + {file = "hiredis-2.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0e98106a28fabb672bb014f6c4506cc67491e4cf9ac56d189cbb1e81a9a3e68"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0f2607e08dcb1c5d1e925c451facbfc357927acaa336a004552c32a6dd68e050"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:954abb363ed1d18dfb7510dbd89402cb7c21106307e04e2ee7bccf35a134f4dd"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0474ab858f5dd15be6b467d89ec14b4c287f53b55ca5455369c3a1a787ef3a24"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b90dd0adb1d659f8c94b32556198af1e61e38edd27fc7434d4b3b68ad4e51d37"}, + {file = "hiredis-2.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a5dac3ae05bc64b233f950edf37dce9c904aedbc7e18cfc2adfb98edb85da46"}, + {file = "hiredis-2.2.1-cp39-cp39-win32.whl", hash = "sha256:19666eb154b7155d043bf941e50d1640125f92d3294e2746df87639cc44a10e6"}, + {file = "hiredis-2.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:c702dd28d52656bb86f7a2a76ea9341ac434810871b51fcd6cd28c6d7490fbdf"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c604919bba041e4c4708ecb0fe6c7c8a92a7f1e886b0ae8d2c13c3e4abfc5eda"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04c972593f26f4769e2be7058b7928179337593bcfc6a8b6bda87eea807b7cbf"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42504e4058246536a9f477f450ab21275126fc5f094be5d5e5290c6de9d855f9"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220b6ac9d3fce60d14ccc34f9790e20a50dc56b6fb747fc357600963c0cf6aca"}, + {file = "hiredis-2.2.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a16d81115128e6a9fc6904de051475be195f6c460c9515583dccfd407b16ff78"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:df6325aade17b1f86c8b87f6a1d9549a4184fda00e27e2fca0e5d2a987130365"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcad9c9239845b29f149a895e7e99b8307889cecbfc37b69924c2dad1f4ae4e8"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ccf6fc116795d76bca72aa301a33874c507f9e77402e857d298c73419b5ea3"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:63f941e77c024be2a1451089e2fdbd5ff450ff0965f49948bbeb383aef1799ea"}, + {file = "hiredis-2.2.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2bb682785a37145b209f44f5d5290b0f9f4b56205542fc592d0f1b3d5ffdfcf0"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8fe289556264cb1a2efbcd3d6b3c55e059394ad01b6afa88151264137f85c352"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96b079c53b6acd355edb6fe615270613f3f7ddc4159d69837ce15ec518925c40"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82ad46d1140c5779cd9dfdafc35f47dd09dadff7654d8001c50bb283da82e7c9"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17e9f363db56a8edb4eff936354cfa273197465bcd970922f3d292032eca87b0"}, + {file = "hiredis-2.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae6b356ed166a0ec663a46b547c988815d2b0e5f2d0af31ef34a16cf3ce705d0"}, + {file = "hiredis-2.2.1.tar.gz", hash = "sha256:d9fbef7f9070055a7cc012ac965e3dbabbf2400b395649ea8d6016dc82a7d13a"}, +] + [[package]] name = "httpcore" version = "0.16.3" @@ -885,6 +1119,18 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pydantic" version = "1.10.4" @@ -1128,6 +1374,26 @@ files = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] +[[package]] +name = "redis" +version = "4.5.1" +description = "Python client for Redis database and key-value store" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-4.5.1-py3-none-any.whl", hash = "sha256:5deb072d26e67d2be1712603bfb7947ec3431fb0eec9c578994052e33035af6d"}, + {file = "redis-4.5.1.tar.gz", hash = "sha256:1eec3741cda408d3a5f84b78d089c8b8d895f21b3b050988351e925faf202864"}, +] + +[package.dependencies] +async-timeout = ">=4.0.2" +hiredis = {version = ">=1.0.0", optional = true, markers = "extra == \"hiredis\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "rfc3986" version = "1.5.0" @@ -1368,6 +1634,37 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "types-pyopenssl" +version = "23.0.0.2" +description = "Typing stubs for pyOpenSSL" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-pyOpenSSL-23.0.0.2.tar.gz", hash = "sha256:2e95f9a667d5eeb0af699196f857f7d23d5b4d642437bd37355bc13a87e9f4ae"}, + {file = "types_pyOpenSSL-23.0.0.2-py3-none-any.whl", hash = "sha256:ea7e5d06f9190a1cb013ad4b13d48896e5cd1e785c04491f38b206d1bc4b8dc1"}, +] + +[package.dependencies] +cryptography = ">=35.0.0" + +[[package]] +name = "types-redis" +version = "4.5.1.0" +description = "Typing stubs for redis" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-redis-4.5.1.0.tar.gz", hash = "sha256:6f6fb1cfeee3708112dec3609a042774f96f2cfcb4709d267c11f51a6976da0a"}, + {file = "types_redis-4.5.1.0-py3-none-any.whl", hash = "sha256:dac6ea398c57a53213b70727be7c8e3a788ded3c3880e94bf74e85c22aa63c7e"}, +] + +[package.dependencies] +cryptography = ">=35.0.0" +types-pyOpenSSL = "*" + [[package]] name = "typing-extensions" version = "4.4.0" @@ -1677,4 +1974,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a0bea5af8a4bec0e0c4d34292ee87a5aeb99682b703e39fb090c23a1eb5d28e9" +content-hash = "2a03e503c50ffaa633267fa16c8db9fc65a0f08415bb3098cd30006fddbbf40d" diff --git a/pyproject.toml b/pyproject.toml index 293bd45..bf18d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ uvicorn = {extras = ["standard"], version = "^0.20.0"} SQLAlchemy = {extras = ["asyncio", "mypy"], version = "^1.4.45"} pydantic = "^1.10.3" asyncpg = "^0.27.0" +redis = {extras = ["hiredis"], version = "^4.5.1"} [tool.poetry.group.dev.dependencies] ruff = "0.0.138" @@ -27,6 +28,7 @@ greenlet = "^2.0.1" aiosqlite = "^0.18.0" pytest-sugar = "^0.9.6" pytest-mock = "^3.10.0" +types-redis = "^4.5.1.0" [build-system] requires = ["poetry-core"] @@ -65,4 +67,5 @@ pretty = true asyncio_mode = "auto" env = [ "D:PG_DSN=postgresql+asyncpg://user:password@localhost:5432/app", # sqlite+aiosqlite:///:memory: + "D:REDIS_DSN=redis://localhost:6379/0" ] \ No newline at end of file diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh index 065de51..e79ba56 100755 --- a/scripts/start-dev.sh +++ b/scripts/start-dev.sh @@ -4,4 +4,4 @@ set -e poetry install --sync - uvicorn app.allocation.routers.main:app --reload --host 0.0.0.0 --port 8000 \ No newline at end of file +uvicorn app.allocation.routers.api:app --reload --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/scripts/start-worker.sh b/scripts/start-worker.sh new file mode 100644 index 0000000..815a01b --- /dev/null +++ b/scripts/start-worker.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +poetry install --sync + +python app/allocation/routers/worker.py \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 4707a9d..fccc0d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,11 @@ import pytest from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from sqlalchemy.orm import clear_mappers from app.allocation.adapters.db import DB -from app.allocation.adapters.orm import metadata, start_mappers -from app.config import get_config +from app.allocation.adapters.orm import metadata +from app.config import config -config = get_config() db = DB(config.PG_DSN) @@ -27,9 +25,7 @@ async def engine() -> AsyncGenerator[AsyncEngine, None]: engine = create_async_engine(config.PG_DSN) async with engine.begin() as conn: await conn.run_sync(metadata.create_all) - start_mappers() yield engine - clear_mappers() async with engine.begin() as conn: await conn.run_sync(metadata.drop_all) await engine.dispose() diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 4f4086e..1f2e361 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import sessionmaker from app.allocation.adapters.orm import metadata -from app.allocation.routers.main import app +from app.allocation.routers.api import app @pytest.fixture diff --git a/tests/e2e/test_external_events.py b/tests/e2e/test_external_events.py new file mode 100644 index 0000000..d35fd4b --- /dev/null +++ b/tests/e2e/test_external_events.py @@ -0,0 +1,74 @@ +from collections.abc import AsyncGenerator +from typing import Any +from uuid import uuid4 + +import orjson +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.allocation.adapters.redis import Redis +from app.allocation.constants import BATCH_QUANTITY_CHANGED_CHANNEL, LINE_ALLOCATED_CHANNEL +from app.allocation.routers.api import app +from app.config import config + + +@pytest.fixture +async def client() -> AsyncGenerator[AsyncClient, Any]: + async with AsyncClient( + app=app, + base_url="http://testserver", + headers={"Content-Type": "application/json"}, + ) as client: + yield client + + +@pytest.fixture +async def rc() -> AsyncGenerator[Redis, None]: + rc = Redis.from_url(config.REDIS_DSN) + yield rc + await rc.flushall() + + +async def test_change_batch_quantity_leading_to_reallocation( + client: AsyncClient, rc: Redis, engine: AsyncEngine +) -> None: + # Given + await client.post( + "/batches", + json={ + "batch_id": "96a3b8cd-f2db-481f-894b-60d6c6bc3c42", + "sku": "SKU", + "quantity": 10, + "eta": "2021-01-01", + }, + ) + await client.post( + "/batches", + json={ + "batch_id": "5a63ca86-94e0-4146-8e72-6d79cf3ae0c2", + "sku": "SKU", + "quantity": 10, + "eta": "2021-01-02", + }, + ) + res = await client.post( + "/allocate", json={"line_id": str(uuid4()), "sku": "SKU", "quantity": 10} + ) + assert res.json() == {"batch_id": "96a3b8cd-f2db-481f-894b-60d6c6bc3c42"} + + pubsub = rc.pubsub() + await pubsub.subscribe(LINE_ALLOCATED_CHANNEL) + confirmation = await pubsub.get_message(timeout=1) + assert confirmation["type"] == "subscribe" + + # When + await rc.publish( + BATCH_QUANTITY_CHANGED_CHANNEL, + orjson.dumps({"id": "96a3b8cd-f2db-481f-894b-60d6c6bc3c42", "qty": 5}), + ) + + # Then + message = await pubsub.get_message(timeout=1) + data = orjson.loads(message["data"]) + assert data["batch_id"] == "5a63ca86-94e0-4146-8e72-6d79cf3ae0c2" diff --git a/tests/test_main.py b/tests/test_main.py index 2d7dd55..67acdbc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,7 @@ @pytest.fixture() async def client() -> AsyncGenerator[TestClient, None]: - from app.allocation.routers.main import app + from app.allocation.routers.api import app yield TestClient(app) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 03af127..103d227 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -49,7 +49,7 @@ class TestAddBatch: async def test_for_new_product(self) -> None: uow = FakeUnitOfWork() await messagebus.handle(commands.CreateBatch(uuid4(), "CRUNCHY-ARMCHAIR", 100), uow) - assert uow.repo.get("CRUNCHY-ARMCHAIR") is not None + assert await uow.repo.get("CRUNCHY-ARMCHAIR") is not None assert uow.committed async def test_for_existing_product(self) -> None: @@ -79,25 +79,25 @@ async def test_returns_allocation(self) -> None: ), uow, ) - results = await messagebus.handle(commands.Allocate(uuid4(), "COMPLICATED-LAMP", 10), uow) + results = await messagebus.handle(commands.Allocate("COMPLICATED-LAMP", 10), uow) assert results.pop(0) == UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617") async def test_errors_for_invalid_sku(self) -> None: uow = FakeUnitOfWork() with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - await messagebus.handle(commands.Allocate(uuid4(), "NONEXISTENTSKU", 10), uow) + await messagebus.handle(commands.Allocate("NONEXISTENTSKU", 10), uow) async def test_commits(self) -> None: uow = FakeUnitOfWork() await messagebus.handle(commands.CreateBatch(uuid4(), "OMINOUS-MIRROR", 100, None), uow) - await messagebus.handle(commands.Allocate(uuid4(), "OMINOUS-MIRROR", 10), uow) + await messagebus.handle(commands.Allocate("OMINOUS-MIRROR", 10), uow) assert uow.committed async def test_sends_email_on_out_of_stock_error(self) -> None: uow = FakeUnitOfWork() await messagebus.handle(commands.CreateBatch(uuid4(), "POPULAR-CURTAINS", 9, None), uow) with mock.patch("app.allocation.adapters.email.send") as mock_send_mail: - await messagebus.handle(commands.Allocate(uuid4(), "POPULAR-CURTAINS", 10), uow) + await messagebus.handle(commands.Allocate("POPULAR-CURTAINS", 10), uow) assert mock_send_mail.call_args == mock.call( "stock@made.com", "Out of stock for POPULAR-CURTAINS" ) @@ -128,8 +128,8 @@ async def test_reallocates_if_necessary(self) -> None: UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), "INDIFFERENT-TABLE", 50, None ), commands.CreateBatch(uuid4(), "INDIFFERENT-TABLE", 50, date.today()), - commands.Allocate(uuid4(), "INDIFFERENT-TABLE", 20), - commands.Allocate(uuid4(), "INDIFFERENT-TABLE", 20), + commands.Allocate("INDIFFERENT-TABLE", 20), + commands.Allocate("INDIFFERENT-TABLE", 20), ] for e in event_history: await messagebus.handle(e, uow) diff --git a/tests/unit/test_product.py b/tests/unit/test_product.py index d2b5bb0..c91450e 100644 --- a/tests/unit/test_product.py +++ b/tests/unit/test_product.py @@ -1,5 +1,7 @@ from datetime import date, timedelta +from uuid import UUID +from app.allocation.domain import events from app.allocation.domain.events import OutOfStock from app.allocation.domain.models import Batch, OrderLine, Product @@ -74,3 +76,22 @@ def test_increment_version_number() -> None: # Then assert product.version_number == 8 + + +def test_outputs_allocated_event() -> None: + # Given + batch = Batch(id=UUID("77ff6655-1a8d-477d-9daf-dfd339d309c5"), sku="RETRO-LAMPSHADE", qty=100) + line = OrderLine(id=UUID("60f44705-15f6-44e7-947f-302109d8cd99"), sku="RETRO-LAMPSHADE", qty=10) + product = Product(sku="RETRO-LAMPSHADE", batches=[batch]) + + # When + product.allocate(line) + + # Then + expected = events.Allocated( + order_id=UUID("60f44705-15f6-44e7-947f-302109d8cd99"), + sku="RETRO-LAMPSHADE", + qty=10, + batch_id=UUID("77ff6655-1a8d-477d-9daf-dfd339d309c5"), + ) + assert product.events[-1] == expected