diff --git a/poetry/installation/pip_installer.py b/poetry/installation/pip_installer.py
index f5de6642f2d..e44d7a61297 100644
--- a/poetry/installation/pip_installer.py
+++ b/poetry/installation/pip_installer.py
@@ -113,7 +113,9 @@ def remove(self, package):
raise
# This is a workaround for https://github.com/pypa/pip/issues/4176
- nspkg_pth_file = self._env.site_packages / "{}-nspkg.pth".format(package.name)
+ nspkg_pth_file = self._env.site_packages.path / "{}-nspkg.pth".format(
+ package.name
+ )
if nspkg_pth_file.exists():
nspkg_pth_file.unlink()
diff --git a/poetry/masonry/builders/editable.py b/poetry/masonry/builders/editable.py
index 99136ecbd3e..74d1f69c886 100644
--- a/poetry/masonry/builders/editable.py
+++ b/poetry/masonry/builders/editable.py
@@ -13,6 +13,7 @@
from poetry.utils._compat import WINDOWS
from poetry.utils._compat import Path
from poetry.utils._compat import decode
+from poetry.utils.helpers import is_dir_writable
SCRIPT_TEMPLATE = """\
@@ -94,7 +95,6 @@ def _setup_build(self):
os.remove(str(setup))
def _add_pth(self):
- pth_file = Path(self._module.name).with_suffix(".pth")
paths = set()
for include in self._module.includes:
if isinstance(include, PackageInclude) and (
@@ -106,34 +106,40 @@ def _add_pth(self):
for path in paths:
content += decode(path + os.linesep)
- for site_package in [self._env.site_packages, self._env.usersite]:
- if not site_package:
- continue
-
- try:
- site_package.mkdir(parents=True, exist_ok=True)
- path = site_package.joinpath(pth_file)
- self._debug(
- " - Adding {} to {} for {}".format(
- path.name, site_package, self._poetry.file.parent
- )
+ pth_file = Path(self._module.name).with_suffix(".pth")
+ try:
+ pth_file = self._env.site_packages.write_text(
+ pth_file, content, encoding="utf-8"
+ )
+ self._debug(
+ " - Adding {} to {} for {}".format(
+ pth_file.name, pth_file.parent, self._poetry.file.parent
)
- path.write_text(content, encoding="utf-8")
- return [path]
- except PermissionError:
- self._debug("- {} is not writable trying next available site")
-
- self._io.error_line(
- " - Failed to create {} for {}".format(
- pth_file.name, self._poetry.file.parent
)
- )
- return []
+ return [pth_file]
+ except OSError:
+ # TODO: Replace with PermissionError
+ self._io.error_line(
+ " - Failed to create {} for {}".format(
+ pth_file.name, self._poetry.file.parent
+ )
+ )
+ return []
def _add_scripts(self):
added = []
entry_points = self.convert_entry_points()
- scripts_path = Path(self._env.paths["scripts"])
+
+ for scripts_path in self._env.script_dirs:
+ if is_dir_writable(path=scripts_path, create=True):
+ break
+ else:
+ self._io.error_line(
+ " - Failed to find a suitable script installation directory for {}".format(
+ self._poetry.file.parent
+ )
+ )
+ return []
scripts = entry_points.get("console_scripts", [])
for script in scripts:
@@ -151,7 +157,7 @@ def _add_scripts(self):
f.write(
decode(
SCRIPT_TEMPLATE.format(
- python=self._env._bin("python"),
+ python=self._env.python,
module=module,
callable_holder=callable_holder,
callable_=callable_,
@@ -165,9 +171,7 @@ def _add_scripts(self):
if WINDOWS:
cmd_script = script_file.with_suffix(".cmd")
- cmd = WINDOWS_CMD_TEMPLATE.format(
- python=self._env._bin("python"), script=name
- )
+ cmd = WINDOWS_CMD_TEMPLATE.format(python=self._env.python, script=name)
self._debug(
" - Adding the {} script wrapper to {}".format(
cmd_script.name, scripts_path
@@ -187,19 +191,27 @@ def _add_dist_info(self, added_files):
added_files = added_files[:]
builder = WheelBuilder(self._poetry)
- dist_info = self._env.site_packages.joinpath(builder.dist_info)
+
+ dist_info_path = Path(builder.dist_info)
+ for dist_info in self._env.site_packages.find(
+ dist_info_path, writable_only=True
+ ):
+ if dist_info.exists():
+ self._debug(
+ " - Removing existing {} directory from {}".format(
+ dist_info.name, dist_info.parent
+ )
+ )
+ shutil.rmtree(str(dist_info))
+
+ dist_info = self._env.site_packages.mkdir(dist_info_path)
self._debug(
" - Adding the {} directory to {}".format(
- dist_info.name, self._env.site_packages
+ dist_info.name, dist_info.parent
)
)
- if dist_info.exists():
- shutil.rmtree(str(dist_info))
-
- dist_info.mkdir()
-
with dist_info.joinpath("METADATA").open("w", encoding="utf-8") as f:
builder._write_metadata_file(f)
diff --git a/poetry/utils/env.py b/poetry/utils/env.py
index ccd855b5c60..9d660c709dd 100644
--- a/poetry/utils/env.py
+++ b/poetry/utils/env.py
@@ -39,6 +39,8 @@
from poetry.utils._compat import encode
from poetry.utils._compat import list_to_shell_command
from poetry.utils._compat import subprocess
+from poetry.utils.helpers import is_dir_writable
+from poetry.utils.helpers import paths_csv
GET_ENVIRONMENT_INFO = """\
@@ -143,6 +145,120 @@ def _version_nodot(version):
"""
+class SitePackages:
+ def __init__(
+ self, path, fallbacks=None, skip_write_checks=False
+ ): # type: (Path, List[Path], bool) -> None
+ self._path = path
+ self._fallbacks = fallbacks or []
+ self._skip_write_checks = skip_write_checks
+ self._candidates = [self._path] + self._fallbacks
+ self._writable_candidates = None if not skip_write_checks else self._candidates
+
+ @property
+ def path(self): # type: () -> Path
+ return self._path
+
+ @property
+ def candidates(self): # type: () -> List[Path]
+ return self._candidates
+
+ @property
+ def writable_candidates(self): # type: () -> List[Path]
+ if self._writable_candidates is not None:
+ return self._writable_candidates
+
+ self._writable_candidates = []
+ for candidate in self._candidates:
+ if not is_dir_writable(path=candidate, create=True):
+ continue
+ self._writable_candidates.append(candidate)
+
+ return self._writable_candidates
+
+ def make_candidates(
+ self, path, writable_only=False
+ ): # type: (Path, bool) -> List[Path]
+ candidates = self._candidates if not writable_only else self.writable_candidates
+ if path.is_absolute():
+ for candidate in candidates:
+ try:
+ path.relative_to(candidate)
+ return [path]
+ except ValueError:
+ pass
+ else:
+ raise ValueError(
+ "{} is not relative to any discovered {}sites".format(
+ path, "writable " if writable_only else ""
+ )
+ )
+
+ return [candidate / path for candidate in candidates if candidate]
+
+ def _path_method_wrapper(
+ self, path, method, *args, **kwargs
+ ): # type: (Path, str, *Any, **Any) -> Union[Tuple[Path, Any], List[Tuple[Path, Any]]]
+
+ # TODO: Move to parameters after dropping Python 2.7
+ return_first = kwargs.pop("return_first", True)
+ writable_only = kwargs.pop("writable_only", False)
+
+ candidates = self.make_candidates(path, writable_only=writable_only)
+
+ if not candidates:
+ raise RuntimeError(
+ 'Unable to find a suitable destination for "{}" in {}'.format(
+ str(path), paths_csv(self._candidates)
+ )
+ )
+
+ results = []
+
+ for candidate in candidates:
+ try:
+ result = candidate, getattr(candidate, method)(*args, **kwargs)
+ if return_first:
+ return result
+ else:
+ results.append(result)
+ except (IOError, OSError):
+ # TODO: Replace with PermissionError
+ pass
+
+ if results:
+ return results
+
+ raise OSError("Unable to access any of {}".format(paths_csv(candidates)))
+
+ def write_text(self, path, *args, **kwargs): # type: (Path, *Any, **Any) -> Path
+ return self._path_method_wrapper(path, "write_text", *args, **kwargs)[0]
+
+ def mkdir(self, path, *args, **kwargs): # type: (Path, *Any, **Any) -> Path
+ return self._path_method_wrapper(path, "mkdir", *args, **kwargs)[0]
+
+ def exists(self, path): # type: (Path) -> bool
+ return any(
+ value[-1]
+ for value in self._path_method_wrapper(path, "exists", return_first=False)
+ )
+
+ def find(self, path, writable_only=False): # type: (Path, bool) -> List[Path]
+ return [
+ value[0]
+ for value in self._path_method_wrapper(
+ path, "exists", return_first=False, writable_only=writable_only
+ )
+ if value[-1] is True
+ ]
+
+ def __getattr__(self, item):
+ try:
+ return super(SitePackages, self).__getattribute__(item)
+ except AttributeError:
+ return getattr(self.path, item)
+
+
class EnvError(Exception):
pass
@@ -756,6 +872,7 @@ def __init__(self, path, base=None): # type: (Path, Optional[Path]) -> None
self._supported_tags = None
self._purelib = None
self._platlib = None
+ self._script_dirs = None
@property
def path(self): # type: () -> Path
@@ -810,9 +927,13 @@ def pip_version(self):
return self._pip_version
@property
- def site_packages(self): # type: () -> Path
+ def site_packages(self): # type: () -> SitePackages
if self._site_packages is None:
- self._site_packages = self.purelib
+ # we disable write checks if no user site exist
+ fallbacks = [self.usersite] if self.usersite else []
+ self._site_packages = SitePackages(
+ self.purelib, fallbacks, skip_write_checks=False if fallbacks else True
+ )
return self._site_packages
@property
@@ -820,6 +941,11 @@ def usersite(self): # type: () -> Optional[Path]
if "usersite" in self.paths:
return Path(self.paths["usersite"])
+ @property
+ def userbase(self): # type: () -> Optional[Path]
+ if "userbase" in self.paths:
+ return Path(self.paths["userbase"])
+
@property
def purelib(self): # type: () -> Path
if self._purelib is None:
@@ -966,6 +1092,18 @@ def execute(self, bin, *args, **kwargs):
def is_venv(self): # type: () -> bool
raise NotImplementedError()
+ @property
+ def script_dirs(self): # type: () -> List[Path]
+ if self._script_dirs is None:
+ self._script_dirs = (
+ [Path(self.paths["scripts"])]
+ if "scripts" in self.paths
+ else self._bin_dir
+ )
+ if self.userbase:
+ self._script_dirs.append(self.userbase / self._script_dirs[0].name)
+ return self._script_dirs
+
def _bin(self, bin): # type: (str) -> str
"""
Return path to the given executable.
@@ -1001,6 +1139,10 @@ class SystemEnv(Env):
A system (i.e. not a virtualenv) Python environment.
"""
+ @property
+ def python(self): # type: () -> str
+ return sys.executable
+
@property
def sys_path(self): # type: () -> List[str]
return sys.path
@@ -1041,6 +1183,7 @@ def get_paths(self): # type: () -> Dict[str, str]
if site.check_enableusersite() and hasattr(obj, "install_usersite"):
paths["usersite"] = getattr(obj, "install_usersite")
+ paths["userbase"] = getattr(obj, "install_userbase")
return paths
@@ -1176,7 +1319,7 @@ def is_venv(self): # type: () -> bool
def is_sane(self):
# A virtualenv is considered sane if both "python" and "pip" exist.
- return os.path.exists(self._bin("python")) and os.path.exists(self._bin("pip"))
+ return os.path.exists(self.python) and os.path.exists(self._bin("pip"))
def _run(self, cmd, **kwargs):
with self.temp_environ():
diff --git a/poetry/utils/helpers.py b/poetry/utils/helpers.py
index 180a90d50f3..232e65b7d44 100644
--- a/poetry/utils/helpers.py
+++ b/poetry/utils/helpers.py
@@ -5,6 +5,7 @@
import tempfile
from contextlib import contextmanager
+from typing import List
from typing import Optional
import requests
@@ -113,3 +114,22 @@ def get_package_version_display_string(
)
return package.full_pretty_version
+
+
+def paths_csv(paths): # type: (List[Path]) -> str
+ return ", ".join('"{}"'.format(str(c)) for c in paths)
+
+
+def is_dir_writable(path, create=False): # type: (Path, bool) -> bool
+ try:
+ if not path.exists():
+ if not create:
+ return False
+ path.mkdir(parents=True, exist_ok=True)
+
+ with tempfile.TemporaryFile(dir=str(path)):
+ pass
+ except (IOError, OSError):
+ return False
+ else:
+ return True
diff --git a/tests/installation/test_pip_installer.py b/tests/installation/test_pip_installer.py
index 206bcce59cc..d0e2e5a4dcd 100644
--- a/tests/installation/test_pip_installer.py
+++ b/tests/installation/test_pip_installer.py
@@ -189,7 +189,9 @@ def test_uninstall_git_package_nspkg_pth_cleanup(mocker, tmp_venv, pool):
)
# we do this here because the virtual env might not be usable if failure case is triggered
- pth_file_candidate = tmp_venv.site_packages / "{}-nspkg.pth".format(package.name)
+ pth_file_candidate = tmp_venv.site_packages.path / "{}-nspkg.pth".format(
+ package.name
+ )
# in order to reproduce the scenario where the git source is removed prior to proper
# clean up of nspkg.pth file, we need to make sure the fixture is copied and not
diff --git a/tests/masonry/builders/test_editable_builder.py b/tests/masonry/builders/test_editable_builder.py
index 3aee74e7c77..daeff0e7777 100644
--- a/tests/masonry/builders/test_editable_builder.py
+++ b/tests/masonry/builders/test_editable_builder.py
@@ -76,14 +76,14 @@ def test_builder_installs_proper_files_for_standard_packages(simple_poetry, tmp_
builder.build()
assert tmp_venv._bin_dir.joinpath("foo").exists()
- assert tmp_venv.site_packages.joinpath("simple_project.pth").exists()
- assert simple_poetry.file.parent.resolve().as_posix() == tmp_venv.site_packages.joinpath(
+ assert tmp_venv.site_packages.path.joinpath("simple_project.pth").exists()
+ assert simple_poetry.file.parent.resolve().as_posix() == tmp_venv.site_packages.path.joinpath(
"simple_project.pth"
).read_text().strip(
os.linesep
)
- dist_info = tmp_venv.site_packages.joinpath("simple_project-1.2.3.dist-info")
+ dist_info = tmp_venv.site_packages.path.joinpath("simple_project-1.2.3.dist-info")
assert dist_info.exists()
assert dist_info.joinpath("INSTALLER").exists()
assert dist_info.joinpath("METADATA").exists()
@@ -130,7 +130,7 @@ def test_builder_installs_proper_files_for_standard_packages(simple_poetry, tmp_
assert metadata == dist_info.joinpath("METADATA").read_text(encoding="utf-8")
records = dist_info.joinpath("RECORD").read_text()
- assert str(tmp_venv.site_packages.joinpath("simple_project.pth")) in records
+ assert str(tmp_venv.site_packages.path.joinpath("simple_project.pth")) in records
assert str(tmp_venv._bin_dir.joinpath("foo")) in records
assert str(tmp_venv._bin_dir.joinpath("baz")) in records
assert str(dist_info.joinpath("METADATA")) in records
@@ -202,7 +202,7 @@ def test_builder_installs_proper_files_when_packages_configured(
builder = EditableBuilder(project_with_include, tmp_venv, NullIO())
builder.build()
- pth_file = tmp_venv.site_packages.joinpath("with_include.pth")
+ pth_file = tmp_venv.site_packages.path.joinpath("with_include.pth")
assert pth_file.is_file()
paths = set()
diff --git a/tests/utils/test_env.py b/tests/utils/test_env.py
index 3779623839f..cd5e1b9fe16 100644
--- a/tests/utils/test_env.py
+++ b/tests/utils/test_env.py
@@ -848,7 +848,7 @@ def test_system_env_has_correct_paths():
assert paths.get("purelib") is not None
assert paths.get("platlib") is not None
assert paths.get("scripts") is not None
- assert env.site_packages == Path(paths["purelib"])
+ assert env.site_packages.path == Path(paths["purelib"])
@pytest.mark.parametrize(
@@ -868,4 +868,4 @@ def test_venv_has_correct_paths(tmp_venv):
assert paths.get("purelib") is not None
assert paths.get("platlib") is not None
assert paths.get("scripts") is not None
- assert tmp_venv.site_packages == Path(paths["purelib"])
+ assert tmp_venv.site_packages.path == Path(paths["purelib"])
diff --git a/tests/utils/test_env_site.py b/tests/utils/test_env_site.py
new file mode 100644
index 00000000000..f25e2142193
--- /dev/null
+++ b/tests/utils/test_env_site.py
@@ -0,0 +1,43 @@
+import uuid
+
+from poetry.utils._compat import Path
+from poetry.utils._compat import decode
+from poetry.utils.env import SitePackages
+
+
+def test_env_site_simple(tmp_dir, mocker):
+ # emulate permission error when creating directory
+ mocker.patch("poetry.utils._compat.Path.mkdir", side_effect=OSError())
+ site_packages = SitePackages(Path("/non-existent"), fallbacks=[Path(tmp_dir)])
+ candidates = site_packages.make_candidates(Path("hello.txt"), writable_only=True)
+ hello = Path(tmp_dir) / "hello.txt"
+
+ assert len(candidates) == 1
+ assert candidates[0].as_posix() == hello.as_posix()
+
+ content = decode(str(uuid.uuid4()))
+ site_packages.write_text(Path("hello.txt"), content, encoding="utf-8")
+
+ assert hello.read_text(encoding="utf-8") == content
+
+ assert not (site_packages.path / "hello.txt").exists()
+
+
+def test_env_site_select_first(tmp_dir):
+ path = Path(tmp_dir)
+ fallback = path / "fallback"
+ fallback.mkdir(parents=True)
+
+ site_packages = SitePackages(path, fallbacks=[fallback])
+ candidates = site_packages.make_candidates(Path("hello.txt"), writable_only=True)
+
+ assert len(candidates) == 2
+ assert len(site_packages.find(Path("hello.txt"))) == 0
+
+ content = decode(str(uuid.uuid4()))
+ site_packages.write_text(Path("hello.txt"), content, encoding="utf-8")
+
+ assert (site_packages.path / "hello.txt").exists()
+ assert not (fallback / "hello.txt").exists()
+
+ assert len(site_packages.find(Path("hello.txt"))) == 1