"""
Common patterns for working with :term:`mapping` objects
"""
import collections
import itertools
import operator
__all__ = [
'NormalizedMapMixin',
'NormalizedMap',
'merged',
'collated',
'bisect',
'as_tuple',
]
[docs]class NormalizedMapMixin(object):
"""
A mixin to automatically normalize :term:`mapping` keys
When this mixin is included in an implementation of
`collections.abc.Mapping`, a user-defined normalizing function (i.e.,
`normalized_key()`) will be applied to keys before values are stored, read,
deleted, or searched in the :term:`mapping`. E.g.:
.. testcode::
import collections
from fictive.patterns.mappings import NormalizedMapMixin
class UpperCaseMapping(NormalizedMapMixin, collections.UserDict):
@staticmethod
def normalized_key(key):
return key.upper()
.. doctest::
>>> normalized = UpperCaseMapping({'foo': 'bar'})
>>> list(normalized.items())
[('FOO', 'bar')]
>>> 'fOO' in normalized
True
>>> normalized['FoO']
'bar'
"""
[docs] @staticmethod
def normalized_key(key):
"""
Function applied to all keys on read / write / search of the mapping
:param key: The key to normalize
By default, this is the identify function (i.e., return `key`
unchanged), but this can be overridden by subclassing, or on a
per-sinstance basis as an argument to the `__init__()` method
"""
return key
[docs] def __init__(self, *args, **kwargs):
"""
Initialize a :term:`mapping` with an optional normalization function
In addition to the base mapping class's arguments, this mixin can
accept an additional positional argument that specifies the
normalization function to use for this instance. The normalization
function must be passed as the last positional argument and should take
a single `key` positional argument. E.g.:
.. testcode::
import collections
from fictive.patterns.mappings import NormalizedMapMixin
class AdHocNormalizing(NormalizedMapMixin, collections.UserDict):
pass
.. doctest::
>>> normalized = AdHocNormalizing({'FOO': 'bar'}, str.lower)
>>> list(normalized.items())
[('foo', 'bar')]
>>> 'fOO' in normalized
True
>>> normalized['FoO']
'bar'
"""
if len(args) > 0:
is_iterable = isinstance(args[-1], collections.abc.Iterable)
is_callable = isinstance(args[-1], collections.abc.Callable)
if is_callable and not is_iterable:
args, key_func = args[:-1], args[-1]
# this `setattr` indirection prevents linters from complaining
# about attributes hiding methods
setattr(self, 'normalized_key', key_func)
super(NormalizedMapMixin, self).__init__(*args, **kwargs)
def __getitem__(self, key):
return super(NormalizedMapMixin, self).__getitem__(
self.normalized_key(key)
)
def __setitem__(self, key, value):
return super(NormalizedMapMixin, self).__setitem__(
self.normalized_key(key), value
)
def __delitem__(self, key):
return super(NormalizedMapMixin, self).__delitem__(
self.normalized_key(key)
)
def __contains__(self, key):
return super(NormalizedMapMixin, self).__contains__(
self.normalized_key(key)
)
[docs] def get(self, key, default=None):
"""
re-implementation standard :py:meth:`dict.get` behavior
Some built in `collections.Mapping` classes don't use `__getitem__`
(e.g., `collections.OrderedDict`) and thus would not apply the
noralization function this method were not overridden.
"""
return self[key] if key in self else default
# pylint: disable=too-many-ancestors
[docs]class NormalizedMap(NormalizedMapMixin, collections.UserDict):
"""
Concrete implementation of `NormalizedMapMixin` for ease of use
This class can be directly initialized by providing a normalization
function to the `NormalizedMapMixin.__init__()` method. E.g.:
.. doctest::
>>> from fictive.patterns.mappings import NormalizedMap
>>> normalized = NormalizedMap({'foo': 'bar'}, str.upper)
>>> list(normalized.items())
[('FOO', 'bar')]
>>> 'fOO' in normalized
True
>>> normalized['FoO']
'bar'
"""
# pylint: enable=too-many-ancestors
[docs]def merged(*maps, merged_class=dict):
"""
create a new :term:`mapping` by combining `maps`
E.g.:
.. testsetup::
from fictive.patterns.mappings import merged
.. doctest::
>>> map_1 = {'name': 'Adam', 'occupation': 'farmer'}
>>> map_2 = {'name': 'Bill', 'height': 'tall'}
>>> import pprint
>>> pprint.pprint(merged(map_1, map_2))
{'height': 'tall', 'name': 'Adam', 'occupation': 'farmer'}
:param maps:
zero or more :term:`mapping`\\s that wil be merged into a new
`collections.abc.Mapping` (e.g., a `dict`). Values for any duplicate
keys will be resolved using the leftmost :term:`mapping` having a value
for that key. If no `maps` are specified, a single empty `dict` is
created.
:type maps: `collections.abc.Mapping`
:param merged_class:
The :term:`type` of the new mapping to be created. If provided, this
should be a :term:`mapping` type (e.g., a subclass of
`collections.abc.Mapping`)
"""
return merged_class(collections.ChainMap(*maps))
[docs]def collated(sortable, key=None, group_key=None):
"""
create :term:`mapping` from a (sortable) iterable, grouping by `key`
E.g.:
.. testcode::
import operator
import pprint
from fictive.patterns.mappings import collated
data = [
('colors', 'red'),
('vegetables', 'carrot'),
('cities', 'Paris'),
('vegetables', 'celery'),
('colors', 'blue'),
('colors', 'green'),
('cities', 'London'),
]
.. doctest::
>>> pprint.pprint(collated(data, key=operator.itemgetter(0)))
{'cities': [('cities', 'London'), ('cities', 'Paris')],
'colors': [('colors', 'blue'), ('colors', 'green'), ('colors', 'red')],
'vegetables': [('vegetables', 'carrot'), ('vegetables', 'celery')]}
:param sortable: an iterable to be collated
:type sortable: `collections.abc.Iterable`
:param key:
specifies a function of one argument that is used to extract a
collation key from each element in `sortable`. If not specified or is
:code:`None`, `key` defaults to an identity function and returns the
element unchanged. The function should be suitable as the `key`
parameter to `sorted()` and `itertools.groupby()`.
:type key: `collections.abc.Callable`
:param group_key:
key specifies a function of one argument that is used to extract a
comparison key from each element in each collation group (for example,
:code:`group_key=str.lower`). The default value is :code:`None`
(compare the elements directly).
:type group_key: `collections.abc.Callable`
"""
return {
key: sorted(group, key=group_key)
for (key, group) in itertools.groupby(
sorted(
sortable,
key=key,
),
key=key,
)
}
[docs]def bisect(mapping, key):
"""
split `mapping` into two :term:`mapping`\\s based on `key`
E.g.:
.. testcode::
import pprint
from fictive.patterns.mappings import bisect
data = {
'color': 'red',
'vegetable': 'carrot',
'cities': 'new york',
'size': 'small',
'temperature': 'hot',
'age': 'old',
}
.. doctest::
>>> key_func = lambda key, value: len(value) < 4
>>> pprint.pprint(bisect(mapping=data, key=key_func))
({'age': 'old', 'color': 'red', 'temperature': 'hot'},
{'cities': 'new york', 'size': 'small', 'vegetable': 'carrot'})
:param mapping: the mapping to bisect
:type mapping: `collections.abc.Mapping`
:param key:
specifies a function of two positional arguments that is used to
extract a bisection key from each item in `mapping`. The arguments to
the `key` function are the `key` and `value` for each item in `mapping`
:type key: `collections.abc.Callable`
"""
true_mapping = {k_: v_ for (k_, v_) in mapping.items() if key(k_, v_)}
false_mapping = {k_: v_ for (k_, v_) in mapping.items() if not key(k_, v_)}
return true_mapping, false_mapping
[docs]def as_tuple(mapping, name='as_tuple'):
"""
Converts a :term:`mapping` to a `collections.namedtuple`
E.g.:
.. testcode::
from fictive.patterns.mappings import as_tuple
mapping = {
'city': 'New York',
'color': 'red',
'vegetable': 'celery',
}
.. doctest::
>>> as_tuple(mapping)
as_tuple(city='New York', color='red', vegetable='celery')
>>> as_tuple(mapping, name='custom_type')
custom_type(city='New York', color='red', vegetable='celery')
"""
items = list(mapping.items())
tuple_class = collections.namedtuple(
name,
map(operator.itemgetter(0), items)
)
return tuple_class(*map(operator.itemgetter(1), items))