mopidy.models.immutable — Mopidy 4.0.0a2 documentation

# Source code for mopidy.models.immutable

    from __future__ import annotations

    import copy
    import itertools
    import weakref
    from collections.abc import Generator
    from typing import Any, ClassVar, Generic, TypeVar

    from mopidy.models.fields import Field

    T = TypeVar("T", bound="type")

    # Registered models for automatic deserialization
    _models = {}

    class _ValidatedImmutableObjectMeta(type, Generic[T]):
        """Helper that initializes fields, slots and memoizes instance creation."""

        _instances: dict[weakref.ReferenceType[_ValidatedImmutableObjectMeta[T]], T] = {}  # noqa: RUF012

        def __new__(
            cls: type[_ValidatedImmutableObjectMeta],
            name: str,
            bases: tuple[type, ...],
            attrs: dict[str, Any],
        ) -> _ValidatedImmutableObjectMeta:  # noqa: PYI019
            fields = {}

            for base in bases:  # Copy parent fields over to our state
                fields.update(getattr(base, "_fields", {}))

            for key, value in attrs.items():  # Add our own fields
                if isinstance(value, Field):
                    fields[key] = "_" + key
                    value._name = key

            attrs["_fields"] = fields
            attrs["_instances"] = weakref.WeakValueDictionary()
            attrs["__slots__"] = list(attrs.get("__slots__", [])) + list(fields.values())

            clsc: _ValidatedImmutableObjectMeta = super().__new__(cls, name, bases, attrs)

            if clsc.__name__ != "ValidatedImmutableObject":
                _models[clsc.__name__] = clsc

            return clsc

        def __call__(
            cls,
            *args: Any,
            **kwargs: Any,
        ) -> T:
            instance = super().__call__(*args, **kwargs)
            return cls._instances.setdefault(weakref.ref(instance), instance)

    class ValidatedImmutableObject(metaclass=_ValidatedImmutableObjectMeta):
        """Superclass for immutable objects whose fields can only be modified via the
        constructor. Fields should be :class:`Field` instances to ensure type
        safety in our models.

        Note that since these models can not be changed, we heavily memoize them
        to save memory. So constructing a class with the same arguments twice will
        give you the same instance twice.
        """

        _fields: ClassVar[dict[str, Any]]
        _instances: ClassVar[weakref.WeakValueDictionary]
        __slots__ = ["__weakref__", "_hash"]

        def __init__(self, *_args, **kwargs):
            for key, value in kwargs.items():
                if not self._is_valid_field(key):
                    msg = f"__init__() got an unexpected keyword argument {key!r}"
                    raise TypeError(msg)
                self._set_field(key, value)

        def __setattr__(self, name, value):
            if name.startswith("_"):
                object.__setattr__(self, name, value)
            else:
                msg = "Object is immutable."
                raise AttributeError(msg)

        def __delattr__(self, name):
            if name.startswith("_"):
                object.__delattr__(self, name)
            else:
                msg = "Object is immutable."
                raise AttributeError(msg)

        def __repr__(self):
            kwarg_pairs = []
            for key, value in sorted(self._items()):
                if isinstance(value, frozenset | tuple):
                    if not value:
                        continue
                    value = list(value)
                kwarg_pairs.append(f"{key}={value!r}")
            return f"{self.__class__.__name__}({', '.join(kwarg_pairs)})"

        def __hash__(self):
            if not hasattr(self, "_hash"):
                hash_sum = 0
                for key, value in self._items():
                    hash_sum += hash(key) + hash(value)
                object.__setattr__(self, "_hash", hash_sum)
            return self._hash

        def __eq__(self, other):
            if not isinstance(other, self.__class__):
                return False
            return all(
                a == b
                for a, b in itertools.zip_longest(
                    self._items(),
                    other._items(),
                    fillvalue=object(),
                )
            )

        def __ne__(self, other):
            return not self.__eq__(other)

        def _is_valid_field(self, name):
            return name in self._fields

        def _set_field(self, name, value):
            object.__setattr__(self, name, value)

        def _items(self) -> Generator[tuple[str, Any], Any, None]:
            for field, key in self._fields.items():
                if hasattr(self, key):
                    yield field, getattr(self, key)

        def replace(self, **kwargs):
            """Replace the fields in the model and return a new instance.

            Examples::

                # Returns a track with a new name
                Track(name='foo').replace(name='bar')
                # Return an album with a new number of tracks
                Album(num_tracks=2).replace(num_tracks=5)

            Note that internally we memoize heavily to keep memory usage down given
            our overly repetitive data structures. So you might get an existing
            instance if it contains the same values.

            :param kwargs: kwargs to set as fields on the object
            :type kwargs: any
            :rtype: instance of the model with replaced fields
            """
            if not kwargs:
                return self
            other = copy.copy(self)
            for key, value in kwargs.items():
                if not self._is_valid_field(key):
                    msg = f"replace() got an unexpected keyword argument {key!r}"
                    raise TypeError(msg)
                other._set_field(key, value)
            if hasattr(self, "_hash"):
                object.__delattr__(other, "_hash")
            return self._instances.setdefault(  # pyright: ignore[reportCallIssue]
                weakref.ref(other),  # pyright: ignore[reportArgumentType]
                other,
            )

        def serialize(self):
            data = {}
            data["__model__"] = self.__class__.__name__
            for key, value in self._items():
                if isinstance(value, set | frozenset | list | tuple):
                    value = [
                        v.serialize() if isinstance(v, ValidatedImmutableObject) else v
                        for v in value
                    ]
                elif isinstance(value, ValidatedImmutableObject):
                    value = value.serialize()
                if not (isinstance(value, list) and len(value) == 0):
                    data[key] = value
            return data