cycler.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. """
  2. Cycler
  3. ======
  4. Cycling through combinations of values, producing dictionaries.
  5. You can add cyclers::
  6. from cycler import cycler
  7. cc = (cycler(color=list('rgb')) +
  8. cycler(linestyle=['-', '--', '-.']))
  9. for d in cc:
  10. print(d)
  11. Results in::
  12. {'color': 'r', 'linestyle': '-'}
  13. {'color': 'g', 'linestyle': '--'}
  14. {'color': 'b', 'linestyle': '-.'}
  15. You can multiply cyclers::
  16. from cycler import cycler
  17. cc = (cycler(color=list('rgb')) *
  18. cycler(linestyle=['-', '--', '-.']))
  19. for d in cc:
  20. print(d)
  21. Results in::
  22. {'color': 'r', 'linestyle': '-'}
  23. {'color': 'r', 'linestyle': '--'}
  24. {'color': 'r', 'linestyle': '-.'}
  25. {'color': 'g', 'linestyle': '-'}
  26. {'color': 'g', 'linestyle': '--'}
  27. {'color': 'g', 'linestyle': '-.'}
  28. {'color': 'b', 'linestyle': '-'}
  29. {'color': 'b', 'linestyle': '--'}
  30. {'color': 'b', 'linestyle': '-.'}
  31. """
  32. from __future__ import (absolute_import, division, print_function,
  33. unicode_literals)
  34. import six
  35. from itertools import product, cycle
  36. from six.moves import zip, reduce
  37. from operator import mul, add
  38. import copy
  39. __version__ = '0.10.0'
  40. def _process_keys(left, right):
  41. """
  42. Helper function to compose cycler keys
  43. Parameters
  44. ----------
  45. left, right : iterable of dictionaries or None
  46. The cyclers to be composed
  47. Returns
  48. -------
  49. keys : set
  50. The keys in the composition of the two cyclers
  51. """
  52. l_peek = next(iter(left)) if left is not None else {}
  53. r_peek = next(iter(right)) if right is not None else {}
  54. l_key = set(l_peek.keys())
  55. r_key = set(r_peek.keys())
  56. if l_key & r_key:
  57. raise ValueError("Can not compose overlapping cycles")
  58. return l_key | r_key
  59. class Cycler(object):
  60. """
  61. Composable cycles
  62. This class has compositions methods:
  63. ``+``
  64. for 'inner' products (zip)
  65. ``+=``
  66. in-place ``+``
  67. ``*``
  68. for outer products (`itertools.product`) and integer multiplication
  69. ``*=``
  70. in-place ``*``
  71. and supports basic slicing via ``[]``
  72. Parameters
  73. ----------
  74. left : Cycler or None
  75. The 'left' cycler
  76. right : Cycler or None
  77. The 'right' cycler
  78. op : func or None
  79. Function which composes the 'left' and 'right' cyclers.
  80. """
  81. def __call__(self):
  82. return cycle(self)
  83. def __init__(self, left, right=None, op=None):
  84. """Semi-private init
  85. Do not use this directly, use `cycler` function instead.
  86. """
  87. if isinstance(left, Cycler):
  88. self._left = Cycler(left._left, left._right, left._op)
  89. elif left is not None:
  90. # Need to copy the dictionary or else that will be a residual
  91. # mutable that could lead to strange errors
  92. self._left = [copy.copy(v) for v in left]
  93. else:
  94. self._left = None
  95. if isinstance(right, Cycler):
  96. self._right = Cycler(right._left, right._right, right._op)
  97. elif right is not None:
  98. # Need to copy the dictionary or else that will be a residual
  99. # mutable that could lead to strange errors
  100. self._right = [copy.copy(v) for v in right]
  101. else:
  102. self._right = None
  103. self._keys = _process_keys(self._left, self._right)
  104. self._op = op
  105. def __contains__(self, k):
  106. return k in self._keys
  107. @property
  108. def keys(self):
  109. """
  110. The keys this Cycler knows about
  111. """
  112. return set(self._keys)
  113. def change_key(self, old, new):
  114. """
  115. Change a key in this cycler to a new name.
  116. Modification is performed in-place.
  117. Does nothing if the old key is the same as the new key.
  118. Raises a ValueError if the new key is already a key.
  119. Raises a KeyError if the old key isn't a key.
  120. """
  121. if old == new:
  122. return
  123. if new in self._keys:
  124. raise ValueError("Can't replace %s with %s, %s is already a key" %
  125. (old, new, new))
  126. if old not in self._keys:
  127. raise KeyError("Can't replace %s with %s, %s is not a key" %
  128. (old, new, old))
  129. self._keys.remove(old)
  130. self._keys.add(new)
  131. if self._right is not None and old in self._right.keys:
  132. self._right.change_key(old, new)
  133. # self._left should always be non-None
  134. # if self._keys is non-empty.
  135. elif isinstance(self._left, Cycler):
  136. self._left.change_key(old, new)
  137. else:
  138. # It should be completely safe at this point to
  139. # assume that the old key can be found in each
  140. # iteration.
  141. self._left = [{new: entry[old]} for entry in self._left]
  142. def _compose(self):
  143. """
  144. Compose the 'left' and 'right' components of this cycle
  145. with the proper operation (zip or product as of now)
  146. """
  147. for a, b in self._op(self._left, self._right):
  148. out = dict()
  149. out.update(a)
  150. out.update(b)
  151. yield out
  152. @classmethod
  153. def _from_iter(cls, label, itr):
  154. """
  155. Class method to create 'base' Cycler objects
  156. that do not have a 'right' or 'op' and for which
  157. the 'left' object is not another Cycler.
  158. Parameters
  159. ----------
  160. label : str
  161. The property key.
  162. itr : iterable
  163. Finite length iterable of the property values.
  164. Returns
  165. -------
  166. cycler : Cycler
  167. New 'base' `Cycler`
  168. """
  169. ret = cls(None)
  170. ret._left = list({label: v} for v in itr)
  171. ret._keys = set([label])
  172. return ret
  173. def __getitem__(self, key):
  174. # TODO : maybe add numpy style fancy slicing
  175. if isinstance(key, slice):
  176. trans = self.by_key()
  177. return reduce(add, (_cycler(k, v[key])
  178. for k, v in six.iteritems(trans)))
  179. else:
  180. raise ValueError("Can only use slices with Cycler.__getitem__")
  181. def __iter__(self):
  182. if self._right is None:
  183. return iter(dict(l) for l in self._left)
  184. return self._compose()
  185. def __add__(self, other):
  186. """
  187. Pair-wise combine two equal length cycles (zip)
  188. Parameters
  189. ----------
  190. other : Cycler
  191. The second Cycler
  192. """
  193. if len(self) != len(other):
  194. raise ValueError("Can only add equal length cycles, "
  195. "not {0} and {1}".format(len(self), len(other)))
  196. return Cycler(self, other, zip)
  197. def __mul__(self, other):
  198. """
  199. Outer product of two cycles (`itertools.product`) or integer
  200. multiplication.
  201. Parameters
  202. ----------
  203. other : Cycler or int
  204. The second Cycler or integer
  205. """
  206. if isinstance(other, Cycler):
  207. return Cycler(self, other, product)
  208. elif isinstance(other, int):
  209. trans = self.by_key()
  210. return reduce(add, (_cycler(k, v*other)
  211. for k, v in six.iteritems(trans)))
  212. else:
  213. return NotImplemented
  214. def __rmul__(self, other):
  215. return self * other
  216. def __len__(self):
  217. op_dict = {zip: min, product: mul}
  218. if self._right is None:
  219. return len(self._left)
  220. l_len = len(self._left)
  221. r_len = len(self._right)
  222. return op_dict[self._op](l_len, r_len)
  223. def __iadd__(self, other):
  224. """
  225. In-place pair-wise combine two equal length cycles (zip)
  226. Parameters
  227. ----------
  228. other : Cycler
  229. The second Cycler
  230. """
  231. if not isinstance(other, Cycler):
  232. raise TypeError("Cannot += with a non-Cycler object")
  233. # True shallow copy of self is fine since this is in-place
  234. old_self = copy.copy(self)
  235. self._keys = _process_keys(old_self, other)
  236. self._left = old_self
  237. self._op = zip
  238. self._right = Cycler(other._left, other._right, other._op)
  239. return self
  240. def __imul__(self, other):
  241. """
  242. In-place outer product of two cycles (`itertools.product`)
  243. Parameters
  244. ----------
  245. other : Cycler
  246. The second Cycler
  247. """
  248. if not isinstance(other, Cycler):
  249. raise TypeError("Cannot *= with a non-Cycler object")
  250. # True shallow copy of self is fine since this is in-place
  251. old_self = copy.copy(self)
  252. self._keys = _process_keys(old_self, other)
  253. self._left = old_self
  254. self._op = product
  255. self._right = Cycler(other._left, other._right, other._op)
  256. return self
  257. def __eq__(self, other):
  258. """
  259. Check equality
  260. """
  261. if len(self) != len(other):
  262. return False
  263. if self.keys ^ other.keys:
  264. return False
  265. return all(a == b for a, b in zip(self, other))
  266. def __repr__(self):
  267. op_map = {zip: '+', product: '*'}
  268. if self._right is None:
  269. lab = self.keys.pop()
  270. itr = list(v[lab] for v in self)
  271. return "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr)
  272. else:
  273. op = op_map.get(self._op, '?')
  274. msg = "({left!r} {op} {right!r})"
  275. return msg.format(left=self._left, op=op, right=self._right)
  276. def _repr_html_(self):
  277. # an table showing the value of each key through a full cycle
  278. output = "<table>"
  279. sorted_keys = sorted(self.keys, key=repr)
  280. for key in sorted_keys:
  281. output += "<th>{key!r}</th>".format(key=key)
  282. for d in iter(self):
  283. output += "<tr>"
  284. for k in sorted_keys:
  285. output += "<td>{val!r}</td>".format(val=d[k])
  286. output += "</tr>"
  287. output += "</table>"
  288. return output
  289. def by_key(self):
  290. """Values by key
  291. This returns the transposed values of the cycler. Iterating
  292. over a `Cycler` yields dicts with a single value for each key,
  293. this method returns a `dict` of `list` which are the values
  294. for the given key.
  295. The returned value can be used to create an equivalent `Cycler`
  296. using only `+`.
  297. Returns
  298. -------
  299. transpose : dict
  300. dict of lists of the values for each key.
  301. """
  302. # TODO : sort out if this is a bottle neck, if there is a better way
  303. # and if we care.
  304. keys = self.keys
  305. out = {k: list() for k in keys}
  306. for d in self:
  307. for k in keys:
  308. out[k].append(d[k])
  309. return out
  310. # for back compatibility
  311. _transpose = by_key
  312. def simplify(self):
  313. """Simplify the Cycler
  314. Returned as a composition using only sums (no multiplications)
  315. Returns
  316. -------
  317. simple : Cycler
  318. An equivalent cycler using only summation"""
  319. # TODO: sort out if it is worth the effort to make sure this is
  320. # balanced. Currently it is is
  321. # (((a + b) + c) + d) vs
  322. # ((a + b) + (c + d))
  323. # I would believe that there is some performance implications
  324. trans = self.by_key()
  325. return reduce(add, (_cycler(k, v) for k, v in six.iteritems(trans)))
  326. def concat(self, other):
  327. """Concatenate this cycler and an other.
  328. The keys must match exactly.
  329. This returns a single Cycler which is equivalent to
  330. `itertools.chain(self, other)`
  331. Examples
  332. --------
  333. >>> num = cycler('a', range(3))
  334. >>> let = cycler('a', 'abc')
  335. >>> num.concat(let)
  336. cycler('a', [0, 1, 2, 'a', 'b', 'c'])
  337. Parameters
  338. ----------
  339. other : `Cycler`
  340. The `Cycler` to concatenate to this one.
  341. Returns
  342. -------
  343. ret : `Cycler`
  344. The concatenated `Cycler`
  345. """
  346. return concat(self, other)
  347. def concat(left, right):
  348. """Concatenate two cyclers.
  349. The keys must match exactly.
  350. This returns a single Cycler which is equivalent to
  351. `itertools.chain(left, right)`
  352. Examples
  353. --------
  354. >>> num = cycler('a', range(3))
  355. >>> let = cycler('a', 'abc')
  356. >>> num.concat(let)
  357. cycler('a', [0, 1, 2, 'a', 'b', 'c'])
  358. Parameters
  359. ----------
  360. left, right : `Cycler`
  361. The two `Cycler` instances to concatenate
  362. Returns
  363. -------
  364. ret : `Cycler`
  365. The concatenated `Cycler`
  366. """
  367. if left.keys != right.keys:
  368. msg = '\n\t'.join(["Keys do not match:",
  369. "Intersection: {both!r}",
  370. "Disjoint: {just_one!r}"]).format(
  371. both=left.keys & right.keys,
  372. just_one=left.keys ^ right.keys)
  373. raise ValueError(msg)
  374. _l = left.by_key()
  375. _r = right.by_key()
  376. return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys))
  377. def cycler(*args, **kwargs):
  378. """
  379. Create a new `Cycler` object from a single positional argument,
  380. a pair of positional arguments, or the combination of keyword arguments.
  381. cycler(arg)
  382. cycler(label1=itr1[, label2=iter2[, ...]])
  383. cycler(label, itr)
  384. Form 1 simply copies a given `Cycler` object.
  385. Form 2 composes a `Cycler` as an inner product of the
  386. pairs of keyword arguments. In other words, all of the
  387. iterables are cycled simultaneously, as if through zip().
  388. Form 3 creates a `Cycler` from a label and an iterable.
  389. This is useful for when the label cannot be a keyword argument
  390. (e.g., an integer or a name that has a space in it).
  391. Parameters
  392. ----------
  393. arg : Cycler
  394. Copy constructor for Cycler (does a shallow copy of iterables).
  395. label : name
  396. The property key. In the 2-arg form of the function,
  397. the label can be any hashable object. In the keyword argument
  398. form of the function, it must be a valid python identifier.
  399. itr : iterable
  400. Finite length iterable of the property values.
  401. Can be a single-property `Cycler` that would
  402. be like a key change, but as a shallow copy.
  403. Returns
  404. -------
  405. cycler : Cycler
  406. New `Cycler` for the given property
  407. """
  408. if args and kwargs:
  409. raise TypeError("cyl() can only accept positional OR keyword "
  410. "arguments -- not both.")
  411. if len(args) == 1:
  412. if not isinstance(args[0], Cycler):
  413. raise TypeError("If only one positional argument given, it must "
  414. " be a Cycler instance.")
  415. return Cycler(args[0])
  416. elif len(args) == 2:
  417. return _cycler(*args)
  418. elif len(args) > 2:
  419. raise TypeError("Only a single Cycler can be accepted as the lone "
  420. "positional argument. Use keyword arguments instead.")
  421. if kwargs:
  422. return reduce(add, (_cycler(k, v) for k, v in six.iteritems(kwargs)))
  423. raise TypeError("Must have at least a positional OR keyword arguments")
  424. def _cycler(label, itr):
  425. """
  426. Create a new `Cycler` object from a property name and
  427. iterable of values.
  428. Parameters
  429. ----------
  430. label : hashable
  431. The property key.
  432. itr : iterable
  433. Finite length iterable of the property values.
  434. Returns
  435. -------
  436. cycler : Cycler
  437. New `Cycler` for the given property
  438. """
  439. if isinstance(itr, Cycler):
  440. keys = itr.keys
  441. if len(keys) != 1:
  442. msg = "Can not create Cycler from a multi-property Cycler"
  443. raise ValueError(msg)
  444. lab = keys.pop()
  445. # Doesn't need to be a new list because
  446. # _from_iter() will be creating that new list anyway.
  447. itr = (v[lab] for v in itr)
  448. return Cycler._from_iter(label, itr)