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 typing
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.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]]:
@ -49,6 +63,7 @@ class AdmonitionInserter:
CLASS_ADMONITION_TYPES = ("use_in", "available_in", "returned_in")
METHOD_ADMONITION_TYPES = ("shortcuts",)
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+)'\)$")
""" 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]] = {
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
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
admonition_type: getattr(self, f"_create_{admonition_type}")()
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.
Each sub-dictionary matches bot methods (for "Shortcuts") or telegram classes (for other
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)
# Parsing part of the docstring with attributes (parsing of properties follows later)
docstring_lines = inspect.getdoc(inspected_class).splitlines()
lines_with_attrs = []
for idx, line in enumerate(docstring_lines):
if line.strip() == "Attributes:":
lines_with_attrs = docstring_lines[idx + 1 :]
break
# docstring_lines = inspect.getdoc(inspected_class).splitlines()
# lines_with_attrs = []
# for idx, line in enumerate(docstring_lines):
# if line.strip() == "Attributes:":
# lines_with_attrs = docstring_lines[idx + 1 :]
# break
for line in lines_with_attrs:
if not (line_match := attr_docstr_pattern.match(line)):
continue
# for line in lines_with_attrs:
# if not (line_match := attr_docstr_pattern.match(line)):
# continue
target_attr = line_match.group("attr_name")
# a typing description of one attribute can contain multiple classes
for match in single_class_name_pattern.finditer(line):
name_of_class_in_attr = match.group("class_name")
# target_attr = line_match.group("attr_name")
# # a typing description of one attribute can contain multiple classes
# for match in single_class_name_pattern.finditer(line):
# name_of_class_in_attr = match.group("class_name")
# Writing to dictionary: matching the class found in the docstring
# and its subclasses to the attribute of the class being inspected.
# 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.
try:
self._resolve_arg_and_add_link(
arg=name_of_class_in_attr,
dict_of_methods_for_class=attrs_for_class,
link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`",
)
except NotImplementedError as e:
raise NotImplementedError(
"Error generating Sphinx 'Available in' admonition "
f"(admonition_inserter.py). Class {name_of_class_in_attr} present in "
f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}"
f" could not be resolved. {e!s}"
) from e
# Writing to dictionary: matching the class found in the docstring
# and its subclasses to the attribute of the class being inspected.
# 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.
# best effort - args of __init__ means not all attributes are covered, but there is no
# other way to get type hints of all attributes, other than doing ast parsing maybe.
# (Docstring parsing was discontinued with the closing of #4414)
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("_")]
for target_attr in class_attrs:
try:
print(f"{inspected_class=},")
self._resolve_arg_and_add_link(
dict_of_methods_for_class=attrs_for_class,
link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`",
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
# listed as attributes.
@ -209,39 +236,26 @@ class AdmonitionInserter:
if prop_name not in inspected_class.__dict__:
continue
# 1. Can't use typing.get_type_hints because double-quoted type hints
# (like "Application") will throw a NameError
# 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
# fget is used to access the actual function under the property wrapper
type_hints = typing.get_type_hints(getattr(inspected_class, prop_name).fget, localns=tg_objects)
first_line = docstring.splitlines()[0]
if not prop_docstring_pattern.match(first_line):
continue
for match in single_class_name_pattern.finditer(first_line):
name_of_class_in_prop = match.group("class_name")
# Writing to dictionary: matching the class found in the docstring and its
# subclasses to the property of the class being inspected.
# 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.
try:
self._resolve_arg_and_add_link(
arg=name_of_class_in_prop,
dict_of_methods_for_class=attrs_for_class,
link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`",
)
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
# Writing to dictionary: matching the class found in the docstring and its
# subclasses to the property of the class being inspected.
# 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.
try:
self._resolve_arg_and_add_link(
dict_of_methods_for_class=attrs_for_class,
link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`",
type_hints={prop_name: 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"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")
@ -256,22 +270,22 @@ class AdmonitionInserter:
for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items():
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)
arg = getattr(cls, method_name)
print(arg, method_name)
ret_type_hint = typing.get_type_hints(arg, localns=tg_objects)
try:
self._resolve_arg_and_add_link(
arg=ret_annot,
dict_of_methods_for_class=methods_for_class,
link=method_link,
type_hints={"return": ret_type_hint.get("return")},
)
except NotImplementedError as e:
raise NotImplementedError(
"Error generating Sphinx 'Returned in' admonition "
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
return self._generate_admonitions(methods_for_class, admonition_type="returned_in")
@ -330,22 +344,20 @@ class AdmonitionInserter:
for method_name in method_names:
method_link = self._generate_link_to_method(method_name, cls)
sig = inspect.signature(getattr(cls, method_name))
parameters = sig.parameters
for param in parameters.values():
try:
self._resolve_arg_and_add_link(
arg=param.annotation,
dict_of_methods_for_class=methods_for_class,
link=method_link,
)
except NotImplementedError as e:
raise NotImplementedError(
"Error generating Sphinx 'Use in' admonition "
f"(admonition_inserter.py). {cls}, method {method_name}, parameter "
f"{param}: Couldn't resolve type hint {param.annotation}. {e!s}"
) from e
arg = getattr(cls, method_name)
param_type_hints = typing.get_type_hints(arg, localns=tg_objects)
param_type_hints.pop("return", None)
try:
self._resolve_arg_and_add_link(
dict_of_methods_for_class=methods_for_class,
link=method_link,
type_hints=param_type_hints,
)
except NotImplementedError as e:
raise NotImplementedError(
"Error generating Sphinx 'Use in' admonition "
f"(admonition_inserter.py). {cls}, method {method_name}, parameter "
) from e
return self._generate_admonitions(methods_for_class, admonition_type="use_in")
@ -448,6 +460,8 @@ class AdmonitionInserter:
@staticmethod
def _iter_subclasses(cls: type) -> Iterator:
if not hasattr(cls, "__subclasses__") or cls is telegram.TelegramObject:
return iter([])
return (
# exclude private classes
c
@ -457,9 +471,9 @@ class AdmonitionInserter:
def _resolve_arg_and_add_link(
self,
arg: Any,
dict_of_methods_for_class: defaultdict,
link: str,
type_hints: dict[str, type],
) -> None:
"""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'
@ -467,7 +481,9 @@ class AdmonitionInserter:
**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,
# the method _resolve_arg returns None if nothing could be resolved.
# 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):
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
or its sub-arguments (in cases like Union[...]) belong to, if they can be resolved to
telegram or telegram.ext classes.
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 (
origin in (collections.abc.Callable, typing.IO)
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
# RECURSIVE CALLS
# for cases like Union[Sequence....
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
args = typing.get_args(typ)
# print(f"In recurse_type, found __origin__ {typ=}, {args=}")
for ar in args:
recurse_type(ar)
elif isinstance(typ, typing.TypeVar):
# gets access to the "bound=..." parameter
recurse_type(typ.__bound__)
elif inspect.isclass(typ) and "telegram" in inspect.getmodule(typ).__name__:
# print(f"typ is a class and inherits from TelegramObject: {typ=}")
telegram_classes.add(typ)
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...].
# 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
print()
print(f"in _resolve_arg {type_hints=}")
for param_name, type_hint in type_hints.items():
print(f"{param_name=}", f"{type_hint=}")
if type_hint is None:
continue
recurse_type(type_hint)
print(f"{telegram_classes=}")
return list(telegram_classes)
# origin = typing.get_origin(arg)
elif isinstance(arg, type):
if "telegram" in str(arg):
yield arg
# if (
# origin in (collections.abc.Callable, typing.IO)
# 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
# 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)
# # RECURSIVE CALLS
# # for cases like Union[Sequence....
# 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)
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
# elif isinstance(arg, typing.TypeVar):
# # gets access to the "bound=..." parameter
# yield from self._resolve_arg(arg.__bound__)
# # END RECURSIVE CALLS
else:
raise NotImplementedError(
f"Cannot process argument {arg} of type {type(arg)} (origin {origin})"
)
# 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:
# 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
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
# 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.
# The tests require Python 3.9+ (just like AdmonitionInserter being tested),
# so they cannot be included in the main suite while older versions of Python are supported.
"""
This module is intentionally named without "test_" prefix.
These tests are supposed to be run on GitHub when building docs.
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