Skip to content

Commit 28828ae

Browse files
committed
feat(python): use findpython
1 parent 8f95399 commit 28828ae

File tree

11 files changed

+445
-292
lines changed

11 files changed

+445
-292
lines changed

poetry.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
"trove-classifiers (>=2022.5.19)",
2828
"virtualenv (>=20.26.6,<21.0.0)",
2929
"xattr (>=1.0.0,<2.0.0) ; sys_platform == 'darwin'",
30+
"findpython (>=0.6.2,<0.7.0)",
3031
]
3132
authors = [
3233
{ name = "Sébastien Eustace", email = "[email protected]" }
@@ -182,6 +183,7 @@ warn_unused_ignores = false
182183
module = [
183184
'deepdiff.*',
184185
'fastjsonschema.*',
186+
'findpython.*',
185187
'httpretty.*',
186188
'requests_toolbelt.*',
187189
'shellingham.*',

src/poetry/utils/env/env_manager.py

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,8 @@ def base_env_name(self) -> str:
114114
def activate(self, python: str) -> Env:
115115
venv_path = self._poetry.config.virtualenvs_path
116116

117-
try:
118-
python_version = Version.parse(python)
119-
python = f"python{python_version.major}"
120-
if python_version.precision > 1:
121-
python += f".{python_version.minor}"
122-
except ValueError:
123-
# Executable in PATH or full executable path
124-
pass
125-
126-
python_ = Python.get_by_name(python)
127-
if python_ is None:
117+
python_instance = Python.get_by_name(python)
118+
if python_instance is None:
128119
raise PythonVersionNotFoundError(python)
129120

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

141-
if python_.patch_version.to_string() != current_patch:
132+
if python_instance.patch_version.to_string() != current_patch:
142133
create = True
143134

144-
self.create_venv(executable=python_.executable, force=create)
135+
self.create_venv(python=python_instance, force=create)
145136

146137
return self.get(reload=True)
147138

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

156147
if (
157-
current_minor == python_.minor_version.to_string()
158-
and current_patch != python_.patch_version.to_string()
148+
current_minor == python_instance.minor_version.to_string()
149+
and current_patch != python_instance.patch_version.to_string()
159150
):
160151
# We need to recreate
161152
create = True
162153

163-
name = f"{self.base_env_name}-py{python_.minor_version.to_string()}"
164-
venv = venv_path / name
154+
venv = (
155+
venv_path
156+
/ f"{self.base_env_name}-py{python_instance.minor_version.to_string()}"
157+
)
165158

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

177-
if python_.patch_version.to_string() != current_patch:
170+
if python_instance.patch_version.to_string() != current_patch:
178171
create = True
179172

180-
self.create_venv(executable=python_.executable, force=create)
173+
self.create_venv(python=python_instance, force=create)
181174

182175
# Activate
183176
envs[self.base_env_name] = {
184-
"minor": python_.minor_version.to_string(),
185-
"patch": python_.patch_version.to_string(),
177+
"minor": python_instance.minor_version.to_string(),
178+
"patch": python_instance.patch_version.to_string(),
186179
}
187180
self.envs_file.write(envs)
188181

@@ -372,7 +365,7 @@ def in_project_venv_exists(self) -> bool:
372365
def create_venv(
373366
self,
374367
name: str | None = None,
375-
executable: Path | None = None,
368+
python: Python | None = None,
376369
force: bool = False,
377370
) -> Env:
378371
if self._env is not None and not force:
@@ -400,11 +393,11 @@ def create_venv(
400393
use_poetry_python = self._poetry.config.get("virtualenvs.use-poetry-python")
401394
venv_prompt = self._poetry.config.get("virtualenvs.prompt")
402395

403-
python = (
404-
Python(executable)
405-
if executable
406-
else Python.get_preferred_python(config=self._poetry.config, io=self._io)
407-
)
396+
specific_python_requested = python is not None
397+
if not python:
398+
python = Python.get_preferred_python(
399+
config=self._poetry.config, io=self._io
400+
)
408401

409402
venv_path = (
410403
self.in_project_venv
@@ -422,7 +415,7 @@ def create_venv(
422415
# If an executable has been specified, we stop there
423416
# and notify the user of the incompatibility.
424417
# Otherwise, we try to find a compatible Python version.
425-
if executable and use_poetry_python:
418+
if specific_python_requested and use_poetry_python:
426419
raise NoCompatiblePythonVersionFoundError(
427420
self._poetry.package.python_versions,
428421
python.patch_version.to_string(),
Lines changed: 76 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
from __future__ import annotations
22

3-
import shutil
4-
import subprocess
53
import sys
64

75
from functools import cached_property
86
from pathlib import Path
97
from typing import TYPE_CHECKING
8+
from typing import cast
9+
from typing import overload
10+
11+
import findpython
12+
import packaging.version
1013

1114
from cleo.io.null_io import NullIO
1215
from cleo.io.outputs.output import Verbosity
1316
from poetry.core.constraints.version import Version
14-
from poetry.core.constraints.version import parse_constraint
1517

16-
from poetry.utils._compat import decode
1718
from poetry.utils.env.exceptions import NoCompatiblePythonVersionFoundError
18-
from poetry.utils.env.script_strings import GET_PYTHON_VERSION_ONELINER
1919

2020

2121
if TYPE_CHECKING:
@@ -26,27 +26,48 @@
2626

2727

2828
class Python:
29-
def __init__(self, executable: str | Path, version: Version | None = None) -> None:
30-
self.executable = Path(executable)
31-
self._version = version
29+
@overload
30+
def __init__(self, *, python: findpython.PythonVersion) -> None: ...
31+
32+
@overload
33+
def __init__(
34+
self, executable: str | Path, version: Version | None = None
35+
) -> None: ...
36+
37+
# we overload __init__ to ensure we do not break any downstream plugins
38+
# that use the this
39+
def __init__(
40+
self,
41+
executable: str | Path | None = None,
42+
version: Version | None = None,
43+
python: findpython.PythonVersion | None = None,
44+
) -> None:
45+
if python and (executable or version):
46+
raise ValueError(
47+
"When python is provided, neither executable or version must be specified"
48+
)
49+
50+
if python:
51+
self._python = python
52+
elif executable:
53+
self._python = findpython.PythonVersion(
54+
executable=Path(executable),
55+
_version=packaging.version.Version(str(version)) if version else None,
56+
)
57+
else:
58+
raise ValueError("Either python or executable must be provided")
3259

3360
@property
34-
def version(self) -> Version:
35-
if not self._version:
36-
if self.executable == Path(sys.executable):
37-
python_version = ".".join(str(v) for v in sys.version_info[:3])
38-
else:
39-
encoding = "locale" if sys.version_info >= (3, 10) else None
40-
python_version = decode(
41-
subprocess.check_output(
42-
[str(self.executable), "-c", GET_PYTHON_VERSION_ONELINER],
43-
text=True,
44-
encoding=encoding,
45-
).strip()
46-
)
47-
self._version = Version.parse(python_version)
61+
def python(self) -> findpython.PythonVersion:
62+
return self._python
4863

49-
return self._version
64+
@property
65+
def executable(self) -> Path:
66+
return cast(Path, self._python.executable)
67+
68+
@property
69+
def version(self) -> Version:
70+
return Version.parse(str(self._python.version))
5071

5172
@cached_property
5273
def patch_version(self) -> Version:
@@ -60,66 +81,47 @@ def patch_version(self) -> Version:
6081
def minor_version(self) -> Version:
6182
return Version.from_parts(major=self.version.major, minor=self.version.minor)
6283

63-
@staticmethod
64-
def _full_python_path(python: str) -> Path | None:
65-
# eg first find pythonXY.bat on windows.
66-
path_python = shutil.which(python)
67-
if path_python is None:
68-
return None
84+
@classmethod
85+
def get_active_python(cls) -> Python | None:
86+
if python := findpython.find():
87+
return cls(python=python)
88+
return None
6989

90+
@classmethod
91+
def from_executable(cls, path: Path | str) -> Python:
7092
try:
71-
encoding = "locale" if sys.version_info >= (3, 10) else None
72-
executable = subprocess.check_output(
73-
[path_python, "-c", "import sys; print(sys.executable)"],
74-
text=True,
75-
encoding=encoding,
76-
).strip()
77-
return Path(executable)
78-
79-
except subprocess.CalledProcessError:
80-
return None
81-
82-
@staticmethod
83-
def _detect_active_python(io: IO) -> Path | None:
84-
io.write_error_line(
85-
"Trying to detect current active python executable as specified in"
86-
" the config.",
87-
verbosity=Verbosity.VERBOSE,
88-
)
89-
90-
executable = Python._full_python_path("python")
91-
92-
if executable is not None:
93-
io.write_error_line(f"Found: {executable}", verbosity=Verbosity.VERBOSE)
94-
else:
95-
io.write_error_line(
96-
"Unable to detect the current active python executable. Falling"
97-
" back to default.",
98-
verbosity=Verbosity.VERBOSE,
99-
)
100-
101-
return executable
93+
return cls(python=findpython.PythonVersion(executable=Path(path)))
94+
except (FileNotFoundError, NotADirectoryError, ValueError):
95+
raise ValueError(f"{path} is not a valid Python executable")
10296

10397
@classmethod
10498
def get_system_python(cls) -> Python:
105-
return cls(executable=sys.executable)
99+
return cls(
100+
python=findpython.PythonVersion(
101+
executable=Path(sys.executable),
102+
_version=packaging.version.Version(
103+
".".join(str(v) for v in sys.version_info[:3])
104+
),
105+
)
106+
)
106107

107108
@classmethod
108109
def get_by_name(cls, python_name: str) -> Python | None:
109-
executable = cls._full_python_path(python_name)
110-
if not executable:
111-
return None
112-
113-
return cls(executable=executable)
110+
if python := findpython.find(python_name):
111+
return cls(python=python)
112+
return None
114113

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

119118
if not config.get("virtualenvs.use-poetry-python") and (
120-
active_python := Python._detect_active_python(io)
119+
active_python := Python.get_active_python()
121120
):
122-
return cls(executable=active_python)
121+
io.write_error_line(
122+
f"Found: {active_python.executable}", verbosity=Verbosity.VERBOSE
123+
)
124+
return active_python
123125

124126
return cls.get_system_python()
125127

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

132-
for suffix in [
133-
*sorted(
134-
poetry.package.AVAILABLE_PYTHONS,
135-
key=lambda v: (v.startswith("3"), -len(v), v),
136-
reverse=True,
137-
),
138-
"",
139-
]:
140-
if len(suffix) == 1:
141-
if not parse_constraint(f"^{suffix}.0").allows_any(supported_python):
142-
continue
143-
elif suffix and not supported_python.allows_any(
144-
parse_constraint(suffix + ".*")
145-
):
146-
continue
147-
148-
python_name = f"python{suffix}"
149-
if io.is_debug():
150-
io.write_error_line(f"<debug>Trying {python_name}</debug>")
151-
152-
executable = cls._full_python_path(python_name)
153-
if executable is None:
154-
continue
155-
156-
candidate = cls(executable)
157-
if supported_python.allows(candidate.patch_version):
158-
python = candidate
134+
for candidate in findpython.find_all():
135+
python = cls(python=candidate)
136+
if python.version.allows_any(supported_python):
159137
io.write_error_line(
160-
f"Using <c1>{python_name}</c1> ({python.patch_version})"
138+
f"Using <c1>{candidate.name}</c1> ({python.patch_version})"
161139
)
162-
break
163-
164-
if not python:
165-
raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions)
140+
return python
166141

167-
return python
142+
raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions)

0 commit comments

Comments
 (0)