From 20386971226f532ec2ca04e6176f926f8e52583b Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Tue, 12 Nov 2019 13:02:27 +1100 Subject: [PATCH 1/3] Get SkipTest types without imports --- hypothesis-python/RELEASE.rst | 8 +++ hypothesis-python/src/hypothesis/core.py | 65 +++++-------------- .../tests/nocover/test_skipping.py | 4 -- 3 files changed, 24 insertions(+), 53 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..01acc0475a --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -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``). diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index aadc4a610a..53ec277d5a 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -25,6 +25,7 @@ import datetime import inspect import random as rnd_module +import sys import traceback import warnings import zlib @@ -398,59 +399,29 @@ 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. """ - 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(): """Return a tuple of exceptions meaning 'this test has failed', to catch. @@ -458,12 +429,8 @@ def failure_exceptions_to_catch(): 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) diff --git a/hypothesis-python/tests/nocover/test_skipping.py b/hypothesis-python/tests/nocover/test_skipping.py index 801077f553..34e50850bf 100644 --- a/hypothesis-python/tests/nocover/test_skipping.py +++ b/hypothesis-python/tests/nocover/test_skipping.py @@ -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() From 8da50fd5fe64666bd3a1f694a6743e42948dcfb0 Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Tue, 12 Nov 2019 14:14:36 +1100 Subject: [PATCH 2/3] Avoid importing numpy --- .../src/hypothesis/_strategies.py | 7 +-- .../hypothesis/internal/conjecture/utils.py | 9 +--- .../src/hypothesis/internal/entropy.py | 27 ++++++----- .../src/hypothesis/internal/floats.py | 45 +++++++++---------- .../tests/numpy/test_lazy_import.py | 39 ++++++++++++++++ 5 files changed, 78 insertions(+), 49 deletions(-) create mode 100644 hypothesis-python/tests/numpy/test_lazy_import.py diff --git a/hypothesis-python/src/hypothesis/_strategies.py b/hypothesis-python/src/hypothesis/_strategies.py index 0da8a6c03e..40fda560d7 100644 --- a/hypothesis-python/src/hypothesis/_strategies.py +++ b/hypothesis-python/src/hypothesis/_strategies.py @@ -126,11 +126,6 @@ except ImportError: pass -try: - import numpy -except ImportError: - numpy = None - if False: import random # noqa from types import ModuleType # noqa @@ -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" ) diff --git a/hypothesis-python/src/hypothesis/internal/conjecture/utils.py b/hypothesis-python/src/hypothesis/internal/conjecture/utils.py index ca7c5b5a0a..104a206b1f 100644 --- a/hypothesis-python/src/hypothesis/internal/conjecture/utils.py +++ b/hypothesis-python/src/hypothesis/internal/conjecture/utils.py @@ -20,6 +20,7 @@ import enum import hashlib import heapq +import sys from collections import OrderedDict from fractions import Fraction @@ -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( ( diff --git a/hypothesis-python/src/hypothesis/internal/entropy.py b/hypothesis-python/src/hypothesis/internal/entropy.py index 2e1b09bf6f..816e635826 100644 --- a/hypothesis-python/src/hypothesis/internal/entropy.py +++ b/hypothesis-python/src/hypothesis/internal/entropy.py @@ -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): @@ -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: diff --git a/hypothesis-python/src/hypothesis/internal/floats.py b/hypothesis-python/src/hypothesis/internal/floats.py index d17744b8b9..16bc43169a 100644 --- a/hypothesis-python/src/hypothesis/internal/floats.py +++ b/hypothesis-python/src/hypothesis/internal/floats.py @@ -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 = { @@ -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 + 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): diff --git a/hypothesis-python/tests/numpy/test_lazy_import.py b/hypothesis-python/tests/numpy/test_lazy_import.py new file mode 100644 index 0000000000..dc69bbf2c6 --- /dev/null +++ b/hypothesis-python/tests/numpy/test_lazy_import.py @@ -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 +# (david@drmaciver.com), 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 +""" + + +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) From 7418432fa0c6d485b418f764d2492c9b0a77649b Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Wed, 13 Nov 2019 11:06:46 +1100 Subject: [PATCH 3/3] Test non-import of test libs --- hypothesis-python/src/hypothesis/core.py | 3 +- .../tests/cover/test_lazy_import.py | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 hypothesis-python/tests/cover/test_lazy_import.py diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 53ec277d5a..3a741f1676 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -403,7 +403,8 @@ 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 """ # This is a set because nose may simply re-export unittest.SkipTest exceptions = set() diff --git a/hypothesis-python/tests/cover/test_lazy_import.py b/hypothesis-python/tests/cover/test_lazy_import.py new file mode 100644 index 0000000000..84ef8145e2 --- /dev/null +++ b/hypothesis-python/tests/cover/test_lazy_import.py @@ -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 +# (david@drmaciver.com), 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])