Skip to content

Commit

Permalink
Merge pull request #4 from xoeye/feature/better-item-exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
Peter Gaultney authored Sep 22, 2020
2 parents 20d0242 + ce30bfe commit cb33805
Show file tree
Hide file tree
Showing 14 changed files with 208 additions and 38 deletions.
7 changes: 6 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
### 1.4.0

- Improved DynamoDB Item-related Exceptions for `GetItem`,
`put_but_raise_if_exists`, and `versioned_diffed_update_item`.

### 1.3.3

- Allow any characters for attribute names in `add_variables_to_expression`.
* We have a lot of snake_cased attribute names. We should be able to use this function with those.
- We have a lot of snake_cased attribute names. We should be able to use this function with those.

### 1.3.2

Expand Down
38 changes: 38 additions & 0 deletions tests/xoto3/dynamodb/exceptions_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest

from xoto3.dynamodb.exceptions import (
get_item_exception_type,
raise_if_empty_getitem_response,
ItemNotFoundException,
ItemAlreadyExistsException,
AlreadyExistsException,
)


def test_dynamically_named_exceptions_names_and_caches_different_types():
media_not_found = get_item_exception_type("Media", ItemNotFoundException)

assert media_not_found.__name__ == "MediaNotFoundException"
assert issubclass(media_not_found, ItemNotFoundException)

assert get_item_exception_type("Media", ItemNotFoundException) is media_not_found

media_already_exists = get_item_exception_type("Media", ItemAlreadyExistsException)
assert issubclass(media_already_exists, AlreadyExistsException)
assert media_already_exists.__name__ == "MediaAlreadyExistsException"
assert not issubclass(media_already_exists, ItemNotFoundException)


def test_raises_uses_nicename():
with pytest.raises(ItemNotFoundException) as infe_info:
raise_if_empty_getitem_response(dict(), nicename="Duck")
assert infe_info.value.__class__.__name__ == "DuckNotFoundException"


def test_raises_includes_key_and_table_name():
with pytest.raises(ItemNotFoundException) as infe_info:
raise_if_empty_getitem_response(
dict(), nicename="Plant", table_name="Greenhouse", key=dict(id="p0001")
)
assert infe_info.value.key == dict(id="p0001")
assert infe_info.value.table_name == "Greenhouse"
39 changes: 39 additions & 0 deletions tests/xoto3/dynamodb/put_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import os
import random

import pytest
import boto3

import xoto3.dynamodb.put as xput

from tests.xoto3.dynamodb.testing_utils import make_pytest_put_fixture_for_table


XOTO3_INTEGRATION_TEST_ID_TABLE_NAME = os.environ.get(
"XOTO3_INTEGRATION_TEST_DYNAMODB_ID_TABLE_NAME"
)


_INTEGRATION_ID_TABLE = (
boto3.resource("dynamodb").Table(XOTO3_INTEGRATION_TEST_ID_TABLE_NAME)
if XOTO3_INTEGRATION_TEST_ID_TABLE_NAME
else None
)
integration_table_put = make_pytest_put_fixture_for_table(_INTEGRATION_ID_TABLE)


@pytest.mark.skipif(
not XOTO3_INTEGRATION_TEST_ID_TABLE_NAME, reason="No integration id table was defined"
)
def test_put_already_exists(integration_table_put):

random_key = dict(id=str(random.randint(9999999, 999999999999999999999)))
item = dict(random_key, test_item_please_ignore=True)
integration_table_put(item)

with pytest.raises(xput.ItemAlreadyExistsException) as ae_info:
xput.put_but_raise_if_exists(
_INTEGRATION_ID_TABLE, dict(item, new_attribute="testing attr"), nicename="TestThing"
)

assert ae_info.value.__class__.__name__ == "TestThingAlreadyExistsException"
22 changes: 22 additions & 0 deletions tests/xoto3/dynamodb/testing_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import pytest

from xoto3.dynamodb.put import PutItem
from xoto3.dynamodb.types import TableResource, InputItem
from xoto3.dynamodb.utils.table import extract_key_from_item


def make_pytest_put_fixture_for_table(table: TableResource):
@pytest.fixture
def put_item_fixture():
keys_put = list()

def _put_item(item: InputItem):
keys_put.append(extract_key_from_item(table, item))
PutItem(table, item)

yield _put_item

for key in keys_put:
table.delete_item(Key=key)

return put_item_fixture
12 changes: 9 additions & 3 deletions tests/xoto3/dynamodb/update/versioned_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_versioned_diffed_update_item():

def test_transform(item: Item) -> Item:
item.pop("to_remove")
item["new"] = "value"
item["new"] = "abcxyz"
return item

called_times = [0]
Expand Down Expand Up @@ -148,14 +148,20 @@ def fail_forever_updater_func(
assert set_attrs and "item_version" in set_attrs
raise ClientError({"Error": {"Code": "ConditionalCheckFailedException"}}, "update_item")

with pytest.raises(VersionedUpdateFailure):
with pytest.raises(VersionedUpdateFailure) as ve_info:
versioned_diffed_update_item(
FakeTableResource(),
test_transform,
test_item,
get_item=lambda x, y: test_item,
get_item=lambda x, y: dict(test_item, item_version=called_times[0]),
update_item=fail_forever_updater_func,
)
ve = ve_info.value
assert ve.table_name == "Fake"
assert ve.key == test_item
assert ve.update_arguments["remove_attrs"] == {"to_remove"}
assert ve.update_arguments["set_attrs"]["item_version"] == 25
assert ve.update_arguments["set_attrs"]["new"] == "abcxyz"

assert called_times[0] == DEFAULT_MAX_ATTEMPTS_BEFORE_FAILURE

Expand Down
2 changes: 1 addition & 1 deletion xoto3/__about__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""xoto3"""
__version__ = "1.3.3"
__version__ = "1.4.0"
__author__ = "Peter Gaultney"
__author_email__ = "[email protected]"
3 changes: 2 additions & 1 deletion xoto3/cloudformation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

def _get_cached_stack(stack_name: str):
if stack_name not in _STACKS:
_STACKS[stack_name] = _CF_RESOURCE().Stack(stack_name)
stack = _CF_RESOURCE().Stack(stack_name) # type: ignore
_STACKS[stack_name] = stack
return _STACKS[stack_name]


Expand Down
2 changes: 1 addition & 1 deletion xoto3/cloudwatch/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def __call__(

metric_dict = dict(Namespace=self.namespace, MetricData=[metric_data])
logger.debug("put_metric", extra=dict(put_metric=metric_dict))
CLOUDWATCH_CLIENT().put_metric_data(**metric_dict)
CLOUDWATCH_CLIENT().put_metric_data(**metric_dict) # type: ignore


PutMetricReturner = ty.Callable[..., float]
Expand Down
58 changes: 49 additions & 9 deletions xoto3/dynamodb/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,67 @@
"""Exceptions for our Dynamo usage"""
from typing import Optional, TypeVar, Type, Dict, Tuple
import botocore.exceptions

from .types import ItemKey


class DynamoDbException(Exception):
"""Wrapping error responses from Dynamo DB"""

pass

class DynamoDbItemException(DynamoDbException):
def __init__(self, msg: str, *, key: Optional[ItemKey] = None, table_name: str = "", **kwargs):
self.__dict__.update(kwargs)
self.key = key
self.table_name = table_name
super().__init__(msg)


class AlreadyExistsException(DynamoDbItemException):
"""Deprecated - prefer ItemAlreadyExistsException"""

class AlreadyExistsException(DynamoDbException):
pass

class ItemAlreadyExistsException(AlreadyExistsException):
"""Backwards-compatible, more consistent name"""

class ItemNotFoundException(DynamoDbException):

class ItemNotFoundException(DynamoDbItemException):
"""Being more specific that an item was not found"""


def raise_if_empty_getitem_response(getitem_response: dict, nicename="Item", key=None):
"""Boto3 does not raise any error if the item could not be found"""
X = TypeVar("X", bound=DynamoDbItemException)


_GENERATED_ITEM_EXCEPTION_TYPES: Dict[Tuple[str, str], type] = {
("Item", "ItemNotFoundException"): ItemNotFoundException
}


def get_item_exception_type(item_name: str, base_exc: Type[X]) -> Type[X]:
if not item_name:
return base_exc
base_name = base_exc.__name__
exc_key = (item_name, base_exc.__name__)
if exc_key not in _GENERATED_ITEM_EXCEPTION_TYPES:
exc_minus_Item = base_name[4:] if base_name.startswith("Item") else base_name
_GENERATED_ITEM_EXCEPTION_TYPES[exc_key] = type(
f"{item_name}{exc_minus_Item}", (base_exc,), dict()
)
return _GENERATED_ITEM_EXCEPTION_TYPES[exc_key]


def raise_if_empty_getitem_response(
getitem_response: dict, nicename="Item", key=None, table_name: str = ""
):
"""Boto3 does not raise any error if the item could not be found. This
is not what we want in many cases, and it's convenient to have a
standard way of identifying ItemNotFound.
"""
if "Item" not in getitem_response:
if "id" in key and len(key) == 1:
key = key["id"]
raise ItemNotFoundException(f"{nicename} '{key}' does not exist!")
key_value = next(iter(key.values())) if key and len(key) == 1 else key
raise get_item_exception_type(nicename, ItemNotFoundException)(
f"{nicename} '{key_value}' does not exist!", key=key, table_name=table_name
)


def translate_clienterrors(client_error: botocore.exceptions.ClientError, names_to_messages: dict):
Expand Down
2 changes: 1 addition & 1 deletion xoto3/dynamodb/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def GetItem(Table: TableResource, Key: ItemKey, nicename="Item", **kwargs) -> It
"""
logger.debug(f"Get{nicename} {Key} from Table {Table.name}")
response = Table.get_item(Key={**Key}, **kwargs)
raise_if_empty_getitem_response(response, nicename, key=Key)
raise_if_empty_getitem_response(response, nicename=nicename, key=Key, table_name=Table.name)
return response["Item"]


Expand Down
19 changes: 12 additions & 7 deletions xoto3/dynamodb/put.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,17 @@
from xoto3.errors import catch_named_clienterrors

from .conditions import item_not_exists
from .exceptions import AlreadyExistsException
from .exceptions import ItemAlreadyExistsException, get_item_exception_type
from .types import InputItem, TableResource, Item
from .prewrite import dynamodb_prewrite
from .utils.table import table_primary_keys
from .utils.table import table_primary_keys, extract_key_from_item
from .get import strongly_consistent_get_item

logger = getLogger(__name__)


def PutItem(Table: TableResource, Item: InputItem, *, nicename="Item", **kwargs) -> InputItem:
"""Convenience wrapper that makes your item Dynamo-safe before writing.
"""
"""Convenience wrapper that makes your item Dynamo-safe before writing."""
logger.debug(f"Put{nicename} into table {Table.name}", extra=dict(json=dict(item=Item)))
Table.put_item(Item=dynamodb_prewrite(Item), **kwargs)
return Item
Expand Down Expand Up @@ -53,21 +52,27 @@ def put_unless_exists(Table: TableResource, item: InputItem) -> Tuple[Optional[E
def put_but_raise_if_exists(
Table: TableResource, item: InputItem, *, nicename: str = "Item"
) -> InputItem:
"""Wrapper for put_item that raises AlreadyExistsException if the item exists.
"""Wrapper for put_item that raises ItemAlreadyExistsException if the item exists,
or a custom-generated subclass thereof if you have provided a better "nicename".
If successful, just returns the passed item.
"""
already_exists_cerror, _response = put_unless_exists(Table, item)
if already_exists_cerror:
raise AlreadyExistsException(f"{nicename} already exists and was not overwritten!")
raise get_item_exception_type(nicename, ItemAlreadyExistsException)(
f"{nicename} already exists and was not overwritten!",
key=extract_key_from_item(Table, item),
table_name=Table.name,
)
return item


def put_or_return_existing(table: TableResource, item: InputItem) -> Union[Item, InputItem]:
try:
put_but_raise_if_exists(table, item)
return item
except AlreadyExistsException:
except ItemAlreadyExistsException:
return strongly_consistent_get_item(
table, {key: item[key] for key in table_primary_keys(table)}
)
1 change: 1 addition & 0 deletions xoto3/dynamodb/update/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def logged_update_item(
except Exception as e:
# verbose logging if an error occurs
logger.info("UpdateItem arguments", extra=dict(json=dict(update_args)))
e.update_item_arguments = update_args # type: ignore
raise e


Expand Down
Loading

0 comments on commit cb33805

Please sign in to comment.