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

Keep empty in-project venv dir when recreating venv to allow for docker volumes #2064

Merged
merged 1 commit into from Jun 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 36 additions & 13 deletions poetry/utils/env.py
Expand Up @@ -15,6 +15,7 @@
from typing import List
from typing import Optional
from typing import Tuple
from typing import Union

import tomlkit

Expand Down Expand Up @@ -403,7 +404,7 @@ def remove(self, python): # type: (str) -> Env
if venv.path.name == python:
# Exact virtualenv name
if not envs_file.exists():
self.remove_venv(str(venv.path))
self.remove_venv(venv.path)

return venv

Expand All @@ -413,15 +414,15 @@ def remove(self, python): # type: (str) -> Env

current_env = envs.get(base_env_name)
if not current_env:
self.remove_venv(str(venv.path))
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(str(venv.path))
self.remove_venv(venv.path)

return venv

Expand Down Expand Up @@ -475,7 +476,7 @@ def remove(self, python): # type: (str) -> Env
del envs[base_env_name]
envs_file.write(envs)

self.remove_venv(str(venv))
self.remove_venv(venv)

return VirtualEnv(venv)

Expand Down Expand Up @@ -621,7 +622,7 @@ def create_venv(
"Creating virtualenv <c1>{}</> in {}".format(name, str(venv_path))
)

self.build_venv(str(venv), executable=executable)
self.build_venv(venv, executable=executable)
else:
if force:
if not env.is_sane():
Expand All @@ -633,8 +634,8 @@ def create_venv(
io.write_line(
"Recreating virtualenv <c1>{}</> in {}".format(name, str(venv))
)
self.remove_venv(str(venv))
self.build_venv(str(venv), executable=executable)
self.remove_venv(venv)
self.build_venv(venv, executable=executable)
elif io.is_very_verbose():
io.write_line("Virtualenv <c1>{}</> already exists.".format(name))

Expand All @@ -657,7 +658,9 @@ def create_venv(
return VirtualEnv(venv)

@classmethod
def build_venv(cls, path, executable=None):
def build_venv(
cls, path, executable=None
): # type: (Union[Path,str], Optional[str]) -> ()
if executable is not None:
# Create virtualenv by using an external executable
try:
Expand All @@ -682,21 +685,41 @@ def build_venv(cls, path, executable=None):
use_symlinks = True

builder = EnvBuilder(with_pip=True, symlinks=use_symlinks)
builder.create(path)
builder.create(str(path))
except ImportError:
try:
# We fallback on virtualenv for Python 2.7
from virtualenv import create_environment

create_environment(path)
create_environment(str(path))
except ImportError:
# since virtualenv>20 we have to use cli_run
from virtualenv import cli_run

cli_run([path])
cli_run([str(path)])

def remove_venv(self, path): # type: (str) -> None
shutil.rmtree(path)
@classmethod
def remove_venv(cls, path): # type: (Union[Path,str]) -> None
if isinstance(path, str):
path = Path(path)
assert path.is_dir()
try:
shutil.rmtree(str(path))
return
except OSError as e:
# Continue only if e.errno == 16
if e.errno != 16: # ERRNO 16: Device or resource busy
raise e

# Delete all files and folders but the toplevel one. This is because sometimes
# the venv folder is mounted by the OS, such as in a docker volume. In such
# cases, an attempt to delete the folder itself will result in an `OSError`.
# See https://github.com/python-poetry/poetry/pull/2064
for file_path in path.iterdir():
if file_path.is_file() or file_path.is_symlink():
file_path.unlink()
elif file_path.is_dir():
shutil.rmtree(str(file_path))

def get_base_prefix(self): # type: () -> Path
if hasattr(sys, "real_prefix"):
Expand Down
14 changes: 6 additions & 8 deletions tests/console/commands/env/test_use.py
@@ -1,7 +1,9 @@
import os
import shutil
import sys

from typing import Optional
from typing import Union

import tomlkit

from cleo.testers import CommandTester
Expand All @@ -16,12 +18,8 @@
CWD = Path(__file__).parent.parent / "fixtures" / "simple_project"


def build_venv(path, executable=None):
os.mkdir(path)


def remove_venv(path):
shutil.rmtree(path)
def build_venv(path, executable=None): # type: (Union[Path,str], Optional[str]) -> ()
os.mkdir(str(path))


def check_output_wrapper(version=Version.parse("3.7.1")):
Expand Down Expand Up @@ -62,7 +60,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(app, tmp_dir, m
)

m.assert_called_with(
os.path.join(tmp_dir, "{}-py3.7".format(venv_name)), executable="python3.7"
Path(tmp_dir) / "{}-py3.7".format(venv_name), executable="python3.7"
)

envs_file = TomlFile(Path(tmp_dir) / "envs.toml")
Expand Down
97 changes: 68 additions & 29 deletions tests/utils/test_env.py
Expand Up @@ -2,6 +2,9 @@
import shutil
import sys

from typing import Optional
from typing import Union

import pytest
import tomlkit

Expand Down Expand Up @@ -83,12 +86,8 @@ def test_env_get_in_project_venv(manager, poetry):
shutil.rmtree(str(venv.path))


def build_venv(path, executable=None):
os.mkdir(path)


def remove_venv(path):
shutil.rmtree(path)
def build_venv(path, executable=None): # type: (Union[Path,str], Optional[str]) -> ()
os.mkdir(str(path))


def check_output_wrapper(version=Version.parse("3.7.1")):
Expand Down Expand Up @@ -125,7 +124,7 @@ def test_activate_activates_non_existing_virtualenv_no_envs_file(
venv_name = EnvManager.generate_env_name("simple-project", str(poetry.file.parent))

m.assert_called_with(
os.path.join(tmp_dir, "{}-py3.7".format(venv_name)), executable="python3.7"
Path(tmp_dir) / "{}-py3.7".format(venv_name), executable="python3.7"
)

envs_file = TomlFile(Path(tmp_dir) / "envs.toml")
Expand Down Expand Up @@ -243,7 +242,7 @@ def test_activate_activates_different_virtualenv_with_envs_file(
env = manager.activate("python3.6", NullIO())

m.assert_called_with(
os.path.join(tmp_dir, "{}-py3.6".format(venv_name)), executable="python3.6"
Path(tmp_dir) / "{}-py3.6".format(venv_name), executable="python3.6"
)

assert envs_file.exists()
Expand Down Expand Up @@ -289,17 +288,15 @@ def test_activate_activates_recreates_for_different_patch(
"poetry.utils.env.EnvManager.build_venv", side_effect=build_venv
)
remove_venv_m = mocker.patch(
"poetry.utils.env.EnvManager.remove_venv", side_effect=remove_venv
"poetry.utils.env.EnvManager.remove_venv", side_effect=EnvManager.remove_venv
)

env = manager.activate("python3.7", NullIO())

build_venv_m.assert_called_with(
os.path.join(tmp_dir, "{}-py3.7".format(venv_name)), executable="python3.7"
)
remove_venv_m.assert_called_with(
os.path.join(tmp_dir, "{}-py3.7".format(venv_name))
Path(tmp_dir) / "{}-py3.7".format(venv_name), executable="python3.7"
)
remove_venv_m.assert_called_with(Path(tmp_dir) / "{}-py3.7".format(venv_name))

assert envs_file.exists()
envs = envs_file.read()
Expand Down Expand Up @@ -340,7 +337,7 @@ def test_activate_does_not_recreate_when_switching_minor(
"poetry.utils.env.EnvManager.build_venv", side_effect=build_venv
)
remove_venv_m = mocker.patch(
"poetry.utils.env.EnvManager.remove_venv", side_effect=remove_venv
"poetry.utils.env.EnvManager.remove_venv", side_effect=EnvManager.remove_venv
)

env = manager.activate("python3.6", NullIO())
Expand Down Expand Up @@ -535,6 +532,54 @@ def test_remove_also_deactivates(tmp_dir, manager, poetry, config, mocker):
assert venv_name not in envs


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
config.merge({"virtualenvs": {"path": str(tmp_dir)}})

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

folder1_path = venv_path / "folder1"
folder1_path.mkdir()

file1_path = folder1_path / "file1"
file1_path.touch(exist_ok=False)

file2_path = venv_path / "file2"
file2_path.touch(exist_ok=False)

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

original_rmtree = shutil.rmtree

def err_on_rm_venv_only(path, *args, **kwargs):
print(path)
if path == str(venv_path):
raise OSError(16, "Test error") # ERRNO 16: Device or resource busy
else:
original_rmtree(path)

m = mocker.patch("shutil.rmtree", side_effect=err_on_rm_venv_only)

venv = manager.remove("{}-py3.6".format(venv_name))

m.assert_any_call(str(venv_path))

assert venv_path == venv.path
assert venv_path.exists()

assert not folder1_path.exists()
assert not file1_path.exists()
assert not file2_path.exists()

m.side_effect = original_rmtree # Avoid teardown using `err_on_rm_venv_only`


def test_env_has_symlinks_on_nix(tmp_dir, tmp_venv):
venv_available = False
try:
Expand Down Expand Up @@ -584,7 +629,7 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_generic_
manager.create_venv(NullIO())

m.assert_called_with(
str(Path("/foo/virtualenvs/{}-py3.7".format(venv_name))), executable="python3"
Path("/foo/virtualenvs/{}-py3.7".format(venv_name)), executable="python3"
)


Expand All @@ -608,7 +653,7 @@ def test_create_venv_tries_to_find_a_compatible_python_executable_using_specific
manager.create_venv(NullIO())

m.assert_called_with(
str(Path("/foo/virtualenvs/{}-py3.8".format(venv_name))), executable="python3.8"
Path("/foo/virtualenvs/{}-py3.8".format(venv_name)), executable="python3.8"
)


Expand Down Expand Up @@ -691,11 +736,9 @@ def test_create_venv_uses_patch_version_to_detect_compatibility(

assert not check_output.called
m.assert_called_with(
str(
Path(
"/foo/virtualenvs/{}-py{}.{}".format(
venv_name, version.major, version.minor
)
Path(
"/foo/virtualenvs/{}-py{}.{}".format(
venv_name, version.major, version.minor
)
),
executable=None,
Expand Down Expand Up @@ -730,11 +773,9 @@ def test_create_venv_uses_patch_version_to_detect_compatibility_with_executable(

assert check_output.called
m.assert_called_with(
str(
Path(
"/foo/virtualenvs/{}-py{}.{}".format(
venv_name, version.major, version.minor - 1
)
Path(
"/foo/virtualenvs/{}-py{}.{}".format(
venv_name, version.major, version.minor - 1
)
),
executable="python{}.{}".format(version.major, version.minor - 1),
Expand Down Expand Up @@ -768,9 +809,7 @@ def test_activate_with_in_project_setting_does_not_fail_if_no_venvs_dir(

manager.activate("python3.7", NullIO())

m.assert_called_with(
os.path.join(str(poetry.file.parent), ".venv"), executable="python3.7"
)
m.assert_called_with(poetry.file.parent / ".venv", executable="python3.7")

envs_file = TomlFile(Path(tmp_dir) / "virtualenvs" / "envs.toml")
assert not envs_file.exists()
Expand Down