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 =

Reply via email to