diff --git a/changelog/7305.feature.rst b/changelog/7305.feature.rst new file mode 100644 index 000000000..96b7f72ee --- /dev/null +++ b/changelog/7305.feature.rst @@ -0,0 +1 @@ +New ``required_plugins`` configuration option allows the user to specify a list of plugins required for pytest to run. An error is raised if any required plugins are not found when running pytest. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 326b3e52a..bf3d1fbbb 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -1559,6 +1559,17 @@ passed multiple times. The expected format is ``name=value``. For example:: See :ref:`change naming conventions` for more detailed examples. +.. confval:: required_plugins + + A space separated list of plugins that must be present for pytest to run. + If any one of the plugins is not found, emit an error. + + .. code-block:: ini + + [pytest] + required_plugins = pytest-html pytest-xdist + + .. confval:: testpaths diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c94ea2a93..6e26bf15c 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -951,6 +951,12 @@ class Config: self._parser.extra_info["inifile"] = self.inifile self._parser.addini("addopts", "extra command line options", "args") self._parser.addini("minversion", "minimally required pytest version") + self._parser.addini( + "required_plugins", + "plugins that must be present for pytest to run", + type="args", + default=[], + ) self._override_ini = ns.override_ini or () def _consider_importhook(self, args: Sequence[str]) -> None: @@ -1034,7 +1040,8 @@ class Config: self.known_args_namespace = ns = self._parser.parse_known_args( args, namespace=copy.copy(self.option) ) - self._validatekeys() + self._validate_plugins() + self._validate_keys() if self.known_args_namespace.confcutdir is None and self.inifile: confcutdir = py.path.local(self.inifile).dirname self.known_args_namespace.confcutdir = confcutdir @@ -1072,12 +1079,33 @@ class Config: % (self.inifile, minver, pytest.__version__,) ) - def _validatekeys(self): + def _validate_keys(self) -> None: for key in sorted(self._get_unknown_ini_keys()): - message = "Unknown config ini key: {}\n".format(key) - if self.known_args_namespace.strict_config: - fail(message, pytrace=False) - sys.stderr.write("WARNING: {}".format(message)) + self._warn_or_fail_if_strict("Unknown config ini key: {}\n".format(key)) + + def _validate_plugins(self) -> None: + required_plugins = sorted(self.getini("required_plugins")) + if not required_plugins: + return + + plugin_info = self.pluginmanager.list_plugin_distinfo() + plugin_dist_names = [dist.project_name for _, dist in plugin_info] + + missing_plugins = [] + for plugin in required_plugins: + if plugin not in plugin_dist_names: + missing_plugins.append(plugin) + + if missing_plugins: + fail( + "Missing required plugins: {}".format(", ".join(missing_plugins)), + pytrace=False, + ) + + def _warn_or_fail_if_strict(self, message: str) -> None: + if self.known_args_namespace.strict_config: + fail(message, pytrace=False) + sys.stderr.write("WARNING: {}".format(message)) def _get_unknown_ini_keys(self) -> List[str]: parser_inicfg = self._parser._inidict diff --git a/src/_pytest/main.py b/src/_pytest/main.py index a95f2f2e7..6d008bcaa 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -80,7 +80,7 @@ def pytest_addoption(parser: Parser) -> None: group._addoption( "--strict-config", action="store_true", - help="invalid ini keys for the `pytest` section of the configuration file raise errors.", + help="any warnings encountered while parsing the `pytest` section of the configuration file raise errors.", ) group._addoption( "--strict-markers", diff --git a/testing/test_config.py b/testing/test_config.py index 31dfd9fa3..a10ac41dd 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -224,6 +224,60 @@ class TestParseIni: with pytest.raises(pytest.fail.Exception, match=exception_text): testdir.runpytest("--strict-config") + @pytest.mark.parametrize( + "ini_file_text, exception_text", + [ + ( + """ + [pytest] + required_plugins = fakePlugin1 fakePlugin2 + """, + "Missing required plugins: fakePlugin1, fakePlugin2", + ), + ( + """ + [pytest] + required_plugins = a pytest-xdist z + """, + "Missing required plugins: a, z", + ), + ( + """ + [pytest] + required_plugins = a q j b c z + """, + "Missing required plugins: a, b, c, j, q, z", + ), + ( + """ + [some_other_header] + required_plugins = wont be triggered + [pytest] + minversion = 5.0.0 + """, + "", + ), + ( + """ + [pytest] + minversion = 5.0.0 + """, + "", + ), + ], + ) + def test_missing_required_plugins(self, testdir, ini_file_text, exception_text): + pytest.importorskip("xdist") + + testdir.tmpdir.join("pytest.ini").write(textwrap.dedent(ini_file_text)) + testdir.monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD") + + if exception_text: + with pytest.raises(pytest.fail.Exception, match=exception_text): + testdir.parseconfig() + else: + testdir.parseconfig() + class TestConfigCmdlineParsing: def test_parsing_again_fails(self, testdir): @@ -681,6 +735,7 @@ def test_preparse_ordering_with_setuptools(testdir, monkeypatch): class Dist: files = () + metadata = {"name": "foo"} entry_points = (EntryPoint(),) def my_dists(): @@ -711,6 +766,7 @@ def test_setuptools_importerror_issue1479(testdir, monkeypatch): class Distribution: version = "1.0" files = ("foo.txt",) + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -735,6 +791,7 @@ def test_importlib_metadata_broken_distribution(testdir, monkeypatch): class Distribution: version = "1.0" files = None + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -760,6 +817,7 @@ def test_plugin_preparse_prevents_setuptools_loading(testdir, monkeypatch, block class Distribution: version = "1.0" files = ("foo.txt",) + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) def distributions(): @@ -791,6 +849,7 @@ def test_disable_plugin_autoload(testdir, monkeypatch, parse_args, should_load): return sys.modules[self.name] class Distribution: + metadata = {"name": "foo"} entry_points = (DummyEntryPoint(),) files = ()