bloop/types.py
class String(Type):
    python_type = str
    backing_type = STRING

    def dynamo_load(self, value, *, context, **kwargs):
        if not value:
            return ""
        return value

    def dynamo_dump(self, value, *, context, **kwargs):
        if not value:
            return None
        return value
bloop/session.py
class SessionWrapper:
    """Provides a consistent interface to DynamoDb and DynamoDbStreams clients.

    If either client is None, that client is built using :func:`boto3.client`.

    :param dynamodb: A boto3 client for DynamoDB.  Defaults to ``boto3.client("dynamodb")``.
    :param dynamodbstreams: A boto3 client for DynamoDbStreams.  Defaults to ``boto3.client("dynamodbstreams")``.
    """
    def __init__(self, dynamodb=None, dynamodbstreams=None):
        dynamodb = dynamodb or boto3.client("dynamodb")
        dynamodbstreams = dynamodbstreams or boto3.client("dynamodbstreams")

        self._tables = {}
        self.dynamodb_client = dynamodb
        self.stream_client = dynamodbstreams

    def clear_cache(self):
        """Clear all cached table descriptions."""
        self._tables.clear()

    def save_item(self, item):
        """Save an object to DynamoDB.

        Returns Optional[dict] of read attributes depending on the "ReturnValues" kwarg.
        Return value is None when no attributes were requested.

        :param item: Unpacked into kwargs for :func:`boto3.DynamoDB.Client.update_item`.
        :raises bloop.exceptions.ConstraintViolation: if the condition (or atomic) is not met.
        """
        try:
            resp = self.dynamodb_client.update_item(**item)
            return resp.get("Attributes", None)
        except botocore.exceptions.ClientError as error:
            handle_constraint_violation(error)

    def delete_item(self, item):
        """Delete an object in DynamoDB.

        Returns Optional[dict] of read attributes depending on the "ReturnValues" kwarg.
        Return value is None when no attributes were requested.

        :param item: Unpacked into kwargs for :func:`boto3.DynamoDB.Client.delete_item`.
        :raises bloop.exceptions.ConstraintViolation: if the condition (or atomic) is not met.
        """
        try:
            resp = self.dynamodb_client.delete_item(**item)
            return resp.get("Attributes", None)
        except botocore.exceptions.ClientError as error:
            handle_constraint_violation(error)

    def load_items(self, items):
        """Loads any number of items in chunks, handling continuation tokens.

        :param items: Unpacked in chunks into "RequestItems" for :func:`boto3.DynamoDB.Client.batch_get_item`.
        """
        loaded_items = {}
        requests = collections.deque(create_batch_get_chunks(items))
        while requests:
            request = requests.pop()
            try:
                response = self.dynamodb_client.batch_get_item(RequestItems=request)
            except botocore.exceptions.ClientError as error:
                raise BloopException("Unexpected error while loading items.") from error

            # Accumulate results
            for table_name, table_items in response.get("Responses", {}).items():
                loaded_items.setdefault(table_name, []).extend(table_items)

            # Push additional request onto the deque.
            # "UnprocessedKeys" is {} if this request is done
            if response["UnprocessedKeys"]:
                requests.append(response["UnprocessedKeys"])
        return loaded_items

    def query_items(self, request):
        """Wraps :func:`boto3.DynamoDB.Client.query`.

        Response always includes "Count" and "ScannedCount"

        :param request: Unpacked into :func:`boto3.DynamoDB.Client.query`
        """
        return self.search_items("query", request)

    def scan_items(self, request):
        """Wraps :func:`boto3.DynamoDB.Client.scan`.

        Response always includes "Count" and "ScannedCount"

        :param request: Unpacked into :func:`boto3.DynamoDB.Client.scan`
        """
        return self.search_items("scan", request)

    def search_items(self, mode, request):
        """Invoke query/scan by name.

        Response always includes "Count" and "ScannedCount"

        :param str mode: "query" or "scan"
        :param request: Unpacked into :func:`boto3.DynamoDB.Client.query` or :func:`boto3.DynamoDB.Client.scan`
        """
        validate_search_mode(mode)
        method = getattr(self.dynamodb_client, mode)
        try:
            response = method(**request)
        except botocore.exceptions.ClientError as error:
            raise BloopException("Unexpected error during {}.".format(mode)) from error
        standardize_query_response(response)
        return response

    def create_table(self, table_name, model):
        """Create the model's table.  Returns True if the table is being created, False otherwise.

        Does not wait for the table to create, and does not validate an existing table.
        Will not raise "ResourceInUseException" if the table exists or is being created.

        :param str table_name: The name of the table to create for the model.
        :param model: The :class:`~bloop.models.BaseModel` to create the table for.
        :return: True if the table is being created, False if the table exists
        :rtype: bool
        """
        table = create_table_request(table_name, model)
        try:
            self.dynamodb_client.create_table(**table)
            is_creating = True
        except botocore.exceptions.ClientError as error:
            handle_table_exists(error, model)
            is_creating = False
        return is_creating

    def describe_table(self, table_name):
        """
        Polls until the table is ready, then returns the first result when the table was ready.

        The returned dict is standardized to ensure all fields are present, even when empty or across different
        DynamoDB API versions.
        TTL information is also inserted.

        :param table_name: The name of the table to describe
        :return: The (sanitized) result of DescribeTable["Table"]
        :rtype: dict
        """
        if table_name in self._tables:
            return self._tables[table_name]
        status, description = None, {}
        calls = 0
        while status is not ready:
            calls += 1
            try:
                description = self.dynamodb_client.describe_table(TableName=table_name)["Table"]
            except botocore.exceptions.ClientError as error:
                raise BloopException("Unexpected error while describing table.") from error
            status = simple_table_status(description)
        logger.debug("describe_table: table \"{}\" was in ACTIVE state after {} calls".format(table_name, calls))
        try:
            ttl = self.dynamodb_client.describe_time_to_live(TableName=table_name)
        except botocore.exceptions.ClientError as error:
            raise BloopException("Unexpected error while describing ttl.") from error
        try:
            backups = self.dynamodb_client.describe_continuous_backups(TableName=table_name)
        except botocore.exceptions.ClientError as error:
            raise BloopException("Unexpected error while describing continuous backups.") from error

        description["TimeToLiveDescription"] = {
            "AttributeName": _read_field(ttl, None, "TimeToLiveDescription", "AttributeName"),
            "TimeToLiveStatus": _read_field(ttl, None, "TimeToLiveDescription", "TimeToLiveStatus"),
        }
        description["ContinuousBackupsDescription"] = {
            "ContinuousBackupsStatus": _read_field(
                backups, None, "ContinuousBackupsDescription", "ContinuousBackupsStatus"),
        }

        table = self._tables[table_name] = sanitize_table_description(description)
        return table

    def validate_table(self, table_name, model):
        """Polls until a creating table is ready, then verifies the description against the model's requirements.

        The model may have a subset of all GSIs and LSIs on the table, but the key structure must be exactly
        the same.  The table must have a stream if the model expects one, but not the other way around.  When read or
        write units are not specified for the model or any GSI, the existing values will always pass validation.

        :param str table_name: The name of the table to validate the model against.
        :param model: The :class:`~bloop.models.BaseModel` to validate the table of.
        :raises bloop.exceptions.TableMismatch: When the table does not meet the constraints of the model.
        """
        actual = self.describe_table(table_name)
        if not compare_tables(model, actual):
            raise TableMismatch("The expected and actual tables for {!r} do not match.".format(model.__name__))

        # Fill in values that Meta doesn't know ahead of time (such as arns).
        # These won't be populated unless Meta explicitly cares about the value
        if model.Meta.stream:
            stream_arn = model.Meta.stream["arn"] = actual["LatestStreamArn"]
            logger.debug(f"Set {model.__name__}.Meta.stream['arn'] to '{stream_arn}' from DescribeTable response")
        if model.Meta.ttl:
            ttl_enabled = actual["TimeToLiveDescription"]["TimeToLiveStatus"].lower() == "enabled"
            model.Meta.ttl["enabled"] = ttl_enabled
            logger.debug(f"Set {model.__name__}.Meta.ttl['enabled'] to '{ttl_enabled}' from DescribeTable response")

        # Fill in meta values that the table didn't care about (eg. billing=None)
        if model.Meta.encryption is None:
            sse_enabled = actual["SSEDescription"]["Status"].lower() == "enabled"
            model.Meta.encryption = {"enabled": sse_enabled}
            logger.debug(
                f"Set {model.__name__}.Meta.encryption['enabled'] to '{sse_enabled}' from DescribeTable response")
        if model.Meta.backups is None:
            backups = actual["ContinuousBackupsDescription"]["ContinuousBackupsStatus"] == "ENABLED"
            model.Meta.backups = {"enabled": backups}
            logger.debug(f"Set {model.__name__}.Meta.backups['enabled'] to '{backups}' from DescribeTable response")
        if model.Meta.billing is None:
            billing_mode = {
                "PAY_PER_REQUEST": "on_demand",
                "PROVISIONED": "provisioned"
            }[actual["BillingModeSummary"]["BillingMode"]]
            model.Meta.billing = {"mode": billing_mode}
            logger.debug(f"Set {model.__name__}.Meta.billing['mode'] to '{billing_mode}' from DescribeTable response")
        if model.Meta.read_units is None:
            read_units = model.Meta.read_units = actual["ProvisionedThroughput"]["ReadCapacityUnits"]
            logger.debug(
                f"Set {model.__name__}.Meta.read_units to {read_units} from DescribeTable response")
        if model.Meta.write_units is None:
            write_units = model.Meta.write_units = actual["ProvisionedThroughput"]["WriteCapacityUnits"]
            logger.debug(
                f"Set {model.__name__}.Meta.write_units to {write_units} from DescribeTable response")

        # Replace any ``None`` values for read_units, write_units in GSIs with their actual values
        gsis = {index["IndexName"]: index for index in actual["GlobalSecondaryIndexes"]}
        for index in model.Meta.gsis:
            read_units = gsis[index.dynamo_name]["ProvisionedThroughput"]["ReadCapacityUnits"]
            write_units = gsis[index.dynamo_name]["ProvisionedThroughput"]["WriteCapacityUnits"]
            if index.read_units is None:
                index.read_units = read_units
                logger.debug(
                    f"Set {model.__name__}.{index.name}.read_units to {read_units} from DescribeTable response")
            if index.write_units is None:
                index.write_units = write_units
                logger.debug(
                    f"Set {model.__name__}.{index.name}.write_units to {write_units} from DescribeTable response")

    def enable_ttl(self, table_name, model):
        """Calls UpdateTimeToLive on the table according to model.Meta["ttl"]

        :param table_name: The name of the table to enable the TTL setting on
        :param model: The model to get TTL settings from
        """
        self._tables.pop(table_name, None)
        ttl_name = model.Meta.ttl["column"].dynamo_name
        request = {
            "TableName": table_name,
            "TimeToLiveSpecification": {"AttributeName": ttl_name, "Enabled": True}
        }
        try:
            self.dynamodb_client.update_time_to_live(**request)
        except botocore.exceptions.ClientError as error:
            raise BloopException("Unexpected error while setting TTL.") from error

    def enable_backups(self, table_name, model):
        """Calls UpdateContinuousBackups on the table according to model.Meta["continuous_backups"]

        :param table_name: The name of the table to enable Continuous Backups on
        :param model: The model to get Continuous Backups settings from
        """
        self._tables.pop(table_name, None)
        request = {
            "TableName": table_name,
            "PointInTimeRecoverySpecification": {"PointInTimeRecoveryEnabled": True}
        }
        try:
            self.dynamodb_client.update_continuous_backups(**request)
        except botocore.exceptions.ClientError as error:
            raise BloopException("Unexpected error while setting Continuous Backups.") from error

    def describe_stream(self, stream_arn, first_shard=None):
        """Wraps :func:`boto3.DynamoDBStreams.Client.describe_stream`, handling continuation tokens.

        :param str stream_arn: Stream arn, usually from the model's ``Meta.stream["arn"]``.
        :param str first_shard: *(Optional)* If provided, only shards after this shard id will be returned.
        :return: All shards in the stream, or a subset if ``first_shard`` is provided.
        :rtype: dict
        """
        description = {"Shards": []}

        request = {"StreamArn": stream_arn, "ExclusiveStartShardId": first_shard}
        # boto3 isn't down with literal Nones.
        if first_shard is None:
            request.pop("ExclusiveStartShardId")

        while request.get("ExclusiveStartShardId") is not missing:
            try:
                response = self.stream_client.describe_stream(**request)["StreamDescription"]
            except botocore.exceptions.ClientError as error:
                if error.response["Error"]["Code"] == "ResourceNotFoundException":
                    raise InvalidStream(f"The stream arn {stream_arn!r} does not exist.") from error
                raise BloopException("Unexpected error while describing stream.") from error
            # Docs aren't clear if the terminal value is null, or won't exist.
            # Since we don't terminate the loop on None, the "or missing" here
            # will ensure we stop on a falsey value.
            request["ExclusiveStartShardId"] = response.pop("LastEvaluatedShardId", None) or missing
            description["Shards"].extend(response.pop("Shards", []))
            description.update(response)
        return description

    def get_shard_iterator(self, *, stream_arn, shard_id, iterator_type, sequence_number=None):
        """Wraps :func:`boto3.DynamoDBStreams.Client.get_shard_iterator`.

        :param str stream_arn: Stream arn.  Usually :data:`Shard.stream_arn <bloop.stream.shard.Shard.stream_arn>`.
        :param str shard_id: Shard identifier.  Usually :data:`Shard.shard_id <bloop.stream.shard.Shard.shard_id>`.
        :param str iterator_type: "sequence_at", "sequence_after", "trim_horizon", or "latest"
        :param sequence_number:
        :return: Iterator id, valid for 15 minutes.
        :rtype: str
        :raises bloop.exceptions.RecordsExpired: Tried to get an iterator beyond the Trim Horizon.
        """
        real_iterator_type = validate_stream_iterator_type(iterator_type)
        request = {
            "StreamArn": stream_arn,
            "ShardId": shard_id,
            "ShardIteratorType": real_iterator_type,
            "SequenceNumber": sequence_number
        }
        # boto3 isn't down with literal Nones.
        if sequence_number is None:
            request.pop("SequenceNumber")
        try:
            return self.stream_client.get_shard_iterator(**request)["ShardIterator"]
        except botocore.exceptions.ClientError as error:
            if error.response["Error"]["Code"] == "TrimmedDataAccessException":
                raise RecordsExpired from error
            raise BloopException("Unexpected error while creating shard iterator") from error

    def get_stream_records(self, iterator_id):
        """Wraps :func:`boto3.DynamoDBStreams.Client.get_records`.

        :param iterator_id: Iterator id.  Usually :data:`Shard.iterator_id <bloop.stream.shard.Shard.iterator_id>`.
        :return: Dict with "Records" list (may be empty) and "NextShardIterator" str (may not exist).
        :rtype: dict
        :raises bloop.exceptions.RecordsExpired: The iterator moved beyond the Trim Horizon since it was created.
        :raises bloop.exceptions.ShardIteratorExpired: The iterator was created more than 15 minutes ago.
        """
        try:
            return self.stream_client.get_records(ShardIterator=iterator_id)
        except botocore.exceptions.ClientError as error:
            if error.response["Error"]["Code"] == "TrimmedDataAccessException":
                raise RecordsExpired from error
            elif error.response["Error"]["Code"] == "ExpiredIteratorException":
                raise ShardIteratorExpired from error
            raise BloopException("Unexpected error while getting records.") from error

    def transaction_read(self, items):
        """
        Wraps :func:`boto3.DynamoDB.Client.db.transact_get_items`.

        :param items: Unpacked into "TransactionItems" for :func:`boto3.DynamoDB.Client.transact_get_items`
        :raises bloop.exceptions.TransactionCanceled: if the transaction was canceled.
        :return: Dict with "Records" list
        """
        try:
            return self.dynamodb_client.transact_get_items(TransactItems=items)
        except botocore.exceptions.ClientError as error:
            if error.response["Error"]["Code"] == "TransactionCanceledException":
                raise TransactionCanceled from error
            raise BloopException("Unexpected error during transaction read.") from error

    def transaction_write(self, items, client_request_token):
        """
        Wraps :func:`boto3.DynamoDB.Client.db.transact_write_items`.

        :param items: Unpacked into "TransactionItems" for :func:`boto3.DynamoDB.Client.transact_write_items`
        :param client_request_token: Idempotency token valid for 10 minutes from first use.
            Unpacked into "ClientRequestToken"
        :raises bloop.exceptions.TransactionCanceled: if the transaction was canceled.
        """
        try:
            self.dynamodb_client.transact_write_items(
                TransactItems=items,
                ClientRequestToken=client_request_token
            )
        except botocore.exceptions.ClientError as error:
            if error.response["Error"]["Code"] == "TransactionCanceledException":
                raise TransactionCanceled from error
            raise BloopException("Unexpected error during transaction write.") from error
bloop/exceptions.py
class InvalidTemplate(BloopException, ValueError):
    """This is not a valid template string."""
bloop/transactions.py
class WriteTransaction(Transaction):
    """
    Applies all updates in the same transaction.  Items can be from different models and tables.

    As with an engine, you can apply conditions to each object that you save or delete, or a condition for the entire
    transaction that won't modify the specified object:

    .. code-block:: python

        # condition on some_obj
        >>> tx.save(some_obj, condition=SomeModel.name.begins_with("foo"))
        # condition on the tx, based on the values of some_other_obj
        >>> tx.check(some_other_obj, condition=ThatModel.capacity >= 100)

    """
    mode = "w"

    def check(self, obj, condition) -> "WriteTransaction":
        """
        Add a condition which must be met for the transaction to commit.

        While the condition is checked against the provided object, that object will not be modified.  It is only
        used to provide the hash and range key to apply the condition to.

        At most 10 items can be checked, saved, or deleted in the same transaction.  The same idempotency token will
        be used for a single prepared transaction, which allows you to safely call commit on the PreparedCommit object
        multiple times.


        :param obj: The object to use for the transaction condition.  This object will not be modified.
        :param condition: A condition on an object which must hold for the transaction to commit.
        :return: this transaction for chaining
        """
        self._extend([TxItem.new("check", obj, condition)])
        return self

    def save(self, *objs, condition=None) -> "WriteTransaction":
        """
        Add one or more objects to be saved in this transaction.

        At most 10 items can be checked, saved, or deleted in the same transaction.  The same idempotency token will
        be used for a single prepared transaction, which allows you to safely call commit on the PreparedCommit object
        multiple times.

        :param objs: Objects to add to the set that are updated in this transaction.
        :param condition: A condition for these objects which must hold for the transaction to commit.
        :return: this transaction for chaining
        """
        self._extend([TxItem.new("save", obj, condition) for obj in objs])
        return self

    def delete(self, *objs, condition=None) -> "WriteTransaction":
        """
        Add one or more objects to be deleted in this transaction.

        At most 10 items can be checked, saved, or deleted in the same transaction.  The same idempotency token will
        be used for a single prepared transaction, which allows you to safely call commit on the PreparedCommit object
        multiple times.

        :param objs: Objects to add to the set that are deleted in this transaction.
        :param condition: A condition for these objects which must hold for the transaction to commit.
        :return: this transaction for chaining
        """
        self._extend([TxItem.new("delete", obj, condition) for obj in objs])
        return self
bloop/models.py
class Column(ComparisonMixin):
    model: BaseModel
    """Represents a single attribute in DynamoDB.

    :param typedef: The type of this attribute.  Can be either a :class:`~bloop.types.Type` or
        an instance thereof.  If a type class is provided, the column will call the constructor without arguments
        to create an instance.  For example, ``Column(Integer)`` and ``Column(Integer())`` are equivalent.
    :param bool hash_key: *(Optional)* True if this is the model's hash key.
        A model must have exactly one Column with ``hash_key=True``.  Default is False.
    :param bool range_key:  *(Optional)* True if this is the model's range key.
        A model can have at most one Column with
        ``range_key=True``.  Default is False.
    :param str dynamo_name: *(Optional)* The column's name in in DynamoDB. Defaults to the index’s name in the model.
    """
    def __init__(self, typedef, hash_key=False, range_key=False, dynamo_name=None, default=missing):
        self.hash_key: bool = hash_key
        self.range_key: bool = range_key
        self._name: str = None
        self._dynamo_name: str = dynamo_name

        if not callable(default):
            self.default = lambda: default
        else:
            self.default = default

        if subclassof(typedef, Type):
            typedef = typedef()
        if instanceof(typedef, Type):
            self.typedef = typedef

        else:
            raise TypeError(f"Expected {typedef} to be instance or subclass of Type")
        super().__init__()

    def __copy__(self):
        """
        Create a shallow copy of this Column.  Primarily used when initializing models that subclass other abstract
        models or mixins (baseless classes that contain Columns and Indexes).  You can override this method to
        change how derived models are created:

        .. code-block:: python

            import copy


            class MyColumn(Column):
                def __copy__(self):
                    new = super().__copy__()
                    new.derived = True
                    return new


            column = MyColumn(Integer)
            same = copy.copy(column)
            assert same.derived  # True

        :return: A shallow copy of this Column, with the ``model`` and ``_name`` attributes unset.
        """
        cls = self.__class__
        obj = cls.__new__(cls)
        obj.__dict__.update(self.__dict__)
        obj.model = None
        obj._name = None
        return obj

    def __set_name__(self, owner, name):
        self._name = name

    __hash__ = object.__hash__

    def __set__(self, obj, value):
        if self._name is None:
            raise AttributeError("Can't set field without binding to model")
        obj.__dict__[self._name] = value
        # Notify the tracking engine that this value was intentionally mutated
        object_modified.send(self, obj=obj, column=self, value=value)

    def __get__(self, obj, type=None):
        if obj is None:
            return self
        if self._name is None:
            raise AttributeError("Can't get field without binding to model")
        try:
            return obj.__dict__[self._name]
        except KeyError:
            raise AttributeError(f"'{obj.__class__}' has no attribute '{self._name}'")

    def __delete__(self, obj):
        try:
            if self._name is None:
                raise AttributeError("Can't delete field without binding to model")
            try:
                del obj.__dict__[self._name]
            except KeyError:
                raise AttributeError(f"'{obj.__class__}' has no attribute '{self._name}'")
        finally:
            # Unlike set, we always want to mark on delete.  If we didn't, and the column wasn't loaded
            # (say from a query) then the intention "ensure this doesn't have a value" wouldn't be captured.
            object_modified.send(self, obj=obj, column=self, value=None)

    def __repr__(self):
        if self.hash_key:
            extra = "=hash"
        elif self.range_key:
            extra = "=range"
        else:
            extra = ""

        # <Column[Pin.url]>
        # <Column[User.id=hash]>
        # <Column[File.fragment=range]>
        return f"<{self.__class__.__name__}[{self.model.__name__}.{self.name}{extra}]>"

    @property
    def name(self):
        """Name of the model's attr that references self"""
        return self._name

    @property
    def dynamo_name(self):
        if self._dynamo_name is None:
            return self.name
        return self._dynamo_name
bloop/models.py
class BaseModel:
    """Abstract base that all models derive from.

    Provides a basic ``__init__`` method that takes ``**kwargs`` whose
    keys are columns names:

    .. code-block:: python

        class URL(BaseModel):
            id = Column(UUID, hash_key=True)
            ip = Column(IPv6)
            name = Column(String)

        url = URL(id=uuid.uuid4(), name="google")

    By default, the ``__init__`` method is not called when new instances are
    required, for example when iterating results from Query, Scan or a Stream.

    """
    class Meta(IMeta):
        abstract = True

    def __init__(self, **attrs):
        # Only set values from **attrs if there's a
        # corresponding `name` for a column in the model
        for column in self.Meta.columns:
            value = attrs.get(column.name, missing)
            if value is missing:
                value = column.default()
            if value is not missing:
                setattr(self, column.name, value)

    def __init_subclass__(cls: type, **kwargs):
        ensure_hash(cls)
        meta = initialize_meta(cls)

        # before we start binding, we should ensure that no combination of parent classes
        # will cause conflicts.  For example:
        #   class C(A, B) where
        #       A has a column named "foo" and dynamo_name "ddb"
        #       B has a column named "bar" and dynamo_name "ddb"
        # both A and B are valid mixins, but C must fail because there isn't a 1:1 binding to the "ddb" column.
        #
        # TODO | for now, we'll assume that the class being defined is special, and can replace columns with the
        # TODO | same dynamo_name.  In the example above, that would mean C has a column named "baz" and dynamo_name
        # TODO | "ddb" which would prevent the parent columns "foo" and "bar" from binding to the child class.

        modeled_attrs = set((attr for (_, attr) in inspect.getmembers(cls, lambda x: isinstance(x, (Column, Index)))))
        local_attrs = {
            name: value
            for name, value in cls.__dict__.items()
            if isinstance(value, (Column, Index))
        }
        derived_attrs = modeled_attrs - set(local_attrs.values())

        # 0.0 Pre-validation for collisions in derived columns/indexes
        dynamo_names = [x.dynamo_name for x in derived_attrs]
        collisions = [name for name, count in collections.Counter(dynamo_names).items() if count > 1]
        if collisions:
            collisions.sort()
            raise InvalidModel(
                f"The model {cls.__name__} subclasses one or more models with conflicting "
                f"column or index definitions for the following values of dynamo_name: {collisions}")
        derived_hash_keys = set((x.name for x in derived_attrs if isinstance(x, Column) and x.hash_key))
        if len(derived_hash_keys) > 1:
            derived_hash_keys = sorted(derived_hash_keys)
            raise InvalidModel(
                f"The model {cls.__name__} subclasses one or more models that declare multiple "
                f"columns as the hash key: {derived_hash_keys}")
        derived_range_keys = set((x.name for x in derived_attrs if isinstance(x, Column) and x.range_key))
        if len(derived_range_keys) > 1:
            derived_range_keys = sorted(derived_range_keys)
            raise InvalidModel(
                f"The model {cls.__name__} subclasses one or more models that declare multiple "
                f"columns as the range key: {derived_range_keys}")

        # 0.1 Pre-validation for collisions in local columns/indexes
        dynamo_names = [x.dynamo_name for x in local_attrs.values()]
        collisions = [name for name, count in collections.Counter(dynamo_names).items() if count > 1]
        if collisions:
            collisions.sort()
            raise InvalidModel(
                f"The model {cls.__name__} contains conflicting column or index definitions for the "
                f"following values of dynamo_name: {collisions}")
        local_hash_keys = [x.name for x in local_attrs.values() if isinstance(x, Column) and x.hash_key]
        if len(local_hash_keys) > 1:
            local_hash_keys = sorted(local_hash_keys)
            raise InvalidModel(
                f"The model {cls.__name__} defines multiple columns as hash columns: {local_hash_keys}")
        local_range_keys = [x.name for x in local_attrs.values() if isinstance(x, Column) and x.range_key]
        if len(local_range_keys) > 1:
            local_range_keys = sorted(local_range_keys)
            raise InvalidModel(
                f"The model {cls.__name__} defines multiple columns as range columns: {local_range_keys}")

        # 1.0 Bind derived columns so they can be referenced by derived indexes
        for attr in derived_attrs:
            if isinstance(attr, Column):
                bind_column(cls, attr.name, attr, copy=True)

        # 1.1 Bind derived indexes
        for attr in derived_attrs:
            if isinstance(attr, Index):
                bind_index(cls, attr.name, attr, copy=True)

        # 1.2 Bind local columns, allowing them to overwrite existing columns
        for name, attr in local_attrs.items():
            if isinstance(attr, Column):
                bind_column(cls, name, attr, force=True)

        # 1.3 Bind local indexes, allowing them to overwrite existing indexes
        for name, attr in local_attrs.items():
            if isinstance(attr, Index):
                bind_index(cls, name, attr, force=True)

        # 2.0 Ensure concrete models are valid
        # Currently, this just checks that a hash key is defined
        if not meta.abstract and not meta.hash_key:
            raise InvalidModel(f"{meta.model.__name__!r} has no hash key.")

        validate_stream(meta)
        validate_ttl(meta)
        validate_encryption(meta)
        validate_backups(meta)
        validate_billing(meta)

        # 3.0 Fire model_created for customizing the class after creation
        model_created.send(None, model=cls)

    def __repr__(self):
        attrs = ", ".join("{}={!r}".format(*item) for item in loaded_columns(self))
        return f"{self.__class__.__name__}({attrs})"
bloop/exceptions.py
class InvalidModel(BloopException, ValueError):
    """This is not a valid Model."""
bloop/exceptions.py
class MissingKey(BloopException, ValueError):
    """The instance must provide values for its key columns."""
bloop/types.py
class Integer(Number):
    """Truncates values when loading or dumping.

    For example, ``3.14`` in DynamoDB is loaded as ``3``. If a value is ``7.5``
    locally, it's stored in DynamoDB as ``7``.
    """
    python_type = int

    def dynamo_load(self, value, *, context, **kwargs):
        if value is None:
            return None
        number = super().dynamo_load(value, context=context, **kwargs)
        return int(number)

    def dynamo_dump(self, value, *, context, **kwargs):
        if value is None:
            return None
        value = int(value)
        return super().dynamo_dump(value, context=context, **kwargs)
tests/helpers/models.py
class ComplexModel(BaseModel):
    class Meta:
        write_units = 2
        read_units = 3
        table_name = "CustomTableName"

    name = Column(UUID, hash_key=True)
    date = Column(String, range_key=True)
    email = Column(String)
    joined = Column(String)
    not_projected = Column(Integer)
    by_email = GlobalSecondaryIndex(hash_key="email", read_units=4, projection="all", write_units=5)
    by_joined = LocalSecondaryIndex(range_key="joined", projection=["email"])
bloop/engine.py
class Engine:
    # noinspection PyUnresolvedReferences
    """Primary means of interacting with DynamoDB.

    To apply a prefix to each model's table name, you can use a simple format string:

    .. code-block:: pycon

        >>> template = "my-prefix-{table_name}"
        >>> engine = Engine(table_name_template=template)

    For more complex table_name customization, you can provide a function:

    .. code-block:: pycon

        >>> def reverse_name(model):
        ...     return model.Meta.table_name[::-1]
        >>> engine = Engine(table_name_template=reverse_name)

    :param dynamodb: DynamoDB client.  Defaults to ``boto3.client("dynamodb")``.
    :param dynamodbstreams: DynamoDBStreams client.  Defaults to ``boto3.client("dynamodbstreams")``.
    :param table_name_template: Customize the table name of each model bound to the engine.  If a string
        is provided, string.format(table_name=model.Meta.table_name) will be called.  If a function is provided, the
        function will be called with the model as its sole argument.  Defaults to "{table_name}".
    """
    def __init__(
            self, *,
            dynamodb=None, dynamodbstreams=None,
            table_name_template: Union[str, TableNameFormatter] = "{table_name}"):
        self._compute_table_name = create_get_table_name_func(table_name_template)
        self.session = SessionWrapper(dynamodb=dynamodb, dynamodbstreams=dynamodbstreams)

    def bind(self, model, *, skip_table_setup=False):
        """Create backing tables for a model and its non-abstract subclasses.

        :param model: Base model to bind.  Can be abstract.
        :param skip_table_setup: Don't create or verify the table in DynamoDB.  Default is False.
        :raises bloop.exceptions.InvalidModel: if ``model`` is not a subclass of :class:`~bloop.models.BaseModel`.
        """
        # Make sure we're looking at models
        validate_is_model(model)

        concrete = set(filter(lambda m: not m.Meta.abstract, walk_subclasses(model)))
        if not model.Meta.abstract:
            concrete.add(model)
        logger.debug("binding non-abstract models {}".format(
            sorted(c.__name__ for c in concrete)
        ))

        # create_table doesn't block until ACTIVE or validate.
        # It also doesn't throw when the table already exists, making it safe
        # to call multiple times for the same unbound model.
        if skip_table_setup:
            logger.info("skip_table_setup is True; not trying to create tables or validate models during bind")
        else:
            self.session.clear_cache()

        is_creating = {}

        for model in concrete:
            table_name = self._compute_table_name(model)
            before_create_table.send(self, engine=self, model=model)
            if not skip_table_setup:
                if table_name in is_creating:
                    continue
                creating = self.session.create_table(table_name, model)
                is_creating[table_name] = creating

        for model in concrete:
            if not skip_table_setup:
                table_name = self._compute_table_name(model)
                if is_creating[table_name]:
                    # polls until table is active
                    self.session.describe_table(table_name)
                    if model.Meta.ttl:
                        self.session.enable_ttl(table_name, model)
                    if model.Meta.backups and model.Meta.backups["enabled"]:
                        self.session.enable_backups(table_name, model)
                self.session.validate_table(table_name, model)
                model_validated.send(self, engine=self, model=model)
            model_bound.send(self, engine=self, model=model)

        logger.info("successfully bound {} models to the engine".format(len(concrete)))

    def delete(self, *objs, condition=None, sync=None):
        """Delete one or more objects.

        :param objs: objects to delete.
        :param condition: only perform each delete if this condition holds.
        :param sync:
            update objects after deleting.  "old" loads attributes before the delete;
            None does not mutate the object locally.  Default is None.
        :raises bloop.exceptions.ConstraintViolation: if the condition (or atomic) is not met.
        """
        objs = set(objs)
        validate_not_abstract(*objs)
        validate_sync("delete", sync)
        for obj in objs:
            attrs = self.session.delete_item({
                "TableName": self._compute_table_name(obj.__class__),
                "Key": dump_key(self, obj),
                "ReturnValues": validate_sync("delete", sync),
                **render(self, obj=obj, condition=condition)
            })
            if attrs is not None:
                unpack_from_dynamodb(attrs=attrs, expected=obj.Meta.columns, engine=self, obj=obj)
            object_deleted.send(self, engine=self, obj=obj)
        logger.info("successfully deleted {} objects".format(len(objs)))

    def load(self, *objs, consistent=False):
        """Populate objects from DynamoDB.

        :param objs: objects to delete.
        :param bool consistent: Use `strongly consistent reads`__ if True.  Default is False.
        :raises bloop.exceptions.MissingKey: if any object doesn't provide a value for a key column.
        :raises bloop.exceptions.MissingObjects: if one or more objects aren't loaded.

        __ http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html
        """
        get_table_name = self._compute_table_name
        objs = set(objs)
        validate_not_abstract(*objs)

        table_index, object_index, request = {}, {}, {}

        for obj in objs:
            table_name = get_table_name(obj.__class__)
            key = dump_key(self, obj)
            index = index_for(key)

            if table_name not in object_index:
                table_index[table_name] = list(sorted(key.keys()))
                object_index[table_name] = {}
                request[table_name] = {"Keys": [], "ConsistentRead": consistent}

            if index not in object_index[table_name]:
                request[table_name]["Keys"].append(key)
                object_index[table_name][index] = set()
            object_index[table_name][index].add(obj)

        response = self.session.load_items(request)

        for table_name, list_of_attrs in response.items():
            for attrs in list_of_attrs:
                key_shape = table_index[table_name]
                key = extract_key(key_shape, attrs)
                index = index_for(key)

                for obj in object_index[table_name].pop(index):
                    unpack_from_dynamodb(
                        attrs=attrs, expected=obj.Meta.columns, engine=self, obj=obj)
                    object_loaded.send(self, engine=self, obj=obj)
                if not object_index[table_name]:
                    object_index.pop(table_name)

        if object_index:
            not_loaded = set()
            for index in object_index.values():
                for index_set in index.values():
                    not_loaded.update(index_set)
            logger.info("loaded {} of {} objects".format(len(objs) - len(not_loaded), len(objs)))
            raise MissingObjects("Failed to load some objects.", objects=not_loaded)
        logger.info("successfully loaded {} objects".format(len(objs)))

    def query(self, model_or_index, key, filter=None, projection="all", consistent=False, forward=True):
        """Create a reusable :class:`~bloop.search.QueryIterator`.

        :param model_or_index: A model or index to query.  For example, ``User`` or ``User.by_email``.
        :param key:
            Key condition.  This must include an equality against the hash key, and optionally one
            of a restricted set of conditions on the range key.
        :param filter: Filter condition.  Only matching objects will be included in the results.
        :param projection:
            "all", "count", a set of column names, or a set of :class:`~bloop.models.Column`.  When projection is
            "count", you must advance the iterator to retrieve the count.
        :param bool consistent: Use `strongly consistent reads`__ if True.  Default is False.
        :param bool forward:  Query in ascending or descending order.  Default is True (ascending).

        :return: A reusable query iterator with helper methods.
        :rtype: :class:`~bloop.search.QueryIterator`

        __ http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html
        """
        if isinstance(model_or_index, Index):
            model, index = model_or_index.model, model_or_index
        else:
            model, index = model_or_index, None
        validate_not_abstract(model)
        q = Search(
            mode="query", engine=self, model=model, index=index, key=key, filter=filter,
            projection=projection, consistent=consistent, forward=forward)
        return iter(q.prepare())

    def save(self, *objs, condition=None, sync=None):
        """Save one or more objects.

        :param objs: objects to save.
        :param condition: only perform each save if this condition holds.
        :param sync:
            update objects after saving.  "new" loads attributes after the save;
            "old" loads attributes before the save; None does not mutate the object locally.  Default is None.
        :raises bloop.exceptions.ConstraintViolation: if the condition (or atomic) is not met.
        """
        objs = set(objs)
        validate_not_abstract(*objs)
        for obj in objs:
            attrs = self.session.save_item({
                "TableName": self._compute_table_name(obj.__class__),
                "Key": dump_key(self, obj),
                "ReturnValues": validate_sync("save", sync),
                **render(self, obj=obj, condition=condition, update=True)
            })
            if attrs is not None:
                unpack_from_dynamodb(attrs=attrs, expected=obj.Meta.columns, engine=self, obj=obj)
            object_saved.send(self, engine=self, obj=obj)
        logger.info("successfully saved {} objects".format(len(objs)))

    def scan(self, model_or_index, filter=None, projection="all", consistent=False, parallel=None):
        """Create a reusable :class:`~bloop.search.ScanIterator`.

        :param model_or_index: A model or index to scan.  For example, ``User`` or ``User.by_email``.
        :param filter: Filter condition.  Only matching objects will be included in the results.
        :param projection:
            "all", "count", a list of column names, or a list of :class:`~bloop.models.Column`.  When projection is
            "count", you must exhaust the iterator to retrieve the count.
        :param bool consistent: Use `strongly consistent reads`__ if True.  Default is False.
        :param tuple parallel: Perform a `parallel scan`__.  A tuple of (Segment, TotalSegments)
            for this portion the scan. Default is None.
        :return: A reusable scan iterator with helper methods.
        :rtype: :class:`~bloop.search.ScanIterator`

        __ http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html
        __ http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html#QueryAndScanParallelScan
        """
        if isinstance(model_or_index, Index):
            model, index = model_or_index.model, model_or_index
        else:
            model, index = model_or_index, None
        validate_not_abstract(model)
        s = Search(
            mode="scan", engine=self, model=model, index=index, filter=filter,
            projection=projection, consistent=consistent, parallel=parallel)
        return iter(s.prepare())

    def stream(self, model, position):
        # noinspection PyUnresolvedReferences
        """Create a :class:`~bloop.stream.Stream` that provides approximate chronological ordering.

        .. code-block:: pycon

            # Create a user so we have a record
            >>> engine = Engine()
            >>> user = User(id=3, email="user@domain.com")
            >>> engine.save(user)
            >>> user.email = "admin@domain.com"
            >>> engine.save(user)

            # First record lacks an "old" value since it's an insert
            >>> stream = engine.stream(User, "trim_horizon")
            >>> next(stream)
            {'key': None,
             'old': None,
             'new': User(email='user@domain.com', id=3, verified=None),
             'meta': {
                 'created_at': datetime.datetime(2016, 10, 23, ...),
                 'event': {
                     'id': '3fe6d339b7cb19a1474b3d853972c12a',
                     'type': 'insert',
                     'version': '1.1'},
                 'sequence_number': '700000000007366876916'}
            }


        :param model: The model to stream records from.
        :param position: "trim_horizon", "latest", a stream token, or a :class:`datetime.datetime`.
        :return: An iterator for records in all shards.
        :rtype: :class:`~bloop.stream.Stream`
        :raises bloop.exceptions.InvalidStream: if the model does not have a stream.
        """
        validate_not_abstract(model)
        if not model.Meta.stream or not model.Meta.stream.get("arn"):
            raise InvalidStream("{!r} does not have a stream arn".format(model))
        stream = Stream(model=model, engine=self)
        stream.move_to(position=position)
        return stream

    def transaction(self, mode="w"):
        # noinspection PyUnresolvedReferences
        """
        Create a new :class:`~bloop.transactions.ReadTransaction` or :class:`~bloop.transactions.WriteTransaction`.

        As a context manager, calling commit when the block exits:

        .. code-block:: pycon

            >>> engine = Engine()
            >>> user = User(id=3, email="user@domain.com")
            >>> tweet = Tweet(id=42, data="hello, world")
            >>> with engine.transaction("w") as tx:
            ...     tx.delete(user)
            ...     tx.save(tweet, condition=Tweet.id.is_(None))

        Or manually calling prepare and commit:

        .. code-block:: pycon

            >>> engine = Engine()
            >>> user = User(id=3, email="user@domain.com")
            >>> tweet = Tweet(id=42, data="hello, world")
            >>> tx = engine.transaction("w")
            >>> tx.delete(user)
            >>> tx.save(tweet, condition=Tweet.id.is_(None))
            >>> tx.prepare().commit()

        :param str mode: Either "r" or "w" to create a ReadTransaction or WriteTransaction.  Default is "w"
        :return: A new transaction that can be committed.
        :rtype: :class:`~bloop.transactions.ReadTransaction` or :class:`~bloop.transactions.WriteTransaction`
        """
        if mode == "r":
            cls = ReadTransaction
        elif mode == "w":
            cls = WriteTransaction
        else:
            raise ValueError(f"unknown mode {mode}")
        return cls(self)
bloop/exceptions.py
class MissingObjects(BloopException):
    """Some objects were not found."""

    #: The objects that failed to load
    objects: list

    def __init__(self, *args, objects=None):
        super().__init__(*args)
        self.objects = list(objects) if objects else []
bloop/util.py
def ordered(obj):
    """
    Return sorted version of nested dicts/lists for comparing.

    Modified from:
    http://stackoverflow.com/a/25851972
    """
    if isinstance(obj, collections.abc.Mapping):
        return sorted((k, ordered(v)) for k, v in obj.items())
    # Special case str since it's a collections.abc.Iterable
    elif isinstance(obj, str):
        return obj
    elif isinstance(obj, collections.abc.Iterable):
        return sorted(ordered(x) for x in obj)
    else:
        return obj
bloop/types.py
class DateTime(String):
    """Always stored in DynamoDB using the :data:`~bloop.types.FIXED_ISO8601_FORMAT` format.

    Naive datetimes (``tzinfo is None``) are not supported, and trying to use one will raise ``ValueError``.

    .. code-block:: python

        from datetime import datetime, timedelta, timezone

        class Model(Base):
            id = Column(Integer, hash_key=True)
            date = Column(DateTime)
        engine.bind()

        obj = Model(id=1, date=datetime.now(timezone.utc))
        engine.save(obj)

        one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)

        query = engine.query(
            Model,
            key=Model.id==1,
            filter=Model.date >= one_day_ago)

        query.first().date

    .. note::

        To use common datetime libraries such as `arrow`_, `delorean`_, or `pendulum`_,
        see :ref:`DateTime and Timestamp Extensions <user-extensions-datetime>` in the user guide.  These
        are drop-in replacements and support non-utc timezones:

        .. code-block:: python

            from bloop import DateTime  # becomes:
            from bloop.ext.pendulum import DateTime

    .. _arrow: http://crsmithdev.com/arrow
    .. _delorean: https://delorean.readthedocs.io/en/latest/
    .. _pendulum: https://pendulum.eustace.io
    """
    python_type = datetime.datetime

    def dynamo_load(self, value, *, context, **kwargs):
        if value is None:
            return None
        dt = datetime.datetime.strptime(value, FIXED_ISO8601_FORMAT)
        # we assume all stored values are utc, so we simply force timezone to utc
        # without changing the day/time values
        return dt.replace(tzinfo=datetime.timezone.utc)

    def dynamo_dump(self, value, *, context, **kwargs):
        if value is None:
            return None
        if value.tzinfo is None:
            raise ValueError(
                "naive datetime instances are not supported.  You can set a timezone with either "
                "your_dt.replace(tzinfo=) or your_dt.astimezone(tz=).  WARNING: calling astimezone on a naive "
                "datetime will assume the naive datetime is in the system's timezone, even though "
                "datetime.utcnow() creates a naive object!  You almost certainly don't want to do that."
            )
        dt = value.astimezone(tz=datetime.timezone.utc)
        return dt.strftime(FIXED_ISO8601_FORMAT)
bloop/types.py
class Timestamp(Integer):
    """Stores the unix (epoch) time in seconds.  Milliseconds are truncated to 0 on load and save.

    Naive datetimes (``tzinfo is None``) are not supported, and trying to use one will raise ``ValueError``.

    .. code-block:: python

        from datetime import datetime, timedelta, timezone

        class Model(Base):
            id = Column(Integer, hash_key=True)
            date = Column(Timestamp)
        engine.bind()

        obj = Model(id=1, date=datetime.now(timezone.utc))
        engine.save(obj)

        one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)

        query = engine.query(
            Model,
            key=Model.id==1,
            filter=Model.date >= one_day_ago)

        query.first().date

    .. note::

        To use common datetime libraries such as `arrow`_, `delorean`_, or `pendulum`_,
        see :ref:`DateTime and Timestamp Extensions <user-extensions-datetime>` in the user guide.  These
        are drop-in replacements and support non-utc timezones:

        .. code-block:: python

            from bloop import Timestamp  # becomes:
            from bloop.ext.pendulum import Timestamp

    .. _arrow: http://crsmithdev.com/arrow
    .. _delorean: https://delorean.readthedocs.io/en/latest/
    .. _pendulum: https://pendulum.eustace.io
    """
    python_type = datetime.datetime

    def dynamo_load(self, value, *, context, **kwargs):
        if value is None:
            return None
        value = super().dynamo_load(value, context=context, **kwargs)
        # we assume all stored values are utc, so we simply force timezone to utc
        # without changing the day/time values
        return datetime.datetime.fromtimestamp(value, tz=datetime.timezone.utc)

    def dynamo_dump(self, value, *, context, **kwargs):
        if value is None:
            return None
        if value.tzinfo is None:
            raise ValueError(
                "naive datetime instances are not supported.  You can set a timezone with either "
                "your_dt.replace(tzinfo=) or your_dt.astimezone(tz=).  WARNING: calling astimezone on a naive "
                "datetime will assume the naive datetime is in the system's timezone, even though "
                "datetime.utcnow() creates a naive object!  You almost certainly don't want to do that."
            )
        value = value.timestamp()
        return super().dynamo_dump(value, context=context, **kwargs)
bloop/transactions.py
class ReadTransaction(Transaction):
    """
    Loads all items in the same transaction.  Items can be from different models and tables.
    """
    mode = "r"

    def load(self, *objs) -> "ReadTransaction":
        """
        Add one or more objects to be loaded in this transaction.

        At most 10 items can be loaded in the same transaction.  All objects will be loaded each time you
        call commit().


        :param objs: Objects to add to the set that are loaded in this transaction.
        :return: this transaction for chaining
        :raises bloop.exceptions.MissingObjects: if one or more objects aren't loaded.
        """
        self._extend([TxItem.new("get", obj) for obj in objs])
        return self
tests/helpers/models.py
class User(BaseModel):
    id = Column(String, hash_key=True)
    age = Column(Integer)
    name = Column(String)
    email = Column(String)
    joined = Column(DateTime, dynamo_name="j")
    by_email = GlobalSecondaryIndex(hash_key="email", projection="all")
bloop/exceptions.py
class InvalidStream(BloopException, ValueError):
    """This is not a valid stream definition."""
tests/helpers/models.py
class VectorModel(BaseModel):
    name = Column(String, hash_key=True)
    list_str = Column(List(String))
    set_str = Column(Set(String))
    map_nested = Column(Map(**{
        "bytes": Binary,
        "str": String,
        "map": Map(**{
            "int": Integer,
            "str": String
        })
    }))
    some_int = Column(Integer)
    some_bytes = Column(Binary)
bloop/models.py
class GlobalSecondaryIndex(Index):
    """See `GlobalSecondaryIndex`_ in the DynamoDB Developer Guide for details.

    :param projection: Either "keys", "all", or a list of column name or objects.
        Included columns will be projected into the index.  Key columns are always included.
    :param hash_key: The column that the index can be queried against.
    :param range_key: *(Optional)* The column that the index can be sorted on.  Default is None.
    :param int read_units: *(Optional)* Provisioned read units for the index.  Default is None.
        When no value is provided and the index does not exist, it will be created with 1 read unit.  If the index
        already exists, it will use the actual index's read units.
    :param int write_units:  *(Optional)* Provisioned write units for the index.  Default is None.
        When no value is provided and the index does not exist, it will be created with 1 write unit.  If the index
        already exists, it will use the actual index's write units.
    :param str dynamo_name: *(Optional)* The index's name in in DynamoDB. Defaults to the index’s name in the model.

    .. _GlobalSecondaryIndex: http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html
    """
    def __init__(
            self, *, projection,
            hash_key, range_key=None,
            read_units=None, write_units=None,
            dynamo_name=None, **kwargs):
        super().__init__(
            hash_key=hash_key, range_key=range_key,
            dynamo_name=dynamo_name, projection=projection, **kwargs)
        self.write_units = write_units
        self.read_units = read_units


Question: import datetime
import logging
import uuid
import pytest
from unittest.mock import Mock
from tests.helpers.models import ComplexModel, User, VectorModel
from bloop.engine import Engine
from bloop.exceptions import (
    InvalidModel,
    InvalidStream,
    InvalidTemplate,
    MissingKey,
    MissingObjects,
)
from bloop.models import BaseModel, Column, GlobalSecondaryIndex
from bloop.session import SessionWrapper
from bloop.transactions import ReadTransaction, WriteTransaction
from bloop.types import DateTime, Integer, String, Timestamp
from bloop.util import ordered


def test_delete_multiple_condition(engine, session, caplog):
    users = [User(id=str(i)) for i in range(3)]
    condition = User.id == "foo"
    expected_calls = [
        {
            "Key": {"id": {"S": user.id}},
            "ExpressionAttributeValues": {":v1": {"S": "foo"}},
            "ExpressionAttributeNames": {"#n0": "id"},
            "ConditionExpression": "(#n0 = :v1)",
            "TableName": "User",
            "ReturnValues": "NONE",
        }
        for user in users]
    engine.delete(*users, condition=condition)
    for expected in expected_calls:
        session.delete_item.assert_any_call(expected)
    assert session.delete_item.call_count == 3

    assert caplog.record_tuples[-1] == ("bloop.engine", logging.INFO, "successfully deleted 3 objects")


def test_delete_new(engine, session):
    """When an object is first created, a delete shouldn't expect anything."""
    user = User(id="user_id")
    expected = {
        "TableName": "User",
        "ReturnValues": "NONE",
        "Key": {"id": {"S": user.id}}}
    engine.delete(user)
    session.delete_item.assert_called_once_with(expected)


@pytest.mark.parametrize("sync", [
    "ALL_NEW", "ALL_OLD", "NONE",  # literal values
    False, True,  # ambiguous if update/all is exposed in the future
    "new",  # only for save, not delete
])
def test_delete_unknown_sync(engine, sync):
    user = User(id="user_id", age=4)
    with pytest.raises(ValueError):
        engine.delete(user, sync=sync)


def test_delete_sync(engine, session):
    """Engine.delete(sync='old') the previous values should be loaded and the object should be marked dirty"""

    session.delete_item.return_value = {
        "id": {"S": "user_id"},
        "age": {"N": "3"}
    }
    user = User(id="user_id", age=4)
    engine.delete(user, sync="old")
    assert user.age == 3


def test_query(engine):
    """Engine.query supports model and index-based queries"""
    index_query = engine.query(
        User.by_email,
        key=User.by_email.hash_key == "placeholder",
        forward=False
    )
    assert index_query.model is User
    assert index_query.index is User.by_email

    model_query = engine.query(User, key=User.Meta.hash_key == "other")
    assert model_query.model is User
    assert model_query.index is None


def test_scan(engine):
    """Engine.scan supports model and index-based queries"""
    index_scan = engine.scan(User.by_email, parallel=(1, 5))
    assert index_scan.model is User
    assert index_scan.index is User.by_email

    model_scan = engine.scan(User)
    assert model_scan.model is User
    assert model_scan.index is None


def test_stream(engine, session):
    class StreamModel(BaseModel):
        class Meta:
            stream = {
                "include": {"new"},
                "arn": "test-arn-manually-set"
            }
        id = Column(String, hash_key=True)
    engine.bind(StreamModel)
    session.describe_stream.return_value = {"Shards": []}

    stream = engine.stream(StreamModel, "latest")
    assert stream.model is StreamModel


def test_invalid_stream(engine, session):
    with pytest.raises(InvalidStream):
        engine.stream(User, "latest")


@pytest.mark.parametrize("mode, cls", [
    ("r", ReadTransaction),
    ("w", WriteTransaction),
])
def test_transaction_mode(mode, cls, engine):
    tx = engine.transaction(mode=mode)
    assert isinstance(tx, cls)
    assert tx.mode == mode


def test_transaction_unknown(engine):
    with pytest.raises(ValueError):
        engine.transaction(mode="unknown")


def test_bind_non_model(engine):
    """Can't bind things that don't subclass BaseModel"""

Answer:     with pytest.raises(InvalidModel):