Repository: HypothesisWorks/hypothesis

Issue:
Improve Ghostwriter for `max()` builtin and similar signatures
Some builtin functions don't support `inspect.signature()`, which the ghostwriter works around by trying to parse their docstring with regex:

https://github.com/HypothesisWorks/hypothesis/blob/3226dd7d1b39d5ce38d559c0c4329aca40ecc9cb/hypothesis-python/src/hypothesis/extra/ghostwriter.py#L328-L345

However, `max.__doc__` highlights a few things not handled by the current pattern:

```python
max(iterable, *[, default=obj, key=func]) -> value
max(arg1, arg2, *args, *[, key=func]) -> value
```

1. square brackets used to indicate optional arguments - we want to allow-but-ignore these
2. `*` indicating that following arguments are keyword-only - we should still assume that positional args are pos-only, but assuming that *kwonly* args are pos-only is silly
3. "handle" multiple signatures by using the first that we can understand - i.e. change "match first line" to "search all matching lines" and keep the first


Discussion:

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py
--- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py
+++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py
@@ -203,13 +203,15 @@ def _exceptions_from_docstring(doc: str) -> Tuple[Type[Exception], ...]:
 # on analysis of type-annotated code to detect arguments which almost always
 # take values of a particular type.
 _GUESS_STRATEGIES_BY_NAME = (
-    (st.integers(0, 32), ["ndims"]),
+    (st.integers(0, 32), ["ndims", "ndigits"]),
     (st.booleans(), ["keepdims"]),
     (st.text(), ["name", "filename", "fname"]),
     (st.floats(), ["real", "imag"]),
     (st.functions(), ["function", "func", "f"]),
     (st.functions(returns=st.booleans(), pure=True), ["pred", "predicate"]),
     (st.iterables(st.integers()) | st.iterables(st.text()), ["iterable"]),
+    (st.builds(object), ["object"]),
+    (st.integers() | st.floats() | st.fractions(), ["number"]),
 )
 
 
@@ -333,16 +335,26 @@ def _get_params(func: Callable) -> Dict[str, inspect.Parameter]:
             # inspect.signature doesn't work on all builtin functions or methods.
             # In such cases, including the operator module on Python 3.6, we can try
             # to reconstruct simple signatures from the docstring.
-            pattern = rf"^{func.__name__}\(([a-z]+(, [a-z]+)*)(, \\)?\)"
-            args = re.match(pattern, func.__doc__)
-            if args is None:
+            match = re.match(rf"^{func.__name__}\((.+?)\)", func.__doc__)
+            if match is None:
                 raise
-            params = [
-                # Note that we assume that the args are positional-only regardless of
-                # whether the signature shows a `/`, because this is often the case.
-                inspect.Parameter(name=name, kind=inspect.Parameter.POSITIONAL_ONLY)
-                for name in args.group(1).split(", ")
-            ]
+            args = match.group(1).replace("[", "").replace("]", "")
+            params = []
+            # Even if the signature doesn't contain a /, we assume that arguments
+            # are positional-only until shown otherwise - the / is often omitted.
+            kind: inspect._ParameterKind = inspect.Parameter.POSITIONAL_ONLY
+            for arg in args.split(", "):
+                arg, *_ = arg.partition("=")
+                if arg == "/":
+                    kind = inspect.Parameter.POSITIONAL_OR_KEYWORD
+                    continue
+                if arg.startswith("*"):
+                    kind = inspect.Parameter.KEYWORD_ONLY
+                    continue  # we omit *varargs, if there are any
+                if arg.startswith("**"):
+                    break  # and likewise omit **varkw
+                params.append(inspect.Parameter(name=arg, kind=kind))
+
         elif _is_probably_ufunc(func):
             # `inspect.signature` doesn't work on ufunc objects, but we can work out
             # what the required parameters would look like if it did.
@@ -688,7 +700,8 @@ def _make_test(imports: Set[Union[str, Tuple[str, str]]], body: str) -> str:
     direct = {f"import {i}" for i in imports - do_not_import if isinstance(i, str)}
     from_imports = defaultdict(set)
     for module, name in {i for i in imports if isinstance(i, tuple)}:
-        from_imports[module].add(name)
+        if not (module.startswith("hypothesis.strategies") and name in st.__all__):
+            from_imports[module].add(name)
     from_ = {
         "from {} import {}".format(module, ", ".join(sorted(names)))
         for module, names in from_imports.items()
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
@@ -814,6 +814,12 @@ def validate(self):
         tuples(*self.args).validate()
         fixed_dictionaries(self.kwargs).validate()
 
+    def __repr__(self):
+        bits = [get_pretty_function_description(self.target)]
+        bits.extend(map(repr, self.args))
+        bits.extend(f"{k}={v!r}" for k, v in self.kwargs.items())
+        return f"builds({', '.join(bits)})"
+
 
 # The ideal signature builds(target, /, *args, **kwargs) is unfortunately a
 # SyntaxError before Python 3.8 so we emulate it with manual argument unpacking.


Test patch:
diff --git a/hypothesis-python/tests/ghostwriter/recorded/magic_builtins.txt b/hypothesis-python/tests/ghostwriter/recorded/magic_builtins.txt
new file mode 100644
--- /dev/null
+++ b/hypothesis-python/tests/ghostwriter/recorded/magic_builtins.txt
@@ -0,0 +1,285 @@
+# This test code was written by the `hypothesis.extra.ghostwriter` module
+# and is provided under the Creative Commons Zero public domain dedication.
+
+from hypothesis import given, strategies as st
+
+# TODO: replace st.nothing() with appropriate strategies
+
+
+@given(x=st.nothing())
+def test_fuzz_abs(x):
+    abs(x)
+
+
+@given(iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())))
+def test_fuzz_all(iterable):
+    all(iterable)
+
+
+@given(iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())))
+def test_fuzz_any(iterable):
+    any(iterable)
+
+
+@given(obj=st.nothing())
+def test_fuzz_ascii(obj):
+    ascii(obj)
+
+
+@given(number=st.one_of(st.integers(), st.floats(), st.fractions()))
+def test_fuzz_bin(number):
+    bin(number)
+
+
+@given(obj=st.nothing())
+def test_fuzz_callable(obj):
+    callable(obj)
+
+
+@given(i=st.nothing())
+def test_fuzz_chr(i):
+    chr(i)
+
+
+@given(
+    source=st.nothing(),
+    filename=st.nothing(),
+    mode=st.nothing(),
+    flags=st.just(0),
+    dont_inherit=st.booleans(),
+    optimize=st.just(-1),
+    _feature_version=st.just(-1),
+)
+def test_fuzz_compile(
+    source, filename, mode, flags, dont_inherit, optimize, _feature_version
+):
+    compile(
+        source=source,
+        filename=filename,
+        mode=mode,
+        flags=flags,
+        dont_inherit=dont_inherit,
+        optimize=optimize,
+        _feature_version=_feature_version,
+    )
+
+
+@given(real=st.just(0), imag=st.just(0))
+def test_fuzz_complex(real, imag):
+    complex(real=real, imag=imag)
+
+
+@given(obj=st.nothing(), name=st.nothing())
+def test_fuzz_delattr(obj, name):
+    delattr(obj, name)
+
+
+@given(object=st.builds(object))
+def test_fuzz_dir(object):
+    dir(object)
+
+
+@given(x=st.nothing(), y=st.nothing())
+def test_fuzz_divmod(x, y):
+    divmod(x, y)
+
+
+@given(
+    iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())),
+    start=st.just(0),
+)
+def test_fuzz_enumerate(iterable, start):
+    enumerate(iterable=iterable, start=start)
+
+
+@given(source=st.nothing(), globals=st.none(), locals=st.none())
+def test_fuzz_eval(source, globals, locals):
+    eval(source, globals, locals)
+
+
+@given(source=st.nothing(), globals=st.none(), locals=st.none())
+def test_fuzz_exec(source, globals, locals):
+    exec(source, globals, locals)
+
+
+@given(x=st.just(0))
+def test_fuzz_float(x):
+    float(x)
+
+
+@given(value=st.nothing(), format_spec=st.just(""))
+def test_fuzz_format(value, format_spec):
+    format(value, format_spec)
+
+
+@given(object=st.builds(object), name=st.nothing(), default=st.nothing())
+def test_fuzz_getattr(object, name, default):
+    getattr(object, name, default)
+
+
+@given(obj=st.nothing(), name=st.nothing())
+def test_fuzz_hasattr(obj, name):
+    hasattr(obj, name)
+
+
+@given(obj=st.nothing())
+def test_fuzz_hash(obj):
+    hash(obj)
+
+
+@given(number=st.one_of(st.integers(), st.floats(), st.fractions()))
+def test_fuzz_hex(number):
+    hex(number)
+
+
+@given(obj=st.nothing())
+def test_fuzz_id(obj):
+    id(obj)
+
+
+@given(prompt=st.none())
+def test_fuzz_input(prompt):
+    input(prompt)
+
+
+@given(obj=st.nothing(), class_or_tuple=st.nothing())
+def test_fuzz_isinstance(obj, class_or_tuple):
+    isinstance(obj, class_or_tuple)
+
+
+@given(cls=st.nothing(), class_or_tuple=st.nothing())
+def test_fuzz_issubclass(cls, class_or_tuple):
+    issubclass(cls, class_or_tuple)
+
+
+@given(iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())))
+def test_fuzz_iter(iterable):
+    iter(iterable)
+
+
+@given(obj=st.nothing())
+def test_fuzz_len(obj):
+    len(obj)
+
+
+@given(iterable=st.just(()))
+def test_fuzz_list(iterable):
+    list(iterable)
+
+
+@given(
+    iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())),
+    default=st.nothing(),
+    key=st.nothing(),
+)
+def test_fuzz_max(iterable, default, key):
+    max(iterable, default=default, key=key)
+
+
+@given(object=st.builds(object))
+def test_fuzz_memoryview(object):
+    memoryview(object=object)
+
+
+@given(
+    iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())),
+    default=st.nothing(),
+    key=st.nothing(),
+)
+def test_fuzz_min(iterable, default, key):
+    min(iterable, default=default, key=key)
+
+
+@given(iterator=st.nothing(), default=st.nothing())
+def test_fuzz_next(iterator, default):
+    next(iterator, default)
+
+
+@given(number=st.one_of(st.integers(), st.floats(), st.fractions()))
+def test_fuzz_oct(number):
+    oct(number)
+
+
+@given(
+    file=st.nothing(),
+    mode=st.just("r"),
+    buffering=st.just(-1),
+    encoding=st.none(),
+    errors=st.none(),
+    newline=st.none(),
+    closefd=st.booleans(),
+    opener=st.none(),
+)
+def test_fuzz_open(file, mode, buffering, encoding, errors, newline, closefd, opener):
+    open(
+        file=file,
+        mode=mode,
+        buffering=buffering,
+        encoding=encoding,
+        errors=errors,
+        newline=newline,
+        closefd=closefd,
+        opener=opener,
+    )
+
+
+@given(c=st.nothing())
+def test_fuzz_ord(c):
+    ord(c)
+
+
+@given(base=st.nothing(), exp=st.nothing(), mod=st.none())
+def test_fuzz_pow(base, exp, mod):
+    pow(base=base, exp=exp, mod=mod)
+
+
+@given(fget=st.none(), fset=st.none(), fdel=st.none(), doc=st.none())
+def test_fuzz_property(fget, fset, fdel, doc):
+    property(fget=fget, fset=fset, fdel=fdel, doc=doc)
+
+
+@given(obj=st.nothing())
+def test_fuzz_repr(obj):
+    repr(obj)
+
+
+@given(sequence=st.nothing())
+def test_fuzz_reversed(sequence):
+    reversed(sequence)
+
+
+@given(number=st.one_of(st.integers(), st.floats(), st.fractions()), ndigits=st.none())
+def test_fuzz_round(number, ndigits):
+    round(number=number, ndigits=ndigits)
+
+
+@given(obj=st.nothing(), name=st.nothing(), value=st.nothing())
+def test_fuzz_setattr(obj, name, value):
+    setattr(obj, name, value)
+
+
+@given(
+    iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())),
+    key=st.none(),
+    reverse=st.booleans(),
+)
+def test_fuzz_sorted(iterable, key, reverse):
+    sorted(iterable, key=key, reverse=reverse)
+
+
+@given(
+    iterable=st.one_of(st.iterables(st.integers()), st.iterables(st.text())),
+    start=st.just(0),
+)
+def test_fuzz_sum(iterable, start):
+    sum(iterable, start=start)
+
+
+@given(iterable=st.just(()))
+def test_fuzz_tuple(iterable):
+    tuple(iterable)
+
+
+@given(object=st.builds(object))
+def test_fuzz_vars(object):
+    vars(object)
diff --git a/hypothesis-python/tests/ghostwriter/test_expected_output.py b/hypothesis-python/tests/ghostwriter/test_expected_output.py
--- a/hypothesis-python/tests/ghostwriter/test_expected_output.py
+++ b/hypothesis-python/tests/ghostwriter/test_expected_output.py
@@ -21,6 +21,7 @@
 
 import ast
 import base64
+import builtins
 import operator
 import pathlib
 import re
@@ -153,6 +154,11 @@ def divide(a: int, b: int) -> float:
                 style="unittest",
             ),
         ),
+        pytest.param(
+            ("magic_builtins", ghostwriter.magic(builtins)),
+            # Signature of builtins.compile() changed in 3.8 and we use that version.
+            marks=[pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason="")],
+        ),
     ],
     ids=lambda x: x[0],
 )