Coverage for colour/utilities/structures.py: 100%
129 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
1"""
2Data Structures
3===============
5Provide various data structure classes for flexible data manipulation.
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.
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"""
28from __future__ import annotations
30import re
31import typing
32from collections import Counter
33from collections.abc import MutableMapping
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
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"
51__all__ = [
52 "Structure",
53 "Lookup",
54 "CanonicalMapping",
55 "LazyCanonicalMapping",
56]
59class Structure(dict):
60 """
61 Represent a :class:`dict`-like structure that enables access to key
62 values through dot notation syntax.
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.
69 Other Parameters
70 ----------------
71 args
72 Arguments.
73 kwargs
74 Key / value pairs.
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__`
85 References
86 ----------
87 :cite:`Rakotoarison2017`
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 """
100 def __init__(self, *args: Any, **kwargs: Any) -> None:
101 super().__init__(*args, **kwargs)
103 def __setattr__(self, name: str, value: Any) -> None:
104 """
105 Assign the specified value to the attribute with the specified name.
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 """
115 self[name] = value
117 def __delattr__(self, name: str) -> None:
118 """
119 Delete the attribute with the specified name.
121 Parameters
122 ----------
123 name
124 Name of the attribute to delete.
125 """
127 del self[name]
129 def __dir__(self) -> Iterable:
130 """
131 Return the list of valid attributes for the :class:`dict`-like
132 object.
134 Returns
135 -------
136 :class:`list`
137 List of valid attributes for the :class:`dict`-like object.
138 """
140 return self.keys()
142 def __getattr__(self, name: str) -> Any:
143 """
144 Return the value from the attribute with the specified name.
146 Parameters
147 ----------
148 name
149 Name of the attribute to get the value from.
151 Returns
152 -------
153 :class:`object`
154 Value of the specified attribute.
156 Raises
157 ------
158 AttributeError
159 If the attribute is not defined.
160 """
162 try:
163 return self[name]
164 except KeyError as error:
165 raise AttributeError(name) from error
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.
173class Lookup(dict):
174 """
175 Represent a :class:`dict`-like object that provides lookup functionality by
176 value(s).
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.
183 Methods
184 -------
185 - :meth:`~colour.utilities.Lookup.keys_from_value`
186 - :meth:`~colour.utilities.Lookup.first_key_from_value`
188 References
189 ----------
190 :cite:`Mansencalc`
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 """
202 def keys_from_value(self, value: Any) -> list:
203 """
204 Return the keys associated with the specified value.
206 Parameters
207 ----------
208 value
209 Value to find the associated keys.
211 Returns
212 -------
213 :class:`list`
214 Keys associated with the specified value.
215 """
217 keys = []
218 for key, data in self.items():
219 matching = data == value
220 try:
221 matching = all(matching)
223 except TypeError:
224 matching = all((matching,))
226 if matching:
227 keys.append(key)
229 return keys
231 def first_key_from_value(self, value: Any) -> Any:
232 """
233 Return the first key associated with the specified value.
235 Parameters
236 ----------
237 value
238 Value to find the associated first key.
240 Returns
241 -------
242 :class:`object`
243 First key associated with the specified value.
244 """
246 return self.keys_from_value(value)[0]
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).
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:
262 - *Original Key*
263 - *Lowercase Key*
264 - *Slugified Key*
265 - *Canonical Key*
267 For example, using the ``McCamy 1992`` key:
269 - *Original Key* : ``McCamy 1992``
270 - *Lowercase Key* : ``mccamy 1992``
271 - *Slugified Key* : ``mccamy-1992``
272 - *Canonical Key* : ``mccamy1992``
274 Parameters
275 ----------
276 data
277 Data to store into the delimiter and case-insensitive
278 :class:`dict`-like object at initialisation.
280 Other Parameters
281 ----------------
282 kwargs
283 Key / value pairs to store into the mapping at initialisation.
285 Attributes
286 ----------
287 - :attr:`~colour.utilities.CanonicalMapping.data`
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`
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 """
322 def __init__(self, data: Generator | Mapping | None = None, **kwargs: Any) -> None:
323 self._data: dict = {}
325 self.update({} if data is None else data, **kwargs)
327 @property
328 def data(self) -> dict:
329 """
330 Getter for the delimiter and case-insensitive :class:`dict`-like
331 object data.
333 Returns
334 -------
335 :class:`dict`
336 Internal data storage.
337 """
339 return self._data
341 def __repr__(self) -> str:
342 """
343 Return an evaluable string representation of the delimiter and
344 case-insensitive :class:`dict`-like object.
346 Returns
347 -------
348 :class:`str`
349 Evaluable string representation.
350 """
352 if is_documentation_building(): # pragma: no cover
353 representation = repr(
354 dict(zip(self.keys(), ["..."] * len(self), strict=True))
355 ).replace("'...'", "...")
357 return f"{self.__class__.__name__}({representation})"
359 return f"{self.__class__.__name__}({dict(self.items())})"
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.
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 """
376 self._data[item] = value
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.
383 Parameters
384 ----------
385 item
386 Item to retrieve the value of from the delimiter and
387 case-insensitive :class:`dict`-like object.
389 Returns
390 -------
391 :class:`object`
392 Item value.
394 Notes
395 -----
396 - The item value can be retrieved by using either its lower-case,
397 slugified or canonical variant.
398 """
400 try:
401 return self._data[item]
402 except KeyError:
403 pass
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
414 try:
415 return self[
416 dict(zip(self.slugified_keys(), self.keys(), strict=True))[item]
417 ]
418 except KeyError:
419 pass
421 return self[dict(zip(self.canonical_keys(), self.keys(), strict=True))[item]]
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.
428 Parameters
429 ----------
430 item
431 Item to delete from the delimiter and case-insensitive
432 :class:`dict`-like object.
434 Notes
435 -----
436 - The item can be deleted by using either its lower-case,
437 slugified or canonical variant.
438 """
440 try:
441 del self._data[item]
442 except KeyError:
443 pass
444 else:
445 return
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
458 try:
459 del self[dict(zip(self.slugified_keys(), self.keys(), strict=True))[item]]
460 except KeyError:
461 pass
462 else:
463 return
465 del self[dict(zip(self.canonical_keys(), self.keys(), strict=True))[item]]
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.
472 Parameters
473 ----------
474 item
475 Item to check for presence in the delimiter and case-insensitive
476 :class:`dict`-like object.
478 Returns
479 -------
480 :class:`bool`
481 Whether the specified item exists in the delimiter and
482 case-insensitive :class:`dict`-like object.
484 Notes
485 -----
486 - Item presence can be checked using its lower-case, slugified, or
487 canonical variant.
488 """
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 )
501 def __iter__(self) -> Generator:
502 """
503 Iterate over the items of the delimiter and case-insensitive
504 :class:`dict`-like object.
506 Yields
507 ------
508 Generator
509 Item generator.
511 Notes
512 -----
513 - The iterated items are the original items.
514 """
516 yield from self._data.keys()
518 def __len__(self) -> int:
519 """
520 Return the item count of the container.
522 Returns
523 -------
524 :class:`int`
525 Item count.
526 """
528 return len(self._data)
530 __hash__ = None
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.
537 Parameters
538 ----------
539 other
540 Object to test for equality with the delimiter and
541 case-insensitive :class:`dict`-like object.
543 Returns
544 -------
545 :class:`bool`
546 Whether the specified object equals the delimiter and
547 case-insensitive :class:`dict`-like object.
548 """
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 )
558 raise TypeError(error)
560 return self._data == other_mapping.data
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.
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.
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 """
580 return not (self == other)
582 @staticmethod
583 def _collision_warning(keys: list) -> None:
584 """
585 Issue a runtime warning for colliding keys.
587 Parameters
588 ----------
589 keys
590 Keys to check for collisions.
591 """
593 from colour.utilities import usage_warning # noqa: PLC0415
595 collisions = [key for (key, value) in Counter(keys).items() if value > 1]
597 if collisions:
598 usage_warning(f"{list(set(keys))} key(s) collide(s)!")
600 def copy(self) -> CanonicalMapping:
601 """
602 Return a copy of the delimiter and case-insensitive
603 :class:`dict`-like object.
605 Returns
606 -------
607 :class:`CanonicalMapping`
608 Case-insensitive :class:`dict`-like object copy.
610 Warnings
611 --------
612 - The :class:`CanonicalMapping` class copy returned is a *copy* of
613 the object not a *deepcopy*!
614 """
616 return CanonicalMapping(dict(**self._data))
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.
623 Yields
624 ------
625 Generator
626 Lower-case key generator.
627 """
629 lower_keys = [str(key).lower() for key in self._data]
631 self._collision_warning(lower_keys)
633 yield from iter(lower_keys)
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.
640 Yields
641 ------
642 Generator
643 Item generator.
644 """
646 yield from ((str(key).lower(), value) for (key, value) in self._data.items())
648 def slugified_keys(self) -> Generator:
649 """
650 Iterate over the slugified keys of the delimiter and
651 case-insensitive :class:`dict`-like object.
653 Yields
654 ------
655 Generator
656 Item generator.
657 """
659 from colour.utilities import slugify # noqa: PLC0415
661 slugified_keys = [slugify(key) for key in self.lower_keys()]
663 self._collision_warning(slugified_keys)
665 yield from iter(slugified_keys)
667 def slugified_items(self) -> Generator:
668 """
669 Iterate over the slugified items of the delimiter and
670 case-insensitive :class:`dict`-like object.
672 Yields
673 ------
674 Generator
675 Item generator.
676 """
678 yield from zip(self.slugified_keys(), self.values(), strict=True)
680 def canonical_keys(self) -> Generator:
681 """
682 Iterate over the canonical keys of the delimiter and
683 case-insensitive :class:`dict`-like object.
685 Yields
686 ------
687 Generator
688 Item generator.
689 """
691 canonical_keys = [re.sub("-|_", "", key) for key in self.slugified_keys()]
693 self._collision_warning(canonical_keys)
695 yield from iter(canonical_keys)
697 def canonical_items(self) -> Generator:
698 """
699 Iterate over the canonical items of the delimiter and case-insensitive
700 :class:`dict`-like object.
702 Yields
703 ------
704 Generator
705 Item generator.
706 """
708 yield from zip(self.canonical_keys(), self.values(), strict=True)
711class LazyCanonicalMapping(CanonicalMapping):
712 """
713 Represent a lazy delimiter and case-insensitive :class:`dict`-like object
714 inheriting from :class:`colour.utilities.CanonicalMapping`.
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.
721 Parameters
722 ----------
723 data
724 Data to store into the lazy delimiter and case-insensitive
725 :class:`dict`-like object at initialisation.
727 Other Parameters
728 ----------------
729 kwargs
730 Key / value pairs to store into the mapping at initialisation.
732 Methods
733 -------
734 - :meth:`~colour.utilities.LazyCanonicalMapping.__getitem__`
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 """
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.
754 Parameters
755 ----------
756 item
757 Item to retrieve the value of from the lazy delimiter and
758 case-insensitive :class:`dict`-like object.
760 Returns
761 -------
762 :class:`object`
763 Item value.
764 """
766 import colour # noqa: PLC0415
768 value = super().__getitem__(item)
770 if callable(value) and hasattr(colour, "__disable_lazy_load__"):
771 value = value()
772 super().__setitem__(item, value)
774 return value