Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for poetry env remove when virtualenvs.in-project = true #2748

Closed
4 changes: 4 additions & 0 deletions docs/docs/managing-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ poetry env remove /full/path/to/python
poetry env remove python3.7
poetry env remove 3.7
poetry env remove test-O3eWbxRl-py3.7
poetry env remove
```

If an argument for the python virtual environment to remove is not passed to the command, the currently activated virtual environment will be removed.
This includes any virtual environment created in-project.

If you remove the currently activated virtual environment, it will be automatically deactivated.
10 changes: 8 additions & 2 deletions poetry/console/commands/env/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ class EnvRemoveCommand(Command):
description = "Removes a specific virtualenv associated with the project."

arguments = [
argument("python", "The python executable to remove the virtualenv for.")
argument(
"python",
"The python executable to remove the virtualenv for."
"If not provided, defaults to removing the virtualenv that is currently active. "
'If "virtualenvs.in-project = True", defaults to removing ".venv/" in the '
"current working directory if it exists.",
optional=True,
)
]

def handle(self):
from poetry.utils.env import EnvManager

manager = EnvManager(self.poetry)
venv = manager.remove(self.argument("python"))

self.line("Deleted virtualenv: <comment>{}</comment>".format(venv.path))
109 changes: 54 additions & 55 deletions poetry/utils/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ class EnvManager(object):

ENVS_FILE = "envs.toml"

@property
def venv_path(self): # type: () -> Path
venv_path = self._poetry.config.get("virtualenvs.path")
return Path(CACHE_DIR) / "virtualenvs" if venv_path is None else Path(venv_path)

def __init__(self, poetry): # type: (Poetry) -> None
self._poetry = poetry

Expand Down Expand Up @@ -523,49 +528,35 @@ def list(self, name=None): # type: (Optional[str]) -> List[VirtualEnv]
env_list.insert(0, VirtualEnv(venv))
return env_list

def remove(self, python): # type: (str) -> Env
venv_path = self._poetry.config.get("virtualenvs.path")
if venv_path is None:
venv_path = Path(CACHE_DIR) / "virtualenvs"
else:
venv_path = Path(venv_path)

def find(self, python): # type: (str) -> Env
cwd = self._poetry.file.parent
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))

if python.startswith(base_env_name):
venvs = self.list()
for venv in venvs:
if venv.path.name == python:
# Exact virtualenv name
if not envs_file.exists():
self.remove_venv(venv.path)

return venv
venv = self._find_by_exact_name(python)
if venv:
return venv

venv_minor = ".".join(str(v) for v in venv.version_info[:2])
base_env_name = self.generate_env_name(cwd.name, str(cwd))
envs = envs_file.read()

current_env = envs.get(base_env_name)
if not current_env:
self.remove_venv(venv.path)

return venv

if current_env["minor"] == venv_minor:
del envs[base_env_name]
envs_file.write(envs)

self.remove_venv(venv.path)
python_version = self._resolve_python_version(python)
minor = "{}.{}".format(python_version.major, python_version.minor)

return venv
name = "{}-py{}".format(base_env_name, minor)
venv = self.venv_path / name

if not venv.exists():
raise ValueError(
'<warning>Environment "{}" does not exist.</warning>'.format(python)
'<warning>Environment "{}" does not exist.</warning>'.format(name)
)

return VirtualEnv(venv)

def _find_by_exact_name(self, python): # type: (str) -> Env
for venv in self.list():
if venv.path.name == python:
return venv
return None

def _resolve_python_version(self, python): # type: (str) -> Version
try:
python_version = Version.parse(python)
python = "python{}".format(python_version.major)
Expand All @@ -590,31 +581,39 @@ def remove(self, python): # type: (str) -> Env
)
except CalledProcessError as e:
raise EnvCommandError(e)
return Version.parse(python_version.strip())

python_version = Version.parse(python_version.strip())
minor = "{}.{}".format(python_version.major, python_version.minor)

name = "{}-py{}".format(base_env_name, minor)
venv = venv_path / name
def remove(self, python): # type: (Optional[str]) -> Env
venv = self.get() if not python else self.find(python)
if venv.path.name != ".venv":
# skip removing from venv file when it in the project
self._remove_from_venv_file(python, venv)
self.remove_venv(venv.path)
return venv

if not venv.exists():
raise ValueError(
'<warning>Environment "{}" does not exist.</warning>'.format(name)
)

if envs_file.exists():
envs = envs_file.read()
current_env = envs.get(base_env_name)
if current_env is not None:
current_minor = current_env["minor"]
def _remove_from_venv_file(self, python, venv): # type: (str, Env) -> None
cwd = self._poetry.file.parent
envs_file = TOMLFile(self.venv_path / self.ENVS_FILE)
if not envs_file.exists():
return

if current_minor == minor:
del envs[base_env_name]
envs_file.write(envs)
if venv.path.name == python:
venv_minor = ".".join(str(v) for v in venv.version_info[:2])
else:
python_version = self._resolve_python_version(python)
venv_minor = "{}.{}".format(python_version.major, python_version.minor)
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))
envs = envs_file.read()
if base_env_name not in envs:
base_env_name = self.generate_env_name(cwd.name, str(cwd))

self.remove_venv(venv)
current_env = envs.get(base_env_name)
if not current_env:
return

return VirtualEnv(venv)
if current_env["minor"] == venv_minor:
del envs[base_env_name]
envs_file.write(envs)

def create_venv(
self, io, name=None, executable=None, force=False
Expand Down Expand Up @@ -1077,7 +1076,7 @@ def _run(self, cmd, **kwargs):
stderr=subprocess.STDOUT,
input=encode(input_),
check=True,
**kwargs
**kwargs,
).stdout
elif call:
return subprocess.call(cmd, stderr=subprocess.STDOUT, **kwargs)
Expand Down Expand Up @@ -1414,7 +1413,7 @@ def __init__(
sys_path=None,
marker_env=None,
supported_tags=None,
**kwargs
**kwargs,
):
super(MockEnv, self).__init__(**kwargs)

Expand Down
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ def tmp_venv(tmp_dir):
venv = VirtualEnv(venv_path)
yield venv

shutil.rmtree(str(venv.path))
try:
shutil.rmtree(str(venv.path))
except FileNotFoundError:
pass # was deleted during a test


@pytest.fixture
Expand Down
9 changes: 9 additions & 0 deletions tests/console/commands/env/test_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,12 @@ def test_remove_by_name(tester, venvs_in_cache_dirs, venv_name, venv_cache):
expected += "Deleted virtualenv: {}\n".format((venv_cache / name))

assert expected == tester.io.fetch_output()


def test_remove_in_project(app, mocker, tester, tmp_venv):
app.poetry.config.merge({"virtualenvs": {"in-project": True}})
mock_manager = mocker.patch("poetry.utils.env.EnvManager")
mock_manager.return_value = mock_manager
mock_manager.remove.return_value = tmp_venv
tester.execute()
assert tester.io.fetch_output() == "Deleted virtualenv: {}\n".format(tmp_venv.path)
73 changes: 73 additions & 0 deletions tests/utils/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,51 @@ def test_deactivate_activated(tmp_dir, manager, poetry, config, mocker):
assert len(envs) == 0


def test_find_by_exact_name(config, manager, poetry, tmp_dir):
config.merge({"virtualenvs": {"path": str(tmp_dir)}})

venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent))
expected = Path(tmp_dir) / "{}-py3.7".format(venv_name)
expected.mkdir()
(Path(tmp_dir) / "{}-py3.6".format(venv_name)).mkdir()

assert manager.find("{}-py3.7".format(venv_name)).path == expected


def test_find_by_python_version(config, manager, mocker, poetry, tmp_dir):
config.merge({"virtualenvs": {"path": str(tmp_dir)}})

venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent))
expected = Path(tmp_dir) / "{}-py3.7".format(venv_name)
expected.mkdir()
(Path(tmp_dir) / "{}-py3.6".format(venv_name)).mkdir()

mocker.patch(
"poetry.utils._compat.subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.9")),
)

assert manager.find("{}-py3.7".format(venv_name)).path == expected


def test_find_not_exist(config, manager, mocker, poetry, tmp_dir):
config.merge({"virtualenvs": {"path": str(tmp_dir)}})

venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent))
(Path(tmp_dir) / "{}-py3.6".format(venv_name)).mkdir()

mocker.patch(
"poetry.utils._compat.subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.9")),
)

with pytest.raises(ValueError) as excinfo:
manager.find(venv_name)
assert str(
excinfo.value
) == '<warning>Environment "{}-py3.7" does not exist.</warning>'.format(venv_name)


def test_get_prefers_explicitly_activated_virtualenvs_over_env_var(
tmp_dir, manager, poetry, config, mocker
):
Expand Down Expand Up @@ -572,6 +617,34 @@ def test_remove_also_deactivates(tmp_dir, manager, poetry, config, mocker):
assert venv_name not in envs


def test_remove_in_project(tmp_venv, config, manager, mocker):
config.merge({"virtualenvs": {"in-project": True}})
mocker.patch.object(manager, "get", return_value=tmp_venv)
manager.remove(None)
assert not tmp_venv.path.exists()


def test_remove_in_project_external(config, manager, mocker, poetry, tmp_path):
config.merge({"virtualenvs": {"in-project": True, "path": str(tmp_path)}})
mocker.patch.object(manager._poetry.file, "parent", tmp_path)

dot_venv_path = tmp_path / ".venv"
dot_venv_path.mkdir()

venv_name = manager.generate_env_name("simple-project", str(poetry.file.parent))
venv_path = tmp_path / "{}-py3.7".format(venv_name)
venv_path.mkdir()

mocker.patch(
"poetry.utils._compat.subprocess.check_output",
side_effect=check_output_wrapper(Version.parse("3.7.6")),
)

manager.remove("python3.7")
assert not venv_path.exists()
assert dot_venv_path.exists


def test_remove_keeps_dir_if_not_deleteable(tmp_dir, manager, poetry, config, mocker):
# Ensure we empty rather than delete folder if its is an active mount point.
# See https://github.com/python-poetry/poetry/pull/2064
Expand Down