Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-hypothesis for
openSUSE:Factory checked in at 2026-05-16 19:23:56
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-hypothesis (Old)
and /work/SRC/openSUSE:Factory/.python-hypothesis.new.1966 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-hypothesis"
Sat May 16 19:23:56 2026 rev:90 rq:1353410 version:6.152.6
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-hypothesis/python-hypothesis.changes
2026-04-28 11:53:14.373713685 +0200
+++
/work/SRC/openSUSE:Factory/.python-hypothesis.new.1966/python-hypothesis.changes
2026-05-16 19:24:23.333702624 +0200
@@ -1,0 +2,8 @@
+Tue May 12 16:55:43 UTC 2026 - Markéta Machová <[email protected]>
+
+- update to 6.152.6:
+ * This patch adds a shrinking pass that tries natural text
+ transformations - unicode decomposition (NFD/NFKD) and case
+ mapping - on individual characters in string choices.
+
+-------------------------------------------------------------------
Old:
----
hypothesis-python-6.152.2.tar.gz
New:
----
hypothesis-python-6.152.6.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-hypothesis.spec ++++++
--- /var/tmp/diff_new_pack.DxVZyq/_old 2026-05-16 19:24:24.005730128 +0200
+++ /var/tmp/diff_new_pack.DxVZyq/_new 2026-05-16 19:24:24.009730291 +0200
@@ -43,7 +43,7 @@
%endif
%{?sle15_python_module_pythons}
Name: python-hypothesis%{psuffix}
-Version: 6.152.2
+Version: 6.152.6
Release: 0
Summary: A library for property based testing
License: MPL-2.0
@@ -103,8 +103,10 @@
BuildRequires: %{python_module pytest-xdist}
BuildRequires: %{python_module python-dateutil >= 1.4}
BuildRequires: %{python_module rich >= 9.0.0}
+BuildRequires: %{python_module syrupy}
BuildRequires: %{python_module typing_extensions}
BuildRequires: %{python_module watchdog}
+BuildRequires: git-core
%if %{with complete_tests}
BuildRequires: %{python_module Django >= 4.2}
BuildRequires: %{python_module fakeredis}
@@ -177,7 +179,7 @@
# flaky tests
donttest+=" or test_has_string_of_max_length or
test_database_listener_directory"
# drop tests testing functionality we don't have
-rm tests/crosshair/test_crosshair.py
+rm tests/crosshair/test_*.py
# adapted from pytest.ini in github repo toplevel dir (above hypothesis-python)
echo '[pytest]
addopts=
++++++ _service ++++++
--- /var/tmp/diff_new_pack.DxVZyq/_old 2026-05-16 19:24:24.069732747 +0200
+++ /var/tmp/diff_new_pack.DxVZyq/_new 2026-05-16 19:24:24.073732911 +0200
@@ -2,7 +2,7 @@
<service name="tar_scm" mode="manual">
<param name="url">https://github.com/HypothesisWorks/hypothesis.git</param>
<param name="scm">git</param>
- <param name="revision">hypothesis-python-6.152.2</param>
+ <param name="revision">hypothesis-python-6.152.6</param>
<param name="versionformat">@PARENT_TAG@</param>
<param name="versionrewrite-pattern">hypothesis-python-(.*)</param>
<param name="subdir">hypothesis-python</param>
++++++ hypothesis-python-6.152.2.tar.gz -> hypothesis-python-6.152.6.tar.gz
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/hypothesis-python-6.152.2/docs/changelog.rst
new/hypothesis-python-6.152.6/docs/changelog.rst
--- old/hypothesis-python-6.152.2/docs/changelog.rst 2026-04-24
06:26:07.000000000 +0200
+++ new/hypothesis-python-6.152.6/docs/changelog.rst 2026-05-11
15:12:45.000000000 +0200
@@ -18,6 +18,45 @@
.. include:: ../RELEASE.rst
+.. _v6.152.6:
+
+--------------------
+6.152.6 - 2026-05-11
+--------------------
+
+This patch adds a shrinking pass that tries natural text transformations -
+unicode decomposition (NFD/NFKD) and case mapping - on individual
+characters in string choices. Failures involving e.g. ``"À" != "À".lower()``
+will now reliably shrink to ``"A"`` rather than sometimes getting stuck on
+the high-codepoint accented form (:issue:`4725`).
+
+.. _v6.152.5:
+
+--------------------
+6.152.5 - 2026-05-10
+--------------------
+
+This patch improves the |Phase.explain| phase so that simple cases like
+``assert n1 == n2`` no longer get a misleading ``# or any other generated
value``
+comment (:issue:`4715`). Before falling back to random sampling, we now also
+try borrowing values from each other arg slice with matching shape.
+
+.. _v6.152.4:
+
+--------------------
+6.152.4 - 2026-04-27
+--------------------
+
+This patch fixes a rare internal error during |Phase.explain| introduced in
:version:`6.149.0` for certain strategies (:issue:`4708`).
+
+.. _v6.152.3:
+
+--------------------
+6.152.3 - 2026-04-26
+--------------------
+
+The ``hypothesis-urandom`` :ref:`backend <alternative-backends>` now reads
from ``/dev/urandom`` with buffering disabled, which improves the control of
those hooking ``/dev/urandom`` to change or read Hypothesis's random decisions.
+
.. _v6.152.2:
--------------------
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/src/hypothesis/internal/conjecture/providers.py
new/hypothesis-python-6.152.6/src/hypothesis/internal/conjecture/providers.py
---
old/hypothesis-python-6.152.2/src/hypothesis/internal/conjecture/providers.py
2026-04-24 06:26:07.000000000 +0200
+++
new/hypothesis-python-6.152.6/src/hypothesis/internal/conjecture/providers.py
2026-05-11 15:12:45.000000000 +0200
@@ -1232,7 +1232,12 @@
@staticmethod
def _urandom(size: int) -> bytes:
- with open("/dev/urandom", "rb") as f:
+ # By default, we buffer more data from /dev/urandom than the actual
`size` number
+ # of bytes we requested. This trips up anyone (and particularly
Antithesis)
+ # hooking /dev/urandom reads to control randomness.
+ #
+ # buffering=0 disables this behavior.
+ with open("/dev/urandom", "rb", buffering=0) as f:
return f.read(size)
def getrandbits(self, k: int) -> int:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/src/hypothesis/internal/conjecture/shrinker.py
new/hypothesis-python-6.152.6/src/hypothesis/internal/conjecture/shrinker.py
---
old/hypothesis-python-6.152.2/src/hypothesis/internal/conjecture/shrinker.py
2026-04-24 06:26:07.000000000 +0200
+++
new/hypothesis-python-6.152.6/src/hypothesis/internal/conjecture/shrinker.py
2026-05-11 15:12:45.000000000 +0200
@@ -9,9 +9,11 @@
# obtain one at https://mozilla.org/MPL/2.0/.
import math
+import unicodedata
from collections import defaultdict
-from collections.abc import Callable, Sequence
+from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass
+from functools import lru_cache
from typing import (
TYPE_CHECKING,
Any,
@@ -89,6 +91,34 @@
)
+@lru_cache(maxsize=4096)
+def _natural_simpler_chars(c, intervals):
+ """Return single-char replacements for ``c`` derived from natural text
+ transformations - case mapping (upper, lower, casefold) and unicode
+ decomposition (NFD, NFKD). We take each individual character of the
+ transformed form so that e.g. ``ß`` can shrink to ``s`` via casefold
+ even though the full case-folded form is two characters.
+
+ Only candidates which are in ``intervals`` and which have a strictly
+ smaller index in shrink order than ``c`` are returned, sorted by that
+ shrink-order index. Callers must pass a single character that is itself
+ in ``intervals``.
+ """
+ candidates: set[str] = set()
+ for form in ("NFKD", "NFD"):
+ candidates.update(unicodedata.normalize(form, c))
+ for transformed in (c.upper(), c.lower(), c.casefold()):
+ candidates.update(transformed)
+ candidates.discard(c)
+ original_idx = intervals.index_from_char_in_shrink_order(c)
+ result = sorted(
+ (intervals.index_from_char_in_shrink_order(cand), cand)
+ for cand in candidates
+ if cand in intervals
+ )
+ return [cand for idx, cand in result if idx < original_idx]
+
+
@dataclass(slots=True, frozen=False)
class ShrinkPass:
function: Any
@@ -321,6 +351,7 @@
ShrinkPass(self.redistribute_numeric_pairs),
ShrinkPass(self.lower_integers_together),
ShrinkPass(self.lower_duplicated_characters),
+ ShrinkPass(self.normalize_unicode_chars),
]
# Because the shrinker is also used to `pareto_optimise` in the target
phase,
@@ -501,27 +532,36 @@
):
continue
+ # Try a few targeted candidates before falling back to random
sampling,
+ # so that simple cases like ``assert n1 == n2`` -- where the only
+ # passing value of ``n1`` is exactly ``n2``'s value -- aren't
reported
+ # as freely-variable just because random sampling missed it.
+ candidates = list(self._explain_candidates(start, end))
+
# Run our experiments
n_same_failures = 0
note = "or any other generated value"
# TODO: is 100 same-failures out of 500 attempts a good heuristic?
- for n_attempt in range(500): # pragma: no branch
+ for n_attempt in range(500 + len(candidates)): # pragma: no branch
# no-branch here because we don't coverage-test the
abort-at-500 logic.
- if n_attempt - 10 > n_same_failures * 5:
+ if n_attempt - 10 - len(candidates) > n_same_failures * 5:
# stop early if we're seeing mostly invalid examples
break # pragma: no cover
- # replace start:end with random values
- replacement = []
- for i in range(start, end):
- node = nodes[i]
- if not node.was_forced:
- value = draw_choice(
- node.type, node.constraints, random=self.random
- )
- node = node.copy(with_value=value)
- replacement.append(node.value)
+ if n_attempt < len(candidates):
+ replacement = list(candidates[n_attempt])
+ else:
+ # replace start:end with random values
+ replacement = []
+ for i in range(start, end):
+ node = nodes[i]
+ if not node.was_forced:
+ value = draw_choice(
+ node.type, node.constraints, random=self.random
+ )
+ node = node.copy(with_value=value)
+ replacement.append(node.value)
attempt = choices[:start] + tuple(replacement) + choices[end:]
result = self.engine.cached_test_function(attempt,
extend="full")
@@ -539,7 +579,6 @@
):
assert span1.start == span2.start
assert span1.start <= start
- assert span1.label == span2.label
if span1.start == start and span1.end == end:
result_end = span2.end
break
@@ -611,6 +650,33 @@
)
break
+ def _explain_candidates(
+ self, start: int, end: int
+ ) -> "Iterator[tuple[ChoiceT, ...]]":
+ """Yield deterministic candidate replacements for ``nodes[start:end]``.
+
+ Random sampling alone misses cases like ``assert n1 == n2``, where the
+ only passing value of ``n1`` is exactly ``n2``'s value. We try
+ substituting values from each other arg slice with matching length and
+ types, which catches such comparisons. Invalid borrowed values just
+ produce an irrelevant test result the outer loop discards.
+ """
+ nodes = self.nodes
+ target_types = tuple(nodes[i].type for i in range(start, end))
+ current_keys = tuple(choice_key(nodes[i].value) for i in range(start,
end))
+ seen: set[tuple[Any, ...]] = {current_keys}
+ for s2, e2 in sorted(self.shrink_target.arg_slices):
+ if (s2, e2) == (start, end) or (e2 - s2) != (end - start):
+ continue
+ if tuple(nodes[s2 + j].type for j in range(end - start)) !=
target_types:
+ continue
+ borrowed = tuple(nodes[s2 + j].value for j in range(end - start))
+ key = tuple(choice_key(v) for v in borrowed)
+ if key in seen:
+ continue
+ seen.add(key)
+ yield borrowed
+
def greedy_shrink(self) -> None:
"""Run a full set of greedy shrinks (that is, ones that will only ever
move to a better target) and update shrink_target appropriately.
@@ -1365,13 +1431,15 @@
)
node2 = chooser.choose(
self.nodes,
- lambda node: can_choose_node(node)
- # Note that it's fine for node2 to be trivial, because we're going
to
- # explicitly make it *not* trivial by adding to its value.
- and not node.was_forced
- # to avoid quadratic behavior, scan ahead only a small amount for
- # the related node.
- and node1.index < node.index <= node1.index + 4,
+ lambda node: (
+ can_choose_node(node)
+ # Note that it's fine for node2 to be trivial, because we're
going to
+ # explicitly make it *not* trivial by adding to its value.
+ and not node.was_forced
+ # to avoid quadratic behavior, scan ahead only a small amount
for
+ # the related node.
+ and node1.index < node.index <= node1.index + 4
+ ),
)
m: int | float = node1.value
@@ -1423,8 +1491,9 @@
node2 = self.nodes[
chooser.choose(
range(node1.index + 1, min(len(self.nodes), node1.index + 3 +
1)),
- lambda i: self.nodes[i].type == "integer"
- and not self.nodes[i].was_forced,
+ lambda i: (
+ self.nodes[i].type == "integer" and not
self.nodes[i].was_forced
+ ),
)
]
@@ -1477,9 +1546,12 @@
node2 = self.nodes[
chooser.choose(
range(node1.index + 1, min(len(self.nodes), node1.index + 1 +
4)),
- lambda i: self.nodes[i].type == "string" and not
self.nodes[i].trivial
- # select nodes which have at least one of the same character
present
- and set(node1.value) & set(self.nodes[i].value),
+ lambda i: (
+ self.nodes[i].type == "string"
+ and not self.nodes[i].trivial
+ # select nodes which have at least one of the same
character present
+ and set(node1.value) & set(self.nodes[i].value)
+ ),
)
]
@@ -1508,6 +1580,42 @@
),
)
+ def normalize_unicode_chars(self, chooser):
+ """For string nodes, try replacing characters with simpler equivalents
+ from natural text transformations: unicode decomposition (NFD, NFKD)
+ and case mapping. For example, an accented latin letter is reduced
+ to its base form, a ligature is reduced to its first base character,
+ a mathematical alphanumeric symbol is reduced to its plain ascii
+ counterpart, and a lowercase letter is replaced with its uppercase
+ form (which has a smaller shrink-order index in the default
+ alphabet).
+
+ The codepoint shrinker is binary-search based, so it can get stuck on
+ a high codepoint whose simpler equivalents aren't reached by halving
+ / shifting / masking. This pass directly tries the natural simpler
+ forms one character at a time.
+ """
+ node = chooser.choose(
+ self.nodes,
+ lambda n: n.type == "string"
+ and any(
+ _natural_simpler_chars(c, n.constraints["intervals"]) for c in
n.value
+ ),
+ )
+ intervals = node.constraints["intervals"]
+ i = chooser.choose(
+ range(len(node.value)),
+ lambda j: bool(_natural_simpler_chars(node.value[j], intervals)),
+ )
+ for replacement in _natural_simpler_chars(node.value[i], intervals):
+ new_value = node.value[:i] + replacement + node.value[i + 1 :]
+ if self.consider_new_nodes(
+ self.nodes[: node.index]
+ + (node.copy(with_value=new_value),)
+ + self.nodes[node.index + 1 :]
+ ):
+ return
+
def minimize_nodes(self, nodes):
choice_type = nodes[0].type
value = nodes[0].value
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/hypothesis-python-6.152.2/src/hypothesis/version.py
new/hypothesis-python-6.152.6/src/hypothesis/version.py
--- old/hypothesis-python-6.152.2/src/hypothesis/version.py 2026-04-24
06:26:07.000000000 +0200
+++ new/hypothesis-python-6.152.6/src/hypothesis/version.py 2026-05-11
15:12:45.000000000 +0200
@@ -8,5 +8,5 @@
# 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/.
-__version_info__ = (6, 152, 2)
+__version_info__ = (6, 152, 6)
__version__ = ".".join(map(str, __version_info__))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/tests/conjecture/test_inquisitor.py
new/hypothesis-python-6.152.6/tests/conjecture/test_inquisitor.py
--- old/hypothesis-python-6.152.2/tests/conjecture/test_inquisitor.py
2026-04-24 06:26:07.000000000 +0200
+++ new/hypothesis-python-6.152.6/tests/conjecture/test_inquisitor.py
2026-05-11 15:12:45.000000000 +0200
@@ -12,8 +12,9 @@
import pytest
-from hypothesis import given, settings, strategies as st
+from hypothesis import Phase, given, settings, strategies as st
+from tests.common.debug import minimal
from tests.common.utils import fails_with
@@ -38,8 +39,7 @@
return _inner
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_comments_basic_fail_if_either(
# The test always failed when commented parts were varied together.
a=False, # or any other generated value
@@ -48,24 +48,21 @@
d=True,
e=False, # or any other generated value
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.booleans(), st.booleans(), st.lists(st.none()), st.booleans(),
st.booleans())
def test_inquisitor_comments_basic_fail_if_either(a, b, c, d, e):
assert not (b and d)
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_comments_basic_fail_if_not_all(
# The test sometimes passed when commented parts were varied together.
a='', # or any other generated value
b='', # or any other generated value
c='', # or any other generated value
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.text(), st.text(), st.text())
def test_inquisitor_comments_basic_fail_if_not_all(a, b, c):
@@ -73,14 +70,12 @@
assert condition
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_no_together_comment_if_single_argument(
a='',
b='', # or any other generated value
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.text(), st.text())
def test_inquisitor_no_together_comment_if_single_argument(a, b):
@@ -95,14 +90,12 @@
return n
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_doesnt_break_on_varying_forced_nodes(
n1=100,
n2=0, # or any other generated value
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.integers(), ints_with_forced_draw())
def test_inquisitor_doesnt_break_on_varying_forced_nodes(n1, n2):
@@ -117,6 +110,21 @@
raise ZeroDivisionError
+@fails_with_output("""
+Falsifying example: test_inquisitor_no_misleading_comment_for_eq_args(
+ n1=0,
+ n2=1,
+)
+""")
+@settings(print_blob=False, derandomize=True)
+@given(st.integers(), st.integers())
+def test_inquisitor_no_misleading_comment_for_eq_args(n1, n2):
+ # Neither argument can vary freely: each one's "passing" value is the
+ # other's current value. Random sampling alone almost never finds this,
+ # so we explicitly try borrowing values from sibling slices.
+ assert n1 == n2
+
+
# Tests for sub-argument explanations
@@ -126,72 +134,63 @@
self.y = y
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_builds_subargs(
obj=MyClass(
0, # or any other generated value
True,
),
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.builds(MyClass, st.integers(), st.booleans()))
def test_inquisitor_builds_subargs(obj):
assert not obj.y
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_builds_kwargs_subargs(
obj=MyClass(
x=0, # or any other generated value
y=True,
),
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.builds(MyClass, x=st.integers(), y=st.booleans()))
def test_inquisitor_builds_kwargs_subargs(obj):
assert not obj.y
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_tuple_subargs(
t=(
0, # or any other generated value
True,
),
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.tuples(st.integers(), st.booleans()))
def test_inquisitor_tuple_subargs(t):
assert not t[1]
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_fixeddict_subargs(
d={
'x': 0, # or any other generated value
'y': True,
},
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.fixed_dictionaries({"x": st.integers(), "y": st.booleans()}))
def test_inquisitor_fixeddict_subargs(d):
assert not d["y"]
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_tuple_multiple_varying(
t=(
0, # or any other generated value
@@ -199,8 +198,7 @@
True,
),
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.tuples(st.integers(), st.text(), st.booleans()))
def test_inquisitor_tuple_multiple_varying(t):
@@ -209,16 +207,14 @@
assert not t[2]
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_skip_subset_slices(
obj=MyClass(
(0, False), # or any other generated value
y=False,
),
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(st.builds(MyClass, st.tuples(st.integers(), st.booleans()),
y=st.booleans()))
def test_inquisitor_skip_subset_slices(obj):
@@ -228,8 +224,7 @@
# Test for duplicate param names at different nesting levels
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_duplicate_param_names(
kw=0, # or any other generated value
b={
@@ -237,8 +232,7 @@
'c': True,
},
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(kw=st.integers(), b=st.fixed_dictionaries({"kw": st.text(), "c":
st.booleans()}))
def test_inquisitor_duplicate_param_names(kw, b):
@@ -260,8 +254,7 @@
self.x = x
-@fails_with_output(
- """
+@fails_with_output("""
Falsifying example: test_inquisitor_multi_level_nesting(
bare=0, # or any other generated value
outer=Outer(
@@ -269,8 +262,7 @@
value=True,
),
)
-"""
-)
+""")
@settings(print_blob=False, derandomize=True)
@given(
bare=st.integers(),
@@ -284,3 +276,29 @@
# comment appears only once on inner. outer.value doesn't get a comment
# because it's the critical value that determines the failure.
assert not outer.value
+
+
+def test_explain_does_not_crash_with_per_run_labels():
+ # Regression test for
https://github.com/HypothesisWorks/hypothesis/issues/4708.
+ # If a strategy constructed inside a composite draws from st.just() of a
+ # fresh instance of a user-defined class, the resulting OneOfStrategy has
+ # a different label on each test invocation. The explain phase would
+ # previously assert equal labels between the shrink target and a replayed
+ # result, which is not guaranteed to hold.
+ class W:
+ def __init__(self, a):
+ self.a = a
+
+ inner = st.one_of(st.integers(), st.tuples(st.integers(), st.integers()))
+ elements = st.tuples(inner, st.just(0))
+ structured = st.lists(elements, min_size=1, max_size=3)
+
+ @st.composite
+ def wrapped(draw):
+ return draw(st.one_of(st.just(W(None)), structured.map(W)))
+
+ minimal(
+ wrapped(),
+ lambda x: isinstance(x.a, list),
+ settings=settings(phases=(Phase.generate, Phase.shrink,
Phase.explain)),
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/tests/conjecture/test_shrinker.py
new/hypothesis-python-6.152.6/tests/conjecture/test_shrinker.py
--- old/hypothesis-python-6.152.2/tests/conjecture/test_shrinker.py
2026-04-24 06:26:07.000000000 +0200
+++ new/hypothesis-python-6.152.6/tests/conjecture/test_shrinker.py
2026-05-11 15:12:45.000000000 +0200
@@ -787,3 +787,74 @@
shrinker.fixate_shrink_passes([ShrinkPass(shrinker.redistribute_numeric_pairs)])
assert shrinker.choices == (shrink_towards, target - shrink_towards)
+
+
[email protected](
+ "start, expected",
+ [
+ # NFKD decomposition + drop combining: latin letters with diacritics.
+ ("À", "A"),
+ ("é", "e"),
+ ("Ñ", "N"),
+ # Compatibility decomposition: superscripts, mathematical alphabets,
+ # circled forms, fullwidth.
+ ("²", "2"),
+ ("𝕿", "T"),
+ ("Ⓐ", "A"),
+ ("B", "B"),
+ # Ligatures decompose to their first base char under NFKD.
+ ("fi", "f"),
+ # Case mapping that produces multiple chars: ``ß.casefold() == "ss"``,
+ # and we accept any of the individual characters of the case-mapped
+ # form as a single-char replacement.
+ ("ß", "s"),
+ # Case mapping: uppercase has a smaller shrink-order index than
+ # lowercase, so we can swap a→A.
+ ("a", "A"),
+ # Multi-character: the pass operates one character at a time, so a
+ # mixed string should reduce each accented char independently.
+ ("Àé", "Ae"),
+ ],
+)
+def test_normalize_unicode_chars_pass(start, expected):
+ @shrinking_from([start])
+ def shrinker(data: ConjectureData):
+ s = data.draw(st.text(min_size=1))
+ # Accept any string where each character is either the original or the
+ # natural-simpler replacement at that position. The pass replaces
+ # characters one at a time, so it must be able to reach `expected` via
+ # a sequence of single-character substitutions.
+ if len(s) == len(start) and all(
+ c in (sc, ec) for c, sc, ec in zip(s, start, expected, strict=True)
+ ):
+ data.mark_interesting(interesting_origin())
+
+
shrinker.fixate_shrink_passes([ShrinkPass(shrinker.normalize_unicode_chars)])
+ assert shrinker.choices == (expected,)
+
+
+def test_normalize_unicode_chars_respects_intervals():
+ # When the strategy's allowed alphabet excludes the simpler ascii form,
+ # the pass must not produce out-of-alphabet replacements. A range that
+ # contains 'À' but not 'A' should leave 'À' alone.
+ alphabet = st.characters(min_codepoint=0xC0, max_codepoint=0xFF)
+
+ @shrinking_from(["À"])
+ def shrinker(data: ConjectureData):
+ data.draw(st.text(min_size=1, alphabet=alphabet))
+ data.mark_interesting(interesting_origin())
+
+
shrinker.fixate_shrink_passes([ShrinkPass(shrinker.normalize_unicode_chars)])
+ assert shrinker.choices == ("À",)
+
+
+def test_normalize_unicode_chars_skips_when_no_simplification():
+ # Plain ascii 'A' has no natural simplification (it is already a base
+ # uppercase letter), so the pass should leave it untouched.
+ @shrinking_from(["A"])
+ def shrinker(data: ConjectureData):
+ data.draw(st.text(min_size=1))
+ data.mark_interesting(interesting_origin())
+
+
shrinker.fixate_shrink_passes([ShrinkPass(shrinker.normalize_unicode_chars)])
+ assert shrinker.choices == ("A",)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/tests/quality/test_shrink_quality.py
new/hypothesis-python-6.152.6/tests/quality/test_shrink_quality.py
--- old/hypothesis-python-6.152.2/tests/quality/test_shrink_quality.py
2026-04-24 06:26:07.000000000 +0200
+++ new/hypothesis-python-6.152.6/tests/quality/test_shrink_quality.py
2026-05-11 15:12:45.000000000 +0200
@@ -539,6 +539,40 @@
)
+def test_shrink_text_differs_from_lower_to_ascii():
+ # Regression test for
https://github.com/HypothesisWorks/hypothesis/issues/4725
+ # Text shrinking would previously sometimes get stuck on a high-codepoint
+ # accented letter such as 'À' instead of converging to 'A'.
+ assert minimal(st.text(min_size=1), lambda s: s != s.lower()) == "A"
+
+
+def test_shrink_text_differs_from_upper_to_ascii():
+ assert minimal(st.text(min_size=1), lambda s: s != s.upper()) == "a"
+
+
+def test_shrink_strips_accent_to_ascii_letter():
+ # Removing the accent from any accented latin letter should shrink that
+ # letter to its base ascii form.
+ assert minimal(st.text(min_size=1), lambda s: "e" in s.lower() or "E" in
s) == "E"
+
+
+def test_shrink_decomposes_compatibility_form_to_ascii():
+ # A character that NFKD-decomposes to an ascii letter (eg a Mathematical
+ # Bold Capital T, or a ligature) should reduce to that base letter when
+ # the predicate also matches the base letter.
+ assert (
+ minimal(st.text(min_size=1), lambda s: any(c.lower() == "t" for c in
s)) == "T"
+ )
+
+
+def test_shrink_ligature_to_base_character():
+ # The ligature fi NFKD-decomposes to "fi"; when the predicate accepts any
+ # 'f' character, we should reduce to plain "F".
+ assert (
+ minimal(st.text(min_size=1), lambda s: any(c.lower() == "f" for c in
s)) == "F"
+ )
+
+
def test_bound5():
# redistribute_numeric_pairs should work for negative integers too
bounded_ints = st.lists(st.integers(-100, 0), max_size=1)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/tests/snapshots/__snapshots__/test_explain.ambr
new/hypothesis-python-6.152.6/tests/snapshots/__snapshots__/test_explain.ambr
---
old/hypothesis-python-6.152.2/tests/snapshots/__snapshots__/test_explain.ambr
2026-04-24 06:26:07.000000000 +0200
+++
new/hypothesis-python-6.152.6/tests/snapshots/__snapshots__/test_explain.ambr
2026-05-11 15:12:45.000000000 +0200
@@ -112,3 +112,13 @@
)
'''
# ---
+# name: test_explain_unstable_one_of_labels
+ '''
+ Falsifying example: inner(
+ w=W([(
+ 0, # or any other generated value
+ 0,
+ )]),
+ )
+ '''
+# ---
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/hypothesis-python-6.152.2/tests/snapshots/test_explain.py
new/hypothesis-python-6.152.6/tests/snapshots/test_explain.py
--- old/hypothesis-python-6.152.2/tests/snapshots/test_explain.py
2026-04-24 06:26:07.000000000 +0200
+++ new/hypothesis-python-6.152.6/tests/snapshots/test_explain.py
2026-05-11 15:12:45.000000000 +0200
@@ -143,3 +143,29 @@
assert not outer.value
assert run_test_for_falsifying_example(inner) == snapshot
+
+
+# Regression for https://github.com/HypothesisWorks/hypothesis/issues/4708.
+# The composite reconstructs ``st.one_of(st.just(W(None)), ...)`` per call,
+# so the OneOfStrategy's label is unstable across runs. Previously this
+# tripped an assertion in the explain phase.
+class W:
+ def __init__(self, a):
+ self.a = a
+
+
[email protected]
+def _wrapped_4708(draw):
+ inner = st.one_of(st.integers(), st.tuples(st.integers(), st.integers()))
+ elements = st.tuples(inner, st.just(0))
+ structured = st.lists(elements, min_size=1, max_size=3)
+ return draw(st.one_of(st.just(W(None)), structured.map(W)))
+
+
+def test_explain_unstable_one_of_labels(snapshot):
+ @EXPLAIN_SETTINGS
+ @given(_wrapped_4708())
+ def inner(w):
+ assert not isinstance(w.a, list)
+
+ assert run_test_for_falsifying_example(inner) == snapshot
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/hypothesis-python-6.152.2/tox.ini
new/hypothesis-python-6.152.6/tox.ini
--- old/hypothesis-python-6.152.2/tox.ini 2026-04-24 06:26:07.000000000
+0200
+++ new/hypothesis-python-6.152.6/tox.ini 2026-05-11 15:12:45.000000000
+0200
@@ -1,9 +1,15 @@
[tox]
envlist =
py{310,py310,311,py311,312,313,313t,314,314t,315,315t}-{brief,full,cover,rest,nocover,niche,custom}
toxworkdir={env:TOX_WORK_DIR:.tox}
+# Create virtualenvs and resolve Python interpreters with uv (via tox-uv).
+requires = tox-uv>=1.35
+runner = uv-venv-runner
[testenv]
basepython={env:TOX_PYTHON_VERSION:python3}
+# Seed pip into each env: several envs below shell out to `pip install …`
+# in commands_pre / test scripts, which wouldn't otherwise find pip.
+uv_seed = true
deps =
-r../requirements/test.txt
extras =