From 5c4622a742c52187e663054ff3e8421d17d6e8eb Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Thu, 6 Jul 2023 14:10:11 +0200 Subject: [PATCH 01/14] Failing test --- hypothesis-python/tests/cover/test_lookup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hypothesis-python/tests/cover/test_lookup.py b/hypothesis-python/tests/cover/test_lookup.py index b1dd255964..cdeaae8c34 100644 --- a/hypothesis-python/tests/cover/test_lookup.py +++ b/hypothesis-python/tests/cover/test_lookup.py @@ -671,7 +671,11 @@ def test_resolving_recursive_type_with_registered_constraint(): with temp_registered( SomeClass, st.builds(SomeClass, value=st.integers(min_value=1)) ): - find_any(st.from_type(SomeClass), lambda s: s.next_node is None) + @given(s=st.from_type(SomeClass)) + def test(s): + assert isinstance(s, SomeClass) + + test() def test_resolving_recursive_type_with_registered_constraint_not_none(): From 952485ec6534c75f8b87345c603ec4b2e9e4e1b3 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Thu, 6 Jul 2023 14:10:23 +0200 Subject: [PATCH 02/14] Fix ignore-warnings for threaded test --- .../tests/nocover/test_recursive.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/hypothesis-python/tests/nocover/test_recursive.py b/hypothesis-python/tests/nocover/test_recursive.py index 0495bd4a50..783cbfa31b 100644 --- a/hypothesis-python/tests/nocover/test_recursive.py +++ b/hypothesis-python/tests/nocover/test_recursive.py @@ -142,11 +142,7 @@ def test_drawing_from_recursive_strategy_is_thread_safe(): @given(data=st.data()) def test(data): try: - # We may get a warning here about not resetting recursionlimit, - # since it was changed during execution; ignore it. - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - data.draw(shared_strategy) + data.draw(shared_strategy) except Exception as exc: errors.append(exc) @@ -154,14 +150,19 @@ def test(data): original_recursionlimit = sys.getrecursionlimit() - for _ in range(4): - threads.append(threading.Thread(target=test)) + # We may get a warning here about not resetting recursionlimit, + # since it was changed during execution; ignore it. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") - for thread in threads: - thread.start() + for _ in range(4): + threads.append(threading.Thread(target=test)) - for thread in threads: - thread.join() + for thread in threads: + thread.start() + + for thread in threads: + thread.join() # Cleanup: reset the recursion limit that was (probably) not reset # automatically in the threaded test. From 9de7b9b29e67fb4bb9220603092172c383e5ec3f Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Thu, 6 Jul 2023 14:11:09 +0200 Subject: [PATCH 03/14] Fix failing test for registered recursive strategy --- .../src/hypothesis/strategies/_internal/core.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index e895505d45..fb648776ab 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -942,14 +942,13 @@ def builds( from hypothesis.strategies._internal.types import _global_type_lookup for kw, t in infer_for.items(): - if ( - getattr(t, "__module__", None) in ("builtins", "typing") - or t in _global_type_lookup - ): + if t in _global_type_lookup: kwargs[kw] = from_type(t) else: # We defer resolution of these type annotations so that the obvious - # approach to registering recursive types just works. See + # approach to registering recursive types just works. I.e., + # if we're inside `register_type_strategy(cls, builds(cls, ...))` + # and `...` contains recursion on `cls`. See # https://github.com/HypothesisWorks/hypothesis/issues/3026 kwargs[kw] = deferred(lambda t=t: from_type(t)) # type: ignore return BuildsStrategy(target, args, kwargs) From b45eb3ee20d7d577a852b7db25d94e2186fb65b6 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Thu, 6 Jul 2023 14:11:34 +0200 Subject: [PATCH 04/14] Fix recursive inference for builtins --- .../hypothesis/strategies/_internal/core.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index fb648776ab..b5a16629f0 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -17,6 +17,7 @@ import sys import typing import warnings +from contextvars import ContextVar from decimal import Context, Decimal, localcontext from fractions import Fraction from functools import lru_cache, reduce @@ -1011,7 +1012,7 @@ def everything_except(excluded_types): try: with warnings.catch_warnings(): warnings.simplefilter("error") - return _from_type(thing, []) + return _from_type(thing) except Exception: return _from_type_deferred(thing) @@ -1027,14 +1028,17 @@ def _from_type_deferred(thing: Type[Ex]) -> SearchStrategy[Ex]: if not thing_repr.startswith(module_prefix): thing_repr = module_prefix + thing_repr return LazyStrategy( - lambda thing: deferred(lambda: _from_type(thing, [])), + lambda thing: deferred(lambda: _from_type(thing)), (thing,), {}, force_repr=f"from_type({thing_repr})", ) -def _from_type(thing: Type[Ex], recurse_guard: List[Type[Ex]]) -> SearchStrategy[Ex]: +_recurse_guard = ContextVar("recurse_guard", default=[]) + + +def _from_type(thing: Type[Ex]) -> SearchStrategy[Ex]: # TODO: We would like to move this to the top level, but pending some major # refactoring it's hard to do without creating circular imports. from hypothesis.strategies._internal import types @@ -1058,11 +1062,12 @@ def as_strategy(strat_or_callable, thing, final=True): def from_type_guarded(thing): """Returns the result of producer, or ... if recursion on thing is encountered""" + recurse_guard = _recurse_guard.get() if thing in recurse_guard: raise RewindRecursive(thing) recurse_guard.append(thing) try: - return _from_type(thing, recurse_guard) + return _from_type(thing) except RewindRecursive as rr: if rr.target != thing: raise @@ -1084,11 +1089,11 @@ def from_type_guarded(thing): # resolve it so, and otherwise resolve as for the base type. if thing in types._global_type_lookup: return as_strategy(types._global_type_lookup[thing], thing) - return _from_type(thing.__supertype__, recurse_guard) + return _from_type(thing.__supertype__) # Unions are not instances of `type` - but we still want to resolve them! if types.is_a_union(thing): args = sorted(thing.__args__, key=types.type_sorting_key) - return one_of([_from_type(t, recurse_guard) for t in args]) + return one_of([_from_type(t) for t in args]) # We also have a special case for TypeVars. # They are represented as instances like `~T` when they come here. # We need to work with their type instead. @@ -1244,13 +1249,13 @@ def from_type_guarded(thing): subclass_strategies = nothing() for sc in subclasses: try: - subclass_strategies |= _from_type(sc, recurse_guard) + subclass_strategies |= _from_type(sc) except Exception: pass if subclass_strategies.is_empty: # We're unable to resolve subclasses now, but we might be able to later - # so we'll just go back to the mixed distribution. - return sampled_from(subclasses).flatmap(lambda t: _from_type(t, recurse_guard)) + return sampled_from(subclasses).flatmap(_from_type) return subclass_strategies From 5641bcb73405e91f263837269c65894da0adf283 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Thu, 6 Jul 2023 14:12:10 +0200 Subject: [PATCH 05/14] Consistent stack depth limit --- .../internal/conjecture/junkdrawer.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/junkdrawer.py b/hypothesis-python/src/hypothesis/internal/conjecture/junkdrawer.py index eb0301a3e8..006cb325a3 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/junkdrawer.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/junkdrawer.py @@ -275,23 +275,22 @@ def stack_depth_of_caller() -> int: class ensure_free_stackframes: - """Context manager that ensures there are at least N free stackframes. The - value of N is chosen to be the recursion limit at import time, or at least - 500. + """Context manager that ensures there are at least N free stackframes (for + a reasonable value of N). """ - initial_maxdepth: int = sys.getrecursionlimit() - def __enter__(self): cur_depth = stack_depth_of_caller() self.old_maxdepth = sys.getrecursionlimit() - self.new_maxdepth = cur_depth + max(self.initial_maxdepth, 500) - # Because we add to the recursion limit, to be good citizens we - # also add a check for unbounded recursion. The default limit - # is 1000, so this can only ever trigger if something really - # strange is happening and it's hard to imagine an + # The default CPython recursionlimit is 1000, but pytest seems to bump + # it to 3000 during test execution. Let's make it something reasonable: + self.new_maxdepth = cur_depth + 2000 + # Because we add to the recursion limit, to be good citizens we also + # add a check for unbounded recursion. The default limit is typically + # 1000/3000, so this can only ever trigger if something really strange + # is happening and it's hard to imagine an # intentionally-deeply-recursive use of this code. - assert cur_depth <= self.initial_maxdepth, ( + assert cur_depth <= 1000, ( "Hypothesis would usually add %d to the stack depth of %d here, " "but we are already much deeper than expected. Aborting now, to " "avoid extending the stack limit in an infinite loop..." From 23e7975a14ac72dc016e26e9f823a494a755eef6 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Tue, 27 Jun 2023 10:23:53 +0200 Subject: [PATCH 06/14] Fix f-string --- .../src/hypothesis/strategies/_internal/collections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/collections.py b/hypothesis-python/src/hypothesis/strategies/_internal/collections.py index 125054f4b0..b07bb4f92b 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/collections.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/collections.py @@ -234,7 +234,7 @@ def not_yet_in_unique_list(val): while elements.more(): value = filtered.do_filtered_draw(data) if value is filter_not_satisfied: - elements.reject("Aborted test because unable to satisfy {filtered!r}") + elements.reject(f"Aborted test because unable to satisfy {filtered!r}") else: for key, seen in zip(self.keys, seen_sets): seen.add(key(value)) From ff3390e57913d9cf96116cb481a8cd45eb98fd1c Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Tue, 27 Jun 2023 10:26:39 +0200 Subject: [PATCH 07/14] Improve extract_lambda_source --- hypothesis-python/src/hypothesis/internal/reflection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index ab1d9ca730..9134c52afd 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -335,10 +335,10 @@ def extract_lambda_source(f): break except SyntaxError: continue - if tree is None and source.startswith("@"): - # This will always eventually find a valid expression because - # the decorator must be a valid Python function call, so will - # eventually be syntactically valid and break out of the loop. + if tree is None and source and source[0] in ["@", "."]: + # This will always eventually find a valid expression because the + # decorator or chained operator must be a valid Python function call, + # so will eventually be syntactically valid and break out of the loop. # Thus, this loop can never terminate normally. for i in range(len(source) + 1): p = source[1:i] From 5dd6c2cc2f434474adb02557382a594fdeb862bf Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Tue, 27 Jun 2023 12:21:34 +0200 Subject: [PATCH 08/14] Remove pragma that is not needed on python 3.10+ --- .../src/hypothesis/internal/conjecture/utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/utils.py b/hypothesis-python/src/hypothesis/internal/conjecture/utils.py index a951879899..7402bb47d3 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/utils.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/utils.py @@ -279,11 +279,7 @@ def biased_coin( # becomes i > falsey. result = i > falsey - if i > 1: # pragma: no branch - # Thanks to bytecode optimisations on CPython >= 3.7 and PyPy - # (see https://bugs.python.org/issue2506), coverage incorrectly - # thinks that this condition is always true. You can trivially - # check by adding `else: assert False` and running the tests. + if i > 1: data.draw_bits(bits, forced=int(result)) break data.stop_example() From 106f1ca83dc45be3a71f1823e643d2f053f445c7 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Thu, 6 Jul 2023 14:31:27 +0200 Subject: [PATCH 09/14] Formatting fix --- hypothesis-python/tests/cover/test_lookup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hypothesis-python/tests/cover/test_lookup.py b/hypothesis-python/tests/cover/test_lookup.py index cdeaae8c34..8cf045ff76 100644 --- a/hypothesis-python/tests/cover/test_lookup.py +++ b/hypothesis-python/tests/cover/test_lookup.py @@ -671,6 +671,7 @@ def test_resolving_recursive_type_with_registered_constraint(): with temp_registered( SomeClass, st.builds(SomeClass, value=st.integers(min_value=1)) ): + @given(s=st.from_type(SomeClass)) def test(s): assert isinstance(s, SomeClass) From 80f8ae1154ca46d985b0ffa46295022df85bd0af Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Fri, 7 Jul 2023 00:12:51 +0200 Subject: [PATCH 10/14] Test failures +style +RELEASE.rst --- hypothesis-python/RELEASE.rst | 5 +++++ .../src/hypothesis/internal/reflection.py | 2 +- .../hypothesis/strategies/_internal/core.py | 19 +++++++++++++------ .../hypothesis/strategies/_internal/lazy.py | 6 +++++- 4 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..d8234f39fb --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,5 @@ +RELEASE_TYPE: patch + +Fixes some lingering issues with inference of recursive types +in `~hypothesis.strategies.from_type`. Closes :issue:`3525`. + diff --git a/hypothesis-python/src/hypothesis/internal/reflection.py b/hypothesis-python/src/hypothesis/internal/reflection.py index 9134c52afd..8afda719c5 100644 --- a/hypothesis-python/src/hypothesis/internal/reflection.py +++ b/hypothesis-python/src/hypothesis/internal/reflection.py @@ -335,7 +335,7 @@ def extract_lambda_source(f): break except SyntaxError: continue - if tree is None and source and source[0] in ["@", "."]: + if tree is None and source.startswith(("@", ".")): # This will always eventually find a valid expression because the # decorator or chained operator must be a valid Python function call, # so will eventually be syntactically valid and break out of the loop. diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index b5a16629f0..2cbcff7f93 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -204,10 +204,13 @@ def sampled_from( raise InvalidArgument("Cannot sample from a length-zero sequence.") if len(values) == 1: return just(values[0]) - if isinstance(elements, type) and issubclass(elements, enum.Enum): - repr_ = f"sampled_from({elements.__module__}.{elements.__name__})" - else: - repr_ = f"sampled_from({elements!r})" + try: + if isinstance(elements, type) and issubclass(elements, enum.Enum): + repr_ = f"sampled_from({elements.__module__}.{elements.__name__})" + else: + repr_ = f"sampled_from({elements!r})" + except Exception: + repr_ = None if isclass(elements) and issubclass(elements, enum.Flag): # Combinations of enum.Flag members are also members. We generate # these dynamically, because static allocation takes O(2^n) memory. @@ -1027,15 +1030,19 @@ def _from_type_deferred(thing: Type[Ex]) -> SearchStrategy[Ex]: module_prefix = f"{thing.__module__}." if not thing_repr.startswith(module_prefix): thing_repr = module_prefix + thing_repr + try: + repr_ = f"from_type({thing_repr})" + except Exception: + repr_ = None return LazyStrategy( lambda thing: deferred(lambda: _from_type(thing)), (thing,), {}, - force_repr=f"from_type({thing_repr})", + force_repr=repr_, ) -_recurse_guard = ContextVar("recurse_guard", default=[]) +_recurse_guard: ContextVar = ContextVar("recurse_guard", default=[]) def _from_type(thing: Type[Ex]) -> SearchStrategy[Ex]: diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/lazy.py b/hypothesis-python/src/hypothesis/strategies/_internal/lazy.py index 18292c044c..5d9ad21529 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/lazy.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/lazy.py @@ -117,12 +117,16 @@ def wrapped_strategy(self): return self.__wrapped_strategy def filter(self, condition): + try: + repr_ = f"{self!r}{_repr_filter(condition)}" + except Exception: + repr_ = None return LazyStrategy( self.function, self.__args, self.__kwargs, self.__filters + (condition,), - force_repr=f"{self!r}{_repr_filter(condition)}", + force_repr=repr_, ) def do_validate(self): From 395ef47c158ab768ffbd94acdecc24fc3bcd08f6 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Fri, 7 Jul 2023 01:16:55 +0200 Subject: [PATCH 11/14] Add pragma: no cover to a couple of excepts --- hypothesis-python/src/hypothesis/strategies/_internal/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 2cbcff7f93..873cc7afed 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -209,7 +209,7 @@ def sampled_from( repr_ = f"sampled_from({elements.__module__}.{elements.__name__})" else: repr_ = f"sampled_from({elements!r})" - except Exception: + except Exception: # pragma: no cover repr_ = None if isclass(elements) and issubclass(elements, enum.Flag): # Combinations of enum.Flag members are also members. We generate @@ -1032,7 +1032,7 @@ def _from_type_deferred(thing: Type[Ex]) -> SearchStrategy[Ex]: thing_repr = module_prefix + thing_repr try: repr_ = f"from_type({thing_repr})" - except Exception: + except Exception: # pragma: no cover repr_ = None return LazyStrategy( lambda thing: deferred(lambda: _from_type(thing)), From 691b021f4217790117ef6aa5637967d546d54fce Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Mon, 10 Jul 2023 17:38:09 +0200 Subject: [PATCH 12/14] Fix ghostwriter failure caused by deferring more strategies in builds() --- hypothesis-python/src/hypothesis/extra/ghostwriter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hypothesis-python/src/hypothesis/extra/ghostwriter.py b/hypothesis-python/src/hypothesis/extra/ghostwriter.py index 9e7b4c48f6..3615940855 100644 --- a/hypothesis-python/src/hypothesis/extra/ghostwriter.py +++ b/hypothesis-python/src/hypothesis/extra/ghostwriter.py @@ -117,6 +117,7 @@ from hypothesis.provisional import domains from hypothesis.strategies._internal.collections import ListStrategy from hypothesis.strategies._internal.core import BuildsStrategy +from hypothesis.strategies._internal.deferred import DeferredStrategy from hypothesis.strategies._internal.flatmapped import FlatMapStrategy from hypothesis.strategies._internal.lazy import LazyStrategy, unwrap_strategies from hypothesis.strategies._internal.strategies import ( @@ -668,6 +669,8 @@ def _valid_syntax_repr(strategy): # Flatten and de-duplicate any one_of strategies, whether that's from resolving # a Union type or combining inputs to multiple functions. try: + if isinstance(strategy, DeferredStrategy): + strategy = strategy.wrapped_strategy if isinstance(strategy, OneOfStrategy): seen = set() elems = [] From aea7cdd7e309ba0c75c6c4b82db1a18d9073a867 Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Mon, 10 Jul 2023 17:40:15 +0200 Subject: [PATCH 13/14] Wrap the actual nicerepr call, not just the string interpolation --- .../src/hypothesis/strategies/_internal/core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 873cc7afed..ba5f94f7c6 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -1025,12 +1025,12 @@ def _from_type_deferred(thing: Type[Ex]) -> SearchStrategy[Ex]: # underlying strategy wherever possible, as a form of user education, but # would prefer to fall back to the default "from_type(...)" repr instead of # "deferred(...)" for recursive types or invalid arguments. - thing_repr = nicerepr(thing) - if hasattr(thing, "__module__"): - module_prefix = f"{thing.__module__}." - if not thing_repr.startswith(module_prefix): - thing_repr = module_prefix + thing_repr try: + thing_repr = nicerepr(thing) + if hasattr(thing, "__module__"): + module_prefix = f"{thing.__module__}." + if not thing_repr.startswith(module_prefix): + thing_repr = module_prefix + thing_repr repr_ = f"from_type({thing_repr})" except Exception: # pragma: no cover repr_ = None From e898a623ab14c835da43eee9fa66b03565c9dd6e Mon Sep 17 00:00:00 2001 From: Joachim B Haga Date: Mon, 10 Jul 2023 19:25:01 +0200 Subject: [PATCH 14/14] Fix (un-share) contextvar default value --- .../src/hypothesis/strategies/_internal/core.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index ba5f94f7c6..3a9a6d0ead 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -1042,7 +1042,7 @@ def _from_type_deferred(thing: Type[Ex]) -> SearchStrategy[Ex]: ) -_recurse_guard: ContextVar = ContextVar("recurse_guard", default=[]) +_recurse_guard: ContextVar = ContextVar("recurse_guard") def _from_type(thing: Type[Ex]) -> SearchStrategy[Ex]: @@ -1069,7 +1069,12 @@ def as_strategy(strat_or_callable, thing, final=True): def from_type_guarded(thing): """Returns the result of producer, or ... if recursion on thing is encountered""" - recurse_guard = _recurse_guard.get() + try: + recurse_guard = _recurse_guard.get() + except LookupError: + # We can't simply define the contextvar with default=[], as the + # default object would be shared across contexts + _recurse_guard.set(recurse_guard := []) if thing in recurse_guard: raise RewindRecursive(thing) recurse_guard.append(thing)