bloop/search.py
def validate_filter_condition(condition, available_columns, column_blacklist):
    if condition is None:
        return

    for column in iter_columns(condition):
        # All of the columns in the condition must be in the available columns
        if column not in available_columns:
            raise InvalidSearch(
                "{!r} is not available for the projection.".format(column))
        # If this is a query, the condition can't contain the hash or range keys.
        # Those are passed in as the column_blacklist.
        if column in column_blacklist:
            raise InvalidSearch("{!r} can not be included in the filter condition.".format(column))
bloop/search.py
class ScanIterator(SearchModelIterator):
    """Reusable scan iterator that unpacks result dicts into model instances.

    Returned from :func:`Engine.scan <bloop.engine.Engine.scan>`.

    :param engine: :class:`~bloop.engine.Engine` to unpack models with.
    :param model: :class:`~bloop.models.BaseModel` being scanned.
    :param index: :class:`~bloop.models.Index` to scan, or None.
    :param dict request: The base request dict for each Scan call.
    :param set projected: Set of :class:`~bloop.models.Column` that should be included in each result.
    """
    mode = "scan"
bloop/search.py
def validate_search_projection(model, index, projection):
    if not projection:
        raise InvalidSearch("The projection must be 'count', 'all', or a set of Columns to include.")
    if projection == "count":
        return None

    if projection == "all":
        return (index or model.Meta).projection["included"]
    elif isinstance(projection, str):
        raise InvalidSearch("The projection must be 'count', 'all', or a set of Columns to include.")

    # Keep original around for error messages
    original_projection = projection
    projection = set(projection)

    # name -> Column
    if all(isinstance(p, str) for p in projection):
        by_name = model.Meta.columns_by_name
        # This code could be simplified to a list comprehension
        # but then the user would gets a KeyError if they passed a set.
        # Instead do each individually and throw a useful exception.
        converted_projection = set()
        for p in projection:
            try:
                converted_projection.add(by_name[p])
            except KeyError:
                raise InvalidSearch("{!r} is not a column of {!r}.".format(p, model))
        projection = converted_projection

    # Could have been str/Column mix, or just not Columns.
    if not all(isinstance(p, Column) for p in projection):
        raise InvalidSearch(
            "{!r} is not valid: it must be only Columns or only their model names.".format(original_projection))

    # Can the full available columns support this projection?
    if projection <= (index or model.Meta).projection["available"]:
        return projection

    raise InvalidSearch(
        "{!r} includes columns that are not available for {!r}.".format(
            original_projection, printable_query(index or model.Meta)))
bloop/util.py
class Sentinel:
    """Simple string-based placeholders for missing or special values.

    Names are unique, and instances are re-used for the same name:

    .. code-block:: pycon

        >>> from bloop.util import Sentinel
        >>> empty = Sentinel("empty")
        >>> empty
        <Sentinel[empty]>
        >>> same_token = Sentinel("empty")
        >>> empty is same_token
        True

    This removes the need to import the same signal or placeholder value everywhere; two modules can create
    ``Sentinel("some-value")`` and refer to the same object.  This is especially helpful where ``None`` is a possible
    value, and so can't be used to indicate omission of an optional parameter.

    Implements ``__repr__`` to render nicely in function signatures.  Standard object-based sentinels:

    .. code-block:: pycon

        >>> missing = object()
        >>> def some_func(optional=missing):
        ...     pass
        ...
        >>> help(some_func)
        Help on function some_func in module __main__:

        some_func(optional=<object object at 0x7f0f3f29e5d0>)

    With the Sentinel class:

    .. code-block:: pycon

        >>> from bloop.util import Sentinel
        >>> missing = Sentinel("Missing")
        >>> def some_func(optional=missing):
        ...     pass
        ...
        >>> help(some_func)
        Help on function some_func in module __main__:

        some_func(optional=<Sentinel[Missing]>)

    :param str name: The name for this sentinel.
    """
    def __new__(cls, name, *args, **kwargs):
        name = name.lower()
        sentinel = _symbols.get(name, None)
        if sentinel is None:
            sentinel = _symbols[name] = super().__new__(cls)
        return sentinel

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return "<Sentinel[{}]>".format(self.name)
bloop/search.py
class SearchModelIterator(SearchIterator):
    """Reusable search iterator that unpacks result dicts into model instances.

    :param engine: :class:`~bloop.engine.Engine` to unpack models with.
    :param model: :class:`~bloop.models.BaseModel` being searched.
    :param index: :class:`~bloop.models.Index` to search, or None.
    :param dict request: The base request dict for each search call.
    :param set projected: Set of :class:`~bloop.models.Column` that should be included in each result.
    """
    def __init__(self, *, engine, model, index, request, projected):
        self.engine = engine

        self.model = model

        super().__init__(
            session=engine.session, model=model, index=index,
            request=request, projected=projected)

    def __next__(self):
        attrs = super().__next__()
        obj = unpack_from_dynamodb(
            attrs=attrs,
            expected=self.projected,
            model=self.model,
            engine=self.engine)
        object_loaded.send(self.engine, engine=self.engine, obj=obj)
        return obj
bloop/search.py
class QueryIterator(SearchModelIterator):
    """Reusable query iterator that unpacks result dicts into model instances.

    Returned from :func:`Engine.query <bloop.engine.Engine.query>`.

    :param engine: :class:`~bloop.engine.Engine` to unpack models with.
    :param model: :class:`~bloop.models.BaseModel` being queried.
    :param index: :class:`~bloop.models.Index` to query, or None.
    :param dict request: The base request dict for each Query call.
    :param set projected: Set of :class:`~bloop.models.Column` that should be included in each result.
    """
    mode = "query"
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)
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
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/exceptions.py
class InvalidSearch(BloopException, ValueError):
    """The search was malformed"""
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/search.py
class SearchIterator:
    """Reusable search iterator.

    :param session: :class:`~bloop.session.SessionWrapper` to make Query, Scan calls.
    :param model: :class:`~bloop.models.BaseModel` for repr only.
    :param index: :class:`~bloop.models.Index` to search, or None.
    :param dict request: The base request dict for each search.
    :param set projected: Set of :class:`~bloop.models.Column` that should be included in each result.
    """
    mode = "<mode-placeholder>"

    def __init__(self, *, session, model, index, request, projected):
        self.session = session
        self.request = request

        self.model = model
        self.index = index
        self.projected = projected

        self.buffer = collections.deque()

        self._count = 0
        self._scanned = 0
        self._exhausted = False
        self._last_yielded = None

    @property
    def token(self):
        """
        JSON-serializable representation of the current SearchIterator state.

        Use :func:`iterator.move_to(token) <bloop.search.SearchIterator.move_to>` to move an iterator to this position.

        Implementations will always include a "ExclusiveStartKey" key but may include additional metadata.
        The iterator's ``count`` and ``scanned`` values are not preserved.

        :returns: Iterator state as a json-friendly dict
        """
        if self._last_yielded is None:
            # If the iterator isn't advanced but the user calls .move_to, ESK will not be None
            # Otherwise, this returns {"ESK": None}
            esk = self.request.get("ExclusiveStartKey")
        else:
            # table keys are always included (since they're always loaded)
            # index keys are included if there's an index
            keys = self.model.Meta.keys | (self.index.keys if self.index else set())
            keys = (k.dynamo_name for k in keys)
            esk = {k: self._last_yielded[k] for k in keys}
        return {"ExclusiveStartKey": esk}

    @property
    def count(self):
        """Number of items that have been loaded from DynamoDB so far, including buffered items."""
        if self.request["Select"] == "COUNT":
            while not self.exhausted:
                next(self, None)
        return self._count

    @property
    def scanned(self):
        """Number of items that DynamoDB evaluated, before any filter was applied."""
        if self.request["Select"] == "COUNT":
            while not self.exhausted:
                next(self, None)
        return self._scanned

    def all(self):
        """Eagerly load all results and return a single list.  If there are no results, the list is empty.

        :return: A list of results.
        """
        self.reset()
        return list(self)

    def first(self):
        """Return the first result.  If there are no results, raises :exc:`~bloop.exceptions.ConstraintViolation`.

        :return: The first result.
        :raises bloop.exceptions.ConstraintViolation: No results.
        """
        self.reset()
        value = next(self, None)
        if value is None:
            raise ConstraintViolation("{} did not find any results.".format(self.mode.capitalize()))
        return value

    def move_to(self, token):
        """Restore an iterator to the state stored in a token.  This will reset all iterator state, including
        ``count``, ``scanned``, and ``exhausted`` properties.

        :param token: a :attr:`SearchIterator.token <bloop.search.SearchIterator.token>`
        """
        esk = token["ExclusiveStartKey"]
        self.reset()
        # Don't set to None since boto3 doesn't like an explicit None
        if esk:
            self.request["ExclusiveStartKey"] = esk
        self._last_yielded = esk

    def one(self):
        """Return the unique result.  If there is not exactly one result,
        raises :exc:`~bloop.exceptions.ConstraintViolation`.

        :return: The unique result.
        :raises bloop.exceptions.ConstraintViolation: Not exactly one result.
        """
        first = self.first()
        second = next(self, None)
        if second is not None:
            raise ConstraintViolation("{} found more than one result.".format(self.mode.capitalize()))
        return first

    def reset(self):
        """Reset to the initial state, clearing the buffer and zeroing count and scanned."""
        self.buffer.clear()
        self._count = 0
        self._scanned = 0
        self._exhausted = False
        self._last_yielded = None
        self.request.pop("ExclusiveStartKey", None)

    @property
    def exhausted(self):
        """True if there are no more results."""
        return self._exhausted and len(self.buffer) == 0

    def __repr__(self):
        return search_repr(self.__class__, self.model, self.index)

    def __iter__(self):
        return self

    def __next__(self):
        while (not self._exhausted) and len(self.buffer) == 0:
            response = self.session.search_items(self.mode, self.request)
            continuation_token = response.get("LastEvaluatedKey", None)
            if continuation_token:
                self.request["ExclusiveStartKey"] = continuation_token
            self._exhausted = not continuation_token

            self._count += response["Count"]
            self._scanned += response["ScannedCount"]

            # Each item is a dict of attributes
            self.buffer.extend(response.get("Items", []))

        if self.buffer:
            self._last_yielded = self.buffer.popleft()
            return self._last_yielded

        # Buffer must be empty (if _buffer)
        # No more continue tokens (while not _exhausted)
        raise StopIteration
bloop/models.py
class LocalSecondaryIndex(Index):
    """See `LocalSecondaryIndex`_ in the DynamoDB Developer Guide for details.

    Unlike :class:`~bloop.models.GlobalSecondaryIndex` each LSI shares its throughput with the table
    and their hash key is always the table hash key.

    :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 range_key: The column that the index can be sorted against.
    :param str dynamo_name: *(Optional)* The index's name in in DynamoDB. Defaults to the index’s name in the model.
    :param bool strict: *(Optional)* Restricts queries and scans on the LSI to columns in the projection.
        When False, DynamoDB may silently incur additional reads to load results.  You should not disable this
        unless you have an explicit need.  Default is True.

    .. _LocalSecondaryIndex: http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html
    """
    def __init__(self, *, projection, range_key, dynamo_name=None, strict=True, **kwargs):
        # Hash key MUST be the table hash; do not specify
        if "hash_key" in kwargs:
            raise InvalidModel("An LSI shares its hash key with the Model.")
        if ("write_units" in kwargs) or ("read_units" in kwargs):
            raise InvalidModel("An LSI shares its provisioned throughput with the Model.")
        super().__init__(range_key=range_key, dynamo_name=dynamo_name, projection=projection, **kwargs)
        self.projection["strict"] = strict

    @property
    def hash_key(self):
        return self.model.Meta.hash_key

    @property
    def read_units(self):
        return self.model.Meta.read_units

    @read_units.setter
    def read_units(self, value):
        self.model.Meta.read_units = value

    @property
    def write_units(self):
        return self.model.Meta.write_units

    @write_units.setter
    def write_units(self, value):
        self.model.Meta.write_units = value
bloop/exceptions.py
class ConstraintViolation(BloopException):
    """A required condition was not met."""
bloop/search.py
def printable_query(query_on):
    # Model.Meta -> Model
    if getattr(query_on, "__name__", "") == "Meta":
        return query_on.model
    # Index -> Index
    return query_on
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
bloop/conditions.py
class ObjectTracking(weakref.WeakKeyDictionary):
class ReferenceTracker:
class ConditionRenderer:
class BaseCondition:
class Condition(BaseCondition):
class AndCondition(BaseCondition):
class OrCondition(BaseCondition):
class NotCondition(BaseCondition):
class ComparisonCondition(BaseCondition):
class BeginsWithCondition(BaseCondition):
class BetweenCondition(BaseCondition):
class ContainsCondition(BaseCondition):
class InCondition(BaseCondition):
class ComparisonMixin:
class Proxy(ComparisonMixin):
    def __getitem__(self, key):
def on_object_modified(_, *, obj, column, **__):
def is_empty(ref):
    def __init__(self, engine):
    def next_index(self):
    def _name_ref(self, name):
    def _path_ref(self, column: "ComparisonMixin"):
    def _value_ref(self, column, value, *, inner=False):
    def any_ref(self, *, column, value=missing, inner=False) -> Reference:
    def pop_refs(self, *refs):
def render(engine, obj=None, filter=None, projection=None, key=None, condition=None, update=None):
    def __init__(self, engine):
    def render(self, obj=None, condition=None, update=False, filter=None, projection=None, key=None):
    def condition_expression(self, condition):
    def filter_expression(self, condition):
    def key_expression(self, condition):
    def projection_expression(self, columns):
    def update_expression(self, obj):
    def output(self):
    def __init__(self, operation, *, column=None, values=None):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __invert__(self):
    def __and__(self, other):
    def __iand__(self, other):
    def __or__(self, other):
    def __ior__(self, other):
    def __eq__(self, other):
    def __init__(self):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, *values):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, *values):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, value):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, operation, column, value):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, column, value):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, column, lower, upper):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, column, value):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __init__(self, column, values):
    def __len__(self):
    def __repr__(self):
    def render(self, renderer):
    def __repr__(self):
    def __getitem__(self, path):
    def __eq__(self, value):
    def __ne__(self, value):
    def __lt__(self, value):
    def __gt__(self, value):
    def __le__(self, value):
    def __ge__(self, value):
    def begins_with(self, value):
    def between(self, lower, upper):
    def contains(self, value):
    def in_(self, *values):
def check_support(column: ComparisonMixin, operation):
    def __init__(self, obj, path):
    def __getattr__(self, item):
    def __getitem__(self, item):
    def __repr__(self):
def printable_name(column, path=None):
def path_of(obj):
def proxied(obj):
def iter_conditions(condition):
def iter_columns(condition):
bloop/search.py
class PreparedSearch:
    """Mutable search object.

     Creates :class:`~bloop.search.SearchModelIterator` objects which can be
     used to iterate the results of a query or search multiple times.
     """
    def __init__(self):
        self.engine = None
        self.mode = None
        self._iterator_cls = None

        self.model = None
        self.index = None
        self.consistent = None

        self.key = None

        self._projected_columns = None
        self._projection_mode = None

        self.filter = None

        self.forward = None
        self.parallel = None

        self._request = None

    def prepare(
            self, engine=None, mode=None, model=None, index=None, key=None,
            filter=None, projection=None, consistent=None, forward=None, parallel=None):
        """Validates the search parameters and builds the base request dict for each Query/Scan call."""

        self.prepare_iterator_cls(engine, mode)
        self.prepare_model(model, index, consistent)
        self.prepare_key(key)
        self.prepare_projection(projection)
        self.prepare_filter(filter)
        self.prepare_constraints(forward, parallel)

        self.prepare_request()

    def prepare_iterator_cls(self, engine, mode):
        self.engine = engine
        self.mode = mode
        validate_search_mode(mode)
        self._iterator_cls = ScanIterator if mode == "scan" else QueryIterator

    def prepare_model(self, model, index, consistent):
        self.model = model
        self.index = index
        self.consistent = consistent

    def prepare_key(self, key):
        if self.mode != "query":
            return
        self.key = key
        validate_key_condition(self.model, self.index, self.key)

    def prepare_projection(self, projection):
        self._projected_columns = validate_search_projection(self.model, self.index, projection)

        if self._projected_columns is None:
            self._projection_mode = "count"
        else:
            # Everything else is specific, even "all" on a non-strict LSI.
            # A table could have columns than this model doesn't cares about;
            # don't load those when they'll be discarded immediately.
            self._projection_mode = "specific"

    def prepare_filter(self, filter):
        self.filter = filter
        if self.mode == "query":
            # Query filters can't include the key columns
            column_blacklist = (self.index or self.model.Meta).keys
        else:
            column_blacklist = set()
        available_columns = (self.index or self.model.Meta).projection["available"]
        validate_filter_condition(self.filter, available_columns, column_blacklist)

    def prepare_constraints(self, forward, parallel):
        self.forward = forward
        self.parallel = parallel

    def prepare_request(self):
        request = self._request = {}
        request["TableName"] = self.engine._compute_table_name(self.model)
        request["ConsistentRead"] = self.consistent

        if self.mode == "scan":
            if self.parallel:
                request["Segments"], request["TotalSegments"] = self.parallel
        else:
            request["ScanIndexForward"] = self.forward

        if self.index:
            request["IndexName"] = self.index.dynamo_name
            # GSI isn't strongly consistent
            if isinstance(self.index, GlobalSecondaryIndex):
                del request["ConsistentRead"]

        if self._projection_mode == "count":
            request["Select"] = "COUNT"
            projected = None
        else:
            request["Select"] = "SPECIFIC_ATTRIBUTES"
            projected = self._projected_columns

        request.update(render(self.engine, filter=self.filter, projection=projected, key=self.key))

    def __repr__(self):
        return search_repr(self.__class__, self.model, self.index)

    def __iter__(self):
        return self._iterator_cls(
            engine=self.engine,
            model=self.model,
            index=self.index,
            request=self._request,
            projected=self._projected_columns
        )
bloop/search.py
def validate_key_condition(model, index, key):
    # Model will always be provided, but Index has priority
    query_on = index or model.Meta

    # (model or index).hash_key == value
    # Valid for both (hash,) and (hash, range)
    if check_hash_key(query_on, key):
        return

    # Failed.  Without a range key, the check above is the only valid key condition.
    if query_on.range_key is None:
        fail_bad_hash(query_on)

    # If the model or index has a range key, the condition can
    # still be (hash key condition AND range key condition)

    if not isinstance(key, BaseCondition) or key.operation != "and":
        # Too many options to fit into a useful error message.
        fail_bad_range(query_on)

    # This intentionally disallows an AND with just one hash key condition.
    # Otherwise we get into unpacking arbitrarily nested conditions.
    if len(key) != 2:
        fail_bad_range(query_on)
    first_key, second_key = key.values

    # Only two options left -- just try both.
    if check_hash_key(query_on, first_key) and check_range_key(query_on, second_key):
        return
    if check_range_key(query_on, first_key) and check_hash_key(query_on, second_key):
        return

    # Nothing else is valid.
    fail_bad_range(query_on)
bloop/search.py
def search_repr(cls, model, index):
    if model is not None:
        if index is not None:
            return "<{}[{}.{}]>".format(cls.__name__, model.__name__, index.name)
        else:
            return "<{}[{}]>".format(cls.__name__, model.__name__)
    else:
        if index is not None:
            return "<{}[None.{}]>".format(cls.__name__, index.name)
        else:
            return "<{}[None]>".format(cls.__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/search.py
class Search:
    """A user-created search object.

    Used to prepare a :class:`~bloop.search.PreparedSearch` which build search iterators.

    :param str mode: Search type, either "query" or "scan".
    :param engine: :class:`~bloop.engine.Engine` to unpack models with.
    :param model: :class:`~bloop.models.BaseModel` being searched.
    :param index: :class:`~bloop.models.Index` to search, or None.
    :param key: *(Query only)* 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 list 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.  Not applicable to GSIs.  Default is False.
    :param bool forward: *(Query only)* Use ascending or descending order.  Default is True (ascending).
    :param tuple parallel: *(Scan only)* A tuple of (Segment, TotalSegments) for this portion of a `parallel scan`__.
            Default is None.

    __ http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html
    __ http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html#QueryAndScanParallelScan
    """

    def __init__(
            self, mode=None, engine=None, model=None, index=None, key=None, filter=None,
            projection=None, consistent=False, forward=True, parallel=None):
        self.mode = mode
        self.engine = engine
        self.model = model
        self.index = index
        self.key = key
        self.filter = filter
        self.projection = projection
        self.consistent = consistent
        self.forward = forward
        self.parallel = parallel

    def __repr__(self):
        return search_repr(self.__class__, self.model, self.index)

    def prepare(self):
        """Constructs a :class:`~bloop.search.PreparedSearch`."""
        p = PreparedSearch()
        p.prepare(
            engine=self.engine,
            mode=self.mode,
            model=self.model,
            index=self.index,
            key=self.key,
            filter=self.filter,
            projection=self.projection,
            consistent=self.consistent,
            forward=self.forward,
            parallel=self.parallel
        )
        return p


Question: import collections
import functools
import pytest
from bloop.conditions import (
    AndCondition,
    BeginsWithCondition,
    BetweenCondition,
    ComparisonCondition,
    Condition,
    ContainsCondition,
    InCondition,
    NotCondition,
    OrCondition,
    comparison_aliases,
)
from bloop.exceptions import ConstraintViolation, InvalidSearch
from bloop.models import (
    BaseModel,
    Column,
    GlobalSecondaryIndex,
    LocalSecondaryIndex,
)
from bloop.search import (
    PreparedSearch,
    QueryIterator,
    ScanIterator,
    Search,
    SearchIterator,
    SearchModelIterator,
    printable_query,
    search_repr,
    validate_filter_condition,
    validate_key_condition,
    validate_search_projection,
)
from bloop.types import Integer
from bloop.util import Sentinel
from ..helpers.models import ComplexModel, User

    return condition_lambdas


def next_non_zero_index(chain, start=0):
    """Return the next index >= start of a non-zero value. -1 on failure to find"""
    non_zeros = filter(lambda x: x, chain[start:])
    value = next(non_zeros, None)
    return chain.index(value, start) if value else -1


def calls_for_current_steps(chain, current_steps):
    """The number of dynamodb calls that are required to iterate the given chain in the given number of steps.

    Here, steps is the number of values returned from next(iterator).  In the table below, the first 3 next() calls
    are all served by the first response from Dynamo, but the 4th next() requires two more calls (second call is empty)
    For example, the chain [3, 0, 1, 4] has the following table:

        +-------+---+---+---+---+---+---+---+---+---+---+
        | steps | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
        +-------+---+---+---+---+---+---+---+---+---+---+
        | calls | 1 | 1 | 1 | 1 | 3 | 4 | 4 | 4 | 4 | 4 |
        +-------+---+---+---+---+---+---+---+---+---+---+
    """
    required_steps = 0
    call_count = 0
    for call_count, page in enumerate(chain):
        required_steps += page
        if required_steps >= current_steps:
            break
    return call_count + 1


def response(count=1, terminate=False, item=Sentinel("item"), items=None):
    """This fills in the required response structure from a single query/scan call:

    Count, ScannedCount
        required fields but not important.
    LastEvaluatedKey
        tells the iterator if there are more pages available.  When it's not falsey,
        it should be fed directly back into the next request's "ExclusiveStartKey".
    Item
        is the number of items in this page.  By passing 0, 1, or 2 we can verify that the buffer is
        fully drained before the next page is loaded.  It also lets us verify that the while loop will
        follow LastEvaluatedKeys until it hits a non-empty page.
    """
    items = items or [item] * count
    return {
        "Count": count,
        "ScannedCount": count * 3,
        "Items": items,
        "LastEvaluatedKey": None if terminate else proceed
    }


def build_responses(chain, items=None):
    """This expands a compact integer description of a set of pages into the appropriate response structure.

    For example: [0, 2, 1] expands into (0 results, proceed) -> (2 results, proceed) -> (1 result, stop).
    We'll also use those integers in the verifier to compare number of results and number of calls.
    """
    items = items or []
    responses = []
    for count in chain[:-1]:
        response_items = None
        if items:
            response_items, items = items[:count], items[count:]
        responses.append(response(count=count, items=response_items))
    responses.append(response(count=chain[-1], items=items, terminate=True))
    return responses


def extract_request(mock_method):
    """Pull out the request object from the last call to the mocked method

    req = extract_request(session.search_items)
    """
    args, kwargs = mock_method.call_args
    *_, request = args
    return request


@pytest.fixture
def simple_iter(engine, session):
    def _simple_iter(cls=SearchIterator, model=User, index=None):
        kwargs = {
            "engine": engine,
            "session": session,
            "model": model,
            "index": index,
            "request": {
                "Select": "SPECIFIC_ATTRIBUTES"
            },
            "projected": set()
        }
        if issubclass(cls, SearchModelIterator):
            kwargs.pop("session")
        elif cls is SearchIterator:
            kwargs.pop("engine")

        return cls(**kwargs)
    return _simple_iter


@pytest.fixture
def valid_search(engine):
    search = Search(
        engine=engine, model=ComplexModel, index=None, key=ComplexModel.name == "foo",
        filter=None, projection="all", consistent=True, forward=False)
    search.mode = "query"
    return search


# VALIDATION TESTS =================================================================================== VALIDATION TESTS

@pytest.mark.parametrize("model, index", all_permutations)
def test_single_hash_key_success(model, index):
    """Single key condition: equality comparison on hash key"""
    query_on = index or model.Meta
    key = query_on.hash_key == "value"

Answer:     validate_key_condition(model, index, key)