Skip to content

Commit

Permalink
feat(cli): introduce python management commands
Browse files Browse the repository at this point in the history
  • Loading branch information
abn committed Feb 2, 2025
1 parent 8e65cbc commit 3589a61
Show file tree
Hide file tree
Showing 17 changed files with 1,083 additions and 31 deletions.
50 changes: 50 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,56 @@ Should match a repository name set by the [`config`](#config) command.
See [Configuring Credentials]({{< relref "repositories/#configuring-credentials" >}}) for more information on how to configure credentials.
{{% /note %}}

## python

The `python` namespace groups subcommands to manage Python versions.

{{% warning %}}
This is an experimental feature, and can change behaviour in upcoming releases.
{{% /warning %}}

*Introduced in 2.1.0*

### python install

The `python install` command installs the specified Python version from the Python Standalone Builds project.

```bash
poetry python install <PYTHON_VERSION>
```

#### Options

* `--clean`: Cleanup installation if check fails.
* `--free-threaded`: Use free-threaded version if available.
* `--implementation`: Python implementation to use. (cpython, pypy)
* `--reinstall`: Reinstall if installation already exists.

### python list

The `python list` command shows Python versions available in the environment. This includes both installed and
discovered System Managed and Poetry Managed installations.

```bash
poetry python list
```
#### Options
* `--all`: List all versions, including those available for download.
* `--implementation`: Python implementation to search for.
* `--managed`: List only Poetry managed Python versions.

### python remove

The `python remove` command removes the specified Python version if managed by Poetry.

```bash
poetry python remove <PYTHON_VERSION>
```

#### Options

* `--implementation`: Python implementation to use. (cpython, pypy)

## remove

The `remove` command removes a package from the current
Expand Down
27 changes: 27 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,21 @@ Defaults to one of the following directories:
- Windows: `C:\Users\<username>\AppData\Local\pypoetry\Cache`
- Unix: `~/.cache/pypoetry`

### `data-dir`

**Type**: `string`

**Environment Variable**: `POETRY_DATA_DIR`

The path to the data directory used by Poetry.

- Linux: `$XDG_DATA_HOME/pypoetry` or `~/.local/share/pypoetry`
- Windows: `%APPDATA%\pypoetry`
- macOS: `~/Library/Application Support/pypoetry`

You can override the Data directory by setting the `POETRY_DATA_DIR` or `POETRY_HOME` environment variables. If
`POETRY_HOME` is set, it will be given higher priority.

### `installer.max-workers`

**Type**: `int`
Expand Down Expand Up @@ -342,6 +357,18 @@ If the config option is _not_ set and the lock file is at least version 2.1
but evaluate the locked markers to decide which of the locked dependencies have to
be installed into the target environment.

### `python.installation-dir`

**Type**: `string`

**Default**: `{data-dir}/python`

**Environment Variable**: `POETRY_PYTHON_INSTALLATION_DIR`

*Introduced in 2.1.0*

The directory in which Poetry managed Python versions are installed to.

### `solver.lazy-wheel`

**Type**: `boolean`
Expand Down
273 changes: 251 additions & 22 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 @@ -20,6 +20,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 @@ -143,6 +144,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 @@ -166,6 +168,7 @@ class Config:
"only-binary": None,
"build-config-settings": {},
},
"python": {"installation-dir": os.path.join("{data-dir}", "python")},
"solver": {
"lazy-wheel": True,
},
Expand Down Expand Up @@ -280,6 +283,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 @@ -82,6 +82,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.
120 changes: 120 additions & 0 deletions src/poetry/console/commands/python/install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import ClassVar

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.console.commands.command import Command
from poetry.console.commands.python.remove import PythonRemoveCommand
from poetry.console.exceptions import PoetryRuntimeError
from poetry.utils.env.python.installer import PythonDownloadNotFoundError
from poetry.utils.env.python.installer import PythonInstallationError
from poetry.utils.env.python.installer import PythonInstaller
from poetry.utils.env.python.providers import PoetryPythonPathProvider


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


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

installer = PythonInstaller(request, impl, free_threaded)

try:
if installer.exists() and not reinstall:
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 PythonDownloadNotFoundError:
self.io.write_error_line(
"No suitable standalone build found for the requested Python version."
)
return 1

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

try:
self.io.write(f"Downloading and installing {request_title} ... ")
installer.install()
except PythonInstallationError as e:
self.io.write("<fg=red>Failed</>\n")
self.io.write_error_line("")
self.io.write_error_line(str(e))
self.io.write_error_line("")
return 1

self.io.write("<fg=green>Done</>\n")
self.io.write(f"Testing {request_title} ... ")

try:
installer.exists()
except PoetryRuntimeError as e:
self.io.write("<fg=red>Failed</>\n")

if installer.installation_directory.exists() and self.option("clean"):
PythonRemoveCommand.remove_python_installation(request, impl, self.io)

raise e

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

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

from typing import TYPE_CHECKING
from typing import ClassVar

from cleo.helpers import argument
from cleo.helpers import option
from poetry.core.constraints.version import parse_constraint

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


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

from poetry.utils.env.python.manager import PythonInfo


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] = []
constraint = None

if self.argument("version"):
constraint = parse_constraint(f"~{self.argument('version')}")

for info in Python.find_all_versions(constraint=constraint):
rows.append(info)

if self.option("all"):
for info in Python.find_downloadable_versions(constraint=constraint):
rows.append(info)

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 "Available for download"
)

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 3589a61

Please sign in to comment.