Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions news/13829.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Emit a deprecation warning when an unexpected module import is detected during
package installation.
71 changes: 71 additions & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import os
import shutil
import site
import sys
from optparse import SUPPRESS_HELP, Values
from pathlib import Path
from typing import Any

from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.requests.exceptions import InvalidProxyURL
Expand Down Expand Up @@ -43,6 +45,7 @@
InstallRequirement,
)
from pip._internal.utils.compat import WINDOWS
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.filesystem import test_writable_dir
from pip._internal.utils.logging import getLogger
from pip._internal.utils.misc import (
Expand All @@ -63,6 +66,67 @@
logger = getLogger(__name__)


_PREVENT_IMPORT_HOOK_ACTIVE = False
_MISSING_MODULES = set()

_KNOWN_POSSIBLE_IMPORTS: tuple[str, ...] = (
# Imported directly by pip:
"netrc",
"difflib",
"distutils.command.build",
# Vendored module
"pip._vendor.rich._windows_renderer",
# Imported by vendored packaging:
"_manylinux",
# Imported by standard library machinery:
"encodings.iso8859_15",
"_suggestions",
"gc",
)


def _prevent_import_hook(name: str, args: tuple[Any, ...]) -> None:
if name == "import":
if args[0] in _MISSING_MODULES:
raise ImportError(f"No module named {args[0]!r}")
deprecated(
reason=f"Unexpected import of {args[0]!r} detected during install.",
replacement=None,
gone_in="26.3",
issue=13842,
)


def _eagerly_import_modules() -> None:
"""
Eagerly import modules that may be imported later in the installation process.
"""
global _MISSING_MODULES
for module in _KNOWN_POSSIBLE_IMPORTS:
try:
__import__(module)
except ImportError:
# If the module doesn't currently exist preserve that
# information to the prevent import hook can raise an
# ImportError rather than trying to import it again.
_MISSING_MODULES.add(module)


def _prevent_further_imports() -> None:
"""
After calling this new imports will emit a warning.

First we must eagerly import possible future imports, these
are imports that are called lazily after the installation step.
"""
global _PREVENT_IMPORT_HOOK_ACTIVE
if _PREVENT_IMPORT_HOOK_ACTIVE:
return

_PREVENT_IMPORT_HOOK_ACTIVE = True
sys.addaudithook(_prevent_import_hook)


class InstallCommand(RequirementCommand):
"""
Install packages from:
Expand Down Expand Up @@ -459,6 +523,13 @@ def run(self, options: Values, args: list[str]) -> int:
if options.target_dir or options.prefix_path:
warn_script_location = False

# Prevent further imports so we don't accidentally
# import something that was just installed
try:
_eagerly_import_modules()
finally:
_prevent_further_imports()

installed = install_given_reqs(
to_install,
root=options.root_path,
Expand Down
50 changes: 50 additions & 0 deletions tests/functional/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,56 @@ def test_pip_second_command_line_interface_works(
result.did_create(initools_folder)


def test_install_warns_on_unexpected_post_install_import(
script: PipTestEnvironment,
) -> None:
"""
Test that pip emits a warning when an unexpected module import is
triggered after install_given_reqs() completes, validating that the
audit hook registered by _prevent_further_imports() catches imports that
could come from newly installed packages.
"""
wheel_path = create_basic_wheel_for_package(script, "mypackage", "1.0")
runner = script.scratch_path / "run_install.py"
runner.write_text(
textwrap.dedent(
"""\
import sys
import pip._internal.commands.install as _install_mod
_orig_get_environment = _install_mod.get_environment

def _patched_get_environment(lib_locations):
try:
import pip_unexpected_module_xyz
except ModuleNotFoundError:
pass
return _orig_get_environment(lib_locations)

_install_mod.get_environment = _patched_get_environment

from pip._internal.cli.main import main
wheels_dir = sys.argv[1]
sys.exit(main([
"install",
"--no-index",
"--find-links",
wheels_dir,
"mypackage"
])
)
"""
)
)

result = script.run(
"python", str(runner), str(wheel_path.parent), expect_stderr=True
)
assert (
"Unexpected import of 'pip_unexpected_module_xyz' detected during install"
in result.stderr
)


def test_install_exit_status_code_when_no_requirements(
script: PipTestEnvironment,
) -> None:
Expand Down
30 changes: 29 additions & 1 deletion tests/unit/test_command_install.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import errno
import sys
import warnings
from unittest import mock

import pytest

from pip._vendor.requests.exceptions import InvalidProxyURL

from pip._internal.commands import install
from pip._internal.commands.install import create_os_error_message, decide_user_install
from pip._internal.commands.install import (
_prevent_further_imports,
create_os_error_message,
decide_user_install,
)
from pip._internal.utils.deprecation import PipDeprecationWarning


class TestDecideUserInstall:
Expand Down Expand Up @@ -183,3 +189,25 @@ def test_create_os_error_message(
monkeypatch.setattr(install, "running_under_virtualenv", lambda: False)
msg = create_os_error_message(error, show_traceback, using_user_site)
assert msg == expected


def test_prevent_further_imports_warns_on_import() -> None:
"""
Test that an import attempted after _prevent_further_imports() is called
emits a deprecation warning via the registered audit hook.
"""
captured_hooks: list[mock.Mock] = []

with mock.patch.object(sys, "addaudithook", side_effect=captured_hooks.append):
_prevent_further_imports()

assert len(captured_hooks) == 1, "Expected exactly one audit hook to be registered"
audit_hook = captured_hooks[0]

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
audit_hook("import", ("unknown_module",))

assert len(caught) == 1
assert "unknown_module" in str(caught[0].message)
assert issubclass(caught[0].category, PipDeprecationWarning)