You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
559 lines
16 KiB
Python
559 lines
16 KiB
Python
5 years ago
|
"""
|
||
|
Cycler
|
||
|
======
|
||
|
|
||
|
Cycling through combinations of values, producing dictionaries.
|
||
|
|
||
|
You can add cyclers::
|
||
|
|
||
|
from cycler import cycler
|
||
|
cc = (cycler(color=list('rgb')) +
|
||
|
cycler(linestyle=['-', '--', '-.']))
|
||
|
for d in cc:
|
||
|
print(d)
|
||
|
|
||
|
Results in::
|
||
|
|
||
|
{'color': 'r', 'linestyle': '-'}
|
||
|
{'color': 'g', 'linestyle': '--'}
|
||
|
{'color': 'b', 'linestyle': '-.'}
|
||
|
|
||
|
|
||
|
You can multiply cyclers::
|
||
|
|
||
|
from cycler import cycler
|
||
|
cc = (cycler(color=list('rgb')) *
|
||
|
cycler(linestyle=['-', '--', '-.']))
|
||
|
for d in cc:
|
||
|
print(d)
|
||
|
|
||
|
Results in::
|
||
|
|
||
|
{'color': 'r', 'linestyle': '-'}
|
||
|
{'color': 'r', 'linestyle': '--'}
|
||
|
{'color': 'r', 'linestyle': '-.'}
|
||
|
{'color': 'g', 'linestyle': '-'}
|
||
|
{'color': 'g', 'linestyle': '--'}
|
||
|
{'color': 'g', 'linestyle': '-.'}
|
||
|
{'color': 'b', 'linestyle': '-'}
|
||
|
{'color': 'b', 'linestyle': '--'}
|
||
|
{'color': 'b', 'linestyle': '-.'}
|
||
|
"""
|
||
|
|
||
|
from __future__ import (absolute_import, division, print_function,
|
||
|
unicode_literals)
|
||
|
|
||
|
import six
|
||
|
from itertools import product, cycle
|
||
|
from six.moves import zip, reduce
|
||
|
from operator import mul, add
|
||
|
import copy
|
||
|
|
||
|
__version__ = '0.10.0'
|
||
|
|
||
|
|
||
|
def _process_keys(left, right):
|
||
|
"""
|
||
|
Helper function to compose cycler keys
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
left, right : iterable of dictionaries or None
|
||
|
The cyclers to be composed
|
||
|
Returns
|
||
|
-------
|
||
|
keys : set
|
||
|
The keys in the composition of the two cyclers
|
||
|
"""
|
||
|
l_peek = next(iter(left)) if left is not None else {}
|
||
|
r_peek = next(iter(right)) if right is not None else {}
|
||
|
l_key = set(l_peek.keys())
|
||
|
r_key = set(r_peek.keys())
|
||
|
if l_key & r_key:
|
||
|
raise ValueError("Can not compose overlapping cycles")
|
||
|
return l_key | r_key
|
||
|
|
||
|
|
||
|
class Cycler(object):
|
||
|
"""
|
||
|
Composable cycles
|
||
|
|
||
|
This class has compositions methods:
|
||
|
|
||
|
``+``
|
||
|
for 'inner' products (zip)
|
||
|
|
||
|
``+=``
|
||
|
in-place ``+``
|
||
|
|
||
|
``*``
|
||
|
for outer products (itertools.product) and integer multiplication
|
||
|
|
||
|
``*=``
|
||
|
in-place ``*``
|
||
|
|
||
|
and supports basic slicing via ``[]``
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
left : Cycler or None
|
||
|
The 'left' cycler
|
||
|
|
||
|
right : Cycler or None
|
||
|
The 'right' cycler
|
||
|
|
||
|
op : func or None
|
||
|
Function which composes the 'left' and 'right' cyclers.
|
||
|
|
||
|
"""
|
||
|
def __call__(self):
|
||
|
return cycle(self)
|
||
|
|
||
|
def __init__(self, left, right=None, op=None):
|
||
|
"""Semi-private init
|
||
|
|
||
|
Do not use this directly, use `cycler` function instead.
|
||
|
"""
|
||
|
if isinstance(left, Cycler):
|
||
|
self._left = Cycler(left._left, left._right, left._op)
|
||
|
elif left is not None:
|
||
|
# Need to copy the dictionary or else that will be a residual
|
||
|
# mutable that could lead to strange errors
|
||
|
self._left = [copy.copy(v) for v in left]
|
||
|
else:
|
||
|
self._left = None
|
||
|
|
||
|
if isinstance(right, Cycler):
|
||
|
self._right = Cycler(right._left, right._right, right._op)
|
||
|
elif right is not None:
|
||
|
# Need to copy the dictionary or else that will be a residual
|
||
|
# mutable that could lead to strange errors
|
||
|
self._right = [copy.copy(v) for v in right]
|
||
|
else:
|
||
|
self._right = None
|
||
|
|
||
|
self._keys = _process_keys(self._left, self._right)
|
||
|
self._op = op
|
||
|
|
||
|
@property
|
||
|
def keys(self):
|
||
|
"""
|
||
|
The keys this Cycler knows about
|
||
|
"""
|
||
|
return set(self._keys)
|
||
|
|
||
|
def change_key(self, old, new):
|
||
|
"""
|
||
|
Change a key in this cycler to a new name.
|
||
|
Modification is performed in-place.
|
||
|
|
||
|
Does nothing if the old key is the same as the new key.
|
||
|
Raises a ValueError if the new key is already a key.
|
||
|
Raises a KeyError if the old key isn't a key.
|
||
|
|
||
|
"""
|
||
|
if old == new:
|
||
|
return
|
||
|
if new in self._keys:
|
||
|
raise ValueError("Can't replace %s with %s, %s is already a key" %
|
||
|
(old, new, new))
|
||
|
if old not in self._keys:
|
||
|
raise KeyError("Can't replace %s with %s, %s is not a key" %
|
||
|
(old, new, old))
|
||
|
|
||
|
self._keys.remove(old)
|
||
|
self._keys.add(new)
|
||
|
|
||
|
if self._right is not None and old in self._right.keys:
|
||
|
self._right.change_key(old, new)
|
||
|
|
||
|
# self._left should always be non-None
|
||
|
# if self._keys is non-empty.
|
||
|
elif isinstance(self._left, Cycler):
|
||
|
self._left.change_key(old, new)
|
||
|
else:
|
||
|
# It should be completely safe at this point to
|
||
|
# assume that the old key can be found in each
|
||
|
# iteration.
|
||
|
self._left = [{new: entry[old]} for entry in self._left]
|
||
|
|
||
|
def _compose(self):
|
||
|
"""
|
||
|
Compose the 'left' and 'right' components of this cycle
|
||
|
with the proper operation (zip or product as of now)
|
||
|
"""
|
||
|
for a, b in self._op(self._left, self._right):
|
||
|
out = dict()
|
||
|
out.update(a)
|
||
|
out.update(b)
|
||
|
yield out
|
||
|
|
||
|
@classmethod
|
||
|
def _from_iter(cls, label, itr):
|
||
|
"""
|
||
|
Class method to create 'base' Cycler objects
|
||
|
that do not have a 'right' or 'op' and for which
|
||
|
the 'left' object is not another Cycler.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
label : str
|
||
|
The property key.
|
||
|
|
||
|
itr : iterable
|
||
|
Finite length iterable of the property values.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
cycler : Cycler
|
||
|
New 'base' `Cycler`
|
||
|
"""
|
||
|
ret = cls(None)
|
||
|
ret._left = list({label: v} for v in itr)
|
||
|
ret._keys = set([label])
|
||
|
return ret
|
||
|
|
||
|
def __getitem__(self, key):
|
||
|
# TODO : maybe add numpy style fancy slicing
|
||
|
if isinstance(key, slice):
|
||
|
trans = self.by_key()
|
||
|
return reduce(add, (_cycler(k, v[key])
|
||
|
for k, v in six.iteritems(trans)))
|
||
|
else:
|
||
|
raise ValueError("Can only use slices with Cycler.__getitem__")
|
||
|
|
||
|
def __iter__(self):
|
||
|
if self._right is None:
|
||
|
return iter(dict(l) for l in self._left)
|
||
|
|
||
|
return self._compose()
|
||
|
|
||
|
def __add__(self, other):
|
||
|
"""
|
||
|
Pair-wise combine two equal length cycles (zip)
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
other : Cycler
|
||
|
The second Cycler
|
||
|
"""
|
||
|
if len(self) != len(other):
|
||
|
raise ValueError("Can only add equal length cycles, "
|
||
|
"not {0} and {1}".format(len(self), len(other)))
|
||
|
return Cycler(self, other, zip)
|
||
|
|
||
|
def __mul__(self, other):
|
||
|
"""
|
||
|
Outer product of two cycles (`itertools.product`) or integer
|
||
|
multiplication.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
other : Cycler or int
|
||
|
The second Cycler or integer
|
||
|
"""
|
||
|
if isinstance(other, Cycler):
|
||
|
return Cycler(self, other, product)
|
||
|
elif isinstance(other, int):
|
||
|
trans = self.by_key()
|
||
|
return reduce(add, (_cycler(k, v*other)
|
||
|
for k, v in six.iteritems(trans)))
|
||
|
else:
|
||
|
return NotImplemented
|
||
|
|
||
|
def __rmul__(self, other):
|
||
|
return self * other
|
||
|
|
||
|
def __len__(self):
|
||
|
op_dict = {zip: min, product: mul}
|
||
|
if self._right is None:
|
||
|
return len(self._left)
|
||
|
l_len = len(self._left)
|
||
|
r_len = len(self._right)
|
||
|
return op_dict[self._op](l_len, r_len)
|
||
|
|
||
|
def __iadd__(self, other):
|
||
|
"""
|
||
|
In-place pair-wise combine two equal length cycles (zip)
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
other : Cycler
|
||
|
The second Cycler
|
||
|
"""
|
||
|
if not isinstance(other, Cycler):
|
||
|
raise TypeError("Cannot += with a non-Cycler object")
|
||
|
# True shallow copy of self is fine since this is in-place
|
||
|
old_self = copy.copy(self)
|
||
|
self._keys = _process_keys(old_self, other)
|
||
|
self._left = old_self
|
||
|
self._op = zip
|
||
|
self._right = Cycler(other._left, other._right, other._op)
|
||
|
return self
|
||
|
|
||
|
def __imul__(self, other):
|
||
|
"""
|
||
|
In-place outer product of two cycles (`itertools.product`)
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
other : Cycler
|
||
|
The second Cycler
|
||
|
"""
|
||
|
if not isinstance(other, Cycler):
|
||
|
raise TypeError("Cannot *= with a non-Cycler object")
|
||
|
# True shallow copy of self is fine since this is in-place
|
||
|
old_self = copy.copy(self)
|
||
|
self._keys = _process_keys(old_self, other)
|
||
|
self._left = old_self
|
||
|
self._op = product
|
||
|
self._right = Cycler(other._left, other._right, other._op)
|
||
|
return self
|
||
|
|
||
|
def __eq__(self, other):
|
||
|
"""
|
||
|
Check equality
|
||
|
"""
|
||
|
if len(self) != len(other):
|
||
|
return False
|
||
|
if self.keys ^ other.keys:
|
||
|
return False
|
||
|
|
||
|
return all(a == b for a, b in zip(self, other))
|
||
|
|
||
|
def __repr__(self):
|
||
|
op_map = {zip: '+', product: '*'}
|
||
|
if self._right is None:
|
||
|
lab = self.keys.pop()
|
||
|
itr = list(v[lab] for v in self)
|
||
|
return "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr)
|
||
|
else:
|
||
|
op = op_map.get(self._op, '?')
|
||
|
msg = "({left!r} {op} {right!r})"
|
||
|
return msg.format(left=self._left, op=op, right=self._right)
|
||
|
|
||
|
def _repr_html_(self):
|
||
|
# an table showing the value of each key through a full cycle
|
||
|
output = "<table>"
|
||
|
sorted_keys = sorted(self.keys, key=repr)
|
||
|
for key in sorted_keys:
|
||
|
output += "<th>{key!r}</th>".format(key=key)
|
||
|
for d in iter(self):
|
||
|
output += "<tr>"
|
||
|
for k in sorted_keys:
|
||
|
output += "<td>{val!r}</td>".format(val=d[k])
|
||
|
output += "</tr>"
|
||
|
output += "</table>"
|
||
|
return output
|
||
|
|
||
|
def by_key(self):
|
||
|
"""Values by key
|
||
|
|
||
|
This returns the transposed values of the cycler. Iterating
|
||
|
over a `Cycler` yields dicts with a single value for each key,
|
||
|
this method returns a `dict` of `list` which are the values
|
||
|
for the given key.
|
||
|
|
||
|
The returned value can be used to create an equivalent `Cycler`
|
||
|
using only `+`.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
transpose : dict
|
||
|
dict of lists of the values for each key.
|
||
|
"""
|
||
|
|
||
|
# TODO : sort out if this is a bottle neck, if there is a better way
|
||
|
# and if we care.
|
||
|
|
||
|
keys = self.keys
|
||
|
# change this to dict comprehension when drop 2.6
|
||
|
out = dict((k, list()) for k in keys)
|
||
|
|
||
|
for d in self:
|
||
|
for k in keys:
|
||
|
out[k].append(d[k])
|
||
|
return out
|
||
|
|
||
|
# for back compatibility
|
||
|
_transpose = by_key
|
||
|
|
||
|
def simplify(self):
|
||
|
"""Simplify the Cycler
|
||
|
|
||
|
Returned as a composition using only sums (no multiplications)
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
simple : Cycler
|
||
|
An equivalent cycler using only summation"""
|
||
|
# TODO: sort out if it is worth the effort to make sure this is
|
||
|
# balanced. Currently it is is
|
||
|
# (((a + b) + c) + d) vs
|
||
|
# ((a + b) + (c + d))
|
||
|
# I would believe that there is some performance implications
|
||
|
|
||
|
trans = self.by_key()
|
||
|
return reduce(add, (_cycler(k, v) for k, v in six.iteritems(trans)))
|
||
|
|
||
|
def concat(self, other):
|
||
|
"""Concatenate this cycler and an other.
|
||
|
|
||
|
The keys must match exactly.
|
||
|
|
||
|
This returns a single Cycler which is equivalent to
|
||
|
`itertools.chain(self, other)`
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
|
||
|
>>> num = cycler('a', range(3))
|
||
|
>>> let = cycler('a', 'abc')
|
||
|
>>> num.concat(let)
|
||
|
cycler('a', [0, 1, 2, 'a', 'b', 'c'])
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
other : `Cycler`
|
||
|
The `Cycler` to concatenate to this one.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ret : `Cycler`
|
||
|
The concatenated `Cycler`
|
||
|
"""
|
||
|
return concat(self, other)
|
||
|
|
||
|
|
||
|
def concat(left, right):
|
||
|
"""Concatenate two cyclers.
|
||
|
|
||
|
The keys must match exactly.
|
||
|
|
||
|
This returns a single Cycler which is equivalent to
|
||
|
`itertools.chain(left, right)`
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
|
||
|
>>> num = cycler('a', range(3))
|
||
|
>>> let = cycler('a', 'abc')
|
||
|
>>> num.concat(let)
|
||
|
cycler('a', [0, 1, 2, 'a', 'b', 'c'])
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
left, right : `Cycler`
|
||
|
The two `Cycler` instances to concatenate
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
ret : `Cycler`
|
||
|
The concatenated `Cycler`
|
||
|
"""
|
||
|
if left.keys != right.keys:
|
||
|
msg = '\n\t'.join(["Keys do not match:",
|
||
|
"Intersection: {both!r}",
|
||
|
"Disjoint: {just_one!r}"]).format(
|
||
|
both=left.keys & right.keys,
|
||
|
just_one=left.keys ^ right.keys)
|
||
|
|
||
|
raise ValueError(msg)
|
||
|
|
||
|
_l = left.by_key()
|
||
|
_r = right.by_key()
|
||
|
return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys))
|
||
|
|
||
|
|
||
|
def cycler(*args, **kwargs):
|
||
|
"""
|
||
|
Create a new `Cycler` object from a single positional argument,
|
||
|
a pair of positional arguments, or the combination of keyword arguments.
|
||
|
|
||
|
cycler(arg)
|
||
|
cycler(label1=itr1[, label2=iter2[, ...]])
|
||
|
cycler(label, itr)
|
||
|
|
||
|
Form 1 simply copies a given `Cycler` object.
|
||
|
|
||
|
Form 2 composes a `Cycler` as an inner product of the
|
||
|
pairs of keyword arguments. In other words, all of the
|
||
|
iterables are cycled simultaneously, as if through zip().
|
||
|
|
||
|
Form 3 creates a `Cycler` from a label and an iterable.
|
||
|
This is useful for when the label cannot be a keyword argument
|
||
|
(e.g., an integer or a name that has a space in it).
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
arg : Cycler
|
||
|
Copy constructor for Cycler (does a shallow copy of iterables).
|
||
|
|
||
|
label : name
|
||
|
The property key. In the 2-arg form of the function,
|
||
|
the label can be any hashable object. In the keyword argument
|
||
|
form of the function, it must be a valid python identifier.
|
||
|
|
||
|
itr : iterable
|
||
|
Finite length iterable of the property values.
|
||
|
Can be a single-property `Cycler` that would
|
||
|
be like a key change, but as a shallow copy.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
cycler : Cycler
|
||
|
New `Cycler` for the given property
|
||
|
|
||
|
"""
|
||
|
if args and kwargs:
|
||
|
raise TypeError("cyl() can only accept positional OR keyword "
|
||
|
"arguments -- not both.")
|
||
|
|
||
|
if len(args) == 1:
|
||
|
if not isinstance(args[0], Cycler):
|
||
|
raise TypeError("If only one positional argument given, it must "
|
||
|
" be a Cycler instance.")
|
||
|
return Cycler(args[0])
|
||
|
elif len(args) == 2:
|
||
|
return _cycler(*args)
|
||
|
elif len(args) > 2:
|
||
|
raise TypeError("Only a single Cycler can be accepted as the lone "
|
||
|
"positional argument. Use keyword arguments instead.")
|
||
|
|
||
|
if kwargs:
|
||
|
return reduce(add, (_cycler(k, v) for k, v in six.iteritems(kwargs)))
|
||
|
|
||
|
raise TypeError("Must have at least a positional OR keyword arguments")
|
||
|
|
||
|
|
||
|
def _cycler(label, itr):
|
||
|
"""
|
||
|
Create a new `Cycler` object from a property name and
|
||
|
iterable of values.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
label : hashable
|
||
|
The property key.
|
||
|
|
||
|
itr : iterable
|
||
|
Finite length iterable of the property values.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
cycler : Cycler
|
||
|
New `Cycler` for the given property
|
||
|
"""
|
||
|
if isinstance(itr, Cycler):
|
||
|
keys = itr.keys
|
||
|
if len(keys) != 1:
|
||
|
msg = "Can not create Cycler from a multi-property Cycler"
|
||
|
raise ValueError(msg)
|
||
|
|
||
|
lab = keys.pop()
|
||
|
# Doesn't need to be a new list because
|
||
|
# _from_iter() will be creating that new list anyway.
|
||
|
itr = (v[lab] for v in itr)
|
||
|
|
||
|
return Cycler._from_iter(label, itr)
|