diff --git a/telegram/bot.py b/telegram/bot.py index b8dc82daad6..ce73a2bc9e6 100644 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -65,6 +65,7 @@ Document, File, GameHighScore, + InputMedia, Location, MaskPosition, Message, @@ -90,8 +91,6 @@ ) from telegram.constants import MAX_INLINE_QUERY_RESULTS from telegram.error import InvalidToken, TelegramError -from telegram.warnings import PTBDeprecationWarning -from telegram.utils.warnings import warn from telegram.utils.defaultvalue import DEFAULT_NONE, DefaultValue, DEFAULT_20 from telegram.utils.datetime import to_timestamp from telegram.utils.files import is_local_file, parse_file_input @@ -99,13 +98,11 @@ from telegram.utils.types import FileInput, JSONDict, ODVInput, DVInput if TYPE_CHECKING: - from telegram.ext import Defaults from telegram import ( InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo, - InputMedia, InlineQueryResult, LabeledPrice, MessageEntity, @@ -147,6 +144,9 @@ class Bot(TelegramObject): * Removed the deprecated methods ``kick_chat_member``, ``kickChatMember``, ``get_chat_members_count`` and ``getChatMembersCount``. * Removed the deprecated property ``commands``. + * Removed the deprecated ``defaults`` parameter. If you want to use + :class:`telegram.ext.Defaults`, please use the subclass :class:`telegram.ext.ExtBot` + instead. Args: token (:obj:`str`): Bot's unique authentication. @@ -156,13 +156,6 @@ class Bot(TelegramObject): :obj:`telegram.request.Request`. private_key (:obj:`bytes`, optional): Private key for decryption of telegram passport data. private_key_password (:obj:`bytes`, optional): Password for above private key. - defaults (:class:`telegram.ext.Defaults`, optional): An object containing default values to - be used if not set explicitly in the bot methods. - - .. deprecated:: 13.6 - Passing :class:`telegram.ext.Defaults` to :class:`telegram.Bot` is deprecated. If - you want to use :class:`telegram.ext.Defaults`, please use - :class:`telegram.ext.ExtBot` instead. """ @@ -171,7 +164,6 @@ class Bot(TelegramObject): 'base_url', 'base_file_url', 'private_key', - 'defaults', '_bot', '_request', 'logger', @@ -185,20 +177,9 @@ def __init__( request: 'Request' = None, private_key: bytes = None, private_key_password: bytes = None, - defaults: 'Defaults' = None, ): self.token = self._validate_token(token) - # Gather default - self.defaults = defaults - - if self.defaults: - warn( - 'Passing Defaults to telegram.Bot is deprecated. Use telegram.ext.ExtBot instead.', - PTBDeprecationWarning, - stacklevel=4, - ) - if base_url is None: base_url = 'https://api.telegram.org/bot' @@ -222,41 +203,42 @@ def __init__( private_key, password=private_key_password, backend=default_backend() ) - def _insert_defaults( + def _insert_defaults( # pylint: disable=no-self-use self, data: Dict[str, object], timeout: ODVInput[float] ) -> Optional[float]: - """ - Inserts the defaults values for optional kwargs for which tg.ext.Defaults provides - convenience functionality, i.e. the kwargs with a tg.utils.helpers.DefaultValue default - - data is edited in-place. As timeout is not passed via the kwargs, it needs to be passed - separately and gets returned. - - This can only work, if all kwargs that may have defaults are passed in data! - """ - effective_timeout = DefaultValue.get_value(timeout) - - # If we have no Defaults, we just need to replace DefaultValue instances - # with the actual value - if not self.defaults: - data.update((key, DefaultValue.get_value(value)) for key, value in data.items()) - return effective_timeout - - # if we have Defaults, we replace all DefaultValue instances with the relevant - # Defaults value. If there is none, we fall back to the default value of the bot method + """This method is here to make ext.Defaults work. Because we need to be able to tell + e.g. `send_message(chat_id, text)` from `send_message(chat_id, text, parse_mode=None)`, the + default values for `parse_mode` etc are not `None` but `DEFAULT_NONE`. While this *could* + be done in ExtBot instead of Bot, shortcuts like `Message.reply_text` need to work for both + Bot and ExtBot, so they also have the `DEFAULT_NONE` default values. + + This makes it necessary to convert `DefaultValue(obj)` to `obj` at some point between + `Message.reply_text` and the request to TG. Doing this here in a centralized manner is a + rather clean and minimally invasive solution, i.e. the link between tg and tg.ext is as + small as possible. + See also _insert_defaults_for_ilq + ExtBot overrides this method to actually insert default values. + + If in the future we come up with a better way of making `Defaults` work, we can cut this + link as well. + """ + # We + # 1) set the correct parse_mode for all InputMedia objects + # 2) replace all DefaultValue instances with the corresponding normal value. for key, val in data.items(): - if isinstance(val, DefaultValue): - data[key] = self.defaults.api_defaults.get(key, val.value) - - if isinstance(timeout, DefaultValue): - # If we get here, we use Defaults.timeout, unless that's not set, which is the - # case if isinstance(self.defaults.timeout, DefaultValue) - return ( - self.defaults.timeout - if not isinstance(self.defaults.timeout, DefaultValue) - else effective_timeout - ) - return effective_timeout + # 1) + if isinstance(val, InputMedia): + val.parse_mode = DefaultValue.get_value( # type: ignore[attr-defined] + val.parse_mode # type: ignore[attr-defined] + ) + elif key == 'media' and isinstance(val, list): + for media in val: + media.parse_mode = DefaultValue.get_value(media.parse_mode) + # 2) + else: + data[key] = DefaultValue.get_value(val) + + return DefaultValue.get_value(timeout) def _post( self, @@ -279,9 +261,16 @@ def _post( effective_timeout = self._insert_defaults(data, timeout) else: effective_timeout = cast(float, timeout) + # Drop any None values because Telegram doesn't handle them well data = {key: value for key, value in data.items() if value is not None} + # We do this here so that _insert_defaults (see above) has a chance to convert + # to the default timezone in case this is called by ExtBot + for key, value in data.items(): + if isinstance(value, datetime): + data[key] = to_timestamp(value) + return self.request.post( f'{self.base_url}/{endpoint}', data=data, timeout=effective_timeout ) @@ -300,7 +289,7 @@ def _message( if reply_to_message_id is not None: data['reply_to_message_id'] = reply_to_message_id - # We don't check if (DEFAULT_)None here, so that _put is able to insert the defaults + # We don't check if (DEFAULT_)None here, so that _post is able to insert the defaults # correctly, if necessary data['disable_notification'] = disable_notification data['allow_sending_without_reply'] = allow_sending_without_reply @@ -313,12 +302,6 @@ def _message( else: data['reply_markup'] = reply_markup - if data.get('media') and (data['media'].parse_mode == DEFAULT_NONE): - if self.defaults: - data['media'].parse_mode = DefaultValue.get_value(self.defaults.parse_mode) - else: - data['media'].parse_mode = None - result = self._post(endpoint, data, timeout=timeout, api_kwargs=api_kwargs) if result is True: @@ -1455,13 +1438,6 @@ def send_media_group( 'allow_sending_without_reply': allow_sending_without_reply, } - for med in data['media']: - if med.parse_mode == DEFAULT_NONE: - if self.defaults: - med.parse_mode = DefaultValue.get_value(self.defaults.parse_mode) - else: - med.parse_mode = None - if reply_to_message_id: data['reply_to_message_id'] = reply_to_message_id @@ -2050,6 +2026,28 @@ def _effective_inline_results( # pylint: disable=R0201 return effective_results, next_offset + @no_type_check # mypy doesn't play too well with hasattr + def _insert_defaults_for_ilq_results( # pylint: disable=R0201 + self, res: 'InlineQueryResult' + ) -> None: + """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. + """ + # pylint: disable=W0212 + if hasattr(res, 'parse_mode'): + 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'): + 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'): + res.input_message_content.disable_web_page_preview = DefaultValue.get_value( + res.input_message_content.disable_web_page_preview + ) + @log def answer_inline_query( self, @@ -2123,44 +2121,13 @@ def answer_inline_query( :class:`telegram.error.TelegramError` """ - - @no_type_check - def _set_defaults(res): - # pylint: disable=W0212 - if hasattr(res, 'parse_mode') and res.parse_mode == DEFAULT_NONE: - if self.defaults: - res.parse_mode = self.defaults.parse_mode - else: - res.parse_mode = 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 == DEFAULT_NONE - ): - if self.defaults: - res.input_message_content.parse_mode = DefaultValue.get_value( - self.defaults.parse_mode - ) - else: - res.input_message_content.parse_mode = None - if ( - hasattr(res.input_message_content, 'disable_web_page_preview') - and res.input_message_content.disable_web_page_preview == DEFAULT_NONE - ): - if self.defaults: - res.input_message_content.disable_web_page_preview = ( - DefaultValue.get_value(self.defaults.disable_web_page_preview) - ) - else: - res.input_message_content.disable_web_page_preview = None - effective_results, next_offset = self._effective_inline_results( results=results, next_offset=next_offset, current_offset=current_offset ) # Apply defaults for result in effective_results: - _set_defaults(result) + self._insert_defaults_for_ilq_results(result) results_dicts = [res.to_dict() for res in effective_results] @@ -2335,10 +2302,6 @@ def ban_chat_member( data: JSONDict = {'chat_id': chat_id, 'user_id': user_id} if until_date is not None: - if isinstance(until_date, datetime): - until_date = to_timestamp( - until_date, tzinfo=self.defaults.tzinfo if self.defaults else None - ) data['until_date'] = until_date if revoke_messages is not None: @@ -3666,10 +3629,6 @@ def restrict_chat_member( } if until_date is not None: - if isinstance(until_date, datetime): - until_date = to_timestamp( - until_date, tzinfo=self.defaults.tzinfo if self.defaults else None - ) data['until_date'] = until_date result = self._post('restrictChatMember', data, timeout=timeout, api_kwargs=api_kwargs) @@ -3938,10 +3897,6 @@ def create_chat_invite_link( } if expire_date is not None: - if isinstance(expire_date, datetime): - expire_date = to_timestamp( - expire_date, tzinfo=self.defaults.tzinfo if self.defaults else None - ) data['expire_date'] = expire_date if member_limit is not None: @@ -3993,10 +3948,6 @@ def edit_chat_invite_link( data: JSONDict = {'chat_id': chat_id, 'invite_link': invite_link} if expire_date is not None: - if isinstance(expire_date, datetime): - expire_date = to_timestamp( - expire_date, tzinfo=self.defaults.tzinfo if self.defaults else None - ) data['expire_date'] = expire_date if member_limit is not None: @@ -4818,10 +4769,6 @@ def send_poll( if open_period: data['open_period'] = open_period if close_date: - if isinstance(close_date, datetime): - close_date = to_timestamp( - close_date, tzinfo=self.defaults.tzinfo if self.defaults else None - ) data['close_date'] = close_date return self._message( # type: ignore[return-value] diff --git a/telegram/ext/dispatcher.py b/telegram/ext/dispatcher.py index cf60d8d6ad0..52e31aa248c 100644 --- a/telegram/ext/dispatcher.py +++ b/telegram/ext/dispatcher.py @@ -42,9 +42,8 @@ from telegram import Update from telegram.error import TelegramError -from telegram.ext import BasePersistence, ContextTypes +from telegram.ext import BasePersistence, ContextTypes, ExtBot from telegram.ext.handler import Handler -import telegram.ext.extbot from telegram.ext.callbackdatacache import CallbackDataCache from telegram.utils.defaultvalue import DefaultValue, DEFAULT_FALSE from telegram.utils.warnings import warn @@ -231,7 +230,7 @@ def __init__( f"bot_data must be of type {self.context_types.bot_data.__name__}" ) if self.persistence.store_data.callback_data: - self.bot = cast(telegram.ext.extbot.ExtBot, self.bot) + self.bot = cast(ExtBot, self.bot) persistent_data = self.persistence.get_callback_data() if persistent_data is not None: if not isinstance(persistent_data, tuple) and len(persistent_data) != 2: @@ -495,7 +494,11 @@ def process_update(self, update: object) -> None: handled_only_async = all(sync_modes) if handled: # Respect default settings - if all(mode is DEFAULT_FALSE for mode in sync_modes) and self.bot.defaults: + if ( + all(mode is DEFAULT_FALSE for mode in sync_modes) + and isinstance(self.bot, ExtBot) + and self.bot.defaults + ): handled_only_async = self.bot.defaults.run_async # If update was only handled by async handlers, we don't need to update here if not handled_only_async: @@ -599,7 +602,7 @@ def __update_persistence(self, update: object = None) -> None: user_ids = [] if self.persistence.store_data.callback_data: - self.bot = cast(telegram.ext.extbot.ExtBot, self.bot) + self.bot = cast(ExtBot, self.bot) try: self.persistence.update_callback_data( self.bot.callback_data_cache.persistence_data @@ -639,7 +642,10 @@ def add_error_handler( Args: callback (:obj:`callable`): The callback function for this error handler. Will be called when an error is raised. - Callback signature: ``def callback(update: Update, context: CallbackContext)`` + Callback signature: + + + ``def callback(update: Update, context: CallbackContext)`` The error that happened will be present in context.error. run_async (:obj:`bool`, optional): Whether this handlers callback should be run @@ -649,7 +655,12 @@ def add_error_handler( self.logger.debug('The callback is already registered as an error handler. Ignoring.') return - if run_async is DEFAULT_FALSE and self.bot.defaults and self.bot.defaults.run_async: + if ( + run_async is DEFAULT_FALSE + and isinstance(self.bot, ExtBot) + and self.bot.defaults + and self.bot.defaults.run_async + ): run_async = True self.error_handlers[callback] = run_async diff --git a/telegram/ext/extbot.py b/telegram/ext/extbot.py index 19824830c4d..1429bc64062 100644 --- a/telegram/ext/extbot.py +++ b/telegram/ext/extbot.py @@ -19,7 +19,20 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Bot with convenience extensions.""" from copy import copy -from typing import Union, cast, List, Callable, Optional, Tuple, TypeVar, TYPE_CHECKING, Sequence +from datetime import datetime +from typing import ( + Union, + cast, + List, + Callable, + Optional, + Tuple, + TypeVar, + TYPE_CHECKING, + Sequence, + Dict, + no_type_check, +) import telegram.bot from telegram import ( @@ -31,11 +44,13 @@ Update, Chat, CallbackQuery, + InputMedia, ) from telegram.ext.callbackdatacache import CallbackDataCache from telegram.utils.types import JSONDict, ODVInput, DVInput -from telegram.utils.defaultvalue import DEFAULT_NONE +from telegram.utils.defaultvalue import DEFAULT_NONE, DefaultValue +from telegram.utils.datetime import to_timestamp if TYPE_CHECKING: from telegram import InlineQueryResult, MessageEntity @@ -73,7 +88,7 @@ class ExtBot(telegram.bot.Bot): """ - __slots__ = ('arbitrary_callback_data', 'callback_data_cache') + __slots__ = ('arbitrary_callback_data', 'callback_data_cache', '_defaults') def __init__( self, @@ -94,8 +109,7 @@ def __init__( private_key=private_key, private_key_password=private_key_password, ) - # We don't pass this to super().__init__ to avoid the deprecation warning - self.defaults = defaults + self._defaults = defaults # set up callback_data if not isinstance(arbitrary_callback_data, bool): @@ -106,6 +120,64 @@ def __init__( self.arbitrary_callback_data = arbitrary_callback_data self.callback_data_cache: CallbackDataCache = CallbackDataCache(bot=self, maxsize=maxsize) + @property + def defaults(self) -> Optional['Defaults']: + """The :class:`telegram.ext.Defaults` used by this bot, if any.""" + # This is a property because defaults shouldn't be changed at runtime + return self._defaults + + def _insert_defaults( + self, data: Dict[str, object], timeout: ODVInput[float] + ) -> Optional[float]: + """Inserts the defaults values for optional kwargs for which tg.ext.Defaults provides + convenience functionality, i.e. the kwargs with a tg.utils.helpers.DefaultValue default + + data is edited in-place. As timeout is not passed via the kwargs, it needs to be passed + separately and gets returned. + + This can only work, if all kwargs that may have defaults are passed in data! + """ + # if we have Defaults, we + # 1) replace all DefaultValue instances with the relevant Defaults value. If there is none, + # we fall back to the default value of the bot method + # 2) convert all datetime.datetime objects to timestamps wrt the correct default timezone + # 3) set the correct parse_mode for all InputMedia objects + for key, val in data.items(): + # 1) + if isinstance(val, DefaultValue): + data[key] = ( + self.defaults.api_defaults.get(key, val.value) + if self.defaults + else DefaultValue.get_value(val) + ) + + # 2) + elif isinstance(val, datetime): + data[key] = to_timestamp( + val, tzinfo=self.defaults.tzinfo if self.defaults else None + ) + + # 3) + elif isinstance(val, InputMedia) and val.parse_mode is DEFAULT_NONE: # type: ignore + val.parse_mode = ( # type: ignore[attr-defined] + self.defaults.parse_mode if self.defaults else None + ) + elif key == 'media' and isinstance(val, list): + for media in val: + if media.parse_mode is DEFAULT_NONE: + media.parse_mode = self.defaults.parse_mode if self.defaults else None + + effective_timeout = DefaultValue.get_value(timeout) + if isinstance(timeout, DefaultValue): + # If we get here, we use Defaults.timeout, unless that's not set, which is the + # case if isinstance(self.defaults.timeout, DefaultValue) + return ( + self.defaults.timeout + if self.defaults and not isinstance(self.defaults.timeout, DefaultValue) + else effective_timeout + ) + return effective_timeout + 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 # CallbackDataCache build a new keyboard with the data replaced. Otherwise return the input @@ -233,8 +305,7 @@ def _effective_inline_results( # pylint: disable=R0201 next_offset: str = None, current_offset: str = None, ) -> Tuple[Sequence['InlineQueryResult'], Optional[str]]: - """ - This method is called by Bot.answer_inline_query to build the actual results list. + """This method is called by Bot.answer_inline_query to build the actual results list. Overriding this to call self._replace_keyboard suffices """ effective_results, next_offset = super()._effective_inline_results( @@ -260,6 +331,30 @@ def _effective_inline_results( # pylint: disable=R0201 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: + """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. + """ + if hasattr(res, 'parse_mode') and res.parse_mode is DEFAULT_NONE: + 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 + ): + res.input_message_content.parse_mode = ( + self.defaults.parse_mode if self.defaults else None + ) + if ( + hasattr(res.input_message_content, 'disable_web_page_preview') + and res.input_message_content.disable_web_page_preview is DEFAULT_NONE + ): + res.input_message_content.disable_web_page_preview = ( + self.defaults.disable_web_page_preview if self.defaults else None + ) + def stop_poll( self, chat_id: Union[int, str], diff --git a/telegram/ext/handler.py b/telegram/ext/handler.py index 4b544b82788..7e715369e57 100644 --- a/telegram/ext/handler.py +++ b/telegram/ext/handler.py @@ -23,6 +23,7 @@ from telegram.ext.utils.promise import Promise from telegram.utils.defaultvalue import DefaultValue, DEFAULT_FALSE from telegram.ext.utils.types import CCT +from .extbot import ExtBot if TYPE_CHECKING: from telegram.ext import Dispatcher @@ -112,6 +113,7 @@ def handle_update( run_async = self.run_async if ( self.run_async is DEFAULT_FALSE + and isinstance(dispatcher.bot, ExtBot) and dispatcher.bot.defaults and dispatcher.bot.defaults.run_async ): diff --git a/telegram/ext/jobqueue.py b/telegram/ext/jobqueue.py index ac255ad355b..6e17adbd420 100644 --- a/telegram/ext/jobqueue.py +++ b/telegram/ext/jobqueue.py @@ -29,6 +29,7 @@ from telegram.ext.callbackcontext import CallbackContext from telegram.utils.types import JSONDict +from .extbot import ExtBot if TYPE_CHECKING: from telegram.ext import Dispatcher @@ -119,7 +120,7 @@ def set_dispatcher(self, dispatcher: 'Dispatcher') -> None: """ self._dispatcher = dispatcher - if dispatcher.bot.defaults: + if isinstance(dispatcher.bot, ExtBot) and dispatcher.bot.defaults: self.scheduler.configure(timezone=dispatcher.bot.defaults.tzinfo or pytz.utc) def run_once( diff --git a/telegram/message.py b/telegram/message.py index 68bc0b65fd7..7348a7c3881 100644 --- a/telegram/message.py +++ b/telegram/message.py @@ -716,8 +716,10 @@ def _quote(self, quote: Optional[bool], reply_to_message_id: Optional[int]) -> O return self.message_id else: - if self.bot.defaults: - default_quote = self.bot.defaults.quote + # Unfortunately we need some ExtBot logic here because it's hard to move shortcut + # logic into ExtBot + if hasattr(self.bot, 'defaults') and self.bot.defaults: # type: ignore[union-attr] + default_quote = self.bot.defaults.quote # type: ignore[union-attr] else: default_quote = None if (default_quote is None and self.chat.type != Chat.PRIVATE) or default_quote: diff --git a/tests/conftest.py b/tests/conftest.py index 8b63ff79e83..7adb67d13d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,11 @@ File, ChatPermissions, Bot, + InlineQueryResultArticle, + InputTextMessageContent, + InlineQueryResultCachedPhoto, + InputMediaPhoto, + InputMedia, ) from telegram.ext import ( Dispatcher, @@ -109,6 +114,11 @@ def bot(bot_info): return DictExtBot(bot_info['token'], private_key=PRIVATE_KEY, request=DictRequest(8)) +@pytest.fixture(scope='session') +def raw_bot(bot_info): + return DictBot(bot_info['token'], private_key=PRIVATE_KEY, request=DictRequest()) + + DEFAULT_BOTS = {} @@ -525,6 +535,58 @@ def make_assertion(**kw): return True +# mainly for check_defaults_handling below +def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAULT_NONE): + kws = {} + for name, param in signature.parameters.items(): + # For required params we need to pass something + if param.default is inspect.Parameter.empty: + # Some special casing + if name == 'permissions': + kws[name] = ChatPermissions() + elif name in ['prices', 'commands', 'errors']: + kws[name] = [] + elif name == 'media': + media = InputMediaPhoto('media', parse_mode=dfv) + if 'list' in str(param.annotation).lower(): + kws[name] = [media] + else: + kws[name] = media + elif name == 'results': + itmc = InputTextMessageContent( + 'text', parse_mode=dfv, disable_web_page_preview=dfv + ) + kws[name] = [ + InlineQueryResultArticle('id', 'title', input_message_content=itmc), + InlineQueryResultCachedPhoto( + 'id', 'photo_file_id', parse_mode=dfv, input_message_content=itmc + ), + ] + elif name == 'ok': + kws['ok'] = False + kws['error_message'] = 'error' + else: + kws[name] = True + # pass values for params that can have defaults only if we don't want to use the + # standard default + elif name in default_kwargs: + if dfv != DEFAULT_NONE: + kws[name] = dfv + # Some special casing for methods that have "exactly one of the optionals" type args + elif name in ['location', 'contact', 'venue', 'inline_message_id']: + kws[name] = True + elif name == 'until_date': + if dfv == 'non-None-value': + # Europe/Berlin + kws[name] = pytz.timezone('Europe/Berlin').localize( + datetime.datetime(2000, 1, 1, 0) + ) + else: + # UTC + kws[name] = datetime.datetime(2000, 1, 1, 0) + return kws + + def check_defaults_handling( method: Callable, bot: ExtBot, @@ -541,31 +603,6 @@ def check_defaults_handling( """ - def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAULT_NONE): - kws = {} - for name, param in signature.parameters.items(): - # For required params we need to pass something - if param.default == param.empty: - # Some special casing - if name == 'permissions': - kws[name] = ChatPermissions() - elif name in ['prices', 'media', 'results', 'commands', 'errors']: - kws[name] = [] - elif name == 'ok': - kws['ok'] = False - kws['error_message'] = 'error' - else: - kws[name] = True - # pass values for params that can have defaults only if we don't want to use the - # standard default - elif name in default_kwargs: - if dfv != DEFAULT_NONE: - kws[name] = dfv - # Some special casing for methods that have "exactly one of the optionals" type args - elif name in ['location', 'contact', 'venue', 'inline_message_id']: - kws[name] = True - return kws - shortcut_signature = inspect.signature(method) kwargs_need_default = [ kwarg @@ -575,23 +612,20 @@ def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAUL # shortcut_signature.parameters['timeout'] is of type DefaultValue method_timeout = shortcut_signature.parameters['timeout'].default.value - default_kwarg_names = kwargs_need_default - # special case explanation_parse_mode of Bot.send_poll: - if 'explanation_parse_mode' in default_kwarg_names: - default_kwarg_names.remove('explanation_parse_mode') - defaults_no_custom_defaults = Defaults() - defaults_custom_defaults = Defaults( - **{kwarg: 'custom_default' for kwarg in default_kwarg_names} - ) + kwargs = {kwarg: 'custom_default' for kwarg in inspect.signature(Defaults).parameters.keys()} + kwargs['tzinfo'] = pytz.timezone('America/New_York') + defaults_custom_defaults = Defaults(**kwargs) expected_return_values = [None, []] if return_value is None else [return_value] def make_assertion(_, data, timeout=DEFAULT_NONE, df_value=DEFAULT_NONE): - expected_timeout = method_timeout if df_value == DEFAULT_NONE else df_value + # Check timeout first + expected_timeout = method_timeout if df_value is DEFAULT_NONE else df_value if timeout != expected_timeout: pytest.fail(f'Got value {timeout} for "timeout", expected {expected_timeout}') + # Check regular arguments that need defaults for arg in (dkw for dkw in kwargs_need_default if dkw != 'timeout'): # 'None' should not be passed along to Telegram if df_value in [None, DEFAULT_NONE]: @@ -604,6 +638,65 @@ def make_assertion(_, data, timeout=DEFAULT_NONE, df_value=DEFAULT_NONE): if value != df_value: pytest.fail(f'Got value {value} for argument {arg} instead of {df_value}') + # Check InputMedia (parse_mode can have a default) + def check_input_media(m: InputMedia): + parse_mode = m.parse_mode + if df_value is DEFAULT_NONE: + if parse_mode is not None: + pytest.fail('InputMedia has non-None parse_mode') + elif parse_mode != df_value: + pytest.fail( + f'Got value {parse_mode} for InputMedia.parse_mode instead of {df_value}' + ) + + media = data.pop('media', None) + if media: + if isinstance(media, InputMedia): + check_input_media(media) + else: + for m in media: + check_input_media(m) + + # Check InlineQueryResults + results = data.pop('results', []) + for result in results: + if df_value in [DEFAULT_NONE, None]: + if 'parse_mode' in result: + pytest.fail('ILQR has a parse mode, expected it to be absent') + # Here we explicitly use that we only pass ILQRPhoto and ILQRArticle for testing + # so ILQRPhoto is expected to have parse_mode if df_value is not in [DF_NONE, NONE] + elif 'photo' in result and result.get('parse_mode') != df_value: + pytest.fail( + f'Got value {result.get("parse_mode")} for ' + f'ILQR.parse_mode instead of {df_value}' + ) + imc = result.get('input_message_content') + if not imc: + continue + for attr in ['parse_mode', 'disable_web_page_preview']: + if df_value in [DEFAULT_NONE, None]: + if attr in imc: + pytest.fail(f'ILQR.i_m_c has a {attr}, expected it to be absent') + # Here we explicitly use that we only pass InputTextMessageContent for testing + # which has both attributes + elif imc.get(attr) != df_value: + pytest.fail( + f'Got value {imc.get(attr)} for ILQR.i_m_c.{attr} instead of {df_value}' + ) + + # Check datetime conversion + until_date = data.pop('until_date', None) + if until_date: + if df_value == 'non-None-value': + if until_date != 946681200: + pytest.fail('Non-naive until_date was interpreted as Europe/Berlin.') + if df_value is DEFAULT_NONE: + if until_date != 946684800: + pytest.fail('Naive until_date was not interpreted as UTC') + if df_value == 'custom_default': + if until_date != 946702800: + pytest.fail('Naive until_date was not interpreted as America/New_York') + if method.__name__ in ['get_file', 'get_small_file', 'get_big_file']: # This is here mainly for PassportFile.get_file, which calls .set_credentials on the # return value @@ -623,7 +716,7 @@ def make_assertion(_, data, timeout=DEFAULT_NONE, df_value=DEFAULT_NONE): (DEFAULT_NONE, defaults_no_custom_defaults), ('custom_default', defaults_custom_defaults), ]: - bot.defaults = defaults + bot._defaults = defaults # 1: test that we get the correct default value, if we don't specify anything kwargs = build_kwargs( shortcut_signature, @@ -652,6 +745,6 @@ def make_assertion(_, data, timeout=DEFAULT_NONE, df_value=DEFAULT_NONE): raise exc finally: setattr(bot.request, 'post', orig_post) - bot.defaults = None + bot._defaults = None return True diff --git a/tests/test_bot.py b/tests/test_bot.py index 8cf62962431..824a7ef7208 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -51,14 +51,17 @@ InlineQueryResultVoice, PollOption, BotCommandScopeChat, + File, + InputMedia, ) from telegram.constants import MAX_INLINE_QUERY_RESULTS -from telegram.ext import ExtBot, Defaults +from telegram.ext import ExtBot from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter, TelegramError from telegram.ext.callbackdatacache import InvalidCallbackData from telegram.utils.datetime import from_timestamp, to_timestamp from telegram.helpers import escape_markdown -from tests.conftest import expect_bad_request, check_defaults_handling, GITHUB_ACTION +from telegram.utils.defaultvalue import DefaultValue +from tests.conftest import expect_bad_request, check_defaults_handling, GITHUB_ACTION, build_kwargs from tests.bots import FALLBACKS @@ -246,9 +249,16 @@ def test_to_dict(self, bot): ] ], ) - def test_defaults_handling(self, bot_method_name, bot): + def test_defaults_handling(self, bot_method_name, bot, raw_bot, monkeypatch): """ - Here we check that the bot methods handle tg.ext.Defaults correctly. As for most defaults, + Here we check that the bot methods handle tg.ext.Defaults correctly. This has two parts: + + 1. Check that ExtBot actually inserts the defaults values correctly + 2. Check that tg.Bot just replaces `DefaultValue(obj)` with `obj`, i.e. that it doesn't + pass any `DefaultValue` instances to Request. See the docstring of + tg.Bot._insert_defaults for details on why we need that + + As for most defaults, we can't really check the effect, we just check if we're passing the correct kwargs to Request.post. As bot method tests a scattered across the different test files, we do this here in one place. @@ -259,9 +269,61 @@ def test_defaults_handling(self, bot_method_name, bot): Finally, there are some tests for Defaults.{parse_mode, quote, allow_sending_without_reply} at the appropriate places, as those are the only things we can actually check. """ + # Check that ExtBot does the right thing bot_method = getattr(bot, bot_method_name) assert check_defaults_handling(bot_method, bot) + # check that tg.Bot does the right thing + # make_assertion basically checks everything that happens in + # Bot._insert_defaults and Bot._insert_defaults_for_ilq_results + def make_assertion(_, data, timeout=None): + # Check regular kwargs + for k, v in data.items(): + if isinstance(v, DefaultValue): + pytest.fail(f'Parameter {k} was passed as DefaultValue to request') + elif isinstance(v, InputMedia) and isinstance(v.parse_mode, DefaultValue): + pytest.fail(f'Parameter {k} has a DefaultValue parse_mode') + # Check InputMedia + elif k == 'media' and isinstance(v, list): + if any(isinstance(med.parse_mode, DefaultValue) for med in v): + pytest.fail('One of the media items has a DefaultValue parse_mode') + # Check timeout + if isinstance(timeout, DefaultValue): + pytest.fail('Parameter timeout was passed as DefaultValue to request') + # Check inline query results + if bot_method_name.lower().replace('_', '') == 'answerinlinequery': + for result_dict in data['results']: + if isinstance(result_dict.get('parse_mode'), DefaultValue): + pytest.fail('InlineQueryResult has DefaultValue parse_mode') + imc = result_dict.get('input_message_content') + if imc and isinstance(imc.get('parse_mode'), DefaultValue): + pytest.fail( + 'InlineQueryResult is InputMessageContext with DefaultValue parse_mode' + ) + if imc and isinstance(imc.get('disable_web_page_preview'), DefaultValue): + pytest.fail( + 'InlineQueryResult is InputMessageContext with DefaultValue ' + 'disable_web_page_preview ' + ) + # Check datetime conversion + until_date = data.pop('until_date', None) + if until_date and until_date != 946684800: + pytest.fail('Naive until_date was not interpreted as UTC') + + if bot_method_name in ['get_file', 'getFile']: + # The get_file methods try to check if the result is a local file + return File(file_id='result', file_unique_id='result').to_dict() + + method = getattr(raw_bot, bot_method_name) + signature = inspect.signature(method) + kwargs_need_default = [ + kwarg + for kwarg, value in signature.parameters.items() + if isinstance(value.default, DefaultValue) + ] + monkeypatch.setattr(raw_bot.request, 'post', make_assertion) + method(**build_kwargs(inspect.signature(method), kwargs_need_default)) + def test_ext_bot_signature(self): """ Here we make sure that all methods of ext.ExtBot have the same signature as the @@ -269,7 +331,9 @@ def test_ext_bot_signature(self): """ # Some methods of ext.ExtBot global_extra_args = set() - extra_args_per_method = defaultdict(set, {'__init__': {'arbitrary_callback_data'}}) + extra_args_per_method = defaultdict( + set, {'__init__': {'arbitrary_callback_data', 'defaults'}} + ) different_hints_per_method = defaultdict(set, {'__setattr__': {'ext_bot'}}) for name, method in inspect.getmembers(Bot, predicate=inspect.isfunction): @@ -2381,18 +2445,6 @@ def post(*args, **kwargs): bot.callback_data_cache.clear_callback_data() bot.callback_data_cache.clear_callback_queries() - @pytest.mark.parametrize( - 'cls,warn', [(Bot, True), (BotSubClass, True), (ExtBot, False), (ExtBotSubClass, False)] - ) - def test_defaults_warning(self, bot, recwarn, cls, warn): - defaults = Defaults() - cls(bot.token, defaults=defaults) - if warn: - assert len(recwarn) == 1 - assert 'Passing Defaults to telegram.Bot is deprecated.' in str(recwarn[-1].message) - else: - assert len(recwarn) == 0 - def test_camel_case_redefinition_extbot(self): invalid_camel_case_functions = [] for function_name, function in ExtBot.__dict__.items(): diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 63fab91a896..efdb52657f3 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -194,7 +194,7 @@ def mock_async_err_handler(*args, **kwargs): self.count = 5 # set defaults value to dp.bot - dp.bot.defaults = Defaults(run_async=run_async) + dp.bot._defaults = Defaults(run_async=run_async) try: dp.add_handler(MessageHandler(Filters.all, self.callback_raise_error)) dp.add_error_handler(self.error_handler_context) @@ -206,7 +206,7 @@ def mock_async_err_handler(*args, **kwargs): finally: # reset dp.bot.defaults values - dp.bot.defaults = None + dp.bot._defaults = None @pytest.mark.parametrize( ['run_async', 'expected_output'], [(True, 'running async'), (False, None)] @@ -216,7 +216,7 @@ def mock_run_async(*args, **kwargs): self.received = 'running async' # set defaults value to dp.bot - dp.bot.defaults = Defaults(run_async=run_async) + dp.bot._defaults = Defaults(run_async=run_async) try: dp.add_handler(MessageHandler(Filters.all, lambda u, c: None)) monkeypatch.setattr(dp, 'run_async', mock_run_async) @@ -225,7 +225,7 @@ def mock_run_async(*args, **kwargs): finally: # reset defaults value - dp.bot.defaults = None + dp.bot._defaults = None def test_run_async_multiple(self, bot, dp, dp2): def get_dispatcher_name(q): @@ -822,7 +822,7 @@ def dummy_callback(*args, **kwargs): dp.process_update(update) assert self.count == 0 - dp.bot.defaults = Defaults(run_async=True) + dp.bot._defaults = Defaults(run_async=True) try: for group in range(5): dp.add_handler(MessageHandler(Filters.text, dummy_callback), group=group) @@ -831,7 +831,7 @@ def dummy_callback(*args, **kwargs): dp.process_update(update) assert self.count == 0 finally: - dp.bot.defaults = None + dp.bot._defaults = None @pytest.mark.parametrize('run_async', [DEFAULT_FALSE, False]) def test_update_persistence_one_sync(self, monkeypatch, dp, run_async): @@ -864,7 +864,7 @@ def dummy_callback(*args, **kwargs): monkeypatch.setattr(dp, 'update_persistence', update_persistence) monkeypatch.setattr(dp, 'run_async', dummy_callback) - dp.bot.defaults = Defaults(run_async=run_async) + dp.bot._defaults = Defaults(run_async=run_async) try: for group in range(5): @@ -874,7 +874,7 @@ def dummy_callback(*args, **kwargs): dp.process_update(update) assert self.count == expected finally: - dp.bot.defaults = None + dp.bot._defaults = None def test_custom_context_init(self, bot): cc = ContextTypes( diff --git a/tests/test_message.py b/tests/test_message.py index 5203510ed27..37bb18d7925 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1484,7 +1484,7 @@ def make_assertion(*args, **kwargs): assert message.unpin() def test_default_quote(self, message): - message.bot.defaults = Defaults() + message.bot._defaults = Defaults() try: message.bot.defaults._quote = False @@ -1500,7 +1500,7 @@ def test_default_quote(self, message): message.chat.type = Chat.GROUP assert message._quote(None, None) finally: - message.bot.defaults = None + message.bot._defaults = None def test_equality(self): id_ = 1