Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1""" 

2Cycler 

3====== 

4 

5Cycling through combinations of values, producing dictionaries. 

6 

7You can add cyclers:: 

8 

9 from cycler import cycler 

10 cc = (cycler(color=list('rgb')) + 

11 cycler(linestyle=['-', '--', '-.'])) 

12 for d in cc: 

13 print(d) 

14 

15Results in:: 

16 

17 {'color': 'r', 'linestyle': '-'} 

18 {'color': 'g', 'linestyle': '--'} 

19 {'color': 'b', 'linestyle': '-.'} 

20 

21 

22You can multiply cyclers:: 

23 

24 from cycler import cycler 

25 cc = (cycler(color=list('rgb')) * 

26 cycler(linestyle=['-', '--', '-.'])) 

27 for d in cc: 

28 print(d) 

29 

30Results in:: 

31 

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

42 

43from __future__ import (absolute_import, division, print_function, 

44 unicode_literals) 

45 

46import six 

47from itertools import product, cycle 

48from six.moves import zip, reduce 

49from operator import mul, add 

50import copy 

51 

52__version__ = '0.10.0' 

53 

54 

55def _process_keys(left, right): 

56 """ 

57 Helper function to compose cycler keys 

58 

59 Parameters 

60 ---------- 

61 left, right : iterable of dictionaries or None 

62 The cyclers to be composed 

63 Returns 

64 ------- 

65 keys : set 

66 The keys in the composition of the two cyclers 

67 """ 

68 l_peek = next(iter(left)) if left is not None else {} 

69 r_peek = next(iter(right)) if right is not None else {} 

70 l_key = set(l_peek.keys()) 

71 r_key = set(r_peek.keys()) 

72 if l_key & r_key: 

73 raise ValueError("Can not compose overlapping cycles") 

74 return l_key | r_key 

75 

76 

77class Cycler(object): 

78 """ 

79 Composable cycles 

80 

81 This class has compositions methods: 

82 

83 ``+`` 

84 for 'inner' products (zip) 

85 

86 ``+=`` 

87 in-place ``+`` 

88 

89 ``*`` 

90 for outer products (itertools.product) and integer multiplication 

91 

92 ``*=`` 

93 in-place ``*`` 

94 

95 and supports basic slicing via ``[]`` 

96 

97 Parameters 

98 ---------- 

99 left : Cycler or None 

100 The 'left' cycler 

101 

102 right : Cycler or None 

103 The 'right' cycler 

104 

105 op : func or None 

106 Function which composes the 'left' and 'right' cyclers. 

107 

108 """ 

109 def __call__(self): 

110 return cycle(self) 

111 

112 def __init__(self, left, right=None, op=None): 

113 """Semi-private init 

114 

115 Do not use this directly, use `cycler` function instead. 

116 """ 

117 if isinstance(left, Cycler): 

118 self._left = Cycler(left._left, left._right, left._op) 

119 elif left is not None: 

120 # Need to copy the dictionary or else that will be a residual 

121 # mutable that could lead to strange errors 

122 self._left = [copy.copy(v) for v in left] 

123 else: 

124 self._left = None 

125 

126 if isinstance(right, Cycler): 

127 self._right = Cycler(right._left, right._right, right._op) 

128 elif right is not None: 

129 # Need to copy the dictionary or else that will be a residual 

130 # mutable that could lead to strange errors 

131 self._right = [copy.copy(v) for v in right] 

132 else: 

133 self._right = None 

134 

135 self._keys = _process_keys(self._left, self._right) 

136 self._op = op 

137 

138 @property 

139 def keys(self): 

140 """ 

141 The keys this Cycler knows about 

142 """ 

143 return set(self._keys) 

144 

145 def change_key(self, old, new): 

146 """ 

147 Change a key in this cycler to a new name. 

148 Modification is performed in-place. 

149 

150 Does nothing if the old key is the same as the new key. 

151 Raises a ValueError if the new key is already a key. 

152 Raises a KeyError if the old key isn't a key. 

153 

154 """ 

155 if old == new: 

156 return 

157 if new in self._keys: 

158 raise ValueError("Can't replace %s with %s, %s is already a key" % 

159 (old, new, new)) 

160 if old not in self._keys: 

161 raise KeyError("Can't replace %s with %s, %s is not a key" % 

162 (old, new, old)) 

163 

164 self._keys.remove(old) 

165 self._keys.add(new) 

166 

167 if self._right is not None and old in self._right.keys: 

168 self._right.change_key(old, new) 

169 

170 # self._left should always be non-None 

171 # if self._keys is non-empty. 

172 elif isinstance(self._left, Cycler): 

173 self._left.change_key(old, new) 

174 else: 

175 # It should be completely safe at this point to 

176 # assume that the old key can be found in each 

177 # iteration. 

178 self._left = [{new: entry[old]} for entry in self._left] 

179 

180 def _compose(self): 

181 """ 

182 Compose the 'left' and 'right' components of this cycle 

183 with the proper operation (zip or product as of now) 

184 """ 

185 for a, b in self._op(self._left, self._right): 

186 out = dict() 

187 out.update(a) 

188 out.update(b) 

189 yield out 

190 

191 @classmethod 

192 def _from_iter(cls, label, itr): 

193 """ 

194 Class method to create 'base' Cycler objects 

195 that do not have a 'right' or 'op' and for which 

196 the 'left' object is not another Cycler. 

197 

198 Parameters 

199 ---------- 

200 label : str 

201 The property key. 

202 

203 itr : iterable 

204 Finite length iterable of the property values. 

205 

206 Returns 

207 ------- 

208 cycler : Cycler 

209 New 'base' `Cycler` 

210 """ 

211 ret = cls(None) 

212 ret._left = list({label: v} for v in itr) 

213 ret._keys = set([label]) 

214 return ret 

215 

216 def __getitem__(self, key): 

217 # TODO : maybe add numpy style fancy slicing 

218 if isinstance(key, slice): 

219 trans = self.by_key() 

220 return reduce(add, (_cycler(k, v[key]) 

221 for k, v in six.iteritems(trans))) 

222 else: 

223 raise ValueError("Can only use slices with Cycler.__getitem__") 

224 

225 def __iter__(self): 

226 if self._right is None: 

227 return iter(dict(l) for l in self._left) 

228 

229 return self._compose() 

230 

231 def __add__(self, other): 

232 """ 

233 Pair-wise combine two equal length cycles (zip) 

234 

235 Parameters 

236 ---------- 

237 other : Cycler 

238 The second Cycler 

239 """ 

240 if len(self) != len(other): 

241 raise ValueError("Can only add equal length cycles, " 

242 "not {0} and {1}".format(len(self), len(other))) 

243 return Cycler(self, other, zip) 

244 

245 def __mul__(self, other): 

246 """ 

247 Outer product of two cycles (`itertools.product`) or integer 

248 multiplication. 

249 

250 Parameters 

251 ---------- 

252 other : Cycler or int 

253 The second Cycler or integer 

254 """ 

255 if isinstance(other, Cycler): 

256 return Cycler(self, other, product) 

257 elif isinstance(other, int): 

258 trans = self.by_key() 

259 return reduce(add, (_cycler(k, v*other) 

260 for k, v in six.iteritems(trans))) 

261 else: 

262 return NotImplemented 

263 

264 def __rmul__(self, other): 

265 return self * other 

266 

267 def __len__(self): 

268 op_dict = {zip: min, product: mul} 

269 if self._right is None: 

270 return len(self._left) 

271 l_len = len(self._left) 

272 r_len = len(self._right) 

273 return op_dict[self._op](l_len, r_len) 

274 

275 def __iadd__(self, other): 

276 """ 

277 In-place pair-wise combine two equal length cycles (zip) 

278 

279 Parameters 

280 ---------- 

281 other : Cycler 

282 The second Cycler 

283 """ 

284 if not isinstance(other, Cycler): 

285 raise TypeError("Cannot += with a non-Cycler object") 

286 # True shallow copy of self is fine since this is in-place 

287 old_self = copy.copy(self) 

288 self._keys = _process_keys(old_self, other) 

289 self._left = old_self 

290 self._op = zip 

291 self._right = Cycler(other._left, other._right, other._op) 

292 return self 

293 

294 def __imul__(self, other): 

295 """ 

296 In-place outer product of two cycles (`itertools.product`) 

297 

298 Parameters 

299 ---------- 

300 other : Cycler 

301 The second Cycler 

302 """ 

303 if not isinstance(other, Cycler): 

304 raise TypeError("Cannot *= with a non-Cycler object") 

305 # True shallow copy of self is fine since this is in-place 

306 old_self = copy.copy(self) 

307 self._keys = _process_keys(old_self, other) 

308 self._left = old_self 

309 self._op = product 

310 self._right = Cycler(other._left, other._right, other._op) 

311 return self 

312 

313 def __eq__(self, other): 

314 """ 

315 Check equality 

316 """ 

317 if len(self) != len(other): 

318 return False 

319 if self.keys ^ other.keys: 

320 return False 

321 

322 return all(a == b for a, b in zip(self, other)) 

323 

324 def __repr__(self): 

325 op_map = {zip: '+', product: '*'} 

326 if self._right is None: 

327 lab = self.keys.pop() 

328 itr = list(v[lab] for v in self) 

329 return "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr) 

330 else: 

331 op = op_map.get(self._op, '?') 

332 msg = "({left!r} {op} {right!r})" 

333 return msg.format(left=self._left, op=op, right=self._right) 

334 

335 def _repr_html_(self): 

336 # an table showing the value of each key through a full cycle 

337 output = "<table>" 

338 sorted_keys = sorted(self.keys, key=repr) 

339 for key in sorted_keys: 

340 output += "<th>{key!r}</th>".format(key=key) 

341 for d in iter(self): 

342 output += "<tr>" 

343 for k in sorted_keys: 

344 output += "<td>{val!r}</td>".format(val=d[k]) 

345 output += "</tr>" 

346 output += "</table>" 

347 return output 

348 

349 def by_key(self): 

350 """Values by key 

351 

352 This returns the transposed values of the cycler. Iterating 

353 over a `Cycler` yields dicts with a single value for each key, 

354 this method returns a `dict` of `list` which are the values 

355 for the given key. 

356 

357 The returned value can be used to create an equivalent `Cycler` 

358 using only `+`. 

359 

360 Returns 

361 ------- 

362 transpose : dict 

363 dict of lists of the values for each key. 

364 """ 

365 

366 # TODO : sort out if this is a bottle neck, if there is a better way 

367 # and if we care. 

368 

369 keys = self.keys 

370 # change this to dict comprehension when drop 2.6 

371 out = dict((k, list()) for k in keys) 

372 

373 for d in self: 

374 for k in keys: 

375 out[k].append(d[k]) 

376 return out 

377 

378 # for back compatibility 

379 _transpose = by_key 

380 

381 def simplify(self): 

382 """Simplify the Cycler 

383 

384 Returned as a composition using only sums (no multiplications) 

385 

386 Returns 

387 ------- 

388 simple : Cycler 

389 An equivalent cycler using only summation""" 

390 # TODO: sort out if it is worth the effort to make sure this is 

391 # balanced. Currently it is is 

392 # (((a + b) + c) + d) vs 

393 # ((a + b) + (c + d)) 

394 # I would believe that there is some performance implications 

395 

396 trans = self.by_key() 

397 return reduce(add, (_cycler(k, v) for k, v in six.iteritems(trans))) 

398 

399 def concat(self, other): 

400 """Concatenate this cycler and an other. 

401 

402 The keys must match exactly. 

403 

404 This returns a single Cycler which is equivalent to 

405 `itertools.chain(self, other)` 

406 

407 Examples 

408 -------- 

409 

410 >>> num = cycler('a', range(3)) 

411 >>> let = cycler('a', 'abc') 

412 >>> num.concat(let) 

413 cycler('a', [0, 1, 2, 'a', 'b', 'c']) 

414 

415 Parameters 

416 ---------- 

417 other : `Cycler` 

418 The `Cycler` to concatenate to this one. 

419 

420 Returns 

421 ------- 

422 ret : `Cycler` 

423 The concatenated `Cycler` 

424 """ 

425 return concat(self, other) 

426 

427 

428def concat(left, right): 

429 """Concatenate two cyclers. 

430 

431 The keys must match exactly. 

432 

433 This returns a single Cycler which is equivalent to 

434 `itertools.chain(left, right)` 

435 

436 Examples 

437 -------- 

438 

439 >>> num = cycler('a', range(3)) 

440 >>> let = cycler('a', 'abc') 

441 >>> num.concat(let) 

442 cycler('a', [0, 1, 2, 'a', 'b', 'c']) 

443 

444 Parameters 

445 ---------- 

446 left, right : `Cycler` 

447 The two `Cycler` instances to concatenate 

448 

449 Returns 

450 ------- 

451 ret : `Cycler` 

452 The concatenated `Cycler` 

453 """ 

454 if left.keys != right.keys: 

455 msg = '\n\t'.join(["Keys do not match:", 

456 "Intersection: {both!r}", 

457 "Disjoint: {just_one!r}"]).format( 

458 both=left.keys & right.keys, 

459 just_one=left.keys ^ right.keys) 

460 

461 raise ValueError(msg) 

462 

463 _l = left.by_key() 

464 _r = right.by_key() 

465 return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys)) 

466 

467 

468def cycler(*args, **kwargs): 

469 """ 

470 Create a new `Cycler` object from a single positional argument, 

471 a pair of positional arguments, or the combination of keyword arguments. 

472 

473 cycler(arg) 

474 cycler(label1=itr1[, label2=iter2[, ...]]) 

475 cycler(label, itr) 

476 

477 Form 1 simply copies a given `Cycler` object. 

478 

479 Form 2 composes a `Cycler` as an inner product of the 

480 pairs of keyword arguments. In other words, all of the 

481 iterables are cycled simultaneously, as if through zip(). 

482 

483 Form 3 creates a `Cycler` from a label and an iterable. 

484 This is useful for when the label cannot be a keyword argument 

485 (e.g., an integer or a name that has a space in it). 

486 

487 Parameters 

488 ---------- 

489 arg : Cycler 

490 Copy constructor for Cycler (does a shallow copy of iterables). 

491 

492 label : name 

493 The property key. In the 2-arg form of the function, 

494 the label can be any hashable object. In the keyword argument 

495 form of the function, it must be a valid python identifier. 

496 

497 itr : iterable 

498 Finite length iterable of the property values. 

499 Can be a single-property `Cycler` that would 

500 be like a key change, but as a shallow copy. 

501 

502 Returns 

503 ------- 

504 cycler : Cycler 

505 New `Cycler` for the given property 

506 

507 """ 

508 if args and kwargs: 

509 raise TypeError("cyl() can only accept positional OR keyword " 

510 "arguments -- not both.") 

511 

512 if len(args) == 1: 

513 if not isinstance(args[0], Cycler): 

514 raise TypeError("If only one positional argument given, it must " 

515 " be a Cycler instance.") 

516 return Cycler(args[0]) 

517 elif len(args) == 2: 

518 return _cycler(*args) 

519 elif len(args) > 2: 

520 raise TypeError("Only a single Cycler can be accepted as the lone " 

521 "positional argument. Use keyword arguments instead.") 

522 

523 if kwargs: 

524 return reduce(add, (_cycler(k, v) for k, v in six.iteritems(kwargs))) 

525 

526 raise TypeError("Must have at least a positional OR keyword arguments") 

527 

528 

529def _cycler(label, itr): 

530 """ 

531 Create a new `Cycler` object from a property name and 

532 iterable of values. 

533 

534 Parameters 

535 ---------- 

536 label : hashable 

537 The property key. 

538 

539 itr : iterable 

540 Finite length iterable of the property values. 

541 

542 Returns 

543 ------- 

544 cycler : Cycler 

545 New `Cycler` for the given property 

546 """ 

547 if isinstance(itr, Cycler): 

548 keys = itr.keys 

549 if len(keys) != 1: 

550 msg = "Can not create Cycler from a multi-property Cycler" 

551 raise ValueError(msg) 

552 

553 lab = keys.pop() 

554 # Doesn't need to be a new list because 

555 # _from_iter() will be creating that new list anyway. 

556 itr = (v[lab] for v in itr) 

557 

558 return Cycler._from_iter(label, itr)