README.md edited

This commit is contained in:
2024-12-06 10:45:08 +09:00
parent 09e4edee6b
commit 1aa387aa59
13921 changed files with 2057290 additions and 10 deletions

View File

@@ -0,0 +1,24 @@
# !/usr/bin/env python
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains classes that handle the networking backend of ``python-telegram-bot``."""
from ._baserequest import BaseRequest
from ._httpxrequest import HTTPXRequest
from ._requestdata import RequestData
__all__ = ("BaseRequest", "HTTPXRequest", "RequestData")

View File

@@ -0,0 +1,457 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains an abstract class to make POST and GET requests."""
import abc
import json
from contextlib import AbstractAsyncContextManager
from http import HTTPStatus
from types import TracebackType
from typing import Final, Optional, TypeVar, Union, final
from telegram._utils.defaultvalue import DEFAULT_NONE as _DEFAULT_NONE
from telegram._utils.defaultvalue import DefaultValue
from telegram._utils.logging import get_logger
from telegram._utils.strings import TextEncoding
from telegram._utils.types import JSONDict, ODVInput
from telegram._utils.warnings import warn
from telegram._version import __version__ as ptb_ver
from telegram.error import (
BadRequest,
ChatMigrated,
Conflict,
Forbidden,
InvalidToken,
NetworkError,
RetryAfter,
TelegramError,
)
from telegram.request._requestdata import RequestData
from telegram.warnings import PTBDeprecationWarning
RT = TypeVar("RT", bound="BaseRequest")
_LOGGER = get_logger(__name__, class_name="BaseRequest")
class BaseRequest(
AbstractAsyncContextManager["BaseRequest"],
abc.ABC,
):
"""Abstract interface class that allows python-telegram-bot to make requests to the Bot API.
Can be implemented via different asyncio HTTP libraries. An implementation of this class
must implement all abstract methods and properties.
Instances of this class can be used as asyncio context managers, where
.. code:: python
async with request_object:
# code
is roughly equivalent to
.. code:: python
try:
await request_object.initialize()
# code
finally:
await request_object.shutdown()
.. seealso:: :meth:`__aenter__` and :meth:`__aexit__`.
Tip:
JSON encoding and decoding is done with the standard library's :mod:`json` by default.
To use a custom library for this, you can override :meth:`parse_json_payload` and implement
custom logic to encode the keys of :attr:`telegram.request.RequestData.parameters`.
.. seealso:: :wiki:`Architecture Overview <Architecture>`,
:wiki:`Builder Pattern <Builder-Pattern>`
.. versionadded:: 20.0
"""
__slots__ = ()
USER_AGENT: Final[str] = f"python-telegram-bot v{ptb_ver} (https://python-telegram-bot.org)"
""":obj:`str`: A description that can be used as user agent for requests made to the Bot API.
"""
DEFAULT_NONE: Final[DefaultValue[None]] = _DEFAULT_NONE
""":class:`object`: A special object that indicates that an argument of a function was not
explicitly passed. Used for the timeout parameters of :meth:`post` and :meth:`do_request`.
Example:
When calling ``request.post(url)``, ``request`` should use the default timeouts set on
initialization. When calling ``request.post(url, connect_timeout=5, read_timeout=None)``,
``request`` should use ``5`` for the connect timeout and :obj:`None` for the read timeout.
Use ``if parameter is (not) BaseRequest.DEFAULT_NONE:`` to check if the parameter was set.
"""
async def __aenter__(self: RT) -> RT:
"""|async_context_manager| :meth:`initializes <initialize>` the Request.
Returns:
The initialized Request instance.
Raises:
:exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown`
is called in this case.
"""
try:
await self.initialize()
except Exception:
await self.shutdown()
raise
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
"""|async_context_manager| :meth:`shuts down <shutdown>` the Request."""
# Make sure not to return `True` so that exceptions are not suppressed
# https://docs.python.org/3/reference/datamodel.html?#object.__aexit__
await self.shutdown()
@property
def read_timeout(self) -> Optional[float]:
"""This property must return the default read timeout in seconds used by this class.
More precisely, the returned value should be the one used when
:paramref:`post.read_timeout` of :meth:post` is not passed/equal to :attr:`DEFAULT_NONE`.
.. versionadded:: 20.7
Warning:
For now this property does not need to be implemented by subclasses and will raise
:exc:`NotImplementedError` if accessed without being overridden. However, in future
versions, this property will be abstract and must be implemented by subclasses.
Returns:
:obj:`float` | :obj:`None`: The read timeout in seconds.
"""
raise NotImplementedError
@abc.abstractmethod
async def initialize(self) -> None:
"""Initialize resources used by this class. Must be implemented by a subclass."""
@abc.abstractmethod
async def shutdown(self) -> None:
"""Stop & clear resources used by this class. Must be implemented by a subclass."""
@final
async def post(
self,
url: str,
request_data: Optional[RequestData] = None,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
) -> Union[JSONDict, list[JSONDict], bool]:
"""Makes a request to the Bot API handles the return code and parses the answer.
Warning:
This method will be called by the methods of :class:`telegram.Bot` and should *not* be
called manually.
Args:
url (:obj:`str`): The URL to request.
request_data (:class:`telegram.request.RequestData`, optional): An object containing
information about parameters and files to upload for the request.
read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a response from Telegram's server instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
write_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a write operation to complete (in terms of
a network socket; i.e. POSTing a request or uploading a file) instead of the time
specified during creating of this object. Defaults to :attr:`DEFAULT_NONE`.
connect_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the
maximum amount of time (in seconds) to wait for a connection attempt to a server
to succeed instead of the time specified during creating of this object. Defaults
to :attr:`DEFAULT_NONE`.
pool_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a connection to become available instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
Returns:
The JSON response of the Bot API.
"""
result = await self._request_wrapper(
url=url,
method="POST",
request_data=request_data,
read_timeout=read_timeout,
write_timeout=write_timeout,
connect_timeout=connect_timeout,
pool_timeout=pool_timeout,
)
json_data = self.parse_json_payload(result)
# For successful requests, the results are in the 'result' entry
# see https://core.telegram.org/bots/api#making-requests
return json_data["result"]
@final
async def retrieve(
self,
url: str,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
) -> bytes:
"""Retrieve the contents of a file by its URL.
Warning:
This method will be called by the methods of :class:`telegram.Bot` and should *not* be
called manually.
Args:
url (:obj:`str`): The web location we want to retrieve.
read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a response from Telegram's server instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
write_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a write operation to complete (in terms of
a network socket; i.e. POSTing a request or uploading a file) instead of the time
specified during creating of this object. Defaults to :attr:`DEFAULT_NONE`.
connect_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the
maximum amount of time (in seconds) to wait for a connection attempt to a server
to succeed instead of the time specified during creating of this object. Defaults
to :attr:`DEFAULT_NONE`.
pool_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a connection to become available instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
Returns:
:obj:`bytes`: The files contents.
"""
return await self._request_wrapper(
url=url,
method="GET",
read_timeout=read_timeout,
write_timeout=write_timeout,
connect_timeout=connect_timeout,
pool_timeout=pool_timeout,
)
async def _request_wrapper(
self,
url: str,
method: str,
request_data: Optional[RequestData] = None,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
) -> bytes:
"""Wraps the real implementation request method.
Performs the following tasks:
* Handle the various HTTP response codes.
* Parse the Telegram server response.
Args:
url (:obj:`str`): The URL to request.
method (:obj:`str`): HTTP method (i.e. 'POST', 'GET', etc.).
request_data (:class:`telegram.request.RequestData`, optional): An object containing
information about parameters and files to upload for the request.
read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a response from Telegram's server instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
write_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a write operation to complete (in terms of
a network socket; i.e. POSTing a request or uploading a file) instead of the time
specified during creating of this object. Defaults to :attr:`DEFAULT_NONE`.
connect_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the
maximum amount of time (in seconds) to wait for a connection attempt to a server
to succeed instead of the time specified during creating of this object. Defaults
to :attr:`DEFAULT_NONE`.
pool_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a connection to become available instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
Returns:
bytes: The payload part of the HTTP server response.
Raises:
TelegramError
"""
# Import needs to be here since HTTPXRequest is a subclass of BaseRequest
from telegram.request import HTTPXRequest # pylint: disable=import-outside-toplevel
# 20 is the documented default value for all the media related bot methods and custom
# implementations of BaseRequest may explicitly rely on that. Hence, we follow the
# standard deprecation policy and deprecate starting with version 20.7.
# For our own implementation HTTPXRequest, we can handle that ourselves, so we skip the
# warning in that case.
has_files = request_data and request_data.multipart_data
if (
has_files
and not isinstance(self, HTTPXRequest)
and isinstance(write_timeout, DefaultValue)
):
warn(
PTBDeprecationWarning(
"20.7",
f"The `write_timeout` parameter passed to {self.__class__.__name__}.do_request"
" will default to `BaseRequest.DEFAULT_NONE` instead of 20 in future versions "
"for *all* methods of the `Bot` class, including methods sending media.",
),
stacklevel=3,
)
write_timeout = 20
try:
code, payload = await self.do_request(
url=url,
method=method,
request_data=request_data,
read_timeout=read_timeout,
write_timeout=write_timeout,
connect_timeout=connect_timeout,
pool_timeout=pool_timeout,
)
except TelegramError:
raise
except Exception as exc:
raise NetworkError(f"Unknown error in HTTP implementation: {exc!r}") from exc
if HTTPStatus.OK <= code <= 299:
# 200-299 range are HTTP success statuses
return payload
response_data = self.parse_json_payload(payload)
description = response_data.get("description")
message = description if description else "Unknown HTTPError"
# In some special cases, we can raise more informative exceptions:
# see https://core.telegram.org/bots/api#responseparameters and
# https://core.telegram.org/bots/api#making-requests
# TGs response also has the fields 'ok' and 'error_code'.
# However, we rather rely on the HTTP status code for now.
parameters = response_data.get("parameters")
if parameters:
migrate_to_chat_id = parameters.get("migrate_to_chat_id")
if migrate_to_chat_id:
raise ChatMigrated(migrate_to_chat_id)
retry_after = parameters.get("retry_after")
if retry_after:
raise RetryAfter(retry_after)
message += f"\nThe server response contained unknown parameters: {parameters}"
if code == HTTPStatus.FORBIDDEN: # 403
raise Forbidden(message)
if code in (HTTPStatus.NOT_FOUND, HTTPStatus.UNAUTHORIZED): # 404 and 401
# TG returns 404 Not found for
# 1) malformed tokens
# 2) correct tokens but non-existing method, e.g. api.tg.org/botTOKEN/unkonwnMethod
# 2) is relevant only for Bot.do_api_request, where we have special handing for it.
# TG returns 401 Unauthorized for correctly formatted tokens that are not valid
raise InvalidToken(message)
if code == HTTPStatus.BAD_REQUEST: # 400
raise BadRequest(message)
if code == HTTPStatus.CONFLICT: # 409
raise Conflict(message)
if code == HTTPStatus.BAD_GATEWAY: # 502
raise NetworkError(description or "Bad Gateway")
raise NetworkError(f"{message} ({code})")
@staticmethod
def parse_json_payload(payload: bytes) -> JSONDict:
"""Parse the JSON returned from Telegram.
Tip:
By default, this method uses the standard library's :func:`json.loads` and
``errors="replace"`` in :meth:`bytes.decode`.
You can override it to customize either of these behaviors.
Args:
payload (:obj:`bytes`): The UTF-8 encoded JSON payload as returned by Telegram.
Returns:
dict: A JSON parsed as Python dict with results.
Raises:
TelegramError: If loading the JSON data failed
"""
decoded_s = payload.decode(TextEncoding.UTF_8, "replace")
try:
return json.loads(decoded_s)
except ValueError as exc:
_LOGGER.exception('Can not load invalid JSON data: "%s"', decoded_s)
raise TelegramError("Invalid server response") from exc
@abc.abstractmethod
async def do_request(
self,
url: str,
method: str,
request_data: Optional[RequestData] = None,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE,
) -> tuple[int, bytes]:
"""Makes a request to the Bot API. Must be implemented by a subclass.
Warning:
This method will be called by :meth:`post` and :meth:`retrieve`. It should *not* be
called manually.
Args:
url (:obj:`str`): The URL to request.
method (:obj:`str`): HTTP method (i.e. ``'POST'``, ``'GET'``, etc.).
request_data (:class:`telegram.request.RequestData`, optional): An object containing
information about parameters and files to upload for the request.
read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a response from Telegram's server instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
write_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a write operation to complete (in terms of
a network socket; i.e. POSTing a request or uploading a file) instead of the time
specified during creating of this object. Defaults to :attr:`DEFAULT_NONE`.
connect_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the
maximum amount of time (in seconds) to wait for a connection attempt to a server
to succeed instead of the time specified during creating of this object. Defaults
to :attr:`DEFAULT_NONE`.
pool_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a connection to become available instead
of the time specified during creating of this object. Defaults to
:attr:`DEFAULT_NONE`.
Returns:
tuple[:obj:`int`, :obj:`bytes`]: The HTTP return code & the payload part of the server
response.
"""

View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains methods to make POST and GET requests using the httpx library."""
from collections.abc import Collection
from typing import Any, Optional, Union
import httpx
from telegram._utils.defaultvalue import DefaultValue
from telegram._utils.logging import get_logger
from telegram._utils.types import HTTPVersion, ODVInput, SocketOpt
from telegram._utils.warnings import warn
from telegram.error import NetworkError, TimedOut
from telegram.request._baserequest import BaseRequest
from telegram.request._requestdata import RequestData
from telegram.warnings import PTBDeprecationWarning
# Note to future devs:
# Proxies are currently only tested manually. The httpx development docs have a nice guide on that:
# https://www.python-httpx.org/contributing/#development-proxy-setup (also saved on archive.org)
# That also works with socks5. Just pass `--mode socks5` to mitmproxy
_LOGGER = get_logger(__name__, "HTTPXRequest")
class HTTPXRequest(BaseRequest):
"""Implementation of :class:`~telegram.request.BaseRequest` using the library
`httpx <https://www.python-httpx.org>`_.
.. versionadded:: 20.0
Args:
connection_pool_size (:obj:`int`, optional): Number of connections to keep in the
connection pool. Defaults to ``1``.
Note:
Independent of the value, one additional connection will be reserved for
:meth:`telegram.Bot.get_updates`.
proxy_url (:obj:`str`, optional): Legacy name for :paramref:`proxy`, kept for backward
compatibility. Defaults to :obj:`None`.
.. deprecated:: 20.7
read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a response from Telegram's server.
This value is used unless a different value is passed to :meth:`do_request`.
Defaults to ``5``.
write_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a write operation to complete (in terms of
a network socket; i.e. POSTing a request or uploading a file).
This value is used unless a different value is passed to :meth:`do_request`.
Defaults to ``5``.
Hint:
This timeout is used for all requests except for those that upload media/files.
For the latter, :paramref:`media_write_timeout` is used.
connect_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the
maximum amount of time (in seconds) to wait for a connection attempt to a server
to succeed. This value is used unless a different value is passed to
:meth:`do_request`. Defaults to ``5``.
pool_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum
amount of time (in seconds) to wait for a connection to become available.
This value is used unless a different value is passed to :meth:`do_request`.
Defaults to ``1``.
Warning:
With a finite pool timeout, you must expect :exc:`telegram.error.TimedOut`
exceptions to be thrown when more requests are made simultaneously than there are
connections in the connection pool!
http_version (:obj:`str`, optional): If ``"2"`` or ``"2.0"``, HTTP/2 will be used instead
of HTTP/1.1. Defaults to ``"1.1"``.
.. versionadded:: 20.1
.. versionchanged:: 20.2
Reset the default version to 1.1.
.. versionchanged:: 20.5
Accept ``"2"`` as a valid value.
socket_options (Collection[:obj:`tuple`], optional): Socket options to be passed to the
underlying `library \
<https://www.encode.io/httpcore/async/#httpcore.AsyncConnectionPool.__init__>`_.
Note:
The values accepted by this parameter depend on the operating system.
This is a low-level parameter and should only be used if you are familiar with
these concepts.
.. versionadded:: 20.7
proxy (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``, optional): The URL to a proxy server,
a ``httpx.Proxy`` object or a ``httpx.URL`` object. For example
``'http://127.0.0.1:3128'`` or ``'socks5://127.0.0.1:3128'``. Defaults to :obj:`None`.
Note:
* The proxy URL can also be set via the environment variables ``HTTPS_PROXY`` or
``ALL_PROXY``. See `the docs of httpx`_ for more info.
* HTTPS proxies can be configured by passing a ``httpx.Proxy`` object with
a corresponding ``ssl_context``.
* For Socks5 support, additional dependencies are required. Make sure to install
PTB via :command:`pip install "python-telegram-bot[socks]"` in this case.
* Socks5 proxies can not be set via environment variables.
.. _the docs of httpx: https://www.python-httpx.org/environment_variables/#proxies
.. versionadded:: 20.7
media_write_timeout (:obj:`float` | :obj:`None`, optional): Like :paramref:`write_timeout`,
but used only for requests that upload media/files. This value is used unless a
different value is passed to :paramref:`do_request.write_timeout` of
:meth:`do_request`. Defaults to ``20`` seconds.
.. versionadded:: 21.0
httpx_kwargs (dict[:obj:`str`, Any], optional): Additional keyword arguments to be passed
to the `httpx.AsyncClient <https://www.python-httpx.org/api/#asyncclient>`_
constructor.
Warning:
This parameter is intended for advanced users that want to fine-tune the behavior
of the underlying ``httpx`` client. The values passed here will override all the
defaults set by ``python-telegram-bot`` and all other parameters passed to
:class:`HTTPXRequest`. The only exception is the :paramref:`media_write_timeout`
parameter, which is not passed to the client constructor.
No runtime warnings will be issued about parameters that are overridden in this
way.
.. versionadded:: 21.6
"""
__slots__ = ("_client", "_client_kwargs", "_http_version", "_media_write_timeout")
def __init__(
self,
connection_pool_size: int = 1,
proxy_url: Optional[Union[str, httpx.Proxy, httpx.URL]] = None,
read_timeout: Optional[float] = 5.0,
write_timeout: Optional[float] = 5.0,
connect_timeout: Optional[float] = 5.0,
pool_timeout: Optional[float] = 1.0,
http_version: HTTPVersion = "1.1",
socket_options: Optional[Collection[SocketOpt]] = None,
proxy: Optional[Union[str, httpx.Proxy, httpx.URL]] = None,
media_write_timeout: Optional[float] = 20.0,
httpx_kwargs: Optional[dict[str, Any]] = None,
):
if proxy_url is not None and proxy is not None:
raise ValueError("The parameters `proxy_url` and `proxy` are mutually exclusive.")
if proxy_url is not None:
proxy = proxy_url
warn(
PTBDeprecationWarning(
"20.7", "The parameter `proxy_url` is deprecated. Use `proxy` instead."
),
stacklevel=2,
)
self._http_version = http_version
self._media_write_timeout = media_write_timeout
timeout = httpx.Timeout(
connect=connect_timeout,
read=read_timeout,
write=write_timeout,
pool=pool_timeout,
)
limits = httpx.Limits(
max_connections=connection_pool_size,
max_keepalive_connections=connection_pool_size,
)
if http_version not in ("1.1", "2", "2.0"):
raise ValueError("`http_version` must be either '1.1', '2.0' or '2'.")
http1 = http_version == "1.1"
http_kwargs = {"http1": http1, "http2": not http1}
transport = (
httpx.AsyncHTTPTransport(
socket_options=socket_options,
)
if socket_options
else None
)
self._client_kwargs = {
"timeout": timeout,
"proxy": proxy,
"limits": limits,
"transport": transport,
**http_kwargs,
**(httpx_kwargs or {}),
}
try:
self._client = self._build_client()
except ImportError as exc:
if "httpx[http2]" not in str(exc) and "httpx[socks]" not in str(exc):
raise
if "httpx[socks]" in str(exc):
raise RuntimeError(
"To use Socks5 proxies, PTB must be installed via `pip install "
'"python-telegram-bot[socks]"`.'
) from exc
raise RuntimeError(
"To use HTTP/2, PTB must be installed via `pip install "
'"python-telegram-bot[http2]"`.'
) from exc
@property
def http_version(self) -> str:
"""
:obj:`str`: Used HTTP version, see :paramref:`http_version`.
.. versionadded:: 20.2
"""
return self._http_version
@property
def read_timeout(self) -> Optional[float]:
"""See :attr:`BaseRequest.read_timeout`.
Returns:
:obj:`float` | :obj:`None`: The default read timeout in seconds as passed to
:paramref:`HTTPXRequest.read_timeout`.
"""
return self._client.timeout.read
def _build_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(**self._client_kwargs)
async def initialize(self) -> None:
"""See :meth:`BaseRequest.initialize`."""
if self._client.is_closed:
self._client = self._build_client()
async def shutdown(self) -> None:
"""See :meth:`BaseRequest.shutdown`."""
if self._client.is_closed:
_LOGGER.debug("This HTTPXRequest is already shut down. Returning.")
return
await self._client.aclose()
async def do_request(
self,
url: str,
method: str,
request_data: Optional[RequestData] = None,
read_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE,
write_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE,
connect_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE,
pool_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE,
) -> tuple[int, bytes]:
"""See :meth:`BaseRequest.do_request`."""
if self._client.is_closed:
raise RuntimeError("This HTTPXRequest is not initialized!")
files = request_data.multipart_data if request_data else None
data = request_data.json_parameters if request_data else None
# If user did not specify timeouts (for e.g. in a bot method), use the default ones when we
# created this instance.
if isinstance(read_timeout, DefaultValue):
read_timeout = self._client.timeout.read
if isinstance(connect_timeout, DefaultValue):
connect_timeout = self._client.timeout.connect
if isinstance(pool_timeout, DefaultValue):
pool_timeout = self._client.timeout.pool
if isinstance(write_timeout, DefaultValue):
write_timeout = self._client.timeout.write if not files else self._media_write_timeout
timeout = httpx.Timeout(
connect=connect_timeout,
read=read_timeout,
write=write_timeout,
pool=pool_timeout,
)
try:
res = await self._client.request(
method=method,
url=url,
headers={"User-Agent": self.USER_AGENT},
timeout=timeout,
files=files,
data=data,
)
except httpx.TimeoutException as err:
if isinstance(err, httpx.PoolTimeout):
raise TimedOut(
message=(
"Pool timeout: All connections in the connection pool are occupied. "
"Request was *not* sent to Telegram. Consider adjusting the connection "
"pool size or the pool timeout."
)
) from err
raise TimedOut from err
except httpx.HTTPError as err:
# HTTPError must come last as its the base httpx exception class
# TODO p4: do something smart here; for now just raise NetworkError
# We include the class name for easier debugging. Especially useful if the error
# message of `err` is empty.
raise NetworkError(f"httpx.{err.__class__.__name__}: {err}") from err
return res.status_code, res.content

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains a class that holds the parameters of a request to the Bot API."""
import json
from typing import Any, Optional, Union, final
from urllib.parse import urlencode
from telegram._utils.strings import TextEncoding
from telegram._utils.types import UploadFileDict
from telegram.request._requestparameter import RequestParameter
@final
class RequestData:
"""Instances of this class collect the data needed for one request to the Bot API, including
all parameters and files to be sent along with the request.
.. versionadded:: 20.0
Warning:
How exactly instances of this are created should be considered an implementation detail
and not part of PTBs public API. Users should exclusively rely on the documented
attributes, properties and methods.
Attributes:
contains_files (:obj:`bool`): Whether this object contains files to be uploaded via
``multipart/form-data``.
"""
__slots__ = ("_parameters", "contains_files")
def __init__(self, parameters: Optional[list[RequestParameter]] = None):
self._parameters: list[RequestParameter] = parameters or []
self.contains_files: bool = any(param.input_files for param in self._parameters)
@property
def parameters(self) -> dict[str, Union[str, int, list[Any], dict[Any, Any]]]:
"""Gives the parameters as mapping of parameter name to the parameter value, which can be
a single object of type :obj:`int`, :obj:`float`, :obj:`str` or :obj:`bool` or any
(possibly nested) composition of lists, tuples and dictionaries, where each entry, key
and value is of one of the mentioned types.
Returns:
dict[:obj:`str`, Union[:obj:`str`, :obj:`int`, list[any], dict[any, any]]]
"""
return {
param.name: param.value # type: ignore[misc]
for param in self._parameters
if param.value is not None
}
@property
def json_parameters(self) -> dict[str, str]:
"""Gives the parameters as mapping of parameter name to the respective JSON encoded
value.
Tip:
By default, this property uses the standard library's :func:`json.dumps`.
To use a custom library for JSON encoding, you can directly encode the keys of
:attr:`parameters` - note that string valued keys should not be JSON encoded.
Returns:
dict[:obj:`str`, :obj:`str`]
"""
return {
param.name: param.json_value
for param in self._parameters
if param.json_value is not None
}
def url_encoded_parameters(self, encode_kwargs: Optional[dict[str, Any]] = None) -> str:
"""Encodes the parameters with :func:`urllib.parse.urlencode`.
Args:
encode_kwargs (dict[:obj:`str`, any], optional): Additional keyword arguments to pass
along to :func:`urllib.parse.urlencode`.
Returns:
:obj:`str`
"""
if encode_kwargs:
return urlencode(self.json_parameters, **encode_kwargs)
return urlencode(self.json_parameters)
def parametrized_url(self, url: str, encode_kwargs: Optional[dict[str, Any]] = None) -> str:
"""Shortcut for attaching the return value of :meth:`url_encoded_parameters` to the
:paramref:`url`.
Args:
url (:obj:`str`): The URL the parameters will be attached to.
encode_kwargs (dict[:obj:`str`, any], optional): Additional keyword arguments to pass
along to :func:`urllib.parse.urlencode`.
Returns:
:obj:`str`
"""
url_parameters = self.url_encoded_parameters(encode_kwargs=encode_kwargs)
return f"{url}?{url_parameters}"
@property
def json_payload(self) -> bytes:
"""The :attr:`parameters` as UTF-8 encoded JSON payload.
Tip:
By default, this property uses the standard library's :func:`json.dumps`.
To use a custom library for JSON encoding, you can directly encode the keys of
:attr:`parameters` - note that string valued keys should not be JSON encoded.
Returns:
:obj:`bytes`
"""
return json.dumps(self.json_parameters).encode(TextEncoding.UTF_8)
@property
def multipart_data(self) -> UploadFileDict:
"""Gives the files contained in this object as mapping of part name to encoded content.
.. versionchanged:: 21.5
Content may now be a file handle.
"""
multipart_data: UploadFileDict = {}
for param in self._parameters:
m_data = param.multipart_data
if m_data:
multipart_data.update(m_data)
return multipart_data

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains a class that describes a single parameter of a request to the Bot API."""
import json
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, final
from telegram._files.inputfile import InputFile
from telegram._files.inputmedia import InputMedia, InputPaidMedia
from telegram._files.inputsticker import InputSticker
from telegram._telegramobject import TelegramObject
from telegram._utils.datetime import to_timestamp
from telegram._utils.enum import StringEnum
from telegram._utils.types import UploadFileDict
@final
@dataclass(repr=True, eq=False, order=False, frozen=True)
class RequestParameter:
"""Instances of this class represent a single parameter to be sent along with a request to
the Bot API.
.. versionadded:: 20.0
Warning:
This class intended is to be used internally by the library and *not* by the user. Changes
to this class are not considered breaking changes and may not be documented in the
changelog.
Args:
name (:obj:`str`): The name of the parameter.
value (:obj:`object` | :obj:`None`): The value of the parameter. Must be JSON-dumpable.
input_files (list[:class:`telegram.InputFile`], optional): A list of files that should be
uploaded along with this parameter.
Attributes:
name (:obj:`str`): The name of the parameter.
value (:obj:`object` | :obj:`None`): The value of the parameter.
input_files (list[:class:`telegram.InputFile` | :obj:`None`): A list of files that should
be uploaded along with this parameter.
"""
__slots__ = ("input_files", "name", "value")
name: str
value: object
input_files: Optional[list[InputFile]]
@property
def json_value(self) -> Optional[str]:
"""The JSON dumped :attr:`value` or :obj:`None` if :attr:`value` is :obj:`None`.
The latter can currently only happen if :attr:`input_files` has exactly one element that
must not be uploaded via an attach:// URI.
"""
if isinstance(self.value, str):
return self.value
if self.value is None:
return None
return json.dumps(self.value)
@property
def multipart_data(self) -> Optional[UploadFileDict]:
"""A dict with the file data to upload, if any.
.. versionchanged:: 21.5
Content may now be a file handle.
"""
if not self.input_files:
return None
return {
(input_file.attach_name or self.name): input_file.field_tuple
for input_file in self.input_files
}
@staticmethod
def _value_and_input_files_from_input( # pylint: disable=too-many-return-statements
value: object,
) -> tuple[object, list[InputFile]]:
"""Converts `value` into something that we can json-dump. Returns two values:
1. the JSON-dumpable value. May be `None` in case the value is an InputFile which must
not be uploaded via an attach:// URI
2. A list of InputFiles that should be uploaded for this value
Note that we handle files differently depending on whether attaching them via an URI of the
form attach://<name> is documented to be allowed or not.
There was some confusion whether this worked for all files, so that we stick to the
documented ways for now.
See https://github.com/tdlib/telegram-bot-api/issues/167 and
https://github.com/tdlib/telegram-bot-api/issues/259
This method only does some special casing for our own helper class StringEnum, but not
for general enums. This is because:
* tg.constants currently only uses IntEnum as second enum type and json dumping that
is no problem
* if a user passes a custom enum, it's unlikely that we can actually properly handle it
even with some special casing.
"""
if isinstance(value, datetime):
return to_timestamp(value), []
if isinstance(value, StringEnum):
return value.value, []
if isinstance(value, InputFile):
if value.attach_uri:
return value.attach_uri, [value]
return None, [value]
if isinstance(value, (InputMedia, InputPaidMedia)) and isinstance(value.media, InputFile):
# We call to_dict and change the returned dict instead of overriding
# value.media in case the same value is reused for another request
data = value.to_dict()
if value.media.attach_uri:
data["media"] = value.media.attach_uri
else:
data.pop("media", None)
thumbnail = data.get("thumbnail", None)
if isinstance(thumbnail, InputFile):
if thumbnail.attach_uri:
data["thumbnail"] = thumbnail.attach_uri
else:
data.pop("thumbnail", None)
return data, [value.media, thumbnail]
return data, [value.media]
if isinstance(value, InputSticker) and isinstance(value.sticker, InputFile):
# We call to_dict and change the returned dict instead of overriding
# value.sticker in case the same value is reused for another request
data = value.to_dict()
data["sticker"] = value.sticker.attach_uri
return data, [value.sticker]
if isinstance(value, TelegramObject):
# Needs to be last, because InputMedia is a subclass of TelegramObject
return value.to_dict(), []
return value, []
@classmethod
def from_input(cls, key: str, value: object) -> "RequestParameter":
"""Builds an instance of this class for a given key-value pair that represents the raw
input as passed along from a method of :class:`telegram.Bot`.
"""
if not isinstance(value, (str, bytes)) and isinstance(value, Sequence):
param_values = []
input_files = []
for obj in value:
param_value, input_file = cls._value_and_input_files_from_input(obj)
if param_value is not None:
param_values.append(param_value)
input_files.extend(input_file)
return RequestParameter(
name=key, value=param_values, input_files=input_files if input_files else None
)
param_value, input_files = cls._value_and_input_files_from_input(value)
return RequestParameter(
name=key, value=param_value, input_files=input_files if input_files else None
)