Skip to content

Commit

Permalink
feat: New option --venv VENV to run a command in the venv with the …
Browse files Browse the repository at this point in the history
…given name (#1820)
  • Loading branch information
frostming authored Apr 7, 2023
1 parent c897ec1 commit 551879f
Show file tree
Hide file tree
Showing 28 changed files with 304 additions and 162 deletions.
24 changes: 24 additions & 0 deletions docs/docs/usage/venv.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ $ eval $(pdm venv activate test-prompt)
(test-project-py3.10) $
```

## Run a command in a virtual environment without activating it

```bash
# Run a script
$ pdm run --venv test test
# Install packages
$ pdm sync --venv test
# List the packages installed
$ pdm list --venv test
```

There are other commands supporting `--venv` flag or `PDM_USE_VENV` environment variable, see the [CLI reference](../reference/cli.md). You should create the virtualenv with `pdm venv create --name <name>` before using this feature.

## Switch to a virtualenv as the project environment

By default, if you use `pdm use` and select a non-venv Python, the project will be switched to [PEP 582 mode](../pep582.md). We also allow you to switch to a named virtual environment via the `--venv` flag:

```bash
# Switch to a virtualenv named test
$ pdm use --venv test
# Switch to the in-project venv located at $PROJECT_ROOT/.venv
$ pdm use --venv
```

## Disable virtualenv mode

You can disable the auto-creation and auto-detection for virtualenv by `pdm config python.use_venv false`.
Expand Down
1 change: 1 addition & 0 deletions news/1705.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New option `--venv <venv>` to run a command in the virtual environment with the given name.
22 changes: 14 additions & 8 deletions src/pdm/cli/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ def do_add(
group = selection.one()
tracked_names: set[str] = set()
requirements: dict[str, Requirement] = {}
lock_groups = project.lockfile.groups
if lock_groups and group not in lock_groups:
lock_groups = [] if project.lockfile.empty() else project.lockfile.groups
if lock_groups is not None and group not in lock_groups:
project.core.ui.echo(f"Adding group [success]{group}[/] to lockfile", err=True, style="info")
lock_groups.append(group)
if group == "default" or not selection.dev and group not in project.pyproject.settings.get("dev-dependencies", {}):
Expand Down Expand Up @@ -570,26 +570,32 @@ def do_use(
ignore_remembered: bool = False,
ignore_requires_python: bool = False,
save: bool = True,
venv: str | None = None,
hooks: HookManager | None = None,
) -> PythonInfo:
"""Use the specified python version and save in project config.
The python can be a version string or interpreter path.
"""
hooks = hooks or HookManager(project)

if python:
python = python.strip()
from pdm.cli.commands.venv.utils import get_venv_python, get_venv_with_name

def version_matcher(py_version: PythonInfo) -> bool:
return py_version.valid and (
ignore_requires_python or project.python_requires.contains(str(py_version.version), True)
)

hooks = hooks or HookManager(project)

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))

if not project.cache_dir.exists():
project.cache_dir.mkdir(parents=True)
use_cache: JSONFileCache[str, str] = JSONFileCache(project.cache_dir / "use_cache.json")
selected_python: PythonInfo | None = None
if python and not ignore_remembered:
if python:
python = python.strip()
if selected_python is None and python and not ignore_remembered:
if python in use_cache:
path = use_cache.get(python)
cached_python = PythonInfo.from_path(path)
Expand Down
2 changes: 2 additions & 0 deletions src/pdm/cli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
skip_option,
unconstrained_option,
update_strategy_group,
venv_option,
)
from pdm.exceptions import PdmUsageError
from pdm.project import Project
Expand All @@ -32,6 +33,7 @@ class Command(BaseCommand):
packages_group,
install_group,
dry_run_option,
venv_option,
skip_option,
]

Expand Down
5 changes: 3 additions & 2 deletions src/pdm/cli/commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json

from pdm.cli.commands.base import BaseCommand
from pdm.cli.options import ArgumentGroup
from pdm.cli.options import ArgumentGroup, venv_option
from pdm.cli.utils import check_project_file
from pdm.project import Project

Expand All @@ -11,6 +11,7 @@ class Command(BaseCommand):
"""Show the project information"""

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
venv_option.add_to_parser(parser)
group = ArgumentGroup("fields", is_mutually_exclusive=True)
group.add_argument("--python", action="store_true", help="Show the interpreter path")
group.add_argument(
Expand All @@ -25,7 +26,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:

def handle(self, project: Project, options: argparse.Namespace) -> None:
check_project_file(project)
interpreter = project.python
interpreter = project.environment.interpreter
packages_path = ""
if project.environment.is_local:
packages_path = project.environment.packages_path # type: ignore[attr-defined]
Expand Down
18 changes: 10 additions & 8 deletions src/pdm/cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@
from pdm.cli.commands.base import BaseCommand
from pdm.cli.filters import GroupSelection
from pdm.cli.hooks import HookManager
from pdm.cli.options import (
dry_run_option,
groups_group,
install_group,
lockfile_option,
skip_option,
)
from pdm.cli.options import dry_run_option, groups_group, install_group, lockfile_option, skip_option, venv_option
from pdm.project import Project


class Command(BaseCommand):
"""Install dependencies from lock file"""

arguments = [*BaseCommand.arguments, groups_group, install_group, dry_run_option, lockfile_option, skip_option]
arguments = [
*BaseCommand.arguments,
groups_group,
install_group,
dry_run_option,
lockfile_option,
skip_option,
venv_option,
]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
Expand Down
2 changes: 2 additions & 0 deletions src/pdm/cli/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from pdm.cli import actions
from pdm.cli.commands.base import BaseCommand
from pdm.cli.options import venv_option
from pdm.cli.utils import (
DirectedGraph,
Package,
Expand All @@ -32,6 +33,7 @@ class Command(BaseCommand):
DEFAULT_FIELDS = "name,version,location"

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
venv_option.add_to_parser(parser)
graph = parser.add_mutually_exclusive_group()

parser.add_argument(
Expand Down
4 changes: 2 additions & 2 deletions src/pdm/cli/commands/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from pdm.cli.commands.base import BaseCommand
from pdm.cli.filters import GroupSelection
from pdm.cli.hooks import HookManager
from pdm.cli.options import dry_run_option, install_group, lockfile_option, skip_option
from pdm.cli.options import dry_run_option, install_group, lockfile_option, skip_option, venv_option
from pdm.project import Project


class Command(BaseCommand):
"""Remove packages from pyproject.toml"""

arguments = [*BaseCommand.arguments, install_group, dry_run_option, lockfile_option, skip_option]
arguments = [*BaseCommand.arguments, install_group, dry_run_option, lockfile_option, skip_option, venv_option]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
Expand Down
6 changes: 3 additions & 3 deletions src/pdm/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from pdm import termui
from pdm.cli.commands.base import BaseCommand
from pdm.cli.hooks import HookManager
from pdm.cli.options import skip_option
from pdm.cli.options import skip_option, venv_option
from pdm.cli.utils import check_project_file, get_pep582_path
from pdm.compat import TypedDict
from pdm.exceptions import PdmUsageError
Expand Down Expand Up @@ -163,7 +163,7 @@ def _run_process(
command, *args = args
if command.endswith(".py"):
args = [command, *args]
command = str(project.python.executable)
command = str(project.environment.interpreter.executable)
expanded_command = project_env.which(command)
if not expanded_command:
raise PdmUsageError(f"Command [success]'{command}'[/] is not found in your PATH.")
Expand Down Expand Up @@ -305,9 +305,9 @@ class Command(BaseCommand):
"""Run commands or scripts with local packages loaded"""

runner_cls: type[TaskRunner] = TaskRunner
arguments = [*BaseCommand.arguments, skip_option, venv_option]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
skip_option.add_to_parser(parser)
parser.add_argument(
"-l",
"--list",
Expand Down
2 changes: 2 additions & 0 deletions src/pdm/cli/commands/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from packaging.version import Version

from pdm.cli.commands.base import BaseCommand
from pdm.cli.options import venv_option
from pdm.exceptions import PdmUsageError
from pdm.models.candidates import Candidate
from pdm.models.project_info import ProjectInfo
Expand All @@ -22,6 +23,7 @@ class Command(BaseCommand):
metadata_keys = ["name", "version", "summary", "license", "platform", "keywords"]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
venv_option.add_to_parser(parser)
parser.add_argument(
"package",
type=normalize_name,
Expand Down
2 changes: 2 additions & 0 deletions src/pdm/cli/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
install_group,
lockfile_option,
skip_option,
venv_option,
)
from pdm.project import Project

Expand All @@ -26,6 +27,7 @@ class Command(BaseCommand):
skip_option,
clean_group,
install_group,
venv_option,
]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
Expand Down
2 changes: 2 additions & 0 deletions src/pdm/cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
skip_option,
unconstrained_option,
update_strategy_group,
venv_option,
)
from pdm.project import Project

Expand All @@ -30,6 +31,7 @@ class Command(BaseCommand):
prerelease_option,
unconstrained_option,
skip_option,
venv_option,
]

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
Expand Down
2 changes: 2 additions & 0 deletions src/pdm/cli/commands/use.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Ignore the remembered selection",
)
parser.add_argument("--venv", help="Use the interpreter in the virtual environment with the given name")
parser.add_argument("python", nargs="?", help="Specify the Python version or path", default="")

def handle(self, project: Project, options: argparse.Namespace) -> None:
Expand All @@ -32,5 +33,6 @@ def handle(self, project: Project, options: argparse.Namespace) -> None:
python=options.python,
first=options.first,
ignore_remembered=options.ignore_remembered,
venv=options.venv,
hooks=HookManager(project, options.skip),
)
17 changes: 3 additions & 14 deletions src/pdm/cli/commands/venv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import argparse
from pathlib import Path
from pdm.cli.commands.venv.utils import iter_venvs, get_venv_python
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
Expand Down Expand Up @@ -32,25 +32,14 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
PurgeCommand.register_to(subparser, "purge")
self.parser = parser

def _get_venv_with_name(self, project: Project, name: str) -> Path:
venv = next((venv for key, venv in iter_venvs(project) if key == name), None)
if not venv:
project.core.ui.echo(
f"No virtualenv with key [success]{name}[/] is found",
style="warning",
err=True,
)
raise SystemExit(1)
return venv

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 = self._get_venv_with_name(project, options.path)
venv = get_venv_with_name(project, options.path)
project.core.ui.echo(str(venv))
elif options.python:
venv = self._get_venv_with_name(project, options.python)
venv = get_venv_with_name(project, options.python)
project.core.ui.echo(str(get_venv_python(venv)))
else:
self.parser.print_help()
16 changes: 5 additions & 11 deletions src/pdm/cli/commands/venv/activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import shellingham

from pdm.cli.commands.base import BaseCommand
from pdm.cli.commands.venv.utils import BIN_DIR, iter_venvs
from pdm.cli.commands.venv.utils import BIN_DIR, iter_venvs, get_venv_with_name
from pdm.cli.options import verbose_option
from pdm.project import Project
from pdm.utils import get_venv_like_prefix
Expand All @@ -21,14 +21,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:

def handle(self, project: Project, options: argparse.Namespace) -> None:
if options.env:
venv = next((venv for key, venv in iter_venvs(project) if key == options.env), None)
if not venv:
project.core.ui.echo(
f"No virtualenv with key [success]{options.env}[/] is found",
style="warning",
err=True,
)
raise SystemExit(1)
venv = get_venv_with_name(project, options.env)
else:
# Use what is saved in .pdm-python
interpreter = project._saved_python
Expand All @@ -39,15 +32,16 @@ def handle(self, project: Project, options: argparse.Namespace) -> None:
err=True,
)
raise SystemExit(1)
venv = get_venv_like_prefix(interpreter)
if venv is None:
venv_like = get_venv_like_prefix(interpreter)
if venv_like is None:
project.core.ui.echo(
f"Can't activate a non-venv Python [success]{interpreter}[/], "
"you can specify one with [success]pdm venv activate <env_name>[/]",
style="warning",
err=True,
)
raise SystemExit(1)
venv = venv_like
project.core.ui.echo(self.get_activate_command(venv))

def get_activate_command(self, venv: Path) -> str: # pragma: no cover
Expand Down
25 changes: 8 additions & 17 deletions src/pdm/cli/commands/venv/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pdm import termui
from pdm.cli.commands.base import BaseCommand
from pdm.cli.commands.venv.utils import iter_venvs
from pdm.cli.commands.venv.utils import get_venv_with_name, iter_venvs
from pdm.cli.options import verbose_option
from pdm.project import Project

Expand All @@ -25,19 +25,10 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:

def handle(self, project: Project, options: argparse.Namespace) -> None:
project.core.ui.echo("Virtualenvs created with this project:")
for ident, venv in iter_venvs(project):
if ident == options.env:
if options.yes or termui.confirm(f"[warning]Will remove: [success]{venv}[/], continue?", default=True):
shutil.rmtree(venv)
saved_python = project._saved_python
if saved_python and Path(saved_python).parent.parent == venv:
project._saved_python = None
project.core.ui.echo("Removed successfully!")
break
else:
project.core.ui.echo(
f"No virtualenv with key [success]{options.env}[/] is found",
style="warning",
err=True,
)
raise SystemExit(1)
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)
saved_python = project._saved_python
if saved_python and Path(saved_python).parent.parent == venv:
project._saved_python = None
project.core.ui.echo("Removed successfully!")
Loading

0 comments on commit 551879f

Please sign in to comment.