Repository: pdm-project/pdm (python)

Issue:
venv detection not working with micromamba 
- [x] I have searched the issue tracker and believe that this is not a duplicate.

The get_venv_python method on Windows assumes that the Python interpreter in a venv is installed under Scripts below the venv root. With micromamba, it is actually installed directly underneath the venv root.

I’m using micromamba since it’s so much faster than Mamba or Conda.

I think the only workaround at the moment is to explicitly set the saved python using PDM_PYTHON or something like that.

even if this isn’t something the project wishes to address, thought anyone who happened to bump into this might want to know about it.

Motivation: the reason I need to futz with this is that I’m using building multiple platform specific wheels and the default dependency resolution for building is a bit finnicky right now. So making the build command just use a manually specified venv is really nice and micromamba is the fastest one out there.

Thanks for this project, btw. Kinda wish it was just the standard.

Pull request description:
refactor: Extract the logic about virtualenv to its own module
Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [x] 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.

Close #1866

Patch:
diff --git a/news/1866.bugfix.md b/news/1866.bugfix.md
new file mode 100644
index 00000000..e878688d
--- /dev/null
+++ b/news/1866.bugfix.md
@@ -0,0 +1,1 @@
+Detect Python interpreters under the root of virtual environments.
diff --git a/pyproject.toml b/pyproject.toml
index 548a433f..10cad0de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -124,7 +124,7 @@ select = [
 ]
 extend-ignore = ["B018", "B019"]
 src = ["src"]
-extend-exclude = ["tests/fixtures"]
+exclude = ["tests/fixtures"]
 target-version = "py37"
 
 [tool.ruff.mccabe]
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index bde17a76..bf311229 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -576,7 +576,7 @@ def do_use(
     """Use the specified python version and save in project config.
     The python can be a version string or interpreter path.
     """
-    from pdm.cli.commands.venv.utils import get_venv_python, get_venv_with_name
+    from pdm.cli.commands.venv.utils import get_venv_with_name
 
     def version_matcher(py_version: PythonInfo) -> bool:
         return py_version.valid and (
@@ -587,8 +587,8 @@ def do_use(
 
     selected_python: PythonInfo | None = None
     if venv:
-        venv_path = get_venv_with_name(project, venv)
-        selected_python = PythonInfo.from_path(get_venv_python(venv_path))
+        virtualenv = get_venv_with_name(project, venv)
+        selected_python = PythonInfo.from_path(virtualenv.interpreter)
 
     if not project.cache_dir.exists():
         project.cache_dir.mkdir(parents=True)
diff --git a/src/pdm/cli/commands/init.py b/src/pdm/cli/commands/init.py
index 7e329a0a..0b5ef15b 100644
--- a/src/pdm/cli/commands/init.py
+++ b/src/pdm/cli/commands/init.py
@@ -9,8 +9,9 @@ from pdm.cli.hooks import HookManager
 from pdm.cli.options import skip_option
 from pdm.models.backends import _BACKENDS, DEFAULT_BACKEND, BuildBackend, get_backend
 from pdm.models.python import PythonInfo
+from pdm.models.venv import get_venv_python
 from pdm.project import Project
-from pdm.utils import get_user_email_from_git, get_venv_like_prefix
+from pdm.utils import get_user_email_from_git
 
 
 class Command(BaseCommand):
@@ -42,8 +43,6 @@ class Command(BaseCommand):
         parser.set_defaults(search_parent=False)
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        from pdm.cli.commands.venv.utils import get_venv_python
-
         hooks = HookManager(project, options.skip)
         if project.pyproject.exists():
             project.core.ui.echo("pyproject.toml already exists, update it now.", style="primary")
@@ -70,7 +69,7 @@ class Command(BaseCommand):
                 save=False,
                 hooks=hooks,
             )
-        if project.config["python.use_venv"] and get_venv_like_prefix(python.executable) is None:
+        if project.config["python.use_venv"] and python.get_venv() is None:
             if not self.interactive or termui.confirm(
                 f"Would you like to create a virtualenv with [success]{python.executable}[/]?",
                 default=True,
@@ -84,7 +83,7 @@ class Command(BaseCommand):
                         style="error",
                         err=True,
                     )
-        if get_venv_like_prefix(python.executable) is None:
+        if python.get_venv() is None:
             project.core.ui.echo(
                 "You are using the PEP 582 mode, no virtualenv is created.\n"
                 "For more info, please visit https://peps.python.org/pep-0582/",
diff --git a/src/pdm/cli/commands/venv/__init__.py b/src/pdm/cli/commands/venv/__init__.py
index 1a5c78bf..18bd9062 100644
--- a/src/pdm/cli/commands/venv/__init__.py
+++ b/src/pdm/cli/commands/venv/__init__.py
@@ -1,10 +1,7 @@
 from __future__ import annotations
 
 import argparse
-from pathlib import Path
-from pdm.cli.commands.venv.utils import get_venv_with_name, iter_venvs, get_venv_python
 
-from pdm.exceptions import PdmUsageError
 from pdm.project import Project
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.commands.venv.activate import ActivateCommand
@@ -12,6 +9,7 @@ from pdm.cli.commands.venv.create import CreateCommand
 from pdm.cli.commands.venv.list import ListCommand
 from pdm.cli.commands.venv.purge import PurgeCommand
 from pdm.cli.commands.venv.remove import RemoveCommand
+from pdm.cli.commands.venv.utils import get_venv_with_name
 from pdm.cli.options import Option
 
 
@@ -22,8 +20,9 @@ class Command(BaseCommand):
     arguments: list[Option] = []
 
     def add_arguments(self, parser: argparse.ArgumentParser) -> None:
-        parser.add_argument("--path", help="Show the path to the given virtualenv")
-        parser.add_argument("--python", help="Show the python interpreter path for the given virtualenv")
+        group = parser.add_mutually_exclusive_group()
+        group.add_argument("--path", help="Show the path to the given virtualenv")
+        group.add_argument("--python", help="Show the python interpreter path for the given virtualenv")
         subparser = parser.add_subparsers()
         CreateCommand.register_to(subparser, "create")
         ListCommand.register_to(subparser, "list")
@@ -33,13 +32,11 @@ class Command(BaseCommand):
         self.parser = parser
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
-        if options.path and options.python:
-            raise PdmUsageError("--path and --python are mutually exclusive")
         if options.path:
             venv = get_venv_with_name(project, options.path)
-            project.core.ui.echo(str(venv))
+            project.core.ui.echo(str(venv.root))
         elif options.python:
             venv = get_venv_with_name(project, options.python)
-            project.core.ui.echo(str(get_venv_python(venv)))
+            project.core.ui.echo(str(venv.interpreter))
         else:
             self.parser.print_help()
diff --git a/src/pdm/cli/commands/venv/activate.py b/src/pdm/cli/commands/venv/activate.py
index 205ac5b9..b4347b28 100644
--- a/src/pdm/cli/commands/venv/activate.py
+++ b/src/pdm/cli/commands/venv/activate.py
@@ -5,10 +5,10 @@ from pathlib import Path
 import shellingham
 
 from pdm.cli.commands.base import BaseCommand
-from pdm.cli.commands.venv.utils import BIN_DIR, iter_venvs, get_venv_with_name
+from pdm.cli.commands.venv.utils import get_venv_with_name
 from pdm.cli.options import verbose_option
+from pdm.models.venv import VirtualEnv
 from pdm.project import Project
-from pdm.utils import get_venv_like_prefix
 
 
 class ActivateCommand(BaseCommand):
@@ -32,7 +32,7 @@ class ActivateCommand(BaseCommand):
                     err=True,
                 )
                 raise SystemExit(1)
-            venv_like = get_venv_like_prefix(interpreter)
+            venv_like = VirtualEnv.from_interpreter(Path(interpreter))
             if venv_like is None:
                 project.core.ui.echo(
                     f"Can't activate a non-venv Python [success]{interpreter}[/], "
@@ -44,7 +44,7 @@ class ActivateCommand(BaseCommand):
             venv = venv_like
         project.core.ui.echo(self.get_activate_command(venv))
 
-    def get_activate_command(self, venv: Path) -> str:  # pragma: no cover
+    def get_activate_command(self, venv: VirtualEnv) -> str:  # pragma: no cover
         shell, _ = shellingham.detect_shell()
         if shell == "fish":
             command, filename = "source", "activate.fish"
@@ -54,8 +54,8 @@ class ActivateCommand(BaseCommand):
             command, filename = ".", "Activate.ps1"
         else:
             command, filename = "source", "activate"
-        activate_script = venv / BIN_DIR / filename
+        activate_script = venv.interpreter.with_name(filename)
         if activate_script.exists():
             return f"{command} {shlex.quote(str(activate_script))}"
         # Conda backed virtualenvs don't have activate scripts
-        return f"conda activate {shlex.quote(str(venv))}"
+        return f"conda activate {shlex.quote(str(venv.root))}"
diff --git a/src/pdm/cli/commands/venv/list.py b/src/pdm/cli/commands/venv/list.py
index 524a109d..1fe129d0 100644
--- a/src/pdm/cli/commands/venv/list.py
+++ b/src/pdm/cli/commands/venv/list.py
@@ -16,8 +16,8 @@ class ListCommand(BaseCommand):
         project.core.ui.echo("Virtualenvs created with this project:\n")
         for ident, venv in iter_venvs(project):
             saved_python = project._saved_python
-            if saved_python and Path(saved_python).parent.parent == venv:
+            if saved_python and Path(saved_python).parent.parent == venv.root:
                 mark = "*"
             else:
                 mark = "-"
-            project.core.ui.echo(f"{mark}  [success]{ident}[/]: {venv}")
+            project.core.ui.echo(f"{mark}  [success]{ident}[/]: {venv.root}")
diff --git a/src/pdm/cli/commands/venv/remove.py b/src/pdm/cli/commands/venv/remove.py
index 2007b71c..a75d5c61 100644
--- a/src/pdm/cli/commands/venv/remove.py
+++ b/src/pdm/cli/commands/venv/remove.py
@@ -4,7 +4,7 @@ from pathlib import Path
 
 from pdm import termui
 from pdm.cli.commands.base import BaseCommand
-from pdm.cli.commands.venv.utils import get_venv_with_name, iter_venvs
+from pdm.cli.commands.venv.utils import get_venv_with_name
 from pdm.cli.options import verbose_option
 from pdm.project import Project
 
@@ -26,9 +26,9 @@ class RemoveCommand(BaseCommand):
     def handle(self, project: Project, options: argparse.Namespace) -> None:
         project.core.ui.echo("Virtualenvs created with this project:")
         venv = get_venv_with_name(project, options.env)
-        if options.yes or termui.confirm(f"[warning]Will remove: [success]{venv}[/], continue?", default=True):
-            shutil.rmtree(venv)
+        if options.yes or termui.confirm(f"[warning]Will remove: [success]{venv.root}[/], continue?", default=True):
+            shutil.rmtree(venv.root)
             saved_python = project._saved_python
-            if saved_python and Path(saved_python).parent.parent == venv:
+            if saved_python and Path(saved_python).parent.parent == venv.root:
                 project._saved_python = None
             project.core.ui.echo("Removed successfully!")
diff --git a/src/pdm/cli/commands/venv/utils.py b/src/pdm/cli/commands/venv/utils.py
index 3679d9b5..fa518789 100644
--- a/src/pdm/cli/commands/venv/utils.py
+++ b/src/pdm/cli/commands/venv/utils.py
@@ -2,31 +2,28 @@ from __future__ import annotations
 
 import base64
 import hashlib
-import sys
 from pathlib import Path
 from typing import Iterable, TypeVar
-from findpython import PythonVersion
 
+from findpython import PythonVersion
 from findpython.providers import BaseProvider
-from pdm.exceptions import PdmUsageError
 
+from pdm.exceptions import PdmUsageError
+from pdm.models.venv import VirtualEnv
 from pdm.project import Project
 
-IS_WIN = sys.platform == "win32"
-BIN_DIR = "Scripts" if IS_WIN else "bin"
-
 
 def hash_path(path: str) -> str:
     """Generate a hash for the given path."""
     return base64.urlsafe_b64encode(hashlib.new("md5", path.encode(), usedforsecurity=False).digest()).decode()[:8]
 
 
-def get_in_project_venv_python(root: Path) -> Path | None:
+def get_in_project_venv(root: Path) -> VirtualEnv | None:
     """Get the python interpreter path of venv-in-project"""
     for possible_dir in (".venv", "venv", "env"):
-        venv_python = get_venv_python(root / possible_dir)
-        if venv_python.exists():
-            return venv_python
+        venv = VirtualEnv.get(root / possible_dir)
+        if venv is not None:
+            return venv
     return None
 
 
@@ -37,22 +34,18 @@ def get_venv_prefix(project: Project) -> str:
     return f"{path.name}-{name_hash}-"
 
 
-def iter_venvs(project: Project) -> Iterable[tuple[str, Path]]:
+def iter_venvs(project: Project) -> Iterable[tuple[str, VirtualEnv]]:
     """Return an iterable of venv paths associated with the project"""
-    in_project_venv_python = get_in_project_venv_python(project.root)
-    if in_project_venv_python is not None:
-        yield "in-project", Path(in_project_venv_python).parent.parent
+    in_project_venv = get_in_project_venv(project.root)
+    if in_project_venv is not None:
+        yield "in-project", in_project_venv
     venv_prefix = get_venv_prefix(project)
     venv_parent = Path(project.config["venv.location"])
-    for venv in venv_parent.glob(f"{venv_prefix}*"):
-        ident = venv.name[len(venv_prefix) :]
-        yield ident, venv
-
-
-def get_venv_python(venv: Path) -> Path:
-    """Get the interpreter path inside the given venv."""
-    suffix = ".exe" if IS_WIN else ""
-    return venv / BIN_DIR / f"python{suffix}"
+    for path in venv_parent.glob(f"{venv_prefix}*"):
+        ident = path.name[len(venv_prefix) :]
+        venv = VirtualEnv.get(path)
+        if venv is not None:
+            yield ident, venv
 
 
 def iter_central_venvs(project: Project) -> Iterable[tuple[str, Path]]:
@@ -78,12 +71,10 @@ class VenvProvider(BaseProvider):
 
     def find_pythons(self) -> Iterable[PythonVersion]:
         for _, venv in iter_venvs(self.project):
-            python = get_venv_python(venv)
-            if python.exists():
-                yield PythonVersion(python, _interpreter=python, keep_symlink=True)
+            yield PythonVersion(venv.interpreter, _interpreter=venv.interpreter, keep_symlink=True)
 
 
-def get_venv_with_name(project: Project, name: str) -> Path:
+def get_venv_with_name(project: Project, name: str) -> VirtualEnv:
     all_venvs = dict(iter_venvs(project))
     try:
         return all_venvs[name]
@@ -91,4 +82,4 @@ def get_venv_with_name(project: Project, name: str) -> Path:
         raise PdmUsageError(
             f"No virtualenv with key '{name}' is found, must be one of {list(all_venvs)}.\n"
             "You can create one with 'pdm venv create'.",
-        )
+        ) from None
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index 5a024d1b..fa2be81a 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -675,11 +675,11 @@ def get_pep582_path(project: Project) -> str:
 
 
 def use_venv(project: Project, name: str) -> None:
-    from pdm.cli.commands.venv.utils import get_venv_python, get_venv_with_name
+    from pdm.cli.commands.venv.utils import get_venv_with_name
     from pdm.environments import PythonEnvironment
 
     venv = get_venv_with_name(project, cast(str, name))
     project.core.ui.echo(
-        f"In virtual environment: [success]{venv}[/]", err=True, style="info", verbosity=termui.Verbosity.DETAIL
+        f"In virtual environment: [success]{venv.root}[/]", err=True, style="info", verbosity=termui.Verbosity.DETAIL
     )
-    project.environment = PythonEnvironment(project, python=str(get_venv_python(venv)))
+    project.environment = PythonEnvironment(project, python=str(venv.interpreter))
diff --git a/src/pdm/environments/python.py b/src/pdm/environments/python.py
index e55860a4..0c1d1b65 100644
--- a/src/pdm/environments/python.py
+++ b/src/pdm/environments/python.py
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
 from pdm.environments.base import BaseEnvironment
 from pdm.models.in_process import get_sys_config_paths
 from pdm.models.python import PythonInfo
-from pdm.utils import get_venv_like_prefix
 
 if TYPE_CHECKING:
     from pdm.project import Project
@@ -27,7 +26,7 @@ class PythonEnvironment(BaseEnvironment):
         return self._interpreter
 
     def get_paths(self) -> dict[str, str]:
-        is_venv = self.interpreter.is_venv
+        is_venv = self.interpreter.get_venv() is not None
         paths = get_sys_config_paths(
             str(self.interpreter.executable),
             kind="user" if not is_venv and self.project.global_config["global_project.user_site"] else "default",
@@ -42,6 +41,7 @@ class PythonEnvironment(BaseEnvironment):
     @property
     def process_env(self) -> dict[str, str]:
         env = super().process_env
-        if self.interpreter.is_venv:
-            env["VIRTUAL_ENV"] = str(get_venv_like_prefix(self.interpreter.executable))
+        venv = self.interpreter.get_venv()
+        if venv is not None:
+            env.update(venv.env_vars())
         return env
diff --git a/src/pdm/models/candidates.py b/src/pdm/models/candidates.py
index 668dcd69..fc2264b0 100644
--- a/src/pdm/models/candidates.py
+++ b/src/pdm/models/candidates.py
@@ -34,7 +34,6 @@ from pdm.utils import (
     convert_hashes,
     create_tracked_tempdir,
     get_rev_from_url,
-    get_venv_like_prefix,
     normalize_name,
     path_to_url,
     url_without_fragments,
@@ -547,10 +546,13 @@ class PreparedCandidate:
         if self.req.editable:
             # In this branch the requirement must be an editable VCS requirement.
             # The repository will be unpacked into a *persistent* src directory.
+            prefix: Path | None = None
             if self.environment.is_local:
                 prefix = self.environment.packages_path  # type: ignore[attr-defined]
             else:
-                prefix = get_venv_like_prefix(self.environment.interpreter.executable)
+                venv = self.environment.interpreter.get_venv()
+                if venv is not None:
+                    prefix = venv.root
             if prefix is not None:
                 src_dir = prefix / "src"
             else:
diff --git a/src/pdm/models/python.py b/src/pdm/models/python.py
index 914a40ea..9c73ef41 100644
--- a/src/pdm/models/python.py
+++ b/src/pdm/models/python.py
@@ -8,7 +8,7 @@ from findpython import PythonVersion
 from packaging.version import InvalidVersion, Version
 
 from pdm.compat import cached_property
-from pdm.utils import get_venv_like_prefix
+from pdm.models.venv import VirtualEnv
 
 
 class PythonInfo:
@@ -80,6 +80,5 @@ class PythonInfo:
         except InvalidVersion:
             return "unknown"
 
-    @property
-    def is_venv(self) -> bool:
-        return get_venv_like_prefix(self.executable) is not None
+    def get_venv(self) -> VirtualEnv | None:
+        return VirtualEnv.from_interpreter(self.executable)
diff --git a/src/pdm/models/venv.py b/src/pdm/models/venv.py
new file mode 100644
index 00000000..749e8c67
--- /dev/null
+++ b/src/pdm/models/venv.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+import dataclasses as dc
+import sys
+from pathlib import Path
+
+from pdm.utils import get_venv_like_prefix
+
+IS_WIN = sys.platform == "win32"
+BIN_DIR = "Scripts" if IS_WIN else "bin"
+
+
+def get_venv_python(venv: Path) -> Path:
+    """Get the interpreter path inside the given venv."""
+    suffix = ".exe" if IS_WIN else ""
+    result = venv / BIN_DIR / f"python{suffix}"
+    if IS_WIN and not result.exists():
+        return venv / "python.exe"  # for conda
+    return result
+
+
+def is_conda_venv(root: Path) -> bool:
+    return (root / "conda-meta").exists()
+
+
+@dc.dataclass(frozen=True)
+class VirtualEnv:
+    root: Path
+    is_conda: bool
+    interpreter: Path
+
+    @classmethod
+    def get(cls, root: Path) -> VirtualEnv | None:
+        path = get_venv_python(root)
+        if not path.exists():
+            return None
+        return cls(root, is_conda_venv(root), path)
+
+    @classmethod
+    def from_interpreter(cls, interpreter: Path) -> VirtualEnv | None:
+        root, is_conda = get_venv_like_prefix(interpreter)
+        if root is not None:
+            return cls(root, is_conda, interpreter)
+        return None
+
+    def env_vars(self) -> dict[str, str]:
+        key = "CONDA_PREFIX" if self.is_conda else "VIRTUAL_ENV"
+        return {key: str(self.root)}
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index f74278e7..3e158238 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -185,7 +185,8 @@ class Project:
 
     def resolve_interpreter(self) -> PythonInfo:
         """Get the Python interpreter path."""
-        from pdm.cli.commands.venv.utils import get_venv_python, iter_venvs
+        from pdm.cli.commands.venv.utils import iter_venvs
+        from pdm.models.venv import get_venv_python
 
         def match_version(python: PythonInfo) -> bool:
             return python.valid and self.python_requires.contains(python.version, True)
@@ -220,16 +221,16 @@ class Project:
                     return python
             # otherwise, get a venv associated with the project
             for _, venv in iter_venvs(self):
-                python = PythonInfo.from_path(get_venv_python(venv))
+                python = PythonInfo.from_path(venv.interpreter)
                 if match_version(python):
-                    note(f"Virtualenv [success]{venv}[/] is reused.")
+                    note(f"Virtualenv [success]{venv.root}[/] is reused.")
                     self.python = python
                     return python
 
             if not self.root.joinpath("__pypackages__").exists():
                 note("python.use_venv is on, creating a virtualenv for this project...")
-                venv = self._create_virtualenv()
-                self.python = PythonInfo.from_path(get_venv_python(venv))
+                venv_path = self._create_virtualenv()
+                self.python = PythonInfo.from_path(get_venv_python(venv_path))
                 return self.python
 
         for py_version in self.find_interpreters():
@@ -253,7 +254,7 @@ class Project:
 
         return (
             PythonEnvironment(self)
-            if self.config["python.use_venv"] and self.python.is_venv
+            if self.config["python.use_venv"] and self.python.get_venv() is not None
             else PythonLocalEnvironment(self)
         )
 
diff --git a/src/pdm/utils.py b/src/pdm/utils.py
index 7abadcef..911029f7 100644
--- a/src/pdm/utils.py
+++ b/src/pdm/utils.py
@@ -271,23 +271,28 @@ def is_path_relative_to(path: str | Path, other: str | Path) -> bool:
     return True
 
 
-def get_venv_like_prefix(interpreter: str | Path) -> Path | None:
+def get_venv_like_prefix(interpreter: str | Path) -> tuple[Path | None, bool]:
     """Check if the given interpreter path is from a virtualenv,
-    and return the prefix if found.
+    and return two values: the root path and whether it's a conda env.
     """
     interpreter = Path(interpreter)
     prefix = interpreter.parent
     if prefix.joinpath("conda-meta").exists():
-        return prefix
+        return prefix, True
 
     prefix = prefix.parent
-    if prefix.joinpath("pyvenv.cfg").exists() or prefix.joinpath("conda-meta").exists():
-        return prefix
+    if prefix.joinpath("pyvenv.cfg").exists():
+        return prefix, False
+    if prefix.joinpath("conda-meta").exists():
+        return prefix, True
 
-    virtual_env = os.getenv("VIRTUAL_ENV", os.getenv("CONDA_PREFIX"))
+    virtual_env = os.getenv("VIRTUAL_ENV")
     if virtual_env and is_path_relative_to(interpreter, virtual_env):
-        return Path(virtual_env)
-    return None
+        return Path(virtual_env), False
+    virtual_env = os.getenv("CONDA_PREFIX")
+    if virtual_env and is_path_relative_to(interpreter, virtual_env):
+        return Path(virtual_env), True
+    return None, False
 
 
 def find_python_in_path(path: str | Path) -> Path | None:


Test patch:
diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py
index 360a9be5..6aeb9648 100644
--- a/tests/cli/test_init.py
+++ b/tests/cli/test_init.py
@@ -98,7 +98,7 @@ def test_init_non_interactive(project_no_init, invoke, mocker):
 
 
 def test_init_auto_create_venv(project_no_init, invoke, mocker):
-    mocker.patch("pdm.cli.commands.init.get_venv_like_prefix", return_value=None)
+    mocker.patch("pdm.models.python.PythonInfo.get_venv", return_value=None)
     project_no_init.project_config["python.use_venv"] = True
     result = invoke(["init"], input="\n\n\n\n\n\n\n", obj=project_no_init)
     assert result.exit_code == 0
@@ -107,7 +107,7 @@ def test_init_auto_create_venv(project_no_init, invoke, mocker):
 
 
 def test_init_auto_create_venv_specify_python(project_no_init, invoke, mocker):
-    mocker.patch("pdm.cli.commands.init.get_venv_like_prefix", return_value=None)
+    mocker.patch("pdm.models.python.PythonInfo.get_venv", return_value=None)
     project_no_init.project_config["python.use_venv"] = True
     result = invoke(
         ["init", f"--python={PYTHON_VERSION}"],
@@ -119,7 +119,7 @@ def test_init_auto_create_venv_specify_python(project_no_init, invoke, mocker):
 
 
 def test_init_auto_create_venv_answer_no(project_no_init, invoke, mocker):
-    mocker.patch("pdm.cli.commands.init.get_venv_like_prefix", return_value=None)
+    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 = invoke(["init"], input="\nn\n\n\n\n\n\n\n", obj=project_no_init)
diff --git a/tests/test_project.py b/tests/test_project.py
index 0160c9a8..8a726fdd 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -7,9 +7,9 @@ import pytest
 from packaging.version import parse
 from pytest_httpserver import HTTPServer
 
-from pdm.cli.commands.venv.utils import get_venv_python
 from pdm.environments import PythonEnvironment
 from pdm.exceptions import PdmException
+from pdm.models.venv import get_venv_python
 from pdm.utils import cd
 
 
@@ -238,16 +238,19 @@ def test_find_interpreters_without_duplicate_relative_paths(invoke, project):
 
 
 def test_iter_project_venvs(project):
-    from pdm.cli.commands.venv import utils
+    from pdm.cli.commands.venv.utils import get_venv_prefix, iter_venvs
+    from pdm.models.venv import get_venv_python
 
     venv_parent = Path(project.config["venv.location"])
-    venv_prefix = utils.get_venv_prefix(project)
+    venv_prefix = get_venv_prefix(project)
     for name in ("foo", "bar", "baz"):
-        venv_parent.joinpath(venv_prefix + name).mkdir(parents=True)
-    dot_venv_python = utils.get_venv_python(project.root / ".venv")
+        venv_python = get_venv_python(venv_parent / (venv_prefix + name))
+        venv_python.parent.mkdir(parents=True)
+        venv_python.touch()
+    dot_venv_python = get_venv_python(project.root / ".venv")
     dot_venv_python.parent.mkdir(parents=True)
     dot_venv_python.touch()
-    venv_keys = [key for key, _ in utils.iter_venvs(project)]
+    venv_keys = [key for key, _ in iter_venvs(project)]
     assert sorted(venv_keys) == ["bar", "baz", "foo", "in-project"]