Source code for colour.utilities.structures

"""
Data Structures
===============

Define various data structures classes:

-   :class:`colour.utilities.Structure`: An object similar to C/C++ structured
    type.
-   :class:`colour.utilities.Lookup`: A :class:`dict` sub-class acting as a
    lookup to retrieve keys by values.
-   :class:`colour.utilities.CanonicalMapping`: A delimiter and
    case-insensitive :class:`dict`-like object allowing values retrieving from
    keys while ignoring the key case.
-   :class:`colour.utilities.LazyCanonicalMapping`: Another delimiter and
    case-insensitive mapping allowing lazy values retrieving from keys while
    ignoring the key case.

References
----------
-   :cite:`Mansencalc` : Mansencal, T. (n.d.). Lookup.
    https://github.com/KelSolaar/Foundations/blob/develop/foundations/\
structures.py
-   :cite:`Rakotoarison2017` : Rakotoarison, H. (2017). Bunch.
    https://github.com/scikit-learn/scikit-learn/blob/\
fb5a498d0bd00fc2b42fbd19b6ef18e1dfeee47e/sklearn/utils/__init__.py#L65
"""

from __future__ import annotations

import re
from collections import Counter
from collections.abc import MutableMapping

from colour.hints import (
    Any,
    Generator,
    Iterable,
    Mapping,
)
from colour.utilities.documentation import is_documentation_building

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "Structure",
    "Lookup",
    "CanonicalMapping",
    "LazyCanonicalMapping",
]


[docs] class Structure(dict): """ Define a :class:`dict`-like object allowing to access key values using dot syntax. Other Parameters ---------------- args Arguments. kwargs Key / value pairs. Methods ------- - :meth:`~colour.utilities.Structure.__init__` - :meth:`~colour.utilities.Structure.__setattr__` - :meth:`~colour.utilities.Structure.__delattr__` - :meth:`~colour.utilities.Structure.__dir__` - :meth:`~colour.utilities.Structure.__getattr__` - :meth:`~colour.utilities.Structure.__setstate__` References ---------- :cite:`Rakotoarison2017` Examples -------- >>> person = Structure(first_name="John", last_name="Doe", gender="male") >>> person.first_name 'John' >>> sorted(person.keys()) ['first_name', 'gender', 'last_name'] >>> person["gender"] 'male' """
[docs] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs)
[docs] def __setattr__(self, name: str, value: Any): """ Assign given value to the attribute with given name. Parameters ---------- name Name of the attribute to assign the ``value`` to. value Value to assign to the attribute. """ self[name] = value
[docs] def __delattr__(self, name: str): """ Delete the attribute with given name. Parameters ---------- name Name of the attribute to delete. """ del self[name]
[docs] def __dir__(self) -> Iterable: """ Return a list of valid attributes for the :class:`dict`-like object. Returns ------- :class:`list` List of valid attributes for the :class:`dict`-like object. """ return self.keys()
[docs] def __getattr__(self, name: str) -> Any: """ Return the value from the attribute with given name. Parameters ---------- name Name of the attribute to get the value from. Returns ------- :class:`object` Raises ------ AttributeError If the attribute is not defined. """ try: return self[name] except KeyError as error: raise AttributeError(name) from error
[docs] def __setstate__(self, state): """Set the object state when unpickling."""
# See https://github.com/scikit-learn/scikit-learn/issues/6196 for more # information.
[docs] class Lookup(dict): """ Extend :class:`dict` type to provide a lookup by value(s). Methods ------- - :meth:`~colour.utilities.Lookup.keys_from_value` - :meth:`~colour.utilities.Lookup.first_key_from_value` References ---------- :cite:`Mansencalc` Examples -------- >>> person = Lookup(first_name="John", last_name="Doe", gender="male") >>> person.first_key_from_value("John") 'first_name' >>> persons = Lookup(John="Doe", Jane="Doe", Luke="Skywalker") >>> sorted(persons.keys_from_value("Doe")) ['Jane', 'John'] """
[docs] def keys_from_value(self, value: Any) -> list: """ Get the keys associated with given value. Parameters ---------- value Value to find the associated keys. Returns ------- :class:`list` Keys associated with given value. """ keys = [] for key, data in self.items(): matching = data == value try: matching = all(matching) except TypeError: matching = all((matching,)) if matching: keys.append(key) return keys
[docs] def first_key_from_value(self, value: Any) -> Any: """ Get the first key associated with given value. Parameters ---------- value Value to find the associated first key. Returns ------- :class:`object` First key associated with given value. """ return self.keys_from_value(value)[0]
[docs] class CanonicalMapping(MutableMapping): """ Implement a delimiter and case-insensitive :class:`dict`-like object with support for slugs, i.e., *SEO* friendly and human-readable version of the keys but also canonical keys, i.e., slugified keys without delimiters. The item keys are expected to be :class:`str`-like objects thus supporting the :meth:`str.lower` method. Setting items is done by using the given keys. Retrieving or deleting an item and testing whether an item exist is done by transforming the item's key in a sequence as follows: - *Original Key* - *Lowercase Key* - *Slugified Key* - *Canonical Key* For example, given the ``McCamy 1992`` key: - *Original Key* : ``McCamy 1992`` - *Lowercase Key* : ``mccamy 1992`` - *Slugified Key* : ``mccamy-1992`` - *Canonical Key* : ``mccamy1992`` Parameters ---------- data Data to store into the delimiter and case-insensitive :class:`dict`-like object at initialisation. Other Parameters ---------------- kwargs Key / value pairs to store into the mapping at initialisation. Attributes ---------- - :attr:`~colour.utilities.CanonicalMapping.data` Methods ------- - :meth:`~colour.utilities.CanonicalMapping.__init__` - :meth:`~colour.utilities.CanonicalMapping.__repr__` - :meth:`~colour.utilities.CanonicalMapping.__setitem__` - :meth:`~colour.utilities.CanonicalMapping.__getitem__` - :meth:`~colour.utilities.CanonicalMapping.__delitem__` - :meth:`~colour.utilities.CanonicalMapping.__contains__` - :meth:`~colour.utilities.CanonicalMapping.__iter__` - :meth:`~colour.utilities.CanonicalMapping.__len__` - :meth:`~colour.utilities.CanonicalMapping.__eq__` - :meth:`~colour.utilities.CanonicalMapping.__ne__` - :meth:`~colour.utilities.CanonicalMapping.copy` - :meth:`~colour.utilities.CanonicalMapping.lower_keys` - :meth:`~colour.utilities.CanonicalMapping.lower_items` - :meth:`~colour.utilities.CanonicalMapping.slugified_keys` - :meth:`~colour.utilities.CanonicalMapping.slugified_items` - :meth:`~colour.utilities.CanonicalMapping.canonical_keys` - :meth:`~colour.utilities.CanonicalMapping.canonical_items` Examples -------- >>> methods = CanonicalMapping({"McCamy 1992": 1, "Hernandez 1999": 2}) >>> methods["mccamy 1992"] 1 >>> methods["MCCAMY 1992"] 1 >>> methods["mccamy-1992"] 1 >>> methods["mccamy1992"] 1 """
[docs] def __init__(self, data: Generator | Mapping | None = None, **kwargs: Any) -> None: self._data: dict = {} self.update({} if data is None else data, **kwargs)
@property def data(self) -> dict: """ Getter property for the delimiter and case-insensitive :class:`dict`-like object data. Returns ------- :class:`dict` Data. """ return self._data
[docs] def __repr__(self) -> str: """ Return an evaluable string representation of the delimiter and case-insensitive :class:`dict`-like object. Returns ------- :class:`str` Evaluable string representation. """ if is_documentation_building(): # pragma: no cover representation = repr(dict(zip(self.keys(), ["..."] * len(self)))).replace( "'...'", "..." ) return f"{self.__class__.__name__}({representation})" else: return f"{self.__class__.__name__}({dict(self.items())})"
[docs] def __setitem__(self, item: str | Any, value: Any): """ Set given item with given value in the delimiter and case-insensitive :class:`dict`-like object. Parameters ---------- item Item to set in the delimiter and case-insensitive :class:`dict`-like object. value Value to store in the delimiter and case-insensitive :class:`dict`-like object. """ self._data[item] = value
[docs] def __getitem__(self, item: str | Any) -> Any: """ Return the value of given item from the delimiter and case-insensitive :class:`dict`-like object. Parameters ---------- item Item to retrieve the value of from the delimiter and case-insensitive :class:`dict`-like object. Returns ------- :class:`object` Item value. Notes ----- - The item value can be retrieved by using either its lower-case, slugified or canonical variant. """ try: return self._data[item] except KeyError: pass try: return self[dict(zip(self.lower_keys(), self.keys()))[str(item).lower()]] except KeyError: pass try: return self[dict(zip(self.slugified_keys(), self.keys()))[item]] except KeyError: pass return self[dict(zip(self.canonical_keys(), self.keys()))[item]]
[docs] def __delitem__(self, item: str | Any): """ Delete given item from the delimiter and case-insensitive :class:`dict`-like object. Parameters ---------- item Item to delete from the delimiter and case-insensitive :class:`dict`-like object. Notes ----- - The item can be deleted by using either its lower-case, slugified or canonical variant. """ try: del self._data[item] return except KeyError: pass try: del self._data[dict(zip(self.lower_keys(), self.keys()))[str(item).lower()]] return except KeyError: pass try: del self[dict(zip(self.slugified_keys(), self.keys()))[item]] return except KeyError: pass del self[dict(zip(self.canonical_keys(), self.keys()))[item]]
[docs] def __contains__(self, item: str | Any) -> bool: """ Return whether the delimiter and case-insensitive :class:`dict`-like object contains given item. Parameters ---------- item Item to find whether it is in the delimiter and case-insensitive :class:`dict`-like object. Returns ------- :class:`bool` Whether given item is in the delimiter and case-insensitive :class:`dict`-like object. Notes ----- - The item presence can be checked by using either its lower-case, slugified or canonical variant. """ return bool( any( [ item in self._data, str(item).lower() in self.lower_keys(), item in self.slugified_keys(), item in self.canonical_keys(), ] ) )
[docs] def __iter__(self) -> Generator: """ Iterate over the items of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. Notes ----- - The iterated items are the original items. """ yield from self._data.keys()
[docs] def __len__(self) -> int: """ Return the items count. Returns ------- :class:`int` Items count. """ return len(self._data)
[docs] def __eq__(self, other: Any) -> bool: """ Return whether the delimiter and case-insensitive :class:`dict`-like object is equal to given other object. Parameters ---------- other Object to test whether it is equal to the delimiter and case-insensitive :class:`dict`-like object Returns ------- :class:`bool` Whether given object is equal to the delimiter and case-insensitive :class:`dict`-like object. """ if isinstance(other, Mapping): other_mapping = CanonicalMapping(other) else: raise TypeError( f"Impossible to test equality with " f'"{other.__class__.__name__}" class type!' ) return self._data == other_mapping.data
[docs] def __ne__(self, other: Any) -> bool: """ Return whether the delimiter and case-insensitive :class:`dict`-like object is not equal to given other object. Parameters ---------- other Object to test whether it is not equal to the delimiter and case-insensitive :class:`dict`-like object Returns ------- :class:`bool` Whether given object is not equal to the delimiter and case-insensitive :class:`dict`-like object. """ return not (self == other)
@staticmethod def _collision_warning(keys: list): """ Issue a runtime warning when given keys are colliding. Parameters ---------- keys """ from colour.utilities import usage_warning collisions = [key for (key, value) in Counter(keys).items() if value > 1] if collisions: usage_warning(f"{list(set(keys))} key(s) collide(s)!")
[docs] def copy(self) -> CanonicalMapping: """ Return a copy of the delimiter and case-insensitive :class:`dict`-like object. Returns ------- :class:`CanonicalMapping` Case-insensitive :class:`dict`-like object copy. Warnings -------- - The :class:`CanonicalMapping` class copy returned is a *copy* of the object not a *deepcopy*! """ return CanonicalMapping(dict(**self._data))
[docs] def lower_keys(self) -> Generator: """ Iterate over the lower-case keys of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. """ lower_keys = [str(key).lower() for key in self._data] self._collision_warning(lower_keys) yield from iter(lower_keys)
[docs] def lower_items(self) -> Generator: """ Iterate over the lower-case items of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. """ yield from ((str(key).lower(), value) for (key, value) in self._data.items())
[docs] def slugified_keys(self) -> Generator: """ Iterate over the slugified keys of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. """ from colour.utilities import slugify slugified_keys = [slugify(key) for key in self.lower_keys()] self._collision_warning(slugified_keys) yield from iter(slugified_keys)
[docs] def slugified_items(self) -> Generator: """ Iterate over the slugified items of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. """ yield from zip(self.slugified_keys(), self.values())
[docs] def canonical_keys(self) -> Generator: """ Iterate over the canonical keys of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. """ canonical_keys = [re.sub("-|_", "", key) for key in self.slugified_keys()] self._collision_warning(canonical_keys) yield from iter(canonical_keys)
[docs] def canonical_items(self) -> Generator: """ Iterate over the canonical items of the delimiter and case-insensitive :class:`dict`-like object. Yields ------ Generator Item generator. """ yield from zip(self.canonical_keys(), self.values())
[docs] class LazyCanonicalMapping(CanonicalMapping): """ Implement a lazy delimiter and case-insensitive :class:`dict`-like object inheriting from :class:`colour.utilities.CanonicalMapping` class. The lazy retrieval is performed as follows: If the value is a callable, then it is evaluated and its return value is stored in place of the current value. Parameters ---------- data Data to store into the lazy delimiter and case-insensitive :class:`dict`-like object at initialisation. Other Parameters ---------------- kwargs Key / value pairs to store into the mapping at initialisation. Methods ------- - :meth:`~colour.utilities.LazyCanonicalMapping.__getitem__` Examples -------- >>> def callable_a(): ... print(2) ... return 2 >>> methods = LazyCanonicalMapping({"McCamy": 1, "Hernandez": callable_a}) >>> methods["mccamy"] 1 >>> methods["hernandez"] 2 2 """
[docs] def __getitem__(self, item: str | Any) -> Any: """ Return the value of given item from the lazy delimiter and case-insensitive :class:`dict`-like object. Parameters ---------- item Item to retrieve the value of from the lazy delimiter and case-insensitive :class:`dict`-like object. Returns ------- :class:`object` Item value. """ import colour value = super().__getitem__(item) if callable(value) and hasattr(colour, "__disable_lazy_load__"): value = value() super().__setitem__(item, value) return value