Merge pull request #7716 from tk0miya/3610_support_overload

Close #3610: autodoc: Support overloaded functions
This commit is contained in:
Takeshi KOMIYA 2020-06-05 02:22:29 +09:00 committed by GitHub
commit bd2caee82f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 365 additions and 16 deletions

View File

@ -51,6 +51,7 @@ Features added
builtin base classes builtin base classes
* #2106: autodoc: Support multiple signatures on docstring * #2106: autodoc: Support multiple signatures on docstring
* #4422: autodoc: Support GenericAlias in Python 3.7 or above * #4422: autodoc: Support GenericAlias in Python 3.7 or above
* #3610: autodoc: Support overloaded functions
* #7466: autosummary: headings in generated documents are not translated * #7466: autosummary: headings in generated documents are not translated
* #7490: autosummary: Add ``:caption:`` option to autosummary directive to set a * #7490: autosummary: Add ``:caption:`` option to autosummary directive to set a
caption to the toctree caption to the toctree

View File

@ -1192,8 +1192,14 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ
self.add_line(' :async:', sourcename) self.add_line(' :async:', sourcename)
def format_signature(self, **kwargs: Any) -> str: def format_signature(self, **kwargs: Any) -> str:
sigs = []
if self.analyzer and '.'.join(self.objpath) in self.analyzer.overloads:
# Use signatures for overloaded functions instead of the implementation function.
overloaded = True
else:
overloaded = False
sig = super().format_signature(**kwargs) sig = super().format_signature(**kwargs)
sigs = [sig] sigs.append(sig)
if inspect.is_singledispatch_function(self.object): if inspect.is_singledispatch_function(self.object):
# append signature of singledispatch'ed functions # append signature of singledispatch'ed functions
@ -1207,6 +1213,10 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ
documenter.object = func documenter.object = func
documenter.objpath = [None] documenter.objpath = [None]
sigs.append(documenter.format_signature()) sigs.append(documenter.format_signature())
if overloaded:
for overload in self.analyzer.overloads.get('.'.join(self.objpath)):
sig = stringify_signature(overload, **kwargs)
sigs.append(sig)
return "\n".join(sigs) return "\n".join(sigs)
@ -1269,6 +1279,9 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
'private-members': bool_option, 'special-members': members_option, 'private-members': bool_option, 'special-members': members_option,
} # type: Dict[str, Callable] } # type: Dict[str, Callable]
_signature_class = None # type: Any
_signature_method_name = None # type: str
def __init__(self, *args: Any) -> None: def __init__(self, *args: Any) -> None:
super().__init__(*args) super().__init__(*args)
merge_special_members_option(self.options) merge_special_members_option(self.options)
@ -1289,7 +1302,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
self.doc_as_attr = True self.doc_as_attr = True
return ret return ret
def _get_signature(self) -> Optional[Signature]: def _get_signature(self) -> Tuple[Optional[Any], Optional[str], Optional[Signature]]:
def get_user_defined_function_or_method(obj: Any, attr: str) -> Any: def get_user_defined_function_or_method(obj: Any, attr: str) -> Any:
""" Get the `attr` function or method from `obj`, if it is user-defined. """ """ Get the `attr` function or method from `obj`, if it is user-defined. """
if inspect.is_builtin_class_method(obj, attr): if inspect.is_builtin_class_method(obj, attr):
@ -1313,7 +1326,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
if call is not None: if call is not None:
self.env.app.emit('autodoc-before-process-signature', call, True) self.env.app.emit('autodoc-before-process-signature', call, True)
try: try:
return inspect.signature(call, bound_method=True) sig = inspect.signature(call, bound_method=True)
return type(self.object), '__call__', sig
except ValueError: except ValueError:
pass pass
@ -1322,7 +1336,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
if new is not None: if new is not None:
self.env.app.emit('autodoc-before-process-signature', new, True) self.env.app.emit('autodoc-before-process-signature', new, True)
try: try:
return inspect.signature(new, bound_method=True) sig = inspect.signature(new, bound_method=True)
return self.object, '__new__', sig
except ValueError: except ValueError:
pass pass
@ -1331,7 +1346,8 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
if init is not None: if init is not None:
self.env.app.emit('autodoc-before-process-signature', init, True) self.env.app.emit('autodoc-before-process-signature', init, True)
try: try:
return inspect.signature(init, bound_method=True) sig = inspect.signature(init, bound_method=True)
return self.object, '__init__', sig
except ValueError: except ValueError:
pass pass
@ -1341,20 +1357,21 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
# the signature from, so just pass the object itself to our hook. # the signature from, so just pass the object itself to our hook.
self.env.app.emit('autodoc-before-process-signature', self.object, False) self.env.app.emit('autodoc-before-process-signature', self.object, False)
try: try:
return inspect.signature(self.object, bound_method=False) sig = inspect.signature(self.object, bound_method=False)
return None, None, sig
except ValueError: except ValueError:
pass pass
# Still no signature: happens e.g. for old-style classes # Still no signature: happens e.g. for old-style classes
# with __init__ in C and no `__text_signature__`. # with __init__ in C and no `__text_signature__`.
return None return None, None, None
def format_args(self, **kwargs: Any) -> str: def format_args(self, **kwargs: Any) -> str:
if self.env.config.autodoc_typehints in ('none', 'description'): if self.env.config.autodoc_typehints in ('none', 'description'):
kwargs.setdefault('show_annotation', False) kwargs.setdefault('show_annotation', False)
try: try:
sig = self._get_signature() self._signature_class, self._signature_method_name, sig = self._get_signature()
except TypeError as exc: except TypeError as exc:
# __signature__ attribute contained junk # __signature__ attribute contained junk
logger.warning(__("Failed to get a constructor signature for %s: %s"), logger.warning(__("Failed to get a constructor signature for %s: %s"),
@ -1370,7 +1387,30 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type:
if self.doc_as_attr: if self.doc_as_attr:
return '' return ''
return super().format_signature(**kwargs) sig = super().format_signature()
overloaded = False
qualname = None
# TODO: recreate analyzer for the module of class (To be clear, owner of the method)
if self._signature_class and self._signature_method_name and self.analyzer:
qualname = '.'.join([self._signature_class.__qualname__,
self._signature_method_name])
if qualname in self.analyzer.overloads:
overloaded = True
sigs = []
if overloaded:
# Use signatures for overloaded methods instead of the implementation method.
for overload in self.analyzer.overloads.get(qualname):
parameters = list(overload.parameters.values())
overload = overload.replace(parameters=parameters[1:],
return_annotation=Parameter.empty)
sig = stringify_signature(overload, **kwargs)
sigs.append(sig)
else:
sigs.append(sig)
return "\n".join(sigs)
def add_directive_header(self, sig: str) -> None: def add_directive_header(self, sig: str) -> None:
sourcename = self.get_sourcename() sourcename = self.get_sourcename()
@ -1693,8 +1733,14 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type:
pass pass
def format_signature(self, **kwargs: Any) -> str: def format_signature(self, **kwargs: Any) -> str:
sigs = []
if self.analyzer and '.'.join(self.objpath) in self.analyzer.overloads:
# Use signatures for overloaded methods instead of the implementation method.
overloaded = True
else:
overloaded = False
sig = super().format_signature(**kwargs) sig = super().format_signature(**kwargs)
sigs = [sig] sigs.append(sig)
meth = self.parent.__dict__.get(self.objpath[-1]) meth = self.parent.__dict__.get(self.objpath[-1])
if inspect.is_singledispatch_method(meth): if inspect.is_singledispatch_method(meth):
@ -1710,6 +1756,14 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type:
documenter.object = func documenter.object = func
documenter.objpath = [None] documenter.objpath = [None]
sigs.append(documenter.format_signature()) sigs.append(documenter.format_signature())
if overloaded:
for overload in self.analyzer.overloads.get('.'.join(self.objpath)):
if not inspect.isstaticmethod(self.object, cls=self.parent,
name=self.object_name):
parameters = list(overload.parameters.values())
overload = overload.replace(parameters=parameters[1:])
sig = stringify_signature(overload, **kwargs)
sigs.append(sig)
return "\n".join(sigs) return "\n".join(sigs)

View File

@ -12,6 +12,7 @@ import re
import tokenize import tokenize
import warnings import warnings
from importlib import import_module from importlib import import_module
from inspect import Signature
from io import StringIO from io import StringIO
from os import path from os import path
from typing import Any, Dict, IO, List, Tuple, Optional from typing import Any, Dict, IO, List, Tuple, Optional
@ -145,6 +146,7 @@ class ModuleAnalyzer:
self.annotations = None # type: Dict[Tuple[str, str], str] self.annotations = None # type: Dict[Tuple[str, str], str]
self.attr_docs = None # type: Dict[Tuple[str, str], List[str]] self.attr_docs = None # type: Dict[Tuple[str, str], List[str]]
self.finals = None # type: List[str] self.finals = None # type: List[str]
self.overloads = None # type: Dict[str, List[Signature]]
self.tagorder = None # type: Dict[str, int] self.tagorder = None # type: Dict[str, int]
self.tags = None # type: Dict[str, Tuple[str, int, int]] self.tags = None # type: Dict[str, Tuple[str, int, int]]
@ -163,6 +165,7 @@ class ModuleAnalyzer:
self.annotations = parser.annotations self.annotations = parser.annotations
self.finals = parser.finals self.finals = parser.finals
self.overloads = parser.overloads
self.tags = parser.definitions self.tags = parser.definitions
self.tagorder = parser.deforders self.tagorder = parser.deforders
except Exception as exc: except Exception as exc:

View File

@ -12,12 +12,14 @@ import itertools
import re import re
import sys import sys
import tokenize import tokenize
from inspect import Signature
from token import NAME, NEWLINE, INDENT, DEDENT, NUMBER, OP, STRING from token import NAME, NEWLINE, INDENT, DEDENT, NUMBER, OP, STRING
from tokenize import COMMENT, NL from tokenize import COMMENT, NL
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from sphinx.pycode.ast import ast # for py37 or older from sphinx.pycode.ast import ast # for py37 or older
from sphinx.pycode.ast import parse, unparse from sphinx.pycode.ast import parse, unparse
from sphinx.util.inspect import signature_from_ast
comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$') comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$')
@ -232,8 +234,10 @@ class VariableCommentPicker(ast.NodeVisitor):
self.previous = None # type: ast.AST self.previous = None # type: ast.AST
self.deforders = {} # type: Dict[str, int] self.deforders = {} # type: Dict[str, int]
self.finals = [] # type: List[str] self.finals = [] # type: List[str]
self.overloads = {} # type: Dict[str, List[Signature]]
self.typing = None # type: str self.typing = None # type: str
self.typing_final = None # type: str self.typing_final = None # type: str
self.typing_overload = None # type: str
super().__init__() super().__init__()
def get_qualname_for(self, name: str) -> Optional[List[str]]: def get_qualname_for(self, name: str) -> Optional[List[str]]:
@ -257,6 +261,12 @@ class VariableCommentPicker(ast.NodeVisitor):
if qualname: if qualname:
self.finals.append(".".join(qualname)) self.finals.append(".".join(qualname))
def add_overload_entry(self, func: ast.FunctionDef) -> None:
qualname = self.get_qualname_for(func.name)
if qualname:
overloads = self.overloads.setdefault(".".join(qualname), [])
overloads.append(signature_from_ast(func))
def add_variable_comment(self, name: str, comment: str) -> None: def add_variable_comment(self, name: str, comment: str) -> None:
qualname = self.get_qualname_for(name) qualname = self.get_qualname_for(name)
if qualname: if qualname:
@ -285,6 +295,22 @@ class VariableCommentPicker(ast.NodeVisitor):
return False return False
def is_overload(self, decorators: List[ast.expr]) -> bool:
overload = []
if self.typing:
overload.append('%s.overload' % self.typing)
if self.typing_overload:
overload.append(self.typing_overload)
for decorator in decorators:
try:
if unparse(decorator) in overload:
return True
except NotImplementedError:
pass
return False
def get_self(self) -> ast.arg: def get_self(self) -> ast.arg:
"""Returns the name of first argument if in function.""" """Returns the name of first argument if in function."""
if self.current_function and self.current_function.args.args: if self.current_function and self.current_function.args.args:
@ -310,6 +336,8 @@ class VariableCommentPicker(ast.NodeVisitor):
self.typing = name.asname or name.name self.typing = name.asname or name.name
elif name.name == 'typing.final': elif name.name == 'typing.final':
self.typing_final = name.asname or name.name self.typing_final = name.asname or name.name
elif name.name == 'typing.overload':
self.typing_overload = name.asname or name.name
def visit_ImportFrom(self, node: ast.ImportFrom) -> None: def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
"""Handles Import node and record it to definition orders.""" """Handles Import node and record it to definition orders."""
@ -318,6 +346,8 @@ class VariableCommentPicker(ast.NodeVisitor):
if node.module == 'typing' and name.name == 'final': if node.module == 'typing' and name.name == 'final':
self.typing_final = name.asname or name.name self.typing_final = name.asname or name.name
elif node.module == 'typing' and name.name == 'overload':
self.typing_overload = name.asname or name.name
def visit_Assign(self, node: ast.Assign) -> None: def visit_Assign(self, node: ast.Assign) -> None:
"""Handles Assign node and pick up a variable comment.""" """Handles Assign node and pick up a variable comment."""
@ -417,6 +447,8 @@ class VariableCommentPicker(ast.NodeVisitor):
self.add_entry(node.name) # should be called before setting self.current_function self.add_entry(node.name) # should be called before setting self.current_function
if self.is_final(node.decorator_list): if self.is_final(node.decorator_list):
self.add_final_entry(node.name) self.add_final_entry(node.name)
if self.is_overload(node.decorator_list):
self.add_overload_entry(node)
self.context.append(node.name) self.context.append(node.name)
self.current_function = node self.current_function = node
for child in node.body: for child in node.body:
@ -518,6 +550,7 @@ class Parser:
self.deforders = {} # type: Dict[str, int] self.deforders = {} # type: Dict[str, int]
self.definitions = {} # type: Dict[str, Tuple[str, int, int]] self.definitions = {} # type: Dict[str, Tuple[str, int, int]]
self.finals = [] # type: List[str] self.finals = [] # type: List[str]
self.overloads = {} # type: Dict[str, List[Signature]]
def parse(self) -> None: def parse(self) -> None:
"""Parse the source code.""" """Parse the source code."""
@ -533,6 +566,7 @@ class Parser:
self.comments = picker.comments self.comments = picker.comments
self.deforders = picker.deforders self.deforders = picker.deforders
self.finals = picker.finals self.finals = picker.finals
self.overloads = picker.overloads
def parse_definition(self) -> None: def parse_definition(self) -> None:
"""Parse the location of definitions from the code.""" """Parse the location of definitions from the code."""

View File

@ -527,10 +527,14 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
def signature_from_str(signature: str) -> inspect.Signature: def signature_from_str(signature: str) -> inspect.Signature:
"""Create a Signature object from string.""" """Create a Signature object from string."""
module = ast.parse('def func' + signature + ': pass') module = ast.parse('def func' + signature + ': pass')
definition = cast(ast.FunctionDef, module.body[0]) # type: ignore function = cast(ast.FunctionDef, module.body[0]) # type: ignore
# parameters return signature_from_ast(function)
args = definition.args
def signature_from_ast(node: ast.FunctionDef) -> inspect.Signature:
"""Create a Signature object from AST *node*."""
args = node.args
defaults = list(args.defaults) defaults = list(args.defaults)
params = [] params = []
if hasattr(args, "posonlyargs"): if hasattr(args, "posonlyargs"):
@ -580,7 +584,7 @@ def signature_from_str(signature: str) -> inspect.Signature:
params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD, params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD,
annotation=annotation)) annotation=annotation))
return_annotation = ast_unparse(definition.returns) or Parameter.empty return_annotation = ast_unparse(node.returns) or Parameter.empty
return inspect.Signature(params, return_annotation=return_annotation) return inspect.Signature(params, return_annotation=return_annotation)

View File

@ -0,0 +1,88 @@
from typing import Any, overload
@overload
def sum(x: int, y: int) -> int:
...
@overload
def sum(x: float, y: float) -> float:
...
@overload
def sum(x: str, y: str) -> str:
...
def sum(x, y):
"""docstring"""
return x + y
class Math:
"""docstring"""
@overload
def sum(self, x: int, y: int) -> int:
...
@overload
def sum(self, x: float, y: float) -> float:
...
@overload
def sum(self, x: str, y: str) -> str:
...
def sum(self, x, y):
"""docstring"""
return x + y
class Foo:
"""docstring"""
@overload
def __new__(cls, x: int, y: int) -> "Foo":
...
@overload
def __new__(cls, x: str, y: str) -> "Foo":
...
def __new__(cls, x, y):
pass
class Bar:
"""docstring"""
@overload
def __init__(cls, x: int, y: int) -> None:
...
@overload
def __init__(cls, x: str, y: str) -> None:
...
def __init__(cls, x, y):
pass
class Meta(type):
@overload
def __call__(cls, x: int, y: int) -> Any:
...
@overload
def __call__(cls, x: str, y: str) -> Any:
...
def __call__(cls, x, y):
pass
class Baz(metaclass=Meta):
"""docstring"""

View File

@ -1787,6 +1787,60 @@ def test_final(app):
] ]
@pytest.mark.sphinx('html', testroot='ext-autodoc')
def test_overload(app):
options = {"members": None}
actual = do_autodoc(app, 'module', 'target.overload', options)
assert list(actual) == [
'',
'.. py:module:: target.overload',
'',
'',
'.. py:class:: Bar(x: int, y: int)',
' Bar(x: str, y: str)',
' :module: target.overload',
'',
' docstring',
'',
'',
'.. py:class:: Baz(x: int, y: int)',
' Baz(x: str, y: str)',
' :module: target.overload',
'',
' docstring',
'',
'',
'.. py:class:: Foo(x: int, y: int)',
' Foo(x: str, y: str)',
' :module: target.overload',
'',
' docstring',
'',
'',
'.. py:class:: Math()',
' :module: target.overload',
'',
' docstring',
'',
'',
' .. py:method:: Math.sum(x: int, y: int) -> int',
' Math.sum(x: float, y: float) -> float',
' Math.sum(x: str, y: str) -> str',
' :module: target.overload',
'',
' docstring',
'',
'',
'.. py:function:: sum(x: int, y: int) -> int',
' sum(x: float, y: float) -> float',
' sum(x: str, y: str) -> str',
' :module: target.overload',
'',
' docstring',
'',
]
@pytest.mark.sphinx('dummy', testroot='ext-autodoc') @pytest.mark.sphinx('dummy', testroot='ext-autodoc')
def test_autodoc(app, status, warning): def test_autodoc(app, status, warning):
app.builder.build_all() app.builder.build_all()

View File

@ -13,6 +13,7 @@ import sys
import pytest import pytest
from sphinx.pycode.parser import Parser from sphinx.pycode.parser import Parser
from sphinx.util.inspect import signature_from_str
def test_comment_picker_basic(): def test_comment_picker_basic():
@ -452,3 +453,80 @@ def test_typing_final_not_imported():
parser = Parser(source) parser = Parser(source)
parser.parse() parser.parse()
assert parser.finals == [] assert parser.finals == []
def test_typing_overload():
source = ('import typing\n'
'\n'
'@typing.overload\n'
'def func(x: int, y: int) -> int: pass\n'
'\n'
'@typing.overload\n'
'def func(x: str, y: str) -> str: pass\n'
'\n'
'def func(x, y): pass\n')
parser = Parser(source)
parser.parse()
assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'),
signature_from_str('(x: str, y: str) -> str')]}
def test_typing_overload_from_import():
source = ('from typing import overload\n'
'\n'
'@overload\n'
'def func(x: int, y: int) -> int: pass\n'
'\n'
'@overload\n'
'def func(x: str, y: str) -> str: pass\n'
'\n'
'def func(x, y): pass\n')
parser = Parser(source)
parser.parse()
assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'),
signature_from_str('(x: str, y: str) -> str')]}
def test_typing_overload_import_as():
source = ('import typing as foo\n'
'\n'
'@foo.overload\n'
'def func(x: int, y: int) -> int: pass\n'
'\n'
'@foo.overload\n'
'def func(x: str, y: str) -> str: pass\n'
'\n'
'def func(x, y): pass\n')
parser = Parser(source)
parser.parse()
assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'),
signature_from_str('(x: str, y: str) -> str')]}
def test_typing_overload_from_import_as():
source = ('from typing import overload as bar\n'
'\n'
'@bar\n'
'def func(x: int, y: int) -> int: pass\n'
'\n'
'@bar\n'
'def func(x: str, y: str) -> str: pass\n'
'\n'
'def func(x, y): pass\n')
parser = Parser(source)
parser.parse()
assert parser.overloads == {'func': [signature_from_str('(x: int, y: int) -> int'),
signature_from_str('(x: str, y: str) -> str')]}
def test_typing_overload_not_imported():
source = ('@typing.final\n'
'def func(x: int, y: int) -> int: pass\n'
'\n'
'@typing.final\n'
'def func(x: str, y: str) -> str: pass\n'
'\n'
'def func(x, y): pass\n')
parser = Parser(source)
parser.parse()
assert parser.overloads == {}

View File

@ -9,6 +9,7 @@
""" """
import _testcapi import _testcapi
import ast
import datetime import datetime
import functools import functools
import sys import sys
@ -350,6 +351,38 @@ def test_signature_from_str_invalid():
inspect.signature_from_str('') inspect.signature_from_str('')
def test_signature_from_ast():
signature = 'def func(a, b, *args, c=0, d="blah", **kwargs): pass'
tree = ast.parse(signature)
sig = inspect.signature_from_ast(tree.body[0])
assert list(sig.parameters.keys()) == ['a', 'b', 'args', 'c', 'd', 'kwargs']
assert sig.parameters['a'].name == 'a'
assert sig.parameters['a'].kind == Parameter.POSITIONAL_OR_KEYWORD
assert sig.parameters['a'].default == Parameter.empty
assert sig.parameters['a'].annotation == Parameter.empty
assert sig.parameters['b'].name == 'b'
assert sig.parameters['b'].kind == Parameter.POSITIONAL_OR_KEYWORD
assert sig.parameters['b'].default == Parameter.empty
assert sig.parameters['b'].annotation == Parameter.empty
assert sig.parameters['args'].name == 'args'
assert sig.parameters['args'].kind == Parameter.VAR_POSITIONAL
assert sig.parameters['args'].default == Parameter.empty
assert sig.parameters['args'].annotation == Parameter.empty
assert sig.parameters['c'].name == 'c'
assert sig.parameters['c'].kind == Parameter.KEYWORD_ONLY
assert sig.parameters['c'].default == '0'
assert sig.parameters['c'].annotation == Parameter.empty
assert sig.parameters['d'].name == 'd'
assert sig.parameters['d'].kind == Parameter.KEYWORD_ONLY
assert sig.parameters['d'].default == "'blah'"
assert sig.parameters['d'].annotation == Parameter.empty
assert sig.parameters['kwargs'].name == 'kwargs'
assert sig.parameters['kwargs'].kind == Parameter.VAR_KEYWORD
assert sig.parameters['kwargs'].default == Parameter.empty
assert sig.parameters['kwargs'].annotation == Parameter.empty
assert sig.return_annotation == Parameter.empty
def test_safe_getattr_with_default(): def test_safe_getattr_with_default():
class Foo: class Foo:
def __getattr__(self, item): def __getattr__(self, item):