Skip to content

Commit

Permalink
fix(env): ensure whl installer uses writable path
Browse files Browse the repository at this point in the history
This change fixes an issue where when system environment is used as a
destination for wheel installation as a user without access to system
site packages, installation fails with permission error.

Functionally, this now follows the same behaviour as when a package is
installed to user site.

Relates-to: #8408
  • Loading branch information
abn committed Jan 20, 2025
1 parent a37988f commit c820ffa
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 11 deletions.
2 changes: 1 addition & 1 deletion src/poetry/installation/wheel_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def install(self, wheel: Path) -> None:
except _WheelFileValidationError as e:
self.invalid_wheels[wheel] = e.issues

scheme_dict = self._env.paths.copy()
scheme_dict = self._env.scheme_dict.copy()
scheme_dict["headers"] = str(
Path(scheme_dict["include"]) / source.distribution
)
Expand Down
56 changes: 56 additions & 0 deletions src/poetry/utils/env/base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
from typing import TYPE_CHECKING
from typing import Any

from installer.utils import SCHEME_NAMES
from virtualenv.seed.wheels.embed import get_embed_wheel

from poetry.utils.env.exceptions import EnvCommandError
from poetry.utils.env.site_packages import SitePackages
from poetry.utils.helpers import get_real_windows_path
from poetry.utils.helpers import is_dir_writable


if TYPE_CHECKING:
Expand Down Expand Up @@ -245,6 +247,60 @@ def set_paths(

# clear cached properties using the env paths
self.__dict__.pop("fallbacks", None)
self.__dict__.pop("scheme_dict", None)

@cached_property
def scheme_dict(self) -> dict[str, str]:
"""
This property exists to allow cases where system environment paths are not writable and
user site is enabled. This enables us to ensure packages (wheels) are correctly installed
into directories where the current user can write to.
If all candidates in `self.paths` is writable, no modification is made. If at least one path is not writable
and all generated writable candidates are indeed writable, these are used instead. If any candidate is not
writable, the original paths are returned.
Alternative writable candidates are generated by replacing discovered prefix, with "userbase"
if available. The original prefix is computed as the common path prefix of "scripts" and "purelib".
For example, given `{ "purelib": "/usr/local/lib/python3.13/site-packages", "scripts": "/usr/local/bin",
"userbase": "/home/user/.local" }`; the candidate "purelib" path would be
`/home/user/.local/lib/python3.13/site-packages`.
"""
paths = self.paths.copy()

if (
not self.is_venv()
and paths.get("userbase")
and ("scripts" in paths and "purelib" in paths)
):
overrides: dict[str, str] = {}

try:
base_path = os.path.commonpath([paths["scripts"], paths["purelib"]])
except ValueError:
return paths

scheme_names = [key for key in SCHEME_NAMES if key in self.paths]

for key in scheme_names:
if not is_dir_writable(path=Path(paths[key]), create=True):
# there is at least one path that is not writable
break
else:
# all paths are writable, return early
return paths

for key in scheme_names:
candidate = paths[key].replace(base_path, paths["userbase"])
if not is_dir_writable(path=Path(candidate), create=True):
# at least one candidate is not writable, we cannot do much here
return paths

overrides[key] = candidate

paths.update(overrides)

return paths

def _get_lib_dirs(self) -> list[Path]:
return [self.purelib, self.platlib, *self.fallbacks]
Expand Down
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import keyring
import pytest

from installer.utils import SCHEME_NAMES
from jaraco.classes import properties
from keyring.backend import KeyringBackend
from keyring.backends.fail import Keyring as FailKeyring
Expand All @@ -33,6 +34,7 @@
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils.cache import ArtifactCache
from poetry.utils.env import EnvManager
from poetry.utils.env import MockEnv
from poetry.utils.env import SystemEnv
from poetry.utils.env import VirtualEnv
from tests.helpers import MOCK_DEFAULT_GIT_REVISION
Expand Down Expand Up @@ -627,3 +629,25 @@ def handle(self) -> int:
return MockCommand()

return _command_factory


@pytest.fixture
def system_env(tmp_path_factory: TempPathFactory, mocker: MockerFixture) -> SystemEnv:
base_path = tmp_path_factory.mktemp("system_env")
env = MockEnv(path=base_path, sys_path=[str(base_path / "purelib")])
assert env.path.is_dir()

userbase = env.path / "userbase"
userbase.mkdir(exist_ok=False)
env.paths["userbase"] = str(userbase)

paths = {str(scheme): str(env.path / scheme) for scheme in SCHEME_NAMES}
env.paths.update(paths)

for path in paths.values():
Path(path).mkdir(exist_ok=False)

mocker.patch.object(EnvManager, "get_system_env", return_value=env)

env.set_paths()
return env
11 changes: 1 addition & 10 deletions tests/plugins/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@
from poetry.repositories import Repository
from poetry.repositories import RepositoryPool
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils.env import Env
from poetry.utils.env import EnvManager
from poetry.utils.env import MockEnv
from tests.helpers import mock_metadata_entry_points


Expand All @@ -40,6 +37,7 @@
from pytest_mock import MockerFixture

from poetry.console.commands.command import Command
from poetry.utils.env import Env
from tests.conftest import Config
from tests.types import FixtureDirGetter

Expand Down Expand Up @@ -84,13 +82,6 @@ def pool(repo: Repository) -> RepositoryPool:
return pool


@pytest.fixture
def system_env(tmp_path: Path, mocker: MockerFixture) -> Env:
env = MockEnv(path=tmp_path, sys_path=[str(tmp_path / "purelib")])
mocker.patch.object(EnvManager, "get_system_env", return_value=env)
return env


@pytest.fixture
def poetry(fixture_dir: FixtureDirGetter, config: Config) -> Poetry:
project_path = fixture_dir("simple_project")
Expand Down
38 changes: 38 additions & 0 deletions tests/utils/env/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

import pytest

from deepdiff.diff import DeepDiff
from installer.utils import SCHEME_NAMES

from poetry.factory import Factory
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils._compat import WINDOWS
Expand All @@ -23,6 +26,7 @@
from poetry.utils.env import VirtualEnv
from poetry.utils.env import build_environment
from poetry.utils.env import ephemeral_environment
from poetry.utils.helpers import is_dir_writable


if TYPE_CHECKING:
Expand Down Expand Up @@ -510,3 +514,37 @@ def test_command_from_bin_preserves_relative_path(manager: EnvManager) -> None:
env = manager.get()
command = env.get_command_from_bin("./foo.py")
assert command == ["./foo.py"]


@pytest.fixture
def system_env_read_only(system_env: SystemEnv, mocker: MockerFixture) -> SystemEnv:
original_is_dir_writable = is_dir_writable

read_only_paths = {system_env.paths[key] for key in SCHEME_NAMES}

def mock_is_dir_writable(path: Path, create: bool = False) -> bool:
if str(path) in read_only_paths:
return False
return original_is_dir_writable(path, create)

mocker.patch("poetry.utils.env.base_env.is_dir_writable", new=mock_is_dir_writable)

return system_env


def test_env_scheme_dict_returns_original_when_writable(system_env: SystemEnv) -> None:
assert not DeepDiff(system_env.scheme_dict, system_env.paths, ignore_order=True)


def test_env_scheme_dict_returns_modified_when_read_only(
system_env_read_only: SystemEnv,
) -> None:
scheme_dict = system_env_read_only.scheme_dict
assert DeepDiff(scheme_dict, system_env_read_only.paths, ignore_order=True)

paths = system_env_read_only.paths
assert all(
Path(scheme_dict[scheme]).exists()
and scheme_dict[scheme].startswith(paths["userbase"])
for scheme in SCHEME_NAMES
)

0 comments on commit c820ffa

Please sign in to comment.