Skip to content

Commit e2e5b4c

Browse files
authored
Merge pull request #3820 from vreuter/vr/warn-sampling-stategies-issue3819
Add note to TypeError when each element to `sampled_from` is a strategy
2 parents 885c6bf + 6101917 commit e2e5b4c

File tree

5 files changed

+148
-2
lines changed

5 files changed

+148
-2
lines changed

AUTHORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ their individual contributions.
181181
* `Tyler Gibbons <https://www.github.com/kavec>`_ ([email protected])
182182
* `Tyler Nickerson <https://www.github.com/nmbrgts>`_
183183
* `Vidya Rani <https://www.github.com/vidyarani-dg>`_ ([email protected])
184+
* `Vince Reuter <https://github.com/vreuter>`_ ([email protected])
184185
* `Vincent Michel <https://www.github.com/vxgmichel>`_ ([email protected])
185186
* `Viorel Pluta <https://github.com/viopl>`_ ([email protected])
186187
* `Vytautas Strimaitis <https://www.github.com/vstrimaitis>`_

hypothesis-python/RELEASE.rst

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
RELEASE_TYPE: patch
2+
3+
If a test uses :func:`~hypothesis.strategies.sampled_from` on a sequence of
4+
strategies, and raises a ``TypeError``, we now :pep:`add a note <678>` asking
5+
whether you meant to use :func:`~hypothesis.strategies.one_of`.
6+
7+
Thanks to Vince Reuter for suggesting and implementing this hint!

hypothesis-python/src/hypothesis/core.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,18 @@ def run(data):
917917
**dict(enumerate(map(to_jsonable, args))),
918918
**{k: to_jsonable(v) for k, v in kwargs.items()},
919919
}
920-
return test(*args, **kwargs)
920+
921+
try:
922+
return test(*args, **kwargs)
923+
except TypeError as e:
924+
# If we sampled from a sequence of strategies, AND failed with a
925+
# TypeError, *AND that exception mentions SearchStrategy*, add a note:
926+
if "SearchStrategy" in str(e):
927+
try:
928+
add_note(e, data._sampled_from_all_strategies_elements_message)
929+
except AttributeError:
930+
pass
931+
raise
921932

922933
# self.test_runner can include the execute_example method, or setup/teardown
923934
# _example, so it's important to get the PRNG and build context in place first.
@@ -1024,7 +1035,6 @@ def _execute_once_for_engine(self, data: ConjectureData) -> None:
10241035
# The test failed by raising an exception, so we inform the
10251036
# engine that this test run was interesting. This is the normal
10261037
# path for test runs that fail.
1027-
10281038
tb = get_trimmed_traceback()
10291039
info = data.extra_information
10301040
info._expected_traceback = format_exception(e, tb) # type: ignore

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

+16
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,22 @@ def _transform(self, element):
528528

529529
def do_draw(self, data):
530530
result = self.do_filtered_draw(data)
531+
if isinstance(result, SearchStrategy) and all(
532+
isinstance(x, SearchStrategy) for x in self.elements
533+
):
534+
max_num_strats = 3
535+
preamble = (
536+
f"(first {max_num_strats}) "
537+
if len(self.elements) > max_num_strats
538+
else ""
539+
)
540+
strat_reprs = ", ".join(
541+
map(get_pretty_function_description, self.elements[:max_num_strats])
542+
)
543+
data._sampled_from_all_strategies_elements_message = (
544+
"sample_from was given a collection of strategies: "
545+
f"{preamble}{strat_reprs}. Was one_of intended?"
546+
)
531547
if result is filter_not_satisfied:
532548
data.mark_invalid(f"Aborted test because unable to satisfy {self!r}")
533549
return result

hypothesis-python/tests/cover/test_sampled_from.py

+112
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,115 @@ class AnnotationsInsteadOfElements(enum.Enum):
190190
def test_suggests_elements_instead_of_annotations():
191191
with pytest.raises(InvalidArgument, match="Cannot sample.*annotations.*dataclass"):
192192
st.sampled_from(AnnotationsInsteadOfElements).example()
193+
194+
195+
class TestErrorNoteBehavior3819:
196+
elements = (st.booleans(), st.decimals(), st.integers(), st.text())
197+
198+
@staticmethod
199+
@given(st.data())
200+
def direct_without_error(data):
201+
data.draw(st.sampled_from((st.floats(), st.binary())))
202+
203+
@staticmethod
204+
@given(st.data())
205+
def direct_with_non_type_error(data):
206+
data.draw(st.sampled_from(st.characters(), st.floats()))
207+
raise Exception("Contains SearchStrategy, but no note addition!")
208+
209+
@staticmethod
210+
@given(st.data())
211+
def direct_with_type_error_without_substring(data):
212+
data.draw(st.sampled_from(st.booleans(), st.binary()))
213+
raise TypeError("Substring not in message!")
214+
215+
@staticmethod
216+
@given(st.data())
217+
def direct_with_type_error_with_substring_but_not_all_strategies(data):
218+
data.draw(st.sampled_from(st.booleans(), False, True))
219+
raise TypeError("Contains SearchStrategy, but no note addition!")
220+
221+
@staticmethod
222+
@given(st.data())
223+
def direct_all_strategies_with_type_error_with_substring(data):
224+
data.draw(st.sampled_from((st.dates(), st.datetimes())))
225+
raise TypeError("This message contains SearchStrategy as substring!")
226+
227+
@staticmethod
228+
@given(st.lists(st.sampled_from(elements)))
229+
def indirect_without_error(_):
230+
return
231+
232+
@staticmethod
233+
@given(st.lists(st.sampled_from(elements)))
234+
def indirect_with_non_type_error(_):
235+
raise Exception("Contains SearchStrategy, but no note addition!")
236+
237+
@staticmethod
238+
@given(st.lists(st.sampled_from(elements)))
239+
def indirect_with_type_error_without_substring(_):
240+
raise TypeError("Substring not in message!")
241+
242+
@staticmethod
243+
@given(st.lists(st.sampled_from((*elements, False, True))))
244+
def indirect_with_type_error_with_substring_but_not_all_strategies(_):
245+
raise TypeError("Contains SearchStrategy, but no note addition!")
246+
247+
@staticmethod
248+
@given(st.lists(st.sampled_from(elements), min_size=1))
249+
def indirect_all_strategies_with_type_error_with_substring(objs):
250+
raise TypeError("Contains SearchStrategy in message, trigger note!")
251+
252+
@pytest.mark.parametrize(
253+
["func_to_call", "exp_err_cls", "should_exp_msg"],
254+
[
255+
pytest.param(f.__func__, err, msg_exp, id=f.__func__.__name__)
256+
for f, err, msg_exp in [
257+
(f, TypeError, True)
258+
for f in (
259+
direct_all_strategies_with_type_error_with_substring,
260+
indirect_all_strategies_with_type_error_with_substring,
261+
)
262+
]
263+
+ [
264+
(f, TypeError, False)
265+
for f in (
266+
direct_with_type_error_without_substring,
267+
direct_with_type_error_with_substring_but_not_all_strategies,
268+
indirect_with_type_error_without_substring,
269+
indirect_with_type_error_with_substring_but_not_all_strategies,
270+
)
271+
]
272+
+ [
273+
(f, Exception, False)
274+
for f in (
275+
direct_with_non_type_error,
276+
indirect_with_non_type_error,
277+
)
278+
]
279+
+ [
280+
(f, None, False)
281+
for f in (
282+
direct_without_error,
283+
indirect_without_error,
284+
)
285+
]
286+
],
287+
)
288+
def test_error_appropriate_error_note_3819(
289+
self, func_to_call, exp_err_cls, should_exp_msg
290+
):
291+
if exp_err_cls is None:
292+
# Here we only care that no exception was raised, so nothing to assert.
293+
func_to_call()
294+
else:
295+
with pytest.raises(exp_err_cls) as err_ctx:
296+
func_to_call()
297+
notes = getattr(err_ctx.value, "__notes__", [])
298+
matching_messages = [
299+
n
300+
for n in notes
301+
if n.startswith("sample_from was given a collection of strategies")
302+
and n.endswith("Was one_of intended?")
303+
]
304+
assert len(matching_messages) == (1 if should_exp_msg else 0)

0 commit comments

Comments
 (0)