Repository: pdm-project/pdm
Pull request #3215 (2024-10-17 02:37:29)

feat: allow linking existing python interpreters to managed location
Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3215?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `94.59459%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 85.61%. Comparing base [(`dc8e3f4`)](https://app.codecov.io/gh/pdm-project/pdm/commit/dc8e3f4140f081eb1c03c8ebfa750bfe7e83c807?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`0c9e541`)](https://app.codecov.io/gh/pdm-project/pdm/commit/0c9e541d4eccaaa83c9f806afe18dc968f5dc053?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/pdm-project/pdm/pull/3215?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/cli/commands/python.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3215?src=pr&el=tree&filepath=src%2Fpdm%2Fcli%2Fcommands%2Fpython.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvcHl0aG9uLnB5) | 94.59% | [0 Missing and 2 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3215?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3215      +/-   ##
==========================================
+ Coverage   85.60%   85.61%   +0.01%     
==========================================
  Files         112      112              
  Lines       11224    11251      +27     
  Branches     2435     2440       +5     
==========================================
+ Hits         9608     9633      +25     
  Misses       1101     1101              
- Partials      515      517       +2     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3215/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/3215/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.42% <94.59%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/3215?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/3215.feature.md b/news/3215.feature.md
new file mode 100644
index 00000000..5065c3fc
--- /dev/null
+++ b/news/3215.feature.md
@@ -0,0 +1,1 @@
+Allow linking existing Python interpreters to PDM's managed location.
diff --git a/pdm.lock b/pdm.lock
index 948c751f..386c1ffa 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -648,7 +648,7 @@ files = [
 
 [[package]]
 name = "findpython"
-version = "0.6.0"
+version = "0.6.2"
 requires_python = ">=3.8"
 summary = "A utility to find python versions on your system"
 groups = ["default"]
@@ -656,8 +656,8 @@ dependencies = [
     "packaging>=20",
 ]
 files = [
-    {file = "findpython-0.6.0-py3-none-any.whl", hash = "sha256:08a2059140dce0d2b48509bad52ac9601f87322c650cf5c1893ed3ede89316eb"},
-    {file = "findpython-0.6.0.tar.gz", hash = "sha256:036a7841b88e2f372433a589b1f09219519737d2989eb5f5370d70afbcf84765"},
+    {file = "findpython-0.6.2-py3-none-any.whl", hash = "sha256:bda62477f858ea623ef2269f5e734469a018104a5f6c0fd9317ba238464ddb76"},
+    {file = "findpython-0.6.2.tar.gz", hash = "sha256:e0c75ba9f35a7f9bb4423eb31bd17358cccf15761b6837317719177aeff46723"},
 ]
 
 [[package]]
diff --git a/src/pdm/cli/commands/python.py b/src/pdm/cli/commands/python.py
index 6e844f0c..75bbe106 100644
--- a/src/pdm/cli/commands/python.py
+++ b/src/pdm/cli/commands/python.py
@@ -4,7 +4,7 @@ import shutil
 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
@@ -32,6 +32,7 @@ class Command(BaseCommand):
         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:
@@ -69,17 +70,24 @@ class RemoveCommand(BaseCommand):
         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)
 
 
@@ -166,3 +174,31 @@ class InstallCommand(BaseCommand):
         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}[/]")


Test patch:
diff --git a/tests/cli/test_python.py b/tests/cli/test_python.py
index 177c3c39..06a52044 100644
--- a/tests/cli/test_python.py
+++ b/tests/cli/test_python.py
@@ -5,6 +5,7 @@ from pathlib import Path
 import pytest
 from pbs_installer import PythonVersion
 
+from pdm.models.python import PythonInfo
 from pdm.utils import parse_version
 
 
@@ -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