diff options
author | Paul Ganssle <1377457+pganssle@users.noreply.github.com> | 2023-05-12 12:35:53 (GMT) |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-12 12:35:53 (GMT) |
commit | d50c37d8adb2d2da9808089d959ca7d6791ac59f (patch) | |
tree | df2daad96229ee0152ae38e5fb6060c03d5e4e09 /Lib/test/support | |
parent | 45f5aa8fc73acf516071d52ef8213532f0381316 (diff) | |
download | cpython-d50c37d8adb2d2da9808089d959ca7d6791ac59f.zip cpython-d50c37d8adb2d2da9808089d959ca7d6791ac59f.tar.gz cpython-d50c37d8adb2d2da9808089d959ca7d6791ac59f.tar.bz2 |
GH-86275: Implementation of hypothesis stubs for property-based tests, with zoneinfo tests (#22863)
These are stubs to be used for adding hypothesis (https://hypothesis.readthedocs.io/en/latest/) tests to the standard library.
When the tests are run in an environment where `hypothesis` and its various dependencies are not installed, the stubs will turn any tests with examples into simple parameterized tests and any tests without examples are skipped.
It also adds hypothesis tests for the `zoneinfo` module, and a Github Actions workflow to run the hypothesis tests as a non-required CI job.
The full hypothesis interface is not stubbed out — missing stubs can be added as necessary.
Co-authored-by: Zac Hatfield-Dodds <zac.hatfield.dodds@gmail.com>
Diffstat (limited to 'Lib/test/support')
-rw-r--r-- | Lib/test/support/_hypothesis_stubs/__init__.py | 111 | ||||
-rw-r--r-- | Lib/test/support/_hypothesis_stubs/_helpers.py | 43 | ||||
-rw-r--r-- | Lib/test/support/_hypothesis_stubs/strategies.py | 91 | ||||
-rw-r--r-- | Lib/test/support/hypothesis_helper.py | 4 |
4 files changed, 249 insertions, 0 deletions
diff --git a/Lib/test/support/_hypothesis_stubs/__init__.py b/Lib/test/support/_hypothesis_stubs/__init__.py new file mode 100644 index 0000000..6ba5bb8 --- /dev/null +++ b/Lib/test/support/_hypothesis_stubs/__init__.py @@ -0,0 +1,111 @@ +from enum import Enum +import functools +import unittest + +__all__ = [ + "given", + "example", + "assume", + "reject", + "register_random", + "strategies", + "HealthCheck", + "settings", + "Verbosity", +] + +from . import strategies + + +def given(*_args, **_kwargs): + def decorator(f): + if examples := getattr(f, "_examples", []): + + @functools.wraps(f) + def test_function(self): + for example_args, example_kwargs in examples: + with self.subTest(*example_args, **example_kwargs): + f(self, *example_args, **example_kwargs) + + else: + # If we have found no examples, we must skip the test. If @example + # is applied after @given, it will re-wrap the test to remove the + # skip decorator. + test_function = unittest.skip( + "Hypothesis required for property test with no " + + "specified examples" + )(f) + + test_function._given = True + return test_function + + return decorator + + +def example(*args, **kwargs): + if bool(args) == bool(kwargs): + raise ValueError("Must specify exactly one of *args or **kwargs") + + def decorator(f): + base_func = getattr(f, "__wrapped__", f) + if not hasattr(base_func, "_examples"): + base_func._examples = [] + + base_func._examples.append((args, kwargs)) + + if getattr(f, "_given", False): + # If the given decorator is below all the example decorators, + # it would be erroneously skipped, so we need to re-wrap the new + # base function. + f = given()(base_func) + + return f + + return decorator + + +def assume(condition): + if not condition: + raise unittest.SkipTest("Unsatisfied assumption") + return True + + +def reject(): + assume(False) + + +def register_random(*args, **kwargs): + pass # pragma: no cover + + +def settings(*args, **kwargs): + return lambda f: f # pragma: nocover + + +class HealthCheck(Enum): + data_too_large = 1 + filter_too_much = 2 + too_slow = 3 + return_value = 5 + large_base_example = 7 + not_a_test_method = 8 + + @classmethod + def all(cls): + return list(cls) + + +class Verbosity(Enum): + quiet = 0 + normal = 1 + verbose = 2 + debug = 3 + + +class Phase(Enum): + explicit = 0 + reuse = 1 + generate = 2 + target = 3 + shrink = 4 + explain = 5 diff --git a/Lib/test/support/_hypothesis_stubs/_helpers.py b/Lib/test/support/_hypothesis_stubs/_helpers.py new file mode 100644 index 0000000..3f6244e --- /dev/null +++ b/Lib/test/support/_hypothesis_stubs/_helpers.py @@ -0,0 +1,43 @@ +# Stub out only the subset of the interface that we actually use in our tests. +class StubClass: + def __init__(self, *args, **kwargs): + self.__stub_args = args + self.__stub_kwargs = kwargs + self.__repr = None + + def _with_repr(self, new_repr): + new_obj = self.__class__(*self.__stub_args, **self.__stub_kwargs) + new_obj.__repr = new_repr + return new_obj + + def __repr__(self): + if self.__repr is not None: + return self.__repr + + argstr = ", ".join(self.__stub_args) + kwargstr = ", ".join(f"{kw}={val}" for kw, val in self.__stub_kwargs.items()) + + in_parens = argstr + if kwargstr: + in_parens += ", " + kwargstr + + return f"{self.__class__.__qualname__}({in_parens})" + + +def stub_factory(klass, name, *, with_repr=None, _seen={}): + if (klass, name) not in _seen: + + class Stub(klass): + def __init__(self, *args, **kwargs): + super().__init__() + self.__stub_args = args + self.__stub_kwargs = kwargs + + Stub.__name__ = name + Stub.__qualname__ = name + if with_repr is not None: + Stub._repr = None + + _seen.setdefault((klass, name, with_repr), Stub) + + return _seen[(klass, name, with_repr)] diff --git a/Lib/test/support/_hypothesis_stubs/strategies.py b/Lib/test/support/_hypothesis_stubs/strategies.py new file mode 100644 index 0000000..d2b885d --- /dev/null +++ b/Lib/test/support/_hypothesis_stubs/strategies.py @@ -0,0 +1,91 @@ +import functools + +from ._helpers import StubClass, stub_factory + + +class StubStrategy(StubClass): + def __make_trailing_repr(self, transformation_name, func): + func_name = func.__name__ or repr(func) + return f"{self!r}.{transformation_name}({func_name})" + + def map(self, pack): + return self._with_repr(self.__make_trailing_repr("map", pack)) + + def flatmap(self, expand): + return self._with_repr(self.__make_trailing_repr("flatmap", expand)) + + def filter(self, condition): + return self._with_repr(self.__make_trailing_repr("filter", condition)) + + def __or__(self, other): + new_repr = f"one_of({self!r}, {other!r})" + return self._with_repr(new_repr) + + +_STRATEGIES = { + "binary", + "booleans", + "builds", + "characters", + "complex_numbers", + "composite", + "data", + "dates", + "datetimes", + "decimals", + "deferred", + "dictionaries", + "emails", + "fixed_dictionaries", + "floats", + "fractions", + "from_regex", + "from_type", + "frozensets", + "functions", + "integers", + "iterables", + "just", + "lists", + "none", + "nothing", + "one_of", + "permutations", + "random_module", + "randoms", + "recursive", + "register_type_strategy", + "runner", + "sampled_from", + "sets", + "shared", + "slices", + "timedeltas", + "times", + "text", + "tuples", + "uuids", +} + +__all__ = sorted(_STRATEGIES) + + +def composite(f): + strategy = stub_factory(StubStrategy, f.__name__) + + @functools.wraps(f) + def inner(*args, **kwargs): + return strategy(*args, **kwargs) + + return inner + + +def __getattr__(name): + if name not in _STRATEGIES: + raise AttributeError(f"Unknown attribute {name}") + + return stub_factory(StubStrategy, f"hypothesis.strategies.{name}") + + +def __dir__(): + return __all__ diff --git a/Lib/test/support/hypothesis_helper.py b/Lib/test/support/hypothesis_helper.py new file mode 100644 index 0000000..76bd249 --- /dev/null +++ b/Lib/test/support/hypothesis_helper.py @@ -0,0 +1,4 @@ +try: + import hypothesis +except ImportError: + from . import _hypothesis_stubs as hypothesis |