Skip to content

Commit

Permalink
feat(python): use findpython
Browse files Browse the repository at this point in the history
  • Loading branch information
abn committed Jan 23, 2025
1 parent 8f95399 commit e4fe342
Show file tree
Hide file tree
Showing 11 changed files with 419 additions and 295 deletions.
17 changes: 16 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"trove-classifiers (>=2022.5.19)",
"virtualenv (>=20.26.6,<21.0.0)",
"xattr (>=1.0.0,<2.0.0) ; sys_platform == 'darwin'",
"findpython (>=0.6.2,<0.7.0)",
]
authors = [
{ name = "Sébastien Eustace", email = "[email protected]" }
Expand Down Expand Up @@ -182,6 +183,7 @@ warn_unused_ignores = false
module = [
'deepdiff.*',
'fastjsonschema.*',
'findpython.*',
'httpretty.*',
'requests_toolbelt.*',
'shellingham.*',
Expand Down
49 changes: 21 additions & 28 deletions src/poetry/utils/env/env_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,8 @@ def base_env_name(self) -> str:
def activate(self, python: str) -> Env:
venv_path = self._poetry.config.virtualenvs_path

try:
python_version = Version.parse(python)
python = f"python{python_version.major}"
if python_version.precision > 1:
python += f".{python_version.minor}"
except ValueError:
# Executable in PATH or full executable path
pass

python_ = Python.get_by_name(python)
if python_ is None:
python_instance = Python.get_by_name(python)
if python_instance is None:
raise PythonVersionNotFoundError(python)

create = False
Expand All @@ -138,10 +129,10 @@ def activate(self, python: str) -> Env:
_venv = VirtualEnv(venv)
current_patch = ".".join(str(v) for v in _venv.version_info[:3])

if python_.patch_version.to_string() != current_patch:
if python_instance.patch_version.to_string() != current_patch:
create = True

self.create_venv(executable=python_.executable, force=create)
self.create_venv(python=python_instance, force=create)

return self.get(reload=True)

Expand All @@ -154,14 +145,16 @@ def activate(self, python: str) -> Env:
current_patch = current_env["patch"]

if (
current_minor == python_.minor_version.to_string()
and current_patch != python_.patch_version.to_string()
current_minor == python_instance.minor_version.to_string()
and current_patch != python_instance.patch_version.to_string()
):
# We need to recreate
create = True

name = f"{self.base_env_name}-py{python_.minor_version.to_string()}"
venv = venv_path / name
venv = (
venv_path
/ f"{self.base_env_name}-py{python_instance.minor_version.to_string()}"
)

# Create if needed
if not venv.exists() or create:
Expand All @@ -174,15 +167,15 @@ def activate(self, python: str) -> Env:
_venv = VirtualEnv(venv)
current_patch = ".".join(str(v) for v in _venv.version_info[:3])

if python_.patch_version.to_string() != current_patch:
if python_instance.patch_version.to_string() != current_patch:
create = True

self.create_venv(executable=python_.executable, force=create)
self.create_venv(python=python_instance, force=create)

# Activate
envs[self.base_env_name] = {
"minor": python_.minor_version.to_string(),
"patch": python_.patch_version.to_string(),
"minor": python_instance.minor_version.to_string(),
"patch": python_instance.patch_version.to_string(),
}
self.envs_file.write(envs)

Expand Down Expand Up @@ -372,7 +365,7 @@ def in_project_venv_exists(self) -> bool:
def create_venv(
self,
name: str | None = None,
executable: Path | None = None,
python: Python | None = None,
force: bool = False,
) -> Env:
if self._env is not None and not force:
Expand Down Expand Up @@ -400,11 +393,11 @@ def create_venv(
use_poetry_python = self._poetry.config.get("virtualenvs.use-poetry-python")
venv_prompt = self._poetry.config.get("virtualenvs.prompt")

python = (
Python(executable)
if executable
else Python.get_preferred_python(config=self._poetry.config, io=self._io)
)
specific_python_requested = python is not None
if not python:
python = Python.get_preferred_python(
config=self._poetry.config, io=self._io
)

venv_path = (
self.in_project_venv
Expand All @@ -422,7 +415,7 @@ def create_venv(
# If an executable has been specified, we stop there
# and notify the user of the incompatibility.
# Otherwise, we try to find a compatible Python version.
if executable and use_poetry_python:
if specific_python_requested and use_poetry_python:
raise NoCompatiblePythonVersionFoundError(
self._poetry.package.python_versions,
python.patch_version.to_string(),
Expand Down
177 changes: 76 additions & 101 deletions src/poetry/utils/env/python_manager.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
from __future__ import annotations

import shutil
import subprocess
import sys

from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING
from typing import cast
from typing import overload

import findpython
import packaging.version

from cleo.io.null_io import NullIO
from cleo.io.outputs.output import Verbosity
from poetry.core.constraints.version import Version
from poetry.core.constraints.version import parse_constraint

from poetry.utils._compat import decode
from poetry.utils.env.exceptions import NoCompatiblePythonVersionFoundError
from poetry.utils.env.script_strings import GET_PYTHON_VERSION_ONELINER


if TYPE_CHECKING:
Expand All @@ -26,27 +26,48 @@


class Python:
def __init__(self, executable: str | Path, version: Version | None = None) -> None:
self.executable = Path(executable)
self._version = version
@overload
def __init__(self, *, python: findpython.PythonVersion) -> None: ...

@overload
def __init__(
self, executable: str | Path, version: Version | None = None
) -> None: ...

# we overload __init__ to ensure we do not break any downstream plugins
# that use the this
def __init__(
self,
executable: str | Path | None = None,
version: Version | None = None,
python: findpython.PythonVersion | None = None,
) -> None:
if python and (executable or version):
ValueError(
"When python is provided, neither executable or version must be specified"
)

if python:
self._python = python
elif executable:
self._python = findpython.PythonVersion(
executable=Path(executable),
_version=packaging.version.Version(str(version)) if version else None,
)
else:
raise ValueError("Either python or executable must be provided")

@property
def version(self) -> Version:
if not self._version:
if self.executable == Path(sys.executable):
python_version = ".".join(str(v) for v in sys.version_info[:3])
else:
encoding = "locale" if sys.version_info >= (3, 10) else None
python_version = decode(
subprocess.check_output(
[str(self.executable), "-c", GET_PYTHON_VERSION_ONELINER],
text=True,
encoding=encoding,
).strip()
)
self._version = Version.parse(python_version)
def python(self) -> findpython.PythonVersion:
return self._python

return self._version
@property
def executable(self) -> Path:
return cast(Path, self._python.executable)

@property
def version(self) -> Version:
return Version.parse(str(self._python.version))

@cached_property
def patch_version(self) -> Version:
Expand All @@ -60,66 +81,47 @@ def patch_version(self) -> Version:
def minor_version(self) -> Version:
return Version.from_parts(major=self.version.major, minor=self.version.minor)

@staticmethod
def _full_python_path(python: str) -> Path | None:
# eg first find pythonXY.bat on windows.
path_python = shutil.which(python)
if path_python is None:
return None
@classmethod
def get_active_python(cls) -> Python | None:
if python := findpython.find():
return cls(python=python)
return None

@classmethod
def from_executable(cls, path: Path | str) -> Python:
try:
encoding = "locale" if sys.version_info >= (3, 10) else None
executable = subprocess.check_output(
[path_python, "-c", "import sys; print(sys.executable)"],
text=True,
encoding=encoding,
).strip()
return Path(executable)

except subprocess.CalledProcessError:
return None

@staticmethod
def _detect_active_python(io: IO) -> Path | None:
io.write_error_line(
"Trying to detect current active python executable as specified in"
" the config.",
verbosity=Verbosity.VERBOSE,
)

executable = Python._full_python_path("python")

if executable is not None:
io.write_error_line(f"Found: {executable}", verbosity=Verbosity.VERBOSE)
else:
io.write_error_line(
"Unable to detect the current active python executable. Falling"
" back to default.",
verbosity=Verbosity.VERBOSE,
)

return executable
return cls(python=findpython.PythonVersion(executable=Path(path)))
except (FileNotFoundError, NotADirectoryError, ValueError):
raise ValueError(f"{path} is not a valid Python executable")

@classmethod
def get_system_python(cls) -> Python:
return cls(executable=sys.executable)
return cls(
python=findpython.PythonVersion(
executable=Path(sys.executable),
_version=packaging.version.Version(
".".join(str(v) for v in sys.version_info[:3])
),
)
)

@classmethod
def get_by_name(cls, python_name: str) -> Python | None:
executable = cls._full_python_path(python_name)
if not executable:
return None

return cls(executable=executable)
if python := findpython.find(python_name):
return cls(python=python)
return None

@classmethod
def get_preferred_python(cls, config: Config, io: IO | None = None) -> Python:
io = io or NullIO()

if not config.get("virtualenvs.use-poetry-python") and (
active_python := Python._detect_active_python(io)
active_python := Python.get_active_python()
):
return cls(executable=active_python)
io.write_error_line(
f"Found: {active_python.executable}", verbosity=Verbosity.VERBOSE
)
return active_python

return cls.get_system_python()

Expand All @@ -129,39 +131,12 @@ def get_compatible_python(cls, poetry: Poetry, io: IO | None = None) -> Python:
supported_python = poetry.package.python_constraint
python = None

for suffix in [
*sorted(
poetry.package.AVAILABLE_PYTHONS,
key=lambda v: (v.startswith("3"), -len(v), v),
reverse=True,
),
"",
]:
if len(suffix) == 1:
if not parse_constraint(f"^{suffix}.0").allows_any(supported_python):
continue
elif suffix and not supported_python.allows_any(
parse_constraint(suffix + ".*")
):
continue

python_name = f"python{suffix}"
if io.is_debug():
io.write_error_line(f"<debug>Trying {python_name}</debug>")

executable = cls._full_python_path(python_name)
if executable is None:
continue

candidate = cls(executable)
if supported_python.allows(candidate.patch_version):
python = candidate
for candidate in findpython.find_all():
python = cls(python=candidate)
if python.version.allows_any(supported_python):
io.write_error_line(
f"Using <c1>{python_name}</c1> ({python.patch_version})"
f"Using <c1>{candidate.name}</c1> ({python.patch_version})"
)
break

if not python:
raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions)
return python

return python
raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions)
Loading

0 comments on commit e4fe342

Please sign in to comment.