Repository: python-telegram-bot/python-telegram-bot

Issue:
[FEATURE] Make tg.Bot comparable
`tg.Bot` didn't get rich comparison in #1724, though there are use cases when comparison of `Bot` with `User` objects would be helpful. Most notable when checking if the bot is one of the new chat members reportet in a status update; with rich comparison that would be as easy as

```python
context.bot in update.effective_message.new_chat_members
```

Currently one can use `bot_instance.bot`, which holds the `User` instance for comparison, but it's undocumented and unintuitive. Proposal:

* Make `Bot.__eq__(self, other)` basically return `self.bot == other` and document the behaviour accordingly
* we need to make sure that `self.bot` is already set. For the `Bot.{id, username, …}` properties, that's currently done with the `@info` decorator. I think it would be cleaner to just convert `self.bot` into a property that calls `self.get_me()` if needed.


Discussion:

Resolving patch:
diff --git a/telegram/bot.py b/telegram/bot.py
--- a/telegram/bot.py
+++ b/telegram/bot.py
@@ -110,22 +110,6 @@
 RT = TypeVar('RT')
 
 
-def info(func: Callable[..., RT]) -> Callable[..., RT]:
-    # pylint: disable=W0212
-    @functools.wraps(func)
-    def decorator(self: 'Bot', *args: Any, **kwargs: Any) -> RT:
-        if not self.bot:
-            self.get_me()
-
-        if self._commands is None:
-            self.get_my_commands()
-
-        result = func(self, *args, **kwargs)
-        return result
-
-    return decorator
-
-
 def log(
     func: Callable[..., RT], *args: Any, **kwargs: Any  # pylint: disable=W0613
 ) -> Callable[..., RT]:
@@ -144,6 +128,10 @@ def decorator(self: 'Bot', *args: Any, **kwargs: Any) -> RT:  # pylint: disable=
 class Bot(TelegramObject):
     """This object represents a Telegram Bot.
 
+    .. versionadded:: 13.2
+        Objects of this class are comparable in terms of equality. Two objects of this class are
+        considered equal, if their :attr:`bot` is equal.
+
     Note:
         Most bot methods have the argument ``api_kwargs`` which allows to pass arbitrary keywords
         to the Telegram API. This can be used to access new features of the API before they were
@@ -217,7 +205,7 @@ def __init__(
 
         self.base_url = str(base_url) + str(self.token)
         self.base_file_url = str(base_file_url) + str(self.token)
-        self.bot: Optional[User] = None
+        self._bot: Optional[User] = None
         self._commands: Optional[List[BotCommand]] = None
         self._request = request or Request()
         self.logger = logging.getLogger(__name__)
@@ -302,68 +290,69 @@ def _validate_token(token: str) -> str:
 
         return token
 
-    @property  # type: ignore
-    @info
+    @property
+    def bot(self) -> User:
+        """:class:`telegram.User`: User instance for the bot as returned by :meth:`get_me`."""
+
+        if self._bot is None:
+            self._bot = self.get_me()
+        return self._bot
+
+    @property
     def id(self) -> int:
         """:obj:`int`: Unique identifier for this bot."""
 
-        return self.bot.id  # type: ignore
+        return self.bot.id
 
-    @property  # type: ignore
-    @info
+    @property
     def first_name(self) -> str:
         """:obj:`str`: Bot's first name."""
 
-        return self.bot.first_name  # type: ignore
+        return self.bot.first_name
 
-    @property  # type: ignore
-    @info
+    @property
     def last_name(self) -> str:
         """:obj:`str`: Optional. Bot's last name."""
 
         return self.bot.last_name  # type: ignore
 
-    @property  # type: ignore
-    @info
+    @property
     def username(self) -> str:
         """:obj:`str`: Bot's username."""
 
         return self.bot.username  # type: ignore
 
-    @property  # type: ignore
-    @info
+    @property
     def link(self) -> str:
         """:obj:`str`: Convenience property. Returns the t.me link of the bot."""
 
         return f"https://t.me/{self.username}"
 
-    @property  # type: ignore
-    @info
+    @property
     def can_join_groups(self) -> bool:
         """:obj:`bool`: Bot's can_join_groups attribute."""
 
         return self.bot.can_join_groups  # type: ignore
 
-    @property  # type: ignore
-    @info
+    @property
     def can_read_all_group_messages(self) -> bool:
         """:obj:`bool`: Bot's can_read_all_group_messages attribute."""
 
         return self.bot.can_read_all_group_messages  # type: ignore
 
-    @property  # type: ignore
-    @info
+    @property
     def supports_inline_queries(self) -> bool:
         """:obj:`bool`: Bot's supports_inline_queries attribute."""
 
         return self.bot.supports_inline_queries  # type: ignore
 
-    @property  # type: ignore
-    @info
+    @property
     def commands(self) -> List[BotCommand]:
         """List[:class:`BotCommand`]: Bot's commands."""
 
-        return self._commands or []
+        if self._commands is None:
+            self._commands = self.get_my_commands()
+        return self._commands
 
     @property
     def name(self) -> str:
@@ -392,9 +381,9 @@ def get_me(self, timeout: int = None, api_kwargs: JSONDict = None) -> User:
         """
         result = self._post('getMe', timeout=timeout, api_kwargs=api_kwargs)
 
-        self.bot = User.de_json(result, self)  # type: ignore
+        self._bot = User.de_json(result, self)  # type: ignore
 
-        return self.bot  # type: ignore[return-value]
+        return self._bot  # type: ignore[return-value]
 
     @log
     def send_message(
@@ -4814,6 +4803,12 @@ def to_dict(self) -> JSONDict:
 
         return data
 
+    def __eq__(self, other: object) -> bool:
+        return self.bot == other
+
+    def __hash__(self) -> int:
+        return hash(self.bot)
+
     # camelCase aliases
     getMe = get_me
     """Alias for :attr:`get_me`"""


Test patch:
diff --git a/tests/test_bot.py b/tests/test_bot.py
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -48,6 +48,8 @@
 from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter
 from telegram.utils.helpers import from_timestamp, escape_markdown, to_timestamp
 from tests.conftest import expect_bad_request
+from tests.bots import FALLBACKS
+
 
 BASE_TIME = time.time()
 HIGHSCORE_DELTA = 1450000000
@@ -144,6 +146,24 @@ def test_get_me_and_properties(self, bot):
         assert get_me_bot.supports_inline_queries == bot.supports_inline_queries
         assert f'https://t.me/{get_me_bot.username}' == bot.link
         assert commands == bot.commands
+        bot._commands = None
+        assert commands == bot.commands
+
+    def test_equality(self):
+        a = Bot(FALLBACKS[0]["token"])
+        b = Bot(FALLBACKS[0]["token"])
+        c = Bot(FALLBACKS[1]["token"])
+        d = Update(123456789)
+
+        assert a == b
+        assert hash(a) == hash(b)
+        assert a is not b
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
 
     @flaky(3, 1)
     @pytest.mark.timeout(10)
diff --git a/tests/test_updater.py b/tests/test_updater.py
--- a/tests/test_updater.py
+++ b/tests/test_updater.py
@@ -208,7 +208,7 @@ def test_start_webhook_no_warning_or_error_logs(self, caplog, updater, monkeypat
         monkeypatch.setattr(updater.bot, 'set_webhook', lambda *args, **kwargs: True)
         monkeypatch.setattr(updater.bot, 'delete_webhook', lambda *args, **kwargs: True)
         # prevent api calls from @info decorator when updater.bot.id is used in thread names
-        monkeypatch.setattr(updater.bot, 'bot', User(id=123, first_name='bot', is_bot=True))
+        monkeypatch.setattr(updater.bot, '_bot', User(id=123, first_name='bot', is_bot=True))
         monkeypatch.setattr(updater.bot, '_commands', [])
 
         ip = '127.0.0.1'