Skip to content

Commit

Permalink
Upgrade define_function_signature
Browse files Browse the repository at this point in the history
  • Loading branch information
Zac-HD committed Jun 29, 2022
1 parent a01b66c commit 98734be
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 221 deletions.
44 changes: 35 additions & 9 deletions hypothesis-python/src/hypothesis/core.py
Expand Up @@ -58,6 +58,7 @@
Flaky,
Found,
HypothesisDeprecationWarning,
HypothesisException,
HypothesisWarning,
InvalidArgument,
MultipleFailures,
Expand Down Expand Up @@ -272,6 +273,13 @@ def wrapped_test(*arguments, **kwargs):
if not (given_arguments or given_kwargs):
return invalid("given must be called with at least one argument")

p = inspect.signature(test).parameters
if p and list(p.values())[0].kind is inspect.Parameter.POSITIONAL_ONLY:
return invalid(
"given does not support tests with positional-only arguments",
exc=HypothesisException,
)

if given_arguments and any(
[original_argspec.varargs, original_argspec.varkw, original_argspec.kwonlyargs]
):
Expand Down Expand Up @@ -310,13 +318,6 @@ def wrapped_test(*arguments, **kwargs):
)
if original_argspec.defaults or original_argspec.kwonlydefaults:
return invalid("Cannot apply @given to a function with defaults.")
missing = [repr(kw) for kw in original_argspec.kwonlyargs if kw not in given_kwargs]
if missing:
return invalid(
"Missing required kwarg{}: {}".format(
"s" if len(missing) > 1 else "", ", ".join(missing)
)
)

# This case would raise Unsatisfiable *anyway*, but by detecting it here we can
# provide a much more helpful error message for people e.g. using the Ghostwriter.
Expand Down Expand Up @@ -972,6 +973,30 @@ def fuzz_one_input(
return self.__cached_target


def fullargspec_to_signature(
argspec: inspect.FullArgSpec, *, return_annotation: object = inspect.Parameter.empty
) -> inspect.Signature:
# Construct a new signature based on this argspec. We'll later convert everything
# over to explicit use of signature everywhere, but this is a nice stopgap.

def as_param(name, kind, defaults):
annot = argspec.annotations.get(name, P.empty)
return P(name, kind, default=defaults.get(name, P.empty), annotation=annot)

params = []
P = inspect.Parameter
for arg in argspec.args:
defaults = dict(zip(argspec.args[::-1], (argspec.defaults or [])[::-1]))
params.append(as_param(arg, P.POSITIONAL_OR_KEYWORD, defaults))
if argspec.varargs:
params.append(as_param(argspec.varargs, P.VAR_POSITIONAL, {}))
for arg in argspec.kwonlyargs:
params.append(as_param(arg, P.KEYWORD_ONLY, argspec.kwonlydefaults or {}))
if argspec.varkw:
params.append(as_param(argspec.varkw, P.VAR_KEYWORD, {}))
return inspect.Signature(params, return_annotation=return_annotation)


@overload
def given(
*_given_arguments: Union[SearchStrategy[Any], InferType],
Expand Down Expand Up @@ -1039,6 +1064,7 @@ def run_test_as_given(test):
del given_arguments

argspec = new_given_argspec(original_argspec, given_kwargs)
new_signature = fullargspec_to_signature(argspec, return_annotation=None)

# Use type information to convert "infer" arguments into appropriate strategies.
if infer in given_kwargs.values():
Expand All @@ -1049,7 +1075,7 @@ def run_test_as_given(test):
# not when it's decorated.

@impersonate(test)
@define_function_signature(test.__name__, test.__doc__, argspec)
@define_function_signature(test.__name__, test.__doc__, new_signature)
def wrapped_test(*arguments, **kwargs):
__tracebackhide__ = True
raise InvalidArgument(
Expand All @@ -1062,7 +1088,7 @@ def wrapped_test(*arguments, **kwargs):
given_kwargs[name] = st.from_type(hints[name])

@impersonate(test)
@define_function_signature(test.__name__, test.__doc__, argspec)
@define_function_signature(test.__name__, test.__doc__, new_signature)
def wrapped_test(*arguments, **kwargs):
# Tell pytest to omit the body of this function from tracebacks
__tracebackhide__ = True
Expand Down
4 changes: 2 additions & 2 deletions hypothesis-python/src/hypothesis/extra/django/_impl.py
Expand Up @@ -22,7 +22,7 @@
from hypothesis import reject, strategies as st
from hypothesis.errors import InvalidArgument
from hypothesis.extra.django._fields import from_field
from hypothesis.internal.reflection import define_function_signature_from_signature
from hypothesis.internal.reflection import define_function_signature
from hypothesis.strategies._internal.utils import defines_strategy
from hypothesis.utils.conventions import infer

Expand Down Expand Up @@ -137,7 +137,7 @@ def from_model(
sig = signature(from_model)
params = list(sig.parameters.values())
params[0] = params[0].replace(kind=Parameter.POSITIONAL_ONLY)
from_model = define_function_signature_from_signature(
from_model = define_function_signature(
name=from_model.__name__,
docstring=from_model.__doc__,
signature=sig.replace(parameters=params),
Expand Down
104 changes: 7 additions & 97 deletions hypothesis-python/src/hypothesis/internal/reflection.py
Expand Up @@ -185,7 +185,7 @@ def convert_positional_arguments(function, args, kwargs):
"""Return a tuple (new_args, new_kwargs) where all possible arguments have
been moved to kwargs.
new_args will only be non-empty if function has a variadic argument.
new_args will only be non-empty if function has pos-only args or *args.
"""
sig = inspect.signature(function, follow_wrapped=False)
bound = sig.bind(*args, **kwargs)
Expand Down Expand Up @@ -436,96 +436,6 @@ def source_exec_as_module(source):
return result


COPY_ARGSPEC_SCRIPT = """
from hypothesis.utils.conventions import not_set
def accept({funcname}):
def {name}({argspec}):
return {funcname}({invocation})
return {name}
""".lstrip()


def define_function_signature(name, docstring, argspec):
"""A decorator which sets the name, argspec and docstring of the function
passed into it."""
if name == "<lambda>":
name = "_lambda_"
check_valid_identifier(name)
for a in argspec.args:
check_valid_identifier(a)
if argspec.varargs is not None:
check_valid_identifier(argspec.varargs)
if argspec.varkw is not None:
check_valid_identifier(argspec.varkw)
n_defaults = len(argspec.defaults or ())
if n_defaults:
parts = []
for a in argspec.args[:-n_defaults]:
parts.append(a)
for a in argspec.args[-n_defaults:]:
parts.append(f"{a}=not_set")
else:
parts = list(argspec.args)
used_names = list(argspec.args) + list(argspec.kwonlyargs)
used_names.append(name)

for a in argspec.kwonlyargs:
check_valid_identifier(a)

def accept(f):
fargspec = getfullargspec_except_self(f)
must_pass_as_kwargs = []
invocation_parts = []
for a in argspec.args:
if a not in fargspec.args and not fargspec.varargs:
must_pass_as_kwargs.append(a) # pragma: no cover
else:
invocation_parts.append(a)
if argspec.varargs:
used_names.append(argspec.varargs)
parts.append("*" + argspec.varargs)
invocation_parts.append("*" + argspec.varargs)
elif argspec.kwonlyargs:
parts.append("*")
for k in must_pass_as_kwargs:
invocation_parts.append(f"{k}={k}") # pragma: no cover

for k in argspec.kwonlyargs:
invocation_parts.append(f"{k}={k}")
if k in (argspec.kwonlydefaults or []):
parts.append(f"{k}=not_set")
else:
parts.append(k)
if argspec.varkw:
used_names.append(argspec.varkw)
parts.append("**" + argspec.varkw)
invocation_parts.append("**" + argspec.varkw)

candidate_names = ["f"] + [f"f_{i}" for i in range(1, len(used_names) + 2)]

for funcname in candidate_names: # pragma: no branch
if funcname not in used_names:
break

source = COPY_ARGSPEC_SCRIPT.format(
name=name,
funcname=funcname,
argspec=", ".join(parts),
invocation=", ".join(invocation_parts),
)
result = source_exec_as_module(source).accept(f)
result.__doc__ = docstring
result.__defaults__ = argspec.defaults
if argspec.kwonlydefaults:
result.__kwdefaults__ = argspec.kwonlydefaults
if argspec.annotations:
result.__annotations__ = argspec.annotations
return result

return accept


COPY_SIGNATURE_SCRIPT = """
from hypothesis.utils.conventions import not_set
Expand All @@ -543,13 +453,11 @@ def get_varargs(sig, kind=inspect.Parameter.VAR_POSITIONAL):
return None


def define_function_signature_from_signature(name, docstring, signature):
def define_function_signature(name, docstring, signature):
"""A decorator which sets the name, argspec and docstring of the function
passed into it."""
# TODO: we will (eventually...) replace the last few uses of getfullargspec
# with this version, and then delete the one above. For now though, this
# works for @proxies() and @given() is under stricter constraints anyway.

if name == "<lambda>":
name = "_lambda_"
check_valid_identifier(name)
for a in signature.parameters:
check_valid_identifier(a)
Expand Down Expand Up @@ -624,6 +532,8 @@ def accept(f):
for p in signature.parameters.values()
if p.annotation is not signature.empty
}
if signature.return_annotation is not signature.empty:
annotations["return"] = signature.return_annotation
if annotations:
result.__annotations__ = annotations
return result
Expand Down Expand Up @@ -653,7 +563,7 @@ def accept(f):


def proxies(target: "T") -> Callable[[Callable], "T"]:
replace_sig = define_function_signature_from_signature(
replace_sig = define_function_signature(
target.__name__.replace("<lambda>", "_lambda_"), # type: ignore
target.__doc__,
get_signature(target, follow_wrapped=False),
Expand Down
6 changes: 3 additions & 3 deletions hypothesis-python/src/hypothesis/strategies/_internal/core.py
Expand Up @@ -56,7 +56,7 @@
)
from hypothesis.internal.entropy import get_seeder_and_restorer
from hypothesis.internal.reflection import (
define_function_signature_from_signature,
define_function_signature,
get_pretty_function_description,
get_signature,
nicerepr,
Expand Down Expand Up @@ -934,7 +934,7 @@ def builds(
# matches the semantics of the function. Great for documentation!
sig = signature(builds)
args, kwargs = sig.parameters.values()
builds = define_function_signature_from_signature(
builds = define_function_signature(
name=builds.__name__,
docstring=builds.__doc__,
signature=sig.replace(
Expand Down Expand Up @@ -1543,7 +1543,7 @@ def composite(f: Callable[..., Ex]) -> Callable[..., SearchStrategy[Ex]]:
)

@defines_strategy()
@define_function_signature_from_signature(f.__name__, f.__doc__, newsig)
@define_function_signature(f.__name__, f.__doc__, newsig)
def accept(*args, **kwargs):
return CompositeStrategy(f, args, kwargs)

Expand Down
10 changes: 5 additions & 5 deletions hypothesis-python/src/hypothesis/strategies/_internal/random.py
Expand Up @@ -17,7 +17,7 @@

from hypothesis.control import should_note
from hypothesis.internal.conjecture import utils as cu
from hypothesis.internal.reflection import define_function_signature_from_signature
from hypothesis.internal.reflection import define_function_signature
from hypothesis.reporting import report
from hypothesis.strategies._internal.core import (
binary,
Expand Down Expand Up @@ -133,11 +133,11 @@ def implementation(self, **kwargs):
self._hypothesis_log_random(name, kwargs, result)
return result

spec = inspect.signature(STUBS.get(name, target))
sig = inspect.signature(STUBS.get(name, target))

result = define_function_signature_from_signature(
target.__name__, target.__doc__, spec
)(implementation)
result = define_function_signature(target.__name__, target.__doc__, sig)(
implementation
)

result.__module__ = __name__
result.__qualname__ = "HypothesisRandom." + result.__name__
Expand Down
33 changes: 14 additions & 19 deletions hypothesis-python/tests/cover/test_annotations.py
Expand Up @@ -8,13 +8,12 @@
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.

from inspect import getfullargspec
from inspect import Parameter as P, signature

import attr
import pytest

from hypothesis import given, strategies as st
from hypothesis.errors import InvalidArgument
from hypothesis.internal.reflection import (
convert_positional_arguments,
define_function_signature,
Expand All @@ -37,16 +36,10 @@ def has_annotation(a: int, *b, c=2) -> None:

@pytest.mark.parametrize("f", [has_annotation, lambda *, a: a, lambda *, a=1: a])
def test_copying_preserves_argspec(f):
af = getfullargspec(f)
af = signature(f)
t = define_function_signature("foo", "docstring", af)(universal_acceptor)
at = getfullargspec(t)
assert af.args == at.args[: len(af.args)]
assert af.varargs == at.varargs
assert af.varkw == at.varkw
assert len(af.defaults or ()) == len(at.defaults or ())
assert af.kwonlyargs == at.kwonlyargs
assert af.kwonlydefaults == at.kwonlydefaults
assert af.annotations == at.annotations
at = signature(t)
assert af.parameters == at.parameters


@pytest.mark.parametrize(
Expand All @@ -69,8 +62,9 @@ def test_given_notices_missing_kwonly_args():
def reqs_kwonly(*, a, b):
pass

with pytest.raises(InvalidArgument):
with pytest.raises(TypeError):
reqs_kwonly()
reqs_kwonly(b=None)


def test_converter_handles_kwonly_args():
Expand Down Expand Up @@ -102,17 +96,18 @@ def first_annot(draw: None):


def test_composite_edits_annotations():
spec_comp = getfullargspec(st.composite(pointless_composite))
assert spec_comp.annotations["return"] == st.SearchStrategy[int]
assert "nothing" in spec_comp.annotations
assert "draw" not in spec_comp.annotations
sig_comp = signature(st.composite(pointless_composite))
assert sig_comp.return_annotation == st.SearchStrategy[int]
assert sig_comp.parameters["nothing"].annotation is not P.empty
assert "draw" not in sig_comp.parameters


@pytest.mark.parametrize("nargs", [1, 2, 3])
def test_given_edits_annotations(nargs):
spec_given = getfullargspec(given(*(nargs * [st.none()]))(pointless_composite))
assert spec_given.annotations.pop("return") is None
assert len(spec_given.annotations) == 3 - nargs
sig_given = signature(given(*(nargs * [st.none()]))(pointless_composite))
assert sig_given.return_annotation is None
assert len(sig_given.parameters) == 3 - nargs
assert all(p.annotation is not P.empty for p in sig_given.parameters.values())


def a_converter(x) -> int:
Expand Down

0 comments on commit 98734be

Please sign in to comment.