Coverage for utilities/structures.py: 64%

135 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2Data Structures 

3=============== 

4 

5Provide various data structure classes for flexible data manipulation. 

6 

7- :class:`colour.utilities.Structure`: An object similar to C/C++ structured 

8 type. 

9- :class:`colour.utilities.Lookup`: A :class:`dict` sub-class acting as a 

10 lookup to retrieve keys by values. 

11- :class:`colour.utilities.CanonicalMapping`: A delimiter and 

12 case-insensitive :class:`dict`-like object allowing values retrieval from 

13 keys while ignoring the key case. 

14- :class:`colour.utilities.LazyCanonicalMapping`: Another delimiter and 

15 case-insensitive mapping allowing lazy values retrieval from keys while 

16 ignoring the key case. 

17 

18References 

19---------- 

20- :cite:`Mansencalc` : Mansencal, T. (n.d.). Lookup. 

21 https://github.com/KelSolaar/Foundations/blob/develop/foundations/\ 

22structures.py 

23- :cite:`Rakotoarison2017` : Rakotoarison, H. (2017). Bunch. 

24 https://github.com/scikit-learn/scikit-learn/blob/\ 

25fb5a498d0bd00fc2b42fbd19b6ef18e1dfeee47e/sklearn/utils/__init__.py#L65 

26""" 

27 

28from __future__ import annotations 

29 

30import re 

31import typing 

32from collections import Counter 

33from collections.abc import MutableMapping 

34 

35if typing.TYPE_CHECKING: 

36 from colour.hints import ( 

37 Any, 

38 Generator, 

39 Iterable, 

40 ) 

41from colour.hints import Mapping 

42from colour.utilities.documentation import is_documentation_building 

43 

44__author__ = "Colour Developers" 

45__copyright__ = "Copyright 2013 Colour Developers" 

46__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" 

47__maintainer__ = "Colour Developers" 

48__email__ = "colour-developers@colour-science.org" 

49__status__ = "Production" 

50 

51__all__ = [ 

52 "Structure", 

53 "Lookup", 

54 "CanonicalMapping", 

55 "LazyCanonicalMapping", 

56] 

57 

58 

59class Structure(dict): 

60 """ 

61 Represent a :class:`dict`-like structure that enables access to key 

62 values through dot notation syntax. 

63 

64 This class extends the built-in :class:`dict` to provide 

65 attribute-style access to dictionary items, allowing both traditional 

66 dictionary access patterns and object-oriented dot notation for 

67 improved code readability and convenience. 

68 

69 Other Parameters 

70 ---------------- 

71 args 

72 Arguments. 

73 kwargs 

74 Key / value pairs. 

75 

76 Methods 

77 ------- 

78 - :meth:`~colour.utilities.Structure.__init__` 

79 - :meth:`~colour.utilities.Structure.__setattr__` 

80 - :meth:`~colour.utilities.Structure.__delattr__` 

81 - :meth:`~colour.utilities.Structure.__dir__` 

82 - :meth:`~colour.utilities.Structure.__getattr__` 

83 - :meth:`~colour.utilities.Structure.__setstate__` 

84 

85 References 

86 ---------- 

87 :cite:`Rakotoarison2017` 

88 

89 Examples 

90 -------- 

91 >>> person = Structure(first_name="John", last_name="Doe", gender="male") 

92 >>> person.first_name 

93 'John' 

94 >>> sorted(person.keys()) 

95 ['first_name', 'gender', 'last_name'] 

96 >>> person["gender"] 

97 'male' 

98 """ 

99 

100 def __init__(self, *args: Any, **kwargs: Any) -> None: 

101 super().__init__(*args, **kwargs) 

102 

103 def __setattr__(self, name: str, value: Any) -> None: 

104 """ 

105 Assign the specified value to the attribute with the specified name. 

106 

107 Parameters 

108 ---------- 

109 name 

110 Name of the attribute to assign the ``value`` to. 

111 value 

112 Value to assign to the attribute. 

113 """ 

114 

115 self[name] = value 

116 

117 def __delattr__(self, name: str) -> None: 

118 """ 

119 Delete the attribute with the specified name. 

120 

121 Parameters 

122 ---------- 

123 name 

124 Name of the attribute to delete. 

125 """ 

126 

127 del self[name] 

128 

129 def __dir__(self) -> Iterable: 

130 """ 

131 Return the list of valid attributes for the :class:`dict`-like 

132 object. 

133 

134 Returns 

135 ------- 

136 :class:`list` 

137 List of valid attributes for the :class:`dict`-like object. 

138 """ 

139 

140 return self.keys() 

141 

142 def __getattr__(self, name: str) -> Any: 

143 """ 

144 Return the value from the attribute with the specified name. 

145 

146 Parameters 

147 ---------- 

148 name 

149 Name of the attribute to get the value from. 

150 

151 Returns 

152 ------- 

153 :class:`object` 

154 Value of the specified attribute. 

155 

156 Raises 

157 ------ 

158 AttributeError 

159 If the attribute is not defined. 

160 """ 

161 

162 try: 

163 return self[name] 

164 except KeyError as error: 

165 raise AttributeError(name) from error 

166 

167 def __setstate__(self, state: Any) -> None: 

168 """Set the object state when unpickling.""" 

169 # See https://github.com/scikit-learn/scikit-learn/issues/6196 for more 

170 # information. 

171 

172 

173class Lookup(dict): 

174 """ 

175 Represent a :class:`dict`-like object that provides lookup functionality by 

176 value(s). 

177 

178 This class extends the built-in :class:`dict` to provide reverse lookup 

179 capabilities for dictionary values, enabling retrieval of keys based on 

180 their associated values. Support both single and multiple key retrieval 

181 for matching values. 

182 

183 Methods 

184 ------- 

185 - :meth:`~colour.utilities.Lookup.keys_from_value` 

186 - :meth:`~colour.utilities.Lookup.first_key_from_value` 

187 

188 References 

189 ---------- 

190 :cite:`Mansencalc` 

191 

192 Examples 

193 -------- 

194 >>> person = Lookup(first_name="John", last_name="Doe", gender="male") 

195 >>> person.first_key_from_value("John") 

196 'first_name' 

197 >>> persons = Lookup(John="Doe", Jane="Doe", Luke="Skywalker") 

198 >>> sorted(persons.keys_from_value("Doe")) 

199 ['Jane', 'John'] 

200 """ 

201 

202 def keys_from_value(self, value: Any) -> list: 

203 """ 

204 Return the keys associated with the specified value. 

205 

206 Parameters 

207 ---------- 

208 value 

209 Value to find the associated keys. 

210 

211 Returns 

212 ------- 

213 :class:`list` 

214 Keys associated with the specified value. 

215 """ 

216 

217 keys = [] 

218 for key, data in self.items(): 

219 matching = data == value 

220 try: 

221 matching = all(matching) 

222 

223 except TypeError: 

224 matching = all((matching,)) 

225 

226 if matching: 

227 keys.append(key) 

228 

229 return keys 

230 

231 def first_key_from_value(self, value: Any) -> Any: 

232 """ 

233 Return the first key associated with the specified value. 

234 

235 Parameters 

236 ---------- 

237 value 

238 Value to find the associated first key. 

239 

240 Returns 

241 ------- 

242 :class:`object` 

243 First key associated with the specified value. 

244 """ 

245 

246 return self.keys_from_value(value)[0] 

247 

248 

249class CanonicalMapping(MutableMapping): 

250 """ 

251 Represent a delimiter and case-insensitive :class:`dict`-like object 

252 supporting both slug keys (*SEO*-friendly, human-readable versions with 

253 delimiters) and canonical keys (slugified keys without delimiters). 

254 

255 This class extends :class:`MutableMapping` to provide flexible key 

256 matching that accepts various transformations of the original key while 

257 maintaining the original key structure for storage. Item keys must be 

258 :class:`str`-like objects supporting the :meth:`str.lower` method. Set 

259 items using the specified original keys. Retrieve, delete, or test item 

260 existence by transforming the query key through the following sequence: 

261 

262 - *Original Key* 

263 - *Lowercase Key* 

264 - *Slugified Key* 

265 - *Canonical Key* 

266 

267 For example, using the ``McCamy 1992`` key: 

268 

269 - *Original Key* : ``McCamy 1992`` 

270 - *Lowercase Key* : ``mccamy 1992`` 

271 - *Slugified Key* : ``mccamy-1992`` 

272 - *Canonical Key* : ``mccamy1992`` 

273 

274 Parameters 

275 ---------- 

276 data 

277 Data to store into the delimiter and case-insensitive 

278 :class:`dict`-like object at initialisation. 

279 

280 Other Parameters 

281 ---------------- 

282 kwargs 

283 Key / value pairs to store into the mapping at initialisation. 

284 

285 Attributes 

286 ---------- 

287 - :attr:`~colour.utilities.CanonicalMapping.data` 

288 

289 Methods 

290 ------- 

291 - :meth:`~colour.utilities.CanonicalMapping.__init__` 

292 - :meth:`~colour.utilities.CanonicalMapping.__repr__` 

293 - :meth:`~colour.utilities.CanonicalMapping.__setitem__` 

294 - :meth:`~colour.utilities.CanonicalMapping.__getitem__` 

295 - :meth:`~colour.utilities.CanonicalMapping.__delitem__` 

296 - :meth:`~colour.utilities.CanonicalMapping.__contains__` 

297 - :meth:`~colour.utilities.CanonicalMapping.__iter__` 

298 - :meth:`~colour.utilities.CanonicalMapping.__len__` 

299 - :meth:`~colour.utilities.CanonicalMapping.__eq__` 

300 - :meth:`~colour.utilities.CanonicalMapping.__ne__` 

301 - :meth:`~colour.utilities.CanonicalMapping.copy` 

302 - :meth:`~colour.utilities.CanonicalMapping.lower_keys` 

303 - :meth:`~colour.utilities.CanonicalMapping.lower_items` 

304 - :meth:`~colour.utilities.CanonicalMapping.slugified_keys` 

305 - :meth:`~colour.utilities.CanonicalMapping.slugified_items` 

306 - :meth:`~colour.utilities.CanonicalMapping.canonical_keys` 

307 - :meth:`~colour.utilities.CanonicalMapping.canonical_items` 

308 

309 Examples 

310 -------- 

311 >>> methods = CanonicalMapping({"McCamy 1992": 1, "Hernandez 1999": 2}) 

312 >>> methods["mccamy 1992"] 

313 1 

314 >>> methods["MCCAMY 1992"] 

315 1 

316 >>> methods["mccamy-1992"] 

317 1 

318 >>> methods["mccamy1992"] 

319 1 

320 """ 

321 

322 def __init__(self, data: Generator | Mapping | None = None, **kwargs: Any) -> None: 

323 self._data: dict = {} 

324 

325 self.update({} if data is None else data, **kwargs) 

326 

327 @property 

328 def data(self) -> dict: 

329 """ 

330 Getter for the delimiter and case-insensitive :class:`dict`-like 

331 object data. 

332 

333 Returns 

334 ------- 

335 :class:`dict` 

336 Internal data storage. 

337 """ 

338 

339 return self._data 

340 

341 def __repr__(self) -> str: 

342 """ 

343 Return an evaluable string representation of the delimiter and 

344 case-insensitive :class:`dict`-like object. 

345 

346 Returns 

347 ------- 

348 :class:`str` 

349 Evaluable string representation. 

350 """ 

351 

352 if is_documentation_building(): # pragma: no cover 

353 representation = repr( 

354 dict(zip(self.keys(), ["..."] * len(self), strict=True)) 

355 ).replace("'...'", "...") 

356 

357 return f"{self.__class__.__name__}({representation})" 

358 

359 return f"{self.__class__.__name__}({dict(self.items())})" 

360 

361 def __setitem__(self, item: str | Any, value: Any) -> None: 

362 """ 

363 Set the specified item with the specified value in the delimiter and 

364 case-insensitive :class:`dict`-like object. 

365 

366 Parameters 

367 ---------- 

368 item 

369 Item to set in the delimiter and case-insensitive 

370 :class:`dict`-like object. 

371 value 

372 Value to store in the delimiter and case-insensitive 

373 :class:`dict`-like object. 

374 """ 

375 

376 self._data[item] = value 

377 

378 def __getitem__(self, item: str | Any) -> Any: 

379 """ 

380 Return the value of the specified item from the delimiter and 

381 case-insensitive :class:`dict`-like object. 

382 

383 Parameters 

384 ---------- 

385 item 

386 Item to retrieve the value of from the delimiter and 

387 case-insensitive :class:`dict`-like object. 

388 

389 Returns 

390 ------- 

391 :class:`object` 

392 Item value. 

393 

394 Notes 

395 ----- 

396 - The item value can be retrieved by using either its lower-case, 

397 slugified or canonical variant. 

398 """ 

399 

400 try: 

401 return self._data[item] 

402 except KeyError: 

403 pass 

404 

405 try: 

406 return self[ 

407 dict(zip(self.lower_keys(), self.keys(), strict=True))[ 

408 str(item).lower() 

409 ] 

410 ] 

411 except KeyError: 

412 pass 

413 

414 try: 

415 return self[ 

416 dict(zip(self.slugified_keys(), self.keys(), strict=True))[item] 

417 ] 

418 except KeyError: 

419 pass 

420 

421 return self[dict(zip(self.canonical_keys(), self.keys(), strict=True))[item]] 

422 

423 def __delitem__(self, item: str | Any) -> None: 

424 """ 

425 Delete the specified item from the delimiter and case-insensitive 

426 :class:`dict`-like object. 

427 

428 Parameters 

429 ---------- 

430 item 

431 Item to delete from the delimiter and case-insensitive 

432 :class:`dict`-like object. 

433 

434 Notes 

435 ----- 

436 - The item can be deleted by using either its lower-case, 

437 slugified or canonical variant. 

438 """ 

439 

440 try: 

441 del self._data[item] 

442 except KeyError: 

443 pass 

444 else: 

445 return 

446 

447 try: 

448 del self._data[ 

449 dict(zip(self.lower_keys(), self.keys(), strict=True))[ 

450 str(item).lower() 

451 ] 

452 ] 

453 except KeyError: 

454 pass 

455 else: 

456 return 

457 

458 try: 

459 del self[dict(zip(self.slugified_keys(), self.keys(), strict=True))[item]] 

460 except KeyError: 

461 pass 

462 else: 

463 return 

464 

465 del self[dict(zip(self.canonical_keys(), self.keys(), strict=True))[item]] 

466 

467 def __contains__(self, item: str | Any) -> bool: 

468 """ 

469 Return whether the delimiter and case-insensitive :class:`dict`-like 

470 object contains the specified item. 

471 

472 Parameters 

473 ---------- 

474 item 

475 Item to check for presence in the delimiter and case-insensitive 

476 :class:`dict`-like object. 

477 

478 Returns 

479 ------- 

480 :class:`bool` 

481 Whether the specified item exists in the delimiter and 

482 case-insensitive :class:`dict`-like object. 

483 

484 Notes 

485 ----- 

486 - Item presence can be checked using its lower-case, slugified, or 

487 canonical variant. 

488 """ 

489 

490 return bool( 

491 any( 

492 [ 

493 item in self._data, 

494 str(item).lower() in self.lower_keys(), 

495 item in self.slugified_keys(), 

496 item in self.canonical_keys(), 

497 ] 

498 ) 

499 ) 

500 

501 def __iter__(self) -> Generator: 

502 """ 

503 Iterate over the items of the delimiter and case-insensitive 

504 :class:`dict`-like object. 

505 

506 Yields 

507 ------ 

508 Generator 

509 Item generator. 

510 

511 Notes 

512 ----- 

513 - The iterated items are the original items. 

514 """ 

515 

516 yield from self._data.keys() 

517 

518 def __len__(self) -> int: 

519 """ 

520 Return the item count of the container. 

521 

522 Returns 

523 ------- 

524 :class:`int` 

525 Item count. 

526 """ 

527 

528 return len(self._data) 

529 

530 __hash__ = None 

531 

532 def __eq__(self, other: object) -> bool: 

533 """ 

534 Test whether the delimiter and case-insensitive :class:`dict`-like 

535 object equals the specified object. 

536 

537 Parameters 

538 ---------- 

539 other 

540 Object to test for equality with the delimiter and 

541 case-insensitive :class:`dict`-like object. 

542 

543 Returns 

544 ------- 

545 :class:`bool` 

546 Whether the specified object equals the delimiter and 

547 case-insensitive :class:`dict`-like object. 

548 """ 

549 

550 if isinstance(other, Mapping): 

551 other_mapping = CanonicalMapping(other) 

552 else: 

553 error = ( 

554 f"Impossible to test equality with " 

555 f'"{other.__class__.__name__}" class type!' 

556 ) 

557 

558 raise TypeError(error) 

559 

560 return self._data == other_mapping.data 

561 

562 def __ne__(self, other: object) -> bool: 

563 """ 

564 Test whether the delimiter and case-insensitive 

565 :class:`dict`-like object is not equal to the specified other object. 

566 

567 Parameters 

568 ---------- 

569 other 

570 Object to test whether it is not equal to the delimiter and 

571 case-insensitive :class:`dict`-like object. 

572 

573 Returns 

574 ------- 

575 :class:`bool` 

576 Whether the specified object is not equal to the delimiter and 

577 case-insensitive :class:`dict`-like object. 

578 """ 

579 

580 return not (self == other) 

581 

582 @staticmethod 

583 def _collision_warning(keys: list) -> None: 

584 """ 

585 Issue a runtime warning for colliding keys. 

586 

587 Parameters 

588 ---------- 

589 keys 

590 Keys to check for collisions. 

591 """ 

592 

593 from colour.utilities import usage_warning # noqa: PLC0415 

594 

595 collisions = [key for (key, value) in Counter(keys).items() if value > 1] 

596 

597 if collisions: 

598 usage_warning(f"{list(set(keys))} key(s) collide(s)!") 

599 

600 def copy(self) -> CanonicalMapping: 

601 """ 

602 Return a copy of the delimiter and case-insensitive 

603 :class:`dict`-like object. 

604 

605 Returns 

606 ------- 

607 :class:`CanonicalMapping` 

608 Case-insensitive :class:`dict`-like object copy. 

609 

610 Warnings 

611 -------- 

612 - The :class:`CanonicalMapping` class copy returned is a *copy* of 

613 the object not a *deepcopy*! 

614 """ 

615 

616 return CanonicalMapping(dict(**self._data)) 

617 

618 def lower_keys(self) -> Generator: 

619 """ 

620 Iterate over the lower-case keys of the delimiter and case-insensitive 

621 :class:`dict`-like object. 

622 

623 Yields 

624 ------ 

625 Generator 

626 Lower-case key generator. 

627 """ 

628 

629 lower_keys = [str(key).lower() for key in self._data] 

630 

631 self._collision_warning(lower_keys) 

632 

633 yield from iter(lower_keys) 

634 

635 def lower_items(self) -> Generator: 

636 """ 

637 Iterate over the lower-case items of the delimiter and case-insensitive 

638 :class:`dict`-like object. 

639 

640 Yields 

641 ------ 

642 Generator 

643 Item generator. 

644 """ 

645 

646 yield from ((str(key).lower(), value) for (key, value) in self._data.items()) 

647 

648 def slugified_keys(self) -> Generator: 

649 """ 

650 Iterate over the slugified keys of the delimiter and 

651 case-insensitive :class:`dict`-like object. 

652 

653 Yields 

654 ------ 

655 Generator 

656 Item generator. 

657 """ 

658 

659 from colour.utilities import slugify # noqa: PLC0415 

660 

661 slugified_keys = [slugify(key) for key in self.lower_keys()] 

662 

663 self._collision_warning(slugified_keys) 

664 

665 yield from iter(slugified_keys) 

666 

667 def slugified_items(self) -> Generator: 

668 """ 

669 Iterate over the slugified items of the delimiter and 

670 case-insensitive :class:`dict`-like object. 

671 

672 Yields 

673 ------ 

674 Generator 

675 Item generator. 

676 """ 

677 

678 yield from zip(self.slugified_keys(), self.values(), strict=True) 

679 

680 def canonical_keys(self) -> Generator: 

681 """ 

682 Iterate over the canonical keys of the delimiter and 

683 case-insensitive :class:`dict`-like object. 

684 

685 Yields 

686 ------ 

687 Generator 

688 Item generator. 

689 """ 

690 

691 canonical_keys = [re.sub("-|_", "", key) for key in self.slugified_keys()] 

692 

693 self._collision_warning(canonical_keys) 

694 

695 yield from iter(canonical_keys) 

696 

697 def canonical_items(self) -> Generator: 

698 """ 

699 Iterate over the canonical items of the delimiter and case-insensitive 

700 :class:`dict`-like object. 

701 

702 Yields 

703 ------ 

704 Generator 

705 Item generator. 

706 """ 

707 

708 yield from zip(self.canonical_keys(), self.values(), strict=True) 

709 

710 

711class LazyCanonicalMapping(CanonicalMapping): 

712 """ 

713 Represent a lazy delimiter and case-insensitive :class:`dict`-like object 

714 inheriting from :class:`colour.utilities.CanonicalMapping`. 

715 

716 This class extends :class:`CanonicalMapping` with lazy evaluation 

717 capabilities. When a value is a callable, it is automatically evaluated 

718 upon first access and its return value is cached, replacing the original 

719 callable for subsequent retrievals. 

720 

721 Parameters 

722 ---------- 

723 data 

724 Data to store into the lazy delimiter and case-insensitive 

725 :class:`dict`-like object at initialisation. 

726 

727 Other Parameters 

728 ---------------- 

729 kwargs 

730 Key / value pairs to store into the mapping at initialisation. 

731 

732 Methods 

733 ------- 

734 - :meth:`~colour.utilities.LazyCanonicalMapping.__getitem__` 

735 

736 Examples 

737 -------- 

738 >>> def callable_a(): 

739 ... print(2) 

740 ... return 2 

741 >>> methods = LazyCanonicalMapping({"McCamy": 1, "Hernandez": callable_a}) 

742 >>> methods["mccamy"] 

743 1 

744 >>> methods["hernandez"] 

745 2 

746 2 

747 """ 

748 

749 def __getitem__(self, item: str | Any) -> Any: 

750 """ 

751 Return the value of the specified item from the lazy delimiter and 

752 case-insensitive :class:`dict`-like object. 

753 

754 Parameters 

755 ---------- 

756 item 

757 Item to retrieve the value of from the lazy delimiter and 

758 case-insensitive :class:`dict`-like object. 

759 

760 Returns 

761 ------- 

762 :class:`object` 

763 Item value. 

764 """ 

765 

766 import colour # noqa: PLC0415 

767 

768 value = super().__getitem__(item) 

769 

770 if callable(value) and hasattr(colour, "__disable_lazy_load__"): 

771 value = value() 

772 super().__setitem__(item, value) 

773 

774 return value