diff --git a/news/12731.feature.rst b/news/12731.feature.rst new file mode 100644 index 00000000000..7fc09b3312d --- /dev/null +++ b/news/12731.feature.rst @@ -0,0 +1,2 @@ +Show a helpful hint when attempting to install a standard library module, +informing users that the module is built-in and can be imported directly. diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py index 40b8f580f4b..52ee1523b44 100644 --- a/src/pip/_internal/commands/index.py +++ b/src/pip/_internal/commands/index.py @@ -22,6 +22,7 @@ from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython from pip._internal.network.session import PipSession +from pip._internal.utils.compat import warn_stdlib_module from pip._internal.utils.misc import write_output logger = logging.getLogger(__name__) @@ -139,6 +140,7 @@ def get_available_package_versions(self, options: Values, args: list[Any]) -> No versions = set(versions) if not versions: + warn_stdlib_module(query) raise DistributionNotFound( f"No matching distribution found for {query}" ) diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py index aa7c2ebd48e..92c135df239 100644 --- a/src/pip/_internal/index/package_finder.py +++ b/src/pip/_internal/index/package_finder.py @@ -41,6 +41,7 @@ from pip._internal.models.wheel import Wheel from pip._internal.req import InstallRequirement from pip._internal.utils._log import getLogger +from pip._internal.utils.compat import warn_stdlib_module from pip._internal.utils.filetypes import WHEEL_EXTENSION from pip._internal.utils.hashes import Hashes from pip._internal.utils.logging import indent_log @@ -1037,6 +1038,11 @@ def _format_versions(cand_iter: Iterable[InstallationCandidate]) -> str: _format_versions(best_candidate_result.all_candidates), ) + # Only show stdlib hint for top-level requirements (not dependencies). + is_top_level = not isinstance(req.comes_from, InstallRequirement) + if is_top_level: + warn_stdlib_module(name) + raise DistributionNotFound(f"No matching distribution found for {req}") def _should_install_candidate( diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py index ede3e6b2b94..31cf058d81d 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py @@ -43,6 +43,7 @@ check_invalid_constraint_type, ) from pip._internal.resolution.base import InstallRequirementProvider +from pip._internal.utils.compat import warn_stdlib_module from pip._internal.utils.compatibility_tags import get_supported from pip._internal.utils.hashes import Hashes from pip._internal.utils.packaging import get_requirement @@ -720,6 +721,10 @@ def _report_single_requirement_conflict( "requirements.txt" ) + # Only show stdlib hint for top-level requirements (not dependencies). + if parent is None: + warn_stdlib_module(req.project_name) + return DistributionNotFound(f"No matching distribution found for {req}") def _has_any_candidates(self, project_name: str) -> bool: diff --git a/src/pip/_internal/utils/compat.py b/src/pip/_internal/utils/compat.py index 324789f1d2e..37ccab80065 100644 --- a/src/pip/_internal/utils/compat.py +++ b/src/pip/_internal/utils/compat.py @@ -7,7 +7,14 @@ import sys from typing import IO -__all__ = ["get_path_uid", "stdlib_pkgs", "tomllib", "WINDOWS"] +__all__ = [ + "get_path_uid", + "stdlib_pkgs", + "stdlib_module_names", + "tomllib", + "warn_stdlib_module", + "WINDOWS", +] logger = logging.getLogger(__name__) @@ -80,6 +87,22 @@ def open_text_resource( # make this ineffective, so hard-coding stdlib_pkgs = {"python", "wsgiref", "argparse"} +# sys.stdlib_module_names is only available in Python 3.10+ +# This is used to provide helpful error messages when users try to install +# standard library modules +stdlib_module_names: frozenset[str] = getattr(sys, "stdlib_module_names", frozenset()) + + +def warn_stdlib_module(name: str) -> None: + """Warn if a package name matches a Python standard library module.""" + if name in stdlib_module_names: + logger.warning( + "%r is a Python standard library module name; it likely does not " + "need to be installed. Installing a package with the same name " + "may override it and break imports.", + name, + ) + # windows detection, covers cpython and ironpython WINDOWS = sys.platform.startswith("win") or (sys.platform == "cli" and os.name == "nt") diff --git a/tests/functional/test_index.py b/tests/functional/test_index.py index 57cfe7af055..f58975e4e25 100644 --- a/tests/functional/test_index.py +++ b/tests/functional/test_index.py @@ -1,4 +1,5 @@ import json +import sys import pytest @@ -162,3 +163,23 @@ def test_index_versions_only_final_for_package(script: PipTestEnvironment) -> No ) assert "1.0" in result.stdout assert "2.0a1" not in result.stdout + + +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="sys.stdlib_module_names only available in Python 3.10+", +) +def test_index_versions_stdlib_module_hint(script: PipTestEnvironment) -> None: + """ + Test that pip index shows a warning when querying a stdlib module name. + """ + result = script.pip( + "index", + "versions", + "--no-index", + "os", # stdlib module + expect_error=True, + ) + + assert "No matching distribution found for os" in result.stderr, str(result) + assert "is a Python standard library module name" in result.stderr, str(result) diff --git a/tests/functional/test_new_resolver.py b/tests/functional/test_new_resolver.py index b5b26128037..ac91d855b68 100644 --- a/tests/functional/test_new_resolver.py +++ b/tests/functional/test_new_resolver.py @@ -301,6 +301,28 @@ def test_new_resolver_no_dist_message(script: PipTestEnvironment) -> None: assert "No matching distribution found for B" in result.stderr, str(result) +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="sys.stdlib_module_names only available in Python 3.10+", +) +def test_new_resolver_stdlib_module_hint(script: PipTestEnvironment) -> None: + """ + Test that pip shows a warning when the user tries to install + a standard library module name. + """ + result = script.pip( + "install", + "--no-cache-dir", + "--no-index", + "os", # stdlib module + expect_error=True, + expect_stderr=True, + ) + + assert "No matching distribution found for os" in result.stderr, str(result) + assert "is a Python standard library module name" in result.stderr, str(result) + + def test_new_resolver_installs_editable(script: PipTestEnvironment) -> None: create_basic_wheel_for_package( script, diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index 74f366b9af4..359295b0178 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -1,5 +1,6 @@ import datetime import logging +import sys from collections.abc import Iterable from unittest.mock import Mock, patch @@ -43,6 +44,28 @@ def test_no_partial_name_match(data: TestData) -> None: assert found.link.url.endswith("gmpy-1.15.tar.gz"), found +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="sys.stdlib_module_names only available in Python 3.10+", +) +def test_find_requirement_stdlib_module_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Finder warns when a top-level requirement matches a stdlib module name.""" + patched_exists = patch( + "pip._internal.index.collector.os.path.exists", return_value=True + ) + with patched_exists: + finder = make_test_finder(find_links=["~/python-pkgs"]) + req = install_req_from_line("os") + with ( + caplog.at_level(logging.WARNING, logger="pip._internal.utils.compat"), + pytest.raises(DistributionNotFound), + ): + finder.find_requirement(req, False) + assert "is a Python standard library module name" in caplog.text + + def test_tilde() -> None: """Finder can accept a path with ~ in it and will normalize it.""" patched_exists = patch(