Repository: HypothesisWorks/hypothesis

Issue:
`hypothesis.extra.numpy.from_dtype` is difficult to use with complex dtypes
The following code ...

```python
import numpy
from hypothesis.extra.numpy import from_dtype

print(repr(from_dtype(numpy.dtype("complex64"), max_magnitude=3).example()))
```

... generates this exception with varying values (line breaks added):
```
hypothesis.errors.InvalidArgument:
min_value=-2.958039891549808 cannot be exactly represented as a float of width 32
- use min_value=-2.9580399990081787 instead.
```

It is raised here (with a useless strack trace):

https://github.com/HypothesisWorks/hypothesis/blob/68b9c204432f184e6bcb28704e75522b59f983e3/hypothesis-python/src/hypothesis/strategies/_internal/numbers.py#L488-L492

Curiously, the same setup works for `hypothesis.strategies.complex_numbers`:

```python
from hypothesis.strategies import complex_numbers

print(repr(complex_numbers(max_magnitude=3).example()))
```

Tested on hypothesis `6.67.1`.


Discussion:

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
@@ -64,6 +64,7 @@
     integer_range,
 )
 from hypothesis.internal.entropy import get_seeder_and_restorer
+from hypothesis.internal.floats import float_of
 from hypothesis.internal.reflection import (
     define_function_signature,
     get_pretty_function_description,
@@ -1715,19 +1716,32 @@ def complex_numbers(
 
     @composite
     def constrained_complex(draw):
+        # We downcast drawn floats to the desired (component) width so we
+        # guarantee the resulting complex values are representable. Note
+        # truncating the mantissa bits with float_of() cannot increase the
+        # magnitude of a float, so we are guaranteed to stay within the allowed
+        # range. See https://github.com/HypothesisWorks/hypothesis/issues/3573
+
         # Draw the imaginary part, and determine the maximum real part given
         # this and the max_magnitude
         if max_magnitude is None:
             zi = draw(floats(**allow_kw))
             rmax = None
         else:
-            zi = draw(floats(-max_magnitude, max_magnitude, **allow_kw))
-            rmax = cathetus(max_magnitude, zi)
+            zi = draw(
+                floats(
+                    -float_of(max_magnitude, component_width),
+                    float_of(max_magnitude, component_width),
+                    **allow_kw,
+                )
+            )
+            rmax = float_of(cathetus(max_magnitude, zi), component_width)
         # Draw the real part from the allowed range given the imaginary part
         if min_magnitude == 0 or math.fabs(zi) >= min_magnitude:
             zr = draw(floats(None if rmax is None else -rmax, rmax, **allow_kw))
         else:
-            zr = draw(floats(cathetus(min_magnitude, zi), rmax, **allow_kw))
+            rmin = float_of(cathetus(min_magnitude, zi), component_width)
+            zr = draw(floats(rmin, rmax, **allow_kw))
         # Order of conditions carefully tuned so that for a given pair of
         # magnitude arguments, we always either draw or do not draw the bool
         # (crucial for good shrinking behaviour) but only invert when needed.


Test patch:
diff --git a/hypothesis-python/tests/cover/test_complex_numbers.py b/hypothesis-python/tests/cover/test_complex_numbers.py
--- a/hypothesis-python/tests/cover/test_complex_numbers.py
+++ b/hypothesis-python/tests/cover/test_complex_numbers.py
@@ -51,6 +51,9 @@ def test_minimal_quadrant4():
 @given(st.data(), st.integers(-5, 5).map(lambda x: 10**x))
 def test_max_magnitude_respected(data, mag):
     c = data.draw(complex_numbers(max_magnitude=mag))
+    # Note we accept epsilon errors here as internally sqrt is used to draw
+    # complex numbers. sqrt on some platforms gets epsilon errors, which is
+    # too tricky to filter out and so - for now - we just accept them.
     assert abs(c) <= mag * (1 + sys.float_info.epsilon)
 
 
@@ -62,6 +65,7 @@ def test_max_magnitude_zero(val):
 @given(st.data(), st.integers(-5, 5).map(lambda x: 10**x))
 def test_min_magnitude_respected(data, mag):
     c = data.draw(complex_numbers(min_magnitude=mag))
+    # See test_max_magnitude_respected comment
     assert (
         abs(c.real) >= mag
         or abs(c.imag) >= mag
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
@@ -99,8 +99,6 @@ def fn_ktest(*fnkwargs):
     (ds.floats, {"exclude_max": True}),  # because max_value=None
     (ds.floats, {"min_value": 1.8, "width": 32}),
     (ds.floats, {"max_value": 1.8, "width": 32}),
-    (ds.complex_numbers, {"min_magnitude": 1.8, "width": 64}),
-    (ds.complex_numbers, {"max_magnitude": 1.8, "width": 64}),
     (ds.fractions, {"min_value": 2, "max_value": 1}),
     (ds.fractions, {"min_value": math.nan}),
     (ds.fractions, {"max_value": math.nan}),
diff --git a/hypothesis-python/tests/nocover/test_complex_numbers.py b/hypothesis-python/tests/nocover/test_complex_numbers.py
new file mode 100644
--- /dev/null
+++ b/hypothesis-python/tests/nocover/test_complex_numbers.py
@@ -0,0 +1,29 @@
+# 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 pytest
+
+from hypothesis import given, settings, strategies as st
+
+
+@pytest.mark.parametrize("width", [32, 64, 128])
+@pytest.mark.parametrize("keyword", ["min_magnitude", "max_magnitude"])
+@given(data=st.data())
+@settings(max_examples=1000)
+def test_magnitude_validates(width, keyword, data):
+    # See https://github.com/HypothesisWorks/hypothesis/issues/3573
+    component_width = width / 2
+    magnitude = data.draw(
+        # 1.8 is a known example that hasn't validated in the past
+        st.floats(0, width=component_width) | st.just(1.8),
+        label=keyword,
+    )
+    strat = st.complex_numbers(width=width, **{keyword: magnitude})
+    data.draw(strat)
diff --git a/hypothesis-python/tests/numpy/test_from_dtype.py b/hypothesis-python/tests/numpy/test_from_dtype.py
--- a/hypothesis-python/tests/numpy/test_from_dtype.py
+++ b/hypothesis-python/tests/numpy/test_from_dtype.py
@@ -203,6 +203,9 @@ def test_arrays_gives_useful_error_on_inconsistent_time_unit():
         (complex, {"allow_nan": False}, lambda x: not np.isnan(x)),
         (complex, {"allow_infinity": False}, lambda x: not np.isinf(x)),
         (complex, {"allow_nan": False, "allow_infinity": False}, np.isfinite),
+        # Note we accept epsilon errors here as internally sqrt is used to draw
+        # complex numbers. sqrt on some platforms gets epsilon errors, which is
+        # too tricky to filter out and so - for now - we just accept them.
         (
             complex,
             {"min_magnitude": 1e3},