Compare commits

..

9 Commits

Author SHA1 Message Date
Hinrich Mahler b1fff6d90a Use Lock instead of semaphore 2025-02-06 12:05:20 +01:00
Hinrich Mahler 31af1a9db8 Add an example on concurrency in FSM 2025-02-06 11:57:18 +01:00
Hinrich Mahler 4441543043 Try setting up infrastructure for optimistic locking. Example will follow 2025-02-05 23:40:15 +01:00
Hinrich Mahler 646ba37391 Move internal state storage to FiniteStateMachine and add state history 2025-02-05 13:13:21 +01:00
Hinrich Mahler 817b71d914 Disable tests harder … 2025-02-05 12:26:02 +01:00
Hinrich Mahler 434cbfade8 Add Some Abstractions for Timeout Jobs 2025-02-05 12:22:07 +01:00
Hinrich Mahler 34832d9db9 Temporarily Disable Tests on this branch 2025-02-05 11:17:47 +01:00
Hinrich Mahler 07225b9a02 Add State.ANY for fallbacks and allow handling multiple states for one update 2025-02-05 10:52:10 +01:00
Hinrich Mahler 0c06ba0a90 Initial FSM PoC 2025-02-04 21:36:40 +01:00
70 changed files with 2454 additions and 3494 deletions
-41
View File
@@ -1,41 +0,0 @@
name: Check Links in Documentation
on:
schedule:
# First day of month at 05:46 in every 2nd month
- cron: '46 5 1 */2 *'
pull_request:
paths:
- .github/workflows/docs-linkcheck.yml
permissions: {}
jobs:
test-sphinx-build:
name: test-sphinx-linkcheck
runs-on: ${{matrix.os}}
strategy:
matrix:
python-version: ['3.10']
os: [ubuntu-latest]
fail-fast: False
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -W ignore -m pip install --upgrade pip
python -W ignore -m pip install -r requirements-dev-all.txt
- name: Check Links
run: sphinx-build docs/source docs/build/html -W --keep-going -j auto -b linkcheck
- name: Upload linkcheck output
# Run also if the previous steps failed
if: always()
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
name: linkcheck-output
path: docs/build/html/output.*
-53
View File
@@ -1,53 +0,0 @@
name: Test Documentation Build
on:
pull_request:
paths:
- telegram/**
- docs/**
push:
branches:
- master
permissions: {}
jobs:
test-sphinx-build:
name: test-sphinx-build
runs-on: ${{matrix.os}}
permissions:
# for uploading artifacts
actions: write
strategy:
matrix:
python-version: ['3.10']
os: [ubuntu-latest]
fail-fast: False
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: '**/requirements*.txt'
- name: Install dependencies
run: |
python -W ignore -m pip install --upgrade pip
python -W ignore -m pip install -r requirements-dev-all.txt
- name: Test autogeneration of admonitions
run: pytest -v --tb=short tests/docs/admonition_inserter.py
- name: Build docs
run: sphinx-build docs/source docs/build/html -W --keep-going -j auto
- name: Upload docs
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
name: HTML Docs
retention-days: 7
path: |
# Exclude the .doctrees folder and .buildinfo file from the artifact
# since they are not needed and add to the size
docs/build/html/*
!docs/build/html/.doctrees
!docs/build/html/.buildinfo
-51
View File
@@ -1,51 +0,0 @@
name: Bot API Tests
on:
pull_request:
paths:
- telegram/**
- tests/**
push:
branches:
- master
schedule:
# Run monday and friday morning at 03:07 - odd time to spread load on GitHub Actions
- cron: '7 3 * * 1,5'
permissions: {}
jobs:
check-conformity:
name: check-conformity
runs-on: ${{matrix.os}}
strategy:
matrix:
python-version: [3.11]
os: [ubuntu-latest]
fail-fast: False
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -W ignore -m pip install --upgrade pip
python -W ignore -m pip install .[all]
python -W ignore -m pip install -r requirements-unit-tests.txt
- name: Compare to official api
run: |
pytest -v tests/test_official/test_official.py --junit-xml=.test_report_official.xml
exit $?
env:
TEST_OFFICIAL: "true"
shell: bash --noprofile --norc {0}
- name: Test Summary
id: test_summary
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4
if: always() # always run, even if tests fail
with:
paths: .test_report_official.xml
-23
View File
@@ -1,23 +0,0 @@
name: Check Type Completeness
on:
pull_request:
paths:
- telegram/**
- pyproject.toml
- .github/workflows/type_completeness.yml
push:
branches:
- master
permissions: {}
jobs:
test-type-completeness:
name: test-type-completeness
runs-on: ubuntu-latest
steps:
- uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # v1.0.1
with:
package-name: telegram
python-version: 3.12
pyright-version: ~=1.1.367
File diff suppressed because one or more lines are too long
-48
View File
@@ -4,54 +4,6 @@
Changelog
=========
Version 21.11
=============
*Released 2025-03-01*
This is the technical changelog for version 21.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel <https://t.me/pythontelegrambotchannel>`_.
Major Changes and New Features
------------------------------
- Full Support for Bot API 8.3 (:pr:`4676` closes :issue:`4677`, :pr:`4682` by `aelkheir <https://github.com/aelkheir>`_, :pr:`4690` by `aelkheir <https://github.com/aelkheir>`_, :pr:`4691` by `aelkheir <https://github.com/aelkheir>`_)
- Make ``provider_token`` Argument Optional (:pr:`4689`)
- Remove Deprecated ``InlineQueryResultArticle.hide_url`` (:pr:`4640` closes :issue:`4638`)
- Accept ``datetime.timedelta`` Input in ``Bot`` Method Parameters (:pr:`4651`)
- Extend Customization Support for ``Bot.base_(file_)url`` (:pr:`4632` closes :issue:`3355`)
- Support ``allow_paid_broadcast`` in ``AIORateLimiter`` (:pr:`4627` closes :issue:`4578`)
- Add ``BaseUpdateProcessor.current_concurrent_updates`` (:pr:`4626` closes :issue:`3984`)
Minor Changes and Bug Fixes
---------------------------
- Add Bootstrapping Logic to ``Application.run_*`` (:pr:`4673` closes :issue:`4657`)
- Fix a Bug in ``edit_user_star_subscription`` (:pr:`4681` by `vavasik800 <https://github.com/vavasik800>`_)
- Simplify Handling of Empty Data in ``TelegramObject.de_json`` and Friends (:pr:`4617` closes :issue:`4614`)
Documentation Improvements
--------------------------
- Documentation Improvements (:pr:`4641`)
- Overhaul Admonition Insertion in Documentation (:pr:`4462` closes :issue:`4414`)
Internal Changes
----------------
- Stabilize Linkcheck Test (:pr:`4693`)
- Bump ``pre-commit`` Hooks to Latest Versions (:pr:`4643`)
- Refactor Tests for ``TelegramObject`` Classes with Subclasses (:pr:`4654` closes :issue:`4652`)
- Use Fine Grained Permissions for GitHub Actions Workflows (:pr:`4668`)
Dependency Updates
------------------
- Bump ``actions/setup-python`` from 5.3.0 to 5.4.0 (:pr:`4665`)
- Bump ``dependabot/fetch-metadata`` from 2.2.0 to 2.3.0 (:pr:`4666`)
- Bump ``actions/stale`` from 9.0.0 to 9.1.0 (:pr:`4667`)
- Bump ``astral-sh/setup-uv`` from 5.1.0 to 5.2.2 (:pr:`4664`)
- Bump ``codecov/test-results-action`` from 1.0.1 to 1.0.2 (:pr:`4663`)
Version 21.10
=============
+2 -2
View File
@@ -11,7 +11,7 @@
:target: https://pypi.org/project/python-telegram-bot/
:alt: Supported Python versions
.. image:: https://img.shields.io/badge/Bot%20API-8.3-blue?logo=telegram
.. image:: https://img.shields.io/badge/Bot%20API-8.2-blue?logo=telegram
:target: https://core.telegram.org/bots/api-changelog
:alt: Supported Bot API version
@@ -81,7 +81,7 @@ After installing_ the library, be sure to check out the section on `working with
Telegram API support
~~~~~~~~~~~~~~~~~~~~
All types and methods of the Telegram Bot API **8.3** are natively supported by this library.
All types and methods of the Telegram Bot API **8.2** are natively supported by this library.
In addition, Bot API functionality not yet natively included can still be used as described `in our wiki <https://github.com/python-telegram-bot/python-telegram-bot/wiki/Bot-API-Forward-Compatibility>`_.
Notable Features
-5
View File
@@ -111,11 +111,6 @@ linkcheck_ignore = [
# Anchors are apparently inserted by GitHub dynamically, so let's skip checking them
"https://github.com/python-telegram-bot/python-telegram-bot/tree/master/examples#",
r"https://github\.com/python-telegram-bot/python-telegram-bot/wiki/[\w\-_,]+\#",
# The LGPL license link regularly causes network errors for some reason
re.escape("https://www.gnu.org/licenses/lgpl-3.0.html"),
# The doc-fixes branch may not always exist - doesn't matter, we only link to it from the
# contributing guide
re.escape("https://docs.python-telegram-bot.org/en/doc-fixes"),
]
linkcheck_allowed_redirects = {
# Redirects to the default version are okay
-1
View File
@@ -27,7 +27,6 @@ Your bot can accept payments from Telegram users. Please see the `introduction t
telegram.successfulpayment
telegram.transactionpartner
telegram.transactionpartneraffiliateprogram
telegram.transactionpartnerchat
telegram.transactionpartnerfragment
telegram.transactionpartnerother
telegram.transactionpartnertelegramads
@@ -1,7 +0,0 @@
TransactionPartnerChat
======================
.. autoclass:: telegram.TransactionPartnerChat
:members:
:show-inheritance:
:inherited-members: TransactionPartner
+1 -3
View File
@@ -98,6 +98,4 @@
.. |tz-naive-dtms| replace:: For timezone naive :obj:`datetime.datetime` objects, the default timezone of the bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
.. |org-verify| replace:: `on behalf of the organization <https://telegram.org/verify#third-party-verification>`__
.. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values.
.. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values.
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python
# pylint: disable=unused-argument
# This program is dedicated to the public domain under the CC0 license.
"""Simple state machine to handle user support.
One admin is supported. The admin can have one active conversation at a time. Other users
are put on hold until the admin finishes the current conversation.
In each conversation, the admin and the user take turns to send messages.
"""
import logging
from typing import Optional
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
FiniteStateMachine,
MessageHandler,
State,
StateInfo,
filters,
)
# Enable logging
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("telegram").setLevel(logging.WARNING)
logging.getLogger("telegram.ext.Application").setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
class UserSupportMachine(FiniteStateMachine[Optional[int]]):
HOLD = State("HOLD")
WELCOMING = State("WELCOMING")
WAITING_FOR_REPLY = State("WAITING_FOR_REPLY")
WRITING = State("WRITING")
def __init__(self, admin_id: int):
self.admin_id = admin_id
super().__init__()
def _get_admin_state(self) -> tuple[State, int]:
return self._states[self.admin_id]
def get_state_info(self, update: object) -> StateInfo[Optional[int]]:
if not isinstance(update, Update) or not (user := update.effective_user):
key = None
state, version = self.states[key]
return StateInfo(key=key, state=state, version=version)
# Admin is easy - just return the state
admin_state, admin_version = self._get_admin_state()
if user.id == self.admin_id:
logging.debug("Returning admin state: %s", admin_state)
return StateInfo(self.admin_id, admin_state, admin_version)
# If the user state is active in the conversation, we can just return that state
user_state, user_version = self._states[user.id]
if user_state.matches(self.WELCOMING | self.WRITING | self.WAITING_FOR_REPLY):
logging.debug("Returning user state: %s", user_state)
return StateInfo(user.id, user_state, user_version)
# On first interaction, we need to determine what to do with the user
# if the admin is not idle, we put the user on hold. Otherwise, they may send the first
# message, and we put the admin in waiting for reply to avoid another user occupying the
# admin first
effective_user_state = self.HOLD if admin_state != State.IDLE else self.WELCOMING
self._do_set_state(user.id, effective_user_state, user_version)
if effective_user_state == self.WELCOMING:
self._do_set_state(self.admin_id, self.WAITING_FOR_REPLY)
logging.debug("Returning user state: %s", effective_user_state)
return StateInfo(user.id, effective_user_state, user_version)
async def welcome_user(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.forward(context.bot_data["admin_id"])
suffix = ""
if UserSupportMachine.HOLD in context.fsm.get_state_history(context.fsm_state_info.key)[:-1]:
suffix = " Thank you for patiently waiting. We hope you enjoyed the music."
await update.effective_message.reply_text(
"Welcome! Your message has been forwarded to the admin. "
f"They will get back to you soon.{suffix}"
)
await context.set_state(UserSupportMachine.WAITING_FOR_REPLY)
await context.fsm.set_state(context.bot_data["admin_id"], UserSupportMachine.WRITING)
context.bot_data["active_user"] = update.effective_user.id
async def conversation_timeout(context: ContextTypes.DEFAULT_TYPE) -> None:
active_user = context.bot_data.get("active_user")
admin_id = context.bot_data["admin_id"]
async def handle(user_id: int) -> None:
await context.bot.send_message(
user_id, "The conversation has been stopped due to inactivity."
)
await context.fsm.set_state(user_id, State.IDLE)
if active_user:
await handle(active_user)
await handle(admin_id)
async def handle_reply(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not (active_user := context.bot_data.get("active_user")):
logger.warning("No active user found, ignoring message")
target = (
active_user
if update.effective_user.id == (admin_id := context.bot_data["admin_id"])
else admin_id
)
await context.bot.send_message(target, update.effective_message.text)
logging.debug("Forwarded message to %s", target)
await context.set_state(UserSupportMachine.WAITING_FOR_REPLY)
logging.debug("Done setting state to WAITING_FOR_REPLY for %s", target)
await context.fsm.set_state(target, UserSupportMachine.WRITING)
logging.debug("Done setting state to WRITING for %s, context.fsm_key")
context.fsm.schedule_timeout(
when=30,
callback=conversation_timeout,
cancel_keys=[active_user, admin_id],
)
async def stop_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = "The conversation has been stopped."
admin_id = context.bot_data["admin_id"]
active_user = context.bot_data.get("active_user")
await context.bot.send_message(admin_id, text)
await context.fsm.set_state(admin_id, State.IDLE)
if active_user:
await context.bot.send_message(active_user, text)
await context.fsm.set_state(active_user, State.IDLE)
async def hold_melody(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(
"You have been put on hold. The admin will get back to you soon. Please hear some music "
"while you wait: https://www.youtube.com/watch?v=dQw4w9WgXcQ"
)
async def not_your_turn(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(
"It's not your turn yet. Please wait for the other party to reply to your message."
)
async def unsupported_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text("This message is not supported.")
def main() -> None:
application = Application.builder().token("TOKEN").build()
application.fsm = UserSupportMachine(admin_id=123456)
application.fsm.set_job_queue(application.job_queue)
application.bot_data["admin_id"] = application.fsm.admin_id
# Users are welcomed only if they are in the corresponding state
application.add_handler(
MessageHandler(~filters.User(application.fsm.admin_id) & filters.TEXT, welcome_user),
state=UserSupportMachine.WELCOMING,
)
# Conversation logic:
# * forward messages between user and admin
# * stop the conversation at any time (admin or user)
# * point out that the other party is currently writing
# Important: Order matters!
application.add_handler(
CommandHandler("stop", stop_conversation),
state=UserSupportMachine.WAITING_FOR_REPLY | UserSupportMachine.WRITING,
)
application.add_handler(
MessageHandler(filters.TEXT, handle_reply), state=UserSupportMachine.WRITING
)
application.add_handler(
MessageHandler(filters.TEXT, not_your_turn), state=UserSupportMachine.WAITING_FOR_REPLY
)
# If the admin is busy, put the user on hold
application.add_handler(
MessageHandler(filters.TEXT, hold_melody), state=UserSupportMachine.HOLD
)
# Fallback
application.add_handler(MessageHandler(filters.ALL, unsupported_message), state=State.ANY)
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python
# pylint: disable=unused-argument
# This program is dedicated to the public domain under the CC0 license.
"""State machine bot showcasing how concurrency can be handled with FSM.
How to use:
* Use Case 1: Concurrent balance updates
- /unsafe_update <balance_update>: Unsafe update of the wallet balance. Send the command
multiple times in quick succession (less than 1 second) to see the effect
- /safe_update <balance_update>: Safe update of the wallet balance. Send the command
multiple times in quick succession (less than 1 second) to see the effect
* Use Case 2: Declare a winner - who is the fastest?
- /unsafe_declare_winner: Unsafe declaration of the user as winner. Send the command
multiple times in quick succession (less than 1 second) to see the effect. Needs restart
after the winner is declared.
- /safe_declare_winner: Safe declaration of the user as winner. Send the command
multiple times in quick succession (less than 1 second) to see the effect. Needs restart
after the winner is declared.
"""
import asyncio
import logging
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
FiniteStateMachine,
MessageHandler,
State,
StateInfo,
filters,
)
# Enable logging
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("telegram").setLevel(logging.WARNING)
logging.getLogger("telegram.ext.Application").setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
class ConcurrentMachine(FiniteStateMachine[None]):
"""This FSM only knows a global state for the whole bot"""
UPDATING_BALANCE = State("UPDATING_BALANCE")
WINNER_DECLARED = State("WINNER_DECLARED")
def get_state_info(self, update: object) -> StateInfo[None]:
state, version = self.states[None]
return StateInfo(key=None, state=state, version=version)
########################################
# Use case 1: Concurrent balance updates
########################################
async def update_balance(context: ContextTypes.DEFAULT_TYPE, update: Update) -> None:
initial_balance = context.bot_data.get("balance", 0)
balance_update = int(context.args[0])
# Simulate heavy computation
await update.effective_message.reply_text(
f"Initiating balance update: {initial_balance}. Updating ..."
)
await update.effective_chat.send_action(ChatAction.TYPING)
await asyncio.sleep(4.5)
new_balance = context.bot_data["balance"] = initial_balance + balance_update
await update.effective_message.reply_text(f"Balance updated. New balance: {new_balance}")
async def unsafe_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Unsafe update of the wallet balance"""
# Simulate heavy computation *before* the update is processed
await asyncio.sleep(1)
await context.fsm.set_state(context.fsm_state_info.key, ConcurrentMachine.UPDATING_BALANCE)
# At this point, the lock is released such that multiple updates can update
# the balance concurrently. This can lead to race conditions.
await update_balance(context, update)
await context.fsm.set_state(context.fsm_state_info.key, State.IDLE)
async def safe_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Safe update of the wallet balance"""
# Simulate heavy computation *before* the update is processed
await asyncio.sleep(1)
async with context.as_fsm_state(ConcurrentMachine.UPDATING_BALANCE):
# At this point, the lock is acquired such that only one update can update
# the balance at a time. This prevents race conditions.
await update_balance(context, update)
async def busy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Busy state"""
await update.effective_message.reply_text("I'm busy, try again later.")
####################################################
# Use case 2: Declare a winner - who is the fastest?
####################################################
async def declare_winner_unsafe(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Declare the user as winner"""
# Simulate heavy computation *before* the update is processed
await asyncio.sleep(1)
# Unsafe state update: No version check, so the state might have already changed
await context.fsm.set_state(context.fsm_state_info.key, ConcurrentMachine.WINNER_DECLARED)
await update.effective_message.reply_text("You are the winner!")
async def declare_winner_safe(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Declare the user as winner"""
# Simulate heavy computation *before* the update is processed
await asyncio.sleep(1)
try:
await context.set_state(ConcurrentMachine.WINNER_DECLARED)
await update.effective_message.reply_text("You are the winner!")
except ValueError:
await update.effective_message.reply_text(
"Sorry, you are too late. Someone else was faster."
)
def main() -> None:
application = Application.builder().token("TOKEN").concurrent_updates(True).build()
application.fsm = ConcurrentMachine()
# Note: OR-combination of states is used here to allow both use cases to be handled
# in parallel. Not really necessary for the showcasing, just a nice touch :)
# Use case 2: Declare a winner - who is the fastest?
application.add_handler(
CommandHandler("unsafe_declare_winner", declare_winner_unsafe),
state=State.IDLE | ConcurrentMachine.UPDATING_BALANCE,
)
application.add_handler(
CommandHandler("safe_declare_winner", declare_winner_safe),
state=State.IDLE | ConcurrentMachine.UPDATING_BALANCE,
)
# Use case 1: Concurrent balance updates
application.add_handler(
CommandHandler("unsafe_update", unsafe_update, has_args=1),
state=State.IDLE | ConcurrentMachine.WINNER_DECLARED,
)
application.add_handler(
CommandHandler("safe_update", safe_update, has_args=1),
state=State.IDLE | ConcurrentMachine.WINNER_DECLARED,
)
# Order matters, so this needs to be added last
application.add_handler(
MessageHandler(filters.ALL, busy), state=ConcurrentMachine.UPDATING_BALANCE
)
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
+2 -8
View File
@@ -61,9 +61,9 @@ async def start_with_shipping_callback(update: Update, context: ContextTypes.DEF
title,
description,
payload,
PAYMENT_PROVIDER_TOKEN,
currency,
prices,
provider_token=PAYMENT_PROVIDER_TOKEN,
need_name=True,
need_phone_number=True,
need_email=True,
@@ -90,13 +90,7 @@ async def start_without_shipping_callback(
# optionally pass need_name=True, need_phone_number=True,
# need_email=True, need_shipping_address=True, is_flexible=True
await context.bot.send_invoice(
chat_id,
title,
description,
payload,
currency,
prices,
provider_token=PAYMENT_PROVIDER_TOKEN,
chat_id, title, description, payload, PAYMENT_PROVIDER_TOKEN, currency, prices
)
-2
View File
@@ -238,7 +238,6 @@ __all__ = (
"TextQuote",
"TransactionPartner",
"TransactionPartnerAffiliateProgram",
"TransactionPartnerChat",
"TransactionPartnerFragment",
"TransactionPartnerOther",
"TransactionPartnerTelegramAds",
@@ -276,7 +275,6 @@ from telegram._payment.stars.startransactions import StarTransaction, StarTransa
from telegram._payment.stars.transactionpartner import (
TransactionPartner,
TransactionPartnerAffiliateProgram,
TransactionPartnerChat,
TransactionPartnerFragment,
TransactionPartnerOther,
TransactionPartnerTelegramAds,
+41 -83
View File
@@ -245,7 +245,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
Example:
``"https://api.telegram.org/bot{token}/test"``
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
Supports callable input and string formatting.
base_file_url (:obj:`str`, optional): Telegram Bot API file URL.
If the string contains ``{token}``, it will be replaced with the bot's
@@ -262,7 +262,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
Example:
``"https://api.telegram.org/file/bot{token}/test"``
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
Supports callable input and string formatting.
request (:class:`telegram.request.BaseRequest`, optional): Pre initialized
:class:`telegram.request.BaseRequest` instances. Will be used for all bot methods
@@ -1218,7 +1218,6 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -1243,10 +1242,6 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
original message was sent (or channel username in the format ``@channelusername``).
message_id (:obj:`int`): Message identifier in the chat specified in
:paramref:`from_chat_id`.
video_start_timestamp (:obj:`int`, optional): New start timestamp for the
forwarded video in the message
.. versionadded:: 21.11
disable_notification (:obj:`bool`, optional): |disable_notification|
protect_content (:obj:`bool`, optional): |protect_content|
@@ -1265,7 +1260,6 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
"chat_id": chat_id,
"from_chat_id": from_chat_id,
"message_id": message_id,
"video_start_timestamp": video_start_timestamp,
}
return await self._send_message(
@@ -1564,7 +1558,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent audio
in seconds.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
performer (:obj:`str`, optional): Performer.
title (:obj:`str`, optional): Track name.
@@ -1961,8 +1955,6 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
message_effect_id: Optional[str] = None,
allow_paid_broadcast: Optional[bool] = None,
show_caption_above_media: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
reply_to_message_id: Optional[int] = None,
@@ -2006,17 +1998,10 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent video
in seconds.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
width (:obj:`int`, optional): Video width.
height (:obj:`int`, optional): Video height.
cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \
optional): Cover for the video in the message. |fileinputnopath|
.. versionadded:: 21.11
start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message.
.. versionadded:: 21.11
caption (:obj:`str`, optional): Video caption (may also be used when resending videos
by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH`
characters after entities parsing.
@@ -2103,8 +2088,6 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
"width": width,
"height": height,
"supports_streaming": supports_streaming,
"cover": self._parse_file_input(cover, attach=True) if cover else None,
"start_timestamp": start_timestamp,
"thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None,
"has_spoiler": has_spoiler,
"show_caption_above_media": show_caption_above_media,
@@ -2192,7 +2175,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent video
in seconds.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
length (:obj:`int`, optional): Video width and height, i.e. diameter of the video
message.
@@ -2344,7 +2327,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent
animation in seconds.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
width (:obj:`int`, optional): Animation width.
height (:obj:`int`, optional): Animation height.
@@ -2528,7 +2511,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the voice
message in seconds.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
disable_notification (:obj:`bool`, optional): |disable_notification|
protect_content (:obj:`bool`, optional): |protect_content|
@@ -2842,7 +2825,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
:tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live
locations that can be edited indefinitely.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
heading (:obj:`int`, optional): For live locations, a direction in which the user is
moving, in degrees. Must be between
@@ -3012,7 +2995,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
.. versionadded:: 21.2.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
business_connection_id (:obj:`str`, optional): |business_id_str_edit|
@@ -3709,7 +3692,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
time in seconds that the
result of the inline query may be cached on the server. Defaults to ``300``.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
is_personal (:obj:`bool`, optional): Pass :obj:`True`, if results may be cached on
the server side only for the user that sent the query. By default,
@@ -4161,7 +4144,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
time in seconds that the
result of the callback query may be cached client-side. Defaults to 0.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
Returns:
@@ -4648,11 +4631,8 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
"""
Use this method to specify a url and receive incoming updates via an outgoing webhook.
Whenever there is an update for the bot, Telegram will send an HTTPS POST request to the
specified url, containing An Update. In case of an unsuccessful request
(a request with response
`HTTP status code <https://en.wikipedia.org/wiki/List_of_HTTP_status_codes>`_different
from ``2XY``),
Telegram will repeat the request and give up after a reasonable amount of attempts.
specified url, containing An Update. In case of an unsuccessful request,
Telegram will give up after a reasonable amount of attempts.
If you'd like to make sure that the Webhook was set by you, you can specify secret data in
the parameter :paramref:`secret_token`. If specified, the request will contain a header
@@ -5197,9 +5177,9 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
title: str,
description: str,
payload: str,
provider_token: Optional[str], # This arg is now optional as of Bot API 7.4
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
start_parameter: Optional[str] = None,
photo_url: Optional[str] = None,
photo_size: Optional[int] = None,
@@ -5252,13 +5232,13 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]):
:tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`-
:tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be
displayed to the user, use it for your internal processes.
provider_token (:obj:`str`, optional): Payments provider token, obtained via
provider_token (:obj:`str`): Payments provider token, obtained via
`@BotFather <https://t.me/BotFather>`_. Pass an empty string for payments in
|tg_stars|.
.. versionchanged:: 21.11
Bot API 7.4 made this parameter is optional and this is now reflected in the
function signature.
.. deprecated:: 21.3
As of Bot API 7.4, this parameter is now optional and future versions of the
library will make it optional as well.
currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies
<https://core.telegram.org/bots/payments#supported-currencies>`_. Pass ``XTR`` for
@@ -6984,7 +6964,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
:tg-const:`telegram.constants.StickerFormat.STATIC` for a
``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED`
for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a
``.WEBM`` video.
WEBM video.
.. versionadded:: 21.1
@@ -6998,7 +6978,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
:tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_THUMBNAIL_SIZE`
kilobytes in size; see
`the docs <https://core.telegram.org/stickers#animation-requirements>`_ for
animated sticker technical requirements, or a ``.WEBM`` video with the thumbnail up
animated sticker technical requirements, or a **.WEBM** video with the thumbnail up
to :tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_THUMBNAIL_SIZE`
kilobytes in size; see
`this <https://core.telegram.org/stickers#video-requirements>`_ for video sticker
@@ -7387,7 +7367,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
:tg-const:`telegram.Poll.MAX_OPEN_PERIOD`. Can't be used together with
:paramref:`close_date`.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
close_date (:obj:`int` | :obj:`datetime.datetime`, optional): Point in time (Unix
timestamp) when the poll will be automatically closed. Must be at least
@@ -7994,7 +7974,6 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
reply_to_message_id: Optional[int] = None,
@@ -8014,10 +7993,6 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the
original message was sent (or channel username in the format ``@channelusername``).
message_id (:obj:`int`): Message identifier in the chat specified in from_chat_id.
video_start_timestamp (:obj:`int`, optional): New start timestamp for the
copied video in the message
.. versionadded:: 21.11
caption (:obj:`str`, optional): New caption for media,
0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after
entities parsing. If not specified, the original caption is kept.
@@ -8108,7 +8083,6 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
"reply_parameters": reply_parameters,
"show_caption_above_media": show_caption_above_media,
"allow_paid_broadcast": allow_paid_broadcast,
"video_start_timestamp": video_start_timestamp,
}
result = await self._post(
@@ -8278,9 +8252,9 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
title: str,
description: str,
payload: str,
provider_token: Optional[str], # This arg is now optional as of Bot API 7.4
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
max_tip_amount: Optional[int] = None,
suggested_tip_amounts: Optional[Sequence[int]] = None,
provider_data: Optional[Union[str, object]] = None,
@@ -8322,13 +8296,13 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
:tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`-
:tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be
displayed to the user, use it for your internal processes.
provider_token (:obj:`str`, optional): Payments provider token, obtained via
provider_token (:obj:`str`): Payments provider token, obtained via
`@BotFather <https://t.me/BotFather>`_. Pass an empty string for payments in
|tg_stars|.
.. versionchanged:: 21.11
Bot API 7.4 made this parameter is optional and this is now reflected in the
function signature.
.. deprecated:: 21.3
As of Bot API 7.4, this parameter is now optional and future versions of the
library will make it optional as well.
currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies
<https://core.telegram.org/bots/payments#supported-currencies>`_. Pass ``XTR`` for
@@ -9300,8 +9274,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
api_kwargs: Optional[JSONDict] = None,
) -> bool:
"""
Use this method to change the chosen reactions on a message. Service messages of some types
can't be
Use this method to change the chosen reactions on a message. Service messages can't be
reacted to. Automatically forwarded messages from a channel to its discussion group have
the same available reactions as messages in the channel. Bots can't use paid reactions.
@@ -9588,7 +9561,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
"is_canceled": is_canceled,
}
return await self._post(
"editUserStarSubscription",
"editUserStartSubscription",
data,
read_timeout=read_timeout,
write_timeout=write_timeout,
@@ -9734,7 +9707,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
active for before the next payment. Currently, it must always be
:tg-const:`telegram.constants.ChatSubscriptionLimit.SUBSCRIPTION_PERIOD` (30 days).
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
|time-period-input|
subscription_price (:obj:`int`): The number of Telegram Stars a user must pay initially
and after each subsequent subscription period to be a member of the chat;
@@ -9831,7 +9804,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: Optional[JSONDict] = None,
) -> Gifts:
"""Returns the list of gifts that can be sent by the bot to users and channel chats.
"""Returns the list of gifts that can be sent by the bot to users.
Requires no parameters.
.. versionadded:: 21.8
@@ -9855,13 +9828,12 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
async def send_gift(
self,
user_id: Optional[int] = None,
gift_id: Union[str, Gift] = None, # type: ignore
user_id: int,
gift_id: Union[str, Gift],
text: Optional[str] = None,
text_parse_mode: ODVInput[str] = DEFAULT_NONE,
text_entities: Optional[Sequence["MessageEntity"]] = None,
pay_for_upgrade: Optional[bool] = None,
chat_id: Optional[Union[str, int]] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -9869,23 +9841,15 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: Optional[JSONDict] = None,
) -> bool:
"""Sends a gift to the given user or channel chat.
The gift can't be converted to Telegram Stars by the receiver.
"""Sends a gift to the given user.
The gift can't be converted to Telegram Stars by the user
.. versionadded:: 21.8
Args:
user_id (:obj:`int`, optional): Required if :paramref:`chat_id` is not specified.
Unique identifier of the target user that will receive the gift.
.. versionchanged:: 21.11
Now optional.
user_id (:obj:`int`): Unique identifier of the target user that will receive the gift
gift_id (:obj:`str` | :class:`~telegram.Gift`): Identifier of the gift or a
:class:`~telegram.Gift` object
chat_id (:obj:`int` | :obj:`str`, optional): Required if :paramref:`user_id`
is not specified. |chat_id_channel| It will receive the gift.
.. versionadded:: 21.11
text (:obj:`str`, optional): Text that will be shown along with the gift;
0- :tg-const:`telegram.constants.GiftLimit.MAX_TEXT_LENGTH` characters
text_parse_mode (:obj:`str`, optional): Mode for parsing entities.
@@ -9912,11 +9876,6 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
Raises:
:class:`telegram.error.TelegramError`
"""
# TODO: Remove when stability policy allows, tags: deprecated 21.11
# also we should raise a deprecation warnung if anything is passed by
# position since it will be moved, not sure how
if gift_id is None:
raise TypeError("Missing required argument `gift_id`.")
data: JSONDict = {
"user_id": user_id,
"gift_id": gift_id.id if isinstance(gift_id, Gift) else gift_id,
@@ -9924,7 +9883,6 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
"text_parse_mode": text_parse_mode,
"text_entities": text_entities,
"pay_for_upgrade": pay_for_upgrade,
"chat_id": chat_id,
}
return await self._post(
"sendGift",
@@ -9947,7 +9905,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: Optional[JSONDict] = None,
) -> bool:
"""Verifies a chat |org-verify| which is represented by the bot.
"""Verifies a chat on behalf of the organization which is represented by the bot.
.. versionadded:: 21.10
@@ -9989,7 +9947,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: Optional[JSONDict] = None,
) -> bool:
"""Verifies a user |org-verify| which is represented by the bot.
"""Verifies a user on behalf of the organization which is represented by the bot.
.. versionadded:: 21.10
@@ -10030,8 +9988,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: Optional[JSONDict] = None,
) -> bool:
"""Removes verification from a chat that is currently verified |org-verify|
represented by the bot.
"""Removes verification from a chat that is currently verified on behalf of the
organization represented by the bot.
@@ -10069,8 +10027,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: Optional[JSONDict] = None,
) -> bool:
"""Removes verification from a user who is currently verified |org-verify|
represented by the bot.
"""Removes verification from a user who is currently verified on behalf of the
organization represented by the bot.
-2
View File
@@ -831,7 +831,6 @@ class CallbackQuery(TelegramObject):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
reply_to_message_id: Optional[int] = None,
@@ -865,7 +864,6 @@ class CallbackQuery(TelegramObject):
chat_id=chat_id,
caption=caption,
parse_mode=parse_mode,
video_start_timestamp=video_start_timestamp,
caption_entities=caption_entities,
disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id,
+3 -23
View File
@@ -1576,9 +1576,9 @@ class _ChatBase(TelegramObject):
title: str,
description: str,
payload: str,
provider_token: Optional[str],
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
start_parameter: Optional[str] = None,
photo_url: Optional[str] = None,
photo_size: Optional[int] = None,
@@ -1940,8 +1940,6 @@ class _ChatBase(TelegramObject):
message_effect_id: Optional[str] = None,
allow_paid_broadcast: Optional[bool] = None,
show_caption_above_media: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -1980,8 +1978,6 @@ class _ChatBase(TelegramObject):
parse_mode=parse_mode,
supports_streaming=supports_streaming,
thumbnail=thumbnail,
cover=cover,
start_timestamp=start_timestamp,
api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply,
caption_entities=caption_entities,
@@ -2203,7 +2199,6 @@ class _ChatBase(TelegramObject):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -2230,7 +2225,6 @@ class _ChatBase(TelegramObject):
from_chat_id=from_chat_id,
message_id=message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -2263,7 +2257,6 @@ class _ChatBase(TelegramObject):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -2290,7 +2283,6 @@ class _ChatBase(TelegramObject):
chat_id=chat_id,
message_id=message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -2406,7 +2398,6 @@ class _ChatBase(TelegramObject):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -2432,7 +2423,6 @@ class _ChatBase(TelegramObject):
chat_id=self.id,
from_chat_id=from_chat_id,
message_id=message_id,
video_start_timestamp=video_start_timestamp,
disable_notification=disable_notification,
read_timeout=read_timeout,
write_timeout=write_timeout,
@@ -2450,7 +2440,6 @@ class _ChatBase(TelegramObject):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -2477,7 +2466,6 @@ class _ChatBase(TelegramObject):
from_chat_id=self.id,
chat_id=chat_id,
message_id=message_id,
video_start_timestamp=video_start_timestamp,
disable_notification=disable_notification,
read_timeout=read_timeout,
write_timeout=write_timeout,
@@ -3474,25 +3462,18 @@ class _ChatBase(TelegramObject):
await bot.send_gift(user_id=update.effective_chat.id, *args, **kwargs )
or::
await bot.send_gift(chat_id=update.effective_chat.id, *args, **kwargs )
For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`.
Caution:
Will only work if the chat is a private or channel chat, see :attr:`type`.
Can only work, if the chat is a private chat, see :attr:`type`.
.. versionadded:: 21.8
.. versionchanged:: 21.11
Added support for channel chats.
Returns:
:obj:`bool`: On success, :obj:`True` is returned.
"""
return await self.get_bot().send_gift(
user_id=self.id,
gift_id=gift_id,
text=text,
text_parse_mode=text_parse_mode,
@@ -3503,7 +3484,6 @@ class _ChatBase(TelegramObject):
connect_timeout=connect_timeout,
pool_timeout=pool_timeout,
api_kwargs=api_kwargs,
**{"chat_id" if self.type == Chat.CHANNEL else "user_id": self.id},
)
async def verify(
+2 -2
View File
@@ -388,8 +388,8 @@ class BackgroundTypeWallpaper(BackgroundType):
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
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
-9
View File
@@ -200,9 +200,6 @@ class ChatFullInfo(_ChatBase):
sent or forwarded to the channel chat. The field is available only for channel chats.
.. versionadded:: 21.4
can_send_gift (:obj:`bool`, optional): :obj:`True`, if gifts can be sent to the chat.
.. versionadded:: 21.11
Attributes:
id (:obj:`int`): Unique identifier for this chat.
@@ -357,9 +354,6 @@ class ChatFullInfo(_ChatBase):
sent or forwarded to the channel chat. The field is available only for channel chats.
.. versionadded:: 21.4
can_send_gift (:obj:`bool`): Optional. :obj:`True`, if gifts can be sent to the chat.
.. versionadded:: 21.11
.. _accent colors: https://core.telegram.org/bots/api#accent-colors
.. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups
@@ -375,7 +369,6 @@ class ChatFullInfo(_ChatBase):
"business_intro",
"business_location",
"business_opening_hours",
"can_send_gift",
"can_send_paid_media",
"can_set_sticker_set",
"custom_emoji_sticker_set_name",
@@ -452,7 +445,6 @@ class ChatFullInfo(_ChatBase):
linked_chat_id: Optional[int] = None,
location: Optional[ChatLocation] = None,
can_send_paid_media: Optional[bool] = None,
can_send_gift: Optional[bool] = None,
*,
api_kwargs: Optional[JSONDict] = None,
):
@@ -518,7 +510,6 @@ class ChatFullInfo(_ChatBase):
self.business_location: Optional[BusinessLocation] = business_location
self.business_opening_hours: Optional[BusinessOpeningHours] = business_opening_hours
self.can_send_paid_media: Optional[bool] = can_send_paid_media
self.can_send_gift: Optional[bool] = can_send_gift
@classmethod
def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo":
+1 -2
View File
@@ -24,7 +24,6 @@ from typing import TYPE_CHECKING, Final, Optional
from telegram import constants
from telegram._telegramobject import TelegramObject
from telegram._user import User
from telegram._utils import enum
from telegram._utils.argumentparsing import de_json_optional
from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp
from telegram._utils.types import JSONDict
@@ -100,7 +99,7 @@ class ChatMember(TelegramObject):
super().__init__(api_kwargs=api_kwargs)
# Required by all subclasses
self.user: User = user
self.status: str = enum.get_member(constants.ChatMemberStatus, status, status)
self.status: str = status
self._id_attrs = (self.user, self.status)
+1 -51
View File
@@ -214,13 +214,6 @@ class InputPaidMediaVideo(InputPaidMedia):
Lastly you can pass an existing :class:`telegram.Video` object to send.
thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \
optional): |thumbdocstringnopath|
cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \
optional): Cover for the video in the message. |fileinputnopath|
.. versionchanged:: 21.11
start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message
.. versionchanged:: 21.11
width (:obj:`int`, optional): Video width.
height (:obj:`int`, optional): Video height.
duration (:obj:`int`, optional): Video duration in seconds.
@@ -232,13 +225,6 @@ class InputPaidMediaVideo(InputPaidMedia):
:tg-const:`telegram.constants.InputPaidMediaType.VIDEO`.
media (:obj:`str` | :class:`telegram.InputFile`): Video to send.
thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase|
cover (:class:`telegram.InputFile`): Optional. Cover for the video in the message.
|fileinputnopath|
.. versionchanged:: 21.11
start_timestamp (:obj:`int`): Optional. Start timestamp for the video in the message
.. versionchanged:: 21.11
width (:obj:`int`): Optional. Video width.
height (:obj:`int`): Optional. Video height.
duration (:obj:`int`): Optional. Video duration in seconds.
@@ -246,15 +232,7 @@ class InputPaidMediaVideo(InputPaidMedia):
suitable for streaming.
"""
__slots__ = (
"cover",
"duration",
"height",
"start_timestamp",
"supports_streaming",
"thumbnail",
"width",
)
__slots__ = ("duration", "height", "supports_streaming", "thumbnail", "width")
def __init__(
self,
@@ -264,8 +242,6 @@ class InputPaidMediaVideo(InputPaidMedia):
height: Optional[int] = None,
duration: Optional[int] = None,
supports_streaming: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
api_kwargs: Optional[JSONDict] = None,
):
@@ -288,10 +264,6 @@ class InputPaidMediaVideo(InputPaidMedia):
self.height: Optional[int] = height
self.duration: Optional[int] = duration
self.supports_streaming: Optional[bool] = supports_streaming
self.cover: Optional[Union[InputFile, str]] = (
parse_file_input(cover, attach=True, local_mode=True) if cover else None
)
self.start_timestamp: Optional[int] = start_timestamp
class InputMediaAnimation(InputMedia):
@@ -564,13 +536,6 @@ class InputMediaVideo(InputMedia):
optional): |thumbdocstringnopath|
.. versionadded:: 20.2
cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \
optional): Cover for the video in the message. |fileinputnopath|
.. versionchanged:: 21.11
start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message
.. versionchanged:: 21.11
show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med|
.. versionadded:: 21.3
@@ -603,22 +568,13 @@ class InputMediaVideo(InputMedia):
show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med|
.. versionadded:: 21.3
cover (:class:`telegram.InputFile`): Optional. Cover for the video in the message.
|fileinputnopath|
.. versionchanged:: 21.11
start_timestamp (:obj:`int`): Optional. Start timestamp for the video in the message
.. versionchanged:: 21.11
"""
__slots__ = (
"cover",
"duration",
"has_spoiler",
"height",
"show_caption_above_media",
"start_timestamp",
"supports_streaming",
"thumbnail",
"width",
@@ -638,8 +594,6 @@ class InputMediaVideo(InputMedia):
has_spoiler: Optional[bool] = None,
thumbnail: Optional[FileInput] = None,
show_caption_above_media: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
api_kwargs: Optional[JSONDict] = None,
):
@@ -671,10 +625,6 @@ class InputMediaVideo(InputMedia):
self.supports_streaming: Optional[bool] = supports_streaming
self.has_spoiler: Optional[bool] = has_spoiler
self.show_caption_above_media: Optional[bool] = show_caption_above_media
self.cover: Optional[Union[InputFile, str]] = (
parse_file_input(cover, attach=True, local_mode=True) if cover else None
)
self.start_timestamp: Optional[int] = start_timestamp
class InputMediaAudio(InputMedia):
+4 -4
View File
@@ -61,8 +61,8 @@ class InputSticker(TelegramObject):
format (:obj:`str`): Format of the added sticker, must be one of
:tg-const:`telegram.constants.StickerFormat.STATIC` for a
``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED`
for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a
``.WEBM`` video.
for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a WEBM
video.
.. versionadded:: 21.1
@@ -84,8 +84,8 @@ class InputSticker(TelegramObject):
format (:obj:`str`): Format of the added sticker, must be one of
:tg-const:`telegram.constants.StickerFormat.STATIC` for a
``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED`
for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a
``.WEBM`` video.
for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a WEBM
video.
.. versionadded:: 21.1
"""
+2 -42
View File
@@ -17,17 +17,12 @@
# 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 Video."""
from collections.abc import Sequence
from typing import TYPE_CHECKING, Optional
from typing import Optional
from telegram._files._basethumbedmedium import _BaseThumbedMedium
from telegram._files.photosize import PhotoSize
from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg
from telegram._utils.types import JSONDict
if TYPE_CHECKING:
from telegram import Bot
class Video(_BaseThumbedMedium):
"""This object represents a video file.
@@ -53,13 +48,6 @@ class Video(_BaseThumbedMedium):
thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail.
.. versionadded:: 20.2
cover (Sequence[:class:`telegram.PhotoSize`], optional): Available sizes of the cover of
the video in the message.
.. versionadded:: 21.11
start_timestamp (:obj:`int`, optional): Timestamp in seconds from which the video
will play in the message
.. versionadded:: 21.11
Attributes:
file_id (:obj:`str`): Identifier for this file, which can be used to download
@@ -76,24 +64,9 @@ class Video(_BaseThumbedMedium):
thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail.
.. versionadded:: 20.2
cover (tuple[:class:`telegram.PhotoSize`]): Optional, Available sizes of the cover of
the video in the message.
.. versionadded:: 21.11
start_timestamp (:obj:`int`): Optional, Timestamp in seconds from which the video
will play in the message
.. versionadded:: 21.11
"""
__slots__ = (
"cover",
"duration",
"file_name",
"height",
"mime_type",
"start_timestamp",
"width",
)
__slots__ = ("duration", "file_name", "height", "mime_type", "width")
def __init__(
self,
@@ -106,8 +79,6 @@ class Video(_BaseThumbedMedium):
file_size: Optional[int] = None,
file_name: Optional[str] = None,
thumbnail: Optional[PhotoSize] = None,
cover: Optional[Sequence[PhotoSize]] = None,
start_timestamp: Optional[int] = None,
*,
api_kwargs: Optional[JSONDict] = None,
):
@@ -126,14 +97,3 @@ class Video(_BaseThumbedMedium):
# Optional
self.mime_type: Optional[str] = mime_type
self.file_name: Optional[str] = file_name
self.cover: Optional[Sequence[PhotoSize]] = parse_sequence_arg(cover)
self.start_timestamp: Optional[int] = start_timestamp
@classmethod
def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Video":
"""See :meth:`telegram.TelegramObject.de_json`."""
data = cls._parse_data(data)
data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot)
return super().de_json(data=data, bot=bot)
+25 -5
View File
@@ -23,7 +23,9 @@ from typing import TYPE_CHECKING, Optional
from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup
from telegram._inline.inlinequeryresult import InlineQueryResult
from telegram._utils.types import JSONDict
from telegram._utils.warnings import warn
from telegram.constants import InlineQueryResultType
from telegram.warnings import PTBDeprecationWarning
if TYPE_CHECKING:
from telegram import InputMessageContent
@@ -38,9 +40,6 @@ class InlineQueryResultArticle(InlineQueryResult):
.. versionchanged:: 20.5
Removed the deprecated arguments and attributes ``thumb_*``.
.. versionchanged:: 21.11
Removed the deprecated argument and attribute ``hide_url``.
Args:
id (:obj:`str`): Unique identifier for this result,
:tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`-
@@ -51,9 +50,12 @@ class InlineQueryResultArticle(InlineQueryResult):
reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached
to the message.
url (:obj:`str`, optional): URL of the result.
hide_url (:obj:`bool`, optional): Pass :obj:`True`, if you don't want the URL to be shown
in the message.
Tip:
Pass an empty string as URL if you don't want the URL to be shown in the message.
.. deprecated:: 21.10
This attribute will be removed in future PTB versions. Pass an empty string as URL
instead.
description (:obj:`str`, optional): Short description of the result.
thumbnail_url (:obj:`str`, optional): Url of the thumbnail for the result.
@@ -76,6 +78,12 @@ class InlineQueryResultArticle(InlineQueryResult):
reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached
to the message.
url (:obj:`str`): Optional. URL of the result.
hide_url (:obj:`bool`): Optional. Pass :obj:`True`, if you don't want the URL to be shown
in the message.
.. deprecated:: 21.10
This attribute will be removed in future PTB versions. Pass an empty string as URL
instead.
description (:obj:`str`): Optional. Short description of the result.
thumbnail_url (:obj:`str`): Optional. Url of the thumbnail for the result.
@@ -91,6 +99,7 @@ class InlineQueryResultArticle(InlineQueryResult):
__slots__ = (
"description",
"hide_url",
"input_message_content",
"reply_markup",
"thumbnail_height",
@@ -107,6 +116,7 @@ class InlineQueryResultArticle(InlineQueryResult):
input_message_content: "InputMessageContent",
reply_markup: Optional[InlineKeyboardMarkup] = None,
url: Optional[str] = None,
hide_url: Optional[bool] = None,
description: Optional[str] = None,
thumbnail_url: Optional[str] = None,
thumbnail_width: Optional[int] = None,
@@ -123,6 +133,16 @@ class InlineQueryResultArticle(InlineQueryResult):
# Optional
self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup
self.url: Optional[str] = url
if hide_url is not None:
warn(
PTBDeprecationWarning(
"21.10",
"The argument `hide_url` will be removed in future PTB"
"versions. Pass an empty string as URL instead.",
),
stacklevel=2,
)
self.hide_url: Optional[bool] = hide_url
self.description: Optional[str] = description
self.thumbnail_url: Optional[str] = thumbnail_url
self.thumbnail_width: Optional[int] = thumbnail_width
+2 -2
View File
@@ -47,7 +47,7 @@ class InlineQueryResultGif(InlineQueryResult):
id (:obj:`str`): Unique identifier for this result,
:tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`-
:tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes.
gif_url (:obj:`str`): A valid URL for the GIF file.
gif_url (:obj:`str`): A valid URL for the GIF file. File size must not exceed 1MB.
gif_width (:obj:`int`, optional): Width of the GIF.
gif_height (:obj:`int`, optional): Height of the GIF.
gif_duration (:obj:`int`, optional): Duration of the GIF in seconds.
@@ -86,7 +86,7 @@ class InlineQueryResultGif(InlineQueryResult):
id (:obj:`str`): Unique identifier for this result,
:tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`-
:tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes.
gif_url (:obj:`str`): A valid URL for the GIF file.
gif_url (:obj:`str`): A valid URL for the GIF file. File size must not exceed 1MB.
gif_width (:obj:`int`): Optional. Width of the GIF.
gif_height (:obj:`int`): Optional. Height of the GIF.
gif_duration (:obj:`int`): Optional. Duration of the GIF in seconds.
@@ -48,7 +48,7 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult):
id (:obj:`str`): Unique identifier for this result,
:tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`-
:tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes.
mpeg4_url (:obj:`str`): A valid URL for the MP4 file.
mpeg4_url (:obj:`str`): A valid URL for the MP4 file. File size must not exceed 1MB.
mpeg4_width (:obj:`int`, optional): Video width.
mpeg4_height (:obj:`int`, optional): Video height.
mpeg4_duration (:obj:`int`, optional): Video duration in seconds.
@@ -88,7 +88,7 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult):
id (:obj:`str`): Unique identifier for this result,
:tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`-
:tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes.
mpeg4_url (:obj:`str`): A valid URL for the MP4 file.
mpeg4_url (:obj:`str`): A valid URL for the MP4 file. File size must not exceed 1MB.
mpeg4_width (:obj:`int`): Optional. Video width.
mpeg4_height (:obj:`int`): Optional. Video height.
mpeg4_duration (:obj:`int`): Optional. Video duration in seconds.
@@ -35,11 +35,9 @@ class InputInvoiceMessageContent(InputMessageContent):
Objects of this class are comparable in terms of equality. Two objects of this class are
considered equal, if their :attr:`title`, :attr:`description`, :attr:`payload`,
:attr:`currency` and :attr:`prices` are equal.
:attr:`provider_token`, :attr:`currency` and :attr:`prices` are equal.
.. versionadded:: 13.5
.. versionchanged:: 21.11
:attr:`provider_token` is no longer considered for equality comparison.
Args:
title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`-
@@ -51,13 +49,13 @@ class InputInvoiceMessageContent(InputMessageContent):
:tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`-
:tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed
to the user, use it for your internal processes.
provider_token (:obj:`str`, optional): Payment provider token, obtained via
provider_token (:obj:`str`): Payment provider token, obtained via
`@Botfather <https://t.me/Botfather>`_. Pass an empty string for payments in
|tg_stars|.
.. versionchanged:: 21.11
Bot API 7.4 made this parameter is optional and this is now reflected in the
class signature.
.. deprecated:: 21.3
As of Bot API 7.4, this parameter is now optional and future versions of the
library will make it optional as well.
currency (:obj:`str`): Three-letter ISO 4217 currency code, see more on
`currencies <https://core.telegram.org/bots/payments#supported-currencies>`_.
Pass ``XTR`` for payments in |tg_stars|.
@@ -201,9 +199,9 @@ class InputInvoiceMessageContent(InputMessageContent):
title: str,
description: str,
payload: str,
provider_token: Optional[str], # This arg is now optional since Bot API 7.4
currency: str,
prices: Sequence[LabeledPrice],
provider_token: Optional[str] = None,
max_tip_amount: Optional[int] = None,
suggested_tip_amounts: Optional[Sequence[int]] = None,
provider_data: Optional[str] = None,
@@ -227,10 +225,10 @@ class InputInvoiceMessageContent(InputMessageContent):
self.title: str = title
self.description: str = description
self.payload: str = payload
self.provider_token: Optional[str] = provider_token
self.currency: str = currency
self.prices: tuple[LabeledPrice, ...] = parse_sequence_arg(prices)
# Optionals
self.provider_token: Optional[str] = provider_token
self.max_tip_amount: Optional[int] = max_tip_amount
self.suggested_tip_amounts: tuple[int, ...] = parse_sequence_arg(suggested_tip_amounts)
self.provider_data: Optional[str] = provider_data
@@ -250,6 +248,7 @@ class InputInvoiceMessageContent(InputMessageContent):
self.title,
self.description,
self.payload,
self.provider_token,
self.currency,
self.prices,
)
+1 -11
View File
@@ -2592,8 +2592,6 @@ class Message(MaybeInaccessibleMessage):
message_effect_id: Optional[str] = None,
allow_paid_broadcast: Optional[bool] = None,
show_caption_above_media: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -2663,8 +2661,6 @@ class Message(MaybeInaccessibleMessage):
message_thread_id=message_thread_id,
has_spoiler=has_spoiler,
thumbnail=thumbnail,
cover=cover,
start_timestamp=start_timestamp,
business_connection_id=self.business_connection_id,
message_effect_id=message_effect_id,
allow_paid_broadcast=allow_paid_broadcast,
@@ -3386,9 +3382,9 @@ class Message(MaybeInaccessibleMessage):
title: str,
description: str,
payload: str,
provider_token: Optional[str],
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
start_parameter: Optional[str] = None,
photo_url: Optional[str] = None,
photo_size: Optional[int] = None,
@@ -3510,7 +3506,6 @@ class Message(MaybeInaccessibleMessage):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -3545,7 +3540,6 @@ class Message(MaybeInaccessibleMessage):
chat_id=chat_id,
from_chat_id=self.chat_id,
message_id=self.message_id,
video_start_timestamp=video_start_timestamp,
disable_notification=disable_notification,
protect_content=protect_content,
message_thread_id=message_thread_id,
@@ -3569,7 +3563,6 @@ class Message(MaybeInaccessibleMessage):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -3600,7 +3593,6 @@ class Message(MaybeInaccessibleMessage):
from_chat_id=self.chat_id,
message_id=self.message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -3633,7 +3625,6 @@ class Message(MaybeInaccessibleMessage):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -3684,7 +3675,6 @@ class Message(MaybeInaccessibleMessage):
from_chat_id=from_chat_id,
message_id=message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -36,9 +36,6 @@ if TYPE_CHECKING:
class StarTransaction(TelegramObject):
"""Describes a Telegram Star transaction.
Note that if the buyer initiates a chargeback with the payment provider from whom they
acquired Stars (e.g., Apple, Google) following this transaction, the refunded Stars will be
deducted from the bot's balance. This is outside of Telegram's control.
Objects of this class are comparable in terms of equality. Two objects of this class are
considered equal, if their :attr:`id`, :attr:`source`, and :attr:`receiver` are equal.
@@ -23,7 +23,6 @@ from collections.abc import Sequence
from typing import TYPE_CHECKING, Final, Optional
from telegram import constants
from telegram._chat import Chat
from telegram._gifts import Gift
from telegram._paidmedia import PaidMedia
from telegram._telegramobject import TelegramObject
@@ -44,7 +43,6 @@ class TransactionPartner(TelegramObject):
transactions. Currently, it can be one of:
* :class:`TransactionPartnerUser`
* :class:`TransactionPartnerChat`
* :class:`TransactionPartnerAffiliateProgram`
* :class:`TransactionPartnerFragment`
* :class:`TransactionPartnerTelegramAds`
@@ -56,9 +54,6 @@ class TransactionPartner(TelegramObject):
.. versionadded:: 21.4
..versionchanged:: 21.11
Added :class:`TransactionPartnerChat`
Args:
type (:obj:`str`): The type of the transaction partner.
@@ -73,11 +68,6 @@ class TransactionPartner(TelegramObject):
.. versionadded:: 21.9
"""
CHAT: Final[str] = constants.TransactionPartnerType.CHAT
""":const:`telegram.constants.TransactionPartnerType.CHAT`
.. versionadded:: 21.11
"""
FRAGMENT: Final[str] = constants.TransactionPartnerType.FRAGMENT
""":const:`telegram.constants.TransactionPartnerType.FRAGMENT`"""
OTHER: Final[str] = constants.TransactionPartnerType.OTHER
@@ -113,7 +103,6 @@ class TransactionPartner(TelegramObject):
_class_mapping: dict[str, type[TransactionPartner]] = {
cls.AFFILIATE_PROGRAM: TransactionPartnerAffiliateProgram,
cls.CHAT: TransactionPartnerChat,
cls.FRAGMENT: TransactionPartnerFragment,
cls.USER: TransactionPartnerUser,
cls.TELEGRAM_ADS: TransactionPartnerTelegramAds,
@@ -182,60 +171,6 @@ class TransactionPartnerAffiliateProgram(TransactionPartner):
return super().de_json(data=data, bot=bot) # type: ignore[return-value]
class TransactionPartnerChat(TransactionPartner):
"""Describes a transaction with a chat.
Objects of this class are comparable in terms of equality. Two objects of this class are
considered equal, if their :attr:`chat` are equal.
.. versionadded:: 21.11
Args:
chat (:class:`telegram.Chat`): Information about the chat.
gift (:class:`telegram.Gift`, optional): The gift sent to the chat by the bot.
Attributes:
type (:obj:`str`): The type of the transaction partner,
always :tg-const:`telegram.TransactionPartner.CHAT`.
chat (:class:`telegram.Chat`): Information about the chat.
gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot.
"""
__slots__ = (
"chat",
"gift",
)
def __init__(
self,
chat: Chat,
gift: Optional[Gift] = None,
*,
api_kwargs: Optional[JSONDict] = None,
) -> None:
super().__init__(type=TransactionPartner.CHAT, api_kwargs=api_kwargs)
with self._unfrozen():
self.chat: Chat = chat
self.gift: Optional[Gift] = gift
self._id_attrs = (
self.type,
self.chat,
)
@classmethod
def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "TransactionPartnerChat":
"""See :meth:`telegram.TransactionPartner.de_json`."""
data = cls._parse_data(data)
data["chat"] = de_json_optional(data.get("chat"), Chat, bot)
data["gift"] = de_json_optional(data.get("gift"), Gift, bot)
return super().de_json(data=data, bot=bot) # type: ignore[return-value]
class TransactionPartnerFragment(TransactionPartner):
"""Describes a withdrawal transaction with Fragment.
-3
View File
@@ -33,9 +33,6 @@ if TYPE_CHECKING:
class SuccessfulPayment(TelegramObject):
"""This object contains basic information about a successful payment.
Note that if the buyer initiates a chargeback with the relevant payment provider following
this transaction, the funds may be debited from your balance. This is outside of
Telegram's control.
Objects of this class are comparable in terms of equality. Two objects of this class are
considered equal, if their :attr:`telegram_payment_charge_id` and
+2 -15
View File
@@ -1018,9 +1018,9 @@ class User(TelegramObject):
title: str,
description: str,
payload: str,
provider_token: Optional[str],
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
start_parameter: Optional[str] = None,
photo_url: Optional[str] = None,
photo_size: Optional[int] = None,
@@ -1328,8 +1328,6 @@ class User(TelegramObject):
message_effect_id: Optional[str] = None,
allow_paid_broadcast: Optional[bool] = None,
show_caption_above_media: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -1371,8 +1369,6 @@ class User(TelegramObject):
parse_mode=parse_mode,
supports_streaming=supports_streaming,
thumbnail=thumbnail,
cover=cover,
start_timestamp=start_timestamp,
api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply,
caption_entities=caption_entities,
@@ -1674,7 +1670,7 @@ class User(TelegramObject):
) -> bool:
"""Shortcut for::
await bot.send_gift(user_id=update.effective_user.id, *args, **kwargs )
await bot.send_gift( user_id=update.effective_user.id, *args, **kwargs )
For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`.
@@ -1684,7 +1680,6 @@ class User(TelegramObject):
:obj:`bool`: On success, :obj:`True` is returned.
"""
return await self.get_bot().send_gift(
chat_id=None,
user_id=self.id,
gift_id=gift_id,
text=text,
@@ -1712,7 +1707,6 @@ class User(TelegramObject):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -1740,7 +1734,6 @@ class User(TelegramObject):
from_chat_id=from_chat_id,
message_id=message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -1773,7 +1766,6 @@ class User(TelegramObject):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -1801,7 +1793,6 @@ class User(TelegramObject):
chat_id=chat_id,
message_id=message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -1917,7 +1908,6 @@ class User(TelegramObject):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -1943,7 +1933,6 @@ class User(TelegramObject):
chat_id=self.id,
from_chat_id=from_chat_id,
message_id=message_id,
video_start_timestamp=video_start_timestamp,
disable_notification=disable_notification,
read_timeout=read_timeout,
write_timeout=write_timeout,
@@ -1961,7 +1950,6 @@ class User(TelegramObject):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -1988,7 +1976,6 @@ class User(TelegramObject):
from_chat_id=self.id,
chat_id=chat_id,
message_id=message_id,
video_start_timestamp=video_start_timestamp,
disable_notification=disable_notification,
read_timeout=read_timeout,
write_timeout=write_timeout,
+1 -1
View File
@@ -51,6 +51,6 @@ class Version(NamedTuple):
__version_info__: Final[Version] = Version(
major=21, minor=11, micro=0, releaselevel="final", serial=0
major=21, minor=10, micro=0, releaselevel="final", serial=0
)
__version__: Final[str] = str(__version_info__)
+5 -13
View File
@@ -155,7 +155,7 @@ class _AccentColor(NamedTuple):
#: :data:`telegram.__bot_api_version_info__`.
#:
#: .. versionadded:: 20.0
BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=3)
BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=2)
#: :obj:`str`: Telegram Bot API
#: version supported by this version of `python-telegram-bot`. Also available as
#: :data:`telegram.__bot_api_version__`.
@@ -1236,12 +1236,9 @@ class GiftLimit(IntEnum):
__slots__ = ()
MAX_TEXT_LENGTH = 128
MAX_TEXT_LENGTH = 255
""":obj:`int`: Maximum number of characters in a :obj:`str` passed as the
:paramref:`~telegram.Bot.send_gift.text` parameter of :meth:`~telegram.Bot.send_gift`.
.. versionchanged:: 21.11
Updated Value to 128 based on Bot API 8.3
"""
@@ -2621,13 +2618,13 @@ class StickerSetLimit(IntEnum):
:meth:`telegram.Bot.add_sticker_to_set`.
"""
MAX_STATIC_THUMBNAIL_SIZE = 128
""":obj:`int`: Maximum size of the thumbnail if it is a ``.WEBP`` or ``.PNG`` in kilobytes,
""":obj:`int`: Maximum size of the thumbnail if it is a **.WEBP** or **.PNG** in kilobytes,
as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`."""
MAX_ANIMATED_THUMBNAIL_SIZE = 32
""":obj:`int`: Maximum size of the thumbnail if it is a ``.TGS`` or ``.WEBM`` in kilobytes,
""":obj:`int`: Maximum size of the thumbnail if it is a **.TGS** or **.WEBM** in kilobytes,
as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`."""
STATIC_THUMB_DIMENSIONS = 100
""":obj:`int`: Exact height and width of the thumbnail if it is a ``.WEBP`` or ``.PNG`` in
""":obj:`int`: Exact height and width of the thumbnail if it is a **.WEBP** or **.PNG** in
pixels, as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`."""
@@ -2662,11 +2659,6 @@ class TransactionPartnerType(StringEnum):
.. versionadded:: 21.9
"""
CHAT = "chat"
""":obj:`str`: Transaction with a chat.
.. versionadded:: 21.11
"""
FRAGMENT = "fragment"
""":obj:`str`: Withdrawal transaction with Fragment."""
OTHER = "other"
+5
View File
@@ -42,6 +42,7 @@ __all__ = (
"Defaults",
"DictPersistence",
"ExtBot",
"FiniteStateMachine",
"InlineQueryHandler",
"InvalidCallbackData",
"Job",
@@ -57,6 +58,9 @@ __all__ = (
"PrefixHandler",
"ShippingQueryHandler",
"SimpleUpdateProcessor",
"SingleStateMachine",
"State",
"StateInfo",
"StringCommandHandler",
"StringRegexHandler",
"TypeHandler",
@@ -77,6 +81,7 @@ from ._contexttypes import ContextTypes
from ._defaults import Defaults
from ._dictpersistence import DictPersistence
from ._extbot import ExtBot
from ._fsm import FiniteStateMachine, SingleStateMachine, State, StateInfo
from ._handlers.basehandler import BaseHandler
from ._handlers.businessconnectionhandler import BusinessConnectionHandler
from ._handlers.businessmessagesdeletedhandler import BusinessMessagesDeletedHandler
+1 -1
View File
@@ -98,7 +98,7 @@ class AIORateLimiter(BaseRateLimiter[int]):
:tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second by
paying a fee in Telegram Stars.
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
This class automatically takes the
:paramref:`~telegram.Bot.send_message.allow_paid_broadcast` parameter into account and
throttles the requests accordingly.
+62 -44
View File
@@ -48,9 +48,9 @@ from telegram.error import TelegramError
from telegram.ext._basepersistence import BasePersistence
from telegram.ext._contexttypes import ContextTypes
from telegram.ext._extbot import ExtBot
from telegram.ext._fsm import SingleStateMachine, State, StateInfo
from telegram.ext._handlers.basehandler import BaseHandler
from telegram.ext._updater import Updater
from telegram.ext._utils.networkloop import network_retry_loop
from telegram.ext._utils.stack import was_called_by
from telegram.ext._utils.trackingdict import TrackingDict
from telegram.ext._utils.types import BD, BT, CCT, CD, JQ, RT, UD, ConversationKey, HandlerCallback
@@ -60,7 +60,7 @@ if TYPE_CHECKING:
from socket import socket
from telegram import Message
from telegram.ext import ConversationHandler, JobQueue
from telegram.ext import ConversationHandler, FiniteStateMachine, JobQueue
from telegram.ext._applicationbuilder import InitApplicationBuilder
from telegram.ext._baseupdateprocessor import BaseUpdateProcessor
from telegram.ext._jobqueue import Job
@@ -267,6 +267,7 @@ class Application(
"update_queue",
"updater",
"user_data",
"fsm",
)
# Allowing '__weakref__' creation here since we need it for the JobQueue
# Currently the __weakref__ slot is already created
@@ -302,11 +303,12 @@ class Application(
stacklevel=2,
)
self.fsm: FiniteStateMachine = SingleStateMachine()
self.bot: BT = bot
self.update_queue: asyncio.Queue[object] = update_queue
self.context_types: ContextTypes[CCT, UD, CD, BD] = context_types
self.updater: Optional[Updater] = updater
self.handlers: dict[int, list[BaseHandler[Any, CCT, Any]]] = {}
self.handlers: dict[State, dict[int, list[BaseHandler[Any, CCT, Any]]]] = {}
self.error_handlers: dict[
HandlerCallback[object, CCT, None], Union[bool, DefaultValue[bool]]
] = {}
@@ -740,7 +742,7 @@ class Application(
self,
poll_interval: float = 0.0,
timeout: int = 10,
bootstrap_retries: int = 0,
bootstrap_retries: int = -1,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -781,19 +783,13 @@ class Application(
Telegram in seconds. Default is ``0.0``.
timeout (:obj:`int`, optional): Passed to
:paramref:`telegram.Bot.get_updates.timeout`. Default is ``10`` seconds.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase
(calling :meth:`initialize` and the boostrapping of
:meth:`telegram.ext.Updater.start_polling`)
will retry on failures on the Telegram server.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
:class:`telegram.ext.Updater` will retry on failures on the Telegram server.
* < 0 - retry indefinitely
* 0 - no retries (default)
* < 0 - retry indefinitely (default)
* 0 - no retries
* > 0 - retry up to X times
.. versionchanged:: 21.11
The default value will be changed to from ``-1`` to ``0``. Indefinite retries
during bootstrapping are not recommended.
read_timeout (:obj:`float`, optional): Value to pass to
:paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
@@ -883,9 +879,8 @@ class Application(
drop_pending_updates=drop_pending_updates,
error_callback=error_callback, # if there is an error in fetching updates
),
stop_signals=stop_signals,
bootstrap_retries=bootstrap_retries,
close_loop=close_loop,
stop_signals=stop_signals,
)
def run_webhook(
@@ -954,10 +949,8 @@ class Application(
url_path (:obj:`str`, optional): Path inside url. Defaults to `` '' ``
cert (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL certificate file.
key (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL key file.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase
(calling :meth:`initialize` and the boostrapping of
:meth:`telegram.ext.Updater.start_polling`)
will retry on failures on the Telegram server.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
:class:`telegram.ext.Updater` will retry on failures on the Telegram server.
* < 0 - retry indefinitely
* 0 - no retries (default)
@@ -1043,28 +1036,18 @@ class Application(
secret_token=secret_token,
unix=unix,
),
stop_signals=stop_signals,
bootstrap_retries=bootstrap_retries,
close_loop=close_loop,
)
async def _bootstrap_initialize(self, max_retries: int) -> None:
await network_retry_loop(
action_cb=self.initialize,
description="Bootstrap Initialize Application",
max_retries=max_retries,
interval=1,
stop_signals=stop_signals,
)
def __run(
self,
updater_coroutine: Coroutine,
stop_signals: ODVInput[Sequence[int]],
bootstrap_retries: int,
close_loop: bool = True,
) -> None:
# Calling get_event_loop() should still be okay even in py3.10+ as long as there is a
# running event loop, or we are in the main thread, which are the intended use cases.
# running event loop or we are in the main thread, which are the intended use cases.
# See the docs of get_event_loop() and get_running_loop() for more info
loop = asyncio.get_event_loop()
@@ -1084,7 +1067,7 @@ class Application(
)
try:
loop.run_until_complete(self._bootstrap_initialize(max_retries=bootstrap_retries))
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():
@@ -1298,19 +1281,46 @@ class Application(
# Processing updates before initialize() is a problem e.g. if persistence is used
self._check_initialized()
fsm_state_info = self.fsm.get_state_info(update)
for state, state_handlers in self.handlers.items():
if state.matches(fsm_state_info.state):
_LOGGER.debug("Processing in state %s", state)
was_handled = await self.__process_update_groups(
update, state_handlers, fsm_state_info
)
if was_handled:
_LOGGER.debug(
"Update was handled in state %s. Stopping further processing", state
)
return
_LOGGER.debug(
"No handlers found for key %s in state %s", fsm_state_info.key, fsm_state_info.state
)
return
async def __process_update_groups(
self,
update: object,
state_handlers: dict[int, list[BaseHandler]],
fsm_state_info: StateInfo,
) -> bool:
context = None
was_handled = False
any_blocking = False # Flag which is set to True if any handler specifies block=True
for handlers in self.handlers.values():
for handlers in state_handlers.values():
try:
for handler in handlers:
check = handler.check_update(update) # Should the handler handle this update?
if check is None or check is False:
continue
was_handled = True
if not context: # build a context if not already built
try:
context = self.context_types.context.from_update(update, self)
context.fsm_state_info = fsm_state_info
except Exception as exc:
_LOGGER.critical(
(
@@ -1320,7 +1330,7 @@ class Application(
update,
exc_info=exc,
)
return
return True
await context.refresh_data()
coroutine: Coroutine = handler.handle_update(update, self, check, context)
@@ -1360,7 +1370,14 @@ class Application(
# (in __create_task_callback)
self._mark_for_persistence_update(update=update)
def add_handler(self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_GROUP) -> None:
return was_handled
def add_handler(
self,
handler: BaseHandler[Any, CCT, Any],
group: int = DEFAULT_GROUP,
state: State = State.IDLE,
) -> None:
"""Register a handler.
TL;DR: Order and priority counts. 0 or 1 handlers per group will be used. End handling of
@@ -1419,11 +1436,11 @@ class Application(
stacklevel=2,
)
if group not in self.handlers:
self.handlers[group] = []
self.handlers = dict(sorted(self.handlers.items())) # lower -> higher groups
state_handlers = self.handlers.setdefault(state, {})
if group not in state_handlers:
state_handlers[group] = []
self.handlers[group].append(handler)
state_handlers[group].append(handler)
def add_handlers(
self,
@@ -1495,10 +1512,11 @@ class Application(
group (:obj:`object`, optional): The group identifier. Default is ``0``.
"""
if handler in self.handlers[group]:
self.handlers[group].remove(handler)
if not self.handlers[group]:
del self.handlers[group]
for state_handlers in self.handlers.values():
if handler in state_handlers[group]:
state_handlers[group].remove(handler)
if not state_handlers[group]:
del state_handlers[group]
def drop_chat_data(self, chat_id: int) -> None:
"""Drops the corresponding entry from the :attr:`chat_data`. Will also be deleted from
+2 -2
View File
@@ -393,7 +393,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
.. seealso:: :paramref:`telegram.Bot.base_url`,
:wiki:`Local Bot API Server <Local-Bot-API-Server>`, :meth:`base_file_url`
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
Supports callable input and string formatting.
Args:
@@ -415,7 +415,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
.. seealso:: :paramref:`telegram.Bot.base_file_url`,
:wiki:`Local Bot API Server <Local-Bot-API-Server>`, :meth:`base_url`
.. versionchanged:: 21.11
.. versionchanged:: NEXT.VERSION
Supports callable input and string formatting.
Args:
+1 -1
View File
@@ -113,7 +113,7 @@ class BaseUpdateProcessor(AbstractAsyncContextManager["BaseUpdateProcessor"], AB
This value is a snapshot of the current number of updates being processed. It may
change immediately after being read.
.. versionadded:: 21.11
.. versionadded:: NEXT.VERSION
"""
return self.max_concurrent_updates - self._semaphore.current_value
+22 -2
View File
@@ -17,8 +17,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/].
"""This module contains the CallbackContext class."""
import asyncio
from collections.abc import Awaitable, Generator
from contextlib import AbstractAsyncContextManager
from re import Match
from typing import TYPE_CHECKING, Any, Generic, NoReturn, Optional, TypeVar, Union
@@ -26,12 +27,13 @@ from telegram._callbackquery import CallbackQuery
from telegram._update import Update
from telegram._utils.warnings import warn
from telegram.ext._extbot import ExtBot
from telegram.ext._fsm import FiniteStateMachine, State
from telegram.ext._utils.types import BD, BT, CD, UD
if TYPE_CHECKING:
from asyncio import Future, Queue
from telegram.ext import Application, Job, JobQueue
from telegram.ext import Application, Job, JobQueue, StateInfo
from telegram.ext._utils.types import CCT
_STORING_DATA_WIKI = (
@@ -121,6 +123,7 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
"args",
"coroutine",
"error",
"fsm_state_info",
"job",
"matches",
)
@@ -141,6 +144,7 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
self.coroutine: Optional[
Union[Generator[Optional[Future[object]], None, Any], Awaitable[Any]]
] = None
self.fsm_state_info: StateInfo = None # type: ignore[assignment]
@property
def application(self) -> "Application[BT, ST, UD, CD, BD, Any]":
@@ -269,6 +273,22 @@ class CallbackContext(Generic[BT, UD, CD, BD]):
"telegram.Bot does not allow for arbitrary callback data."
)
@property
def fsm(self) -> FiniteStateMachine:
return self.application.fsm
def fsm_semaphore(self) -> asyncio.Lock:
return self.fsm.get_lock(self.fsm_state_info.key)
async def set_state(self, state: State) -> None:
await self.fsm.set_state(self.fsm_state_info.key, state, self.fsm_state_info.version)
def set_state_nowait(self, state: State) -> None:
self.fsm.set_state_nowait(self.fsm_state_info.key, state, self.fsm_state_info.version)
def as_fsm_state(self, state: State) -> AbstractAsyncContextManager[None]:
return self.fsm.as_state(self.fsm_state_info.key, state)
@classmethod
def from_error(
cls: type["CCT"],
+4 -14
View File
@@ -815,7 +815,6 @@ class ExtBot(Bot, Generic[RLARGS]):
reply_parameters: Optional["ReplyParameters"] = None,
show_caption_above_media: Optional[bool] = None,
allow_paid_broadcast: Optional[bool] = None,
video_start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -832,7 +831,6 @@ class ExtBot(Bot, Generic[RLARGS]):
from_chat_id=from_chat_id,
message_id=message_id,
caption=caption,
video_start_timestamp=video_start_timestamp,
parse_mode=parse_mode,
caption_entities=caption_entities,
disable_notification=disable_notification,
@@ -1197,9 +1195,9 @@ class ExtBot(Bot, Generic[RLARGS]):
title: str,
description: str,
payload: str,
provider_token: Optional[str],
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
max_tip_amount: Optional[int] = None,
suggested_tip_amounts: Optional[Sequence[int]] = None,
provider_data: Optional[Union[str, object]] = None,
@@ -1754,7 +1752,6 @@ class ExtBot(Bot, Generic[RLARGS]):
disable_notification: ODVInput[bool] = DEFAULT_NONE,
protect_content: ODVInput[bool] = DEFAULT_NONE,
message_thread_id: Optional[int] = None,
video_start_timestamp: Optional[int] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -1767,7 +1764,6 @@ class ExtBot(Bot, Generic[RLARGS]):
chat_id=chat_id,
from_chat_id=from_chat_id,
message_id=message_id,
video_start_timestamp=video_start_timestamp,
disable_notification=disable_notification,
protect_content=protect_content,
message_thread_id=message_thread_id,
@@ -2772,9 +2768,9 @@ class ExtBot(Bot, Generic[RLARGS]):
title: str,
description: str,
payload: str,
provider_token: Optional[str],
currency: str,
prices: Sequence["LabeledPrice"],
provider_token: Optional[str] = None,
start_parameter: Optional[str] = None,
photo_url: Optional[str] = None,
photo_size: Optional[int] = None,
@@ -3244,8 +3240,6 @@ class ExtBot(Bot, Generic[RLARGS]):
message_effect_id: Optional[str] = None,
allow_paid_broadcast: Optional[bool] = None,
show_caption_above_media: Optional[bool] = None,
cover: Optional[FileInput] = None,
start_timestamp: Optional[int] = None,
*,
reply_to_message_id: Optional[int] = None,
allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE,
@@ -3276,8 +3270,6 @@ class ExtBot(Bot, Generic[RLARGS]):
business_connection_id=business_connection_id,
has_spoiler=has_spoiler,
thumbnail=thumbnail,
cover=cover,
start_timestamp=start_timestamp,
filename=filename,
reply_parameters=reply_parameters,
read_timeout=read_timeout,
@@ -4476,13 +4468,12 @@ class ExtBot(Bot, Generic[RLARGS]):
async def send_gift(
self,
user_id: Optional[int] = None,
gift_id: Union[str, Gift] = None, # type: ignore
user_id: int,
gift_id: Union[str, Gift],
text: Optional[str] = None,
text_parse_mode: ODVInput[str] = DEFAULT_NONE,
text_entities: Optional[Sequence["MessageEntity"]] = None,
pay_for_upgrade: Optional[bool] = None,
chat_id: Optional[Union[str, int]] = None,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -4493,7 +4484,6 @@ class ExtBot(Bot, Generic[RLARGS]):
) -> bool:
return await super().send_gift(
user_id=user_id,
chat_id=chat_id,
gift_id=gift_id,
text=text,
text_parse_mode=text_parse_mode,
+6
View File
@@ -0,0 +1,6 @@
"""Private Submbodule for finite state machine implementation."""
__all__ = ["FiniteStateMachine", "SingleStateMachine", "State", "StateInfo"]
from .machine import FiniteStateMachine, SingleStateMachine, StateInfo
from .states import State
+200
View File
@@ -0,0 +1,200 @@
"""This Module contains the FiniteStateMachine class and the built-in subclass SingleStateMachine.
"""
import abc
import asyncio
import contextlib
import datetime as dtm
import logging
import time
import weakref
from collections import defaultdict, deque
from collections.abc import AsyncIterator, Hashable, Mapping, MutableSequence, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Generic, Literal, Optional, TypeVar, Union, overload
from telegram.ext._fsm.states import State
from telegram.ext._utils.types import JobCallback
if TYPE_CHECKING:
from collections.abc import MutableMapping
from telegram.ext import JobQueue
_KT = TypeVar("_KT", bound=Hashable)
_LOGGER = logging.getLogger(__name__)
_LOGGER.setLevel(logging.DEBUG)
class StateInfo(Generic[_KT]):
def __init__(self: "StateInfo[_KT]", key: _KT, state: State, version: int) -> None:
self.key: _KT = key
self.state: State = state
self.version: int = version
class FiniteStateMachine(abc.ABC, Generic[_KT]):
def __init__(self) -> None:
self._locks: MutableMapping[_KT, asyncio.Lock] = weakref.WeakValueDictionary()
# There is likely litte benefit for a user to customize how exactly the states are stored
# and accessed. So we make this private and only provide a read-only view.
self.__states: dict[_KT, tuple[State, int]] = defaultdict(
lambda: (State.IDLE, time.perf_counter_ns())
)
self._states = MappingProxyType(self.__states)
self.__job_queue: Optional[weakref.ReferenceType[JobQueue]] = None
self.__history: Mapping[_KT, MutableSequence[State]] = defaultdict(
lambda: deque(maxlen=10)
)
@property
def states(self) -> Mapping[_KT, tuple[State, int]]:
return self._states
def store_state_history(self, key: _KT, state: State) -> None:
# Making this public so that users can override if they want to customize the history
# E.g., they could want to store more/fewer states, also depending on the key
self.__history[key].append(state)
def get_state_history(self, key: _KT) -> Sequence[State]:
return list(self.__history[key])
def get_lock(self, key: _KT) -> asyncio.Lock:
"""Returns a lock that is unique for this key at runtime.
It can be used to prevent concurrent access to resources associated to this key.
"""
return self._locks.setdefault(key, asyncio.Lock())
@abc.abstractmethod
def get_state_info(self, update: object) -> StateInfo[_KT]:
"""Returns exactly one active state for the update.
If more than one stored key applies to the update, one must be chosen.
It's recommended to select the most specific one.
Example:
The state of a chat, a user or a user in a specific chat could be tracked.
For a message in that chat, the state of the user in that chat should be returned if
available. Otherwise, the state of the chat should be returned.
Important:
This must be an atomic operation and not e.g. wait for a lock.
Instead, if necessary, return a special state indicating that the key is currently
busy.
"""
def _do_set_state(
self, key: _KT, state: State, version: Optional[int] = None
) -> StateInfo[_KT]:
"""Protected method to set the state for the specified key.
The version can be optionally used for optimistic locking. If the version does not match
the current version, the state should not be updated.
Important:
This should be used exclusively by methods of this class and subclasses.
It should *not* be called directly by users of this class!
"""
_LOGGER.debug("Setting %s state to %s", key, state)
if state is State.ANY:
raise ValueError("State.ANY is not supported in set_state")
if version and version != self._states.get(key, (None, None))[1]:
raise ValueError("Optimistic locking failed. Not updating state.")
if jq := self._get_job_queue(raise_exception=False):
# This is a rather tight coupling between FSM and JobQueue
# Not sure if we like that. Makes it even harder to replace JobQueue
# (or the JQ implementation) with something else.
# The upside is that we don't need to maintain any additional internal state
# for the jobs and persistence is handled by the JobQueue.
cancel_jobs = jq.jobs(pattern=str(hash(key)))
for job in cancel_jobs:
_LOGGER.debug("Cancelling timeout job %s", job)
job.schedule_removal()
# important to use time.perf_counter_ns() here, as time_ns() is not monotonic
self.__states[key] = (state, time.perf_counter_ns())
# Doing this *after* do_set_state so that any exceptions are raised before the history
# is updated
self.store_state_history(key, state)
return StateInfo(key, state, self._states[key][1])
async def set_state(self, key: _KT, state: State, version: Optional[int] = None) -> None:
"""Store the state for the specified key."""
async with self.get_lock(key):
self._do_set_state(key, state, version)
def set_state_nowait(self, key: _KT, state: State, version: Optional[int] = None) -> None:
"""Store the state for the specified key without waiting for a lock."""
if self.get_lock(key).locked():
raise asyncio.InvalidStateError("Lock is locked")
self._do_set_state(key, state, version)
@contextlib.asynccontextmanager
async def as_state(self, key: _KT, state: State) -> AsyncIterator[None]:
"""Context manager to set the state for the specified key and reset it afterwards."""
async with self.get_lock(key):
current_state, current_version = self.states[key]
new_version = self._do_set_state(key, state, current_version).version
try:
yield
finally:
self._do_set_state(key, current_state, new_version)
@staticmethod
def _build_job_name(keys: Sequence[_KT]) -> str:
return f"FSM_Job_{'_'.join(str(hash(k)) for k in keys)}"
def set_job_queue(self, job_queue: "JobQueue") -> None:
self.__job_queue = weakref.ref(job_queue)
@overload
def _get_job_queue(self, raise_exception: Literal[False]) -> Optional["JobQueue"]: ...
@overload
def _get_job_queue(self) -> "JobQueue": ...
def _get_job_queue(self, raise_exception: bool = True) -> Optional["JobQueue"]:
if self.__job_queue is None:
if raise_exception:
raise RuntimeError("JobQueue not set")
return None
job_queue = self.__job_queue()
if job_queue is None:
if raise_exception:
raise RuntimeError("JobQueue was garbage collected")
return None
return job_queue
def schedule_timeout(
self,
callback: JobCallback,
when: Union[float, dtm.timedelta, dtm.datetime, dtm.time],
cancel_keys: Optional[Sequence[_KT]] = None,
job_kwargs: Optional[dict[str, Any]] = None,
) -> None:
"""Schedule a timeout job. This is a thin wrapper around JobQueue.run_once.
The callback will have to take care of resetting any state if necessary.
Pass cancel_keys to automatically cancel the job when a new state is set for any of the
keys.
"""
job_kwargs = job_kwargs or {}
if cancel_keys:
if "name" in job_kwargs:
raise ValueError("job_kwargs must not contain a 'name' key")
job_kwargs["name"] = self._build_job_name(cancel_keys)
self._get_job_queue().run_once(callback, when, **job_kwargs)
_LOGGER.debug(
"Scheduled timeout. Will be cancelled when a new set state is for either of: %s",
cancel_keys or [],
)
class SingleStateMachine(FiniteStateMachine[None]):
def get_state_info(self, update: object) -> StateInfo[None]: # noqa: ARG002
return StateInfo(None, State.IDLE, 0)
def do_set_state(self, key: None, state: State) -> None:
pass
+114
View File
@@ -0,0 +1,114 @@
"""This Module contains implementations of State classes for Finite State Machines"""
import abc
import contextlib
from typing import ClassVar, Optional
from uuid import uuid4
class State(abc.ABC):
__knows_uids: ClassVar[set[str]] = set()
__not_cache: ClassVar[dict[str, "_NOTState"]] = {}
__or_cache: ClassVar[dict[tuple[str, str], "_ORState"]] = {}
__and_cache: ClassVar[dict[tuple[str, str], "_ANDState"]] = {}
__xor_cache: ClassVar[dict[tuple[str, str], "_XORState"]] = {}
IDLE: "State"
"""Default State for all Finite State Machines"""
ANY: "State"
"""Special State that matches any other State. Useful to define fallback behavior.
*Not* supported in ``set_state`` method of FSMs.
"""
def __init__(self, uid: Optional[str] = None):
effective_uid = uid or uuid4().hex
if effective_uid in self.__knows_uids:
raise ValueError(f"Duplicate UID: {effective_uid} already registered")
self._uid = effective_uid
self.__knows_uids.add(effective_uid)
def __invert__(self) -> "_NOTState":
with contextlib.suppress(KeyError):
return self.__not_cache[self.uid]
return self.__not_cache.setdefault(self.uid, _NOTState(self))
def __or__(self, other: "State") -> "_ORState":
key = (self.uid, other.uid)
with contextlib.suppress(KeyError):
return self.__or_cache[key]
return self.__or_cache.setdefault(key, _ORState(self, other))
def __and__(self, other: "State") -> "_ANDState":
key = (self.uid, other.uid)
with contextlib.suppress(KeyError):
return self.__and_cache[key]
return self.__and_cache.setdefault(key, _ANDState(self, other))
def __xor__(self, other: "State") -> "_XORState":
key = (self.uid, other.uid)
with contextlib.suppress(KeyError):
return self.__xor_cache[key]
return self.__xor_cache.setdefault(key, _XORState(self, other))
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {self.uid}>"
def __str__(self) -> str:
return self.uid
@property
def uid(self) -> str:
return self._uid
def matches(self, state: "State") -> bool:
if isinstance(state, (_NOTState, _ANDState, _ORState, _XORState)):
return state.matches(self)
return self.uid == state.uid
class _AnyState(State):
def matches(self, state: "State") -> bool: # noqa: ARG002
return True
State.IDLE = State("IDLE")
State.ANY = _AnyState("ANY")
class _XORState(State):
def __init__(self, state_one: State, state_two: State):
super().__init__(uid=f"({state_one.uid})^({state_two.uid})")
self._state_one = state_one
self._state_two = state_two
def matches(self, state: "State") -> bool:
return self._state_one.matches(state) ^ self._state_two.matches(state)
class _ORState(State):
def __init__(self, state_one: State, state_two: State):
super().__init__(uid=f"({state_one.uid})|({state_two.uid})")
self._state_one = state_one
self._state_two = state_two
def matches(self, state: "State") -> bool:
return self._state_one.matches(state) or self._state_two.matches(state)
class _ANDState(State):
def __init__(self, state_one: State, state_two: State):
super().__init__(uid=f"({state_one.uid})&({state_two.uid})")
self._state_one = state_one
self._state_two = state_two
def matches(self, state: "State") -> bool:
return self._state_one.matches(state) and self._state_two.matches(state)
class _NOTState(State):
def __init__(self, state: State):
super().__init__(uid=f"!({state.uid})")
self._state = state
def matches(self, state: "State") -> bool:
return not self._state.matches(state)
+1 -1
View File
@@ -97,7 +97,7 @@ class JobQueue(Generic[CCT]):
"""
__slots__ = ("_application", "_executor", "scheduler")
__slots__ = ("__weakref__", "_application", "_executor", "scheduler")
_CRON_MAPPING = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
def __init__(self) -> None:
+110 -29
View File
@@ -30,8 +30,7 @@ from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DEFAULT_NONE, D
from telegram._utils.logging import get_logger
from telegram._utils.repr import build_repr_with_selected_attrs
from telegram._utils.types import DVType, ODVInput
from telegram.error import TelegramError
from telegram.ext._utils.networkloop import network_retry_loop
from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut
try:
from telegram.ext._utils.webhookhandler import WebhookAppClass, WebhookServer
@@ -207,7 +206,7 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
self,
poll_interval: float = 0.0,
timeout: int = 10,
bootstrap_retries: int = 0,
bootstrap_retries: int = -1,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
connect_timeout: ODVInput[float] = DEFAULT_NONE,
@@ -226,16 +225,12 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
Telegram in seconds. Default is ``0.0``.
timeout (:obj:`int`, optional): Passed to
:paramref:`telegram.Bot.get_updates.timeout`. Defaults to ``10`` seconds.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of
will retry on failures on the Telegram server.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
:class:`telegram.ext.Updater` will retry on failures on the Telegram server.
* < 0 - retry indefinitely
* 0 - no retries (default)
* < 0 - retry indefinitely (default)
* 0 - no retries
* > 0 - retry up to X times
.. versionchanged:: 21.11
The default value will be changed to from ``-1`` to ``0``. Indefinite retries
during bootstrapping are not recommended.
read_timeout (:obj:`float`, optional): Value to pass to
:paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
@@ -414,14 +409,12 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
# updates from Telegram and inserts them in the update queue of the
# Application.
self.__polling_task = asyncio.create_task(
network_retry_loop(
is_running=lambda: self.running,
self._network_loop_retry(
action_cb=polling_action_cb,
on_err_cb=error_callback or default_error_callback,
description="Polling Updates",
description="getting Updates",
interval=poll_interval,
stop_event=self.__polling_task_stop_event,
max_retries=-1,
),
name="Updater:start_polling:polling_task",
)
@@ -514,8 +507,8 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
Telegram servers before actually starting to poll. Default is :obj:`False`.
.. versionadded :: 13.4
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of
will retry on failures on the Telegram server.
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
:class:`telegram.ext.Updater` will retry on failures on the Telegram server.
* < 0 - retry indefinitely
* 0 - no retries (default)
@@ -705,6 +698,78 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
# say differently!
return f"{protocol}://{listen}:{port}{url_path}"
async def _network_loop_retry(
self,
action_cb: Callable[..., Coroutine],
on_err_cb: Callable[[TelegramError], None],
description: str,
interval: float,
stop_event: Optional[asyncio.Event],
) -> None:
"""Perform a loop calling `action_cb`, retrying after network errors.
Stop condition for loop: `self.running` evaluates :obj:`False` or return value of
`action_cb` evaluates :obj:`False`.
Args:
action_cb (:term:`coroutine function`): Network oriented callback function to call.
on_err_cb (:obj:`callable`): Callback to call when TelegramError is caught. Receives
the exception object as a parameter.
description (:obj:`str`): Description text to use for logs and exception raised.
interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to
`action_cb`.
stop_event (:class:`asyncio.Event` | :obj:`None`): Event to wait on for stopping the
loop. Setting the event will make the loop exit even if `action_cb` is currently
running.
"""
async def do_action() -> bool:
if not stop_event:
return await action_cb()
action_cb_task = asyncio.create_task(action_cb())
stop_task = asyncio.create_task(stop_event.wait())
done, pending = await asyncio.wait(
(action_cb_task, stop_task), return_when=asyncio.FIRST_COMPLETED
)
with contextlib.suppress(asyncio.CancelledError):
for task in pending:
task.cancel()
if stop_task in done:
_LOGGER.debug("Network loop retry %s was cancelled", description)
return False
return action_cb_task.result()
_LOGGER.debug("Start network loop retry %s", description)
cur_interval = interval
while self.running:
try:
if not await do_action():
break
except RetryAfter as exc:
_LOGGER.info("%s", exc)
cur_interval = 0.5 + exc.retry_after
except TimedOut as toe:
_LOGGER.debug("Timed out %s: %s", description, toe)
# If failure is due to timeout, we should retry asap.
cur_interval = 0
except InvalidToken:
_LOGGER.exception("Invalid token; aborting")
raise
except TelegramError as telegram_exc:
on_err_cb(telegram_exc)
# increase waiting times on subsequent errors up to 30secs
cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval)
else:
cur_interval = interval
if cur_interval:
await asyncio.sleep(cur_interval)
async def _bootstrap(
self,
max_retries: int,
@@ -721,6 +786,7 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
updates if appropriate. If there are unsuccessful attempts, this will retry as specified by
:paramref:`max_retries`.
"""
retries = 0
async def bootstrap_del_webhook() -> bool:
_LOGGER.debug("Deleting webhook")
@@ -744,30 +810,45 @@ class Updater(contextlib.AbstractAsyncContextManager["Updater"]):
)
return False
def bootstrap_on_err_cb(exc: Exception) -> None:
# We need this since retries is an immutable object otherwise and the changes
# wouldn't propagate outside of thi function
nonlocal retries
if not isinstance(exc, InvalidToken) and (max_retries < 0 or retries < max_retries):
retries += 1
_LOGGER.warning(
"Failed bootstrap phase; try=%s max_retries=%s", retries, max_retries
)
else:
_LOGGER.error("Failed bootstrap phase after %s retries (%s)", retries, exc)
raise exc
# Dropping pending updates from TG can be efficiently done with the drop_pending_updates
# parameter of delete/start_webhook, even in the case of polling. Also, we want to make
# sure that no webhook is configured in case of polling, so we just always call
# delete_webhook for polling
if drop_pending_updates or not webhook_url:
await network_retry_loop(
is_running=lambda: self.running,
action_cb=bootstrap_del_webhook,
description="Bootstrap delete Webhook",
interval=bootstrap_interval,
await self._network_loop_retry(
bootstrap_del_webhook,
bootstrap_on_err_cb,
"bootstrap del webhook",
bootstrap_interval,
stop_event=None,
max_retries=max_retries,
)
# Reset the retries counter for the next _network_loop_retry call
retries = 0
# Restore/set webhook settings, if needed. Again, we don't know ahead if a webhook is set,
# so we set it anyhow.
if webhook_url:
await network_retry_loop(
is_running=lambda: self.running,
action_cb=bootstrap_set_webhook,
description="Bootstrap Set Webhook",
interval=bootstrap_interval,
await self._network_loop_retry(
bootstrap_set_webhook,
bootstrap_on_err_cb,
"bootstrap set webhook",
bootstrap_interval,
stop_event=None,
max_retries=max_retries,
)
async def stop(self) -> None:
+1 -1
View File
@@ -18,7 +18,7 @@
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains helper functions related to the std-lib asyncio module.
.. versionadded:: 21.11
.. versionadded:: NEXT.VERSION
Warning:
Contents of this module are intended to be used internally by the library and *not* by the
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2025
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains a network retry loop implementation.
Its specifically tailored to handling the Telegram API and its errors.
.. versionadded:: 21.11
Hint:
It was originally part of the `Updater` class, but as part of #4657 it was extracted into its
own module to be used by other parts of the library.
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.
"""
import asyncio
import contextlib
from collections.abc import Coroutine
from typing import Callable, Optional
from telegram._utils.logging import get_logger
from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut
_LOGGER = get_logger(__name__)
async def network_retry_loop(
*,
action_cb: Callable[..., Coroutine],
on_err_cb: Optional[Callable[[TelegramError], None]] = None,
description: str,
interval: float,
stop_event: Optional[asyncio.Event] = None,
is_running: Optional[Callable[[], bool]] = None,
max_retries: int,
) -> None:
"""Perform a loop calling `action_cb`, retrying after network errors.
Stop condition for loop:
* `is_running()` evaluates :obj:`False` or
* return value of `action_cb` evaluates :obj:`False`
* or `stop_event` is set.
* or `max_retries` is reached.
Args:
action_cb (:term:`coroutine function`): Network oriented callback function to call.
on_err_cb (:obj:`callable`): Optional. Callback to call when TelegramError is caught.
Receives the exception object as a parameter.
Hint:
Only required if you want to handle the error in a special way. Logging about
the error is already handled by the loop.
Important:
Must not raise exceptions! If it does, the loop will be aborted.
description (:obj:`str`): Description text to use for logs and exception raised.
interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to
`action_cb`.
stop_event (:class:`asyncio.Event` | :obj:`None`): Event to wait on for stopping the
loop. Setting the event will make the loop exit even if `action_cb` is currently
running. Defaults to :obj:`None`.
is_running (:obj:`callable`): Function to check if the loop should continue running.
Must return a boolean value. Defaults to `lambda: True`.
max_retries (:obj:`int`): Maximum number of retries before stopping the loop.
* < 0: Retry indefinitely.
* 0: No retries.
* > 0: Number of retries.
"""
log_prefix = f"Network Retry Loop ({description}):"
effective_is_running = is_running or (lambda: True)
async def do_action() -> bool:
if not stop_event:
return await action_cb()
action_cb_task = asyncio.create_task(action_cb())
stop_task = asyncio.create_task(stop_event.wait())
done, pending = await asyncio.wait(
(action_cb_task, stop_task), return_when=asyncio.FIRST_COMPLETED
)
with contextlib.suppress(asyncio.CancelledError):
for task in pending:
task.cancel()
if stop_task in done:
_LOGGER.debug("%s Cancelled", log_prefix)
return False
return action_cb_task.result()
_LOGGER.debug("%s Starting", log_prefix)
cur_interval = interval
retries = 0
while effective_is_running():
try:
if not await do_action():
break
except RetryAfter as exc:
slack_time = 0.5
_LOGGER.info(
"%s %s. Adding %s seconds to the specified time.", log_prefix, exc, slack_time
)
cur_interval = slack_time + exc.retry_after
except TimedOut as toe:
_LOGGER.debug("%s Timed out: %s. Retrying immediately.", log_prefix, toe)
# If failure is due to timeout, we should retry asap.
cur_interval = 0
except InvalidToken:
_LOGGER.exception("%s Invalid token. Aborting retry loop.", log_prefix)
raise
except TelegramError as telegram_exc:
if on_err_cb:
on_err_cb(telegram_exc)
if max_retries < 0 or retries < max_retries:
_LOGGER.debug(
"%s Failed run number %s of %s. Retrying.", log_prefix, retries, max_retries
)
else:
_LOGGER.exception(
"%s Failed run number %s of %s. Aborting.", log_prefix, retries, max_retries
)
raise
# increase waiting times on subsequent errors up to 30secs
cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval)
else:
cur_interval = interval
finally:
retries += 1
if cur_interval:
await asyncio.sleep(cur_interval)
+2 -24
View File
@@ -58,8 +58,6 @@ def input_media_video(class_thumb_file):
parse_mode=InputMediaVideoTestBase.parse_mode,
caption_entities=InputMediaVideoTestBase.caption_entities,
thumbnail=class_thumb_file,
cover=class_thumb_file,
start_timestamp=InputMediaVideoTestBase.start_timestamp,
supports_streaming=InputMediaVideoTestBase.supports_streaming,
has_spoiler=InputMediaVideoTestBase.has_spoiler,
show_caption_above_media=InputMediaVideoTestBase.show_caption_above_media,
@@ -132,8 +130,6 @@ def input_paid_media_video(class_thumb_file):
return InputPaidMediaVideo(
media=InputMediaVideoTestBase.media,
thumbnail=class_thumb_file,
cover=class_thumb_file,
start_timestamp=InputMediaVideoTestBase.start_timestamp,
width=InputMediaVideoTestBase.width,
height=InputMediaVideoTestBase.height,
duration=InputMediaVideoTestBase.duration,
@@ -148,7 +144,6 @@ class InputMediaVideoTestBase:
width = 3
height = 4
duration = 5
start_timestamp = 3
parse_mode = "HTML"
supports_streaming = True
caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)]
@@ -174,8 +169,6 @@ class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase):
assert input_media_video.caption_entities == tuple(self.caption_entities)
assert input_media_video.supports_streaming == self.supports_streaming
assert isinstance(input_media_video.thumbnail, InputFile)
assert isinstance(input_media_video.cover, InputFile)
assert input_media_video.start_timestamp == self.start_timestamp
assert input_media_video.has_spoiler == self.has_spoiler
assert input_media_video.show_caption_above_media == self.show_caption_above_media
@@ -201,8 +194,6 @@ class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase):
input_media_video_dict["show_caption_above_media"]
== input_media_video.show_caption_above_media
)
assert input_media_video_dict["cover"] == input_media_video.cover
assert input_media_video_dict["start_timestamp"] == input_media_video.start_timestamp
def test_with_video(self, video):
# fixture found in test_video
@@ -223,13 +214,10 @@ class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase):
def test_with_local_files(self):
input_media_video = InputMediaVideo(
data_file("telegram.mp4"),
thumbnail=data_file("telegram.jpg"),
cover=data_file("telegram.jpg"),
data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg")
)
assert input_media_video.media == data_file("telegram.mp4").as_uri()
assert input_media_video.thumbnail == data_file("telegram.jpg").as_uri()
assert input_media_video.cover == data_file("telegram.jpg").as_uri()
def test_type_enum_conversion(self):
# Since we have a lot of different test classes for all the input media types, we test this
@@ -577,8 +565,6 @@ class TestInputPaidMediaVideoWithoutRequest(InputMediaVideoTestBase):
assert input_paid_media_video.duration == self.duration
assert input_paid_media_video.supports_streaming == self.supports_streaming
assert isinstance(input_paid_media_video.thumbnail, InputFile)
assert isinstance(input_paid_media_video.cover, InputFile)
assert input_paid_media_video.start_timestamp == self.start_timestamp
def test_to_dict(self, input_paid_media_video):
input_paid_media_video_dict = input_paid_media_video.to_dict()
@@ -592,11 +578,6 @@ class TestInputPaidMediaVideoWithoutRequest(InputMediaVideoTestBase):
== input_paid_media_video.supports_streaming
)
assert input_paid_media_video_dict["thumbnail"] == input_paid_media_video.thumbnail
assert input_paid_media_video_dict["cover"] == input_paid_media_video.cover
assert (
input_paid_media_video_dict["start_timestamp"]
== input_paid_media_video.start_timestamp
)
def test_with_video(self, video):
# fixture found in test_video
@@ -615,13 +596,10 @@ class TestInputPaidMediaVideoWithoutRequest(InputMediaVideoTestBase):
def test_with_local_files(self):
input_paid_media_video = InputPaidMediaVideo(
data_file("telegram.mp4"),
thumbnail=data_file("telegram.jpg"),
cover=data_file("telegram.jpg"),
data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg")
)
assert input_paid_media_video.media == data_file("telegram.mp4").as_uri()
assert input_paid_media_video.thumbnail == data_file("telegram.jpg").as_uri()
assert input_paid_media_video.cover == data_file("telegram.jpg").as_uri()
@pytest.fixture(scope="module")
+1 -16
View File
@@ -46,8 +46,6 @@ class VideoTestBase:
mime_type = "video/mp4"
supports_streaming = True
file_name = "telegram.mp4"
start_timestamp = 3
cover = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),)
thumb_width = 180
thumb_height = 320
thumb_file_size = 1767
@@ -94,8 +92,6 @@ class TestVideoWithoutRequest(VideoTestBase):
"mime_type": self.mime_type,
"file_size": self.file_size,
"file_name": self.file_name,
"start_timestamp": self.start_timestamp,
"cover": [photo_size.to_dict() for photo_size in self.cover],
}
json_video = Video.de_json(json_dict, offline_bot)
assert json_video.api_kwargs == {}
@@ -108,8 +104,6 @@ class TestVideoWithoutRequest(VideoTestBase):
assert json_video.mime_type == self.mime_type
assert json_video.file_size == self.file_size
assert json_video.file_name == self.file_name
assert json_video.start_timestamp == self.start_timestamp
assert json_video.cover == self.cover
def test_to_dict(self, video):
video_dict = video.to_dict()
@@ -229,9 +223,7 @@ class TestVideoWithoutRequest(VideoTestBase):
class TestVideoWithRequest(VideoTestBase):
@pytest.mark.parametrize("duration", [dtm.timedelta(seconds=5), 5])
async def test_send_all_args(
self, bot, chat_id, video_file, video, thumb_file, photo_file, duration
):
async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file, duration):
message = await bot.send_video(
chat_id,
video_file,
@@ -244,8 +236,6 @@ class TestVideoWithRequest(VideoTestBase):
height=video.height,
parse_mode="Markdown",
thumbnail=thumb_file,
cover=photo_file,
start_timestamp=self.start_timestamp,
has_spoiler=True,
show_caption_above_media=True,
)
@@ -266,11 +256,6 @@ class TestVideoWithRequest(VideoTestBase):
assert message.video.thumbnail.width == self.thumb_width
assert message.video.thumbnail.height == self.thumb_height
assert message.video.start_timestamp == self.start_timestamp
assert isinstance(message.video.cover, tuple)
assert isinstance(message.video.cover[0], PhotoSize)
assert message.video.file_name == self.file_name
assert message.has_protected_content
assert message.has_media_spoiler
@@ -28,6 +28,7 @@ from telegram import (
InputTextMessageContent,
)
from telegram.constants import InlineQueryResultType
from telegram.warnings import PTBDeprecationWarning
from tests.auxil.slots import mro_slots
@@ -39,6 +40,7 @@ def inline_query_result_article():
input_message_content=InlineQueryResultArticleTestBase.input_message_content,
reply_markup=InlineQueryResultArticleTestBase.reply_markup,
url=InlineQueryResultArticleTestBase.url,
hide_url=InlineQueryResultArticleTestBase.hide_url,
description=InlineQueryResultArticleTestBase.description,
thumbnail_url=InlineQueryResultArticleTestBase.thumbnail_url,
thumbnail_height=InlineQueryResultArticleTestBase.thumbnail_height,
@@ -53,6 +55,7 @@ class InlineQueryResultArticleTestBase:
input_message_content = InputTextMessageContent("input_message_content")
reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]])
url = "url"
hide_url = True
description = "description"
thumbnail_url = "thumb url"
thumbnail_height = 10
@@ -76,6 +79,7 @@ class TestInlineQueryResultArticleWithoutRequest(InlineQueryResultArticleTestBas
)
assert inline_query_result_article.reply_markup.to_dict() == self.reply_markup.to_dict()
assert inline_query_result_article.url == self.url
assert inline_query_result_article.hide_url == self.hide_url
assert inline_query_result_article.description == self.description
assert inline_query_result_article.thumbnail_url == self.thumbnail_url
assert inline_query_result_article.thumbnail_height == self.thumbnail_height
@@ -97,6 +101,7 @@ class TestInlineQueryResultArticleWithoutRequest(InlineQueryResultArticleTestBas
== inline_query_result_article.reply_markup.to_dict()
)
assert inline_query_result_article_dict["url"] == inline_query_result_article.url
assert inline_query_result_article_dict["hide_url"] == inline_query_result_article.hide_url
assert (
inline_query_result_article_dict["description"]
== inline_query_result_article.description
@@ -153,3 +158,31 @@ class TestInlineQueryResultArticleWithoutRequest(InlineQueryResultArticleTestBas
assert a != e
assert hash(a) != hash(e)
def test_deprecation_warning_for_hide_url(self):
with pytest.warns(PTBDeprecationWarning, match="The argument `hide_url`") as record:
InlineQueryResultArticle(
self.id_, self.title, self.input_message_content, hide_url=True
)
assert record[0].filename == __file__, "wrong stacklevel!"
with pytest.warns(PTBDeprecationWarning, match="The argument `hide_url`") as record:
InlineQueryResultArticle(
self.id_, self.title, self.input_message_content, hide_url=False
)
assert record[0].filename == __file__, "wrong stacklevel!"
assert (
InlineQueryResultArticle(
self.id_, self.title, self.input_message_content, hide_url=True
).hide_url
is True
)
assert (
InlineQueryResultArticle(
self.id_, self.title, self.input_message_content, hide_url=False
).hide_url
is False
)
@@ -282,10 +282,10 @@ class TestInputInvoiceMessageContentWithoutRequest(InputInvoiceMessageContentTes
self.title,
self.description,
self.payload,
self.provider_token,
self.currency,
# the first prices amount & the second lebal changed
[LabeledPrice("label1", 24), LabeledPrice("label22", 314)],
self.provider_token,
)
d = InputInvoiceMessageContent(
self.title,
@@ -22,14 +22,12 @@ import pytest
from telegram import (
AffiliateInfo,
Chat,
Gift,
PaidMediaVideo,
RevenueWithdrawalStatePending,
Sticker,
TransactionPartner,
TransactionPartnerAffiliateProgram,
TransactionPartnerChat,
TransactionPartnerFragment,
TransactionPartnerOther,
TransactionPartnerTelegramAds,
@@ -97,10 +95,6 @@ class TransactionPartnerTestBase:
amount=42,
)
request_count = 42
chat = Chat(
id=3,
type=Chat.CHANNEL,
)
class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase):
@@ -129,7 +123,6 @@ class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase):
("telegram_ads", TransactionPartnerTelegramAds),
("telegram_api", TransactionPartnerTelegramApi),
("other", TransactionPartnerOther),
("chat", TransactionPartnerChat),
],
)
def test_subclass(self, offline_bot, tp_type, subclass):
@@ -457,58 +450,3 @@ class TestTransactionPartnerTelegramApiWithoutRequest(TransactionPartnerTestBase
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def transaction_partner_chat():
return TransactionPartnerChat(
chat=TransactionPartnerTestBase.chat,
gift=TransactionPartnerTestBase.gift,
)
class TestTransactionPartnerChatWithoutRequest(TransactionPartnerTestBase):
type = TransactionPartnerType.CHAT
def test_slot_behaviour(self, transaction_partner_chat):
inst = transaction_partner_chat
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(self, offline_bot):
json_dict = {
"chat": self.chat.to_dict(),
"gift": self.gift.to_dict(),
}
tp = TransactionPartnerChat.de_json(json_dict, offline_bot)
assert tp.api_kwargs == {}
assert tp.type == "chat"
assert tp.chat == self.chat
assert tp.gift == self.gift
def test_to_dict(self, transaction_partner_chat):
json_dict = transaction_partner_chat.to_dict()
assert json_dict["type"] == self.type
assert json_dict["chat"] == self.chat.to_dict()
assert json_dict["gift"] == self.gift.to_dict()
def test_equality(self, transaction_partner_chat):
a = transaction_partner_chat
b = TransactionPartnerChat(
chat=self.chat,
gift=self.gift,
)
c = TransactionPartnerChat(
chat=Chat(id=1, type=Chat.CHANNEL),
)
d = Chat(id=1, type=Chat.CHANNEL)
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
+5 -4
View File
@@ -269,9 +269,9 @@ class TestInvoiceWithRequest(InvoiceTestBase):
self.title,
self.description,
self.payload,
provider_token,
self.currency,
self.prices,
provider_token,
**kwargs,
)
for kwargs in ({}, {"protect_content": False})
@@ -301,6 +301,7 @@ class TestInvoiceWithRequest(InvoiceTestBase):
self.title,
self.description,
self.payload,
"", # using tg stars
"XTR",
[self.prices[0]],
allow_sending_without_reply=custom,
@@ -314,9 +315,9 @@ class TestInvoiceWithRequest(InvoiceTestBase):
self.title,
self.description,
self.payload,
provider_token,
self.currency,
self.prices,
provider_token,
reply_to_message_id=reply_to_message.message_id,
)
assert message.reply_to_message is None
@@ -327,9 +328,9 @@ class TestInvoiceWithRequest(InvoiceTestBase):
self.title,
self.description,
self.payload,
provider_token,
self.currency,
self.prices,
provider_token,
reply_to_message_id=reply_to_message.message_id,
)
@@ -339,9 +340,9 @@ class TestInvoiceWithRequest(InvoiceTestBase):
self.title,
self.description,
self.payload,
provider_token,
self.currency,
self.prices,
provider_token=provider_token,
max_tip_amount=self.max_tip_amount,
suggested_tip_amounts=self.suggested_tip_amounts,
start_parameter=self.start_parameter,
-3
View File
@@ -351,9 +351,6 @@ def build_kwargs(
allow_sending_without_reply=manually_passed_value,
quote_parse_mode=manually_passed_value,
)
# TODO remove when gift_id isnt marked as optional anymore, tags: deprecated 21.11
elif name == "gift_id":
kws[name] = "GIFT-ID"
return kws
+1 -42
View File
@@ -38,7 +38,7 @@ from typing import Optional
import pytest
from telegram import Bot, Chat, Message, MessageEntity, User
from telegram.error import InvalidToken, TelegramError
from telegram.error import TelegramError
from telegram.ext import (
Application,
ApplicationBuilder,
@@ -2359,47 +2359,6 @@ class TestApplication:
for record in recwarn:
assert not str(record.message).startswith("Could not add signal handlers for the stop")
@pytest.mark.parametrize("exception_class", [InvalidToken, TelegramError])
@pytest.mark.parametrize("retries", [3, 0])
@pytest.mark.parametrize("method_name", ["run_polling", "run_webhook"])
async def test_run_polling_webhook_bootstrap_retries(
self, monkeypatch, exception_class, retries, offline_bot, method_name
):
"""This doesn't test all of the internals of the network retry loop. We do that quite
intensively for the `Updater` and here we just want to make sure that the `Application`
does do the retries.
"""
def thread_target():
asyncio.set_event_loop(asyncio.new_event_loop())
app = (
ApplicationBuilder().bot(offline_bot).application_class(PytestApplication).build()
)
async def initialize(*args, **kwargs):
self.count += 1
raise exception_class(str(self.count))
monkeypatch.setattr(app, "initialize", initialize)
method = functools.partial(
getattr(app, method_name),
bootstrap_retries=retries,
close_loop=False,
stop_signals=None,
)
if exception_class == InvalidToken:
with pytest.raises(InvalidToken, match="1"):
method()
else:
with pytest.raises(TelegramError, match=str(retries + 1)):
method()
thread = Thread(target=thread_target)
thread.start()
thread.join(timeout=10)
assert not thread.is_alive(), "Test took to long to run. Aborting"
@pytest.mark.flaky(3, 1) # loop.call_later will error the test when a flood error is received
def test_signal_handlers(self, app, monkeypatch):
# this test should make sure that signal handlers are set by default on Linux + Mac,
-2
View File
@@ -1617,7 +1617,6 @@ class TestBotWithoutRequest:
== [MessageEntity(MessageEntity.BOLD, 0, 4).to_dict()],
data["protect_content"] is True,
data["message_thread_id"] == 1,
data["video_start_timestamp"] == 999,
]
):
pytest.fail("I got wrong parameters in post")
@@ -1629,7 +1628,6 @@ class TestBotWithoutRequest:
from_chat_id=chat_id,
message_id=media_message.message_id,
caption=caption,
video_start_timestamp=999,
caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 4)],
parse_mode=ParseMode.HTML,
reply_to_message_id=media_message.message_id,
+149 -356
View File
@@ -16,6 +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 copy import deepcopy
import pytest
@@ -34,232 +35,147 @@ from telegram.constants import BotCommandScopeType
from tests.auxil.slots import mro_slots
@pytest.fixture
def bot_command_scope():
return BotCommandScope(BotCommandScopeTestBase.type)
@pytest.fixture(scope="module", params=["str", "int"])
def chat_id(request):
if request.param == "str":
return "@supergroupusername"
return 43
class BotCommandScopeTestBase:
type = BotCommandScopeType.DEFAULT
chat_id = 123456789
user_id = 987654321
@pytest.fixture(
scope="class",
params=[
BotCommandScope.DEFAULT,
BotCommandScope.ALL_PRIVATE_CHATS,
BotCommandScope.ALL_GROUP_CHATS,
BotCommandScope.ALL_CHAT_ADMINISTRATORS,
BotCommandScope.CHAT,
BotCommandScope.CHAT_ADMINISTRATORS,
BotCommandScope.CHAT_MEMBER,
],
)
def scope_type(request):
return request.param
class TestBotCommandScopeWithoutRequest(BotCommandScopeTestBase):
@pytest.fixture(
scope="module",
params=[
BotCommandScopeDefault,
BotCommandScopeAllPrivateChats,
BotCommandScopeAllGroupChats,
BotCommandScopeAllChatAdministrators,
BotCommandScopeChat,
BotCommandScopeChatAdministrators,
BotCommandScopeChatMember,
],
ids=[
BotCommandScope.DEFAULT,
BotCommandScope.ALL_PRIVATE_CHATS,
BotCommandScope.ALL_GROUP_CHATS,
BotCommandScope.ALL_CHAT_ADMINISTRATORS,
BotCommandScope.CHAT,
BotCommandScope.CHAT_ADMINISTRATORS,
BotCommandScope.CHAT_MEMBER,
],
)
def scope_class(request):
return request.param
@pytest.fixture(
scope="module",
params=[
(BotCommandScopeDefault, BotCommandScope.DEFAULT),
(BotCommandScopeAllPrivateChats, BotCommandScope.ALL_PRIVATE_CHATS),
(BotCommandScopeAllGroupChats, BotCommandScope.ALL_GROUP_CHATS),
(BotCommandScopeAllChatAdministrators, BotCommandScope.ALL_CHAT_ADMINISTRATORS),
(BotCommandScopeChat, BotCommandScope.CHAT),
(BotCommandScopeChatAdministrators, BotCommandScope.CHAT_ADMINISTRATORS),
(BotCommandScopeChatMember, BotCommandScope.CHAT_MEMBER),
],
ids=[
BotCommandScope.DEFAULT,
BotCommandScope.ALL_PRIVATE_CHATS,
BotCommandScope.ALL_GROUP_CHATS,
BotCommandScope.ALL_CHAT_ADMINISTRATORS,
BotCommandScope.CHAT,
BotCommandScope.CHAT_ADMINISTRATORS,
BotCommandScope.CHAT_MEMBER,
],
)
def scope_class_and_type(request):
return request.param
@pytest.fixture(scope="module")
def bot_command_scope(scope_class_and_type, chat_id):
# we use de_json here so that we don't have to worry about which class needs which arguments
return scope_class_and_type[0].de_json(
{"type": scope_class_and_type[1], "chat_id": chat_id, "user_id": 42}, bot=None
)
# All the scope types are very similar, so we test everything via parametrization
class TestBotCommandScopeWithoutRequest:
def test_slot_behaviour(self, bot_command_scope):
inst = bot_command_scope
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"
for attr in bot_command_scope.__slots__:
assert getattr(bot_command_scope, attr, "err") != "err", f"got extra slot '{attr}'"
assert len(mro_slots(bot_command_scope)) == len(
set(mro_slots(bot_command_scope))
), "duplicate slot"
def test_type_enum_conversion(self, bot_command_scope):
def test_de_json(self, offline_bot, scope_class_and_type, chat_id):
cls = scope_class_and_type[0]
type_ = scope_class_and_type[1]
json_dict = {"type": type_, "chat_id": chat_id, "user_id": 42}
bot_command_scope = BotCommandScope.de_json(json_dict, offline_bot)
assert set(bot_command_scope.api_kwargs.keys()) == {"chat_id", "user_id"} - set(
cls.__slots__
)
assert isinstance(bot_command_scope, BotCommandScope)
assert isinstance(bot_command_scope, cls)
assert bot_command_scope.type == type_
if "chat_id" in cls.__slots__:
assert bot_command_scope.chat_id == chat_id
if "user_id" in cls.__slots__:
assert bot_command_scope.user_id == 42
def test_de_json_invalid_type(self, offline_bot):
json_dict = {"type": "invalid", "chat_id": chat_id, "user_id": 42}
bot_command_scope = BotCommandScope.de_json(json_dict, offline_bot)
assert type(bot_command_scope) is BotCommandScope
assert bot_command_scope.type == "invalid"
def test_de_json_subclass(self, scope_class, offline_bot, chat_id):
"""This makes sure that e.g. BotCommandScopeDefault(data) never returns a
BotCommandScopeChat instance."""
json_dict = {"type": "invalid", "chat_id": chat_id, "user_id": 42}
assert type(scope_class.de_json(json_dict, offline_bot)) is scope_class
def test_to_dict(self, bot_command_scope):
bot_command_scope_dict = bot_command_scope.to_dict()
assert isinstance(bot_command_scope_dict, dict)
assert bot_command_scope["type"] == bot_command_scope.type
if hasattr(bot_command_scope, "chat_id"):
assert bot_command_scope["chat_id"] == bot_command_scope.chat_id
if hasattr(bot_command_scope, "user_id"):
assert bot_command_scope["user_id"] == bot_command_scope.user_id
def test_type_enum_conversion(self):
assert type(BotCommandScope("default").type) is BotCommandScopeType
assert BotCommandScope("unknown").type == "unknown"
def test_de_json(self, offline_bot):
data = {"type": "unknown"}
transaction_partner = BotCommandScope.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "unknown"
@pytest.mark.parametrize(
("bcs_type", "subclass"),
[
("all_private_chats", BotCommandScopeAllPrivateChats),
("all_chat_administrators", BotCommandScopeAllChatAdministrators),
("all_group_chats", BotCommandScopeAllGroupChats),
("chat", BotCommandScopeChat),
("chat_administrators", BotCommandScopeChatAdministrators),
("chat_member", BotCommandScopeChatMember),
("default", BotCommandScopeDefault),
],
)
def test_de_json_subclass(self, offline_bot, bcs_type, subclass):
json_dict = {
"type": bcs_type,
"chat_id": self.chat_id,
"user_id": self.user_id,
}
bcs = BotCommandScope.de_json(json_dict, offline_bot)
assert type(bcs) is subclass
assert set(bcs.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"type"
}
assert bcs.type == bcs_type
def test_to_dict(self, bot_command_scope):
data = bot_command_scope.to_dict()
assert data == {"type": "default"}
def test_equality(self, bot_command_scope):
a = bot_command_scope
b = BotCommandScope(self.type)
c = BotCommandScope("unknown")
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def bot_command_scope_all_private_chats():
return BotCommandScopeAllPrivateChats()
class TestBotCommandScopeAllPrivateChatsWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.ALL_PRIVATE_CHATS
def test_slot_behaviour(self, bot_command_scope_all_private_chats):
inst = bot_command_scope_all_private_chats
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(self, offline_bot):
transaction_partner = BotCommandScopeAllPrivateChats.de_json({}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "all_private_chats"
def test_to_dict(self, bot_command_scope_all_private_chats):
assert bot_command_scope_all_private_chats.to_dict() == {
"type": bot_command_scope_all_private_chats.type
}
def test_equality(self, bot_command_scope_all_private_chats):
a = bot_command_scope_all_private_chats
b = BotCommandScopeAllPrivateChats()
c = Dice(5, "test")
d = BotCommandScopeDefault()
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def bot_command_scope_all_chat_administrators():
return BotCommandScopeAllChatAdministrators()
class TestBotCommandScopeAllChatAdministratorsWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.ALL_CHAT_ADMINISTRATORS
def test_slot_behaviour(self, bot_command_scope_all_chat_administrators):
inst = bot_command_scope_all_chat_administrators
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(self, offline_bot):
transaction_partner = BotCommandScopeAllChatAdministrators.de_json({}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "all_chat_administrators"
def test_to_dict(self, bot_command_scope_all_chat_administrators):
assert bot_command_scope_all_chat_administrators.to_dict() == {
"type": bot_command_scope_all_chat_administrators.type
}
def test_equality(self, bot_command_scope_all_chat_administrators):
a = bot_command_scope_all_chat_administrators
b = BotCommandScopeAllChatAdministrators()
c = Dice(5, "test")
d = BotCommandScopeDefault()
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def bot_command_scope_all_group_chats():
return BotCommandScopeAllGroupChats()
class TestBotCommandScopeAllGroupChatsWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.ALL_GROUP_CHATS
def test_slot_behaviour(self, bot_command_scope_all_group_chats):
inst = bot_command_scope_all_group_chats
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(self, offline_bot):
transaction_partner = BotCommandScopeAllGroupChats.de_json({}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "all_group_chats"
def test_to_dict(self, bot_command_scope_all_group_chats):
assert bot_command_scope_all_group_chats.to_dict() == {
"type": bot_command_scope_all_group_chats.type
}
def test_equality(self, bot_command_scope_all_group_chats):
a = bot_command_scope_all_group_chats
b = BotCommandScopeAllGroupChats()
c = Dice(5, "test")
d = BotCommandScopeDefault()
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def bot_command_scope_chat():
return BotCommandScopeChat(TestBotCommandScopeChatWithoutRequest.chat_id)
class TestBotCommandScopeChatWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.CHAT
def test_slot_behaviour(self, bot_command_scope_chat):
inst = bot_command_scope_chat
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(self, offline_bot):
transaction_partner = BotCommandScopeChat.de_json({"chat_id": self.chat_id}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "chat"
assert transaction_partner.chat_id == self.chat_id
def test_to_dict(self, bot_command_scope_chat):
assert bot_command_scope_chat.to_dict() == {
"type": bot_command_scope_chat.type,
"chat_id": self.chat_id,
}
def test_equality(self, bot_command_scope_chat):
a = bot_command_scope_chat
b = BotCommandScopeChat(self.chat_id)
c = BotCommandScopeChat(self.chat_id + 1)
d = Dice(5, "test")
e = BotCommandScopeDefault()
def test_equality(self, bot_command_scope, offline_bot):
a = BotCommandScope("base_type")
b = BotCommandScope("base_type")
c = bot_command_scope
d = deepcopy(bot_command_scope)
e = Dice(4, "emoji")
assert a == b
assert hash(a) == hash(b)
@@ -273,147 +189,24 @@ class TestBotCommandScopeChatWithoutRequest(BotCommandScopeTestBase):
assert a != e
assert hash(a) != hash(e)
assert c == d
assert hash(c) == hash(d)
@pytest.fixture
def bot_command_scope_chat_administrators():
return BotCommandScopeChatAdministrators(
TestBotCommandScopeChatAdministratorsWithoutRequest.chat_id
)
assert c != e
assert hash(c) != hash(e)
if hasattr(c, "chat_id"):
json_dict = c.to_dict()
json_dict["chat_id"] = 0
f = c.__class__.de_json(json_dict, offline_bot)
class TestBotCommandScopeChatAdministratorsWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.CHAT_ADMINISTRATORS
assert c != f
assert hash(c) != hash(f)
def test_slot_behaviour(self, bot_command_scope_chat_administrators):
inst = bot_command_scope_chat_administrators
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"
if hasattr(c, "user_id"):
json_dict = c.to_dict()
json_dict["user_id"] = 0
g = c.__class__.de_json(json_dict, offline_bot)
def test_de_json(self, offline_bot):
transaction_partner = BotCommandScopeChatAdministrators.de_json(
{"chat_id": self.chat_id}, offline_bot
)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "chat_administrators"
assert transaction_partner.chat_id == self.chat_id
def test_to_dict(self, bot_command_scope_chat_administrators):
assert bot_command_scope_chat_administrators.to_dict() == {
"type": bot_command_scope_chat_administrators.type,
"chat_id": self.chat_id,
}
def test_equality(self, bot_command_scope_chat_administrators):
a = bot_command_scope_chat_administrators
b = BotCommandScopeChatAdministrators(self.chat_id)
c = BotCommandScopeChatAdministrators(self.chat_id + 1)
d = Dice(5, "test")
e = BotCommandScopeDefault()
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
def bot_command_scope_chat_member():
return BotCommandScopeChatMember(
TestBotCommandScopeChatMemberWithoutRequest.chat_id,
TestBotCommandScopeChatMemberWithoutRequest.user_id,
)
class TestBotCommandScopeChatMemberWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.CHAT_MEMBER
def test_slot_behaviour(self, bot_command_scope_chat_member):
inst = bot_command_scope_chat_member
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(self, offline_bot):
transaction_partner = BotCommandScopeChatMember.de_json(
{"chat_id": self.chat_id, "user_id": self.user_id}, offline_bot
)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "chat_member"
assert transaction_partner.chat_id == self.chat_id
assert transaction_partner.user_id == self.user_id
def test_to_dict(self, bot_command_scope_chat_member):
assert bot_command_scope_chat_member.to_dict() == {
"type": bot_command_scope_chat_member.type,
"chat_id": self.chat_id,
"user_id": self.user_id,
}
def test_equality(self, bot_command_scope_chat_member):
a = bot_command_scope_chat_member
b = BotCommandScopeChatMember(self.chat_id, self.user_id)
c = BotCommandScopeChatMember(self.chat_id + 1, self.user_id)
d = BotCommandScopeChatMember(self.chat_id, self.user_id + 1)
e = Dice(5, "test")
f = BotCommandScopeDefault()
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 a != f
assert hash(a) != hash(f)
@pytest.fixture
def bot_command_scope_default():
return BotCommandScopeDefault()
class TestBotCommandScopeDefaultWithoutRequest(BotCommandScopeTestBase):
type = BotCommandScopeType.DEFAULT
def test_slot_behaviour(self, bot_command_scope_default):
inst = bot_command_scope_default
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(self, offline_bot):
transaction_partner = BotCommandScopeDefault.de_json({}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "default"
def test_to_dict(self, bot_command_scope_default):
assert bot_command_scope_default.to_dict() == {"type": bot_command_scope_default.type}
def test_equality(self, bot_command_scope_default):
a = bot_command_scope_default
b = BotCommandScopeDefault()
c = Dice(5, "test")
d = BotCommandScopeChatMember(123, 456)
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 c != g
assert hash(c) != hash(g)
+5 -33
View File
@@ -610,9 +610,9 @@ class TestChatWithoutRequest(ChatTestBase):
"title",
"description",
"payload",
"provider_token",
"currency",
"prices",
"provider_token",
)
async def test_instance_method_send_location(self, monkeypatch, chat):
@@ -1312,7 +1312,7 @@ class TestChatWithoutRequest(ChatTestBase):
)
async def test_instance_method_send_gift(self, monkeypatch, chat):
async def make_assertion_private(*_, **kwargs):
async def make_assertion(*_, **kwargs):
return (
kwargs["user_id"] == chat.id
and kwargs["gift_id"] == "gift_id"
@@ -1321,39 +1321,11 @@ class TestChatWithoutRequest(ChatTestBase):
and kwargs["text_entities"] == "text_entities"
)
async def make_assertion_channel(*_, **kwargs):
return (
kwargs["chat_id"] == chat.id
and kwargs["gift_id"] == "gift_id"
and kwargs["text"] == "text"
and kwargs["text_parse_mode"] == "text_parse_mode"
and kwargs["text_entities"] == "text_entities"
)
# TODO discuss if better way exists
# tags: deprecated 21.11
with pytest.raises(
Exception,
match="Default for argument gift_id does not match the default of the Bot method.",
):
assert check_shortcut_signature(
Chat.send_gift, Bot.send_gift, ["user_id", "chat_id"], []
)
assert await check_shortcut_call(
chat.send_gift, chat.get_bot(), "send_gift", ["user_id", "chat_id"]
)
assert check_shortcut_signature(Chat.send_gift, Bot.send_gift, ["user_id"], [])
assert await check_shortcut_call(chat.send_gift, chat.get_bot(), "send_gift")
assert await check_defaults_handling(chat.send_gift, chat.get_bot())
monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion_private)
chat.type = chat.PRIVATE
assert await chat.send_gift(
gift_id="gift_id",
text="text",
text_parse_mode="text_parse_mode",
text_entities="text_entities",
)
monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion_channel)
chat.type = chat.CHANNEL
monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion)
assert await chat.send_gift(
gift_id="gift_id",
text="text",
+250 -494
View File
@@ -16,6 +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 inspect
from copy import deepcopy
from typing import Union
import pytest
@@ -29,306 +32,204 @@ from telegram import (
BackgroundTypeFill,
BackgroundTypePattern,
BackgroundTypeWallpaper,
ChatBackground,
Dice,
Document,
)
from telegram.constants import BackgroundFillType, BackgroundTypeType
from tests.auxil.slots import mro_slots
@pytest.fixture
def background_fill():
return BackgroundFill(BackgroundFillTestBase.type)
ignored = ["self", "api_kwargs"]
class BackgroundFillTestBase:
type = BackgroundFill.SOLID
color = 42
top_color = 43
bottom_color = 44
class BFDefaults:
color = 0
top_color = 1
bottom_color = 2
rotation_angle = 45
colors = [46, 47, 48, 49]
colors = [0, 1, 2]
class TestBackgroundFillWithoutRequest(BackgroundFillTestBase):
def test_slots(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_type_enum_conversion(self, background_fill):
assert type(BackgroundFill("solid").type) is BackgroundFillType
assert BackgroundFill("unknown").type == "unknown"
def test_de_json(self, offline_bot):
data = {"type": "unknown"}
transaction_partner = BackgroundFill.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "unknown"
@pytest.mark.parametrize(
("bf_type", "subclass"),
[
("solid", BackgroundFillSolid),
("gradient", BackgroundFillGradient),
("freeform_gradient", BackgroundFillFreeformGradient),
],
)
def test_de_json_subclass(self, offline_bot, bf_type, subclass):
json_dict = {
"type": bf_type,
"color": self.color,
"top_color": self.top_color,
"bottom_color": self.bottom_color,
"rotation_angle": self.rotation_angle,
"colors": self.colors,
}
bf = BackgroundFill.de_json(json_dict, offline_bot)
assert type(bf) is subclass
assert set(bf.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"type"
}
assert bf.type == bf_type
def test_to_dict(self, background_fill):
assert background_fill.to_dict() == {"type": background_fill.type}
def test_equality(self, background_fill):
a = background_fill
b = BackgroundFill(self.type)
c = BackgroundFill("unknown")
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
def background_fill_solid():
return BackgroundFillSolid(BFDefaults.color)
@pytest.fixture
def background_fill_gradient():
return BackgroundFillGradient(
TestBackgroundFillGradientWithoutRequest.top_color,
TestBackgroundFillGradientWithoutRequest.bottom_color,
TestBackgroundFillGradientWithoutRequest.rotation_angle,
BFDefaults.top_color, BFDefaults.bottom_color, BFDefaults.rotation_angle
)
class TestBackgroundFillGradientWithoutRequest(BackgroundFillTestBase):
type = BackgroundFill.GRADIENT
def test_slots(self, background_fill_gradient):
inst = background_fill_gradient
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(self, offline_bot):
data = {
"top_color": self.top_color,
"bottom_color": self.bottom_color,
"rotation_angle": self.rotation_angle,
}
transaction_partner = BackgroundFillGradient.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "gradient"
def test_to_dict(self, background_fill_gradient):
assert background_fill_gradient.to_dict() == {
"type": background_fill_gradient.type,
"top_color": self.top_color,
"bottom_color": self.bottom_color,
"rotation_angle": self.rotation_angle,
}
def test_equality(self, background_fill_gradient):
a = background_fill_gradient
b = BackgroundFillGradient(
self.top_color,
self.bottom_color,
self.rotation_angle,
)
c = BackgroundFillGradient(
self.top_color + 1,
self.bottom_color + 1,
self.rotation_angle + 1,
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def background_fill_freeform_gradient():
return BackgroundFillFreeformGradient(
TestBackgroundFillFreeformGradientWithoutRequest.colors,
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,
)
class TestBackgroundFillFreeformGradientWithoutRequest(BackgroundFillTestBase):
type = BackgroundFill.FREEFORM_GRADIENT
def background_type_pattern():
return BackgroundTypePattern(
BTDefaults.document,
BTDefaults.fill,
BTDefaults.intensity,
BTDefaults.is_inverted,
BTDefaults.is_moving,
)
def test_slots(self, background_fill_freeform_gradient):
inst = background_fill_freeform_gradient
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(self, offline_bot):
data = {"colors": self.colors}
transaction_partner = BackgroundFillFreeformGradient.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "freeform_gradient"
def background_type_chat_theme():
return BackgroundTypeChatTheme(BTDefaults.theme_name)
def test_to_dict(self, background_fill_freeform_gradient):
assert background_fill_freeform_gradient.to_dict() == {
"type": background_fill_freeform_gradient.type,
"colors": self.colors,
}
def test_equality(self, background_fill_freeform_gradient):
a = background_fill_freeform_gradient
b = BackgroundFillFreeformGradient(self.colors)
c = BackgroundFillFreeformGradient([color + 1 for color in self.colors])
d = Dice(5, "test")
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__)
assert a == b
assert hash(a) == hash(b)
for param in sig.parameters.values():
if param.name in ignored: # ignore irrelevant params
continue
assert a != c
assert hash(a) != hash(c)
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
assert a != d
assert hash(a) != hash(d)
# 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_fill_solid():
return BackgroundFillSolid(TestBackgroundFillSolidWithoutRequest.color)
def background_type(request):
return request.param()
class TestBackgroundFillSolidWithoutRequest(BackgroundFillTestBase):
type = BackgroundFill.SOLID
def test_slots(self, background_fill_solid):
inst = background_fill_solid
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(self, offline_bot):
data = {"color": self.color}
transaction_partner = BackgroundFillSolid.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "solid"
def test_to_dict(self, background_fill_solid):
assert background_fill_solid.to_dict() == {
"type": background_fill_solid.type,
"color": self.color,
}
def test_equality(self, background_fill_solid):
a = background_fill_solid
b = BackgroundFillSolid(self.color)
c = BackgroundFillSolid(self.color + 1)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def background_type():
return BackgroundType(BackgroundTypeTestBase.type)
class BackgroundTypeTestBase:
type = BackgroundType.WALLPAPER
fill = BackgroundFillSolid(42)
dark_theme_dimming = 43
document = Document("file_id", "file_unique_id", "file_name", 42)
is_blurred = True
is_moving = True
intensity = 45
is_inverted = True
theme_name = "test theme name"
class TestBackgroundTypeWithoutRequest(BackgroundTypeTestBase):
def test_slots(self, background_type):
@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_type_enum_conversion(self, background_type):
assert type(BackgroundType("wallpaper").type) is BackgroundTypeType
assert BackgroundType("unknown").type == "unknown"
def test_de_json_required_args(self, offline_bot, background_type):
cls = background_type.__class__
def test_de_json(self, offline_bot):
data = {"type": "unknown"}
transaction_partner = BackgroundType.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "unknown"
json_dict = make_json_dict(background_type)
const_background_type = BackgroundType.de_json(json_dict, offline_bot)
assert const_background_type.api_kwargs == {}
@pytest.mark.parametrize(
("bt_type", "subclass"),
[
("wallpaper", BackgroundTypeWallpaper),
("fill", BackgroundTypeFill),
("pattern", BackgroundTypePattern),
("chat_theme", BackgroundTypeChatTheme),
],
)
def test_de_json_subclass(self, offline_bot, bt_type, subclass):
json_dict = {
"type": bt_type,
"fill": self.fill.to_dict(),
"dark_theme_dimming": self.dark_theme_dimming,
"document": self.document.to_dict(),
"is_blurred": self.is_blurred,
"is_moving": self.is_moving,
"intensity": self.intensity,
"is_inverted": self.is_inverted,
"theme_name": self.theme_name,
}
bt = BackgroundType.de_json(json_dict, offline_bot)
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
assert type(bt) is subclass
assert set(bt.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"type"
}
assert bt.type == bt_type
def test_de_json_all_args(self, offline_bot, background_type):
json_dict = make_json_dict(background_type, include_optional_args=True)
const_background_type = BackgroundType.de_json(json_dict, offline_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, offline_bot):
json_dict = {"type": "invalid", "theme_name": BTDefaults.theme_name}
background_type = BackgroundType.de_json(json_dict, offline_bot)
assert type(background_type) is BackgroundType
assert background_type.type == "invalid"
def test_de_json_subclass(self, background_type, offline_bot, chat_id):
"""This makes sure that e.g. BackgroundTypeFill(data, offline_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, offline_bot)) is cls
def test_to_dict(self, background_type):
assert background_type.to_dict() == {"type": background_type.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 = background_type
b = BackgroundType(self.type)
c = BackgroundType("unknown")
d = Dice(5, "test")
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)
@@ -339,42 +240,102 @@ class TestBackgroundTypeWithoutRequest(BackgroundTypeTestBase):
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_type_fill():
return BackgroundTypeFill(
fill=TestBackgroundTypeFillWithoutRequest.fill,
dark_theme_dimming=TestBackgroundTypeFillWithoutRequest.dark_theme_dimming,
)
def background_fill(request):
return request.param()
class TestBackgroundTypeFillWithoutRequest(BackgroundTypeTestBase):
type = BackgroundType.FILL
def test_slots(self, background_type_fill):
inst = background_type_fill
@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(self, offline_bot):
data = {"fill": self.fill.to_dict(), "dark_theme_dimming": self.dark_theme_dimming}
transaction_partner = BackgroundTypeFill.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "fill"
def test_de_json_required_args(self, offline_bot, background_fill):
cls = background_fill.__class__
def test_to_dict(self, background_type_fill):
assert background_type_fill.to_dict() == {
"type": background_type_fill.type,
"fill": self.fill.to_dict(),
"dark_theme_dimming": self.dark_theme_dimming,
}
json_dict = make_json_dict(background_fill)
const_background_fill = BackgroundFill.de_json(json_dict, offline_bot)
assert const_background_fill.api_kwargs == {}
def test_equality(self, background_type_fill):
a = background_type_fill
b = BackgroundTypeFill(self.fill, self.dark_theme_dimming)
c = BackgroundTypeFill(BackgroundFillSolid(43), 44)
d = Dice(5, "test")
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, offline_bot, background_fill):
json_dict = make_json_dict(background_fill, include_optional_args=True)
const_background_fill = BackgroundFill.de_json(json_dict, offline_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, offline_bot):
json_dict = {"type": "invalid", "theme_name": BTDefaults.theme_name}
background_fill = BackgroundFill.de_json(json_dict, offline_bot)
assert type(background_fill) is BackgroundFill
assert background_fill.type == "invalid"
def test_de_json_subclass(self, background_fill, offline_bot):
"""This makes sure that e.g. BackgroundFillSolid(data, offline_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, offline_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)
@@ -385,219 +346,14 @@ class TestBackgroundTypeFillWithoutRequest(BackgroundTypeTestBase):
assert a != d
assert hash(a) != hash(d)
assert a != e
assert hash(a) != hash(e)
@pytest.fixture
def background_type_pattern():
return BackgroundTypePattern(
TestBackgroundTypePatternWithoutRequest.document,
TestBackgroundTypePatternWithoutRequest.fill,
TestBackgroundTypePatternWithoutRequest.intensity,
TestBackgroundTypePatternWithoutRequest.is_inverted,
TestBackgroundTypePatternWithoutRequest.is_moving,
)
assert c == d
assert hash(c) == hash(d)
assert c != e
assert hash(c) != hash(e)
class TestBackgroundTypePatternWithoutRequest(BackgroundTypeTestBase):
type = BackgroundType.PATTERN
def test_slots(self, background_type_pattern):
inst = background_type_pattern
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(self, offline_bot):
data = {
"document": self.document.to_dict(),
"fill": self.fill.to_dict(),
"intensity": self.intensity,
"is_inverted": self.is_inverted,
"is_moving": self.is_moving,
}
transaction_partner = BackgroundTypePattern.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "pattern"
def test_to_dict(self, background_type_pattern):
assert background_type_pattern.to_dict() == {
"type": background_type_pattern.type,
"document": self.document.to_dict(),
"fill": self.fill.to_dict(),
"intensity": self.intensity,
"is_inverted": self.is_inverted,
"is_moving": self.is_moving,
}
def test_equality(self, background_type_pattern):
a = background_type_pattern
b = BackgroundTypePattern(
self.document,
self.fill,
self.intensity,
)
c = BackgroundTypePattern(
Document("other", "other", "file_name", 43),
False,
False,
44,
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def background_type_chat_theme():
return BackgroundTypeChatTheme(
TestBackgroundTypeChatThemeWithoutRequest.theme_name,
)
class TestBackgroundTypeChatThemeWithoutRequest(BackgroundTypeTestBase):
type = BackgroundType.CHAT_THEME
def test_slots(self, background_type_chat_theme):
inst = background_type_chat_theme
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(self, offline_bot):
data = {"theme_name": self.theme_name}
transaction_partner = BackgroundTypeChatTheme.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "chat_theme"
def test_to_dict(self, background_type_chat_theme):
assert background_type_chat_theme.to_dict() == {
"type": background_type_chat_theme.type,
"theme_name": self.theme_name,
}
def test_equality(self, background_type_chat_theme):
a = background_type_chat_theme
b = BackgroundTypeChatTheme(self.theme_name)
c = BackgroundTypeChatTheme("other")
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def background_type_wallpaper():
return BackgroundTypeWallpaper(
TestBackgroundTypeWallpaperWithoutRequest.document,
TestBackgroundTypeWallpaperWithoutRequest.dark_theme_dimming,
TestBackgroundTypeWallpaperWithoutRequest.is_blurred,
TestBackgroundTypeWallpaperWithoutRequest.is_moving,
)
class TestBackgroundTypeWallpaperWithoutRequest(BackgroundTypeTestBase):
type = BackgroundType.WALLPAPER
def test_slots(self, background_type_wallpaper):
inst = background_type_wallpaper
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(self, offline_bot):
data = {
"document": self.document.to_dict(),
"dark_theme_dimming": self.dark_theme_dimming,
"is_blurred": self.is_blurred,
"is_moving": self.is_moving,
}
transaction_partner = BackgroundTypeWallpaper.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "wallpaper"
def test_to_dict(self, background_type_wallpaper):
assert background_type_wallpaper.to_dict() == {
"type": background_type_wallpaper.type,
"document": self.document.to_dict(),
"dark_theme_dimming": self.dark_theme_dimming,
"is_blurred": self.is_blurred,
"is_moving": self.is_moving,
}
def test_equality(self, background_type_wallpaper):
a = background_type_wallpaper
b = BackgroundTypeWallpaper(
self.document,
self.dark_theme_dimming,
self.is_blurred,
self.is_moving,
)
c = BackgroundTypeWallpaper(
Document("other", "other", "file_name", 43),
44,
False,
False,
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def chat_background():
return ChatBackground(ChatBackgroundTestBase.type)
class ChatBackgroundTestBase:
type = BackgroundTypeFill(BackgroundFillSolid(42), 43)
class TestChatBackgroundWithoutRequest(ChatBackgroundTestBase):
def test_slots(self, chat_background):
inst = chat_background
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(self, offline_bot):
data = {"type": self.type.to_dict()}
transaction_partner = ChatBackground.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == self.type
def test_to_dict(self, chat_background):
assert chat_background.to_dict() == {"type": chat_background.type.to_dict()}
def test_equality(self, chat_background):
a = chat_background
b = ChatBackground(self.type)
c = ChatBackground(BackgroundTypeFill(BackgroundFillSolid(43), 44))
d = Dice(5, "test")
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 f != c
assert hash(f) != hash(c)
+244 -263
View File
@@ -16,6 +16,8 @@
# along with this program. If not, see [http://www.gnu.org/licenses/].
import datetime as dtm
import inspect
from copy import deepcopy
import pytest
@@ -41,231 +43,27 @@ from tests.auxil.slots import mro_slots
class ChatBoostDefaults:
source = ChatBoostSource.PREMIUM
chat_id = 1
boost_id = "2"
giveaway_message_id = 3
is_unclaimed = False
chat = Chat(1, "group")
user = User(1, "user", False)
date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0)
date = to_timestamp(dtm.datetime.utcnow())
default_source = ChatBoostSourcePremium(user)
prize_star_count = 99
boost = ChatBoost(
boost_id=boost_id,
add_date=date,
expiration_date=date,
source=default_source,
)
@pytest.fixture(scope="module")
def chat_boost_source():
return ChatBoostSource(
source=ChatBoostDefaults.source,
def chat_boost_removed():
return ChatBoostRemoved(
chat=ChatBoostDefaults.chat,
boost_id=ChatBoostDefaults.boost_id,
remove_date=ChatBoostDefaults.date,
source=ChatBoostDefaults.default_source,
)
class TestChatBoostSourceWithoutRequest(ChatBoostDefaults):
def test_slot_behaviour(self, chat_boost_source):
inst = chat_boost_source
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_type_enum_conversion(self, chat_boost_source):
assert type(ChatBoostSource("premium").source) is ChatBoostSources
assert ChatBoostSource("unknown").source == "unknown"
def test_de_json(self, offline_bot):
json_dict = {
"source": "unknown",
}
cbs = ChatBoostSource.de_json(json_dict, offline_bot)
assert cbs.api_kwargs == {}
assert cbs.source == "unknown"
@pytest.mark.parametrize(
("cb_source", "subclass"),
[
("premium", ChatBoostSourcePremium),
("gift_code", ChatBoostSourceGiftCode),
("giveaway", ChatBoostSourceGiveaway),
],
)
def test_de_json_subclass(self, offline_bot, cb_source, subclass):
json_dict = {
"source": cb_source,
"user": ChatBoostDefaults.user.to_dict(),
"giveaway_message_id": ChatBoostDefaults.giveaway_message_id,
}
cbs = ChatBoostSource.de_json(json_dict, offline_bot)
assert type(cbs) is subclass
assert set(cbs.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"source"
}
assert cbs.source == cb_source
def test_to_dict(self, chat_boost_source):
chat_boost_source_dict = chat_boost_source.to_dict()
assert isinstance(chat_boost_source_dict, dict)
assert chat_boost_source_dict["source"] == chat_boost_source.source
def test_equality(self, chat_boost_source):
a = chat_boost_source
b = ChatBoostSource(source=ChatBoostDefaults.source)
c = ChatBoostSource(source="unknown")
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture(scope="module")
def chat_boost_source_premium():
return ChatBoostSourcePremium(
user=TestChatBoostSourcePremiumWithoutRequest.user,
)
class TestChatBoostSourcePremiumWithoutRequest(ChatBoostDefaults):
source = ChatBoostSources.PREMIUM
def test_slot_behaviour(self, chat_boost_source_premium):
inst = chat_boost_source_premium
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(self, offline_bot):
json_dict = {
"user": self.user.to_dict(),
}
cbsp = ChatBoostSourcePremium.de_json(json_dict, offline_bot)
assert cbsp.api_kwargs == {}
assert cbsp.user == self.user
def test_to_dict(self, chat_boost_source_premium):
chat_boost_source_premium_dict = chat_boost_source_premium.to_dict()
assert isinstance(chat_boost_source_premium_dict, dict)
assert chat_boost_source_premium_dict["source"] == self.source
assert chat_boost_source_premium_dict["user"] == self.user.to_dict()
def test_equality(self, chat_boost_source_premium):
a = chat_boost_source_premium
b = ChatBoostSourcePremium(user=self.user)
c = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
@pytest.fixture(scope="module")
def chat_boost_source_gift_code():
return ChatBoostSourceGiftCode(
user=TestChatBoostSourceGiftCodeWithoutRequest.user,
)
class TestChatBoostSourceGiftCodeWithoutRequest(ChatBoostDefaults):
source = ChatBoostSources.GIFT_CODE
def test_slot_behaviour(self, chat_boost_source_gift_code):
inst = chat_boost_source_gift_code
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(self, offline_bot):
json_dict = {
"user": self.user.to_dict(),
}
cbsgc = ChatBoostSourceGiftCode.de_json(json_dict, offline_bot)
assert cbsgc.api_kwargs == {}
assert cbsgc.user == self.user
def test_to_dict(self, chat_boost_source_gift_code):
chat_boost_source_gift_code_dict = chat_boost_source_gift_code.to_dict()
assert isinstance(chat_boost_source_gift_code_dict, dict)
assert chat_boost_source_gift_code_dict["source"] == self.source
assert chat_boost_source_gift_code_dict["user"] == self.user.to_dict()
def test_equality(self, chat_boost_source_gift_code):
a = chat_boost_source_gift_code
b = ChatBoostSourceGiftCode(user=self.user)
c = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
@pytest.fixture(scope="module")
def chat_boost_source_giveaway():
return ChatBoostSourceGiveaway(
user=TestChatBoostSourceGiveawayWithoutRequest.user,
giveaway_message_id=TestChatBoostSourceGiveawayWithoutRequest.giveaway_message_id,
)
class TestChatBoostSourceGiveawayWithoutRequest(ChatBoostDefaults):
source = ChatBoostSources.GIVEAWAY
def test_slot_behaviour(self, chat_boost_source_giveaway):
inst = chat_boost_source_giveaway
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(self, offline_bot):
json_dict = {
"user": self.user.to_dict(),
"giveaway_message_id": self.giveaway_message_id,
}
cbsg = ChatBoostSourceGiveaway.de_json(json_dict, offline_bot)
assert cbsg.api_kwargs == {}
assert cbsg.user == self.user
assert cbsg.giveaway_message_id == self.giveaway_message_id
def test_to_dict(self, chat_boost_source_giveaway):
chat_boost_source_giveaway_dict = chat_boost_source_giveaway.to_dict()
assert isinstance(chat_boost_source_giveaway_dict, dict)
assert chat_boost_source_giveaway_dict["source"] == self.source
assert chat_boost_source_giveaway_dict["user"] == self.user.to_dict()
assert chat_boost_source_giveaway_dict["giveaway_message_id"] == self.giveaway_message_id
def test_equality(self, chat_boost_source_giveaway):
a = chat_boost_source_giveaway
b = ChatBoostSourceGiveaway(user=self.user, giveaway_message_id=self.giveaway_message_id)
c = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
@pytest.fixture(scope="module")
def chat_boost():
return ChatBoost(
@@ -276,6 +74,189 @@ def chat_boost():
)
@pytest.fixture(scope="module")
def chat_boost_updated(chat_boost):
return ChatBoostUpdated(
chat=ChatBoostDefaults.chat,
boost=chat_boost,
)
def chat_boost_source_gift_code():
return ChatBoostSourceGiftCode(
user=ChatBoostDefaults.user,
)
def chat_boost_source_giveaway():
return ChatBoostSourceGiveaway(
user=ChatBoostDefaults.user,
giveaway_message_id=ChatBoostDefaults.giveaway_message_id,
is_unclaimed=ChatBoostDefaults.is_unclaimed,
prize_star_count=ChatBoostDefaults.prize_star_count,
)
def chat_boost_source_premium():
return ChatBoostSourcePremium(
user=ChatBoostDefaults.user,
)
@pytest.fixture(scope="module")
def user_chat_boosts(chat_boost):
return UserChatBoosts(
boosts=[chat_boost],
)
@pytest.fixture
def chat_boost_source(request):
return request.param()
ignored = ["self", "api_kwargs"]
def make_json_dict(instance: ChatBoostSource, 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 = {"source": instance.source}
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)
if hasattr(val, "to_dict"): # convert the user object or any future ones to dict.
val = val.to_dict()
json_dict[param.name] = val
return json_dict
def iter_args(
instance: ChatBoostSource, de_json_inst: ChatBoostSource, 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.source, de_json_inst.source # 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 isinstance(json_at, dtm.datetime): # Convert dtm to int
json_at = to_timestamp(json_at)
if (
param.default is not inspect.Parameter.empty and include_optional
) or param.default is inspect.Parameter.empty:
yield inst_at, json_at
@pytest.mark.parametrize(
"chat_boost_source",
[
chat_boost_source_gift_code,
chat_boost_source_giveaway,
chat_boost_source_premium,
],
indirect=True,
)
class TestChatBoostSourceTypesWithoutRequest:
def test_slot_behaviour(self, chat_boost_source):
inst = chat_boost_source
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, offline_bot, chat_boost_source):
cls = chat_boost_source.__class__
json_dict = make_json_dict(chat_boost_source)
const_boost_source = ChatBoostSource.de_json(json_dict, offline_bot)
assert const_boost_source.api_kwargs == {}
assert isinstance(const_boost_source, ChatBoostSource)
assert isinstance(const_boost_source, cls)
for chat_mem_type_at, const_chat_mem_at in iter_args(
chat_boost_source, const_boost_source
):
assert chat_mem_type_at == const_chat_mem_at
def test_de_json_all_args(self, offline_bot, chat_boost_source):
json_dict = make_json_dict(chat_boost_source, include_optional_args=True)
const_boost_source = ChatBoostSource.de_json(json_dict, offline_bot)
assert const_boost_source.api_kwargs == {}
assert isinstance(const_boost_source, ChatBoostSource)
assert isinstance(const_boost_source, chat_boost_source.__class__)
for c_mem_type_at, const_c_mem_at in iter_args(
chat_boost_source, const_boost_source, True
):
assert c_mem_type_at == const_c_mem_at
def test_de_json_invalid_source(self, chat_boost_source, offline_bot):
json_dict = {"source": "invalid"}
chat_boost_source = ChatBoostSource.de_json(json_dict, offline_bot)
assert type(chat_boost_source) is ChatBoostSource
assert chat_boost_source.source == "invalid"
def test_de_json_subclass(self, chat_boost_source, offline_bot):
"""This makes sure that e.g. ChatBoostSourcePremium(data, offline_bot) never returns a
ChatBoostSourceGiftCode instance."""
cls = chat_boost_source.__class__
json_dict = make_json_dict(chat_boost_source, True)
assert type(cls.de_json(json_dict, offline_bot)) is cls
def test_to_dict(self, chat_boost_source):
chat_boost_dict = chat_boost_source.to_dict()
assert isinstance(chat_boost_dict, dict)
assert chat_boost_dict["source"] == chat_boost_source.source
assert chat_boost_dict["user"] == chat_boost_source.user.to_dict()
for slot in chat_boost_source.__slots__: # additional verification for the optional args
if slot == "user": # we already test "user" above:
continue
assert getattr(chat_boost_source, slot) == chat_boost_dict[slot]
def test_equality(self, chat_boost_source):
a = ChatBoostSource(source="status")
b = ChatBoostSource(source="status")
c = chat_boost_source
d = deepcopy(chat_boost_source)
e = Dice(4, "emoji")
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)
def test_enum_init(self, chat_boost_source):
cbs = ChatBoostSource(source="foo")
assert cbs.source == "foo"
cbs = ChatBoostSource(source="premium")
assert cbs.source == ChatBoostSources.PREMIUM
class TestChatBoostWithoutRequest(ChatBoostDefaults):
def test_slot_behaviour(self, chat_boost):
inst = chat_boost
@@ -285,24 +266,30 @@ class TestChatBoostWithoutRequest(ChatBoostDefaults):
def test_de_json(self, offline_bot, chat_boost):
json_dict = {
"boost_id": self.boost_id,
"add_date": to_timestamp(self.date),
"expiration_date": to_timestamp(self.date),
"boost_id": "2",
"add_date": self.date,
"expiration_date": self.date,
"source": self.default_source.to_dict(),
}
cb = ChatBoost.de_json(json_dict, offline_bot)
assert cb.api_kwargs == {}
assert cb.boost_id == self.boost_id
assert (cb.add_date) == self.date
assert (cb.expiration_date) == self.date
assert cb.source == self.default_source
assert isinstance(cb, ChatBoost)
assert isinstance(cb.add_date, dtm.datetime)
assert isinstance(cb.expiration_date, dtm.datetime)
assert isinstance(cb.source, ChatBoostSource)
with cb._unfrozen():
cb.add_date = to_timestamp(cb.add_date)
cb.expiration_date = to_timestamp(cb.expiration_date)
# We don't compare cbu.boost to self.boost because we have to update the _id_attrs (sigh)
for slot in cb.__slots__:
assert getattr(cb, slot) == getattr(chat_boost, slot), f"attribute {slot} differs"
def test_de_json_localization(self, offline_bot, raw_bot, tz_bot):
json_dict = {
"boost_id": "2",
"add_date": to_timestamp(self.date),
"expiration_date": to_timestamp(self.date),
"add_date": self.date,
"expiration_date": self.date,
"source": self.default_source.to_dict(),
}
@@ -323,8 +310,8 @@ class TestChatBoostWithoutRequest(ChatBoostDefaults):
assert isinstance(chat_boost_dict, dict)
assert chat_boost_dict["boost_id"] == chat_boost.boost_id
assert chat_boost_dict["add_date"] == to_timestamp(chat_boost.add_date)
assert chat_boost_dict["expiration_date"] == to_timestamp(chat_boost.expiration_date)
assert chat_boost_dict["add_date"] == chat_boost.add_date
assert chat_boost_dict["expiration_date"] == chat_boost.expiration_date
assert chat_boost_dict["source"] == chat_boost.source.to_dict()
def test_equality(self):
@@ -354,14 +341,6 @@ class TestChatBoostWithoutRequest(ChatBoostDefaults):
assert hash(a) != hash(c)
@pytest.fixture(scope="module")
def chat_boost_updated(chat_boost):
return ChatBoostUpdated(
chat=ChatBoostDefaults.chat,
boost=chat_boost,
)
class TestChatBoostUpdatedWithoutRequest(ChatBoostDefaults):
def test_slot_behaviour(self, chat_boost_updated):
inst = chat_boost_updated
@@ -372,13 +351,25 @@ class TestChatBoostUpdatedWithoutRequest(ChatBoostDefaults):
def test_de_json(self, offline_bot, chat_boost):
json_dict = {
"chat": self.chat.to_dict(),
"boost": self.boost.to_dict(),
"boost": {
"boost_id": "2",
"add_date": self.date,
"expiration_date": self.date,
"source": self.default_source.to_dict(),
},
}
cbu = ChatBoostUpdated.de_json(json_dict, offline_bot)
assert cbu.api_kwargs == {}
assert isinstance(cbu, ChatBoostUpdated)
assert cbu.chat == self.chat
assert cbu.boost == self.boost
# We don't compare cbu.boost to chat_boost because we have to update the _id_attrs (sigh)
with cbu.boost._unfrozen():
cbu.boost.add_date = to_timestamp(cbu.boost.add_date)
cbu.boost.expiration_date = to_timestamp(cbu.boost.expiration_date)
for slot in cbu.boost.__slots__: # Assumes _id_attrs are same as slots
assert getattr(cbu.boost, slot) == getattr(chat_boost, slot), f"attr {slot} differs"
# no need to test localization since that is already tested in the above class.
def test_to_dict(self, chat_boost_updated):
chat_boost_updated_dict = chat_boost_updated.to_dict()
@@ -423,16 +414,6 @@ class TestChatBoostUpdatedWithoutRequest(ChatBoostDefaults):
assert hash(a) != hash(c)
@pytest.fixture(scope="module")
def chat_boost_removed():
return ChatBoostRemoved(
chat=ChatBoostDefaults.chat,
boost_id=ChatBoostDefaults.boost_id,
remove_date=ChatBoostDefaults.date,
source=ChatBoostDefaults.default_source,
)
class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults):
def test_slot_behaviour(self, chat_boost_removed):
inst = chat_boost_removed
@@ -443,23 +424,23 @@ class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults):
def test_de_json(self, offline_bot, chat_boost_removed):
json_dict = {
"chat": self.chat.to_dict(),
"boost_id": self.boost_id,
"remove_date": to_timestamp(self.date),
"boost_id": "2",
"remove_date": self.date,
"source": self.default_source.to_dict(),
}
cbr = ChatBoostRemoved.de_json(json_dict, offline_bot)
assert cbr.api_kwargs == {}
assert isinstance(cbr, ChatBoostRemoved)
assert cbr.chat == self.chat
assert cbr.boost_id == self.boost_id
assert cbr.remove_date == self.date
assert to_timestamp(cbr.remove_date) == self.date
assert cbr.source == self.default_source
def test_de_json_localization(self, offline_bot, raw_bot, tz_bot):
json_dict = {
"chat": self.chat.to_dict(),
"boost_id": self.boost_id,
"remove_date": to_timestamp(self.date),
"boost_id": "2",
"remove_date": self.date,
"source": self.default_source.to_dict(),
}
@@ -481,9 +462,7 @@ class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults):
assert isinstance(chat_boost_removed_dict, dict)
assert chat_boost_removed_dict["chat"] == chat_boost_removed.chat.to_dict()
assert chat_boost_removed_dict["boost_id"] == chat_boost_removed.boost_id
assert chat_boost_removed_dict["remove_date"] == to_timestamp(
chat_boost_removed.remove_date
)
assert chat_boost_removed_dict["remove_date"] == chat_boost_removed.remove_date
assert chat_boost_removed_dict["source"] == chat_boost_removed.source.to_dict()
def test_equality(self):
@@ -513,13 +492,6 @@ class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults):
assert hash(a) != hash(c)
@pytest.fixture(scope="module")
def user_chat_boosts(chat_boost):
return UserChatBoosts(
boosts=[chat_boost],
)
class TestUserChatBoostsWithoutRequest(ChatBoostDefaults):
def test_slot_behaviour(self, user_chat_boosts):
inst = user_chat_boosts
@@ -530,13 +502,22 @@ class TestUserChatBoostsWithoutRequest(ChatBoostDefaults):
def test_de_json(self, offline_bot, user_chat_boosts):
json_dict = {
"boosts": [
self.boost.to_dict(),
{
"boost_id": "2",
"add_date": self.date,
"expiration_date": self.date,
"source": self.default_source.to_dict(),
}
]
}
ucb = UserChatBoosts.de_json(json_dict, offline_bot)
assert ucb.api_kwargs == {}
assert ucb.boosts[0] == self.boost
assert isinstance(ucb, UserChatBoosts)
assert isinstance(ucb.boosts[0], ChatBoost)
assert ucb.boosts[0].boost_id == self.boost_id
assert to_timestamp(ucb.boosts[0].add_date) == self.date
assert to_timestamp(ucb.boosts[0].expiration_date) == self.date
assert ucb.boosts[0].source == self.default_source
def test_to_dict(self, user_chat_boosts):
user_chat_boosts_dict = user_chat_boosts.to_dict()
+239 -623
View File
@@ -34,107 +34,257 @@ from telegram import (
User,
)
from telegram._utils.datetime import UTC, to_timestamp
from telegram.constants import ChatMemberStatus
from tests.auxil.slots import mro_slots
ignored = ["self", "api_kwargs"]
class CMDefaults:
user = User(1, "First name", False)
custom_title: str = "PTB"
is_anonymous: bool = True
until_date: dtm.datetime = to_timestamp(dtm.datetime.utcnow())
can_be_edited: bool = False
can_change_info: bool = True
can_post_messages: bool = True
can_edit_messages: bool = True
can_delete_messages: bool = True
can_invite_users: bool = True
can_restrict_members: bool = True
can_pin_messages: bool = True
can_promote_members: bool = True
can_send_messages: bool = True
can_send_media_messages: bool = True
can_send_polls: bool = True
can_send_other_messages: bool = True
can_add_web_page_previews: bool = True
is_member: bool = True
can_manage_chat: bool = True
can_manage_video_chats: bool = True
can_manage_topics: bool = True
can_send_audios: bool = True
can_send_documents: bool = True
can_send_photos: bool = True
can_send_videos: bool = True
can_send_video_notes: bool = True
can_send_voice_notes: bool = True
can_post_stories: bool = True
can_edit_stories: bool = True
can_delete_stories: bool = True
def chat_member_owner():
return ChatMemberOwner(CMDefaults.user, CMDefaults.is_anonymous, CMDefaults.custom_title)
def chat_member_administrator():
return ChatMemberAdministrator(
CMDefaults.user,
CMDefaults.can_be_edited,
CMDefaults.is_anonymous,
CMDefaults.can_manage_chat,
CMDefaults.can_delete_messages,
CMDefaults.can_manage_video_chats,
CMDefaults.can_restrict_members,
CMDefaults.can_promote_members,
CMDefaults.can_change_info,
CMDefaults.can_invite_users,
CMDefaults.can_post_stories,
CMDefaults.can_edit_stories,
CMDefaults.can_delete_stories,
CMDefaults.can_post_messages,
CMDefaults.can_edit_messages,
CMDefaults.can_pin_messages,
CMDefaults.can_manage_topics,
CMDefaults.custom_title,
)
def chat_member_member():
return ChatMemberMember(CMDefaults.user, until_date=CMDefaults.until_date)
def chat_member_restricted():
return ChatMemberRestricted(
CMDefaults.user,
CMDefaults.is_member,
CMDefaults.can_change_info,
CMDefaults.can_invite_users,
CMDefaults.can_pin_messages,
CMDefaults.can_send_messages,
CMDefaults.can_send_polls,
CMDefaults.can_send_other_messages,
CMDefaults.can_add_web_page_previews,
CMDefaults.can_manage_topics,
CMDefaults.until_date,
CMDefaults.can_send_audios,
CMDefaults.can_send_documents,
CMDefaults.can_send_photos,
CMDefaults.can_send_videos,
CMDefaults.can_send_video_notes,
CMDefaults.can_send_voice_notes,
)
def chat_member_left():
return ChatMemberLeft(CMDefaults.user)
def chat_member_banned():
return ChatMemberBanned(CMDefaults.user, CMDefaults.until_date)
def make_json_dict(instance: ChatMember, 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 = {"status": instance.status}
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)
# or if the param is optional but for backwards compatability
elif (
param.default is not inspect.Parameter.empty and include_optional_args
) or param.name in ["can_delete_stories", "can_post_stories", "can_edit_stories"]:
json_dict[param.name] = val
return json_dict
def iter_args(instance: ChatMember, de_json_inst: ChatMember, 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.status, de_json_inst.status # 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 isinstance(json_at, dtm.datetime): # Convert dtm to int
json_at = to_timestamp(json_at)
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 chat_member():
return ChatMember(ChatMemberTestBase.user, ChatMemberTestBase.status)
def chat_member_type(request):
return request.param()
class ChatMemberTestBase:
status = ChatMemberStatus.MEMBER
user = User(1, "test_user", is_bot=False)
is_anonymous = True
custom_title = "test_title"
can_be_edited = True
can_manage_chat = True
can_delete_messages = True
can_manage_video_chats = True
can_restrict_members = True
can_promote_members = True
can_change_info = True
can_invite_users = True
can_post_messages = True
can_edit_messages = True
can_pin_messages = True
can_post_stories = True
can_edit_stories = True
can_delete_stories = True
can_manage_topics = True
until_date = dtm.datetime.now(UTC).replace(microsecond=0)
can_send_polls = True
can_send_other_messages = True
can_add_web_page_previews = True
can_send_audios = True
can_send_documents = True
can_send_photos = True
can_send_videos = True
can_send_video_notes = True
can_send_voice_notes = True
can_send_messages = True
is_member = True
class TestChatMemberWithoutRequest(ChatMemberTestBase):
def test_slot_behaviour(self, chat_member):
inst = chat_member
@pytest.mark.parametrize(
"chat_member_type",
[
chat_member_owner,
chat_member_administrator,
chat_member_member,
chat_member_restricted,
chat_member_left,
chat_member_banned,
],
indirect=True,
)
class TestChatMemberTypesWithoutRequest:
def test_slot_behaviour(self, chat_member_type):
inst = chat_member_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_status_enum_conversion(self, chat_member):
assert type(ChatMember(ChatMemberTestBase.user, "member").status) is ChatMemberStatus
assert ChatMember(ChatMemberTestBase.user, "unknown").status == "unknown"
def test_de_json_required_args(self, offline_bot, chat_member_type):
cls = chat_member_type.__class__
def test_de_json(self, offline_bot):
data = {"status": "unknown", "user": self.user.to_dict()}
chat_member = ChatMember.de_json(data, offline_bot)
assert chat_member.api_kwargs == {}
assert chat_member.status == "unknown"
assert chat_member.user == self.user
json_dict = make_json_dict(chat_member_type)
const_chat_member = ChatMember.de_json(json_dict, offline_bot)
assert const_chat_member.api_kwargs == {}
@pytest.mark.parametrize(
("status", "subclass"),
[
("administrator", ChatMemberAdministrator),
("kicked", ChatMemberBanned),
("left", ChatMemberLeft),
("member", ChatMemberMember),
("creator", ChatMemberOwner),
("restricted", ChatMemberRestricted),
],
)
def test_de_json_subclass(self, offline_bot, status, subclass):
json_dict = {
"status": status,
"user": self.user.to_dict(),
"is_anonymous": self.is_anonymous,
"is_member": self.is_member,
"until_date": to_timestamp(self.until_date),
**{name: value for name, value in inspect.getmembers(self) if name.startswith("can_")},
}
chat_member = ChatMember.de_json(json_dict, offline_bot)
assert isinstance(const_chat_member, ChatMember)
assert isinstance(const_chat_member, cls)
for chat_mem_type_at, const_chat_mem_at in iter_args(chat_member_type, const_chat_member):
assert chat_mem_type_at == const_chat_mem_at
assert type(chat_member) is subclass
assert set(chat_member.api_kwargs.keys()) == set(json_dict.keys()) - set(
subclass.__slots__
) - {"status", "user"}
assert chat_member.user == self.user
def test_de_json_all_args(self, offline_bot, chat_member_type):
json_dict = make_json_dict(chat_member_type, include_optional_args=True)
const_chat_member = ChatMember.de_json(json_dict, offline_bot)
assert const_chat_member.api_kwargs == {}
def test_to_dict(self, chat_member):
assert chat_member.to_dict() == {
"status": chat_member.status,
"user": chat_member.user.to_dict(),
assert isinstance(const_chat_member, ChatMember)
assert isinstance(const_chat_member, chat_member_type.__class__)
for c_mem_type_at, const_c_mem_at in iter_args(chat_member_type, const_chat_member, True):
assert c_mem_type_at == const_c_mem_at
def test_de_json_chatmemberbanned_localization(
self, chat_member_type, tz_bot, offline_bot, raw_bot
):
# We only test two classes because the other three don't have datetimes in them.
if isinstance(
chat_member_type, (ChatMemberBanned, ChatMemberRestricted, ChatMemberMember)
):
json_dict = make_json_dict(chat_member_type, include_optional_args=True)
chatmember_raw = ChatMember.de_json(json_dict, raw_bot)
chatmember_bot = ChatMember.de_json(json_dict, offline_bot)
chatmember_tz = ChatMember.de_json(json_dict, tz_bot)
# comparing utcoffsets because comparing timezones is unpredicatable
chatmember_offset = chatmember_tz.until_date.utcoffset()
tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
chatmember_tz.until_date.replace(tzinfo=None)
)
assert chatmember_raw.until_date.tzinfo == UTC
assert chatmember_bot.until_date.tzinfo == UTC
assert chatmember_offset == tz_bot_offset
def test_de_json_invalid_status(self, chat_member_type, offline_bot):
json_dict = {"status": "invalid", "user": CMDefaults.user.to_dict()}
chat_member_type = ChatMember.de_json(json_dict, offline_bot)
assert type(chat_member_type) is ChatMember
assert chat_member_type.status == "invalid"
def test_de_json_subclass(self, chat_member_type, offline_bot, chat_id):
"""This makes sure that e.g. ChatMemberAdministrator(data, offline_bot) never returns a
ChatMemberBanned instance."""
cls = chat_member_type.__class__
json_dict = make_json_dict(chat_member_type, True)
assert type(cls.de_json(json_dict, offline_bot)) is cls
def test_to_dict(self, chat_member_type):
chat_member_dict = chat_member_type.to_dict()
assert isinstance(chat_member_dict, dict)
assert chat_member_dict["status"] == chat_member_type.status
assert chat_member_dict["user"] == chat_member_type.user.to_dict()
for slot in chat_member_type.__slots__: # additional verification for the optional args
assert getattr(chat_member_type, slot) == chat_member_dict[slot]
def test_chat_member_restricted_api_kwargs(self, chat_member_type):
json_dict = make_json_dict(chat_member_restricted())
json_dict["can_send_media_messages"] = "can_send_media_messages"
chat_member_restricted_instance = ChatMember.de_json(json_dict, None)
assert chat_member_restricted_instance.api_kwargs == {
"can_send_media_messages": "can_send_media_messages",
}
def test_equality(self, chat_member):
a = chat_member
b = ChatMember(self.user, self.status)
c = ChatMember(self.user, "unknown")
d = ChatMember(User(2, "test_bot", is_bot=True), self.status)
e = Dice(5, "test")
def test_equality(self, chat_member_type):
a = ChatMember(status="status", user=CMDefaults.user)
b = ChatMember(status="status", user=CMDefaults.user)
c = chat_member_type
d = deepcopy(chat_member_type)
e = Dice(4, "emoji")
assert a == b
assert hash(a) == hash(b)
@@ -148,542 +298,8 @@ class TestChatMemberWithoutRequest(ChatMemberTestBase):
assert a != e
assert hash(a) != hash(e)
assert c == d
assert hash(c) == hash(d)
@pytest.fixture
def chat_member_administrator():
return ChatMemberAdministrator(
TestChatMemberAdministratorWithoutRequest.user,
TestChatMemberAdministratorWithoutRequest.can_be_edited,
TestChatMemberAdministratorWithoutRequest.can_change_info,
TestChatMemberAdministratorWithoutRequest.can_delete_messages,
TestChatMemberAdministratorWithoutRequest.can_delete_stories,
TestChatMemberAdministratorWithoutRequest.can_edit_messages,
TestChatMemberAdministratorWithoutRequest.can_edit_stories,
TestChatMemberAdministratorWithoutRequest.can_invite_users,
TestChatMemberAdministratorWithoutRequest.can_manage_chat,
TestChatMemberAdministratorWithoutRequest.can_manage_topics,
TestChatMemberAdministratorWithoutRequest.can_manage_video_chats,
TestChatMemberAdministratorWithoutRequest.can_pin_messages,
TestChatMemberAdministratorWithoutRequest.can_post_messages,
TestChatMemberAdministratorWithoutRequest.can_post_stories,
TestChatMemberAdministratorWithoutRequest.can_promote_members,
TestChatMemberAdministratorWithoutRequest.can_restrict_members,
TestChatMemberAdministratorWithoutRequest.custom_title,
TestChatMemberAdministratorWithoutRequest.is_anonymous,
)
class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase):
status = ChatMemberStatus.ADMINISTRATOR
def test_slot_behaviour(self, chat_member_administrator):
inst = chat_member_administrator
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(self, offline_bot):
data = {
"user": self.user.to_dict(),
"can_be_edited": self.can_be_edited,
"can_change_info": self.can_change_info,
"can_delete_messages": self.can_delete_messages,
"can_delete_stories": self.can_delete_stories,
"can_edit_messages": self.can_edit_messages,
"can_edit_stories": self.can_edit_stories,
"can_invite_users": self.can_invite_users,
"can_manage_chat": self.can_manage_chat,
"can_manage_topics": self.can_manage_topics,
"can_manage_video_chats": self.can_manage_video_chats,
"can_pin_messages": self.can_pin_messages,
"can_post_messages": self.can_post_messages,
"can_post_stories": self.can_post_stories,
"can_promote_members": self.can_promote_members,
"can_restrict_members": self.can_restrict_members,
"custom_title": self.custom_title,
"is_anonymous": self.is_anonymous,
}
chat_member = ChatMemberAdministrator.de_json(data, offline_bot)
assert type(chat_member) is ChatMemberAdministrator
assert chat_member.api_kwargs == {}
assert chat_member.user == self.user
assert chat_member.can_be_edited == self.can_be_edited
assert chat_member.can_change_info == self.can_change_info
assert chat_member.can_delete_messages == self.can_delete_messages
assert chat_member.can_delete_stories == self.can_delete_stories
assert chat_member.can_edit_messages == self.can_edit_messages
assert chat_member.can_edit_stories == self.can_edit_stories
assert chat_member.can_invite_users == self.can_invite_users
assert chat_member.can_manage_chat == self.can_manage_chat
assert chat_member.can_manage_topics == self.can_manage_topics
assert chat_member.can_manage_video_chats == self.can_manage_video_chats
assert chat_member.can_pin_messages == self.can_pin_messages
assert chat_member.can_post_messages == self.can_post_messages
assert chat_member.can_post_stories == self.can_post_stories
assert chat_member.can_promote_members == self.can_promote_members
assert chat_member.can_restrict_members == self.can_restrict_members
assert chat_member.custom_title == self.custom_title
assert chat_member.is_anonymous == self.is_anonymous
def test_to_dict(self, chat_member_administrator):
assert chat_member_administrator.to_dict() == {
"status": chat_member_administrator.status,
"user": chat_member_administrator.user.to_dict(),
"can_be_edited": chat_member_administrator.can_be_edited,
"can_change_info": chat_member_administrator.can_change_info,
"can_delete_messages": chat_member_administrator.can_delete_messages,
"can_delete_stories": chat_member_administrator.can_delete_stories,
"can_edit_messages": chat_member_administrator.can_edit_messages,
"can_edit_stories": chat_member_administrator.can_edit_stories,
"can_invite_users": chat_member_administrator.can_invite_users,
"can_manage_chat": chat_member_administrator.can_manage_chat,
"can_manage_topics": chat_member_administrator.can_manage_topics,
"can_manage_video_chats": chat_member_administrator.can_manage_video_chats,
"can_pin_messages": chat_member_administrator.can_pin_messages,
"can_post_messages": chat_member_administrator.can_post_messages,
"can_post_stories": chat_member_administrator.can_post_stories,
"can_promote_members": chat_member_administrator.can_promote_members,
"can_restrict_members": chat_member_administrator.can_restrict_members,
"custom_title": chat_member_administrator.custom_title,
"is_anonymous": chat_member_administrator.is_anonymous,
}
def test_equality(self, chat_member_administrator):
a = chat_member_administrator
b = ChatMemberAdministrator(
User(1, "test_user", is_bot=False),
True,
True,
True,
True,
True,
True,
True,
True,
True,
True,
True,
True,
)
c = ChatMemberAdministrator(
User(1, "test_user", is_bot=False),
False,
False,
False,
False,
False,
False,
False,
False,
False,
False,
False,
False,
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a == c
assert hash(a) == hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def chat_member_banned():
return ChatMemberBanned(
TestChatMemberBannedWithoutRequest.user,
TestChatMemberBannedWithoutRequest.until_date,
)
class TestChatMemberBannedWithoutRequest(ChatMemberTestBase):
status = ChatMemberStatus.BANNED
def test_slot_behaviour(self, chat_member_banned):
inst = chat_member_banned
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(self, offline_bot):
data = {
"user": self.user.to_dict(),
"until_date": to_timestamp(self.until_date),
}
chat_member = ChatMemberBanned.de_json(data, offline_bot)
assert type(chat_member) is ChatMemberBanned
assert chat_member.api_kwargs == {}
assert chat_member.user == self.user
assert chat_member.until_date == self.until_date
def test_de_json_localization(self, tz_bot, offline_bot, raw_bot):
json_dict = {
"user": self.user.to_dict(),
"until_date": to_timestamp(self.until_date),
}
cmb_raw = ChatMemberBanned.de_json(json_dict, raw_bot)
cmb_bot = ChatMemberBanned.de_json(json_dict, offline_bot)
cmb_bot_tz = ChatMemberBanned.de_json(json_dict, tz_bot)
# comparing utcoffsets because comparing timezones is unpredicatable
cmb_bot_tz_offset = cmb_bot_tz.until_date.utcoffset()
tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
cmb_bot_tz.until_date.replace(tzinfo=None)
)
assert cmb_raw.until_date.tzinfo == UTC
assert cmb_bot.until_date.tzinfo == UTC
assert cmb_bot_tz_offset == tz_bot_offset
def test_to_dict(self, chat_member_banned):
assert chat_member_banned.to_dict() == {
"status": chat_member_banned.status,
"user": chat_member_banned.user.to_dict(),
"until_date": to_timestamp(chat_member_banned.until_date),
}
def test_equality(self, chat_member_banned):
a = chat_member_banned
b = ChatMemberBanned(
User(1, "test_user", is_bot=False), dtm.datetime.now(UTC).replace(microsecond=0)
)
c = ChatMemberBanned(
User(2, "test_bot", is_bot=True), dtm.datetime.now(UTC).replace(microsecond=0)
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def chat_member_left():
return ChatMemberLeft(TestChatMemberLeftWithoutRequest.user)
class TestChatMemberLeftWithoutRequest(ChatMemberTestBase):
status = ChatMemberStatus.LEFT
def test_slot_behaviour(self, chat_member_left):
inst = chat_member_left
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(self, offline_bot):
data = {"user": self.user.to_dict()}
chat_member = ChatMemberLeft.de_json(data, offline_bot)
assert type(chat_member) is ChatMemberLeft
assert chat_member.api_kwargs == {}
assert chat_member.user == self.user
def test_to_dict(self, chat_member_left):
assert chat_member_left.to_dict() == {
"status": chat_member_left.status,
"user": chat_member_left.user.to_dict(),
}
def test_equality(self, chat_member_left):
a = chat_member_left
b = ChatMemberLeft(User(1, "test_user", is_bot=False))
c = ChatMemberLeft(User(2, "test_bot", is_bot=True))
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def chat_member_member():
return ChatMemberMember(TestChatMemberMemberWithoutRequest.user)
class TestChatMemberMemberWithoutRequest(ChatMemberTestBase):
status = ChatMemberStatus.MEMBER
def test_slot_behaviour(self, chat_member_member):
inst = chat_member_member
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(self, offline_bot):
data = {"user": self.user.to_dict(), "until_date": to_timestamp(self.until_date)}
chat_member = ChatMemberMember.de_json(data, offline_bot)
assert type(chat_member) is ChatMemberMember
assert chat_member.api_kwargs == {}
assert chat_member.user == self.user
assert chat_member.until_date == self.until_date
def test_de_json_localization(self, tz_bot, offline_bot, raw_bot):
json_dict = {
"user": self.user.to_dict(),
"until_date": to_timestamp(self.until_date),
}
cmm_raw = ChatMemberMember.de_json(json_dict, raw_bot)
cmm_bot = ChatMemberMember.de_json(json_dict, offline_bot)
cmm_bot_tz = ChatMemberMember.de_json(json_dict, tz_bot)
# comparing utcoffsets because comparing timezones is unpredicatable
cmm_bot_tz_offset = cmm_bot_tz.until_date.utcoffset()
tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
cmm_bot_tz.until_date.replace(tzinfo=None)
)
assert cmm_raw.until_date.tzinfo == UTC
assert cmm_bot.until_date.tzinfo == UTC
assert cmm_bot_tz_offset == tz_bot_offset
def test_to_dict(self, chat_member_member):
assert chat_member_member.to_dict() == {
"status": chat_member_member.status,
"user": chat_member_member.user.to_dict(),
}
def test_equality(self, chat_member_member):
a = chat_member_member
b = ChatMemberMember(User(1, "test_user", is_bot=False))
c = ChatMemberMember(User(2, "test_bot", is_bot=True))
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def chat_member_owner():
return ChatMemberOwner(
TestChatMemberOwnerWithoutRequest.user,
TestChatMemberOwnerWithoutRequest.is_anonymous,
TestChatMemberOwnerWithoutRequest.custom_title,
)
class TestChatMemberOwnerWithoutRequest(ChatMemberTestBase):
status = ChatMemberStatus.OWNER
def test_slot_behaviour(self, chat_member_owner):
inst = chat_member_owner
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(self, offline_bot):
data = {
"user": self.user.to_dict(),
"is_anonymous": self.is_anonymous,
"custom_title": self.custom_title,
}
chat_member = ChatMemberOwner.de_json(data, offline_bot)
assert type(chat_member) is ChatMemberOwner
assert chat_member.api_kwargs == {}
assert chat_member.user == self.user
assert chat_member.is_anonymous == self.is_anonymous
assert chat_member.custom_title == self.custom_title
def test_to_dict(self, chat_member_owner):
assert chat_member_owner.to_dict() == {
"status": chat_member_owner.status,
"user": chat_member_owner.user.to_dict(),
"is_anonymous": chat_member_owner.is_anonymous,
"custom_title": chat_member_owner.custom_title,
}
def test_equality(self, chat_member_owner):
a = chat_member_owner
b = ChatMemberOwner(User(1, "test_user", is_bot=False), True, "test_title")
c = ChatMemberOwner(User(1, "test_user", is_bot=False), False, "test_title")
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a == c
assert hash(a) == hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def chat_member_restricted():
return ChatMemberRestricted(
user=TestChatMemberRestrictedWithoutRequest.user,
can_add_web_page_previews=TestChatMemberRestrictedWithoutRequest.can_add_web_page_previews,
can_change_info=TestChatMemberRestrictedWithoutRequest.can_change_info,
can_invite_users=TestChatMemberRestrictedWithoutRequest.can_invite_users,
can_manage_topics=TestChatMemberRestrictedWithoutRequest.can_manage_topics,
can_pin_messages=TestChatMemberRestrictedWithoutRequest.can_pin_messages,
can_send_audios=TestChatMemberRestrictedWithoutRequest.can_send_audios,
can_send_documents=TestChatMemberRestrictedWithoutRequest.can_send_documents,
can_send_messages=TestChatMemberRestrictedWithoutRequest.can_send_messages,
can_send_other_messages=TestChatMemberRestrictedWithoutRequest.can_send_other_messages,
can_send_photos=TestChatMemberRestrictedWithoutRequest.can_send_photos,
can_send_polls=TestChatMemberRestrictedWithoutRequest.can_send_polls,
can_send_video_notes=TestChatMemberRestrictedWithoutRequest.can_send_video_notes,
can_send_videos=TestChatMemberRestrictedWithoutRequest.can_send_videos,
can_send_voice_notes=TestChatMemberRestrictedWithoutRequest.can_send_voice_notes,
is_member=TestChatMemberRestrictedWithoutRequest.is_member,
until_date=TestChatMemberRestrictedWithoutRequest.until_date,
)
class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase):
status = ChatMemberStatus.RESTRICTED
def test_slot_behaviour(self, chat_member_restricted):
inst = chat_member_restricted
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(self, offline_bot):
data = {
"user": self.user.to_dict(),
"can_add_web_page_previews": self.can_add_web_page_previews,
"can_change_info": self.can_change_info,
"can_invite_users": self.can_invite_users,
"can_manage_topics": self.can_manage_topics,
"can_pin_messages": self.can_pin_messages,
"can_send_audios": self.can_send_audios,
"can_send_documents": self.can_send_documents,
"can_send_messages": self.can_send_messages,
"can_send_other_messages": self.can_send_other_messages,
"can_send_photos": self.can_send_photos,
"can_send_polls": self.can_send_polls,
"can_send_video_notes": self.can_send_video_notes,
"can_send_videos": self.can_send_videos,
"can_send_voice_notes": self.can_send_voice_notes,
"is_member": self.is_member,
"until_date": to_timestamp(self.until_date),
# legacy argument
"can_send_media_messages": False,
}
chat_member = ChatMemberRestricted.de_json(data, offline_bot)
assert type(chat_member) is ChatMemberRestricted
assert chat_member.api_kwargs == {"can_send_media_messages": False}
assert chat_member.user == self.user
assert chat_member.can_add_web_page_previews == self.can_add_web_page_previews
assert chat_member.can_change_info == self.can_change_info
assert chat_member.can_invite_users == self.can_invite_users
assert chat_member.can_manage_topics == self.can_manage_topics
assert chat_member.can_pin_messages == self.can_pin_messages
assert chat_member.can_send_audios == self.can_send_audios
assert chat_member.can_send_documents == self.can_send_documents
assert chat_member.can_send_messages == self.can_send_messages
assert chat_member.can_send_other_messages == self.can_send_other_messages
assert chat_member.can_send_photos == self.can_send_photos
assert chat_member.can_send_polls == self.can_send_polls
assert chat_member.can_send_video_notes == self.can_send_video_notes
assert chat_member.can_send_videos == self.can_send_videos
assert chat_member.can_send_voice_notes == self.can_send_voice_notes
assert chat_member.is_member == self.is_member
assert chat_member.until_date == self.until_date
def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, chat_member_restricted):
json_dict = chat_member_restricted.to_dict()
cmr_raw = ChatMemberRestricted.de_json(json_dict, raw_bot)
cmr_bot = ChatMemberRestricted.de_json(json_dict, offline_bot)
cmr_bot_tz = ChatMemberRestricted.de_json(json_dict, tz_bot)
# comparing utcoffsets because comparing timezones is unpredicatable
cmr_bot_tz_offset = cmr_bot_tz.until_date.utcoffset()
tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(
cmr_bot_tz.until_date.replace(tzinfo=None)
)
assert cmr_raw.until_date.tzinfo == UTC
assert cmr_bot.until_date.tzinfo == UTC
assert cmr_bot_tz_offset == tz_bot_offset
def test_to_dict(self, chat_member_restricted):
assert chat_member_restricted.to_dict() == {
"status": chat_member_restricted.status,
"user": chat_member_restricted.user.to_dict(),
"can_add_web_page_previews": chat_member_restricted.can_add_web_page_previews,
"can_change_info": chat_member_restricted.can_change_info,
"can_invite_users": chat_member_restricted.can_invite_users,
"can_manage_topics": chat_member_restricted.can_manage_topics,
"can_pin_messages": chat_member_restricted.can_pin_messages,
"can_send_audios": chat_member_restricted.can_send_audios,
"can_send_documents": chat_member_restricted.can_send_documents,
"can_send_messages": chat_member_restricted.can_send_messages,
"can_send_other_messages": chat_member_restricted.can_send_other_messages,
"can_send_photos": chat_member_restricted.can_send_photos,
"can_send_polls": chat_member_restricted.can_send_polls,
"can_send_video_notes": chat_member_restricted.can_send_video_notes,
"can_send_videos": chat_member_restricted.can_send_videos,
"can_send_voice_notes": chat_member_restricted.can_send_voice_notes,
"is_member": chat_member_restricted.is_member,
"until_date": to_timestamp(chat_member_restricted.until_date),
}
def test_equality(self, chat_member_restricted):
a = chat_member_restricted
b = deepcopy(chat_member_restricted)
c = ChatMemberRestricted(
User(1, "test_user", is_bot=False),
False,
False,
False,
False,
False,
False,
False,
False,
False,
self.until_date,
False,
False,
False,
False,
False,
False,
)
d = Dice(5, "test")
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 c != e
assert hash(c) != hash(e)
-37
View File
@@ -164,43 +164,6 @@ class TestGiftWithoutRequest(GiftTestBase):
pay_for_upgrade=True,
)
@pytest.mark.parametrize("id_name", ["user_id", "chat_id"])
async def test_send_gift_user_chat_id(self, offline_bot, gift, monkeypatch, id_name):
# Only here because we have to temporarily mark gift_id as optional.
# tags: deprecated 21.11
# We can't send actual gifts, so we just check that the correct parameters are passed
text_entities = [
MessageEntity(MessageEntity.TEXT_LINK, 0, 4, "url"),
MessageEntity(MessageEntity.BOLD, 5, 9),
]
async def make_assertion(url, request_data: RequestData, *args, **kwargs):
received_id = request_data.parameters[id_name] == id_name
gift_id = request_data.parameters["gift_id"] == "some_id"
text = request_data.parameters["text"] == "text"
text_parse_mode = request_data.parameters["text_parse_mode"] == "text_parse_mode"
tes = request_data.parameters["text_entities"] == [
me.to_dict() for me in text_entities
]
pay_for_upgrade = request_data.parameters["pay_for_upgrade"] is True
return received_id and gift_id and text and text_parse_mode and tes and pay_for_upgrade
monkeypatch.setattr(offline_bot.request, "post", make_assertion)
assert await offline_bot.send_gift(
gift_id=gift,
text="text",
text_parse_mode="text_parse_mode",
text_entities=text_entities,
pay_for_upgrade=True,
**{id_name: id_name},
)
async def test_send_gift_without_gift_id(self, offline_bot):
with pytest.raises(TypeError, match="Missing required argument `gift_id`."):
await offline_bot.send_gift()
@pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True)
@pytest.mark.parametrize(
("passed_value", "expected_value"),
+136 -167
View File
@@ -16,6 +16,8 @@
#
# 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 copy import deepcopy
import pytest
from telegram import (
@@ -30,189 +32,134 @@ from telegram.constants import MenuButtonType
from tests.auxil.slots import mro_slots
@pytest.fixture
def menu_button():
return MenuButton(MenuButtonTestBase.type)
@pytest.fixture(
scope="module",
params=[
MenuButton.DEFAULT,
MenuButton.WEB_APP,
MenuButton.COMMANDS,
],
)
def scope_type(request):
return request.param
@pytest.fixture(
scope="module",
params=[
MenuButtonDefault,
MenuButtonCommands,
MenuButtonWebApp,
],
ids=[
MenuButton.DEFAULT,
MenuButton.COMMANDS,
MenuButton.WEB_APP,
],
)
def scope_class(request):
return request.param
@pytest.fixture(
scope="module",
params=[
(MenuButtonDefault, MenuButton.DEFAULT),
(MenuButtonCommands, MenuButton.COMMANDS),
(MenuButtonWebApp, MenuButton.WEB_APP),
],
ids=[
MenuButton.DEFAULT,
MenuButton.COMMANDS,
MenuButton.WEB_APP,
],
)
def scope_class_and_type(request):
return request.param
@pytest.fixture(scope="module")
def menu_button(scope_class_and_type):
# We use de_json here so that we don't have to worry about which class gets which arguments
return scope_class_and_type[0].de_json(
{
"type": scope_class_and_type[1],
"text": MenuButtonTestBase.text,
"web_app": MenuButtonTestBase.web_app.to_dict(),
},
bot=None,
)
class MenuButtonTestBase:
type = MenuButtonType.DEFAULT
text = "this is a test string"
web_app = WebAppInfo(url="https://python-telegram-bot.org")
text = "button_text"
web_app = WebAppInfo(url="https://python-telegram-bot.org/web_app")
# All the scope types are very similar, so we test everything via parametrization
class TestMenuButtonWithoutRequest(MenuButtonTestBase):
def test_slot_behaviour(self, menu_button):
inst = menu_button
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"
for attr in menu_button.__slots__:
assert getattr(menu_button, attr, "err") != "err", f"got extra slot '{attr}'"
assert len(mro_slots(menu_button)) == len(set(mro_slots(menu_button))), "duplicate slot"
def test_type_enum_conversion(self, menu_button):
assert type(MenuButton("default").type) is MenuButtonType
assert MenuButton("unknown").type == "unknown"
def test_de_json(self, offline_bot, scope_class_and_type):
cls = scope_class_and_type[0]
type_ = scope_class_and_type[1]
def test_de_json(self, offline_bot):
data = {"type": "unknown"}
transaction_partner = MenuButton.de_json(data, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "unknown"
json_dict = {"type": type_, "text": self.text, "web_app": self.web_app.to_dict()}
menu_button = MenuButton.de_json(json_dict, offline_bot)
assert set(menu_button.api_kwargs.keys()) == {"text", "web_app"} - set(cls.__slots__)
@pytest.mark.parametrize(
("mb_type", "subclass"),
[
("commands", MenuButtonCommands),
("web_app", MenuButtonWebApp),
("default", MenuButtonDefault),
],
)
def test_de_json_subclass(self, offline_bot, mb_type, subclass):
json_dict = {
"type": mb_type,
"web_app": self.web_app.to_dict(),
"text": self.text,
}
mb = MenuButton.de_json(json_dict, offline_bot)
assert isinstance(menu_button, MenuButton)
assert type(menu_button) is cls
assert menu_button.type == type_
if "web_app" in cls.__slots__:
assert menu_button.web_app == self.web_app
if "text" in cls.__slots__:
assert menu_button.text == self.text
assert type(mb) is subclass
assert set(mb.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"type"
}
assert mb.type == mb_type
def test_de_json_invalid_type(self, offline_bot):
json_dict = {"type": "invalid", "text": self.text, "web_app": self.web_app.to_dict()}
menu_button = MenuButton.de_json(json_dict, offline_bot)
assert menu_button.api_kwargs == {"text": self.text, "web_app": self.web_app.to_dict()}
assert type(menu_button) is MenuButton
assert menu_button.type == "invalid"
def test_de_json_subclass(self, scope_class, offline_bot):
"""This makes sure that e.g. MenuButtonDefault(data) never returns a
MenuButtonChat instance."""
json_dict = {"type": "invalid", "text": self.text, "web_app": self.web_app.to_dict()}
assert type(scope_class.de_json(json_dict, offline_bot)) is scope_class
def test_de_json_empty_data(self, scope_class):
if scope_class in (MenuButtonWebApp,):
pytest.skip(
"This test is not relevant for subclasses that have more attributes than just type"
)
assert isinstance(scope_class.de_json({}, None), scope_class)
def test_to_dict(self, menu_button):
assert menu_button.to_dict() == {"type": menu_button.type}
menu_button_dict = menu_button.to_dict()
def test_equality(self, menu_button):
a = menu_button
b = MenuButton(self.type)
c = MenuButton("unknown")
d = Dice(5, "test")
assert isinstance(menu_button_dict, dict)
assert menu_button_dict["type"] == menu_button.type
if hasattr(menu_button, "web_app"):
assert menu_button_dict["web_app"] == menu_button.web_app.to_dict()
if hasattr(menu_button, "text"):
assert menu_button_dict["text"] == menu_button.text
assert a == b
assert hash(a) == hash(b)
def test_type_enum_conversion(self):
assert type(MenuButton("commands").type) is MenuButtonType
assert MenuButton("unknown").type == "unknown"
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def menu_button_commands():
return MenuButtonCommands()
class TestMenuButtonCommandsWithoutRequest(MenuButtonTestBase):
type = MenuButtonType.COMMANDS
def test_slot_behaviour(self, menu_button_commands):
inst = menu_button_commands
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(self, offline_bot):
transaction_partner = MenuButtonCommands.de_json({}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "commands"
def test_to_dict(self, menu_button_commands):
assert menu_button_commands.to_dict() == {"type": menu_button_commands.type}
def test_equality(self, menu_button_commands):
a = menu_button_commands
b = MenuButtonCommands()
c = Dice(5, "test")
d = MenuButtonDefault()
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def menu_button_default():
return MenuButtonDefault()
class TestMenuButtonDefaultWithoutRequest(MenuButtonTestBase):
type = MenuButtonType.DEFAULT
def test_slot_behaviour(self, menu_button_default):
inst = menu_button_default
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(self, offline_bot):
transaction_partner = MenuButtonDefault.de_json({}, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "default"
def test_to_dict(self, menu_button_default):
assert menu_button_default.to_dict() == {"type": menu_button_default.type}
def test_equality(self, menu_button_default):
a = menu_button_default
b = MenuButtonDefault()
c = Dice(5, "test")
d = MenuButtonCommands()
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def menu_button_web_app():
return MenuButtonWebApp(
web_app=TestMenuButtonWebAppWithoutRequest.web_app,
text=TestMenuButtonWebAppWithoutRequest.text,
)
class TestMenuButtonWebAppWithoutRequest(MenuButtonTestBase):
type = MenuButtonType.WEB_APP
def test_slot_behaviour(self, menu_button_web_app):
inst = menu_button_web_app
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(self, offline_bot):
json_dict = {"web_app": self.web_app.to_dict(), "text": self.text}
transaction_partner = MenuButtonWebApp.de_json(json_dict, offline_bot)
assert transaction_partner.api_kwargs == {}
assert transaction_partner.type == "web_app"
assert transaction_partner.web_app == self.web_app
assert transaction_partner.text == self.text
def test_to_dict(self, menu_button_web_app):
assert menu_button_web_app.to_dict() == {
"type": menu_button_web_app.type,
"web_app": menu_button_web_app.web_app.to_dict(),
"text": menu_button_web_app.text,
}
def test_equality(self, menu_button_web_app):
a = menu_button_web_app
b = MenuButtonWebApp(web_app=self.web_app, text=self.text)
c = MenuButtonWebApp(web_app=self.web_app, text="other text")
d = MenuButtonWebApp(web_app=WebAppInfo(url="https://example.org"), text=self.text)
e = Dice(5, "test")
def test_equality(self, menu_button, offline_bot):
a = MenuButton("base_type")
b = MenuButton("base_type")
c = menu_button
d = deepcopy(menu_button)
e = Dice(4, "emoji")
assert a == b
assert hash(a) == hash(b)
@@ -225,3 +172,25 @@ class TestMenuButtonWebAppWithoutRequest(MenuButtonTestBase):
assert a != e
assert hash(a) != hash(e)
assert c == d
assert hash(c) == hash(d)
assert c != e
assert hash(c) != hash(e)
if hasattr(c, "web_app"):
json_dict = c.to_dict()
json_dict["web_app"] = WebAppInfo("https://foo.bar/web_app").to_dict()
f = c.__class__.de_json(json_dict, offline_bot)
assert c != f
assert hash(c) != hash(f)
if hasattr(c, "text"):
json_dict = c.to_dict()
json_dict["text"] = "other text"
g = c.__class__.de_json(json_dict, offline_bot)
assert c != g
assert hash(c) != hash(g)
+1 -1
View File
@@ -2216,9 +2216,9 @@ class TestMessageWithoutRequest(MessageTestBase):
"title",
"description",
"payload",
"provider_token",
"currency",
"prices",
"provider_token",
)
await self.check_quote_parsing(
message,
+30 -34
View File
@@ -19,7 +19,7 @@
"""This module contains exceptions to our API compared to the official API."""
import datetime as dtm
from telegram import Animation, Audio, Document, PhotoSize, Sticker, Video, VideoNote, Voice
from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice
from tests.test_official.helpers import _get_params_base
IGNORED_OBJECTS = ("ResponseParameters",)
@@ -47,8 +47,7 @@ class ParamTypeCheckingExceptions:
"animation": Animation,
"voice": Voice,
"sticker": Sticker,
# TODO: Deprecated and will be corrected (and readded) in next major bot API release:
# "gift_id": Gift,
"gift_id": Gift,
},
"(delete|set)_sticker.*": {
"sticker$": Sticker,
@@ -73,40 +72,36 @@ class ParamTypeCheckingExceptions:
("keyboard", True): "KeyboardButton", # + sequence[sequence[str]]
("reaction", False): "ReactionType", # + str
("options", False): "InputPollOption", # + str
# TODO: Deprecated and will be corrected (and removed) in next bot api release
# TODO: Deprecated and will be corrected (and removed) in next major PTB version:
("file_hashes", True): "list[str]",
}
# Special cases for other parameters that accept more types than the official API, and are
# too complex to compare/predict with official API
# structure: class/method_name: {param_name: reduced form of annotation}
COMPLEX_TYPES = {
"send_poll": {"correct_option_id": int}, # actual: Literal
"get_file": {
"file_id": str, # actual: Union[str, objs_with_file_id_attr]
},
r"\w+invite_link": {
"invite_link": str, # actual: Union[str, ChatInviteLink]
},
"send_invoice|create_invoice_link": {
"provider_data": str, # actual: Union[str, obj]
},
"InlineKeyboardButton": {
"callback_data": str, # actual: Union[str, obj]
},
"Input(Paid)?Media.*": {
"media": str, # actual: Union[str, InputMedia*, FileInput]
# see also https://github.com/tdlib/telegram-bot-api/issues/707
"thumbnail": str, # actual: Union[str, FileInput]
"cover": str, # actual: Union[str, FileInput]
},
"EncryptedPassportElement": {
"data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress]
},
# TODO: Deprecated and will be corrected (and removed) in next major PTB
# version:
"send_gift": {"gift_id": str}, # actual: Non optional
}
COMPLEX_TYPES = (
{ # (param_name, is_class (i.e appears in a class?)): reduced form of annotation
"send_poll": {"correct_option_id": int}, # actual: Literal
"get_file": {
"file_id": str, # actual: Union[str, objs_with_file_id_attr]
},
r"\w+invite_link": {
"invite_link": str, # actual: Union[str, ChatInviteLink]
},
"send_invoice|create_invoice_link": {
"provider_data": str, # actual: Union[str, obj]
},
"InlineKeyboardButton": {
"callback_data": str, # actual: Union[str, obj]
},
"Input(Paid)?Media.*": {
"media": str, # actual: Union[str, InputMedia*, FileInput]
},
"EncryptedPassportElement": {
"data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress]
},
}
)
# param names ignored in the param type checking in classes for the `tg.Defaults` case.
IGNORED_DEFAULTS_PARAM_NAMES = {
@@ -203,8 +198,6 @@ IGNORED_PARAM_REQUIREMENTS = {
"send_venue": {"latitude", "longitude", "title", "address"},
"send_contact": {"phone_number", "first_name"},
# ---->
# here for backwards compatibility. Todo: remove on next bot api release
"send_gift": {"gift_id"},
}
@@ -213,7 +206,10 @@ 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]] = {}
BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {
"send_invoice|create_invoice_link|InputInvoiceMessageContent": {"provider_token"},
"InlineQueryResultArticle": {"hide_url"},
}
def backwards_compat_kwargs(object_name: str) -> set[str]:
+198 -238
View File
@@ -37,13 +37,102 @@ from telegram.constants import PaidMediaType
from tests.auxil.slots import mro_slots
@pytest.fixture
def paid_media():
return PaidMedia(type=PaidMediaType.PHOTO)
@pytest.fixture(
scope="module",
params=[
PaidMedia.PREVIEW,
PaidMedia.PHOTO,
PaidMedia.VIDEO,
],
)
def pm_scope_type(request):
return request.param
@pytest.fixture(
scope="module",
params=[
PaidMediaPreview,
PaidMediaPhoto,
PaidMediaVideo,
],
ids=[
PaidMedia.PREVIEW,
PaidMedia.PHOTO,
PaidMedia.VIDEO,
],
)
def pm_scope_class(request):
return request.param
@pytest.fixture(
scope="module",
params=[
(
PaidMediaPreview,
PaidMedia.PREVIEW,
),
(
PaidMediaPhoto,
PaidMedia.PHOTO,
),
(
PaidMediaVideo,
PaidMedia.VIDEO,
),
],
ids=[
PaidMedia.PREVIEW,
PaidMedia.PHOTO,
PaidMedia.VIDEO,
],
)
def pm_scope_class_and_type(request):
return request.param
@pytest.fixture(scope="module")
def paid_media(pm_scope_class_and_type):
# We use de_json here so that we don't have to worry about which class gets which arguments
return pm_scope_class_and_type[0].de_json(
{
"type": pm_scope_class_and_type[1],
"width": PaidMediaTestBase.width,
"height": PaidMediaTestBase.height,
"duration": PaidMediaTestBase.duration,
"video": PaidMediaTestBase.video.to_dict(),
"photo": [p.to_dict() for p in PaidMediaTestBase.photo],
},
bot=None,
)
def paid_media_video():
return PaidMediaVideo(video=PaidMediaTestBase.video)
def paid_media_photo():
return PaidMediaPhoto(photo=PaidMediaTestBase.photo)
@pytest.fixture(scope="module")
def paid_media_info():
return PaidMediaInfo(
star_count=PaidMediaInfoTestBase.star_count,
paid_media=[paid_media_video(), paid_media_photo()],
)
@pytest.fixture(scope="module")
def paid_media_purchased():
return PaidMediaPurchased(
from_user=PaidMediaPurchasedTestBase.from_user,
paid_media_payload=PaidMediaPurchasedTestBase.paid_media_payload,
)
class PaidMediaTestBase:
type = PaidMediaType.PHOTO
width = 640
height = 480
duration = 60
@@ -71,49 +160,97 @@ class TestPaidMediaWithoutRequest(PaidMediaTestBase):
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_type_enum_conversion(self, paid_media):
assert type(PaidMedia("photo").type) is PaidMediaType
assert PaidMedia("unknown").type == "unknown"
def test_de_json(self, offline_bot, pm_scope_class_and_type):
cls = pm_scope_class_and_type[0]
type_ = pm_scope_class_and_type[1]
def test_de_json(self, offline_bot):
data = {"type": "unknown"}
paid_media = PaidMedia.de_json(data, offline_bot)
assert paid_media.api_kwargs == {}
assert paid_media.type == "unknown"
@pytest.mark.parametrize(
("pm_type", "subclass"),
[
("photo", PaidMediaPhoto),
("video", PaidMediaVideo),
("preview", PaidMediaPreview),
],
)
def test_de_json_subclass(self, offline_bot, pm_type, subclass):
json_dict = {
"type": pm_type,
"video": self.video.to_dict(),
"photo": [p.to_dict() for p in self.photo],
"type": type_,
"width": self.width,
"height": self.height,
"duration": self.duration,
"video": self.video.to_dict(),
"photo": [p.to_dict() for p in self.photo],
}
pm = PaidMedia.de_json(json_dict, offline_bot)
assert set(pm.api_kwargs.keys()) == {
"width",
"height",
"duration",
"video",
"photo",
} - set(cls.__slots__)
assert type(pm) is subclass
assert set(pm.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"type"
assert isinstance(pm, PaidMedia)
assert type(pm) is cls
assert pm.type == type_
if "width" in cls.__slots__:
assert pm.width == self.width
assert pm.height == self.height
assert pm.duration == self.duration
if "video" in cls.__slots__:
assert pm.video == self.video
if "photo" in cls.__slots__:
assert pm.photo == self.photo
def test_de_json_invalid_type(self, offline_bot):
json_dict = {
"type": "invalid",
"width": self.width,
"height": self.height,
"duration": self.duration,
"video": self.video.to_dict(),
"photo": [p.to_dict() for p in self.photo],
}
assert pm.type == pm_type
pm = PaidMedia.de_json(json_dict, offline_bot)
assert pm.api_kwargs == {
"width": self.width,
"height": self.height,
"duration": self.duration,
"video": self.video.to_dict(),
"photo": [p.to_dict() for p in self.photo],
}
assert type(pm) is PaidMedia
assert pm.type == "invalid"
def test_de_json_subclass(self, pm_scope_class, offline_bot):
"""This makes sure that e.g. PaidMediaPreivew(data) never returns a
TransactionPartnerPhoto instance."""
json_dict = {
"type": "invalid",
"width": self.width,
"height": self.height,
"duration": self.duration,
"video": self.video.to_dict(),
"photo": [p.to_dict() for p in self.photo],
}
assert type(pm_scope_class.de_json(json_dict, offline_bot)) is pm_scope_class
def test_to_dict(self, paid_media):
assert paid_media.to_dict() == {"type": paid_media.type}
pm_dict = paid_media.to_dict()
def test_equality(self, paid_media):
a = paid_media
b = PaidMedia(self.type)
c = PaidMedia("unknown")
d = Dice(5, "test")
assert isinstance(pm_dict, dict)
assert pm_dict["type"] == paid_media.type
if hasattr(paid_media_info, "width"):
assert pm_dict["width"] == paid_media.width
assert pm_dict["height"] == paid_media.height
assert pm_dict["duration"] == paid_media.duration
if hasattr(paid_media_info, "video"):
assert pm_dict["video"] == paid_media.video.to_dict()
if hasattr(paid_media_info, "photo"):
assert pm_dict["photo"] == [p.to_dict() for p in paid_media.photo]
def test_type_enum_conversion(self):
assert type(PaidMedia("video").type) is PaidMediaType
assert PaidMedia("unknown").type == "unknown"
def test_equality(self, paid_media, offline_bot):
a = PaidMedia("base_type")
b = PaidMedia("base_type")
c = paid_media
d = deepcopy(paid_media)
e = Dice(4, "emoji")
assert a == b
assert hash(a) == hash(b)
@@ -124,216 +261,35 @@ class TestPaidMediaWithoutRequest(PaidMediaTestBase):
assert a != d
assert hash(a) != hash(d)
assert a != e
assert hash(a) != hash(e)
@pytest.fixture
def paid_media_photo():
return PaidMediaPhoto(
photo=TestPaidMediaPhotoWithoutRequest.photo,
)
assert c == d
assert hash(c) == hash(d)
assert c != e
assert hash(c) != hash(e)
class TestPaidMediaPhotoWithoutRequest(PaidMediaTestBase):
type = PaidMediaType.PHOTO
if hasattr(c, "video"):
json_dict = c.to_dict()
json_dict["video"] = Video("different", "d2", 1, 1, 1).to_dict()
f = c.__class__.de_json(json_dict, offline_bot)
def test_slot_behaviour(self, paid_media_photo):
inst = paid_media_photo
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"
assert c != f
assert hash(c) != hash(f)
def test_de_json(self, offline_bot):
json_dict = {
"photo": [p.to_dict() for p in self.photo],
}
pmp = PaidMediaPhoto.de_json(json_dict, offline_bot)
assert pmp.photo == tuple(self.photo)
assert pmp.api_kwargs == {}
if hasattr(c, "photo"):
json_dict = c.to_dict()
json_dict["photo"] = [PhotoSize("different", "d2", 1, 1, 1).to_dict()]
f = c.__class__.de_json(json_dict, offline_bot)
def test_to_dict(self, paid_media_photo):
assert paid_media_photo.to_dict() == {
"type": paid_media_photo.type,
"photo": [p.to_dict() for p in self.photo],
}
def test_equality(self, paid_media_photo):
a = paid_media_photo
b = PaidMediaPhoto(deepcopy(self.photo))
c = PaidMediaPhoto([PhotoSize("file_id", 640, 480, "file_unique_id")])
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def paid_media_video():
return PaidMediaVideo(
video=TestPaidMediaVideoWithoutRequest.video,
)
class TestPaidMediaVideoWithoutRequest(PaidMediaTestBase):
type = PaidMediaType.VIDEO
def test_slot_behaviour(self, paid_media_video):
inst = paid_media_video
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(self, offline_bot):
json_dict = {
"video": self.video.to_dict(),
}
pmv = PaidMediaVideo.de_json(json_dict, offline_bot)
assert pmv.video == self.video
assert pmv.api_kwargs == {}
def test_to_dict(self, paid_media_video):
assert paid_media_video.to_dict() == {
"type": self.type,
"video": paid_media_video.video.to_dict(),
}
def test_equality(self, paid_media_video):
a = paid_media_video
b = PaidMediaVideo(
video=deepcopy(self.video),
)
c = PaidMediaVideo(
video=Video("test", "test_unique", 640, 480, 60),
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture
def paid_media_preview():
return PaidMediaPreview(
width=TestPaidMediaPreviewWithoutRequest.width,
height=TestPaidMediaPreviewWithoutRequest.height,
duration=TestPaidMediaPreviewWithoutRequest.duration,
)
class TestPaidMediaPreviewWithoutRequest(PaidMediaTestBase):
type = PaidMediaType.PREVIEW
def test_slot_behaviour(self, paid_media_preview):
inst = paid_media_preview
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(self, offline_bot):
json_dict = {
"width": self.width,
"height": self.height,
"duration": self.duration,
}
pmp = PaidMediaPreview.de_json(json_dict, offline_bot)
assert pmp.width == self.width
assert pmp.height == self.height
assert pmp.duration == self.duration
assert pmp.api_kwargs == {}
def test_to_dict(self, paid_media_preview):
assert paid_media_preview.to_dict() == {
"type": paid_media_preview.type,
"width": self.width,
"height": self.height,
"duration": self.duration,
}
def test_equality(self, paid_media_preview):
a = paid_media_preview
b = PaidMediaPreview(
width=self.width,
height=self.height,
duration=self.duration,
)
c = PaidMediaPreview(
width=100,
height=100,
duration=100,
)
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
# ===========================================================================================
@pytest.fixture(scope="module")
def paid_media_info():
return PaidMediaInfo(
star_count=PaidMediaInfoTestBase.star_count,
paid_media=PaidMediaInfoTestBase.paid_media,
)
@pytest.fixture(scope="module")
def paid_media_purchased():
return PaidMediaPurchased(
from_user=PaidMediaPurchasedTestBase.from_user,
paid_media_payload=PaidMediaPurchasedTestBase.paid_media_payload,
)
assert c != f
assert hash(c) != hash(f)
class PaidMediaInfoTestBase:
star_count = 200
paid_media = [
PaidMediaVideo(
video=Video(
file_id="video_file_id",
width=640,
height=480,
file_unique_id="file_unique_id",
duration=60,
)
),
PaidMediaPhoto(
photo=[
PhotoSize(
file_id="photo_file_id",
width=640,
height=480,
file_unique_id="file_unique_id",
)
]
),
]
paid_media = [paid_media_video(), paid_media_photo()]
class TestPaidMediaInfoWithoutRequest(PaidMediaInfoTestBase):
@@ -359,9 +315,13 @@ class TestPaidMediaInfoWithoutRequest(PaidMediaInfoTestBase):
}
def test_equality(self):
pmi1 = PaidMediaInfo(star_count=self.star_count, paid_media=self.paid_media)
pmi2 = PaidMediaInfo(star_count=self.star_count, paid_media=self.paid_media)
pmi3 = PaidMediaInfo(star_count=100, paid_media=[self.paid_media[0]])
pmi1 = PaidMediaInfo(
star_count=self.star_count, paid_media=[paid_media_video(), paid_media_photo()]
)
pmi2 = PaidMediaInfo(
star_count=self.star_count, paid_media=[paid_media_video(), paid_media_photo()]
)
pmi3 = PaidMediaInfo(star_count=100, paid_media=[paid_media_photo()])
assert pmi1 == pmi2
assert hash(pmi1) == hash(pmi2)
+147 -137
View File
@@ -16,6 +16,8 @@
#
# 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
import pytest
@@ -27,100 +29,161 @@ from telegram import (
ReactionTypeCustomEmoji,
ReactionTypeEmoji,
ReactionTypePaid,
constants,
)
from telegram.constants import ReactionEmoji
from tests.auxil.slots import mro_slots
@pytest.fixture(scope="module")
def reaction_type():
return ReactionType(type=TestReactionTypeWithoutRequest.type)
ignored = ["self", "api_kwargs"]
class ReactionTypeTestBase:
type = "emoji"
emoji = "some_emoji"
custom_emoji_id = "some_custom_emoji_id"
class RTDefaults:
custom_emoji = "123custom"
normal_emoji = ReactionEmoji.THUMBS_UP
class TestReactionTypeWithoutRequest(ReactionTypeTestBase):
def reaction_type_custom_emoji():
return ReactionTypeCustomEmoji(RTDefaults.custom_emoji)
def reaction_type_emoji():
return ReactionTypeEmoji(RTDefaults.normal_emoji)
def reaction_type_paid():
return ReactionTypePaid()
def make_json_dict(instance: ReactionType, 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)-
# currently not needed, keeping for completeness
elif param.default is not inspect.Parameter.empty and include_optional_args:
json_dict[param.name] = val
return json_dict
def iter_args(instance: ReactionType, de_json_inst: ReactionType, 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 reaction_type(request):
return request.param()
@pytest.mark.parametrize(
"reaction_type",
[
reaction_type_custom_emoji,
reaction_type_emoji,
reaction_type_paid,
],
indirect=True,
)
class TestReactionTypesWithoutRequest:
def test_slot_behaviour(self, reaction_type):
inst = reaction_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_type_enum_conversion(self):
assert type(ReactionType("emoji").type) is constants.ReactionType
assert ReactionType("unknown").type == "unknown"
def test_de_json_required_args(self, offline_bot, reaction_type):
cls = reaction_type.__class__
def test_de_json(self, offline_bot):
json_dict = {"type": "unknown"}
json_dict = make_json_dict(reaction_type)
const_reaction_type = ReactionType.de_json(json_dict, offline_bot)
assert const_reaction_type.api_kwargs == {}
assert isinstance(const_reaction_type, ReactionType)
assert isinstance(const_reaction_type, cls)
for reaction_type_at, const_reaction_type_at in iter_args(
reaction_type, const_reaction_type
):
assert reaction_type_at == const_reaction_type_at
def test_de_json_all_args(self, offline_bot, reaction_type):
json_dict = make_json_dict(reaction_type, include_optional_args=True)
const_reaction_type = ReactionType.de_json(json_dict, offline_bot)
assert const_reaction_type.api_kwargs == {}
assert isinstance(const_reaction_type, ReactionType)
assert isinstance(const_reaction_type, reaction_type.__class__)
for c_mem_type_at, const_c_mem_at in iter_args(reaction_type, const_reaction_type, True):
assert c_mem_type_at == const_c_mem_at
def test_de_json_invalid_type(self, offline_bot, reaction_type):
json_dict = {"type": "invalid"}
reaction_type = ReactionType.de_json(json_dict, offline_bot)
assert reaction_type.api_kwargs == {}
assert reaction_type.type == "unknown"
@pytest.mark.parametrize(
("rt_type", "subclass"),
[
("emoji", ReactionTypeEmoji),
("custom_emoji", ReactionTypeCustomEmoji),
("paid", ReactionTypePaid),
],
)
def test_de_json_subclass(self, offline_bot, rt_type, subclass):
json_dict = {
"type": rt_type,
"emoji": self.emoji,
"custom_emoji_id": self.custom_emoji_id,
}
rt = ReactionType.de_json(json_dict, offline_bot)
assert type(reaction_type) is ReactionType
assert reaction_type.type == "invalid"
assert type(rt) is subclass
assert set(rt.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - {
"type"
}
assert rt.type == rt_type
def test_de_json_subclass(self, reaction_type, offline_bot, chat_id):
"""This makes sure that e.g. ReactionTypeEmoji(data, offline_bot) never returns a
ReactionTypeCustomEmoji instance."""
cls = reaction_type.__class__
json_dict = make_json_dict(reaction_type, True)
assert type(cls.de_json(json_dict, offline_bot)) is cls
def test_to_dict(self, reaction_type):
reaction_type_dict = reaction_type.to_dict()
assert isinstance(reaction_type_dict, dict)
assert reaction_type_dict["type"] == reaction_type.type
if reaction_type.type == ReactionType.EMOJI:
assert reaction_type_dict["emoji"] == reaction_type.emoji
elif reaction_type.type == ReactionType.CUSTOM_EMOJI:
assert reaction_type_dict["custom_emoji_id"] == reaction_type.custom_emoji_id
for slot in reaction_type.__slots__: # additional verification for the optional args
assert getattr(reaction_type, slot) == reaction_type_dict[slot]
@pytest.fixture(scope="module")
def reaction_type_emoji():
return ReactionTypeEmoji(emoji=TestReactionTypeEmojiWithoutRequest.emoji)
def test_reaction_type_api_kwargs(self, reaction_type):
json_dict = make_json_dict(reaction_type_custom_emoji())
json_dict["custom_arg"] = "wuhu"
reaction_type_custom_emoji_instance = ReactionType.de_json(json_dict, None)
assert reaction_type_custom_emoji_instance.api_kwargs == {
"custom_arg": "wuhu",
}
class TestReactionTypeEmojiWithoutRequest(ReactionTypeTestBase):
type = constants.ReactionType.EMOJI
def test_slot_behaviour(self, reaction_type_emoji):
inst = reaction_type_emoji
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(self, offline_bot):
json_dict = {"emoji": self.emoji}
reaction_type_emoji = ReactionTypeEmoji.de_json(json_dict, offline_bot)
assert reaction_type_emoji.api_kwargs == {}
assert reaction_type_emoji.type == self.type
assert reaction_type_emoji.emoji == self.emoji
def test_to_dict(self, reaction_type_emoji):
reaction_type_emoji_dict = reaction_type_emoji.to_dict()
assert isinstance(reaction_type_emoji_dict, dict)
assert reaction_type_emoji_dict["type"] == reaction_type_emoji.type
assert reaction_type_emoji_dict["emoji"] == reaction_type_emoji.emoji
def test_equality(self, reaction_type_emoji):
a = reaction_type_emoji
b = ReactionTypeEmoji(emoji=self.emoji)
c = ReactionTypeEmoji(emoji="other_emoji")
d = Dice(5, "test")
def test_equality(self, reaction_type):
a = ReactionTypeEmoji(emoji=RTDefaults.normal_emoji)
b = ReactionTypeEmoji(emoji=RTDefaults.normal_emoji)
c = ReactionTypeCustomEmoji(custom_emoji_id=RTDefaults.custom_emoji)
d = ReactionTypeCustomEmoji(custom_emoji_id=RTDefaults.custom_emoji)
e = ReactionTypeEmoji(emoji=ReactionEmoji.RED_HEART)
f = ReactionTypeCustomEmoji(custom_emoji_id="1234custom")
g = deepcopy(a)
h = deepcopy(c)
i = Dice(4, "emoji")
assert a == b
assert hash(a) == hash(b)
@@ -128,82 +191,29 @@ class TestReactionTypeEmojiWithoutRequest(ReactionTypeTestBase):
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 a == g
assert hash(a) == hash(g)
@pytest.fixture(scope="module")
def reaction_type_custom_emoji():
return ReactionTypeCustomEmoji(
custom_emoji_id=TestReactionTypeCustomEmojiWithoutRequest.custom_emoji_id
)
assert a != i
assert hash(a) != hash(i)
assert c == d
assert hash(c) == hash(d)
class TestReactionTypeCustomEmojiWithoutRequest(ReactionTypeTestBase):
type = constants.ReactionType.CUSTOM_EMOJI
assert c != e
assert hash(c) != hash(e)
def test_slot_behaviour(self, reaction_type_custom_emoji):
inst = reaction_type_custom_emoji
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"
assert c != f
assert hash(c) != hash(f)
def test_de_json(self, offline_bot):
json_dict = {"custom_emoji_id": self.custom_emoji_id}
reaction_type_custom_emoji = ReactionTypeCustomEmoji.de_json(json_dict, offline_bot)
assert reaction_type_custom_emoji.api_kwargs == {}
assert reaction_type_custom_emoji.type == self.type
assert reaction_type_custom_emoji.custom_emoji_id == self.custom_emoji_id
assert c == h
assert hash(c) == hash(h)
def test_to_dict(self, reaction_type_custom_emoji):
reaction_type_custom_emoji_dict = reaction_type_custom_emoji.to_dict()
assert isinstance(reaction_type_custom_emoji_dict, dict)
assert reaction_type_custom_emoji_dict["type"] == reaction_type_custom_emoji.type
assert (
reaction_type_custom_emoji_dict["custom_emoji_id"]
== reaction_type_custom_emoji.custom_emoji_id
)
def test_equality(self, reaction_type_custom_emoji):
a = reaction_type_custom_emoji
b = ReactionTypeCustomEmoji(custom_emoji_id=self.custom_emoji_id)
c = ReactionTypeCustomEmoji(custom_emoji_id="other_custom_emoji_id")
d = Dice(5, "test")
assert a == b
assert hash(a) == hash(b)
assert a != c
assert hash(a) != hash(c)
assert a != d
assert hash(a) != hash(d)
@pytest.fixture(scope="module")
def reaction_type_paid():
return ReactionTypePaid()
class TestReactionTypePaidWithoutRequest(ReactionTypeTestBase):
type = constants.ReactionType.PAID
def test_slot_behaviour(self, reaction_type_paid):
inst = reaction_type_paid
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(self, offline_bot):
json_dict = {}
reaction_type_paid = ReactionTypePaid.de_json(json_dict, offline_bot)
assert reaction_type_paid.api_kwargs == {}
assert reaction_type_paid.type == self.type
def test_to_dict(self, reaction_type_paid):
reaction_type_paid_dict = reaction_type_paid.to_dict()
assert isinstance(reaction_type_paid_dict, dict)
assert reaction_type_paid_dict["type"] == reaction_type_paid.type
assert c != i
assert hash(c) != hash(i)
@pytest.fixture(scope="module")
+3 -13
View File
@@ -343,9 +343,9 @@ class TestUserWithoutRequest(UserTestBase):
"title",
"description",
"payload",
"provider_token",
"currency",
"prices",
"provider_token",
)
async def test_instance_method_send_location(self, monkeypatch, user):
@@ -731,18 +731,8 @@ class TestUserWithoutRequest(UserTestBase):
and kwargs["text_entities"] == "text_entities"
)
# TODO discuss if better way exists
# tags: deprecated 21.11
with pytest.raises(
Exception,
match="Default for argument gift_id does not match the default of the Bot method.",
):
assert check_shortcut_signature(
user.send_gift, Bot.send_gift, ["user_id", "chat_id"], []
)
assert await check_shortcut_call(
user.send_gift, user.get_bot(), "send_gift", ["chat_id", "user_id"]
)
assert check_shortcut_signature(user.send_gift, Bot.send_gift, ["user_id"], [])
assert await check_shortcut_call(user.send_gift, user.get_bot(), "send_gift")
assert await check_defaults_handling(user.send_gift, user.get_bot())
monkeypatch.setattr(user.get_bot(), "send_gift", make_assertion)