From 0c6ad49a16f0eb7b6d0a03d73d43803c862e3af5 Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 20 Aug 2022 09:00:25 -0700 Subject: [PATCH 1/6] Small refactorings --- .../internal/conjecture/shrinker.py | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py index 9080d07f63..2ee5d5f4cb 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/shrinker.py @@ -278,8 +278,8 @@ def __init__(self, engine, initial, predicate, allow_transition): # We keep track of the current best example on the shrink_target # attribute. - self.shrink_target = None - self.update_shrink_target(initial) + self.shrink_target = initial + self.clear_change_tracking() self.shrinks = 0 # We terminate shrinks that seem to have reached their logical @@ -447,23 +447,15 @@ def s(n): return "s" if n != 1 else "" total_deleted = self.initial_size - len(self.shrink_target.buffer) - - self.debug("---------------------") - self.debug("Shrink pass profiling") - self.debug("---------------------") - self.debug("") calls = self.engine.call_count - self.initial_calls + self.debug( - "Shrinking made a total of %d call%s " - "of which %d shrank. This deleted %d byte%s out of %d." - % ( - calls, - s(calls), - self.shrinks, - total_deleted, - s(total_deleted), - self.initial_size, - ) + "---------------------\n" + "Shrink pass profiling\n" + "---------------------\n\n" + f"Shrinking made a total of {calls} call{s(calls)} of which " + f"{self.shrinks} shrank. This deleted {total_deleted} bytes out " + f"of {self.initial_size}." ) for useful in [True, False]: self.debug("") @@ -828,22 +820,17 @@ def __changed_blocks(self): def update_shrink_target(self, new_target): assert isinstance(new_target, ConjectureResult) - if self.shrink_target is not None: - self.shrinks += 1 - # If we are just taking a long time to shrink we don't want to - # trigger this heuristic, so whenever we shrink successfully - # we give ourselves a bit of breathing room to make sure we - # would find a shrink that took that long to find the next time. - # The case where we're taking a long time but making steady - # progress is handled by `finish_shrinking_deadline` in engine.py - self.max_stall = max( - self.max_stall, (self.calls - self.calls_at_last_shrink) * 2 - ) - self.calls_at_last_shrink = self.calls - else: - self.__all_changed_blocks = set() - self.__last_checked_changed_at = new_target - + self.shrinks += 1 + # If we are just taking a long time to shrink we don't want to + # trigger this heuristic, so whenever we shrink successfully + # we give ourselves a bit of breathing room to make sure we + # would find a shrink that took that long to find the next time. + # The case where we're taking a long time but making steady + # progress is handled by `finish_shrinking_deadline` in engine.py + self.max_stall = max( + self.max_stall, (self.calls - self.calls_at_last_shrink) * 2 + ) + self.calls_at_last_shrink = self.calls self.shrink_target = new_target self.__derived_values = {} From 233a6284ea2718ed3890dc26fadadb1baef5a98f Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 20 Aug 2022 09:00:25 -0700 Subject: [PATCH 2/6] Remove dead code This never actually worked, because the __module__ string doesn't have a __name__ attribute. Since we want DB keys to be independent of the file anyway, we'll just delete the clause. --- hypothesis-python/src/hypothesis/internal/reflection.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index b758a8ef20..736d4ede9e 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -64,10 +64,6 @@ def function_digest(function): hasher.update(function.__name__.encode()) except AttributeError: pass - try: - hasher.update(function.__module__.__name__.encode()) - except AttributeError: - pass try: # We prefer to use the modern signature API, but left this for compatibility. # While we don't promise stability of the database, there's no advantage to From 7c56922991a700bad02a58c5aed1972439de6132 Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 20 Aug 2022 09:00:25 -0700 Subject: [PATCH 3/6] Fix ParamSpec for typecheckers Co-Authored-By: Shantanu <12621235+hauntsaninja@users.noreply.github.com> --- .../src/hypothesis/internal/compat.py | 13 +++++++++++++ .../src/hypothesis/strategies/_internal/core.py | 17 ++++++++--------- .../tests/nocover/test_type_lookup.py | 7 +++---- .../typing_extensions/test_backported_types.py | 2 +- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py index 67297bcdaa..e7e3bf2a53 100644 --- a/hypothesis-python/src/hypothesis/internal/compat.py +++ b/hypothesis-python/src/hypothesis/internal/compat.py @@ -22,6 +22,19 @@ BaseExceptionGroup as BaseExceptionGroup, ExceptionGroup as ExceptionGroup, ) +if typing.TYPE_CHECKING: # pragma: no cover + from typing_extensions import Concatenate as Concatenate, ParamSpec as ParamSpec +else: + try: + from typing import Concatenate as Concatenate, ParamSpec as ParamSpec + except ImportError: + try: + from typing_extensions import ( + Concatenate as Concatenate, + ParamSpec as ParamSpec, + ) + except ImportError: + Concatenate, ParamSpec = None, None PYPY = platform.python_implementation() == "PyPy" WINDOWS = platform.system() == "Windows" diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 3c0eadc7b5..c122985fb9 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -49,7 +49,14 @@ from hypothesis.errors import InvalidArgument, ResolutionFailed from hypothesis.internal.cathetus import cathetus from hypothesis.internal.charmap import as_general_categories -from hypothesis.internal.compat import ceil, floor, get_type_hints, is_typed_named_tuple +from hypothesis.internal.compat import ( + Concatenate, + ParamSpec, + ceil, + floor, + get_type_hints, + is_typed_named_tuple, +) from hypothesis.internal.conjecture.utils import ( calc_label_from_cls, check_sample, @@ -122,14 +129,6 @@ except ImportError: # < py3.8 Protocol = object # type: ignore[assignment] -try: - from typing import Concatenate, ParamSpec -except ImportError: - try: - from typing_extensions import Concatenate, ParamSpec - except ImportError: - ParamSpec = None # type: ignore - @cacheable @defines_strategy() diff --git a/hypothesis-python/tests/nocover/test_type_lookup.py b/hypothesis-python/tests/nocover/test_type_lookup.py index 038d454870..b5c2bfbd04 100644 --- a/hypothesis-python/tests/nocover/test_type_lookup.py +++ b/hypothesis-python/tests/nocover/test_type_lookup.py @@ -14,14 +14,13 @@ from hypothesis import strategies as st from hypothesis.errors import InvalidArgument +from hypothesis.internal.compat import Concatenate, ParamSpec from hypothesis.strategies._internal.types import NON_RUNTIME_TYPES try: - from typing import Concatenate, ParamSpec, TypeGuard # new in 3.10 + from typing import TypeGuard # new in 3.10 except ImportError: - Concatenate = ParamSpec = TypeGuard = None - -# See also typing_extensions/test_backported_types.py + TypeGuard = None @pytest.mark.parametrize("non_runtime_type", NON_RUNTIME_TYPES) diff --git a/hypothesis-python/tests/typing_extensions/test_backported_types.py b/hypothesis-python/tests/typing_extensions/test_backported_types.py index 61f59b0565..a6f3512de0 100644 --- a/hypothesis-python/tests/typing_extensions/test_backported_types.py +++ b/hypothesis-python/tests/typing_extensions/test_backported_types.py @@ -31,7 +31,7 @@ from tests.common.debug import assert_all_examples, find_any -# See also nocover/test_type_lookp.py +# See also nocover/test_type_lookup.py @pytest.mark.parametrize("value", ["dog", b"goldfish", 42, 63.4, -80.5, False]) From fa23c23335197cff3b9da2b7e80d8815db319e5c Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 20 Aug 2022 09:00:26 -0700 Subject: [PATCH 4/6] Drop some old warnings config --- hypothesis-python/tests/common/setup.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/hypothesis-python/tests/common/setup.py b/hypothesis-python/tests/common/setup.py index 25c37ef253..f1a36994c9 100644 --- a/hypothesis-python/tests/common/setup.py +++ b/hypothesis-python/tests/common/setup.py @@ -25,10 +25,7 @@ def run(): filterwarnings("ignore", category=ImportWarning) filterwarnings("ignore", category=FutureWarning, module="pandas._version") - # Fixed in recent versions but allowed by pytest=3.0.0; see #1630 - filterwarnings("ignore", category=DeprecationWarning, module="pluggy") - - # See https://github.com/numpy/numpy/pull/432 + # See https://github.com/numpy/numpy/pull/432; still a thing as of 2022. filterwarnings("ignore", message="numpy.dtype size changed") filterwarnings("ignore", message="numpy.ufunc size changed") @@ -42,14 +39,6 @@ def run(): category=UserWarning, ) - # Imported by Pandas in version 1.9, but fixed in later versions. - filterwarnings( - "ignore", message="Importing from numpy.testing.decorators is deprecated" - ) - filterwarnings( - "ignore", message="Importing from numpy.testing.nosetester is deprecated" - ) - # User-facing warning which does not apply to our own tests filterwarnings("ignore", category=NonInteractiveExampleWarning) From fb0855de3dacdf7bf8b2e1c61881002fb6dc64fa Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 20 Aug 2022 09:00:26 -0700 Subject: [PATCH 5/6] Dogfooding the explain phase --- hypothesis-python/tests/common/setup.py | 8 ++++++-- hypothesis-python/tests/cover/test_phases.py | 3 ++- hypothesis-python/tests/cover/test_settings.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/hypothesis-python/tests/common/setup.py b/hypothesis-python/tests/common/setup.py index f1a36994c9..d109273199 100644 --- a/hypothesis-python/tests/common/setup.py +++ b/hypothesis-python/tests/common/setup.py @@ -12,7 +12,7 @@ from tempfile import mkdtemp from warnings import filterwarnings -from hypothesis import Verbosity, settings +from hypothesis import Phase, Verbosity, settings from hypothesis._settings import not_set from hypothesis.configuration import set_hypothesis_home_dir from hypothesis.errors import NonInteractiveExampleWarning @@ -66,7 +66,11 @@ def run(): ) settings.register_profile( - "default", settings(max_examples=20 if IN_COVERAGE_TESTS else not_set) + "default", + settings( + max_examples=20 if IN_COVERAGE_TESTS else not_set, + phases=list(Phase), # Dogfooding the explain phase + ), ) settings.register_profile("speedy", settings(max_examples=5)) diff --git a/hypothesis-python/tests/cover/test_phases.py b/hypothesis-python/tests/cover/test_phases.py index ca1e9b2b9d..8ccca601ed 100644 --- a/hypothesis-python/tests/cover/test_phases.py +++ b/hypothesis-python/tests/cover/test_phases.py @@ -11,6 +11,7 @@ import pytest from hypothesis import Phase, example, given, settings, strategies as st +from hypothesis._settings import all_settings from hypothesis.database import ExampleDatabase, InMemoryExampleDatabase from hypothesis.errors import InvalidArgument @@ -47,7 +48,7 @@ def test_sorts_and_dedupes_phases(arg, expected): def test_phases_default_to_all_except_explain(): - assert settings().phases + (Phase.explain,) == tuple(Phase) + assert all_settings["phases"].default + (Phase.explain,) == tuple(Phase) def test_does_not_reuse_saved_examples_if_reuse_not_in_phases(): diff --git a/hypothesis-python/tests/cover/test_settings.py b/hypothesis-python/tests/cover/test_settings.py index 19914c42cb..e3ca7376ce 100644 --- a/hypothesis-python/tests/cover/test_settings.py +++ b/hypothesis-python/tests/cover/test_settings.py @@ -464,7 +464,7 @@ def __repr__(self): def test_show_changed(): - s = settings(max_examples=999, database=None) + s = settings(max_examples=999, database=None, phases=tuple(Phase)[:-1]) assert s.show_changed() == "database=None, max_examples=999" From 90c4b7bc4764fc39257ee468014a513231b07279 Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sat, 20 Aug 2022 09:00:26 -0700 Subject: [PATCH 6/6] Skip known-uninformative locations --- hypothesis-python/RELEASE.rst | 5 ++ hypothesis-python/src/hypothesis/core.py | 3 +- .../src/hypothesis/internal/scrutineer.py | 32 ++++++++++--- .../tests/cover/test_scrutineer.py | 46 ------------------- .../tests/cover/test_stateful.py | 23 +++++++--- .../tests/nocover/test_scrutineer.py | 22 +++++++++ 6 files changed, 69 insertions(+), 62 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst delete mode 100644 hypothesis-python/tests/cover/test_scrutineer.py diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..75347a5ebc --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,5 @@ +RELEASE_TYPE: patch + +This patch fixes some type annotations for Python 3.9 and earlier (:issue:`3397`), +and teaches :ref:`explain mode ` about certain locations it should not +bother reporting (:issue:`3439`). diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 14ade3fbb1..87af19d789 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -881,8 +881,7 @@ def run_engine(self): errors_to_report.append((fragments, err)) except BaseException as e: # If we have anything for explain-mode, this is the time to report. - for line in explanations[falsifying_example.interesting_origin]: - fragments.append(line) + fragments.extend(explanations[falsifying_example.interesting_origin]) errors_to_report.append( (fragments, e.with_traceback(get_trimmed_traceback())) ) diff --git a/hypothesis-python/src/hypothesis/internal/scrutineer.py b/hypothesis-python/src/hypothesis/internal/scrutineer.py index 0a63508699..3071111f6a 100644 --- a/hypothesis-python/src/hypothesis/internal/scrutineer.py +++ b/hypothesis-python/src/hypothesis/internal/scrutineer.py @@ -12,6 +12,7 @@ from collections import defaultdict from functools import lru_cache, reduce from itertools import groupby +from os import sep from pathlib import Path from hypothesis._settings import Phase, Verbosity @@ -45,6 +46,20 @@ def trace(self, frame, event, arg): self._previous_location = current_location +UNHELPFUL_LOCATIONS = ( + # There's a branch which is only taken when an exception is active while exiting + # a contextmanager; this is probably after the fault has been triggered. + # Similar reasoning applies to a few other standard-library modules: even + # if the fault was later, these still aren't useful locations to report! + f"{sep}contextlib.py", + f"{sep}inspect.py", + f"{sep}re.py", + f"{sep}re{sep}__init__.py", # refactored in Python 3.11 + # Quite rarely, the first AFNP line is in Pytest's assertion-rewriting module. + f"{sep}_pytest{sep}assertion{sep}rewrite.py", +) + + def get_explaining_locations(traces): # Traces is a dict[interesting_origin | None, set[frozenset[tuple[str, int]]]] # Each trace in the set might later become a Counter instead of frozenset. @@ -84,7 +99,13 @@ def get_explaining_locations(traces): else: queue.update(cf_graphs[origin][src] - seen) - return explanations + # The last step is to filter out explanations that we know would be uninformative. + # When this is the first AFNP location, we conclude that Scrutineer missed the + # real divergence (earlier in the trace) and drop that unhelpful explanation. + return { + origin: {loc for loc in afnp_locs if not loc[0].endswith(UNHELPFUL_LOCATIONS)} + for origin, afnp_locs in explanations.items() + } LIB_DIR = str(Path(sys.executable).parent / "lib") @@ -92,13 +113,11 @@ def get_explaining_locations(traces): "Explanation:", " These lines were always and only run by failing examples:", ) -HAD_TRACE = " We didn't try to explain this, because sys.gettrace()=" def make_report(explanations, cap_lines_at=5): report = defaultdict(list) for origin, locations in explanations.items(): - assert locations # or else we wouldn't have stored the key, above. report_lines = [ " {}:{}".format(k, ", ".join(map(str, sorted(l for _, l in v)))) for k, v in groupby(locations, lambda kv: kv[0]) @@ -107,15 +126,14 @@ def make_report(explanations, cap_lines_at=5): if len(report_lines) > cap_lines_at + 1: msg = " (and {} more with settings.verbosity >= verbose)" report_lines[cap_lines_at:] = [msg.format(len(report_lines[cap_lines_at:]))] - report[origin] = list(EXPLANATION_STUB) + report_lines + if report_lines: # We might have filtered out every location as uninformative. + report[origin] = list(EXPLANATION_STUB) + report_lines return report def explanatory_lines(traces, settings): if Phase.explain in settings.phases and sys.gettrace() and not traces: - return defaultdict( - lambda: [EXPLANATION_STUB[0], HAD_TRACE + repr(sys.gettrace())] - ) + return defaultdict(list) # Return human-readable report lines summarising the traces explanations = get_explaining_locations(traces) max_lines = 5 if settings.verbosity <= Verbosity.normal else 100 diff --git a/hypothesis-python/tests/cover/test_scrutineer.py b/hypothesis-python/tests/cover/test_scrutineer.py deleted file mode 100644 index f2cfe2bd0d..0000000000 --- a/hypothesis-python/tests/cover/test_scrutineer.py +++ /dev/null @@ -1,46 +0,0 @@ -# This file is part of Hypothesis, which may be found at -# https://github.com/HypothesisWorks/hypothesis/ -# -# Copyright the Hypothesis Authors. -# Individual contributors are listed in AUTHORS.rst and the git log. -# -# This Source Code Form is subject to the terms of the Mozilla Public License, -# 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/. - -import re -import sys - -import pytest - -from hypothesis.internal.compat import PYPY -from hypothesis.internal.scrutineer import HAD_TRACE - -CODE_SAMPLE = """ -from hypothesis import Phase, given, settings, strategies as st - -@settings(phases=tuple(Phase), derandomize=True) -@given(st.integers()) -def test_reports_branch_in_test(x): - if x > 10: - raise AssertionError # BUG -""" - - -@pytest.mark.skipif(PYPY, reason="Tracing is slow under PyPy") -def test_cannot_explain_message(testdir): - # Most of the explanation-related code can't be run under coverage, but - # what we can cover is the code that prints a message when the explanation - # tracer couldn't be installed. - - no_tracer = sys.gettrace() is None - try: - if no_tracer: - sys.settrace(lambda frame, event, arg: None) - test_file = testdir.makepyfile(CODE_SAMPLE) - testdir.runpytest_inprocess(test_file, "--tb=native").stdout.re_match_lines( - [r"Explanation:", re.escape(HAD_TRACE)], consecutive=True - ) - finally: - if no_tracer: - sys.settrace(None) diff --git a/hypothesis-python/tests/cover/test_stateful.py b/hypothesis-python/tests/cover/test_stateful.py index dbbb911f9e..ce6f60b55c 100644 --- a/hypothesis-python/tests/cover/test_stateful.py +++ b/hypothesis-python/tests/cover/test_stateful.py @@ -9,6 +9,7 @@ # obtain one at https://mozilla.org/MPL/2.0/. import base64 +import sys from collections import defaultdict import pytest @@ -19,6 +20,7 @@ from hypothesis.control import current_build_context from hypothesis.database import ExampleDatabase from hypothesis.errors import DidNotReproduce, Flaky, InvalidArgument, InvalidDefinition +from hypothesis.internal.compat import PYPY from hypothesis.internal.entropy import deterministic_PRNG from hypothesis.stateful import ( Bundle, @@ -676,10 +678,7 @@ def rule_1(self): with pytest.raises(ValueError) as err: run_state_machine_as_test(BadRuleWithGoodInvariants) - result = "\n".join(err.value.__notes__) - assert ( - result - == """ + expected = """\ Falsifying example: state = BadRuleWithGoodInvariants() state.invariant_1() @@ -691,9 +690,19 @@ def rule_1(self): state.invariant_2() state.invariant_3() state.rule_1() -state.teardown() -""".strip() - ) +state.teardown()""" + + if PYPY or sys.gettrace(): # explain mode disabled in these cases + result = "\n".join(err.value.__notes__) + else: + # Non-PyPy runs include explain mode, but we skip the final line because + # it includes the absolute path, which of course varies between machines. + expected += """ +Explanation: + These lines were always and only run by failing examples:""" + result = "\n".join(err.value.__notes__[:-1]) + + assert expected == result def test_always_runs_at_least_one_step(): diff --git a/hypothesis-python/tests/nocover/test_scrutineer.py b/hypothesis-python/tests/nocover/test_scrutineer.py index b24033a170..b0050320e7 100644 --- a/hypothesis-python/tests/nocover/test_scrutineer.py +++ b/hypothesis-python/tests/nocover/test_scrutineer.py @@ -79,3 +79,25 @@ def test_no_explanations_if_deadline_exceeded(code, testdir): code = code.replace("AssertionError", "DeadlineExceeded(timedelta(), timedelta())") pytest_stdout, _ = get_reports(DEADLINE_PRELUDE + PRELUDE + code, testdir=testdir) assert "Explanation:" not in pytest_stdout + + +NO_SHOW_CONTEXTLIB = """ +from contextlib import contextmanager +from hypothesis import given, strategies as st, Phase, settings + +@contextmanager +def ctx(): + yield + +@settings(phases=list(Phase)) +@given(st.integers()) +def test(x): + with ctx(): + assert x < 100 +""" + + +@pytest.mark.skipif(PYPY, reason="Tracing is slow under PyPy") +def test_skips_uninformative_locations(testdir): + pytest_stdout, _ = get_reports(NO_SHOW_CONTEXTLIB, testdir=testdir) + assert "Explanation:" not in pytest_stdout