diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 6485097..e3d5a89 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -31,7 +31,7 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] implementation: [ - {flavor: "other", name: "dynalite"}, + {flavor: "dynalite", name: "dynalite"}, {flavor: "other", name: "dynamodb-local"}, {flavor: "other", name: "localstack"}, {flavor: "scylla", name: "scylla"} diff --git a/docs/development.rst b/docs/development.rst index 6862783..2afe5af 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -10,7 +10,7 @@ Please ensure you have `pre-commit`_ set up so that code formatting is applied a Tests ----- -To run the tests run ``poetry run pytest``. +To run the tests run ``poetry run pytest``. On most systems ``poetry run pytest --numprocesses auto`` will lead to a much faster execution of the test suite. Integration Tests ----------------- @@ -22,9 +22,9 @@ Alternative DynamoDB Implementations Currently, aiodynamo is tested with `dynamodb-local`_, `dynalite`_ and `ScyllaDB Alternator`_. -To test with one or more implementations, set the ``DYNAMODB_URLS`` environment variable. The value of that variable should be a space separated list of ``=`` pairs, where ```` is ``[,]`` with ```` being one of ``real``, ``scylla`` or ``other``. The flavor must be set for `ScyllaDB Alternator`_ as it has a slightly different behavior in ``DescribeTable`` compared to other implementations. +To test with one or more implementations, set the ``DYNAMODB_URLS`` environment variable. The value of that variable should be a space separated list of ``=`` pairs, where ```` is ``[,]`` with ```` being one of ``real``, ``dynalite``, ``scylla`` or ``other``. The flavor must be set for `ScyllaDB Alternator`_ as it has a slightly different behavior in ``DescribeTable`` compared to other implementations and `dynalite`_ as it has some known issues. -For example, to run the tests for all three instances with `dynamodb-local`_ running on port 8001, `dynalite`_ running on port 8002 and `ScyllaDB Alternator`_ running on port 8003, you would set ``DYNAMODB_URLS='dynamodb-local=http://localhost:8001 dynalite=http://localhost:8002 scylla=http://localhost:8003,scylla'`` +For example, to run the tests for all three instances with `dynamodb-local`_ running on port 8001, `dynalite`_ running on port 8002 and `ScyllaDB Alternator`_ running on port 8003, you would set ``DYNAMODB_URLS='dynamodb-local=http://localhost:8001 dynalite=http://localhost:8002,dynalite scylla=http://localhost:8003,scylla'`` Since these alternative implementations still require credentials to be set, set both ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` to some made up value. diff --git a/pyproject.toml b/pyproject.toml index e0962c1..3103060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ httpx = ["httpx"] aiohttp = ["aiohttp"] [tool.poetry.group.dev.dependencies] -pytest = "^6.0" +pytest = "^7.0" pytest-asyncio = "^0.17" pytest-cov = "^2.6" black = "^22.3" @@ -49,6 +49,7 @@ furo = "^2023.9.10" ruff = "^0.0.292" httpx = ">=0.15.0 <1.0.0" aiohttp = "^3.6.2" +pytest-xdist = "^3.6.1" [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 576b66f..8aede18 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -44,6 +44,7 @@ class Flavor(Enum): real = "real" scylla = "scylla" + dynalite = "dynalite" other = "other" @@ -94,13 +95,23 @@ def table_name_prefix() -> str: @pytest.fixture(scope="session") -def real_dynamo(dynamodb_implementation: Implementation) -> bool: - return dynamodb_implementation.flavor is Flavor.real +def flavor(dynamodb_implementation: Implementation) -> Flavor: + return dynamodb_implementation.flavor @pytest.fixture(scope="session") -def scylla(dynamodb_implementation: Implementation) -> bool: - return dynamodb_implementation.flavor is Flavor.scylla +def real_dynamo(flavor: Flavor) -> bool: + return flavor is Flavor.real + + +@pytest.fixture(scope="session") +def scylla(flavor: Flavor) -> bool: + return flavor is Flavor.scylla + + +@pytest.fixture(scope="session") +def dynalite(flavor: Flavor) -> bool: + return flavor is Flavor.dynalite @pytest.fixture() diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py index 851bd29..87f49ef 100644 --- a/tests/integration/test_client.py +++ b/tests/integration/test_client.py @@ -1,7 +1,9 @@ import asyncio +import contextlib import secrets +import typing from operator import itemgetter -from typing import List, Type +from typing import Any, List, Type import pytest from yarl import URL @@ -10,6 +12,7 @@ from aiodynamo.client import Client from aiodynamo.credentials import ChainCredentials from aiodynamo.errors import ( + ConditionalCheckFailed, ItemNotFound, NoCredentialsFound, TableNotFound, @@ -220,6 +223,48 @@ async def test_update_item(client: Client, table: TableName) -> None: } +@pytest.mark.parametrize( + "check,cond,ok", + [ + (False, F("check").equals(False), True), + (False, F("check").is_in([False]), True), + (0, F("check").is_in([0]), True), + (None, F("check").is_in([None]), True), + (False, F("check").equals(True), False), + (False, F("check").is_in([True]), False), + ], + ids=repr, +) +async def test_update_item_condition( + client: Client, + table: TableName, + check: Any, + cond: Condition, + ok: bool, + dynalite: bool, +) -> None: + if dynalite: + pytest.xfail( + "IN condition known to be broken on dynalite: https://github.com/architect/dynalite/pull/159" + ) + key = {"h": "hkv", "r": "rkv"} + item = {**key, "target": 1, "check": check} + await client.put_item(table, item) + ctx: typing.Any = ( + contextlib.nullcontext() if ok else pytest.raises(ConditionalCheckFailed) + ) + with ctx: + updated = await client.update_item( + table, + key, + F("target").add(1), + condition=cond, + return_values=ReturnValues.all_new, + ) + assert updated + assert updated["target"] == 2 + + async def test_delete_item(client: Client, table: TableName) -> None: item = {"h": "h", "r": "r"} await client.put_item(table, item) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 2b51217..9734ef9 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -19,6 +19,28 @@ def test_binary_decode() -> None: } +class XDistReprFix: + """ + This wrapper is needed because some types, such as + `DYNAMODB_CONTEXT.create_decimal` cannot be used in + `pytest.mark.parametrize` because they do not have a + stable `__repr__` (the `__repr__` includes the objects + id which is different in each process), causing + non-deterministic test case order, which pytest-xdist + rejects. + """ + + def __init__(self, ntc: NumericTypeConverter) -> None: + self.ntc = ntc + self.name = ntc.__name__ + + def __repr__(self) -> str: + return self.name + + def __call__(self, value: str) -> Any: + return self.ntc(value) + + @pytest.mark.parametrize( "value,numeric_type,result", [ @@ -26,12 +48,20 @@ def test_binary_decode() -> None: { "N": "1.2", }, - float, + XDistReprFix(float), 1.2, ), - ({"NS": ["1.2"]}, float, {1.2}), - ({"N": "1.2"}, DYNAMODB_CONTEXT.create_decimal, Decimal("1.2")), - ({"NS": ["1.2"]}, DYNAMODB_CONTEXT.create_decimal, {Decimal("1.2")}), + ({"NS": ["1.2"]}, XDistReprFix(float), {1.2}), + ( + {"N": "1.2"}, + XDistReprFix(DYNAMODB_CONTEXT.create_decimal), + Decimal("1.2"), + ), + ( + {"NS": ["1.2"]}, + XDistReprFix(DYNAMODB_CONTEXT.create_decimal), + {Decimal("1.2")}, + ), ], ) def test_numeric_decode(