diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5c4b8ca4dd..68c828caeb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -227,8 +227,8 @@ jobs: - alt-nocover - alt-rest exclude: - - { os: macos-latest, python-architecture: "x86" } - - { python-version: "3.13", python-architecture: "x86" } + - { os: macos-latest, python-architecture: "x86" } + - { python-version: "3.13", python-architecture: "x86" } - { python-version: "3.11", task: nocover } - { python-version: "3.11", task: rest } - { python-version: "3.13", task: alt-nocover } diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..7df4525ce2 --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,3 @@ +RELEASE_TYPE: patch + +Fixes our bundled |run_conformance_test| not respecting |PrimitiveProvider.avoid_realization|. diff --git a/hypothesis-python/docs/index.rst b/hypothesis-python/docs/index.rst index dd751d0b90..a64609a7d5 100644 --- a/hypothesis-python/docs/index.rst +++ b/hypothesis-python/docs/index.rst @@ -51,7 +51,7 @@ Hypothesis is the property-based testing library for Python. With Hypothesis, yo @given(st.lists(st.integers() | st.floats())) def test_sort_correct(lst): - # lst is a random list of numbers + # hypothesis generates random lists of numbers to test assert my_sort(lst) == sorted(lst) test_sort_correct() diff --git a/hypothesis-python/docs/prolog.rst b/hypothesis-python/docs/prolog.rst index 9ede98b7c5..f9350bd874 100644 --- a/hypothesis-python/docs/prolog.rst +++ b/hypothesis-python/docs/prolog.rst @@ -137,8 +137,10 @@ .. |PrimitiveProvider.add_observability_callback| replace:: :data:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.add_observability_callback` .. |PrimitiveProvider.span_start| replace:: :func:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.span_start` .. |PrimitiveProvider.span_end| replace:: :func:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.span_end` +.. |PrimitiveProvider.avoid_realization| replace:: :data:`~hypothesis.internal.conjecture.providers.PrimitiveProvider.avoid_realization` .. |AVAILABLE_PROVIDERS| replace:: :data:`~hypothesis.internal.conjecture.providers.AVAILABLE_PROVIDERS` +.. |run_conformance_test| replace:: :func:`~hypothesis.internal.conjecture.provider_conformance.run_conformance_test` .. |add_observability_callback| replace:: :data:`~hypothesis.internal.observability.add_observability_callback` .. |remove_observability_callback| replace:: :data:`~hypothesis.internal.observability.remove_observability_callback` diff --git a/hypothesis-python/src/hypothesis/internal/cache.py b/hypothesis-python/src/hypothesis/internal/cache.py index d2d90e8c84..73b9ed76d2 100644 --- a/hypothesis-python/src/hypothesis/internal/cache.py +++ b/hypothesis-python/src/hypothesis/internal/cache.py @@ -117,24 +117,7 @@ def __setitem__(self, key: K, value: V) -> None: raise ValueError( "Cannot increase size of cache where all keys have been pinned." ) from None - try: - del self.keys_to_indices[evicted.key] - except KeyError: # pragma: no cover - # This can't happen, but happens nevertheless with - # id(key1) == id(key2) - # but - # hash(key1) != hash(key2) - # (see https://github.com/HypothesisWorks/hypothesis/issues/4442) - # Rebuild keys_to_indices to match data. - self.keys_to_indices.clear() - self.keys_to_indices.update( - { - entry.key: i - for i, entry in enumerate(self.data) - if entry is not evicted - } - ) - assert len(self.keys_to_indices) == len(self.data) - 1 + del self.keys_to_indices[evicted.key] i = 0 self.data[0] = entry else: diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/engine.py b/hypothesis-python/src/hypothesis/internal/conjecture/engine.py index 6ec53868cf..17c90fd3c8 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/engine.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/engine.py @@ -170,8 +170,11 @@ class RunIsComplete(Exception): def _get_provider(backend: str) -> Union[type, PrimitiveProvider]: - module_name, class_name = AVAILABLE_PROVIDERS[backend].rsplit(".", 1) - provider_cls = getattr(importlib.import_module(module_name), class_name) + provider_cls = AVAILABLE_PROVIDERS[backend] + if isinstance(provider_cls, str): + module_name, class_name = provider_cls.rsplit(".", 1) + provider_cls = getattr(importlib.import_module(module_name), class_name) + if provider_cls.lifetime == "test_function": return provider_cls(None) elif provider_cls.lifetime == "test_case": diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/provider_conformance.py b/hypothesis-python/src/hypothesis/internal/conjecture/provider_conformance.py index c1f28f334f..3615cb6585 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/provider_conformance.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/provider_conformance.py @@ -29,7 +29,9 @@ from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.conjecture.providers import ( COLLECTION_DEFAULT_MAX_SIZE, + HypothesisProvider, PrimitiveProvider, + with_register_backend, ) from hypothesis.internal.floats import SMALLEST_SUBNORMAL, sign_aware_lte from hypothesis.internal.intervalsets import IntervalSet @@ -369,119 +371,132 @@ def test_conformance(): treat those exceptions as fatal errors. """ - @Settings(settings, suppress_health_check=[HealthCheck.too_slow]) - class ProviderConformanceTest(RuleBasedStateMachine): - def __init__(self): - super().__init__() - - @initialize(random=st.randoms()) - def setup(self, random): - if Provider.lifetime == "test_case": - data = ConjectureData(random=random, provider=Provider) - self.provider = data.provider - else: - self.provider = Provider(None) - - self.context_manager = self.provider.per_test_case_context_manager() - self.context_manager.__enter__() - self.frozen = False - - def _draw(self, choice_type, constraints): - del constraints["forced"] - draw_func = getattr(self.provider, f"draw_{choice_type}") - - try: - choice = draw_func(**constraints) - note(f"drew {choice_type} {choice}") - expected_type = { - "integer": int, - "float": float, - "bytes": bytes, - "string": str, - "boolean": bool, - }[choice_type] - assert isinstance(choice, expected_type) - assert choice_permitted(choice, constraints) - except context_manager_exceptions as e: - note(f"caught exception {type(e)} in context_manager_exceptions: {e}") + class CopiesRealizationProvider(HypothesisProvider): + avoid_realization = Provider.avoid_realization + + with with_register_backend("copies_realization", CopiesRealizationProvider): + + @Settings( + settings, + suppress_health_check=[HealthCheck.too_slow], + backend="copies_realization", + ) + class ProviderConformanceTest(RuleBasedStateMachine): + def __init__(self): + super().__init__() + + @initialize(random=st.randoms()) + def setup(self, random): + if Provider.lifetime == "test_case": + data = ConjectureData(random=random, provider=Provider) + self.provider = data.provider + else: + self.provider = Provider(None) + + self.context_manager = self.provider.per_test_case_context_manager() + self.context_manager.__enter__() + self.frozen = False + + def _draw(self, choice_type, constraints): + del constraints["forced"] + draw_func = getattr(self.provider, f"draw_{choice_type}") + try: - self.context_manager.__exit__(type(e), e, None) - except BackendCannotProceed: - self.frozen = True - return None - - return choice - - @precondition(lambda self: not self.frozen) - @rule(constraints=integer_constraints()) - def draw_integer(self, constraints): - self._draw("integer", constraints) - - @precondition(lambda self: not self.frozen) - @rule(constraints=float_constraints()) - def draw_float(self, constraints): - self._draw("float", constraints) - - @precondition(lambda self: not self.frozen) - @rule(constraints=bytes_constraints()) - def draw_bytes(self, constraints): - self._draw("bytes", constraints) - - @precondition(lambda self: not self.frozen) - @rule(constraints=string_constraints()) - def draw_string(self, constraints): - self._draw("string", constraints) - - @precondition(lambda self: not self.frozen) - @rule(constraints=boolean_constraints()) - def draw_boolean(self, constraints): - self._draw("boolean", constraints) - - @precondition(lambda self: not self.frozen) - @rule(label=st.integers()) - def span_start(self, label): - self.provider.span_start(label) - - @precondition(lambda self: not self.frozen) - @rule(discard=st.booleans()) - def span_end(self, discard): - self.provider.span_end(discard) - - @precondition(lambda self: not self.frozen) - @rule() - def freeze(self): - # phase-transition, mimicking data.freeze() at the end of a test case. - self.frozen = True - self.context_manager.__exit__(None, None, None) - - @precondition(lambda self: self.frozen) - @rule(value=_realize_objects) - def realize(self, value): - # filter out nans and weirder things - try: - assume(value == value) - except Exception: - # e.g. value = Decimal('-sNaN') - assume(False) - - # if `value` is non-symbolic, the provider should return it as-is. - assert self.provider.realize(value) == value - - @precondition(lambda self: self.frozen) - @rule() - def observe_test_case(self): - observations = self.provider.observe_test_case() - assert isinstance(observations, dict) - - @precondition(lambda self: self.frozen) - @rule(lifetime=st.sampled_from(["test_function", "test_case"])) - def observe_information_messages(self, lifetime): - observations = self.provider.observe_information_messages(lifetime=lifetime) - for observation in observations: - assert isinstance(observation, dict) - - def teardown(self): - if not self.frozen: + choice = draw_func(**constraints) + note(f"drew {choice_type} {choice}") + expected_type = { + "integer": int, + "float": float, + "bytes": bytes, + "string": str, + "boolean": bool, + }[choice_type] + assert isinstance(choice, expected_type) + assert choice_permitted(choice, constraints) + except context_manager_exceptions as e: + note( + f"caught exception {type(e)} in context_manager_exceptions: {e}" + ) + try: + self.context_manager.__exit__(type(e), e, None) + except BackendCannotProceed: + self.frozen = True + return None + + return choice + + @precondition(lambda self: not self.frozen) + @rule(constraints=integer_constraints()) + def draw_integer(self, constraints): + self._draw("integer", constraints) + + @precondition(lambda self: not self.frozen) + @rule(constraints=float_constraints()) + def draw_float(self, constraints): + self._draw("float", constraints) + + @precondition(lambda self: not self.frozen) + @rule(constraints=bytes_constraints()) + def draw_bytes(self, constraints): + self._draw("bytes", constraints) + + @precondition(lambda self: not self.frozen) + @rule(constraints=string_constraints()) + def draw_string(self, constraints): + self._draw("string", constraints) + + @precondition(lambda self: not self.frozen) + @rule(constraints=boolean_constraints()) + def draw_boolean(self, constraints): + self._draw("boolean", constraints) + + @precondition(lambda self: not self.frozen) + @rule(label=st.integers()) + def span_start(self, label): + self.provider.span_start(label) + + @precondition(lambda self: not self.frozen) + @rule(discard=st.booleans()) + def span_end(self, discard): + self.provider.span_end(discard) + + @precondition(lambda self: not self.frozen) + @rule() + def freeze(self): + # phase-transition, mimicking data.freeze() at the end of a test case. + self.frozen = True self.context_manager.__exit__(None, None, None) - ProviderConformanceTest.TestCase().runTest() + @precondition(lambda self: self.frozen) + @rule(value=_realize_objects) + def realize(self, value): + # filter out nans and weirder things + try: + assume(value == value) + except Exception: + # e.g. value = Decimal('-sNaN') + assume(False) + + # if `value` is non-symbolic, the provider should return it as-is. + assert self.provider.realize(value) == value + + @precondition(lambda self: self.frozen) + @rule() + def observe_test_case(self): + observations = self.provider.observe_test_case() + assert isinstance(observations, dict) + + @precondition(lambda self: self.frozen) + @rule(lifetime=st.sampled_from(["test_function", "test_case"])) + def observe_information_messages(self, lifetime): + observations = self.provider.observe_information_messages( + lifetime=lifetime + ) + for observation in observations: + assert isinstance(observation, dict) + + def teardown(self): + if not self.frozen: + self.context_manager.__exit__(None, None, None) + + ProviderConformanceTest.TestCase().runTest() diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/providers.py b/hypothesis-python/src/hypothesis/internal/conjecture/providers.py index fd6ca4b04a..24f4209bce 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/providers.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/providers.py @@ -14,7 +14,7 @@ import sys import warnings from collections.abc import Iterable -from contextlib import AbstractContextManager +from contextlib import AbstractContextManager, contextmanager from functools import cached_property from random import Random from sys import float_info @@ -77,10 +77,16 @@ COLLECTION_DEFAULT_MAX_SIZE = 10**10 # "arbitrarily large" -#: Registered Hypothesis backends. This is a dictionary whose keys are the name -#: to be used in |settings.backend|, and whose values are a string of the absolute -#: importable path to a subclass of |PrimitiveProvider|, which Hypothesis will -#: instantiate when your backend is requested by a test's |settings.backend| value. +#: Registered Hypothesis backends. This is a dictionary where keys are the name +#: to be used in |settings.backend|. The value of a key can be either: +#: +#: * A string corresponding to an importable absolute path of a +#: |PrimitiveProvider| subclass +#: * A |PrimitiveProvider| subclass (the class itself, not an instance of the +#: class) +#: +#: Hypothesis will instantiate the corresponding |PrimitiveProvider| subclass +#: when the backend is requested by a test's |settings.backend| value. #: #: For example, the default Hypothesis backend is registered as: #: @@ -89,6 +95,8 @@ #: from hypothesis.internal.conjecture.providers import AVAILABLE_PROVIDERS #: #: AVAILABLE_PROVIDERS["hypothesis"] = "hypothesis.internal.conjecture.providers.HypothesisProvider" +#: # or +#: AVAILABLE_PROVIDERS["hypothesis"] = HypothesisProvider #: #: And can be used with: #: @@ -104,10 +112,14 @@ #: Though, as ``backend="hypothesis"`` is the default setting, the above would #: typically not have any effect. #: -#: The purpose of mapping to an absolute importable path, rather than the actual -#: |PrimitiveProvider| class, is to avoid slowing down Hypothesis startup times -#: by only importing alternative backends when required. -AVAILABLE_PROVIDERS = { +#: For third-party backend authors, we strongly encourage ensuring that +#: ``import hypothesis`` does not automatically import the expensive parts of +#: your package, by: +#: +#: - setting a string path here, instead of a provider class +#: - ensuring the registered hypothesis plugin path references a path which just +#: sets AVAILABLE_PROVIDERS and does not import your package +AVAILABLE_PROVIDERS: dict[str, Union[str, type["PrimitiveProvider"]]] = { "hypothesis": "hypothesis.internal.conjecture.providers.HypothesisProvider", "hypothesis-urandom": "hypothesis.internal.conjecture.providers.URandomProvider", } @@ -299,6 +311,15 @@ def _get_local_constants() -> Constants: return _local_constants +@contextmanager +def with_register_backend(name, provider_cls): + try: + AVAILABLE_PROVIDERS[name] = provider_cls + yield + finally: + del AVAILABLE_PROVIDERS[name] + + class _BackendInfoMsg(TypedDict): type: InfoObservationType title: str diff --git a/hypothesis-python/tests/common/setup.py b/hypothesis-python/tests/common/setup.py index 86c7fd3c4e..a1712fd24f 100644 --- a/hypothesis-python/tests/common/setup.py +++ b/hypothesis-python/tests/common/setup.py @@ -76,6 +76,9 @@ def run(): if "crosshair" in AVAILABLE_PROVIDERS: settings.register_profile( "crosshair", + # inherit from default profile, even on CI. See + # https://github.com/HypothesisWorks/hypothesis/pull/4536#issuecomment-3366741772 + settings.get_profile("default"), backend="crosshair", max_examples=20, deadline=None, diff --git a/hypothesis-python/tests/conjecture/test_provider.py b/hypothesis-python/tests/conjecture/test_provider.py index 3865a610c7..4e269affab 100644 --- a/hypothesis-python/tests/conjecture/test_provider.py +++ b/hypothesis-python/tests/conjecture/test_provider.py @@ -48,6 +48,7 @@ AVAILABLE_PROVIDERS, COLLECTION_DEFAULT_MAX_SIZE, HypothesisProvider, + with_register_backend, ) from hypothesis.internal.floats import SIGNALING_NAN, clamp from hypothesis.internal.intervalsets import IntervalSet @@ -174,14 +175,12 @@ def draw_bytes( _temp_register_backend_lock = RLock() +# same as with_register_backend, but adds a lock for our threading tests. @contextmanager -def temp_register_backend(name, cls): +def temp_register_backend(name, provider_cls): with _temp_register_backend_lock: - try: - AVAILABLE_PROVIDERS[name] = f"{__name__}.{cls.__name__}" + with with_register_backend(name, provider_cls): yield - finally: - AVAILABLE_PROVIDERS.pop(name) @pytest.mark.parametrize( diff --git a/hypothesis-python/tests/cover/test_arbitrary_data.py b/hypothesis-python/tests/cover/test_arbitrary_data.py index cf19386290..7aa265430e 100644 --- a/hypothesis-python/tests/cover/test_arbitrary_data.py +++ b/hypothesis-python/tests/cover/test_arbitrary_data.py @@ -11,7 +11,7 @@ import pytest from pytest import raises -from hypothesis import find, given, strategies as st +from hypothesis import find, given, settings, strategies as st from hypothesis.errors import InvalidArgument @@ -64,6 +64,10 @@ def test(data1, data2): assert "Draw 2: 0" in err.value.__notes__ +# `find` doesn't seem to be thread-safe, though I don't actually see why not +@pytest.mark.xfail( + settings._current_profile == "threading", strict=False, reason="not thread-safe?" +) def test_data_supports_find(): data = find(st.data(), lambda data: data.draw(st.integers()) >= 10) assert data.conjecture_data.choices == (10,) diff --git a/hypothesis-python/tests/nocover/test_explore_arbitrary_languages.py b/hypothesis-python/tests/nocover/test_explore_arbitrary_languages.py index 9d3906f046..1fe2dcafc5 100644 --- a/hypothesis-python/tests/nocover/test_explore_arbitrary_languages.py +++ b/hypothesis-python/tests/nocover/test_explore_arbitrary_languages.py @@ -121,7 +121,8 @@ def test(local_data): # unclear why the warning doesn't trigger on a # single thread, but that was previous behavior so I'm not looking too deep into it. @skipif_threading -@xfail_on_crosshair(Why.nested_given) # technically nested-engine, but same problem +# technically nested-engine, but same problem +@xfail_on_crosshair(Why.nested_given, strict=False) @settings( suppress_health_check=list(HealthCheck), deadline=None, diff --git a/hypothesis-python/tox.ini b/hypothesis-python/tox.ini index b6b6219c36..f3c1abec76 100644 --- a/hypothesis-python/tox.ini +++ b/hypothesis-python/tox.ini @@ -250,7 +250,9 @@ commands = [testenv:threading] deps = -r../requirements/test.txt - pytest-run-parallel>=0.6.0 + # in pytest-run-parallel==0.7.0, python3.10 + pytest-run-parallel + xdist does + # not collect any tests. I don't know why. + pytest-run-parallel==0.6.0 setenv= PYTHONWARNDEFAULTENCODING=1 HYPOTHESIS_PROFILE=threading