Usage

To use, import where from predicate.

from predicate import where

where takes any number of keywork arguments as well as one reserved keyword argument that is combinator= which defaults to "and".

Each keyword argument (except combinator=), generates a predicate function and they are combined into a single predicate function based on the value of combinator=.

To level set, predicate functions are functions that take a single argument and return True or False. Their type signature looks like Callable[[Any], bool]. These are useful for separating a collection of objects or filtering. One of the main uses of predicates is in the builtin filter function as well as many of the grouping slicing tools in the standard lib itertools.

Inspiration

This project is heavily inspired by django queryset filters. These make it very easy to build complex SQL filters when working with the Django ORM. This project is an attempt to make the developer friendliness of the ORM filtering available to native python collections.

Basic Usage

The most simple usage of where is for simple equality filtering of an attribute.

>>> from predicate import where

>>> is_18 = where(age=18)

>>> is_18({'age': 18})
True

>>> list(filter(is_18, [{'name': 'Joe', 'age': 18}, {'name': 'Bob', 'age': 19}]))
[{'name': 'Joe', 'age': 18}]

The equivalent code could be expressed with a lambda:

is_18 = lambda x: x.get('age', None) == 18

however in this case it may or may not be more readable.

However, one of the things the lambda cant handle that where can is mixed dictionary and objects.

>>> class Person:
...     def __init__(self, name, age):
...         self.name = name
...         self.age = age
...     
...     def __repr__(self):
...         return f"{self.name} ({self.age})"

>>> mixed_data = [
...     {'name': 'Joe', 'age': 18},
...     {'name': 'Bob', 'age': 19},
...     Person('Jane', 18)
... ]

>>> list(filter(where(age=18), mixed_data))
[{'name': 'Joe', 'age': 18}, Jane (18)]

Compound Predicates

Going further into capabilities, we can pass multiple keyword arguments to where to produce a compound predicate.

>>> from predicate import where

>>> is_18yo_male = where(age=18, gender='M')

>>> is_18yo_male({'age': 18, 'gender': 'F'})
False

Again this logic can be reproduced using a lambda:

is_18yo_male = lambda x: x.get('age', None) == 18 and x.get('gender', None) == 'F'

You can chain as many of them as you need, making the readablity increase with the more complex predicates.

Complex Combinators

By Default, where uses combinator='and', which combines all of the predicates using and. This means by default, that all of the predicates need to be met for where to also return True. However, there are other combinators available:

  • comininator='and' (default) - Returns True only if all of the predicates are True
  • combinator='or' - Returns True if any of the predicates are True
  • combinator='none' - Returns True only if none of the predicates are True (all are False)
  • combinator='nand' - Only Returns False if all predicates are True otherwise returns True. Useful for filtering out specific cases.
>>> from predicate import where 

>>> is_18_or_is_female = where(age=18, gender='F', combinator='or')

>>> is_18_or_is_female({'age': 18, 'gender': 'F'})
True

>>> is_18_or_is_female({'age': 17, 'gender': 'M'})
False

>>> is_not_18_or_female = where(age=18, gender='F', combinator='none')

>>> is_not_18_or_female({'age': 18, 'gender': 'M'})
False

>>> is_not_18_or_female({'age': 18, 'gender': 'F'})
False

>>> is_not_18_or_female({'age': 17, 'gender': 'M'})
True

>>> is_not_18_and_female = where(age=18, gender='F', combinator='nand')

>>> is_not_18_and_female({'age': 17, 'gender': 'M'})
True

>>> is_not_18_and_female({'age': 18, 'gender': 'M'})
True

>>> is_not_18_and_female({'age': 18, 'gender': 'F'})
False

Nested Data

Getting beyond the simple use case, where follows django queryset filters in digging into nested data structures by allowing access to nested fields via __.

>>> from predicate import where

>>> is_smith = where(name__last='Smith')

>>> is_smith({'name': {'first': 'John', 'last': 'Smith'}})
True

This nested operation digs through both objects and dictionaries. Missing keys will be returned as None.

NonEquality Comparisons

Another idea borrowed from django querysets is to do filtering beyond simple equality. These non-equality comparators, like in django querysets, are a suffix with __. The avaliable comparators are:

  • __eq (default) - Equals
  • __gt Greater than
  • __gte Greater than or equal to
  • __lt Less than
  • __lte Less than or equal to
  • __ne Not equal to
  • __contains Checks for membership using in. Can be uses on anything that in can be used with
  • __startswith Does the field start with the given string
  • __endswith Does the field end with the given string
  • __exact Same as equals but here for compatibility with django filters
  • __iexact Case insensitive string comparison
  • __in Does the field exist in a given collection.
  • __type Check if the value at a given position is a given type
>>> from predicate import where

>>> is_adult = where(age__gte=18)

>>> is_adult({'age': 17})
False

>>> is_adult({'age': 18})
True

>>> age_is_int = where(age__type=int)

>>> age_is_int({'age': 18.5})
False

Custom Comparisons

If the given set of comparators is not enough, there is a __func, comparator that takes a predicate function that will be applied at the given key.

>>> from predicate import where 

>>> last_name_has_5_characters = where(name__last__func=lambda name: len(name) == 5)

>>> last_name_has_5_characters({'name': {'last': 'Smith'}})
True

>>> last_name_has_5_characters({'name': {'last': 'Bob'}})
False

Custom Combinators

Like with __func, if the given combinators (and, or, none) dont work for you, you can pass your own combinator function in to the combinator= argument. This function should take have the signature Callable[[Iterable[bool]], bool].

>>> from predicate import where

>>> only_one_true = lambda xs: sum(xs) == 1

>>> is_eligible = where(age=18, name__first__startswith='K', gender='F', combinator=only_one_true)

>>> is_eligible({'age': 18, 'name': {'first': 'John', 'last': 'Smith'}, 'gender': 'M'})
True

>>> is_eligible({'age': 18, 'name': {'first': 'John', 'last': 'Smith'}, 'gender': 'F'})
False