|
| 1 | +# This file is part of Hypothesis, which may be found at |
| 2 | +# https://github.com/HypothesisWorks/hypothesis/ |
| 3 | +# |
| 4 | +# Most of this work is copyright (C) 2013-2021 David R. MacIver |
| 5 | +# ([email protected]), but it contains contributions by others. See |
| 6 | +# CONTRIBUTING.rst for a full list of people who may hold copyright, and |
| 7 | +# consult the git log if you need to determine who owns an individual |
| 8 | +# contribution. |
| 9 | +# |
| 10 | +# This Source Code Form is subject to the terms of the Mozilla Public License, |
| 11 | +# v. 2.0. If a copy of the MPL was not distributed with this file, You can |
| 12 | +# obtain one at https://mozilla.org/MPL/2.0/. |
| 13 | +# |
| 14 | +# END HEADER |
| 15 | + |
| 16 | +""" |
| 17 | +The pytest plugin for Hypothesis. |
| 18 | +
|
| 19 | +We move this from the old location at `hypothesis.extra.pytestplugin` so that it |
| 20 | +can be loaded by Pytest without importing Hypothesis. In turn, this means that |
| 21 | +Hypothesis will not load our own third-party plugins (with associated side-effects) |
| 22 | +unless and until the user explicitly runs `import hypothesis`. |
| 23 | +
|
| 24 | +See https://github.com/HypothesisWorks/hypothesis/issues/3140 for details. |
| 25 | +""" |
| 26 | + |
| 27 | +import base64 |
| 28 | +import sys |
| 29 | +from inspect import signature |
| 30 | + |
| 31 | +import pytest |
| 32 | + |
| 33 | +LOAD_PROFILE_OPTION = "--hypothesis-profile" |
| 34 | +VERBOSITY_OPTION = "--hypothesis-verbosity" |
| 35 | +PRINT_STATISTICS_OPTION = "--hypothesis-show-statistics" |
| 36 | +SEED_OPTION = "--hypothesis-seed" |
| 37 | +EXPLAIN_OPTION = "--hypothesis-explain" |
| 38 | + |
| 39 | +_VERBOSITY_NAMES = ["quiet", "normal", "verbose", "debug"] |
| 40 | +_ALL_OPTIONS = [ |
| 41 | + LOAD_PROFILE_OPTION, |
| 42 | + VERBOSITY_OPTION, |
| 43 | + PRINT_STATISTICS_OPTION, |
| 44 | + SEED_OPTION, |
| 45 | + EXPLAIN_OPTION, |
| 46 | +] |
| 47 | +_FIXTURE_MSG = """Function-scoped fixture {0!r} used by {1!r} |
| 48 | +
|
| 49 | +Function-scoped fixtures are not reset between examples generated by |
| 50 | +`@given(...)`, which is often surprising and can cause subtle test bugs. |
| 51 | +
|
| 52 | +If you were expecting the fixture to run separately for each generated example, |
| 53 | +then unfortunately you will need to find a different way to achieve your goal |
| 54 | +(e.g. using a similar context manager instead of a fixture). |
| 55 | +
|
| 56 | +If you are confident that your test will work correctly even though the |
| 57 | +fixture is not reset between generated examples, you can suppress this health |
| 58 | +check to assure Hypothesis that you understand what you are doing. |
| 59 | +""" |
| 60 | + |
| 61 | + |
| 62 | +class StoringReporter: |
| 63 | + def __init__(self, config): |
| 64 | + assert "hypothesis" in sys.modules |
| 65 | + from hypothesis.reporting import default |
| 66 | + |
| 67 | + self.report = default |
| 68 | + self.config = config |
| 69 | + self.results = [] |
| 70 | + |
| 71 | + def __call__(self, msg): |
| 72 | + if self.config.getoption("capture", "fd") == "no": |
| 73 | + self.report(msg) |
| 74 | + if not isinstance(msg, str): |
| 75 | + msg = repr(msg) |
| 76 | + self.results.append(msg) |
| 77 | + |
| 78 | + |
| 79 | +# Avoiding distutils.version.LooseVersion due to |
| 80 | +# https://github.com/HypothesisWorks/hypothesis/issues/2490 |
| 81 | +if tuple(map(int, pytest.__version__.split(".")[:2])) < (4, 6): # pragma: no cover |
| 82 | + import warnings |
| 83 | + |
| 84 | + PYTEST_TOO_OLD_MESSAGE = """ |
| 85 | + You are using pytest version %s. Hypothesis tests work with any test |
| 86 | + runner, but our pytest plugin requires pytest 4.6 or newer. |
| 87 | + Note that the pytest developers no longer support your version either! |
| 88 | + Disabling the Hypothesis pytest plugin... |
| 89 | + """ |
| 90 | + warnings.warn(PYTEST_TOO_OLD_MESSAGE % (pytest.__version__,)) |
| 91 | + |
| 92 | +else: |
| 93 | + |
| 94 | + def pytest_addoption(parser): |
| 95 | + group = parser.getgroup("hypothesis", "Hypothesis") |
| 96 | + group.addoption( |
| 97 | + LOAD_PROFILE_OPTION, |
| 98 | + action="store", |
| 99 | + help="Load in a registered hypothesis.settings profile", |
| 100 | + ) |
| 101 | + group.addoption( |
| 102 | + VERBOSITY_OPTION, |
| 103 | + action="store", |
| 104 | + choices=_VERBOSITY_NAMES, |
| 105 | + help="Override profile with verbosity setting specified", |
| 106 | + ) |
| 107 | + group.addoption( |
| 108 | + PRINT_STATISTICS_OPTION, |
| 109 | + action="store_true", |
| 110 | + help="Configure when statistics are printed", |
| 111 | + default=False, |
| 112 | + ) |
| 113 | + group.addoption( |
| 114 | + SEED_OPTION, |
| 115 | + action="store", |
| 116 | + help="Set a seed to use for all Hypothesis tests", |
| 117 | + ) |
| 118 | + group.addoption( |
| 119 | + EXPLAIN_OPTION, |
| 120 | + action="store_true", |
| 121 | + help="Enable the `explain` phase for failing Hypothesis tests", |
| 122 | + default=False, |
| 123 | + ) |
| 124 | + |
| 125 | + def _any_hypothesis_option(config): |
| 126 | + return bool(any(config.getoption(opt) for opt in _ALL_OPTIONS)) |
| 127 | + |
| 128 | + def pytest_report_header(config): |
| 129 | + if not ( |
| 130 | + config.option.verbose >= 1 |
| 131 | + or "hypothesis" in sys.modules |
| 132 | + or _any_hypothesis_option(config) |
| 133 | + ): |
| 134 | + return None |
| 135 | + |
| 136 | + from hypothesis import Verbosity, settings |
| 137 | + |
| 138 | + if config.option.verbose < 1 and settings.default.verbosity < Verbosity.verbose: |
| 139 | + return None |
| 140 | + settings_str = settings.default.show_changed() |
| 141 | + if settings_str != "": |
| 142 | + settings_str = f" -> {settings_str}" |
| 143 | + return f"hypothesis profile {settings._current_profile!r}{settings_str}" |
| 144 | + |
| 145 | + def pytest_configure(config): |
| 146 | + config.addinivalue_line("markers", "hypothesis: Tests which use hypothesis.") |
| 147 | + if not _any_hypothesis_option(config): |
| 148 | + return |
| 149 | + from hypothesis import Phase, Verbosity, core, settings |
| 150 | + |
| 151 | + profile = config.getoption(LOAD_PROFILE_OPTION) |
| 152 | + if profile: |
| 153 | + settings.load_profile(profile) |
| 154 | + verbosity_name = config.getoption(VERBOSITY_OPTION) |
| 155 | + if verbosity_name and verbosity_name != settings.default.verbosity.name: |
| 156 | + verbosity_value = Verbosity[verbosity_name] |
| 157 | + name = f"{settings._current_profile}-with-{verbosity_name}-verbosity" |
| 158 | + # register_profile creates a new profile, exactly like the current one, |
| 159 | + # with the extra values given (in this case 'verbosity') |
| 160 | + settings.register_profile(name, verbosity=verbosity_value) |
| 161 | + settings.load_profile(name) |
| 162 | + if ( |
| 163 | + config.getoption(EXPLAIN_OPTION) |
| 164 | + and Phase.explain not in settings.default.phases |
| 165 | + ): |
| 166 | + name = f"{settings._current_profile}-with-explain-phase" |
| 167 | + phases = settings.default.phases + (Phase.explain,) |
| 168 | + settings.register_profile(name, phases=phases) |
| 169 | + settings.load_profile(name) |
| 170 | + |
| 171 | + seed = config.getoption(SEED_OPTION) |
| 172 | + if seed is not None: |
| 173 | + try: |
| 174 | + seed = int(seed) |
| 175 | + except ValueError: |
| 176 | + pass |
| 177 | + core.global_force_seed = seed |
| 178 | + |
| 179 | + @pytest.hookimpl(hookwrapper=True) |
| 180 | + def pytest_runtest_call(item): |
| 181 | + if not (hasattr(item, "obj") and "hypothesis" in sys.modules): |
| 182 | + yield |
| 183 | + return |
| 184 | + |
| 185 | + from hypothesis import core |
| 186 | + from hypothesis.internal.detection import is_hypothesis_test |
| 187 | + |
| 188 | + core.running_under_pytest = True |
| 189 | + |
| 190 | + if not is_hypothesis_test(item.obj): |
| 191 | + # If @given was not applied, check whether other hypothesis |
| 192 | + # decorators were applied, and raise an error if they were. |
| 193 | + if getattr(item.obj, "is_hypothesis_strategy_function", False): |
| 194 | + from hypothesis.errors import InvalidArgument |
| 195 | + |
| 196 | + raise InvalidArgument( |
| 197 | + f"{item.nodeid} is a function that returns a Hypothesis strategy, " |
| 198 | + "but pytest has collected it as a test function. This is useless " |
| 199 | + "as the function body will never be executed. To define a test " |
| 200 | + "function, use @given instead of @composite." |
| 201 | + ) |
| 202 | + message = "Using `@%s` on a test without `@given` is completely pointless." |
| 203 | + for name, attribute in [ |
| 204 | + ("example", "hypothesis_explicit_examples"), |
| 205 | + ("seed", "_hypothesis_internal_use_seed"), |
| 206 | + ("settings", "_hypothesis_internal_settings_applied"), |
| 207 | + ("reproduce_example", "_hypothesis_internal_use_reproduce_failure"), |
| 208 | + ]: |
| 209 | + if hasattr(item.obj, attribute): |
| 210 | + from hypothesis.errors import InvalidArgument |
| 211 | + |
| 212 | + raise InvalidArgument(message % (name,)) |
| 213 | + yield |
| 214 | + else: |
| 215 | + from hypothesis import HealthCheck, settings |
| 216 | + from hypothesis.internal.escalation import current_pytest_item |
| 217 | + from hypothesis.internal.healthcheck import fail_health_check |
| 218 | + from hypothesis.reporting import with_reporter |
| 219 | + from hypothesis.statistics import collector, describe_statistics |
| 220 | + |
| 221 | + # Retrieve the settings for this test from the test object, which |
| 222 | + # is normally a Hypothesis wrapped_test wrapper. If this doesn't |
| 223 | + # work, the test object is probably something weird |
| 224 | + # (e.g a stateful test wrapper), so we skip the function-scoped |
| 225 | + # fixture check. |
| 226 | + settings = getattr(item.obj, "_hypothesis_internal_use_settings", None) |
| 227 | + |
| 228 | + # Check for suspicious use of function-scoped fixtures, but only |
| 229 | + # if the corresponding health check is not suppressed. |
| 230 | + if ( |
| 231 | + settings is not None |
| 232 | + and HealthCheck.function_scoped_fixture |
| 233 | + not in settings.suppress_health_check |
| 234 | + ): |
| 235 | + # Warn about function-scoped fixtures, excluding autouse fixtures because |
| 236 | + # the advice is probably not actionable and the status quo seems OK... |
| 237 | + # See https://github.com/HypothesisWorks/hypothesis/issues/377 for detail. |
| 238 | + argnames = None |
| 239 | + for fx_defs in item._request._fixturemanager.getfixtureinfo( |
| 240 | + node=item, func=item.function, cls=None |
| 241 | + ).name2fixturedefs.values(): |
| 242 | + if argnames is None: |
| 243 | + argnames = frozenset(signature(item.function).parameters) |
| 244 | + for fx in fx_defs: |
| 245 | + if fx.argname in argnames: |
| 246 | + active_fx = item._request._get_active_fixturedef(fx.argname) |
| 247 | + if active_fx.scope == "function": |
| 248 | + fail_health_check( |
| 249 | + settings, |
| 250 | + _FIXTURE_MSG.format(fx.argname, item.nodeid), |
| 251 | + HealthCheck.function_scoped_fixture, |
| 252 | + ) |
| 253 | + |
| 254 | + if item.get_closest_marker("parametrize") is not None: |
| 255 | + # Give every parametrized test invocation a unique database key |
| 256 | + key = item.nodeid.encode() |
| 257 | + item.obj.hypothesis.inner_test._hypothesis_internal_add_digest = key |
| 258 | + |
| 259 | + store = StoringReporter(item.config) |
| 260 | + |
| 261 | + def note_statistics(stats): |
| 262 | + stats["nodeid"] = item.nodeid |
| 263 | + item.hypothesis_statistics = base64.b64encode( |
| 264 | + describe_statistics(stats).encode() |
| 265 | + ).decode() |
| 266 | + |
| 267 | + with collector.with_value(note_statistics): |
| 268 | + with with_reporter(store): |
| 269 | + with current_pytest_item.with_value(item): |
| 270 | + yield |
| 271 | + if store.results: |
| 272 | + item.hypothesis_report_information = list(store.results) |
| 273 | + |
| 274 | + @pytest.hookimpl(hookwrapper=True) |
| 275 | + def pytest_runtest_makereport(item, call): |
| 276 | + report = (yield).get_result() |
| 277 | + if hasattr(item, "hypothesis_report_information"): |
| 278 | + report.sections.append( |
| 279 | + ("Hypothesis", "\n".join(item.hypothesis_report_information)) |
| 280 | + ) |
| 281 | + if hasattr(item, "hypothesis_statistics") and report.when == "teardown": |
| 282 | + name = "hypothesis-statistics-" + item.nodeid |
| 283 | + try: |
| 284 | + item.config._xml.add_global_property(name, item.hypothesis_statistics) |
| 285 | + except AttributeError: |
| 286 | + # --junitxml not passed, or Pytest 4.5 (before add_global_property) |
| 287 | + # We'll fail xunit2 xml schema checks, upgrade pytest if you care. |
| 288 | + report.user_properties.append((name, item.hypothesis_statistics)) |
| 289 | + # If there's an HTML report, include our summary stats for each test |
| 290 | + stats = base64.b64decode(item.hypothesis_statistics.encode()).decode() |
| 291 | + pytest_html = item.config.pluginmanager.getplugin("html") |
| 292 | + if pytest_html is not None: # pragma: no cover |
| 293 | + report.extra = getattr(report, "extra", []) + [ |
| 294 | + pytest_html.extras.text(stats, name="Hypothesis stats") |
| 295 | + ] |
| 296 | + |
| 297 | + def pytest_terminal_summary(terminalreporter): |
| 298 | + if not terminalreporter.config.getoption(PRINT_STATISTICS_OPTION): |
| 299 | + return |
| 300 | + terminalreporter.section("Hypothesis Statistics") |
| 301 | + |
| 302 | + def report(properties): |
| 303 | + for name, value in properties: |
| 304 | + if name.startswith("hypothesis-statistics-"): |
| 305 | + if hasattr(value, "uniobj"): |
| 306 | + # Under old versions of pytest, `value` was a `py.xml.raw` |
| 307 | + # rather than a string, so we get the (unicode) string off it. |
| 308 | + value = value.uniobj |
| 309 | + line = base64.b64decode(value.encode()).decode() + "\n\n" |
| 310 | + terminalreporter.write_line(line) |
| 311 | + |
| 312 | + try: |
| 313 | + global_properties = terminalreporter.config._xml.global_properties |
| 314 | + except AttributeError: |
| 315 | + # terminalreporter.stats is a dict, where the empty string appears to |
| 316 | + # always be the key for a list of _pytest.reports.TestReport objects |
| 317 | + for test_report in terminalreporter.stats.get("", []): |
| 318 | + if test_report.when == "teardown": |
| 319 | + report(test_report.user_properties) |
| 320 | + else: |
| 321 | + report(global_properties) |
| 322 | + |
| 323 | + def pytest_collection_modifyitems(items): |
| 324 | + if "hypothesis" not in sys.modules: |
| 325 | + return |
| 326 | + |
| 327 | + from hypothesis.internal.detection import is_hypothesis_test |
| 328 | + |
| 329 | + for item in items: |
| 330 | + if isinstance(item, pytest.Function) and is_hypothesis_test(item.obj): |
| 331 | + item.add_marker("hypothesis") |
| 332 | + |
| 333 | + |
| 334 | +def load(): |
| 335 | + """Required for `pluggy` to load a plugin from setuptools entrypoints.""" |
0 commit comments