Source code for fictive.patterns.mappings

"""
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))