Skip to content

Commit a743726

Browse files
harshil21Bibo-Joshi
authored andcommitted
Persistence of Bots: Refactor Automatic Replacement and Integration with TelegramObject (python-telegram-bot#2893)
1 parent 7b37f9a commit a743726

File tree

12 files changed

+465
-691
lines changed

12 files changed

+465
-691
lines changed

telegram/_bot.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import functools
2424
import logging
25+
import pickle
2526
from datetime import datetime
2627

2728
from typing import (
@@ -37,6 +38,7 @@
3738
cast,
3839
Sequence,
3940
Any,
41+
NoReturn,
4042
)
4143

4244
try:
@@ -123,10 +125,13 @@ class Bot(TelegramObject):
123125
considered equal, if their :attr:`bot` is equal.
124126
125127
Note:
126-
Most bot methods have the argument ``api_kwargs`` which allows to pass arbitrary keywords
127-
to the Telegram API. This can be used to access new features of the API before they were
128-
incorporated into PTB. However, this is not guaranteed to work, i.e. it will fail for
129-
passing files.
128+
* Most bot methods have the argument ``api_kwargs`` which allows passing arbitrary keywords
129+
to the Telegram API. This can be used to access new features of the API before they are
130+
incorporated into PTB. However, this is not guaranteed to work, i.e. it will fail for
131+
passing files.
132+
* Bots should not be serialized since if you for e.g. change the bots token, then your
133+
serialized instance will not reflect that change. Trying to pickle a bot instance will
134+
raise :exc:`pickle.PicklingError`.
130135
131136
.. versionchanged:: 14.0
132137
@@ -136,6 +141,7 @@ class Bot(TelegramObject):
136141
* Removed the deprecated ``defaults`` parameter. If you want to use
137142
:class:`telegram.ext.Defaults`, please use the subclass :class:`telegram.ext.ExtBot`
138143
instead.
144+
* Attempting to pickle a bot instance will now raise :exc:`pickle.PicklingError`.
139145
140146
Args:
141147
token (:obj:`str`): Bot's unique authentication.
@@ -157,7 +163,7 @@ class Bot(TelegramObject):
157163
'private_key',
158164
'_bot_user',
159165
'_request',
160-
'logger',
166+
'_logger',
161167
)
162168

163169
def __init__(
@@ -176,7 +182,7 @@ def __init__(
176182
self._bot_user: Optional[User] = None
177183
self._request = request or Request()
178184
self.private_key = None
179-
self.logger = logging.getLogger(__name__)
185+
self._logger = logging.getLogger(__name__)
180186

181187
if private_key:
182188
if not CRYPTO_INSTALLED:
@@ -188,6 +194,10 @@ def __init__(
188194
private_key, password=private_key_password, backend=default_backend()
189195
)
190196

197+
def __reduce__(self) -> NoReturn:
198+
"""Called by pickle.dumps(). Serializing bots is unadvisable, so we forbid pickling."""
199+
raise pickle.PicklingError('Bot objects cannot be pickled!')
200+
191201
# TODO: After https://youtrack.jetbrains.com/issue/PY-50952 is fixed, we can revisit this and
192202
# consider adding Paramspec from typing_extensions to properly fix this. Currently a workaround
193203
def _log(func: Any): # type: ignore[no-untyped-def] # skipcq: PY-D0003
@@ -2999,9 +3009,9 @@ def get_updates(
29993009
)
30003010

30013011
if result:
3002-
self.logger.debug('Getting updates: %s', [u['update_id'] for u in result])
3012+
self._logger.debug('Getting updates: %s', [u['update_id'] for u in result])
30033013
else:
3004-
self.logger.debug('No new updates found.')
3014+
self._logger.debug('No new updates found.')
30053015

30063016
return Update.de_list(result, self) # type: ignore[return-value]
30073017

telegram/_files/_basemedium.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class _BaseMedium(TelegramObject):
5151
5252
"""
5353

54-
__slots__ = ('bot', 'file_id', 'file_size', 'file_unique_id')
54+
__slots__ = ('file_id', 'file_size', 'file_unique_id')
5555

5656
def __init__(
5757
self, file_id: str, file_unique_id: str, file_size: int = None, bot: 'Bot' = None

telegram/_passport/credentials.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,14 @@ class Credentials(TelegramObject):
210210
nonce (:obj:`str`): Bot-specified nonce
211211
"""
212212

213-
__slots__ = ('bot', 'nonce', 'secure_data')
213+
__slots__ = ('nonce', 'secure_data')
214214

215215
def __init__(self, secure_data: 'SecureData', nonce: str, bot: 'Bot' = None, **_kwargs: Any):
216216
# Required
217217
self.secure_data = secure_data
218218
self.nonce = nonce
219219

220-
self.bot = bot
220+
self.set_bot(bot)
221221

222222
@classmethod
223223
def de_json(cls, data: Optional[JSONDict], bot: 'Bot') -> Optional['Credentials']:
@@ -261,7 +261,6 @@ class SecureData(TelegramObject):
261261
"""
262262

263263
__slots__ = (
264-
'bot',
265264
'utility_bill',
266265
'personal_details',
267266
'temporary_registration',
@@ -304,7 +303,7 @@ def __init__(
304303
self.passport = passport
305304
self.personal_details = personal_details
306305

307-
self.bot = bot
306+
self.set_bot(bot)
308307

309308
@classmethod
310309
def de_json(cls, data: Optional[JSONDict], bot: 'Bot') -> Optional['SecureData']:
@@ -360,7 +359,7 @@ class SecureValue(TelegramObject):
360359
361360
"""
362361

363-
__slots__ = ('data', 'front_side', 'reverse_side', 'selfie', 'files', 'translation', 'bot')
362+
__slots__ = ('data', 'front_side', 'reverse_side', 'selfie', 'files', 'translation')
364363

365364
def __init__(
366365
self,
@@ -380,7 +379,7 @@ def __init__(
380379
self.files = files
381380
self.translation = translation
382381

383-
self.bot = bot
382+
self.set_bot(bot)
384383

385384
@classmethod
386385
def de_json(cls, data: Optional[JSONDict], bot: 'Bot') -> Optional['SecureValue']:
@@ -412,17 +411,17 @@ def to_dict(self) -> JSONDict:
412411
class _CredentialsBase(TelegramObject):
413412
"""Base class for DataCredentials and FileCredentials."""
414413

415-
__slots__ = ('hash', 'secret', 'file_hash', 'data_hash', 'bot')
414+
__slots__ = ('hash', 'secret', 'file_hash', 'data_hash')
416415

417416
def __init__(self, hash: str, secret: str, bot: 'Bot' = None, **_kwargs: Any):
418417
self.hash = hash
419418
self.secret = secret
420419

421-
# Aliases just be be sure
420+
# Aliases just to be sure
422421
self.file_hash = self.hash
423422
self.data_hash = self.hash
424423

425-
self.bot = bot
424+
self.set_bot(bot)
426425

427426

428427
class DataCredentials(_CredentialsBase):

telegram/_telegramobject.py

Lines changed: 93 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
# You should have received a copy of the GNU Lesser Public License
1818
# along with this program. If not, see [http://www.gnu.org/licenses/].
1919
"""Base class for Telegram Objects."""
20+
from copy import deepcopy
21+
2022
try:
2123
import ujson as json
2224
except ImportError:
2325
import json # type: ignore[no-redef]
2426

25-
from typing import TYPE_CHECKING, List, Optional, Type, TypeVar, Tuple
27+
from typing import TYPE_CHECKING, List, Optional, Type, TypeVar, Tuple, Dict, Union
2628

2729
from telegram._utils.types import JSONDict
2830
from telegram._utils.warnings import warn
@@ -40,6 +42,12 @@ class TelegramObject:
4042
is equivalent to ``telegram_object.attribute_name``. If the object does not have an attribute
4143
with the appropriate name, a :exc:`KeyError` will be raised.
4244
45+
When objects of this type are pickled, the :class:`~telegram.Bot` attribute associated with the
46+
object will be removed. However, when copying the object via :func:`copy.deepcopy`, the copy
47+
will have the *same* bot instance associated with it, i.e::
48+
49+
assert telegram_object.get_bot() is copy.deepcopy(telegram_object).get_bot()
50+
4351
.. versionchanged:: 14.0
4452
``telegram_object['from']`` will look up the key ``from_user``. This is to account for
4553
special cases like :attr:`Message.from_user` that deviate from the official Bot API.
@@ -53,15 +61,12 @@ class TelegramObject:
5361
_bot: Optional['Bot']
5462
# Adding slots reduces memory usage & allows for faster attribute access.
5563
# Only instance variables should be added to __slots__.
56-
__slots__ = (
57-
'_id_attrs',
58-
'_bot',
59-
)
64+
__slots__ = ('_id_attrs', '_bot')
6065

6166
# pylint: disable=unused-argument
6267
def __new__(cls, *args: object, **kwargs: object) -> 'TelegramObject':
6368
# We add _id_attrs in __new__ instead of __init__ since we want to add this to the slots
64-
# w/o calling __init__ in all of the subclasses. This is what we also do in BaseFilter.
69+
# w/o calling __init__ in all of the subclasses.
6570
instance = super().__new__(cls)
6671
instance._id_attrs = ()
6772
instance._bot = None
@@ -81,6 +86,86 @@ def __getitem__(self, item: str) -> object:
8186
f"`{item}`."
8287
) from exc
8388

89+
def __getstate__(self) -> Dict[str, Union[str, object]]:
90+
"""
91+
This method is used for pickling. We remove the bot attribute of the object since those
92+
are not pickable.
93+
"""
94+
return self._get_attrs(include_private=True, recursive=False, remove_bot=True)
95+
96+
def __setstate__(self, state: dict) -> None:
97+
"""
98+
This method is used for unpickling. The data, which is in the form a dictionary, is
99+
converted back into a class. Should be modified in place.
100+
"""
101+
for key, val in state.items():
102+
setattr(self, key, val)
103+
104+
def __deepcopy__(self: TO, memodict: dict) -> TO:
105+
"""This method deepcopies the object and sets the bot on the newly created copy."""
106+
bot = self._bot # Save bot so we can set it after copying
107+
self.set_bot(None) # set to None so it is not deepcopied
108+
cls = self.__class__
109+
result = cls.__new__(cls) # create a new instance
110+
memodict[id(self)] = result # save the id of the object in the dict
111+
112+
attrs = self._get_attrs(include_private=True) # get all its attributes
113+
114+
for k in attrs: # now we set the attributes in the deepcopied object
115+
setattr(result, k, deepcopy(getattr(self, k), memodict))
116+
117+
result.set_bot(bot) # Assign the bots back
118+
self.set_bot(bot)
119+
return result # type: ignore[return-value]
120+
121+
def _get_attrs(
122+
self,
123+
include_private: bool = False,
124+
recursive: bool = False,
125+
remove_bot: bool = False,
126+
) -> Dict[str, Union[str, object]]:
127+
"""This method is used for obtaining the attributes of the object.
128+
129+
Args:
130+
include_private (:obj:`bool`): Whether the result should include private variables.
131+
recursive (:obj:`bool`): If :obj:`True`, will convert any TelegramObjects (if found) in
132+
the attributes to a dictionary. Else, preserves it as an object itself.
133+
remove_bot (:obj:`bool`): Whether the bot should be included in the result.
134+
135+
Returns:
136+
:obj:`dict`: A dict where the keys are attribute names and values are their values.
137+
"""
138+
data = {}
139+
140+
if not recursive:
141+
try:
142+
# __dict__ has attrs from superclasses, so no need to put in the for loop below
143+
data.update(self.__dict__)
144+
except AttributeError:
145+
pass
146+
# We want to get all attributes for the class, using self.__slots__ only includes the
147+
# attributes used by that class itself, and not its superclass(es). Hence, we get its MRO
148+
# and then get their attributes. The `[:-1]` slice excludes the `object` class
149+
for cls in self.__class__.__mro__[:-1]:
150+
for key in cls.__slots__:
151+
if not include_private and key.startswith('_'):
152+
continue
153+
154+
value = getattr(self, key, None)
155+
if value is not None:
156+
if recursive and hasattr(value, 'to_dict'):
157+
data[key] = value.to_dict()
158+
else:
159+
data[key] = value
160+
elif not recursive:
161+
data[key] = value
162+
163+
if recursive and data.get('from_user'):
164+
data['from'] = data.pop('from_user', None)
165+
if remove_bot:
166+
data.pop('_bot', None)
167+
return data
168+
84169
@staticmethod
85170
def _parse_data(data: Optional[JSONDict]) -> Optional[JSONDict]:
86171
return None if data is None else data.copy()
@@ -137,27 +222,7 @@ def to_dict(self) -> JSONDict:
137222
Returns:
138223
:obj:`dict`
139224
"""
140-
data = {}
141-
142-
# We want to get all attributes for the class, using self.__slots__ only includes the
143-
# attributes used by that class itself, and not its superclass(es). Hence we get its MRO
144-
# and then get their attributes. The `[:-2]` slice excludes the `object` class & the
145-
# TelegramObject class itself.
146-
attrs = {attr for cls in self.__class__.__mro__[:-2] for attr in cls.__slots__}
147-
for key in attrs:
148-
if key == 'bot' or key.startswith('_'):
149-
continue
150-
151-
value = getattr(self, key, None)
152-
if value is not None:
153-
if hasattr(value, 'to_dict'):
154-
data[key] = value.to_dict()
155-
else:
156-
data[key] = value
157-
158-
if data.get('from_user'):
159-
data['from'] = data.pop('from_user', None)
160-
return data
225+
return self._get_attrs(recursive=True)
161226

162227
def get_bot(self) -> 'Bot':
163228
"""Returns the :class:`telegram.Bot` instance associated with this object.
@@ -171,8 +236,7 @@ def get_bot(self) -> 'Bot':
171236
"""
172237
if self._bot is None:
173238
raise RuntimeError(
174-
'This object has no bot associated with it. \
175-
Shortcuts cannot be used.'
239+
'This object has no bot associated with it. Shortcuts cannot be used.'
176240
)
177241
return self._bot
178242

0 commit comments

Comments
 (0)