Repository: pdm-project/pdm
Pull request #2720 (2024-03-26 08:23:06)

feat: create venv when switching python versions
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/2720?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 `87.50000%` with `2 lines` in your changes are missing coverage. Please review.
> Project coverage is 84.89%. Comparing base [(`ba72e45`)](https://app.codecov.io/gh/pdm-project/pdm/commit/ba72e45970b241c16fa4e42fd2461b44fdf34e9f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`235541a`)](https://app.codecov.io/gh/pdm-project/pdm/pull/2720?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

| [Files](https://app.codecov.io/gh/pdm-project/pdm/pull/2720?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/project/core.py](https://app.codecov.io/gh/pdm-project/pdm/pull/2720?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2NvcmUucHk=) | 71.42% | [0 Missing and 2 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/2720?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    #2720   +/-   ##
=======================================
  Coverage   84.89%   84.89%           
=======================================
  Files         105      105           
  Lines       10417    10414    -3     
  Branches     2272     2271    -1     
=======================================
- Hits         8843     8841    -2     
  Misses       1101     1101           
+ Partials      473      472    -1     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/2720/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/2720/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `84.68% <87.50%> (+<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/2720?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/docs/docs/usage/venv.md b/docs/docs/usage/venv.md
index 2aa9de1e..20f4ea96 100644
--- a/docs/docs/usage/venv.md
+++ b/docs/docs/usage/venv.md
@@ -18,6 +18,10 @@ You can choose the backend used by PDM to create a virtualenv. Currently it supp
 
 You can change it by `pdm config venv.backend [virtualenv|venv|conda]`.
 
++++ 2.13.0
+
+    Moreover, when `python.use_venv` config is set to `true`, PDM will always try to create a virtualenv when using `pdm use` to switch the Python interpreter.
+
 ## Create a virtualenv yourself
 
 You can create more than one virtualenvs with whatever Python version you want.
@@ -127,8 +131,8 @@ For more CLI usage, see the [`pdm venv`](../reference/cli.md#venv) documentation
       }
       ```
 
-      Copy and paste this function to your `~/.bashrc` file and restart your shell. 
-      
+      Copy and paste this function to your `~/.bashrc` file and restart your shell.
+
       For `fish` shell you can put the following into your `~/fish/config.fish` or in `~/.config/fish/config.fish`
 
       ```fish
@@ -142,7 +146,7 @@ For more CLI usage, see the [`pdm venv`](../reference/cli.md#venv) documentation
             end
         end
       ```
-      
+
       Now you can run `pdm shell` to activate the virtualenv.
       **The virtualenv can be deactivated with `deactivate` command as usual.**
 
diff --git a/news/2720.feature.md b/news/2720.feature.md
new file mode 100644
index 00000000..242d5792
--- /dev/null
+++ b/news/2720.feature.md
@@ -0,0 +1,1 @@
+When `python.use_venv` is on, always try to create a virtualenv when using `pdm use` to switch the Python interpreter.
diff --git a/src/pdm/cli/commands/init.py b/src/pdm/cli/commands/init.py
index d80669a7..71590bcc 100644
--- a/src/pdm/cli/commands/init.py
+++ b/src/pdm/cli/commands/init.py
@@ -11,12 +11,9 @@ from pdm.cli.options import skip_option
 from pdm.cli.templates import ProjectTemplate
 from pdm.exceptions import PdmUsageError
 from pdm.models.backends import _BACKENDS, DEFAULT_BACKEND, BuildBackend, get_backend
-from pdm.models.python import PythonInfo
 from pdm.models.specifiers import get_specifier
-from pdm.models.venv import get_venv_python
 from pdm.utils import (
     get_user_email_from_git,
-    is_conda_base_python,
     package_installed,
     sanitize_project_name,
     validate_project_name,
@@ -224,21 +221,6 @@ class Command(BaseCommand):
             hooks=hooks,
         )
 
-        if project.config["python.use_venv"] and (
-            python_info.get_venv() is None or is_conda_base_python(python_info.path)
-        ):
-            if not self.interactive or termui.confirm(
-                f"Would you like to create a virtualenv with [success]{python_info.executable}[/]?",
-                default=True,
-            ):
-                project._python = python_info
-                try:
-                    path = project._create_virtualenv()
-                    python_info = PythonInfo.from_path(get_venv_python(path))
-                except Exception as e:  # pragma: no cover
-                    project.core.ui.error(
-                        f"Error occurred when creating virtualenv: {e}\nPlease fix it and create later."
-                    )
         if python_info.get_venv() is None:
             project.core.ui.info(
                 "You are using the PEP 582 mode, no virtualenv is created.\n"
diff --git a/src/pdm/cli/commands/use.py b/src/pdm/cli/commands/use.py
index 45e233c1..40c605d6 100644
--- a/src/pdm/cli/commands/use.py
+++ b/src/pdm/cli/commands/use.py
@@ -9,6 +9,7 @@ from pdm.cli.options import skip_option
 from pdm.exceptions import NoPythonVersion
 from pdm.models.caches import JSONFileCache
 from pdm.models.python import PythonInfo
+from pdm.models.venv import get_venv_python
 from pdm.project import Project
 
 
@@ -109,7 +110,7 @@ class Command(BaseCommand):
         """Use the specified python version and save in project config.
         The python can be a version string or interpreter path.
         """
-        from pdm.environments.local import PythonLocalEnvironment
+        from pdm.environments import PythonLocalEnvironment
 
         selected_python = self.select_python(
             project,
@@ -122,7 +123,9 @@ class Command(BaseCommand):
         if python:
             use_cache: JSONFileCache[str, str] = JSONFileCache(project.cache_dir / "use_cache.json")
             use_cache.set(python, selected_python.path.as_posix())
-
+        if selected_python.get_venv() is None and project.config["python.use_venv"]:
+            venv_path = project._create_virtualenv(str(selected_python.path))
+            selected_python = PythonInfo.from_path(get_venv_python(venv_path))
         if not save:
             return selected_python
 
@@ -133,18 +136,15 @@ class Command(BaseCommand):
         )
         project.python = selected_python
         if project.environment.is_local:
+            assert isinstance(project.environment, PythonLocalEnvironment)
             project.core.ui.echo(
                 "Using __pypackages__ because non-venv Python is used.",
                 style="primary",
                 err=True,
             )
-        if (
-            old_python
-            and old_python.executable != selected_python.executable
-            and isinstance(project.environment, PythonLocalEnvironment)
-        ):
-            project.core.ui.echo("Updating executable scripts...", style="primary")
-            project.environment.update_shebangs(selected_python.executable.as_posix())
+            if old_python and old_python.executable != selected_python.executable:
+                project.core.ui.echo("Updating executable scripts...", style="primary")
+                project.environment.update_shebangs(selected_python.executable.as_posix())
 
         hooks = hooks or HookManager(project)
         hooks.try_emit("post_use", python=selected_python)
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index 24ca52ac..fda56152 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -245,12 +245,12 @@ class Project:
                 self.python = PythonInfo.from_path(get_venv_python(venv_path))
                 return self.python
 
-        for py_version in self.find_interpreters():
-            if match_version(py_version):
-                if config.get("python.use_venv"):
+        if self.root.joinpath("__pypackages__").exists() or not config["python.use_venv"]:
+            for py_version in self.find_interpreters():
+                if match_version(py_version):
                     note("[success]__pypackages__[/] is detected, using the PEP 582 mode")
-                self.python = py_version
-                return py_version
+                    self.python = py_version
+                    return py_version
 
         raise NoPythonVersion(f"No Python that satisfies {self.python_requires} is found on the system.")
 
@@ -272,11 +272,11 @@ class Project:
             else PythonLocalEnvironment(self)
         )
 
-    def _create_virtualenv(self) -> Path:
+    def _create_virtualenv(self, python: str | None = None) -> Path:
         from pdm.cli.commands.venv.backends import BACKENDS
 
         backend: str = self.config["venv.backend"]
-        venv_backend = BACKENDS[backend](self, None)
+        venv_backend = BACKENDS[backend](self, python)
         path = venv_backend.create(
             force=True,
             in_project=self.config["venv.in_project"],


Test patch:
diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py
index c524cde9..5048b1cb 100644
--- a/tests/cli/test_init.py
+++ b/tests/cli/test_init.py
@@ -108,7 +108,7 @@ def test_init_non_interactive(project_no_init, pdm, mocker):
 def test_init_auto_create_venv(project_no_init, pdm, mocker):
     mocker.patch("pdm.models.python.PythonInfo.get_venv", return_value=None)
     project_no_init.project_config["python.use_venv"] = True
-    result = pdm(["init"], input="\n\n\n\n\n\n\n\n\n", obj=project_no_init)
+    result = pdm(["init"], input="\n\n\n\n\n\n\n\n", obj=project_no_init)
     assert result.exit_code == 0
     assert project_no_init.python.executable.parent.parent == project_no_init.root / ".venv"
     assert ".pdm-python" in (project_no_init.root / ".gitignore").read_text()
@@ -119,23 +119,13 @@ def test_init_auto_create_venv_specify_python(project_no_init, pdm, mocker):
     project_no_init.project_config["python.use_venv"] = True
     result = pdm(
         ["init", f"--python={PYTHON_VERSION}"],
-        input="\n\n\n\n\n\n\n\n",
+        input="\n\n\n\n\n\n\n",
         obj=project_no_init,
     )
     assert result.exit_code == 0
     assert project_no_init.python.executable.parent.parent == project_no_init.root / ".venv"
 
 
-def test_init_auto_create_venv_answer_no(project_no_init, pdm, mocker):
-    mocker.patch("pdm.models.python.PythonInfo.get_venv", return_value=None)
-    creator = mocker.patch("pdm.cli.commands.venv.backends.Backend.create")
-    project_no_init.project_config["python.use_venv"] = True
-    result = pdm(["init"], input="\nn\n\n\n\n\n\n\n\n\n", obj=project_no_init)
-    assert result.exit_code == 0
-    creator.assert_not_called()
-    assert project_no_init.python.executable.parent.parent != project_no_init.root / ".venv"
-
-
 def test_init_with_backend_default_library(project_no_init, pdm):
     pdm(["init", "--backend", "flit-core"], input="\n\n\n\n\n\n\n\n\n", obj=project_no_init)
     assert project_no_init.backend.__class__.__name__ == "FlitBackend"