Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow linking existing python interpreters to managed location #3215

Merged
merged 3 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/3215.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow linking existing Python interpreters to PDM's managed location.
6 changes: 3 additions & 3 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 48 additions & 12 deletions src/pdm/cli/commands/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

from pdm.cli.commands.base import BaseCommand
from pdm.cli.options import verbose_option
Expand Down Expand Up @@ -32,6 +32,7 @@ def add_arguments(self, parser: ArgumentParser) -> None:
ListCommand.register_to(subparsers, name="list")
RemoveCommand.register_to(subparsers, name="remove")
InstallCommand.register_to(subparsers, name="install")
LinkCommand.register_to(subparsers, name="link")

@classmethod
def register_to(cls, subparsers: _SubParsersAction, name: str | None = None, **kwargs: Any) -> None:
Expand Down Expand Up @@ -69,17 +70,24 @@ def handle(self, project: Project, options: Namespace) -> None:
if not root.exists():
ui.error(f"No Python interpreter found for {options.version!r}")
sys.exit(1)
version = options.version.lower()
if "@" not in version: # pragma: no cover
version = f"cpython@{version}"
matched = next((child for child in root.iterdir() if child.name == version), None)
if not matched:
ui.error(f"No Python interpreter found for {options.version!r}")
ui.echo("Installed Pythons:", err=True)
for child in root.iterdir():
ui.echo(f" {child.name}", err=True)
sys.exit(1)
shutil.rmtree(matched, ignore_errors=True)
version = str(options.version)
if root.joinpath(version).exists():
version_dir = root.joinpath(version)
else:
version = options.version.lower()
if "@" not in version: # pragma: no cover
version = f"cpython@{version}"
version_dir = root.joinpath(version)
if not version_dir.exists():
ui.error(f"No Python interpreter found for {options.version!r}")
ui.echo("Installed Pythons:", err=True)
for child in root.iterdir():
ui.echo(f" {child.name}", err=True)
sys.exit(1)
if version_dir.is_symlink():
version_dir.unlink()
else:
shutil.rmtree(version_dir, ignore_errors=True)
ui.echo(f"[success]Removed installed[/] {options.version}", verbosity=Verbosity.NORMAL)


Expand Down Expand Up @@ -166,3 +174,31 @@ def install_python(project: Project, request: str) -> PythonInfo:
ui.echo(f"[info]Version:[/] {python_info.version}", verbosity=Verbosity.NORMAL)
ui.echo(f"[info]Executable:[/] {python_info.path}", verbosity=Verbosity.NORMAL)
return python_info


class LinkCommand(BaseCommand):
"""Link an external Python interpreter to PDM"""

arguments = (verbose_option,)

def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument("interpreter", help="The path to the Python interpreter to link")
parser.add_argument("--name", help="The name of the link")

def handle(self, project: Project, options: Namespace) -> None:
python_info = PythonInfo.from_path(options.interpreter)
if not python_info.valid:
raise PdmArgumentError("Invalid Python interpreter")
if options.name is None:
link_name = f"{python_info.implementation}@{python_info.identifier}"
else:
link_name = cast(str, options.name)
link_path = Path(project.config["python.install_root"]).expanduser() / link_name
if link_path.exists():
raise PdmArgumentError(f"Link {link_name} already exists")
exe_dir = python_info.path.parent
if exe_dir.name in ("Scripts", "bin"):
exe_dir = exe_dir.parent
link_path.parent.mkdir(parents=True, exist_ok=True)
link_path.symlink_to(exe_dir)
project.core.ui.echo(f"[success]Successfully linked {link_name} to {exe_dir}[/]")
31 changes: 31 additions & 0 deletions tests/cli/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from pbs_installer import PythonVersion

from pdm.models.python import PythonInfo
from pdm.utils import parse_version


Expand Down Expand Up @@ -155,3 +156,33 @@ def test_use_auto_install_strategy_min(project, pdm, mock_install, mocker):
mock_find_interpreters.assert_not_called()
mock_best_match.assert_called_once_with(True)
assert len(list(root.iterdir())) == 1


def test_link_python(project, pdm):
root = Path(project.config["python.install_root"])
pdm(["python", "link", sys.executable], obj=project, strict=True)
python_info = PythonInfo.from_path(sys.executable)
link_name = f"{python_info.implementation}@{python_info.identifier}"
assert (root / link_name).resolve() == Path(sys.prefix).resolve()

pdm(["python", "remove", link_name], obj=project, strict=True)
assert not (root / link_name).exists()

pdm(["python", "link", sys.executable, "--name", "foo"], obj=project, strict=True)
assert (root / "foo").resolve() == Path(sys.prefix).resolve()

pdm(["python", "remove", "foo"], obj=project, strict=True)
assert not (root / "foo").exists()


def test_link_python_invalid_interpreter(project, pdm):
result = pdm(["python", "link", "/path/to/invalid/python"], obj=project)
assert result.exit_code != 0
assert "Invalid Python interpreter" in result.stderr

root = Path(project.config["python.install_root"])
root.mkdir(parents=True, exist_ok=True)
root.joinpath("foo").touch()
result = pdm(["python", "link", sys.executable, "--name", "foo"], obj=project)
assert result.exit_code != 0
assert "Link foo already exists" in result.stderr
Loading