Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions changelog/13253.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New flag: :ref:`--disable-plugin-autoload <disable_plugin_autoload>` which works as an alternative to :envvar:`PYTEST_DISABLE_PLUGIN_AUTOLOAD` when setting environment variables is inconvenient; and allows setting it in config files with :confval:`addopts`.
23 changes: 22 additions & 1 deletion doc/en/how-to/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,25 @@ CI server), you can set ``PYTEST_ADDOPTS`` environment variable to

See :ref:`findpluginname` for how to obtain the name of a plugin.

.. _`builtin plugins`:
.. _`disable_plugin_autoload`:

Disabling plugins from autoloading
----------------------------------

If you want to disable plugins from loading automatically, requiring you to
Comment thread
nicoddemus marked this conversation as resolved.
Outdated
manually specify each plugin with ``-p`` or :envvar:`PYTEST_PLUGINS`, you can use ``--disable-plugin-autoload`` or :envvar:`PYTEST_DISABLE_PLUGIN_AUTOLOAD`.

.. code-block:: bash

export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
export PYTEST_PLUGINS=NAME,NAME2
pytest

.. code-block:: bash

pytest --disable-plugin-autoload -p NAME,NAME2

.. code-block:: ini

[pytest]
addopts = --disable-plugin-autoload -p NAME,NAME2
Comment thread
nicoddemus marked this conversation as resolved.
7 changes: 5 additions & 2 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1161,8 +1161,9 @@ as discussed in :ref:`temporary directory location and retention`.
.. envvar:: PYTEST_DISABLE_PLUGIN_AUTOLOAD

When set, disables plugin auto-loading through :std:doc:`entry point packaging
metadata <packaging:guides/creating-and-discovering-plugins>`. Only explicitly
specified plugins will be loaded.
metadata <packaging:guides/creating-and-discovering-plugins>`. Only plugins
explicitly specified in :envvar:`PYTEST_PLUGINS` or with ``-p`` will be loaded.
See also :ref:`--disable-plugin-autoload <disable_plugin_autoload>`.

.. envvar:: PYTEST_PLUGINS

Expand All @@ -1172,6 +1173,8 @@ Contains comma-separated list of modules that should be loaded as plugins:

export PYTEST_PLUGINS=mymodule.plugin,xdist

See also ``-p``.

.. envvar:: PYTEST_THEME

Sets a `pygment style <https://pygments.org/docs/styles/>`_ to use for the code output.
Expand Down
26 changes: 19 additions & 7 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@


if TYPE_CHECKING:
from _pytest.assertions.rewrite import AssertionRewritingHook
from _pytest.cacheprovider import Cache
from _pytest.terminal import TerminalReporter

Expand Down Expand Up @@ -1271,6 +1272,10 @@ def _consider_importhook(self, args: Sequence[str]) -> None:
"""
ns, unknown_args = self._parser.parse_known_and_unknown_args(args)
mode = getattr(ns, "assertmode", "plain")

disable_autoload = getattr(ns, "disable_plugin_autoload", False) | bool(
Comment thread
nicoddemus marked this conversation as resolved.
Outdated
os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
)
if mode == "rewrite":
import _pytest.assertion

Expand All @@ -1279,16 +1284,18 @@ def _consider_importhook(self, args: Sequence[str]) -> None:
except SystemError:
mode = "plain"
else:
self._mark_plugins_for_rewrite(hook)
self._mark_plugins_for_rewrite(hook, disable_autoload)
self._warn_about_missing_assertion(mode)

def _mark_plugins_for_rewrite(self, hook) -> None:
def _mark_plugins_for_rewrite(
self, hook: AssertionRewritingHook, disable_autoload: bool
) -> None:
"""Given an importhook, mark for rewrite any top-level
modules or packages in the distribution package for
all pytest plugins."""
self.pluginmanager.rewrite_hook = hook

if os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"):
if disable_autoload:
# We don't autoload from distribution package entry points,
# no need to continue.
return
Expand Down Expand Up @@ -1393,10 +1400,15 @@ def _preparse(self, args: list[str], addopts: bool = True) -> None:
self._consider_importhook(args)
self._configure_python_path()
self.pluginmanager.consider_preparse(args, exclude_only=False)
if not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"):
# Don't autoload from distribution package entry point. Only
# explicitly specified plugins are going to be loaded.
if (
not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
and not self.known_args_namespace.disable_plugin_autoload
):
# Autoloading from distribution package entry point has
# not been disabled.
self.pluginmanager.load_setuptools_entrypoints("pytest11")
# Otherwise only plugins explicitly specified in PYTEST_PLUGINS
# are going to be loaded.
self.pluginmanager.consider_env()

self.known_args_namespace = self._parser.parse_known_args(
Expand All @@ -1419,7 +1431,7 @@ def _preparse(self, args: list[str], addopts: bool = True) -> None:
except ConftestImportFailure as e:
if self.known_args_namespace.help or self.known_args_namespace.version:
# we don't want to prevent --help/--version to work
# so just let is pass and print a warning at the end
# so just let it pass and print a warning at the end
self.issue_config_time_warning(
PytestConfigWarning(f"could not load initial conftests: {e.path}"),
stacklevel=2,
Expand Down
9 changes: 8 additions & 1 deletion src/_pytest/helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ def pytest_addoption(parser: Parser) -> None:
metavar="name",
help="Early-load given plugin module name or entry point (multi-allowed). "
"To avoid loading of plugins, use the `no:` prefix, e.g. "
"`no:doctest`.",
"`no:doctest`. See also --disable-plugin-autoload.",
)
group.addoption(
"--disable-plugin-autoload",
action="store_true",
default=False,
help="Disable plugin auto-loading through entry point packaging metadata. "
"Only plugins explicitly specified in -p or env var PYTEST_PLUGINS will be loaded.",
)
group.addoption(
"--traceconfig",
Expand Down
51 changes: 43 additions & 8 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,36 @@ def test_foo(pytestconfig):
assert result.ret == 0

@pytest.mark.parametrize("mode", ["plain", "rewrite"])
@pytest.mark.parametrize("disable_plugin_autoload", ["env_var", "cli", ""])
@pytest.mark.parametrize("explicit_specify", ["env_var", "cli", ""])
def test_installed_plugin_rewrite(
self, pytester: Pytester, mode, monkeypatch
self,
pytester: Pytester,
mode: str,
monkeypatch: pytest.MonkeyPatch,
disable_plugin_autoload: str,
explicit_specify: str,
) -> None:
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
args = ["mainwrapper.py", "-s", f"--assert={mode}"]
if disable_plugin_autoload == "env_var":
monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
elif disable_plugin_autoload == "cli":
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
args.append("--disable-plugin-autoload")
else:
assert disable_plugin_autoload == ""
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)

name = "spamplugin"

if explicit_specify == "env_var":
monkeypatch.setenv("PYTEST_PLUGINS", name)
elif explicit_specify == "cli":
args.append("-p")
args.append(name)
else:
assert explicit_specify == ""

# Make sure the hook is installed early enough so that plugins
# installed via distribution package are rewritten.
pytester.mkdir("hampkg")
Expand Down Expand Up @@ -250,7 +276,7 @@ def check(values, value):
import pytest

class DummyEntryPoint(object):
name = 'spam'
name = 'spamplugin'
module_name = 'spam.py'
group = 'pytest11'

Expand All @@ -275,20 +301,29 @@ def test(check_first):
check_first([10, 30], 30)

def test2(check_first2):
check_first([10, 30], 30)
check_first2([10, 30], 30)
""",
}
pytester.makepyfile(**contents)
result = pytester.run(
sys.executable, "mainwrapper.py", "-s", f"--assert={mode}"
)
result = pytester.run(sys.executable, *args)
if mode == "plain":
expected = "E AssertionError"
elif mode == "rewrite":
expected = "*assert 10 == 30*"
else:
assert 0
result.stdout.fnmatch_lines([expected])

if not disable_plugin_autoload or explicit_specify:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

With all the additional logic in this test, I wonder if it would be better to have a fixture for the hampkg and split this into two distinct tests at least (autoload enabled/disabled)?

result.assert_outcomes(failed=2)
result.stdout.fnmatch_lines([expected, expected])
else:
result.assert_outcomes(errors=2)
result.stdout.fnmatch_lines(
[
"E fixture 'check_first' not found",
"E fixture 'check_first2' not found",
]
)

def test_rewrite_ast(self, pytester: Pytester) -> None:
pytester.mkdir("pkg")
Expand Down
61 changes: 49 additions & 12 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import importlib.metadata
import os
from pathlib import Path
import platform
import re
import sys
import textwrap
Expand Down Expand Up @@ -1314,14 +1315,13 @@ def distributions():
)


@pytest.mark.parametrize(
"parse_args,should_load", [(("-p", "mytestplugin"), True), ((), False)]
)
@pytest.mark.parametrize("disable_plugin_method", ["env_var", "flag", ""])
@pytest.mark.parametrize("enable_plugin_method", ["env_var", "flag", ""])
def test_disable_plugin_autoload(
pytester: Pytester,
monkeypatch: MonkeyPatch,
parse_args: tuple[str, str] | tuple[()],
should_load: bool,
enable_plugin_method: str,
disable_plugin_method: str,
) -> None:
class DummyEntryPoint:
project_name = name = "mytestplugin"
Expand All @@ -1342,23 +1342,60 @@ class PseudoPlugin:
attrs_used = []

def __getattr__(self, name):
assert name == "__loader__"
assert name in ("__loader__", "__spec__")
self.attrs_used.append(name)
return object()

def distributions():
return (Distribution(),)

monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
parse_args: list[str] = []

if disable_plugin_method == "env_var":
monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
elif disable_plugin_method == "flag":
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
parse_args.append("--disable-plugin-autoload")
else:
assert disable_plugin_method == ""
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD")

if enable_plugin_method == "env_var":
monkeypatch.setenv("PYTEST_PLUGINS", "mytestplugin")
elif enable_plugin_method == "flag":
parse_args.extend(["-p", "mytestplugin"])
else:
assert enable_plugin_method == ""

monkeypatch.setattr(importlib.metadata, "distributions", distributions)
monkeypatch.setitem(sys.modules, "mytestplugin", PseudoPlugin())
config = pytester.parseconfig(*parse_args)

has_loaded = config.pluginmanager.get_plugin("mytestplugin") is not None
assert has_loaded == should_load
if should_load:
assert PseudoPlugin.attrs_used == ["__loader__"]
else:
assert PseudoPlugin.attrs_used == []
# it should load if it's enabled, or we haven't disabled autoloading
assert has_loaded == (bool(enable_plugin_method) or not disable_plugin_method)

# The reason for the discrepancy between 'has_loaded' and __loader__ being accessed
# appears to be the monkeypatching of importlib.metadata.distributions; where
# files being empty means that _mark_plugins_for_rewrite doesn't find the plugin.
# But enable_method==flag ends up in mark_rewrite being called and __loader__
# being accessed.
assert ("__loader__" in PseudoPlugin.attrs_used) == (
has_loaded
and not (enable_plugin_method in ("env_var", "") and not disable_plugin_method)
)

# __spec__ is accessed in AssertionRewritingHook.exec_module, which would be
# eventually called if we did a full pytest run; but it's only accessed with
# enable_plugin_method=="env_var" because that will early-load it.
# Except when autoloads aren't disabled, in which case PytestPluginManager.import_plugin
# bails out before importing it.. because it knows it'll be loaded later?
# The above seems a bit weird, but I *think* it's true.
if platform.python_implementation() != "PyPy":
assert ("__spec__" in PseudoPlugin.attrs_used) == bool(
enable_plugin_method == "env_var" and disable_plugin_method
)
# __spec__ is present when testing locally on pypy, but not in CI ????


def test_plugin_loading_order(pytester: Pytester) -> None:
Expand Down