diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index 33e714bdec8..b5ce5921484 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -157,7 +157,7 @@ Check-list for PRs This checklist is a non-exhaustive reminder of things that should be done before a PR is merged, both for you as contributor and for the maintainers. Feel free to copy (parts of) the checklist to the PR description to remind you or the maintainers of open points or if you have questions on anything. -- Added ``.. versionadded:: NEXT.VERSION``, ``.. versionchanged:: NEXT.VERSION`` or ``.. deprecated:: NEXT.VERSION`` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes) +- Added ``.. versionadded:: NEXT.VERSION``, ``.. versionchanged:: NEXT.VERSION``, ``.. deprecated:: NEXT.VERSION`` or ``.. versionremoved:: NEXT.VERSION`` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes) - Created new or adapted existing unit tests - Documented code changes according to the `CSI standard `__ - Added myself alphabetically to ``AUTHORS.rst`` (optional) @@ -276,7 +276,7 @@ This gives us the flexibility to re-order arguments and more importantly to add new required arguments. It's also more explicit and easier to read. -.. _`Code of Conduct`: https://www.python.org/psf/conduct/ +.. _`Code of Conduct`: https://policies.python.org/python.org/code-of-conduct/ .. _`issue tracker`: https://github.com/python-telegram-bot/python-telegram-bot/issues .. _`Telegram group`: https://telegram.me/pythontelegrambotgroup .. _`PEP 8 Style Guide`: https://peps.python.org/pep-0008/ diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index c3ad2fb6df3..1836230ff21 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -12,6 +12,8 @@ body: To make it easier for us to help you, please read this [article](https://github.com/python-telegram-bot/python-telegram-bot/wiki/Ask-Right). Please mind that there is also a users' [Telegram group](https://t.me/pythontelegrambotgroup) for questions about the library. Questions asked there might be answered quicker than here. Moreover, [GitHub Discussions](https://github.com/python-telegram-bot/python-telegram-bot/discussions) offer a slightly better format to discuss usage questions. + + If you have asked the same question elsewhere (e.g. the [Telegram group](https://t.me/pythontelegrambotgroup) or [StackOverflow](https://stackoverflow.com/questions/tagged/python-telegram-bot)), provide a link to that thread. - type: textarea id: issue-faced diff --git a/.github/workflows/dependabot-prs.yml b/.github/workflows/dependabot-prs.yml index 5a971fbbdce..80bace2d95d 100644 --- a/.github/workflows/dependabot-prs.yml +++ b/.github/workflows/dependabot-prs.yml @@ -16,7 +16,7 @@ jobs: - name: Fetch Dependabot metadata id: dependabot-metadata - uses: dependabot/fetch-metadata@v2.0.0 + uses: dependabot/fetch-metadata@v2.1.0 - uses: actions/checkout@v4 with: diff --git a/.github/workflows/labelling.yml b/.github/workflows/labelling.yml index a0b44c74494..12cd1b4bea7 100644 --- a/.github/workflows/labelling.yml +++ b/.github/workflows/labelling.yml @@ -11,7 +11,7 @@ jobs: pull-requests: write # for srvaroa/labeler to add labels in PR runs-on: ubuntu-latest steps: - - uses: srvaroa/labeler@v1.10.0 + - uses: srvaroa/labeler@v1.10.1 # Config file at .github/labeler.yml env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7528af1f9d7..fffe4573ddb 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -6,6 +6,7 @@ on: - tests/** - requirements.txt - requirements-opts.txt + - requirements-dev.txt push: branches: - master diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55166a45339..a0e83a6521c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.3.5' + rev: 'v0.4.3' hooks: - id: ruff name: ruff @@ -17,7 +17,7 @@ repos: - cachetools~=5.3.3 - aiolimiter~=1.1.0 - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.3.0 + rev: 24.4.2 hooks: - id: black args: @@ -40,7 +40,7 @@ repos: - aiolimiter~=1.1.0 - . # this basically does `pip install -e .` - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.10.0 hooks: - id: mypy name: mypy-ptb diff --git a/CHANGES.rst b/CHANGES.rst index 91e0eb8f00e..9a509ab485c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,58 @@ Changelog ========= +Version 21.2 +============ + +*Released 2024-05-20* + +This is the technical changelog for version 21.2. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 7.3 (:pr:`4246`, :pr:`4260`, :pr:`4243`, :pr:`4248`, :pr:`4242` closes :issue:`4236`, :pr:`4247` by `aelkheir `_) +- Remove Functionality Deprecated by Bot API 7.2 (:pr:`4245`) + +New Features +------------ + +- Add Version to ``PTBDeprecationWarning`` (:pr:`4262` closes :issue:`4261`) +- Handle Exceptions in building ``CallbackContext`` (:pr:`4222`) + +Bug Fixes +--------- + +- Call ``Application.post_stop`` Only if ``Application.stop`` was called (:pr:`4211` closes :issue:`4210`) +- Handle ``SystemExit`` raised in Handlers (:pr:`4157` closes :issue:`4155` and :issue:`4156`) +- Make ``Birthdate.to_date`` Return a ``datetime.date`` Object (:pr:`4251`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4217`) + +Internal Changes +---------------- + +- Add New Rules to ``ruff`` Config (:pr:`4250`) +- Adapt Test Suite to Changes in Error Messages (:pr:`4238`) + +Dependency Updates +------------------ + +- Bump ``furo`` from 2024.4.27 to 2024.5.6 (:pr:`4252`) +- ``pre-commit`` autoupdate (:pr:`4239`) +- Bump ``pytest`` from 8.1.1 to 8.2.0 (:pr:`4231`) +- Bump ``dependabot/fetch-metadata`` from 2.0.0 to 2.1.0 (:pr:`4228`) +- Bump ``pytest-asyncio`` from 0.21.1 to 0.21.2 (:pr:`4232`) +- Bump ``pytest-xdist`` from 3.6.0 to 3.6.1 (:pr:`4233`) +- Bump ``furo`` from 2024.1.29 to 2024.4.27 (:pr:`4230`) +- Bump ``srvaroa/labeler`` from 1.10.0 to 1.10.1 (:pr:`4227`) +- Bump ``pytest`` from 7.4.4 to 8.1.1 (:pr:`4218`) +- Bump ``sphinx`` from 7.2.6 to 7.3.7 (:pr:`4215`) +- Bump ``pytest-xdist`` from 3.5.0 to 3.6.0 (:pr:`4215`) + Version 21.1.1 ============== diff --git a/README.rst b/README.rst index 1d6b20aafea..76743bf0250 100644 --- a/README.rst +++ b/README.rst @@ -14,9 +14,9 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-7.2-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-7.3-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog - :alt: Supported Bot API versions + :alt: Supported Bot API version .. image:: https://img.shields.io/pypi/dm/python-telegram-bot :target: https://pypistats.org/packages/python-telegram-bot @@ -89,7 +89,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **7.2** are supported. +All types and methods of the Telegram Bot API **7.3** are supported. Installing ========== diff --git a/README_RAW.rst b/README_RAW.rst index df1312e4857..45f636bc2f7 100644 --- a/README_RAW.rst +++ b/README_RAW.rst @@ -14,9 +14,9 @@ :target: https://pypi.org/project/python-telegram-bot-raw/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-7.2-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-7.3-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog - :alt: Supported Bot API versions + :alt: Supported Bot API version .. image:: https://img.shields.io/pypi/dm/python-telegram-bot-raw :target: https://pypistats.org/packages/python-telegram-bot-raw @@ -85,7 +85,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **7.2** are supported. +All types and methods of the Telegram Bot API **7.3** are supported. Installing ========== diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 3aa5506bdbf..13bface7d25 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,5 +1,5 @@ -sphinx==7.2.6 -furo==2024.1.29 +sphinx==7.3.7 +furo==2024.5.6 furo-sphinx-search @ git+https://github.com/harshil21/furo-sphinx-search@v0.2.0.1 sphinx-paramlinks==0.6.0 sphinxcontrib-mermaid==0.9.2 diff --git a/docs/source/conf.py b/docs/source/conf.py index 92388805993..a50e3dbdb05 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,9 +20,9 @@ # built documents. # # The short X.Y version. -version = "21.1.1" # telegram.__version__[:3] +version = "21.2" # telegram.__version__[:3] # The full version, including alpha/beta/rc tags. -release = "21.1.1" # telegram.__version__ +release = "21.2" # telegram.__version__ # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = "6.1.3" @@ -67,7 +67,9 @@ master_doc = "index" # Global substitutions -rst_prolog = (Path.cwd() / "../substitutions/global.rst").read_text(encoding="utf-8") +rst_prolog = "" +for file in Path.cwd().glob("../substitutions/*.rst"): + rst_prolog += "\n" + file.read_text(encoding="utf-8") # -- Extension settings ------------------------------------------------ napoleon_use_admonition_for_examples = True diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 3d78292588a..f9ac8dd6702 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -28,6 +28,16 @@ Available Types telegram.callbackquery telegram.chat telegram.chatadministratorrights + telegram.chatbackground + telegram.backgroundtype + telegram.backgroundtypefill + telegram.backgroundtypewallpaper + telegram.backgroundtypepattern + telegram.backgroundtypechattheme + telegram.backgroundfill + telegram.backgroundfillsolid + telegram.backgroundfillgradient + telegram.backgroundfillfreeformgradient telegram.chatboost telegram.chatboostadded telegram.chatboostremoved @@ -36,6 +46,7 @@ Available Types telegram.chatboostsourcegiveaway telegram.chatboostsourcepremium telegram.chatboostupdated + telegram.chatfullinfo telegram.chatinvitelink telegram.chatjoinrequest telegram.chatlocation @@ -77,6 +88,7 @@ Available Types telegram.inputmediadocument telegram.inputmediaphoto telegram.inputmediavideo + telegram.inputpolloption telegram.inputsticker telegram.keyboardbutton telegram.keyboardbuttonpolltype diff --git a/docs/source/telegram.backgroundfill.rst b/docs/source/telegram.backgroundfill.rst new file mode 100644 index 00000000000..0c7c03cb737 --- /dev/null +++ b/docs/source/telegram.backgroundfill.rst @@ -0,0 +1,8 @@ +BackgroundFill +============== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFill + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundfillfreeformgradient.rst b/docs/source/telegram.backgroundfillfreeformgradient.rst new file mode 100644 index 00000000000..663c15e8181 --- /dev/null +++ b/docs/source/telegram.backgroundfillfreeformgradient.rst @@ -0,0 +1,8 @@ +BackgroundFillFreeformGradient +============================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFillFreeformGradient + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundfillgradient.rst b/docs/source/telegram.backgroundfillgradient.rst new file mode 100644 index 00000000000..313d4bd6468 --- /dev/null +++ b/docs/source/telegram.backgroundfillgradient.rst @@ -0,0 +1,8 @@ +BackgroundFillGradient +====================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFillGradient + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundfillsolid.rst b/docs/source/telegram.backgroundfillsolid.rst new file mode 100644 index 00000000000..5130de5f988 --- /dev/null +++ b/docs/source/telegram.backgroundfillsolid.rst @@ -0,0 +1,8 @@ +BackgroundFillSolid +=================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFillSolid + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtype.rst b/docs/source/telegram.backgroundtype.rst new file mode 100644 index 00000000000..08e9dc0222a --- /dev/null +++ b/docs/source/telegram.backgroundtype.rst @@ -0,0 +1,8 @@ +BackgroundType +============== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundType + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypechattheme.rst b/docs/source/telegram.backgroundtypechattheme.rst new file mode 100644 index 00000000000..1d26bde2076 --- /dev/null +++ b/docs/source/telegram.backgroundtypechattheme.rst @@ -0,0 +1,8 @@ +BackgroundTypeChatTheme +======================= + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypeChatTheme + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypefill.rst b/docs/source/telegram.backgroundtypefill.rst new file mode 100644 index 00000000000..636ff3d7ee2 --- /dev/null +++ b/docs/source/telegram.backgroundtypefill.rst @@ -0,0 +1,8 @@ +BackgroundTypeFill +================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypeFill + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypepattern.rst b/docs/source/telegram.backgroundtypepattern.rst new file mode 100644 index 00000000000..7b14d52bf46 --- /dev/null +++ b/docs/source/telegram.backgroundtypepattern.rst @@ -0,0 +1,8 @@ +BackgroundTypePattern +===================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypePattern + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypewallpaper.rst b/docs/source/telegram.backgroundtypewallpaper.rst new file mode 100644 index 00000000000..143c042553b --- /dev/null +++ b/docs/source/telegram.backgroundtypewallpaper.rst @@ -0,0 +1,8 @@ +BackgroundTypeWallpaper +======================= + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypeWallpaper + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatbackground.rst b/docs/source/telegram.chatbackground.rst new file mode 100644 index 00000000000..6f43b27fb80 --- /dev/null +++ b/docs/source/telegram.chatbackground.rst @@ -0,0 +1,8 @@ +ChatBackground +============== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.ChatBackground + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatfullinfo.rst b/docs/source/telegram.chatfullinfo.rst new file mode 100644 index 00000000000..f15dbeedaa1 --- /dev/null +++ b/docs/source/telegram.chatfullinfo.rst @@ -0,0 +1,6 @@ +ChatFullInfo +============ + +.. autoclass:: telegram.ChatFullInfo + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpolloption.rst b/docs/source/telegram.inputpolloption.rst new file mode 100644 index 00000000000..51a2aab5a3b --- /dev/null +++ b/docs/source/telegram.inputpolloption.rst @@ -0,0 +1,6 @@ +InputPollOption +=============== + +.. autoclass:: telegram.InputPollOption + :members: + :show-inheritance: diff --git a/docs/substitutions/application.rst b/docs/substitutions/application.rst new file mode 100644 index 00000000000..45643304451 --- /dev/null +++ b/docs/substitutions/application.rst @@ -0,0 +1 @@ +.. |app_run_shutdown| replace:: The app will shut down when :exc:`KeyboardInterrupt` or :exc:`SystemExit` is raised. This also works from within handlers, error handlers and jobs. However, using :meth:`~telegram.ext.Application.stop_running` will give a somewhat cleaner shutdown behavior than manually raising those exceptions. On unix, the app will also shut down on receiving the signals specified by \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 34c2a763798..b02870776ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,15 @@ explicit-preview-rules = true ignore = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PERF203"] select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET", "RSE", "G", "ISC", "PT", "ASYNC", "TCH", "SLOT", "PERF", "PYI", "FLY", "AIR", "RUF022", - "RUF023", "Q", "INP", "W"] + "RUF023", "Q", "INP", "W", "YTT", "DTZ", "ARG"] # Add "FURB" after it's out of preview +# Add "A (flake8-builtins)" after we drop pylint [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["B018"] -"tests/**.py" = ["RUF012", "ASYNC101"] -"docs/**.py" = ["INP001"] +"tests/**.py" = ["RUF012", "ASYNC101", "DTZ", "ARG"] +"docs/**.py" = ["INP001", "ARG"] +"examples/**.py" = ["ARG"] # PYLINT: [tool.pylint."messages control"] @@ -48,7 +50,7 @@ exclude-protected = ["_unfrozen"] # PYTEST: [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--no-success-flaky-report -rsxX" +addopts = "--no-success-flaky-report -rX" filterwarnings = [ "error", "ignore::DeprecationWarning", diff --git a/requirements-dev.txt b/requirements-dev.txt index f9900c8589e..8dab397cd0b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,9 @@ pre-commit # needed for pre-commit hooks in the git commit command # For the test suite -pytest==7.4.4 -pytest-asyncio==0.21.1 # needed because pytest doesn't come with native support for coroutines as tests -pytest-xdist==3.5.0 # xdist runs tests in parallel +pytest==8.2.0 +pytest-asyncio==0.21.2 # needed because pytest doesn't come with native support for coroutines as tests +pytest-xdist==3.6.1 # xdist runs tests in parallel flaky # Used for flaky tests (flaky decorator) beautifulsoup4 # used in test_official for parsing tg docs diff --git a/telegram/__init__.py b/telegram/__init__.py index 304425c4d61..5e0f3eaac3b 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -22,6 +22,15 @@ __all__ = ( "Animation", "Audio", + "BackgroundFill", + "BackgroundFillFreeformGradient", + "BackgroundFillGradient", + "BackgroundFillSolid", + "BackgroundType", + "BackgroundTypeChatTheme", + "BackgroundTypeFill", + "BackgroundTypePattern", + "BackgroundTypeWallpaper", "Birthdate", "Bot", "BotCommand", @@ -46,6 +55,7 @@ "CallbackQuery", "Chat", "ChatAdministratorRights", + "ChatBackground", "ChatBoost", "ChatBoostAdded", "ChatBoostRemoved", @@ -54,6 +64,7 @@ "ChatBoostSourceGiveaway", "ChatBoostSourcePremium", "ChatBoostUpdated", + "ChatFullInfo", "ChatInviteLink", "ChatJoinRequest", "ChatLocation", @@ -131,6 +142,7 @@ "InputMediaPhoto", "InputMediaVideo", "InputMessageContent", + "InputPollOption", "InputSticker", "InputTextMessageContent", "InputVenueMessageContent", @@ -258,6 +270,18 @@ from ._callbackquery import CallbackQuery from ._chat import Chat from ._chatadministratorrights import ChatAdministratorRights +from ._chatbackground import ( + BackgroundFill, + BackgroundFillFreeformGradient, + BackgroundFillGradient, + BackgroundFillSolid, + BackgroundType, + BackgroundTypeChatTheme, + BackgroundTypeFill, + BackgroundTypePattern, + BackgroundTypeWallpaper, + ChatBackground, +) from ._chatboost import ( ChatBoost, ChatBoostAdded, @@ -269,6 +293,7 @@ ChatBoostUpdated, UserChatBoosts, ) +from ._chatfullinfo import ChatFullInfo from ._chatinvitelink import ChatInviteLink from ._chatjoinrequest import ChatJoinRequest from ._chatlocation import ChatLocation @@ -403,7 +428,7 @@ from ._payment.shippingoption import ShippingOption from ._payment.shippingquery import ShippingQuery from ._payment.successfulpayment import SuccessfulPayment -from ._poll import Poll, PollAnswer, PollOption +from ._poll import InputPollOption, Poll, PollAnswer, PollOption from ._proximityalerttriggered import ProximityAlertTriggered from ._reaction import ReactionCount, ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote diff --git a/telegram/_birthdate.py b/telegram/_birthdate.py index 23c3ebc4764..06caf67d5ec 100644 --- a/telegram/_birthdate.py +++ b/telegram/_birthdate.py @@ -17,7 +17,7 @@ # 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 object that represents a Telegram Birthday.""" -from datetime import datetime +from datetime import date from typing import Optional from telegram._telegramobject import TelegramObject @@ -26,7 +26,7 @@ class Birthdate(TelegramObject): """ - This object represents a user's birthday. + This object describes the birthdate of a user. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`day`, and :attr:`month` are equal. @@ -70,19 +70,23 @@ def __init__( self._freeze() - def to_date(self, year: Optional[int] = None) -> datetime: - """Return the birthdate as a datetime object. + def to_date(self, year: Optional[int] = None) -> date: + """Return the birthdate as a date object. + + .. versionchanged:: 21.2 + Now returns a :obj:`datetime.date` object instead of a :obj:`datetime.datetime` object, + as was originally intended. Args: year (:obj:`int`, optional): The year to use. Required, if the :attr:`year` was not present. Returns: - :obj:`datetime.datetime`: The birthdate as a datetime object. + :obj:`datetime.date`: The birthdate as a date object. """ if self.year is None and year is None: raise ValueError( "The `year` argument is required if the `year` attribute was not present." ) - return datetime(year or self.year, self.month, self.day) # type: ignore[arg-type] + return date(year or self.year, self.month, self.day) # type: ignore[arg-type] diff --git a/telegram/_bot.py b/telegram/_bot.py index 8bb4af23de7..cf08284c7fe 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -58,9 +58,9 @@ from telegram._botdescription import BotDescription, BotShortDescription from telegram._botname import BotName from telegram._business import BusinessConnection -from telegram._chat import Chat from telegram._chatadministratorrights import ChatAdministratorRights from telegram._chatboost import UserChatBoosts +from telegram._chatfullinfo import ChatFullInfo from telegram._chatinvitelink import ChatInviteLink from telegram._chatmember import ChatMember from telegram._chatpermissions import ChatPermissions @@ -84,7 +84,7 @@ from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId -from telegram._poll import Poll +from telegram._poll import InputPollOption, Poll from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram._reply import ReplyParameters from telegram._sentwebappmessage import SentWebAppMessage @@ -524,7 +524,10 @@ def name(self) -> str: @classmethod def _warn( - cls, message: str, category: Type[Warning] = PTBUserWarning, stacklevel: int = 0 + cls, + message: Union[str, PTBUserWarning], + category: Type[Warning] = PTBUserWarning, + stacklevel: int = 0, ) -> None: """Convenience method to issue a warning. This method is here mostly to make it easier for ExtBot to add 1 level to all warning calls. @@ -837,7 +840,6 @@ async def do_api_request( f"Please use 'Bot.{endpoint}' instead of " f"'Bot.do_api_request(\"{endpoint}\", ...)'" ), - PTBDeprecationWarning, stacklevel=2, ) @@ -2287,9 +2289,10 @@ async def send_voice( """ Use this method to send audio files, if you want Telegram clients to display the file as a playable voice message. For this to work, your audio must be in an ``.ogg`` file - encoded with OPUS (other formats may be sent as Audio or Document). Bots can currently - send voice messages of up to :tg-const:`telegram.constants.FileSizeLimit.FILESIZE_UPLOAD` - in size, this limit may be changed in the future. + encoded with OPUS , or in .MP3 format, or in .M4A format (other formats may be sent as + :class:`~telegram.Audio` or :class:`~telegram.Document`). Bots can currently send voice + messages of up to :tg-const:`telegram.constants.FileSizeLimit.FILESIZE_UPLOAD` in size, + this limit may be changed in the future. Note: To use this method, the file must have the type :mimetype:`audio/ogg` and be no more @@ -2610,7 +2613,9 @@ async def send_location( live_period (:obj:`int`, optional): Period in seconds for which the location will be updated, should be between :tg-const:`telegram.constants.LocationLimit.MIN_LIVE_PERIOD` and - :tg-const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD`. + :tg-const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD`, or + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live + locations that can be edited indefinitely. heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.constants.LocationLimit.MIN_HEADING` and @@ -2720,6 +2725,7 @@ async def edit_message_live_location( horizontal_accuracy: Optional[float] = None, heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, + live_period: Optional[int] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2758,6 +2764,15 @@ async def edit_message_live_location( if specified. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new inline keyboard. + live_period (:obj:`int`, optional): New period in seconds during which the location + can be updated, starting from the message send date. If + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` is specified, + then the location can be updated forever. Otherwise, the new value must not exceed + the current ``live_period`` by more than a day, and the live location expiration + date must remain within the next 90 days. If not specified, then ``live_period`` + remains unchanged + + .. versionadded:: 21.2. Keyword Args: location (:class:`telegram.Location`, optional): The location to send. @@ -2790,6 +2805,7 @@ async def edit_message_live_location( "horizontal_accuracy": horizontal_accuracy, "heading": heading, "proximity_alert_radius": proximity_alert_radius, + "live_period": live_period, } return await self._send_message( @@ -4195,10 +4211,12 @@ async def get_updates( except NotImplementedError: arg_read_timeout = 2 self._warn( - f"The class {self._request[0].__class__.__name__} does not override " - "the property `read_timeout`. Overriding this property will be mandatory in " - "future versions. Using 2 seconds as fallback.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.7", + f"The class {self._request[0].__class__.__name__} does not override " + "the property `read_timeout`. Overriding this property will be mandatory " + "in future versions. Using 2 seconds as fallback.", + ), stacklevel=2, ) @@ -4434,16 +4452,19 @@ async def get_chat( connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: Optional[JSONDict] = None, - ) -> Chat: + ) -> ChatFullInfo: """ Use this method to get up to date information about the chat (current name of the user for one-on-one conversations, current username of a user, group or channel, etc.). + .. versionchanged:: 21.2 + In accordance to Bot API 7.3, this method now returns a :class:`telegram.ChatFullInfo`. + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| Returns: - :class:`telegram.Chat` + :class:`telegram.ChatFullInfo` Raises: :class:`telegram.error.TelegramError` @@ -4461,7 +4482,7 @@ async def get_chat( api_kwargs=api_kwargs, ) - return Chat.de_json(result, self) # type: ignore[return-value] + return ChatFullInfo.de_json(result, self) # type: ignore[return-value] async def get_chat_administrators( self, @@ -5311,7 +5332,8 @@ async def promote_chat_member( .. versionadded:: 20.6 can_edit_stories (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can - edit stories posted by other users. + edit stories posted by other users, post stories to the chat page, pin chat + stories, and access the chat's story archive .. versionadded:: 20.6 can_delete_stories (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can @@ -6312,7 +6334,6 @@ async def create_new_sticker_set( name: str, title: str, stickers: Sequence["InputSticker"], - sticker_format: Optional[str] = None, sticker_type: Optional[str] = None, needs_repainting: Optional[bool] = None, *, @@ -6339,6 +6360,9 @@ async def create_new_sticker_set( Removed the deprecated parameters mentioned above and adjusted the order of the parameters. + .. versionremoved:: 21.2 + Removed the deprecated parameter ``sticker_format``. + Args: user_id (:obj:`int`): User identifier of created sticker set owner. name (:obj:`str`): Short name of sticker set, to be used in t.me/addstickers/ URLs @@ -6358,16 +6382,6 @@ async def create_new_sticker_set( .. versionadded:: 20.2 - sticker_format (:obj:`str`): Format of stickers in the set, must be one of - :attr:`~telegram.constants.StickerFormat.STATIC`, - :attr:`~telegram.constants.StickerFormat.ANIMATED` or - :attr:`~telegram.constants.StickerFormat.VIDEO`. - - .. versionadded:: 20.2 - - .. deprecated:: 21.1 - Use :paramref:`telegram.InputSticker.format` instead. - sticker_type (:obj:`str`, optional): Type of stickers in the set, pass :attr:`telegram.Sticker.REGULAR` or :attr:`telegram.Sticker.MASK`, or :attr:`telegram.Sticker.CUSTOM_EMOJI`. By default, a regular sticker set is created @@ -6387,20 +6401,11 @@ async def create_new_sticker_set( Raises: :class:`telegram.error.TelegramError` """ - if sticker_format is not None: - warn( - "The parameter `sticker_format` is deprecated. Use the parameter" - " `InputSticker.format` in the parameter `stickers` instead.", - stacklevel=2, - category=PTBDeprecationWarning, - ) - data: JSONDict = { "user_id": user_id, "name": name, "title": title, "stickers": stickers, - "sticker_format": sticker_format, "sticker_type": sticker_type, "needs_repainting": needs_repainting, } @@ -6815,7 +6820,7 @@ async def send_poll( self, chat_id: Union[int, str], question: str, - options: Sequence[str], + options: Sequence[Union[str, "InputPollOption"]], is_anonymous: Optional[bool] = None, type: Optional[str] = None, # pylint: disable=redefined-builtin allows_multiple_answers: Optional[bool] = None, @@ -6832,6 +6837,8 @@ async def send_poll( message_thread_id: Optional[int] = None, reply_parameters: Optional["ReplyParameters"] = None, business_connection_id: Optional[str] = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Optional[Sequence["MessageEntity"]] = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: Optional[int] = None, @@ -6848,14 +6855,20 @@ async def send_poll( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`- :tg-const:`telegram.Poll.MAX_QUESTION_LENGTH` characters. - options (Sequence[:obj:`str`]): Sequence of answer options, + options (Sequence[:obj:`str` | :class:`telegram.InputPollOption`]): Sequence of :tg-const:`telegram.Poll.MIN_OPTION_NUMBER`- - :tg-const:`telegram.Poll.MAX_OPTION_NUMBER` strings + :tg-const:`telegram.Poll.MAX_OPTION_NUMBER` answer options. Each option may either + be a string with :tg-const:`telegram.Poll.MIN_OPTION_LENGTH`- - :tg-const:`telegram.Poll.MAX_OPTION_LENGTH` characters each. + :tg-const:`telegram.Poll.MAX_OPTION_LENGTH` characters or an + :class:`~telegram.InputPollOption` object. Strings are converted to + :class:`~telegram.InputPollOption` objects automatically. .. versionchanged:: 20.0 |sequenceargs| + + .. versionchanged:: 21.2 + Bot API 7.3 adds support for :class:`~telegram.InputPollOption` objects. is_anonymous (:obj:`bool`, optional): :obj:`True`, if the poll needs to be anonymous, defaults to :obj:`True`. type (:obj:`str`, optional): Poll type, :tg-const:`telegram.Poll.QUIZ` or @@ -6910,6 +6923,16 @@ async def send_poll( business_connection_id (:obj:`str`, optional): |business_id_str| .. versionadded:: 21.1 + question_parse_mode (:obj:`str`, optional): Mode for parsing entities in the question. + See the constants in :class:`telegram.constants.ParseMode` for the available modes. + Currently, only custom emoji entities are allowed. + + .. versionadded:: 21.2 + question_entities (Sequence[:class:`telegram.Message`], optional): Special entities + that appear in the poll :paramref:`question`. It can be specified instead of + :paramref:`question_parse_mode`. + + .. versionadded:: 21.2 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -6941,7 +6964,10 @@ async def send_poll( data: JSONDict = { "chat_id": chat_id, "question": question, - "options": options, + "options": [ + InputPollOption(option) if isinstance(option, str) else option + for option in options + ], "explanation_parse_mode": explanation_parse_mode, "is_anonymous": is_anonymous, "type": type, @@ -6952,6 +6978,8 @@ async def send_poll( "explanation_entities": explanation_entities, "open_period": open_period, "close_date": close_date, + "question_parse_mode": question_parse_mode, + "question_entities": question_entities, } return await self._send_message( @@ -8867,7 +8895,7 @@ async def replace_sticker_in_set( api_kwargs=api_kwargs, ) - def to_dict(self, recursive: bool = True) -> JSONDict: + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} diff --git a/telegram/_business.py b/telegram/_business.py index b15fd260b06..ab1fdb91b51 100644 --- a/telegram/_business.py +++ b/telegram/_business.py @@ -189,7 +189,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessMess class BusinessIntro(TelegramObject): """ - This object represents the intro of a business account. + This object contains information about the start page settings of a Telegram Business account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their @@ -246,7 +246,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessIntr class BusinessLocation(TelegramObject): """ - This object represents the location of a business account. + This object contains information about the location of a Telegram Business account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their @@ -298,7 +298,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BusinessLoca class BusinessOpeningHoursInterval(TelegramObject): """ - This object represents the time intervals describing business opening hours. + This object describes an interval of time during which a business is open. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their @@ -390,7 +390,7 @@ def closing_time(self) -> Tuple[int, int, int]: class BusinessOpeningHours(TelegramObject): """ - This object represents the opening hours of a business account. + This object describes the opening hours of a business. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their diff --git a/telegram/_callbackquery.py b/telegram/_callbackquery.py index 8fbd0013a4e..3df7089c997 100644 --- a/telegram/_callbackquery.py +++ b/telegram/_callbackquery.py @@ -461,6 +461,7 @@ async def edit_message_live_location( horizontal_accuracy: Optional[float] = None, heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, + live_period: Optional[int] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -509,6 +510,7 @@ async def edit_message_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, + live_period=live_period, chat_id=None, message_id=None, ) @@ -525,6 +527,7 @@ async def edit_message_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, + live_period=live_period, ) async def stop_message_live_location( diff --git a/telegram/_chat.py b/telegram/_chat.py index 94991c9b391..b94d006e141 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -20,7 +20,7 @@ """This module contains an object that represents a Telegram Chat.""" from datetime import datetime from html import escape -from typing import TYPE_CHECKING, Final, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Final, Optional, Sequence, Tuple, Union from telegram import constants from telegram._birthdate import Birthdate @@ -36,9 +36,11 @@ from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.warnings import warn from telegram.helpers import escape_markdown from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import ( @@ -57,6 +59,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPollOption, LabeledPrice, LinkPreviewOptions, Location, @@ -74,6 +77,45 @@ ) +_deprecated_attrs = ( + "accent_color_id", + "active_usernames", + "available_reactions", + "background_custom_emoji_id", + "bio", + "birthdate", + "business_intro", + "business_location", + "business_opening_hours", + "can_set_sticker_set", + "custom_emoji_sticker_set_name", + "description", + "emoji_status_custom_emoji_id", + "emoji_status_expiration_date", + "has_aggressive_anti_spam_enabled", + "has_hidden_members", + "has_private_forwards", + "has_protected_content", + "has_restricted_voice_and_video_messages", + "has_visible_history", + "invite_link", + "join_by_request", + "join_to_send_messages", + "linked_chat_id", + "location", + "message_auto_delete_time", + "permissions", + "personal_chat", + "photo", + "pinned_message", + "profile_accent_color_id", + "profile_background_custom_emoji_id", + "slow_mode_delay", + "sticker_set_name", + "unrestrict_boost_count", +) + + class Chat(TelegramObject): """This object represents a chat. @@ -107,62 +149,134 @@ class Chat(TelegramObject): last_name (:obj:`str`, optional): Last name of the other party in a private chat. photo (:class:`telegram.ChatPhoto`, optional): Chat photo. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. bio (:obj:`str`, optional): Bio of the other party in a private chat. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_private_forwards (:obj:`bool`, optional): :obj:`True`, if privacy settings of the other party in the private chat allows to use ``tg://user?id=`` links only in chats with the user. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.9 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. description (:obj:`str`, optional): Description, for groups, supergroups and channel chats. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. invite_link (:obj:`str`, optional): Primary invite link, for groups, supergroups and channel. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. pinned_message (:class:`telegram.Message`, optional): The most recent pinned message (by sending date). Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, for groups and supergroups. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. slow_mode_delay (:obj:`int`, optional): For supergroups, the minimum allowed delay between consecutive messages sent by each unprivileged user. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. message_auto_delete_time (:obj:`int`, optional): The time after which all messages sent to the chat will be automatically deleted; in seconds. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.4 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_protected_content (:obj:`bool`, optional): :obj:`True`, if messages from the chat can't be forwarded to other chats. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.9 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_visible_history (:obj:`bool`, optional): :obj:`True`, if new chat members will have access to old messages; available only to chat administrators. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. sticker_set_name (:obj:`str`, optional): For supergroups, name of group sticker set. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. can_set_sticker_set (:obj:`bool`, optional): :obj:`True`, if the bot can change group the sticker set. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. linked_chat_id (:obj:`int`, optional): Unique identifier for the linked chat, i.e. the discussion group identifier for a channel and vice versa; for supergroups and channel chats. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. location (:class:`telegram.ChatLocation`, optional): For supergroups, the location to which the supergroup is connected. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. join_to_send_messages (:obj:`bool`, optional): :obj:`True`, if users need to join the supergroup before they can send messages. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. join_by_request (:obj:`bool`, optional): :obj:`True`, if all users directly joining the - supergroup need to be approved by supergroup administrators. Returned only in - :meth:`telegram.Bot.get_chat`. + supergroup without using an invite link need to be approved by supergroup + administrators. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_restricted_voice_and_video_messages (:obj:`bool`, optional): :obj:`True`, if the privacy settings of the other party restrict sending voice and video note messages in the private chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. is_forum (:obj:`bool`, optional): :obj:`True`, if the supergroup chat is a forum (has topics_ enabled). @@ -173,27 +287,47 @@ class Chat(TelegramObject): only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. business_intro (:class:`telegram.BusinessIntro`, optional): For private chats with business accounts, the intro of the business. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. business_location (:class:`telegram.BusinessLocation`, optional): For private chats with business accounts, the location of the business. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. business_opening_hours (:class:`telegram.BusinessOpeningHours`, optional): For private chats with business accounts, the opening hours of the business. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. available_reactions (Sequence[:class:`telegram.ReactionType`], optional): List of available reactions allowed in the chat. If omitted, then all of :const:`telegram.constants.ReactionEmoji` are allowed. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. accent_color_id (:obj:`int`, optional): Identifier of the :class:`accent color ` for the chat name and backgrounds of the chat photo, reply header, and link preview. See `accent colors`_ @@ -201,62 +335,110 @@ class Chat(TelegramObject): :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. background_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji chosen by the chat for the reply header and link preview background. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. profile_accent_color_id (:obj:`int`, optional): Identifier of the :class:`accent color ` for the chat's profile background. See profile `accent colors`_ for more details. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. profile_background_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of the emoji chosen by the chat for its profile background. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji status of the chat or the other party in a private chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. emoji_status_expiration_date (:class:`datetime.datetime`, optional): Expiration date of emoji status of the chat or the other party in a private chat, in seconds. Returned only in :meth:`telegram.Bot.get_chat`. |datetime_localization| .. versionadded:: 20.5 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_aggressive_anti_spam_enabled (:obj:`bool`, optional): :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_hidden_members (:obj:`bool`, optional): :obj:`True`, if non-administrators can only get the list of bots and administrators in the chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. unrestrict_boost_count (:obj:`int`, optional): For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. custom_emoji_sticker_set_name (:obj:`str`, optional): For supergroups, the name of the group's custom emoji sticker set. Custom emoji from this set can be used by all users and bots in the group. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. birthdate (:obj:`telegram.Birthdate`, optional): For private chats, the date of birth of the user. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. personal_chat (:obj:`telegram.Chat`, optional): For private chats, the personal channel of the user. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. + Attributes: id (:obj:`int`): Unique identifier for this chat. This number may be greater than 32 bits and some programming languages may have difficulty/silent defects in interpreting it. @@ -271,62 +453,134 @@ class Chat(TelegramObject): last_name (:obj:`str`): Optional. Last name of the other party in a private chat. photo (:class:`telegram.ChatPhoto`): Optional. Chat photo. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. bio (:obj:`str`): Optional. Bio of the other party in a private chat. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_private_forwards (:obj:`bool`): Optional. :obj:`True`, if privacy settings of the other party in the private chat allows to use ``tg://user?id=`` links only in chats with the user. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.9 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. description (:obj:`str`): Optional. Description, for groups, supergroups and channel chats. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. invite_link (:obj:`str`): Optional. Primary invite link, for groups, supergroups and channel. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. pinned_message (:class:`telegram.Message`): Optional. The most recent pinned message (by sending date). Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, for groups and supergroups. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. slow_mode_delay (:obj:`int`): Optional. For supergroups, the minimum allowed delay between consecutive messages sent by each unprivileged user. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. message_auto_delete_time (:obj:`int`): Optional. The time after which all messages sent to the chat will be automatically deleted; in seconds. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.4 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_protected_content (:obj:`bool`): Optional. :obj:`True`, if messages from the chat can't be forwarded to other chats. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.9 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_visible_history (:obj:`bool`): Optional. :obj:`True`, if new chat members will have access to old messages; available only to chat administrators. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. sticker_set_name (:obj:`str`): Optional. For supergroups, name of Group sticker set. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. can_set_sticker_set (:obj:`bool`): Optional. :obj:`True`, if the bot can change group the sticker set. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. linked_chat_id (:obj:`int`): Optional. Unique identifier for the linked chat, i.e. the discussion group identifier for a channel and vice versa; for supergroups and channel chats. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. location (:class:`telegram.ChatLocation`): Optional. For supergroups, the location to which the supergroup is connected. Returned only in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. join_to_send_messages (:obj:`bool`): Optional. :obj:`True`, if users need to join the supergroup before they can send messages. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 - join_by_request (:obj:`bool`): Optional. :obj:`True`, if all users directly - joining the supergroup need to be approved by supergroup administrators. Returned only - in :meth:`telegram.Bot.get_chat`. + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. + join_by_request (:obj:`bool`): Optional. :obj:`True`, if all users directly joining the + supergroup without using an invite link need to be approved by supergroup + administrators. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_restricted_voice_and_video_messages (:obj:`bool`): Optional. :obj:`True`, if the privacy settings of the other party restrict sending voice and video note messages in the private chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. is_forum (:obj:`bool`): Optional. :obj:`True`, if the supergroup chat is a forum (has topics_ enabled). @@ -339,27 +593,47 @@ class Chat(TelegramObject): obtained via :meth:`~telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. business_intro (:class:`telegram.BusinessIntro`): Optional. For private chats with business accounts, the intro of the business. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. business_location (:class:`telegram.BusinessLocation`): Optional. For private chats with business accounts, the location of the business. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. business_opening_hours (:class:`telegram.BusinessOpeningHours`): Optional. For private chats with business accounts, the opening hours of the business. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. available_reactions (Tuple[:class:`telegram.ReactionType`]): Optional. List of available reactions allowed in the chat. If omitted, then all of :const:`telegram.constants.ReactionEmoji` are allowed. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. accent_color_id (:obj:`int`): Optional. Identifier of the :class:`accent color ` for the chat name and backgrounds of the chat photo, reply header, and link preview. See `accent colors`_ @@ -367,62 +641,110 @@ class Chat(TelegramObject): :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. background_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji chosen by the chat for the reply header and link preview background. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. profile_accent_color_id (:obj:`int`): Optional. Identifier of the :class:`accent color ` for the chat's profile background. See profile `accent colors`_ for more details. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. profile_background_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of the emoji chosen by the chat for its profile background. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.8 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. emoji_status_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji status of the chat or the other party in a private chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. emoji_status_expiration_date (:class:`datetime.datetime`): Optional. Expiration date of emoji status of the chat or the other party in a private chat, in seconds. Returned only in :meth:`telegram.Bot.get_chat`. |datetime_localization| .. versionadded:: 20.5 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. has_hidden_members (:obj:`bool`): Optional. :obj:`True`, if non-administrators can only get the list of bots and administrators in the chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. unrestrict_boost_count (:obj:`int`): Optional. For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. custom_emoji_sticker_set_name (:obj:`str`): Optional. For supergroups, the name of the group's custom emoji sticker set. Custom emoji from this set can be used by all users and bots in the group. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.0 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. birthdate (:obj:`telegram.Birthdate`): Optional. For private chats, the date of birth of the user. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. personal_chat (:obj:`telegram.Chat`): Optional. For private chats, the personal channel of the user. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 21.1 + .. deprecated:: 21.2 + In accordance to Bot API 7.3, this attribute will be moved to + :class:`telegram.ChatFullInfo`. + .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups .. _accent colors: https://core.telegram.org/bots/api#accent-colors """ @@ -471,7 +793,6 @@ class Chat(TelegramObject): "unrestrict_boost_count", "username", ) - SENDER: Final[str] = constants.ChatType.SENDER """:const:`telegram.constants.ChatType.SENDER` @@ -518,7 +839,7 @@ def __init__( has_aggressive_anti_spam_enabled: Optional[bool] = None, has_hidden_members: Optional[bool] = None, available_reactions: Optional[Sequence[ReactionType]] = None, - accent_color_id: Optional[int] = None, + accent_color_id: Optional[int] = None, # required in API 7.3 - Optional for back compat background_custom_emoji_id: Optional[str] = None, profile_accent_color_id: Optional[int] = None, profile_background_custom_emoji_id: Optional[str] = None, @@ -585,10 +906,34 @@ def __init__( self.business_location: Optional["BusinessLocation"] = business_location self.business_opening_hours: Optional["BusinessOpeningHours"] = business_opening_hours + if self.__class__ is Chat: + for arg in _deprecated_attrs: + if (val := object.__getattribute__(self, arg)) is not None and val != (): + warn( + PTBDeprecationWarning( + "21.2", + f"The argument `{arg}` is deprecated and will only be available via " + "`ChatFullInfo` in the future.", + ), + stacklevel=2, + ) + self._id_attrs = (self.id,) self._freeze() + def __getattribute__(self, name: str) -> Any: + if name in _deprecated_attrs and self.__class__ is Chat: + warn( + PTBDeprecationWarning( + "21.2", + f"The attribute `{name}` is deprecated and will only be accessible via " + "`ChatFullInfo` in the future.", + ), + stacklevel=2, + ) + return super().__getattribute__(name) + @property def effective_name(self) -> Optional[str]: """ @@ -658,7 +1003,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Chat"]: data["location"] = ChatLocation.de_json(data.get("location"), bot) data["available_reactions"] = ReactionType.de_list(data.get("available_reactions"), bot) data["birthdate"] = Birthdate.de_json(data.get("birthdate"), bot) - data["personal_chat"] = cls.de_json(data.get("personal_chat"), bot) + data["personal_chat"] = Chat.de_json(data.get("personal_chat"), bot) data["business_intro"] = BusinessIntro.de_json(data.get("business_intro"), bot) data["business_location"] = BusinessLocation.de_json(data.get("business_location"), bot) data["business_opening_hours"] = BusinessOpeningHours.de_json( @@ -2545,7 +2890,7 @@ async def send_voice( async def send_poll( self, question: str, - options: Sequence[str], + options: Sequence[Union[str, "InputPollOption"]], is_anonymous: Optional[bool] = None, type: Optional[str] = None, allows_multiple_answers: Optional[bool] = None, @@ -2562,6 +2907,8 @@ async def send_poll( message_thread_id: Optional[int] = None, reply_parameters: Optional["ReplyParameters"] = None, business_connection_id: Optional[str] = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Optional[Sequence["MessageEntity"]] = None, *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -2608,6 +2955,8 @@ async def send_poll( protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=business_connection_id, + question_parse_mode=question_parse_mode, + question_entities=question_entities, ) async def send_copy( diff --git a/telegram/_chatadministratorrights.py b/telegram/_chatadministratorrights.py index f2274fd8f9c..f0d0b033f62 100644 --- a/telegram/_chatadministratorrights.py +++ b/telegram/_chatadministratorrights.py @@ -80,8 +80,9 @@ class ChatAdministratorRights(TelegramObject): .. versionadded:: 20.6 .. versionchanged:: 21.0 |non_optional_story_argument| - can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit - stories posted by other users. + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive .. versionadded:: 20.6 .. versionchanged:: 21.0 @@ -128,8 +129,9 @@ class ChatAdministratorRights(TelegramObject): .. versionadded:: 20.6 .. versionchanged:: 21.0 |non_optional_story_argument| - can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit - stories posted by other users. + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive .. versionadded:: 20.6 .. versionchanged:: 21.0 diff --git a/telegram/_chatbackground.py b/telegram/_chatbackground.py new file mode 100644 index 00000000000..66b58c92a25 --- /dev/null +++ b/telegram/_chatbackground.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# 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 objects related to chat backgrounds.""" +from typing import TYPE_CHECKING, Dict, Final, Optional, Sequence, Tuple, Type + +from telegram import constants +from telegram._files.document import Document +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class BackgroundFill(TelegramObject): + """Base class for Telegram BackgroundFill Objects. It can be one of: + + * :class:`telegram.BackgroundFillSolid` + * :class:`telegram.BackgroundFillGradient` + * :class:`telegram.BackgroundFillFreeformGradient` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 21.2 + + Args: + type (:obj:`str`): Type of the background fill. Can be one of: + :attr:`~telegram.BackgroundFill.SOLID`, :attr:`~telegram.BackgroundFill.GRADIENT` + or :attr:`~telegram.BackgroundFill.FREEFORM_GRADIENT`. + + Attributes: + type (:obj:`str`): Type of the background fill. Can be one of: + :attr:`~telegram.BackgroundFill.SOLID`, :attr:`~telegram.BackgroundFill.GRADIENT` + or :attr:`~telegram.BackgroundFill.FREEFORM_GRADIENT`. + """ + + __slots__ = ("type",) + + SOLID: Final[constants.BackgroundFillType] = constants.BackgroundFillType.SOLID + """:const:`telegram.constants.BackgroundFillType.SOLID`""" + GRADIENT: Final[constants.BackgroundFillType] = constants.BackgroundFillType.GRADIENT + """:const:`telegram.constants.BackgroundFillType.GRADIENT`""" + FREEFORM_GRADIENT: Final[constants.BackgroundFillType] = ( + constants.BackgroundFillType.FREEFORM_GRADIENT + ) + """:const:`telegram.constants.BackgroundFillType.FREEFORM_GRADIENT`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.BackgroundFillType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BackgroundFill"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + _class_mapping: Dict[str, Type[BackgroundFill]] = { + cls.SOLID: BackgroundFillSolid, + cls.GRADIENT: BackgroundFillGradient, + cls.FREEFORM_GRADIENT: BackgroundFillFreeformGradient, + } + + if cls is BackgroundFill and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class BackgroundFillSolid(BackgroundFill): + """ + The background is filled using the selected color. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`color` is equal. + + .. versionadded:: 21.2 + + Args: + color (:obj:`int`): The color of the background fill in the `RGB24` format. + + Attributes: + type (:obj:`str`): Type of the background fill. Always + :attr:`~telegram.BackgroundFill.SOLID`. + color (:obj:`int`): The color of the background fill in the `RGB24` format. + """ + + __slots__ = ("color",) + + def __init__( + self, + color: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.SOLID, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.color: int = color + + self._id_attrs = (self.color,) + + +class BackgroundFillGradient(BackgroundFill): + """ + The background is a gradient fill. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`top_color`, :attr:`bottom_color` + and :attr:`rotation_angle` are equal. + + .. versionadded:: 21.2 + + Args: + top_color (:obj:`int`): Top color of the gradient in the `RGB24` format. + bottom_color (:obj:`int`): Bottom color of the gradient in the `RGB24` format. + rotation_angle (:obj:`int`): Clockwise rotation angle of the background + fill in degrees; + 0-:tg-const:`telegram.constants.BackgroundFillLimit.MAX_ROTATION_ANGLE`. + + + Attributes: + type (:obj:`str`): Type of the background fill. Always + :attr:`~telegram.BackgroundFill.GRADIENT`. + top_color (:obj:`int`): Top color of the gradient in the `RGB24` format. + bottom_color (:obj:`int`): Bottom color of the gradient in the `RGB24` format. + rotation_angle (:obj:`int`): Clockwise rotation angle of the background + fill in degrees; + 0-:tg-const:`telegram.constants.BackgroundFillLimit.MAX_ROTATION_ANGLE`. + """ + + __slots__ = ("bottom_color", "rotation_angle", "top_color") + + def __init__( + self, + top_color: int, + bottom_color: int, + rotation_angle: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.GRADIENT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.top_color: int = top_color + self.bottom_color: int = bottom_color + self.rotation_angle: int = rotation_angle + + self._id_attrs = (self.top_color, self.bottom_color, self.rotation_angle) + + +class BackgroundFillFreeformGradient(BackgroundFill): + """ + The background is a freeform gradient that rotates after every message in the chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`colors` is equal. + + .. versionadded:: 21.2 + + Args: + colors (Sequence[:obj:`int`]): A list of the 3 or 4 base colors that are used to + generate the freeform gradient in the `RGB24` format + + Attributes: + type (:obj:`str`): Type of the background fill. Always + :attr:`~telegram.BackgroundFill.FREEFORM_GRADIENT`. + colors (Sequence[:obj:`int`]): A list of the 3 or 4 base colors that are used to + generate the freeform gradient in the `RGB24` format + """ + + __slots__ = ("colors",) + + def __init__( + self, + colors: Sequence[int], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.FREEFORM_GRADIENT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.colors: Tuple[int, ...] = parse_sequence_arg(colors) + + self._id_attrs = (self.colors,) + + +class BackgroundType(TelegramObject): + """Base class for Telegram BackgroundType Objects. It can be one of: + + * :class:`telegram.BackgroundTypeFill` + * :class:`telegram.BackgroundTypeWallpaper` + * :class:`telegram.BackgroundTypePattern` + * :class:`telegram.BackgroundTypeChatTheme`. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 21.2 + + Args: + type (:obj:`str`): Type of the background. Can be one of: + :attr:`~telegram.BackgroundType.FILL`, :attr:`~telegram.BackgroundType.WALLPAPER` + :attr:`~telegram.BackgroundType.PATTERN` or + :attr:`~telegram.BackgroundType.CHAT_THEME`. + + Attributes: + type (:obj:`str`): Type of the background. Can be one of: + :attr:`~telegram.BackgroundType.FILL`, :attr:`~telegram.BackgroundType.WALLPAPER` + :attr:`~telegram.BackgroundType.PATTERN` or + :attr:`~telegram.BackgroundType.CHAT_THEME`. + + """ + + __slots__ = ("type",) + + FILL: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.FILL + """:const:`telegram.constants.BackgroundTypeType.FILL`""" + WALLPAPER: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.WALLPAPER + """:const:`telegram.constants.BackgroundTypeType.WALLPAPER`""" + PATTERN: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.PATTERN + """:const:`telegram.constants.BackgroundTypeType.PATTERN`""" + CHAT_THEME: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.CHAT_THEME + """:const:`telegram.constants.BackgroundTypeType.CHAT_THEME`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.BackgroundTypeType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BackgroundType"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + _class_mapping: Dict[str, Type[BackgroundType]] = { + cls.FILL: BackgroundTypeFill, + cls.WALLPAPER: BackgroundTypeWallpaper, + cls.PATTERN: BackgroundTypePattern, + cls.CHAT_THEME: BackgroundTypeChatTheme, + } + + if cls is BackgroundType and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + if "fill" in data: + data["fill"] = BackgroundFill.de_json(data.get("fill"), bot) + + if "document" in data: + data["document"] = Document.de_json(data.get("document"), bot) + + return super().de_json(data=data, bot=bot) + + +class BackgroundTypeFill(BackgroundType): + """ + The background is automatically filled based on the selected colors. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`fill` and :attr:`dark_theme_dimming` are equal. + + .. versionadded:: 21.2 + + Args: + fill (:obj:`telegram.BackgroundFill`): The background fill. + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.FILL`. + fill (:obj:`telegram.BackgroundFill`): The background fill. + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + """ + + __slots__ = ("dark_theme_dimming", "fill") + + def __init__( + self, + fill: BackgroundFill, + dark_theme_dimming: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.FILL, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.fill: BackgroundFill = fill + self.dark_theme_dimming: int = dark_theme_dimming + + self._id_attrs = (self.fill, self.dark_theme_dimming) + + +class BackgroundTypeWallpaper(BackgroundType): + """ + The background is a wallpaper in the `JPEG` format. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`document` and :attr:`dark_theme_dimming` are equal. + + .. versionadded:: 21.2 + + Args: + document (:obj:`telegram.Document`): Document with the wallpaper + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + is_blurred (:obj:`bool`, optional): :obj:`True`, if the wallpaper is downscaled to fit + in a 450x450 square and then box-blurred with radius 12 + is_moving (:obj:`bool`, optional): :obj:`True`, if the background moves slightly + when the device is tilted + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.WALLPAPER`. + document (:obj:`telegram.Document`): Document with the wallpaper + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + is_blurred (:obj:`bool`): Optional. :obj:`True`, if the wallpaper is downscaled to fit + in a 450x450 square and then box-blurred with radius 12 + is_moving (:obj:`bool`): Optional. :obj:`True`, if the background moves slightly + when the device is tilted + """ + + __slots__ = ("dark_theme_dimming", "document", "is_blurred", "is_moving") + + def __init__( + self, + document: Document, + dark_theme_dimming: int, + is_blurred: Optional[bool] = None, + is_moving: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.WALLPAPER, api_kwargs=api_kwargs) + + with self._unfrozen(): + # Required + self.document: Document = document + self.dark_theme_dimming: int = dark_theme_dimming + # Optionals + self.is_blurred: Optional[bool] = is_blurred + self.is_moving: Optional[bool] = is_moving + + self._id_attrs = (self.document, self.dark_theme_dimming) + + +class BackgroundTypePattern(BackgroundType): + """ + The background is a `PNG` or `TGV` (gzipped subset of `SVG` with `MIME` type + `"application/x-tgwallpattern"`) pattern to be combined with the background fill + chosen by the user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`document` and :attr:`fill` and :attr:`intensity` are equal. + + .. versionadded:: 21.2 + + Args: + document (:obj:`telegram.Document`): Document with the pattern. + fill (:obj:`telegram.BackgroundFill`): The background fill that is combined with + the pattern. + intensity (:obj:`int`): Intensity of the pattern when it is shown above the filled + background; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_INTENSITY`. + is_inverted (:obj:`int`, optional): :obj:`True`, if the background fill must be applied + only to the pattern itself. All other pixels are black in this case. For dark + themes only. + is_moving (:obj:`bool`, optional): :obj:`True`, if the background moves slightly + when the device is tilted. + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.PATTERN`. + document (:obj:`telegram.Document`): Document with the pattern. + fill (:obj:`telegram.BackgroundFill`): The background fill that is combined with + the pattern. + intensity (:obj:`int`): Intensity of the pattern when it is shown above the filled + background; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_INTENSITY`. + is_inverted (:obj:`int`): Optional. :obj:`True`, if the background fill must be applied + only to the pattern itself. All other pixels are black in this case. For dark + themes only. + is_moving (:obj:`bool`): Optional. :obj:`True`, if the background moves slightly + when the device is tilted. + """ + + __slots__ = ( + "document", + "fill", + "intensity", + "is_inverted", + "is_moving", + ) + + def __init__( + self, + document: Document, + fill: BackgroundFill, + intensity: int, + is_inverted: Optional[bool] = None, + is_moving: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.PATTERN, api_kwargs=api_kwargs) + + with self._unfrozen(): + # Required + self.document: Document = document + self.fill: BackgroundFill = fill + self.intensity: int = intensity + # Optionals + self.is_inverted: Optional[bool] = is_inverted + self.is_moving: Optional[bool] = is_moving + + self._id_attrs = (self.document, self.fill, self.intensity) + + +class BackgroundTypeChatTheme(BackgroundType): + """ + The background is taken directly from a built-in chat theme. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`theme_name` is equal. + + .. versionadded:: 21.2 + + Args: + theme_name (:obj:`str`): Name of the chat theme, which is usually an emoji. + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.CHAT_THEME`. + theme_name (:obj:`str`): Name of the chat theme, which is usually an emoji. + """ + + __slots__ = ("theme_name",) + + def __init__( + self, + theme_name: str, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.CHAT_THEME, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.theme_name: str = theme_name + + self._id_attrs = (self.theme_name,) + + +class ChatBackground(TelegramObject): + """ + This object represents a chat background. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 21.2 + + Args: + type (:obj:`telegram.BackgroundType`): Type of the background. + + Attributes: + type (:obj:`telegram.BackgroundType`): Type of the background. + """ + + __slots__ = ("type",) + + def __init__( + self, + type: BackgroundType, # pylint: disable=redefined-builtin + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: BackgroundType = type + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBackground"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["type"] = BackgroundType.de_json(data.get("type"), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py new file mode 100644 index 00000000000..9b100830bfc --- /dev/null +++ b/telegram/_chatfullinfo.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# 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 object that represents a Telegram ChatFullInfo.""" +from datetime import datetime +from typing import TYPE_CHECKING, Optional, Sequence + +from telegram._birthdate import Birthdate +from telegram._chat import Chat +from telegram._chatlocation import ChatLocation +from telegram._chatpermissions import ChatPermissions +from telegram._files.chatphoto import ChatPhoto +from telegram._reaction import ReactionType +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import BusinessIntro, BusinessLocation, BusinessOpeningHours, Message + + +class ChatFullInfo(Chat): + """ + This object contains full information about a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`~telegram.Chat.id` is equal. + + Caution: + This class is a subclass of :class:`telegram.Chat` and inherits all attributes and methods + for backwards compatibility. In the future, this class will *NOT* inherit from + :class:`telegram.Chat`. + + .. seealso:: + All arguments and attributes can be found in :class:`telegram.Chat`. + + .. versionadded:: 21.2 + + Args: + max_reaction_count (:obj:`int`): The maximum number of reactions that can be set on a + message in the chat. + + .. versionadded:: 21.2 + + Attributes: + max_reaction_count (:obj:`int`): The maximum number of reactions that can be set on a + message in the chat. + + .. versionadded:: 21.2 + """ + + __slots__ = ("max_reaction_count",) + + def __init__( + self, + id: int, + type: str, + accent_color_id: int, # API 7.3 made this argument required + max_reaction_count: int, # NEW arg in api 7.3 and is required + title: Optional[str] = None, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + is_forum: Optional[bool] = None, + photo: Optional[ChatPhoto] = None, + active_usernames: Optional[Sequence[str]] = None, + birthdate: Optional[Birthdate] = None, + business_intro: Optional["BusinessIntro"] = None, + business_location: Optional["BusinessLocation"] = None, + business_opening_hours: Optional["BusinessOpeningHours"] = None, + personal_chat: Optional["Chat"] = None, + available_reactions: Optional[Sequence[ReactionType]] = None, + background_custom_emoji_id: Optional[str] = None, + profile_accent_color_id: Optional[int] = None, + profile_background_custom_emoji_id: Optional[str] = None, + emoji_status_custom_emoji_id: Optional[str] = None, + emoji_status_expiration_date: Optional[datetime] = None, + bio: Optional[str] = None, + has_private_forwards: Optional[bool] = None, + has_restricted_voice_and_video_messages: Optional[bool] = None, + join_to_send_messages: Optional[bool] = None, + join_by_request: Optional[bool] = None, + description: Optional[str] = None, + invite_link: Optional[str] = None, + pinned_message: Optional["Message"] = None, + permissions: Optional[ChatPermissions] = None, + slow_mode_delay: Optional[int] = None, + unrestrict_boost_count: Optional[int] = None, + message_auto_delete_time: Optional[int] = None, + has_aggressive_anti_spam_enabled: Optional[bool] = None, + has_hidden_members: Optional[bool] = None, + has_protected_content: Optional[bool] = None, + has_visible_history: Optional[bool] = None, + sticker_set_name: Optional[str] = None, + can_set_sticker_set: Optional[bool] = None, + custom_emoji_sticker_set_name: Optional[str] = None, + linked_chat_id: Optional[int] = None, + location: Optional[ChatLocation] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__( + id=id, + type=type, + title=title, + username=username, + first_name=first_name, + last_name=last_name, + photo=photo, + description=description, + invite_link=invite_link, + pinned_message=pinned_message, + permissions=permissions, + sticker_set_name=sticker_set_name, + can_set_sticker_set=can_set_sticker_set, + slow_mode_delay=slow_mode_delay, + bio=bio, + linked_chat_id=linked_chat_id, + location=location, + message_auto_delete_time=message_auto_delete_time, + has_private_forwards=has_private_forwards, + has_protected_content=has_protected_content, + join_to_send_messages=join_to_send_messages, + join_by_request=join_by_request, + has_restricted_voice_and_video_messages=has_restricted_voice_and_video_messages, + is_forum=is_forum, + active_usernames=active_usernames, + emoji_status_custom_emoji_id=emoji_status_custom_emoji_id, + emoji_status_expiration_date=emoji_status_expiration_date, + has_aggressive_anti_spam_enabled=has_aggressive_anti_spam_enabled, + has_hidden_members=has_hidden_members, + available_reactions=available_reactions, + accent_color_id=accent_color_id, + background_custom_emoji_id=background_custom_emoji_id, + profile_accent_color_id=profile_accent_color_id, + profile_background_custom_emoji_id=profile_background_custom_emoji_id, + has_visible_history=has_visible_history, + unrestrict_boost_count=unrestrict_boost_count, + custom_emoji_sticker_set_name=custom_emoji_sticker_set_name, + birthdate=birthdate, + personal_chat=personal_chat, + business_intro=business_intro, + business_location=business_location, + business_opening_hours=business_opening_hours, + api_kwargs=api_kwargs, + ) + + # Required and unique to this class- + with self._unfrozen(): + self.max_reaction_count: int = max_reaction_count + + self._freeze() diff --git a/telegram/_chatmember.py b/telegram/_chatmember.py index 20e28f4713b..b399af30e28 100644 --- a/telegram/_chatmember.py +++ b/telegram/_chatmember.py @@ -235,8 +235,9 @@ class ChatMemberAdministrator(ChatMember): .. versionadded:: 20.6 .. versionchanged:: 21.0 |non_optional_story_argument| - can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit - stories posted by other users. + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive .. versionadded:: 20.6 .. versionchanged:: 21.0 @@ -294,8 +295,9 @@ class ChatMemberAdministrator(ChatMember): .. versionadded:: 20.6 .. versionchanged:: 21.0 |non_optional_story_argument| - can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit - stories posted by other users. + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive .. versionadded:: 20.6 .. versionchanged:: 21.0 diff --git a/telegram/_chatmemberupdated.py b/telegram/_chatmemberupdated.py index 7d5ee556be7..c51b88b7b3b 100644 --- a/telegram/_chatmemberupdated.py +++ b/telegram/_chatmemberupdated.py @@ -63,6 +63,11 @@ class ChatMemberUpdated(TelegramObject): chat via a chat folder invite link .. versionadded:: 20.3 + via_join_request (:obj:`bool`, optional): :obj:`True`, if the user joined the chat after + sending a direct join request without using an invite link and being approved by + an administrator + + .. versionadded:: 21.2 Attributes: chat (:class:`telegram.Chat`): Chat the user belongs to. @@ -80,6 +85,11 @@ class ChatMemberUpdated(TelegramObject): chat via a chat folder invite link .. versionadded:: 20.3 + via_join_request (:obj:`bool`): Optional. :obj:`True`, if the user joined the chat after + sending a direct join request without using an invite link and being approved + by an administrator + + .. versionadded:: 21.2 """ @@ -91,6 +101,7 @@ class ChatMemberUpdated(TelegramObject): "new_chat_member", "old_chat_member", "via_chat_folder_invite_link", + "via_join_request", ) def __init__( @@ -102,6 +113,7 @@ def __init__( new_chat_member: ChatMember, invite_link: Optional[ChatInviteLink] = None, via_chat_folder_invite_link: Optional[bool] = None, + via_join_request: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -116,6 +128,7 @@ def __init__( # Optionals self.invite_link: Optional[ChatInviteLink] = invite_link + self.via_join_request: Optional[bool] = via_join_request self._id_attrs = ( self.chat, diff --git a/telegram/_files/sticker.py b/telegram/_files/sticker.py index a4d1fb994df..b1194deeeea 100644 --- a/telegram/_files/sticker.py +++ b/telegram/_files/sticker.py @@ -27,8 +27,6 @@ from telegram._utils import enum from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot @@ -237,20 +235,12 @@ class StickerSet(TelegramObject): .. versionchanged:: 20.5 |removed_thumb_note| + .. versionremoved:: 21.2 + Removed the deprecated arguments and attributes ``is_animated`` and ``is_video``. + Args: name (:obj:`str`): Sticker set name. title (:obj:`str`): Sticker set title. - is_animated (:obj:`bool`): :obj:`True`, if the sticker set contains animated stickers. - - .. deprecated:: 21.1 - Bot API 7.2 deprecated this field. This parameter will be removed in a future - version of the library. - is_video (:obj:`bool`): :obj:`True`, if the sticker set contains video stickers. - .. versionadded:: 13.11 - - .. deprecated:: 21.1 - Bot API 7.2 deprecated this field. This parameter will be removed in a future - version of the library. stickers (Sequence[:class:`telegram.Sticker`]): List of all set stickers. .. versionchanged:: 20.0 @@ -269,17 +259,6 @@ class StickerSet(TelegramObject): Attributes: name (:obj:`str`): Sticker set name. title (:obj:`str`): Sticker set title. - is_animated (:obj:`bool`): :obj:`True`, if the sticker set contains animated stickers. - - .. deprecated:: 21.1 - Bot API 7.2 deprecated this field. This parameter will be removed in a future - version of the library. - is_video (:obj:`bool`): :obj:`True`, if the sticker set contains video stickers. - .. versionadded:: 13.11 - - .. deprecated:: 21.1 - Bot API 7.2 deprecated this field. This parameter will be removed in a future - version of the library. stickers (Tuple[:class:`telegram.Sticker`]): List of all set stickers. .. versionchanged:: 20.0 @@ -297,8 +276,6 @@ class StickerSet(TelegramObject): """ __slots__ = ( - "is_animated", - "is_video", "name", "sticker_type", "stickers", @@ -312,8 +289,6 @@ def __init__( title: str, stickers: Sequence[Sticker], sticker_type: str, - is_animated: Optional[bool] = None, - is_video: Optional[bool] = None, thumbnail: Optional[PhotoSize] = None, *, api_kwargs: Optional[JSONDict] = None, @@ -325,15 +300,6 @@ def __init__( self.sticker_type: str = sticker_type # Optional self.thumbnail: Optional[PhotoSize] = thumbnail - if is_animated is not None or is_video is not None: - warn( - "The parameters `is_animated` and `is_video` are deprecated and will be removed " - "in a future version.", - PTBDeprecationWarning, - stacklevel=2, - ) - self.is_animated: Optional[bool] = is_animated - self.is_video: Optional[bool] = is_video self._id_attrs = (self.name,) self._freeze() @@ -350,7 +316,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["StickerSet"] api_kwargs = {} # These are deprecated fields that TG still returns for backwards compatibility # Let's filter them out to speed up the de-json process - for deprecated_field in ("contains_masks", "thumb"): + for deprecated_field in ("contains_masks", "thumb", "is_animated", "is_video"): if deprecated_field in data: api_kwargs[deprecated_field] = data.pop(deprecated_field) diff --git a/telegram/_forcereply.py b/telegram/_forcereply.py index a5f0debaee5..cce00996bbd 100644 --- a/telegram/_forcereply.py +++ b/telegram/_forcereply.py @@ -30,7 +30,8 @@ class ForceReply(TelegramObject): Upon receiving a message with this object, Telegram clients will display a reply interface to the user (act as if the user has selected the bot's message and tapped 'Reply'). This can be extremely useful if you want to create user-friendly step-by-step interfaces without having - to sacrifice privacy mode. + to sacrifice `privacy mode `_. Not + supported in channels and for messages sent on behalf of a Telegram Business account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`selective` is equal. diff --git a/telegram/_inline/inlinekeyboardbutton.py b/telegram/_inline/inlinekeyboardbutton.py index 0b5c75a5c45..9af5d14eda6 100644 --- a/telegram/_inline/inlinekeyboardbutton.py +++ b/telegram/_inline/inlinekeyboardbutton.py @@ -91,6 +91,7 @@ class InlineKeyboardButton(TelegramObject): to the bot when button is pressed, UTF-8 :tg-const:`telegram.InlineKeyboardButton.MIN_CALLBACK_DATA`- :tg-const:`telegram.InlineKeyboardButton.MAX_CALLBACK_DATA` bytes. + Not supported for messages sent on behalf of a Telegram Business account. If the bot instance allows arbitrary callback data, anything can be passed. Tip: @@ -102,25 +103,25 @@ class InlineKeyboardButton(TelegramObject): `_ that will be launched when the user presses the button. The Web App will be able to send an arbitrary message on behalf of the user using the method :meth:`~telegram.Bot.answer_web_app_query`. Available only in - private chats between a user and the bot. + private chats between a user and the bot. Not supported for messages sent on behalf of + a Telegram Business account. .. versionadded:: 20.0 - switch_inline_query (:obj:`str`, optional): If set, pressing the button will prompt the - user to select one of their chats, open that chat and insert the bot's username and the - specified inline query in the input field. Can be empty, in which case just the bot's - username will be inserted. This offers an easy way for users to start using your bot - in inline mode when they are currently in a private chat with it. Especially useful - when combined with ``switch_pm*`` actions - in this case the user will be automatically - returned to the chat they switched from, skipping the chat selection screen. + switch_inline_query (:obj:`str`, optional): If set, pressing the button will insert the + bot's username and the specified inline query in the current chat's input field. May be + empty, in which case only the bot's username will be inserted. + + This offers a quick way for the user to open your bot in inline mode in the same chat - + good for selecting something from multiple options. Not supported in channels and for + messages sent on behalf of a Telegram Business account. Tip: This is similar to the new parameter :paramref:`switch_inline_query_chosen_chat`, but gives no control over which chats can be selected. switch_inline_query_current_chat (:obj:`str`, optional): If set, pressing the button will - insert the bot's username and the specified inline query in the current chat's input - field. Can be empty, in which case only the bot's username will be inserted. This - offers a quick way for the user to open your bot in inline mode in the same chat - good - for selecting something from multiple options. + prompt the user to select one of their chats of the specified type, open that chat and + insert the bot's username and the specified inline query in the input field. Not + supported for messages sent on behalf of a Telegram Business account. callback_game (:class:`telegram.CallbackGame`, optional): Description of the game that will be launched when the user presses the button. This type of button **must** always be the **first** button in the first row. @@ -130,7 +131,8 @@ class InlineKeyboardButton(TelegramObject): switch_inline_query_chosen_chat (:obj:`telegram.SwitchInlineQueryChosenChat`, optional): If set, pressing the button will prompt the user to select one of their chats of the specified type, open that chat and insert the bot's username and the specified inline - query in the input field. + query in the input field. Not supported for messages sent on behalf of a Telegram + Business account. .. versionadded:: 20.3 @@ -159,29 +161,30 @@ class InlineKeyboardButton(TelegramObject): to the bot when button is pressed, UTF-8 :tg-const:`telegram.InlineKeyboardButton.MIN_CALLBACK_DATA`- :tg-const:`telegram.InlineKeyboardButton.MAX_CALLBACK_DATA` bytes. + Not supported for messages sent on behalf of a Telegram Business account. web_app (:obj:`telegram.WebAppInfo`): Optional. Description of the `Web App `_ that will be launched when the user presses the button. The Web App will be able to send an arbitrary message on behalf of the user using the method :meth:`~telegram.Bot.answer_web_app_query`. Available only in - private chats between a user and the bot. + private chats between a user and the bot. Not supported for messages sent on behalf of + a Telegram Business account. .. versionadded:: 20.0 - switch_inline_query (:obj:`str`): Optional. If set, pressing the button will prompt the - user to select one of their chats, open that chat and insert the bot's username and the - specified inline query in the input field. Can be empty, in which case just the bot's - username will be inserted. This offers an easy way for users to start using your bot - in inline mode when they are currently in a private chat with it. Especially useful - when combined with ``switch_pm*`` actions - in this case the user will be automatically - returned to the chat they switched from, skipping the chat selection screen. + switch_inline_query (:obj:`str`): Optional. If set, pressing the button will insert the + bot's username and the specified inline query in the current chat's input field. May be + empty, in which case only the bot's username will be inserted. + + This offers a quick way for the user to open your bot in inline mode in the same chat - + good for selecting something from multiple options. Not supported in channels and for + messages sent on behalf of a Telegram Business account. Tip: This is similar to the new parameter :paramref:`switch_inline_query_chosen_chat`, but gives no control over which chats can be selected. switch_inline_query_current_chat (:obj:`str`): Optional. If set, pressing the button will - insert the bot's username and the specified inline query in the current chat's input - field. Can be empty, in which case only the bot's username will be inserted. This - offers a quick way for the user to open your bot in inline mode in the same chat - good - for selecting something from multiple options. + prompt the user to select one of their chats of the specified type, open that chat and + insert the bot's username and the specified inline query in the input field. Not + supported for messages sent on behalf of a Telegram Business account. callback_game (:class:`telegram.CallbackGame`): Optional. Description of the game that will be launched when the user presses the button. This type of button **must** always be the **first** button in the first row. @@ -191,7 +194,8 @@ class InlineKeyboardButton(TelegramObject): switch_inline_query_chosen_chat (:obj:`telegram.SwitchInlineQueryChosenChat`): Optional. If set, pressing the button will prompt the user to select one of their chats of the specified type, open that chat and insert the bot's username and the specified inline - query in the input field. + query in the input field. Not supported for messages sent on behalf of a Telegram + Business account. .. versionadded:: 20.3 diff --git a/telegram/_inline/inlinequeryresultlocation.py b/telegram/_inline/inlinequeryresultlocation.py index 0c370ee8a74..dff2b29a48b 100644 --- a/telegram/_inline/inlinequeryresultlocation.py +++ b/telegram/_inline/inlinequeryresultlocation.py @@ -89,7 +89,9 @@ class InlineQueryResultLocation(InlineQueryResult): live_period (:obj:`int`): Optional. Period in seconds for which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and - :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD`. + :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD` or + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live + locations that can be edited indefinitely. heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and diff --git a/telegram/_inline/inputlocationmessagecontent.py b/telegram/_inline/inputlocationmessagecontent.py index 22cb2d9ef62..d9642c485c5 100644 --- a/telegram/_inline/inputlocationmessagecontent.py +++ b/telegram/_inline/inputlocationmessagecontent.py @@ -42,7 +42,9 @@ class InputLocationMessageContent(InputMessageContent): live_period (:obj:`int`, optional): Period in seconds for which the location will be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and - :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD`. + :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD` or + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live + locations that can be edited indefinitely. heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and diff --git a/telegram/_message.py b/telegram/_message.py index 87ecdd300f3..eaea1f3fb08 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, TypedDict, Union from telegram._chat import Chat +from telegram._chatbackground import ChatBackground from telegram._chatboost import ChatBoostAdded from telegram._dice import Dice from telegram._files.animation import Animation @@ -64,6 +65,7 @@ from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue +from telegram._utils.entities import parse_message_entities, parse_message_entity from telegram._utils.types import ( CorrectOptionID, FileInput, @@ -99,6 +101,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPollOption, LabeledPrice, MessageId, MessageOrigin, @@ -198,7 +201,7 @@ def _de_json( data["date"] = from_timestamp(data["date"], tzinfo=loc_tzinfo) data["chat"] = Chat.de_json(data.get("chat"), bot) - return super()._de_json(data=data, bot=bot) + return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) class InaccessibleMessage(MaybeInaccessibleMessage): @@ -553,6 +556,11 @@ class Message(MaybeInaccessibleMessage): .. versionadded:: 21.1 + chat_background_set (:obj:`telegram.ChatBackground`, optional): Service message: chat + background set. + + .. versionadded:: 21.2 + Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. from_user (:class:`telegram.User`): Optional. Sender of the message; empty for messages @@ -853,6 +861,11 @@ class Message(MaybeInaccessibleMessage): .. versionadded:: 21.1 + chat_background_set (:obj:`telegram.ChatBackground`): Optional. Service message: chat + background set + + .. versionadded:: 21.2 + .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a :exc:`ValueError` when encountering a custom emoji. @@ -876,6 +889,7 @@ class Message(MaybeInaccessibleMessage): "caption", "caption_entities", "channel_chat_created", + "chat_background_set", "chat_shared", "connected_website", "contact", @@ -1029,6 +1043,7 @@ def __init__( business_connection_id: Optional[str] = None, sender_business_bot: Optional[User] = None, is_from_offline: Optional[bool] = None, + chat_background_set: Optional[ChatBackground] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1127,6 +1142,7 @@ def __init__( self.business_connection_id: Optional[str] = business_connection_id self.sender_business_bot: Optional[User] = sender_business_bot self.is_from_offline: Optional[bool] = is_from_offline + self.chat_background_set: Optional[ChatBackground] = chat_background_set self._effective_attachment = DEFAULT_NONE @@ -1241,6 +1257,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: ) data["users_shared"] = UsersShared.de_json(data.get("users_shared"), bot) data["chat_shared"] = ChatShared.de_json(data.get("chat_shared"), bot) + data["chat_background_set"] = ChatBackground.de_json(data.get("chat_background_set"), bot) # Unfortunately, this needs to be here due to cyclic imports from telegram._giveaway import ( # pylint: disable=import-outside-toplevel @@ -1562,9 +1579,11 @@ async def _parse_quote_arguments( if quote is not None: warn( - "The `quote` parameter is deprecated in favor of the `do_quote` parameter. Please " - "update your code to use `do_quote` instead.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.8", + "The `quote` parameter is deprecated in favor of the `do_quote` parameter. " + "Please update your code to use `do_quote` instead.", + ), stacklevel=2, ) @@ -2890,7 +2909,7 @@ async def reply_contact( async def reply_poll( self, question: str, - options: Sequence[str], + options: Sequence[Union[str, "InputPollOption"]], is_anonymous: Optional[bool] = None, type: Optional[str] = None, # pylint: disable=redefined-builtin allows_multiple_answers: Optional[bool] = None, @@ -2906,6 +2925,8 @@ async def reply_poll( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, reply_parameters: Optional["ReplyParameters"] = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Optional[Sequence["MessageEntity"]] = None, *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -2976,6 +2997,8 @@ async def reply_poll( protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=self.business_connection_id, + question_parse_mode=question_parse_mode, + question_entities=question_entities, ) async def reply_dice( @@ -3653,6 +3676,7 @@ async def edit_live_location( horizontal_accuracy: Optional[float] = None, heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, + live_period: Optional[int] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3694,6 +3718,7 @@ async def edit_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, + live_period=live_period, inline_message_id=None, ) @@ -4184,9 +4209,7 @@ def parse_entity(self, entity: MessageEntity) -> str: if not self.text: raise RuntimeError("This Message has no 'text'.") - entity_text = self.text.encode("utf-16-le") - entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - return entity_text.decode("utf-16-le") + return parse_message_entity(self.text, entity) def parse_caption_entity(self, entity: MessageEntity) -> str: """Returns the text from a given :class:`telegram.MessageEntity`. @@ -4210,9 +4233,7 @@ def parse_caption_entity(self, entity: MessageEntity) -> str: if not self.caption: raise RuntimeError("This Message has no 'caption'.") - entity_text = self.caption.encode("utf-16-le") - entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - return entity_text.decode("utf-16-le") + return parse_message_entity(self.caption, entity) def parse_entities(self, types: Optional[List[str]] = None) -> Dict[MessageEntity, str]: """ @@ -4237,12 +4258,7 @@ def parse_entities(self, types: Optional[List[str]] = None) -> Dict[MessageEntit the text that belongs to them, calculated based on UTF-16 codepoints. """ - if types is None: - types = MessageEntity.ALL_TYPES - - return { - entity: self.parse_entity(entity) for entity in self.entities if entity.type in types - } + return parse_message_entities(self.text, self.entities, types=types) def parse_caption_entities( self, types: Optional[List[str]] = None @@ -4269,14 +4285,7 @@ def parse_caption_entities( the text that belongs to them, calculated based on UTF-16 codepoints. """ - if types is None: - types = MessageEntity.ALL_TYPES - - return { - entity: self.parse_caption_entity(entity) - for entity in self.caption_entities - if entity.type in types - } + return parse_message_entities(self.caption, self.caption_entities, types=types) @classmethod def _parse_html( diff --git a/telegram/_passport/encryptedpassportelement.py b/telegram/_passport/encryptedpassportelement.py index e6a22ee2e7e..f0d869fe2e3 100644 --- a/telegram/_passport/encryptedpassportelement.py +++ b/telegram/_passport/encryptedpassportelement.py @@ -157,7 +157,10 @@ def __init__( reverse_side: Optional[PassportFile] = None, selfie: Optional[PassportFile] = None, translation: Optional[Sequence[PassportFile]] = None, - credentials: Optional["Credentials"] = None, # pylint: disable=unused-argument + # TODO: Remove the credentials argument in 22.0 or later + credentials: Optional[ # pylint: disable=unused-argument # noqa: ARG002 + "Credentials" + ] = None, *, api_kwargs: Optional[JSONDict] = None, ): diff --git a/telegram/_passport/passportelementerrors.py b/telegram/_passport/passportelementerrors.py index 0692c98f314..8d6911439c7 100644 --- a/telegram/_passport/passportelementerrors.py +++ b/telegram/_passport/passportelementerrors.py @@ -210,9 +210,11 @@ def file_hashes(self) -> List[str]: This attribute will return a tuple instead of a list in future major versions. """ warn( - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.6", + "The attribute `file_hashes` will return a tuple instead of a list in future major" + " versions.", + ), stacklevel=2, ) return self._file_hashes @@ -427,10 +429,12 @@ def file_hashes(self) -> List[str]: This attribute will return a tuple instead of a list in future major versions. """ warn( - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions. See the stability policy:" - " https://docs.python-telegram-bot.org/en/stable/stability_policy.html", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.6", + "The attribute `file_hashes` will return a tuple instead of a list in future major" + " versions. See the stability policy:" + " https://docs.python-telegram-bot.org/en/stable/stability_policy.html", + ), stacklevel=2, ) return self._file_hashes diff --git a/telegram/_passport/passportfile.py b/telegram/_passport/passportfile.py index 12c0f6f049d..3c69e9eb570 100644 --- a/telegram/_passport/passportfile.py +++ b/telegram/_passport/passportfile.py @@ -107,9 +107,11 @@ def file_date(self) -> int: This attribute will return a datetime instead of a integer in future major versions. """ warn( - "The attribute `file_date` will return a datetime instead of an integer in future" - " major versions.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.6", + "The attribute `file_date` will return a datetime instead of an integer in future" + " major versions.", + ), stacklevel=2, ) return self._file_date diff --git a/telegram/_poll.py b/telegram/_poll.py index 7c1a65204a4..cd6397cf733 100644 --- a/telegram/_poll.py +++ b/telegram/_poll.py @@ -28,12 +28,80 @@ from telegram._utils import enum from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict, ODVInput if TYPE_CHECKING: from telegram import Bot +class InputPollOption(TelegramObject): + """ + This object contains information about one answer option in a poll to send. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`text` is equal. + + .. versionadded:: 21.2 + + Args: + text (:obj:`str`): Option text, + :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` + characters. + text_parse_mode (:obj:`str`, optional): |parse_mode| + Currently, only custom emoji entities are allowed. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities + that appear in the option :paramref:`text`. It can be specified instead of + :paramref:`text_parse_mode`. + Currently, only custom emoji entities are allowed. + This list is empty if the text does not contain entities. + + Attributes: + text (:obj:`str`): Option text, + :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` + characters. + text_parse_mode (:obj:`str`): Optional. |parse_mode| + Currently, only custom emoji entities are allowed. + text_entities (Sequence[:class:`telegram.MessageEntity`]): Special entities + that appear in the option :paramref:`text`. It can be specified instead of + :paramref:`text_parse_mode`. + Currently, only custom emoji entities are allowed. + This list is empty if the text does not contain entities. + """ + + __slots__ = ("text", "text_entities", "text_parse_mode") + + def __init__( + self, + text: str, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Optional[Sequence[MessageEntity]] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.text: str = text + self.text_parse_mode: ODVInput[str] = text_parse_mode + self.text_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + + self._id_attrs = (self.text,) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InputPollOption"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["text_entities"] = MessageEntity.de_list(data.get("text_entities"), bot) + + return super().de_json(data=data, bot=bot) + + class PollOption(TelegramObject): """ This object contains information about one answer option in a poll. @@ -46,26 +114,101 @@ class PollOption(TelegramObject): :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` characters. voter_count (:obj:`int`): Number of users that voted for this option. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities + that appear in the option text. Currently, only custom emoji entities are allowed in + poll option texts. + + .. versionadded:: 21.2 Attributes: text (:obj:`str`): Option text, :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` characters. voter_count (:obj:`int`): Number of users that voted for this option. + text_entities (Tuple[:class:`telegram.MessageEntity`]): Special entities + that appear in the option text. Currently, only custom emoji entities are allowed in + poll option texts. + This list is empty if the question does not contain entities. + + .. versionadded:: 21.2 """ - __slots__ = ("text", "voter_count") + __slots__ = ("text", "text_entities", "voter_count") - def __init__(self, text: str, voter_count: int, *, api_kwargs: Optional[JSONDict] = None): + def __init__( + self, + text: str, + voter_count: int, + text_entities: Optional[Sequence[MessageEntity]] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): super().__init__(api_kwargs=api_kwargs) self.text: str = text self.voter_count: int = voter_count + self.text_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) self._id_attrs = (self.text, self.voter_count) self._freeze() + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PollOption"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["text_entities"] = MessageEntity.de_list(data.get("text_entities"), bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`text_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + .. versionadded:: 21.2 + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: Optional[List[str]] = None) -> Dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls question filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`text_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + .. versionadded:: 21.2 + + Args: + types (List[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.text, self.text_entities, types) + MIN_LENGTH: Final[int] = constants.PollLimit.MIN_OPTION_LENGTH """:const:`telegram.constants.PollLimit.MIN_OPTION_LENGTH` @@ -215,6 +358,11 @@ class Poll(TelegramObject): .. versionchanged:: 20.3 |datetime_localization| + question_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities + that appear in the :attr:`question`. Currently, only custom emoji entities are allowed + in poll questions. + + .. versionadded:: 21.2 Attributes: id (:obj:`str`): Unique poll identifier. @@ -251,6 +399,12 @@ class Poll(TelegramObject): .. versionchanged:: 20.3 |datetime_localization| + question_entities (Tuple[:class:`telegram.MessageEntity`]): Special entities + that appear in the :attr:`question`. Currently, only custom emoji entities are allowed + in poll questions. + This list is empty if the question does not contain entities. + + .. versionadded:: 21.2 """ @@ -266,6 +420,7 @@ class Poll(TelegramObject): "open_period", "options", "question", + "question_entities", "total_voter_count", "type", ) @@ -285,6 +440,7 @@ def __init__( explanation_entities: Optional[Sequence[MessageEntity]] = None, open_period: Optional[int] = None, close_date: Optional[datetime.datetime] = None, + question_entities: Optional[Sequence[MessageEntity]] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -304,6 +460,7 @@ def __init__( ) self.open_period: Optional[int] = open_period self.close_date: Optional[datetime.datetime] = close_date + self.question_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) self._id_attrs = (self.id,) @@ -323,11 +480,13 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Poll"]: data["options"] = [PollOption.de_json(option, bot) for option in data["options"]] data["explanation_entities"] = MessageEntity.de_list(data.get("explanation_entities"), bot) data["close_date"] = from_timestamp(data.get("close_date"), tzinfo=loc_tzinfo) + data["question_entities"] = MessageEntity.de_list(data.get("question_entities"), bot) return super().de_json(data=data, bot=bot) def parse_explanation_entity(self, entity: MessageEntity) -> str: - """Returns the text from a given :class:`telegram.MessageEntity`. + """Returns the text in :attr:`explanation` from a given :class:`telegram.MessageEntity` of + :attr:`explanation_entities`. Note: This method is present because Telegram calculates the offset and length in @@ -336,7 +495,7 @@ def parse_explanation_entity(self, entity: MessageEntity) -> str: Args: entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must - be an entity that belongs to this message. + be an entity that belongs to :attr:`explanation_entities`. Returns: :obj:`str`: The text of the given entity. @@ -348,10 +507,7 @@ def parse_explanation_entity(self, entity: MessageEntity) -> str: if not self.explanation: raise RuntimeError("This Poll has no 'explanation'.") - entity_text = self.explanation.encode("utf-16-le") - entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - - return entity_text.decode("utf-16-le") + return parse_message_entity(self.explanation, entity) def parse_explanation_entities( self, types: Optional[List[str]] = None @@ -375,15 +531,61 @@ def parse_explanation_entities( Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to the text that belongs to them, calculated based on UTF-16 codepoints. + Raises: + RuntimeError: If the poll has no explanation. + + """ + if not self.explanation: + raise RuntimeError("This Poll has no 'explanation'.") + + return parse_message_entities(self.explanation, self.explanation_entities, types) + + def parse_question_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`question` from a given :class:`telegram.MessageEntity` of + :attr:`question_entities`. + + .. versionadded:: 21.2 + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`question_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.question, entity) + + def parse_question_entities( + self, types: Optional[List[str]] = None + ) -> Dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls question filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + .. versionadded:: 21.2 + + Note: + This method should always be used instead of the :attr:`question_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_question_entity` for more info. + + Args: + types (List[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ - if types is None: - types = MessageEntity.ALL_TYPES - - return { - entity: self.parse_explanation_entity(entity) - for entity in self.explanation_entities - if entity.type in types - } + return parse_message_entities(self.question, self.question_entities, types) REGULAR: Final[str] = constants.PollType.REGULAR """:const:`telegram.constants.PollType.REGULAR`""" diff --git a/telegram/_reply.py b/telegram/_reply.py index c77e33ddbe9..973cee5ddfe 100644 --- a/telegram/_reply.py +++ b/telegram/_reply.py @@ -355,6 +355,7 @@ class ReplyParameters(TelegramObject): chat, or in the chat :paramref:`chat_id` if it is specified. chat_id (:obj:`int` | :obj:`str`, optional): If the message to be replied to is from a different chat, |chat_id_channel| + Not supported for messages sent on behalf of a business account. allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| Can be used only for replies in the same chat and forum topic. quote (:obj:`str`, optional): Quoted part of the message to be replied to; 0-1024 @@ -376,6 +377,7 @@ class ReplyParameters(TelegramObject): chat, or in the chat :paramref:`chat_id` if it is specified. chat_id (:obj:`int` | :obj:`str`): Optional. If the message to be replied to is from a different chat, |chat_id_channel| + Not supported for messages sent on behalf of a business account. allow_sending_without_reply (:obj:`bool`): Optional. |allow_sending_without_reply| Can be used only for replies in the same chat and forum topic. quote (:obj:`str`): Optional. Quoted part of the message to be replied to; 0-1024 diff --git a/telegram/_replykeyboardmarkup.py b/telegram/_replykeyboardmarkup.py index dfc0640d27a..cfca12cc350 100644 --- a/telegram/_replykeyboardmarkup.py +++ b/telegram/_replykeyboardmarkup.py @@ -28,7 +28,8 @@ class ReplyKeyboardMarkup(TelegramObject): - """This object represents a custom keyboard with reply options. + """This object represents a custom keyboard with reply options. Not supported in channels and + for messages sent on behalf of a Telegram Business account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their size of :attr:`keyboard` and all the buttons are equal. diff --git a/telegram/_replykeyboardremove.py b/telegram/_replykeyboardremove.py index 92fc464e4c5..6cd1a649f4e 100644 --- a/telegram/_replykeyboardremove.py +++ b/telegram/_replykeyboardremove.py @@ -29,6 +29,7 @@ class ReplyKeyboardRemove(TelegramObject): keyboard and display the default letter-keyboard. By default, custom keyboards are displayed until a new keyboard is sent by a bot. An exception is made for one-time keyboards that are hidden immediately after the user presses a button (see :class:`telegram.ReplyKeyboardMarkup`). + Not supported in channels and for messages sent on behalf of a Telegram Business account. Note: User will not be able to summon this keyboard; if you want to hide the keyboard from diff --git a/telegram/_shared.py b/telegram/_shared.py index 70180044703..8c791154e2f 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -23,12 +23,6 @@ from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn -from telegram._utils.warnings_transition import ( - build_deprecation_warning_message, - warn_about_deprecated_attr_in_property, -) -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram._bot import Bot @@ -44,11 +38,14 @@ class UsersShared(TelegramObject): .. versionadded:: 20.8 Bot API 7.0 replaces ``UserShared`` with this class. The only difference is that now - the :attr:`user_ids` is a sequence instead of a single integer. + the ``user_ids`` is a sequence instead of a single integer. .. versionchanged:: 21.1 The argument :attr:`users` is now considered for the equality comparison instead of - :attr:`user_ids`. + ``user_ids``. + + .. versionremoved:: 21.2 + Removed the deprecated argument and attribute ``user_ids``. Args: request_id (:obj:`int`): Identifier of the request. @@ -57,18 +54,8 @@ class UsersShared(TelegramObject): .. versionadded:: 21.1 - .. deprecated:: 21.1 - In future versions, this argument will become keyword only. - user_ids (Sequence[:obj:`int`], optional): Identifiers of the shared users. These numbers - may have more than 32 significant bits and some programming languages may have - difficulty/silent defects in interpreting them. But they have at most 52 significant - bits, so 64-bit integers or double-precision float types are safe for storing these - identifiers. The bot may not have access to the users and could be unable to use - these identifiers, unless the users are already known to the bot by some other means. - - .. deprecated:: 21.1 - Bot API 7.2 introduced by :paramref:`users`, replacing this argument. Hence, this - argument is now optional and will be removed in future versions. + .. versionchanged:: 21.2 + This argument is now required. Attributes: request_id (:obj:`int`): Identifier of the request. @@ -83,31 +70,14 @@ class UsersShared(TelegramObject): def __init__( self, request_id: int, - user_ids: Optional[Sequence[int]] = None, - users: Optional[Sequence["SharedUser"]] = None, + users: Sequence["SharedUser"], *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) self.request_id: int = request_id - - if users is None: - raise TypeError("`users` is a required argument since Bot API 7.2") - self.users: Tuple[SharedUser, ...] = parse_sequence_arg(users) - if user_ids is not None: - warn( - build_deprecation_warning_message( - deprecated_name="user_ids", - new_name="users", - object_type="parameter", - bot_api_version="7.2", - ), - PTBDeprecationWarning, - stacklevel=2, - ) - self._id_attrs = (self.request_id, self.users) self._freeze() @@ -130,28 +100,6 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["UsersShared" return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) - @property - def user_ids(self) -> Tuple[int, ...]: - """ - Tuple[:obj:`int`]: Identifiers of the shared users. These numbers may have - more than 32 significant bits and some programming languages may have difficulty/silent - defects in interpreting them. But they have at most 52 significant bits, so 64-bit - integers or double-precision float types are safe for storing these identifiers. The - bot may not have access to the users and could be unable to use these identifiers, - unless the users are already known to the bot by some other means. - - .. deprecated:: 21.1 - As Bot API 7.2 replaces this attribute with :attr:`users`, this attribute will be - removed in future versions. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="user_ids", - new_attr_name="users", - bot_api_version="7.2", - stacklevel=2, - ) - return tuple(user.user_id for user in self.users) - class ChatShared(TelegramObject): """ diff --git a/telegram/_update.py b/telegram/_update.py index ada70da258c..784dea52aba 100644 --- a/telegram/_update.py +++ b/telegram/_update.py @@ -141,8 +141,8 @@ class Update(TelegramObject): .. versionadded:: 21.1 - business_message (:class:`telegram.Message`, optional): New non-service message - from a connected business account. + business_message (:class:`telegram.Message`, optional): New message from a connected + business account. .. versionadded:: 21.1 @@ -249,8 +249,8 @@ class Update(TelegramObject): .. versionadded:: 21.1 - business_message (:class:`telegram.Message`): Optional. New non-service message - from a connected business account. + business_message (:class:`telegram.Message`): Optional. New message from a connected + business account. .. versionadded:: 21.1 diff --git a/telegram/_user.py b/telegram/_user.py index ef6c4f4f504..17b58f2df6f 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -40,6 +40,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPollOption, LabeledPrice, LinkPreviewOptions, Location, @@ -1482,7 +1483,7 @@ async def send_voice( async def send_poll( self, question: str, - options: Sequence[str], + options: Sequence[Union[str, "InputPollOption"]], is_anonymous: Optional[bool] = None, type: Optional[str] = None, allows_multiple_answers: Optional[bool] = None, @@ -1499,6 +1500,8 @@ async def send_poll( message_thread_id: Optional[int] = None, reply_parameters: Optional["ReplyParameters"] = None, business_connection_id: Optional[str] = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Optional[Sequence["MessageEntity"]] = None, *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -1548,6 +1551,8 @@ async def send_poll( protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=business_connection_id, + question_parse_mode=question_parse_mode, + question_entities=question_entities, ) async def send_copy( diff --git a/telegram/_utils/entities.py b/telegram/_utils/entities.py new file mode 100644 index 00000000000..a3994cd0426 --- /dev/null +++ b/telegram/_utils/entities.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# 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 auxiliary functionality for parsing MessageEntity objects. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" +from typing import Dict, Optional, Sequence + +from telegram._messageentity import MessageEntity + + +def parse_message_entity(text: str, entity: MessageEntity) -> str: + """Returns the text from a given :class:`telegram.MessageEntity`. + + Args: + text (:obj:`str`): The text to extract the entity from. + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. + + Returns: + :obj:`str`: The text of the given entity. + """ + entity_text = text.encode("utf-16-le") + entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] + + return entity_text.decode("utf-16-le") + + +def parse_message_entities( + text: str, entities: Sequence[MessageEntity], types: Optional[Sequence[str]] = None +) -> Dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Args: + text (:obj:`str`): The text to extract the entity from. + entities (List[:class:`telegram.MessageEntity`]): The entities to extract the text from. + types (List[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + if types is None: + types = MessageEntity.ALL_TYPES + + return { + entity: parse_message_entity(text, entity) for entity in entities if entity.type in types + } diff --git a/telegram/_utils/enum.py b/telegram/_utils/enum.py index aa370cbd44a..20a045c02fe 100644 --- a/telegram/_utils/enum.py +++ b/telegram/_utils/enum.py @@ -60,7 +60,7 @@ def __str__(self) -> str: # Apply the __repr__ modification and __str__ fix to IntEnum -class IntEnum(_enum.IntEnum): +class IntEnum(_enum.IntEnum): # pylint: disable=invalid-slots """Helper class for int enums where ``str(member)`` prints the value, but ``repr(member)`` gives ``EnumName.MEMBER_NAME``. """ diff --git a/telegram/_utils/warnings.py b/telegram/_utils/warnings.py index d81f4e79234..dcc3fc150d9 100644 --- a/telegram/_utils/warnings.py +++ b/telegram/_utils/warnings.py @@ -26,19 +26,28 @@ the changelog. """ import warnings -from typing import Type +from typing import Type, Union from telegram.warnings import PTBUserWarning -def warn(message: str, category: Type[Warning] = PTBUserWarning, stacklevel: int = 0) -> None: +def warn( + message: Union[str, PTBUserWarning], + category: Type[Warning] = PTBUserWarning, + stacklevel: int = 0, +) -> None: """ Helper function used as a shortcut for warning with default values. .. versionadded:: 20.0 Args: - message (:obj:`str`): Specify the warnings message to pass to ``warnings.warn()``. + message (:obj:`str` | :obj:`PTBUserWarning`): Specify the warnings message to pass to + ``warnings.warn()``. + + .. versionchanged:: 21.2 + Now also accepts a :obj:`PTBUserWarning` instance. + category (:obj:`Type[Warning]`, optional): Specify the Warning class to pass to ``warnings.warn()``. Defaults to :class:`telegram.warnings.PTBUserWarning`. stacklevel (:obj:`int`, optional): Specify the stacklevel to pass to ``warnings.warn()``. diff --git a/telegram/_utils/warnings_transition.py b/telegram/_utils/warnings_transition.py index 655450d158d..a135ee5e648 100644 --- a/telegram/_utils/warnings_transition.py +++ b/telegram/_utils/warnings_transition.py @@ -23,10 +23,10 @@ .. versionadded:: 20.2 """ -from typing import Any, Callable, Type +from typing import Any, Callable, Type, Union from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning +from telegram.warnings import PTBDeprecationWarning, PTBUserWarning def build_deprecation_warning_message( @@ -54,8 +54,9 @@ def warn_about_deprecated_arg_return_new_arg( deprecated_arg_name: str, new_arg_name: str, bot_api_version: str, + ptb_version: str, stacklevel: int = 2, - warn_callback: Callable[[str, Type[Warning], int], None] = warn, + warn_callback: Callable[[Union[str, PTBUserWarning], Type[Warning], int], None] = warn, ) -> Any: """A helper function for the transition in API when argument is renamed. @@ -80,10 +81,12 @@ def warn_about_deprecated_arg_return_new_arg( if deprecated_arg: warn_callback( - f"Bot API {bot_api_version} renamed the argument '{deprecated_arg_name}' to " - f"'{new_arg_name}'.", - PTBDeprecationWarning, - stacklevel + 1, + PTBDeprecationWarning( + ptb_version, + f"Bot API {bot_api_version} renamed the argument '{deprecated_arg_name}' to " + f"'{new_arg_name}'.", + ), + stacklevel=stacklevel + 1, # type: ignore[call-arg] ) return deprecated_arg @@ -94,6 +97,7 @@ def warn_about_deprecated_attr_in_property( deprecated_attr_name: str, new_attr_name: str, bot_api_version: str, + ptb_version: str, stacklevel: int = 2, ) -> None: """A helper function for the transition in API when attribute is renamed. Call from properties. @@ -101,8 +105,10 @@ def warn_about_deprecated_attr_in_property( The properties replace deprecated attributes in classes and issue these deprecation warnings. """ warn( - f"Bot API {bot_api_version} renamed the attribute '{deprecated_attr_name}' to " - f"'{new_attr_name}'.", - PTBDeprecationWarning, + PTBDeprecationWarning( + ptb_version, + f"Bot API {bot_api_version} renamed the attribute '{deprecated_attr_name}' to " + f"'{new_attr_name}'.", + ), stacklevel=stacklevel + 1, ) diff --git a/telegram/_version.py b/telegram/_version.py index bc75598cc1a..e1a1bbe79f2 100644 --- a/telegram/_version.py +++ b/telegram/_version.py @@ -51,7 +51,7 @@ def __str__(self) -> str: __version_info__: Final[Version] = Version( - major=21, minor=1, micro=1, releaselevel="final", serial=0 + major=21, minor=2, micro=0, releaselevel="final", serial=0 ) __version__: Final[str] = str(__version_info__) diff --git a/telegram/constants.py b/telegram/constants.py index 2eac123fc14..a2d91885cb4 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -29,7 +29,7 @@ * Most of the constants in this module are grouped into enums. """ # TODO: Remove this when https://github.com/PyCQA/pylint/issues/6887 is resolved. -# pylint: disable=invalid-enum-extension +# pylint: disable=invalid-enum-extension,invalid-slots __all__ = [ "BOT_API_VERSION", @@ -37,6 +37,10 @@ "SUPPORTED_WEBHOOK_PORTS", "ZERO_DATE", "AccentColor", + "BackgroundFillLimit", + "BackgroundFillType", + "BackgroundTypeLimit", + "BackgroundTypeType", "BotCommandLimit", "BotCommandScopeType", "BotDescriptionLimit", @@ -142,7 +146,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=2) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=3) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -822,6 +826,46 @@ class ChatLimit(IntEnum): """ +class BackgroundTypeLimit(IntEnum): + """This enum contains limitations for :class:`telegram.BackgroundTypeFill`, + :class:`telegram.BackgroundTypeWallpaper` and :class:`telegram.BackgroundTypePattern`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + MAX_DIMMING = 100 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.BackgroundTypeFill.dark_theme_dimming` parameter of + :class:`telegram.BackgroundTypeFill` + * :paramref:`~telegram.BackgroundTypeWallpaper.dark_theme_dimming` parameter of + :class:`telegram.BackgroundTypeWallpaper` + """ + MAX_INTENSITY = 100 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.BackgroundTypePattern.intensity` + parameter of :class:`telegram.BackgroundTypePattern` + """ + + +class BackgroundFillLimit(IntEnum): + """This enum contains limitations for :class:`telegram.BackgroundFillGradient`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + MAX_ROTATION_ANGLE = 359 + """:obj:`int`: Maximum value allowed for: + :paramref:`~telegram.BackgroundFillGradient.rotation_angle` parameter of + :class:`telegram.BackgroundFillGradient` + """ + + class ChatMemberStatus(StringEnum): """This enum contains the available states for :class:`telegram.ChatMember`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. @@ -1427,6 +1471,21 @@ class LocationLimit(IntEnum): :meth:`telegram.Bot.send_location` """ + LIVE_PERIOD_FOREVER = int(hex(0x7FFFFFFF), 16) + """:obj:`int`: Value for live locations that can be edited indefinitely. Passed in: + + * :paramref:`~telegram.InlineQueryResultLocation.live_period` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.live_period` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.live_period` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.live_period` parameter of + :meth:`telegram.Bot.send_location` + + .. versionadded:: 21.2 + """ + MIN_PROXIMITY_ALERT_RADIUS = 1 """:obj:`int`: Minimum value allowed for: @@ -1726,6 +1785,11 @@ class MessageType(StringEnum): .. versionadded:: 20.8 """ + CHAT_BACKGROUND_SET = "chat_background_set" + """:obj:`str`: Messages with :attr:`telegram.Message.chat_background_set`. + + .. versionadded:: 21.2 + """ CONNECTED_WEBSITE = "connected_website" """:obj:`str`: Messages with :attr:`telegram.Message.connected_website`.""" CONTACT = "contact" @@ -2878,3 +2942,39 @@ class ReactionEmoji(StringEnum): """:obj:`str`: Woman Shrugging""" POUTING_FACE = "😡" """:obj:`str`: Pouting face""" + + +class BackgroundTypeType(StringEnum): + """This enum contains the available types of :class:`telegram.BackgroundType`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + FILL = "fill" + """:obj:`str`: A :class:`telegram.BackgroundType` with fill background.""" + WALLPAPER = "wallpaper" + """:obj:`str`: A :class:`telegram.BackgroundType` with wallpaper background.""" + PATTERN = "pattern" + """:obj:`str`: A :class:`telegram.BackgroundType` with pattern background.""" + CHAT_THEME = "chat_theme" + """:obj:`str`: A :class:`telegram.BackgroundType` with chat_theme background.""" + + +class BackgroundFillType(StringEnum): + """This enum contains the available types of :class:`telegram.BackgroundFill`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + SOLID = "solid" + """:obj:`str`: A :class:`telegram.BackgroundFill` with solid fill.""" + GRADIENT = "gradient" + """:obj:`str`: A :class:`telegram.BackgroundFill` with gradient fill.""" + FREEFORM_GRADIENT = "freeform_gradient" + """:obj:`str`: A :class:`telegram.BackgroundFill` with freeform_gradient fill.""" diff --git a/telegram/ext/_aioratelimiter.py b/telegram/ext/_aioratelimiter.py index ac9c4f3f79b..714bbc63f61 100644 --- a/telegram/ext/_aioratelimiter.py +++ b/telegram/ext/_aioratelimiter.py @@ -208,7 +208,7 @@ async def process_request( callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, List[JSONDict]]]], args: Any, kwargs: Dict[str, Any], - endpoint: str, + endpoint: str, # noqa: ARG002 data: Dict[str, Any], rate_limit_args: Optional[int], ) -> Union[bool, JSONDict, List[JSONDict]]: diff --git a/telegram/ext/_application.py b/telegram/ext/_application.py index 8c32910a5fa..f5a9d6df49a 100644 --- a/telegram/ext/_application.py +++ b/telegram/ext/_application.py @@ -365,6 +365,7 @@ def __init__( self.__update_persistence_event = asyncio.Event() self.__update_persistence_lock = asyncio.Lock() self.__create_task_tasks: Set[asyncio.Task] = set() # Used for awaiting tasks upon exit + self.__stop_running_marker = asyncio.Event() async def __aenter__(self: _AppType) -> _AppType: # noqa: PYI019 """|async_context_manager| :meth:`initializes ` the App. @@ -516,6 +517,7 @@ async def initialize(self) -> None: await self._add_ch_to_persistence(handler) self._initialized = True + self.__stop_running_marker.clear() async def _add_ch_to_persistence(self, handler: "ConversationHandler") -> None: self._conversation_handler_conversations.update( @@ -670,14 +672,26 @@ async def stop(self) -> None: raise RuntimeError("This Application is not running!") self._running = False + self.__stop_running_marker.clear() _LOGGER.info("Application is stopping. This might take a moment.") # Stop listening for new updates and handle all pending ones - await self.update_queue.put(_STOP_SIGNAL) - _LOGGER.debug("Waiting for update_queue to join") - await self.update_queue.join() if self.__update_fetcher_task: - await self.__update_fetcher_task + if self.__update_fetcher_task.done(): + try: + self.__update_fetcher_task.result() + except BaseException as exc: + _LOGGER.critical( + "Fetching updates was aborted due to %r. Suppressing " + "exception to ensure graceful shutdown.", + exc, + exc_info=True, + ) + else: + await self.update_queue.put(_STOP_SIGNAL) + _LOGGER.debug("Waiting for update_queue to join") + await self.update_queue.join() + await self.__update_fetcher_task _LOGGER.debug("Application stopped fetching of updates.") if self._job_queue: @@ -703,17 +717,36 @@ def stop_running(self) -> None: shutdown of the application, i.e. the methods listed in :attr:`run_polling` and :attr:`run_webhook` will still be executed. + This method can also be called within :meth:`post_init`. This allows for a graceful, + early shutdown of the application if some condition is met (e.g., a database connection + could not be established). + Note: - If the application is not running, this method does nothing. + If the application is not running and this method is not called within + :meth:`post_init`, this method does nothing. + + Warning: + This method is designed to for use in combination with :meth:`run_polling` or + :meth:`run_webhook`. Using this method in combination with a custom logic for starting + and stopping the application is not guaranteed to work as expected. Use at your own + risk. .. versionadded:: 20.5 + + .. versionchanged:: 21.2 + Added support for calling within :meth:`post_init`. """ if self.running: # This works because `__run` is using `loop.run_forever()`. If that changes, this # method needs to be adapted. asyncio.get_running_loop().stop() else: - _LOGGER.debug("Application is not running, stop_running() does nothing.") + self.__stop_running_marker.set() + if not self._initialized: + _LOGGER.debug( + "Application is not running and not initialized. `stop_running()` likely has " + "no effect." + ) def run_polling( self, @@ -733,9 +766,7 @@ def run_polling( polling updates from Telegram using :meth:`telegram.ext.Updater.start_polling` and a graceful shutdown of the app on exit. - The app will shut down when :exc:`KeyboardInterrupt` or :exc:`SystemExit` is raised. - On unix, the app will also shut down on receiving the signals specified by - :paramref:`stop_signals`. + |app_run_shutdown| :paramref:`stop_signals`. The order of execution by :meth:`run_polling` is roughly as follows: @@ -826,9 +857,11 @@ def run_polling( if (read_timeout, write_timeout, connect_timeout, pool_timeout) != ((DEFAULT_NONE,) * 4): warn( - "Setting timeouts via `Application.run_polling` is deprecated. " - "Please use `ApplicationBuilder.get_updates_*_timeout` instead.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.6", + "Setting timeouts via `Application.run_polling` is deprecated. " + "Please use `ApplicationBuilder.get_updates_*_timeout` instead.", + ), stacklevel=2, ) @@ -874,9 +907,7 @@ def run_webhook( listening for updates from Telegram using :meth:`telegram.ext.Updater.start_webhook` and a graceful shutdown of the app on exit. - The app will shut down when :exc:`KeyboardInterrupt` or :exc:`SystemExit` is raised. - On unix, the app will also shut down on receiving the signals specified by - :paramref:`stop_signals`. + |app_run_shutdown| :paramref:`stop_signals`. If :paramref:`cert` and :paramref:`key` are not provided, the webhook will be started directly on @@ -1038,25 +1069,28 @@ def __run( loop.run_until_complete(self.initialize()) if self.post_init: loop.run_until_complete(self.post_init(self)) + if self.__stop_running_marker.is_set(): + _LOGGER.info("Application received stop signal via `stop_running`. Shutting down.") + return loop.run_until_complete(updater_coroutine) # one of updater.start_webhook/polling loop.run_until_complete(self.start()) loop.run_forever() except (KeyboardInterrupt, SystemExit): _LOGGER.debug("Application received stop signal. Shutting down.") - except Exception as exc: - # In case the coroutine wasn't awaited, we don't need to bother the user with a warning - updater_coroutine.close() - raise exc finally: # We arrive here either by catching the exceptions above or if the loop gets stopped + # In case the coroutine wasn't awaited, we don't need to bother the user with a warning + updater_coroutine.close() + try: # Mypy doesn't know that we already check if updater is None if self.updater.running: # type: ignore[union-attr] loop.run_until_complete(self.updater.stop()) # type: ignore[union-attr] if self.running: loop.run_until_complete(self.stop()) - if self.post_stop: - loop.run_until_complete(self.post_stop(self)) + # post_stop should be called only if stop was called! + if self.post_stop: + loop.run_until_complete(self.post_stop(self)) loop.run_until_complete(self.shutdown()) if self.post_shutdown: loop.run_until_complete(self.post_shutdown(self)) @@ -1151,9 +1185,11 @@ async def __create_task_callback( # Generator-based coroutines are not supported in Python 3.12+ if sys.version_info < (3, 12) and isinstance(coroutine, Generator): warn( - "Generator-based coroutines are deprecated in create_task and will not work" - " in Python 3.12+", - category=PTBDeprecationWarning, + PTBDeprecationWarning( + "20.4", + "Generator-based coroutines are deprecated in create_task and will not" + " work in Python 3.12+", + ), ) return await asyncio.create_task(coroutine) # If user uses generator in python 3.12+, Exception will happen and we cannot do @@ -1185,45 +1221,44 @@ async def __create_task_callback( finally: self._mark_for_persistence_update(update=update) - async def _update_fetcher(self) -> None: + async def __update_fetcher(self) -> None: # Continuously fetch updates from the queue. Exit only once the signal object is found. while True: - try: - update = await self.update_queue.get() - - if update is _STOP_SIGNAL: - _LOGGER.debug("Dropping pending updates") - while not self.update_queue.empty(): - self.update_queue.task_done() + update = await self.update_queue.get() - # For the _STOP_SIGNAL - self.update_queue.task_done() - return + if update is _STOP_SIGNAL: + # For the _STOP_SIGNAL + self.update_queue.task_done() + return - _LOGGER.debug("Processing update %s", update) + _LOGGER.debug("Processing update %s", update) - if self._update_processor.max_concurrent_updates > 1: - # We don't await the below because it has to be run concurrently - self.create_task( - self.__process_update_wrapper(update), - update=update, - name=f"Application:{self.bot.id}:process_concurrent_update", - ) - else: - await self.__process_update_wrapper(update) - - except asyncio.CancelledError: - # This may happen if the application is manually run via application.start() and - # then a KeyboardInterrupt is sent. We must prevent this loop to die since - # application.stop() will wait for it's clean shutdown. - _LOGGER.warning( - "Fetching updates got a asyncio.CancelledError. Ignoring as this task may only" - "be closed via `Application.stop`." + if self._update_processor.max_concurrent_updates > 1: + # We don't await the below because it has to be run concurrently + self.create_task( + self.__process_update_wrapper(update), + update=update, + name=f"Application:{self.bot.id}:process_concurrent_update", ) + else: + await self.__process_update_wrapper(update) + + async def _update_fetcher(self) -> None: + try: + await self.__update_fetcher() + finally: + while not self.update_queue.empty(): + _LOGGER.debug("Dropping pending update: %s", self.update_queue.get_nowait()) + with contextlib.suppress(ValueError): + # Since we're shutting down here, it's not too bad if we call task_done + # on an empty queue + self.update_queue.task_done() async def __process_update_wrapper(self, update: object) -> None: - await self._update_processor.process_update(update, self.process_update(update)) - self.update_queue.task_done() + try: + await self._update_processor.process_update(update, self.process_update(update)) + finally: + self.update_queue.task_done() async def process_update(self, update: object) -> None: """Processes a single update and marks the update to be updated by the persistence later. @@ -1252,30 +1287,43 @@ async def process_update(self, update: object) -> None: try: for handler in handlers: check = handler.check_update(update) # Should the handler handle this update? - if not (check is None or check is False): # if yes, - if not context: # build a context if not already built + if check is None or check is False: + continue + + if not context: # build a context if not already built + try: context = self.context_types.context.from_update(update, self) - await context.refresh_data() - coroutine: Coroutine = handler.handle_update(update, self, check, context) - - if not handler.block or ( # if handler is running with block=False, - handler.block is DEFAULT_TRUE - and isinstance(self.bot, ExtBot) - and self.bot.defaults - and not self.bot.defaults.block - ): - self.create_task( - coroutine, - update=update, - name=( - f"Application:{self.bot.id}:process_update_non_blocking" - f":{handler}" + except Exception as exc: + _LOGGER.critical( + ( + "Error while building CallbackContext for update %s. " + "Update will not be processed." ), + update, + exc_info=exc, ) - else: - any_blocking = True - await coroutine - break # Only a max of 1 handler per group is handled + return + await context.refresh_data() + coroutine: Coroutine = handler.handle_update(update, self, check, context) + + if not handler.block or ( # if handler is running with block=False, + handler.block is DEFAULT_TRUE + and isinstance(self.bot, ExtBot) + and self.bot.defaults + and not self.bot.defaults.block + ): + self.create_task( + coroutine, + update=update, + name=( + f"Application:{self.bot.id}:process_update_non_blocking" + f":{handler}" + ), + ) + else: + any_blocking = True + await coroutine + break # Only a max of 1 handler per group is handled # Stop processing with any other handler. except ApplicationHandlerStop: @@ -1809,13 +1857,25 @@ async def process_error( callback, block, ) in self.error_handlers.items(): - context = self.context_types.context.from_error( - update=update, - error=error, - application=self, - job=job, - coroutine=coroutine, - ) + try: + context = self.context_types.context.from_error( + update=update, + error=error, + application=self, + job=job, + coroutine=coroutine, + ) + except Exception as exc: + _LOGGER.critical( + ( + "Error while building CallbackContext for exception %s. " + "Exception will not be processed by error handlers." + ), + error, + exc_info=exc, + ) + return False + if not block or ( # If error handler has `block=False`, create a Task to run cb block is DEFAULT_TRUE and isinstance(self.bot, ExtBot) diff --git a/telegram/ext/_applicationbuilder.py b/telegram/ext/_applicationbuilder.py index 783a4985872..2da56279941 100644 --- a/telegram/ext/_applicationbuilder.py +++ b/telegram/ext/_applicationbuilder.py @@ -528,9 +528,11 @@ def proxy_url(self: BuilderType, proxy_url: str) -> BuilderType: :class:`ApplicationBuilder`: The same builder with the updated argument. """ warn( - "`ApplicationBuilder.proxy_url` is deprecated since version " - "20.7. Use `ApplicationBuilder.proxy` instead.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.7", + "`ApplicationBuilder.proxy_url` is deprecated. Use `ApplicationBuilder.proxy` " + "instead.", + ), stacklevel=2, ) return self.proxy(proxy_url) @@ -760,9 +762,11 @@ def get_updates_proxy_url(self: BuilderType, get_updates_proxy_url: str) -> Buil :class:`ApplicationBuilder`: The same builder with the updated argument. """ warn( - "`ApplicationBuilder.get_updates_proxy_url` is deprecated since version " - "20.7. Use `ApplicationBuilder.get_updates_proxy` instead.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.7", + "`ApplicationBuilder.get_updates_proxy_url` is deprecated. Use " + "`ApplicationBuilder.get_updates_proxy` instead.", + ), stacklevel=2, ) return self.get_updates_proxy(get_updates_proxy_url) @@ -1334,7 +1338,13 @@ def post_stop( Tip: This can be used for custom stop logic that requires to await coroutines, e.g. - sending message to a chat before shutting down the bot + sending message to a chat before shutting down the bot. + + Hint: + The callback will be called only, if :meth:`Application.stop` was indeed called + successfully. For example, if the application is stopped early by calling + :meth:`Application.stop_running` within :meth:`post_init`, then the set callback will + *not* be called. Example: .. code:: diff --git a/telegram/ext/_basepersistence.py b/telegram/ext/_basepersistence.py index dafd352340c..5199a165bb6 100644 --- a/telegram/ext/_basepersistence.py +++ b/telegram/ext/_basepersistence.py @@ -163,7 +163,7 @@ def update_interval(self) -> float: return self._update_interval @update_interval.setter - def update_interval(self, value: object) -> NoReturn: + def update_interval(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to update_interval after initialization." ) diff --git a/telegram/ext/_baseupdateprocessor.py b/telegram/ext/_baseupdateprocessor.py index 7bcca890edf..89d51d96fc2 100644 --- a/telegram/ext/_baseupdateprocessor.py +++ b/telegram/ext/_baseupdateprocessor.py @@ -165,7 +165,7 @@ class SimpleUpdateProcessor(BaseUpdateProcessor): async def do_process_update( self, - update: object, + update: object, # noqa: ARG002 coroutine: "Awaitable[Any]", ) -> None: """Immediately awaits the coroutine, i.e. does not apply any additional processing. diff --git a/telegram/ext/_callbackcontext.py b/telegram/ext/_callbackcontext.py index 65abe7d520e..92113bae9a4 100644 --- a/telegram/ext/_callbackcontext.py +++ b/telegram/ext/_callbackcontext.py @@ -165,7 +165,7 @@ def bot_data(self) -> BD: return self.application.bot_data @bot_data.setter - def bot_data(self, value: object) -> NoReturn: + def bot_data(self, _: object) -> NoReturn: raise AttributeError( f"You can not assign a new value to bot_data, see {_STORING_DATA_WIKI}" ) @@ -192,7 +192,7 @@ def chat_data(self) -> Optional[CD]: return None @chat_data.setter - def chat_data(self, value: object) -> NoReturn: + def chat_data(self, _: object) -> NoReturn: raise AttributeError( f"You can not assign a new value to chat_data, see {_STORING_DATA_WIKI}" ) @@ -214,7 +214,7 @@ def user_data(self) -> Optional[UD]: return None @user_data.setter - def user_data(self, value: object) -> NoReturn: + def user_data(self, _: object) -> NoReturn: raise AttributeError( f"You can not assign a new value to user_data, see {_STORING_DATA_WIKI}" ) diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py index f277a4b0e61..100d54e6ef0 100644 --- a/telegram/ext/_defaults.py +++ b/telegram/ext/_defaults.py @@ -156,9 +156,11 @@ def __init__( raise ValueError("`quote` and `do_quote` are mutually exclusive") if disable_web_page_preview is not None: warn( - "`Defaults.disable_web_page_preview` is deprecated. Use " - "`Defaults.link_preview_options` instead.", - category=PTBDeprecationWarning, + PTBDeprecationWarning( + "20.8", + "`Defaults.disable_web_page_preview` is deprecated. Use " + "`Defaults.link_preview_options` instead.", + ), stacklevel=2, ) self._link_preview_options: Optional[LinkPreviewOptions] = LinkPreviewOptions( @@ -169,8 +171,9 @@ def __init__( if quote is not None: warn( - "`Defaults.quote` is deprecated. Use `Defaults.do_quote` instead.", - category=PTBDeprecationWarning, + PTBDeprecationWarning( + "20.8", "`Defaults.quote` is deprecated. Use `Defaults.do_quote` instead." + ), stacklevel=2, ) self._do_quote: Optional[bool] = quote @@ -179,13 +182,14 @@ def __init__( # Gather all defaults that actually have a default value self._api_defaults = {} for kwarg in ( - "parse_mode", - "explanation_parse_mode", - "disable_notification", "allow_sending_without_reply", - "protect_content", - "link_preview_options", + "disable_notification", "do_quote", + "explanation_parse_mode", + "link_preview_options", + "parse_mode", + "protect_content", + "question_parse_mode", ): value = getattr(self, kwarg) if value is not None: @@ -235,7 +239,7 @@ def parse_mode(self) -> Optional[str]: return self._parse_mode @parse_mode.setter - def parse_mode(self, value: object) -> NoReturn: + def parse_mode(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to parse_mode after initialization.") @property @@ -246,7 +250,7 @@ def explanation_parse_mode(self) -> Optional[str]: return self._parse_mode @explanation_parse_mode.setter - def explanation_parse_mode(self, value: object) -> NoReturn: + def explanation_parse_mode(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to explanation_parse_mode after initialization." ) @@ -259,11 +263,41 @@ def quote_parse_mode(self) -> Optional[str]: return self._parse_mode @quote_parse_mode.setter - def quote_parse_mode(self, value: object) -> NoReturn: + def quote_parse_mode(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to quote_parse_mode after initialization." ) + @property + def text_parse_mode(self) -> Optional[str]: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :class:`telegram.InputPollOption`. + + .. versionadded:: 21.2 + """ + return self._parse_mode + + @text_parse_mode.setter + def text_parse_mode(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to text_parse_mode after initialization." + ) + + @property + def question_parse_mode(self) -> Optional[str]: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :meth:`telegram.Bot.send_poll`. + + .. versionadded:: 21.2 + """ + return self._parse_mode + + @question_parse_mode.setter + def question_parse_mode(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to question_parse_mode after initialization." + ) + @property def disable_notification(self) -> Optional[bool]: """:obj:`bool`: Optional. Sends the message silently. Users will @@ -272,7 +306,7 @@ def disable_notification(self) -> Optional[bool]: return self._disable_notification @disable_notification.setter - def disable_notification(self, value: object) -> NoReturn: + def disable_notification(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to disable_notification after initialization." ) @@ -289,7 +323,7 @@ def disable_web_page_preview(self) -> ODVInput[bool]: return self._link_preview_options.is_disabled if self._link_preview_options else None @disable_web_page_preview.setter - def disable_web_page_preview(self, value: object) -> NoReturn: + def disable_web_page_preview(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to disable_web_page_preview after initialization." ) @@ -302,7 +336,7 @@ def allow_sending_without_reply(self) -> Optional[bool]: return self._allow_sending_without_reply @allow_sending_without_reply.setter - def allow_sending_without_reply(self, value: object) -> NoReturn: + def allow_sending_without_reply(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to allow_sending_without_reply after initialization." ) @@ -318,7 +352,7 @@ def quote(self) -> Optional[bool]: return self._do_quote if self._do_quote is not None else None @quote.setter - def quote(self, value: object) -> NoReturn: + def quote(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to quote after initialization.") @property @@ -329,7 +363,7 @@ def tzinfo(self) -> datetime.tzinfo: return self._tzinfo @tzinfo.setter - def tzinfo(self, value: object) -> NoReturn: + def tzinfo(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to tzinfo after initialization.") @property @@ -341,7 +375,7 @@ def block(self) -> bool: return self._block @block.setter - def block(self, value: object) -> NoReturn: + def block(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to block after initialization.") @property @@ -354,7 +388,7 @@ def protect_content(self) -> Optional[bool]: return self._protect_content @protect_content.setter - def protect_content(self, value: object) -> NoReturn: + def protect_content(self, _: object) -> NoReturn: raise AttributeError( "You can't assign a new value to protect_content after initialization." ) diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 7b5649ebea3..6cefee43c18 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -50,8 +50,8 @@ BotShortDescription, BusinessConnection, CallbackQuery, - Chat, ChatAdministratorRights, + ChatFullInfo, ChatInviteLink, ChatMember, ChatPermissions, @@ -64,6 +64,7 @@ InlineKeyboardMarkup, InlineQueryResultsButton, InputMedia, + InputPollOption, LinkPreviewOptions, Location, MaskPosition, @@ -113,7 +114,7 @@ ) from telegram.ext import BaseRateLimiter, Defaults -HandledTypes = TypeVar("HandledTypes", bound=Union[Message, CallbackQuery, Chat]) +HandledTypes = TypeVar("HandledTypes", bound=Union[Message, CallbackQuery, ChatFullInfo]) KT = TypeVar("KT", bound=ReplyMarkup) @@ -262,7 +263,10 @@ def __repr__(self) -> str: @classmethod def _warn( - cls, message: str, category: Type[Warning] = PTBUserWarning, stacklevel: int = 0 + cls, + message: Union[str, PTBUserWarning], + category: Type[Warning] = PTBUserWarning, + stacklevel: int = 0, ) -> None: """We override this method to add one more level to the stacklevel, so that the warning points to the user's code, not to the PTB code. @@ -436,6 +440,7 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: # 3) set the correct parse_mode for all InputMedia objects # 4) handle the LinkPreviewOptions case (see below) # 5) handle the ReplyParameters case (see below) + # 6) handle text_parse_mode in InputPollOption for key, val in data.items(): # 1) if isinstance(val, DefaultValue): @@ -487,6 +492,21 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: data[key] = new_value + # 6) + elif isinstance(val, Sequence) and all( + isinstance(obj, InputPollOption) for obj in val + ): + new_val = [] + for option in val: + if not isinstance(option.text_parse_mode, DefaultValue): + new_val.append(option) + else: + new_option = copy(option) + with new_option._unfrozen(): + new_option.text_parse_mode = self.defaults.text_parse_mode + new_val.append(new_option) + data[key] = new_val + def _replace_keyboard(self, reply_markup: Optional[KT]) -> Optional[KT]: # 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 @@ -554,7 +574,7 @@ def _insert_callback_data(self, obj: HandledTypes) -> HandledTypes: self.callback_data_cache.process_message(message=obj) return obj # type: ignore[return-value] - if isinstance(obj, Chat) and obj.pinned_message: + if isinstance(obj, ChatFullInfo) and obj.pinned_message: self.callback_data_cache.process_message(obj.pinned_message) return obj @@ -853,7 +873,7 @@ async def get_chat( pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: Optional[JSONDict] = None, rate_limit_args: Optional[RLARGS] = None, - ) -> Chat: + ) -> ChatFullInfo: # We override this method to call self._insert_callback_data result = await super().get_chat( chat_id=chat_id, @@ -1185,7 +1205,6 @@ async def create_new_sticker_set( name: str, title: str, stickers: Sequence["InputSticker"], - sticker_format: Optional[str] = None, sticker_type: Optional[str] = None, needs_repainting: Optional[bool] = None, *, @@ -1201,7 +1220,6 @@ async def create_new_sticker_set( name=name, title=title, stickers=stickers, - sticker_format=sticker_format, sticker_type=sticker_type, needs_repainting=needs_repainting, read_timeout=read_timeout, @@ -1522,6 +1540,7 @@ async def edit_message_live_location( horizontal_accuracy: Optional[float] = None, heading: Optional[int] = None, proximity_alert_radius: Optional[int] = None, + live_period: Optional[int] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1541,6 +1560,7 @@ async def edit_message_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, + live_period=live_period, location=location, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2917,7 +2937,7 @@ async def send_poll( self, chat_id: Union[int, str], question: str, - options: Sequence[str], + options: Sequence[Union[str, "InputPollOption"]], is_anonymous: Optional[bool] = None, type: Optional[str] = None, # pylint: disable=redefined-builtin allows_multiple_answers: Optional[bool] = None, @@ -2934,6 +2954,8 @@ async def send_poll( message_thread_id: Optional[int] = None, reply_parameters: Optional["ReplyParameters"] = None, business_connection_id: Optional[str] = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Optional[Sequence["MessageEntity"]] = None, *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -2971,6 +2993,8 @@ async def send_poll( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + question_parse_mode=question_parse_mode, + question_entities=question_entities, ) async def send_sticker( diff --git a/telegram/ext/_handlers/callbackqueryhandler.py b/telegram/ext/_handlers/callbackqueryhandler.py index 2fb79b567d3..afd64887964 100644 --- a/telegram/ext/_handlers/callbackqueryhandler.py +++ b/telegram/ext/_handlers/callbackqueryhandler.py @@ -159,8 +159,8 @@ def check_update(self, update: object) -> Optional[Union[bool, object]]: def collect_additional_context( self, context: CCT, - update: Update, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Union[bool, Match[str]], ) -> None: """Add the result of ``re.match(pattern, update.callback_query.data)`` to diff --git a/telegram/ext/_handlers/choseninlineresulthandler.py b/telegram/ext/_handlers/choseninlineresulthandler.py index 9191ad250f2..db7a8721448 100644 --- a/telegram/ext/_handlers/choseninlineresulthandler.py +++ b/telegram/ext/_handlers/choseninlineresulthandler.py @@ -109,8 +109,8 @@ def check_update(self, update: object) -> Optional[Union[bool, object]]: def collect_additional_context( self, context: CCT, - update: Update, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Union[bool, Match[str]], ) -> None: """This function adds the matched regex pattern result to diff --git a/telegram/ext/_handlers/commandhandler.py b/telegram/ext/_handlers/commandhandler.py index 94ee77493bb..edad3963aad 100644 --- a/telegram/ext/_handlers/commandhandler.py +++ b/telegram/ext/_handlers/commandhandler.py @@ -152,7 +152,6 @@ def _check_correct_args(self, args: List[str]) -> Optional[bool]: Returns: :obj:`bool`: Whether the args are valid for this handler. """ - # pylint: disable=too-many-boolean-expressions return bool( (self.has_args is None) or (self.has_args is True and args) @@ -205,8 +204,8 @@ def check_update( def collect_additional_context( self, context: CCT, - update: Update, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Optional[Union[bool, Tuple[List[str], Optional[bool]]]], ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single diff --git a/telegram/ext/_handlers/conversationhandler.py b/telegram/ext/_handlers/conversationhandler.py index 3c7dcc767c6..f3fad9f8324 100644 --- a/telegram/ext/_handlers/conversationhandler.py +++ b/telegram/ext/_handlers/conversationhandler.py @@ -473,7 +473,7 @@ def entry_points(self) -> List[BaseHandler[Update, CCT]]: return self._entry_points @entry_points.setter - def entry_points(self, value: object) -> NoReturn: + def entry_points(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to entry_points after initialization." ) @@ -487,7 +487,7 @@ def states(self) -> Dict[object, List[BaseHandler[Update, CCT]]]: return self._states @states.setter - def states(self, value: object) -> NoReturn: + def states(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to states after initialization.") @property @@ -499,7 +499,7 @@ def fallbacks(self) -> List[BaseHandler[Update, CCT]]: return self._fallbacks @fallbacks.setter - def fallbacks(self, value: object) -> NoReturn: + def fallbacks(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to fallbacks after initialization.") @property @@ -508,7 +508,7 @@ def allow_reentry(self) -> bool: return self._allow_reentry @allow_reentry.setter - def allow_reentry(self, value: object) -> NoReturn: + def allow_reentry(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to allow_reentry after initialization." ) @@ -519,7 +519,7 @@ def per_user(self) -> bool: return self._per_user @per_user.setter - def per_user(self, value: object) -> NoReturn: + def per_user(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to per_user after initialization.") @property @@ -528,7 +528,7 @@ def per_chat(self) -> bool: return self._per_chat @per_chat.setter - def per_chat(self, value: object) -> NoReturn: + def per_chat(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to per_chat after initialization.") @property @@ -537,7 +537,7 @@ def per_message(self) -> bool: return self._per_message @per_message.setter - def per_message(self, value: object) -> NoReturn: + def per_message(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to per_message after initialization.") @property @@ -551,7 +551,7 @@ def conversation_timeout( return self._conversation_timeout @conversation_timeout.setter - def conversation_timeout(self, value: object) -> NoReturn: + def conversation_timeout(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to conversation_timeout after initialization." ) @@ -562,7 +562,7 @@ def name(self) -> Optional[str]: return self._name @name.setter - def name(self, value: object) -> NoReturn: + def name(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to name after initialization.") @property @@ -574,7 +574,7 @@ def persistent(self) -> bool: return self._persistent @persistent.setter - def persistent(self, value: object) -> NoReturn: + def persistent(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to persistent after initialization.") @property @@ -586,7 +586,7 @@ def map_to_parent(self) -> Optional[Dict[object, object]]: return self._map_to_parent @map_to_parent.setter - def map_to_parent(self, value: object) -> NoReturn: + def map_to_parent(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to map_to_parent after initialization." ) diff --git a/telegram/ext/_handlers/inlinequeryhandler.py b/telegram/ext/_handlers/inlinequeryhandler.py index 1db74fb28bc..21a5925cb1d 100644 --- a/telegram/ext/_handlers/inlinequeryhandler.py +++ b/telegram/ext/_handlers/inlinequeryhandler.py @@ -130,8 +130,8 @@ def check_update(self, update: object) -> Optional[Union[bool, Match[str]]]: def collect_additional_context( self, context: CCT, - update: Update, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Optional[Union[bool, Match[str]]], ) -> None: """Add the result of ``re.match(pattern, update.inline_query.query)`` to diff --git a/telegram/ext/_handlers/messagehandler.py b/telegram/ext/_handlers/messagehandler.py index fab4422fe77..0cd42884ba6 100644 --- a/telegram/ext/_handlers/messagehandler.py +++ b/telegram/ext/_handlers/messagehandler.py @@ -102,8 +102,8 @@ def check_update(self, update: object) -> Optional[Union[bool, Dict[str, List[An def collect_additional_context( self, context: CCT, - update: Update, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Optional[Union[bool, Dict[str, object]]], ) -> None: """Adds possible output of data filters to the :class:`CallbackContext`.""" diff --git a/telegram/ext/_handlers/prefixhandler.py b/telegram/ext/_handlers/prefixhandler.py index 00a9ff1964e..3b10a0a1caf 100644 --- a/telegram/ext/_handlers/prefixhandler.py +++ b/telegram/ext/_handlers/prefixhandler.py @@ -171,8 +171,8 @@ def check_update( def collect_additional_context( self, context: CCT, - update: Update, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Optional[Union[bool, Tuple[List[str], Optional[bool]]]], ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single diff --git a/telegram/ext/_handlers/stringcommandhandler.py b/telegram/ext/_handlers/stringcommandhandler.py index 2fa085899a1..d5c29bf6639 100644 --- a/telegram/ext/_handlers/stringcommandhandler.py +++ b/telegram/ext/_handlers/stringcommandhandler.py @@ -49,7 +49,7 @@ class StringCommandHandler(BaseHandler[str, CCT]): called when :meth:`check_update` has determined that an update should be processed by this handler. Callback signature:: - async def callback(update: Update, context: CallbackContext) + async def callback(update: str, context: CallbackContext) The return value of the callback is usually ignored except for the special case of :class:`telegram.ext.ConversationHandler`. @@ -98,8 +98,8 @@ def check_update(self, update: object) -> Optional[List[str]]: def collect_additional_context( self, context: CCT, - update: str, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: str, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Optional[List[str]], ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single diff --git a/telegram/ext/_handlers/stringregexhandler.py b/telegram/ext/_handlers/stringregexhandler.py index 2eeb2b4d08e..c2e22c10655 100644 --- a/telegram/ext/_handlers/stringregexhandler.py +++ b/telegram/ext/_handlers/stringregexhandler.py @@ -52,7 +52,7 @@ class StringRegexHandler(BaseHandler[str, CCT]): called when :meth:`check_update` has determined that an update should be processed by this handler. Callback signature:: - async def callback(update: Update, context: CallbackContext) + async def callback(update: str, context: CallbackContext) The return value of the callback is usually ignored except for the special case of :class:`telegram.ext.ConversationHandler`. @@ -103,8 +103,8 @@ def check_update(self, update: object) -> Optional[Match[str]]: def collect_additional_context( self, context: CCT, - update: str, - application: "Application[Any, CCT, Any, Any, Any, Any]", + update: str, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 check_result: Optional[Match[str]], ) -> None: """Add the result of ``re.match(pattern, update)`` to :attr:`CallbackContext.matches` as diff --git a/telegram/ext/_handlers/typehandler.py b/telegram/ext/_handlers/typehandler.py index ac195b8982a..151a7fbf137 100644 --- a/telegram/ext/_handlers/typehandler.py +++ b/telegram/ext/_handlers/typehandler.py @@ -43,7 +43,7 @@ class TypeHandler(BaseHandler[UT, CCT]): called when :meth:`check_update` has determined that an update should be processed by this handler. Callback signature:: - async def callback(update: Update, context: CallbackContext) + async def callback(update: object, context: CallbackContext) The return value of the callback is usually ignored except for the special case of :class:`telegram.ext.ConversationHandler`. diff --git a/telegram/ext/_jobqueue.py b/telegram/ext/_jobqueue.py index 1229659f6f9..6edd5a892ea 100644 --- a/telegram/ext/_jobqueue.py +++ b/telegram/ext/_jobqueue.py @@ -31,6 +31,7 @@ except ImportError: APS_AVAILABLE = False +from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.types import JSONDict from telegram.ext._extbot import ExtBot @@ -44,6 +45,7 @@ _ALL_DAYS = tuple(range(7)) +_LOGGER = get_logger(__name__, class_name="JobQueue") class JobQueue(Generic[CCT]): @@ -953,7 +955,16 @@ async def _run( self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]" ) -> None: try: - context = application.context_types.context.from_job(self, application) + try: + context = application.context_types.context.from_job(self, application) + except Exception as exc: + _LOGGER.critical( + "Error while building CallbackContext for job %s. Job will not be run.", + self._job, + exc_info=exc, + ) + return + await context.refresh_data() await self.callback(context) except Exception as exc: diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index 614673628e1..1fef40a5781 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -289,7 +289,7 @@ def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: :attr:`telegram.Update.edited_business_message`, or :obj:`False` otherwise. """ return bool( # Only message updates should be handled. - update.channel_post # pylint: disable=too-many-boolean-expressions + update.channel_post or update.message or update.edited_channel_post or update.edited_message @@ -401,7 +401,7 @@ def name(self) -> str: return f"" @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError("Cannot set name for combined filters.") @@ -492,7 +492,7 @@ def name(self) -> str: ) @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError("Cannot set name for combined filters.") @@ -522,14 +522,14 @@ def name(self) -> str: return f"<{self.base_filter} xor {self.xor_filter}>" @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError("Cannot set name for combined filters.") class _All(MessageFilter): __slots__ = () - def filter(self, message: Message) -> bool: + def filter(self, message: Message) -> bool: # noqa: ARG002 return True @@ -809,7 +809,7 @@ def name(self) -> str: ) @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError(f"Cannot set name for filters.{self.__class__.__name__}") @@ -1909,7 +1909,8 @@ class _All(UpdateFilter): def filter(self, update: Update) -> bool: return bool( # keep this alphabetically sorted for easier maintenance - StatusUpdate.CHAT_CREATED.check_update(update) + StatusUpdate.CHAT_BACKGROUND_SET.check_update(update) + or StatusUpdate.CHAT_CREATED.check_update(update) or StatusUpdate.CHAT_SHARED.check_update(update) or StatusUpdate.CONNECTED_WEBSITE.check_update(update) or StatusUpdate.DELETE_CHAT_PHOTO.check_update(update) @@ -1942,6 +1943,15 @@ def filter(self, update: Update) -> bool: ALL = _All(name="filters.StatusUpdate.ALL") """Messages that contain any of the below.""" + class _ChatBackgroundSet(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.chat_background_set) + + CHAT_BACKGROUND_SET = _ChatBackgroundSet(name="filters.StatusUpdate.CHAT_BACKGROUND_SET") + """Messages that contain :attr:`telegram.Message.chat_background_set`.""" + class _ChatCreated(MessageFilter): __slots__ = () diff --git a/telegram/request/_baserequest.py b/telegram/request/_baserequest.py index cc8b73706a0..93024d6c4d0 100644 --- a/telegram/request/_baserequest.py +++ b/telegram/request/_baserequest.py @@ -318,10 +318,12 @@ async def _request_wrapper( and isinstance(write_timeout, DefaultValue) ): warn( - 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.", - PTBDeprecationWarning, + 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 diff --git a/telegram/request/_httpxrequest.py b/telegram/request/_httpxrequest.py index 626cce83002..e9861539234 100644 --- a/telegram/request/_httpxrequest.py +++ b/telegram/request/_httpxrequest.py @@ -146,9 +146,9 @@ def __init__( if proxy_url is not None: proxy = proxy_url warn( - "The parameter `proxy_url` is deprecated since version 20.7. Use `proxy` " - "instead.", - PTBDeprecationWarning, + PTBDeprecationWarning( + "20.7", "The parameter `proxy_url` is deprecated. Use `proxy` instead." + ), stacklevel=2, ) diff --git a/telegram/warnings.py b/telegram/warnings.py index 5ff74191a70..0c761b97421 100644 --- a/telegram/warnings.py +++ b/telegram/warnings.py @@ -54,6 +54,34 @@ class PTBDeprecationWarning(PTBUserWarning, DeprecationWarning): .. versionchanged:: 20.0 Renamed TelegramDeprecationWarning to PTBDeprecationWarning. + + Args: + version (:obj:`str`): The version in which the feature was deprecated. + + .. versionadded:: 21.2 + message (:obj:`str`): The message to display. + + .. versionadded:: 21.2 + + Attributes: + version (:obj:`str`): The version in which the feature was deprecated. + + .. versionadded:: 21.2 + message (:obj:`str`): The message to display. + + .. versionadded:: 21.2 """ - __slots__ = () + __slots__ = ("message", "version") + + def __init__(self, version: str, message: str) -> None: + self.version: str = version + self.message: str = message + + def __str__(self) -> str: + """Returns a string representation of the warning, using :attr:`message` and + :attr:`version`. + + .. versionadded:: 21.2 + """ + return f"Deprecated since version {self.version}: {self.message}" diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index ba35fb700eb..f14504a75fd 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -351,7 +351,7 @@ async def test_send_animation_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_animation( chat_id, animation, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 4e7b8cc0504..12857ddc6e9 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -378,7 +378,7 @@ async def test_send_audio_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_audio( chat_id, audio, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_contact.py b/tests/_files/test_contact.py index 539dcfad9c6..a4793c3faf5 100644 --- a/tests/_files/test_contact.py +++ b/tests/_files/test_contact.py @@ -185,7 +185,7 @@ async def test_send_contact_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_contact( chat_id, contact=contact, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_document.py b/tests/_files/test_document.py index a1c63fd0e82..5d078fced20 100644 --- a/tests/_files/test_document.py +++ b/tests/_files/test_document.py @@ -364,7 +364,7 @@ async def test_send_document_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_document( chat_id, document, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index 72d981f7bf2..6febe12c87f 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -873,7 +873,7 @@ async def test_send_media_group_default_allow_sending_without_reply( ) assert [m.reply_to_message is None for m in messages] else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_media_group( chat_id, media_group, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_location.py b/tests/_files/test_location.py index 44d96ffe57f..5b94df4916b 100644 --- a/tests/_files/test_location.py +++ b/tests/_files/test_location.py @@ -124,7 +124,8 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ha = data["horizontal_accuracy"] == "50" heading = data["heading"] == "90" prox_alert = data["proximity_alert_radius"] == "1000" - return lat and lon and id_ and ha and heading and prox_alert + live = data["live_period"] == "900" + return lat and lon and id_ and ha and heading and prox_alert and live monkeypatch.setattr(bot.request, "post", make_assertion) assert await bot.edit_message_live_location( @@ -133,6 +134,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): horizontal_accuracy=50, heading=90, proximity_alert_radius=1000, + live_period=900, ) # TODO: Needs improvement with in inline sent live location. @@ -218,7 +220,7 @@ async def test_send_location_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_location( chat_id, location=location, reply_to_message_id=reply_to_message.message_id ) @@ -262,6 +264,7 @@ async def test_send_live_location(self, bot, chat_id): horizontal_accuracy=30, heading=10, proximity_alert_radius=500, + live_period=200, ) assert pytest.approx(message2.location.latitude, rel=1e-5) == 52.223098 @@ -269,6 +272,7 @@ async def test_send_live_location(self, bot, chat_id): assert message2.location.horizontal_accuracy == 30 assert message2.location.heading == 10 assert message2.location.proximity_alert_radius == 500 + assert message2.location.live_period == 200 await bot.stop_message_live_location(message.chat_id, message.message_id) with pytest.raises(BadRequest, match="Message can't be edited"): diff --git a/tests/_files/test_photo.py b/tests/_files/test_photo.py index 267b414274b..d8be6e81473 100644 --- a/tests/_files/test_photo.py +++ b/tests/_files/test_photo.py @@ -384,7 +384,7 @@ async def test_send_photo_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_photo( chat_id, photo_file, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_sticker.py b/tests/_files/test_sticker.py index c408468118a..bf60272ba04 100644 --- a/tests/_files/test_sticker.py +++ b/tests/_files/test_sticker.py @@ -39,7 +39,6 @@ from telegram.constants import ParseMode, StickerFormat, StickerType from telegram.error import BadRequest, TelegramError from telegram.request import RequestData -from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -457,7 +456,7 @@ async def test_send_sticker_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_sticker( chat_id, sticker, reply_to_message_id=reply_to_message.message_id ) @@ -714,7 +713,6 @@ async def make_assertion(_, data, *args, **kwargs): assert data["name"] == "name" assert data["title"] == "title" assert data["stickers"] == ["wow.png", "wow.tgs", "wow.webp"] - assert data["sticker_format"] == "static" assert data["needs_repainting"] is True monkeypatch.setattr(bot, "_post", make_assertion) @@ -723,7 +721,6 @@ async def make_assertion(_, data, *args, **kwargs): "name", "title", stickers=["wow.png", "wow.tgs", "wow.webp"], - sticker_format=StickerFormat.STATIC, needs_repainting=True, ) @@ -784,27 +781,6 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(sticker.get_bot(), "get_file", make_assertion) assert await sticker.get_file() - async def test_create_new_sticker_set_format_arg_depr( - self, bot, chat_id, sticker_file, monkeypatch - ): - async def make_assertion(*_, **kwargs): - pass - - monkeypatch.setattr(bot, "_post", make_assertion) - with pytest.warns(PTBDeprecationWarning, match="`sticker_format` is deprecated"): - await bot.create_new_sticker_set( - chat_id, - "name", - "title", - stickers=sticker_file, - sticker_format="static", - ) - - async def test_deprecation_creation_args(self, recwarn): - with pytest.warns(PTBDeprecationWarning, match="The parameters `is_animated` and ") as w: - StickerSet("name", "title", [], "static", is_animated=True) - assert w[0].filename == __file__, "wrong stacklevel!" - @pytest.mark.xdist_group("stickerset") class TestStickerSetWithRequest: diff --git a/tests/_files/test_venue.py b/tests/_files/test_venue.py index 85a950bf81e..0cb8f500b50 100644 --- a/tests/_files/test_venue.py +++ b/tests/_files/test_venue.py @@ -200,7 +200,7 @@ async def test_send_venue_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_venue( chat_id, venue=venue, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 59b49c3d925..c7fedbb8cf0 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -386,7 +386,7 @@ async def test_send_video_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_video( chat_id, video, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index d9f66ae6e25..625e85eba87 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -287,7 +287,7 @@ async def test_send_video_note_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_video_note( chat_id, video_note, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index cc85e8bdab5..8060221c724 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -335,7 +335,7 @@ async def test_send_voice_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_voice( chat_id, voice, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_payment/test_invoice.py b/tests/_payment/test_invoice.py index bdbc56a0403..532fae0a89b 100644 --- a/tests/_payment/test_invoice.py +++ b/tests/_payment/test_invoice.py @@ -312,7 +312,7 @@ async def test_send_invoice_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_invoice( chat_id, self.title, diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 9c71190bd6b..7b69863b1c3 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -314,6 +314,8 @@ def build_kwargs( elif name == "ok": kws["ok"] = False kws["error_message"] = "error" + elif name == "options": + kws[name] = ["option1", "option2"] else: kws[name] = True diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py index 04df441206f..714abf8537a 100644 --- a/tests/ext/test_application.py +++ b/tests/ext/test_application.py @@ -19,6 +19,7 @@ """The integration of persistence into the application is tested in test_basepersistence. """ import asyncio +import functools import inspect import logging import os @@ -2083,75 +2084,174 @@ def thread_target(): assert set(self.received.keys()) == set(expected.keys()) assert self.received == expected - @pytest.mark.skipif( - platform.system() == "Windows", - reason="Can't send signals without stopping whole process on windows", - ) - async def test_cancellation_error_does_not_stop_polling( - self, one_time_bot, monkeypatch, caplog + @pytest.mark.parametrize("exception", [SystemExit, KeyboardInterrupt]) + def test_raise_system_exit_keyboard_interrupt_post_init( + self, one_time_bot, monkeypatch, exception ): - """ - Ensures that hitting CTRL+C while polling *without* run_polling doesn't kill - the update_fetcher loop such that a shutdown is still possible. - This test is far from perfect, but it's the closest we can come with sane effort. - """ + async def post_init(application): + raise exception - async def get_updates(*args, **kwargs): - await asyncio.sleep(0) - return [None] + called_callbacks = set() + + async def callback(*args, **kwargs): + called_callbacks.add(kwargs["name"]) + + for cls, method, entry in [ + (Application, "initialize", "app_initialize"), + (Application, "start", "app_start"), + (Application, "stop", "app_stop"), + (Application, "shutdown", "app_shutdown"), + (Updater, "initialize", "updater_initialize"), + (Updater, "shutdown", "updater_shutdown"), + (Updater, "stop", "updater_stop"), + (Updater, "start_polling", "updater_start_polling"), + ]: + + def after(_, name): + called_callbacks.add(name) + + monkeypatch.setattr( + cls, + method, + call_after(getattr(cls, method), functools.partial(after, name=entry)), + ) - monkeypatch.setattr(one_time_bot, "get_updates", get_updates) - app = ApplicationBuilder().bot(one_time_bot).build() + app = ( + ApplicationBuilder() + .bot(one_time_bot) + .post_init(post_init) + .post_stop(functools.partial(callback, name="post_stop")) + .post_shutdown(functools.partial(callback, name="post_shutdown")) + .build() + ) - original_get = app.update_queue.get - raise_cancelled_error = threading.Event() + app.run_polling(close_loop=False) - async def get(*arg, **kwargs): - await asyncio.sleep(0.05) - if raise_cancelled_error.is_set(): - raise_cancelled_error.clear() - raise asyncio.CancelledError("Mocked CancelledError") - return await original_get(*arg, **kwargs) + # This checks two things: + # 1. start/stop are *not* called! + # 2. we do have a graceful shutdown + assert called_callbacks == { + "app_initialize", + "updater_initialize", + "app_shutdown", + "post_shutdown", + "updater_shutdown", + } - monkeypatch.setattr(app.update_queue, "get", get) + @pytest.mark.parametrize("exception", [SystemExit("PTBTest"), KeyboardInterrupt("PTBTest")]) + @pytest.mark.parametrize("kind", ["handler", "error_handler", "job"]) + # @pytest.mark.parametrize("block", [True, False]) + # Testing with block=False would be nice but that doesn't work well with pytest for some reason + # in any case, block=False is the simpler behavior since it is roughly similar to what happens + # when you hit CTRL+C in the commandline. + def test_raise_system_exit_keyboard_jobs_handlers( + self, one_time_bot, monkeypatch, exception, kind, caplog + ): + async def queue_and_raise(application): + await application.update_queue.put("will_not_be_processed") + raise exception - def thread_target(): - waited = 0 - while not app.running: - time.sleep(0.05) - waited += 0.05 - if waited > 5: - pytest.fail("App apparently won't start") + async def handler_callback(update, context): + if kind == "handler": + await queue_and_raise(context.application) + elif kind == "error_handler": + raise TelegramError("Triggering error callback") - time.sleep(0.1) - raise_cancelled_error.set() + async def error_callback(update, context): + await queue_and_raise(context.application) - async with app: - with caplog.at_level(logging.WARNING): - thread = Thread(target=thread_target) - await app.start() - thread.start() - assert thread.is_alive() - raise_cancelled_error.wait() + async def job_callback(context): + await queue_and_raise(context.application) - # The exit should have been caught and the app should still be running - assert not thread.is_alive() - assert app.running + async def enqueue_update(): + await asyncio.sleep(0.5) + await app.update_queue.put(1) - # Explicit shutdown is required - await app.stop() - thread.join() + async def post_init(application): + if kind == "job": + application.job_queue.run_once(when=0.5, callback=job_callback) + else: + app.create_task(enqueue_update()) - assert not thread.is_alive() - assert not app.running + async def update_logger_callback(update, context): + context.bot_data.setdefault("processed_updates", set()).add(update) - # Make sure that we were warned about the necessity of a manual shutdown - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.name == "telegram.ext.Application" - assert record.getMessage().startswith( - "Fetching updates got a asyncio.CancelledError. Ignoring" + called_callbacks = set() + + async def callback(*args, **kwargs): + called_callbacks.add(kwargs["name"]) + + async def get_updates(*args, **kwargs): + await asyncio.sleep(0) + return [] + + for cls, method, entry in [ + (Application, "initialize", "app_initialize"), + (Application, "start", "app_start"), + (Application, "stop", "app_stop"), + (Application, "shutdown", "app_shutdown"), + (Updater, "initialize", "updater_initialize"), + (Updater, "shutdown", "updater_shutdown"), + (Updater, "stop", "updater_stop"), + (Updater, "start_polling", "updater_start_polling"), + ]: + + def after(_, name): + called_callbacks.add(name) + + monkeypatch.setattr( + cls, + method, + call_after(getattr(cls, method), functools.partial(after, name=entry)), + ) + + app = ( + ApplicationBuilder() + .bot(one_time_bot) + .post_init(post_init) + .post_stop(functools.partial(callback, name="post_stop")) + .post_shutdown(functools.partial(callback, name="post_shutdown")) + .build() ) + monkeypatch.setattr(app.bot, "get_updates", get_updates) + + app.add_handler(TypeHandler(object, update_logger_callback), group=-10) + app.add_handler(TypeHandler(object, handler_callback)) + app.add_error_handler(error_callback) + with caplog.at_level(logging.DEBUG): + app.run_polling(close_loop=False) + + # This checks that we have a clean shutdown even when the user raises SystemExit + # or KeyboardInterrupt in a handler/error handler/job callback + assert called_callbacks == { + "app_initialize", + "app_shutdown", + "app_start", + "app_stop", + "post_shutdown", + "post_stop", + "updater_initialize", + "updater_shutdown", + "updater_start_polling", + "updater_stop", + } + + # These next checks make sure that the update queue is properly cleaned even if there are + # still pending updates in the queue + # Unfortunately this is apparently extremely hard to get right with jobs, so we're + # skipping that case for the sake of simplicity + if kind == "job": + return + + found = False + for record in caplog.records: + if record.getMessage() != "Dropping pending update: will_not_be_processed": + continue + assert record.name == "telegram.ext.Application" + assert record.levelno == logging.DEBUG + found = True + assert found, "`Dropping pending updates` message not found in logs!" + assert "will_not_be_processed" not in app.bot_data.get("processed_updates", set()) def test_run_without_updater(self, one_time_bot): app = ApplicationBuilder().bot(one_time_bot).updater(None).build() @@ -2297,7 +2397,7 @@ def abort_app(): assert received_signals == [signal.SIGINT, signal.SIGTERM, signal.SIGABRT] received_signals.clear() - loop.call_later(0.6, abort_app) + loop.call_later(0.8, abort_app) app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) if platform.system() == "Windows": @@ -2311,7 +2411,44 @@ def test_stop_running_not_running(self, app, caplog): assert len(caplog.records) == 1 assert caplog.records[-1].name == "telegram.ext.Application" - assert caplog.records[-1].getMessage().endswith("stop_running() does nothing.") + assert caplog.records[-1].getMessage().endswith("`stop_running()` likely has no effect.") + + def test_stop_running_post_init(self, app, monkeypatch, caplog, one_time_bot): + async def post_init(app): + app.stop_running() + + called_callbacks = [] + + async def callback(*args, **kwargs): + called_callbacks.append(kwargs["name"]) + + monkeypatch.setattr(Application, "start", functools.partial(callback, name="start")) + monkeypatch.setattr( + Updater, "start_polling", functools.partial(callback, name="start_polling") + ) + + app = ( + ApplicationBuilder() + .bot(one_time_bot) + .post_init(post_init) + .post_stop(functools.partial(callback, name="post_stop")) + .post_shutdown(functools.partial(callback, name="post_shutdown")) + .build() + ) + + with caplog.at_level(logging.INFO): + app.run_polling(close_loop=False) + + # The important part here is that start(_polling) are *not* called! + # post_stop must not be called either, since we never called stop() + assert called_callbacks == ["post_shutdown"] + + assert len(caplog.records) == 1 + assert caplog.records[-1].name == "telegram.ext.Application" + assert ( + "Application received stop signal via `stop_running`" + in caplog.records[-1].getMessage() + ) @pytest.mark.parametrize("method", ["polling", "webhook"]) def test_stop_running(self, one_time_bot, monkeypatch, method): @@ -2421,3 +2558,83 @@ async def callback(update, context): assert len(assertions) == 5 for key, value in assertions.items(): assert value, f"assertion '{key}' failed!" + + async def test_process_update_exception_in_building_context(self, monkeypatch, caplog, app): + # Makes sure that exceptions in building the context don't stop the application + exception = ValueError("TestException") + original_from_update = CallbackContext.from_update + + def raise_exception(update, application): + if update == 1: + raise exception + return original_from_update(update, application) + + monkeypatch.setattr(CallbackContext, "from_update", raise_exception) + + received_updates = set() + + async def callback(update, context): + received_updates.add(update) + + app.add_handler(TypeHandler(int, callback)) + + async with app: + with caplog.at_level(logging.CRITICAL): + await app.process_update(1) + + assert received_updates == set() + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.name == "telegram.ext.Application" + assert record.getMessage().startswith( + "Error while building CallbackContext for update 1" + ) + assert record.levelno == logging.CRITICAL + + # Let's also check that no critical log is produced when the exception is not raised + caplog.clear() + with caplog.at_level(logging.CRITICAL): + await app.process_update(2) + + assert received_updates == {2} + assert len(caplog.records) == 0 + + async def test_process_error_exception_in_building_context(self, monkeypatch, caplog, app): + # Makes sure that exceptions in building the context don't stop the application + exception = ValueError("TestException") + original_from_error = CallbackContext.from_error + + def raise_exception(update, error, application, *args, **kwargs): + if error == 1: + raise exception + return original_from_error(update, error, application, *args, **kwargs) + + monkeypatch.setattr(CallbackContext, "from_error", raise_exception) + + received_errors = set() + + async def callback(update, context): + received_errors.add(context.error) + + app.add_error_handler(callback) + + async with app: + with caplog.at_level(logging.CRITICAL): + await app.process_error(update=None, error=1) + + assert received_errors == set() + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.name == "telegram.ext.Application" + assert record.getMessage().startswith( + "Error while building CallbackContext for exception 1" + ) + assert record.levelno == logging.CRITICAL + + # Let's also check that no critical log is produced when the exception is not raised + caplog.clear() + with caplog.at_level(logging.CRITICAL): + await app.process_error(update=None, error=2) + + assert received_errors == {2} + assert len(caplog.records) == 0 diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 694ea009a6f..fc88e428404 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1090,6 +1090,11 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) update.message.giveaway_completed = None + update.message.chat_background_set = "test_background" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHAT_BACKGROUND_SET.check_update(update) + update.message.chat_background_set = None + def test_filters_forwarded(self, update, message_origin_user): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(datetime.datetime.utcnow(), 1) diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py index 0a3723763d9..929591d38b9 100644 --- a/tests/ext/test_jobqueue.py +++ b/tests/ext/test_jobqueue.py @@ -646,3 +646,44 @@ async def test_from_aps_job_missing_reference(self, job_queue): tg_job = Job.from_aps_job(aps_job) assert tg_job is job assert tg_job.job is aps_job + + async def test_run_job_exception_in_building_context( + self, monkeypatch, job_queue, caplog, app + ): + # Makes sure that exceptions in building the context don't stop the application + exception = ValueError("TestException") + original_from_job = CallbackContext.from_job + + def raise_exception(job, application): + if job.data == 1: + raise exception + return original_from_job(job, application) + + monkeypatch.setattr(CallbackContext, "from_job", raise_exception) + + received_jobs = set() + + async def job_callback(context): + received_jobs.add(context.job.data) + + with caplog.at_level(logging.CRITICAL): + job_queue.run_once(job_callback, 0.1, data=1) + await asyncio.sleep(0.2) + + assert received_jobs == set() + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.name == "telegram.ext.JobQueue" + assert record.getMessage().startswith( + "Error while building CallbackContext for job job_callback" + ) + assert record.levelno == logging.CRITICAL + + # Let's also check that no critical log is produced when the exception is not raised + caplog.clear() + with caplog.at_level(logging.CRITICAL): + job_queue.run_once(job_callback, 0.1, data=2) + await asyncio.sleep(0.2) + + assert received_jobs == {2} + assert len(caplog.records) == 0 diff --git a/tests/ext/test_prefixhandler.py b/tests/ext/test_prefixhandler.py index b0d75b06951..a42ec4e058e 100644 --- a/tests/ext/test_prefixhandler.py +++ b/tests/ext/test_prefixhandler.py @@ -25,7 +25,7 @@ def combinations(prefixes, commands): - return (prefix + command for prefix in prefixes for command in commands) + return [prefix + command for prefix in prefixes for command in commands] class TestPrefixHandler(BaseTest): @@ -40,31 +40,31 @@ def test_slot_behaviour(self): assert getattr(handler, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(handler)) == len(set(mro_slots(handler))), "duplicate slot" - @pytest.fixture(scope="class", params=PREFIXES) + @pytest.fixture(params=PREFIXES) def prefix(self, request): return request.param - @pytest.fixture(scope="class", params=[1, 2], ids=["single prefix", "multiple prefixes"]) + @pytest.fixture(params=[1, 2], ids=["single prefix", "multiple prefixes"]) def prefixes(self, request): return TestPrefixHandler.PREFIXES[: request.param] - @pytest.fixture(scope="class", params=COMMANDS) + @pytest.fixture(params=COMMANDS) def command(self, request): return request.param - @pytest.fixture(scope="class", params=[1, 2], ids=["single command", "multiple commands"]) + @pytest.fixture(params=[1, 2], ids=["single command", "multiple commands"]) def commands(self, request): return TestPrefixHandler.COMMANDS[: request.param] - @pytest.fixture(scope="class") + @pytest.fixture() def prefix_message_text(self, prefix, command): return prefix + command - @pytest.fixture(scope="class") + @pytest.fixture() def prefix_message(self, prefix_message_text): return make_message(prefix_message_text) - @pytest.fixture(scope="class") + @pytest.fixture() def prefix_message_update(self, prefix_message): return make_message_update(prefix_message) @@ -94,12 +94,12 @@ async def test_basic(self, app, prefix, command): assert isinstance(handler.commands, frozenset) assert handler.commands == {"#cmd", "#bmd"} - def test_single_multi_prefixes_commands(self, prefixes, commands, prefix_message_update): + def test_single_multi_prefixes_commands(self, prefix_message_update): """Test various combinations of prefixes and commands""" handler = self.make_default_handler() result = is_match(handler, prefix_message_update) - expected = prefix_message_update.message.text in combinations(prefixes, commands) - return result == expected + expected = prefix_message_update.message.text in self.COMBINATIONS + assert result == expected def test_edited(self, prefix_message): handler_edited = self.make_default_handler() diff --git a/tests/test_birthdate.py b/tests/test_birthdate.py index 4c028661ac8..a5e90d413f8 100644 --- a/tests/test_birthdate.py +++ b/tests/test_birthdate.py @@ -16,7 +16,7 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -from datetime import datetime +from datetime import date import pytest @@ -72,10 +72,10 @@ def test_equality(self): assert hash(bd1) != hash(bd4) def test_to_date(self, birthdate): - assert isinstance(birthdate.to_date(), datetime) - assert birthdate.to_date() == datetime(self.year, self.month, self.day) + assert isinstance(birthdate.to_date(), date) + assert birthdate.to_date() == date(self.year, self.month, self.day) new_bd = birthdate.to_date(2023) - assert new_bd == datetime(2023, self.month, self.day) + assert new_bd == date(2023, self.month, self.day) def test_to_date_no_year(self): bd = Birthdate(1, 1) diff --git a/tests/test_bot.py b/tests/test_bot.py index 7021867da64..047238907e6 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -43,6 +43,7 @@ CallbackQuery, Chat, ChatAdministratorRights, + ChatFullInfo, ChatPermissions, Dice, InlineKeyboardButton, @@ -55,6 +56,7 @@ InputMediaDocument, InputMediaPhoto, InputMessageContent, + InputPollOption, InputTextMessageContent, LabeledPrice, LinkPreviewOptions, @@ -1937,6 +1939,59 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): chat_id, message, reply_parameters=ReplyParameters(**kwargs) ) + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, "NOTHING"), + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_poll_default_text_question_parse_mode( + self, default_bot, raw_bot, chat_id, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + expected = default_bot.defaults.text_parse_mode if custom == "NOTHING" else custom + + option_1 = request_data.parameters["options"][0] + option_2 = request_data.parameters["options"][1] + assert option_1.get("text_parse_mode") == (default_bot.defaults.text_parse_mode) + assert option_2.get("text_parse_mode") == expected + assert request_data.parameters.get("question_parse_mode") == expected + + return make_message("dummy reply").to_dict() + + async def make_raw_assertion(url, request_data: RequestData, *args, **kwargs): + expected = None if custom == "NOTHING" else custom + + option_1 = request_data.parameters["options"][0] + option_2 = request_data.parameters["options"][1] + assert option_1.get("text_parse_mode") is None + assert option_2.get("text_parse_mode") == expected + + assert request_data.parameters.get("question_parse_mode") == expected + + return make_message("dummy reply").to_dict() + + if custom == "NOTHING": + option_2 = InputPollOption("option2") + kwargs = {} + else: + option_2 = InputPollOption("option2", text_parse_mode=custom) + kwargs = {"question_parse_mode": custom} + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_poll( + chat_id, question="question", options=["option1", option_2], **kwargs + ) + + monkeypatch.setattr(raw_bot.request, "post", make_raw_assertion) + await raw_bot.send_poll( + chat_id, question="question", options=["option1", option_2], **kwargs + ) + @pytest.mark.parametrize( ("default_bot", "custom"), [ @@ -1967,6 +2022,30 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): reply_parameters=ReplyParameters(**kwargs), ) + async def test_send_poll_question_parse_mode_entities(self, bot, monkeypatch): + # Currently only custom emoji are supported as entities which we can't test + # We just test that the correct data is passed for now + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["question_entities"] == [ + {"type": "custom_emoji", "offset": 0, "length": 1}, + {"type": "custom_emoji", "offset": 2, "length": 1}, + ] + assert request_data.parameters["question_parse_mode"] == ParseMode.MARKDOWN_V2 + return make_message("dummy reply").to_dict() + + monkeypatch.setattr(bot.request, "post", make_assertion) + await bot.send_poll( + 1, + question="😀😃", + options=["option1", "option2"], + question_entities=[ + MessageEntity(MessageEntity.CUSTOM_EMOJI, 0, 1), + MessageEntity(MessageEntity.CUSTOM_EMOJI, 2, 1), + ], + question_parse_mode=ParseMode.MARKDOWN_V2, + ) + @pytest.mark.parametrize( ("default_bot", "custom"), [ @@ -2026,6 +2105,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(bot.request, "post", make_assertion) assert await bot.do_api_request("camel_case") + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") async def test_do_api_request_media_write_timeout(self, bot, chat_id, monkeypatch): test_flag = None @@ -2064,6 +2144,7 @@ async def do_request(self_, *args, **kwargs) -> Tuple[int, bytes]: DEFAULT_NONE, ) + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") async def test_do_api_request_default_timezone(self, tz_bot, monkeypatch): until = dtm.datetime(2020, 1, 11, 16, 13) until_timestamp = to_timestamp(until, tzinfo=tz_bot.defaults.tzinfo) @@ -2326,7 +2407,7 @@ async def make_assertion(*args, **_): ) async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): question = "Is this a test?" - answers = ["Yes", "No", "Maybe"] + answers = ["Yes", InputPollOption("No"), "Maybe"] explanation = "[Here is a link](https://google.com)" explanation_entities = [ MessageEntity(MessageEntity.TEXT_LINK, 0, 14, url="https://google.com") @@ -2360,7 +2441,7 @@ async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): assert message.poll assert message.poll.question == question assert message.poll.options[0].text == answers[0] - assert message.poll.options[1].text == answers[1] + assert message.poll.options[1].text == answers[1].text assert message.poll.options[2].text == answers[2] assert not message.poll.is_anonymous assert message.poll.allows_multiple_answers @@ -2380,7 +2461,7 @@ async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): assert poll.is_closed assert poll.options[0].text == answers[0] assert poll.options[0].voter_count == 0 - assert poll.options[1].text == answers[1] + assert poll.options[1].text == answers[1].text assert poll.options[1].voter_count == 0 assert poll.options[2].text == answers[2] assert poll.options[2].voter_count == 0 @@ -2552,7 +2633,7 @@ async def test_send_poll_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_poll( chat_id, question=question, @@ -2609,7 +2690,7 @@ async def test_send_dice_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_dice( chat_id, reply_to_message_id=reply_to_message.message_id ) @@ -2921,10 +3002,10 @@ async def test_leave_chat(self, bot): await bot.leave_chat(-123456) async def test_get_chat(self, bot, super_group_id): - chat = await bot.get_chat(super_group_id) - assert chat.type == "supergroup" - assert chat.title == f">>> telegram.Bot(test) @{bot.username}" - assert chat.id == int(super_group_id) + cfi = await bot.get_chat(super_group_id) + assert cfi.type == "supergroup" + assert cfi.title == f">>> telegram.Bot(test) @{bot.username}" + assert cfi.id == int(super_group_id) async def test_get_chat_administrators(self, bot, channel_id): admins = await bot.get_chat_administrators(channel_id) @@ -2999,7 +3080,7 @@ async def test_send_game_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_game( chat_id, game_short_name, reply_to_message_id=reply_to_message.message_id ) @@ -3595,7 +3676,7 @@ async def test_send_message_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="Message to reply not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_message( chat_id, "test", reply_to_message_id=reply_to_message.message_id ) @@ -3900,9 +3981,9 @@ async def test_get_chat_arbitrary_callback_data(self, channel_id, cdc_bot): ) assert data == "callback_data" - chat = await bot.get_chat(channel_id) - assert chat.pinned_message == message - assert chat.pinned_message.reply_markup == reply_markup + cfi = await bot.get_chat(channel_id) + assert cfi.pinned_message == message + assert cfi.pinned_message.reply_markup == reply_markup assert await message.unpin() # (not placed in finally block since msg can be unbound) finally: bot.callback_data_cache.clear_callback_data() @@ -3915,11 +3996,11 @@ async def test_arbitrary_callback_data_get_chat_no_pinned_message( await bot.unpin_all_chat_messages(super_group_id) try: - chat = await bot.get_chat(super_group_id) + cfi = await bot.get_chat(super_group_id) - assert isinstance(chat, Chat) - assert int(chat.id) == int(super_group_id) - assert chat.pinned_message is None + assert isinstance(cfi, ChatFullInfo) + assert int(cfi.id) == int(super_group_id) + assert cfi.pinned_message is None finally: bot.callback_data_cache.clear_callback_data() bot.callback_data_cache.clear_callback_queries() @@ -4011,7 +4092,7 @@ async def test_set_message_reaction(self, bot, chat_id, message): @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) async def test_do_api_request_warning_known_method(self, bot, bot_class): - with pytest.warns(PTBDeprecationWarning, match="Please use 'Bot.get_me'") as record: + with pytest.warns(PTBUserWarning, match="Please use 'Bot.get_me'") as record: await bot_class(bot.token).do_api_request("get_me") assert record[0].filename == __file__, "Wrong stack level!" @@ -4020,6 +4101,7 @@ async def test_do_api_request_unknown_method(self, bot): with pytest.raises(EndPointNotFound, match="'unknownEndpoint' not found"): await bot.do_api_request("unknown_endpoint") + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") async def test_do_api_request_invalid_token(self, bot): # we do not initialize the bot here on purpose b/c that's the case were we actually # do not know for sure if the token is invalid or the method was not found @@ -4034,6 +4116,7 @@ async def test_do_api_request_invalid_token(self, bot): ): await Bot(bot.token).do_api_request("unknown_endpoint") + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") @pytest.mark.parametrize("return_type", [Message, None]) async def test_do_api_request_basic_and_files(self, bot, chat_id, return_type): result = await bot.do_api_request( @@ -4058,6 +4141,7 @@ async def test_do_api_request_basic_and_files(self, bot, chat_id, return_type): assert out.read() == data_file("telegram.png").open("rb").read() assert result.document.file_name == "telegram.png" + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") @pytest.mark.parametrize("return_type", [Message, None]) async def test_do_api_request_list_return_type(self, bot, chat_id, return_type): result = await bot.do_api_request( @@ -4096,6 +4180,7 @@ async def test_do_api_request_list_return_type(self, bot, chat_id, return_type): assert out.read() == data_file(file_name).open("rb").read() assert message.document.file_name == file_name + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") @pytest.mark.parametrize("return_type", [Message, None]) async def test_do_api_request_bool_return_type(self, bot, chat_id, return_type): assert await bot.do_api_request("delete_my_commands", return_type=return_type) is True diff --git a/tests/test_callbackquery.py b/tests/test_callbackquery.py index 1db05e4b973..66dc6856924 100644 --- a/tests/test_callbackquery.py +++ b/tests/test_callbackquery.py @@ -301,8 +301,9 @@ async def test_edit_message_live_location(self, monkeypatch, callback_query): async def make_assertion(*_, **kwargs): latitude = kwargs.get("latitude") == 1 longitude = kwargs.get("longitude") == 2 + live = kwargs.get("live_period") == 900 ids = self.check_passed_ids(callback_query, kwargs) - return ids and latitude and longitude + return ids and latitude and longitude and live assert check_shortcut_signature( CallbackQuery.edit_message_live_location, @@ -322,8 +323,10 @@ async def make_assertion(*_, **kwargs): ) monkeypatch.setattr(callback_query.get_bot(), "edit_message_live_location", make_assertion) - assert await callback_query.edit_message_live_location(latitude=1, longitude=2) - assert await callback_query.edit_message_live_location(1, 2) + assert await callback_query.edit_message_live_location( + latitude=1, longitude=2, live_period=900 + ) + assert await callback_query.edit_message_live_location(1, 2, live_period=900) async def test_stop_message_live_location(self, monkeypatch, callback_query): if isinstance(callback_query.message, InaccessibleMessage): diff --git a/tests/test_chat.py b/tests/test_chat.py index 11ef38dda15..7af7a677ce0 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -16,7 +16,9 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. + import datetime +import warnings import pytest @@ -35,9 +37,11 @@ ReactionTypeEmoji, User, ) +from telegram._chat import _deprecated_attrs from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ChatAction, ChatType, ReactionEmoji from telegram.helpers import escape_markdown +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -84,6 +88,8 @@ def chat(bot): business_opening_hours=TestChatBase.business_opening_hours, birthdate=Birthdate(1, 1), personal_chat=TestChatBase.personal_chat, + first_name=TestChatBase.first_name, + last_name=TestChatBase.last_name, ) chat.set_bot(bot) chat._unfreeze() @@ -137,6 +143,8 @@ class TestChatBase: custom_emoji_sticker_set_name = "custom_emoji_sticker_set_name" birthdate = Birthdate(1, 1) personal_chat = Chat(3, "private", "private") + first_name = "first" + last_name = "last" class TestChatWithoutRequest(TestChatBase): @@ -185,6 +193,8 @@ def test_de_json(self, bot): "custom_emoji_sticker_set_name": self.custom_emoji_sticker_set_name, "birthdate": self.birthdate.to_dict(), "personal_chat": self.personal_chat.to_dict(), + "first_name": self.first_name, + "last_name": self.last_name, } chat = Chat.de_json(json_dict, bot) @@ -230,6 +240,8 @@ def test_de_json(self, bot): assert chat.custom_emoji_sticker_set_name == self.custom_emoji_sticker_set_name assert chat.birthdate == self.birthdate assert chat.personal_chat == self.personal_chat + assert chat.first_name == self.first_name + assert chat.last_name == self.last_name def test_de_json_localization(self, bot, raw_bot, tz_bot): json_dict = { @@ -251,6 +263,15 @@ def test_de_json_localization(self, bot, raw_bot, tz_bot): assert chat_bot_raw.emoji_status_expiration_date.tzinfo == UTC assert emoji_expire_offset_tz == emoji_expire_offset + def test_always_tuples_attributes(self): + chat = Chat( + id=123, + title="title", + type=Chat.PRIVATE, + ) + assert isinstance(chat.active_usernames, tuple) + assert chat.active_usernames == () + def test_to_dict(self, chat): chat_dict = chat.to_dict() @@ -300,15 +321,25 @@ def test_to_dict(self, chat): assert chat_dict["unrestrict_boost_count"] == chat.unrestrict_boost_count assert chat_dict["birthdate"] == chat.birthdate.to_dict() assert chat_dict["personal_chat"] == chat.personal_chat.to_dict() - - def test_always_tuples_attributes(self): - chat = Chat( - id=123, - title="title", - type=Chat.PRIVATE, - ) - assert isinstance(chat.active_usernames, tuple) - assert chat.active_usernames == () + assert chat_dict["first_name"] == chat.first_name + assert chat_dict["last_name"] == chat.last_name + + def test_deprecated_attributes(self, chat): + for depr_attr in _deprecated_attrs: + with pytest.warns(PTBDeprecationWarning, match="deprecated and will only be accessib"): + getattr(chat, depr_attr) + with warnings.catch_warnings(): # No warning should be raised + warnings.simplefilter("error") + chat.id + chat.first_name + + def test_deprecated_arguments(self): + for depr_attr in _deprecated_attrs: + with pytest.warns(PTBDeprecationWarning, match="deprecated and will only be availabl"): + Chat(1, "type", **{depr_attr: "1"}) + with warnings.catch_warnings(): # No warning should be raised + warnings.simplefilter("error") + Chat(1, "type", first_name="first_name") def test_enum_init(self): chat = Chat(id=1, type="foo") @@ -348,10 +379,7 @@ def test_full_name(self): assert chat.full_name == "first\u2022name last\u2022name" chat = Chat(id=1, type=Chat.PRIVATE, first_name="first\u2022name") assert chat.full_name == "first\u2022name" - chat = Chat( - id=1, - type=Chat.PRIVATE, - ) + chat = Chat(id=1, type=Chat.PRIVATE) assert chat.full_name is None def test_effective_name(self): @@ -588,7 +616,7 @@ async def make_assertion(*_, **kwargs): async def test_set_permissions(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id - permissions = kwargs["permissions"] == self.permissions + permissions = kwargs["permissions"] == ChatPermissions.all_permissions() return chat_id and permissions assert check_shortcut_signature( @@ -600,7 +628,7 @@ async def make_assertion(*_, **kwargs): assert await check_defaults_handling(chat.set_permissions, chat.get_bot()) monkeypatch.setattr(chat.get_bot(), "set_chat_permissions", make_assertion) - assert await chat.set_permissions(permissions=self.permissions) + assert await chat.set_permissions(permissions=ChatPermissions.all_permissions()) async def test_set_administrator_custom_title(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): diff --git a/tests/test_chatbackground.py b/tests/test_chatbackground.py new file mode 100644 index 00000000000..1f8be1eb451 --- /dev/null +++ b/tests/test_chatbackground.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# 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/]. +import inspect +from copy import deepcopy +from typing import Union + +import pytest + +from telegram import ( + BackgroundFill, + BackgroundFillFreeformGradient, + BackgroundFillGradient, + BackgroundFillSolid, + BackgroundType, + BackgroundTypeChatTheme, + BackgroundTypeFill, + BackgroundTypePattern, + BackgroundTypeWallpaper, + Dice, + Document, +) +from tests.auxil.slots import mro_slots + +ignored = ["self", "api_kwargs"] + + +class BFDefaults: + color = 0 + top_color = 1 + bottom_color = 2 + rotation_angle = 45 + colors = [0, 1, 2] + + +def background_fill_solid(): + return BackgroundFillSolid(BFDefaults.color) + + +def background_fill_gradient(): + return BackgroundFillGradient( + BFDefaults.top_color, BFDefaults.bottom_color, BFDefaults.rotation_angle + ) + + +def background_fill_freeform_gradient(): + return BackgroundFillFreeformGradient(BFDefaults.colors) + + +class BTDefaults: + document = Document(1, 2) + fill = BackgroundFillSolid(color=0) + dark_theme_dimming = 20 + is_blurred = True + is_moving = False + intensity = 90 + is_inverted = False + theme_name = "ice" + + +def background_type_fill(): + return BackgroundTypeFill(BTDefaults.fill, BTDefaults.dark_theme_dimming) + + +def background_type_wallpaper(): + return BackgroundTypeWallpaper( + BTDefaults.document, + BTDefaults.dark_theme_dimming, + BTDefaults.is_blurred, + BTDefaults.is_moving, + ) + + +def background_type_pattern(): + return BackgroundTypePattern( + BTDefaults.document, + BTDefaults.fill, + BTDefaults.intensity, + BTDefaults.is_inverted, + BTDefaults.is_moving, + ) + + +def background_type_chat_theme(): + return BackgroundTypeChatTheme(BTDefaults.theme_name) + + +def make_json_dict( + instance: Union[BackgroundType, BackgroundFill], include_optional_args: bool = False +) -> dict: + """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" + json_dict = {"type": instance.type} + sig = inspect.signature(instance.__class__.__init__) + + for param in sig.parameters.values(): + if param.name in ignored: # ignore irrelevant params + continue + + val = getattr(instance, param.name) + # Compulsory args- + if param.default is inspect.Parameter.empty: + if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. + val = val.to_dict() + json_dict[param.name] = val + + # If we want to test all args (for de_json)- + elif param.default is not inspect.Parameter.empty and include_optional_args: + json_dict[param.name] = val + return json_dict + + +def iter_args( + instance: Union[BackgroundType, BackgroundFill], + de_json_inst: Union[BackgroundType, BackgroundFill], + include_optional: bool = False, +): + """ + We accept both the regular instance and de_json created instance and iterate over them for + easy one line testing later one. + """ + yield instance.type, de_json_inst.type # yield this here cause it's not available in sig. + + sig = inspect.signature(instance.__class__.__init__) + for param in sig.parameters.values(): + if param.name in ignored: + continue + inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) + if ( + param.default is not inspect.Parameter.empty and include_optional + ) or param.default is inspect.Parameter.empty: + yield inst_at, json_at + + +@pytest.fixture() +def background_type(request): + return request.param() + + +@pytest.mark.parametrize( + "background_type", + [ + background_type_fill, + background_type_wallpaper, + background_type_pattern, + background_type_chat_theme, + ], + indirect=True, +) +class TestBackgroundTypeWithoutRequest: + def test_slot_behaviour(self, background_type): + inst = background_type + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json_required_args(self, bot, background_type): + cls = background_type.__class__ + assert cls.de_json({}, bot) is None + + json_dict = make_json_dict(background_type) + const_background_type = BackgroundType.de_json(json_dict, bot) + assert const_background_type.api_kwargs == {} + + assert isinstance(const_background_type, BackgroundType) + assert isinstance(const_background_type, cls) + for bg_type_at, const_bg_type_at in iter_args(background_type, const_background_type): + assert bg_type_at == const_bg_type_at + + def test_de_json_all_args(self, bot, background_type): + json_dict = make_json_dict(background_type, include_optional_args=True) + const_background_type = BackgroundType.de_json(json_dict, bot) + + assert const_background_type.api_kwargs == {} + + assert isinstance(const_background_type, BackgroundType) + assert isinstance(const_background_type, background_type.__class__) + for bg_type_at, const_bg_type_at in iter_args( + background_type, const_background_type, True + ): + assert bg_type_at == const_bg_type_at + + def test_de_json_invalid_type(self, background_type, bot): + json_dict = {"type": "invalid", "theme_name": BTDefaults.theme_name} + background_type = BackgroundType.de_json(json_dict, bot) + + assert type(background_type) is BackgroundType + assert background_type.type == "invalid" + + def test_de_json_subclass(self, background_type, bot, chat_id): + """This makes sure that e.g. BackgroundTypeFill(data, bot) never returns a + BackgroundTypeWallpaper instance.""" + cls = background_type.__class__ + json_dict = make_json_dict(background_type, True) + assert type(cls.de_json(json_dict, bot)) is cls + + def test_to_dict(self, background_type): + bg_type_dict = background_type.to_dict() + + assert isinstance(bg_type_dict, dict) + assert bg_type_dict["type"] == background_type.type + + for slot in background_type.__slots__: # additional verification for the optional args + if slot in ("fill", "document"): + assert (getattr(background_type, slot)).to_dict() == bg_type_dict[slot] + continue + assert getattr(background_type, slot) == bg_type_dict[slot] + + def test_equality(self, background_type): + a = BackgroundType(type="type") + b = BackgroundType(type="type") + c = background_type + d = deepcopy(background_type) + e = Dice(4, "emoji") + sig = inspect.signature(background_type.__class__.__init__) + params = [ + "random" for param in sig.parameters.values() if param.name not in [*ignored, "type"] + ] + f = background_type.__class__(*params) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + assert f != c + assert hash(f) != hash(c) + + +@pytest.fixture() +def background_fill(request): + return request.param() + + +@pytest.mark.parametrize( + "background_fill", + [ + background_fill_solid, + background_fill_gradient, + background_fill_freeform_gradient, + ], + indirect=True, +) +class TestBackgroundFillWithoutRequest: + def test_slot_behaviour(self, background_fill): + inst = background_fill + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json_required_args(self, bot, background_fill): + cls = background_fill.__class__ + assert cls.de_json({}, bot) is None + + json_dict = make_json_dict(background_fill) + const_background_fill = BackgroundFill.de_json(json_dict, bot) + assert const_background_fill.api_kwargs == {} + + assert isinstance(const_background_fill, BackgroundFill) + assert isinstance(const_background_fill, cls) + for bg_fill_at, const_bg_fill_at in iter_args(background_fill, const_background_fill): + assert bg_fill_at == const_bg_fill_at + + def test_de_json_all_args(self, bot, background_fill): + json_dict = make_json_dict(background_fill, include_optional_args=True) + const_background_fill = BackgroundFill.de_json(json_dict, bot) + + assert const_background_fill.api_kwargs == {} + + assert isinstance(const_background_fill, BackgroundFill) + assert isinstance(const_background_fill, background_fill.__class__) + for bg_fill_at, const_bg_fill_at in iter_args( + background_fill, const_background_fill, True + ): + assert bg_fill_at == const_bg_fill_at + + def test_de_json_invalid_type(self, background_fill, bot): + json_dict = {"type": "invalid", "theme_name": BTDefaults.theme_name} + background_fill = BackgroundFill.de_json(json_dict, bot) + + assert type(background_fill) is BackgroundFill + assert background_fill.type == "invalid" + + def test_de_json_subclass(self, background_fill, bot): + """This makes sure that e.g. BackgroundFillSolid(data, bot) never returns a + BackgroundFillGradient instance.""" + cls = background_fill.__class__ + json_dict = make_json_dict(background_fill, True) + assert type(cls.de_json(json_dict, bot)) is cls + + def test_to_dict(self, background_fill): + bg_fill_dict = background_fill.to_dict() + + assert isinstance(bg_fill_dict, dict) + assert bg_fill_dict["type"] == background_fill.type + + for slot in background_fill.__slots__: # additional verification for the optional args + if slot == "colors": + assert getattr(background_fill, slot) == tuple(bg_fill_dict[slot]) + continue + assert getattr(background_fill, slot) == bg_fill_dict[slot] + + def test_equality(self, background_fill): + a = BackgroundFill(type="type") + b = BackgroundFill(type="type") + c = background_fill + d = deepcopy(background_fill) + e = Dice(4, "emoji") + sig = inspect.signature(background_fill.__class__.__init__) + params = [ + "random" for param in sig.parameters.values() if param.name not in [*ignored, "type"] + ] + f = background_fill.__class__(*params) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + assert f != c + assert hash(f) != hash(c) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py new file mode 100644 index 00000000000..f42642e4ed2 --- /dev/null +++ b/tests/test_chatfullinfo.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# 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/]. +import datetime +import warnings + +import pytest + +from telegram import ( + Birthdate, + BusinessIntro, + BusinessLocation, + BusinessOpeningHours, + BusinessOpeningHoursInterval, + Chat, + ChatFullInfo, + ChatLocation, + ChatPermissions, + Location, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, +) +from telegram._chat import _deprecated_attrs +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ReactionEmoji +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def chat_full_info(bot): + chat = ChatFullInfo( + TestChatInfoBase.id_, + type=TestChatInfoBase.type_, + accent_color_id=TestChatInfoBase.accent_color_id, + max_reaction_count=TestChatInfoBase.max_reaction_count, + title=TestChatInfoBase.title, + username=TestChatInfoBase.username, + sticker_set_name=TestChatInfoBase.sticker_set_name, + can_set_sticker_set=TestChatInfoBase.can_set_sticker_set, + permissions=TestChatInfoBase.permissions, + slow_mode_delay=TestChatInfoBase.slow_mode_delay, + bio=TestChatInfoBase.bio, + linked_chat_id=TestChatInfoBase.linked_chat_id, + location=TestChatInfoBase.location, + has_private_forwards=True, + has_protected_content=True, + has_visible_history=True, + join_to_send_messages=True, + join_by_request=True, + has_restricted_voice_and_video_messages=True, + is_forum=True, + active_usernames=TestChatInfoBase.active_usernames, + emoji_status_custom_emoji_id=TestChatInfoBase.emoji_status_custom_emoji_id, + emoji_status_expiration_date=TestChatInfoBase.emoji_status_expiration_date, + has_aggressive_anti_spam_enabled=TestChatInfoBase.has_aggressive_anti_spam_enabled, + has_hidden_members=TestChatInfoBase.has_hidden_members, + available_reactions=TestChatInfoBase.available_reactions, + background_custom_emoji_id=TestChatInfoBase.background_custom_emoji_id, + profile_accent_color_id=TestChatInfoBase.profile_accent_color_id, + profile_background_custom_emoji_id=TestChatInfoBase.profile_background_custom_emoji_id, + unrestrict_boost_count=TestChatInfoBase.unrestrict_boost_count, + custom_emoji_sticker_set_name=TestChatInfoBase.custom_emoji_sticker_set_name, + business_intro=TestChatInfoBase.business_intro, + business_location=TestChatInfoBase.business_location, + business_opening_hours=TestChatInfoBase.business_opening_hours, + birthdate=Birthdate(1, 1), + personal_chat=TestChatInfoBase.personal_chat, + ) + chat.set_bot(bot) + chat._unfreeze() + return chat + + +class TestChatInfoBase: + id_ = -28767330 + max_reaction_count = 2 + title = "ToledosPalaceBot - Group" + type_ = "group" + username = "username" + all_members_are_administrators = False + sticker_set_name = "stickers" + can_set_sticker_set = False + permissions = ChatPermissions( + can_send_messages=True, + can_change_info=False, + can_invite_users=True, + ) + slow_mode_delay = 30 + bio = "I'm a Barbie Girl in a Barbie World" + linked_chat_id = 11880 + location = ChatLocation(Location(123, 456), "Barbie World") + has_protected_content = True + has_visible_history = True + has_private_forwards = True + join_to_send_messages = True + join_by_request = True + has_restricted_voice_and_video_messages = True + is_forum = True + active_usernames = ["These", "Are", "Usernames!"] + emoji_status_custom_emoji_id = "VeryUniqueCustomEmojiID" + emoji_status_expiration_date = datetime.datetime.now(tz=UTC).replace(microsecond=0) + has_aggressive_anti_spam_enabled = True + has_hidden_members = True + available_reactions = [ + ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN), + ReactionTypeCustomEmoji("custom_emoji_id"), + ] + business_intro = BusinessIntro("Title", "Description", None) + business_location = BusinessLocation("Address", Location(123, 456)) + business_opening_hours = BusinessOpeningHours( + "Country/City", + [BusinessOpeningHoursInterval(opening, opening + 60) for opening in (0, 24 * 60)], + ) + accent_color_id = 1 + background_custom_emoji_id = "background_custom_emoji_id" + profile_accent_color_id = 2 + profile_background_custom_emoji_id = "profile_background_custom_emoji_id" + unrestrict_boost_count = 100 + custom_emoji_sticker_set_name = "custom_emoji_sticker_set_name" + birthdate = Birthdate(1, 1) + personal_chat = Chat(3, "private", "private") + + +class TestChatWithoutRequest(TestChatInfoBase): + def test_slot_behaviour(self, chat_full_info): + cfi = chat_full_info + for attr in cfi.__slots__: + assert getattr(cfi, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(cfi)) == len(set(mro_slots(cfi))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "id": self.id_, + "title": self.title, + "type": self.type_, + "accent_color_id": self.accent_color_id, + "max_reaction_count": self.max_reaction_count, + "username": self.username, + "all_members_are_administrators": self.all_members_are_administrators, + "sticker_set_name": self.sticker_set_name, + "can_set_sticker_set": self.can_set_sticker_set, + "permissions": self.permissions.to_dict(), + "slow_mode_delay": self.slow_mode_delay, + "bio": self.bio, + "business_intro": self.business_intro.to_dict(), + "business_location": self.business_location.to_dict(), + "business_opening_hours": self.business_opening_hours.to_dict(), + "has_protected_content": self.has_protected_content, + "has_visible_history": self.has_visible_history, + "has_private_forwards": self.has_private_forwards, + "linked_chat_id": self.linked_chat_id, + "location": self.location.to_dict(), + "join_to_send_messages": self.join_to_send_messages, + "join_by_request": self.join_by_request, + "has_restricted_voice_and_video_messages": ( + self.has_restricted_voice_and_video_messages + ), + "is_forum": self.is_forum, + "active_usernames": self.active_usernames, + "emoji_status_custom_emoji_id": self.emoji_status_custom_emoji_id, + "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), + "has_aggressive_anti_spam_enabled": self.has_aggressive_anti_spam_enabled, + "has_hidden_members": self.has_hidden_members, + "available_reactions": [reaction.to_dict() for reaction in self.available_reactions], + "background_custom_emoji_id": self.background_custom_emoji_id, + "profile_accent_color_id": self.profile_accent_color_id, + "profile_background_custom_emoji_id": self.profile_background_custom_emoji_id, + "unrestrict_boost_count": self.unrestrict_boost_count, + "custom_emoji_sticker_set_name": self.custom_emoji_sticker_set_name, + "birthdate": self.birthdate.to_dict(), + "personal_chat": self.personal_chat.to_dict(), + } + cfi = ChatFullInfo.de_json(json_dict, bot) + assert cfi.max_reaction_count == self.max_reaction_count + + def test_to_dict(self, chat_full_info): + cfi = chat_full_info + cfi_dict = cfi.to_dict() + + assert isinstance(cfi_dict, dict) + assert cfi_dict["max_reaction_count"] == cfi.max_reaction_count + + def test_attr_access_no_warning(self, chat_full_info): + cfi = chat_full_info + for depr_attr in _deprecated_attrs: + with warnings.catch_warnings(): # No warning should be raised + warnings.simplefilter("error") + getattr(cfi, depr_attr) + + def test_cfi_creation_no_warning(self, chat_full_info): + cfi = chat_full_info + with warnings.catch_warnings(): + dict = cfi.to_dict() + ChatFullInfo(**dict) diff --git a/tests/test_chatmemberupdated.py b/tests/test_chatmemberupdated.py index 0cf5e58101c..0efbcd8d0ab 100644 --- a/tests/test_chatmemberupdated.py +++ b/tests/test_chatmemberupdated.py @@ -82,7 +82,9 @@ def invite_link(user): @pytest.fixture(scope="module") def chat_member_updated(user, chat, old_chat_member, new_chat_member, invite_link, time): - return ChatMemberUpdated(chat, user, time, old_chat_member, new_chat_member, invite_link, True) + return ChatMemberUpdated( + chat, user, time, old_chat_member, new_chat_member, invite_link, True, True + ) class TestChatMemberUpdatedBase: @@ -129,6 +131,7 @@ def test_de_json_all_args( "new_chat_member": new_chat_member.to_dict(), "invite_link": invite_link.to_dict(), "via_chat_folder_invite_link": True, + "via_join_request": True, } chat_member_updated = ChatMemberUpdated.de_json(json_dict, bot) @@ -142,6 +145,7 @@ def test_de_json_all_args( assert chat_member_updated.new_chat_member == new_chat_member assert chat_member_updated.invite_link == invite_link assert chat_member_updated.via_chat_folder_invite_link is True + assert chat_member_updated.via_join_request is True def test_de_json_localization( self, bot, raw_bot, tz_bot, user, chat, old_chat_member, new_chat_member, time, invite_link @@ -188,6 +192,7 @@ def test_to_dict(self, chat_member_updated): chat_member_updated_dict["via_chat_folder_invite_link"] == chat_member_updated.via_chat_folder_invite_link ) + assert chat_member_updated_dict["via_join_request"] == chat_member_updated.via_join_request def test_equality(self, time, old_chat_member, new_chat_member, invite_link): a = ChatMemberUpdated( diff --git a/tests/test_enum_types.py b/tests/test_enum_types.py index 9e7140ee1df..b16002c6642 100644 --- a/tests/test_enum_types.py +++ b/tests/test_enum_types.py @@ -27,7 +27,10 @@ / "_passport", } -exclude_patterns = {re.compile(re.escape("self.type: ReactionType = type"))} +exclude_patterns = { + re.compile(re.escape("self.type: ReactionType = type")), + re.compile(re.escape("self.type: BackgroundType = type")), +} def test_types_are_converted_to_enum(): diff --git a/tests/test_message.py b/tests/test_message.py index e70b8f0668f..46a7f89b865 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -24,8 +24,10 @@ from telegram import ( Animation, Audio, + BackgroundTypeChatTheme, Bot, Chat, + ChatBackground, ChatBoostAdded, ChatShared, Contact, @@ -270,6 +272,7 @@ def message(bot): {"is_from_offline": True}, {"sender_business_bot": User(1, "BusinessBot", True)}, {"business_connection_id": "123456789"}, + {"chat_background_set": ChatBackground(type=BackgroundTypeChatTheme("ice"))}, ], ids=[ "reply", @@ -338,6 +341,7 @@ def message(bot): "sender_business_bot", "business_connection_id", "is_from_offline", + "chat_background_set", ], ) def message_params(bot, request): @@ -2414,7 +2418,8 @@ async def make_assertion(*_, **kwargs): message_id = kwargs["message_id"] == message.message_id latitude = kwargs["latitude"] == 1 longitude = kwargs["longitude"] == 2 - return chat_id and message_id and longitude and latitude + live = kwargs["live_period"] == 900 + return chat_id and message_id and longitude and latitude and live assert check_shortcut_signature( Message.edit_live_location, @@ -2432,7 +2437,7 @@ async def make_assertion(*_, **kwargs): assert await check_defaults_handling(message.edit_live_location, message.get_bot()) monkeypatch.setattr(message.get_bot(), "edit_message_live_location", make_assertion) - assert await message.edit_live_location(latitude=1, longitude=2) + assert await message.edit_live_location(latitude=1, longitude=2, live_period=900) async def test_stop_live_location(self, monkeypatch, message): async def make_assertion(*_, **kwargs): diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py index 2ccd7808cb5..24ef867ba70 100644 --- a/tests/test_official/arg_type_checker.py +++ b/tests/test_official/arg_type_checker.py @@ -148,8 +148,11 @@ def check_param_type( # Now let's do the checking, starting with "Array of ..." types. if "Array of " in tg_param_type: # For exceptions just check if they contain the annotation - if ptb_param.name in PTCE.ARRAY_OF_EXCEPTIONS: - return PTCE.ARRAY_OF_EXCEPTIONS[ptb_param.name] in str(ptb_annotation), Sequence + if any(ptb_param.name in key for key in PTCE.ARRAY_OF_EXCEPTIONS): + for (p_name, is_expected_class), exception_type in PTCE.ARRAY_OF_EXCEPTIONS.items(): + if ptb_param.name == p_name and is_class is is_expected_class: + log("Checking that `%s` is an exception!\n", ptb_param.name) + return exception_type in str(ptb_annotation), Sequence obj_match: re.Match | None = re.search(ARRAY_OF_PATTERN, tg_param_type) if obj_match is None: diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 89892741bd4..ac043de997d 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -20,6 +20,7 @@ from telegram import Animation, Audio, Document, PhotoSize, Sticker, Video, VideoNote, Voice +from telegram._chat import _deprecated_attrs from tests.test_official.helpers import _get_params_base IGNORED_OBJECTS = ("ResponseParameters",) @@ -47,15 +48,17 @@ class ParamTypeCheckingExceptions: "sticker": Sticker, } + # TODO: Look into merging this with COMPLEX_TYPES # Exceptions to the "Array of" types, where we accept more types than the official API - # key: parameter name, value: type which must be present in the annotation + # key: (parameter name, is_class), value: type which must be present in the annotation ARRAY_OF_EXCEPTIONS = { - "results": "InlineQueryResult", # + Callable - "commands": "BotCommand", # + tuple[str, str] - "keyboard": "KeyboardButton", # + sequence[sequence[str]] - "reaction": "ReactionType", # + str + ("results", False): "InlineQueryResult", # + Callable + ("commands", False): "BotCommand", # + tuple[str, str] + ("keyboard", True): "KeyboardButton", # + sequence[sequence[str]] + ("reaction", False): "ReactionType", # + str + ("options", False): "InputPollOption", # + str # TODO: Deprecated and will be corrected (and removed) in next major PTB version: - "file_hashes": "List[str]", + ("file_hashes", True): "List[str]", } # Special cases for other parameters that accept more types than the official API, and are @@ -120,6 +123,8 @@ class ParamTypeCheckingExceptions: "ChatBoostSource": {"source"}, # attributes common to all subclasses "MessageOrigin": {"type", "date"}, # attributes common to all subclasses "ReactionType": {"type"}, # attributes common to all subclasses + "BackgroundType": {"type"}, # attributes common to all subclasses + "BackgroundFill": {"type"}, # attributes common to all subclasses "InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat } @@ -143,6 +148,8 @@ def ptb_extra_params(object_name: str) -> set[str]: r"MessageOrigin\w+": {"type"}, r"ChatBoostSource\w+": {"source"}, r"ReactionType\w+": {"type"}, + r"BackgroundType\w+": {"type"}, + r"BackgroundFill\w+": {"type"}, } @@ -167,9 +174,7 @@ def ignored_param_requirements(object_name: str) -> set[str]: # Arguments that are optional arguments for now for backwards compatibility BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { - "create_new_sticker_set": {"sticker_format"}, # removed by bot api 7.2 - "StickerSet": {"is_animated", "is_video"}, # removed by bot api 7.2 - "UsersShared": {"user_ids", "users"}, # removed/added by bot api 7.2 + "Chat": set(_deprecated_attrs), # removed by bot api 7.3 } diff --git a/tests/test_poll.py b/tests/test_poll.py index 5ec4291130b..92c58339daf 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -19,15 +19,97 @@ import pytest -from telegram import Chat, MessageEntity, Poll, PollAnswer, PollOption, User +from telegram import Chat, InputPollOption, MessageEntity, Poll, PollAnswer, PollOption, User from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType from tests.auxil.slots import mro_slots +@pytest.fixture(scope="module") +def input_poll_option(): + out = InputPollOption( + text=TestInputPollOptionBase.text, + text_parse_mode=TestInputPollOptionBase.text_parse_mode, + text_entities=TestInputPollOptionBase.text_entities, + ) + out._unfreeze() + return out + + +class TestInputPollOptionBase: + text = "test option" + text_parse_mode = "MarkdownV2" + text_entities = [ + MessageEntity(0, 4, MessageEntity.BOLD), + MessageEntity(5, 7, MessageEntity.ITALIC), + ] + + +class TestInputPollOptionWithoutRequest(TestInputPollOptionBase): + def test_slot_behaviour(self, input_poll_option): + for attr in input_poll_option.__slots__: + assert getattr(input_poll_option, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(input_poll_option)) == len( + set(mro_slots(input_poll_option)) + ), "duplicate slot" + + def test_de_json(self): + assert InputPollOption.de_json({}, None) is None + + json_dict = { + "text": self.text, + "text_parse_mode": self.text_parse_mode, + "text_entities": [e.to_dict() for e in self.text_entities], + } + input_poll_option = InputPollOption.de_json(json_dict, None) + assert input_poll_option.api_kwargs == {} + + assert input_poll_option.text == self.text + assert input_poll_option.text_parse_mode == self.text_parse_mode + assert input_poll_option.text_entities == tuple(self.text_entities) + + def test_to_dict(self, input_poll_option): + input_poll_option_dict = input_poll_option.to_dict() + + assert isinstance(input_poll_option_dict, dict) + assert input_poll_option_dict["text"] == input_poll_option.text + assert input_poll_option_dict["text_parse_mode"] == input_poll_option.text_parse_mode + assert input_poll_option_dict["text_entities"] == [ + e.to_dict() for e in input_poll_option.text_entities + ] + + # Test that the default-value parameter is handled correctly + input_poll_option = InputPollOption("text") + input_poll_option_dict = input_poll_option.to_dict() + assert "text_parse_mode" not in input_poll_option_dict + + def test_equality(self): + a = InputPollOption("text") + b = InputPollOption("text", self.text_parse_mode) + c = InputPollOption("text", text_entities=self.text_entities) + d = InputPollOption("different_text") + e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + @pytest.fixture(scope="module") def poll_option(): - out = PollOption(text=TestPollOptionBase.text, voter_count=TestPollOptionBase.voter_count) + out = PollOption( + text=TestPollOptionBase.text, + voter_count=TestPollOptionBase.voter_count, + text_entities=TestPollOptionBase.text_entities, + ) out._unfreeze() return out @@ -35,6 +117,10 @@ def poll_option(): class TestPollOptionBase: text = "test option" voter_count = 3 + text_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 6), + ] class TestPollOptionWithoutRequest(TestPollOptionBase): @@ -51,12 +137,43 @@ def test_de_json(self): assert poll_option.text == self.text assert poll_option.voter_count == self.voter_count + def test_de_json_all(self): + json_dict = { + "text": self.text, + "voter_count": self.voter_count, + "text_entities": [e.to_dict() for e in self.text_entities], + } + poll_option = PollOption.de_json(json_dict, None) + assert PollOption.de_json(None, None) is None + assert poll_option.api_kwargs == {} + + assert poll_option.text == self.text + assert poll_option.voter_count == self.voter_count + assert poll_option.text_entities == tuple(self.text_entities) + def test_to_dict(self, poll_option): poll_option_dict = poll_option.to_dict() assert isinstance(poll_option_dict, dict) assert poll_option_dict["text"] == poll_option.text assert poll_option_dict["voter_count"] == poll_option.voter_count + assert poll_option_dict["text_entities"] == [ + e.to_dict() for e in poll_option.text_entities + ] + + def test_parse_entity(self, poll_option): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + poll_option.text_entities = [entity] + + assert poll_option.parse_entity(entity) == "test" + + def test_parse_entities(self, poll_option): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 6) + poll_option.text_entities = [entity, entity_2] + + assert poll_option.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert poll_option.parse_entities() == {entity: "test", entity_2: "option"} def test_equality(self): a = PollOption("text", 1) @@ -159,6 +276,7 @@ def poll(): explanation_entities=TestPollBase.explanation_entities, open_period=TestPollBase.open_period, close_date=TestPollBase.close_date, + question_entities=TestPollBase.question_entities, ) poll._unfreeze() return poll @@ -166,7 +284,7 @@ def poll(): class TestPollBase: id_ = "id" - question = "Test?" + question = "Test Question?" options = [PollOption("test", 10), PollOption("test2", 11)] total_voter_count = 0 is_closed = True @@ -180,6 +298,10 @@ class TestPollBase: explanation_entities = [MessageEntity(13, 17, MessageEntity.URL)] open_period = 42 close_date = datetime.now(timezone.utc) + question_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ] class TestPollWithoutRequest(TestPollBase): @@ -197,6 +319,7 @@ def test_de_json(self, bot): "explanation_entities": [self.explanation_entities[0].to_dict()], "open_period": self.open_period, "close_date": to_timestamp(self.close_date), + "question_entities": [e.to_dict() for e in self.question_entities], } poll = Poll.de_json(json_dict, bot) assert poll.api_kwargs == {} @@ -218,6 +341,7 @@ def test_de_json(self, bot): assert poll.open_period == self.open_period assert abs(poll.close_date - self.close_date) < timedelta(seconds=1) assert to_timestamp(poll.close_date) == to_timestamp(self.close_date) + assert poll.question_entities == tuple(self.question_entities) def test_de_json_localization(self, tz_bot, bot, raw_bot): json_dict = { @@ -233,6 +357,7 @@ def test_de_json_localization(self, tz_bot, bot, raw_bot): "explanation_entities": [self.explanation_entities[0].to_dict()], "open_period": self.open_period, "close_date": to_timestamp(self.close_date), + "question_entities": [e.to_dict() for e in self.question_entities], } poll_raw = Poll.de_json(json_dict, raw_bot) @@ -265,6 +390,7 @@ def test_to_dict(self, poll): assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] assert poll_dict["open_period"] == poll.open_period assert poll_dict["close_date"] == to_timestamp(poll.close_date) + assert poll_dict["question_entities"] == [e.to_dict() for e in poll.question_entities] def test_equality(self): a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) @@ -305,7 +431,7 @@ def test_enum_init(self): ) assert poll.type is PollType.QUIZ - def test_parse_entity(self, poll): + def test_parse_explanation_entity(self, poll): entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17) poll.explanation_entities = [entity] @@ -323,10 +449,36 @@ def test_parse_entity(self, poll): allows_multiple_answers=False, ).parse_explanation_entity(entity) - def test_parse_entities(self, poll): + def test_parse_explanation_entities(self, poll): entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17) entity_2 = MessageEntity(type=MessageEntity.BOLD, offset=13, length=1) poll.explanation_entities = [entity_2, entity] assert poll.parse_explanation_entities(MessageEntity.URL) == {entity: "http://google.com"} assert poll.parse_explanation_entities() == {entity: "http://google.com", entity_2: "h"} + + with pytest.raises(RuntimeError, match="Poll has no"): + Poll( + "id", + "question", + [PollOption("text", voter_count=0)], + total_voter_count=0, + is_closed=False, + is_anonymous=False, + type=Poll.QUIZ, + allows_multiple_answers=False, + ).parse_explanation_entities() + + def test_parse_question_entity(self, poll): + entity = MessageEntity(MessageEntity.ITALIC, 5, 8) + poll.question_entities = [entity] + + assert poll.parse_question_entity(entity) == "Question" + + def test_parse_question_entities(self, poll): + entity = MessageEntity(MessageEntity.ITALIC, 5, 8) + entity_2 = MessageEntity(MessageEntity.BOLD, 0, 4) + poll.question_entities = [entity_2, entity] + + assert poll.parse_question_entities(MessageEntity.ITALIC) == {entity: "Question"} + assert poll.parse_question_entities() == {entity: "Question", entity_2: "Test"} diff --git a/tests/test_shared.py b/tests/test_shared.py index fcad7ec345a..53e5fe4d882 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -20,7 +20,6 @@ import pytest from telegram import ChatShared, PhotoSize, SharedUser, UsersShared -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -59,24 +58,9 @@ def test_de_json(self, bot): assert users_shared.request_id == self.request_id assert users_shared.users == self.users - assert users_shared.user_ids == tuple(self.user_ids) assert UsersShared.de_json({}, bot) is None - def test_users_is_required_argument(self): - with pytest.raises(TypeError, match="`users` is a required argument"): - UsersShared(self.request_id, user_ids=self.user_ids) - - def test_user_ids_deprecation_warning(self): - with pytest.warns( - PTBDeprecationWarning, match="'user_ids' was renamed to 'users' in Bot API 7.2" - ): - users_shared = UsersShared(self.request_id, user_ids=self.user_ids, users=self.users) - with pytest.warns( - PTBDeprecationWarning, match="renamed the attribute 'user_ids' to 'users'" - ): - users_shared.user_ids - def test_equality(self): a = UsersShared(self.request_id, users=self.users) b = UsersShared(self.request_id, users=self.users) diff --git a/tests/test_warnings.py b/tests/test_warnings.py index 06161d59ffe..3e3beb48fd4 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -33,7 +33,7 @@ class TestWarnings: [ (PTBUserWarning("test message")), (PTBRuntimeWarning("test message")), - (PTBDeprecationWarning()), + (PTBDeprecationWarning("20.6", "test message")), ], ) def test_slots_behavior(self, inst): @@ -80,9 +80,8 @@ def test_warn(self, recwarn): assert str(recwarn[1].message) == "test message 2" assert Path(recwarn[1].filename) == expected_file, "incorrect stacklevel!" - warn("test message 3", stacklevel=1, category=PTBDeprecationWarning) - expected_file = Path(__file__) + warn(PTBDeprecationWarning("20.6", "test message 3"), stacklevel=1) assert len(recwarn) == 3 assert recwarn[2].category is PTBDeprecationWarning - assert str(recwarn[2].message) == "test message 3" - assert Path(recwarn[2].filename) == expected_file, "incorrect stacklevel!" + assert str(recwarn[2].message) == "Deprecated since version 20.6: test message 3" + assert Path(recwarn[2].filename) == Path(__file__), "incorrect stacklevel!"