Skip to content

Commit

Permalink
Add sphinx.pycode.function.Signature
Browse files Browse the repository at this point in the history
  • Loading branch information
tk0miya committed Jan 4, 2020
1 parent 0319faf commit 17b44f9
Show file tree
Hide file tree
Showing 2 changed files with 215 additions and 0 deletions.
118 changes: 118 additions & 0 deletions sphinx/pycode/function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
sphinx.pycode.function
~~~~~~~~~~~~~~~~~~~~~~
Utilities parsing function definitions
:copyright: Copyright 2007-2020 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
"""

import ast
from collections import OrderedDict
from inspect import Parameter
from typing import cast
from typing import Mapping


def stringify(node: ast.AST) -> str:
"""Stringify an AST node."""
if node is None:
return None
elif isinstance(node, ast.Attribute):
return '%s.%s' % (stringify(node.value), node.attr)
elif isinstance(node, ast.Bytes):
return repr(node.s)
elif isinstance(node, ast.Call):
args = ([stringify(e) for e in node.args] +
['%s=%s' % (k.arg, stringify(k.value)) for k in node.keywords])
return '%s(%s)' % (stringify(node.func), ', '.join(args))
elif isinstance(node, ast.Dict):
keys = (stringify(k) for k in node.keys)
values = (stringify(v) for v in node.values)
items = (k + ': ' + v for k, v in zip(keys, values))
return '{' + ', '.join(items) + '}'
elif isinstance(node, ast.Ellipsis):
return '...'
elif sys.version_info > (3, 6) and isinstance(node, ast.Constant):
return repr(node.value)
elif isinstance(node, ast.Index):
return stringify(node.value)
elif isinstance(node, ast.Lambda):
return '<function <lambda>>' # TODO
elif isinstance(node, ast.List):
return '[' + ', '.join(stringify(e) for e in node.elts) + ']'
elif isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.NameConstant):
return repr(node.value)
elif isinstance(node, ast.Num):
return repr(node.n)
elif isinstance(node, ast.Set):
return "{" + ', '.join(stringify(e) for e in node.elts) + "}"
elif isinstance(node, ast.Str):
return repr(node.s)
elif isinstance(node, ast.Subscript):
return "%s[%s]" % (stringify(node.value), stringify(node.slice))
elif isinstance(node, ast.Tuple):
return ', '.join(stringify(e) for e in node.elts)
else:
# TODO: f-string
raise NotImplementedError('Unable to render %s instance' % type(node).__name__)


class Signature:
"""A Signature object represents the call signature of a function and its return annotation.
This class provides ``inspect.Signature`` like interface for string based call signatures::
sig = Signature('(a, b, *args, **kwargs)')
sig.parameters # => returns a dict for Parameters
"""

def __init__(self, signature: str) -> None:
module = ast.parse('def func' + signature + ': pass')
self.definition = cast(ast.FunctionDef, module.body[0])

@property
def parameters(self) -> Mapping:
args = self.definition.args
params = []

if hasattr(args, "posonlyargs"): # for py38+
for arg in args.posonlyargs: # type: ignore
annotation = stringify(arg.annotation) or Parameter.empty
params.append(Parameter(arg.arg, Parameter.POSITIONAL_ONLY,
annotation=annotation))

for i, arg in enumerate(args.args):
if len(args.args) - i <= len(args.defaults):
default = stringify(args.defaults[-len(args.args) + i])
else:
default = Parameter.empty

annotation = stringify(arg.annotation) or Parameter.empty
params.append(Parameter(arg.arg, Parameter.POSITIONAL_OR_KEYWORD,
default=default, annotation=annotation))

if args.vararg:
annotation = stringify(args.vararg.annotation) or Parameter.empty
params.append(Parameter(args.vararg.arg, Parameter.VAR_POSITIONAL,
annotation=annotation))

for i, arg in enumerate(args.kwonlyargs):
default = stringify(args.kw_defaults[i])
annotation = stringify(arg.annotation) or Parameter.empty
params.append(Parameter(arg.arg, Parameter.KEYWORD_ONLY, default=default,
annotation=annotation))

if args.kwarg:
annotation = stringify(args.kwarg.annotation) or Parameter.empty
params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD,
annotation=annotation))

return OrderedDict((p.name, p) for p in params)

@property
def return_annotation(self) -> str:
return stringify(self.definition.returns)
97 changes: 97 additions & 0 deletions tests/test_pycode_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""
test_pycode_function
~~~~~~~~~~~~~~~~~~~~
Test pycode.function.
:copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
"""

import sys
from inspect import Parameter

import pytest

from sphinx.pycode.function import Signature


def test_signature_basic():
sig = Signature('(a, b, *args, c=0, d="blah", **kwargs)')
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 is None


def test_signature_default_values():
sig = Signature('(a=0, b=0.0, c="str", d=b"bytes", e=..., f=True, '
'g=[1, 2, 3], h={"a": 1}, i={1, 2, 3}, '
'j=lambda x, y: None, k=None, l=object(), m=foo.bar.CONSTANT)')
assert sig.parameters['a'].default == '0'
assert sig.parameters['b'].default == '0.0'
assert sig.parameters['c'].default == "'str'"
assert sig.parameters['d'].default == "b'bytes'"
assert sig.parameters['e'].default == '...'
assert sig.parameters['f'].default == 'True'
assert sig.parameters['g'].default == '[1, 2, 3]'
assert sig.parameters['h'].default == "{'a': 1}"
assert sig.parameters['i'].default == '{1, 2, 3}'
assert sig.parameters['j'].default == '<function <lambda>>'
assert sig.parameters['k'].default == 'None'
assert sig.parameters['l'].default == 'object()'
assert sig.parameters['m'].default == 'foo.bar.CONSTANT'


def test_signature_annotations():
sig = Signature('(a: int, *args: bytes, b: str = "blah", **kwargs: float) -> None')
assert list(sig.parameters.keys()) == ['a', 'args', 'b', 'kwargs']
assert sig.parameters['a'].annotation == "int"
assert sig.parameters['args'].annotation == "bytes"
assert sig.parameters['b'].annotation == "str"
assert sig.parameters['kwargs'].annotation == "float"
assert sig.return_annotation == 'None'


def test_signature_complex_annotations():
sig = Signature('() -> Tuple[str, int, ...]')
assert sig.return_annotation == 'Tuple[str, int, ...]'

sig = Signature('() -> Callable[[int, int], int]')
assert sig.return_annotation == 'Callable[[int, int], int]'


@pytest.mark.skipif(sys.version_info < (3, 8),
reason='python-3.8 or above is required')
def test_signature_positionaly_only_args():
sig = Signature('(a, /, b)')
assert list(sig.parameters.keys()) == ['a', 'b']
assert sig.parameters['a'].kind == Parameter.POSITIONAL_ONLY
assert sig.parameters['b'].kind == Parameter.POSITIONAL_OR_KEYWORD


def test_signature_invalid():
with pytest.raises(SyntaxError):
Signature('')

0 comments on commit 17b44f9

Please sign in to comment.