#!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API # Copyright (C) 2015-2022 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser Public License for more details. # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Base class for Telegram Objects.""" from copy import deepcopy try: import ujson as json except ImportError: import json # type: ignore[no-redef] from typing import TYPE_CHECKING, List, Optional, Type, TypeVar, Tuple, Dict, Union from telegram._utils.types import JSONDict from telegram._utils.warnings import warn if TYPE_CHECKING: from telegram import Bot TO = TypeVar('TO', bound='TelegramObject', covariant=True) class TelegramObject: """Base class for most Telegram objects. Objects of this type are subscriptable with strings, where ``telegram_object[attribute_name]`` is equivalent to ``telegram_object.attribute_name``. If the object does not have an attribute with the appropriate name, a :exc:`KeyError` will be raised. When objects of this type are pickled, the :class:`~telegram.Bot` attribute associated with the object will be removed. However, when copying the object via :func:`copy.deepcopy`, the copy will have the *same* bot instance associated with it, i.e:: assert telegram_object.get_bot() is copy.deepcopy(telegram_object).get_bot() .. versionchanged:: 14.0 ``telegram_object['from']`` will look up the key ``from_user``. This is to account for special cases like :attr:`Message.from_user` that deviate from the official Bot API. """ # type hints in __new__ are not read by mypy (https://github.com/python/mypy/issues/1021). As a # workaround we can type hint instance variables in __new__ using a syntax defined in PEP 526 - # https://www.python.org/dev/peps/pep-0526/#class-and-instance-variable-annotations if TYPE_CHECKING: _id_attrs: Tuple[object, ...] _bot: Optional['Bot'] # Adding slots reduces memory usage & allows for faster attribute access. # Only instance variables should be added to __slots__. __slots__ = ('_id_attrs', '_bot') # pylint: disable=unused-argument def __new__(cls, *args: object, **kwargs: object) -> 'TelegramObject': # We add _id_attrs in __new__ instead of __init__ since we want to add this to the slots # w/o calling __init__ in all of the subclasses. instance = super().__new__(cls) instance._id_attrs = () instance._bot = None return instance def __str__(self) -> str: return str(self.to_dict()) def __getitem__(self, item: str) -> object: if item == 'from': item = 'from_user' try: return getattr(self, item) except AttributeError as exc: raise KeyError( f"Objects of type {self.__class__.__name__} don't have an attribute called " f"`{item}`." ) from exc def __getstate__(self) -> Dict[str, Union[str, object]]: """ This method is used for pickling. We remove the bot attribute of the object since those are not pickable. """ return self._get_attrs(include_private=True, recursive=False, remove_bot=True) def __setstate__(self, state: dict) -> None: """ This method is used for unpickling. The data, which is in the form a dictionary, is converted back into a class. Should be modified in place. """ for key, val in state.items(): setattr(self, key, val) def __deepcopy__(self: TO, memodict: dict) -> TO: """This method deepcopies the object and sets the bot on the newly created copy.""" bot = self._bot # Save bot so we can set it after copying self.set_bot(None) # set to None so it is not deepcopied cls = self.__class__ result = cls.__new__(cls) # create a new instance memodict[id(self)] = result # save the id of the object in the dict attrs = self._get_attrs(include_private=True) # get all its attributes for k in attrs: # now we set the attributes in the deepcopied object setattr(result, k, deepcopy(getattr(self, k), memodict)) result.set_bot(bot) # Assign the bots back self.set_bot(bot) return result # type: ignore[return-value] def _get_attrs( self, include_private: bool = False, recursive: bool = False, remove_bot: bool = False, ) -> Dict[str, Union[str, object]]: """This method is used for obtaining the attributes of the object. Args: include_private (:obj:`bool`): Whether the result should include private variables. recursive (:obj:`bool`): If :obj:`True`, will convert any TelegramObjects (if found) in the attributes to a dictionary. Else, preserves it as an object itself. remove_bot (:obj:`bool`): Whether the bot should be included in the result. Returns: :obj:`dict`: A dict where the keys are attribute names and values are their values. """ data = {} if not recursive: try: # __dict__ has attrs from superclasses, so no need to put in the for loop below data.update(self.__dict__) except AttributeError: pass # We want to get all attributes for the class, using self.__slots__ only includes the # attributes used by that class itself, and not its superclass(es). Hence, we get its MRO # and then get their attributes. The `[:-1]` slice excludes the `object` class for cls in self.__class__.__mro__[:-1]: for key in cls.__slots__: if not include_private and key.startswith('_'): continue value = getattr(self, key, None) if value is not None: if recursive and hasattr(value, 'to_dict'): data[key] = value.to_dict() else: data[key] = value elif not recursive: data[key] = value if recursive and data.get('from_user'): data['from'] = data.pop('from_user', None) if remove_bot: data.pop('_bot', None) return data @staticmethod def _parse_data(data: Optional[JSONDict]) -> Optional[JSONDict]: return None if data is None else data.copy() @classmethod def de_json(cls: Type[TO], data: Optional[JSONDict], bot: 'Bot') -> Optional[TO]: """Converts JSON data to a Telegram object. Args: data (Dict[:obj:`str`, ...]): The JSON data. bot (:class:`telegram.Bot`): The bot associated with this object. Returns: The Telegram object. """ data = cls._parse_data(data) if data is None: return None if cls == TelegramObject: return cls() return cls(bot=bot, **data) @classmethod def de_list(cls: Type[TO], data: Optional[List[JSONDict]], bot: 'Bot') -> List[Optional[TO]]: """Converts JSON data to a list of Telegram objects. Args: data (Dict[:obj:`str`, ...]): The JSON data. bot (:class:`telegram.Bot`): The bot associated with these objects. Returns: A list of Telegram objects. """ if not data: return [] return [cls.de_json(d, bot) for d in data] def to_json(self) -> str: """Gives a JSON representation of object. Returns: :obj:`str` """ return json.dumps(self.to_dict()) def to_dict(self) -> JSONDict: """Gives representation of object as :obj:`dict`. Returns: :obj:`dict` """ return self._get_attrs(recursive=True) def get_bot(self) -> 'Bot': """Returns the :class:`telegram.Bot` instance associated with this object. .. seealso:: :meth:`set_bot` .. versionadded: 14.0 Raises: RuntimeError: If no :class:`telegram.Bot` instance was set for this object. """ if self._bot is None: raise RuntimeError( 'This object has no bot associated with it. Shortcuts cannot be used.' ) return self._bot def set_bot(self, bot: Optional['Bot']) -> None: """Sets the :class:`telegram.Bot` instance associated with this object. .. seealso:: :meth:`get_bot` .. versionadded: 14.0 Arguments: bot (:class:`telegram.Bot` | :obj:`None`): The bot instance. """ self._bot = bot def __eq__(self, other: object) -> bool: # pylint: disable=no-member if isinstance(other, self.__class__): if self._id_attrs == (): warn( f"Objects of type {self.__class__.__name__} can not be meaningfully tested for" " equivalence.", stacklevel=2, ) if other._id_attrs == (): warn( f"Objects of type {other.__class__.__name__} can not be meaningfully tested" " for equivalence.", stacklevel=2, ) return self._id_attrs == other._id_attrs return super().__eq__(other) def __hash__(self) -> int: # pylint: disable=no-member if self._id_attrs: return hash((self.__class__, self._id_attrs)) return super().__hash__()