8000 Extend Customization Support for `Bot.base_(file_)url` (#4632) · vavasik800/python-telegram-bot@5dd7b8f · GitHub
[go: up one dir, main page]

Skip to content

Commit 5dd7b8f

Browse files
authored
Extend Customization Support for Bot.base_(file_)url (python-telegram-bot#4632)
1 parent 61b87ba commit 5dd7b8f

File tree

5 files changed

+190
-26
lines changed

5 files changed

+190
-26
lines changed

telegram/_bot.py

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,14 @@
9696
from telegram._utils.logging import get_logger
9797
from telegram._utils.repr import build_repr_with_selected_attrs
9898
from telegram._utils.strings import to_camel_case
99-
from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup
99+
from telegram._utils.types import (
100+
BaseUrl,
101+
CorrectOptionID,
102+
FileInput,
103+
JSONDict,
104+
ODVInput,
105+
ReplyMarkup,
106+
)
100107
from telegram._utils.warnings import warn
101108
from telegram._webhookinfo import WebhookInfo
102109
from telegram.constants import InlineQueryLimit, ReactionEmoji
@@ -126,6 +133,35 @@
126133
BT = TypeVar("BT", bound="Bot")
127134

128135

136+
# Even though we document only {token} as supported insertion, we are a bit more flexible
137+
# internally and support additional variants. At the very least, we don't want the insertion
138+
# to be case sensitive.
139+
_SUPPORTED_INSERTIONS = {"token", "TOKEN", "bot_token", "BOT_TOKEN", "bot-token", "BOT-TOKEN"}
140+
_INSERTION_STRINGS = {f"{{{insertion}}}" for insertion in _SUPPORTED_INSERTIONS}
141+
142+
143+
class _TokenDict(dict):
144+
__slots__ = ("token",)
145+
146+
# small helper to make .format_map work without knowing which exact insertion name is used
147+
def __init__(self, token: str):
148+
self.token = token
149+
super().__init__()
150+
151+
def __missing__(self, key: str) -> str:
152+
if key in _SUPPORTED_INSERTIONS:
153+
return self.token
154+
raise KeyError(f"Base URL string contains unsupported insertion: {key}")
155+
156+
157+
def _parse_base_url(value: BaseUrl, token: str) -> str:
158+
if callable(value):
159+
return value(token)
160+
if any(insertion in value for insertion in _INSERTION_STRINGS):
161+
return value.format_map(_TokenDict(token))
162+
return value + token
163+
164+
129165
class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
130166
"""This object represents a Telegram Bot.
131167
@@ -193,8 +229,40 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
193229
194230
Args:
195231
token (:obj:`str`): Bot's unique authentication token.
196-
base_url (:obj:`str`, optional): Telegram Bot API service URL.
232+
base_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`], optional): Telegram Bot API
233+
service URL. If the string contains ``{token}``, it will be replaced with the bot's
234+
token. If a callable is passed, it will be called with the bot's token as the only
235+
argument and must return the base URL. Otherwise, the token will be appended to the
236+
string. Defaults to ``"https://api.telegram.org/bot"``.
237+
238+
Tip:
239+
Customizing the base URL can be used to run a bot against
240+
:wiki:`Local Bot API Server <Local-Bot-API-Server>` or using Telegrams
241+
`test environment \
242+
<https://core.telegram.org/bots/features#dedicated-test-environment>`_.
243+
244+
Example:
245+
``"https://api.telegram.org/bot{token}/test"``
246+
247+
.. versionchanged:: NEXT.VERSION
248+
Supports callable input and string formatting.
197249
base_file_url (:obj:`str`, optional): Telegram Bot API file URL.
250+
If the string contains ``{token}``, it will be replaced with the bot's
251+
token. If a callable is passed, it will be called with the bot's token as the only
252+
argument and must return the base URL. Otherwise, the token will be appended to the
253+
string. Defaults to ``"https://api.telegram.org/bot"``.
254+
255+
Tip:
256+
Customizing the base URL can be used to run a bot against
257+
:wiki:`Local Bot API Server <Local-Bot-API-Server>` or using Telegrams
258+
`test environment \
259+
<https://core.telegram.org/bots/features#dedicated-test-environment>`_.
260+
261+
Example:
262+
``"https://api.telegram.org/file/bot{token}/test"``
263+
264+
.. versionchanged:: NEXT.VERSION
265+
Supports callable input and string formatting.
198266
request (:class:`telegram.request.BaseRequest`, optional): Pre initialized
199267
:class:`telegram.request.BaseRequest` instances. Will be used for all bot methods
200268
*except* for :meth:`get_updates`. If not passed, an instance of
@@ -239,8 +307,8 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
239307
def __init__(
240308
self,
241309
token: str,
242-
base_url: str = "https://api.telegram.org/bot",
243-
base_file_url: str = "https://api.telegram.org/file/bot",
310+
base_url: BaseUrl = "https://api.telegram.org/bot",
311+
base_file_url: BaseUrl = "https://api.telegram.org/file/bot",
244312
request: Optional[BaseRequest] = None,
245313
get_updates_request: Optional[BaseRequest] = None,
246314
private_key: Optional[bytes] = None,
@@ -252,8 +320,11 @@ def __init__(
252320
raise InvalidToken("You must pass the token you received from https://t.me/Botfather!")
253321
self._token: str = token
254322

255-
self._base_url: str = base_url + self._token
256-
self._base_file_url: str = base_file_url + self._token
323+
self._base_url: str = _parse_base_url(base_url, self._token)
324+
self._base_file_url: str = _parse_base_url(base_file_url, self._token)
325+
self._LOGGER.debug("Set Bot API URL: %s", self._base_url)
326+
self._LOGGER.debug("Set Bot API File URL: %s", self._base_file_url)
327+
257328
self._local_mode: bool = local_mode
258329
self._bot_user: Optional[User] = None
259330
self._private_key: Optional[bytes] = None
@@ -264,7 +335,7 @@ def __init__(
264335
HTTPXRequest() if request is None else request,
265336
)
266337

267-
# this section is about issuing a warning when using HTTP/2 and connect to a self hosted
338+
# this section is about issuing a warning when using HTTP/2 and connect to a self-hosted
268339
# bot api instance, which currently only supports HTTP/1.1. Checking if a custom base url
269340
# is set is the best way to do that.
270341

@@ -273,14 +344,14 @@ def __init__(
273344
if (
274345
isinstance(self._request[0], HTTPXRequest)
275346
and self._request[0].http_version == "2"
276-
and not base_url.startswith("https://api.telegram.org/bot")
347+
and not self.base_url.startswith("https://api.telegram.org/bot")
277348
):
278349
warning_string = "get_updates_request"
279350

280351
if (
281352
isinstance(self._request[1], HTTPXRequest)
282353
and self._request[1].http_version == "2"
283-
and not base_url.startswith("https://api.telegram.org/bot")
354+
and not self.base_url.startswith("https://api.telegram.org/bot")
284355
):
285356
if warning_string:
286357
warning_string += " and request"

telegram/_utils/types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"""
2626
from collections.abc import Collection
2727
from pathlib import Path
28-
from typing import IO, TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union
28+
from typing import IO, TYPE_CHECKING, Any, Callable, Literal, Optional, TypeVar, Union
2929

3030
if TYPE_CHECKING:
3131
from telegram import (
@@ -91,3 +91,5 @@
9191
tuple[int, int, Union[bytes, bytearray]],
9292
tuple[int, int, None, int],
9393
]
94+
95+
BaseUrl = Union[str, Callable[[str], str]]

telegram/ext/_applicationbuilder.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@
2626

2727
from telegram._bot import Bot
2828
from telegram._utils.defaultvalue import DEFAULT_FALSE, DEFAULT_NONE, DefaultValue
29-
from telegram._utils.types import DVInput, DVType, FilePathInput, HTTPVersion, ODVInput, SocketOpt
29+
from telegram._utils.types import (
30+
BaseUrl,
31+
DVInput,
32+
DVType,
33+
FilePathInput,
34+
HTTPVersion,
35+
ODVInput,
36+
SocketOpt,
37+
)
3038
from telegram._utils.warnings import warn
3139
from telegram.ext._application import Application
3240
from telegram.ext._baseupdateprocessor import BaseUpdateProcessor, SimpleUpdateProcessor
@@ -164,8 +172,8 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
164172

165173
def __init__(self: "InitApplicationBuilder"):
166174
self._token: DVType[str] = DefaultValue("")
167-
self._base_url: DVType[str] = DefaultValue("https://api.telegram.org/bot")
168-
self._base_file_url: DVType[str] = DefaultValue("https://api.telegram.org/file/bot")
175+
self._base_url: DVType[BaseUrl] = DefaultValue("https://api.telegram.org/bot")
176+
self._base_file_url: DVType[BaseUrl] = DefaultValue("https://api.telegram.org/file/bot")
169177
self._connection_pool_size: DVInput[int] = DEFAULT_NONE
170178
self._proxy: DVInput[Union[str, httpx.Proxy, httpx.URL]] = DEFAULT_NONE
171179
self._socket_options: DVInput[Collection[SocketOpt]] = DEFAULT_NONE
@@ -378,15 +386,19 @@ def token(self: BuilderType, token: str) -> BuilderType:
378386
self._token = token
379387
return self
380388

381-
def base_url(self: BuilderType, base_url: str) -> BuilderType:
389+
def base_url(self: BuilderType, base_url: BaseUrl) -> BuilderType:
382390
"""Sets the base URL for :attr:`telegram.ext.Application.bot`. If not called,
383391
will default to ``'https://api.telegram.org/bot'``.
384392
385393
.. seealso:: :paramref:`telegram.Bot.base_url`,
386394
:wiki:`Local Bot API Server <Local-Bot-API-Server>`, :meth:`base_file_url`
387395
396+
.. versionchanged:: NEXT.VERSION
397+
Supports callable input and string formatting.
398+
388399
Args:
389-
base_url (:obj:`str`): The URL.
400+
base_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`]): The URL or
401+
input for the URL as accepted by :paramref:`telegram.Bot.base_url`.
390402
391403
Returns:
392404
:class:`ApplicationBuilder`: The same builder with the updated argument.
@@ -396,15 +408,19 @@ def base_url(self: BuilderType, base_url: str) -> BuilderType:
396408
self._base_url = base_url
397409
return self
398410

399-
def base_file_url(self: BuilderType, base_file_url: str) -> BuilderType:
411+
def base_file_url(self: BuilderType, base_file_url: BaseUrl) -> BuilderType:
400412
"""Sets the base file URL for :attr:`telegram.ext.Application.bot`. If not
401413
called, will default to ``'https://api.telegram.org/file/bot'``.
402414
403415
.. seealso:: :paramref:`telegram.Bot.base_file_url`,
404416
:wiki:`Local Bot API Server <Local-Bot-API-Server>`, :meth:`base_url`
405417
418+
.. versionchanged:: NEXT.VERSION
419+
Supports callable input and string formatting.
420+
406421
Args:
407-
base_file_url (:obj:`str`): The URL.
422+
base_file_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`]): The URL or
423+
input for the URL as accepted by :paramref:`telegram.Bot.base_file_url`.
408424
409425
Returns:
410426
:class:`ApplicationBuilder`: The same builder with the updated argument.

telegram/ext/_extbot.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@
9393
from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
9494
from telegram._utils.logging import get_logger
9595
from telegram._utils.repr import build_repr_with_selected_attrs
96-
from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup
96+
from telegram._utils.types import (
97+
BaseUrl,
98+
CorrectOptionID,
99+
FileInput,
100+
JSONDict,
101+
ODVInput,
102+
ReplyMarkup,
103+
)
97104
from telegram.ext._callbackdatacache import CallbackDataCache
98105
from telegram.ext._utils.types import RLARGS
99106
from telegram.request import BaseRequest
@@ -184,8 +191,8 @@ class ExtBot(Bot, Generic[RLARGS]):
184191
def __init__(
185192
self: "ExtBot[None]",
186193
token: str,
187-
base_url: str = "https://api.telegram.org/bot",
188-
base_file_url: str = "https://api.telegram.org/file/bot",
194+
base_url: BaseUrl = "https://api.telegram.org/bot",
195+
base_file_url: BaseUrl = "https://api.telegram.org/file/bot",
189196
request: Optional[BaseRequest] = None,
190197
get_updates_request: Optional[BaseRequest] = None,
191198
private_key: Optional[bytes] = None,
@@ -199,8 +206,8 @@ def __init__(
199206
def __init__(
200207
self: "ExtBot[RLARGS]",
201208
token: str,
202-
base_url: str = "https://api.telegram.org/bot",
203-
base_file_url: str = "https://api.telegram.org/file/bot",
209+
base_url: BaseUrl = "https://api.telegram.org/bot",
210+
base_file_url: BaseUrl = "https://api.telegram.org/file/bot",
204211
request: Optional[BaseRequest] = None,
205212
get_updates_request: Optional[BaseRequest] = None,
206213
private_key: Optional[bytes] = None,
@@ -214,8 +221,8 @@ def __init__(
214221
def __init__(
215222
self,
216223
token: str,
217-
base_url: str = "https://api.telegram.org/bot",
218-
base_file_url: str = "https://api.telegram.org/file/bot",
224+
base_url: BaseUrl = "https://api.telegram.org/bot",
225+
base_file_url: BaseUrl = "https://api.telegram.org/file/bot",
219226
request: Optional[BaseRequest] = None,
220227
get_updates_request: Optional[BaseRequest] = None,
221228
private_key: Optional[bytes] = None,

tests/test_bot.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ def _reset(self):
235235

236236
@pytest.mark.parametrize("bot_class", [Bot, ExtBot])
237237
def test_slot_behaviour(self, bot_class, offline_bot):
238-
inst = bot_class(offline_bot.token)
238+
inst = bot_class(
239+
offline_bot.token, request=OfflineRequest(1), get_updates_request=OfflineRequest(1)
240+
)
239241
for attr in inst.__slots__:
240242
assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'"
241243
assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot"
@@ -244,6 +246,71 @@ async def test_no_token_passed(self):
244246
with pytest.raises(InvalidToken, match="You must pass the token"):
245247
Bot("")
246248

249+
def test_base_url_parsing_basic(self, caplog):
250+
with caplog.at_level(logging.DEBUG):
251+
bot = Bot(
252+
token="!!Test String!!",
253+
base_url="base/",
254+
base_file_url="base/",
255+
request=OfflineRequest(1),
256+
get_updates_request=OfflineRequest(1),
257+
)
258+
259+
assert bot.base_url == "base/!!Test String!!"
260+
assert bot.base_file_url == "base/!!Test String!!"
261+
262+
assert len(caplog.records) >= 2
263+
messages = [record.getMessage() for record in caplog.records]
264+
assert "Set Bot API URL: base/!!Test String!!" in messages
265+
assert "Set Bot API File URL: base/!!Test String!!" in messages
266+
267+
@pytest.mark.parametrize(
268+
"insert_key", ["token", "TOKEN", "bot_token", "BOT_TOKEN", "bot-token", "BOT-TOKEN"]
269+
)
270+
def test_base_url_parsing_string_format(self, insert_key, caplog):
271+
string = f"{{{insert_key}}}"
272+
273+
with caplog.at_level(logging.DEBUG):
274+
bot = Bot(
275+
token="!!Test String!!",
276+
base_url=string,
277+
base_file_url=string,
278+
request=OfflineRequest(1),
279+
get_updates_request=OfflineRequest(1),
280+
)
281+
282+
assert bot.base_url == "!!Test String!!"
283+
assert bot.base_file_url == "!!Test String!!"
284+
285+
assert len(caplog.records) >= 2
286+
messages = [record.getMessage() for record in caplog.records]
287+
assert "Set Bot API URL: !!Test String!!" in messages
288+
assert "Set Bot API File URL: !!Test String!!" in messages
289+
290+
with pytest.raises(KeyError, match="unsupported insertion: unknown"):
291+
Bot("token", base_url="{unknown}{token}")
292+
293+
def test_base_url_parsing_callable(self, caplog):
294+
def build_url(_: str) -> str:
295+
return "!!Test String!!"
296+
297+
with caplog.at_level(logging.DEBUG):
298+
bot = Bot(
299+
token="some-token",
300+
base_url=build_url,
301+
base_file_url=build_url,
302+
request=OfflineRequest(1),
303+
get_updates_request=OfflineRequest(1),
304+
)
305+
306+
assert bot.base_url == "!!Test String!!"
307+
assert bot.base_file_url == "!!Test String!!"
308+
309+
assert len(caplog.records) >= 2
310+
messages = [record.getMessage() for record in caplog.records]
311+
assert "Set Bot API URL: !!Test String!!" in messages
312+
assert "Set Bot API File URL: !!Test String!!" in messages
313+
247314
async def test_repr(self):
248315
offline_bot = Bot(token="some_token", base_file_url="")
249316
assert repr(offline_bot) == "Bot[token=some_token]"
@@ -409,9 +476,10 @@ def test_bot_deepcopy_error(self, offline_bot):
409476
("cls", "logger_name"), [(Bot, "telegram.Bot"), (ExtBot, "telegram.ext.ExtBot")]
410477
)
411478
async def test_bot_method_logging(self, offline_bot: PytestExtBot, cls, logger_name, caplog):
479+
instance = cls(offline_bot.token)
412480
# Second argument makes sure that we ignore logs from e.g. httpx
413481
with caplog.at_level(logging.DEBUG, logger="telegram"):
414-
await cls(offline_bot.token).get_me()
482+
await instance.get_me()
415483
# Only for stabilizing this test-
416484
if len(caplog.records) == 4:
417485
for idx, record in enumerate(caplog.records):

0 commit comments

Comments
 (0)
0