diff --git a/news/11057.feature.rst b/news/11057.feature.rst new file mode 100644 index 00000000000..0514afb8772 --- /dev/null +++ b/news/11057.feature.rst @@ -0,0 +1,2 @@ +Try to suggest python/python3/pythonX.Y/pylauncher command in pip warning +before falling back to sys.executable diff --git a/src/pip/_internal/self_outdated_check.py b/src/pip/_internal/self_outdated_check.py index 7300e0ea4c0..750a379a014 100644 --- a/src/pip/_internal/self_outdated_check.py +++ b/src/pip/_internal/self_outdated_check.py @@ -4,7 +4,11 @@ import logging import optparse import os.path +import platform +import shutil +import subprocess import sys +from pathlib import Path from typing import Any, Dict from pip._vendor.packaging.version import parse as parse_version @@ -96,6 +100,102 @@ def was_installed_by_pip(pkg: str) -> bool: return dist is not None and "pip" == dist.installer +def get_py_executable() -> str: + """Get path to launch a Python executable. + + First test if python/python3/pythonX.Y on PATH matches the current + interpreter, and use that if possible. Then try to get the correct + pylauncher command to launch a process of the current python + version, fallback to sys.executable + """ + + if not sys.executable: + # docs (python 3.10) says that sys.executable can be can be None or an + # empty string if this value cannot be determined, although this is + # very rare. In this case, there is nothing much we can do + return "python3" + + # windows paths are case-insensitive, pathlib takes that into account + sys_executable_path = Path(sys.executable) + + major, minor, *_ = sys.version_info + + # first handle common case: test if path to python/python3/pythonX.Y + # matches sys.executable + for py in ("python", "python3", f"python{major}.{minor}"): + which = shutil.which(py) + if which is None: + continue + + try: + # resolve() removes symlinks, normalises paths and makes them + # absolute + if Path(which).resolve() == sys_executable_path.resolve(): + return py + + except RuntimeError: + # happens when resolve() encounters an infinite loop + pass + + # version in the format used by pylauncher + pylauncher_version = f"-{major}.{minor}-{64 if sys.maxsize > 2**32 else 32}" + + # checks that pylauncher is usable, also makes sure pylauncher recognises + # the current python version and has the correct path of the current + # executable. + try: + proc = subprocess.run( + ["py", "--list-paths"], + capture_output=True, + timeout=1, + text=True, + check=True, + ) + + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + FileNotFoundError, + ): + pass + + else: + for line in proc.stdout.splitlines(): + # this is not failsafe, in the future pylauncher might change + # the format of the output. In that case, this implementation + # would start falling back to sys.executable which is better than + # throwing unhandled exceptions to users + try: + line_ver, line_path = line.strip().split(maxsplit=1) + except ValueError: + # got less values to unpack + continue + + # strip invalid characters in line_path + invalid_chars = "/\0" # \0 is NUL + if platform.system() == "Windows": + invalid_chars += '<>:"\\|?*' + + line_path = line_path.strip(invalid_chars) + try: + if ( + line_ver == pylauncher_version + and Path(line_path).resolve() == sys_executable_path.resolve() + ): + return f"py {line_ver}" + except RuntimeError: + # happens when resolve() encounters an infinite loop + pass + + # Returning sys.executable is reliable, but this does not accommodate for + # spaces in the path string. Currently it is not possible to workaround + # without knowing the user's shell. + # Thus, it won't be done until possible through the standard library. + # Do not be tempted to use the undocumented subprocess.list2cmdline, it is + # considered an internal implementation detail for a reason. + return sys.executable + + def pip_self_version_check(session: PipSession, options: optparse.Values) -> None: """Check for an update for pip. @@ -165,15 +265,7 @@ def pip_self_version_check(session: PipSession, options: optparse.Values) -> Non if not local_version_is_older: return - # We cannot tell how the current pip is available in the current - # command context, so be pragmatic here and suggest the command - # that's always available. This does not accommodate spaces in - # `sys.executable` on purpose as it is not possible to do it - # correctly without knowing the user's shell. Thus, - # it won't be done until possible through the standard library. - # Do not be tempted to use the undocumented subprocess.list2cmdline. - # It is considered an internal implementation detail for a reason. - pip_cmd = f"{sys.executable} -m pip" + pip_cmd = f"{get_py_executable()} -m pip" logger.warning( "You are using pip version %s; however, version %s is " "available.\nYou should consider upgrading via the "