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

Issue:
[BUG] 13.1+ persistence migration script raises an exception when converting UUID objects
<!--
Thanks for reporting issues of python-telegram-bot!

Use this template to notify us if you found a bug.

To make it easier for us to help you please enter detailed information below.

Please note, we only support the latest version of python-telegram-bot and
master branch. Please make sure to upgrade & recreate the issue on the latest
version prior to opening an issue.
-->
### Steps to reproduce
1. run a bot using python-telegram-bot <13 (in my case, 12.8) and store some `uuid.UUID` instances in an user's `user_data`, and configure the bot to serialize temp data to a pickle file using `PicklePersistence`

2. use the [`PicklePersistence` migration script](https://gist.github.com/Bibo-Joshi/23b8c02249264256ded07661d0cd8a4f) linked in the wiki against your pickle file generated by ptb <13

### Expected behaviour
The pickle file migration is successfull

### Actual behaviour
Running the script raises an exception. Here's the full traceback:

```
Traceback (most recent call last):
  File "C:/Users/redacted/PycharmProjects/sticker-thief/convert_pp_13.1.py", line 92, in <module>
    persistence.get_user_data()
  File "C:\Users\redacted\PycharmProjects\sticker-thief\venv\lib\site-packages\telegram\ext\basepersistence.py", line 136, in get_user_data_insert_bot
    return instance.insert_bot(get_user_data())
  File "C:\Users\redacted\PycharmProjects\sticker-thief\venv\lib\site-packages\telegram\ext\basepersistence.py", line 310, in insert_bot
    return self._insert_bot(obj, {})
  File "C:\Users\redacted\PycharmProjects\sticker-thief\venv\lib\site-packages\telegram\ext\basepersistence.py", line 364, in _insert_bot
    new_obj[self._insert_bot(k, memo)] = self._insert_bot(val, memo)
  File "C:\Users\redacted\PycharmProjects\sticker-thief\venv\lib\site-packages\telegram\ext\basepersistence.py", line 364, in _insert_bot
    new_obj[self._insert_bot(k, memo)] = self._insert_bot(val, memo)
  File "C:\Users\redacted\PycharmProjects\sticker-thief\venv\lib\site-packages\telegram\ext\basepersistence.py", line 364, in _insert_bot
    new_obj[self._insert_bot(k, memo)] = self._insert_bot(val, memo)
  File "C:\Users\redacted\PycharmProjects\sticker-thief\venv\lib\site-packages\telegram\ext\basepersistence.py", line 369, in _insert_bot
    setattr(new_obj, attr_name, self._insert_bot(attr, memo))
  File "C:\Users\redacted\AppData\Local\Programs\Python\Python36-32\lib\uuid.py", line 224, in __setattr__
    raise TypeError('UUID objects are immutable')
TypeError: UUID objects are immutable
```

The exception is raised because UUID object don't want the user to use `__setattr__` (the exception is raised by `uuid.UUID().__setattr__()`), which is what the migration script explicitly uses at line 75 - but the exception is actually raised first, when the script uses `PicklePersistence().get_user_data()` (which calls `BasePersistence.insert_bot()`, which will try to recursively inject a new attribute to every object that has a `__dict__` attribute ([link to code](https://github.com/python-telegram-bot/python-telegram-bot/blob/52ce03929b3255eade1081b2ee6fa278708dbef9/telegram/ext/basepersistence.py#L382)), such as UUID instances)

Related thread in the group chat: https://t.me/pythontelegrambotgroup/487577

### Configuration
**Operating System:** Widnows 10


**Version of Python, python-telegram-bot & dependencies:**

```
$ python -m telegram
python-telegram-bot 13.6
Bot API 5.2
certifi 2019.09.11
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 17:26:49) [MSC v.1900 32 bit (Intel)]
```

### Logs
Insert logs here (if necessary)



Discussion:

Resolving patch:
diff --git a/telegram/ext/basepersistence.py b/telegram/ext/basepersistence.py
--- a/telegram/ext/basepersistence.py
+++ b/telegram/ext/basepersistence.py
@@ -212,7 +212,8 @@ def replace_bot(cls, obj: object) -> object:
         :attr:`REPLACED_BOT`. Currently, this handles objects of type ``list``, ``tuple``, ``set``,
         ``frozenset``, ``dict``, ``defaultdict`` and objects that have a ``__dict__`` or
         ``__slots__`` attribute, excluding classes and objects that can't be copied with
-        ``copy.copy``.
+        ``copy.copy``. If the parsing of an object fails, the object will be returned unchanged and
+         the error will be logged.
 
         Args:
             obj (:obj:`object`): The object
@@ -278,20 +279,31 @@ def _replace_bot(cls, obj: object, memo: Dict[int, object]) -> object:  # pylint
             return new_obj
         # if '__dict__' in obj.__slots__, we already cover this here, that's why the
         # __dict__ case comes below
-        if hasattr(obj, '__slots__'):
-            for attr_name in new_obj.__slots__:
-                setattr(
-                    new_obj,
-                    attr_name,
-                    cls._replace_bot(cls._replace_bot(getattr(new_obj, attr_name), memo), memo),
-                )
-            memo[obj_id] = new_obj
-            return new_obj
-        if hasattr(obj, '__dict__'):
-            for attr_name, attr in new_obj.__dict__.items():
-                setattr(new_obj, attr_name, cls._replace_bot(attr, memo))
-            memo[obj_id] = new_obj
-            return new_obj
+        try:
+            if hasattr(obj, '__slots__'):
+                for attr_name in new_obj.__slots__:
+                    setattr(
+                        new_obj,
+                        attr_name,
+                        cls._replace_bot(
+                            cls._replace_bot(getattr(new_obj, attr_name), memo), memo
+                        ),
+                    )
+                memo[obj_id] = new_obj
+                return new_obj
+            if hasattr(obj, '__dict__'):
+                for attr_name, attr in new_obj.__dict__.items():
+                    setattr(new_obj, attr_name, cls._replace_bot(attr, memo))
+                memo[obj_id] = new_obj
+                return new_obj
+        except Exception as exception:
+            warnings.warn(
+                f'Parsing of an object failed with the following exception: {exception}. '
+                f'See the docs of BasePersistence.replace_bot for more information.',
+                RuntimeWarning,
+            )
+            memo[obj_id] = obj
+            return obj
 
         return obj
 
@@ -301,7 +313,8 @@ def insert_bot(self, obj: object) -> object:
         :attr:`bot`. Currently, this handles objects of type ``list``, ``tuple``, ``set``,
         ``frozenset``, ``dict``, ``defaultdict`` and objects that have a ``__dict__`` or
         ``__slots__`` attribute, excluding classes and objects that can't be copied with
-        ``copy.copy``.
+        ``copy.copy``. If the parsing of an object fails, the object will be returned unchanged and
+        the error will be logged.
 
         Args:
             obj (:obj:`object`): The object
@@ -368,20 +381,31 @@ def _insert_bot(self, obj: object, memo: Dict[int, object]) -> object:  # pylint
             return new_obj
         # if '__dict__' in obj.__slots__, we already cover this here, that's why the
         # __dict__ case comes below
-        if hasattr(obj, '__slots__'):
-            for attr_name in obj.__slots__:
-                setattr(
-                    new_obj,
-                    attr_name,
-                    self._insert_bot(self._insert_bot(getattr(new_obj, attr_name), memo), memo),
-                )
-            memo[obj_id] = new_obj
-            return new_obj
-        if hasattr(obj, '__dict__'):
-            for attr_name, attr in new_obj.__dict__.items():
-                setattr(new_obj, attr_name, self._insert_bot(attr, memo))
-            memo[obj_id] = new_obj
-            return new_obj
+        try:
+            if hasattr(obj, '__slots__'):
+                for attr_name in obj.__slots__:
+                    setattr(
+                        new_obj,
+                        attr_name,
+                        self._insert_bot(
+                            self._insert_bot(getattr(new_obj, attr_name), memo), memo
+                        ),
+                    )
+                memo[obj_id] = new_obj
+                return new_obj
+            if hasattr(obj, '__dict__'):
+                for attr_name, attr in new_obj.__dict__.items():
+                    setattr(new_obj, attr_name, self._insert_bot(attr, memo))
+                memo[obj_id] = new_obj
+                return new_obj
+        except Exception as exception:
+            warnings.warn(
+                f'Parsing of an object failed with the following exception: {exception}. '
+                f'See the docs of BasePersistence.insert_bot for more information.',
+                RuntimeWarning,
+            )
+            memo[obj_id] = obj
+            return obj
 
         return obj
 


Test patch:
diff --git a/tests/test_persistence.py b/tests/test_persistence.py
--- a/tests/test_persistence.py
+++ b/tests/test_persistence.py
@@ -18,6 +18,7 @@
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
 import gzip
 import signal
+import uuid
 from threading import Lock
 
 from telegram.ext.callbackdatacache import CallbackDataCache
@@ -689,6 +690,36 @@ def __copy__(self):
             "BasePersistence.insert_bot does not handle objects that can not be copied."
         )
 
+    def test_bot_replace_insert_bot_unparsable_objects(self, bot, bot_persistence, recwarn):
+        """Here check that objects in __dict__ or __slots__ that can't
+        be parsed are just returned verbatim."""
+        persistence = bot_persistence
+        persistence.set_bot(bot)
+
+        uuid_obj = uuid.uuid4()
+
+        persistence.update_bot_data({1: uuid_obj})
+        assert persistence.bot_data[1] is uuid_obj
+        persistence.update_chat_data(123, {1: uuid_obj})
+        assert persistence.chat_data[123][1] is uuid_obj
+        persistence.update_user_data(123, {1: uuid_obj})
+        assert persistence.user_data[123][1] is uuid_obj
+        persistence.update_callback_data(([('1', 2, {0: uuid_obj})], {'1': '2'}))
+        assert persistence.callback_data[0][0][2][0] is uuid_obj
+
+        assert persistence.get_bot_data()[1] is uuid_obj
+        assert persistence.get_chat_data()[123][1] is uuid_obj
+        assert persistence.get_user_data()[123][1] is uuid_obj
+        assert persistence.get_callback_data()[0][0][2][0] is uuid_obj
+
+        assert len(recwarn) == 2
+        assert str(recwarn[0].message).startswith(
+            "Parsing of an object failed with the following exception: "
+        )
+        assert str(recwarn[1].message).startswith(
+            "Parsing of an object failed with the following exception: "
+        )
+
     def test_bot_replace_insert_bot_classes(self, bot, bot_persistence, recwarn):
         """Here check that classes are just returned verbatim."""
         persistence = bot_persistence