WIP: Get basic idea working

This commit is contained in:
Harshil 2024-09-08 00:36:56 -04:00
parent 743517ce5c
commit 42103f0c73
No known key found for this signature in database
GPG key ID: 4AC061E441A1F727
2 changed files with 212 additions and 159 deletions

View file

@ -20,10 +20,24 @@ import inspect
import re import re
import typing import typing
from collections import defaultdict from collections import defaultdict
from typing import Any, Iterator, Union from typing import Any, Iterator, Literal, Union
from tests.auxil.slots import mro_slots
import telegram import telegram
import telegram.ext import telegram.ext
import telegram.ext._utils.types
import telegram._utils.types
import telegram._utils.defaultvalue
from socket import socket
from apscheduler.job import Job as APSJob
tg_objects = vars(telegram)
tg_objects.update(vars(telegram._utils.types))
tg_objects.update(vars(telegram._utils.defaultvalue))
tg_objects.update(vars(telegram.ext))
tg_objects.update(vars(telegram.ext._utils.types))
tg_objects.update(vars(telegram.ext._applicationbuilder))
tg_objects.update({"socket": socket, "APSJob": APSJob})
def _iter_own_public_methods(cls: type) -> Iterator[tuple[str, type]]: def _iter_own_public_methods(cls: type) -> Iterator[tuple[str, type]]:
@ -49,6 +63,7 @@ class AdmonitionInserter:
CLASS_ADMONITION_TYPES = ("use_in", "available_in", "returned_in") CLASS_ADMONITION_TYPES = ("use_in", "available_in", "returned_in")
METHOD_ADMONITION_TYPES = ("shortcuts",) METHOD_ADMONITION_TYPES = ("shortcuts",)
ALL_ADMONITION_TYPES = CLASS_ADMONITION_TYPES + METHOD_ADMONITION_TYPES ALL_ADMONITION_TYPES = CLASS_ADMONITION_TYPES + METHOD_ADMONITION_TYPES
# ALL_ADMONITION_TYPES = ("use_in",)
FORWARD_REF_PATTERN = re.compile(r"^ForwardRef\('(?P<class_name>\w+)'\)$") FORWARD_REF_PATTERN = re.compile(r"^ForwardRef\('(?P<class_name>\w+)'\)$")
""" A pattern to find a class name in a ForwardRef typing annotation. """ A pattern to find a class name in a ForwardRef typing annotation.
@ -66,7 +81,7 @@ class AdmonitionInserter:
METHOD_NAMES_FOR_BOT_AND_APPBUILDER: typing.ClassVar[dict[type, str]] = { METHOD_NAMES_FOR_BOT_AND_APPBUILDER: typing.ClassVar[dict[type, str]] = {
cls: tuple(m[0] for m in _iter_own_public_methods(cls)) # m[0] means we take only names cls: tuple(m[0] for m in _iter_own_public_methods(cls)) # m[0] means we take only names
for cls in (telegram.Bot, telegram.ext.ApplicationBuilder) for cls in (telegram.Bot, telegram.ext.ApplicationBuilder, telegram.ext.Application)
} }
"""A dictionary mapping Bot and ApplicationBuilder classes to their relevant methods that will """A dictionary mapping Bot and ApplicationBuilder classes to their relevant methods that will
be mentioned in 'Returned in' and 'Use in' admonitions in other classes' docstrings. be mentioned in 'Returned in' and 'Use in' admonitions in other classes' docstrings.
@ -78,7 +93,11 @@ class AdmonitionInserter:
# dynamically determine which method to use to create a sub-dictionary # dynamically determine which method to use to create a sub-dictionary
admonition_type: getattr(self, f"_create_{admonition_type}")() admonition_type: getattr(self, f"_create_{admonition_type}")()
for admonition_type in self.ALL_ADMONITION_TYPES for admonition_type in self.ALL_ADMONITION_TYPES
# "use_in": self._create_use_in(),
# "shortcuts": self._create_shortcuts(),
# "available_in": self._create_available_in(),
} }
# print(self.admonitions["use_in"])
"""Dictionary with admonitions. Contains sub-dictionaries, one per admonition type. """Dictionary with admonitions. Contains sub-dictionaries, one per admonition type.
Each sub-dictionary matches bot methods (for "Shortcuts") or telegram classes (for other Each sub-dictionary matches bot methods (for "Shortcuts") or telegram classes (for other
admonition types) to texts of admonitions, e.g.: admonition types) to texts of admonitions, e.g.:
@ -166,39 +185,47 @@ class AdmonitionInserter:
name_of_inspected_class_in_docstr = self._generate_class_name_for_link(inspected_class) name_of_inspected_class_in_docstr = self._generate_class_name_for_link(inspected_class)
# Parsing part of the docstring with attributes (parsing of properties follows later) # Parsing part of the docstring with attributes (parsing of properties follows later)
docstring_lines = inspect.getdoc(inspected_class).splitlines() # docstring_lines = inspect.getdoc(inspected_class).splitlines()
lines_with_attrs = [] # lines_with_attrs = []
for idx, line in enumerate(docstring_lines): # for idx, line in enumerate(docstring_lines):
if line.strip() == "Attributes:": # if line.strip() == "Attributes:":
lines_with_attrs = docstring_lines[idx + 1 :] # lines_with_attrs = docstring_lines[idx + 1 :]
break # break
for line in lines_with_attrs: # for line in lines_with_attrs:
if not (line_match := attr_docstr_pattern.match(line)): # if not (line_match := attr_docstr_pattern.match(line)):
continue # continue
target_attr = line_match.group("attr_name") # target_attr = line_match.group("attr_name")
# a typing description of one attribute can contain multiple classes # # a typing description of one attribute can contain multiple classes
for match in single_class_name_pattern.finditer(line): # for match in single_class_name_pattern.finditer(line):
name_of_class_in_attr = match.group("class_name") # name_of_class_in_attr = match.group("class_name")
# Writing to dictionary: matching the class found in the docstring # Writing to dictionary: matching the class found in the docstring
# and its subclasses to the attribute of the class being inspected. # and its subclasses to the attribute of the class being inspected.
# The class in the attribute docstring (or its subclass) is the key, # The class in the attribute docstring (or its subclass) is the key,
# ReST link to attribute of the class currently being inspected is the value. # ReST link to attribute of the class currently being inspected is the value.
try:
self._resolve_arg_and_add_link( # best effort - args of __init__ means not all attributes are covered, but there is no
arg=name_of_class_in_attr, # other way to get type hints of all attributes, other than doing ast parsing maybe.
dict_of_methods_for_class=attrs_for_class, # (Docstring parsing was discontinued with the closing of #4414)
link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`", type_hints = typing.get_type_hints(inspected_class.__init__, localns=tg_objects)
) class_attrs = [slot for slot in mro_slots(inspected_class) if not slot.startswith("_")]
except NotImplementedError as e: for target_attr in class_attrs:
raise NotImplementedError( try:
"Error generating Sphinx 'Available in' admonition " print(f"{inspected_class=},")
f"(admonition_inserter.py). Class {name_of_class_in_attr} present in " self._resolve_arg_and_add_link(
f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}" dict_of_methods_for_class=attrs_for_class,
f" could not be resolved. {e!s}" link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`",
) from e type_hints={target_attr: type_hints.get(target_attr)},
)
except NotImplementedError as e:
raise NotImplementedError(
"Error generating Sphinx 'Available in' admonition "
f"(admonition_inserter.py). Class {inspected_class} present in "
f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}"
f" could not be resolved. {e!s}"
) from e
# Properties need to be parsed separately because they act like attributes but not # Properties need to be parsed separately because they act like attributes but not
# listed as attributes. # listed as attributes.
@ -209,39 +236,26 @@ class AdmonitionInserter:
if prop_name not in inspected_class.__dict__: if prop_name not in inspected_class.__dict__:
continue continue
# 1. Can't use typing.get_type_hints because double-quoted type hints # fget is used to access the actual function under the property wrapper
# (like "Application") will throw a NameError type_hints = typing.get_type_hints(getattr(inspected_class, prop_name).fget, localns=tg_objects)
# 2. Can't use inspect.signature because return annotations of properties can be
# hard to parse (like "(self) -> BD").
# 3. fget is used to access the actual function under the property wrapper
docstring = inspect.getdoc(getattr(inspected_class, prop_name).fget)
if docstring is None:
continue
first_line = docstring.splitlines()[0] # Writing to dictionary: matching the class found in the docstring and its
if not prop_docstring_pattern.match(first_line): # subclasses to the property of the class being inspected.
continue # The class in the property docstring (or its subclass) is the key,
# ReST link to property of the class currently being inspected is the value.
for match in single_class_name_pattern.finditer(first_line): try:
name_of_class_in_prop = match.group("class_name") self._resolve_arg_and_add_link(
dict_of_methods_for_class=attrs_for_class,
# Writing to dictionary: matching the class found in the docstring and its link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`",
# subclasses to the property of the class being inspected. type_hints={prop_name: type_hints.get(target_attr)},
# The class in the property docstring (or its subclass) is the key, )
# ReST link to property of the class currently being inspected is the value. except NotImplementedError as e:
try: raise NotImplementedError(
self._resolve_arg_and_add_link( "Error generating Sphinx 'Available in' admonition "
arg=name_of_class_in_prop, f"(admonition_inserter.py). Class {inspected_class} present in "
dict_of_methods_for_class=attrs_for_class, f"property {prop_name} of class {name_of_inspected_class_in_docstr}"
link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`", f" could not be resolved. {e!s}"
) ) from e
except NotImplementedError as e:
raise NotImplementedError(
"Error generating Sphinx 'Available in' admonition "
f"(admonition_inserter.py). Class {name_of_class_in_prop} present in "
f"property {prop_name} of class {name_of_inspected_class_in_docstr}"
f" could not be resolved. {e!s}"
) from e
return self._generate_admonitions(attrs_for_class, admonition_type="available_in") return self._generate_admonitions(attrs_for_class, admonition_type="available_in")
@ -256,22 +270,22 @@ class AdmonitionInserter:
for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items(): for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items():
for method_name in method_names: for method_name in method_names:
sig = inspect.signature(getattr(cls, method_name))
ret_annot = sig.return_annotation
method_link = self._generate_link_to_method(method_name, cls) method_link = self._generate_link_to_method(method_name, cls)
arg = getattr(cls, method_name)
print(arg, method_name)
ret_type_hint = typing.get_type_hints(arg, localns=tg_objects)
try: try:
self._resolve_arg_and_add_link( self._resolve_arg_and_add_link(
arg=ret_annot,
dict_of_methods_for_class=methods_for_class, dict_of_methods_for_class=methods_for_class,
link=method_link, link=method_link,
type_hints={"return": ret_type_hint.get("return")},
) )
except NotImplementedError as e: except NotImplementedError as e:
raise NotImplementedError( raise NotImplementedError(
"Error generating Sphinx 'Returned in' admonition " "Error generating Sphinx 'Returned in' admonition "
f"(admonition_inserter.py). {cls}, method {method_name}. " f"(admonition_inserter.py). {cls}, method {method_name}. "
f"Couldn't resolve type hint in return annotation {ret_annot}. {e!s}" f"Couldn't resolve type hint in return annotation {ret_type_hint}. {e!s}"
) from e ) from e
return self._generate_admonitions(methods_for_class, admonition_type="returned_in") return self._generate_admonitions(methods_for_class, admonition_type="returned_in")
@ -330,22 +344,20 @@ class AdmonitionInserter:
for method_name in method_names: for method_name in method_names:
method_link = self._generate_link_to_method(method_name, cls) method_link = self._generate_link_to_method(method_name, cls)
sig = inspect.signature(getattr(cls, method_name)) arg = getattr(cls, method_name)
parameters = sig.parameters param_type_hints = typing.get_type_hints(arg, localns=tg_objects)
param_type_hints.pop("return", None)
for param in parameters.values(): try:
try: self._resolve_arg_and_add_link(
self._resolve_arg_and_add_link( dict_of_methods_for_class=methods_for_class,
arg=param.annotation, link=method_link,
dict_of_methods_for_class=methods_for_class, type_hints=param_type_hints,
link=method_link, )
) except NotImplementedError as e:
except NotImplementedError as e: raise NotImplementedError(
raise NotImplementedError( "Error generating Sphinx 'Use in' admonition "
"Error generating Sphinx 'Use in' admonition " f"(admonition_inserter.py). {cls}, method {method_name}, parameter "
f"(admonition_inserter.py). {cls}, method {method_name}, parameter " ) from e
f"{param}: Couldn't resolve type hint {param.annotation}. {e!s}"
) from e
return self._generate_admonitions(methods_for_class, admonition_type="use_in") return self._generate_admonitions(methods_for_class, admonition_type="use_in")
@ -448,6 +460,8 @@ class AdmonitionInserter:
@staticmethod @staticmethod
def _iter_subclasses(cls: type) -> Iterator: def _iter_subclasses(cls: type) -> Iterator:
if not hasattr(cls, "__subclasses__") or cls is telegram.TelegramObject:
return iter([])
return ( return (
# exclude private classes # exclude private classes
c c
@ -457,9 +471,9 @@ class AdmonitionInserter:
def _resolve_arg_and_add_link( def _resolve_arg_and_add_link(
self, self,
arg: Any,
dict_of_methods_for_class: defaultdict, dict_of_methods_for_class: defaultdict,
link: str, link: str,
type_hints: dict[str, type],
) -> None: ) -> None:
"""A helper method. Tries to resolve the arg into a valid class. In case of success, """A helper method. Tries to resolve the arg into a valid class. In case of success,
adds the link (to a method, attribute, or property) for that class' and its subclasses' adds the link (to a method, attribute, or property) for that class' and its subclasses'
@ -467,7 +481,9 @@ class AdmonitionInserter:
**Modifies dictionary in place.** **Modifies dictionary in place.**
""" """
for cls in self._resolve_arg(arg): type_hints.pop("self", None)
for cls in self._resolve_arg(type_hints):
# When trying to resolve an argument from args or return annotation, # When trying to resolve an argument from args or return annotation,
# the method _resolve_arg returns None if nothing could be resolved. # the method _resolve_arg returns None if nothing could be resolved.
# Also, if class was resolved correctly, "telegram" will definitely be in its str(). # Also, if class was resolved correctly, "telegram" will definitely be in its str().
@ -479,88 +495,123 @@ class AdmonitionInserter:
for subclass in self._iter_subclasses(cls): for subclass in self._iter_subclasses(cls):
dict_of_methods_for_class[subclass].add(link) dict_of_methods_for_class[subclass].add(link)
def _resolve_arg(self, arg: Any) -> Iterator[Union[type, None]]: def _resolve_arg(self, type_hints: dict[str, type]) -> list[type]:
"""Analyzes an argument of a method and recursively yields classes that the argument """Analyzes an argument of a method and recursively yields classes that the argument
or its sub-arguments (in cases like Union[...]) belong to, if they can be resolved to or its sub-arguments (in cases like Union[...]) belong to, if they can be resolved to
telegram or telegram.ext classes. telegram or telegram.ext classes.
Raises `NotImplementedError`. Raises `NotImplementedError`.
""" """
telegram_classes = set()
origin = typing.get_origin(arg) def recurse_type(typ):
if hasattr(typ, '__origin__'): # For generic types like Union, List, etc.
# Make sure it's not a telegram.ext generic type (e.g. ContextTypes[...])
org = typing.get_origin(typ)
if "telegram.ext" in str(org):
telegram_classes.add(org)
if ( args = typing.get_args(typ)
origin in (collections.abc.Callable, typing.IO) # print(f"In recurse_type, found __origin__ {typ=}, {args=}")
or arg is None for ar in args:
# no other check available (by type or origin) for these: recurse_type(ar)
or str(type(arg)) in ("<class 'typing._SpecialForm'>", "<class 'ellipsis'>") elif isinstance(typ, typing.TypeVar):
): # gets access to the "bound=..." parameter
pass recurse_type(typ.__bound__)
elif inspect.isclass(typ) and "telegram" in inspect.getmodule(typ).__name__:
# RECURSIVE CALLS # print(f"typ is a class and inherits from TelegramObject: {typ=}")
# for cases like Union[Sequence.... telegram_classes.add(typ)
elif origin in (
Union,
collections.abc.Coroutine,
collections.abc.Sequence,
):
for sub_arg in typing.get_args(arg):
yield from self._resolve_arg(sub_arg)
elif isinstance(arg, typing.TypeVar):
# gets access to the "bound=..." parameter
yield from self._resolve_arg(arg.__bound__)
# END RECURSIVE CALLS
elif isinstance(arg, typing.ForwardRef):
m = self.FORWARD_REF_PATTERN.match(str(arg))
# We're sure it's a ForwardRef, so, unless it belongs to known exceptions,
# the class must be resolved.
# If it isn't resolved, we'll have the program throw an exception to be sure.
try:
cls = self._resolve_class(m.group("class_name"))
except AttributeError as exc:
# skip known ForwardRef's that need not be resolved to a Telegram class
if self.FORWARD_REF_SKIP_PATTERN.match(str(arg)):
pass
else:
raise NotImplementedError(f"Could not process ForwardRef: {arg}") from exc
else: else:
yield cls pass
# print(f"typ is not a class or doesn't inherit from TelegramObject: {typ=}. The "
# f"type is: {type(typ)=}")
# print(f"{inspect.isclass(typ)=}")
# if inspect.isclass(typ):
# print(f"{inspect.getmodule(typ).__name__=}")
# For custom generics like telegram.ext._application.Application[~BT, ~CCT, ~UD...]. print()
# This must come before the check for isinstance(type) because GenericAlias can also be print(f"in _resolve_arg {type_hints=}")
# recognized as type if it belongs to <class 'types.GenericAlias'>. for param_name, type_hint in type_hints.items():
elif str(type(arg)) in ( print(f"{param_name=}", f"{type_hint=}")
"<class 'typing._GenericAlias'>", if type_hint is None:
"<class 'types.GenericAlias'>", continue
"<class 'typing._LiteralGenericAlias'>", recurse_type(type_hint)
): print(f"{telegram_classes=}")
if "telegram" in str(arg): return list(telegram_classes)
# get_origin() of telegram.ext._application.Application[~BT, ~CCT, ~UD...] # origin = typing.get_origin(arg)
# will produce <class 'telegram.ext._application.Application'>
yield origin
elif isinstance(arg, type): # if (
if "telegram" in str(arg): # origin in (collections.abc.Callable, typing.IO)
yield arg # or arg is None
# # no other check available (by type or origin) for these:
# or str(type(arg)) in ("<class 'typing._SpecialForm'>", "<class 'ellipsis'>")
# ):
# pass
# For some reason "InlineQueryResult", "InputMedia" & some others are currently not # # RECURSIVE CALLS
# recognized as ForwardRefs and are identified as plain strings. # # for cases like Union[Sequence....
elif isinstance(arg, str): # elif origin in (
# args like "ApplicationBuilder[BT, CCT, UD, CD, BD, JQ]" can be recognized as strings. # Union,
# Remove whatever is in the square brackets because it doesn't need to be parsed. # collections.abc.Coroutine,
arg = re.sub(r"\[.+]", "", arg) # collections.abc.Sequence,
# ):
# for sub_arg in typing.get_args(arg):
# yield from self._resolve_arg(sub_arg)
cls = self._resolve_class(arg) # elif isinstance(arg, typing.TypeVar):
# Here we don't want an exception to be thrown since we're not sure it's ForwardRef # # gets access to the "bound=..." parameter
if cls is not None: # yield from self._resolve_arg(arg.__bound__)
yield cls # # END RECURSIVE CALLS
else: # elif isinstance(arg, typing.ForwardRef):
raise NotImplementedError( # m = self.FORWARD_REF_PATTERN.match(str(arg))
f"Cannot process argument {arg} of type {type(arg)} (origin {origin})" # # We're sure it's a ForwardRef, so, unless it belongs to known exceptions,
) # # the class must be resolved.
# # If it isn't resolved, we'll have the program throw an exception to be sure.
# try:
# cls = self._resolve_class(m.group("class_name"))
# except AttributeError as exc:
# # skip known ForwardRef's that need not be resolved to a Telegram class
# if self.FORWARD_REF_SKIP_PATTERN.match(str(arg)):
# pass
# else:
# raise NotImplementedError(f"Could not process ForwardRef: {arg}") from exc
# else:
# yield cls
# # For custom generics like telegram.ext._application.Application[~BT, ~CCT, ~UD...].
# # This must come before the check for isinstance(type) because GenericAlias can also be
# # recognized as type if it belongs to <class 'types.GenericAlias'>.
# elif str(type(arg)) in (
# "<class 'typing._GenericAlias'>",
# "<class 'types.GenericAlias'>",
# "<class 'typing._LiteralGenericAlias'>",
# ):
# if "telegram" in str(arg):
# # get_origin() of telegram.ext._application.Application[~BT, ~CCT, ~UD...]
# # will produce <class 'telegram.ext._application.Application'>
# yield origin
# elif isinstance(arg, type):
# if "telegram" in str(arg):
# yield arg
# # For some reason "InlineQueryResult", "InputMedia" & some others are currently not
# # recognized as ForwardRefs and are identified as plain strings.
# elif isinstance(arg, str):
# # args like "ApplicationBuilder[BT, CCT, UD, CD, BD, JQ]" can be recognized as strings.
# # Remove whatever is in the square brackets because it doesn't need to be parsed.
# arg = re.sub(r"\[.+]", "", arg)
# cls = self._resolve_class(arg)
# # Here we don't want an exception to be thrown since we're not sure it's ForwardRef
# if cls is not None:
# yield cls
# else:
# raise NotImplementedError(
# f"Cannot process argument {arg} of type {type(arg)} (origin {origin})"
# )
@staticmethod @staticmethod
def _resolve_class(name: str) -> Union[type, None]: def _resolve_class(name: str) -> Union[type, None]:

View file

@ -17,10 +17,12 @@
# You should have received a copy of the GNU Lesser Public License # You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/]. # along with this program. If not, see [http://www.gnu.org/licenses/].
# This module is intentionally named without "test_" prefix. """
# These tests are supposed to be run on GitHub when building docs. This module is intentionally named without "test_" prefix.
# The tests require Python 3.9+ (just like AdmonitionInserter being tested), These tests are supposed to be run on GitHub when building docs.
# so they cannot be included in the main suite while older versions of Python are supported. The tests require Python 3.10+ (just like AdmonitionInserter being tested),
so they cannot be included in the main suite while older versions of Python are supported.
"""
import collections.abc import collections.abc