"""Collections for pyatv.""" import asyncio import collections.abc import typing T = typing.TypeVar("T") SharedType = typing.TypeVar("SharedType") # pylint: disable=invalid-name def dict_merge( dict_a: typing.Dict[typing.Any, typing.Any], dict_b: typing.Mapping[typing.Any, typing.Any], /, allow_overwrite: bool = False, ) -> typing.Dict[typing.Any, typing.Any]: """Merge items from dict_b into dict_a, not overriding existing keys. This is effectively the same as the merge operator in python 3.9: dict_a | dict_b """ dict_a.update( { key: value for key, value in dict_b.items() if allow_overwrite or key not in dict_a } ) return dict_a def dict_subtract( dict_a: typing.Dict[typing.Any, typing.Any], dict_b: typing.Mapping[typing.Any, typing.Any], /, remove_if_same_value: bool = False, ) -> typing.Dict[typing.Any, typing.Any]: """Subtract items from a dict (based on key) recursively. This corresponds to dict_a - dict_b more or less. If remove_if_same_value is set to True, keys will only be removed if they have the same value, e.g. if a=1 in dict_a and a=1 also in dict_b. """ diff = {} for key, val in dict_a.items(): if isinstance(val, dict): diff[key] = dict_subtract(val, dict_b[key]) elif key not in dict_b or (remove_if_same_value and dict_a[key] != dict_b[key]): diff[key] = val return diff class CaseInsensitiveDict( # pylint: disable=too-many-ancestors typing.MutableMapping[str, T] ): """A mapping where the keys are compared case-insensitively. As a consequence of this, the keys *must* be strings. """ _data: typing.Dict[str, T] @staticmethod def _lower_key(key_value: typing.Tuple[str, T]) -> typing.Tuple[str, T]: key, value = key_value return key.lower(), value def __init__( self, mapping_or_iterable: typing.Union[ typing.Mapping[str, T], typing.Iterable[typing.Tuple[str, T]], None ] = None, **kwargs, ): """Create a `CaseInensitiveDict`. It takes the same arguments as `dict`.""" self._data = {} if isinstance(mapping_or_iterable, collections.abc.Mapping): self._data.update(map(self._lower_key, mapping_or_iterable.items())) elif isinstance(mapping_or_iterable, collections.abc.Iterable): self._data.update( # It doesn't look like mypy goes deep enough to check this properly, (it # thinks the argument to the callable should be a sequence of objects, # not an iterable of tuples) so casting it is. map( typing.cast( typing.Callable[ [typing.Sequence[object]], typing.Tuple[str, T] ], self._lower_key, ), mapping_or_iterable, ) ) if kwargs: self._data.update(map(self._lower_key, kwargs.items())) def __getitem__(self, key: str) -> T: """Get a value referenced by a string key, compared case-insensitively.""" return self._data[key.lower()] def __setitem__(self, key: str, value: T): """Set a value referenced by a string key, compared case-insensitively.""" self._data[key.lower()] = value def __delitem__(self, key: str): """Delete a value referenced by a string key, compared case-insensitively.""" del self._data[key.lower()] def __contains__(self, key) -> bool: """Check if a key is present in the dictionary, compared case-insensitively.""" try: return super().__contains__(key.lower()) except AttributeError: return NotImplemented def __len__(self) -> int: """Get the number of keys (and values) present in the mapping.""" return len(self._data) def __iter__(self) -> typing.Iterator[str]: """Return an iterator over the mapping keys.""" return iter(self._data) def __eq__(self, other) -> bool: """Comparetwo dictionaries, with keys compared case-insensitively.""" if isinstance(other, CaseInsensitiveDict): # If it's another CaseInsensitiveDict, super easy return self._data == other._data if isinstance(other, collections.abc.Mapping): # if it's not, but it is a dict, attempt to lower the keys of the other dict for other_key, other_value in other.items(): try: lowered_key = other_key.lower() except AttributeError: # If the other keys aren't strings, these dicts aren't equal return False if other_value != self[lowered_key]: return False return True raise NotImplementedError def __str__(self) -> str: """Return string representation of instance.""" return str(self._data) def __repr__(self) -> str: """Return representation of instance.""" return repr(self._data) class SharedData(typing.Generic[SharedType]): """Synchronization barrier used to synchronization data transfer between tasks.""" def __init__(self) -> None: """Initialize a new SharedData instance.""" self._event: asyncio.Event = asyncio.Event() self._data: typing.Optional[SharedType] = None async def wait(self, timeout: float = 5.0) -> SharedType: """Wait for shared data to arrive.""" await asyncio.wait_for(self._event.wait(), timeout) # We know self._data has a value here return typing.cast(SharedType, self._data) def set(self, data: SharedType) -> None: """Signal that shared data has arrived.""" self._data = data self._event.set()