Repository: HypothesisWorks/hypothesis

Issue:
Deprecate min_value or max_value that cannot be generated
Many Hypothesis strategies accept `min_value` and `max_value` arguments, which usually have to be the same type as the examples the strategy can generate.  The strategies for numbers generally allow any type of number to be used - which is convenient, but has a surprising catch: sometimes, the strategy will be unable to generate the given bound or bounds!

We should deprecate bounds for `integers()` and `floats()` which cannot be exactly represented as integers and floats respectively.  For example, you can check `if min_value != min_int_value` and then use `note_deprecation` to issue the warning.

Finally, we should deprecate bounds for `fractions()` with a non-None `max_denominator` where the denominator of the bound (after casting to Fraction) is larger than `max_denominator`.


Discussion:
This looks like something I can give a shot tomorrow. 👍 
Awesome!  This issue is all your for the next week :smile: 

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/strategies.py b/hypothesis-python/src/hypothesis/strategies.py
--- a/hypothesis-python/src/hypothesis/strategies.py
+++ b/hypothesis-python/src/hypothesis/strategies.py
@@ -333,6 +333,19 @@ def integers(min_value=None, max_value=None):
     min_int_value = None if min_value is None else ceil(min_value)
     max_int_value = None if max_value is None else floor(max_value)
 
+    if min_value != min_int_value:
+        note_deprecation(
+            'min_value=%r cannot be exactly represented as an integer, which '
+            'will be an error in a future version.'
+            % (min_value)
+        )
+    if max_value != max_int_value:
+        note_deprecation(
+            'max_value=%r cannot be exactly represented as an integer, which '
+            'will be an error in a future version.'
+            % (max_value)
+        )
+
     if min_int_value is not None and max_int_value is not None and \
             min_int_value > max_int_value:
         raise InvalidArgument('No integers between min_value=%r and '
@@ -440,6 +453,21 @@ def floats(
         max_value = float_of(max_value, width)
         assert isinstance(max_value, float)
 
+    if min_value != min_arg:
+        note_deprecation(
+            'min_value=%r cannot be exactly represented as a float of width '
+            '%d, which will be an error in a future version. Use min_value=%r '
+            'instead.'
+            % (min_value, width, min_arg)
+        )
+    if max_value != max_arg:
+        note_deprecation(
+            'max_value=%r cannot be exactly represented as a float of width '
+            '%d, which will be an error in a future version. Use max_value=%r '
+            'instead'
+            % (max_value, width, max_arg)
+        )
+
     check_valid_interval(min_value, max_value, 'min_value', 'max_value')
     if min_value == float(u'-inf'):
         min_value = None
@@ -1362,6 +1390,7 @@ def fraction_bounds(value):
             assert max_denominator is not None
             if value is None or value.denominator <= max_denominator:
                 return value, value
+
             p0, q0, p1, q1 = 0, 1, 1, 0
             n, d = value.numerator, value.denominator
             while True:
@@ -1379,8 +1408,22 @@ def fraction_bounds(value):
         # Take the high approximation for min_value and low for max_value
         bounds = (max_denominator, min_value, max_value)
         if min_value is not None:
+            if min_value.denominator > max_denominator:
+                note_deprecation(
+                    'The min_value=%r has a denominator greater than the '
+                    'max_denominator=%r, which will be an error in a future '
+                    'version.'
+                    % (min_value, max_denominator)
+                )
             _, min_value = fraction_bounds(min_value)
         if max_value is not None:
+            if max_value.denominator > max_denominator:
+                note_deprecation(
+                    'The max_value=%r has a denominator greater than the '
+                    'max_denominator=%r, which will be an error in a future '
+                    'version.'
+                    % (max_value, max_denominator)
+                )
             max_value, _ = fraction_bounds(max_value)
 
         if min_value is not None and max_value is not None and \


Test patch:
diff --git a/hypothesis-python/tests/cover/test_direct_strategies.py b/hypothesis-python/tests/cover/test_direct_strategies.py
--- a/hypothesis-python/tests/cover/test_direct_strategies.py
+++ b/hypothesis-python/tests/cover/test_direct_strategies.py
@@ -61,7 +61,6 @@ def fn_ktest(*fnkwargs):
 @fn_ktest(
     (ds.integers, {'min_value': float('nan')}),
     (ds.integers, {'min_value': 2, 'max_value': 1}),
-    (ds.integers, {'min_value': 0.1, 'max_value': 0.2}),
     (ds.integers, {'min_value': float('nan')}),
     (ds.integers, {'max_value': float('nan')}),
     (ds.dates, {'min_value': 'fish'}),
@@ -99,8 +98,6 @@ def fn_ktest(*fnkwargs):
     (ds.floats, {'min_value': complex(1, 2)}),
     (ds.floats, {'max_value': complex(1, 2)}),
     (ds.fractions, {'min_value': 2, 'max_value': 1}),
-    (ds.fractions, {'min_value': '1/3', 'max_value': '1/3',
-                    'max_denominator': 2}),
     (ds.fractions, {'min_value': float('nan')}),
     (ds.fractions, {'max_value': float('nan')}),
     (ds.fractions, {'max_denominator': 0}),
@@ -163,8 +160,7 @@ def test_validates_keyword_arguments(fn, kwargs):
     (ds.integers, {'min_value': 11}),
     (ds.integers, {'min_value': 11, 'max_value': 100}),
     (ds.integers, {'max_value': 0}),
-    (ds.integers, {'min_value': decimal.Decimal('1.5')}),
-    (ds.integers, {'min_value': -1.5, 'max_value': -0.5}),
+    (ds.integers, {'min_value': -2, 'max_value': -1}),
     (ds.decimals, {'min_value': 1.0, 'max_value': 1.5}),
     (ds.decimals, {'min_value': '1.0', 'max_value': '1.5'}),
     (ds.decimals, {'min_value': decimal.Decimal('1.5')}),
@@ -182,8 +178,8 @@ def test_validates_keyword_arguments(fn, kwargs):
     (ds.fractions, {'min_value': 1.0}),
     (ds.fractions, {'min_value': decimal.Decimal('1.0')}),
     (ds.fractions, {'min_value': fractions.Fraction(1, 2)}),
-    (ds.fractions, {'min_value': '1/2', 'max_denominator': 1}),
-    (ds.fractions, {'max_value': '1/2', 'max_denominator': 1}),
+    (ds.fractions, {'min_value': '1/2', 'max_denominator': 2}),
+    (ds.fractions, {'max_value': '1/2', 'max_denominator': 3}),
     (ds.lists, {'elements': ds.nothing(), 'max_size': 0}),
     (ds.lists, {'elements': ds.integers()}),
     (ds.lists, {'elements': ds.integers(), 'max_size': 5}),
@@ -424,6 +420,41 @@ def test_average_size_is_deprecated():
     ds.lists(ds.integers(), average_size=1).example()
 
 
+@checks_deprecated_behaviour
+@pytest.mark.parametrize('fn,kwargs', [
+    (ds.integers, {'min_value': decimal.Decimal('1.5')}),
+    (ds.integers, {'max_value': decimal.Decimal('1.5')}),
+    (ds.integers, {'min_value': -1.5, 'max_value': -0.5}),
+    (ds.floats, {'min_value': 1.8, 'width': 32}),
+    (ds.floats, {'max_value': 1.8, 'width': 32}),
+    (ds.fractions, {'min_value': '1/3', 'max_value': '1/2',
+                    'max_denominator': 2}),
+    (ds.fractions, {'min_value': '0', 'max_value': '1/3',
+                    'max_denominator': 2})
+])
+def test_disallowed_bounds_are_deprecated(fn, kwargs):
+    fn(**kwargs).example()
+
+
+@checks_deprecated_behaviour
+@pytest.mark.parametrize('fn,kwargs', [
+    (ds.integers, {'min_value': 0.1, 'max_value': 0.2})
+])
+def test_no_integers_in_bounds(fn, kwargs):
+    with pytest.raises(InvalidArgument):
+        fn(**kwargs).example()
+
+
+@checks_deprecated_behaviour
+@pytest.mark.parametrize('fn,kwargs', [
+    (ds.fractions, {'min_value': '1/3', 'max_value': '1/3',
+                    'max_denominator': 2})
+])
+def test_no_fractions_in_bounds(fn, kwargs):
+    with pytest.raises(InvalidArgument):
+        fn(**kwargs).example()
+
+
 @pytest.mark.parametrize('parameter_name', ['min_value', 'max_value'])
 @pytest.mark.parametrize('value', [-1, 0, 1])
 def test_no_infinity_for_min_max_values(value, parameter_name):
diff --git a/hypothesis-python/tests/cover/test_float_nastiness.py b/hypothesis-python/tests/cover/test_float_nastiness.py
--- a/hypothesis-python/tests/cover/test_float_nastiness.py
+++ b/hypothesis-python/tests/cover/test_float_nastiness.py
@@ -19,6 +19,7 @@
 
 import sys
 import math
+import warnings
 from decimal import Decimal
 
 import pytest
@@ -28,6 +29,7 @@
 from hypothesis import given, assume, settings
 from hypothesis.errors import InvalidArgument
 from tests.common.debug import minimal, find_any
+from tests.common.utils import checks_deprecated_behaviour
 from hypothesis.internal.compat import WINDOWS, CAN_PACK_HALF_FLOAT
 from hypothesis.internal.floats import next_up, next_down, float_to_int, \
     int_to_float
@@ -180,6 +182,7 @@ def test_updown_roundtrip(val):
     assert val == next_down(next_up(val))
 
 
+@checks_deprecated_behaviour
 @given(st.data(), st.floats(allow_nan=False, allow_infinity=False))
 def test_floats_in_tiny_interval_within_bounds(data, center):
     assume(not (math.isinf(next_down(center)) or math.isinf(next_up(center))))
@@ -190,6 +193,7 @@ def test_floats_in_tiny_interval_within_bounds(data, center):
     assert lo < val < hi
 
 
+@checks_deprecated_behaviour
 def test_float_free_interval_is_invalid():
     lo = (2 ** 54) + 1
     hi = lo + 2
@@ -244,4 +248,8 @@ def test_no_single_floats_in_range():
     high = low + 2
     st.floats(low, high).validate()  # Note: OK for 64bit floats
     with pytest.raises(InvalidArgument):
-        st.floats(low, high, width=32).validate()
+        """Unrepresentable bounds are deprecated; but we're not testing that
+        here."""
+        with warnings.catch_warnings():
+            warnings.simplefilter('ignore')
+            st.floats(low, high, width=32).validate()
diff --git a/hypothesis-python/tests/nocover/test_simple_numbers.py b/hypothesis-python/tests/nocover/test_simple_numbers.py
--- a/hypothesis-python/tests/nocover/test_simple_numbers.py
+++ b/hypothesis-python/tests/nocover/test_simple_numbers.py
@@ -24,6 +24,7 @@
 
 from hypothesis import given
 from tests.common.debug import minimal
+from tests.common.utils import checks_deprecated_behaviour
 from hypothesis.strategies import lists, floats, integers
 
 
@@ -220,6 +221,7 @@ class TestFloatsAreFloats(object):
     def test_unbounded(self, arg):
         assert isinstance(arg, float)
 
+    @checks_deprecated_behaviour
     @given(floats(min_value=0, max_value=2 ** 64 - 1))
     def test_int_int(self, arg):
         assert isinstance(arg, float)
@@ -228,6 +230,7 @@ def test_int_int(self, arg):
     def test_int_float(self, arg):
         assert isinstance(arg, float)
 
+    @checks_deprecated_behaviour
     @given(floats(min_value=float(0), max_value=2 ** 64 - 1))
     def test_float_int(self, arg):
         assert isinstance(arg, float)