Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: Unify the code about selecting interpreter #331

Merged
merged 3 commits into from
Mar 23, 2021
Merged
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
1 change: 1 addition & 0 deletions news/331.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refactor: Unify the code about selecting interpreter to reduce the duplication.
38 changes: 22 additions & 16 deletions pdm/cli/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,42 +416,48 @@ def do_use(project: Project, python: str, first: bool = False) -> None:
"""Use the specified python version and save in project config.
The python can be a version string or interpreter path.
"""

def version_matcher(py_version):
return project.python_requires.contains(str(py_version.version))

python = python.strip()
found_interpreters = list(dict.fromkeys(project.find_interpreters(python)))

found_interpreters = list(
dict.fromkeys(filter(version_matcher, project.find_interpreters(python)))
)
if not found_interpreters:
raise NoPythonVersion(
f"Python interpreter {python} is not found on the system."
)
if first or len(found_interpreters) == 1:
python_path = found_interpreters[0]
selected_python = found_interpreters[0]
else:
for i, path in enumerate(found_interpreters):
python_version, is_64bit = get_python_version(path, True)
for i, py_version in enumerate(found_interpreters):
python_version = str(py_version.version)
is_64bit = py_version.get_architecture() == "64bit"
version_string = get_python_version_string(python_version, is_64bit)
project.core.ui.echo(f"{i}. {termui.green(path)} ({version_string})")
project.core.ui.echo(
f"{i}. {termui.green(py_version.executable)} ({version_string})"
)
selection = click.prompt(
"Please select:",
type=click.Choice([str(i) for i in range(len(found_interpreters))]),
default="0",
show_choices=False,
)
python_path = found_interpreters[int(selection)]
python_version, is_64bit = get_python_version(python_path, True)
selected_python = found_interpreters[int(selection)]

if not project.python_requires.contains(python_version):
raise NoPythonVersion(
"The target Python version {} doesn't satisfy "
"the Python requirement: {}".format(python_version, project.python_requires)
)
old_path = project.config.get("python.path")
new_path = selected_python.executable
python_version = str(selected_python.version)
is_64bit = selected_python.get_architecture() == "64bit"
project.core.ui.echo(
"Using Python interpreter: {} ({})".format(
termui.green(python_path),
termui.green(str(new_path)),
get_python_version_string(python_version, is_64bit),
)
)
old_path = project.config.get("python.path")
new_path = python_path
project.project_config["python.path"] = Path(new_path).as_posix()
project.python_executable = new_path
if old_path and Path(old_path) != Path(new_path) and not project.is_global:
project.core.ui.echo(termui.cyan("Updating executable scripts..."))
project.environment.update_shebangs(new_path)
Expand Down
116 changes: 57 additions & 59 deletions pdm/project/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Type, Union

import tomlkit
from pythonfinder import Finder
from pythonfinder.environment import PYENV_INSTALLED, PYENV_ROOT
from pythonfinder.models.python import PythonVersion
from tomlkit.items import Comment, Whitespace

from pdm import termui
Expand All @@ -34,7 +34,7 @@
cd,
find_project_root,
find_python_in_path,
get_venv_python,
get_in_project_venv_python,
is_venv_python,
setdefault,
)
Expand Down Expand Up @@ -148,6 +148,11 @@ def python_executable(self) -> str:
self._python_executable = self.resolve_interpreter()
return self._python_executable

@python_executable.setter
def python_executable(self, value: str) -> None:
self._python_executable = value
self.project_config["python.path"] = value

def resolve_interpreter(self) -> str:
"""Get the Python interpreter path."""
config = self.config
Expand All @@ -159,48 +164,20 @@ def resolve_interpreter(self) -> str:
del self.project_config["python.path"]
else:
return saved_path
path = None
if config["use_venv"]:
path = get_venv_python(self.root)
if path:
self.core.ui.echo(
f"Virtualenv interpreter {termui.green(path)} is detected.",
err=True,
verbosity=termui.DETAIL,
)
if not path and PYENV_INSTALLED and config.get("python.use_pyenv", True):
path = Path(PYENV_ROOT, "shims", "python").as_posix()
if not path:
path = shutil.which("python")

version = None
if path:
try:
version, _ = get_python_version(path, True)
except (FileNotFoundError, subprocess.CalledProcessError):
version = None
if not version or not self.python_requires.contains(version):
finder = Finder()
for python in finder.find_all_python_versions():
version, _ = get_python_version(python.path.as_posix(), True)
if self.python_requires.contains(version):
path = python.path.as_posix()
break
else:
version = ".".join(map(str, sys.version_info[:3]))
if self.python_requires.contains(version):
path = sys.executable
if path:
if os.path.normcase(path) == os.path.normcase(sys.executable):
# Refer to the base interpreter to allow for venvs
path = getattr(sys, "_base_executable", sys.executable)
self.core.ui.echo(
"Using Python interpreter: {} ({})".format(termui.green(path), version),
err=True,
)
if not os.getenv("PDM_IGNORE_SAVED_PYTHON"):
self.project_config["python.path"] = Path(path).as_posix()
return path
if os.name == "nt":
suffix = ".exe"
scripts = "Scripts"
else:
suffix = ""
scripts = "bin"
if config["use_venv"] and os.getenv("VIRTUAL_ENV"):
return os.path.join(os.getenv("VIRTUAL_ENV"), scripts, f"python{suffix}")

for py_version in self.find_interpreters():
if self.python_requires.contains(str(py_version.version)):
self.python_executable = py_version.executable
return self.python_executable

raise NoPythonVersion(
"No Python that satisfies {} is found on the system.".format(
self.python_requires
Expand Down Expand Up @@ -521,26 +498,47 @@ def make_candidate_info_cache(self) -> CandidateInfoCache:
def make_hash_cache(self) -> HashCache:
return HashCache(directory=self.cache("hashes").as_posix())

def find_interpreters(self, python_spec: str) -> Iterable[str]:
def find_interpreters(
self, python_spec: Optional[str] = None
) -> Iterable[PythonVersion]:
"""Return an iterable of interpreter paths that matches the given specifier,
which can be:
1. a version specifier like 3.7
2. an absolute path
3. a short name like python3
4. None that returns all possible interpreters
"""
import pythonfinder
config = self.config
PythonVersion.__hash__ = lambda self: hash(self.executable)

if python_spec and not all(c.isdigit() for c in python_spec.split(".")):
if Path(python_spec).exists():
python_path = find_python_in_path(python_spec)
if python_path:
yield os.path.abspath(python_path)
else:
python_path = shutil.which(python_spec)
if python_path:
yield python_path
return
args = [int(v) for v in python_spec.split(".") if v != ""]
finder = pythonfinder.Finder()
if not python_spec:
if config.get("python.use_pyenv", True) and PYENV_INSTALLED:
yield PythonVersion.from_path(
os.path.join(PYENV_ROOT, "shims", "python")
)
if config.get("use_venv"):
python = get_in_project_venv_python(self.root)
if python:
yield PythonVersion.from_path(python)
python = shutil.which("python")
if python:
yield PythonVersion.from_path(python)
args = ()
else:
if not all(c.isdigit() for c in python_spec.split(".")):
if Path(python_spec).exists():
python = find_python_in_path(python_spec)
if python:
yield PythonVersion.from_path(str(python))
else:
python = shutil.which(python_spec)
if python:
yield PythonVersion.from_path(python)
return
args = [int(v) for v in python_spec.split(".") if v != ""]
finder = Finder()
for entry in finder.find_all_python_versions(*args):
yield entry.path.as_posix()
yield entry.py_version
if not python_spec:
this_python = getattr(sys, "_base_executable", sys.executable)
yield PythonVersion.from_path(this_python)
18 changes: 6 additions & 12 deletions pdm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,24 +243,18 @@ def add_ssh_scheme_to_git_uri(uri: str) -> str:
return uri


def get_venv_python(root: Path) -> Optional[str]:
"""Get the python interpreter path of venv"""
def get_in_project_venv_python(root: Path) -> Optional[Path]:
"""Get the python interpreter path of venv-in-project"""
if os.name == "nt":
suffix = ".exe"
scripts = "Scripts"
else:
suffix = ""
scripts = "bin"
venv = None
if "VIRTUAL_ENV" in os.environ:
venv = os.environ["VIRTUAL_ENV"]
else:
for possible_dir in ("venv", ".venv", "env"):
if (root / possible_dir / scripts / f"python{suffix}").exists():
venv = str(root / possible_dir)
break
if venv:
return os.path.join(venv, scripts, f"python{suffix}")
for possible_dir in ("venv", ".venv", "env"):
if (root / possible_dir / scripts / f"python{suffix}").exists():
venv = root / possible_dir
return venv / scripts / f"python{suffix}"
return None


Expand Down
9 changes: 8 additions & 1 deletion tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ def test_project_python_with_pyenv_support(project, mocker):
from pythonfinder.environment import PYENV_ROOT

del project.project_config["python.path"]
project._python_executable = None
pyenv_python = Path(PYENV_ROOT, "shims", "python")
with temp_environ():
os.environ["PDM_IGNORE_SAVED_PYTHON"] = "1"
mocker.patch("pdm.project.core.PYENV_INSTALLED", True)
mocker.patch("pdm.project.core.get_python_version", return_value=("3.8", True))
mocker.patch(
"pythonfinder.models.python.get_python_version",
return_value="3.8.0",
)
assert Path(project.python_executable) == pyenv_python

# Clean cache
Expand Down Expand Up @@ -63,6 +67,7 @@ def test_global_project(tmp_path, core):

def test_project_use_venv(project):
del project.project_config["python.path"]
project._python_executable = None
scripts = "Scripts" if os.name == "nt" else "bin"
suffix = ".exe" if os.name == "nt" else ""
venv.create(project.root / "venv")
Expand Down Expand Up @@ -107,6 +112,7 @@ def test_project_auto_detect_venv(project):
suffix = ".exe" if os.name == "nt" else ""

project.project_config["use_venv"] = True
project._python_executable = None
project.project_config["python.path"] = (
project.root / "test_venv" / scripts / f"python{suffix}"
).as_posix()
Expand All @@ -116,6 +122,7 @@ def test_project_auto_detect_venv(project):

def test_ignore_saved_python(project):
project.project_config["use_venv"] = True
project._python_executable = None
scripts = "Scripts" if os.name == "nt" else "bin"
suffix = ".exe" if os.name == "nt" else ""
venv.create(project.root / "venv")
Expand Down