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/12731.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/pip/_internal/commands/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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}"
)
Expand Down
6 changes: 6 additions & 0 deletions src/pip/_internal/index/package_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 24 additions & 1 deletion src/pip/_internal/utils/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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")
21 changes: 21 additions & 0 deletions tests/functional/test_index.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import sys

import pytest

Expand Down Expand Up @@ -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)
22 changes: 22 additions & 0 deletions tests/functional/test_new_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/test_finder.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import logging
import sys
from collections.abc import Iterable
from unittest.mock import Mock, patch

Expand Down Expand Up @@ -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(
Expand Down