diff --git a/src/poetry/installation/wheel_installer.py b/src/poetry/installation/wheel_installer.py index 27a867f85bc..57ee02d9a98 100644 --- a/src/poetry/installation/wheel_installer.py +++ b/src/poetry/installation/wheel_installer.py @@ -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 ) diff --git a/src/poetry/utils/env/base_env.py b/src/poetry/utils/env/base_env.py index d01da7d3c65..7f0bbbd74e9 100644 --- a/src/poetry/utils/env/base_env.py +++ b/src/poetry/utils/env/base_env.py @@ -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: @@ -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] diff --git a/tests/conftest.py b/tests/conftest.py index d69dc6bd12d..7dbbfc13f5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -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 @@ -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 diff --git a/tests/plugins/test_plugin_manager.py b/tests/plugins/test_plugin_manager.py index 5d254b1ee6c..8b52c1bf0e2 100644 --- a/tests/plugins/test_plugin_manager.py +++ b/tests/plugins/test_plugin_manager.py @@ -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 @@ -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 @@ -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") diff --git a/tests/utils/env/test_env.py b/tests/utils/env/test_env.py index 97eda471416..2cba8371411 100644 --- a/tests/utils/env/test_env.py +++ b/tests/utils/env/test_env.py @@ -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 @@ -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: @@ -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 + )