Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ee009c0
feat(`Bot`): add shortcut to pass caption to media group
lemontree210 Oct 16, 2022
07b175e
minor fix(`ExtBot`): remove blank line after docsting
lemontree210 Oct 16, 2022
0cdfd92
fix(`test_official.py`) add convenience arguments to `ignored`
lemontree210 Oct 16, 2022
3ff6f19
fix(`test_inputmedia.py`) remove `flaky` from test checking ValueError
lemontree210 Oct 18, 2022
5af855f
fix(`send_media_group`) change `ValueError` message, add caption checks
lemontree210 Oct 18, 2022
af61aac
fix(`test_send_media_group`) check media except for 1st have no captions
lemontree210 Oct 18, 2022
20c89dd
fix(`send_media_group`) add `:paramref:` to docstring, tweak text
lemontree210 Oct 18, 2022
063b2da
fix(`_extbot.py`) remove docstring for `send_media_group()`
lemontree210 Oct 18, 2022
e4b7971
fix(`send_media_group`) fix `Defaults`, enhance docstring
lemontree210 Oct 18, 2022
c38801f
refactor(`send_media_group`) copy just one media item if necessary
lemontree210 Oct 18, 2022
61af4f4
refactor(`send_media_group`) rename caption arguments
lemontree210 Oct 19, 2022
7643dc6
refactor(`send_media_group`) remove redundant auxiliary list variable
lemontree210 Oct 19, 2022
7a6ffc2
fix(`test_send_media_group`) remove redundant check for no attribute
lemontree210 Oct 19, 2022
cb8fef6
fix(`test_send_media_group`) check that method caused no side effects
lemontree210 Oct 19, 2022
dac8359
fix(`send_media_group`) fix check of `parse_mode` of individual item
lemontree210 Oct 19, 2022
bad0128
fix(`test_send_media_group`) check media w/o `caption`, w/ other attrs
lemontree210 Oct 20, 2022
4e8007d
fix(`send_media_group`) fix check for individual captions
lemontree210 Oct 21, 2022
6d8954e
refactor(`test_send_media_group`) use new fixture, don't change existing
lemontree210 Oct 21, 2022
b0d20c4
fix: revert `parse_mode` typing to `ODVInput[str] = DEFAULT_NONE`, test
lemontree210 Oct 22, 2022
008f201
fix typo in test for default parse mode
lemontree210 Oct 22, 2022
86d024b
fix side effect of handling of `parse_mode` by bot with defaults
lemontree210 Oct 22, 2022
7b68315
fix(conftest.py) exclude `parse_mode` only for `..._media_group()`
lemontree210 Oct 23, 2022
b23acc5
fix(test) test send_media_group with Defaults bot and `parse_mode=None`
lemontree210 Oct 23, 2022
4010cff
merge `upstream/master`, fix `copy` in `_bot.py`
lemontree210 Oct 31, 2022
43d2d28
refactor(`Bot.send_media_group()`): copy only 1st item in media group
lemontree210 Oct 31, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The following wonderful people contributed directly or indirectly to this projec
- `daimajia <https://github.com/daimajia>`_
- `Daniel Reed <https://github.com/nmlorg>`_
- `D David Livingston <https://github.com/daviddl9>`_
- `Dmitry Kolomatskiy <https://github.com/lemontree210>`_
- `DonalDuck004 <https://github.com/DonalDuck004>`_
- `Eana Hufwe <https://github.com/blueset>`_
- `Ehsan Online <https://github.com/ehsanonline>`_
Expand Down
59 changes: 51 additions & 8 deletions telegram/_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains an object that represents a Telegram Bot."""
import asyncio
import copy
import functools
import logging
import pickle
from contextlib import AbstractAsyncContextManager
from copy import copy
from datetime import datetime
from types import TracebackType
from typing import (
Expand Down Expand Up @@ -348,12 +348,12 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: # skipcq: PYL-R020
# 1)
if isinstance(val, InputMedia):
# Copy object as not to edit it in-place
val = copy(val)
val = copy.copy(val)
val.parse_mode = DefaultValue.get_value(val.parse_mode)
data[key] = val
elif key == "media" and isinstance(val, list):
# Copy objects as not to edit them in-place
copy_list = [copy(media) for media in val]
copy_list = [copy.copy(media) for media in val]
for media in copy_list:
media.parse_mode = DefaultValue.get_value(media.parse_mode)
data[key] = copy_list
Expand Down Expand Up @@ -2005,9 +2005,17 @@ async def send_media_group(
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = None,
caption: Optional[str] = None,
parse_mode: ODVInput[str] = DEFAULT_NONE,
caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None,
) -> List[Message]:
"""Use this method to send a group of photos or videos as an album.

Note:
If you supply a :paramref:`caption` (along with either
:paramref:`parse_mode` or :paramref:`caption_entities`),
then items in :paramref:`media` must have no captions, and vice verca.

.. seealso:: :attr:`telegram.Message.reply_media_group`,
:attr:`telegram.Chat.send_media_group`,
:attr:`telegram.User.send_media_group`
Expand Down Expand Up @@ -2044,13 +2052,48 @@ async def send_media_group(
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the
Telegram API.
caption (:obj:`str`, optional): Caption that will be added to the
first element of :paramref:`media`, so that it will be used as caption for the
whole media group.
Defaults to :obj:`None`.
parse_mode (:obj:`str` | :obj:`None`, optional):
Parse mode for :paramref:`caption`.
See the constants in :class:`telegram.constants.ParseMode` for the
available modes.
caption_entities (List[:class:`telegram.MessageEntity`], optional):
List of special entities for :paramref:`caption`,
which can be specified instead of :paramref:`parse_mode`.
Defaults to :obj:`None`.

Returns:
List[:class:`telegram.Message`]: An array of the sent Messages.

Raises:
:class:`telegram.error.TelegramError`
"""
if caption and any(
[
any(item.caption for item in media),
any(item.caption_entities for item in media),
# if parse_mode was set explicitly, even to None, error must be raised
any(item.parse_mode is not DEFAULT_NONE for item in media),
]
):
raise ValueError("You can only supply either group caption or media with captions.")

if caption:
# Copy first item (to avoid mutation of original object), apply group caption to it.
# This will lead to the group being shown with this caption.
item_to_get_caption = copy.copy(media[0])
item_to_get_caption.caption = caption
if parse_mode is not DEFAULT_NONE:
item_to_get_caption.parse_mode = parse_mode
item_to_get_caption.caption_entities = caption_entities

# copy the list (just the references) to avoid mutating the original list
media = media[:]
media[0] = item_to_get_caption

data: JSONDict = {
"chat_id": chat_id,
"media": media,
Expand Down Expand Up @@ -2870,22 +2913,22 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ
# Copy the objects that need modification to avoid modifying the original object
copied = False
if hasattr(res, "parse_mode"):
res = copy(res)
res = copy.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)
res = copy.copy(res)
copied = True
res.input_message_content = copy(res.input_message_content)
res.input_message_content = copy.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 = copy.copy(res)
res.input_message_content = copy.copy(res.input_message_content)
res.input_message_content.disable_web_page_preview = DefaultValue.get_value(
res.input_message_content.disable_web_page_preview
)
Expand Down
6 changes: 6 additions & 0 deletions telegram/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,9 @@ async def send_media_group(
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = None,
caption: Optional[str] = None,
parse_mode: ODVInput[str] = DEFAULT_NONE,
caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None,
) -> List["Message"]:
"""Shortcut for::

Expand All @@ -1257,6 +1260,9 @@ async def send_media_group(
api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply,
protect_content=protect_content,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
)

async def send_chat_action(
Expand Down
6 changes: 6 additions & 0 deletions telegram/_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,9 @@ async def reply_media_group(
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = None,
caption: Optional[str] = None,
parse_mode: ODVInput[str] = DEFAULT_NONE,
caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None,
) -> List["Message"]:
"""Shortcut for::

Expand Down Expand Up @@ -1043,6 +1046,9 @@ async def reply_media_group(
api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply,
protect_content=protect_content,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
)

async def reply_photo(
Expand Down
6 changes: 6 additions & 0 deletions telegram/_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,9 @@ async def send_media_group(
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = None,
caption: Optional[str] = None,
parse_mode: ODVInput[str] = DEFAULT_NONE,
caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None,
) -> List["Message"]:
"""Shortcut for::

Expand All @@ -497,6 +500,9 @@ async def send_media_group(
api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply,
protect_content=protect_content,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
)

async def send_audio(
Expand Down
6 changes: 6 additions & 0 deletions telegram/ext/_extbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2259,6 +2259,9 @@ async def send_media_group(
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = None,
rate_limit_args: RLARGS = None,
caption: Optional[str] = None,
parse_mode: ODVInput[str] = DEFAULT_NONE,
caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None,
) -> List[Message]:
return await super().send_media_group(
chat_id=chat_id,
Expand All @@ -2272,6 +2275,9 @@ async def send_media_group(
connect_timeout=connect_timeout,
pool_timeout=pool_timeout,
api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args),
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
)

async def send_message(
Expand Down
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@ async def check_defaults_handling(
if isinstance(value.default, DefaultValue) and not kwarg.endswith("_timeout")
]

if method.__name__.endswith("_media_group"):
# the parse_mode is applied to the first media item, and we test this elsewhere
kwargs_need_default.remove("parse_mode")

defaults_no_custom_defaults = Defaults()
kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters.keys()}
kwargs["tzinfo"] = pytz.timezone("America/New_York")
Expand All @@ -732,7 +736,7 @@ async def make_assertion(
data = request_data.parameters

# Check regular arguments that need defaults
for arg in (dkw for dkw in kwargs_need_default if dkw != "timeout"):
for arg in kwargs_need_default:
# 'None' should not be passed along to Telegram
if df_value in [None, DEFAULT_NONE]:
if arg in data:
Expand Down
139 changes: 139 additions & 0 deletions tests/test_inputmedia.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,27 @@ def media_group(photo, thumb): # noqa: F811
]


@pytest.fixture(scope="function") # noqa: F811
def media_group_no_caption_args(photo, thumb): # noqa: F811
return [InputMediaPhoto(photo), InputMediaPhoto(thumb), InputMediaPhoto(photo)]


@pytest.fixture(scope="function") # noqa: F811
def media_group_no_caption_only_caption_entities(photo, thumb): # noqa: F811
return [
InputMediaPhoto(photo, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 5)]),
InputMediaPhoto(photo, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 5)]),
]


@pytest.fixture(scope="function") # noqa: F811
def media_group_no_caption_only_parse_mode(photo, thumb): # noqa: F811
return [
InputMediaPhoto(photo, parse_mode="Markdown"),
InputMediaPhoto(thumb, parse_mode="HTML"),
]


class TestSendMediaGroup:
@flaky(3, 1)
async def test_send_media_group_photo(self, bot, chat_id, media_group):
Expand All @@ -445,6 +466,79 @@ async def test_send_media_group_photo(self, bot, chat_id, media_group):
mes.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)] for mes in messages
)

async def test_send_media_group_throws_error_with_group_caption_and_individual_captions(
self,
bot,
chat_id,
media_group,
media_group_no_caption_only_caption_entities,
media_group_no_caption_only_parse_mode,
):
for group in (
media_group,
media_group_no_caption_only_caption_entities,
media_group_no_caption_only_parse_mode,
):
with pytest.raises(
ValueError,
match="You can only supply either group caption or media with captions.",
):
await bot.send_media_group(chat_id, group, caption="foo")

@pytest.mark.parametrize(
"caption, parse_mode, caption_entities",
[
# same combinations of caption options as in media_group fixture
("*photo* 1", "Markdown", None),
("<b>photo</b> 1", "HTML", None),
("photo 1", None, [MessageEntity(MessageEntity.BOLD, 0, 5)]),
],
)
@flaky(3, 1)
async def test_send_media_group_with_group_caption(
self,
bot,
chat_id,
media_group_no_caption_args,
caption,
parse_mode,
caption_entities,
):
# prepare a copy to check later on if calling the method has caused side effects
copied_media_group = media_group_no_caption_args.copy()

messages = await bot.send_media_group(
chat_id,
media_group_no_caption_args,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
)

# Check that the method had no side effects:
# original group was not changed and 1st item still points to the same object
# (1st item must be copied within the method before adding the caption)
assert media_group_no_caption_args == copied_media_group
assert media_group_no_caption_args[0] is copied_media_group[0]

assert not any(item.parse_mode for item in media_group_no_caption_args)

assert isinstance(messages, list)
assert len(messages) == 3
assert all(isinstance(mes, Message) for mes in messages)

first_message, other_messages = messages[0], messages[1:]
assert all(mes.media_group_id == first_message.media_group_id for mes in messages)

# Make sure first message got the caption, which will lead
# to Telegram displaying its caption as group caption
assert first_message.caption
assert first_message.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)]

# Check that other messages have no captions
assert all(mes.caption is None for mes in other_messages)
assert not any(mes.caption_entities for mes in other_messages)

@flaky(3, 1)
async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_group):
ext_bot = bot
Expand Down Expand Up @@ -600,6 +694,51 @@ async def test_send_media_group_default_protect_content(
)
assert not all(msg.has_protected_content for msg in unprotected)

@flaky(3, 1)
@pytest.mark.parametrize("default_bot", [{"parse_mode": ParseMode.HTML}], indirect=True)
async def test_send_media_group_default_parse_mode(
self, chat_id, media_group_no_caption_args, default_bot
):
default = await default_bot.send_media_group(
chat_id, media_group_no_caption_args, caption="<b>photo</b> 1"
)

# make sure no parse_mode was set as a side effect
assert not any(item.parse_mode for item in media_group_no_caption_args)

overridden_markdown_v2 = await default_bot.send_media_group(
chat_id,
media_group_no_caption_args.copy(),
caption="*photo* 1",
parse_mode=ParseMode.MARKDOWN_V2,
)

overridden_none = await default_bot.send_media_group(
chat_id,
media_group_no_caption_args.copy(),
caption="<b>photo</b> 1",
parse_mode=None,
)

# Make sure first message got the caption, which will lead to Telegram
# displaying its caption as group caption
assert overridden_none[0].caption == "<b>photo</b> 1"
assert not overridden_none[0].caption_entities
# First messages in these two groups have to have caption "photo 1"
# because of parse mode (default or explicit)
for mes_group in (default, overridden_markdown_v2):
first_message = mes_group[0]
assert first_message.caption == "photo 1"
assert first_message.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)]

# This check is valid for all 3 groups of messages
for mes_group in (default, overridden_markdown_v2, overridden_none):
first_message, other_messages = mes_group[0], mes_group[1:]
assert all(mes.media_group_id == first_message.media_group_id for mes in mes_group)
# Check that messages from 2nd message onwards have no captions
assert all(mes.caption is None for mes in other_messages)
assert not any(mes.caption_entities for mes in other_messages)

@flaky(3, 1)
async def test_edit_message_media(self, bot, raw_bot, chat_id, media_group):
ext_bot = bot
Expand Down
Loading