diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..ee287870fe --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,10 @@ +RELEASE_TYPE: minor + +Did you know that of the 2\ :superscript:`64` possible floating-point numbers, +2\ :superscript:`53` of them are ``nan`` - and Python prints them all the same way? + +While nans *usually* have all zeros in the sign bit and mantissa, this +`isn't always true `__, +and :wikipedia:`'signaling' nans might trap or error `. +To help distinguish such errors in e.g. CI logs, Hypothesis now prints ``-nan`` for +negative nans, and adds a comment like ``# Saw 3 signaling NaNs`` if applicable. diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/numbers.py b/hypothesis-python/src/hypothesis/strategies/_internal/numbers.py index 328a94bc9b..6f726a022d 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/numbers.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/numbers.py @@ -147,6 +147,9 @@ def integers( return IntegersStrategy(min_value, max_value) +SIGNALING_NAN = int_to_float(0x7FF8_0000_0000_0001) # nonzero mantissa +assert math.isnan(SIGNALING_NAN) and math.copysign(1, SIGNALING_NAN) == 1 + NASTY_FLOATS = sorted( [ 0.0, @@ -170,7 +173,8 @@ def integers( ] + [2.0 ** -n for n in (24, 14, 149, 126)] # minimum (sub)normals for float16,32 + [float_info.min / n for n in (2, 10, 1000, 100_000)] # subnormal in float64 - + [math.inf, math.nan] * 5, + + [math.inf, math.nan] * 5 + + [SIGNALING_NAN], key=flt.float_to_lex, ) NASTY_FLOATS = list(map(float, NASTY_FLOATS)) diff --git a/hypothesis-python/src/hypothesis/vendor/pretty.py b/hypothesis-python/src/hypothesis/vendor/pretty.py index 688a8a2277..8973fff1a7 100644 --- a/hypothesis-python/src/hypothesis/vendor/pretty.py +++ b/hypothesis-python/src/hypothesis/vendor/pretty.py @@ -70,10 +70,12 @@ def _repr_pretty_(self, p, cycle): import datetime import platform import re +import struct import types from collections import deque from contextlib import contextmanager from io import StringIO +from math import copysign, isnan __all__ = [ "pretty", @@ -161,6 +163,8 @@ def __init__( self.group_queue = GroupQueue(root_group) self.indentation = 0 + self.snans = 0 + def _break_outer_groups(self): while self.max_width < self.output_width + self.buffer_width: group = self.group_queue.deq() @@ -266,6 +270,12 @@ def end_group(self, dedent=0, close=""): def flush(self): """Flush data that is left in the buffer.""" + if self.snans: + # Reset self.snans *before* calling breakable(), which might flush() + snans = self.snans + self.snans = 0 + self.breakable(" ") + self.text(f"# Saw {snans} signaling NaN" + "s" * (snans > 1)) for data in self.buffer: self.output_width += data.output(self.output, self.output_width) self.buffer.clear() @@ -735,10 +745,20 @@ def _exception_pprint(obj, p, cycle): p.end_group(step, ")") +def _repr_float_counting_nans(obj, p, cycle): + if isnan(obj) and hasattr(p, "snans"): + if struct.pack("!d", abs(obj)) != struct.pack("!d", float("nan")): + p.snans += 1 + if copysign(1.0, obj) == -1.0: + p.text("-nan") + return + p.text(repr(obj)) + + #: printers for builtin types _type_pprinters = { int: _repr_pprint, - float: _repr_pprint, + float: _repr_float_counting_nans, str: _repr_pprint, tuple: _seq_pprinter_factory("(", ")", tuple), list: _seq_pprinter_factory("[", "]", list), diff --git a/hypothesis-python/tests/cover/test_pretty.py b/hypothesis-python/tests/cover/test_pretty.py index 4294812415..758d53baac 100644 --- a/hypothesis-python/tests/cover/test_pretty.py +++ b/hypothesis-python/tests/cover/test_pretty.py @@ -59,6 +59,7 @@ import pytest from hypothesis.internal.compat import PYPY +from hypothesis.strategies._internal.numbers import SIGNALING_NAN from hypothesis.vendor import pretty @@ -622,3 +623,17 @@ def test_empty_printer(): def test_breakable_at_group_boundary(): assert "\n" in pretty.pretty([[], "000000"], max_width=5) + + +@pytest.mark.parametrize( + "obj, rep", + [ + (float("nan"), "nan"), + (-float("nan"), "-nan"), + (SIGNALING_NAN, "nan # Saw 1 signaling NaN"), + (-SIGNALING_NAN, "-nan # Saw 1 signaling NaN"), + ((SIGNALING_NAN, SIGNALING_NAN), "(nan, nan) # Saw 2 signaling NaNs"), + ], +) +def test_nan_reprs(obj, rep): + assert pretty.pretty(obj) == rep diff --git a/hypothesis-python/tests/nocover/test_floating.py b/hypothesis-python/tests/nocover/test_floating.py index a84c9dba8a..808d61e479 100644 --- a/hypothesis-python/tests/nocover/test_floating.py +++ b/hypothesis-python/tests/nocover/test_floating.py @@ -21,9 +21,10 @@ import pytest from hypothesis import HealthCheck, assume, given, settings -from hypothesis.internal.floats import next_down +from hypothesis.internal.floats import float_to_int, next_down from hypothesis.strategies import data, floats, lists +from tests.common.debug import find_any from tests.common.utils import fails TRY_HARDER = settings( @@ -161,3 +162,16 @@ def test_floats_are_in_range(x, y, data): t = data.draw(floats(x, y)) assert x <= t <= y + + +@pytest.mark.parametrize("neg", [False, True]) +@pytest.mark.parametrize("snan", [False, True]) +def test_can_find_negative_and_signaling_nans(neg, snan): + find_any( + floats().filter(math.isnan), + lambda x: ( + snan is (float_to_int(abs(x)) != float_to_int(float("nan"))) + and neg is (math.copysign(1, x) == -1) + ), + settings=TRY_HARDER, + )