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