diff --git a/README.rst b/README.rst index 772aa757119..f0482409e2a 100644 --- a/README.rst +++ b/README.rst @@ -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.0-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-8.1-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.0** are natively supported by this library. +All types and methods of the Telegram Bot API **8.1** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index 6853a7fbe93..dd0bf3aac31 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -187,6 +187,11 @@ def autodoc_process_bases(app, name, obj, option, bases: list) -> None: bases[idx] = ":class:`enum.IntEnum`" continue + if "FloatEnum" in base: + bases[idx] = ":class:`enum.Enum`" + bases.insert(0, ":class:`float`") + continue + # Drop generics (at least for now) if base.endswith("]"): base = base.split("[", maxsplit=1)[0] diff --git a/docs/source/telegram.affiliateinfo.rst b/docs/source/telegram.affiliateinfo.rst new file mode 100644 index 00000000000..0b2e51863af --- /dev/null +++ b/docs/source/telegram.affiliateinfo.rst @@ -0,0 +1,6 @@ +AffiliateInfo +============= + +.. autoclass:: telegram.AffiliateInfo + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.constants.rst b/docs/source/telegram.constants.rst index 4b5edf51094..ef1e6720107 100644 --- a/docs/source/telegram.constants.rst +++ b/docs/source/telegram.constants.rst @@ -5,5 +5,5 @@ telegram.constants Module :members: :show-inheritance: :no-undoc-members: - :inherited-members: Enum, EnumMeta, str, int + :inherited-members: Enum, EnumMeta, str, int, float :exclude-members: __format__, __new__, __repr__, __str__ diff --git a/docs/source/telegram.payments-tree.rst b/docs/source/telegram.payments-tree.rst index 590a96fdaa5..e8ec7bd3e3b 100644 --- a/docs/source/telegram.payments-tree.rst +++ b/docs/source/telegram.payments-tree.rst @@ -9,6 +9,7 @@ Your bot can accept payments from Telegram users. Please see the `introduction t .. toctree:: :titlesonly: + telegram.affiliateinfo telegram.invoice telegram.labeledprice telegram.orderinfo @@ -25,6 +26,7 @@ Your bot can accept payments from Telegram users. Please see the `introduction t telegram.startransactions telegram.successfulpayment telegram.transactionpartner + telegram.transactionpartneraffiliateprogram telegram.transactionpartnerfragment telegram.transactionpartnerother telegram.transactionpartnertelegramads diff --git a/docs/source/telegram.transactionpartneraffiliateprogram.rst b/docs/source/telegram.transactionpartneraffiliateprogram.rst new file mode 100644 index 00000000000..dfcab6ec22b --- /dev/null +++ b/docs/source/telegram.transactionpartneraffiliateprogram.rst @@ -0,0 +1,6 @@ +TransactionPartnerAffiliateProgram +=================================== + +.. autoclass:: telegram.TransactionPartnerAffiliateProgram + :members: + :show-inheritance: \ No newline at end of file diff --git a/telegram/__init__.py b/telegram/__init__.py index 009a51dccd4..a827670d66b 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -20,6 +20,7 @@ __author__ = "devs@python-telegram-bot.org" __all__ = ( + "AffiliateInfo", "Animation", "Audio", "BackgroundFill", @@ -236,6 +237,7 @@ "TelegramObject", "TextQuote", "TransactionPartner", + "TransactionPartnerAffiliateProgram", "TransactionPartnerFragment", "TransactionPartnerOther", "TransactionPartnerTelegramAds", @@ -469,6 +471,7 @@ from ._payment.shippingoption import ShippingOption from ._payment.shippingquery import ShippingQuery from ._payment.stars import ( + AffiliateInfo, RevenueWithdrawalState, RevenueWithdrawalStateFailed, RevenueWithdrawalStatePending, @@ -476,6 +479,7 @@ StarTransaction, StarTransactions, TransactionPartner, + TransactionPartnerAffiliateProgram, TransactionPartnerFragment, TransactionPartnerOther, TransactionPartnerTelegramAds, diff --git a/telegram/_bot.py b/telegram/_bot.py index 7ba6c9a789f..08fd31f4acc 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -8175,7 +8175,10 @@ async def create_invoice_link( ``“XTR”`` (Telegram Stars) if the parameter is used. Currently, it must always be :tg-const:`telegram.constants.InvoiceLimit.SUBSCRIPTION_PERIOD` if specified. Any number of subscriptions can be active for a given bot at the same time, including - multiple concurrent subscriptions from the same user. + multiple concurrent subscriptions from the same user. Subscription price must + not exceed + :tg-const:`telegram.constants.InvoiceLimit.SUBSCRIPTION_MAX_PRICE` + Telegram Stars. .. versionadded:: 21.8 max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the diff --git a/telegram/_payment/stars.py b/telegram/_payment/stars.py index 61b5ded46d6..f62f28822a9 100644 --- a/telegram/_payment/stars.py +++ b/telegram/_payment/stars.py @@ -24,6 +24,7 @@ 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 @@ -194,13 +195,107 @@ def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: self._freeze() +class AffiliateInfo(TelegramObject): + """Contains information about the affiliate that received a commission via this transaction. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`affiliate_user`, :attr:`affiliate_chat`, + :attr:`commission_per_mille`, :attr:`amount`, and :attr:`nanostar_amount` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + affiliate_user (:class:`telegram.User`, optional): The bot or the user that received an + affiliate commission if it was received by a bot or a user + affiliate_chat (:class:`telegram.Chat`, optional): The chat that received an affiliate + commission if it was received by a chat + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the affiliate + for each 1000 Telegram Stars received by the bot from referred users + amount (:obj:`int`): Integer amount of Telegram Stars received by the affiliate from the + transaction, rounded to 0; can be negative for refunds + nanostar_amount (:obj:`int`, optional): The number of + :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + Stars received by the affiliate; from + :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT` to + :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT`; + can be negative for refunds + + Attributes: + affiliate_user (:class:`telegram.User`): Optional. The bot or the user that received an + affiliate commission if it was received by a bot or a user + affiliate_chat (:class:`telegram.Chat`): Optional. The chat that received an affiliate + commission if it was received by a chat + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the affiliate + for each 1000 Telegram Stars received by the bot from referred users + amount (:obj:`int`): Integer amount of Telegram Stars received by the affiliate from the + transaction, rounded to 0; can be negative for refunds + nanostar_amount (:obj:`int`): Optional. The number of + :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + Stars received by the affiliate; from + :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT` to + :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT`; + can be negative for refunds + """ + + __slots__ = ( + "affiliate_chat", + "affiliate_user", + "amount", + "commission_per_mille", + "nanostar_amount", + ) + + def __init__( + self, + commission_per_mille: int, + amount: int, + affiliate_user: Optional["User"] = None, + affiliate_chat: Optional["Chat"] = None, + nanostar_amount: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.affiliate_user: Optional[User] = affiliate_user + self.affiliate_chat: Optional[Chat] = affiliate_chat + self.commission_per_mille: int = commission_per_mille + self.amount: int = amount + self.nanostar_amount: Optional[int] = nanostar_amount + + self._id_attrs = ( + self.affiliate_user, + self.affiliate_chat, + self.commission_per_mille, + self.amount, + self.nanostar_amount, + ) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["AffiliateInfo"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["affiliate_user"] = User.de_json(data.get("affiliate_user"), bot) + data["affiliate_chat"] = Chat.de_json(data.get("affiliate_chat"), bot) + + return super().de_json(data=data, bot=bot) + + class TransactionPartner(TelegramObject): """This object describes the source of a transaction, or its recipient for outgoing transactions. Currently, it can be one of: * :class:`TransactionPartnerUser` + * :class:`TransactionPartnerAffiliateProgram` * :class:`TransactionPartnerFragment` * :class:`TransactionPartnerTelegramAds` + * :class:`TransactionPartnerTelegramApi` * :class:`TransactionPartnerOther` Objects of this class are comparable in terms of equality. Two objects of this class are @@ -217,6 +312,11 @@ class TransactionPartner(TelegramObject): __slots__ = ("type",) + AFFILIATE_PROGRAM: Final[str] = constants.TransactionPartnerType.AFFILIATE_PROGRAM + """:const:`telegram.constants.TransactionPartnerType.AFFILIATE_PROGRAM` + + .. versionadded:: NEXT.VERSION + """ FRAGMENT: Final[str] = constants.TransactionPartnerType.FRAGMENT """:const:`telegram.constants.TransactionPartnerType.FRAGMENT`""" OTHER: Final[str] = constants.TransactionPartnerType.OTHER @@ -259,6 +359,7 @@ def de_json( return None _class_mapping: dict[str, type[TransactionPartner]] = { + cls.AFFILIATE_PROGRAM: TransactionPartnerAffiliateProgram, cls.FRAGMENT: TransactionPartnerFragment, cls.USER: TransactionPartnerUser, cls.TELEGRAM_ADS: TransactionPartnerTelegramAds, @@ -272,6 +373,64 @@ def de_json( return super().de_json(data=data, bot=bot) +class TransactionPartnerAffiliateProgram(TransactionPartner): + """Describes the affiliate program that issued the affiliate commission received via this + transaction. + + This object is comparable in terms of equality. Two objects of this class are considered equal, + if their :attr:`commission_per_mille` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + sponsor_user (:class:`telegram.User`, optional): Information about the bot that sponsored + the affiliate program + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the bot for + each 1000 Telegram Stars received by the affiliate program sponsor from referred users. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.AFFILIATE_PROGRAM`. + sponsor_user (:class:`telegram.User`): Optional. Information about the bot that sponsored + the affiliate program + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the bot for + each 1000 Telegram Stars received by the affiliate program sponsor from referred users. + """ + + __slots__ = ("commission_per_mille", "sponsor_user") + + def __init__( + self, + commission_per_mille: int, + sponsor_user: Optional["User"] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=TransactionPartner.AFFILIATE_PROGRAM, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sponsor_user: Optional[User] = sponsor_user + self.commission_per_mille: int = commission_per_mille + self._id_attrs = ( + self.type, + self.commission_per_mille, + ) + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["TransactionPartnerAffiliateProgram"]: + """See :meth:`telegram.TransactionPartner.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["sponsor_user"] = User.de_json(data.get("sponsor_user"), bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + class TransactionPartnerFragment(TransactionPartner): """Describes a withdrawal transaction with Fragment. @@ -328,6 +487,10 @@ class TransactionPartnerUser(TransactionPartner): Args: user (:class:`telegram.User`): Information about the user. + affiliate (:class:`telegram.AffiliateInfo`, optional): Information about the affiliate that + received a commission via this transaction + + .. versionadded:: NEXT.VERSION invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. subscription_period (:class:`datetime.timedelta`, optional): The duration of the paid subscription @@ -348,6 +511,10 @@ class TransactionPartnerUser(TransactionPartner): type (:obj:`str`): The type of the transaction partner, always :tg-const:`telegram.TransactionPartner.USER`. user (:class:`telegram.User`): Information about the user. + affiliate (:class:`telegram.AffiliateInfo`): Optional. Information about the affiliate that + received a commission via this transaction + + .. versionadded:: NEXT.VERSION invoice_payload (:obj:`str`): Optional. Bot-specified invoice payload. subscription_period (:class:`datetime.timedelta`): Optional. The duration of the paid subscription @@ -367,6 +534,7 @@ class TransactionPartnerUser(TransactionPartner): """ __slots__ = ( + "affiliate", "gift", "invoice_payload", "paid_media", @@ -383,6 +551,7 @@ def __init__( paid_media_payload: Optional[str] = None, subscription_period: Optional[dtm.timedelta] = None, gift: Optional[Gift] = None, + affiliate: Optional[AffiliateInfo] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: @@ -390,6 +559,7 @@ def __init__( with self._unfrozen(): self.user: User = user + self.affiliate: Optional[AffiliateInfo] = affiliate self.invoice_payload: Optional[str] = invoice_payload self.paid_media: Optional[tuple[PaidMedia, ...]] = parse_sequence_arg(paid_media) self.paid_media_payload: Optional[str] = paid_media_payload @@ -412,6 +582,7 @@ def de_json( return None data["user"] = User.de_json(data.get("user"), bot) + data["affiliate"] = AffiliateInfo.de_json(data.get("affiliate"), bot) data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) data["subscription_period"] = ( dtm.timedelta(seconds=sp) @@ -499,7 +670,13 @@ class StarTransaction(TelegramObject): of the original transaction for refund transactions. Coincides with :attr:`SuccessfulPayment.telegram_payment_charge_id` for successful incoming payments from users. - amount (:obj:`int`): Number of Telegram Stars transferred by the transaction. + amount (:obj:`int`): Integer amount of Telegram Stars transferred by the transaction. + nanostar_amount (:obj:`int`, optional): The number of + :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + Stars transferred by the transaction; from 0 to + :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT` + + .. versionadded:: NEXT.VERSION date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. source (:class:`telegram.TransactionPartner`, optional): Source of an incoming transaction (e.g., a user purchasing goods or services, Fragment refunding a failed withdrawal). @@ -513,7 +690,13 @@ class StarTransaction(TelegramObject): of the original transaction for refund transactions. Coincides with :attr:`SuccessfulPayment.telegram_payment_charge_id` for successful incoming payments from users. - amount (:obj:`int`): Number of Telegram Stars transferred by the transaction. + amount (:obj:`int`): Integer amount of Telegram Stars transferred by the transaction. + nanostar_amount (:obj:`int`): Optional. The number of + :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + Stars transferred by the transaction; from 0 to + :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT` + + .. versionadded:: NEXT.VERSION date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. source (:class:`telegram.TransactionPartner`): Optional. Source of an incoming transaction (e.g., a user purchasing goods or services, Fragment refunding a failed withdrawal). @@ -523,7 +706,7 @@ class StarTransaction(TelegramObject): outgoing transactions. """ - __slots__ = ("amount", "date", "id", "receiver", "source") + __slots__ = ("amount", "date", "id", "nanostar_amount", "receiver", "source") def __init__( self, @@ -532,6 +715,7 @@ def __init__( date: dtm.datetime, source: Optional[TransactionPartner] = None, receiver: Optional[TransactionPartner] = None, + nanostar_amount: Optional[int] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: @@ -541,6 +725,7 @@ def __init__( self.date: dtm.datetime = date self.source: Optional[TransactionPartner] = source self.receiver: Optional[TransactionPartner] = receiver + self.nanostar_amount: Optional[int] = nanostar_amount self._id_attrs = ( self.id, diff --git a/telegram/_utils/enum.py b/telegram/_utils/enum.py index e58d3c0cb0a..2f0e77b9b2c 100644 --- a/telegram/_utils/enum.py +++ b/telegram/_utils/enum.py @@ -74,3 +74,17 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self.value) + + +class FloatEnum(float, _enum.Enum): + """Helper class for float enums where ``str(member)`` prints the value, but ``repr(member)`` + gives ``EnumName.MEMBER_NAME``. + """ + + __slots__ = () + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}.{self.name}>" + + def __str__(self) -> str: + return str(self.value) diff --git a/telegram/constants.py b/telegram/constants.py index 4f0b993f30d..71f9f376661 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -95,6 +95,7 @@ "ReactionType", "ReplyLimit", "RevenueWithdrawalStateType", + "StarTransactions", "StarTransactionsLimit", "StickerFormat", "StickerLimit", @@ -112,7 +113,7 @@ from typing import Final, NamedTuple, Optional from telegram._utils.datetime import UTC -from telegram._utils.enum import IntEnum, StringEnum +from telegram._utils.enum import FloatEnum, IntEnum, StringEnum class _BotAPIVersion(NamedTuple): @@ -153,7 +154,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=0) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=1) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -2461,8 +2462,25 @@ class RevenueWithdrawalStateType(StringEnum): """:obj:`str`: A withdrawal failed and the transaction was refunded.""" +class StarTransactions(FloatEnum): + """This enum contains constants for :class:`telegram.StarTransaction`. + The enum members of this enumeration are instances of :class:`float` and can be treated as + such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + NANOSTAR_VALUE = 1 / 1000000000 + """:obj:`float`: The value of one nanostar as used in + :attr:`telegram.StarTransaction.nanostar_amount`. + """ + + class StarTransactionsLimit(IntEnum): - """This enum contains limitations for :class:`telegram.Bot.get_star_transactions`. + """This enum contains limitations for :class:`telegram.Bot.get_star_transactions` and + :class:`telegram.StarTransaction`. The enum members of this enumeration are instances of :class:`int` and can be treated as such. .. versionadded:: 21.4 @@ -2478,6 +2496,20 @@ class StarTransactionsLimit(IntEnum): """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Bot.get_star_transactions.limit` parameter of :meth:`telegram.Bot.get_star_transactions`.""" + NANOSTAR_MIN_AMOUNT = -999999999 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.AffiliateInfo.nanostar_amount` + parameter of :class:`telegram.AffiliateInfo`. + + .. versionadded:: NEXT.VERSION + """ + NANOSTAR_MAX_AMOUNT = 999999999 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.StarTransaction.nanostar_amount` + parameter of :class:`telegram.StarTransaction` and + :paramref:`~telegram.AffiliateInfo.nanostar_amount` parameter of + :class:`telegram.AffiliateInfo`. + + .. versionadded:: NEXT.VERSION + """ class StickerFormat(StringEnum): @@ -2622,6 +2654,11 @@ class TransactionPartnerType(StringEnum): __slots__ = () + AFFILIATE_PROGRAM = "affiliate_program" + """:obj:`str`: Transaction with Affiliate Program. + + .. versionadded:: NEXT.VERSION + """ FRAGMENT = "fragment" """:obj:`str`: Withdrawal transaction with Fragment.""" OTHER = "other" @@ -2925,6 +2962,12 @@ class InvoiceLimit(IntEnum): .. versionadded:: 21.8 """ + SUBSCRIPTION_MAX_PRICE = 2500 + """:obj:`int`: The maximum price of a subscription created wtih + :meth:`telegram.Bot.create_invoice_link`. + + .. versionadded:: NEXT.VERSION + """ class UserProfilePhotosLimit(IntEnum): diff --git a/tests/test_constants.py b/tests/test_constants.py index dc76bea3aef..45304a78a38 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -24,7 +24,7 @@ import pytest from telegram import Message, constants -from telegram._utils.enum import IntEnum, StringEnum +from telegram._utils.enum import FloatEnum, IntEnum, StringEnum from telegram.error import BadRequest from tests.auxil.build_messages import make_message from tests.auxil.files import data_file @@ -41,6 +41,11 @@ class IntEnumTest(IntEnum): BAR = 2 +class FloatEnumTest(FloatEnum): + FOO = 1.1 + BAR = 2.1 + + class TestConstantsWithoutRequest: """Also test _utils.enum.StringEnum on the fly because tg.constants is currently the only place where that class is used.""" @@ -69,6 +74,7 @@ def test_message_attachment_type(self): def test_to_json(self): assert json.dumps(StrEnumTest.FOO) == json.dumps("foo") assert json.dumps(IntEnumTest.FOO) == json.dumps(1) + assert json.dumps(FloatEnumTest.FOO) == json.dumps(1.1) def test_string_representation(self): # test __repr__ @@ -90,6 +96,15 @@ def test_int_representation(self): # test __str__ assert str(IntEnumTest.FOO) == "1" + def test_float_representation(self): + # test __repr__ + assert repr(FloatEnumTest.FOO) == "" + # test __format__ + assert f"{FloatEnumTest.FOO}/0 is undefined!" == "1.1/0 is undefined!" + assert f"{FloatEnumTest.FOO:*^10}" == "***1.1****" + # test __str__ + assert str(FloatEnumTest.FOO) == "1.1" + def test_string_inheritance(self): assert isinstance(StrEnumTest.FOO, str) assert StrEnumTest.FOO + StrEnumTest.BAR == "foobar" @@ -115,6 +130,18 @@ def test_int_inheritance(self): assert hash(IntEnumTest.FOO) == hash(1) + def test_float_inheritance(self): + assert isinstance(FloatEnumTest.FOO, float) + assert FloatEnumTest.FOO + FloatEnumTest.BAR == 3.2 + + assert FloatEnumTest.FOO == FloatEnumTest.FOO + assert FloatEnumTest.FOO == 1.1 + assert FloatEnumTest.FOO != FloatEnumTest.BAR + assert FloatEnumTest.FOO != 2.1 + assert object() != FloatEnumTest.FOO + + assert hash(FloatEnumTest.FOO) == hash(1.1) + def test_bot_api_version_and_info(self): assert str(constants.BOT_API_VERSION_INFO) == constants.BOT_API_VERSION assert ( diff --git a/tests/test_stars.py b/tests/test_stars.py index 5fb7a3c4068..542f24d41a6 100644 --- a/tests/test_stars.py +++ b/tests/test_stars.py @@ -18,11 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import datetime +from collections.abc import Sequence from copy import deepcopy import pytest from telegram import ( + Chat, Dice, Gift, PaidMediaPhoto, @@ -34,6 +36,7 @@ StarTransaction, StarTransactions, Sticker, + TelegramObject, TransactionPartner, TransactionPartnerFragment, TransactionPartnerOther, @@ -42,6 +45,7 @@ TransactionPartnerUser, User, ) +from telegram._payment.stars import AffiliateInfo, TransactionPartnerAffiliateProgram from telegram._utils.datetime import UTC, from_timestamp, to_timestamp from telegram.constants import RevenueWithdrawalStateType, TransactionPartnerType from tests.auxil.slots import mro_slots @@ -67,6 +71,13 @@ def withdrawal_state_pending(): def transaction_partner_user(): return TransactionPartnerUser( user=User(id=1, is_bot=False, first_name="first_name", username="username"), + affiliate=AffiliateInfo( + affiliate_user=User(id=2, is_bot=True, first_name="first_name", username="username"), + affiliate_chat=Chat(id=3, type="private", title="title"), + commission_per_mille=1, + amount=2, + nanostar_amount=3, + ), invoice_payload="payload", paid_media=[ PaidMediaPhoto( @@ -97,6 +108,13 @@ def transaction_partner_user(): ) +def transaction_partner_affiliate_program(): + return TransactionPartnerAffiliateProgram( + sponsor_user=User(id=1, is_bot=True, first_name="first_name", username="username"), + commission_per_mille=42, + ) + + def transaction_partner_fragment(): return TransactionPartnerFragment( withdrawal_state=withdrawal_state_succeeded(), @@ -107,6 +125,7 @@ def star_transaction(): return StarTransaction( id="1", amount=1, + nanostar_amount=365, date=to_timestamp(datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC)), source=transaction_partner_user(), receiver=transaction_partner_fragment(), @@ -126,6 +145,7 @@ def star_transactions(): @pytest.fixture( scope="module", params=[ + TransactionPartner.AFFILIATE_PROGRAM, TransactionPartner.FRAGMENT, TransactionPartner.OTHER, TransactionPartner.TELEGRAM_ADS, @@ -140,6 +160,7 @@ def tp_scope_type(request): @pytest.fixture( scope="module", params=[ + TransactionPartnerAffiliateProgram, TransactionPartnerFragment, TransactionPartnerOther, TransactionPartnerTelegramAds, @@ -147,6 +168,7 @@ def tp_scope_type(request): TransactionPartnerUser, ], ids=[ + TransactionPartner.AFFILIATE_PROGRAM, TransactionPartner.FRAGMENT, TransactionPartner.OTHER, TransactionPartner.TELEGRAM_ADS, @@ -161,6 +183,7 @@ def tp_scope_class(request): @pytest.fixture( scope="module", params=[ + (TransactionPartnerAffiliateProgram, TransactionPartner.AFFILIATE_PROGRAM), (TransactionPartnerFragment, TransactionPartner.FRAGMENT), (TransactionPartnerOther, TransactionPartner.OTHER), (TransactionPartnerTelegramAds, TransactionPartner.TELEGRAM_ADS), @@ -168,6 +191,7 @@ def tp_scope_class(request): (TransactionPartnerUser, TransactionPartner.USER), ], ids=[ + TransactionPartner.AFFILIATE_PROGRAM, TransactionPartner.FRAGMENT, TransactionPartner.OTHER, TransactionPartner.TELEGRAM_ADS, @@ -188,7 +212,14 @@ def transaction_partner(tp_scope_class_and_type): "invoice_payload": TransactionPartnerTestBase.invoice_payload, "withdrawal_state": TransactionPartnerTestBase.withdrawal_state.to_dict(), "user": TransactionPartnerTestBase.user.to_dict(), + "affiliate": TransactionPartnerTestBase.affiliate.to_dict(), "request_count": TransactionPartnerTestBase.request_count, + "sponsor_user": TransactionPartnerTestBase.sponsor_user.to_dict(), + "commission_per_mille": TransactionPartnerTestBase.commission_per_mille, + "gift": TransactionPartnerTestBase.gift.to_dict(), + "paid_media": [m.to_dict() for m in TransactionPartnerTestBase.paid_media], + "paid_media_payload": TransactionPartnerTestBase.paid_media_payload, + "subscription_period": TransactionPartnerTestBase.subscription_period.total_seconds(), }, bot=None, ) @@ -256,6 +287,7 @@ def revenue_withdrawal_state(rws_scope_class_and_type): class StarTransactionTestBase: id = "2" amount = 2 + nanostar_amount = 365 date = to_timestamp(datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC)) source = TransactionPartnerUser( user=User( @@ -278,6 +310,7 @@ def test_de_json(self, offline_bot): json_dict = { "id": self.id, "amount": self.amount, + "nanostar_amount": self.nanostar_amount, "date": self.date, "source": self.source.to_dict(), "receiver": self.receiver.to_dict(), @@ -287,6 +320,7 @@ def test_de_json(self, offline_bot): assert st.api_kwargs == {} assert st.id == self.id assert st.amount == self.amount + assert st.nanostar_amount == self.nanostar_amount assert st.date == from_timestamp(self.date) assert st.source == self.source assert st.receiver == self.receiver @@ -311,6 +345,7 @@ def test_to_dict(self): expected_dict = { "id": "1", "amount": 1, + "nanostar_amount": 365, "date": st.date, "source": st.source.to_dict(), "receiver": st.receiver.to_dict(), @@ -401,8 +436,15 @@ def test_equality(self): class TransactionPartnerTestBase: withdrawal_state = withdrawal_state_succeeded() user = transaction_partner_user().user + affiliate = transaction_partner_user().affiliate invoice_payload = "payload" request_count = 42 + sponsor_user = transaction_partner_affiliate_program().sponsor_user + commission_per_mille = transaction_partner_affiliate_program().commission_per_mille + gift = transaction_partner_user().gift + paid_media = transaction_partner_user().paid_media + paid_media_payload = transaction_partner_user().paid_media_payload + subscription_period = transaction_partner_user().subscription_period class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase): @@ -421,26 +463,28 @@ def test_de_json(self, offline_bot, tp_scope_class_and_type): "invoice_payload": self.invoice_payload, "withdrawal_state": self.withdrawal_state.to_dict(), "user": self.user.to_dict(), + "affiliate": self.affiliate.to_dict(), "request_count": self.request_count, + "sponsor_user": self.sponsor_user.to_dict(), + "commission_per_mille": self.commission_per_mille, } tp = TransactionPartner.de_json(json_dict, offline_bot) assert set(tp.api_kwargs.keys()) == { "user", + "affiliate", "withdrawal_state", "invoice_payload", "request_count", + "sponsor_user", + "commission_per_mille", } - set(cls.__slots__) assert isinstance(tp, TransactionPartner) assert type(tp) is cls assert tp.type == type_ - if "withdrawal_state" in cls.__slots__: - assert tp.withdrawal_state == self.withdrawal_state - if "user" in cls.__slots__: - assert tp.user == self.user - assert tp.invoice_payload == self.invoice_payload - if "request_count" in cls.__slots__: - assert tp.request_count == self.request_count + for key in json_dict: + if key in cls.__slots__: + assert getattr(tp, key) == getattr(self, key) assert cls.de_json(None, offline_bot) is None assert TransactionPartner.de_json({}, offline_bot) is None @@ -451,14 +495,20 @@ def test_de_json_invalid_type(self, offline_bot): "invoice_payload": self.invoice_payload, "withdrawal_state": self.withdrawal_state.to_dict(), "user": self.user.to_dict(), + "affiliate": self.affiliate.to_dict(), "request_count": self.request_count, + "sponsor_user": self.sponsor_user.to_dict(), + "commission_per_mille": self.commission_per_mille, } tp = TransactionPartner.de_json(json_dict, offline_bot) assert tp.api_kwargs == { "withdrawal_state": self.withdrawal_state.to_dict(), "user": self.user.to_dict(), + "affiliate": self.affiliate.to_dict(), "invoice_payload": self.invoice_payload, "request_count": self.request_count, + "sponsor_user": self.sponsor_user.to_dict(), + "commission_per_mille": self.commission_per_mille, } assert type(tp) is TransactionPartner @@ -472,7 +522,9 @@ def test_de_json_subclass(self, tp_scope_class, offline_bot): "invoice_payload": self.invoice_payload, "withdrawal_state": self.withdrawal_state.to_dict(), "user": self.user.to_dict(), + "affiliate": self.affiliate.to_dict(), "request_count": self.request_count, + "commission_per_mille": self.commission_per_mille, } assert type(tp_scope_class.de_json(json_dict, offline_bot)) is tp_scope_class @@ -481,11 +533,16 @@ def test_to_dict(self, transaction_partner): assert isinstance(tp_dict, dict) assert tp_dict["type"] == transaction_partner.type - if hasattr(transaction_partner, "user"): - assert tp_dict["user"] == transaction_partner.user.to_dict() - assert tp_dict["invoice_payload"] == transaction_partner.invoice_payload - if hasattr(transaction_partner, "withdrawal_state"): - assert tp_dict["withdrawal_state"] == transaction_partner.withdrawal_state.to_dict() + for attr in transaction_partner.__slots__: + attribute = getattr(transaction_partner, attr) + if isinstance(attribute, TelegramObject): + assert tp_dict[attr] == attribute.to_dict() + elif not isinstance(attribute, str) and isinstance(attribute, Sequence): + assert tp_dict[attr] == [a.to_dict() for a in attribute] + elif isinstance(attribute, datetime.timedelta): + assert tp_dict[attr] == attribute.total_seconds() + else: + assert tp_dict[attr] == attribute def test_type_enum_conversion(self): assert type(TransactionPartner("other").type) is TransactionPartnerType @@ -661,3 +718,132 @@ def test_equality(self, revenue_withdrawal_state, offline_bot): assert c != f assert hash(c) != hash(f) + + +@pytest.fixture +def affiliate_info(): + return AffiliateInfo( + affiliate_user=AffiliateInfoTestBase.affiliate_user, + affiliate_chat=AffiliateInfoTestBase.affiliate_chat, + commission_per_mille=AffiliateInfoTestBase.commission_per_mille, + amount=AffiliateInfoTestBase.amount, + nanostar_amount=AffiliateInfoTestBase.nanostar_amount, + ) + + +class AffiliateInfoTestBase: + affiliate_user = User(id=1, is_bot=True, first_name="affiliate_user", username="username") + affiliate_chat = Chat(id=2, type="private", title="affiliate_chat") + commission_per_mille = 13 + amount = 14 + nanostar_amount = -42 + + +class TestAffiliateInfoWithoutRequest(AffiliateInfoTestBase): + def test_slot_behaviour(self, affiliate_info): + inst = affiliate_info + 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 = { + "affiliate_user": self.affiliate_user.to_dict(), + "affiliate_chat": self.affiliate_chat.to_dict(), + "commission_per_mille": self.commission_per_mille, + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + } + ai = AffiliateInfo.de_json(json_dict, offline_bot) + assert ai.api_kwargs == {} + assert ai.affiliate_user == self.affiliate_user + assert ai.affiliate_chat == self.affiliate_chat + assert ai.commission_per_mille == self.commission_per_mille + assert ai.amount == self.amount + assert ai.nanostar_amount == self.nanostar_amount + + assert AffiliateInfo.de_json(None, offline_bot) is None + assert AffiliateInfo.de_json({}, offline_bot) is None + + def test_to_dict(self, affiliate_info): + ai_dict = affiliate_info.to_dict() + + assert isinstance(ai_dict, dict) + assert ai_dict["affiliate_user"] == affiliate_info.affiliate_user.to_dict() + assert ai_dict["affiliate_chat"] == affiliate_info.affiliate_chat.to_dict() + assert ai_dict["commission_per_mille"] == affiliate_info.commission_per_mille + assert ai_dict["amount"] == affiliate_info.amount + assert ai_dict["nanostar_amount"] == affiliate_info.nanostar_amount + + def test_equality(self, affiliate_info, offline_bot): + a = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + b = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + c = AffiliateInfo( + affiliate_user=User(id=3, is_bot=True, first_name="first_name", username="username"), + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + d = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=Chat(id=3, type="private", title="title"), + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + e = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=1, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + f = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=1, + nanostar_amount=self.nanostar_amount, + ) + g = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=1, + ) + h = 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 a != f + assert hash(a) != hash(f) + + assert a != g + assert hash(a) != hash(g) + + assert a != h + assert hash(a) != hash(h)