Skip to content

Commit

Permalink
experiment: pbs-installer
Browse files Browse the repository at this point in the history
  • Loading branch information
abn committed Jan 27, 2025
1 parent 8ee22b5 commit 3d4aa3f
Show file tree
Hide file tree
Showing 11 changed files with 759 additions and 12 deletions.
239 changes: 234 additions & 5 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
"virtualenv (>=20.26.6,<21.0.0)",
"xattr (>=1.0.0,<2.0.0) ; sys_platform == 'darwin'",
"findpython (>=0.6.2,<0.7.0)",
"pbs-installer[download,install] (>=2025.1.6,<2026.0.0)",
]
authors = [
{ name = "Sébastien Eustace", email = "sebastien@eustace.io" }
Expand Down
10 changes: 10 additions & 0 deletions src/poetry/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from poetry.config.file_config_source import FileConfigSource
from poetry.locations import CONFIG_DIR
from poetry.locations import DEFAULT_CACHE_DIR
from poetry.locations import data_dir
from poetry.toml import TOMLFile


Expand Down Expand Up @@ -107,6 +108,7 @@ def validator(cls, policy: str) -> bool:
class Config:
default_config: ClassVar[dict[str, Any]] = {
"cache-dir": str(DEFAULT_CACHE_DIR),
"data-dir": str(data_dir()),
"virtualenvs": {
"create": True,
"in-project": None,
Expand All @@ -129,6 +131,7 @@ class Config:
"no-binary": None,
"only-binary": None,
},
"python": {"installation-dir": os.path.join("{data-dir}", "python")},
"solver": {
"lazy-wheel": True,
},
Expand Down Expand Up @@ -223,6 +226,13 @@ def virtualenvs_path(self) -> Path:
path = Path(self.get("cache-dir")) / "virtualenvs"
return Path(path).expanduser()

@property
def python_installation_dir(self) -> Path:
path = self.get("python.installation-dir")
if path is None:
path = Path(self.get("cache-dir")) / "python"
return Path(path).expanduser()

@property
def installer_max_workers(self) -> int:
# This should be directly handled by ThreadPoolExecutor
Expand Down
4 changes: 4 additions & 0 deletions src/poetry/console/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ def _load() -> Command:
"env list",
"env remove",
"env use",
# Python commands,
"python install",
"python list",
"python remove",
# Self commands
"self add",
"self install",
Expand Down
Empty file.
195 changes: 195 additions & 0 deletions src/poetry/console/commands/python/install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
from __future__ import annotations

from subprocess import CalledProcessError
from typing import TYPE_CHECKING
from typing import ClassVar
from typing import cast

import pbs_installer as pbi

from cleo.helpers import argument
from cleo.helpers import option
from poetry.core.constraints.version.version import Version
from poetry.core.version.exceptions import InvalidVersionError

from poetry.config.config import Config
from poetry.console.commands.command import Command
from poetry.console.commands.python.remove import PythonRemoveCommand
from poetry.console.exceptions import ConsoleMessage
from poetry.console.exceptions import PoetryRuntimeError
from poetry.utils.env.python_manager import PoetryPythonPathProvider


if TYPE_CHECKING:
from cleo.io.inputs.argument import Argument
from cleo.io.inputs.option import Option


BAD_PYTHON_INSTALL_INFO = [
"This could happen because you are missing platform dependencies required.",
"Please refer to https://gregoryszorc.com/docs/python-build-standalone/main/running.html#runtime-requirements "
"for more information about the necessary requirements.",
"Please remove the failing Python installation using <c1>poetry python remove <version></> before continuing.",
]


class PythonInstallCommand(Command):
name = "python install"

arguments: ClassVar[list[Argument]] = [
argument("python", "The python version to install.")
]

options: ClassVar[list[Option]] = [
option("clean", "c", "Cleanup installation if check fails.", flag=True),
option(
"free-threaded", "f", "Use free-threaded version if available.", flag=True
),
option(
"implementation",
"i",
"Python implementation to use. (cpython, pypy)",
flag=False,
default="cpython",
),
option(
"reinstall", "r", "Reinstall if installation already exists.", flag=True
),
]

description = "Install the specified Python version from the Python Standalone Builds project."

def handle(self) -> int:
request = self.argument("python")
impl = self.option("implementation").lower()
reinstall = self.option("reinstall")
free_threaded = self.option("free-threaded")

try:
version = Version.parse(request)
except (ValueError, InvalidVersionError):
self.io.write_error_line(
f"<error>Invalid Python version requested <b>{request}</></error>"
)
return 1

if free_threaded and version < Version.parse("3.13.0"):
self.io.write_error_line("")
self.io.write_error_line(
"Free threading is not supported for Python versions prior to <c1>3.13.0</>.\n\n"
"See https://docs.python.org/3/howto/free-threading-python.html for more information."
)
self.io.write_error_line("")
return 1

try:
pyver, _ = pbi.get_download_link(
request, implementation=impl, free_threaded=free_threaded
)
except ValueError:
self.io.write_error_line(
"No suitable standalone build found for the requested Python version."
)
return 1

version = Version.from_parts(
major=pyver.major, minor=pyver.minor, patch=pyver.micro
)

provider: PoetryPythonPathProvider = cast(
PoetryPythonPathProvider, PoetryPythonPathProvider.create()
)
bad_executables = set()

for python in provider.find_pythons():
try:
if python.implementation.lower() != impl:
continue

if version == Version.parse(str(python.version)):
if reinstall:
break
self.io.write_line(
"Python version already installed at "
f"<b>{PoetryPythonPathProvider.installation_dir(version, impl)}</>.\n"
)
self.io.write_line(
f"Use <c1>--reinstall</> to install anyway, "
f"or use <c1>poetry python remove {version}</> first."
)
return 1
except CalledProcessError:
bad_executables.add(python.executable)

if bad_executables:
raise PoetryRuntimeError(
reason="One or more installed version do not work on your system. This is not a Poetry issue.",
messages=[
ConsoleMessage("\n".join(e.as_posix() for e in bad_executables))
.indent(" - ")
.make_section("Failing Executables")
.wrap("info"),
*[
ConsoleMessage(m).wrap("warning")
for m in BAD_PYTHON_INSTALL_INFO
],
],
)

request_title = f"<c1>{request}</> (<b>{impl}</>)"

try:
self.io.write(f"Downloading and installing {request_title} ... ")
# this can be broken into download, and install_file if required to make
# use of Poetry's own mechanics for download and unpack
pbi.install(
request,
Config().python_installation_dir,
True,
implementation=impl,
free_threaded=free_threaded,
)
except ValueError:
self.io.write("<fg=red>Failed</>\n")
self.io.write_error_line("")
self.io.write_error_line(
"No suitable standalone build found for the requested Python version."
)
self.io.write_error_line("")
return 1

self.io.write("<fg=green>Done</>\n")

self.io.write(f"Testing {request_title} ... ")

provider = PoetryPythonPathProvider(
PoetryPythonPathProvider.installation_bin_paths(version, impl)
)

for python in provider.find_pythons():
try:
# this forces a python -c command internally in pbs-installer
_ = python.version
except CalledProcessError as e:
self.io.write("<fg=red>Failed</>\n")

installation_dir = PoetryPythonPathProvider.installation_dir(
version, impl
)
if installation_dir.exists() and self.option("clean"):
PythonRemoveCommand.remove_python_installation(
request, impl, self.io
)

raise PoetryRuntimeError.create(
reason="The installed version did not work on your system. This is not a Poetry issue.",
exception=e,
info=[
ConsoleMessage(f"{m}\n").wrap("info").text
for m in BAD_PYTHON_INSTALL_INFO
],
)

self.io.write("<fg=green>Done</>\n")

return 0
142 changes: 142 additions & 0 deletions src/poetry/console/commands/python/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import ClassVar
from typing import NamedTuple

from cleo.helpers import argument
from cleo.helpers import option
from pbs_installer._install import THIS_ARCH
from pbs_installer._install import THIS_PLATFORM
from pbs_installer._versions import PYTHON_VERSIONS

from poetry.config.config import Config
from poetry.console.commands.command import Command
from poetry.utils.env.python_manager import Python


if TYPE_CHECKING:
from pathlib import Path

from cleo.io.inputs.argument import Argument
from cleo.io.inputs.option import Option


class PythonInfo(NamedTuple):
major: int
minor: int
patch: int
implementation: str
executable: Path | None


class PythonListCommand(Command):
name = "python list"

arguments: ClassVar[list[Argument]] = [
argument("version", "Python version to search for.", optional=True)
]

options: ClassVar[list[Option]] = [
option(
"all",
"a",
"List all versions, including those available for download.",
flag=True,
),
option(
"implementation", "i", "Python implementation to search for.", flag=False
),
option("managed", "m", "List only Poetry managed Python versions.", flag=True),
]

description = "Shows Python versions available for this environment."

def handle(self) -> int:
rows: list[PythonInfo] = []

for pv in Python.find_all():
rows.append(
PythonInfo(
major=pv.major,
minor=pv.minor,
patch=pv.patch,
implementation=pv.implementation.lower(),
executable=pv.executable,
)
)

if self.option("all"):
for pv in PYTHON_VERSIONS:
for _ in {
k[1]
for k in PYTHON_VERSIONS[pv]
if (k[0], k[1]) == (THIS_PLATFORM, THIS_ARCH)
}:
rows.append(
PythonInfo(
major=pv.major,
minor=pv.minor,
patch=pv.micro,
implementation=pv.implementation.lower(),
executable=None,
)
)

rows.sort(
key=lambda x: (x.major, x.minor, x.patch, x.implementation), reverse=True
)

table = self.table(style="compact")
table.set_headers(
[
"<fg=magenta;options=bold>Version</>",
"<fg=magenta;options=bold>Implementation</>",
"<fg=magenta;options=bold>Manager</>",
"<fg=magenta;options=bold>Path</>",
]
)

implementations = {"cpython": "CPython", "pypy": "PyPy"}
python_installation_path = Config().python_installation_dir

row_count = 0

for pv in rows:
version = f"{pv.major}.{pv.minor}.{pv.patch}"
implementation = implementations.get(
pv.implementation.lower(), pv.implementation
)
is_poetry_managed = (
pv.executable is None
or pv.executable.resolve().is_relative_to(python_installation_path)
)

if self.option("managed") and not is_poetry_managed:
continue

manager = (
"<fg=blue>Poetry</>" if is_poetry_managed else "<fg=yellow>System</>"
)
path = (
f"<fg=green>{pv.executable.as_posix()}</>"
if pv.executable
else "Downloadable."
)

table.add_row(
[
f"<c1>{version}</>",
f"<b>{implementation}</>",
f"{manager}",
f"{path}",
]
)
row_count += 1

if row_count > 0:
table.render()
else:
self.io.write_line("No Python installations found.")

return 0
Loading

0 comments on commit 3d4aa3f

Please sign in to comment.