Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Teach the Ghostwriter about @staticmethod and @classmethod methods #3354

Merged
merged 55 commits into from
Jun 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
45e903f
improve CLi for staticmethod
Cheukting May 19, 2022
1550b15
shed
Cheukting May 19, 2022
5709757
fix classname error
Cheukting May 20, 2022
4edfd79
Add matches
Cheukting May 20, 2022
1032296
Adding from err
Cheukting May 20, 2022
d2179a9
Add test and fix err
Cheukting May 20, 2022
7aa6a69
remove err on line 98
Cheukting May 21, 2022
b101eba
check if func_class is class
Cheukting May 21, 2022
ba482b0
add test
Cheukting May 21, 2022
c08d3c5
magic scan and include methods in classes
Cheukting May 21, 2022
f6b170f
adding magic discovery test
Cheukting May 21, 2022
5b6c402
adding roundtrip test
Cheukting May 21, 2022
f614a7f
add more roundtrip test
Cheukting May 21, 2022
1fc4e6b
remove pkg check for class attributes
Cheukting May 21, 2022
7d9202e
fix typo
Cheukting May 21, 2022
c1d253c
update output, include class/static methods in tests
Cheukting May 22, 2022
bb1b526
cli can accept class as input
Cheukting May 22, 2022
1ad5f09
refactoring
Cheukting May 23, 2022
4c43092
fuzz only works on functions
Cheukting May 23, 2022
8b9a226
add more tests
Cheukting May 23, 2022
6e7f60b
single ghostwriters does not works on class itself
Cheukting May 23, 2022
7559c85
fix test using a class, use method instead
Cheukting May 23, 2022
8dc4dac
Adding expected output test
Cheukting May 23, 2022
1674c1c
shed
Cheukting May 23, 2022
3960917
Add test for class/static methods
Cheukting May 24, 2022
be1ee66
fix xml_etree_ElementTree output test
Cheukting May 24, 2022
315c9cb
Adding release.rst
Cheukting May 24, 2022
c232650
fix release.rst
Cheukting May 25, 2022
e222c93
reflactor into describe_close_matches
Cheukting May 25, 2022
bf982ac
reenable classes can be used byitself in ghostwriter
Cheukting May 25, 2022
e6f3277
fix typos
Cheukting May 25, 2022
e551fd4
fix release.rst again
Cheukting May 25, 2022
5d0f9eb
include the class itself in magic discovery
Cheukting May 25, 2022
a41306b
switch back test space_in_name
Cheukting May 25, 2022
b6c8e48
rename classes
Cheukting May 25, 2022
79d9531
fix releast.rst again
Cheukting May 25, 2022
ef08075
shed shed shed it out
Cheukting May 25, 2022
524d62b
fix releast.rst typos
Cheukting May 25, 2022
0d32379
Will it link?
Cheukting May 26, 2022
43c4807
delete dead code
Cheukting May 26, 2022
06c6e96
using loop to test
Cheukting May 26, 2022
31915da
make mypy happy again
Cheukting May 26, 2022
a85fd3e
release.rst
Cheukting May 26, 2022
ff3f06e
Update RELEASE.rst
Zac-HD May 27, 2022
230d85e
Update RELEASE.rst
Zac-HD May 27, 2022
789fb00
remove duplicate cause
Cheukting May 31, 2022
158bb6e
update type annotation
Cheukting May 31, 2022
a14c416
condense more with round_trip_code
Cheukting May 31, 2022
4502059
Fix formating
Cheukting May 31, 2022
1a6e42a
xml.etree.ElementTree stable after 3.9
Cheukting Jun 1, 2022
9364727
use list instead of copy
Cheukting Jun 2, 2022
42dfdb7
swap testing with xml to custom class
Cheukting Jun 2, 2022
860eaff
shed
Cheukting Jun 2, 2022
dfee405
type annotation
Cheukting Jun 5, 2022
2c615d7
Tighten up tests a bit
Zac-HD Jun 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
RELEASE_TYPE: minor

The :doc:`Ghostwritter <ghostwriter>` can now write tests for
:obj:`@classmethod <classmethod>` or :obj:`@staticmethod <staticmethod>`
methods, in addition to the existing support for functions and other callables
(:issue:`3318`). Thanks to Cheuk Ting Ho for the patch.
63 changes: 51 additions & 12 deletions hypothesis-python/src/hypothesis/extra/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@

import builtins
import importlib
import inspect
import sys
import types
from difflib import get_close_matches
from functools import partial
from multiprocessing import Pool
Expand Down Expand Up @@ -84,27 +86,64 @@ def obj_name(s: str) -> object:
return importlib.import_module(s)
except ImportError:
pass
classname = None
if "." not in s:
modulename, module, funcname = "builtins", builtins, s
else:
modulename, funcname = s.rsplit(".", 1)
try:
module = importlib.import_module(modulename)
except ImportError as err:
try:
modulename, classname = modulename.rsplit(".", 1)
module = importlib.import_module(modulename)
except ImportError:
raise click.UsageError(
f"Failed to import the {modulename} module for introspection. "
"Check spelling and your Python import path, or use the Python API?"
) from err

def describe_close_matches(
module_or_class: types.ModuleType, objname: str
) -> str:
public_names = [
name for name in vars(module_or_class) if not name.startswith("_")
]
matches = get_close_matches(objname, public_names)
if matches:
return f" Closest matches: {matches!r}"
else:
return ""

if classname is None:
try:
return getattr(module, funcname)
except AttributeError as err:
raise click.UsageError(
f"Failed to import the {modulename} module for introspection. "
"Check spelling and your Python import path, or use the Python API?"
f"Found the {modulename!r} module, but it doesn't have a "
f"{funcname!r} attribute."
+ describe_close_matches(module, funcname)
) from err
else:
try:
func_class = getattr(module, classname)
except AttributeError as err:
raise click.UsageError(
f"Found the {modulename!r} module, but it doesn't have a "
f"{classname!r} class." + describe_close_matches(module, classname)
) from err
try:
return getattr(func_class, funcname)
except AttributeError as err:
if inspect.isclass(func_class):
func_class_is = "class"
else:
func_class_is = "attribute"
raise click.UsageError(
f"Found the {modulename!r} module and {classname!r} {func_class_is}, "
f"but it doesn't have a {funcname!r} attribute."
+ describe_close_matches(func_class, funcname)
) from err
try:
return getattr(module, funcname)
except AttributeError as err:
public_names = [name for name in vars(module) if not name.startswith("_")]
matches = get_close_matches(funcname, public_names)
raise click.UsageError(
f"Found the {modulename!r} module, but it doesn't have a "
f"{funcname!r} attribute."
+ (f" Closest matches: {matches!r}" if matches else "")
) from err

def _refactor(func, fname):
try:
Expand Down
46 changes: 32 additions & 14 deletions hypothesis-python/src/hypothesis/extra/ghostwriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,10 +891,15 @@ def magic(
for thing in modules_or_functions:
if callable(thing):
functions.add(thing)
# class need to be added for exploration
if inspect.isclass(thing):
funcs: List[Optional[Any]] = [thing]
else:
funcs = []
elif isinstance(thing, types.ModuleType):
if hasattr(thing, "__all__"):
funcs = [getattr(thing, name, None) for name in thing.__all__]
else:
elif hasattr(thing, "__package__"):
pkg = thing.__package__
funcs = [
v
Expand All @@ -906,22 +911,35 @@ def magic(
]
if pkg and any(getattr(f, "__module__", pkg) == pkg for f in funcs):
funcs = [f for f in funcs if getattr(f, "__module__", pkg) == pkg]
for f in funcs:
try:
if (
(not is_mock(f))
and callable(f)
and _get_params(f)
and not isinstance(f, enum.EnumMeta)
):
functions.add(f)
if getattr(thing, "__name__", None):
KNOWN_FUNCTION_LOCATIONS[f] = thing.__name__
except (TypeError, ValueError):
pass
else:
raise InvalidArgument(f"Can't test non-module non-callable {thing!r}")

for f in list(funcs):
if inspect.isclass(f):
funcs += [
v.__get__(f)
for k, v in vars(f).items()
if hasattr(v, "__func__")
and not is_mock(v)
and not k.startswith("_")
]
for f in funcs:
try:
if (
(not is_mock(f))
and callable(f)
and _get_params(f)
and not isinstance(f, enum.EnumMeta)
):
functions.add(f)
if getattr(thing, "__name__", None):
if inspect.isclass(thing):
KNOWN_FUNCTION_LOCATIONS[f] = thing.__module__
else:
KNOWN_FUNCTION_LOCATIONS[f] = thing.__name__
except (TypeError, ValueError):
pass

imports = set()
parts = []

Expand Down
10 changes: 10 additions & 0 deletions hypothesis-python/tests/ghostwriter/recorded/fuzz_staticmethod.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This test code was written by the `hypothesis.extra.ghostwriter` module
# and is provided under the Creative Commons Zero public domain dedication.

import test_expected_output
from hypothesis import given, strategies as st


@given(arg=st.integers())
def test_fuzz_A_Class_a_staticmethod(arg):
test_expected_output.A_Class.a_staticmethod(arg=arg)
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ def test_fuzz_settings(
)


@given(name=st.text())
def test_fuzz_settings_get_profile(name):
hypothesis.settings.get_profile(name=name)


@given(name=st.text())
def test_fuzz_settings_load_profile(name):
hypothesis.settings.load_profile(name=name)


@given(name=st.text(), parent=st.one_of(st.none(), st.builds(settings)))
def test_fuzz_settings_register_profile(name, parent):
hypothesis.settings.register_profile(name=name, parent=parent)


@given(observation=st.one_of(st.floats(), st.integers()), label=st.text())
def test_fuzz_target(observation, label):
hypothesis.target(observation=observation, label=label)
10 changes: 10 additions & 0 deletions hypothesis-python/tests/ghostwriter/recorded/magic_builtins.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ def test_fuzz_bin(number):
bin(number)


@given(frm=st.nothing(), to=st.nothing())
def test_fuzz_bytearray_maketrans(frm, to):
bytearray.maketrans(frm, to)


@given(frm=st.nothing(), to=st.nothing())
def test_fuzz_bytes_maketrans(frm, to):
bytes.maketrans(frm, to)


@given(obj=st.nothing())
def test_fuzz_callable(obj):
callable(obj)
Expand Down
20 changes: 20 additions & 0 deletions hypothesis-python/tests/ghostwriter/recorded/magic_class.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# This test code was written by the `hypothesis.extra.ghostwriter` module
# and is provided under the Creative Commons Zero public domain dedication.

import test_expected_output
from hypothesis import given, strategies as st


@given()
def test_fuzz_A_Class():
test_expected_output.A_Class()


@given(arg=st.integers())
def test_fuzz_A_Class_a_classmethod(arg):
test_expected_output.A_Class.a_classmethod(arg=arg)


@given(arg=st.integers())
def test_fuzz_A_Class_a_staticmethod(arg):
test_expected_output.A_Class.a_staticmethod(arg=arg)
6 changes: 6 additions & 0 deletions hypothesis-python/tests/ghostwriter/test_expected_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class A_Class:
def a_classmethod(cls, arg: int):
pass

@staticmethod
def a_staticmethod(arg: int):
pass


def add(a: float, b: float) -> float:
return a + b
Expand All @@ -86,6 +90,7 @@ def divide(a: int, b: int) -> float:
("fuzz_sorted", ghostwriter.fuzz(sorted)),
("fuzz_with_docstring", ghostwriter.fuzz(with_docstring)),
("fuzz_classmethod", ghostwriter.fuzz(A_Class.a_classmethod)),
("fuzz_staticmethod", ghostwriter.fuzz(A_Class.a_staticmethod)),
("fuzz_ufunc", ghostwriter.fuzz(numpy.add)),
("magic_gufunc", ghostwriter.magic(numpy.matmul)),
("magic_base64_roundtrip", ghostwriter.magic(base64.b64encode)),
Expand Down Expand Up @@ -176,6 +181,7 @@ def divide(a: int, b: int) -> float:
style="unittest",
),
),
("magic_class", ghostwriter.magic(A_Class)),
pytest.param(
("magic_builtins", ghostwriter.magic(builtins)),
marks=[
Expand Down
28 changes: 28 additions & 0 deletions hypothesis-python/tests/ghostwriter/test_ghostwriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,34 @@ def test_invalid_func_inputs(gw, args):
gw(*args)


class A:
@classmethod
def to_json(cls, obj: Union[dict, list]) -> str:
return json.dumps(obj)

@classmethod
def from_json(cls, obj: str) -> Union[dict, list]:
return json.loads(obj)

@staticmethod
def static_sorter(seq: Sequence[int]) -> List[int]:
return sorted(seq)


@pytest.mark.parametrize(
"gw,args",
[
(ghostwriter.fuzz, [A.static_sorter]),
(ghostwriter.idempotent, [A.static_sorter]),
(ghostwriter.roundtrip, [A.to_json, A.from_json]),
(ghostwriter.equivalent, [A.to_json, json.dumps]),
],
)
def test_class_methods_inputs(gw, args):
source_code = gw(*args)
get_test_function(source_code)()
Zac-HD marked this conversation as resolved.
Show resolved Hide resolved


def test_run_ghostwriter_fuzz():
# Our strategy-guessing code works for all the arguments to sorted,
# and we handle positional-only arguments in calls correctly too.
Expand Down
Loading