diff --git a/changelog/13807.deprecation.rst b/changelog/13807.deprecation.rst new file mode 100644 index 00000000000..59bd62214e1 --- /dev/null +++ b/changelog/13807.deprecation.rst @@ -0,0 +1,3 @@ +:meth:`monkeypatch.syspath_prepend() ` now issues a deprecation warning when the prepended path contains legacy namespace packages (those using ``pkg_resources.declare_namespace()``). +Users should migrate to native namespace packages (:pep:`420`). +See :ref:`monkeypatch-fixup-namespace-packages` for details. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 6897fb28fc1..e129dd931a9 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -15,6 +15,41 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _monkeypatch-fixup-namespace-packages: + +``monkeypatch.syspath_prepend`` with legacy namespace packages +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.0 + +When using :meth:`monkeypatch.syspath_prepend() `, +pytest automatically calls ``pkg_resources.fixup_namespace_packages()`` if ``pkg_resources`` is imported. +This is only needed for legacy namespace packages that use ``pkg_resources.declare_namespace()``. + +Legacy namespace packages are deprecated in favor of native namespace packages (:pep:`420`). +If you are using ``pkg_resources.declare_namespace()`` in your ``__init__.py`` files, +you should migrate to native namespace packages by removing the ``__init__.py`` files from your namespace packages. + +This deprecation warning will only be issued when: + +1. ``pkg_resources`` is imported, and +2. The specific path being prepended contains a declared namespace package (via ``pkg_resources.declare_namespace()``) + +To fix this warning, convert your legacy namespace packages to native namespace packages: + +**Legacy namespace package** (deprecated): + +.. code-block:: python + + # mypkg/__init__.py + __import__("pkg_resources").declare_namespace(__name__) + +**Native namespace package** (recommended): + +Simply remove the ``__init__.py`` file entirely. +Python 3.3+ natively supports namespace packages without ``__init__.py``. + + .. _sync-test-async-fixture: sync test depending on async fixture diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 24e6462506b..60540552401 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -15,6 +15,7 @@ from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestRemovedIn9Warning +from _pytest.warning_types import PytestRemovedIn10Warning from _pytest.warning_types import UnformattedWarning @@ -66,6 +67,13 @@ "See docs: https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function" ) +MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES = PytestRemovedIn10Warning( + "monkeypatch.syspath_prepend() called with pkg_resources legacy namespace packages detected.\n" + "Legacy namespace packages (using pkg_resources.declare_namespace) are deprecated.\n" + "Please use native namespace packages (PEP 420) instead.\n" + "See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages" +) + # You want to make some `__init__` or function "private". # # def my_private_function(some, args): diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 1285e571551..07cc3fc4b0f 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -8,6 +8,7 @@ from collections.abc import MutableMapping from contextlib import contextmanager import os +from pathlib import Path import re import sys from typing import Any @@ -16,6 +17,7 @@ from typing import TypeVar import warnings +from _pytest.deprecated import MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES from _pytest.fixtures import fixture from _pytest.warning_types import PytestWarning @@ -346,8 +348,26 @@ def syspath_prepend(self, path) -> None: # https://github.com/pypa/setuptools/blob/d8b901bc/docs/pkg_resources.txt#L162-L171 # this is only needed when pkg_resources was already loaded by the namespace package if "pkg_resources" in sys.modules: + import pkg_resources from pkg_resources import fixup_namespace_packages + # Only issue deprecation warning if this call would actually have an + # effect for this specific path. + if ( + hasattr(pkg_resources, "_namespace_packages") + and pkg_resources._namespace_packages + ): + path_obj = Path(str(path)) + for ns_pkg in pkg_resources._namespace_packages: + if ns_pkg is None: + continue + ns_pkg_path = path_obj / ns_pkg.replace(".", os.sep) + if ns_pkg_path.is_dir(): + warnings.warn( + MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES, stacklevel=2 + ) + break + fixup_namespace_packages(str(path)) # A call to syspathinsert() usually means that the caller wants to diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index edf3169bed5..c321439e398 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -7,8 +7,7 @@ import re import sys import textwrap - -import setuptools +import warnings from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester @@ -430,14 +429,16 @@ class A: assert A.x == 1 -@pytest.mark.filterwarnings(r"ignore:.*\bpkg_resources\b:DeprecationWarning") -@pytest.mark.skipif( - int(setuptools.__version__.split(".")[0]) >= 80, - reason="modern setuptools removing pkg_resources", +@pytest.mark.filterwarnings( + r"ignore:.*\bpkg_resources\b:DeprecationWarning", + r"ignore:.*\bpkg_resources\b:UserWarning", ) def test_syspath_prepend_with_namespace_packages( pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: + # Needs to be in sys.modules. + pytest.importorskip("pkg_resources") + for dirname in "hello", "world": d = pytester.mkdir(dirname) ns = d.joinpath("ns_pkg") @@ -451,7 +452,9 @@ def test_syspath_prepend_with_namespace_packages( f"def check(): return {dirname!r}", encoding="utf-8" ) + # First call should not warn - namespace package not registered yet. monkeypatch.syspath_prepend("hello") + # This registers ns_pkg as a namespace package. import ns_pkg.hello assert ns_pkg.hello.check() == "hello" @@ -460,13 +463,19 @@ def test_syspath_prepend_with_namespace_packages( import ns_pkg.world # Prepending should call fixup_namespace_packages. - monkeypatch.syspath_prepend("world") + # This call should warn - ns_pkg is now registered and "world" contains it + with pytest.warns(pytest.PytestRemovedIn10Warning, match="legacy namespace"): + monkeypatch.syspath_prepend("world") import ns_pkg.world assert ns_pkg.world.check() == "world" # Should invalidate caches via importlib.invalidate_caches. + # Should not warn for path without namespace packages. modules_tmpdir = pytester.mkdir("modules_tmpdir") - monkeypatch.syspath_prepend(str(modules_tmpdir)) + with warnings.catch_warnings(): + warnings.simplefilter("error") + monkeypatch.syspath_prepend(str(modules_tmpdir)) + modules_tmpdir.joinpath("main_app.py").write_text("app = True", encoding="utf-8") from main_app import app # noqa: F401