From 62c5e9f69a7a369e1d25ed14f1f69f49688a70fd Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Mon, 30 Jun 2025 08:25:46 +0330 Subject: [PATCH 01/14] Improve error hint for long filenames on Windows (EINVAL) --- src/pip/_internal/commands/install.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 302bc63f67d..6c7524b54fc 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -7,6 +7,7 @@ import shutil import site from optparse import SUPPRESS_HELP, Values +from pathlib import Path from pip._vendor.packaging.utils import canonicalize_name from pip._vendor.requests.exceptions import InvalidProxyURL @@ -775,9 +776,24 @@ def create_os_error_message( ) parts.append(".\n") + # Windows raises EINVAL when a file or folder name exceeds 255 characters, + # even if long path support is enabled. Add a hint for that case. + if ( + WINDOWS + and error.errno == errno.EINVAL + and error.filename + and any(len(part) > 255 for part in Path(error.filename).parts) + ): + + parts.append( + "HINT: This error might be caused by a file or folder name exceeding " + "255 characters, which is a Windows limitation even if long paths are enabled.\n" + ) + + # Suggest the user to enable Long Paths if path length is # more than 260 - if ( + elif ( WINDOWS and error.errno == errno.ENOENT and error.filename @@ -791,4 +807,5 @@ def create_os_error_message( "https://pip.pypa.io/warnings/enable-long-paths\n" ) + return "".join(parts).strip() + "\n" From 50a471843a73c843b7f44f4cce63574342fb5ce0 Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Mon, 30 Jun 2025 08:45:20 +0330 Subject: [PATCH 02/14] Add 13346.bugfix.rst news file --- news/13346.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/13346.bugfix.rst diff --git a/news/13346.bugfix.rst b/news/13346.bugfix.rst new file mode 100644 index 00000000000..f2e23ce088c --- /dev/null +++ b/news/13346.bugfix.rst @@ -0,0 +1 @@ +This change will improve error hint for long filenames on Windows (EINVAL) From 51dfb4e5f767a331b608b611845aec04524f4806 Mon Sep 17 00:00:00 2001 From: Sepehr Rasouli Date: Wed, 2 Jul 2025 08:25:39 +0330 Subject: [PATCH 03/14] Fix Line too long pre-commit error --- src/pip/_internal/commands/install.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 6c7524b54fc..8f566bc1edf 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -784,13 +784,12 @@ def create_os_error_message( and error.filename and any(len(part) > 255 for part in Path(error.filename).parts) ): - parts.append( "HINT: This error might be caused by a file or folder name exceeding " - "255 characters, which is a Windows limitation even if long paths are enabled.\n" + "255 characters, which is a Windows limitation even if long paths " + "are enabled.\n " ) - # Suggest the user to enable Long Paths if path length is # more than 260 elif ( @@ -807,5 +806,4 @@ def create_os_error_message( "https://pip.pypa.io/warnings/enable-long-paths\n" ) - return "".join(parts).strip() + "\n" From c255546e2e8f324fe0b73df02ec3745cbc16e033 Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Thu, 17 Jul 2025 11:13:19 +0330 Subject: [PATCH 04/14] Add testcase for windows long filename and long path error --- tests/unit/test_command_install.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/test_command_install.py b/tests/unit/test_command_install.py index e28edf6449c..ea1f85f6b44 100644 --- a/tests/unit/test_command_install.py +++ b/tests/unit/test_command_install.py @@ -120,6 +120,19 @@ def test_most_cases( "Consider checking your local proxy configuration" ' with "pip config debug".\n', ), + # Testing both long path error (ENOENT) and long file/folder name error (EINVAL) + ( + OSError(errno.ENOENT, "No such file or directory", "C:/foo/" + "a" * 261), + False, + False, + f"""Could not install packages due to an OSError: [Errno 2] No such file or directory: 'C:/foo/{"a"*261}'\n""", + ), + ( + OSError(errno.EINVAL, "No such file or directory", "C:/foo/" + "a" * 256), + False, + False, + f"""Could not install packages due to an OSError: [Errno 22] No such file or directory: 'C:/foo/{"a"*256}'\n""", + ), ], ) def test_create_os_error_message( From 72ce992dc6ba2c6791c0e6e8acab85aeaf99c5ce Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Thu, 17 Jul 2025 11:13:28 +0330 Subject: [PATCH 05/14] Reword changelog entry --- news/13346.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/13346.bugfix.rst b/news/13346.bugfix.rst index f2e23ce088c..252ffbcb59e 100644 --- a/news/13346.bugfix.rst +++ b/news/13346.bugfix.rst @@ -1 +1 @@ -This change will improve error hint for long filenames on Windows (EINVAL) +Provide a hint if a system error is raised involving long filenames or path segments on Windows. \ No newline at end of file From 3f40103be19afe3a9bdb63ced83b618a8ce07a89 Mon Sep 17 00:00:00 2001 From: Sepehr Rasouli Date: Thu, 17 Jul 2025 11:15:53 +0330 Subject: [PATCH 06/14] Update src/pip/_internal/commands/install.py comment Co-authored-by: Richard Si --- src/pip/_internal/commands/install.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index c7269b54e64..cc990118f9a 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -775,8 +775,8 @@ def create_os_error_message( ) parts.append(".\n") - # Windows raises EINVAL when a file or folder name exceeds 255 characters, - # even if long path support is enabled. Add a hint for that case. + # Windows raises EINVAL when a file*name* or path segment exceeds 255 + # characters, even if long path support is enabled. if ( WINDOWS and error.errno == errno.EINVAL From 42819b9310f9414bcfba33064772a8f38db16f88 Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Thu, 17 Jul 2025 12:27:21 +0330 Subject: [PATCH 07/14] Fix os error message testcase bug on windows platforms --- tests/unit/test_command_install.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_command_install.py b/tests/unit/test_command_install.py index ea1f85f6b44..4b8fa1f7cd5 100644 --- a/tests/unit/test_command_install.py +++ b/tests/unit/test_command_install.py @@ -1,4 +1,5 @@ import errno +import sys from unittest import mock import pytest @@ -120,18 +121,34 @@ def test_most_cases( "Consider checking your local proxy configuration" ' with "pip config debug".\n', ), - # Testing both long path error (ENOENT) and long file/folder name error (EINVAL) - ( + # Testing both long path error (ENOENT) and long file/folder name error (EINVAL) on Windows + pytest.param( OSError(errno.ENOENT, "No such file or directory", "C:/foo/" + "a" * 261), False, False, - f"""Could not install packages due to an OSError: [Errno 2] No such file or directory: 'C:/foo/{"a"*261}'\n""", + "Could not install packages due to an OSError: " + f"[Errno 2] No such file or directory: 'C:/foo/{'a'*261}'\n" + "HINT: This error might have occurred since " + "this system does not have Windows Long Path " + "support enabled. You can find information on " + "how to enable this at " + "https://pip.pypa.io/warnings/enable-long-paths\n", + marks=pytest.mark.skipif( + sys.platform != "win32", reason="Windows-specific filename length test" + ), ), - ( + pytest.param( OSError(errno.EINVAL, "No such file or directory", "C:/foo/" + "a" * 256), False, False, - f"""Could not install packages due to an OSError: [Errno 22] No such file or directory: 'C:/foo/{"a"*256}'\n""", + "Could not install packages due to an OSError: " + f"[Errno 22] No such file or directory: 'C:/foo/{'a'*256}'\n" + "HINT: This error might be caused by a file or folder name exceeding " + "255 characters, which is a Windows limitation even if long paths " + "are enabled.\n", + marks=pytest.mark.skipif( + sys.platform != "win32", reason="Windows-specific filename length test" + ), ), ], ) From 95fe31eb3c4b81b1678ec7b55759ac86f1ac893e Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Thu, 17 Jul 2025 12:28:28 +0330 Subject: [PATCH 08/14] Fix pre-commit error --- news/13346.bugfix.rst | 2 +- tests/unit/test_command_install.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/news/13346.bugfix.rst b/news/13346.bugfix.rst index 252ffbcb59e..b2b9413e7b4 100644 --- a/news/13346.bugfix.rst +++ b/news/13346.bugfix.rst @@ -1 +1 @@ -Provide a hint if a system error is raised involving long filenames or path segments on Windows. \ No newline at end of file +Provide a hint if a system error is raised involving long filenames or path segments on Windows. diff --git a/tests/unit/test_command_install.py b/tests/unit/test_command_install.py index 4b8fa1f7cd5..a9814422727 100644 --- a/tests/unit/test_command_install.py +++ b/tests/unit/test_command_install.py @@ -121,7 +121,8 @@ def test_most_cases( "Consider checking your local proxy configuration" ' with "pip config debug".\n', ), - # Testing both long path error (ENOENT) and long file/folder name error (EINVAL) on Windows + # Testing both long path error (ENOENT) + # and long file/folder name error (EINVAL) on Windows pytest.param( OSError(errno.ENOENT, "No such file or directory", "C:/foo/" + "a" * 261), False, From 4aabcca2d45ec81e57563cedf7d9c27757684ec2 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Thu, 17 Jul 2025 13:23:11 -0400 Subject: [PATCH 09/14] refactor: Add BuildEnvironmentInstaller protocol (#13452) This enables alternative build dependency installers to be added. This is a step towards installing build dependencies in-process. --- src/pip/_internal/build_env.py | 187 +++++++++++-------- src/pip/_internal/cli/req_command.py | 2 + src/pip/_internal/distributions/base.py | 4 +- src/pip/_internal/distributions/installed.py | 8 +- src/pip/_internal/distributions/sdist.py | 22 ++- src/pip/_internal/distributions/wheel.py | 4 +- src/pip/_internal/operations/prepare.py | 14 +- tests/functional/test_build_env.py | 42 +++-- tests/functional/test_pep517.py | 15 +- tests/unit/test_req.py | 3 + 10 files changed, 173 insertions(+), 128 deletions(-) diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 69a2e72ce42..3a246a1e349 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -11,7 +11,7 @@ from collections import OrderedDict from collections.abc import Iterable from types import TracebackType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from pip._vendor.packaging.version import Version @@ -26,6 +26,7 @@ if TYPE_CHECKING: from pip._internal.index.package_finder import PackageFinder + from pip._internal.req.req_install import InstallRequirement logger = logging.getLogger(__name__) @@ -79,10 +80,108 @@ def _get_system_sitepackages() -> set[str]: return {os.path.normcase(path) for path in system_sites} +class BuildEnvironmentInstaller(Protocol): + """ + Interface for installing build dependencies into an isolated build + environment. + """ + + def install( + self, + requirements: Iterable[str], + prefix: _Prefix, + *, + kind: str, + for_req: InstallRequirement | None, + ) -> None: ... + + +class SubprocessBuildEnvironmentInstaller: + """ + Install build dependencies by calling pip in a subprocess. + """ + + def __init__(self, finder: PackageFinder) -> None: + self.finder = finder + + def install( + self, + requirements: Iterable[str], + prefix: _Prefix, + *, + kind: str, + for_req: InstallRequirement | None, + ) -> None: + finder = self.finder + args: list[str] = [ + sys.executable, + get_runnable_pip(), + "install", + "--ignore-installed", + "--no-user", + "--prefix", + prefix.path, + "--no-warn-script-location", + "--disable-pip-version-check", + # As the build environment is ephemeral, it's wasteful to + # pre-compile everything, especially as not every Python + # module will be used/compiled in most cases. + "--no-compile", + # The prefix specified two lines above, thus + # target from config file or env var should be ignored + "--target", + "", + ] + if logger.getEffectiveLevel() <= logging.DEBUG: + args.append("-vv") + elif logger.getEffectiveLevel() <= VERBOSE: + args.append("-v") + for format_control in ("no_binary", "only_binary"): + formats = getattr(finder.format_control, format_control) + args.extend( + ( + "--" + format_control.replace("_", "-"), + ",".join(sorted(formats or {":none:"})), + ) + ) + + index_urls = finder.index_urls + if index_urls: + args.extend(["-i", index_urls[0]]) + for extra_index in index_urls[1:]: + args.extend(["--extra-index-url", extra_index]) + else: + args.append("--no-index") + for link in finder.find_links: + args.extend(["--find-links", link]) + + if finder.proxy: + args.extend(["--proxy", finder.proxy]) + for host in finder.trusted_hosts: + args.extend(["--trusted-host", host]) + if finder.custom_cert: + args.extend(["--cert", finder.custom_cert]) + if finder.client_cert: + args.extend(["--client-cert", finder.client_cert]) + if finder.allow_all_prereleases: + args.append("--pre") + if finder.prefer_binary: + args.append("--prefer-binary") + args.append("--") + args.extend(requirements) + with open_spinner(f"Installing {kind}") as spinner: + call_subprocess( + args, + command_desc=f"pip subprocess to install {kind}", + spinner=spinner, + ) + + class BuildEnvironment: """Creates and manages an isolated environment to install build deps""" - def __init__(self) -> None: + def __init__(self, installer: BuildEnvironmentInstaller) -> None: + self.installer = installer temp_dir = TempDirectory(kind=tempdir_kinds.BUILD_ENV, globally_managed=True) self._prefixes = OrderedDict( @@ -205,96 +304,18 @@ def check_requirements( def install_requirements( self, - finder: PackageFinder, requirements: Iterable[str], prefix_as_string: str, *, kind: str, + for_req: InstallRequirement | None = None, ) -> None: prefix = self._prefixes[prefix_as_string] assert not prefix.setup prefix.setup = True if not requirements: return - self._install_requirements( - get_runnable_pip(), - finder, - requirements, - prefix, - kind=kind, - ) - - @staticmethod - def _install_requirements( - pip_runnable: str, - finder: PackageFinder, - requirements: Iterable[str], - prefix: _Prefix, - *, - kind: str, - ) -> None: - args: list[str] = [ - sys.executable, - pip_runnable, - "install", - "--ignore-installed", - "--no-user", - "--prefix", - prefix.path, - "--no-warn-script-location", - "--disable-pip-version-check", - # As the build environment is ephemeral, it's wasteful to - # pre-compile everything, especially as not every Python - # module will be used/compiled in most cases. - "--no-compile", - # The prefix specified two lines above, thus - # target from config file or env var should be ignored - "--target", - "", - ] - if logger.getEffectiveLevel() <= logging.DEBUG: - args.append("-vv") - elif logger.getEffectiveLevel() <= VERBOSE: - args.append("-v") - for format_control in ("no_binary", "only_binary"): - formats = getattr(finder.format_control, format_control) - args.extend( - ( - "--" + format_control.replace("_", "-"), - ",".join(sorted(formats or {":none:"})), - ) - ) - - index_urls = finder.index_urls - if index_urls: - args.extend(["-i", index_urls[0]]) - for extra_index in index_urls[1:]: - args.extend(["--extra-index-url", extra_index]) - else: - args.append("--no-index") - for link in finder.find_links: - args.extend(["--find-links", link]) - - if finder.proxy: - args.extend(["--proxy", finder.proxy]) - for host in finder.trusted_hosts: - args.extend(["--trusted-host", host]) - if finder.custom_cert: - args.extend(["--cert", finder.custom_cert]) - if finder.client_cert: - args.extend(["--client-cert", finder.client_cert]) - if finder.allow_all_prereleases: - args.append("--pre") - if finder.prefer_binary: - args.append("--prefer-binary") - args.append("--") - args.extend(requirements) - with open_spinner(f"Installing {kind}") as spinner: - call_subprocess( - args, - command_desc=f"pip subprocess to install {kind}", - spinner=spinner, - ) + self.installer.install(requirements, prefix, kind=kind, for_req=for_req) class NoOpBuildEnvironment(BuildEnvironment): @@ -319,10 +340,10 @@ def cleanup(self) -> None: def install_requirements( self, - finder: PackageFinder, requirements: Iterable[str], prefix_as_string: str, *, kind: str, + for_req: InstallRequirement | None = None, ) -> None: raise NotImplementedError() diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 27255098830..dc1328ff019 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -12,6 +12,7 @@ from optparse import Values from typing import Any +from pip._internal.build_env import SubprocessBuildEnvironmentInstaller from pip._internal.cache import WheelCache from pip._internal.cli import cmdoptions from pip._internal.cli.index_command import IndexGroupCommand @@ -136,6 +137,7 @@ def make_requirement_preparer( src_dir=options.src_dir, download_dir=download_dir, build_isolation=options.build_isolation, + build_isolation_installer=SubprocessBuildEnvironmentInstaller(finder), check_build_deps=options.check_build_deps, build_tracker=build_tracker, session=session, diff --git a/src/pip/_internal/distributions/base.py b/src/pip/_internal/distributions/base.py index 6ebf49ad501..ea61f3501e7 100644 --- a/src/pip/_internal/distributions/base.py +++ b/src/pip/_internal/distributions/base.py @@ -7,7 +7,7 @@ from pip._internal.req import InstallRequirement if TYPE_CHECKING: - from pip._internal.index.package_finder import PackageFinder + from pip._internal.build_env import BuildEnvironmentInstaller class AbstractDistribution(metaclass=abc.ABCMeta): @@ -48,7 +48,7 @@ def get_metadata_distribution(self) -> BaseDistribution: @abc.abstractmethod def prepare_distribution_metadata( self, - finder: PackageFinder, + build_env_installer: BuildEnvironmentInstaller, build_isolation: bool, check_build_deps: bool, ) -> None: diff --git a/src/pip/_internal/distributions/installed.py b/src/pip/_internal/distributions/installed.py index 8ceed309446..b6a67df24f4 100644 --- a/src/pip/_internal/distributions/installed.py +++ b/src/pip/_internal/distributions/installed.py @@ -1,9 +1,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pip._internal.distributions.base import AbstractDistribution -from pip._internal.index.package_finder import PackageFinder from pip._internal.metadata import BaseDistribution +if TYPE_CHECKING: + from pip._internal.build_env import BuildEnvironmentInstaller + class InstalledDistribution(AbstractDistribution): """Represents an installed package. @@ -22,7 +26,7 @@ def get_metadata_distribution(self) -> BaseDistribution: def prepare_distribution_metadata( self, - finder: PackageFinder, + build_env_installer: BuildEnvironmentInstaller, build_isolation: bool, check_build_deps: bool, ) -> None: diff --git a/src/pip/_internal/distributions/sdist.py b/src/pip/_internal/distributions/sdist.py index 67468955e4a..e2821f89e00 100644 --- a/src/pip/_internal/distributions/sdist.py +++ b/src/pip/_internal/distributions/sdist.py @@ -11,7 +11,7 @@ from pip._internal.utils.subprocess import runner_with_spinner_message if TYPE_CHECKING: - from pip._internal.index.package_finder import PackageFinder + from pip._internal.build_env import BuildEnvironmentInstaller logger = logging.getLogger(__name__) @@ -34,7 +34,7 @@ def get_metadata_distribution(self) -> BaseDistribution: def prepare_distribution_metadata( self, - finder: PackageFinder, + build_env_installer: BuildEnvironmentInstaller, build_isolation: bool, check_build_deps: bool, ) -> None: @@ -46,7 +46,7 @@ def prepare_distribution_metadata( if should_isolate: # Setup an isolated environment and install the build backend static # requirements in it. - self._prepare_build_backend(finder) + self._prepare_build_backend(build_env_installer) # Check that if the requirement is editable, it either supports PEP 660 or # has a setup.py or a setup.cfg. This cannot be done earlier because we need # to setup the build backend to verify it supports build_editable, nor can @@ -56,7 +56,7 @@ def prepare_distribution_metadata( # without setup.py nor setup.cfg. self.req.isolated_editable_sanity_check() # Install the dynamic build requirements. - self._install_build_reqs(finder) + self._install_build_reqs(build_env_installer) # Check if the current environment provides build dependencies should_check_deps = self.req.use_pep517 and check_build_deps if should_check_deps: @@ -71,15 +71,17 @@ def prepare_distribution_metadata( self._raise_missing_reqs(missing) self.req.prepare_metadata() - def _prepare_build_backend(self, finder: PackageFinder) -> None: + def _prepare_build_backend( + self, build_env_installer: BuildEnvironmentInstaller + ) -> None: # Isolate in a BuildEnvironment and install the build-time # requirements. pyproject_requires = self.req.pyproject_requires assert pyproject_requires is not None - self.req.build_env = BuildEnvironment() + self.req.build_env = BuildEnvironment(build_env_installer) self.req.build_env.install_requirements( - finder, pyproject_requires, "overlay", kind="build dependencies" + pyproject_requires, "overlay", kind="build dependencies", for_req=self.req ) conflicting, missing = self.req.build_env.check_requirements( self.req.requirements_to_check @@ -115,7 +117,9 @@ def _get_build_requires_editable(self) -> Iterable[str]: with backend.subprocess_runner(runner): return backend.get_requires_for_build_editable() - def _install_build_reqs(self, finder: PackageFinder) -> None: + def _install_build_reqs( + self, build_env_installer: BuildEnvironmentInstaller + ) -> None: # Install any extra build dependencies that the backend requests. # This must be done in a second pass, as the pyproject.toml # dependencies must be installed before we can call the backend. @@ -131,7 +135,7 @@ def _install_build_reqs(self, finder: PackageFinder) -> None: if conflicting: self._raise_conflicts("the backend dependencies", conflicting) self.req.build_env.install_requirements( - finder, missing, "normal", kind="backend dependencies" + missing, "normal", kind="backend dependencies", for_req=self.req ) def _raise_conflicts( diff --git a/src/pip/_internal/distributions/wheel.py b/src/pip/_internal/distributions/wheel.py index 54e1e35f2c8..ee12bfadc2e 100644 --- a/src/pip/_internal/distributions/wheel.py +++ b/src/pip/_internal/distributions/wheel.py @@ -12,7 +12,7 @@ ) if TYPE_CHECKING: - from pip._internal.index.package_finder import PackageFinder + from pip._internal.build_env import BuildEnvironmentInstaller class WheelDistribution(AbstractDistribution): @@ -37,7 +37,7 @@ def get_metadata_distribution(self) -> BaseDistribution: def prepare_distribution_metadata( self, - finder: PackageFinder, + build_env_installer: BuildEnvironmentInstaller, build_isolation: bool, check_build_deps: bool, ) -> None: diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py index 6c40643e138..00b1a33a030 100644 --- a/src/pip/_internal/operations/prepare.py +++ b/src/pip/_internal/operations/prepare.py @@ -14,6 +14,7 @@ from pip._vendor.packaging.utils import canonicalize_name +from pip._internal.build_env import BuildEnvironmentInstaller from pip._internal.distributions import make_distribution_for_install_requirement from pip._internal.distributions.installed import InstalledDistribution from pip._internal.exceptions import ( @@ -64,7 +65,7 @@ def _get_prepared_distribution( req: InstallRequirement, build_tracker: BuildTracker, - finder: PackageFinder, + build_env_installer: BuildEnvironmentInstaller, build_isolation: bool, check_build_deps: bool, ) -> BaseDistribution: @@ -74,7 +75,7 @@ def _get_prepared_distribution( if tracker_id is not None: with build_tracker.track(req, tracker_id): abstract_dist.prepare_distribution_metadata( - finder, build_isolation, check_build_deps + build_env_installer, build_isolation, check_build_deps ) return abstract_dist.get_metadata_distribution() @@ -224,12 +225,14 @@ def _check_download_dir( class RequirementPreparer: """Prepares a Requirement""" - def __init__( + def __init__( # noqa: PLR0913 (too many parameters) self, + *, build_dir: str, download_dir: str | None, src_dir: str, build_isolation: bool, + build_isolation_installer: BuildEnvironmentInstaller, check_build_deps: bool, build_tracker: BuildTracker, session: PipSession, @@ -257,6 +260,7 @@ def __init__( # Is build isolation allowed? self.build_isolation = build_isolation + self.build_env_installer = build_isolation_installer # Should check build dependencies? self.check_build_deps = check_build_deps @@ -648,7 +652,7 @@ def _prepare_linked_requirement( dist = _get_prepared_distribution( req, self.build_tracker, - self.finder, + self.build_env_installer, self.build_isolation, self.check_build_deps, ) @@ -704,7 +708,7 @@ def prepare_editable_requirement( dist = _get_prepared_distribution( req, self.build_tracker, - self.finder, + self.build_env_installer, self.build_isolation, self.check_build_deps, ) diff --git a/tests/functional/test_build_env.py b/tests/functional/test_build_env.py index 1a1839b4027..ec1b59f068c 100644 --- a/tests/functional/test_build_env.py +++ b/tests/functional/test_build_env.py @@ -6,7 +6,11 @@ import pytest -from pip._internal.build_env import BuildEnvironment, _get_system_sitepackages +from pip._internal.build_env import ( + BuildEnvironment, + SubprocessBuildEnvironmentInstaller, + _get_system_sitepackages, +) from tests.lib import ( PipTestEnvironment, @@ -33,7 +37,10 @@ def run_with_build_env( import subprocess import sys - from pip._internal.build_env import BuildEnvironment + from pip._internal.build_env import ( + BuildEnvironment, + SubprocessBuildEnvironmentInstaller, + ) from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import PackageFinder from pip._internal.models.search_scope import SearchScope @@ -56,7 +63,9 @@ def run_with_build_env( ) with global_tempdir_manager(): - build_env = BuildEnvironment() + build_env = BuildEnvironment( + SubprocessBuildEnvironmentInstaller(finder) + ) """ ) + indent(dedent(setup_script_contents), " ") @@ -81,30 +90,26 @@ def run_with_build_env( def test_build_env_allow_empty_requirements_install() -> None: finder = make_test_finder() - build_env = BuildEnvironment() + build_env = BuildEnvironment(SubprocessBuildEnvironmentInstaller(finder)) for prefix in ("normal", "overlay"): - build_env.install_requirements( - finder, [], prefix, kind="Installing build dependencies" - ) + build_env.install_requirements([], prefix, kind="Installing build dependencies") def test_build_env_allow_only_one_install(script: PipTestEnvironment) -> None: create_basic_wheel_for_package(script, "foo", "1.0") create_basic_wheel_for_package(script, "bar", "1.0") finder = make_test_finder(find_links=[os.fspath(script.scratch_path)]) - build_env = BuildEnvironment() + build_env = BuildEnvironment(SubprocessBuildEnvironmentInstaller(finder)) for prefix in ("normal", "overlay"): build_env.install_requirements( - finder, ["foo"], prefix, kind=f"installing foo in {prefix}" + ["foo"], prefix, kind=f"installing foo in {prefix}" ) with pytest.raises(AssertionError): build_env.install_requirements( - finder, ["bar"], prefix, kind=f"installing bar in {prefix}" + ["bar"], prefix, kind=f"installing bar in {prefix}" ) with pytest.raises(AssertionError): - build_env.install_requirements( - finder, [], prefix, kind=f"installing in {prefix}" - ) + build_env.install_requirements([], prefix, kind=f"installing in {prefix}") def test_build_env_requirements_check(script: PipTestEnvironment) -> None: @@ -132,7 +137,7 @@ def test_build_env_requirements_check(script: PipTestEnvironment) -> None: run_with_build_env( script, """ - build_env.install_requirements(finder, ['foo', 'bar==3.0'], 'normal', + build_env.install_requirements(['foo', 'bar==3.0'], 'normal', kind='installing foo in normal') r = build_env.check_requirements(['foo', 'bar', 'other']) @@ -149,9 +154,9 @@ def test_build_env_requirements_check(script: PipTestEnvironment) -> None: run_with_build_env( script, """ - build_env.install_requirements(finder, ['foo', 'bar==3.0'], 'normal', + build_env.install_requirements(['foo', 'bar==3.0'], 'normal', kind='installing foo in normal') - build_env.install_requirements(finder, ['bar==1.0'], 'overlay', + build_env.install_requirements(['bar==1.0'], 'overlay', kind='installing foo in overlay') r = build_env.check_requirements(['foo', 'bar', 'other']) @@ -170,7 +175,6 @@ def test_build_env_requirements_check(script: PipTestEnvironment) -> None: script, """ build_env.install_requirements( - finder, ["bar==3.0"], "normal", kind="installing bar in normal", @@ -193,9 +197,9 @@ def test_build_env_overlay_prefix_has_priority(script: PipTestEnvironment) -> No result = run_with_build_env( script, """ - build_env.install_requirements(finder, ['pkg==2.0'], 'overlay', + build_env.install_requirements(['pkg==2.0'], 'overlay', kind='installing pkg==2.0 in overlay') - build_env.install_requirements(finder, ['pkg==4.3'], 'normal', + build_env.install_requirements(['pkg==4.3'], 'normal', kind='installing pkg==4.3 in normal') """, """ diff --git a/tests/functional/test_pep517.py b/tests/functional/test_pep517.py index 9be3834241f..47dae96817f 100644 --- a/tests/functional/test_pep517.py +++ b/tests/functional/test_pep517.py @@ -7,7 +7,10 @@ import pytest import tomli_w -from pip._internal.build_env import BuildEnvironment +from pip._internal.build_env import ( + BuildEnvironment, + SubprocessBuildEnvironmentInstaller, +) from pip._internal.req import InstallRequirement from tests.lib import ( @@ -43,9 +46,9 @@ def test_backend(tmpdir: Path, data: TestData) -> None: req = InstallRequirement(None, None) req.source_dir = os.fspath(project_dir) # make req believe it has been unpacked req.load_pyproject_toml() - env = BuildEnvironment() finder = make_test_finder(find_links=[data.backends]) - env.install_requirements(finder, ["dummy_backend"], "normal", kind="Installing") + env = BuildEnvironment(SubprocessBuildEnvironmentInstaller(finder)) + env.install_requirements(["dummy_backend"], "normal", kind="Installing") conflicting, missing = env.check_requirements(["dummy_backend"]) assert not conflicting assert not missing @@ -73,7 +76,7 @@ def test_backend_path(tmpdir: Path, data: TestData) -> None: req.source_dir = os.fspath(project_dir) # make req believe it has been unpacked req.load_pyproject_toml() - env = BuildEnvironment() + env = BuildEnvironment(object()) # type: ignore assert hasattr(req.pep517_backend, "build_wheel") with env: assert req.pep517_backend is not None @@ -91,9 +94,9 @@ def test_backend_path_and_dep(tmpdir: Path, data: TestData) -> None: req = InstallRequirement(None, None) req.source_dir = os.fspath(project_dir) # make req believe it has been unpacked req.load_pyproject_toml() - env = BuildEnvironment() finder = make_test_finder(find_links=[data.backends]) - env.install_requirements(finder, ["dummy_backend"], "normal", kind="Installing") + env = BuildEnvironment(SubprocessBuildEnvironmentInstaller(finder)) + env.install_requirements(["dummy_backend"], "normal", kind="Installing") assert hasattr(req.pep517_backend, "build_wheel") with env: diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py index ab9b8ea71c6..0547131134e 100644 --- a/tests/unit/test_req.py +++ b/tests/unit/test_req.py @@ -17,6 +17,7 @@ from pip._vendor.packaging.markers import Marker from pip._vendor.packaging.requirements import Requirement +from pip._internal.build_env import SubprocessBuildEnvironmentInstaller from pip._internal.cache import WheelCache from pip._internal.commands import create_command from pip._internal.commands.install import InstallCommand @@ -97,11 +98,13 @@ def _basic_resolver( session = PipSession() with get_build_tracker() as tracker: + installer = SubprocessBuildEnvironmentInstaller(finder) preparer = RequirementPreparer( build_dir=os.path.join(self.tempdir, "build"), src_dir=os.path.join(self.tempdir, "src"), download_dir=None, build_isolation=True, + build_isolation_installer=installer, check_build_deps=False, build_tracker=tracker, session=session, From c2706585804f8b40386505987fc13a4733ece834 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Thu, 17 Jul 2025 13:50:01 -0400 Subject: [PATCH 10/14] Add rich based spinner (#13451) The benefit of this spinner over our legacy spinners is that Rich will update and render the spinner automatically for us. This is much nicer than having to call .spin() manually. --- src/pip/_internal/cli/spinners.py | 81 +++++++++++++++++++++++++++++-- tests/unit/test_cli_spinners.py | 65 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_cli_spinners.py diff --git a/src/pip/_internal/cli/spinners.py b/src/pip/_internal/cli/spinners.py index 1d5c351d896..58aad2853dd 100644 --- a/src/pip/_internal/cli/spinners.py +++ b/src/pip/_internal/cli/spinners.py @@ -6,13 +6,26 @@ import sys import time from collections.abc import Generator -from typing import IO +from typing import IO, Final + +from pip._vendor.rich.console import ( + Console, + ConsoleOptions, + RenderableType, + RenderResult, +) +from pip._vendor.rich.live import Live +from pip._vendor.rich.measure import Measurement +from pip._vendor.rich.text import Text from pip._internal.utils.compat import WINDOWS -from pip._internal.utils.logging import get_indentation +from pip._internal.utils.logging import get_console, get_indentation logger = logging.getLogger(__name__) +SPINNER_CHARS: Final = r"-\|/" +SPINS_PER_SECOND: Final = 8 + class SpinnerInterface: def spin(self) -> None: @@ -27,9 +40,9 @@ def __init__( self, message: str, file: IO[str] | None = None, - spin_chars: str = "-\\|/", + spin_chars: str = SPINNER_CHARS, # Empirically, 8 updates/second looks nice - min_update_interval_seconds: float = 0.125, + min_update_interval_seconds: float = 1 / SPINS_PER_SECOND, ): self._message = message if file is None: @@ -139,6 +152,66 @@ def open_spinner(message: str) -> Generator[SpinnerInterface, None, None]: spinner.finish("done") +class _PipRichSpinner: + """ + Custom rich spinner that matches the style of the legacy spinners. + + (*) Updates will be handled in a background thread by a rich live panel + which will call render() automatically at the appropriate time. + """ + + def __init__(self, label: str) -> None: + self.label = label + self._spin_cycle = itertools.cycle(SPINNER_CHARS) + self._spinner_text = "" + self._finished = False + self._indent = get_indentation() * " " + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield self.render() + + def __rich_measure__( + self, console: Console, options: ConsoleOptions + ) -> Measurement: + text = self.render() + return Measurement.get(console, options, text) + + def render(self) -> RenderableType: + if not self._finished: + self._spinner_text = next(self._spin_cycle) + + return Text.assemble(self._indent, self.label, " ... ", self._spinner_text) + + def finish(self, status: str) -> None: + """Stop spinning and set a final status message.""" + self._spinner_text = status + self._finished = True + + +@contextlib.contextmanager +def open_rich_spinner(label: str, console: Console | None = None) -> Generator[None]: + if not logger.isEnabledFor(logging.INFO): + # Don't show spinner if --quiet is given. + yield + return + + console = console or get_console() + spinner = _PipRichSpinner(label) + with Live(spinner, refresh_per_second=SPINS_PER_SECOND, console=console): + try: + yield + except KeyboardInterrupt: + spinner.finish("canceled") + raise + except Exception: + spinner.finish("error") + raise + else: + spinner.finish("done") + + HIDE_CURSOR = "\x1b[?25l" SHOW_CURSOR = "\x1b[?25h" diff --git a/tests/unit/test_cli_spinners.py b/tests/unit/test_cli_spinners.py new file mode 100644 index 00000000000..c196795da2d --- /dev/null +++ b/tests/unit/test_cli_spinners.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from collections.abc import Generator +from contextlib import contextmanager +from io import StringIO +from typing import Callable +from unittest.mock import Mock + +import pytest + +from pip._vendor.rich.console import Console + +from pip._internal.cli import spinners +from pip._internal.cli.spinners import open_rich_spinner + + +@contextmanager +def patch_logger_level(level: int) -> Generator[None]: + """Patch the spinner logger level temporarily.""" + original_level = spinners.logger.level + spinners.logger.setLevel(level) + try: + yield + finally: + spinners.logger.setLevel(original_level) + + +class TestRichSpinner: + @pytest.mark.parametrize( + "status, func", + [ + ("done", lambda: None), + ("error", lambda: 1 / 0), + ("canceled", Mock(side_effect=KeyboardInterrupt)), + ], + ) + def test_finish(self, status: str, func: Callable[[], None]) -> None: + """ + Check that the spinner finish message is set correctly depending + on how the spinner came to a stop. + """ + stream = StringIO() + try: + with patch_logger_level(logging.INFO): + with open_rich_spinner("working", Console(file=stream)): + func() + except BaseException: + pass + + output = stream.getvalue() + assert output == f"working ... {status}" + + @pytest.mark.parametrize( + "level, visible", + [(logging.ERROR, False), (logging.INFO, True), (logging.DEBUG, True)], + ) + def test_verbosity(self, level: int, visible: bool) -> None: + """Is the spinner hidden at the appropriate verbosity?""" + stream = StringIO() + with patch_logger_level(level): + with open_rich_spinner("working", Console(file=stream)): + pass + + assert bool(stream.getvalue()) == visible From db1d21594b0153edf35ef61b09fe171984b533a7 Mon Sep 17 00:00:00 2001 From: Richard Si Date: Thu, 17 Jul 2025 14:39:30 -0400 Subject: [PATCH 11/14] test: Enforce network hygiene with pytest-subket (#13481) pytest-subket is my fork of pytest-socket with changes that allow it to function in subprocesses and even across environments (as long as the pytest-subket package is installed, no dependencies needed). To promote better test hygiene, tests should need to opt-in to access the Internet. This is managed via the network marker, but is not enforced. With pytest-subket, tests accessing the Internet w/o the marker will immediately fail. This will encourage us to write tests to avoid hitting the network which will result in a faster, more reliable test suite that won't regress. This also makes the lives of downstream that run our test suite in a sandboxed environment nicer as our network marker should be up to date 24/7 now (barring any tests using remote VCS repositories). * test: Bring network marker up to date with reality All instances of the network marker were removed. Afterwards a full test suite was performed with pytest-subket active. The marker was reapplied to the tests that actually needed it (including tests that hit the network via Git or other VCS software). --- pyproject.toml | 7 +++++-- tests/conftest.py | 23 ++++++++++++++++++++++- tests/functional/test_download.py | 4 +++- tests/functional/test_install.py | 3 +++ tests/functional/test_install_compat.py | 3 --- tests/functional/test_install_extras.py | 3 ++- tests/functional/test_install_report.py | 5 +---- tests/unit/test_finder.py | 2 -- 8 files changed, 36 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90f04307f47..019cab5eb3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,10 +53,12 @@ test = [ "cryptography", "freezegun", "installer", - "pytest", + # pytest-subket requires 7.0+ + "pytest >= 7.0", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", + "pytest-subket", "scripttest", "setuptools", # macOS (darwin) arm64 always uses virtualenv >= 20.0 @@ -76,6 +78,7 @@ test-common-wheels = [ "wheel", # As required by pytest-cov. "coverage >= 4.4", + "pytest-subket >= 0.8.1", ] [tool.setuptools] @@ -296,7 +299,7 @@ follow_imports = "skip" # [tool.pytest.ini_options] -addopts = "--ignore src/pip/_vendor --ignore tests/tests_cache -r aR --color=yes" +addopts = "--ignore src/pip/_vendor --ignore tests/tests_cache -r aR --color=yes --disable-socket --allow-hosts=localhost" xfail_strict = true markers = [ "network: tests that need network", diff --git a/tests/conftest.py b/tests/conftest.py index bb8593d2e3b..75cc97a14f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,6 +102,10 @@ def pytest_collection_modifyitems(config: Config, items: list[pytest.Function]) if item.get_closest_marker("search") and not config.getoption("--run-search"): item.add_marker(pytest.mark.skip("pip search test skipped")) + # Exempt tests known to use the network from pytest-subket. + if item.get_closest_marker("network") is not None: + item.add_marker(pytest.mark.enable_socket) + if "CI" in os.environ: # Mark network tests as flaky if item.get_closest_marker("network") is not None: @@ -447,6 +451,18 @@ def coverage_install( return _common_wheel_editable_install(tmpdir_factory, common_wheels, "coverage") +@pytest.fixture(scope="session") +def socket_install(tmpdir_factory: pytest.TempPathFactory, common_wheels: Path) -> Path: + lib_dir = _common_wheel_editable_install( + tmpdir_factory, common_wheels, "pytest_subket" + ) + # pytest-subket is only included so it can intercept and block unexpected + # network requests. It should NOT be visible to the pip under test. + dist_info = next(lib_dir.glob("*.dist-info")) + shutil.rmtree(dist_info) + return lib_dir + + def install_pth_link( venv: VirtualEnvironment, project_name: str, lib_dir: Path ) -> None: @@ -464,6 +480,7 @@ def virtualenv_template( setuptools_install: Path, wheel_install: Path, coverage_install: Path, + socket_install: Path, ) -> VirtualEnvironment: venv_type: VirtualEnvironmentType if request.config.getoption("--use-venv"): @@ -475,9 +492,13 @@ def virtualenv_template( tmpdir = tmpdir_factory.mktemp("virtualenv") venv = VirtualEnvironment(tmpdir.joinpath("venv_orig"), venv_type=venv_type) - # Install setuptools, wheel and pip. + # Install setuptools, wheel, pytest-subket, and pip. install_pth_link(venv, "setuptools", setuptools_install) install_pth_link(venv, "wheel", wheel_install) + install_pth_link(venv, "pytest_subket", socket_install) + # Also copy pytest-subket's .pth file so it can intercept socket calls. + with open(venv.site / "pytest_socket.pth", "w") as f: + f.write(socket_install.joinpath("pytest_socket.pth").read_text()) pth, dist_info = pip_editable_parts diff --git a/tests/functional/test_download.py b/tests/functional/test_download.py index d91eb22a5e1..c5887aa1bf0 100644 --- a/tests/functional/test_download.py +++ b/tests/functional/test_download.py @@ -1101,7 +1101,9 @@ def test_download_file_url_existing_ok_download( simple_pkg = shared_data.packages / "simple-1.0.tar.gz" url = f"{simple_pkg.as_uri()}#sha256={sha256(downloaded_path_bytes).hexdigest()}" - shared_script.pip("download", "-d", str(download_dir), url) + shared_script.pip( + "download", "-d", str(download_dir), url, "--disable-pip-version-check" + ) assert downloaded_path_bytes == downloaded_path.read_bytes() diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py index 51134430fa1..f23b156dcf7 100644 --- a/tests/functional/test_install.py +++ b/tests/functional/test_install.py @@ -155,6 +155,7 @@ def test_pep518_refuses_invalid_build_system( assert "pyproject.toml" in result.stderr +@pytest.mark.network def test_pep518_allows_missing_requires( script: PipTestEnvironment, data: TestData, common_wheels: Path ) -> None: @@ -175,6 +176,7 @@ def test_pep518_allows_missing_requires( assert result.files_created +@pytest.mark.network @pytest.mark.usefixtures("enable_user_site") def test_pep518_with_user_pip( script: PipTestEnvironment, pip_src: Path, data: TestData, common_wheels: Path @@ -2424,6 +2426,7 @@ def test_install_sends_certs_for_pep518_deps( assert environ.get("SSL_CLIENT_CERT", "") +@pytest.mark.network def test_install_skip_work_dir_pkg(script: PipTestEnvironment, data: TestData) -> None: """ Test that install of a package in working directory diff --git a/tests/functional/test_install_compat.py b/tests/functional/test_install_compat.py index 7ecacdb8e7a..47ba41bdf9e 100644 --- a/tests/functional/test_install_compat.py +++ b/tests/functional/test_install_compat.py @@ -6,8 +6,6 @@ import os from pathlib import Path -import pytest - from tests.lib import ( PipTestEnvironment, TestData, @@ -16,7 +14,6 @@ ) -@pytest.mark.network def test_debian_egg_name_workaround( script: PipTestEnvironment, shared_data: TestData, diff --git a/tests/functional/test_install_extras.py b/tests/functional/test_install_extras.py index 4e74f54a2c3..44dbc2a3694 100644 --- a/tests/functional/test_install_extras.py +++ b/tests/functional/test_install_extras.py @@ -188,10 +188,11 @@ def test_install_special_extra( ) in result.stderr, str(result) +@pytest.mark.network def test_install_requirements_no_r_flag(script: PipTestEnvironment) -> None: """Beginners sometimes forget the -r and this leads to confusion""" result = script.pip("install", "requirements.txt", expect_error=True) - assert 'literally named "requirements.txt"' in result.stdout + assert 'literally named "requirements.txt"' in result.stdout, str(result) @pytest.mark.parametrize( diff --git a/tests/functional/test_install_report.py b/tests/functional/test_install_report.py index 3c1ec11858c..968e30f2dec 100644 --- a/tests/functional/test_install_report.py +++ b/tests/functional/test_install_report.py @@ -117,6 +117,7 @@ def test_skipped_yanked_version( assert simple_report["metadata"]["version"] == "2.0" +@pytest.mark.network @pytest.mark.parametrize( "specifiers", [ @@ -127,7 +128,6 @@ def test_skipped_yanked_version( ("Paste[openid]==1.7.5.1", "Paste==1.7.5.1"), ], ) -@pytest.mark.network def test_install_report_index( script: PipTestEnvironment, tmp_path: Path, specifiers: tuple[str, ...] ) -> None: @@ -178,7 +178,6 @@ def test_install_report_index_multiple_extras( assert install_dict["paste"]["requested_extras"] == ["openid", "subprocess"] -@pytest.mark.network def test_install_report_direct_archive( script: PipTestEnvironment, tmp_path: Path, shared_data: TestData ) -> None: @@ -297,7 +296,6 @@ def test_install_report_vcs_editable( assert pip_test_package_report["download_info"]["dir_info"]["editable"] is True -@pytest.mark.network def test_install_report_local_path_with_extras( script: PipTestEnvironment, tmp_path: Path, shared_data: TestData ) -> None: @@ -342,7 +340,6 @@ def test_install_report_local_path_with_extras( assert "requested_extras" not in simple_report -@pytest.mark.network def test_install_report_editable_local_path_with_extras( script: PipTestEnvironment, tmp_path: Path, shared_data: TestData ) -> None: diff --git a/tests/unit/test_finder.py b/tests/unit/test_finder.py index 21fe1d90620..c8ab1abb78b 100644 --- a/tests/unit/test_finder.py +++ b/tests/unit/test_finder.py @@ -81,7 +81,6 @@ def test_incorrect_case_file_index(data: TestData) -> None: assert found.link.url.endswith("Dinner-2.0.tar.gz") -@pytest.mark.network def test_finder_detects_latest_already_satisfied_find_links(data: TestData) -> None: """Test PackageFinder detects latest already satisfied using find-links""" req = install_req_from_line("simple") @@ -98,7 +97,6 @@ def test_finder_detects_latest_already_satisfied_find_links(data: TestData) -> N finder.find_requirement(req, True) -@pytest.mark.network def test_finder_detects_latest_already_satisfied_pypi_links() -> None: """Test PackageFinder detects latest already satisfied using pypi links""" req = install_req_from_line("initools") From f6bab759e89dda8384d5d96590409f5f266f1fd9 Mon Sep 17 00:00:00 2001 From: sepehrrasooli Date: Sat, 19 Jul 2025 07:48:45 +0330 Subject: [PATCH 12/14] Fixed problematic testcase and added coverage for both ENOENT and EINVAL errors in the long path handler. --- src/pip/_internal/commands/install.py | 2 +- tests/unit/test_command_install.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index cc990118f9a..b9d5280916a 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -779,7 +779,7 @@ def create_os_error_message( # characters, even if long path support is enabled. if ( WINDOWS - and error.errno == errno.EINVAL + and error.errno in (errno.EINVAL,errno.ENOENT) and error.filename and any(len(part) > 255 for part in Path(error.filename).parts) ): diff --git a/tests/unit/test_command_install.py b/tests/unit/test_command_install.py index a9814422727..4f595756e70 100644 --- a/tests/unit/test_command_install.py +++ b/tests/unit/test_command_install.py @@ -124,11 +124,11 @@ def test_most_cases( # Testing both long path error (ENOENT) # and long file/folder name error (EINVAL) on Windows pytest.param( - OSError(errno.ENOENT, "No such file or directory", "C:/foo/" + "a" * 261), + OSError(errno.ENOENT, "No such file or directory", f'C:/foo/{'/a/'*261}'), False, False, "Could not install packages due to an OSError: " - f"[Errno 2] No such file or directory: 'C:/foo/{'a'*261}'\n" + f"[Errno 2] No such file or directory: 'C:/foo/{'/a/'*261}'\n" "HINT: This error might have occurred since " "this system does not have Windows Long Path " "support enabled. You can find information on " @@ -139,7 +139,7 @@ def test_most_cases( ), ), pytest.param( - OSError(errno.EINVAL, "No such file or directory", "C:/foo/" + "a" * 256), + OSError(errno.EINVAL, "No such file or directory", f'C:/foo/{'a'*256}'), False, False, "Could not install packages due to an OSError: " From 50e41bb92f1006717529513813bc6c8dc99c1adf Mon Sep 17 00:00:00 2001 From: Sepehr Rasouli Date: Sat, 19 Jul 2025 07:56:29 +0330 Subject: [PATCH 13/14] Fixed testcase fstring error --- tests/unit/test_command_install.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_command_install.py b/tests/unit/test_command_install.py index 4f595756e70..db288cf0f4c 100644 --- a/tests/unit/test_command_install.py +++ b/tests/unit/test_command_install.py @@ -124,7 +124,7 @@ def test_most_cases( # Testing both long path error (ENOENT) # and long file/folder name error (EINVAL) on Windows pytest.param( - OSError(errno.ENOENT, "No such file or directory", f'C:/foo/{'/a/'*261}'), + OSError(errno.ENOENT, "No such file or directory", f"C:/foo/{'/a/'*261}"), False, False, "Could not install packages due to an OSError: " @@ -139,7 +139,7 @@ def test_most_cases( ), ), pytest.param( - OSError(errno.EINVAL, "No such file or directory", f'C:/foo/{'a'*256}'), + OSError(errno.EINVAL, "No such file or directory", f"C:/foo/{'a'*256}"), False, False, "Could not install packages due to an OSError: " From 29aa7d8f98e43217d129d50dcdba4c6c7b70a0f6 Mon Sep 17 00:00:00 2001 From: Sepehr Rasouli Date: Sat, 19 Jul 2025 07:59:46 +0330 Subject: [PATCH 14/14] Fixed pre-commit issue. --- src/pip/_internal/commands/install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index b9d5280916a..5cb9e6f3b00 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -779,7 +779,7 @@ def create_os_error_message( # characters, even if long path support is enabled. if ( WINDOWS - and error.errno in (errno.EINVAL,errno.ENOENT) + and error.errno in (errno.EINVAL, errno.ENOENT) and error.filename and any(len(part) > 255 for part in Path(error.filename).parts) ):