Refactor and Overhaul test_official (#4087)

This commit is contained in:
Harshil 2024-02-09 12:12:13 -05:00 committed by GitHub
parent 680dc2b6b8
commit 1cf63c26c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 878 additions and 610 deletions

View file

@ -33,7 +33,7 @@ jobs:
python -W ignore -m pip install -r requirements-dev.txt
- name: Compare to official api
run: |
pytest -v tests/test_official.py --junit-xml=.test_report_official.xml
pytest -v tests/test_official/test_official.py --junit-xml=.test_report_official.xml
exit $?
env:
TEST_OFFICIAL: "true"

View file

@ -64,6 +64,8 @@ markers = [
"req",
]
asyncio_mode = "auto"
log_format = "%(funcName)s - Line %(lineno)d - %(message)s"
# log_level = "DEBUG" # uncomment to see DEBUG logs
# MYPY:
[tool.mypy]

View file

@ -6138,8 +6138,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
async def upload_sticker_file(
self,
user_id: int,
sticker: Optional[FileInput],
sticker_format: Optional[str],
sticker: FileInput,
sticker_format: str,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@ -6180,7 +6180,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
"""
data: JSONDict = {
"user_id": user_id,
"sticker": self._parse_file_input(sticker), # type: ignore[arg-type]
"sticker": self._parse_file_input(sticker),
"sticker_format": sticker_format,
}
result = await self._post(
@ -6199,7 +6199,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
self,
user_id: int,
name: str,
sticker: Optional["InputSticker"],
sticker: "InputSticker",
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@ -6298,8 +6298,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified.
user_id: int,
name: str,
title: str,
stickers: Optional[Sequence["InputSticker"]],
sticker_format: Optional[str],
stickers: Sequence["InputSticker"],
sticker_format: str,
sticker_type: Optional[str] = None,
needs_repainting: Optional[bool] = None,
*,

View file

@ -862,7 +862,7 @@ class ExtBot(Bot, Generic[RLARGS]):
self,
user_id: int,
name: str,
sticker: Optional["InputSticker"],
sticker: "InputSticker",
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,
@ -1177,8 +1177,8 @@ class ExtBot(Bot, Generic[RLARGS]):
user_id: int,
name: str,
title: str,
stickers: Optional[Sequence["InputSticker"]],
sticker_format: Optional[str],
stickers: Sequence["InputSticker"],
sticker_format: str,
sticker_type: Optional[str] = None,
needs_repainting: Optional[bool] = None,
*,
@ -3673,8 +3673,8 @@ class ExtBot(Bot, Generic[RLARGS]):
async def upload_sticker_file(
self,
user_id: int,
sticker: Optional[FileInput],
sticker_format: Optional[str],
sticker: FileInput,
sticker_format: str,
*,
read_timeout: ODVInput[float] = DEFAULT_NONE,
write_timeout: ODVInput[float] = DEFAULT_NONE,

View file

@ -82,6 +82,17 @@ Use as follows:
$ pytest -m dev
Debugging tests
===============
Writing tests can be challenging, and fixing failing tests can be even more so. To help with this,
PTB has started to adopt the use of ``logging`` in the test suite. You can insert debug logging
statements in your tests to help you understand what's going on. To enable these logs, you can set
``log_level = DEBUG`` in ``setup.cfg`` or use the ``--log-level=INFO`` flag when running the tests.
If a test is large and complicated, it is recommended to leave the debug logs for others to use as
well.
Bots used in tests
==================

View file

@ -57,7 +57,7 @@ collect_ignore = []
if sys.version_info < (3, 10):
if RUN_TEST_OFFICIAL:
logging.warning("Skipping test_official.py since it requires Python 3.10+")
collect_ignore.append("test_official.py")
collect_ignore_glob = ["test_official/*.py"]
# This is here instead of in setup.cfg due to https://github.com/pytest-dev/pytest/issues/8343

View file

@ -1,597 +0,0 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# 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/].
import inspect
import re
from datetime import datetime
from types import FunctionType
from typing import Any, Callable, ForwardRef, Sequence, get_args, get_origin
import httpx
import pytest
from bs4 import BeautifulSoup, PageElement, Tag
import telegram
from telegram._utils.defaultvalue import DefaultValue
from telegram._utils.types import FileInput, ODVInput
from telegram.ext import Defaults
from tests.auxil.envvars import RUN_TEST_OFFICIAL
IGNORED_OBJECTS = ("ResponseParameters", "CallbackGame")
GLOBALLY_IGNORED_PARAMETERS = {
"self",
"read_timeout",
"write_timeout",
"connect_timeout",
"pool_timeout",
"bot",
"api_kwargs",
}
# Types for certain parameters accepted by PTB but not in the official API
ADDITIONAL_TYPES = {
"photo": ForwardRef("PhotoSize"),
"video": ForwardRef("Video"),
"video_note": ForwardRef("VideoNote"),
"audio": ForwardRef("Audio"),
"document": ForwardRef("Document"),
"animation": ForwardRef("Animation"),
"voice": ForwardRef("Voice"),
"sticker": ForwardRef("Sticker"),
}
# Exceptions to the "Array of" types, where we accept more types than the official API
# key: parameter name, value: type which must be present in the annotation
ARRAY_OF_EXCEPTIONS = {
"results": "InlineQueryResult", # + Callable
"commands": "BotCommand", # + tuple[str, str]
"keyboard": "KeyboardButton", # + sequence[sequence[str]]
"reaction": "ReactionType", # + str
# TODO: Deprecated and will be corrected (and removed) in next major PTB version:
"file_hashes": "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:
COMPLEX_TYPES = { # (param_name, is_class (i.e appears in a class?)): reduced form of annotation
("correct_option_id", False): int, # actual: Literal
("file_id", False): str, # actual: Union[str, objs_with_file_id_attr]
("invite_link", False): str, # actual: Union[str, ChatInviteLink]
("provider_data", False): str, # actual: Union[str, obj]
("callback_data", True): str, # actual: Union[str, obj]
("media", True): str, # actual: Union[str, InputMedia*, FileInput]
("data", True): str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress]
}
# These are param names ignored in the param type checking in classes for the `tg.Defaults` case.
IGNORED_DEFAULTS_PARAM_NAMES = {
"quote",
"link_preview_options",
}
# These classes' params are all ODVInput, so we ignore them in the defaults type checking.
IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"}
def _get_params_base(object_name: str, search_dict: dict[str, set[Any]]) -> set[Any]:
"""Helper function for the *_params functions below.
Given an object name and a search dict, goes through the keys of the search dict and checks if
the object name matches any of the regexes (keys). The union of all the sets (values) of the
matching regexes is returned. `object_name` may be a CamelCase or snake_case name.
"""
out = set()
for regex, params in search_dict.items():
if re.fullmatch(regex, object_name):
out.update(params)
# also check the snake_case version
snake_case_name = re.sub(r"(?<!^)(?=[A-Z])", "_", object_name).lower()
if re.fullmatch(regex, snake_case_name):
out.update(params)
return out
# Arguments *added* to the official API
PTB_EXTRA_PARAMS = {
"send_contact": {"contact"},
"send_location": {"location"},
"edit_message_live_location": {"location"},
"send_venue": {"venue"},
"answer_inline_query": {"current_offset"},
"send_media_group": {"caption", "parse_mode", "caption_entities"},
"send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"},
"InlineQueryResult": {"id", "type"}, # attributes common to all subclasses
"ChatMember": {"user", "status"}, # attributes common to all subclasses
"BotCommandScope": {"type"}, # attributes common to all subclasses
"MenuButton": {"type"}, # attributes common to all subclasses
"PassportFile": {"credentials"},
"EncryptedPassportElement": {"credentials"},
"PassportElementError": {"source", "type", "message"},
"InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"},
"InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"},
"InputFile": {"attach", "filename", "obj"},
"MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls
"ChatBoostSource": {"source"}, # attributes common to all subclasses
"MessageOrigin": {"type", "date"}, # attributes common to all subclasses
"ReactionType": {"type"}, # attributes common to all subclasses
"InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat
}
def ptb_extra_params(object_name: str) -> set[str]:
return _get_params_base(object_name, PTB_EXTRA_PARAMS)
# Arguments *removed* from the official API
# Mostly due to the value being fixed anyway
PTB_IGNORED_PARAMS = {
r"InlineQueryResult\w+": {"type"},
r"ChatMember\w+": {"status"},
r"PassportElementError\w+": {"source"},
"ForceReply": {"force_reply"},
"ReplyKeyboardRemove": {"remove_keyboard"},
r"BotCommandScope\w+": {"type"},
r"MenuButton\w+": {"type"},
r"InputMedia\w+": {"type"},
"InaccessibleMessage": {"date"},
r"MessageOrigin\w+": {"type"},
r"ChatBoostSource\w+": {"source"},
r"ReactionType\w+": {"type"},
}
def ptb_ignored_params(object_name: str) -> set[str]:
return _get_params_base(object_name, PTB_IGNORED_PARAMS)
IGNORED_PARAM_REQUIREMENTS = {
# Ignore these since there's convenience params in them (eg. Venue)
# <----
"send_location": {"latitude", "longitude"},
"edit_message_live_location": {"latitude", "longitude"},
"send_venue": {"latitude", "longitude", "title", "address"},
"send_contact": {"phone_number", "first_name"},
# ---->
}
def ignored_param_requirements(object_name: str) -> set[str]:
return _get_params_base(object_name, IGNORED_PARAM_REQUIREMENTS)
# Arguments that are optional arguments for now for backwards compatibility
BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {
# Deprecated by Bot API 7.0, kept for now for bw compat:
"KeyboardButton": {"request_user"},
"Message": {
"forward_from",
"forward_signature",
"forward_sender_name",
"forward_date",
"forward_from_chat",
"forward_from_message_id",
"user_shared",
},
"(send_message|edit_message_text)": {
"disable_web_page_preview",
"reply_to_message_id",
"allow_sending_without_reply",
},
r"copy_message|send_\w+": {"allow_sending_without_reply", "reply_to_message_id"},
}
def backwards_compat_kwargs(object_name: str) -> set[str]:
return _get_params_base(object_name, BACKWARDS_COMPAT_KWARGS)
IGNORED_PARAM_REQUIREMENTS.update(BACKWARDS_COMPAT_KWARGS)
def find_next_sibling_until(tag: Tag, name: str, until: Tag) -> PageElement | None:
for sibling in tag.next_siblings:
if sibling is until:
return None
if sibling.name == name:
return sibling
return None
def parse_table(h4: Tag) -> list[list[str]]:
"""Parses the Telegram doc table and has an output of a 2D list."""
table = find_next_sibling_until(h4, "table", h4.find_next_sibling("h4"))
if not table:
return []
return [[td.text for td in tr.find_all("td")] for tr in table.find_all("tr")[1:]]
def check_method(h4: Tag) -> None:
name = h4.text # name of the method in telegram's docs.
method: FunctionType | None = getattr(telegram.Bot, name, None) # Retrieve our lib method
if not method:
raise AssertionError(f"Method {name} not found in telegram.Bot")
table = parse_table(h4)
# Check arguments based on source
sig = inspect.signature(method, follow_wrapped=True)
checked = []
for tg_parameter in table: # Iterates through each row in the table
# Check if parameter is present in our method
param = sig.parameters.get(
tg_parameter[0] # parameter[0] is first element (the param name)
)
if param is None:
raise AssertionError(f"Parameter {tg_parameter[0]} not found in {method.__name__}")
# Check if type annotation is present and correct
if param.annotation is inspect.Parameter.empty:
raise AssertionError(
f"Param {param.name!r} of {method.__name__!r} should have a type annotation"
)
if not check_param_type(param, tg_parameter, method):
raise AssertionError(
f"Param {param.name!r} of {method.__name__!r} should be {tg_parameter[1]} or "
"something else!"
)
# Now check if the parameter is required or not
if not check_required_param(tg_parameter, param, method.__name__):
raise AssertionError(
f"Param {param.name!r} of method {method.__name__!r} requirement mismatch!"
)
# Now we will check that we don't pass default values if the parameter is not required.
if param.default is not inspect.Parameter.empty: # If there is a default argument...
default_arg_none = check_defaults_type(param) # check if it's None
if not default_arg_none:
raise AssertionError(f"Param {param.name!r} of {method.__name__!r} should be None")
checked.append(tg_parameter[0])
expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy()
expected_additional_args |= ptb_extra_params(name)
expected_additional_args |= backwards_compat_kwargs(name)
unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args
if unexpected_args != set():
raise AssertionError(
f"In {method.__qualname__}, unexpected args were found: {unexpected_args}."
)
kw_or_positional_args = [
p.name for p in sig.parameters.values() if p.kind != inspect.Parameter.KEYWORD_ONLY
]
non_kw_only_args = set(kw_or_positional_args).difference(checked).difference(["self"])
non_kw_only_args -= backwards_compat_kwargs(name)
if non_kw_only_args != set():
raise AssertionError(
f"In {method.__qualname__}, extra args should be keyword only "
f"(compared to {name} in API)"
)
def check_object(h4: Tag) -> None:
name = h4.text
obj = getattr(telegram, name)
table = parse_table(h4)
# Check arguments based on source. Makes sure to only check __init__'s signature & nothing else
sig = inspect.signature(obj.__init__, follow_wrapped=True)
checked = set()
fields_removed_by_ptb = ptb_ignored_params(name)
for tg_parameter in table:
field: str = tg_parameter[0] # From telegram docs
if field in fields_removed_by_ptb:
continue
if field == "from":
field = "from_user"
param = sig.parameters.get(field)
if param is None:
raise AssertionError(f"Attribute {field} not found in {obj.__name__}")
# Check if type annotation is present and correct
if param.annotation is inspect.Parameter.empty:
raise AssertionError(
f"Param {param.name!r} of {obj.__name__!r} should have a type annotation"
)
if not check_param_type(param, tg_parameter, obj):
raise AssertionError(
f"Param {param.name!r} of {obj.__name__!r} should be {tg_parameter[1]} or "
"something else!"
)
if not check_required_param(tg_parameter, param, obj.__name__):
raise AssertionError(f"{obj.__name__!r} parameter {param.name!r} requirement mismatch")
if param.default is not inspect.Parameter.empty: # If there is a default argument...
default_arg_none = check_defaults_type(param) # check if its None
if not default_arg_none:
raise AssertionError(f"Param {param.name!r} of {obj.__name__!r} should be `None`")
checked.add(field)
expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy()
expected_additional_args |= ptb_extra_params(name)
expected_additional_args |= backwards_compat_kwargs(name)
unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args
if unexpected_args != set():
raise AssertionError(f"In {name}, unexpected args were found: {unexpected_args}.")
def is_parameter_required_by_tg(field: str) -> bool:
if field in {"Required", "Yes"}:
return True
return field.split(".", 1)[0] != "Optional" # splits the sentence and extracts first word
def check_required_param(
param_desc: list[str], param: inspect.Parameter, method_or_obj_name: str
) -> bool:
"""Checks if the method/class parameter is a required/optional param as per Telegram docs.
Returns:
:obj:`bool`: The boolean returned represents whether our parameter's requirement (optional
or required) is the same as Telegram's or not.
"""
is_ours_required = param.default is inspect.Parameter.empty
telegram_requires = is_parameter_required_by_tg(param_desc[2])
# Handle cases where we provide convenience intentionally-
if param.name in ignored_param_requirements(method_or_obj_name):
return True
return telegram_requires is is_ours_required
def check_defaults_type(ptb_param: inspect.Parameter) -> bool:
return DefaultValue.get_value(ptb_param.default) is None
def check_param_type(
ptb_param: inspect.Parameter, tg_parameter: list[str], obj: FunctionType | type
) -> bool:
"""This function checks whether the type annotation of the parameter is the same as the one
specified in the official API. It also checks for some special cases where we accept more types
Args:
ptb_param (inspect.Parameter): The parameter object from our methods/classes
tg_parameter (list[str]): The table row corresponding to the parameter from official API.
obj (object): The object (method/class) that we are checking.
Returns:
:obj:`bool`: The boolean returned represents whether our parameter's type annotation is the
same as Telegram's or not.
"""
# PRE-PROCESSING:
# In order to evaluate the type annotation, we need to first have a mapping of the types
# specified in the official API to our types. The keys are types in the column of official API.
TYPE_MAPPING: dict[str, set[Any]] = {
"Integer or String": {int | str},
"Integer": {int},
"String": {str},
r"Boolean|True": {bool},
r"Float(?: number)?": {float},
# Distinguishing 1D and 2D Sequences and finding the inner type is done later.
r"Array of (?:Array of )?[\w\,\s]*": {Sequence},
r"InputFile(?: or String)?": {FileInput},
}
tg_param_type: str = tg_parameter[1] # Type of parameter as specified in the docs
is_class = inspect.isclass(obj)
# Let's check for a match:
mapped: set[type] = _get_params_base(tg_param_type, TYPE_MAPPING)
# We should have a maximum of one match.
assert len(mapped) <= 1, f"More than one match found for {tg_param_type}"
if not mapped: # no match found, it's from telegram module
# it could be a list of objects, so let's check that:
objs = _extract_words(tg_param_type)
# We want to store both string version of class and the class obj itself. e.g. "InputMedia"
# and InputMedia because some annotations might be ForwardRefs.
if len(objs) >= 2: # We have to unionize the objects
mapped_type: tuple[Any, ...] = (_unionizer(objs, False), _unionizer(objs, True))
else:
mapped_type = (
getattr(telegram, tg_param_type), # This will fail if it's not from telegram mod
ForwardRef(tg_param_type),
tg_param_type, # for some reason, some annotations are just a string.
)
elif len(mapped) == 1:
mapped_type = mapped.pop()
# Resolve nested annotations to get inner types.
if (ptb_annotation := list(get_args(ptb_param.annotation))) == []:
ptb_annotation = ptb_param.annotation # if it's not nested, just use the annotation
if isinstance(ptb_annotation, list):
# Some cleaning:
# Remove 'Optional[...]' from the annotation if it's present. We do it this way since: 1)
# we already check if argument should be optional or not + type checkers will complain.
# 2) we want to check if our `obj` is same as API's `obj`, and since python evaluates
# `Optional[obj] != obj` we have to remove the Optional, so that we can compare the two.
if type(None) in ptb_annotation:
ptb_annotation.remove(type(None))
# Cleaning done... now let's put it back together.
# Join all the annotations back (i.e. Union)
ptb_annotation = _unionizer(ptb_annotation, False)
# Last step, we need to use get_origin to get the original type, since using get_args
# above will strip that out.
wrapped = get_origin(ptb_param.annotation)
if wrapped is not None:
# collections.abc.Sequence -> typing.Sequence
if "collections.abc.Sequence" in str(wrapped):
wrapped = Sequence
ptb_annotation = wrapped[ptb_annotation]
# We have put back our annotation together after removing the NoneType!
# CHECKING:
# Each branch may have exits in the form of return statements. If the annotation is found to be
# correct, the function will return True. If not, it will return False.
# 1) HANDLING ARRAY TYPES:
# Now let's do the checking, starting with "Array of ..." types.
if "Array of " in tg_param_type:
assert mapped_type is Sequence
# For exceptions just check if they contain the annotation
if ptb_param.name in ARRAY_OF_EXCEPTIONS:
return ARRAY_OF_EXCEPTIONS[ptb_param.name] in str(ptb_annotation)
pattern = r"Array of(?: Array of)? ([\w\,\s]*)"
obj_match: re.Match | None = re.search(pattern, tg_param_type) # extract obj from string
if obj_match is None:
raise AssertionError(f"Array of {tg_param_type} not found in {ptb_param.name}")
obj_str: str = obj_match.group(1)
# is obj a regular type like str?
array_of_mapped: set[type] = _get_params_base(obj_str, TYPE_MAPPING)
if len(array_of_mapped) == 0: # no match found, it's from telegram module
# it could be a list of objects, so let's check that:
objs = _extract_words(obj_str)
# let's unionize all the objects, with and without ForwardRefs.
unionized_objs: list[type] = [_unionizer(objs, True), _unionizer(objs, False)]
else:
unionized_objs = [array_of_mapped.pop()]
# This means it is Array of Array of [obj]
if "Array of Array of" in tg_param_type:
return any(Sequence[Sequence[o]] == ptb_annotation for o in unionized_objs)
# This means it is Array of [obj]
return any(mapped_type[o] == ptb_annotation for o in unionized_objs)
# 2) HANDLING DEFAULTS PARAMETERS:
# Classes whose parameters are all ODVInput should be converted and checked.
if obj.__name__ in IGNORED_DEFAULTS_CLASSES:
parsed = ODVInput[mapped_type]
return (ptb_annotation | None) == parsed # We have to add back None in our annotation
if not (
# Defaults checking should not be done for:
# 1. Parameters that have name conflict with `Defaults.name`
is_class
and obj.__name__ in ("ReplyParameters", "Message", "ExternalReplyInfo")
and ptb_param.name in IGNORED_DEFAULTS_PARAM_NAMES
):
# Now let's check if the parameter is a Defaults parameter, it should be
for name, _ in inspect.getmembers(Defaults, lambda x: isinstance(x, property)):
if name == ptb_param.name or "parse_mode" in ptb_param.name:
# mapped_type should not be a tuple since we need to check for equality:
# This can happen when the Defaults parameter is a class, e.g. LinkPreviewOptions
if isinstance(mapped_type, tuple):
mapped_type = mapped_type[1] # We select the ForwardRef
# Assert if it's ODVInput by checking equality:
parsed = ODVInput[mapped_type]
if (ptb_annotation | None) == parsed: # We have to add back None in our annotation
return True
return False
# 3) HANDLING OTHER TYPES:
# Special case for send_* methods where we accept more types than the official API:
if (
ptb_param.name in ADDITIONAL_TYPES
and not isinstance(mapped_type, tuple)
and obj.__name__.startswith("send")
):
mapped_type = mapped_type | ADDITIONAL_TYPES[ptb_param.name]
# 4) HANDLING DATETIMES:
if (
re.search(
r"""([_]+|\b) # check for word boundary or underscore
date # check for "date"
[^\w]*\b # optionally check for a word after 'date'
""",
ptb_param.name,
re.VERBOSE,
)
or "Unix time" in tg_parameter[-1]
):
# TODO: Remove this in v22 when it becomes a datetime
datetime_exceptions = {
"file_date",
}
if ptb_param.name in datetime_exceptions:
return True
# If it's a class, we only accept datetime as the parameter
mapped_type = datetime if is_class else mapped_type | datetime
# RESULTS: ALL OTHER BASIC TYPES-
# Some types are too complicated, so we replace them with a simpler type:
for (param_name, expected_class), exception_type in COMPLEX_TYPES.items():
if ptb_param.name == param_name and is_class is expected_class:
ptb_annotation = exception_type
# Final check, if the annotation is a tuple, we need to check if any of the types in the tuple
# match the mapped type.
if isinstance(mapped_type, tuple) and any(ptb_annotation == t for t in mapped_type):
return True
# If the annotation is not a tuple, we can just check if it's equal to the mapped type.
return mapped_type == ptb_annotation
def _extract_words(text: str) -> set[str]:
"""Extracts all words from a string, removing all punctuation and words like 'and' & 'or'."""
return set(re.sub(r"[^\w\s]", "", text).split()) - {"and", "or"}
def _unionizer(annotation: Sequence[Any] | set[Any], forward_ref: bool) -> Any:
"""Returns a union of all the types in the annotation. If forward_ref is True, it wraps the
annotation in a ForwardRef and then unionizes."""
union = None
for t in annotation:
if forward_ref:
t = ForwardRef(t) # noqa: PLW2901
elif not forward_ref and isinstance(t, str): # we have to import objects from lib
t = getattr(telegram, t) # noqa: PLW2901
union = t if union is None else union | t
return union
argvalues: list[tuple[Callable[[Tag], None], Tag]] = []
names: list[str] = []
if RUN_TEST_OFFICIAL:
argvalues = []
names = []
request = httpx.get("https://core.telegram.org/bots/api")
soup = BeautifulSoup(request.text, "html.parser")
for thing in soup.select("h4 > a.anchor"):
# Methods and types don't have spaces in them, luckily all other sections of the docs do
# TODO: don't depend on that
if "-" not in thing["name"]:
h4: Tag | None = thing.parent
if h4 is None:
raise AssertionError("h4 is None")
# Is it a method
if h4.text[0].lower() == h4.text[0]:
argvalues.append((check_method, h4))
names.append(h4.text)
elif h4.text not in IGNORED_OBJECTS: # Or a type/object
argvalues.append((check_object, h4))
names.append(h4.text)
@pytest.mark.skipif(not RUN_TEST_OFFICIAL, reason="test_official is not enabled")
@pytest.mark.parametrize(("method", "data"), argvalues=argvalues, ids=names)
def test_official(method, data):
method(data)

View file

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains functions which confirm that the parameters of our methods and classes
match the official API. It also checks if the type annotations are correct and if the parameters
are required or not."""
import inspect
import logging
import re
from datetime import datetime
from types import FunctionType
from typing import Any, Sequence
from telegram._utils.defaultvalue import DefaultValue
from telegram._utils.types import FileInput, ODVInput
from telegram.ext import Defaults
from tests.test_official.exceptions import ParamTypeCheckingExceptions as PTCE
from tests.test_official.exceptions import ignored_param_requirements
from tests.test_official.helpers import (
_extract_words,
_get_params_base,
_unionizer,
cached_type_hints,
resolve_forward_refs_in_type,
wrap_with_none,
)
from tests.test_official.scraper import TelegramParameter
ARRAY_OF_PATTERN = r"Array of(?: Array of)? ([\w\,\s]*)"
# In order to evaluate the type annotation, we need to first have a mapping of the types
# specified in the official API to our types. The keys are types in the column of official API.
TYPE_MAPPING: dict[str, set[Any]] = {
"Integer or String": {int | str},
"Integer": {int},
"String": {str},
r"Boolean|True": {bool},
r"Float(?: number)?": {float},
# Distinguishing 1D and 2D Sequences and finding the inner type is done later.
ARRAY_OF_PATTERN: {Sequence},
r"InputFile(?: or String)?": {resolve_forward_refs_in_type(FileInput)},
}
ALL_DEFAULTS = inspect.getmembers(Defaults, lambda x: isinstance(x, property))
DATETIME_REGEX = re.compile(
r"""([_]+|\b) # check for word boundary or underscore
date # check for "date"
[^\w]*\b # optionally check for a word after 'date'
""",
re.VERBOSE,
)
log = logging.debug
def check_required_param(
tg_param: TelegramParameter, param: inspect.Parameter, method_or_obj_name: str
) -> bool:
"""Checks if the method/class parameter is a required/optional param as per Telegram docs.
Returns:
:obj:`bool`: The boolean returned represents whether our parameter's requirement (optional
or required) is the same as Telegram's or not.
"""
is_ours_required = param.default is inspect.Parameter.empty
# Handle cases where we provide convenience intentionally-
if param.name in ignored_param_requirements(method_or_obj_name):
return True
return tg_param.param_required is is_ours_required
def check_defaults_type(ptb_param: inspect.Parameter) -> bool:
return DefaultValue.get_value(ptb_param.default) is None
def check_param_type(
ptb_param: inspect.Parameter,
tg_parameter: TelegramParameter,
obj: FunctionType | type,
) -> tuple[bool, type]:
"""This function checks whether the type annotation of the parameter is the same as the one
specified in the official API. It also checks for some special cases where we accept more types
Args:
ptb_param: The parameter object from our methods/classes
tg_parameter: The table row corresponding to the parameter from official API.
obj: The object (method/class) that we are checking.
Returns:
:obj:`tuple`: A tuple containing:
* :obj:`bool`: The boolean returned represents whether our parameter's type annotation
is the same as Telegram's or not.
* :obj:`type`: The expected type annotation of the parameter.
"""
# PRE-PROCESSING:
tg_param_type: str = tg_parameter.param_type
is_class = inspect.isclass(obj)
ptb_annotation = cached_type_hints(obj, is_class).get(ptb_param.name)
# Let's check for a match:
# In order to evaluate the type annotation, we need to first have a mapping of the types
# (see TYPE_MAPPING comment defined at the top level of this module)
mapped: set[type] = _get_params_base(tg_param_type, TYPE_MAPPING)
# We should have a maximum of one match.
assert len(mapped) <= 1, f"More than one match found for {tg_param_type}"
# it may be a list of objects, so let's extract them using _extract_words:
mapped_type = _unionizer(_extract_words(tg_param_type)) if not mapped else mapped.pop()
# If the parameter is not required by TG, `None` should be added to `mapped_type`
mapped_type = wrap_with_none(tg_parameter, mapped_type, obj)
log(
"At the end of PRE-PROCESSING, the values of variables are:\n"
"Parameter name: %s\n"
"ptb_annotation= %s\n"
"mapped_type= %s\n"
"tg_param_type= %s\n"
"tg_parameter.param_required= %s\n",
ptb_param.name,
ptb_annotation,
mapped_type,
tg_param_type,
tg_parameter.param_required,
)
# CHECKING:
# Each branch manipulates the `mapped_type` (except for 4) ) to match the `ptb_annotation`.
# 1) HANDLING ARRAY TYPES:
# Now let's do the checking, starting with "Array of ..." types.
if "Array of " in tg_param_type:
# For exceptions just check if they contain the annotation
if ptb_param.name in PTCE.ARRAY_OF_EXCEPTIONS:
return PTCE.ARRAY_OF_EXCEPTIONS[ptb_param.name] in str(ptb_annotation), Sequence
obj_match: re.Match | None = re.search(ARRAY_OF_PATTERN, tg_param_type)
if obj_match is None:
raise AssertionError(f"Array of {tg_param_type} not found in {ptb_param.name}")
obj_str: str = obj_match.group(1)
# is obj a regular type like str?
array_map: set[type] = _get_params_base(obj_str, TYPE_MAPPING)
mapped_type = _unionizer(_extract_words(obj_str)) if not array_map else array_map.pop()
if "Array of Array of" in tg_param_type:
log("Array of Array of type found in `%s`\n", tg_param_type)
mapped_type = Sequence[Sequence[mapped_type]]
else:
log("Array of type found in `%s`\n", tg_param_type)
mapped_type = Sequence[mapped_type]
# 2) HANDLING OTHER TYPES:
# Special case for send_* methods where we accept more types than the official API:
elif ptb_param.name in PTCE.ADDITIONAL_TYPES and obj.__name__.startswith("send"):
log("Checking that `%s` has an additional argument!\n", ptb_param.name)
mapped_type = mapped_type | PTCE.ADDITIONAL_TYPES[ptb_param.name]
# 3) HANDLING DATETIMES:
elif (
re.search(
DATETIME_REGEX,
ptb_param.name,
)
or "Unix time" in tg_parameter.param_description
):
log("Checking that `%s` is a datetime!\n", ptb_param.name)
if ptb_param.name in PTCE.DATETIME_EXCEPTIONS:
return True, mapped_type
# If it's a class, we only accept datetime as the parameter
mapped_type = datetime if is_class else mapped_type | datetime
# 4) COMPLEX TYPES:
# Some types are too complicated, so we replace our annotation with a simpler type:
elif any(ptb_param.name in key for key in PTCE.COMPLEX_TYPES):
log("Converting `%s` to a simpler type!\n", ptb_param.name)
for (param_name, is_expected_class), exception_type in PTCE.COMPLEX_TYPES.items():
if ptb_param.name == param_name and is_class is is_expected_class:
ptb_annotation = wrap_with_none(tg_parameter, exception_type, obj)
# 5) HANDLING DEFAULTS PARAMETERS:
# Classes whose parameters are all ODVInput should be converted and checked.
elif obj.__name__ in PTCE.IGNORED_DEFAULTS_CLASSES:
log("Checking that `%s`'s param is ODVInput:\n", obj.__name__)
mapped_type = ODVInput[mapped_type]
elif not (
# Defaults checking should not be done for:
# 1. Parameters that have name conflict with `Defaults.name`
is_class
and obj.__name__ in ("ReplyParameters", "Message", "ExternalReplyInfo")
and ptb_param.name in PTCE.IGNORED_DEFAULTS_PARAM_NAMES
):
# Now let's check if the parameter is a Defaults parameter, it should be
for name, _ in ALL_DEFAULTS:
if name == ptb_param.name or "parse_mode" in ptb_param.name:
log("Checking that `%s` is a Defaults parameter!\n", ptb_param.name)
mapped_type = ODVInput[mapped_type]
break
# RESULTS:-
mapped_type = wrap_with_none(tg_parameter, mapped_type, obj)
mapped_type = resolve_forward_refs_in_type(mapped_type)
log(
"At RESULTS, we are comparing:\nptb_annotation= %s\nmapped_type= %s\n",
ptb_annotation,
mapped_type,
)
return mapped_type == ptb_annotation, mapped_type

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains exceptions to our API compared to the official API."""
from telegram import Animation, Audio, Document, PhotoSize, Sticker, Video, VideoNote, Voice
from tests.test_official.helpers import _get_params_base
IGNORED_OBJECTS = ("ResponseParameters",)
GLOBALLY_IGNORED_PARAMETERS = {
"self",
"read_timeout",
"write_timeout",
"connect_timeout",
"pool_timeout",
"bot",
"api_kwargs",
}
class ParamTypeCheckingExceptions:
# Types for certain parameters accepted by PTB but not in the official API
ADDITIONAL_TYPES = {
"photo": PhotoSize,
"video": Video,
"video_note": VideoNote,
"audio": Audio,
"document": Document,
"animation": Animation,
"voice": Voice,
"sticker": Sticker,
}
# Exceptions to the "Array of" types, where we accept more types than the official API
# key: parameter name, value: type which must be present in the annotation
ARRAY_OF_EXCEPTIONS = {
"results": "InlineQueryResult", # + Callable
"commands": "BotCommand", # + tuple[str, str]
"keyboard": "KeyboardButton", # + sequence[sequence[str]]
"reaction": "ReactionType", # + str
# TODO: Deprecated and will be corrected (and removed) in next major PTB version:
"file_hashes": "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:
COMPLEX_TYPES = (
{ # (param_name, is_class (i.e appears in a class?)): reduced form of annotation
("correct_option_id", False): int, # actual: Literal
("file_id", False): str, # actual: Union[str, objs_with_file_id_attr]
("invite_link", False): str, # actual: Union[str, ChatInviteLink]
("provider_data", False): str, # actual: Union[str, obj]
("callback_data", True): str, # actual: Union[str, obj]
("media", True): str, # actual: Union[str, InputMedia*, FileInput]
(
"data",
True,
): 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 = {
"quote",
"link_preview_options",
}
# These classes' params are all ODVInput, so we ignore them in the defaults type checking.
IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"}
# TODO: Remove this in v22 when it becomes a datetime (also remove from arg_type_checker.py)
DATETIME_EXCEPTIONS = {
"file_date",
}
# Arguments *added* to the official API
PTB_EXTRA_PARAMS = {
"send_contact": {"contact"},
"send_location": {"location"},
"edit_message_live_location": {"location"},
"send_venue": {"venue"},
"answer_inline_query": {"current_offset"},
"send_media_group": {"caption", "parse_mode", "caption_entities"},
"send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"},
"InlineQueryResult": {"id", "type"}, # attributes common to all subclasses
"ChatMember": {"user", "status"}, # attributes common to all subclasses
"BotCommandScope": {"type"}, # attributes common to all subclasses
"MenuButton": {"type"}, # attributes common to all subclasses
"PassportFile": {"credentials"},
"EncryptedPassportElement": {"credentials"},
"PassportElementError": {"source", "type", "message"},
"InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"},
"InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"},
"InputFile": {"attach", "filename", "obj"},
"MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls
"ChatBoostSource": {"source"}, # attributes common to all subclasses
"MessageOrigin": {"type", "date"}, # attributes common to all subclasses
"ReactionType": {"type"}, # attributes common to all subclasses
"InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat
}
def ptb_extra_params(object_name: str) -> set[str]:
return _get_params_base(object_name, PTB_EXTRA_PARAMS)
# Arguments *removed* from the official API
# Mostly due to the value being fixed anyway
PTB_IGNORED_PARAMS = {
r"InlineQueryResult\w+": {"type"},
r"ChatMember\w+": {"status"},
r"PassportElementError\w+": {"source"},
"ForceReply": {"force_reply"},
"ReplyKeyboardRemove": {"remove_keyboard"},
r"BotCommandScope\w+": {"type"},
r"MenuButton\w+": {"type"},
r"InputMedia\w+": {"type"},
"InaccessibleMessage": {"date"},
r"MessageOrigin\w+": {"type"},
r"ChatBoostSource\w+": {"source"},
r"ReactionType\w+": {"type"},
}
def ptb_ignored_params(object_name: str) -> set[str]:
return _get_params_base(object_name, PTB_IGNORED_PARAMS)
IGNORED_PARAM_REQUIREMENTS = {
# Ignore these since there's convenience params in them (eg. Venue)
# <----
"send_location": {"latitude", "longitude"},
"edit_message_live_location": {"latitude", "longitude"},
"send_venue": {"latitude", "longitude", "title", "address"},
"send_contact": {"phone_number", "first_name"},
# ---->
}
def ignored_param_requirements(object_name: str) -> set[str]:
return _get_params_base(object_name, IGNORED_PARAM_REQUIREMENTS)
# Arguments that are optional arguments for now for backwards compatibility
BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {
# Deprecated by Bot API 7.0, kept for now for bw compat:
"KeyboardButton": {"request_user"},
"Message": {
"forward_from",
"forward_signature",
"forward_sender_name",
"forward_date",
"forward_from_chat",
"forward_from_message_id",
"user_shared",
},
"(send_message|edit_message_text)": {
"disable_web_page_preview",
"reply_to_message_id",
"allow_sending_without_reply",
},
r"copy_message|send_\w+": {"allow_sending_without_reply", "reply_to_message_id"},
}
def backwards_compat_kwargs(object_name: str) -> set[str]:
return _get_params_base(object_name, BACKWARDS_COMPAT_KWARGS)
IGNORED_PARAM_REQUIREMENTS.update(BACKWARDS_COMPAT_KWARGS)

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains helper functions for the official API tests used in the other modules."""
import functools
import re
from typing import TYPE_CHECKING, Any, Sequence, _eval_type, get_type_hints
from bs4 import PageElement, Tag
import telegram
import telegram._utils.defaultvalue
import telegram._utils.types
if TYPE_CHECKING:
from tests.test_official.scraper import TelegramParameter
tg_objects = vars(telegram)
tg_objects.update(vars(telegram._utils.types))
tg_objects.update(vars(telegram._utils.defaultvalue))
def _get_params_base(object_name: str, search_dict: dict[str, set[Any]]) -> set[Any]:
"""Helper function for the *_params functions below.
Given an object name and a search dict, goes through the keys of the search dict and checks if
the object name matches any of the regexes (keys). The union of all the sets (values) of the
matching regexes is returned. `object_name` may be a CamelCase or snake_case name.
"""
out = set()
for regex, params in search_dict.items():
if re.fullmatch(regex, object_name):
out.update(params)
# also check the snake_case version
snake_case_name = re.sub(r"(?<!^)(?=[A-Z])", "_", object_name).lower()
if re.fullmatch(regex, snake_case_name):
out.update(params)
return out
def _extract_words(text: str) -> set[str]:
"""Extracts all words from a string, removing all punctuation and words like 'and' & 'or'."""
return set(re.sub(r"[^\w\s]", "", text).split()) - {"and", "or"}
def _unionizer(annotation: Sequence[Any] | set[Any]) -> Any:
"""Returns a union of all the types in the annotation. Also imports objects from lib."""
union = None
for t in annotation:
if isinstance(t, str): # we have to import objects from lib
t = getattr(telegram, t) # noqa: PLW2901
union = t if union is None else union | t
return union
def find_next_sibling_until(tag: Tag, name: str, until: Tag) -> PageElement | None:
for sibling in tag.next_siblings:
if sibling is until:
return None
if sibling.name == name:
return sibling
return None
def is_pascal_case(s):
"PascalCase. Starts with a capital letter and has no spaces. Useful for identifying classes."
return bool(re.match(r"^[A-Z][a-zA-Z\d]*$", s))
def is_parameter_required_by_tg(field: str) -> bool:
if field in {"Required", "Yes"}:
return True
return field.split(".", 1)[0] != "Optional" # splits the sentence and extracts first word
def wrap_with_none(tg_parameter: "TelegramParameter", mapped_type: Any, obj: object) -> type:
"""Adds `None` to type annotation if the parameter isn't required. Respects ignored params."""
# have to import here to avoid circular imports
from tests.test_official.exceptions import ignored_param_requirements
if tg_parameter.param_name in ignored_param_requirements(obj.__name__):
return mapped_type | type(None)
return mapped_type | type(None) if not tg_parameter.param_required else mapped_type
@functools.cache
def cached_type_hints(obj: Any, is_class: bool) -> dict[str, Any]:
"""Returns type hints of a class, method, or function, with forward refs evaluated."""
return get_type_hints(obj.__init__ if is_class else obj, localns=tg_objects)
@functools.cache
def resolve_forward_refs_in_type(obj: type) -> type:
"""Resolves forward references in a type hint."""
return _eval_type(obj, localns=tg_objects, globalns=None)

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains functions which are used to scrape the official Bot API documentation."""
import asyncio
from dataclasses import dataclass
from typing import Literal, overload
import httpx
from bs4 import BeautifulSoup, Tag
from tests.test_official.exceptions import IGNORED_OBJECTS
from tests.test_official.helpers import (
find_next_sibling_until,
is_parameter_required_by_tg,
is_pascal_case,
)
@dataclass(slots=True, frozen=True)
class TelegramParameter:
"""Represents the scraped Telegram parameter. Contains all relevant attributes needed for
comparison. Relevant for both TelegramMethod and TelegramClass."""
param_name: str
param_type: str
param_required: bool
param_description: str
@dataclass(slots=True, frozen=True)
class TelegramClass:
"""Represents the scraped Telegram class. Contains all relevant attributes needed for
comparison."""
class_name: str
class_parameters: list[TelegramParameter]
# class_description: str
@dataclass(slots=True, frozen=True)
class TelegramMethod:
"""Represents the scraped Telegram method. Contains all relevant attributes needed for
comparison."""
method_name: str
method_parameters: list[TelegramParameter]
# method_description: str
@dataclass(slots=True, frozen=False)
class Scraper:
request: httpx.Response | None = None
soup: BeautifulSoup | None = None
async def make_request(self) -> None:
async with httpx.AsyncClient() as client:
self.request = await client.get("https://core.telegram.org/bots/api", timeout=10)
self.soup = BeautifulSoup(self.request.text, "html.parser")
@overload
def parse_docs(
self, doc_type: Literal["method"]
) -> tuple[list[TelegramMethod], list[str]]: ...
@overload
def parse_docs(self, doc_type: Literal["class"]) -> tuple[list[TelegramClass], list[str]]: ...
def parse_docs(self, doc_type):
argvalues = []
names: list[str] = []
if self.request is None:
asyncio.run(self.make_request())
for unparsed in self.soup.select("h4 > a.anchor"):
if "-" not in unparsed["name"]:
h4: Tag | None = unparsed.parent
name = h4.text
if h4 is None:
raise AssertionError("h4 is None")
if doc_type == "method" and name[0].lower() == name[0]:
params = parse_table_for_params(h4)
obj = TelegramMethod(method_name=name, method_parameters=params)
argvalues.append(obj)
names.append(name)
elif doc_type == "class" and is_pascal_case(name) and name not in IGNORED_OBJECTS:
params = parse_table_for_params(h4)
obj = TelegramClass(class_name=name, class_parameters=params)
argvalues.append(obj)
names.append(name)
return argvalues, names
def collect_methods(self) -> tuple[list[TelegramMethod], list[str]]:
return self.parse_docs("method")
def collect_classes(self) -> tuple[list[TelegramClass], list[str]]:
return self.parse_docs("class")
def parse_table_for_params(h4: Tag) -> list[TelegramParameter]:
"""Parses the Telegram doc table and outputs a list of TelegramParameter objects."""
table = find_next_sibling_until(h4, "table", h4.find_next_sibling("h4"))
if not table:
return []
params = []
for tr in table.find_all("tr")[1:]:
fields = []
for td in tr.find_all("td"):
param = td.text
fields.append(param)
param_name = fields[0]
param_type = fields[1]
param_required = is_parameter_required_by_tg(fields[2])
param_desc = fields[-1] # since length can be 2 or 3, but desc is always the last
params.append(TelegramParameter(param_name, param_type, param_required, param_desc))
return params

View file

@ -0,0 +1,193 @@
#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2024
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
import inspect
from typing import TYPE_CHECKING
import pytest
import telegram
from tests.auxil.envvars import RUN_TEST_OFFICIAL
from tests.test_official.arg_type_checker import (
check_defaults_type,
check_param_type,
check_required_param,
)
from tests.test_official.exceptions import (
GLOBALLY_IGNORED_PARAMETERS,
backwards_compat_kwargs,
ptb_extra_params,
ptb_ignored_params,
)
from tests.test_official.scraper import Scraper, TelegramClass, TelegramMethod
if TYPE_CHECKING:
from types import FunctionType
# Will skip all tests in this file if the env var is False
pytestmark = pytest.mark.skipif(not RUN_TEST_OFFICIAL, reason="test_official is not enabled")
methods, method_ids, classes, class_ids = [], [], [], [] # not needed (just for completeness)
if RUN_TEST_OFFICIAL:
scraper = Scraper()
methods, method_ids = scraper.collect_methods()
classes, class_ids = scraper.collect_classes()
@pytest.mark.parametrize("tg_method", argvalues=methods, ids=method_ids)
def test_check_method(tg_method: TelegramMethod) -> None:
"""This function checks for the following things compared to the official API docs:
- Method existence
- Parameter existence
- Parameter requirement correctness
- Parameter type annotation existence
- Parameter type annotation correctness
- Parameter default value correctness
- No unexpected parameters
- Extra parameters should be keyword only
"""
ptb_method: FunctionType | None = getattr(telegram.Bot, tg_method.method_name, None)
assert ptb_method, f"Method {tg_method.method_name} not found in telegram.Bot"
# Check arguments based on source
sig = inspect.signature(ptb_method, follow_wrapped=True)
checked = []
for tg_parameter in tg_method.method_parameters:
# Check if parameter is present in our method
ptb_param = sig.parameters.get(tg_parameter.param_name)
assert (
ptb_param is not None
), f"Parameter {tg_parameter.param_name} not found in {ptb_method.__name__}"
# Now check if the parameter is required or not
assert check_required_param(
tg_parameter, ptb_param, ptb_method.__name__
), f"Param {ptb_param.name!r} of {ptb_method.__name__!r} requirement mismatch"
# Check if type annotation is present
assert (
ptb_param.annotation is not inspect.Parameter.empty
), f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should have a type annotation!"
# Check if type annotation is correct
correct_type_hint, expected_type_hint = check_param_type(
ptb_param,
tg_parameter,
ptb_method,
)
assert correct_type_hint, (
f"Type hint of param {ptb_param.name!r} of {ptb_method.__name__!r} should be "
f"{expected_type_hint!r} or something else!"
)
# Now we will check that we don't pass default values if the parameter is not required.
if ptb_param.default is not inspect.Parameter.empty: # If there is a default argument...
default_arg_none = check_defaults_type(ptb_param) # check if it's None
assert (
default_arg_none
), f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should be `None`"
checked.append(tg_parameter.param_name)
expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy()
expected_additional_args |= ptb_extra_params(tg_method.method_name)
expected_additional_args |= backwards_compat_kwargs(tg_method.method_name)
unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args
assert (
unexpected_args == set()
), f"In {ptb_method.__qualname__}, unexpected args were found: {unexpected_args}."
kw_or_positional_args = [
p.name for p in sig.parameters.values() if p.kind != inspect.Parameter.KEYWORD_ONLY
]
non_kw_only_args = set(kw_or_positional_args).difference(checked).difference(["self"])
non_kw_only_args -= backwards_compat_kwargs(tg_method.method_name)
assert non_kw_only_args == set(), (
f"In {ptb_method.__qualname__}, extra args should be keyword only (compared to "
f"{tg_method.method_name} in API)"
)
@pytest.mark.parametrize("tg_class", argvalues=classes, ids=class_ids)
def test_check_object(tg_class: TelegramClass) -> None:
"""This function checks for the following things compared to the official API docs:
- Class existence
- Parameter existence
- Parameter requirement correctness
- Parameter type annotation existence
- Parameter type annotation correctness
- Parameter default value correctness
- No unexpected parameters
"""
obj = getattr(telegram, tg_class.class_name)
# Check arguments based on source. Makes sure to only check __init__'s signature & nothing else
sig = inspect.signature(obj.__init__, follow_wrapped=True)
checked = set()
fields_removed_by_ptb = ptb_ignored_params(tg_class.class_name)
for tg_parameter in tg_class.class_parameters:
field: str = tg_parameter.param_name
if field in fields_removed_by_ptb:
continue
if field == "from":
field = "from_user"
ptb_param = sig.parameters.get(field)
assert ptb_param is not None, f"Attribute {field} not found in {obj.__name__}"
# Now check if the parameter is required or not
assert check_required_param(
tg_parameter, ptb_param, obj.__name__
), f"Param {ptb_param.name!r} of {obj.__name__!r} requirement mismatch"
# Check if type annotation is present
assert (
ptb_param.annotation is not inspect.Parameter.empty
), f"Param {ptb_param.name!r} of {obj.__name__!r} should have a type annotation"
# Check if type annotation is correct
correct_type_hint, expected_type_hint = check_param_type(ptb_param, tg_parameter, obj)
assert correct_type_hint, (
f"Type hint of param {ptb_param.name!r} of {obj.__name__!r} should be "
f"{expected_type_hint!r} or something else!"
)
# Now we will check that we don't pass default values if the parameter is not required.
if ptb_param.default is not inspect.Parameter.empty: # If there is a default argument...
default_arg_none = check_defaults_type(ptb_param) # check if its None
assert (
default_arg_none
), f"Param {ptb_param.name!r} of {obj.__name__!r} should be `None`"
checked.add(field)
expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy()
expected_additional_args |= ptb_extra_params(tg_class.class_name)
expected_additional_args |= backwards_compat_kwargs(tg_class.class_name)
unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args
assert (
unexpected_args == set()
), f"In {tg_class.class_name}, unexpected args were found: {unexpected_args}."