Skip to content

Commit

Permalink
feat: allow linking existing python interpreters to managed location (#…
Browse files Browse the repository at this point in the history
…3215)

* feat: allow linking existing python interpreters to managed location

Signed-off-by: Frost Ming <[email protected]>

* add news

Signed-off-by: Frost Ming <[email protected]>

* fix: update findpython

Signed-off-by: Frost Ming <[email protected]>
  • Loading branch information
frostming authored Oct 17, 2024
1 parent 039c9a0 commit d5b0bc9
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 15 deletions.
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

0 comments on commit d5b0bc9

Please sign in to comment.