Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Warnings for side effects during import #3837

Merged
merged 14 commits into from
Jan 16, 2024
Merged
18 changes: 18 additions & 0 deletions hypothesis-python/src/_hypothesis_pytestplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import base64
import json
import sys
import warnings
from inspect import signature

import pytest
Expand Down Expand Up @@ -407,6 +408,23 @@ def pytest_collection_modifyitems(items):
if isinstance(item, pytest.Function) and is_hypothesis_test(item.obj):
item.add_marker("hypothesis")

def pytest_sessionstart(session):
if "hypothesis" not in sys.modules:
return

from hypothesis.configuration import (
has_sideeffect_should_warn_been_called_after_import,
)
from hypothesis.errors import HypothesisSideeffectWarning

if has_sideeffect_should_warn_been_called_after_import():
warnings.warn(
"A plugin (or conftest.py) has caused hypothesis to perform undesired work during "
"initialization, possibly causing slowdown or creation of files. To pinpoint and explain "
"the problem, execute with environment 'PYTHONWARNINGS=error HYPOTHESIS_WARN_SIDEEFFECT=1'",
HypothesisSideeffectWarning,
)

# Monkeypatch some internals to prevent applying @pytest.fixture() to a
# function which has already been decorated with @hypothesis.given().
# (the reverse case is already an explicit error in Hypothesis)
Expand Down
5 changes: 5 additions & 0 deletions hypothesis-python/src/hypothesis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
failing examples it finds.
"""


_is_importing = True # noqa


from hypothesis._settings import HealthCheck, Phase, Verbosity, settings
from hypothesis.control import (
assume,
Expand Down Expand Up @@ -54,3 +58,4 @@

run()
del run
del _is_importing
46 changes: 45 additions & 1 deletion hypothesis-python/src/hypothesis/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
# obtain one at https://mozilla.org/MPL/2.0/.

import os
import sys
import warnings
from pathlib import Path

import hypothesis
from hypothesis.errors import HypothesisSideeffectWarning

__hypothesis_home_directory_default = Path.cwd() / ".hypothesis"

__hypothesis_home_directory = None
Expand All @@ -21,11 +26,50 @@ def set_hypothesis_home_dir(directory):
__hypothesis_home_directory = None if directory is None else Path(directory)


def storage_directory(*names):
def storage_directory(*names, intent_to_write=True):
if intent_to_write and sideeffect_should_warn():
warnings.warn(
"Accessing the storage directory during import or initialization is "
"discouraged, as it may cause the .hypothesis directory to be created "
"even if hypothesis is not actually used. Typically, the fix will be "
"to defer initialization of strategies.",
HypothesisSideeffectWarning,
stacklevel=2,
)

global __hypothesis_home_directory
if not __hypothesis_home_directory:
if where := os.getenv("HYPOTHESIS_STORAGE_DIRECTORY"):
__hypothesis_home_directory = Path(where)
if not __hypothesis_home_directory:
__hypothesis_home_directory = __hypothesis_home_directory_default
return __hypothesis_home_directory.joinpath(*names)


def _sideeffect_never_warn():
return False


if os.environ.get("HYPOTHESIS_WARN_SIDEEFFECT"):

def sideeffect_should_warn():
return True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we want to always warn?

Copy link
Contributor Author

@jobh jobh Jan 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a bit of a long story, so I'll explain. Pardon the verbosity! I agree there's nothing pytest-specific about the check for plugins that we load ourselves, but pytest plugins (and conftest) are an instance of a wider check: Side effects during initialization, where initialization is not just the initial import.

We can't warn directly about side effects during post-import initialization, but we can detect them. Even at no additional cost, since we can check whether the patching has been done - which happens on the first side-effect-inducing operation after import. And then the pytest plugin (or other test runners) can quite easily check if this is the case at start of session.

Now, it can warn that it has happened, but it can't see who did it or where, at least not easily [*]. That's where this setting comes in:

    "initialization, possibly causing slowdown or creation of files. To pinpoint and explain "
    "the problem, execute with environment 'PYTHONWARNINGS=error HYPOTHESIS_WARN_SIDEEFFECT=1'",

...which will cause the warning to happen, even after import is finished, and show the relevant stack trace.

[*] Yeah, we could record the stack of the first such call, but that's more effort than it's worth IMO

Copy link
Contributor Author

@jobh jobh Jan 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[*] Yeah, we could record the stack of the first such call, but that's more effort than it's worth IMO

Hm, or maybe. I guess it's easy enough to stash the stacktrace and message. Would simplify the wording.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, there are basically two ways to go about this:

  1. Warn at the end of initialization, if we can see that a side-effectful operation happened.
  2. Warn on a side-effectful operation, if we're not yet finished initializing.

I generally prefer (2), because the stack trace from -Werror makes it really easy to tell when this is happening.

I guess the problem is how to tell whether we're in post-import initialization, but I think there we could set a global from the earliest possible pytest setup hook, and then unset it at the end? We might still miss a few cases due to the ordering of hooks between our and other pytest plugins, but I think that applies equally to (1) for side effects triggered by pytest plugins.

At that point I suppose there's still a case for (1) to warn if we detect that there was a side-effect between finishing import hypothesis and delivery of the first pytest hook... but that seems concerningly prone to false alarms (c.f. testdir.runpytest_inprocess() test changes!). Let's just skip detection in this case then?

Copy link
Contributor Author

@jobh jobh Jan 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree (2) would be best, and we could do that as soon as possible (I was thinking even plugin-module execution, not wait for the import hook). I haven't done this because I am concerned that there is a significant chance of something happening during that hole in coverage. But hey, maybe it's not that important compared to having a simpler best-effort variant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah... if there is a hole, we must check and deal with that anyway to revert the patching, so we may as well warn in that case, too. I'll follow this path then.


else:

def sideeffect_should_warn():
if hasattr(hypothesis, "_is_importing"):
return True
else:
# We are no longer importing, patch this method to always return False from now on.
global sideeffect_should_warn
sideeffect_should_warn = _sideeffect_never_warn
return False


def has_sideeffect_should_warn_been_called_after_import():
"""We warn automatically if sideeffects are induced during import.
For sideeffects during initialization but after import, e.g. in pytest
plugins, this method can be used to show a catch-all warning at
start of session."""
return sideeffect_should_warn == _sideeffect_never_warn
3 changes: 2 additions & 1 deletion hypothesis-python/src/hypothesis/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def _db_for_path(path=None):
"https://hypothesis.readthedocs.io/en/latest/settings.html#settings-profiles"
)

path = storage_directory("examples")
path = storage_directory("examples", intent_to_write=False)
if not _usable_dir(path): # pragma: no cover
warnings.warn(
"The database setting is not configured, and the default "
Expand Down Expand Up @@ -496,6 +496,7 @@ def _prepare_for_io(self) -> None:

def _initialize_db(self) -> None:
# Create the cache directory if it doesn't exist
storage_directory() # trigger warning that we suppressed earlier with intent_to_write=False
self.path.mkdir(exist_ok=True, parents=True)

# Get all artifacts
Expand Down
7 changes: 7 additions & 0 deletions hypothesis-python/src/hypothesis/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ class HypothesisDeprecationWarning(HypothesisWarning, FutureWarning):
"""


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


class Frozen(HypothesisException):
"""Raised when a mutation method has been called on a ConjectureData object
after freeze() has been called."""
Expand Down
20 changes: 16 additions & 4 deletions hypothesis-python/src/hypothesis/strategies/_internal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from hypothesis._settings import note_deprecation
from hypothesis.control import cleanup, current_build_context, note
from hypothesis.errors import (
HypothesisSideeffectWarning,
HypothesisWarning,
InvalidArgument,
ResolutionFailed,
Expand Down Expand Up @@ -2196,14 +2197,25 @@ def register_type_strategy(
f"{custom_type=} is not allowed to be registered, "
f"because there is no such thing as a runtime instance of {custom_type!r}"
)
elif not (isinstance(strategy, SearchStrategy) or callable(strategy)):
if not (isinstance(strategy, SearchStrategy) or callable(strategy)):
raise InvalidArgument(
f"{strategy=} must be a SearchStrategy, or a function that takes "
"a generic type and returns a specific SearchStrategy"
)
elif isinstance(strategy, SearchStrategy) and strategy.is_empty:
raise InvalidArgument(f"{strategy=} must not be empty")
elif types.has_type_arguments(custom_type):
if isinstance(strategy, SearchStrategy):
with warnings.catch_warnings():
warnings.simplefilter("error", HypothesisSideeffectWarning)

# Calling is_empty forces materialization of lazy strategies. If this is done at import
# time, lazy strategies will warn about it; here, we force that warning to raise to
# avoid the materialization. Ideally, we'd just check if the strategy is lazy, but the
# lazy strategy may be wrapped underneath another strategy so that's complicated.
try:
if strategy.is_empty:
raise InvalidArgument(f"{strategy=} must not be empty")
except HypothesisSideeffectWarning:
pass
if types.has_type_arguments(custom_type):
raise InvalidArgument(
f"Cannot register generic type {custom_type!r}, because it has type "
"arguments which would not be handled. Instead, register a function "
Expand Down
12 changes: 12 additions & 0 deletions hypothesis-python/src/hypothesis/strategies/_internal/lazy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@

from inspect import signature
from typing import MutableMapping
import warnings
from weakref import WeakKeyDictionary

from hypothesis.configuration import sideeffect_should_warn
from hypothesis.errors import HypothesisSideeffectWarning
from hypothesis.internal.reflection import (
convert_keyword_arguments,
convert_positional_arguments,
Expand Down Expand Up @@ -100,6 +103,15 @@ def calc_is_cacheable(self, recur):
@property
def wrapped_strategy(self):
if self.__wrapped_strategy is None:
if sideeffect_should_warn():
warnings.warn(
"Materializing lazy strategies at import or initialization time is "
"discouraged, as it may cause a slowdown even when not actively "
"using hypothesis.",
HypothesisSideeffectWarning,
stacklevel=2,
)

unwrapped_args = tuple(unwrap_strategies(s) for s in self.__args)
unwrapped_kwargs = {
k: unwrap_strategies(v) for k, v in self.__kwargs.items()
Expand Down
5 changes: 4 additions & 1 deletion hypothesis-python/tests/common/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from hypothesis import Phase, Verbosity, settings
from hypothesis._settings import not_set
from hypothesis.errors import NonInteractiveExampleWarning
from hypothesis.errors import NonInteractiveExampleWarning, HypothesisSideeffectWarning
from hypothesis.internal.coverage import IN_COVERAGE_TESTS


Expand All @@ -39,6 +39,9 @@ def run():
# User-facing warning which does not apply to our own tests
filterwarnings("ignore", category=NonInteractiveExampleWarning)

# Freely cause side effects during initialization
filterwarnings("ignore", category=HypothesisSideeffectWarning)

# We do a smoke test here before we mess around with settings.
x = settings()

Expand Down