Skip to content

Commit c9cf93b

Browse files
authored
Merge pull request #4629 from Zac-HD/claude/fix-hypothesis-4628-9Gp2O
Fix `from_type()` to handle parameterized type aliases
2 parents 6d2588a + e3b0779 commit c9cf93b

File tree

5 files changed

+201
-3
lines changed

5 files changed

+201
-3
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
RELEASE_TYPE: patch
2+
3+
This patch fixes :func:`~hypothesis.strategies.from_type` to properly handle
4+
parameterized type aliases created with Python 3.12+'s :pep:`695` ``type``
5+
statement. For example, ``st.from_type(A[int])`` where ``type A[T] = list[T]``
6+
now correctly resolves to ``lists(integers())`` instead of raising a
7+
``TypeError`` (:issue:`4628`).

hypothesis-python/src/hypothesis/strategies/_internal/core.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,16 @@ def from_type_guarded(thing):
13481348
if strategy is not NotImplemented:
13491349
return strategy
13501350
return _from_type(thing.__value__) # type: ignore
1351+
if types.is_a_type_alias_type(origin := get_origin(thing)): # pragma: no cover
1352+
# Handle parametrized type aliases like `type A[T] = list[T]; thing = A[int]`.
1353+
# In this case, `thing` is a GenericAlias whose origin is a TypeAliasType.
1354+
#
1355+
# covered by 3.12+ tests.
1356+
if origin in types._global_type_lookup:
1357+
strategy = as_strategy(types._global_type_lookup[origin], thing)
1358+
if strategy is not NotImplemented:
1359+
return strategy
1360+
return _from_type(types.evaluate_type_alias_type(thing))
13511361
if types.is_a_union(thing):
13521362
args = sorted(thing.__args__, key=types.type_sorting_key) # type: ignore
13531363
return one_of([_from_type(t) for t in args])
@@ -1397,9 +1407,9 @@ def from_type_guarded(thing):
13971407
return strategy
13981408
elif (
13991409
isinstance(thing, GenericAlias)
1400-
and (to := get_origin(thing)) in types._global_type_lookup
1410+
and (origin := get_origin(thing)) in types._global_type_lookup
14011411
):
1402-
strategy = as_strategy(types._global_type_lookup[to], thing)
1412+
strategy = as_strategy(types._global_type_lookup[origin], thing)
14031413
if strategy is not NotImplemented:
14041414
return strategy
14051415
except TypeError: # pragma: no cover

hypothesis-python/src/hypothesis/strategies/_internal/types.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@
3535
from typing import TYPE_CHECKING, Any, NewType, get_args, get_origin
3636

3737
from hypothesis import strategies as st
38-
from hypothesis.errors import HypothesisWarning, InvalidArgument, ResolutionFailed
38+
from hypothesis.errors import (
39+
HypothesisException,
40+
HypothesisWarning,
41+
InvalidArgument,
42+
ResolutionFailed,
43+
)
3944
from hypothesis.internal.compat import PYPY, BaseExceptionGroup, ExceptionGroup
4045
from hypothesis.internal.conjecture.utils import many as conjecture_utils_many
4146
from hypothesis.internal.filtering import max_len, min_len
@@ -252,6 +257,67 @@ def try_issubclass(thing, superclass):
252257
return False
253258

254259

260+
def _evaluate_type_alias_type(thing, *, typevars): # pragma: no cover # 3.12+
261+
if isinstance(thing, typing.TypeVar):
262+
if thing not in typevars:
263+
raise ValueError(
264+
f"Cannot look up value for unbound type var {thing}. "
265+
f"Bound typevars: {typevars}"
266+
)
267+
return typevars[thing]
268+
269+
origin = get_origin(thing)
270+
if origin is None:
271+
# not a parametrized type, so nothing to substitute.
272+
return thing
273+
274+
args = get_args(thing)
275+
# we had an origin, so we must have an args
276+
# note: I'm only mostly confident this is true and there may be a subtle
277+
# violator.
278+
assert args
279+
280+
concrete_args = tuple(
281+
_evaluate_type_alias_type(arg, typevars=typevars) for arg in args
282+
)
283+
if isinstance(origin, typing.TypeAliasType):
284+
for param in origin.__type_params__:
285+
# there's no principled reason not to support these, they're just
286+
# annoying to implement.
287+
if isinstance(param, typing.TypeVarTuple):
288+
raise HypothesisException(
289+
f"Hypothesis does not yet support resolution for TypeVarTuple "
290+
f"{param} (in origin: {origin!r}). Please open an issue if "
291+
"you would like to see support for this."
292+
)
293+
if isinstance(param, typing.ParamSpec):
294+
raise HypothesisException(
295+
f"Hypothesis does not yet support resolution for ParamSpec "
296+
f"{param} (in origin: {origin!r}). Please open an issue if you "
297+
"would like to see support for this."
298+
)
299+
# this zip is non-strict to allow for e.g.
300+
# `type A[T1, T2] = list[T1]; st.from_type(A[int]).example()`,
301+
# which leaves T2 free but is still acceptable as it never references
302+
# it.
303+
#
304+
# We disallow referencing a free / unbound type var by erroring
305+
# elsewhere in this function.
306+
typevars |= dict(zip(origin.__type_params__, concrete_args, strict=False))
307+
return _evaluate_type_alias_type(origin.__value__, typevars=typevars)
308+
309+
return origin[concrete_args]
310+
311+
312+
def evaluate_type_alias_type(thing): # pragma: no cover # covered on 3.12+
313+
# this function takes a GenericAlias whose origin is a TypeAliasType,
314+
# which corresponds to `type A[T] = list[T]; thing = A[int]`, and returns
315+
# the fully-instantiated underlying type.
316+
assert isinstance(thing, GenericAlias)
317+
assert is_a_type_alias_type(get_origin(thing))
318+
return _evaluate_type_alias_type(thing, typevars={})
319+
320+
255321
def is_a_type_alias_type(thing): # pragma: no cover # covered by 3.12+ tests
256322
# TypeAliasType is new in python 3.12, through the type statement. If we're
257323
# before python 3.12 then this can't possibly by a TypeAliasType.

hypothesis-python/tests/cover/test_typealias_py312.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

11+
from collections.abc import Callable
12+
from typing import get_args, get_origin
13+
1114
import pytest
1215

1316
from hypothesis import strategies as st
17+
from hypothesis.errors import HypothesisException
18+
from hypothesis.strategies._internal.types import evaluate_type_alias_type
1419

1520
from tests.common.debug import assert_simple_property, find_any
1621
from tests.common.utils import temp_registered
@@ -39,6 +44,13 @@ def test_resolves_nested():
3944
assert_simple_property(st.from_type(Point3), lambda x: isinstance(x, int))
4045

4146

47+
def test_resolves_parametrized():
48+
type MyList = list[int]
49+
assert_simple_property(
50+
st.from_type(MyList), lambda l: all(isinstance(x, int) for x in l)
51+
)
52+
53+
4254
def test_mutually_recursive_fails():
4355
# example from
4456
# https://docs.python.org/3/library/typing.html#typing.TypeAliasType.__value__
@@ -51,6 +63,15 @@ def test_mutually_recursive_fails():
5163
find_any(st.from_type(A))
5264

5365

66+
def test_mutually_recursive_fails_parametrized():
67+
# same with parametrized types
68+
type A[T] = B[T]
69+
type B[T] = A[T]
70+
71+
with pytest.raises(RecursionError):
72+
find_any(st.from_type(A[int]))
73+
74+
5475
def test_can_register_typealias():
5576
type A = int
5677
st.register_type_strategy(A, st.just("a"))
@@ -65,3 +86,94 @@ def test_prefers_manually_registered_typealias():
6586

6687
with temp_registered(A, st.booleans()):
6788
assert_simple_property(st.from_type(A), lambda x: isinstance(x, bool))
89+
90+
91+
def test_resolves_parameterized_typealias():
92+
type A[T] = list[T]
93+
94+
assert_simple_property(st.from_type(A[int]), lambda x: isinstance(x, list))
95+
find_any(st.from_type(A[int]), lambda x: len(x) > 0)
96+
assert_simple_property(
97+
st.from_type(A[int]), lambda x: all(isinstance(i, int) for i in x)
98+
)
99+
100+
101+
def test_resolves_nested_parameterized_typealias():
102+
type Inner[T] = list[T]
103+
type Outer[T] = Inner[T]
104+
105+
assert_simple_property(st.from_type(Outer[str]), lambda x: isinstance(x, list))
106+
assert_simple_property(
107+
st.from_type(Outer[str]), lambda x: all(isinstance(i, str) for i in x)
108+
)
109+
110+
111+
def test_resolves_parameterized_typealias_with_literal_types():
112+
# Type param used in non-first position with literal types
113+
type MyDict[T] = dict[str, T]
114+
115+
assert_simple_property(st.from_type(MyDict[int]), lambda x: isinstance(x, dict))
116+
assert_simple_property(
117+
st.from_type(MyDict[int]),
118+
lambda x: all(isinstance(k, str) and isinstance(v, int) for k, v in x.items()),
119+
)
120+
121+
122+
def test_can_register_parameterized_typealias_with_unused_params():
123+
# Users can explicitly register strategies for such types using a resolver function
124+
type MyList[T1, T2] = list[T1]
125+
126+
# Register a function that resolves the type alias
127+
def resolve_mylist(thing):
128+
if get_origin(thing) is MyList:
129+
args = get_args(thing)
130+
# Use the first type argument, ignore the second
131+
return st.lists(st.from_type(args[0]))
132+
return NotImplemented
133+
134+
st.register_type_strategy(MyList, resolve_mylist)
135+
136+
assert_simple_property(
137+
st.from_type(MyList[int, float]), lambda x: isinstance(x, list)
138+
)
139+
assert_simple_property(
140+
st.from_type(MyList[int, float]), lambda x: all(isinstance(i, int) for i in x)
141+
)
142+
143+
144+
def test_typealias_evaluation():
145+
type A[T1, T2] = list[T1]
146+
assert evaluate_type_alias_type(A[int, float]) == list[int]
147+
148+
type A[T1, T2] = list[T2]
149+
assert evaluate_type_alias_type(A[float, int]) == list[int]
150+
151+
type A[K, V] = dict[V, K]
152+
assert evaluate_type_alias_type(A[str, int]) == dict[int, str]
153+
154+
type A[T] = list[list[T]]
155+
assert evaluate_type_alias_type(A[int]) == list[list[int]]
156+
157+
type Inner[T] = list[T]
158+
type Outer[T] = Inner[T]
159+
assert evaluate_type_alias_type(Outer[int]) == list[int]
160+
161+
type Bare[T] = list
162+
assert evaluate_type_alias_type(Bare[int]) == list
163+
164+
type A[T1, T2] = list[T1]
165+
assert evaluate_type_alias_type(A[int]) == list[int]
166+
167+
# tries to reference free variable
168+
type A[T1, T2] = list[T2]
169+
with pytest.raises(ValueError):
170+
evaluate_type_alias_type(A[int])
171+
172+
# (currently) unsupported type forms
173+
type A[*Ts] = tuple[*Ts]
174+
with pytest.raises(HypothesisException, match="Hypothesis does not yet support"):
175+
assert evaluate_type_alias_type(A[int, str, float]) == tuple[int, str, float]
176+
177+
type A[**P] = Callable[P, int]
178+
with pytest.raises(HypothesisException, match="Hypothesis does not yet support"):
179+
assert evaluate_type_alias_type(A[[str, float]]) == Callable[[str, float], int]

hypothesis-python/tests/crosshair/test_crosshair.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def f(n):
4646
@pytest.mark.parametrize("verbosity", list(Verbosity))
4747
def test_crosshair_works_for_all_verbosities_data(verbosity):
4848
# data draws have their own print path
49+
if verbosity == Verbosity.quiet:
50+
pytest.skip("Flaky test, pending fix")
51+
4952
@given(st.data())
5053
@settings(
5154
backend="crosshair",

0 commit comments

Comments
 (0)