Skip to content

Commit a9b9596

Browse files
authored
Merge pull request #2952 from Zac-HD/fix-2951
Improve `try_issubclass`
2 parents 75f483c + 0106cfd commit a9b9596

File tree

5 files changed

+95
-13
lines changed

5 files changed

+95
-13
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
RELEASE_TYPE: patch
2+
3+
This patch ensures that registering a strategy for a subclass of a a parametrised
4+
generic type such as ``class Lines(Sequence[str]):`` will not "leak" into unrelated
5+
strategies such as ``st.from_type(Sequence[int])`` (:issue:`2951`).
6+
Unfortunately this fix requires :pep:`560`, meaning Python 3.7 or later.

hypothesis-python/src/hypothesis/internal/reflection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ def get_pretty_function_description(f):
381381
# their module as __self__. This might include c-extensions generally?
382382
if not (self is None or inspect.isclass(self) or inspect.ismodule(self)):
383383
return f"{self!r}.{name}"
384-
elif getattr(dict, name, object()) is f:
384+
elif isinstance(name, str) and getattr(dict, name, object()) is f:
385385
# special case for keys/values views in from_type() / ghostwriter output
386386
return f"dict.{name}"
387387
return name

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

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,45 @@ def type_sorting_key(t):
8888

8989

9090
def try_issubclass(thing, superclass):
91-
thing = getattr(thing, "__origin__", None) or thing
92-
superclass = getattr(superclass, "__origin__", None) or superclass
9391
try:
94-
return issubclass(thing, superclass)
92+
# In this case we're looking at two distinct classes - which might be generics.
93+
# That brings in some complications:
94+
if issubclass(
95+
getattr(thing, "__origin__", None) or thing,
96+
getattr(superclass, "__origin__", None) or superclass,
97+
):
98+
superclass_args = getattr(superclass, "__args__", None)
99+
if sys.version_info[:2] == (3, 6) or not superclass_args:
100+
# Python 3.6 doesn't have PEP-560 semantics or __orig_bases__,
101+
# so there's no point continuing; we therefore stop early.
102+
# The superclass is not generic, so we're definitely a subclass.
103+
return True
104+
# Sadly this is just some really fiddly logic to handle all the cases
105+
# of user-defined generic types, types inheriting from parametrised
106+
# generics, and so on. If you need to change this code, read PEP-560
107+
# and Hypothesis issue #2951 closely first, and good luck. The tests
108+
# will help you, I hope - good luck.
109+
thing_args = getattr(thing, "__args__", None)
110+
thing_orig_bases = getattr(thing, "__orig_bases__", None) or [None]
111+
if thing_args is None and len(thing_orig_bases) != 1: # pragma: no cover
112+
raise ResolutionFailed(
113+
f"It looks like you're trying to resolve {thing!r} which "
114+
"inherits from more than one generic type, which isn't supported. "
115+
"Try using register_type_strategy()."
116+
)
117+
thing_orig_bases_args = getattr(thing_orig_bases[0], "__args__", None)
118+
if thing_args is None and thing_orig_bases_args is not None:
119+
return len(thing_orig_bases_args) == len(superclass_args) and all(
120+
# "a==b or either is a typevar" is a hacky approximation, but it's
121+
# good enough for all the cases that I've seen so far and has the
122+
# substantial virtue of (relative) simplicity.
123+
a == b
124+
or isinstance(a, typing.TypeVar)
125+
or isinstance(b, typing.TypeVar)
126+
for a, b in zip(thing_orig_bases_args, superclass_args)
127+
)
128+
return True
129+
return False
95130
except (AttributeError, TypeError):
96131
# Some types can't be the subject or object of an instance or subclass check
97132
return False
@@ -297,8 +332,8 @@ def from_typing_type(thing):
297332
empty = ", ".join(repr(s) for s in strategies if s.is_empty)
298333
if empty or not strategies:
299334
raise ResolutionFailed(
300-
"Could not resolve %s to a strategy; consider using "
301-
"register_type_strategy" % (empty or thing,)
335+
f"Could not resolve {empty or thing} to a strategy; "
336+
"consider using register_type_strategy"
302337
)
303338
return st.one_of(strategies)
304339

hypothesis-python/tests/cover/test_lookup_py37.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ def test_resolving_standard_valuesview_as_generic(x: collections.abc.ValuesView[
238238
check(collections.abc.ValuesView, x)
239239

240240

241+
@pytest.mark.xfail # Weird interaction with fixes in PR #2952
241242
@given(x=infer)
242243
def test_resolving_standard_contextmanager_as_generic(
243244
x: contextlib.AbstractContextManager[Elem],

hypothesis-python/tests/cover/test_type_lookup.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import abc
1717
import enum
1818
import sys
19-
from typing import Callable, Generic, List, Sequence, TypeVar, Union
19+
from typing import Callable, Dict, Generic, List, Sequence, TypeVar, Union
2020

2121
import pytest
2222

@@ -26,6 +26,7 @@
2626
InvalidArgument,
2727
ResolutionFailed,
2828
)
29+
from hypothesis.internal.reflection import get_pretty_function_description
2930
from hypothesis.strategies._internal import types
3031
from hypothesis.strategies._internal.core import _from_type
3132
from hypothesis.strategies._internal.types import _global_type_lookup
@@ -232,6 +233,18 @@ def __init__(self, arg: T) -> None:
232233
self.arg = arg
233234

234235

236+
class Lines(Sequence[str]):
237+
"""Represent a sequence of text lines.
238+
239+
It turns out that resolving a class which inherits from a parametrised generic
240+
type is... tricky. See https://github.com/HypothesisWorks/hypothesis/issues/2951
241+
"""
242+
243+
244+
class SpecificDict(Dict[int, int]):
245+
pass
246+
247+
235248
def using_generic(instance: MyGeneric[T]) -> T:
236249
return instance.arg
237250

@@ -248,6 +261,26 @@ def test_generic_origin_empty():
248261
_skip_callables_mark = pytest.mark.skipif(sys.version_info[:2] < (3, 7), reason="old")
249262

250263

264+
@_skip_callables_mark
265+
def test_issue_2951_regression():
266+
lines_strat = st.builds(Lines, lines=st.lists(st.text()))
267+
with temp_registered(Lines, lines_strat):
268+
assert st.from_type(Lines) == lines_strat
269+
# Now let's test that the strategy for ``Sequence[int]`` did not
270+
# change just because we registered a strategy for ``Lines``:
271+
expected = "one_of(binary(), lists(integers()))"
272+
assert repr(st.from_type(Sequence[int])) == expected
273+
274+
275+
@_skip_callables_mark
276+
def test_issue_2951_regression_two_params():
277+
map_strat = st.builds(SpecificDict, st.dictionaries(st.integers(), st.integers()))
278+
expected = repr(st.from_type(Dict[int, int]))
279+
with temp_registered(SpecificDict, map_strat):
280+
assert st.from_type(SpecificDict) == map_strat
281+
assert expected == repr(st.from_type(Dict[int, int]))
282+
283+
251284
@pytest.mark.parametrize(
252285
"generic",
253286
(
@@ -287,13 +320,20 @@ def test_generic_origin_without_type_args(generic):
287320
pass
288321

289322

290-
def test_generic_origin_from_type():
323+
@pytest.mark.parametrize(
324+
"strat, type_",
325+
[
326+
(st.from_type, MyGeneric[T]),
327+
(st.from_type, MyGeneric[int]),
328+
(st.from_type, MyGeneric),
329+
(st.builds, using_generic),
330+
(st.builds, using_concrete_generic),
331+
],
332+
ids=get_pretty_function_description,
333+
)
334+
def test_generic_origin_from_type(strat, type_):
291335
with temp_registered(MyGeneric, st.builds(MyGeneric)):
292-
find_any(st.from_type(MyGeneric[T]))
293-
find_any(st.from_type(MyGeneric[int]))
294-
find_any(st.from_type(MyGeneric))
295-
find_any(st.builds(using_generic))
296-
find_any(st.builds(using_concrete_generic))
336+
find_any(strat(type_))
297337

298338

299339
def test_generic_origin_concrete_builds():

0 commit comments

Comments
 (0)