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

Issue:
[BUG] `(Ext)Bot._insert_defaults` edits objects in-place
### Steps to Reproduce

1. create an `InputMediaPhoto` with a HTML-Caption
2. use it in a `send_media_group` call of a bot without `Defaults`
3. use it in a `send_media_group` call of a bot with `Defaults.parse_mode`


### Expected behaviour

in step 3., the html markup is parsed by TG

### Actual behaviour

it's not.

### Operating System

Any

### Version of Python, python-telegram-bot & dependencies

```shell
PTB v20
```


### Relevant log output

_No response_

### Additional Context

This is due to `_insert_defaults` doing something like `media.parse_mode = None`. Instead it should create a copy of `media` and set the parse mode on the copy.

This issue surfaced in #3295 (https://github.com/python-telegram-bot/python-telegram-bot/pull/3295#discussion_r1002723820) and was also teased in #3249.


Discussion:

Resolving patch:
diff --git a/telegram/_bot.py b/telegram/_bot.py
--- a/telegram/_bot.py
+++ b/telegram/_bot.py
@@ -23,6 +23,7 @@
 import logging
 import pickle
 from contextlib import AbstractAsyncContextManager
+from copy import copy
 from datetime import datetime
 from types import TracebackType
 from typing import (
@@ -346,10 +347,16 @@ def _insert_defaults(self, data: Dict[str, object]) -> None:  # skipcq: PYL-R020
         for key, val in data.items():
             # 1)
             if isinstance(val, InputMedia):
+                # Copy object as not to edit it in-place
+                val = copy(val)
                 val.parse_mode = DefaultValue.get_value(val.parse_mode)
+                data[key] = val
             elif key == "media" and isinstance(val, list):
-                for media in val:
+                # Copy objects as not to edit them in-place
+                copy_list = [copy(media) for media in val]
+                for media in copy_list:
                     media.parse_mode = DefaultValue.get_value(media.parse_mode)
+                data[key] = copy_list
             # 2)
             else:
                 data[key] = DefaultValue.get_value(val)
@@ -2852,24 +2859,39 @@ def _effective_inline_results(  # skipcq: PYL-R0201
         return effective_results, next_offset
 
     @no_type_check  # mypy doesn't play too well with hasattr
-    def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> None:
+    def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQueryResult":
         """The reason why this method exists is similar to the description of _insert_defaults
         The reason why we do this in rather than in _insert_defaults is because converting
         DEFAULT_NONE to NONE *before* calling to_dict() makes it way easier to drop None entries
         from the json data.
+
+        Must return the correct object instead of editing in-place!
         """
+        # Copy the objects that need modification to avoid modifying the original object
+        copied = False
         if hasattr(res, "parse_mode"):
+            res = copy(res)
+            copied = True
             res.parse_mode = DefaultValue.get_value(res.parse_mode)
         if hasattr(res, "input_message_content") and res.input_message_content:
             if hasattr(res.input_message_content, "parse_mode"):
+                if not copied:
+                    res = copy(res)
+                    copied = True
+                res.input_message_content = copy(res.input_message_content)
                 res.input_message_content.parse_mode = DefaultValue.get_value(
                     res.input_message_content.parse_mode
                 )
             if hasattr(res.input_message_content, "disable_web_page_preview"):
+                if not copied:
+                    res = copy(res)
+                res.input_message_content = copy(res.input_message_content)
                 res.input_message_content.disable_web_page_preview = DefaultValue.get_value(
                     res.input_message_content.disable_web_page_preview
                 )
 
+        return res
+
     @_log
     async def answer_inline_query(
         self,
@@ -2968,8 +2990,9 @@ async def answer_inline_query(
         )
 
         # Apply defaults
-        for result in effective_results:
-            self._insert_defaults_for_ilq_results(result)
+        effective_results = [
+            self._insert_defaults_for_ilq_results(result) for result in effective_results
+        ]
 
         data: JSONDict = {"inline_query_id": inline_query_id, "results": effective_results}
 
diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py
--- a/telegram/ext/_extbot.py
+++ b/telegram/ext/_extbot.py
@@ -370,11 +370,17 @@ def _insert_defaults(self, data: Dict[str, object]) -> None:
 
             # 3)
             elif isinstance(val, InputMedia) and val.parse_mode is DEFAULT_NONE:
+                # Copy object as not to edit it in-place
+                val = copy(val)
                 val.parse_mode = self.defaults.parse_mode if self.defaults else None
+                data[key] = val
             elif key == "media" and isinstance(val, list):
-                for media in val:
+                # Copy objects as not to edit them in-place
+                copy_list = [copy(media) for media in val]
+                for media in copy_list:
                     if media.parse_mode is DEFAULT_NONE:
                         media.parse_mode = self.defaults.parse_mode if self.defaults else None
+                data[key] = copy_list
 
     def _replace_keyboard(self, reply_markup: Optional[ReplyMarkup]) -> Optional[ReplyMarkup]:
         # If the reply_markup is an inline keyboard and we allow arbitrary callback data, let the
@@ -546,18 +552,25 @@ def _effective_inline_results(
         return results, next_offset
 
     @no_type_check  # mypy doesn't play too well with hasattr
-    def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> None:
+    def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQueryResult":
         """This method is called by Bot.answer_inline_query to replace `DefaultValue(obj)` with
         `obj`.
         Overriding this to call insert the actual desired default values.
         """
+        # Copy the objects that need modification to avoid modifying the original object
+        copied = False
         if hasattr(res, "parse_mode") and res.parse_mode is DEFAULT_NONE:
+            res = copy(res)
+            copied = True
             res.parse_mode = self.defaults.parse_mode if self.defaults else None
         if hasattr(res, "input_message_content") and res.input_message_content:
             if (
                 hasattr(res.input_message_content, "parse_mode")
                 and res.input_message_content.parse_mode is DEFAULT_NONE
             ):
+                if not copied:
+                    res = copy(res)
+                    copied = True
                 res.input_message_content.parse_mode = (
                     self.defaults.parse_mode if self.defaults else None
                 )
@@ -565,10 +578,14 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> None:
                 hasattr(res.input_message_content, "disable_web_page_preview")
                 and res.input_message_content.disable_web_page_preview is DEFAULT_NONE
             ):
+                if not copied:
+                    res = copy(res)
                 res.input_message_content.disable_web_page_preview = (
                     self.defaults.disable_web_page_preview if self.defaults else None
                 )
 
+        return res
+
     async def stop_poll(
         self,
         chat_id: Union[int, str],


Test patch:
diff --git a/tests/test_bot.py b/tests/test_bot.py
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -17,6 +17,7 @@
 # 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 asyncio
+import copy
 import datetime as dtm
 import inspect
 import logging
@@ -46,6 +47,7 @@
     InlineQueryResultVoice,
     InputFile,
     InputMedia,
+    InputMessageContent,
     InputTextMessageContent,
     LabeledPrice,
     MenuButton,
@@ -63,7 +65,7 @@
     WebAppInfo,
 )
 from telegram._utils.datetime import from_timestamp, to_timestamp
-from telegram._utils.defaultvalue import DefaultValue
+from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
 from telegram.constants import ChatAction, InlineQueryLimit, MenuButtonType, ParseMode
 from telegram.error import BadRequest, InvalidToken, NetworkError
 from telegram.ext import ExtBot, InvalidCallbackData
@@ -166,6 +168,27 @@ def bot_methods(ext_bot=True):
     )
 
 
+class InputMessageContentDWPP(InputMessageContent):
+    """
+    This is here to cover the case of InputMediaContent classes in testing answer_ilq that have
+    `disable_web_page_preview` but not `parse_mode`. Unlikely to ever happen, but better be save
+    than sorry …
+    """
+
+    __slots__ = ("disable_web_page_preview", "parse_mode", "entities", "message_text")
+
+    def __init__(
+        self,
+        message_text: str,
+        disable_web_page_preview=DEFAULT_NONE,
+        *,
+        api_kwargs=None,
+    ):
+        super().__init__(api_kwargs=api_kwargs)
+        self.message_text = message_text
+        self.disable_web_page_preview = disable_web_page_preview
+
+
 class TestBot:
     """
     Most are executed on tg.ext.ExtBot, as that class only extends the functionality of tg.bot
@@ -992,7 +1015,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
         assert web_app_msg.inline_message_id == "321"
 
     # TODO: Needs improvement. We need incoming inline query to test answer.
-    async def test_answer_inline_query(self, monkeypatch, bot):
+    async def test_answer_inline_query(self, monkeypatch, bot, raw_bot):
         # For now just test that our internals pass the correct data
         async def make_assertion(url, request_data: RequestData, *args, **kwargs):
             return request_data.parameters == {
@@ -1010,6 +1033,16 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
                         "type": "article",
                         "input_message_content": {"message_text": "second"},
                     },
+                    {
+                        "title": "test_result",
+                        "id": "123",
+                        "type": "document",
+                        "document_url": "https://raw.githubusercontent.com/python-telegram-bot"
+                        "/logos/master/logo/png/ptb-logo_240.png",
+                        "mime_type": "image/png",
+                        "caption": "ptb_logo",
+                        "input_message_content": {"message_text": "imc"},
+                    },
                 ],
                 "next_offset": "42",
                 "switch_pm_parameter": "start_pm",
@@ -1018,28 +1051,71 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
                 "switch_pm_text": "switch pm",
             }
 
-        monkeypatch.setattr(bot.request, "post", make_assertion)
         results = [
             InlineQueryResultArticle("11", "first", InputTextMessageContent("first")),
-            InlineQueryResultArticle("12", "second", InputTextMessageContent("second")),
+            InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")),
+            InlineQueryResultDocument(
+                id="123",
+                document_url="https://raw.githubusercontent.com/python-telegram-bot/logos/master/"
+                "logo/png/ptb-logo_240.png",
+                title="test_result",
+                mime_type="image/png",
+                caption="ptb_logo",
+                input_message_content=InputMessageContentDWPP("imc"),
+            ),
         ]
 
-        assert await bot.answer_inline_query(
-            1234,
-            results=results,
-            cache_time=300,
-            is_personal=True,
-            next_offset="42",
-            switch_pm_text="switch pm",
-            switch_pm_parameter="start_pm",
-        )
-        monkeypatch.delattr(bot.request, "post")
+        copied_results = copy.copy(results)
+        ext_bot = bot
+        for bot in (ext_bot, raw_bot):
+            # We need to test 1) below both the bot and raw_bot and setting this up with
+            # pytest.parametrize appears to be difficult ...
+            monkeypatch.setattr(bot.request, "post", make_assertion)
+            assert await bot.answer_inline_query(
+                1234,
+                results=results,
+                cache_time=300,
+                is_personal=True,
+                next_offset="42",
+                switch_pm_text="switch pm",
+                switch_pm_parameter="start_pm",
+            )
+
+            # 1)
+            # make sure that the results were not edited in-place
+            assert results == copied_results
+            for idx, result in enumerate(results):
+                if hasattr(result, "parse_mode"):
+                    assert result.parse_mode == copied_results[idx].parse_mode
+                if hasattr(result, "input_message_content"):
+                    assert getattr(result.input_message_content, "parse_mode", None) == getattr(
+                        copied_results[idx].input_message_content, "parse_mode", None
+                    )
+                    assert getattr(
+                        result.input_message_content, "disable_web_page_preview", None
+                    ) == getattr(
+                        copied_results[idx].input_message_content, "disable_web_page_preview", None
+                    )
+
+            monkeypatch.delattr(bot.request, "post")
 
     async def test_answer_inline_query_no_default_parse_mode(self, monkeypatch, bot):
         async def make_assertion(url, request_data: RequestData, *args, **kwargs):
             return request_data.parameters == {
                 "cache_time": 300,
                 "results": [
+                    {
+                        "title": "first",
+                        "id": "11",
+                        "type": "article",
+                        "input_message_content": {"message_text": "first"},
+                    },
+                    {
+                        "title": "second",
+                        "id": "12",
+                        "type": "article",
+                        "input_message_content": {"message_text": "second"},
+                    },
                     {
                         "title": "test_result",
                         "id": "123",
@@ -1049,7 +1125,8 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
                         "ptb-logo_240.png",
                         "mime_type": "image/png",
                         "caption": "ptb_logo",
-                    }
+                        "input_message_content": {"message_text": "imc"},
+                    },
                 ],
                 "next_offset": "42",
                 "switch_pm_parameter": "start_pm",
@@ -1060,6 +1137,8 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
 
         monkeypatch.setattr(bot.request, "post", make_assertion)
         results = [
+            InlineQueryResultArticle("11", "first", InputTextMessageContent("first")),
+            InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")),
             InlineQueryResultDocument(
                 id="123",
                 document_url="https://raw.githubusercontent.com/python-telegram-bot/logos/master/"
@@ -1067,9 +1146,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
                 title="test_result",
                 mime_type="image/png",
                 caption="ptb_logo",
-            )
+                input_message_content=InputMessageContentDWPP("imc"),
+            ),
         ]
 
+        copied_results = copy.copy(results)
         assert await bot.answer_inline_query(
             1234,
             results=results,
@@ -1079,13 +1160,50 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
             switch_pm_text="switch pm",
             switch_pm_parameter="start_pm",
         )
+        # make sure that the results were not edited in-place
+        assert results == copied_results
+        for idx, result in enumerate(results):
+            if hasattr(result, "parse_mode"):
+                assert result.parse_mode == copied_results[idx].parse_mode
+            if hasattr(result, "input_message_content"):
+                assert getattr(result.input_message_content, "parse_mode", None) == getattr(
+                    copied_results[idx].input_message_content, "parse_mode", None
+                )
+                assert getattr(
+                    result.input_message_content, "disable_web_page_preview", None
+                ) == getattr(
+                    copied_results[idx].input_message_content, "disable_web_page_preview", None
+                )
 
-    @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True)
+    @pytest.mark.parametrize(
+        "default_bot",
+        [{"parse_mode": "Markdown", "disable_web_page_preview": True}],
+        indirect=True,
+    )
     async def test_answer_inline_query_default_parse_mode(self, monkeypatch, default_bot):
         async def make_assertion(url, request_data: RequestData, *args, **kwargs):
             return request_data.parameters == {
                 "cache_time": 300,
                 "results": [
+                    {
+                        "title": "first",
+                        "id": "11",
+                        "type": "article",
+                        "input_message_content": {
+                            "message_text": "first",
+                            "parse_mode": "Markdown",
+                            "disable_web_page_preview": True,
+                        },
+                    },
+                    {
+                        "title": "second",
+                        "id": "12",
+                        "type": "article",
+                        "input_message_content": {
+                            "message_text": "second",
+                            "disable_web_page_preview": True,
+                        },
+                    },
                     {
                         "title": "test_result",
                         "id": "123",
@@ -1096,7 +1214,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
                         "mime_type": "image/png",
                         "caption": "ptb_logo",
                         "parse_mode": "Markdown",
-                    }
+                        "input_message_content": {
+                            "message_text": "imc",
+                            "disable_web_page_preview": True,
+                            "parse_mode": "Markdown",
+                        },
+                    },
                 ],
                 "next_offset": "42",
                 "switch_pm_parameter": "start_pm",
@@ -1107,6 +1230,8 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
 
         monkeypatch.setattr(default_bot.request, "post", make_assertion)
         results = [
+            InlineQueryResultArticle("11", "first", InputTextMessageContent("first")),
+            InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")),
             InlineQueryResultDocument(
                 id="123",
                 document_url="https://raw.githubusercontent.com/python-telegram-bot/logos/master/"
@@ -1114,9 +1239,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
                 title="test_result",
                 mime_type="image/png",
                 caption="ptb_logo",
-            )
+                input_message_content=InputTextMessageContent("imc"),
+            ),
         ]
 
+        copied_results = copy.copy(results)
         assert await default_bot.answer_inline_query(
             1234,
             results=results,
@@ -1126,6 +1253,20 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
             switch_pm_text="switch pm",
             switch_pm_parameter="start_pm",
         )
+        # make sure that the results were not edited in-place
+        assert results == copied_results
+        for idx, result in enumerate(results):
+            if hasattr(result, "parse_mode"):
+                assert result.parse_mode == copied_results[idx].parse_mode
+            if hasattr(result, "input_message_content"):
+                assert getattr(result.input_message_content, "parse_mode", None) == getattr(
+                    copied_results[idx].input_message_content, "parse_mode", None
+                )
+                assert getattr(
+                    result.input_message_content, "disable_web_page_preview", None
+                ) == getattr(
+                    copied_results[idx].input_message_content, "disable_web_page_preview", None
+                )
 
     async def test_answer_inline_query_current_offset_error(self, bot, inline_results):
         with pytest.raises(ValueError, match=("`current_offset` and `next_offset`")):
diff --git a/tests/test_inputmedia.py b/tests/test_inputmedia.py
--- a/tests/test_inputmedia.py
+++ b/tests/test_inputmedia.py
@@ -16,6 +16,8 @@
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
+import copy
+
 import pytest
 from flaky import flaky
 
@@ -444,24 +446,39 @@ async def test_send_media_group_photo(self, bot, chat_id, media_group):
         )
 
     @flaky(3, 1)
-    async def test_send_media_group_all_args(self, bot, chat_id, media_group):
-        m1 = await bot.send_message(chat_id, text="test")
-        messages = await bot.send_media_group(
-            chat_id,
-            media_group,
-            disable_notification=True,
-            reply_to_message_id=m1.message_id,
-            protect_content=True,
-        )
-        assert isinstance(messages, list)
-        assert len(messages) == 3
-        assert all(isinstance(mes, Message) for mes in messages)
-        assert all(mes.media_group_id == messages[0].media_group_id for mes in messages)
-        assert all(mes.caption == f"photo {idx+1}" for idx, mes in enumerate(messages))
-        assert all(
-            mes.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)] for mes in messages
-        )
-        assert all(mes.has_protected_content for mes in messages)
+    async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_group):
+        ext_bot = bot
+        for bot in (ext_bot, raw_bot):
+            # We need to test 1) below both the bot and raw_bot and setting this up with
+            # pytest.parametrize appears to be difficult ...
+
+            m1 = await bot.send_message(chat_id, text="test")
+            copied_media_group = copy.copy(media_group)
+            messages = await bot.send_media_group(
+                chat_id,
+                media_group,
+                disable_notification=True,
+                reply_to_message_id=m1.message_id,
+                protect_content=True,
+            )
+
+            # 1)
+            # make sure that the media_group was not modified
+            assert media_group == copied_media_group
+            assert all(
+                a.parse_mode == b.parse_mode for a, b in zip(media_group, copied_media_group)
+            )
+
+            assert isinstance(messages, list)
+            assert len(messages) == 3
+            assert all(isinstance(mes, Message) for mes in messages)
+            assert all(mes.media_group_id == messages[0].media_group_id for mes in messages)
+            assert all(mes.caption == f"photo {idx+1}" for idx, mes in enumerate(messages))
+            assert all(
+                mes.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)]
+                for mes in messages
+            )
+            assert all(mes.has_protected_content for mes in messages)
 
     @flaky(3, 1)
     async def test_send_media_group_custom_filename(
@@ -584,14 +601,23 @@ async def test_send_media_group_default_protect_content(
         assert not all(msg.has_protected_content for msg in unprotected)
 
     @flaky(3, 1)
-    async def test_edit_message_media(self, bot, chat_id, media_group):
-        messages = await bot.send_media_group(chat_id, media_group)
-        cid = messages[-1].chat.id
-        mid = messages[-1].message_id
-        new_message = await bot.edit_message_media(
-            chat_id=cid, message_id=mid, media=media_group[0]
-        )
-        assert isinstance(new_message, Message)
+    async def test_edit_message_media(self, bot, raw_bot, chat_id, media_group):
+        ext_bot = bot
+        for bot in (ext_bot, raw_bot):
+            # We need to test 1) below both the bot and raw_bot and setting this up with
+            # pytest.parametrize appears to be difficult ...
+            messages = await bot.send_media_group(chat_id, media_group)
+            cid = messages[-1].chat.id
+            mid = messages[-1].message_id
+            copied_media = copy.copy(media_group[0])
+            new_message = await bot.edit_message_media(
+                chat_id=cid, message_id=mid, media=media_group[0]
+            )
+            assert isinstance(new_message, Message)
+
+            # 1)
+            # make sure that the media was not modified
+            assert media_group[0].parse_mode == copied_media.parse_mode
 
     @flaky(3, 1)
     async def test_edit_message_media_new_file(self, bot, chat_id, media_group, thumb_file):
@@ -666,32 +692,44 @@ def build_media(parse_mode, med_type):
 
         message = await default_bot.send_photo(chat_id, photo)
 
+        media = build_media(parse_mode=ParseMode.HTML, med_type=media_type)
+        copied_media = copy.copy(media)
         message = await default_bot.edit_message_media(
-            build_media(parse_mode=ParseMode.HTML, med_type=media_type),
+            media,
             message.chat_id,
             message.message_id,
         )
         assert message.caption == test_caption
         assert message.caption_entities == test_entities
+        # make sure that the media was not modified
+        assert media.parse_mode == copied_media.parse_mode
 
         # Remove caption to avoid "Message not changed"
         await message.edit_caption()
 
+        media = build_media(parse_mode=ParseMode.MARKDOWN_V2, med_type=media_type)
+        copied_media = copy.copy(media)
         message = await default_bot.edit_message_media(
-            build_media(parse_mode=ParseMode.MARKDOWN_V2, med_type=media_type),
+            media,
             message.chat_id,
             message.message_id,
         )
         assert message.caption == test_caption
         assert message.caption_entities == test_entities
+        # make sure that the media was not modified
+        assert media.parse_mode == copied_media.parse_mode
 
         # Remove caption to avoid "Message not changed"
         await message.edit_caption()
 
+        media = build_media(parse_mode=None, med_type=media_type)
+        copied_media = copy.copy(media)
         message = await default_bot.edit_message_media(
-            build_media(parse_mode=None, med_type=media_type),
+            media,
             message.chat_id,
             message.message_id,
         )
         assert message.caption == markdown_caption
         assert message.caption_entities == []
+        # make sure that the media was not modified
+        assert media.parse_mode == copied_media.parse_mode