Skip to content

Commit 1a92276

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

File tree

11 files changed

+322
-290
lines changed

11 files changed

+322
-290
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: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -111,21 +111,12 @@ def base_env_name(self) -> str:
111111
str(self._poetry.file.path.parent),
112112
)
113113

114-
def activate(self, python: str) -> Env:
114+
def activate(self, name: 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:
128-
raise PythonVersionNotFoundError(python)
117+
python = Python.get_by_name(name)
118+
if python is None:
119+
raise PythonVersionNotFoundError(name)
129120

130121
create = False
131122
# If we are required to create the virtual environment in the project directory,
@@ -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.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, force=create)
145136

146137
return self.get(reload=True)
147138

@@ -154,13 +145,13 @@ 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.minor_version.to_string()
149+
and current_patch != python.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()}"
154+
name = f"{self.base_env_name}-py{python.minor_version.to_string()}"
164155
venv = venv_path / name
165156

166157
# Create if needed
@@ -174,15 +165,15 @@ def activate(self, python: str) -> Env:
174165
_venv = VirtualEnv(venv)
175166
current_patch = ".".join(str(v) for v in _venv.version_info[:3])
176167

177-
if python_.patch_version.to_string() != current_patch:
168+
if python.patch_version.to_string() != current_patch:
178169
create = True
179170

180-
self.create_venv(executable=python_.executable, force=create)
171+
self.create_venv(python=python, force=create)
181172

182173
# Activate
183174
envs[self.base_env_name] = {
184-
"minor": python_.minor_version.to_string(),
185-
"patch": python_.patch_version.to_string(),
175+
"minor": python.minor_version.to_string(),
176+
"patch": python.patch_version.to_string(),
186177
}
187178
self.envs_file.write(envs)
188179

@@ -372,7 +363,7 @@ def in_project_venv_exists(self) -> bool:
372363
def create_venv(
373364
self,
374365
name: str | None = None,
375-
executable: Path | None = None,
366+
python: Python | None = None,
376367
force: bool = False,
377368
) -> Env:
378369
if self._env is not None and not force:
@@ -400,11 +391,11 @@ def create_venv(
400391
use_poetry_python = self._poetry.config.get("virtualenvs.use-poetry-python")
401392
venv_prompt = self._poetry.config.get("virtualenvs.prompt")
402393

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

409400
venv_path = (
410401
self.in_project_venv
@@ -422,7 +413,7 @@ def create_venv(
422413
# If an executable has been specified, we stop there
423414
# and notify the user of the incompatibility.
424415
# Otherwise, we try to find a compatible Python version.
425-
if executable and use_poetry_python:
416+
if specific_python_requested and use_poetry_python:
426417
raise NoCompatiblePythonVersionFoundError(
427418
self._poetry.package.python_versions,
428419
python.patch_version.to_string(),
Lines changed: 32 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
11
from __future__ import annotations
22

3-
import shutil
4-
import subprocess
5-
import sys
6-
73
from functools import cached_property
84
from pathlib import Path
95
from typing import TYPE_CHECKING
6+
from typing import cast
7+
8+
import findpython
109

1110
from cleo.io.null_io import NullIO
1211
from cleo.io.outputs.output import Verbosity
1312
from poetry.core.constraints.version import Version
14-
from poetry.core.constraints.version import parse_constraint
1513

16-
from poetry.utils._compat import decode
1714
from poetry.utils.env.exceptions import NoCompatiblePythonVersionFoundError
18-
from poetry.utils.env.script_strings import GET_PYTHON_VERSION_ONELINER
1915

2016

2117
if TYPE_CHECKING:
@@ -26,27 +22,16 @@
2622

2723

2824
class Python:
29-
def __init__(self, executable: str | Path, version: Version | None = None) -> None:
30-
self.executable = Path(executable)
31-
self._version = version
25+
def __init__(self, python: findpython.PythonVersion) -> None:
26+
self._python = python
3227

3328
@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)
29+
def executable(self) -> Path:
30+
return cast(Path, self._python.executable)
4831

49-
return self._version
32+
@property
33+
def version(self) -> Version:
34+
return Version.parse(str(self._python.version))
5035

5136
@cached_property
5237
def patch_version(self) -> Version:
@@ -61,65 +46,37 @@ def minor_version(self) -> Version:
6146
return Version.from_parts(major=self.version.major, minor=self.version.minor)
6247

6348
@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
49+
def get_active_python() -> findpython.PythonVersion | None:
50+
return findpython.find("python")
6951

52+
@classmethod
53+
def from_executable(cls, path: Path | str) -> Python:
7054
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
55+
return cls(findpython.PythonVersion(executable=Path(path)))
56+
except (FileNotFoundError, NotADirectoryError, ValueError):
57+
raise ValueError(f"{path} is not a valid Python executable")
10258

10359
@classmethod
10460
def get_system_python(cls) -> Python:
105-
return cls(executable=sys.executable)
61+
return cls(findpython.find())
10662

10763
@classmethod
10864
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)
65+
if python := findpython.find(python_name):
66+
return cls(python)
67+
return None
11468

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

11973
if not config.get("virtualenvs.use-poetry-python") and (
120-
active_python := Python._detect_active_python(io)
74+
active_python := Python.get_active_python()
12175
):
122-
return cls(executable=active_python)
76+
io.write_error_line(
77+
f"Found: {active_python.executable}", verbosity=Verbosity.VERBOSE
78+
)
79+
return cls(active_python)
12380

12481
return cls.get_system_python()
12582

@@ -129,39 +86,12 @@ def get_compatible_python(cls, poetry: Poetry, io: IO | None = None) -> Python:
12986
supported_python = poetry.package.python_constraint
13087
python = None
13188

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
89+
for candidate in findpython.find_all():
90+
python = cls(candidate)
91+
if python.version.allows_any(supported_python):
15992
io.write_error_line(
160-
f"Using <c1>{python_name}</c1> ({python.patch_version})"
93+
f"Using <c1>{candidate.name}</c1> ({python.patch_version})"
16194
)
162-
break
163-
164-
if not python:
165-
raise NoCompatiblePythonVersionFoundError(poetry.package.python_versions)
95+
return python
16696

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

0 commit comments

Comments
 (0)