Repository: python-telegram-bot/python-telegram-bot
Pull request: Make (most) objects comparable
Closes #1708

Added `_id_attrs` to most of the telegram objects (more or less) by the following paradigm:
1. If object has some sort of ID, just use that
2. If object has only optional args, use all of them
3. If object has required arg, use that
4. If object hast required arg and exactly one of the optionals must be given, use all

Also added the corresponding tests.

On the way I encountered some difficulties:
1. Objects with nested lists as attributes, which are not hashable: I had to overwrite `__hash__` to use a tuple representation of the lists, e.g. `InlineKeyboardMarkup`
2. `ReplyKeyboardMarkup` accepts both strings and `KeyboardButtons` while converting stings to `KB` only on `to_dict`. So I overwrote `__equal__` to cope with this
3. There was no test for `WebhookInfo`. I added one
4. I couldn't come up with a clever way to test `InputFile.input_media_content` for equality (in case an actual file handler is passed, that is), so I left that and `InputMedia…` out
5. I have no clue of the passport module. All objects that have an ID already had the `_id_attrs` set and it seemed to me that all the other classes have no stand alone tests. So I left them out as well …

Needs to be updated, when 

- [x] API 4.5 #1508 
- [x] API 4.6 #1723
- [x] API 4.7 #1858 
- [x] API 4.8

are merged

Problem statement:
[FEATURE] Add _id_attrs
#### Is your feature request related to a problem? Please describe.
`InlineKeyboardMarkup` and `InlineKeyboardButton` objects are currently not really testable for equality because the `_id_attrs` are not set. We should add all attributes of `IKB` to the `_id_attr` and `inline_keyboard` for `IKM`.

#### Additional context
We already did that for `MessageEntity` iirc. Probably should go through the lib and do that for all the telegram objects.

#### Current workaround
Use `__dict__` for everything

Hints from issue discussion:

Commit message: Update for API 4.8

Patch to review:
diff --git a/telegram/base.py b/telegram/base.py
index 3431396c95c..1fb0ba61d8e 100644
--- a/telegram/base.py
+++ b/telegram/base.py
@@ -23,6 +23,8 @@
 except ImportError:
     import json
 
+import warnings
+
 
 class TelegramObject(object):
     """Base class for most telegram objects."""
@@ -78,6 +80,9 @@ def to_dict(self):
 
     def __eq__(self, other):
         if isinstance(other, self.__class__):
+            if self._id_attrs == ():
+                warnings.warn("Objects of type {} can not be meaningfully tested for "
+                              "equivalence.".format(self.__class__.__name__))
             return self._id_attrs == other._id_attrs
         return super(TelegramObject, self).__eq__(other)  # pylint: disable=no-member
 
diff --git a/telegram/botcommand.py b/telegram/botcommand.py
index 293a5035ca1..189b6761b2a 100644
--- a/telegram/botcommand.py
+++ b/telegram/botcommand.py
@@ -38,6 +38,8 @@ def __init__(self, command, description, **kwargs):
         self.command = command
         self.description = description
 
+        self._id_attrs = (self.command, self.description)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/chatpermissions.py b/telegram/chatpermissions.py
index 6f135918a4d..1531f7e286d 100644
--- a/telegram/chatpermissions.py
+++ b/telegram/chatpermissions.py
@@ -84,6 +84,17 @@ def __init__(self, can_send_messages=None, can_send_media_messages=None, can_sen
         self.can_invite_users = can_invite_users
         self.can_pin_messages = can_pin_messages
 
+        self._id_attrs = (
+            self.can_send_messages,
+            self.can_send_media_messages,
+            self.can_send_polls,
+            self.can_send_other_messages,
+            self.can_add_web_page_previews,
+            self.can_change_info,
+            self.can_invite_users,
+            self.can_pin_messages
+        )
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/dice.py b/telegram/dice.py
index d1cbff6b3aa..b5f7d05e030 100644
--- a/telegram/dice.py
+++ b/telegram/dice.py
@@ -44,6 +44,8 @@ def __init__(self, value, emoji, **kwargs):
         self.value = value
         self.emoji = emoji
 
+        self._id_attrs = (self.value, self.emoji)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/forcereply.py b/telegram/forcereply.py
index d0cfbafa7e9..17196f971ed 100644
--- a/telegram/forcereply.py
+++ b/telegram/forcereply.py
@@ -49,3 +49,5 @@ def __init__(self, force_reply=True, selective=False, **kwargs):
         self.force_reply = bool(force_reply)
         # Optionals
         self.selective = bool(selective)
+
+        self._id_attrs = (self.force_reply, self.selective)
diff --git a/telegram/games/game.py b/telegram/games/game.py
index 68e3f502365..872068655c0 100644
--- a/telegram/games/game.py
+++ b/telegram/games/game.py
@@ -65,13 +65,17 @@ def __init__(self,
                  text_entities=None,
                  animation=None,
                  **kwargs):
+        # Required
         self.title = title
         self.description = description
         self.photo = photo
+        # Optionals
         self.text = text
         self.text_entities = text_entities or list()
         self.animation = animation
 
+        self._id_attrs = (self.title, self.description, self.photo)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
@@ -147,3 +151,6 @@ def parse_text_entities(self, types=None):
             entity: self.parse_text_entity(entity)
             for entity in self.text_entities if entity.type in types
         }
+
+    def __hash__(self):
+        return hash((self.title, self.description, tuple(p for p in self.photo)))
diff --git a/telegram/games/gamehighscore.py b/telegram/games/gamehighscore.py
index f09c3b74772..c2fa6b39e81 100644
--- a/telegram/games/gamehighscore.py
+++ b/telegram/games/gamehighscore.py
@@ -41,6 +41,8 @@ def __init__(self, position, user, score):
         self.user = user
         self.score = score
 
+        self._id_attrs = (self.position, self.user, self.score)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/inline/inlinekeyboardbutton.py b/telegram/inline/inlinekeyboardbutton.py
index fda629bbee4..495c9ad9294 100644
--- a/telegram/inline/inlinekeyboardbutton.py
+++ b/telegram/inline/inlinekeyboardbutton.py
@@ -95,6 +95,17 @@ def __init__(self,
         self.callback_game = callback_game
         self.pay = pay
 
+        self._id_attrs = (
+            self.text,
+            self.url,
+            self.login_url,
+            self.callback_data,
+            self.switch_inline_query,
+            self.switch_inline_query_current_chat,
+            self.callback_game,
+            self.pay,
+        )
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/inline/inlinekeyboardmarkup.py b/telegram/inline/inlinekeyboardmarkup.py
index 2679fcdb929..3579907986f 100644
--- a/telegram/inline/inlinekeyboardmarkup.py
+++ b/telegram/inline/inlinekeyboardmarkup.py
@@ -109,3 +109,19 @@ def from_column(cls, button_column, **kwargs):
         """
         button_grid = [[button] for button in button_column]
         return cls(button_grid, **kwargs)
+
+    def __eq__(self, other):
+        if isinstance(other, self.__class__):
+            if len(self.inline_keyboard) != len(other.inline_keyboard):
+                return False
+            for idx, row in enumerate(self.inline_keyboard):
+                if len(row) != len(other.inline_keyboard[idx]):
+                    return False
+                for jdx, button in enumerate(row):
+                    if button != other.inline_keyboard[idx][jdx]:
+                        return False
+            return True
+        return super(InlineKeyboardMarkup, self).__eq__(other)  # pylint: disable=no-member
+
+    def __hash__(self):
+        return hash(tuple(tuple(button for button in row) for row in self.inline_keyboard))
diff --git a/telegram/inline/inputcontactmessagecontent.py b/telegram/inline/inputcontactmessagecontent.py
index f82d0ef338d..4a41cc986e5 100644
--- a/telegram/inline/inputcontactmessagecontent.py
+++ b/telegram/inline/inputcontactmessagecontent.py
@@ -48,3 +48,5 @@ def __init__(self, phone_number, first_name, last_name=None, vcard=None, **kwarg
         # Optionals
         self.last_name = last_name
         self.vcard = vcard
+
+        self._id_attrs = (self.phone_number, self.first_name)
diff --git a/telegram/inline/inputlocationmessagecontent.py b/telegram/inline/inputlocationmessagecontent.py
index 7375e073af8..450ddb0701b 100644
--- a/telegram/inline/inputlocationmessagecontent.py
+++ b/telegram/inline/inputlocationmessagecontent.py
@@ -43,3 +43,5 @@ def __init__(self, latitude, longitude, live_period=None, **kwargs):
         self.latitude = latitude
         self.longitude = longitude
         self.live_period = live_period
+
+        self._id_attrs = (self.latitude, self.longitude, self.live_period)
diff --git a/telegram/inline/inputtextmessagecontent.py b/telegram/inline/inputtextmessagecontent.py
index d23aa694cd8..47ab2bfcf6e 100644
--- a/telegram/inline/inputtextmessagecontent.py
+++ b/telegram/inline/inputtextmessagecontent.py
@@ -55,3 +55,5 @@ def __init__(self,
         # Optionals
         self.parse_mode = parse_mode
         self.disable_web_page_preview = disable_web_page_preview
+
+        self._id_attrs = (self.message_text,)
diff --git a/telegram/inline/inputvenuemessagecontent.py b/telegram/inline/inputvenuemessagecontent.py
index 26732365097..29d79db31e1 100644
--- a/telegram/inline/inputvenuemessagecontent.py
+++ b/telegram/inline/inputvenuemessagecontent.py
@@ -57,3 +57,10 @@ def __init__(self, latitude, longitude, title, address, foursquare_id=None,
         # Optionals
         self.foursquare_id = foursquare_id
         self.foursquare_type = foursquare_type
+
+        self._id_attrs = (
+            self.latitude,
+            self.longitude,
+            self.title,
+            self.address
+        )
diff --git a/telegram/keyboardbutton.py b/telegram/keyboardbutton.py
index 0b2cf5023b0..0f0a7f58073 100644
--- a/telegram/keyboardbutton.py
+++ b/telegram/keyboardbutton.py
@@ -63,3 +63,6 @@ def __init__(self, text, request_contact=None, request_location=None, request_po
         self.request_contact = request_contact
         self.request_location = request_location
         self.request_poll = request_poll
+
+        self._id_attrs = (self.text, self.request_contact, self.request_location,
+                          self.request_poll)
diff --git a/telegram/payment/invoice.py b/telegram/payment/invoice.py
index 4993f9b87a5..f1a55c28d20 100644
--- a/telegram/payment/invoice.py
+++ b/telegram/payment/invoice.py
@@ -50,6 +50,14 @@ def __init__(self, title, description, start_parameter, currency, total_amount,
         self.currency = currency
         self.total_amount = total_amount
 
+        self._id_attrs = (
+            self.title,
+            self.description,
+            self.start_parameter,
+            self.currency,
+            self.total_amount,
+        )
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/payment/labeledprice.py b/telegram/payment/labeledprice.py
index 7fc08d30ccf..a8f5a437c45 100644
--- a/telegram/payment/labeledprice.py
+++ b/telegram/payment/labeledprice.py
@@ -41,3 +41,5 @@ class LabeledPrice(TelegramObject):
     def __init__(self, label, amount, **kwargs):
         self.label = label
         self.amount = amount
+
+        self._id_attrs = (self.label, self.amount)
diff --git a/telegram/payment/orderinfo.py b/telegram/payment/orderinfo.py
index 3cd34200370..95da5db2e2b 100644
--- a/telegram/payment/orderinfo.py
+++ b/telegram/payment/orderinfo.py
@@ -45,6 +45,8 @@ def __init__(self, name=None, phone_number=None, email=None, shipping_address=No
         self.email = email
         self.shipping_address = shipping_address
 
+        self._id_attrs = (self.name, self.phone_number, self.email, self.shipping_address)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/poll.py b/telegram/poll.py
index f92224837ef..a1c0e61ff01 100644
--- a/telegram/poll.py
+++ b/telegram/poll.py
@@ -43,6 +43,8 @@ def __init__(self, text, voter_count, **kwargs):
         self.text = text
         self.voter_count = voter_count
 
+        self._id_attrs = (self.text,)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
@@ -72,6 +74,8 @@ def __init__(self, poll_id, user, option_ids, **kwargs):
         self.user = user
         self.option_ids = option_ids
 
+        self._id_attrs = (self.poll_id, self.user, tuple(self.option_ids))
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/telegram/replykeyboardmarkup.py b/telegram/replykeyboardmarkup.py
index 5954b046e3a..2c767b05df0 100644
--- a/telegram/replykeyboardmarkup.py
+++ b/telegram/replykeyboardmarkup.py
@@ -19,6 +19,7 @@
 """This module contains an object that represents a Telegram ReplyKeyboardMarkup."""
 
 from telegram import ReplyMarkup
+from .keyboardbutton import KeyboardButton
 
 
 class ReplyKeyboardMarkup(ReplyMarkup):
@@ -66,7 +67,16 @@ def __init__(self,
                  selective=False,
                  **kwargs):
         # Required
-        self.keyboard = keyboard
+        self.keyboard = []
+        for row in keyboard:
+            r = []
+            for button in row:
+                if hasattr(button, 'to_dict'):
+                    r.append(button)  # telegram.KeyboardButton
+                else:
+                    r.append(KeyboardButton(button))  # str
+            self.keyboard.append(r)
+
         # Optionals
         self.resize_keyboard = bool(resize_keyboard)
         self.one_time_keyboard = bool(one_time_keyboard)
@@ -213,3 +223,22 @@ def from_column(cls,
                    one_time_keyboard=one_time_keyboard,
                    selective=selective,
                    **kwargs)
+
+    def __eq__(self, other):
+        if isinstance(other, self.__class__):
+            if len(self.keyboard) != len(other.keyboard):
+                return False
+            for idx, row in enumerate(self.keyboard):
+                if len(row) != len(other.keyboard[idx]):
+                    return False
+                for jdx, button in enumerate(row):
+                    if button != other.keyboard[idx][jdx]:
+                        return False
+            return True
+        return super(ReplyKeyboardMarkup, self).__eq__(other)  # pylint: disable=no-member
+
+    def __hash__(self):
+        return hash((
+            tuple(tuple(button for button in row) for row in self.keyboard),
+            self.resize_keyboard, self.one_time_keyboard, self.selective
+        ))
diff --git a/telegram/userprofilephotos.py b/telegram/userprofilephotos.py
index 5101a7f8ee2..daf069f8b90 100644
--- a/telegram/userprofilephotos.py
+++ b/telegram/userprofilephotos.py
@@ -40,6 +40,8 @@ def __init__(self, total_count, photos, **kwargs):
         self.total_count = int(total_count)
         self.photos = photos
 
+        self._id_attrs = (self.total_count, self.photos)
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
@@ -59,3 +61,6 @@ def to_dict(self):
             data['photos'].append([x.to_dict() for x in photo])
 
         return data
+
+    def __hash__(self):
+        return hash(tuple(tuple(p for p in photo) for photo in self.photos))
diff --git a/telegram/webhookinfo.py b/telegram/webhookinfo.py
index e063035fced..08f658f436e 100644
--- a/telegram/webhookinfo.py
+++ b/telegram/webhookinfo.py
@@ -71,6 +71,16 @@ def __init__(self,
         self.max_connections = max_connections
         self.allowed_updates = allowed_updates
 
+        self._id_attrs = (
+            self.url,
+            self.has_custom_certificate,
+            self.pending_update_count,
+            self.last_error_date,
+            self.last_error_message,
+            self.max_connections,
+            self.allowed_updates,
+        )
+
     @classmethod
     def de_json(cls, data, bot):
         if not data:
diff --git a/tests/test_botcommand.py b/tests/test_botcommand.py
index 9b339276dc2..8cb3cbbba64 100644
--- a/tests/test_botcommand.py
+++ b/tests/test_botcommand.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import BotCommand
+from telegram import BotCommand, Dice
 
 
 @pytest.fixture(scope="class")
@@ -46,3 +46,22 @@ def test_to_dict(self, bot_command):
         assert isinstance(bot_command_dict, dict)
         assert bot_command_dict['command'] == bot_command.command
         assert bot_command_dict['description'] == bot_command.description
+
+    def test_equality(self):
+        a = BotCommand('start', 'some description')
+        b = BotCommand('start', 'some description')
+        c = BotCommand('start', 'some other description')
+        d = BotCommand('hepl', 'some description')
+        e = Dice(4, 'emoji')
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
diff --git a/tests/test_chatpermissions.py b/tests/test_chatpermissions.py
index dbf5558e550..148a80f5dd3 100644
--- a/tests/test_chatpermissions.py
+++ b/tests/test_chatpermissions.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import ChatPermissions
+from telegram import ChatPermissions, User
 
 
 @pytest.fixture(scope="class")
@@ -77,3 +77,34 @@ def test_to_dict(self, chat_permissions):
         assert permissions_dict['can_change_info'] == chat_permissions.can_change_info
         assert permissions_dict['can_invite_users'] == chat_permissions.can_invite_users
         assert permissions_dict['can_pin_messages'] == chat_permissions.can_pin_messages
+
+    def test_equality(self):
+        a = ChatPermissions(
+            can_send_messages=True,
+            can_send_media_messages=True,
+            can_send_polls=True,
+            can_send_other_messages=False
+        )
+        b = ChatPermissions(
+            can_send_polls=True,
+            can_send_other_messages=False,
+            can_send_messages=True,
+            can_send_media_messages=True,
+        )
+        c = ChatPermissions(
+            can_send_messages=False,
+            can_send_media_messages=True,
+            can_send_polls=True,
+            can_send_other_messages=False
+        )
+        d = User(123, '', False)
+
+        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)
diff --git a/tests/test_dice.py b/tests/test_dice.py
index 1770715d828..565c3562ae3 100644
--- a/tests/test_dice.py
+++ b/tests/test_dice.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import Dice
+from telegram import Dice, BotCommand
 
 
 @pytest.fixture(scope="class",
@@ -46,3 +46,22 @@ def test_to_dict(self, dice):
         assert isinstance(dice_dict, dict)
         assert dice_dict['value'] == dice.value
         assert dice_dict['emoji'] == dice.emoji
+
+    def test_equality(self):
+        a = Dice(3, '🎯')
+        b = Dice(3, '🎯')
+        c = Dice(3, '🎲')
+        d = Dice(4, '🎯')
+        e = BotCommand('start', 'description')
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
diff --git a/tests/test_forcereply.py b/tests/test_forcereply.py
index 5f4649dd1ea..356eefcbd3c 100644
--- a/tests/test_forcereply.py
+++ b/tests/test_forcereply.py
@@ -20,7 +20,7 @@
 import pytest
 from flaky import flaky
 
-from telegram import ForceReply
+from telegram import ForceReply, ReplyKeyboardRemove
 
 
 @pytest.fixture(scope='class')
@@ -49,3 +49,18 @@ def test_to_dict(self, force_reply):
         assert isinstance(force_reply_dict, dict)
         assert force_reply_dict['force_reply'] == force_reply.force_reply
         assert force_reply_dict['selective'] == force_reply.selective
+
+    def test_equality(self):
+        a = ForceReply(True, False)
+        b = ForceReply(True, False)
+        c = ForceReply(True, True)
+        d = ReplyKeyboardRemove()
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_game.py b/tests/test_game.py
index 23a01565d5d..356094e7d06 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -95,3 +95,20 @@ def test_parse_entities(self, game):
 
         assert game.parse_text_entities(MessageEntity.URL) == {entity: 'http://google.com'}
         assert game.parse_text_entities() == {entity: 'http://google.com', entity_2: 'h'}
+
+    def test_equality(self):
+        a = Game('title', 'description', [PhotoSize('Blah', 'unique_id', 640, 360, file_size=0)])
+        b = Game('title', 'description', [PhotoSize('Blah', 'unique_id', 640, 360, file_size=0)],
+                 text='Here is a text')
+        c = Game('eltit', 'description', [PhotoSize('Blah', 'unique_id', 640, 360, file_size=0)],
+                 animation=Animation('blah', 'unique_id', 320, 180, 1))
+        d = Animation('blah', 'unique_id', 320, 180, 1)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_gamehighscore.py b/tests/test_gamehighscore.py
index 55788a0c05b..a929d993ef7 100644
--- a/tests/test_gamehighscore.py
+++ b/tests/test_gamehighscore.py
@@ -51,3 +51,22 @@ def test_to_dict(self, game_highscore):
         assert game_highscore_dict['position'] == game_highscore.position
         assert game_highscore_dict['user'] == game_highscore.user.to_dict()
         assert game_highscore_dict['score'] == game_highscore.score
+
+    def test_equality(self):
+        a = GameHighScore(1, User(2, 'test user', False), 42)
+        b = GameHighScore(1, User(2, 'test user', False), 42)
+        c = GameHighScore(2, User(2, 'test user', False), 42)
+        d = GameHighScore(1, User(3, 'test user', False), 42)
+        e = User(3, 'test user', False)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
diff --git a/tests/test_inlinekeyboardbutton.py b/tests/test_inlinekeyboardbutton.py
index 2513959eea4..4f5891d7c9e 100644
--- a/tests/test_inlinekeyboardbutton.py
+++ b/tests/test_inlinekeyboardbutton.py
@@ -92,3 +92,26 @@ def test_de_json(self, bot):
                 == self.switch_inline_query_current_chat)
         assert inline_keyboard_button.callback_game == self.callback_game
         assert inline_keyboard_button.pay == self.pay
+
+    def test_equality(self):
+        a = InlineKeyboardButton('text', callback_data='data')
+        b = InlineKeyboardButton('text', callback_data='data')
+        c = InlineKeyboardButton('texts', callback_data='data')
+        d = InlineKeyboardButton('text', callback_data='info')
+        e = InlineKeyboardButton('text', url='http://google.com')
+        f = LoginUrl("http://google.com")
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
+
+        assert a != f
+        assert hash(a) != hash(f)
diff --git a/tests/test_inlinekeyboardmarkup.py b/tests/test_inlinekeyboardmarkup.py
index 67a6d5fcd7f..1501c6cd815 100644
--- a/tests/test_inlinekeyboardmarkup.py
+++ b/tests/test_inlinekeyboardmarkup.py
@@ -20,7 +20,7 @@
 import pytest
 from flaky import flaky
 
-from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyMarkup
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyMarkup, ReplyKeyboardMarkup
 
 
 @pytest.fixture(scope='class')
@@ -129,3 +129,52 @@ def test_de_json(self):
 
         assert keyboard[0][0].text == 'start'
         assert keyboard[0][0].url == 'http://google.com'
+
+    def test_equality(self):
+        a = InlineKeyboardMarkup.from_column([
+            InlineKeyboardButton(label, callback_data='data')
+            for label in ['button1', 'button2', 'button3']
+        ])
+        b = InlineKeyboardMarkup.from_column([
+            InlineKeyboardButton(label, callback_data='data')
+            for label in ['button1', 'button2', 'button3']
+        ])
+        c = InlineKeyboardMarkup.from_column([
+            InlineKeyboardButton(label, callback_data='data')
+            for label in ['button1', 'button2']
+        ])
+        d = InlineKeyboardMarkup.from_column([
+            InlineKeyboardButton(label, callback_data=label)
+            for label in ['button1', 'button2', 'button3']
+        ])
+        e = InlineKeyboardMarkup.from_column([
+            InlineKeyboardButton(label, url=label)
+            for label in ['button1', 'button2', 'button3']
+        ])
+        f = InlineKeyboardMarkup([
+            [InlineKeyboardButton(label, callback_data='data')
+             for label in ['button1', 'button2']],
+            [InlineKeyboardButton(label, callback_data='data')
+             for label in ['button1', 'button2']],
+            [InlineKeyboardButton(label, callback_data='data')
+             for label in ['button1', 'button2']]
+        ])
+        g = ReplyKeyboardMarkup.from_column(['button1', 'button2', 'button3'])
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
+
+        assert a != f
+        assert hash(a) != hash(f)
+
+        assert a != g
+        assert hash(a) != hash(g)
diff --git a/tests/test_inputcontactmessagecontent.py b/tests/test_inputcontactmessagecontent.py
index 47b91f01f10..f4eb7ae67dd 100644
--- a/tests/test_inputcontactmessagecontent.py
+++ b/tests/test_inputcontactmessagecontent.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import InputContactMessageContent
+from telegram import InputContactMessageContent, User
 
 
 @pytest.fixture(scope='class')
@@ -49,3 +49,18 @@ def test_to_dict(self, input_contact_message_content):
                 == input_contact_message_content.first_name)
         assert (input_contact_message_content_dict['last_name']
                 == input_contact_message_content.last_name)
+
+    def test_equality(self):
+        a = InputContactMessageContent('phone', 'first', last_name='last')
+        b = InputContactMessageContent('phone', 'first', vcard='vcard')
+        c = InputContactMessageContent('phone_number', 'first', vcard='vcard')
+        d = User(123, 'first', False)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_inputlocationmessagecontent.py b/tests/test_inputlocationmessagecontent.py
index 65e02f2099b..1edd29e4169 100644
--- a/tests/test_inputlocationmessagecontent.py
+++ b/tests/test_inputlocationmessagecontent.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import InputLocationMessageContent
+from telegram import InputLocationMessageContent, Location
 
 
 @pytest.fixture(scope='class')
@@ -49,3 +49,18 @@ def test_to_dict(self, input_location_message_content):
                 == input_location_message_content.longitude)
         assert (input_location_message_content_dict['live_period']
                 == input_location_message_content.live_period)
+
+    def test_equality(self):
+        a = InputLocationMessageContent(123, 456, 70)
+        b = InputLocationMessageContent(123, 456, 70)
+        c = InputLocationMessageContent(123, 456, 80)
+        d = Location(123, 456)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_inputtextmessagecontent.py b/tests/test_inputtextmessagecontent.py
index 832b48a7e77..120cf27da1f 100644
--- a/tests/test_inputtextmessagecontent.py
+++ b/tests/test_inputtextmessagecontent.py
@@ -50,3 +50,18 @@ def test_to_dict(self, input_text_message_content):
                 == input_text_message_content.parse_mode)
         assert (input_text_message_content_dict['disable_web_page_preview']
                 == input_text_message_content.disable_web_page_preview)
+
+    def test_equality(self):
+        a = InputTextMessageContent('text')
+        b = InputTextMessageContent('text', parse_mode=ParseMode.HTML)
+        c = InputTextMessageContent('label')
+        d = ParseMode.HTML
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_inputvenuemessagecontent.py b/tests/test_inputvenuemessagecontent.py
index 844bd817efa..d4516e0df04 100644
--- a/tests/test_inputvenuemessagecontent.py
+++ b/tests/test_inputvenuemessagecontent.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import InputVenueMessageContent
+from telegram import InputVenueMessageContent, Location
 
 
 @pytest.fixture(scope='class')
@@ -62,3 +62,18 @@ def test_to_dict(self, input_venue_message_content):
                 == input_venue_message_content.foursquare_id)
         assert (input_venue_message_content_dict['foursquare_type']
                 == input_venue_message_content.foursquare_type)
+
+    def test_equality(self):
+        a = InputVenueMessageContent(123, 456, 'title', 'address')
+        b = InputVenueMessageContent(123, 456, 'title', 'address', foursquare_id=123)
+        c = InputVenueMessageContent(456, 123, 'title', 'address', foursquare_id=123)
+        d = Location(123, 456)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_invoice.py b/tests/test_invoice.py
index 16096405f67..541077a982a 100644
--- a/tests/test_invoice.py
+++ b/tests/test_invoice.py
@@ -120,3 +120,18 @@ def test(_, url, data, **kwargs):
         assert bot.send_invoice(chat_id, self.title, self.description, self.payload,
                                 provider_token, self.start_parameter, self.currency,
                                 self.prices, provider_data={'test_data': 123456789})
+
+    def test_equality(self):
+        a = Invoice('invoice', 'desc', 'start', 'EUR', 7)
+        b = Invoice('invoice', 'desc', 'start', 'EUR', 7)
+        c = Invoice('invoices', 'description', 'stop', 'USD', 8)
+        d = LabeledPrice('label', 5)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_keyboardbutton.py b/tests/test_keyboardbutton.py
index e3da6dac202..24b1c7a78b6 100644
--- a/tests/test_keyboardbutton.py
+++ b/tests/test_keyboardbutton.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import KeyboardButton
+from telegram import KeyboardButton, InlineKeyboardButton
 from telegram.keyboardbuttonpolltype import KeyboardButtonPollType
 
 
@@ -51,3 +51,18 @@ def test_to_dict(self, keyboard_button):
         assert keyboard_button_dict['request_location'] == keyboard_button.request_location
         assert keyboard_button_dict['request_contact'] == keyboard_button.request_contact
         assert keyboard_button_dict['request_poll'] == keyboard_button.request_poll.to_dict()
+
+    def test_equality(self):
+        a = KeyboardButton('test', request_contact=True)
+        b = KeyboardButton('test', request_contact=True)
+        c = KeyboardButton('Test', request_location=True)
+        d = InlineKeyboardButton('test', callback_data='test')
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_labeledprice.py b/tests/test_labeledprice.py
index 325fa9063d2..21031e2b986 100644
--- a/tests/test_labeledprice.py
+++ b/tests/test_labeledprice.py
@@ -19,7 +19,7 @@
 
 import pytest
 
-from telegram import LabeledPrice
+from telegram import LabeledPrice, Location
 
 
 @pytest.fixture(scope='class')
@@ -41,3 +41,18 @@ def test_to_dict(self, labeled_price):
         assert isinstance(labeled_price_dict, dict)
         assert labeled_price_dict['label'] == labeled_price.label
         assert labeled_price_dict['amount'] == labeled_price.amount
+
+    def test_equality(self):
+        a = LabeledPrice('label', 100)
+        b = LabeledPrice('label', 100)
+        c = LabeledPrice('Label', 101)
+        d = Location(123, 456)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_orderinfo.py b/tests/test_orderinfo.py
index 70fc20244b8..dcc3bd84db7 100644
--- a/tests/test_orderinfo.py
+++ b/tests/test_orderinfo.py
@@ -56,3 +56,26 @@ def test_to_dict(self, order_info):
         assert order_info_dict['phone_number'] == order_info.phone_number
         assert order_info_dict['email'] == order_info.email
         assert order_info_dict['shipping_address'] == order_info.shipping_address.to_dict()
+
+    def test_equality(self):
+        a = OrderInfo('name', 'number', 'mail',
+                      ShippingAddress('GB', '', 'London', '12 Grimmauld Place', '', 'WC1'))
+        b = OrderInfo('name', 'number', 'mail',
+                      ShippingAddress('GB', '', 'London', '12 Grimmauld Place', '', 'WC1'))
+        c = OrderInfo('name', 'number', 'mail',
+                      ShippingAddress('GB', '', 'London', '13 Grimmauld Place', '', 'WC1'))
+        d = OrderInfo('name', 'number', 'e-mail',
+                      ShippingAddress('GB', '', 'London', '12 Grimmauld Place', '', 'WC1'))
+        e = ShippingAddress('GB', '', 'London', '12 Grimmauld Place', '', 'WC1')
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
diff --git a/tests/test_poll.py b/tests/test_poll.py
index bc1c0542064..216c17810ef 100644
--- a/tests/test_poll.py
+++ b/tests/test_poll.py
@@ -51,6 +51,21 @@ def test_to_dict(self, poll_option):
         assert poll_option_dict['text'] == poll_option.text
         assert poll_option_dict['voter_count'] == poll_option.voter_count
 
+    def test_equality(self):
+        a = PollOption('text', 1)
+        b = PollOption('text', 2)
+        c = PollOption('text_1', 1)
+        d = Poll(123, 'question', ['O1', 'O2'], 1, False, True, Poll.REGULAR, True)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
 
 @pytest.fixture(scope="class")
 def poll_answer():
@@ -83,6 +98,25 @@ def test_to_dict(self, poll_answer):
         assert poll_answer_dict['user'] == poll_answer.user.to_dict()
         assert poll_answer_dict['option_ids'] == poll_answer.option_ids
 
+    def test_equality(self):
+        a = PollAnswer(123, self.user, [2])
+        b = PollAnswer(123, User(1, 'first', False), [2])
+        c = PollAnswer(123, self.user, [1, 2])
+        d = PollAnswer(456, self.user, [2])
+        e = PollOption('Text', 1)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
+
 
 @pytest.fixture(scope='class')
 def poll():
@@ -181,3 +215,18 @@ def test_parse_entities(self, poll):
 
         assert poll.parse_explanation_entities(MessageEntity.URL) == {entity: 'http://google.com'}
         assert poll.parse_explanation_entities() == {entity: 'http://google.com', entity_2: 'h'}
+
+    def test_equality(self):
+        a = Poll(123, 'question', ['O1', 'O2'], 1, False, True, Poll.REGULAR, True)
+        b = Poll(123, 'question', ['o1', 'o2'], 1, True, False, Poll.REGULAR, True)
+        c = Poll(456, 'question', ['o1', 'o2'], 1, True, False, Poll.REGULAR, True)
+        d = PollOption('Text', 1)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_replykeyboardmarkup.py b/tests/test_replykeyboardmarkup.py
index dd63a85c491..569e757c53f 100644
--- a/tests/test_replykeyboardmarkup.py
+++ b/tests/test_replykeyboardmarkup.py
@@ -20,7 +20,7 @@
 import pytest
 from flaky import flaky
 
-from telegram import ReplyKeyboardMarkup, KeyboardButton
+from telegram import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup
 
 
 @pytest.fixture(scope='class')
@@ -106,3 +106,28 @@ def test_to_dict(self, reply_keyboard_markup):
         assert (reply_keyboard_markup_dict['one_time_keyboard']
                 == reply_keyboard_markup.one_time_keyboard)
         assert reply_keyboard_markup_dict['selective'] == reply_keyboard_markup.selective
+
+    def test_equality(self):
+        a = ReplyKeyboardMarkup.from_column(['button1', 'button2', 'button3'])
+        b = ReplyKeyboardMarkup.from_column([
+            KeyboardButton(text) for text in ['button1', 'button2', 'button3']
+        ])
+        c = ReplyKeyboardMarkup.from_column(['button1', 'button2'])
+        d = ReplyKeyboardMarkup.from_column(['button1', 'button2', 'button3.1'])
+        e = ReplyKeyboardMarkup([['button1', 'button1'], ['button2'], ['button3.1']])
+        f = InlineKeyboardMarkup.from_column(['button1', 'button2', 'button3'])
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
+
+        assert a != e
+        assert hash(a) != hash(e)
+
+        assert a != f
+        assert hash(a) != hash(f)
diff --git a/tests/test_shippingquery.py b/tests/test_shippingquery.py
index 88ef057a11a..78f3070ec99 100644
--- a/tests/test_shippingquery.py
+++ b/tests/test_shippingquery.py
@@ -50,7 +50,7 @@ def test_de_json(self, bot):
         assert shipping_query.invoice_payload == self.invoice_payload
         assert shipping_query.from_user == self.from_user
         assert shipping_query.shipping_address == self.shipping_address
-        assert shipping_query.bot == bot
+        assert shipping_query.bot is bot
 
     def test_to_dict(self, shipping_query):
         shipping_query_dict = shipping_query.to_dict()
diff --git a/tests/test_telegramobject.py b/tests/test_telegramobject.py
index 66f52dbfb88..be2ee7693f2 100644
--- a/tests/test_telegramobject.py
+++ b/tests/test_telegramobject.py
@@ -74,3 +74,24 @@ def test_to_json_ujson(self, monkeypatch):
 
         monkeypatch.setattr('telegram.TelegramObject.to_dict', lambda _: d)
         telegram_object.to_json()
+
+    def test_meaningless_comparison(self, recwarn):
+        class TGO(TelegramObject):
+            pass
+
+        a = TGO()
+        b = TGO()
+        assert a == b
+        assert len(recwarn) == 1
+        assert str(recwarn[0].message) == (
+            "Objects of type TGO can not be meaningfully tested for equivalence."
+        )
+
+    def test_meaningful_comparison(self, recwarn):
+        class TGO(TelegramObject):
+            _id_attrs = (1,)
+
+        a = TGO()
+        b = TGO()
+        assert a == b
+        assert len(recwarn) == 0
diff --git a/tests/test_userprofilephotos.py b/tests/test_userprofilephotos.py
index 5cfe2ed37af..ecb2bfa491d 100644
--- a/tests/test_userprofilephotos.py
+++ b/tests/test_userprofilephotos.py
@@ -48,3 +48,18 @@ def test_to_dict(self):
         for ix, x in enumerate(user_profile_photos_dict['photos']):
             for iy, y in enumerate(x):
                 assert y == user_profile_photos.photos[ix][iy].to_dict()
+
+    def test_equality(self):
+        a = UserProfilePhotos(2, self.photos)
+        b = UserProfilePhotos(2, self.photos)
+        c = UserProfilePhotos(1, [self.photos[0]])
+        d = PhotoSize('file_id1', 'unique_id', 512, 512)
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+        assert a != c
+        assert hash(a) != hash(c)
+
+        assert a != d
+        assert hash(a) != hash(d)
diff --git a/tests/test_webhookinfo.py b/tests/test_webhookinfo.py
new file mode 100644
index 00000000000..6d27277353f
--- /dev/null
+++ b/tests/test_webhookinfo.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+#
+# A library that provides a Python interface to the Telegram Bot API
+# Copyright (C) 2015-2020
+# Leandro Toledo de Souza <devs@python-telegram-bot.org>
+#
+# 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/].
+
+import pytest
+import time
+
+from telegram import WebhookInfo, LoginUrl
+
+
+@pytest.fixture(scope='class')
+def webhook_info():
+    return WebhookInfo(
+        url=TestWebhookInfo.url,
+        has_custom_certificate=TestWebhookInfo.has_custom_certificate,
+        pending_update_count=TestWebhookInfo.pending_update_count,
+        last_error_date=TestWebhookInfo.last_error_date,
+        max_connections=TestWebhookInfo.max_connections,
+        allowed_updates=TestWebhookInfo.allowed_updates,
+    )
+
+
+class TestWebhookInfo(object):
+    url = "http://www.google.com"
+    has_custom_certificate = False
+    pending_update_count = 5
+    last_error_date = time.time()
+    max_connections = 42
+    allowed_updates = ['type1', 'type2']
+
+    def test_to_dict(self, webhook_info):
+        webhook_info_dict = webhook_info.to_dict()
+
+        assert isinstance(webhook_info_dict, dict)
+        assert webhook_info_dict['url'] == self.url
+        assert webhook_info_dict['pending_update_count'] == self.pending_update_count
+        assert webhook_info_dict['last_error_date'] == self.last_error_date
+        assert webhook_info_dict['max_connections'] == self.max_connections
+        assert webhook_info_dict['allowed_updates'] == self.allowed_updates
+
+    def test_equality(self):
+        a = WebhookInfo(
+            url=self.url,
+            has_custom_certificate=self.has_custom_certificate,
+            pending_update_count=self.pending_update_count,
+            last_error_date=self.last_error_date,
+            max_connections=self.max_connections,
+        )
+        b = WebhookInfo(
+            url=self.url,
+            has_custom_certificate=self.has_custom_certificate,
+            pending_update_count=self.pending_update_count,
+            last_error_date=self.last_error_date,
+            max_connections=self.max_connections,
+        )
+        c = WebhookInfo(
+            url="http://github.com",
+            has_custom_certificate=True,
+            pending_update_count=78,
+            last_error_date=0,
+            max_connections=1,
+        )
+        d = LoginUrl("text.com")
+
+        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)


Code review comments:
diff_hunk: @@ -78,6 +80,9 @@ def to_dict(self):
 
     def __eq__(self, other):
         if isinstance(other, self.__class__):
+            if self._id_attrs == ():
path: telegram/base.py
text: @user1:
What about checking `other._id_attrs`?

@author:
Mh … you're right. Will add.

diff_hunk: @@ -43,3 +43,5 @@ def __init__(self, latitude, longitude, live_period=None, **kwargs):
         self.latitude = latitude
         self.longitude = longitude
         self.live_period = live_period
+
+        self._id_attrs = (self.latitude, self.longitude, self.live_period)
path: telegram/inline/inputlocationmessagecontent.py
text: @author:
As keeping live locations up to dateis not easy as far as I understood from the recent discussion in the user group, one could arge that one could skip `live_period` here

Edit: Actually, that's an optional anyway. I'll remove it.

diff_hunk: @@ -43,6 +43,8 @@ def __init__(self, text, voter_count, **kwargs):
         self.text = text
         self.voter_count = voter_count
 
+        self._id_attrs = (self.text,)
path: telegram/poll.py
text: @author:
`voter_count` is required. should be added, as polls might have duplicate options.

diff_hunk: @@ -49,3 +49,5 @@ def __init__(self, force_reply=True, selective=False, **kwargs):
         self.force_reply = bool(force_reply)
         # Optionals
         self.selective = bool(selective)
+
+        self._id_attrs = (self.force_reply, self.selective)
path: telegram/forcereply.py
text: @author:
Adding `selective` to `_id_attrs` differs from the other classes with required args, where no optionals where added. However, `selective` is quite a difference `ForceReply`, actually the only relevant setting (as `force_reply` is always `True`, i.e. the API docs state its Type as `True`, not `Boolean`)

diff_hunk: @@ -48,3 +48,5 @@ def __init__(self, phone_number, first_name, last_name=None, vcard=None, **kwarg
         # Optionals
         self.last_name = last_name
         self.vcard = vcard
+
+        self._id_attrs = (self.phone_number, self.first_name)
path: telegram/inline/inputcontactmessagecontent.py
text: @author:
One *could* argue, that `last_name` should be added. That reduces the risk of evaluating to people with the same (first) name as equal.

@author:
Then again, for `Contact`, we only use phone number. That seems cleaner to me, as the same phone number shouldn't appear twice.

@user1:
Agreed. Phone number should be enough to uniquely identify someone, first name on top is nice to have, but definitely enough