Skip to content

Commit f3ea914

Browse files
committed
Improve and complete, with parts from Zac-HD/hypothesis/plugin-checks
1 parent 61d800e commit f3ea914

12 files changed

+182
-100
lines changed

hypothesis-python/RELEASE.rst

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
RELEASE_TYPE: minor
2+
3+
This release avoids creating a ``.hypothesis`` directory when using
4+
:func:`~hypothesis.strategies.register_type_strategy` (:issue:`3836`),
5+
and adds warnings for plugins which do so by other means or have
6+
other unintended side-effects.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# This file is part of Hypothesis, which may be found at
2+
# https://github.com/HypothesisWorks/hypothesis/
3+
#
4+
# Copyright the Hypothesis Authors.
5+
# Individual contributors are listed in AUTHORS.rst and the git log.
6+
#
7+
# This Source Code Form is subject to the terms of the Mozilla Public License,
8+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9+
# obtain one at https://mozilla.org/MPL/2.0/.
10+
11+
"""
12+
Module for globals shared between plugin(s) and the main hypothesis module, without
13+
depending on either. This file should have no imports outside of stdlib.
14+
"""
15+
16+
import os
17+
18+
19+
in_initialization = 1
20+
"""If nonzero, indicates that hypothesis is still initializing (importing or loading
21+
the test environment). `import hypothesis` will cause this number to be decremented,
22+
and the pytest plugin increments at load time, then decrements it just before the test
23+
session starts. However, this leads to a hole in coverage if another pytest plugin
24+
imports hypothesis before our plugin is loaded. HYPOTHESIS_EXTEND_INITIALIZATION may
25+
be set to pre-increment the value on behalf of _hypothesis_pytestplugin, plugging the
26+
hole."""
27+
28+
if os.environ.get("HYPOTHESIS_EXTEND_INITIALIZATION"):
29+
in_initialization += 1

hypothesis-python/src/_hypothesis_pytestplugin.py

+23-15
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121

2222
import base64
2323
import json
24+
import os
2425
import sys
2526
import warnings
2627
from inspect import signature
2728

2829
import pytest
2930

31+
import _hypothesis_globals
32+
3033
try:
3134
from _pytest.junitxml import xml_key
3235
except ImportError:
@@ -95,6 +98,19 @@ def __call__(self, msg):
9598
warnings.warn(PYTEST_TOO_OLD_MESSAGE % (pytest.__version__,), stacklevel=1)
9699

97100
else:
101+
# Restart side-effect detection as early as possible, to maximize coverage. We
102+
# need balanced increment/decrement in configure/sessionstart to support nested
103+
# pytest (e.g. runpytest_inprocess), so this early increment in effect replaces
104+
# the first one in pytest_configure.
105+
_configured = False
106+
if not os.environ.get("HYPOTHESIS_EXTEND_INITIALIZATION"):
107+
_hypothesis_globals.in_initialization += 1
108+
if "hypothesis" in sys.modules:
109+
# Some other plugin has imported hypothesis, so we'll check if there
110+
# have been undetected side-effects and warn if so.
111+
from hypothesis.configuration import notice_initialization_restarted
112+
113+
notice_initialization_restarted()
98114

99115
def pytest_addoption(parser):
100116
group = parser.getgroup("hypothesis", "Hypothesis")
@@ -148,6 +164,12 @@ def pytest_report_header(config):
148164
return f"hypothesis profile {settings._current_profile!r}{settings_str}"
149165

150166
def pytest_configure(config):
167+
global _configured
168+
# skip first increment because we pre-incremented at import time
169+
if _configured:
170+
_hypothesis_globals.in_initialization += 1
171+
_configured = True
172+
151173
config.addinivalue_line("markers", "hypothesis: Tests which use hypothesis.")
152174
if not _any_hypothesis_option(config):
153175
return
@@ -409,21 +431,7 @@ def pytest_collection_modifyitems(items):
409431
item.add_marker("hypothesis")
410432

411433
def pytest_sessionstart(session):
412-
if "hypothesis" not in sys.modules:
413-
return
414-
415-
from hypothesis.configuration import (
416-
has_sideeffect_should_warn_been_called_after_import,
417-
)
418-
from hypothesis.errors import HypothesisSideeffectWarning
419-
420-
if has_sideeffect_should_warn_been_called_after_import():
421-
warnings.warn(
422-
"A plugin (or conftest.py) has caused hypothesis to perform undesired work during "
423-
"initialization, possibly causing slowdown or creation of files. To pinpoint and explain "
424-
"the problem, execute with environment 'PYTHONWARNINGS=error HYPOTHESIS_WARN_SIDEEFFECT=1'",
425-
HypothesisSideeffectWarning,
426-
)
434+
_hypothesis_globals.in_initialization -= 1
427435

428436
# Monkeypatch some internals to prevent applying @pytest.fixture() to a
429437
# function which has already been decorated with @hypothesis.given().

hypothesis-python/src/hypothesis/__init__.py

+4-5
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@
1515
failing examples it finds.
1616
"""
1717

18-
19-
_is_importing = True # noqa
20-
21-
18+
import _hypothesis_globals
2219
from hypothesis._settings import HealthCheck, Phase, Verbosity, settings
2320
from hypothesis.control import (
2421
assume,
@@ -58,4 +55,6 @@
5855

5956
run()
6057
del run
61-
del _is_importing
58+
59+
_hypothesis_globals.in_initialization -= 1
60+
del _hypothesis_globals

hypothesis-python/src/hypothesis/configuration.py

+42-33
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import warnings
1313
from pathlib import Path
1414

15+
import _hypothesis_globals
1516
import hypothesis
1617
from hypothesis.errors import HypothesisSideeffectWarning
1718

@@ -26,14 +27,9 @@ def set_hypothesis_home_dir(directory):
2627

2728

2829
def storage_directory(*names, intent_to_write=True):
29-
if intent_to_write and sideeffect_should_warn():
30-
warnings.warn(
31-
"Accessing the storage directory during import or initialization is "
32-
"discouraged, as it may cause the .hypothesis directory to be created "
33-
"even if hypothesis is not actually used. Typically, the fix will be "
34-
"to defer initialization of strategies.",
35-
HypothesisSideeffectWarning,
36-
stacklevel=2,
30+
if intent_to_write:
31+
check_sideeffect_during_initialization(
32+
f"accessing storage for {'/'.join(names)}"
3733
)
3834

3935
global __hypothesis_home_directory
@@ -45,30 +41,43 @@ def storage_directory(*names, intent_to_write=True):
4541
return __hypothesis_home_directory.joinpath(*names)
4642

4743

48-
def _sideeffect_never_warn():
49-
return False
50-
51-
52-
if os.environ.get("HYPOTHESIS_WARN_SIDEEFFECT"):
53-
54-
def sideeffect_should_warn():
55-
return True
44+
_first_postinit_what = None
5645

57-
else:
5846

59-
def sideeffect_should_warn():
60-
if hasattr(hypothesis, "_is_importing"):
61-
return True
62-
else:
63-
# We are no longer importing, patch this method to always return False from now on.
64-
global sideeffect_should_warn
65-
sideeffect_should_warn = _sideeffect_never_warn
66-
return False
67-
68-
69-
def has_sideeffect_should_warn_been_called_after_import():
70-
"""We warn automatically if sideeffects are induced during import.
71-
For sideeffects during initialization but after import, e.g. in pytest
72-
plugins, this method can be used to show a catch-all warning at
73-
start of session."""
74-
return sideeffect_should_warn == _sideeffect_never_warn
47+
def check_sideeffect_during_initialization(what: str, extra: str = "") -> None:
48+
"""Called from locations that should not be executed during initialization, for example
49+
touching disk or materializing lazy/deferred strategies from plugins. If initialization
50+
is in progress, a warning is emitted."""
51+
global _first_postinit_what
52+
# This is not a particularly hot path, but neither is it doing productive work, so we want to
53+
# minimize the cost by returning immediately. The drawback is that we require
54+
# notice_initialization_restarted() to be called if in_initialization changes away from zero.
55+
if _first_postinit_what is not None:
56+
return
57+
elif _hypothesis_globals.in_initialization:
58+
# Note: -Werror is insufficient under pytest, as doesn't take effect until
59+
# test session start.
60+
warnings.warn(
61+
f"Slow code in plugin: avoid {what} at import time! Set PYTHONWARNINGS=error "
62+
"to get a traceback and show which plugin is responsible." + extra,
63+
HypothesisSideeffectWarning,
64+
stacklevel=3,
65+
)
66+
else:
67+
_first_postinit_what = what
68+
69+
70+
def notice_initialization_restarted(warn: bool = True) -> None:
71+
"""Reset _first_postinit_what, so that we don't think we're in post-init. Additionally, if it
72+
was set that means that there has been a sideeffect that we haven't warned about, so do that
73+
now (the warning text will be correct, and we also hint that the stacktrace can be improved).
74+
"""
75+
global _first_postinit_what
76+
if _first_postinit_what is not None:
77+
what = _first_postinit_what
78+
_first_postinit_what = None
79+
if warn:
80+
check_sideeffect_during_initialization(
81+
what,
82+
" Additionally, set HYPOTHESIS_EXTEND_INITIALIZATION=1 to pinpoint the exact location.",
83+
)

hypothesis-python/src/hypothesis/errors.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class HypothesisDeprecationWarning(HypothesisWarning, FutureWarning):
117117
"""
118118

119119

120-
class HypothesisSideeffectWarning(HypothesisDeprecationWarning):
120+
class HypothesisSideeffectWarning(HypothesisWarning):
121121
"""A warning issued by Hypothesis when it sees actions that are
122122
discouraged at import or initialization time because they have
123123
user-visible side effects.

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

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import inspect
1212

13+
from hypothesis.configuration import check_sideeffect_during_initialization
1314
from hypothesis.errors import InvalidArgument
1415
from hypothesis.internal.reflection import get_pretty_function_description
1516
from hypothesis.strategies._internal.strategies import SearchStrategy, check_strategy
@@ -27,6 +28,8 @@ def __init__(self, definition):
2728
@property
2829
def wrapped_strategy(self):
2930
if self.__wrapped_strategy is None:
31+
check_sideeffect_during_initialization(f"deferred evaluation of {self!r}")
32+
3033
if not inspect.isfunction(self.__definition):
3134
raise InvalidArgument(
3235
f"Expected definition to be a function but got {self.__definition!r} "

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

+2-9
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from typing import MutableMapping
1414
from weakref import WeakKeyDictionary
1515

16-
from hypothesis.configuration import sideeffect_should_warn
16+
from hypothesis.configuration import check_sideeffect_during_initialization
1717
from hypothesis.errors import HypothesisSideeffectWarning
1818
from hypothesis.internal.reflection import (
1919
convert_keyword_arguments,
@@ -103,14 +103,7 @@ def calc_is_cacheable(self, recur):
103103
@property
104104
def wrapped_strategy(self):
105105
if self.__wrapped_strategy is None:
106-
if sideeffect_should_warn():
107-
warnings.warn(
108-
"Materializing lazy strategies at import or initialization time is "
109-
"discouraged, as it may cause a slowdown even when not actively "
110-
"using hypothesis.",
111-
HypothesisSideeffectWarning,
112-
stacklevel=2,
113-
)
106+
check_sideeffect_during_initialization(f"lazy evaluation of {self!r}")
114107

115108
unwrapped_args = tuple(unwrap_strategies(s) for s in self.__args)
116109
unwrapped_kwargs = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# This file is part of Hypothesis, which may be found at
2+
# https://github.com/HypothesisWorks/hypothesis/
3+
#
4+
# Copyright the Hypothesis Authors.
5+
# Individual contributors are listed in AUTHORS.rst and the git log.
6+
#
7+
# This Source Code Form is subject to the terms of the Mozilla Public License,
8+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9+
# obtain one at https://mozilla.org/MPL/2.0/.
10+
11+
from pathlib import Path
12+
import warnings
13+
14+
import pytest
15+
16+
import _hypothesis_globals
17+
from hypothesis import configuration as fs
18+
from hypothesis.errors import HypothesisSideeffectWarning
19+
from hypothesis import strategies as st
20+
21+
IN_INITIALIZATION_ATTR = "in_initialization"
22+
23+
24+
@pytest.fixture
25+
def extend_initialization(monkeypatch):
26+
monkeypatch.setattr(_hypothesis_globals, IN_INITIALIZATION_ATTR, 1)
27+
fs.notice_initialization_restarted(warn=False)
28+
29+
30+
@pytest.mark.parametrize(
31+
"sideeffect_script, warning_text",
32+
[
33+
("st.integers().is_empty", "lazy evaluation"),
34+
("st.deferred(st.integers).is_empty", "deferred evaluation"),
35+
("fs.storage_directory()", "accessing storage"),
36+
],
37+
)
38+
def test_sideeffect_warning(sideeffect_script, warning_text, extend_initialization):
39+
with pytest.warns(HypothesisSideeffectWarning, match=warning_text):
40+
exec(sideeffect_script)
41+
42+
43+
def test_sideeffect_delayed_warning(monkeypatch, extend_initialization):
44+
what = "synthetic side-effect"
45+
# extend_initialization ensures we start at known clean slate (no delayed warnings).
46+
# Then: stop initialization, check a side-effect operation, and restart it.
47+
monkeypatch.setattr(_hypothesis_globals, IN_INITIALIZATION_ATTR, 0)
48+
fs.check_sideeffect_during_initialization(what)
49+
fs.check_sideeffect_during_initialization("ignored since not first")
50+
with pytest.warns(HypothesisSideeffectWarning, match=what):
51+
monkeypatch.setattr(_hypothesis_globals, IN_INITIALIZATION_ATTR, 1)
52+
fs.notice_initialization_restarted()

hypothesis-python/tests/nocover/test_baseexception.py

+2-9
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
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-
import warnings
12-
1311
import pytest
1412

1513
from hypothesis import given
16-
from hypothesis.errors import Flaky, HypothesisSideeffectWarning
14+
from hypothesis.errors import Flaky
1715
from hypothesis.strategies import composite, integers, none
1816

1917

@@ -127,11 +125,6 @@ def test_explanations(testdir, exc_name, use_composite):
127125
exception=exc_name, strategy="things()" if use_composite else "st.none()"
128126
)
129127
test_file = str(testdir.makepyfile(code))
130-
with warnings.catch_warnings():
131-
# running inprocess, side effects will be present from the beginning
132-
warnings.simplefilter("ignore", HypothesisSideeffectWarning)
133-
pytest_stdout = str(
134-
testdir.runpytest_inprocess(test_file, "--tb=native").stdout
135-
)
128+
pytest_stdout = str(testdir.runpytest_inprocess(test_file, "--tb=native").stdout)
136129
assert "x=101" in pytest_stdout
137130
assert exc_name in pytest_stdout

hypothesis-python/tests/nocover/test_scrutineer.py

+1-8
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

1111
import sys
12-
import warnings
1312

1413
import pytest
1514

16-
from hypothesis.errors import HypothesisSideeffectWarning
1715
from hypothesis.internal.compat import PYPY
1816
from hypothesis.internal.scrutineer import make_report
1917

@@ -54,12 +52,7 @@ def get_reports(file_contents, *, testdir):
5452
# multi-line report strings which we expect to see in explain-mode output.
5553
# The list length is the number of explainable bugs, usually one.
5654
test_file = str(testdir.makepyfile(file_contents))
57-
with warnings.catch_warnings():
58-
# running inprocess, side effects will be present from the beginning
59-
warnings.simplefilter("ignore", HypothesisSideeffectWarning)
60-
pytest_stdout = str(
61-
testdir.runpytest_inprocess(test_file, "--tb=native").stdout
62-
)
55+
pytest_stdout = str(testdir.runpytest_inprocess(test_file, "--tb=native").stdout)
6356

6457
explanations = {
6558
i: {(test_file, i)}

0 commit comments

Comments
 (0)