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

Avoid being the first to import large packages #2204

Merged
merged 3 commits into from
Nov 20, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
RELEASE_TYPE: patch

This patch avoids importing test runners such as :pypi`pytest`, :pypi`unittest2`,
or :pypi`nose` solely to access their special "skip test" exception types -
if the module is not in ``sys.modules``, the exception can't be raised anyway.

This fixes a problem where importing an otherwise unused module could cause
spurious errors due to import-time side effects (and possibly ``-Werror``).
7 changes: 1 addition & 6 deletions hypothesis-python/src/hypothesis/_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,6 @@
except ImportError:
pass

try:
import numpy
except ImportError:
numpy = None

if False:
import random # noqa
from types import ModuleType # noqa
Expand Down Expand Up @@ -485,7 +480,7 @@ def floats(
"Got width=%r, but the only valid values are the integers 16, "
"32, and 64." % (width,)
)
if width == 16 and sys.version_info[:2] < (3, 6) and numpy is None:
if width == 16 and sys.version_info[:2] < (3, 6) and "numpy" not in sys.modules:
raise InvalidArgument( # pragma: no cover
"width=16 requires either Numpy, or Python >= 3.6"
)
Expand Down
68 changes: 18 additions & 50 deletions hypothesis-python/src/hypothesis/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import datetime
import inspect
import random as rnd_module
import sys
import traceback
import warnings
import zlib
Expand Down Expand Up @@ -398,72 +399,39 @@ def process_arguments_to_given(
return arguments, kwargs, test_runner, search_strategy


def run_once(fn):
"""Wraps a no-args function so that its outcome is cached.
We use this for calculating various lists of exceptions
the first time we use them."""
result = [None]

def run():
if result[0] is None:
result[0] = fn()
assert result[0] is not None
return result[0]

run.__name__ = fn.__name__
return run


@run_once
def skip_exceptions_to_reraise():
"""Return a tuple of exceptions meaning 'skip this test', to re-raise.

This is intended to cover most common test runners; if you would
like another to be added please open an issue or pull request.
like another to be added please open an issue or pull request adding
it to this function and to tests/cover/test_lazy_import.py
"""
import unittest

# This is a set because nose may simply re-export unittest.SkipTest
exceptions = {unittest.SkipTest}

try: # pragma: no cover
from unittest2 import SkipTest

exceptions.add(SkipTest)
except ImportError:
pass

try: # pragma: no cover
from pytest.runner import Skipped

exceptions.add(Skipped)
except ImportError:
pass

try: # pragma: no cover
from nose import SkipTest as NoseSkipTest

exceptions.add(NoseSkipTest)
except ImportError:
pass

exceptions = set()
# We use this sys.modules trick to avoid importing libraries -
# you can't be an instance of a type from an unimported module!
# This is fast enough that we don't need to cache the result,
# and more importantly it avoids possible side-effects :-)
if "unittest" in sys.modules:
exceptions.add(sys.modules["unittest"].SkipTest)
if "unittest2" in sys.modules: # pragma: no cover
exceptions.add(sys.modules["unittest2"].SkipTest)
if "nose" in sys.modules: # pragma: no cover
exceptions.add(sys.modules["nose"].SkipTest)
if "_pytest" in sys.modules: # pragma: no branch
exceptions.add(sys.modules["_pytest"].outcomes.Skipped)
return tuple(sorted(exceptions, key=str))


@run_once
def failure_exceptions_to_catch():
Copy link
Member

Choose a reason for hiding this comment

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

I am not wild about running these methods on every exception raised - given that that raising exceptions is the main thing Hypothesis tests do it's pretty hot path, and doing all this conditional querying as to whether stuff is already in sys.modules is technically fine but looks pretty weird and results in all of these coverage pragmas.

How about instead catching all exceptions and checking whether the current exception is one to reraise based on a string version of its name? e.g. something like:

from hypothesis.internal.compat import qualname

SKIP_EXCEPTIONS = frozenset({
    'unittest.SkipTest', 
    'unittest2.SkipTest',
    'nose.SkipTest',
    '_pytest.outcomes.Skipped', 
})

...

except BaseException as e:
    if qualname(e) in SKIP_EXCEPTIONS or (
        not isinstance(e, Exception) and qualname(e) != '_pytest.outcomes.Failed'
    ):
        raise
    # normal handling logic continues here
    

Copy link
Member Author

Choose a reason for hiding this comment

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

That really doesn't look better to me! The sys.modules checks are unusual, but the whole function is fast - we have four dict lookups, and up to five attribute accesses. The slowest part is sorting the types by str!

And we already had those coverage pragmas, spread out across more lines of code.

"""Return a tuple of exceptions meaning 'this test has failed', to catch.

This is intended to cover most common test runners; if you would
like another to be added please open an issue or pull request.
"""
exceptions = [Exception]
try: # pragma: no cover
from _pytest.outcomes import Failed

exceptions.append(Failed)
except ImportError:
pass
if "_pytest" in sys.modules: # pragma: no branch
exceptions.append(sys.modules["_pytest"].outcomes.Failed)
return tuple(exceptions)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import enum
import hashlib
import heapq
import sys
from collections import OrderedDict
from fractions import Fraction

Expand Down Expand Up @@ -114,14 +115,8 @@ def integer_range(data, lower, upper, center=None):
return int(result)


try:
from numpy import ndarray
except ImportError: # pragma: no cover
ndarray = ()


def check_sample(values, strategy_name):
if isinstance(values, ndarray):
if "numpy" in sys.modules and isinstance(values, sys.modules["numpy"].ndarray):
if values.ndim != 1:
raise InvalidArgument(
(
Expand Down
27 changes: 15 additions & 12 deletions hypothesis-python/src/hypothesis/internal/entropy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,24 @@

import contextlib
import random
import sys

from hypothesis.errors import InvalidArgument
from hypothesis.internal.compat import integer_types

RANDOMS_TO_MANAGE = [random] # type: list

try:
import numpy.random as npr
except ImportError:
pass
else:

class NumpyRandomWrapper(object):
"""A shim to remove those darn underscores."""
class NumpyRandomWrapper(object):
def __init__(self):
assert "numpy" in sys.modules
# This class provides a shim that matches the numpy to stdlib random,
# and lets us avoid importing Numpy until it's already in use.
import numpy.random

seed = npr.seed
getstate = npr.get_state
setstate = npr.set_state

RANDOMS_TO_MANAGE.append(NumpyRandomWrapper)
self.seed = numpy.random.seed
self.getstate = numpy.random.get_state
self.setstate = numpy.random.set_state


def register_random(r):
Expand Down Expand Up @@ -74,6 +72,11 @@ def get_seeder_and_restorer(seed=0):
assert isinstance(seed, integer_types) and 0 <= seed < 2 ** 32
states = [] # type: list

if "numpy" in sys.modules and not any(
isinstance(x, NumpyRandomWrapper) for x in RANDOMS_TO_MANAGE
):
RANDOMS_TO_MANAGE.append(NumpyRandomWrapper())

def seed_all():
assert not states
for r in RANDOMS_TO_MANAGE:
Expand Down
45 changes: 21 additions & 24 deletions hypothesis-python/src/hypothesis/internal/floats.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,6 @@
struct_unpack,
)

try:
import numpy
except (ImportError, TypeError): # pragma: no cover
# We catch TypeError because that can be raised if Numpy is installed on
# PyPy for Python 2.7; and we only need a workaround until 2020-01-01.
numpy = None


# Format codes for (int, float) sized types, used for byte-wise casts.
# See https://docs.python.org/3/library/struct.html#format-characters
STRUCT_FORMATS = {
Expand All @@ -46,25 +38,30 @@
# There are two versions of this: the one that uses Numpy to support Python
# 3.5 and earlier, and the elegant one for new versions. We use the new
# one if Numpy is unavailable too, because it's slightly faster in all cases.
if numpy and not CAN_PACK_HALF_FLOAT: # pragma: no cover

def reinterpret_bits(x, from_, to):
if from_ == b"!e":
arr = numpy.array([x], dtype=">f2")
if numpy.isfinite(x) and not numpy.isfinite(arr[0]):
quiet_raise(OverflowError("%r too large for float16" % (x,)))
buf = arr.tobytes()
else:
buf = struct_pack(from_, x)
if to == b"!e":
return float(numpy.frombuffer(buf, dtype=">f2")[0])
return struct_unpack(to, buf)[0]
def reinterpret_bits(x, from_, to):
return struct_unpack(to, struct_pack(from_, x))[0]


else:
if not CAN_PACK_HALF_FLOAT: # pragma: no cover
try:
import numpy
Copy link
Member

Choose a reason for hiding this comment

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

Do we not care about non-lazily importing numpy in this case? I guess it only matters until January.

Copy link
Member Author

Choose a reason for hiding this comment

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

Also for Python 3.5, so until September!

But basically no, I don't care about this case - pypistats show very few people still using 3.5. A lazy import here would be pretty awful code, impact the floats() check too, and we'd be taking it out in 10 months anyway. I'll add it if someone asks, though.

except (ImportError, TypeError):
# We catch TypeError because that can be raised if Numpy is installed on
# PyPy for Python 2.7; and we only need a workaround until 2020-01-01.
pass
else:

def reinterpret_bits(x, from_, to):
return struct_unpack(to, struct_pack(from_, x))[0]
def reinterpret_bits(x, from_, to): # pylint: disable=function-redefined
if from_ == b"!e":
arr = numpy.array([x], dtype=">f2")
if numpy.isfinite(x) and not numpy.isfinite(arr[0]):
quiet_raise(OverflowError("%r too large for float16" % (x,)))
buf = arr.tobytes()
else:
buf = struct_pack(from_, x)
if to == b"!e":
return float(numpy.frombuffer(buf, dtype=">f2")[0])
return struct_unpack(to, buf)[0]


def float_of(x, width):
Expand Down
57 changes: 57 additions & 0 deletions hypothesis-python/tests/cover/test_lazy_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# coding=utf-8
#
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Most of this work is copyright (C) 2013-2019 David R. MacIver
# ([email protected]), but it contains contributions by others. See
# CONTRIBUTING.rst for a full list of people who may hold copyright, and
# consult the git log if you need to determine who owns an individual
# contribution.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.
#
# END HEADER

from __future__ import absolute_import, division, print_function

import subprocess
import sys

SHOULD_NOT_IMPORT_TEST_RUNNERS = """
import sys
import unittest
from hypothesis import given, strategies as st

class TestDoesNotImportRunners(unittest.TestCase):
strat = st.integers() | st.floats() | st.sampled_from(["a", "b"])

@given(strat)
def test_does_not_import_unittest2(self, x):
assert "unittest2" not in sys.modules

@given(strat)
def test_does_not_import_nose(self, x):
assert "nose" not in sys.modules

@given(strat)
def test_does_not_import_pytest(self, x):
assert "pytest" not in sys.modules

if __name__ == '__main__':
unittest.main()
"""


def test_hypothesis_does_not_import_test_runners(tmp_path):
# We obviously can't use pytest to check that pytest is not imported,
# so for consistency we use unittest for all three non-stdlib test runners.
# It's unclear which of our dependencies is importing unittest, but
# since I doubt it's causing any spurious failures I don't really care.
# See https://github.com/HypothesisWorks/hypothesis/pull/2204
fname = str(tmp_path / "test.py")
with open(fname, "w") as f:
f.write(SHOULD_NOT_IMPORT_TEST_RUNNERS)
subprocess.check_call([sys.executable, fname])
4 changes: 0 additions & 4 deletions hypothesis-python/tests/nocover/test_skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,3 @@ def test_to_be_skipped(self, xs):
unittest.TextTestRunner().run(suite)

assert "Falsifying example" not in o.getvalue()


def test_skipping_is_cached():
assert skip_exceptions_to_reraise() is skip_exceptions_to_reraise()
39 changes: 39 additions & 0 deletions hypothesis-python/tests/numpy/test_lazy_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# coding=utf-8
#
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Most of this work is copyright (C) 2013-2019 David R. MacIver
# ([email protected]), but it contains contributions by others. See
# CONTRIBUTING.rst for a full list of people who may hold copyright, and
# consult the git log if you need to determine who owns an individual
# contribution.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.
#
# END HEADER

from __future__ import absolute_import, division, print_function

from hypothesis.internal.compat import CAN_PACK_HALF_FLOAT

SHOULD_NOT_IMPORT_NUMPY = """
import sys
from hypothesis import given, strategies as st

@given(st.integers() | st.floats() | st.sampled_from(["a", "b"]))
def test_no_numpy_import(x):
assert "numpy" not in sys.modules
Zac-HD marked this conversation as resolved.
Show resolved Hide resolved
"""


def test_hypothesis_is_not_the_first_to_import_numpy(testdir):
result = testdir.runpytest(testdir.makepyfile(SHOULD_NOT_IMPORT_NUMPY))
# OK, we import numpy on Python < 3.6 to get 16-bit float support.
# But otherwise we only import it if the user did so first.
if CAN_PACK_HALF_FLOAT:
result.assert_outcomes(passed=1, failed=0)
else:
result.assert_outcomes(passed=0, failed=1)