Skip to content

Commit 3d4aa3f

Browse files
committed
experiment: pbs-installer
1 parent 8ee22b5 commit 3d4aa3f

File tree

11 files changed

+759
-12
lines changed

11 files changed

+759
-12
lines changed

poetry.lock

Lines changed: 234 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies = [
2828
"virtualenv (>=20.26.6,<21.0.0)",
2929
"xattr (>=1.0.0,<2.0.0) ; sys_platform == 'darwin'",
3030
"findpython (>=0.6.2,<0.7.0)",
31+
"pbs-installer[download,install] (>=2025.1.6,<2026.0.0)",
3132
]
3233
authors = [
3334
{ name = "Sébastien Eustace", email = "[email protected]" }

src/poetry/config/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from poetry.config.file_config_source import FileConfigSource
1818
from poetry.locations import CONFIG_DIR
1919
from poetry.locations import DEFAULT_CACHE_DIR
20+
from poetry.locations import data_dir
2021
from poetry.toml import TOMLFile
2122

2223

@@ -107,6 +108,7 @@ def validator(cls, policy: str) -> bool:
107108
class Config:
108109
default_config: ClassVar[dict[str, Any]] = {
109110
"cache-dir": str(DEFAULT_CACHE_DIR),
111+
"data-dir": str(data_dir()),
110112
"virtualenvs": {
111113
"create": True,
112114
"in-project": None,
@@ -129,6 +131,7 @@ class Config:
129131
"no-binary": None,
130132
"only-binary": None,
131133
},
134+
"python": {"installation-dir": os.path.join("{data-dir}", "python")},
132135
"solver": {
133136
"lazy-wheel": True,
134137
},
@@ -223,6 +226,13 @@ def virtualenvs_path(self) -> Path:
223226
path = Path(self.get("cache-dir")) / "virtualenvs"
224227
return Path(path).expanduser()
225228

229+
@property
230+
def python_installation_dir(self) -> Path:
231+
path = self.get("python.installation-dir")
232+
if path is None:
233+
path = Path(self.get("cache-dir")) / "python"
234+
return Path(path).expanduser()
235+
226236
@property
227237
def installer_max_workers(self) -> int:
228238
# This should be directly handled by ThreadPoolExecutor

src/poetry/console/application.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ def _load() -> Command:
8080
"env list",
8181
"env remove",
8282
"env use",
83+
# Python commands,
84+
"python install",
85+
"python list",
86+
"python remove",
8387
# Self commands
8488
"self add",
8589
"self install",

src/poetry/console/commands/python/__init__.py

Whitespace-only changes.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
from __future__ import annotations
2+
3+
from subprocess import CalledProcessError
4+
from typing import TYPE_CHECKING
5+
from typing import ClassVar
6+
from typing import cast
7+
8+
import pbs_installer as pbi
9+
10+
from cleo.helpers import argument
11+
from cleo.helpers import option
12+
from poetry.core.constraints.version.version import Version
13+
from poetry.core.version.exceptions import InvalidVersionError
14+
15+
from poetry.config.config import Config
16+
from poetry.console.commands.command import Command
17+
from poetry.console.commands.python.remove import PythonRemoveCommand
18+
from poetry.console.exceptions import ConsoleMessage
19+
from poetry.console.exceptions import PoetryRuntimeError
20+
from poetry.utils.env.python_manager import PoetryPythonPathProvider
21+
22+
23+
if TYPE_CHECKING:
24+
from cleo.io.inputs.argument import Argument
25+
from cleo.io.inputs.option import Option
26+
27+
28+
BAD_PYTHON_INSTALL_INFO = [
29+
"This could happen because you are missing platform dependencies required.",
30+
"Please refer to https://gregoryszorc.com/docs/python-build-standalone/main/running.html#runtime-requirements "
31+
"for more information about the necessary requirements.",
32+
"Please remove the failing Python installation using <c1>poetry python remove <version></> before continuing.",
33+
]
34+
35+
36+
class PythonInstallCommand(Command):
37+
name = "python install"
38+
39+
arguments: ClassVar[list[Argument]] = [
40+
argument("python", "The python version to install.")
41+
]
42+
43+
options: ClassVar[list[Option]] = [
44+
option("clean", "c", "Cleanup installation if check fails.", flag=True),
45+
option(
46+
"free-threaded", "f", "Use free-threaded version if available.", flag=True
47+
),
48+
option(
49+
"implementation",
50+
"i",
51+
"Python implementation to use. (cpython, pypy)",
52+
flag=False,
53+
default="cpython",
54+
),
55+
option(
56+
"reinstall", "r", "Reinstall if installation already exists.", flag=True
57+
),
58+
]
59+
60+
description = "Install the specified Python version from the Python Standalone Builds project."
61+
62+
def handle(self) -> int:
63+
request = self.argument("python")
64+
impl = self.option("implementation").lower()
65+
reinstall = self.option("reinstall")
66+
free_threaded = self.option("free-threaded")
67+
68+
try:
69+
version = Version.parse(request)
70+
except (ValueError, InvalidVersionError):
71+
self.io.write_error_line(
72+
f"<error>Invalid Python version requested <b>{request}</></error>"
73+
)
74+
return 1
75+
76+
if free_threaded and version < Version.parse("3.13.0"):
77+
self.io.write_error_line("")
78+
self.io.write_error_line(
79+
"Free threading is not supported for Python versions prior to <c1>3.13.0</>.\n\n"
80+
"See https://docs.python.org/3/howto/free-threading-python.html for more information."
81+
)
82+
self.io.write_error_line("")
83+
return 1
84+
85+
try:
86+
pyver, _ = pbi.get_download_link(
87+
request, implementation=impl, free_threaded=free_threaded
88+
)
89+
except ValueError:
90+
self.io.write_error_line(
91+
"No suitable standalone build found for the requested Python version."
92+
)
93+
return 1
94+
95+
version = Version.from_parts(
96+
major=pyver.major, minor=pyver.minor, patch=pyver.micro
97+
)
98+
99+
provider: PoetryPythonPathProvider = cast(
100+
PoetryPythonPathProvider, PoetryPythonPathProvider.create()
101+
)
102+
bad_executables = set()
103+
104+
for python in provider.find_pythons():
105+
try:
106+
if python.implementation.lower() != impl:
107+
continue
108+
109+
if version == Version.parse(str(python.version)):
110+
if reinstall:
111+
break
112+
self.io.write_line(
113+
"Python version already installed at "
114+
f"<b>{PoetryPythonPathProvider.installation_dir(version, impl)}</>.\n"
115+
)
116+
self.io.write_line(
117+
f"Use <c1>--reinstall</> to install anyway, "
118+
f"or use <c1>poetry python remove {version}</> first."
119+
)
120+
return 1
121+
except CalledProcessError:
122+
bad_executables.add(python.executable)
123+
124+
if bad_executables:
125+
raise PoetryRuntimeError(
126+
reason="One or more installed version do not work on your system. This is not a Poetry issue.",
127+
messages=[
128+
ConsoleMessage("\n".join(e.as_posix() for e in bad_executables))
129+
.indent(" - ")
130+
.make_section("Failing Executables")
131+
.wrap("info"),
132+
*[
133+
ConsoleMessage(m).wrap("warning")
134+
for m in BAD_PYTHON_INSTALL_INFO
135+
],
136+
],
137+
)
138+
139+
request_title = f"<c1>{request}</> (<b>{impl}</>)"
140+
141+
try:
142+
self.io.write(f"Downloading and installing {request_title} ... ")
143+
# this can be broken into download, and install_file if required to make
144+
# use of Poetry's own mechanics for download and unpack
145+
pbi.install(
146+
request,
147+
Config().python_installation_dir,
148+
True,
149+
implementation=impl,
150+
free_threaded=free_threaded,
151+
)
152+
except ValueError:
153+
self.io.write("<fg=red>Failed</>\n")
154+
self.io.write_error_line("")
155+
self.io.write_error_line(
156+
"No suitable standalone build found for the requested Python version."
157+
)
158+
self.io.write_error_line("")
159+
return 1
160+
161+
self.io.write("<fg=green>Done</>\n")
162+
163+
self.io.write(f"Testing {request_title} ... ")
164+
165+
provider = PoetryPythonPathProvider(
166+
PoetryPythonPathProvider.installation_bin_paths(version, impl)
167+
)
168+
169+
for python in provider.find_pythons():
170+
try:
171+
# this forces a python -c command internally in pbs-installer
172+
_ = python.version
173+
except CalledProcessError as e:
174+
self.io.write("<fg=red>Failed</>\n")
175+
176+
installation_dir = PoetryPythonPathProvider.installation_dir(
177+
version, impl
178+
)
179+
if installation_dir.exists() and self.option("clean"):
180+
PythonRemoveCommand.remove_python_installation(
181+
request, impl, self.io
182+
)
183+
184+
raise PoetryRuntimeError.create(
185+
reason="The installed version did not work on your system. This is not a Poetry issue.",
186+
exception=e,
187+
info=[
188+
ConsoleMessage(f"{m}\n").wrap("info").text
189+
for m in BAD_PYTHON_INSTALL_INFO
190+
],
191+
)
192+
193+
self.io.write("<fg=green>Done</>\n")
194+
195+
return 0
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
from typing import ClassVar
5+
from typing import NamedTuple
6+
7+
from cleo.helpers import argument
8+
from cleo.helpers import option
9+
from pbs_installer._install import THIS_ARCH
10+
from pbs_installer._install import THIS_PLATFORM
11+
from pbs_installer._versions import PYTHON_VERSIONS
12+
13+
from poetry.config.config import Config
14+
from poetry.console.commands.command import Command
15+
from poetry.utils.env.python_manager import Python
16+
17+
18+
if TYPE_CHECKING:
19+
from pathlib import Path
20+
21+
from cleo.io.inputs.argument import Argument
22+
from cleo.io.inputs.option import Option
23+
24+
25+
class PythonInfo(NamedTuple):
26+
major: int
27+
minor: int
28+
patch: int
29+
implementation: str
30+
executable: Path | None
31+
32+
33+
class PythonListCommand(Command):
34+
name = "python list"
35+
36+
arguments: ClassVar[list[Argument]] = [
37+
argument("version", "Python version to search for.", optional=True)
38+
]
39+
40+
options: ClassVar[list[Option]] = [
41+
option(
42+
"all",
43+
"a",
44+
"List all versions, including those available for download.",
45+
flag=True,
46+
),
47+
option(
48+
"implementation", "i", "Python implementation to search for.", flag=False
49+
),
50+
option("managed", "m", "List only Poetry managed Python versions.", flag=True),
51+
]
52+
53+
description = "Shows Python versions available for this environment."
54+
55+
def handle(self) -> int:
56+
rows: list[PythonInfo] = []
57+
58+
for pv in Python.find_all():
59+
rows.append(
60+
PythonInfo(
61+
major=pv.major,
62+
minor=pv.minor,
63+
patch=pv.patch,
64+
implementation=pv.implementation.lower(),
65+
executable=pv.executable,
66+
)
67+
)
68+
69+
if self.option("all"):
70+
for pv in PYTHON_VERSIONS:
71+
for _ in {
72+
k[1]
73+
for k in PYTHON_VERSIONS[pv]
74+
if (k[0], k[1]) == (THIS_PLATFORM, THIS_ARCH)
75+
}:
76+
rows.append(
77+
PythonInfo(
78+
major=pv.major,
79+
minor=pv.minor,
80+
patch=pv.micro,
81+
implementation=pv.implementation.lower(),
82+
executable=None,
83+
)
84+
)
85+
86+
rows.sort(
87+
key=lambda x: (x.major, x.minor, x.patch, x.implementation), reverse=True
88+
)
89+
90+
table = self.table(style="compact")
91+
table.set_headers(
92+
[
93+
"<fg=magenta;options=bold>Version</>",
94+
"<fg=magenta;options=bold>Implementation</>",
95+
"<fg=magenta;options=bold>Manager</>",
96+
"<fg=magenta;options=bold>Path</>",
97+
]
98+
)
99+
100+
implementations = {"cpython": "CPython", "pypy": "PyPy"}
101+
python_installation_path = Config().python_installation_dir
102+
103+
row_count = 0
104+
105+
for pv in rows:
106+
version = f"{pv.major}.{pv.minor}.{pv.patch}"
107+
implementation = implementations.get(
108+
pv.implementation.lower(), pv.implementation
109+
)
110+
is_poetry_managed = (
111+
pv.executable is None
112+
or pv.executable.resolve().is_relative_to(python_installation_path)
113+
)
114+
115+
if self.option("managed") and not is_poetry_managed:
116+
continue
117+
118+
manager = (
119+
"<fg=blue>Poetry</>" if is_poetry_managed else "<fg=yellow>System</>"
120+
)
121+
path = (
122+
f"<fg=green>{pv.executable.as_posix()}</>"
123+
if pv.executable
124+
else "Downloadable."
125+
)
126+
127+
table.add_row(
128+
[
129+
f"<c1>{version}</>",
130+
f"<b>{implementation}</>",
131+
f"{manager}",
132+
f"{path}",
133+
]
134+
)
135+
row_count += 1
136+
137+
if row_count > 0:
138+
table.render()
139+
else:
140+
self.io.write_line("No Python installations found.")
141+
142+
return 0

0 commit comments

Comments
 (0)