From 5feaa80c08b2fd65db90a39a619fe4f0bd555385 Mon Sep 17 00:00:00 2001 From: quettabit Date: Sun, 10 Aug 2025 02:43:16 -0600 Subject: [PATCH] initial commit --- .github/workflows/ci.yml | 4 +- pyproject.toml | 12 ++- pytest.ini | 18 ++++ tests/__init__.py | 0 tests/conftest.py | 99 ++++++++++++++++++ tests/test_account_ops.py | 211 ++++++++++++++++++++++++++++++++++++++ tests/test_basin_ops.py | 128 +++++++++++++++++++++++ tests/test_stream_ops.py | 196 +++++++++++++++++++++++++++++++++++ uv.lock | 92 +++++++++++++++++ 9 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_account_ops.py create mode 100644 tests/test_basin_ops.py create mode 100644 tests/test_stream_ops.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e477a2f..9a75e87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,14 @@ jobs: uses: astral-sh/setup-uv@v6 with: version: "0.8.2" + - name: Sync dependencies + run: | + uv sync --all-groups - name: Static code check run: uv run poe ci_checker - name: Check docs build working-directory: ./docs run: | - uv sync --group docs make html - name: Check PR title style uses: actions/github-script@v7 diff --git a/pyproject.toml b/pyproject.toml index 755f69a..0aa04de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,12 @@ dependencies = [ [dependency-groups] dev = ["mypy>=1.14.1", "poethepoet>=0.36.0", "ruff>=0.9.1"] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-timeout>=2.3.0", + "pytest-xdist>=3.5.0", +] docs = [ "enum-tools[sphinx]>=0.12.0", "furo>=2024.8.6", @@ -27,7 +33,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.mypy] -files = ["src/"] +files = ["src/", "tests/", "examples/"] [tool.ruff] exclude = [ @@ -51,3 +57,7 @@ ci_linter = "uv run ruff check" ci_formatter = "uv run ruff format --check" checker = ["linter", "formatter", "type_checker"] ci_checker = ["ci_linter", "ci_formatter", "type_checker"] +e2e_tests = "uv run pytest tests/ -v -s" +e2e_account_tests = "uv run pytest tests/ -v -s -m account" +e2e_basin_tests = "uv run pytest tests/ -v -s -m basin" +e2e_stream_tests = "uv run pytest tests/ -v -s -m stream" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5a18750 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,18 @@ +[pytest] + +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session + +timeout = 300 +timeout_method = thread + +markers = + account: tests for account operations + basin: tests for basin operations + stream: tests for stream operations diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..97c4c05 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,99 @@ +import os +import uuid +from typing import AsyncGenerator, Final + +import pytest +import pytest_asyncio + +from streamstore import S2, Basin, Stream + +pytest_plugins = ["pytest_asyncio"] + + +BASIN_PREFIX: Final[str] = "test-py-sdk" + + +@pytest.fixture(scope="session") +def access_token() -> str: + token = os.getenv("S2_ACCESS_TOKEN") + if not token: + pytest.fail("S2_ACCESS_TOKEN environment variable not set") + return token + + +@pytest.fixture(scope="session") +def basin_prefix() -> str: + return BASIN_PREFIX + + +@pytest_asyncio.fixture(scope="session") +async def s2(access_token: str) -> AsyncGenerator[S2, None]: + async with S2(access_token=access_token) as client: + yield client + + +@pytest.fixture +def basin_name() -> str: + return _basin_name() + + +@pytest.fixture +def basin_names() -> list[str]: + return [_basin_name() for _ in range(3)] + + +@pytest.fixture +def stream_name() -> str: + return _stream_name() + + +@pytest.fixture +def stream_names() -> list[str]: + return [_stream_name() for _ in range(3)] + + +@pytest.fixture +def token_id() -> str: + return f"token-{uuid.uuid4().hex[:8]}" + + +@pytest_asyncio.fixture +async def basin(s2: S2, basin_name: str) -> AsyncGenerator[Basin, None]: + await s2.create_basin( + name=basin_name, + ) + + try: + yield s2.basin(basin_name) + finally: + await s2.delete_basin(basin_name) + + +@pytest_asyncio.fixture(scope="class") +async def shared_basin(s2: S2) -> AsyncGenerator[Basin, None]: + basin_name = _basin_name() + await s2.create_basin(name=basin_name) + + try: + yield s2.basin(basin_name) + finally: + await s2.delete_basin(basin_name) + + +@pytest_asyncio.fixture +async def stream(shared_basin: Basin, stream_name: str) -> AsyncGenerator[Stream, None]: + basin = shared_basin + await basin.create_stream(name=stream_name) + + try: + yield basin.stream(stream_name) + finally: + await basin.delete_stream(stream_name) + + +def _basin_name() -> str: + return f"{BASIN_PREFIX}-{uuid.uuid4().hex[:8]}" + + +def _stream_name() -> str: + return f"stream-{uuid.uuid4().hex[:8]}" diff --git a/tests/test_account_ops.py b/tests/test_account_ops.py new file mode 100644 index 0000000..506ff06 --- /dev/null +++ b/tests/test_account_ops.py @@ -0,0 +1,211 @@ +import time + +import pytest + +from streamstore import S2, Basin +from streamstore.schemas import ( + AccessTokenScope, + BasinConfig, + BasinScope, + BasinState, + Operation, + OperationGroupPermissions, + Permission, + ResourceMatchOp, + ResourceMatchRule, + StorageClass, + StreamConfig, + Timestamping, + TimestampingMode, +) + + +@pytest.mark.account +class TestAccountOperations: + async def test_create_basin(self, s2: S2, basin_name: str): + basin_info = await s2.create_basin(name=basin_name) + + try: + assert basin_info.name == basin_name + assert basin_info.scope == BasinScope.AWS_US_EAST_1 + assert basin_info.state in (BasinState.ACTIVE, BasinState.CREATING) + finally: + await s2.delete_basin(basin_name) + + async def test_create_basin_with_config(self, s2: S2, basin_name: str): + config = BasinConfig( + default_stream_config=StreamConfig( + storage_class=StorageClass.STANDARD, + retention_age=86400 * 7, + timestamping=Timestamping( + mode=TimestampingMode.CLIENT_REQUIRE, + uncapped=True, + ), + delete_on_empty_min_age=3600, + ), + create_stream_on_append=True, + ) + + basin_info = await s2.create_basin(name=basin_name, config=config) + + try: + assert basin_info.name == basin_name + + retrieved_config = await s2.get_basin_config(basin_name) + assert config == retrieved_config + finally: + await s2.delete_basin(basin_name) + + async def test_reconfigure_basin(self, s2: S2, basin: Basin): + config = BasinConfig( + default_stream_config=StreamConfig( + storage_class=StorageClass.STANDARD, + retention_age=3600, + ), + create_stream_on_append=True, + ) + + updated_config = await s2.reconfigure_basin(basin.name, config) + + assert config.default_stream_config is not None + assert ( + updated_config.default_stream_config.storage_class + == config.default_stream_config.storage_class + ) + assert ( + updated_config.default_stream_config.retention_age + == config.default_stream_config.retention_age + ) + assert updated_config.create_stream_on_append == config.create_stream_on_append + + assert ( + updated_config.default_stream_config.timestamping.mode + == TimestampingMode.UNSPECIFIED + ) + + assert updated_config.default_stream_config.delete_on_empty_min_age == 0 + + async def test_list_basins(self, s2: S2, basin_names: list[str]): + basin_infos = [] + try: + for basin_name in basin_names: + stream_info = await s2.create_basin(name=basin_name) + basin_infos.append(stream_info) + + page = await s2.list_basins() + + retrieved_basin_names = [b.name for b in page.items] + assert set(basin_names).issubset(retrieved_basin_names) + + finally: + for basin_info in basin_infos: + await s2.delete_basin(basin_info.name) + + async def test_list_basins_with_limit(self, s2: S2, basin_names: list[str]): + basin_infos = [] + try: + for basin_name in basin_names: + stream_info = await s2.create_basin(name=basin_name) + basin_infos.append(stream_info) + + page = await s2.list_basins(limit=1) + + assert len(page.items) == 1 + + finally: + for basin_info in basin_infos: + await s2.delete_basin(basin_info.name) + + async def test_list_basins_with_prefix(self, s2: S2, basin_name: str): + await s2.create_basin(name=basin_name) + + try: + prefix = basin_name[:5] + page = await s2.list_basins(prefix=prefix) + + basin_names = [b.name for b in page.items] + assert basin_name in basin_names + + for name in basin_names: + assert name.startswith(prefix) + + finally: + await s2.delete_basin(basin_name) + + async def test_issue_access_token(self, s2: S2, token_id: str, basin_prefix: str): + scope = AccessTokenScope( + basins=ResourceMatchRule( + match_op=ResourceMatchOp.PREFIX, value=basin_prefix + ), + streams=ResourceMatchRule(match_op=ResourceMatchOp.PREFIX, value=""), + op_group_perms=OperationGroupPermissions( + basin=Permission.READ, + stream=Permission.READ, + ), + ) + + token = await s2.issue_access_token(id=token_id, scope=scope) + + try: + assert isinstance(token, str) + assert len(token) > 0 + finally: + token_info = await s2.revoke_access_token(token_id) + assert token_info.scope == scope + + async def test_issue_access_token_with_expiry(self, s2: S2, token_id: str): + expires_at = int(time.time()) + 3600 + + scope = AccessTokenScope( + streams=ResourceMatchRule(match_op=ResourceMatchOp.PREFIX, value=""), + ops=[Operation.READ, Operation.CHECK_TAIL], + ) + + token = await s2.issue_access_token( + id=token_id, + scope=scope, + expires_at=expires_at, + ) + + try: + assert isinstance(token, str) + assert len(token) > 0 + + page = await s2.list_access_tokens(prefix=token_id) + + token_info = next((t for t in page.items if t.id == token_id), None) + assert token_info is not None + assert token_info.expires_at == expires_at + assert token_info.scope.streams == scope.streams + assert set(token_info.scope.ops) == set(scope.ops) + + finally: + await s2.revoke_access_token(token_id) + + async def test_issue_access_token_with_auto_prefix(self, s2: S2, token_id: str): + scope = AccessTokenScope( + streams=ResourceMatchRule(match_op=ResourceMatchOp.PREFIX, value="prefix/"), + op_group_perms=OperationGroupPermissions(stream=Permission.READ_WRITE), + ) + + token = await s2.issue_access_token( + id=token_id, + scope=scope, + auto_prefix_streams=True, + ) + + try: + assert isinstance(token, str) + assert len(token) > 0 + + page = await s2.list_access_tokens(prefix=token_id, limit=1) + + assert len(page.items) == 1 + + token_info = page.items[0] + assert token_info is not None + assert token_info.scope == scope + assert token_info.auto_prefix_streams is True + + finally: + await s2.revoke_access_token(token_id) diff --git a/tests/test_basin_ops.py b/tests/test_basin_ops.py new file mode 100644 index 0000000..2e3c59a --- /dev/null +++ b/tests/test_basin_ops.py @@ -0,0 +1,128 @@ +import pytest + +from streamstore import Basin, Stream +from streamstore.schemas import ( + StorageClass, + StreamConfig, + Timestamping, + TimestampingMode, +) + + +@pytest.mark.basin +class TestBasinOperations: + async def test_create_stream(self, shared_basin: Basin, stream_name: str): + basin = shared_basin + + stream_info = await basin.create_stream(name=stream_name) + try: + assert stream_info.name == stream_name + assert stream_info.created_at is not None + assert stream_info.deleted_at is None + + finally: + await basin.delete_stream(stream_name) + + async def test_create_stream_with_config( + self, shared_basin: Basin, stream_name: str + ): + basin = shared_basin + + config = StreamConfig( + storage_class=StorageClass.STANDARD, + retention_age=86400 * 3, + timestamping=Timestamping( + mode=TimestampingMode.ARRIVAL, + uncapped=False, + ), + delete_on_empty_min_age=7200, + ) + + stream_info = await basin.create_stream(name=stream_name, config=config) + try: + assert stream_info.name == stream_name + + retrieved_config = await basin.get_stream_config(stream_name) + assert retrieved_config == config + + finally: + await basin.delete_stream(stream_name) + + async def test_default_stream_config(self, shared_basin: Basin, stream: Stream): + basin = shared_basin + + config = await basin.get_stream_config(stream.name) + assert config.storage_class == StorageClass.EXPRESS + assert config.retention_age == 86400 * 7 + + async def test_reconfigure_stream(self, shared_basin: Basin, stream: Stream): + basin = shared_basin + + config = StreamConfig( + storage_class=StorageClass.STANDARD, + retention_age=86400 * 21, + timestamping=Timestamping( + mode=TimestampingMode.CLIENT_REQUIRE, uncapped=True + ), + delete_on_empty_min_age=1800, + ) + + updated_config = await basin.reconfigure_stream(stream.name, config) + assert updated_config == config + + async def test_list_streams(self, shared_basin: Basin, stream_names: list[str]): + basin = shared_basin + + stream_infos = [] + try: + for stream_name in stream_names: + stream_info = await basin.create_stream(name=stream_name) + stream_infos.append(stream_info) + + page = await basin.list_streams() + + retrieved_stream_names = [s.name for s in page.items] + assert set(stream_names).issubset(retrieved_stream_names) + + finally: + for stream_info in stream_infos: + await basin.delete_stream(stream_info.name) + + async def test_list_streams_with_limit( + self, shared_basin: Basin, stream_names: list[str] + ): + basin = shared_basin + + stream_infos = [] + try: + for stream_name in stream_names: + stream_info = await basin.create_stream(name=stream_name) + stream_infos.append(stream_info) + + page = await basin.list_streams(limit=1) + + assert len(page.items) == 1 + + finally: + for stream_info in stream_infos: + await basin.delete_stream(stream_info.name) + + async def test_list_streams_with_prefix( + self, shared_basin: Basin, stream_name: str + ): + basin = shared_basin + + await basin.create_stream(name=stream_name) + + try: + prefix = stream_name[:5] + page = await basin.list_streams(prefix=prefix) + + stream_names = [s.name for s in page.items] + assert stream_name in stream_names + + for name in stream_names: + assert name.startswith(prefix) + + finally: + await basin.delete_stream(stream_name) diff --git a/tests/test_stream_ops.py b/tests/test_stream_ops.py new file mode 100644 index 0000000..7792e24 --- /dev/null +++ b/tests/test_stream_ops.py @@ -0,0 +1,196 @@ +import asyncio +import time +from typing import AsyncIterable + +import pytest + +from streamstore import Stream +from streamstore.schemas import ( + AppendInput, + ReadLimit, + Record, + SeqNum, + Tail, + TailOffset, + Timestamp, +) +from streamstore.utils import metered_bytes + + +@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.stream +class TestStreamOperations: + async def test_check_tail_empty_stream(self, stream: Stream): + tail = await stream.check_tail() + + assert tail.next_seq_num == 0 + assert tail.last_timestamp == 0 + + async def test_append_single_record(self, stream: Stream): + input = AppendInput(records=[Record(body=b"record-0")]) + output = await stream.append(input) + + assert output.start_seq_num == 0 + assert output.end_seq_num == 1 + assert output.next_seq_num == 1 + assert output.start_timestamp > 0 + assert output.end_timestamp > 0 + assert output.last_timestamp > 0 + + async def test_append_multiple_records(self, stream: Stream): + input = AppendInput( + records=[ + Record(body=f"record-{i}".encode(), headers=[(b"key", b"value")]) + for i in range(3) + ] + ) + output = await stream.append(input) + + assert output.start_seq_num == 0 + assert output.end_seq_num == 3 + assert output.next_seq_num == 3 + + async def test_append_with_match_seq_num(self, stream: Stream): + input_0 = AppendInput(records=[Record(body=b"record-0")]) + output_0 = await stream.append(input_0) + + input_1 = AppendInput( + records=[Record(body=b"record-1")], match_seq_num=output_0.next_seq_num + ) + output_1 = await stream.append(input_1) + + assert output_1.start_seq_num == 1 + assert output_1.end_seq_num == 2 + assert output_1.next_seq_num == 2 + + async def test_append_with_timestamp(self, stream: Stream): + timestamp_1 = int(time.time()) + await asyncio.sleep(0.1) + timestamp_2 = int(time.time()) + + input = AppendInput( + records=[ + Record(body=b"record-0", timestamp=timestamp_1), + Record(body=b"record-1", timestamp=timestamp_2), + ] + ) + output = await stream.append(input) + + assert output.start_seq_num == 0 + assert output.start_timestamp == timestamp_1 + assert output.end_seq_num == 2 + assert output.end_timestamp == timestamp_2 + assert output.next_seq_num == 2 + assert output.last_timestamp == timestamp_2 + + async def test_read_from_seq_num_zero(self, stream: Stream): + await stream.append( + AppendInput(records=[Record(body=f"record-{i}".encode()) for i in range(3)]) + ) + + records = await stream.read(start=SeqNum(0)) + + assert isinstance(records, list) + assert len(records) == 3 + + for i, record in enumerate(records): + assert record.seq_num == i + assert record.body == f"record-{i}".encode() + + async def test_read_with_limit(self, stream: Stream): + await stream.append( + AppendInput(records=[Record(body=f"record-{i}".encode()) for i in range(5)]) + ) + + records = await stream.read(start=SeqNum(0), limit=ReadLimit(count=2)) + + assert isinstance(records, list) + assert len(records) == 2 + + records = await stream.read(start=SeqNum(0), limit=ReadLimit(bytes=20)) + + assert isinstance(records, list) + total_bytes = sum(metered_bytes([r]) for r in records) + assert total_bytes <= 20 + + async def test_read_from_timestamp(self, stream: Stream): + output = await stream.append(AppendInput(records=[Record(body=b"record-0")])) + + records = await stream.read(start=Timestamp(output.start_timestamp)) + + assert isinstance(records, list) + assert len(records) == 1 + + async def test_read_from_tail_offset(self, stream: Stream): + await stream.append( + AppendInput(records=[Record(body=f"record-{i}".encode()) for i in range(5)]) + ) + + records = await stream.read(start=TailOffset(2)) + + assert isinstance(records, list) + assert len(records) == 2 + assert records[0].body == b"record-3" + assert records[1].body == b"record-4" + + async def test_read_beyond_tail(self, stream: Stream): + await stream.append( + AppendInput(records=[Record(body=f"record-{i}".encode()) for i in range(5)]) + ) + + tail = await stream.read(start=SeqNum(100)) + + assert isinstance(tail, Tail) + assert tail.next_seq_num == 5 + + async def test_append_session(self, stream: Stream): + async def inputs_gen() -> AsyncIterable[AppendInput]: + for i in range(3): + records = [ + Record(body=f"batch-{i}-record-{j}".encode()) for j in range(2) + ] + yield AppendInput(records=records) + + outputs = [] + async for output in stream.append_session(inputs_gen()): + outputs.append(output) + + assert len(outputs) == 3 + + exp_seq_num = 0 + for output in outputs: + assert output.start_seq_num == exp_seq_num + exp_seq_num = output.end_seq_num + + async def test_read_session_termination(self, stream: Stream): + await stream.append( + AppendInput(records=[Record(body=f"record-{i}".encode()) for i in range(5)]) + ) + + outputs = [] + async for output in stream.read_session( + start=SeqNum(0), limit=ReadLimit(count=2) + ): + outputs.append(output) + + assert len(outputs) == 1 + + assert isinstance(outputs[0], list) + assert len(outputs[0]) == 2 + + async def test_read_session_tailing(self, stream: Stream): + tail = await stream.check_tail() + + async def producer(): + await asyncio.sleep(0.5) + await stream.append(AppendInput(records=[Record(body=b"record-0")])) + + producer_task = asyncio.create_task(producer()) + + try: + async for output in stream.read_session(start=SeqNum(tail.next_seq_num)): + if isinstance(output, list) and len(output) > 0: + assert output[0].body == b"record-0" + break + finally: + producer_task.cancel() diff --git a/uv.lock b/uv.lock index 21f1d32..7136b8b 100644 --- a/uv.lock +++ b/uv.lock @@ -241,6 +241,15 @@ sphinx = [ { name = "sphinx-toolbox" }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + [[package]] name = "filelock" version = "3.16.1" @@ -383,6 +392,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "jinja2" version = "3.1.5" @@ -619,6 +637,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, ] +[[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 = "poethepoet" version = "0.36.0" @@ -655,6 +682,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pytest" +version = "8.4.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/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1034,6 +1114,12 @@ docs = [ { name = "sphinx" }, { name = "sphinx-autodoc-typehints" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, +] [package.metadata] requires-dist = [ @@ -1057,6 +1143,12 @@ docs = [ { name = "sphinx", specifier = "==8.1.3" }, { name = "sphinx-autodoc-typehints", specifier = ">=3.0.0" }, ] +test = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest-timeout", specifier = ">=2.3.0" }, + { name = "pytest-xdist", specifier = ">=3.5.0" }, +] [[package]] name = "tabulate"