From b48ba8dbd98cab39e92b59e70f2b7b282c81312b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 06:25:02 +0000 Subject: [PATCH] Fix out-of-bounds decimals() with places and high-precision bounds Compute the integer bounds for fixed-point decimals exactly with Fraction. Previously they were derived via a limited-precision Context.divide whose precision was based only on the integer-part magnitude, so bounds with more fractional digits than `places` could round during the division and make ceil/floor over- or undershoot, yielding values outside min_value/max_value. https://claude.ai/code/session_01PaoeogHgq8MwBbXGi5kP4v --- hypothesis/RELEASE.rst | 6 +++++ .../hypothesis/strategies/_internal/core.py | 7 ++++-- hypothesis/tests/cover/test_numerics.py | 23 ++++++++++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 hypothesis/RELEASE.rst diff --git a/hypothesis/RELEASE.rst b/hypothesis/RELEASE.rst new file mode 100644 index 0000000000..d85cbfd7a9 --- /dev/null +++ b/hypothesis/RELEASE.rst @@ -0,0 +1,6 @@ +RELEASE_TYPE: patch + +This patch fixes a bug where :func:`~hypothesis.strategies.decimals` with the +``places`` argument could generate values outside the ``min_value`` and +``max_value`` bounds, when those bounds had more fractional digits than +``places`` (:issue:`4651`). diff --git a/hypothesis/src/hypothesis/strategies/_internal/core.py b/hypothesis/src/hypothesis/strategies/_internal/core.py index 9ef4a555c4..f054aa57d1 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis/src/hypothesis/strategies/_internal/core.py @@ -1840,10 +1840,13 @@ def int_to_decimal(val): factor = Decimal(10) ** -places min_num, max_num = None, None + # Work out the integer bounds exactly: limited-precision division can + # round when the bounds have more than `places` fractional digits, + # which would make ceil/floor over- or undershoot the true bound. if min_value is not None: - min_num = ceil(ctx(min_value).divide(min_value, factor)) + min_num = ceil(Fraction(min_value) / Fraction(factor)) if max_value is not None: - max_num = floor(ctx(max_value).divide(max_value, factor)) + max_num = floor(Fraction(max_value) / Fraction(factor)) if min_num is not None and max_num is not None and min_num > max_num: raise InvalidArgument( f"There are no decimals with {places} places between " diff --git a/hypothesis/tests/cover/test_numerics.py b/hypothesis/tests/cover/test_numerics.py index 09e915b797..3702eb2af5 100644 --- a/hypothesis/tests/cover/test_numerics.py +++ b/hypothesis/tests/cover/test_numerics.py @@ -89,9 +89,9 @@ def test_fuzz_fractions_bounds(data): @given(data()) def test_fuzz_decimals_bounds(data): places = data.draw(none() | integers(0, 20), label="places") - finite_decs = ( - decimals(allow_nan=False, allow_infinity=False, places=places) | none() - ) + # Note that the bounds are *not* restricted to `places` digits, so they may + # have more fractional digits than the values we generate (see issue #4651). + finite_decs = decimals(allow_nan=False, allow_infinity=False) | none() low, high = data.draw(tuples(finite_decs, finite_decs), label="low, high") if low is not None and high is not None and low > high: low, high = high, low @@ -160,6 +160,23 @@ def test_issue_725_regression(x): pass +@given( + decimals( + min_value=decimal.Decimal(f"0.{'0' * 63}1"), + max_value=decimal.Decimal(f"{'9' * 64}.{'9' * 64}"), + places=2, + ) +) +def test_issue_4651_regression(x): + # Bounds with more fractional digits than `places` must not push the + # integer bounds out of range via rounding in the conversion. + assert ( + decimal.Decimal(f"0.{'0' * 63}1") + <= x + <= decimal.Decimal(f"{'9' * 64}.{'9' * 64}") + ) + + @given(decimals(min_value="0.1", max_value="0.3")) def test_issue_739_regression(x): pass